feat(ui): add showcase toggle and runtime renderer to design explorer
This commit is contained in:
51
app/api/design-systems/[id]/showcase/route.ts
Normal file
51
app/api/design-systems/[id]/showcase/route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { renderDesignSystemShowcase } from "@/lib/scaffold/open-design/design-system-showcase";
|
||||
|
||||
export async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id || id.includes("..") || id.includes("/")) {
|
||||
return new NextResponse("Invalid design system ID", { status: 400 });
|
||||
}
|
||||
|
||||
const systemPath = path.join(
|
||||
process.cwd(),
|
||||
"lib",
|
||||
"scaffold",
|
||||
"open-design",
|
||||
"design-systems",
|
||||
id
|
||||
);
|
||||
|
||||
const mdPath = path.join(systemPath, "DESIGN.md");
|
||||
|
||||
try {
|
||||
if (!fs.existsSync(systemPath)) {
|
||||
return new NextResponse(`Design system not found for ${id}`, { status: 404 });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(mdPath)) {
|
||||
return new NextResponse(`DESIGN.md not found for ${id}`, { status: 404 });
|
||||
}
|
||||
|
||||
const rawMarkdown = await fs.promises.readFile(mdPath, "utf-8");
|
||||
const html = renderDesignSystemShowcase(id, rawMarkdown);
|
||||
|
||||
return new NextResponse(html, {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "text/html; charset=utf-8",
|
||||
"X-Frame-Options": "SAMEORIGIN",
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
return new NextResponse(`Error loading showcase: ${err.message}`, {
|
||||
status: 500,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useParams } from "next/navigation";
|
||||
import { Loader2, Check, Search, Eye, Sparkles, LayoutTemplate } from "lucide-react";
|
||||
import { Loader2, Check, Search, Eye, Sparkles, LayoutTemplate, Palette } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { STARTER_KITS } from "@/lib/design-kits/registry";
|
||||
import { DEFAULT_DESIGN_KIT_ID } from "@/lib/design-kits/types";
|
||||
@@ -13,6 +13,7 @@ export function DesignSystemExplorer() {
|
||||
|
||||
const [activeKitId, setActiveKitId] = useState<string>(DEFAULT_DESIGN_KIT_ID);
|
||||
const [previewKitId, setPreviewKitId] = useState<string>(DEFAULT_DESIGN_KIT_ID);
|
||||
const [previewMode, setPreviewMode] = useState<"showcase" | "tokens">("showcase");
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
@@ -113,7 +114,12 @@ export function DesignSystemExplorer() {
|
||||
return (
|
||||
<button
|
||||
key={kit.id}
|
||||
onClick={() => setPreviewKitId(kit.id)}
|
||||
onClick={() => {
|
||||
setPreviewKitId(kit.id);
|
||||
if (!kit.hasPreview && previewMode === "tokens") {
|
||||
setPreviewMode("showcase"); // force showcase if no raw tokens gallery
|
||||
}
|
||||
}}
|
||||
className={`w-full text-left p-3 rounded-lg flex items-start gap-3 transition-colors group relative ${
|
||||
isSelected ? 'bg-indigo-50/80 ring-1 ring-indigo-500/30' : 'hover:bg-zinc-50'
|
||||
}`}
|
||||
@@ -137,8 +143,8 @@ export function DesignSystemExplorer() {
|
||||
</div>
|
||||
{/* Visual Indicator if it has an HTML preview */}
|
||||
{kit.hasPreview && (
|
||||
<div className="absolute right-3 top-3.5 text-zinc-300 group-hover:text-indigo-400 transition-colors" title="Visual Preview Available">
|
||||
<LayoutTemplate className="w-3.5 h-3.5" />
|
||||
<div className="absolute right-3 top-3.5 text-zinc-300 group-hover:text-indigo-400 transition-colors" title="Detailed Token Gallery Available">
|
||||
<Palette className="w-3.5 h-3.5" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
@@ -156,14 +162,34 @@ export function DesignSystemExplorer() {
|
||||
<div className="flex-1 flex flex-col h-full bg-zinc-100 overflow-hidden relative">
|
||||
{/* Top Action Bar */}
|
||||
<div className="h-14 flex-shrink-0 bg-white border-b border-zinc-200 px-6 flex items-center justify-between shadow-sm z-10">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Eye className="w-4 h-4 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-900 truncate flex items-center gap-2">
|
||||
Previewing: {previewKit.name}
|
||||
{!previewKit.hasPreview && (
|
||||
<span className="px-2 py-0.5 rounded-full bg-zinc-100 text-zinc-500 text-[10px] font-mono tracking-wide uppercase border border-zinc-200">Rules Only</span>
|
||||
<div className="flex items-center gap-6 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Eye className="w-4 h-4 text-zinc-400" />
|
||||
<span className="text-sm font-medium text-zinc-900 truncate">
|
||||
{previewKit.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex bg-zinc-100 p-0.5 rounded-lg border border-zinc-200">
|
||||
<button
|
||||
onClick={() => setPreviewMode("showcase")}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
previewMode === "showcase" ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'
|
||||
}`}
|
||||
>
|
||||
Showcase
|
||||
</button>
|
||||
{previewKit.hasPreview && (
|
||||
<button
|
||||
onClick={() => setPreviewMode("tokens")}
|
||||
className={`px-3 py-1 text-xs font-medium rounded-md transition-colors ${
|
||||
previewMode === "tokens" ? 'bg-white text-zinc-900 shadow-sm' : 'text-zinc-500 hover:text-zinc-700'
|
||||
}`}
|
||||
>
|
||||
Raw Tokens
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@@ -189,13 +215,13 @@ export function DesignSystemExplorer() {
|
||||
<div className="w-2.5 h-2.5 rounded-full bg-green-400/80"></div>
|
||||
</div>
|
||||
<div className="mx-auto bg-white px-3 py-0.5 rounded-md border border-zinc-200 text-[10px] text-zinc-400 font-mono shadow-sm">
|
||||
{previewKit.id}.design.preview
|
||||
{previewKit.id}.{previewMode}.preview
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<iframe
|
||||
key={previewKit.id} // Forces iframe to remount
|
||||
src={`/api/design-systems/${previewKit.id}/preview`}
|
||||
key={`${previewKit.id}-${previewMode}`} // Forces iframe to remount on change
|
||||
src={previewMode === "showcase" ? `/api/design-systems/${previewKit.id}/showcase` : `/api/design-systems/${previewKit.id}/preview`}
|
||||
className="flex-1 w-full h-full bg-zinc-50"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
/>
|
||||
|
||||
877
lib/scaffold/open-design/design-system-showcase.ts
Normal file
877
lib/scaffold/open-design/design-system-showcase.ts
Normal file
@@ -0,0 +1,877 @@
|
||||
/**
|
||||
* Build a fully-formed product webpage that demonstrates a design system in
|
||||
* action — not just a list of tokens, but a real-feeling marketing /
|
||||
* product page (nav, hero, social proof, feature grid, dashboard preview,
|
||||
* pricing, testimonials, FAQ, CTA, footer) styled entirely from the
|
||||
* tokens we extract from the system's DESIGN.md.
|
||||
*
|
||||
* Same parsing utilities as design-system-preview.js — kept inline rather
|
||||
* than imported so the two views can evolve independently.
|
||||
*/
|
||||
|
||||
type ColorToken = { name: string; value: string; role: string };
|
||||
type FontHints = { display?: string; heading?: string; body?: string; mono?: string };
|
||||
type RowStatus = 'up' | '';
|
||||
|
||||
export function renderDesignSystemShowcase(id: string, raw: string): string {
|
||||
const titleMatch = /^#\s+(.+?)\s*$/m.exec(raw);
|
||||
const rawTitle = titleMatch?.[1] ?? id;
|
||||
const title = cleanTitle(rawTitle);
|
||||
const subtitle = extractSubtitle(raw) || 'A design system rendered as a real product surface.';
|
||||
const colors = extractColors(raw);
|
||||
const fonts = extractFonts(raw);
|
||||
|
||||
// Hints are matched against each color's role description (the prose that
|
||||
// follows the name in DESIGN.md, e.g. "Primary background.") first, then
|
||||
// against the color name. We use word-boundary matching so descriptive
|
||||
// names like "Cardinal Red" don't accidentally satisfy a "card" hint and
|
||||
// "Gem Pink" doesn't satisfy "ink".
|
||||
// Hint ordering matters: more specific phrases come first so a system
|
||||
// with both "Primary background" and "Page background in light mode" (e.g.
|
||||
// Linear's marketing black + light-mode escape hatch) lands on the
|
||||
// dominant role rather than the light-mode subtitle. We drop 'page
|
||||
// background' from the bg hints entirely because in practice it almost
|
||||
// always belongs to a secondary, light-mode-only entry.
|
||||
const bg =
|
||||
pickColor(colors, ['primary background', 'background', 'canvas', 'paper'])
|
||||
?? firstLightish(colors)
|
||||
?? '#ffffff';
|
||||
// Exclude `bg` so a token whose hex matches the page background (for
|
||||
// example Warp's "Warm Parchment" doubling as primary text *and* the
|
||||
// firstLightish bg fallback) doesn't make body copy invisible.
|
||||
const fg =
|
||||
pickColor(
|
||||
colors,
|
||||
[
|
||||
'primary text',
|
||||
'body text',
|
||||
'foreground',
|
||||
'ink primary',
|
||||
'heading',
|
||||
'ink',
|
||||
'graphite',
|
||||
'navy',
|
||||
],
|
||||
[bg],
|
||||
)
|
||||
?? pickReadableForeground(bg)
|
||||
?? '#0a0a0a';
|
||||
const accent =
|
||||
pickColor(colors, [
|
||||
'brand primary',
|
||||
'primary brand',
|
||||
'primary cta',
|
||||
'gradient origin',
|
||||
'brand mark',
|
||||
'brand color',
|
||||
])
|
||||
?? firstNonNeutral(colors, [bg, fg])
|
||||
?? '#2f6feb';
|
||||
const accent2 =
|
||||
pickColor(colors, [
|
||||
'brand secondary',
|
||||
'secondary brand',
|
||||
'gradient terminus',
|
||||
'tertiary brand',
|
||||
'tertiary',
|
||||
'highlight',
|
||||
])
|
||||
?? secondNonNeutral(colors, [accent, bg, fg])
|
||||
?? accent;
|
||||
const muted =
|
||||
pickColor(colors, ['secondary text', 'caption', 'metadata', 'placeholder', 'muted', 'subtle'])
|
||||
?? '#666666';
|
||||
const border =
|
||||
pickColor(colors, ['border', 'divider', 'hairline', 'rule', 'stroke'])
|
||||
?? '#e6e6e6';
|
||||
const surface =
|
||||
pickColor(colors, [
|
||||
'secondary surface',
|
||||
'section break',
|
||||
'sidebar',
|
||||
'surface subtle',
|
||||
'surface',
|
||||
'panel',
|
||||
'elevated',
|
||||
'card surface',
|
||||
])
|
||||
?? mixSurface(bg);
|
||||
|
||||
const display = fonts.display ?? fonts.heading ?? "system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif";
|
||||
const body = fonts.body ?? display;
|
||||
const mono = fonts.mono ?? "ui-monospace, 'JetBrains Mono', monospace";
|
||||
|
||||
const accentFg = pickReadableForeground(accent);
|
||||
const accent2Fg = pickReadableForeground(accent2);
|
||||
|
||||
const productName = title;
|
||||
const tagline = oneLine(subtitle).slice(0, 120);
|
||||
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>${escapeHtml(productName)} — showcase</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: ${bg};
|
||||
--fg: ${fg};
|
||||
--accent: ${accent};
|
||||
--accent-fg: ${accentFg};
|
||||
--accent-2: ${accent2};
|
||||
--accent-2-fg: ${accent2Fg};
|
||||
--muted: ${muted};
|
||||
--border: ${border};
|
||||
--surface: ${surface};
|
||||
--display: ${display};
|
||||
--body: ${body};
|
||||
--mono: ${mono};
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; padding: 0; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: var(--body);
|
||||
line-height: 1.6;
|
||||
font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
img { max-width: 100%; display: block; }
|
||||
.container { max-width: 1180px; margin: 0 auto; padding: 0 28px; }
|
||||
|
||||
/* Nav */
|
||||
.nav {
|
||||
position: sticky; top: 0; z-index: 30;
|
||||
background: rgba(255,255,255,0.7);
|
||||
backdrop-filter: saturate(180%) blur(14px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.nav-row {
|
||||
display: flex; align-items: center; gap: 32px;
|
||||
height: 64px;
|
||||
}
|
||||
.brand { display: flex; align-items: center; gap: 10px; font-family: var(--display); font-weight: 700; font-size: 17px; letter-spacing: -0.01em; }
|
||||
.brand-mark {
|
||||
width: 26px; height: 26px; border-radius: 7px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
}
|
||||
.nav-links { display: flex; gap: 22px; font-size: 14px; color: var(--muted); }
|
||||
.nav-links a:hover { color: var(--fg); }
|
||||
.nav-spacer { flex: 1; }
|
||||
.nav-cta {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
background: var(--fg); color: var(--bg);
|
||||
padding: 8px 14px; border-radius: 8px; font-size: 13px; font-weight: 500;
|
||||
}
|
||||
.nav-link-cta { color: var(--fg); font-weight: 500; font-size: 14px; }
|
||||
|
||||
/* Hero */
|
||||
.hero { padding: 96px 0 72px; }
|
||||
.hero-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
font-family: var(--mono); font-size: 12px; color: var(--muted);
|
||||
text-transform: uppercase; letter-spacing: 0.08em;
|
||||
padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px;
|
||||
background: var(--surface);
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
.hero-eyebrow .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
|
||||
.hero h1 {
|
||||
font-family: var(--display);
|
||||
font-size: clamp(44px, 6.6vw, 84px);
|
||||
line-height: 1.02;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 0 0 22px;
|
||||
max-width: 18ch;
|
||||
font-weight: 700;
|
||||
}
|
||||
.hero h1 em { font-style: normal; background: linear-gradient(120deg, var(--accent), var(--accent-2)); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
.hero p.lede {
|
||||
font-size: 19px; color: var(--muted);
|
||||
max-width: 56ch; margin: 0 0 36px;
|
||||
}
|
||||
.hero-actions { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; }
|
||||
.btn {
|
||||
font: inherit; cursor: pointer; border-radius: 10px;
|
||||
padding: 13px 22px; font-size: 14.5px; font-weight: 500;
|
||||
border: 1px solid transparent; display: inline-flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.btn-primary { background: var(--accent); color: var(--accent-fg); border-color: var(--accent); }
|
||||
.btn-primary:hover { filter: brightness(1.06); }
|
||||
.btn-ghost { background: transparent; color: var(--fg); border-color: var(--border); }
|
||||
.btn-ghost:hover { background: var(--surface); }
|
||||
.hero-meta { display: flex; gap: 24px; margin-top: 44px; color: var(--muted); font-size: 13px; }
|
||||
.hero-meta span strong { color: var(--fg); font-weight: 600; }
|
||||
|
||||
/* Logo strip */
|
||||
.logos { padding: 36px 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||
.logos-label { font-size: 12px; color: var(--muted); text-align: center; letter-spacing: 0.08em; text-transform: uppercase; margin-bottom: 18px; }
|
||||
.logos-row { display: flex; flex-wrap: wrap; justify-content: center; gap: 44px; align-items: center; opacity: 0.85; }
|
||||
.logo-pill { font-family: var(--display); font-weight: 700; font-size: 17px; letter-spacing: -0.01em; color: var(--muted); }
|
||||
|
||||
/* Features grid */
|
||||
.section { padding: 96px 0; }
|
||||
.section-eyebrow { font-family: var(--mono); text-transform: uppercase; letter-spacing: 0.1em; font-size: 12px; color: var(--accent); margin-bottom: 12px; }
|
||||
.section-title { font-family: var(--display); font-size: clamp(32px, 4.2vw, 48px); letter-spacing: -0.02em; line-height: 1.1; margin: 0 0 18px; max-width: 22ch; font-weight: 700; }
|
||||
.section-lede { color: var(--muted); font-size: 17px; max-width: 56ch; margin: 0 0 48px; }
|
||||
.features {
|
||||
display: grid; gap: 18px;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
}
|
||||
@media (max-width: 920px) { .features { grid-template-columns: 1fr 1fr; } }
|
||||
@media (max-width: 600px) { .features { grid-template-columns: 1fr; } }
|
||||
.feature {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 14px;
|
||||
padding: 26px; display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
.feature-icon {
|
||||
width: 36px; height: 36px; border-radius: 8px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
color: var(--accent-fg);
|
||||
display: inline-flex; align-items: center; justify-content: center;
|
||||
font-size: 18px; font-weight: 700;
|
||||
}
|
||||
.feature h3 { font-family: var(--display); font-size: 18px; margin: 0; letter-spacing: -0.01em; }
|
||||
.feature p { color: var(--muted); margin: 0; font-size: 14.5px; line-height: 1.55; }
|
||||
|
||||
/* Product preview / dashboard mock */
|
||||
.preview-wrap { padding-top: 24px; padding-bottom: 96px; }
|
||||
.preview-frame {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 18px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 30px 80px rgba(0,0,0,0.06), 0 12px 30px rgba(0,0,0,0.04);
|
||||
}
|
||||
.preview-titlebar { display: flex; gap: 6px; padding: 4px 8px 12px; }
|
||||
.preview-titlebar span { width: 10px; height: 10px; border-radius: 50%; background: var(--border); }
|
||||
.preview-app {
|
||||
background: var(--bg); border: 1px solid var(--border); border-radius: 12px;
|
||||
display: grid; grid-template-columns: 220px 1fr; min-height: 440px; overflow: hidden;
|
||||
}
|
||||
.preview-side { background: var(--surface); border-right: 1px solid var(--border); padding: 18px 14px; display: flex; flex-direction: column; gap: 4px; }
|
||||
.side-link { display: flex; align-items: center; gap: 10px; padding: 8px 10px; border-radius: 8px; font-size: 13.5px; color: var(--muted); }
|
||||
.side-link.active { background: var(--bg); color: var(--fg); font-weight: 500; box-shadow: inset 0 0 0 1px var(--border); }
|
||||
.side-link .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); }
|
||||
.side-section { font-family: var(--mono); text-transform: uppercase; font-size: 10px; letter-spacing: 0.08em; color: var(--muted); padding: 14px 10px 6px; }
|
||||
.preview-main { padding: 22px 24px; display: flex; flex-direction: column; gap: 22px; }
|
||||
.preview-head { display: flex; align-items: center; justify-content: space-between; }
|
||||
.preview-head h4 { font-family: var(--display); font-size: 22px; margin: 0; letter-spacing: -0.01em; }
|
||||
.kpi-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; }
|
||||
.kpi { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; }
|
||||
.kpi .label { font-size: 11.5px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
.kpi .value { font-family: var(--display); font-size: 24px; font-weight: 700; margin-top: 4px; letter-spacing: -0.01em; }
|
||||
.kpi .delta { font-family: var(--mono); font-size: 11.5px; margin-top: 2px; color: var(--accent); }
|
||||
.chart-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 18px; }
|
||||
.chart-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 8px; }
|
||||
.chart-head .title { font-weight: 600; font-size: 14px; }
|
||||
.chart-head .meta { font-family: var(--mono); font-size: 11px; color: var(--muted); }
|
||||
.chart svg { width: 100%; height: 160px; display: block; }
|
||||
.preview-row-2 { display: grid; grid-template-columns: 1.6fr 1fr; gap: 14px; }
|
||||
.list-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; }
|
||||
.list-row { display: grid; grid-template-columns: 1fr auto auto; gap: 12px; padding: 12px 16px; border-top: 1px solid var(--border); align-items: center; }
|
||||
.list-row:first-of-type { border-top: none; }
|
||||
.list-row .name { font-weight: 500; font-size: 13.5px; }
|
||||
.list-row .meta { font-family: var(--mono); font-size: 11.5px; color: var(--muted); }
|
||||
.badge { display: inline-flex; align-items: center; gap: 6px; padding: 3px 8px; border-radius: 999px; font-size: 11px; font-weight: 500; background: var(--bg); border: 1px solid var(--border); color: var(--muted); }
|
||||
.badge.up { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 30%, transparent); }
|
||||
.list-card .head { display: flex; justify-content: space-between; align-items: baseline; padding: 14px 16px; border-bottom: 1px solid var(--border); }
|
||||
.list-card .head h5 { margin: 0; font-size: 14px; }
|
||||
|
||||
/* Pricing */
|
||||
.pricing { display: grid; grid-template-columns: repeat(3, 1fr); gap: 18px; }
|
||||
@media (max-width: 920px) { .pricing { grid-template-columns: 1fr; } }
|
||||
.price-card {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 28px; display: flex; flex-direction: column; gap: 18px;
|
||||
}
|
||||
.price-card.featured {
|
||||
background: var(--fg); color: var(--bg); border-color: var(--fg);
|
||||
}
|
||||
.price-card.featured .muted, .price-card.featured h3, .price-card.featured .price { color: var(--bg); }
|
||||
.price-card .tier-name { font-family: var(--display); font-size: 14px; font-weight: 600; letter-spacing: 0.04em; text-transform: uppercase; color: var(--muted); }
|
||||
.price-card .price { font-family: var(--display); font-size: 44px; font-weight: 700; letter-spacing: -0.02em; line-height: 1; }
|
||||
.price-card .price small { font-size: 14px; color: var(--muted); font-weight: 400; }
|
||||
.price-card ul { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 10px; font-size: 14.5px; }
|
||||
.price-card li::before { content: "✓"; color: var(--accent); margin-right: 8px; font-weight: 700; }
|
||||
.price-card.featured li::before { color: var(--accent-2); }
|
||||
|
||||
/* Testimonials */
|
||||
.quotes { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; }
|
||||
@media (max-width: 760px) { .quotes { grid-template-columns: 1fr; } }
|
||||
.quote { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 26px; display: flex; flex-direction: column; gap: 18px; }
|
||||
.quote p { font-size: 17px; line-height: 1.55; margin: 0; font-family: var(--display); letter-spacing: -0.01em; }
|
||||
.quote-author { display: flex; align-items: center; gap: 12px; }
|
||||
.quote-author .avatar { width: 36px; height: 36px; border-radius: 50%; background: linear-gradient(135deg, var(--accent), var(--accent-2)); }
|
||||
.quote-author .name { font-weight: 600; font-size: 13.5px; }
|
||||
.quote-author .role { font-size: 12.5px; color: var(--muted); }
|
||||
|
||||
/* FAQ */
|
||||
.faq { display: grid; grid-template-columns: 1fr 1fr; gap: 14px 32px; }
|
||||
@media (max-width: 760px) { .faq { grid-template-columns: 1fr; } }
|
||||
.faq-item { padding: 18px 0; border-top: 1px solid var(--border); }
|
||||
.faq-item h4 { margin: 0 0 6px; font-family: var(--display); font-size: 17px; letter-spacing: -0.01em; }
|
||||
.faq-item p { margin: 0; color: var(--muted); font-size: 14.5px; }
|
||||
|
||||
/* CTA */
|
||||
.cta {
|
||||
margin: 48px 0 96px;
|
||||
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
||||
color: var(--accent-fg);
|
||||
border-radius: 24px;
|
||||
padding: 64px 56px;
|
||||
display: grid;
|
||||
grid-template-columns: 1.4fr auto;
|
||||
gap: 32px;
|
||||
align-items: center;
|
||||
}
|
||||
@media (max-width: 760px) { .cta { grid-template-columns: 1fr; padding: 36px; } }
|
||||
.cta h2 { font-family: var(--display); font-size: clamp(28px, 4vw, 40px); letter-spacing: -0.02em; margin: 0 0 10px; line-height: 1.1; max-width: 22ch; }
|
||||
.cta p { margin: 0; opacity: 0.92; font-size: 16px; max-width: 50ch; }
|
||||
.cta .btn { background: var(--accent-fg); color: var(--accent); border: none; }
|
||||
.cta .btn-secondary { background: transparent; color: var(--accent-fg); border: 1px solid color-mix(in srgb, var(--accent-fg) 35%, transparent); }
|
||||
|
||||
/* Footer */
|
||||
footer { border-top: 1px solid var(--border); padding: 36px 0 56px; color: var(--muted); font-size: 13.5px; }
|
||||
.footer-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 32px; margin-bottom: 32px; }
|
||||
@media (max-width: 760px) { .footer-row { grid-template-columns: 1fr 1fr; } }
|
||||
.footer-col h6 { color: var(--fg); font-family: var(--display); font-size: 13.5px; margin: 0 0 12px; font-weight: 600; }
|
||||
.footer-col a { display: block; padding: 4px 0; }
|
||||
.footer-col a:hover { color: var(--fg); }
|
||||
.footer-bottom { display: flex; justify-content: space-between; padding-top: 24px; border-top: 1px solid var(--border); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header class="nav">
|
||||
<div class="container nav-row">
|
||||
<a class="brand" href="#"><span class="brand-mark"></span>${escapeHtml(productName)}</a>
|
||||
<nav class="nav-links">
|
||||
<a href="#features">Product</a>
|
||||
<a href="#preview">Workspace</a>
|
||||
<a href="#pricing">Pricing</a>
|
||||
<a href="#faq">Docs</a>
|
||||
<a href="#faq">Customers</a>
|
||||
</nav>
|
||||
<div class="nav-spacer"></div>
|
||||
<a class="nav-link-cta" href="#">Sign in</a>
|
||||
<a class="nav-cta" href="#">Get started →</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<section class="hero">
|
||||
<div class="container">
|
||||
<div class="hero-eyebrow"><span class="dot"></span>${escapeHtml(productName)} · live preview</div>
|
||||
<h1>The system that makes <em>${escapeHtml(productName)}</em> feel like ${escapeHtml(productName)}.</h1>
|
||||
<p class="lede">${escapeHtml(tagline)}</p>
|
||||
<div class="hero-actions">
|
||||
<a class="btn btn-primary" href="#">Start a free trial →</a>
|
||||
<a class="btn btn-ghost" href="#preview">See it in action</a>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span><strong>4.9</strong> · App Store rating</span>
|
||||
<span><strong>SOC 2</strong> · Type II compliant</span>
|
||||
<span><strong>120k+</strong> active teams</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="logos">
|
||||
<div class="container">
|
||||
<div class="logos-label">Trusted by teams shipping serious work</div>
|
||||
<div class="logos-row">
|
||||
<span class="logo-pill">Northwind</span>
|
||||
<span class="logo-pill">Pioneer</span>
|
||||
<span class="logo-pill">Lattice</span>
|
||||
<span class="logo-pill">Atlas Co.</span>
|
||||
<span class="logo-pill">Voltage</span>
|
||||
<span class="logo-pill">Foundry</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="features">
|
||||
<div class="container">
|
||||
<div class="section-eyebrow">What it does</div>
|
||||
<h2 class="section-title">Every primitive a fast team needs.</h2>
|
||||
<p class="section-lede">A system styled entirely from the tokens of ${escapeHtml(productName)} — palette, typography, surfaces, and motion. Drop it into any product and it stays in character.</p>
|
||||
<div class="features">
|
||||
${featureCard('★', 'Tokens that compose', 'Color, type, spacing, and elevation defined once and reused across every surface — from a marketing hero to a row in a table.')}
|
||||
${featureCard('◐', 'Light & dark in lockstep', 'Every component ships with both modes. The accent reads as confident in either context, and contrast meets WCAG AA out of the box.')}
|
||||
${featureCard('⌘', 'Desktop-first, but mobile-honest', 'Layouts collapse from a 12-column desktop grid to a focused single column without losing density or rhythm.')}
|
||||
${featureCard('▣', 'Production-grade primitives', '40+ components — from the obvious (button, input) to the load-bearing (data table, command bar, empty states).')}
|
||||
${featureCard('↗', 'Designed for handoff', 'Every spec carries a Figma frame, a code snippet, and a "do/don’t" pair so engineers don’t have to guess.')}
|
||||
${featureCard('∞', 'Built to evolve', 'Tokens version semver-style. A palette refresh ships through one file — no component code touches.')}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="preview-wrap" id="preview">
|
||||
<div class="container">
|
||||
<div class="section-eyebrow">In production</div>
|
||||
<h2 class="section-title">A workspace, fully styled.</h2>
|
||||
<p class="section-lede">This is the same component library you'd use in your app — rendered with ${escapeHtml(productName)} tokens.</p>
|
||||
<div class="preview-frame">
|
||||
<div class="preview-titlebar"><span></span><span></span><span></span></div>
|
||||
<div class="preview-app">
|
||||
<aside class="preview-side">
|
||||
<div class="brand" style="margin-bottom: 14px;"><span class="brand-mark"></span>${escapeHtml(productName)}</div>
|
||||
<a class="side-link active"><span class="dot"></span>Overview</a>
|
||||
<a class="side-link">Customers</a>
|
||||
<a class="side-link">Pipeline</a>
|
||||
<a class="side-link">Reports</a>
|
||||
<a class="side-link">Automations</a>
|
||||
<div class="side-section">Workspaces</div>
|
||||
<a class="side-link">Growth</a>
|
||||
<a class="side-link">Lifecycle</a>
|
||||
<a class="side-link">Finance</a>
|
||||
</aside>
|
||||
<div class="preview-main">
|
||||
<div class="preview-head">
|
||||
<h4>Overview</h4>
|
||||
<span class="badge up">↑ 12.4% this week</span>
|
||||
</div>
|
||||
<div class="kpi-row">
|
||||
${kpi('MRR', '$184,210', '+8.2%')}
|
||||
${kpi('Active orgs', '2,914', '+121')}
|
||||
${kpi('Conversion', '4.6%', '+0.4 pp')}
|
||||
${kpi('Net retention', '113%', '+2 pp')}
|
||||
</div>
|
||||
<div class="chart-card">
|
||||
<div class="chart-head">
|
||||
<span class="title">Revenue · last 12 weeks</span>
|
||||
<span class="meta">USD · weekly</span>
|
||||
</div>
|
||||
<div class="chart">
|
||||
${inlineLineChart()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-row-2">
|
||||
<div class="list-card">
|
||||
<div class="head">
|
||||
<h5>Top accounts</h5>
|
||||
<span class="badge">View all</span>
|
||||
</div>
|
||||
${listRow('Northwind Trading', 'Annual · NA', '$48,200', 'up')}
|
||||
${listRow('Pioneer Robotics', 'Quarterly · EMEA', '$31,890', 'up')}
|
||||
${listRow('Atlas Cooperative', 'Annual · APAC', '$22,400', '')}
|
||||
${listRow('Foundry Group', 'Monthly · NA', '$14,750', 'up')}
|
||||
</div>
|
||||
<div class="list-card">
|
||||
<div class="head">
|
||||
<h5>Activity</h5>
|
||||
<span class="badge">Live</span>
|
||||
</div>
|
||||
${activityRow('Renewal closed', 'Lattice · 11m ago')}
|
||||
${activityRow('Trial started', 'Voltage · 22m ago')}
|
||||
${activityRow('Plan upgraded', 'Pioneer · 1h ago')}
|
||||
${activityRow('Invoice paid', 'Atlas · 2h ago')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="pricing" style="padding-top: 24px;">
|
||||
<div class="container">
|
||||
<div class="section-eyebrow">Pricing</div>
|
||||
<h2 class="section-title">Built for teams of one to one thousand.</h2>
|
||||
<p class="section-lede">Pick the plan that matches the way your team ships. Every tier ships the full token system.</p>
|
||||
<div class="pricing">
|
||||
${priceCard('Starter', '$0', 'Free forever', ['Single user', 'All core tokens', 'Up to 3 projects', 'Community support'])}
|
||||
${priceCard('Team', '$24', 'per seat / month', ['Unlimited projects', 'Real-time co-edit', 'Brand themes', 'Priority email support'], true)}
|
||||
${priceCard('Enterprise', 'Custom', 'volume pricing', ['SSO + SCIM', 'Audit logs', 'Custom token schemas', 'Dedicated success manager'])}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="section-eyebrow">Customers</div>
|
||||
<h2 class="section-title">Loved by teams who care about craft.</h2>
|
||||
<div class="quotes">
|
||||
${quote('"Our marketing site, our app, and our internal dashboards finally feel like the same product. The token system is doing all the work."', 'Mira Okafor', 'Head of Design · Pioneer')}
|
||||
${quote('"We swapped our entire design language in an afternoon. Nothing broke. That’s the line, and we crossed it."', 'Caleb Renner', 'Engineering Lead · Northwind')}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="faq" style="padding-top: 24px;">
|
||||
<div class="container">
|
||||
<div class="section-eyebrow">FAQ</div>
|
||||
<h2 class="section-title">Questions, answered.</h2>
|
||||
<div class="faq">
|
||||
${faq('Is this a Figma library, a code library, or both?', 'Both. Tokens flow from one source of truth into Figma styles and into the codegen pipeline at the same time.')}
|
||||
${faq('Can we ship our own brand theme?', 'Yes — fork the token file, change the palette and type stack, and every component reskins automatically.')}
|
||||
${faq('What about accessibility?', 'Color contrast meets WCAG AA on every surface. Components ship with focus rings, ARIA roles, and keyboard handling.')}
|
||||
${faq('How do you handle dark mode?', 'Every token has a paired dark value. The system flips at the document level — no per-component overrides needed.')}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="container">
|
||||
<div class="cta">
|
||||
<div>
|
||||
<h2>Ship a product that finally feels finished.</h2>
|
||||
<p>Drop the system into your app today. The first project is on us.</p>
|
||||
</div>
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<a class="btn btn-primary" href="#">Start free trial</a>
|
||||
<a class="btn btn-secondary" href="#">Talk to sales</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<div class="container">
|
||||
<div class="footer-row">
|
||||
<div class="footer-col">
|
||||
<div class="brand" style="margin-bottom: 12px;"><span class="brand-mark"></span>${escapeHtml(productName)}</div>
|
||||
<p style="margin: 0; max-width: 38ch;">${escapeHtml(tagline)}</p>
|
||||
</div>
|
||||
<div class="footer-col"><h6>Product</h6><a href="#">Features</a><a href="#">Pricing</a><a href="#">Changelog</a><a href="#">Roadmap</a></div>
|
||||
<div class="footer-col"><h6>Company</h6><a href="#">About</a><a href="#">Customers</a><a href="#">Careers</a><a href="#">Press</a></div>
|
||||
<div class="footer-col"><h6>Resources</h6><a href="#">Docs</a><a href="#">Status</a><a href="#">Brand</a><a href="#">Contact</a></div>
|
||||
</div>
|
||||
<div class="footer-bottom">
|
||||
<span>© ${new Date().getFullYear()} ${escapeHtml(productName)}. All rights reserved.</span>
|
||||
<span>Showcase rendered from <code style="font-family: var(--mono);">design-systems/${escapeHtml(id)}/DESIGN.md</code></span>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function featureCard(icon: string, title: string, body: string): string {
|
||||
return `<div class="feature">
|
||||
<div class="feature-icon">${escapeHtml(icon)}</div>
|
||||
<h3>${escapeHtml(title)}</h3>
|
||||
<p>${escapeHtml(body)}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function kpi(label: string, value: string, delta: string): string {
|
||||
return `<div class="kpi">
|
||||
<div class="label">${escapeHtml(label)}</div>
|
||||
<div class="value">${escapeHtml(value)}</div>
|
||||
<div class="delta">${escapeHtml(delta)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function listRow(name: string, meta: string, value: string, status: RowStatus): string {
|
||||
const badge = status === 'up' ? '<span class="badge up">↑</span>' : '<span class="badge">·</span>';
|
||||
return `<div class="list-row">
|
||||
<div>
|
||||
<div class="name">${escapeHtml(name)}</div>
|
||||
<div class="meta">${escapeHtml(meta)}</div>
|
||||
</div>
|
||||
<div class="meta">${escapeHtml(value)}</div>
|
||||
${badge}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function activityRow(name: string, meta: string): string {
|
||||
return `<div class="list-row">
|
||||
<div>
|
||||
<div class="name">${escapeHtml(name)}</div>
|
||||
<div class="meta">${escapeHtml(meta)}</div>
|
||||
</div>
|
||||
<div></div>
|
||||
<span class="badge">●</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function priceCard(name: string, price: string, sub: string, features: string[], featured = false): string {
|
||||
return `<div class="price-card${featured ? ' featured' : ''}">
|
||||
<div class="tier-name">${escapeHtml(name)}</div>
|
||||
<div class="price">${escapeHtml(price)} <small>${escapeHtml(sub)}</small></div>
|
||||
<ul>${features.map((f) => `<li>${escapeHtml(f)}</li>`).join('')}</ul>
|
||||
<a class="btn ${featured ? 'btn-primary' : 'btn-ghost'}" href="#" style="${featured ? 'background: var(--accent); color: var(--accent-fg); border-color: var(--accent);' : ''}">Choose ${escapeHtml(name)}</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function quote(text: string, name: string, role: string): string {
|
||||
return `<div class="quote">
|
||||
<p>${escapeHtml(text)}</p>
|
||||
<div class="quote-author">
|
||||
<div class="avatar"></div>
|
||||
<div>
|
||||
<div class="name">${escapeHtml(name)}</div>
|
||||
<div class="role">${escapeHtml(role)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function faq(q: string, a: string): string {
|
||||
return `<div class="faq-item">
|
||||
<h4>${escapeHtml(q)}</h4>
|
||||
<p>${escapeHtml(a)}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function inlineLineChart(): string {
|
||||
// Deterministic numbers so the chart looks specific (12 weekly data points).
|
||||
const data = [38, 44, 41, 52, 49, 61, 58, 67, 71, 76, 82, 88];
|
||||
const max = Math.max(...data);
|
||||
const min = Math.min(...data);
|
||||
const w = 720;
|
||||
const h = 160;
|
||||
const padX = 8;
|
||||
const padY = 14;
|
||||
const stepX = (w - padX * 2) / (data.length - 1);
|
||||
const norm = (v: number) => padY + (h - padY * 2) * (1 - (v - min) / (max - min));
|
||||
const points = data.map((v, i) => `${padX + i * stepX},${norm(v).toFixed(1)}`).join(' ');
|
||||
const area = `${padX},${h} ${points} ${w - padX},${h}`;
|
||||
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">
|
||||
<defs>
|
||||
<linearGradient id="lg" x1="0" x2="0" y1="0" y2="1">
|
||||
<stop offset="0%" stop-color="var(--accent)" stop-opacity="0.32"/>
|
||||
<stop offset="100%" stop-color="var(--accent)" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<polygon points="${area}" fill="url(#lg)"/>
|
||||
<polyline points="${points}" fill="none" stroke="var(--accent)" stroke-width="2.5" stroke-linejoin="round" stroke-linecap="round"/>
|
||||
${data.map((v, i) => `<circle cx="${padX + i * stepX}" cy="${norm(v).toFixed(1)}" r="${i === data.length - 1 ? 4 : 0}" fill="var(--accent)"/>`).join('')}
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function extractSubtitle(raw: string): string {
|
||||
const lines = raw.split(/\r?\n/);
|
||||
const h1 = lines.findIndex((l) => /^#\s+/.test(l));
|
||||
if (h1 === -1) return '';
|
||||
const after = lines.slice(h1 + 1);
|
||||
const nextHeading = after.findIndex((l) => /^#{1,6}\s+/.test(l));
|
||||
const window = (nextHeading === -1 ? after : after.slice(0, nextHeading))
|
||||
.join('\n')
|
||||
.replace(/^>\s*Category:.*$/gim, '')
|
||||
.replace(/^>\s*/gm, '')
|
||||
.trim();
|
||||
return window.split(/\n\n/)[0]?.slice(0, 240) ?? '';
|
||||
}
|
||||
|
||||
export function extractColors(raw: string): ColorToken[] {
|
||||
const colors: ColorToken[] = [];
|
||||
const seen = new Set<string>();
|
||||
function push(name: string, value: string, role: string): void {
|
||||
const cleanName = String(name).replace(/[*_`]+/g, '').replace(/\s+/g, ' ').trim();
|
||||
if (!cleanName || cleanName.length > 60) return;
|
||||
const v = normalizeHex(value);
|
||||
const key = `${cleanName.toLowerCase()}|${v}`;
|
||||
const cleanRole = String(role || '')
|
||||
.replace(/[`*_]+/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/[.;]+$/, '');
|
||||
if (seen.has(key)) {
|
||||
// Already recorded — but if this occurrence carries a richer role
|
||||
// description, upgrade the stored entry so role-based lookups don't
|
||||
// fall back to the bare name.
|
||||
if (cleanRole) {
|
||||
const existing = colors.find(
|
||||
(c) => c.name.toLowerCase() === cleanName.toLowerCase() && c.value === v,
|
||||
);
|
||||
if (existing && (!existing.role || cleanRole.length > existing.role.length)) {
|
||||
existing.role = cleanRole;
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
seen.add(key);
|
||||
colors.push({ name: cleanName, value: v, role: cleanRole });
|
||||
}
|
||||
|
||||
// Process the file line-by-line so multi-hex entries like Linear's
|
||||
// `**Marketing Black** (\`#010102\` / \`#08090a\`): role` don't confuse a
|
||||
// single global regex. We extract three pieces from each candidate line:
|
||||
// - the bold (or list-prefixed) name
|
||||
// - the FIRST hex on the line
|
||||
// - everything after the first `:` that follows the hex (the role)
|
||||
for (const rawLine of raw.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
|
||||
// Pattern A: **Name** … #hex … : role description
|
||||
const bold = /\*\*([A-Za-z][A-Za-z0-9 /&()+_'’-]{1,40}?)\*\*([^\n]+)/.exec(line);
|
||||
if (bold) {
|
||||
const rest = bold[2] ?? '';
|
||||
const hex = /#[0-9a-fA-F]{3,8}\b/.exec(rest);
|
||||
if (hex) {
|
||||
const after = rest.slice((hex.index ?? 0) + hex[0].length);
|
||||
const colonIdx = after.search(/[::]/);
|
||||
const role = colonIdx >= 0 ? after.slice(colonIdx + 1).trim() : '';
|
||||
push(bold[1] ?? '', hex[0], role);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Pattern B: list-prefixed spec lines like
|
||||
// "- Background: `#7d2ae8`" inside a ### Buttons block.
|
||||
// Also handles the `- **Name:** \`#hex\`` shape (colon inside the bold
|
||||
// wrapper) used by agentic/warm-editorial: the optional `\*{0,2}` slots
|
||||
// before the name and after the colon let us absorb the surrounding
|
||||
// `**` markers without needing a third pattern.
|
||||
// Use the name itself as the role so lookups can still see "Background"
|
||||
// and "Text" labels.
|
||||
const spec = /^[\s>*-]*\*{0,2}([A-Za-z][^:*\n]{1,40}?)\*{0,2}\s*[::]\s*\*{0,2}\s*`?(#[0-9a-fA-F]{3,8})/.exec(line);
|
||||
if (spec) {
|
||||
push(spec[1] ?? '', spec[2] ?? '', spec[1] ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
return colors;
|
||||
}
|
||||
|
||||
function extractFonts(raw: string): FontHints {
|
||||
const out: FontHints = {};
|
||||
const re = /^[\s>*-]*\**\s*([A-Za-z][A-Za-z /]{1,30}?)\s*\**\s*[::]\s*`?([^`\n]+?)`?$/gm;
|
||||
let m;
|
||||
while ((m = re.exec(raw)) !== null) {
|
||||
const label = (m[1] ?? '').toLowerCase();
|
||||
const value = (m[2] ?? '').trim().replace(/[*_`]+$/g, '').trim();
|
||||
if (!/[a-zA-Z]/.test(value)) continue;
|
||||
if (value.startsWith('#')) continue;
|
||||
if (/display|heading|h1|title/.test(label) && !out.display) out.display = value;
|
||||
else if (/body|text|paragraph|copy/.test(label) && !out.body) out.body = value;
|
||||
else if (/mono|code/.test(label) && !out.mono) out.mono = value;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// Match a hint as a whole word inside `text` (case-insensitive). We use word
|
||||
// boundaries so descriptive color names like "Cardinal Red" don't satisfy a
|
||||
// "card" hint, and "Gem Pink" doesn't satisfy "ink" — both real bugs the
|
||||
// substring-based version produced for the Duolingo and Canva showcases.
|
||||
function matchesHint(text: string, hint: string): boolean {
|
||||
if (!text) return false;
|
||||
const needle = hint.toLowerCase().trim();
|
||||
if (!needle) return false;
|
||||
const re = new RegExp(`\\b${escapeRegex(needle)}\\b`, 'i');
|
||||
return re.test(text);
|
||||
}
|
||||
|
||||
function pickColor(colors: ColorToken[], hints: string[], exclude: string[] = []): string | null {
|
||||
// Two-pass lookup: each hint is first checked against every color's role
|
||||
// description (the prose authors use to explain how the color is used)
|
||||
// and only then against the bare name. This ensures a `**Snow** … Primary
|
||||
// background.` line is recognised as the page background even though the
|
||||
// name "Snow" doesn't contain the word "background".
|
||||
// `exclude` skips colors whose hex equals an already-chosen role (e.g.
|
||||
// pass `[bg]` when picking `fg`) so two roles can't collapse to the same
|
||||
// hex and erase contrast.
|
||||
const blocked = new Set(
|
||||
exclude
|
||||
.map((v) => (v == null ? '' : String(v).toLowerCase()))
|
||||
.filter((v) => v.length > 0),
|
||||
);
|
||||
const isAllowed = (c: ColorToken) => !blocked.has(c.value.toLowerCase());
|
||||
for (const hint of hints) {
|
||||
const byRole = colors.find((c) => isAllowed(c) && matchesHint(c.role, hint));
|
||||
if (byRole) return byRole.value;
|
||||
const byName = colors.find((c) => isAllowed(c) && matchesHint(c.name, hint));
|
||||
if (byName) return byName.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function colorSaturation(hex: string): number {
|
||||
const v = String(hex).replace('#', '').toLowerCase();
|
||||
if (v.length !== 6) return 0;
|
||||
const r = parseInt(v.slice(0, 2), 16);
|
||||
const g = parseInt(v.slice(2, 4), 16);
|
||||
const b = parseInt(v.slice(4, 6), 16);
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
return max === 0 ? 0 : (max - min) / max;
|
||||
}
|
||||
|
||||
function colorLuminance(hex: string): number {
|
||||
const v = String(hex).replace('#', '').toLowerCase();
|
||||
if (v.length !== 6) return 0.5;
|
||||
const r = parseInt(v.slice(0, 2), 16);
|
||||
const g = parseInt(v.slice(2, 4), 16);
|
||||
const b = parseInt(v.slice(4, 6), 16);
|
||||
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
}
|
||||
|
||||
function firstLightish(colors: ColorToken[]): string | null {
|
||||
for (const c of colors) {
|
||||
if (colorSaturation(c.value) > 0.15) continue;
|
||||
if (colorLuminance(c.value) >= 0.92) return c.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function firstNonNeutral(colors: ColorToken[], exclude: string[] = []): string | null {
|
||||
const set = new Set(exclude.map((v) => String(v || '').toLowerCase()));
|
||||
for (const c of colors) {
|
||||
if (set.has(c.value.toLowerCase())) continue;
|
||||
if (colorSaturation(c.value) > 0.25) return c.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function secondNonNeutral(colors: ColorToken[], exclude: string[] = []): string | null {
|
||||
const set = new Set(exclude.map((v) => String(v || '').toLowerCase()));
|
||||
for (const c of colors) {
|
||||
if (set.has(c.value.toLowerCase())) continue;
|
||||
if (colorSaturation(c.value) > 0.25) return c.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function pickReadableForeground(hex: string): string {
|
||||
const n = normalizeHex(hex);
|
||||
if (n.length !== 7) return '#ffffff';
|
||||
const r = parseInt(n.slice(1, 3), 16);
|
||||
const g = parseInt(n.slice(3, 5), 16);
|
||||
const b = parseInt(n.slice(5, 7), 16);
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return lum > 0.6 ? '#0a0a0a' : '#ffffff';
|
||||
}
|
||||
|
||||
function mixSurface(bg: string): string {
|
||||
const n = normalizeHex(bg);
|
||||
if (n.length !== 7) return '#fafafa';
|
||||
const r = parseInt(n.slice(1, 3), 16);
|
||||
const g = parseInt(n.slice(3, 5), 16);
|
||||
const b = parseInt(n.slice(5, 7), 16);
|
||||
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
// Lift dark backgrounds; tint light backgrounds slightly cooler.
|
||||
const adjust = lum < 0.4 ? 16 : -8;
|
||||
const fix = (v: number) => Math.max(0, Math.min(255, v + adjust)).toString(16).padStart(2, '0');
|
||||
return `#${fix(r)}${fix(g)}${fix(b)}`;
|
||||
}
|
||||
|
||||
function normalizeHex(hex: string): string {
|
||||
let h = hex.toLowerCase();
|
||||
if (h.length === 4) {
|
||||
h = '#' + h.slice(1).split('').map((c) => c + c).join('');
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
function cleanTitle(raw: string): string {
|
||||
return String(raw).replace(/^Design System (Inspired by|for)\s+/i, '').trim();
|
||||
}
|
||||
|
||||
function oneLine(s: string): string {
|
||||
return String(s).replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return String(s).replace(/[&<>"']/g, (c) =>
|
||||
c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : ''',
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user