Add design system panel to /impeccable live

The live-mode float-bar now includes a "Design" toggle that slides a
panel in from the right. Tile-based layout (neutral canvas, one level
of hierarchy, no nested cards) with color swatches and tonal ramps,
typography specimens, corner radii, shadow previews, and *live
component primitives* rendered from the project's real tokens.
Collapsible Named Rules / Do's-and-Don'ts / Overview hold the
narrative context without crowding the tiles.

The /impeccable document command now writes a DESIGN.json sidecar
alongside DESIGN.md. The sidecar carries structured tokens plus
self-contained HTML+CSS snippets per component — this is what lets
the panel render each project's actual button/input/nav instead of
generic approximations. The document spec documents the translation
rules for Tailwind, CSS-in-JS, shadcn, and framework components.

The live server exposes /design-system.json and /design-system/raw.
If DESIGN.json is missing but DESIGN.md is present, the panel falls
back to a limited "basic view" parsed from the markdown and prompts
the user to run /impeccable document for the full visualization. A
stale hint appears when DESIGN.md has been edited after DESIGN.json.

Includes a hand-authored DESIGN.json for this project so the panel
has something to render against out of the box, and a deterministic
DESIGN.md parser (design-parser.mjs) as the fallback source.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-15 12:11:10 -07:00
co-authored by Claude Opus 4.6
parent 50dfeef39f
commit fcb347b9b5
5 changed files with 2211 additions and 2 deletions
+242
View File
@@ -0,0 +1,242 @@
{
"schemaVersion": 1,
"generatedAt": "2026-04-15T00:00:00Z",
"title": "Design System: Impeccable",
"tokens": {
"colors": [
{
"role": "primary",
"name": "Editorial Magenta",
"value": "oklch(60% 0.25 350)",
"description": "The one vibrant voice. Primary CTAs, active navigation, live-state indicators.",
"tonalRamp": [
"oklch(22% 0.12 350)",
"oklch(32% 0.18 350)",
"oklch(42% 0.22 350)",
"oklch(52% 0.25 350)",
"oklch(60% 0.25 350)",
"oklch(72% 0.18 350)",
"oklch(84% 0.10 350)",
"oklch(94% 0.04 350)"
]
},
{
"role": "neutral",
"name": "Warm Ash Cream",
"value": "oklch(96% 0.005 350)",
"description": "Primary page background. Almost-imperceptible magenta tint.",
"tonalRamp": [
"oklch(15% 0.005 350)",
"oklch(25% 0.005 350)",
"oklch(40% 0.005 350)",
"oklch(55% 0.005 350)",
"oklch(70% 0.005 350)",
"oklch(82% 0.005 350)",
"oklch(92% 0.005 350)",
"oklch(96% 0.005 350)"
]
},
{
"role": "neutral",
"name": "Deep Graphite",
"value": "oklch(10% 0 0)",
"description": "Primary text, body CTAs. Softer than pure black.",
"tonalRamp": [
"oklch(10% 0 0)",
"oklch(25% 0 0)",
"oklch(40% 0 0)",
"oklch(55% 0 0)",
"oklch(70% 0 0)",
"oklch(85% 0 0)",
"oklch(92% 0 0)",
"oklch(98% 0 0)"
]
}
],
"typography": [
{
"role": "display",
"name": "Display",
"family": "Cormorant Garamond",
"fallback": "Georgia, serif",
"weight": 300,
"style": "italic",
"sampleSize": "4.5rem",
"lineHeight": "1",
"purpose": "Hero title. Light italic for an author-signature feel."
},
{
"role": "body",
"name": "Body",
"family": "Instrument Sans",
"fallback": "system-ui, sans-serif",
"weight": 400,
"style": "normal",
"sampleSize": "1rem",
"lineHeight": "1.6",
"purpose": "All paragraph copy. Capped at 6575ch."
},
{
"role": "label",
"name": "Label",
"family": "Instrument Sans",
"fallback": "system-ui, sans-serif",
"weight": 500,
"style": "normal",
"sampleSize": "0.9rem",
"lineHeight": "1",
"letterSpacing": "0.05em",
"textTransform": "uppercase",
"purpose": "CTA labels. Short, declarative."
},
{
"role": "mono",
"name": "Mono",
"family": "Space Grotesk",
"fallback": "ui-monospace, monospace",
"weight": 500,
"style": "normal",
"sampleSize": "0.75rem",
"lineHeight": "1.4",
"letterSpacing": "0.1em",
"textTransform": "uppercase",
"purpose": "Micro-labels: 'What's included', 'v3.0 Changelog'."
}
],
"radii": [
{ "name": "sm", "value": "4px" },
{ "name": "md", "value": "8px" },
{ "name": "lg", "value": "12px" },
{ "name": "xl", "value": "16px" }
],
"shadows": [
{
"name": "Soft Hover Lift",
"value": "0 4px 24px -4px rgba(0,0,0,0.12), 0 1px 3px rgba(0,0,0,0.06)",
"purpose": "Default hover response on cards and interactive surfaces."
},
{
"name": "Lifted Card",
"value": "0 20px 40px rgba(0,0,0,0.08)",
"purpose": "Deliberately elevated content (featured cards, install blocks)."
},
{
"name": "Accent Glow",
"value": "0 20px 60px oklch(60% 0.25 350 / 0.15)",
"purpose": "Magenta-tinted ambient shadow. Used sparingly."
}
],
"spacing": [
{ "name": "xs", "value": "8px" },
{ "name": "sm", "value": "16px" },
{ "name": "md", "value": "24px" },
{ "name": "lg", "value": "32px" },
{ "name": "xl", "value": "48px" },
{ "name": "2xl", "value": "80px" },
{ "name": "3xl", "value": "120px" }
]
},
"components": [
{
"name": "Primary CTA",
"kind": "button",
"description": "Sharp, squared, uppercase. The editorial signature.",
"html": "<button class=\"ds-btn-primary\">GET STARTED</button>",
"css": ".ds-btn-primary { display: inline-block; padding: 16px 48px; font-family: 'Instrument Sans', system-ui, sans-serif; font-size: 0.9rem; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase; color: oklch(98% 0 0); background: oklch(10% 0 0); border: none; border-radius: 0; cursor: pointer; transition: transform 0.2s ease, background 0.2s ease; } .ds-btn-primary:hover { transform: translateY(-2px); background: oklch(60% 0.25 350); }"
},
{
"name": "Inline Text Link",
"kind": "button",
"description": "Secondary action. No boxed secondary button exists in the system.",
"html": "<a href=\"#\" class=\"ds-link\">Read the docs</a>",
"css": ".ds-link { color: oklch(10% 0 0); font-family: 'Instrument Sans', system-ui, sans-serif; font-weight: 500; text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1px; transition: color 0.2s ease; } .ds-link:hover { color: oklch(60% 0.25 350); }"
},
{
"name": "Email Input",
"kind": "input",
"description": "Hairline border, transparent background, magenta focus glow.",
"html": "<input type=\"email\" class=\"ds-input-email\" placeholder=\"you@example.com\">",
"css": ".ds-input-email { width: 100%; padding: 10px 14px; font-family: 'Instrument Sans', system-ui, sans-serif; font-size: 0.9rem; color: oklch(10% 0 0); background: transparent; border: 1px solid oklch(92% 0 0); border-radius: 6px; outline: none; transition: border-color 0.15s ease, box-shadow 0.15s ease; } .ds-input-email::placeholder { color: oklch(55% 0 0); } .ds-input-email:focus { border-color: oklch(60% 0.25 350); box-shadow: 0 0 0 3px oklch(60% 0.25 350 / 0.15); }"
},
{
"name": "Micro-Label",
"kind": "chip",
"description": "'What's included' style. Uppercase mono, wide tracking.",
"html": "<span class=\"ds-microlabel\">What's included</span>",
"css": ".ds-microlabel { display: inline-block; font-family: 'Space Grotesk', ui-monospace, monospace; font-size: 0.6875rem; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: oklch(55% 0 0); }"
},
{
"name": "Site Navigation",
"kind": "nav",
"description": "62px compact bar. No underline at rest; accent underline on active.",
"html": "<nav class=\"ds-nav\"><span class=\"ds-nav-brand\">/ Impeccable</span><div class=\"ds-nav-links\"><a href=\"#\" class=\"ds-nav-link ds-nav-active\">Home</a><a href=\"#\" class=\"ds-nav-link\">Docs</a><a href=\"#\" class=\"ds-nav-link\">Anti-patterns</a></div></nav>",
"css": ".ds-nav { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 12px 20px; background: oklch(96% 0.005 350); font-family: 'Instrument Sans', system-ui, sans-serif; } .ds-nav-brand { font-family: 'Cormorant Garamond', Georgia, serif; font-style: italic; font-size: 1.05rem; color: oklch(10% 0 0); } .ds-nav-links { display: flex; gap: 16px; } .ds-nav-link { font-size: 0.85rem; font-weight: 500; color: oklch(10% 0 0); text-decoration: none; padding-bottom: 3px; border-bottom: 1px solid transparent; transition: color 0.2s ease, border-color 0.2s ease; } .ds-nav-link:hover { color: oklch(60% 0.25 350); } .ds-nav-active { border-bottom-color: oklch(60% 0.25 350); }"
},
{
"name": "Feature Card",
"kind": "card",
"description": "Standard card: 12px radius, hairline border, hover lift with soft shadow.",
"html": "<article class=\"ds-card\"><h3 class=\"ds-card-title\">/impeccable polish</h3><p class=\"ds-card-body\">Sweep a site for AI-tool tells and refine typography, spacing, color in one pass.</p></article>",
"css": ".ds-card { padding: 20px 22px; background: oklch(98% 0 0); border: 1px solid oklch(92% 0 0); border-radius: 12px; transition: transform 0.2s ease, box-shadow 0.2s ease; font-family: 'Instrument Sans', system-ui, sans-serif; } .ds-card:hover { transform: translateY(-2px); box-shadow: 0 4px 24px -4px rgba(0,0,0,0.12), 0 1px 3px rgba(0,0,0,0.06); } .ds-card-title { font-family: 'Space Grotesk', ui-monospace, monospace; font-size: 0.85rem; font-weight: 500; color: oklch(10% 0 0); margin: 0 0 6px; } .ds-card-body { font-size: 0.85rem; line-height: 1.55; color: oklch(25% 0 0); margin: 0; }"
},
{
"name": "Command Category Tile",
"kind": "custom",
"description": "Signature component: periodic-table tile used in the commands visualization.",
"html": "<div class=\"ds-tile\"><span class=\"ds-tile-num\">07</span><span class=\"ds-tile-sym\">Po</span><span class=\"ds-tile-lbl\">polish</span></div>",
"css": ".ds-tile { display: grid; grid-template-rows: auto 1fr auto; align-items: center; justify-items: center; width: 56px; height: 64px; padding: 4px; background: #fdf2f8; border: 1px solid #ec4899; border-radius: 6px; color: #be185d; cursor: pointer; transition: transform 0.2s ease, box-shadow 0.2s ease; } .ds-tile:hover { transform: translateY(-2px); box-shadow: 0 6px 16px rgba(236, 72, 153, 0.18); } .ds-tile-num { font-family: 'Space Grotesk', ui-monospace, monospace; font-size: 7px; font-weight: 500; letter-spacing: 0.05em; justify-self: start; } .ds-tile-sym { font-family: 'Cormorant Garamond', Georgia, serif; font-style: italic; font-size: 20px; font-weight: 500; line-height: 1; } .ds-tile-lbl { font-family: 'Space Grotesk', ui-monospace, monospace; font-size: 7px; font-weight: 500; letter-spacing: 0.05em; text-transform: lowercase; }"
}
],
"narrative": {
"northStar": "The Editorial Sanctuary",
"overview": "The Impeccable site reads more like a printed design publication than a SaaS landing page. Committed typography, generous breathing room, a single decisive accent. The aesthetic philosophy is restraint in service of craft. Every element earns its place.",
"keyCharacteristics": [
"Warm off-white paper tones with an almost-imperceptible magenta tint.",
"A single decisive magenta accent used on ≤10% of any screen.",
"Italic serif for display type; clean neutral sans for body at 1.6 line-height.",
"Sharp, uppercase, letter-tracked primary CTAs.",
"Flat surfaces at rest. Shadows appear only on state change.",
"Magazine-scale spacing (8 / 16 / 24 / 32 / 48 / 80 / 120px)."
],
"rules": [
{ "name": "The One Voice Rule", "section": "colors", "body": "Editorial Magenta is the only vibrant color. No supporting accent, ever. If a second emphasis point is needed, use scale or weight, never a second hue." },
{ "name": "The Paper-Not-White Rule", "section": "colors", "body": "Page background is Warm Ash Cream, never Crisp Paper White. Warmth is load-bearing." },
{ "name": "The OKLCH-Only Rule", "section": "colors", "body": "All new colors declared in OKLCH. Hex is reserved for the fenced Command Category Tints." },
{ "name": "The Italic-Is-Voice Rule", "section": "typography", "body": "Italic is a voice choice for display type, not emphasis inside body copy. Body emphasis is carried by weight." },
{ "name": "The 1.6 Leading Rule", "section": "typography", "body": "Body line-height is 1.6 everywhere. Not 1.5, not 1.7. This is the load-bearing readability decision." },
{ "name": "The Fluid-Headlines-Only Rule", "section": "typography", "body": "Headings use clamp() fluid sizing. Body copy uses fixed rem values." },
{ "name": "The Flat-By-Default Rule", "section": "elevation", "body": "Surfaces are flat at rest. Reach for shadows only on hover or deliberate elevation." },
{ "name": "The Low-Alpha Rule", "section": "elevation", "body": "Every shadow uses ≤0.15 alpha on its strongest blur. Higher alphas read as 2014 Material drop shadows." },
{ "name": "The Tinted-Shadow-Only-For-Accent Rule", "section": "elevation", "body": "Neutral shadows for structure. Magenta-tinted shadows only for deliberate accent-glow moments." }
],
"dos": [
"Do treat Warm Ash Cream as the default page background.",
"Do use Editorial Magenta on ≤10% of any given screen.",
"Do set all new colors in OKLCH.",
"Do use italic display type as a voice, not as emphasis inside paragraphs.",
"Do use clamp() fluid sizing for headings; use fixed rem for body.",
"Do keep the primary CTA sharp and squared. border-radius: 0, uppercase, letter-tracked.",
"Do use --ease-out (cubic-bezier(0.16, 1, 0.3, 1)) or --ease-out-quint on transitions.",
"Do leave surfaces flat at rest. Reach for shadows only on hover or deliberate elevation.",
"Do respect prefers-reduced-motion on every animation.",
"Do cap body line length at 6575ch via max-width."
],
"donts": [
"Don't use pure black (#000) or pure white (#fff).",
"Don't use border-left or border-right greater than 1px as a colored stripe. The AI-dashboard tell.",
"Don't use background-clip: text with a gradient. Gradient text is banned.",
"Don't default to dark mode.",
"Don't use glassmorphism.",
"Don't add a second accent color.",
"Don't use rounded rectangles with generic drop shadows.",
"Don't use bounce or elastic easing.",
"Don't animate layout properties (width, height, padding, margin). Use transform and opacity.",
"Don't nest cards inside cards.",
"Don't use identical card grids.",
"Don't use the hero-metric layout template.",
"Don't extend the Command Category Tints vocabulary.",
"Don't hedge in UI copy.",
"Don't introduce a new spacing token outside the 8/16/24/32/48/80/120 scale."
]
}
}
+102 -2
View File
@@ -166,11 +166,111 @@ Concrete, forceful guardrails. Lead each with "Do" or "Don't". Be specific — i
- **Don't** [...]
```
### Step 4b: Write DESIGN.json sidecar
After the Markdown is written, produce a machine-readable sidecar at `DESIGN.json` next to `DESIGN.md`. This powers the `/impeccable live` design-system panel, which renders a tile-based visualization of the system — color swatches with tonal ramps, Aa type specimens, live component previews. **The sidecar is how the panel shows *this project's* actual button/input/nav/card, not a generic approximation.**
Regenerate the sidecar whenever you regenerate DESIGN.md. If the user only asks to refresh the sidecar (e.g., from the live panel's stale-hint), preserve DESIGN.md and write only DESIGN.json.
#### Schema
```json
{
"schemaVersion": 1,
"generatedAt": "ISO-8601 string",
"title": "Design System: [Project Title]",
"tokens": {
"colors": [
{
"role": "primary | secondary | tertiary | neutral | accent",
"name": "Descriptive Name",
"value": "#HEX or oklch(...) or rgba(...)",
"description": "Short role explanation (one sentence).",
"tonalRamp": ["...", "...", "..."]
}
],
"typography": [
{
"role": "display | headline | title | body | label | mono",
"name": "Display",
"family": "Cormorant Garamond",
"fallback": "Georgia, serif",
"weight": 300,
"style": "normal | italic",
"sampleSize": "clamp(2.5rem, 7vw, 4.5rem) or 1rem",
"lineHeight": "1 | 1.2 | 1.6",
"letterSpacing": "normal | 0.05em",
"textTransform": "none | uppercase",
"purpose": "Short description of where this role is used."
}
],
"radii": [{ "name": "sm|md|lg|xl|full", "value": "4px" }],
"shadows": [{ "name": "Descriptive Name", "value": "0 4px 24px rgba(0,0,0,0.12)", "purpose": "..." }],
"spacing": [{ "name": "xs|sm|md|lg|xl|2xl|3xl", "value": "8px" }]
},
"components": [
{
"name": "Primary Button",
"kind": "button | input | nav | chip | card | custom",
"description": "One-line what and when.",
"html": "<button class=\"ds-btn-primary\">GET STARTED</button>",
"css": ".ds-btn-primary { background: #191c1d; color: #fff; padding: 16px 48px; letter-spacing: 0.05em; text-transform: uppercase; font-weight: 500; border: none; border-radius: 0; transition: background 0.2s, transform 0.2s; } .ds-btn-primary:hover { background: oklch(60% 0.25 350); transform: translateY(-2px); }"
}
],
"narrative": {
"northStar": "The Editorial Sanctuary",
"overview": "2-3 paragraphs of the philosophy — pulled from DESIGN.md Overview section.",
"keyCharacteristics": ["...", "..."],
"rules": [{ "name": "The One Voice Rule", "body": "...", "section": "colors|typography|elevation" }],
"dos": ["Do use ..."],
"donts": ["Don't use ..."]
}
}
```
#### Component translation rules
The `html` and `css` fields must be **self-contained, drop-in snippets** that render correctly when injected into a shadow DOM. The panel applies them directly — no post-processing, no framework runtime.
1. **Tailwind expansion.** If the source uses Tailwind (className="bg-primary text-white rounded-lg px-6 py-3"), expand every utility to literal CSS properties in the `css` string. Do **not** reference Tailwind classes; do **not** assume a Tailwind CSS bundle is loaded. Each component is self-contained.
2. **Token resolution.** If the project exposes tokens as CSS custom properties on `:root` (e.g. `--color-primary`, `--radius-md`), reference them via `var(--color-primary)` — they inherit through the shadow DOM and stay live-bound. If tokens live only in JS theme objects (styled-components, CSS-in-JS), resolve to literal values at generation time.
3. **Icons.** Inline as SVG. Do not reference Lucide/Heroicons packages, icon fonts, or `<img src="...">`. A typical icon is 16-24px; copy the SVG path data directly.
4. **States.** Include `:hover`, `:focus-visible`, and (if meaningful) `:active` rules inline. A static default-only snapshot makes the panel feel dead. Hover + focus rules in the CSS make it feel alive.
5. **Reset bloat.** Extract only the component's *distinctive* CSS (background, color, padding, border-radius, typography, transition). Skip universal resets (`box-sizing: border-box`, `line-height: inherit`, `-webkit-font-smoothing`). The panel already has a neutral canvas; don't re-ship resets.
6. **Scoped class names.** Prefix every class with `ds-` (e.g. `ds-btn-primary`, `ds-input-search`) so component CSS doesn't collide with other components' CSS in the same shadow DOM.
#### What to include
Aim for a tight set of **5-10 components** that best represent the visual system:
- **Canonical primitives (always include if the project has them):** button (each variant as a separate component entry), input/text field, navigation, chip/tag, card.
- **Signature components (include if distinctive):** hero CTA, featured card, filter pill, any custom pattern the user mentioned as important in PRODUCT.md.
- **Skip the rest.** Utility components, form building blocks, wrapper layouts — not worth documenting unless visually distinctive.
If the project has **no component library yet** (bare landing page, new project), synthesize canonical primitives from the tokens using best-practice defaults consistent with the DESIGN.md's rules. Every DESIGN.json has *something* to render, even on day zero.
#### Tonal ramps
For each color token, generate an 8-step `tonalRamp` array — dark to light, same hue and chroma, stepped lightness from ~15% to ~95%. The panel renders this as a strip under the swatch. If the project already defines a tonal scale (Material `surface-container-low` family, Tailwind-style `blue-50..blue-900`), use those values. Otherwise synthesize in OKLCH.
#### Narrative mapping
Pull directly from the DESIGN.md you just wrote:
- `narrative.northStar` → the `**Creative North Star: "..."**` line from Overview
- `narrative.overview` → the philosophy paragraphs from Overview
- `narrative.keyCharacteristics` → the bulleted `**Key Characteristics:**` list
- `narrative.rules` → every `**The [Name] Rule.** [body]` across all sections, tagged with `section`
- `narrative.dos` / `narrative.donts` → the bullet lists from Do's and Don'ts verbatim
Do not reword. The panel shows these as secondary collapsible context; the same voice that's in the Markdown carries through.
### Step 5: Confirm, refine, and refresh session cache
1. Show the user the full DESIGN.md you wrote. Briefly highlight the non-obvious creative choices (descriptive color names, atmosphere language, named rules).
2. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?"
3. **Refresh the session cache.** Run `node {{scripts_path}}/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading.
2. Mention that `DESIGN.json` was also written alongside — the live panel will now render this project's actual button/input/nav primitives instead of generic approximations.
3. Offer to refine any section: "Want me to revise a section, add component patterns I missed, or adjust the atmosphere language?"
4. **Refresh the session cache.** Run `node {{scripts_path}}/load-context.mjs` one final time so the newly-written DESIGN.md lands in conversation. Subsequent commands in this session will use the fresh version automatically without re-reading.
## Style guidelines
@@ -0,0 +1,722 @@
// Parse a DESIGN.md (Stitch-spec format) into a structured JSON model that
// the live-mode design-system panel can render. Deterministic, dependency-free.
const CANONICAL_SECTIONS = [
'Overview',
'Colors',
'Typography',
'Elevation',
'Components',
"Do's and Don'ts",
];
const HEX_RE = /#[0-9a-fA-F]{3,8}\b/g;
const OKLCH_RE = /oklch\([^)]+\)/gi;
const RGBA_RE = /rgba?\([^)]+\)/gi;
const BOX_SHADOW_RE = /(?:box-shadow:\s*)?((?:-?\d[\w\d\s\-.,/()#%]*)+)/;
const NAMED_RULE_RE = /\*\*(The [^*]+?Rule)\.\*\*\s*(.+)/;
// ---------- Section splitting ----------
function splitSections(md) {
const lines = md.split(/\r?\n/);
let title = null;
const sections = {};
let current = null;
for (const raw of lines) {
const line = raw.trimEnd();
if (!title && line.startsWith('# ') && !line.startsWith('## ')) {
title = line.replace(/^#\s+/, '').trim();
continue;
}
const h2 = line.match(/^##\s+(?:\d+\.\s*)?([^:\n]+?)(?::\s*(.+))?$/);
if (h2) {
const rawName = normalizeApostrophes(h2[1].trim());
const subtitle = h2[2] ? h2[2].trim() : null;
const canonical = matchCanonicalSection(rawName);
if (canonical) {
current = { name: canonical, subtitle, lines: [] };
sections[canonical] = current;
continue;
}
// non-canonical H2 — ignore but stop feeding into current
current = null;
continue;
}
if (current) current.lines.push(raw);
}
return { title, sections };
}
function normalizeApostrophes(s) {
return s.replace(/[\u2018\u2019]/g, "'");
}
function matchCanonicalSection(name) {
const normalized = normalizeApostrophes(name).toLowerCase();
// Exact match first
for (const c of CANONICAL_SECTIONS) {
if (normalizeApostrophes(c).toLowerCase() === normalized) return c;
}
// Keyword-contained match: "Overview & Creative North Star" -> "Overview",
// "Elevation & Depth" -> "Elevation", etc.
for (const c of CANONICAL_SECTIONS) {
const key = normalizeApostrophes(c).toLowerCase();
const pattern = new RegExp(`\\b${key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
if (pattern.test(normalized)) return c;
}
return null;
}
// ---------- Subsection splitting (inside a canonical section) ----------
function splitSubsections(lines) {
const subs = [];
let current = { name: null, lines: [] };
subs.push(current);
for (const raw of lines) {
const h3 = raw.match(/^###\s+(.+?)\s*$/);
if (h3) {
current = { name: h3[1].trim(), lines: [] };
subs.push(current);
continue;
}
current.lines.push(raw);
}
return subs;
}
// ---------- Generic helpers ----------
function collectParagraphs(lines) {
const paragraphs = [];
let buf = [];
const flush = () => {
if (buf.length) {
paragraphs.push(buf.join(' ').trim());
buf = [];
}
};
for (const raw of lines) {
const trimmed = raw.trim();
if (trimmed === '') { flush(); continue; }
// Horizontal rules (---, ***) and headings/bullets end a paragraph.
if (/^(?:-{3,}|\*{3,}|_{3,})$/.test(trimmed)) { flush(); continue; }
if (raw.startsWith('#') || raw.match(/^[-*]\s/)) { flush(); continue; }
buf.push(trimmed);
}
flush();
return paragraphs.filter(Boolean);
}
function collectBullets(lines) {
const bullets = [];
let current = null;
for (const raw of lines) {
const m = raw.match(/^\s*[-*]\s+(.+)$/);
if (m) {
if (current) bullets.push(current);
current = m[1];
continue;
}
// continuation of a bullet (indented line)
if (current && raw.match(/^\s{2,}\S/)) {
current += ' ' + raw.trim();
continue;
}
// blank line ends a bullet
if (raw.trim() === '' && current) {
bullets.push(current);
current = null;
}
}
if (current) bullets.push(current);
return bullets;
}
function stripBold(s) {
return s.replace(/\*\*(.+?)\*\*/g, '$1');
}
function extractNamedRules(lines) {
const rules = [];
const seen = new Set();
// Style A (Impeccable): "**The X Rule.** body body body" — can span lines.
const joined = lines.join('\n');
const inlineStart = /\*\*(The [^*]+?Rule)\.\*\*/g;
const inlineMatches = [];
let m;
while ((m = inlineStart.exec(joined)) !== null) {
inlineMatches.push({ name: m[1], start: m.index, end: inlineStart.lastIndex });
}
for (let i = 0; i < inlineMatches.length; i++) {
const mm = inlineMatches[i];
const bodyEnd = i + 1 < inlineMatches.length ? inlineMatches[i + 1].start : joined.length;
const body = joined
.slice(mm.end, bodyEnd)
.replace(/\n##[^\n]*$/s, '')
.replace(/\n###[^\n]*$/s, '')
.trim();
const name = stripBold(mm.name).trim();
seen.add(name.toLowerCase());
rules.push({ name, body: stripBold(body) });
}
// Style B (Stitch): `### The "X" Rule` or `### The X Fallback`, body is the
// bullets/paragraphs until the next heading. Accept Rule / Fallback / Principle.
for (let i = 0; i < lines.length; i++) {
const h3 = lines[i].match(/^###\s+(.+?)\s*$/);
if (!h3) continue;
const headerName = stripBold(h3[1]).replace(/["“”]/g, '').trim();
if (!/^The\b.*\b(Rule|Fallback|Principle)\b/i.test(headerName)) continue;
if (seen.has(headerName.toLowerCase())) continue;
const bodyLines = [];
for (let j = i + 1; j < lines.length; j++) {
if (/^##\s|^###\s/.test(lines[j])) break;
bodyLines.push(lines[j]);
}
const body = stripBold(bodyLines.join('\n').replace(/\n+/g, ' ')).trim();
if (body) {
seen.add(headerName.toLowerCase());
rules.push({ name: headerName, body });
}
}
// Style C (Stitch bullet form): "* **The Layering Principle:** body"
// Colon/period lives inside the bold, so match "**...**" then inspect.
for (const b of collectBullets(lines)) {
const mm = b.match(/^\*\*([^*]+?)\*\*\s*(.+)$/);
if (!mm) continue;
const nameRaw = mm[1].replace(/[.:]\s*$/, '').replace(/["“”]/g, '').trim();
if (!/^The\b.+\b(Rule|Fallback|Principle)$/i.test(nameRaw)) continue;
if (seen.has(nameRaw.toLowerCase())) continue;
seen.add(nameRaw.toLowerCase());
rules.push({ name: nameRaw, body: stripBold(mm[2]).trim() });
}
return rules;
}
// ---------- Per-section extractors ----------
function extractOverview(section) {
if (!section) return null;
const text = section.lines.join('\n');
const northStar = text.match(/\*\*Creative North Star:\s*"([^"]+)"\*\*/);
const keyChars = [];
const keyCharMatch = text.match(/\*\*Key Characteristics:\*\*\s*\n([\s\S]+?)(?:\n##|\n###|$)/);
if (keyCharMatch) {
for (const line of keyCharMatch[1].split('\n')) {
const m = line.match(/^\s*[-*]\s+(.+)$/);
if (m) keyChars.push(stripBold(m[1].trim()));
}
}
// Philosophy paragraphs: everything that isn't a rule header or key-char block
const paragraphs = collectParagraphs(section.lines).filter(
(p) =>
!p.startsWith('**Creative North Star') &&
!p.startsWith('**Key Characteristics')
);
return {
subtitle: section.subtitle,
creativeNorthStar: northStar ? northStar[1] : null,
philosophy: paragraphs,
keyCharacteristics: keyChars,
};
}
function extractColors(section) {
if (!section) return null;
const subs = splitSubsections(section.lines);
const description = collectParagraphs(subs[0].lines).join(' ');
const groups = [];
const ROLE_KEYWORDS = /^(primary|secondary|tertiary|neutral|accent)\b/i;
for (const sub of subs.slice(1)) {
if (!sub.name || /Named Rules?/i.test(sub.name) || /^The\s/i.test(sub.name)) continue;
const bullets = collectBullets(sub.lines);
const parsed = bullets.map((b) => parseColorBullet(b)).filter(Boolean);
if (parsed.length === 0) continue;
// If every bullet starts with a role keyword (Primary/Secondary/...), promote
// each bullet to its own group. Otherwise keep the subsection as the group.
const allRoleBullets =
parsed.length > 0 && parsed.every((p) => p.name && ROLE_KEYWORDS.test(p.name));
if (allRoleBullets) {
for (const p of parsed) {
groups.push({ role: p.name, colors: [p] });
}
} else {
groups.push({ role: sub.name, colors: parsed });
}
}
// If the Colors section has no subsections at all (unlikely), fall back to
// scanning the whole section as a flat bullet list.
if (groups.length === 0) {
const flat = collectBullets(section.lines)
.map((b) => parseColorBullet(b))
.filter(Boolean);
if (flat.length) {
for (const p of flat) {
if (p.name && ROLE_KEYWORDS.test(p.name)) {
groups.push({ role: p.name, colors: [p] });
} else {
const fallback = groups.find((g) => g.role === 'Palette');
if (fallback) fallback.colors.push(p);
else groups.push({ role: 'Palette', colors: [p] });
}
}
}
}
return {
subtitle: section.subtitle,
description: description || null,
groups,
rules: extractNamedRules(section.lines),
};
}
function parseColorBullet(bullet) {
const text = bullet.trim();
// Case 1 (Impeccable): **Name** (value-with-maybe-nested-parens): description
const bold = text.match(/^\*\*(.+?)\*\*\s*(.*)$/);
if (bold && bold[2].startsWith('(')) {
const value = extractParenGroup(bold[2]);
if (value !== null) {
const after = bold[2].slice(value.length + 2).trimStart();
if (after.startsWith(':')) {
return buildColor(bold[1], value, after.slice(1).trim());
}
}
}
// Case 2 (Stitch): **Name (values):** description — value embedded in bold.
const stitch = text.match(/^\*\*([^*]+?)\s*\(([^)]+)\):\*\*\s*(.*)$/);
if (stitch) {
return buildColor(stitch[1].trim(), stitch[2], stitch[3]);
}
// Case 3: bullet without bold, just hex/oklch inside.
const values = collectColorValues(text);
if (values.length) {
return buildColor(null, values.join(' to '), text);
}
return null;
}
function extractParenGroup(s) {
if (s[0] !== '(') return null;
let depth = 0;
for (let i = 0; i < s.length; i++) {
if (s[i] === '(') depth++;
else if (s[i] === ')') {
depth--;
if (depth === 0) return s.slice(1, i);
}
}
return null;
}
function buildColor(name, rawValue, description) {
const values = collectColorValues(rawValue);
const primary = values[0] ?? rawValue.trim();
return {
name: name ? stripBold(name).trim() : null,
value: primary,
valueRange: values.length > 1 ? values : null,
format: detectFormat(primary),
description: stripBold(description || '').trim() || null,
};
}
function collectColorValues(s) {
const out = [];
s.replace(HEX_RE, (v) => {
out.push(v);
return v;
});
s.replace(OKLCH_RE, (v) => {
out.push(v);
return v;
});
return out;
}
function detectFormat(v) {
if (!v) return 'unknown';
if (v.startsWith('#')) return 'hex';
if (/^oklch/i.test(v)) return 'oklch';
if (/^rgb/i.test(v)) return 'rgb';
return 'unknown';
}
function scanInlineColors(lines) {
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '');
const color = parseColorBullet(trimmed);
if (color) out.push(color);
}
return out;
}
function parseStitchInlineGroups(lines) {
// Stitch writes: `* **Primary (`#00478d` to `#005eb8`):** Use for "..."`
// Each bullet IS its own role. Group them under the spoken role name.
const out = [];
for (const line of lines) {
if (!/^\s*[-*]\s/.test(line)) continue;
const trimmed = line.replace(/^\s*[-*]\s+/, '').trim();
const m = trimmed.match(
/^\*\*([A-Z][a-zA-Z]+)\s*\(([^)]+)\):\*\*\s*(.*)$/
);
if (m) {
const role = m[1];
const color = buildColor(role, m[2], m[3]);
out.push({ role, colors: [color] });
}
}
return out;
}
function extractTypography(section) {
if (!section) return null;
const text = section.lines.join('\n');
const fonts = {};
// Pattern A: **Display Font:** Family (with fallback)
const fontLineRe = /\*\*([\w\s/]+?)Font:\*\*\s*([^\n(]+?)(?:\s*\(with\s+([^)]+)\))?\s*$/gm;
let fm;
while ((fm = fontLineRe.exec(text)) !== null) {
const rawRole = fm[1].trim().toLowerCase().replace(/\s+/g, '-');
const role = normalizeFontRole(rawRole) || 'display';
fonts[role] = {
family: fm[2].trim(),
fallback: fm[3] ? fm[3].trim() : null,
};
}
// Pattern B (Stitch): * **Display & Headlines (Noto Serif):** description
if (Object.keys(fonts).length === 0) {
const stitchRe = /\*\*([\w\s&/]+?)\s*\(([^)]+)\):\*\*\s*(.+)/g;
let sm;
while ((sm = stitchRe.exec(text)) !== null) {
const rawRole = sm[1]
.trim()
.toLowerCase()
.replace(/\s*&\s*/g, '-')
.replace(/\s+/g, '-');
const role = normalizeFontRole(rawRole) || rawRole;
fonts[role] = { family: sm[2].trim(), fallback: null, purpose: sm[3].trim() };
}
}
// Character paragraph — either a **Character:** label, or fall back to the
// first free paragraph under the section header (Stitch style).
const characterMatch = text.match(/\*\*Character:\*\*\s*([^\n]+(?:\n[^\n]+)*?)(?=\n\n|\n###|\n##|$)/);
let character = characterMatch ? characterMatch[1].replace(/\n/g, ' ').trim() : null;
if (!character) {
const paragraphs = collectParagraphs(section.lines).filter(
(p) => !/^\*\*[\w\s/&]+Font/i.test(p) && !/^\*\*[\w\s/&]+\([^)]+\)/.test(p)
);
if (paragraphs.length) character = paragraphs[0];
}
// Hierarchy bullets under ### Hierarchy
const subs = splitSubsections(section.lines);
let hierarchy = [];
const hierSub = subs.find((s) => s.name && /hierarch/i.test(s.name));
if (hierSub) {
const bullets = collectBullets(hierSub.lines);
hierarchy = bullets.map(parseTypeBullet).filter(Boolean);
}
return {
subtitle: section.subtitle,
fonts,
character,
hierarchy,
rules: extractNamedRules(section.lines),
};
}
function normalizeFontRole(raw) {
// Canonical roles the panel cares about: display, body, label, mono.
// Stitch often writes compound roles like "display-&-headlines" or "ui-&-body"
// — collapse them to the first canonical role present.
const tokens = raw.split(/[-/&\s]+/).filter(Boolean);
const priority = ['display', 'headline', 'body', 'ui', 'label', 'mono'];
const canonical = { headline: 'display', ui: 'body' };
for (const p of priority) {
if (tokens.includes(p)) return canonical[p] || p;
}
return null;
}
function parseTypeBullet(bullet) {
// - **Display** (family, weight 300, italic, clamp(...), line-height 1): purpose
const m = bullet.match(/^\*\*(.+?)\*\*\s*\(([^)]+)\):\s*(.*)$/);
if (!m) return null;
const name = m[1].trim();
const specs = m[2].split(',').map((s) => s.trim());
return {
name,
specs,
purpose: stripBold(m[3] || '').trim() || null,
};
}
function extractElevation(section) {
if (!section) return null;
const subs = splitSubsections(section.lines);
const description = collectParagraphs(subs[0].lines).join(' ') || null;
const shadows = [];
const seen = new Set();
const dedupe = (entry) => {
const key = (entry.name || '') + '::' + entry.value;
if (seen.has(key)) return;
seen.add(key);
shadows.push(entry);
};
for (const b of collectBullets(section.lines)) {
const parsed = parseShadowBullet(b);
if (parsed) dedupe(parsed);
}
// Fallback: extract shadows written inline in prose. Stitch style is
// "...use an extra-diffused shadow: `box-shadow: 0 12px 40px rgba(...)`."
for (const p of collectParagraphs(section.lines)) {
for (const inline of extractInlineShadows(p)) dedupe(inline);
}
for (const b of collectBullets(section.lines)) {
for (const inline of extractInlineShadows(b)) dedupe(inline);
}
return {
subtitle: section.subtitle,
description,
shadows,
rules: extractNamedRules(section.lines),
};
}
function extractInlineShadows(text) {
// Find `box-shadow: ...` anywhere in prose and capture the value. Work on the
// raw string so it handles both backtick-fenced and unfenced variants.
const out = [];
const re = /box-shadow\s*:\s*([^`;\n]+)/gi;
let m;
while ((m = re.exec(text)) !== null) {
const value = m[1].replace(/[`.)]+$/, '').trim();
if (!value) continue;
// Name heuristic: the noun immediately before the shadow phrase.
// e.g. "an extra-diffused shadow: ..." -> "extra-diffused shadow"
const before = text.slice(0, m.index);
const nameMatch = before.match(/\b([A-Za-z][A-Za-z\- ]{2,40})\s+shadow\b[^A-Za-z0-9]*$/i);
let name = null;
if (nameMatch) {
const stripped = nameMatch[1]
.replace(/^(?:use|using|apply|applying|is|are|looks? like)\s+/i, '')
.replace(/^(?:a|an|the)\s+/i, '')
.trim();
if (stripped) {
name =
stripped.charAt(0).toUpperCase() + stripped.slice(1) + ' shadow';
}
}
out.push({
name,
value,
purpose: null,
});
}
return out;
}
function parseShadowBullet(bullet) {
// - **Name** (`box-shadow: value`): purpose
// - **Name** (`value`): purpose
// Only accept if the paren content looks like a shadow value (contains px,
// rem, rgba, or box-shadow). This filters out `**Rule Name:**` bullets.
const m = bullet.match(/^\*\*(.+?)\*\*\s*\(`?([^`]+?)`?\):\s*(.*)$/);
if (!m) return null;
const rawValue = m[2].replace(/^box-shadow:\s*/i, '').trim();
const looksLikeShadow =
/box-shadow|rgba?\(|\bpx\b|\brem\b|^-?\d+\s/i.test(rawValue) &&
/\d/.test(rawValue);
if (!looksLikeShadow) return null;
const name = stripBold(m[1]).trim();
return {
name,
value: rawValue,
purpose: stripBold(m[3] || '').trim() || null,
};
}
function extractComponents(section) {
if (!section) return null;
const subs = splitSubsections(section.lines);
const components = [];
for (const sub of subs.slice(1)) {
if (!sub.name) continue;
const bullets = collectBullets(sub.lines);
const paragraphs = collectParagraphs(sub.lines);
const variants = [];
const properties = {};
for (const b of bullets) {
// - **Key:** value
const m = b.match(/^\*\*(.+?):?\*\*:?\s*(.+)$/);
if (m) {
const key = stripBold(m[1]).trim();
const value = stripBold(m[2]).trim();
// Heuristic: "Primary", "Secondary", "Hover", "Focus" etc are variants;
// "Shape", "Background", "Padding" are properties.
if (/^(primary|secondary|tertiary|ghost|hover|focus|active|disabled|default|error|selected|unselected|state)$/i.test(key.split(/[\s/]/)[0])) {
variants.push({ name: key, description: value });
} else {
properties[key.toLowerCase()] = value;
}
}
}
components.push({
name: sub.name,
description: paragraphs.join(' ') || null,
properties,
variants,
});
}
return {
subtitle: section.subtitle,
components,
};
}
function extractDosDonts(section) {
if (!section) return null;
const subs = splitSubsections(section.lines);
const dos = [];
const donts = [];
for (const sub of subs.slice(1)) {
if (!sub.name) continue;
const subName = normalizeApostrophes(sub.name);
const bullets = collectBullets(sub.lines).map((b) => stripBold(b).trim());
if (/^do'?t?:?$/i.test(subName) || /^do:?$/i.test(subName)) {
dos.push(...bullets);
} else if (/^don'?t:?$/i.test(subName)) {
donts.push(...bullets);
}
}
// Classify by bullet prefix as a backup (catches loose bullets outside H3 wrappers)
for (const b of collectBullets(section.lines)) {
const stripped = normalizeApostrophes(stripBold(b).trim());
if (/^don'?t\b/i.test(stripped)) {
if (!donts.some((d) => normalizeApostrophes(d) === stripped)) donts.push(stripped);
} else if (/^do\b/i.test(stripped)) {
if (!dos.some((d) => normalizeApostrophes(d) === stripped)) dos.push(stripped);
}
}
return { dos, donts };
}
// ---------- Coverage assessment ----------
function assessCoverage(model) {
const report = {};
report.overview = model.overview
? {
northStar: Boolean(model.overview.creativeNorthStar),
philosophy: model.overview.philosophy.length > 0,
keyCharacteristics: model.overview.keyCharacteristics.length,
}
: 'missing';
report.colors = model.colors
? {
groups: model.colors.groups.length,
totalColors: model.colors.groups.reduce((n, g) => n + g.colors.length, 0),
rules: model.colors.rules.length,
}
: 'missing';
report.typography = model.typography
? {
fonts: Object.keys(model.typography.fonts).length,
hierarchyEntries: model.typography.hierarchy.length,
character: Boolean(model.typography.character),
rules: model.typography.rules.length,
}
: 'missing';
report.elevation = model.elevation
? {
shadows: model.elevation.shadows.length,
rules: model.elevation.rules.length,
description: Boolean(model.elevation.description),
}
: 'missing';
report.components = model.components
? {
count: model.components.components.length,
variantTotal: model.components.components.reduce((n, c) => n + c.variants.length, 0),
}
: 'missing';
report.dosDonts = model.dosDonts
? {
dos: model.dosDonts.dos.length,
donts: model.dosDonts.donts.length,
}
: 'missing';
return report;
}
// ---------- Main ----------
export function parseDesignMd(md) {
const { title, sections } = splitSections(md);
return {
schemaVersion: 1,
title,
overview: extractOverview(sections['Overview']),
colors: extractColors(sections['Colors']),
typography: extractTypography(sections['Typography']),
elevation: extractElevation(sections['Elevation']),
components: extractComponents(sections['Components']),
dosDonts: extractDosDonts(sections["Do's and Don'ts"]),
};
}
export { assessCoverage };
File diff suppressed because it is too large Load Diff
@@ -20,6 +20,7 @@ import path from 'node:path';
import os from 'node:os';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
import { parseDesignMd } from './design-parser.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PID file in the project root so both the server and agent can find it
@@ -107,6 +108,10 @@ function hasProjectContext() {
} catch { return false; }
}
function statOrNull(filePath) {
try { return fs.statSync(filePath); } catch { return null; }
}
// ---------------------------------------------------------------------------
// Validation (inline — no external import needed for self-contained script)
// ---------------------------------------------------------------------------
@@ -176,6 +181,62 @@ function createRequestHandler({ detectScript, liveScriptWithToken }) {
return;
}
// --- Design system sidecar + raw ---
// /design-system.json prefers DESIGN.json; falls back to parsed DESIGN.md
// returns { mode, model, mdNewerThanJson, ... }
// /design-system/raw returns DESIGN.md markdown verbatim
if (p === '/design-system.json' || p === '/design-system/raw') {
const token = url.searchParams.get('token');
if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
const mdPath = path.join(process.cwd(), 'DESIGN.md');
const jsonPath = path.join(process.cwd(), 'DESIGN.json');
const mdStat = statOrNull(mdPath);
const jsonStat = statOrNull(jsonPath);
if (p === '/design-system/raw') {
if (!mdStat) { res.writeHead(404); res.end('Not found'); return; }
res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
res.end(fs.readFileSync(mdPath, 'utf-8'));
return;
}
if (!mdStat && !jsonStat) {
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: false }));
return;
}
// Prefer DESIGN.json — it's the richer source (live component HTML).
if (jsonStat) {
let model;
try {
model = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: true, error: 'Failed to parse DESIGN.json: ' + err.message }));
return;
}
const mdNewerThanJson = !!(mdStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: true, mode: 'sidecar', model, mdNewerThanJson }));
return;
}
// Fallback: DESIGN.md present but no sidecar. Panel shows a "basic mode"
// view + a CTA to run /impeccable document for the full visualization.
try {
const raw = fs.readFileSync(mdPath, 'utf-8');
const parsedMd = parseDesignMd(raw);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: true, mode: 'parsed-md', parsedMd }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: true, error: err.message }));
}
return;
}
// --- Source file (no-HMR fallback) ---
if (p === '/source') {
const token = url.searchParams.get('token');