diff --git a/DESIGN.json b/DESIGN.json
new file mode 100644
index 000000000..af72cd851
--- /dev/null
+++ b/DESIGN.json
@@ -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 65–75ch."
+ },
+ {
+ "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": "GET STARTED ",
+ "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": "Read the docs ",
+ "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": " ",
+ "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": "What's included ",
+ "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": "/ Impeccable ",
+ "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": "/impeccable polish Sweep a site for AI-tool tells and refine typography, spacing, color in one pass.
",
+ "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": "
07 Po polish
",
+ "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 65–75ch 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."
+ ]
+ }
+}
diff --git a/source/skills/impeccable/reference/document.md b/source/skills/impeccable/reference/document.md
index ec78c560c..73bbbaec7 100644
--- a/source/skills/impeccable/reference/document.md
+++ b/source/skills/impeccable/reference/document.md
@@ -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": "GET STARTED ",
+ "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 ` `. 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
diff --git a/source/skills/impeccable/scripts/design-parser.mjs b/source/skills/impeccable/scripts/design-parser.mjs
new file mode 100644
index 000000000..6220f67e7
--- /dev/null
+++ b/source/skills/impeccable/scripts/design-parser.mjs
@@ -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 };
diff --git a/source/skills/impeccable/scripts/live-browser.js b/source/skills/impeccable/scripts/live-browser.js
index 59e92d239..de7b92446 100644
--- a/source/skills/impeccable/scripts/live-browser.js
+++ b/source/skills/impeccable/scripts/live-browser.js
@@ -1500,6 +1500,1089 @@
console.log('[impeccable] Live mode exited.');
}
+ // ---------------------------------------------------------------------------
+ // Design System Panel — visualizes the project's DESIGN.json sidecar
+ // ---------------------------------------------------------------------------
+
+ const DESIGN_PREFS_KEY = 'impeccable-live-design-panel';
+ const DESIGN_PANEL_WIDTH = 440;
+
+ let designHost = null;
+ let designShadow = null;
+ let designState = {
+ open: false,
+ tab: 'visual', // 'visual' | 'raw'
+ mode: null, // 'sidecar' | 'parsed-md' | null
+ model: null, // DESIGN.json object (sidecar mode)
+ parsedMd: null, // fallback parsed-md output
+ present: null, // true/false once fetch resolves
+ raw: null, // raw DESIGN.md for the raw tab
+ mdNewerThanJson: false, // stale-hint flag
+ loading: false,
+ error: null,
+ collapsed: { // narrative-section accordion state
+ rules: true, dosdonts: true, overview: true,
+ },
+ };
+
+ function loadDesignPrefs() {
+ try {
+ const raw = localStorage.getItem(DESIGN_PREFS_KEY);
+ if (!raw) return;
+ const prefs = JSON.parse(raw);
+ if (typeof prefs.open === 'boolean') designState.open = prefs.open;
+ if (prefs.tab === 'visual' || prefs.tab === 'raw') designState.tab = prefs.tab;
+ if (prefs.collapsed && typeof prefs.collapsed === 'object') {
+ Object.assign(designState.collapsed, prefs.collapsed);
+ }
+ } catch { /* ignore */ }
+ }
+
+ function saveDesignPrefs() {
+ try {
+ localStorage.setItem(DESIGN_PREFS_KEY, JSON.stringify({
+ open: designState.open, tab: designState.tab,
+ collapsed: designState.collapsed,
+ }));
+ } catch { /* ignore */ }
+ }
+
+ function initDesignPanel() {
+ designHost = document.createElement('div');
+ designHost.id = PREFIX + '-design-host';
+ Object.assign(designHost.style, {
+ position: 'fixed', top: '0', left: '0',
+ width: '0', height: '0',
+ zIndex: String(Z.bar + 10),
+ pointerEvents: 'none',
+ });
+ designShadow = designHost.attachShadow({ mode: 'open' });
+
+ const style = document.createElement('style');
+ style.textContent = designPanelCss();
+ designShadow.appendChild(style);
+
+ const root = document.createElement('div');
+ root.className = 'root';
+ designShadow.appendChild(root);
+
+ document.body.appendChild(designHost);
+
+ loadDesignPrefs();
+ renderDesignChrome();
+ if (designState.open) {
+ fetchDesignSystem();
+ }
+ }
+
+ // Neutral panel palette — deliberately NOT Impeccable-branded. The panel is
+ // a viewer of the project's design system, not an Impeccable surface.
+ const DP = {
+ canvas: 'oklch(94% 0 0)', // panel background
+ tile: 'oklch(98.5% 0 0)', // card-on-canvas
+ tileAlt: 'oklch(96% 0 0)', // subtler tile for inner surfaces
+ ink: 'oklch(15% 0 0)',
+ ink2: 'oklch(35% 0 0)',
+ meta: 'oklch(55% 0 0)',
+ hairline: 'oklch(88% 0 0)',
+ hairlineSoft: 'oklch(92% 0 0)',
+ amber: 'oklch(70% 0.13 65)', // stale-hint accent
+ amberBg: 'oklch(95% 0.05 80)',
+ };
+
+ function designPanelCss() {
+ return `
+ :host, .root { all: initial; }
+ .root {
+ font-family: ${FONT};
+ color: ${DP.ink};
+ pointer-events: none;
+ }
+ .root * { box-sizing: border-box; }
+ button { font: inherit; color: inherit; }
+
+ /* --- FAB --- */
+ .fab {
+ position: fixed; right: 16px; bottom: 16px;
+ display: inline-flex; align-items: center; gap: 8px;
+ padding: 8px 14px 8px 10px;
+ background: ${DP.tile};
+ color: ${DP.ink};
+ border: 1px solid ${DP.hairline};
+ border-radius: 999px;
+ box-shadow: 0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06);
+ font-family: ${FONT}; font-size: 11px; font-weight: 600;
+ letter-spacing: 0.08em; text-transform: uppercase;
+ cursor: pointer; pointer-events: auto;
+ transition: transform 0.2s ${EASE}, box-shadow 0.2s ${EASE}, background 0.2s ${EASE};
+ }
+ .fab:hover { transform: translateY(-1px);
+ box-shadow: 0 8px 24px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08); }
+ .fab[data-open="true"] { display: none; }
+ .fab-swatches {
+ width: 16px; height: 16px; border-radius: 3px;
+ display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: 1fr 1fr;
+ overflow: hidden; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.1);
+ }
+ .fab-swatches > span { display: block; }
+
+ /* --- Panel shell --- */
+ .panel {
+ position: fixed; top: 12px; bottom: 12px; right: 12px;
+ width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px);
+ background: ${DP.canvas};
+ border-radius: 14px;
+ box-shadow: 0 20px 60px oklch(0% 0 0 / 0.14), 0 2px 8px oklch(0% 0 0 / 0.06);
+ display: flex; flex-direction: column;
+ transform: translateX(calc(100% + 24px));
+ opacity: 0;
+ transition: transform 0.35s ${EASE}, opacity 0.25s ${EASE};
+ pointer-events: none;
+ overflow: hidden;
+ }
+ .panel[data-open="true"] { transform: translateX(0); opacity: 1; pointer-events: auto; }
+
+ .panel-header {
+ display: flex; align-items: center; gap: 10px;
+ padding: 14px 12px 12px 18px;
+ background: ${DP.canvas};
+ }
+ .panel-title {
+ flex: 1; min-width: 0;
+ font-size: 13px; font-weight: 600;
+ color: ${DP.ink};
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+ }
+ .panel-close {
+ border: none; background: transparent; color: ${DP.meta};
+ width: 28px; height: 28px; border-radius: 8px;
+ display: inline-flex; align-items: center; justify-content: center;
+ cursor: pointer; transition: background 0.15s ease, color 0.15s ease;
+ }
+ .panel-close:hover { background: oklch(90% 0 0); color: ${DP.ink}; }
+
+ .tabs {
+ display: inline-flex; padding: 2px;
+ background: oklch(90% 0 0);
+ border-radius: 8px;
+ gap: 2px;
+ }
+ .tab {
+ border: none; background: transparent;
+ padding: 4px 12px; border-radius: 6px;
+ font-size: 10.5px; font-weight: 600; letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: ${DP.meta}; cursor: pointer;
+ transition: background 0.15s ease, color 0.15s ease;
+ }
+ .tab[data-active="true"] { background: ${DP.tile}; color: ${DP.ink};
+ box-shadow: 0 1px 2px oklch(0% 0 0 / 0.08); }
+
+ .panel-body {
+ flex: 1; overflow-y: auto;
+ padding: 4px 12px 20px;
+ scrollbar-width: thin;
+ scrollbar-color: ${DP.hairline} transparent;
+ }
+ .panel-body::-webkit-scrollbar { width: 8px; }
+ .panel-body::-webkit-scrollbar-thumb { background: ${DP.hairline}; border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; }
+
+ /* --- States --- */
+ .empty, .loading, .error {
+ margin: 16px 4px;
+ padding: 28px 20px; text-align: center;
+ background: ${DP.tile}; border-radius: 14px;
+ color: ${DP.ink2}; font-size: 13px; line-height: 1.55;
+ }
+ .empty strong { color: ${DP.ink}; display: block; margin-bottom: 6px; font-size: 14px; }
+ .empty code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: ${DP.ink}; }
+ .error { color: oklch(45% 0.15 25); }
+
+ /* --- Stale hint --- */
+ .stale {
+ display: flex; align-items: center; gap: 8px;
+ margin: 8px 4px 12px;
+ padding: 8px 12px;
+ background: ${DP.amberBg};
+ border-radius: 10px;
+ font-size: 11.5px; color: ${DP.ink2};
+ }
+ .stale-dot { width: 8px; height: 8px; border-radius: 50%; background: ${DP.amber}; flex-shrink: 0; }
+ .stale-text { flex: 1; min-width: 0; }
+ .stale-text strong { color: ${DP.ink}; font-weight: 600; }
+
+ /* --- Parsed-md fallback banner --- */
+ .parsed-md-cta {
+ margin: 8px 4px 14px;
+ padding: 14px 16px;
+ background: ${DP.tile};
+ border: 1px dashed ${DP.hairline};
+ border-radius: 12px;
+ font-size: 12px; color: ${DP.ink2}; line-height: 1.55;
+ }
+ .parsed-md-cta strong { color: ${DP.ink}; display: block; margin-bottom: 4px; font-size: 13px; font-weight: 600; }
+ .parsed-md-cta code { font-family: ${MONO}; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; font-size: 11.5px; color: ${DP.ink}; }
+
+ /* --- Tile primitives --- */
+ .tile {
+ position: relative;
+ background: ${DP.tile};
+ border-radius: 16px;
+ padding: 16px;
+ margin: 0 4px 10px;
+ }
+ .tile-row { margin: 0 4px 10px; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
+ .tile-row .tile { margin: 0; }
+ .tile-meta {
+ display: flex; align-items: baseline; justify-content: space-between;
+ gap: 10px;
+ font-family: ${MONO};
+ font-size: 10px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase;
+ color: ${DP.meta};
+ }
+ .tile-meta .name { color: ${DP.ink}; font-weight: 600; letter-spacing: 0.05em; text-transform: none; font-family: ${FONT}; font-size: 12.5px; }
+
+ /* --- Color tile --- */
+ .c-tile { cursor: pointer; transition: transform 0.2s ${EASE}; }
+ .c-tile:hover { transform: translateY(-1px); }
+ .c-hero {
+ height: 72px; border-radius: 10px; margin-top: 10px;
+ box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.05);
+ }
+ .c-ramp {
+ display: flex; gap: 0; height: 14px; border-radius: 4px; overflow: hidden;
+ margin-top: 8px;
+ box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.04);
+ }
+ .c-ramp > span { flex: 1; }
+ .c-desc { margin-top: 8px; font-size: 11.5px; line-height: 1.45; color: ${DP.ink2}; }
+
+ /* --- Type tile --- */
+ .t-tile { }
+ .t-specimen {
+ margin: 4px 0 6px;
+ color: ${DP.ink};
+ line-height: 0.9;
+ }
+ .t-family { margin-top: 4px; font-size: 12px; font-weight: 600; color: ${DP.ink}; }
+ .t-purpose { margin-top: 4px; font-size: 11px; line-height: 1.45; color: ${DP.ink2}; }
+
+ /* --- Shadow tile --- */
+ .s-tile { }
+ .s-surface {
+ height: 60px; margin: 8px 2px 10px;
+ background: ${DP.tile};
+ border-radius: 10px;
+ }
+ .s-value { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; word-break: break-all; line-height: 1.4; }
+ .s-purpose { margin-top: 4px; font-size: 11px; color: ${DP.ink2}; line-height: 1.45; }
+
+ /* --- Radii strip --- */
+ .r-strip { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
+ .r-item { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: 1; min-width: 60px; }
+ .r-sample { width: 44px; height: 44px; background: ${DP.canvas}; box-shadow: inset 0 0 0 1px oklch(0% 0 0 / 0.08); }
+ .r-label { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; text-transform: uppercase; }
+ .r-val { font-family: ${MONO}; font-size: 10px; color: ${DP.ink}; }
+
+ /* --- Component tile (hosts live primitives) --- */
+ .cmp-tile { }
+ .cmp-stage {
+ margin: 12px -4px 0;
+ padding: 18px 16px 10px;
+ border-top: 1px solid ${DP.hairlineSoft};
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
+ gap: 14px;
+ min-height: 68px;
+ }
+ .cmp-stage + .cmp-stage { border-top: 1px dashed ${DP.hairlineSoft}; }
+ .cmp-sublabel { font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.06em; }
+ .cmp-kind { font-family: ${MONO}; font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; }
+
+ /* --- Collapsible --- */
+ .coll {
+ margin: 0 4px 8px;
+ background: ${DP.tile};
+ border-radius: 12px;
+ overflow: hidden;
+ }
+ .coll-head {
+ display: flex; align-items: center; gap: 10px;
+ width: 100%;
+ padding: 12px 14px;
+ background: transparent; border: none;
+ cursor: pointer; text-align: left;
+ font-family: ${FONT}; font-size: 12.5px; font-weight: 600; color: ${DP.ink};
+ transition: background 0.12s ease;
+ }
+ .coll-head:hover { background: ${DP.tileAlt}; }
+ .coll-chev {
+ width: 12px; height: 12px; flex-shrink: 0;
+ color: ${DP.meta};
+ transition: transform 0.2s ${EASE};
+ }
+ .coll[data-open="true"] .coll-chev { transform: rotate(90deg); }
+ .coll-count { margin-left: auto; font-family: ${MONO}; font-size: 10px; color: ${DP.meta}; letter-spacing: 0.05em; }
+ .coll-body { padding: 0 14px 14px; display: none; }
+ .coll[data-open="true"] .coll-body { display: block; }
+
+ .rule-card {
+ padding: 10px 0;
+ border-top: 1px solid ${DP.hairlineSoft};
+ }
+ .rule-card:first-child { border-top: none; padding-top: 2px; }
+ .rule-card .name { font-size: 11.5px; font-weight: 700; color: ${DP.ink}; margin-bottom: 3px; }
+ .rule-card .name .section { font-family: ${MONO}; font-size: 9px; font-weight: 500; letter-spacing: 0.1em; text-transform: uppercase; color: ${DP.meta}; margin-left: 8px; }
+ .rule-card .body { font-size: 11.5px; color: ${DP.ink2}; line-height: 1.5; }
+
+ .coll .dos { display: grid; gap: 0; margin-top: 2px; }
+ .coll .do, .coll .dont {
+ position: relative;
+ padding: 8px 0 8px 22px;
+ font-size: 11.5px; line-height: 1.5; color: ${DP.ink2};
+ border-top: 1px solid ${DP.hairlineSoft};
+ }
+ .coll .do:first-child, .coll .dont:first-child,
+ .coll .do:first-of-type { border-top: none; }
+ .coll .do + .dont { border-top: 1px solid ${DP.hairlineSoft}; }
+ .coll .do::before, .coll .dont::before {
+ content: ''; position: absolute; left: 4px; top: 13px;
+ width: 8px; height: 8px; border-radius: 50%;
+ }
+ .coll .do::before { background: oklch(62% 0.16 145); }
+ .coll .dont::before { background: oklch(58% 0.22 25); }
+
+ .coll .overview-body {
+ font-size: 12px; line-height: 1.55; color: ${DP.ink2};
+ }
+ .coll .overview-body .north-star {
+ display: block; font-family: ${FONT}; font-style: italic;
+ font-size: 15px; line-height: 1.3; color: ${DP.ink};
+ margin-bottom: 8px;
+ }
+ .coll .overview-body p { margin: 0 0 8px; }
+ .coll .overview-body ul { margin: 6px 0 0; padding-left: 16px; font-size: 11.5px; }
+ .coll .overview-body li { margin-bottom: 3px; }
+
+ /* --- raw tab markdown (unchanged layout, neutralized palette) --- */
+ .md { padding: 4px 10px 20px; font-size: 13px; line-height: 1.6; color: ${DP.ink}; }
+ .md h1, .md h2, .md h3, .md h4 { margin: 20px 0 8px; color: ${DP.ink}; font-weight: 600; }
+ .md h1 { font-size: 18px; }
+ .md h2 { font-size: 15px; padding-bottom: 4px; border-bottom: 1px solid ${DP.hairlineSoft}; }
+ .md h3 { font-size: 13px; }
+ .md h4 { font-size: 12px; color: ${DP.meta}; }
+ .md p { margin: 0 0 10px; }
+ .md ul, .md ol { margin: 0 0 10px; padding-left: 20px; }
+ .md li { margin-bottom: 4px; }
+ .md code { font-family: ${MONO}; font-size: 12px; background: ${DP.canvas}; padding: 1px 5px; border-radius: 4px; }
+ .md pre { font-family: ${MONO}; font-size: 12px; background: ${DP.canvas}; padding: 10px 12px; border-radius: 8px; overflow-x: auto; margin: 0 0 10px; }
+ .md pre code { background: none; padding: 0; }
+ .md strong { font-weight: 700; }
+ .md em { font-style: italic; }
+ .md a { color: ${DP.ink}; text-decoration: underline; }
+ .md hr { border: none; border-top: 1px solid ${DP.hairlineSoft}; margin: 16px 0; }
+ `;
+ }
+
+ function renderDesignChrome() {
+ const root = designShadow.querySelector('.root');
+ root.innerHTML = '';
+
+ // FAB button
+ const fab = document.createElement('button');
+ fab.className = 'fab';
+ fab.setAttribute('data-open', designState.open ? 'true' : 'false');
+ fab.setAttribute('aria-label', 'Toggle Design System panel');
+ fab.innerHTML = `
+
+
+
+
+
+
+ Design
+ `;
+ fab.addEventListener('click', toggleDesignPanel);
+ root.appendChild(fab);
+
+ // Panel
+ const panel = document.createElement('aside');
+ panel.className = 'panel';
+ panel.setAttribute('data-open', designState.open ? 'true' : 'false');
+ panel.appendChild(buildDesignHeader());
+ const body = document.createElement('div');
+ body.className = 'panel-body';
+ body.id = 'panel-body';
+ panel.appendChild(body);
+ root.appendChild(panel);
+
+ renderDesignBody();
+ }
+
+ function buildDesignHeader() {
+ const header = document.createElement('div');
+ header.className = 'panel-header';
+
+ const title = document.createElement('div');
+ title.className = 'panel-title';
+ title.textContent = designState.model?.title
+ || designState.parsedMd?.title
+ || 'Design System';
+ header.appendChild(title);
+
+ const tabs = document.createElement('div');
+ tabs.className = 'tabs';
+ for (const t of [['visual', 'Visual'], ['raw', 'Raw']]) {
+ const btn = document.createElement('button');
+ btn.className = 'tab';
+ btn.textContent = t[1];
+ btn.setAttribute('data-active', designState.tab === t[0] ? 'true' : 'false');
+ btn.addEventListener('click', () => {
+ if (designState.tab === t[0]) return;
+ designState.tab = t[0];
+ saveDesignPrefs();
+ renderDesignChrome();
+ if (t[0] === 'raw' && designState.raw === null && !designState.loading) {
+ fetchDesignSystem(); // raw is part of the same fetch pair
+ }
+ });
+ tabs.appendChild(btn);
+ }
+ header.appendChild(tabs);
+
+ const close = document.createElement('button');
+ close.className = 'panel-close';
+ close.innerHTML = '✕';
+ close.setAttribute('aria-label', 'Close panel');
+ close.addEventListener('click', toggleDesignPanel);
+ header.appendChild(close);
+
+ return header;
+ }
+
+ function toggleDesignPanel() {
+ designState.open = !designState.open;
+ saveDesignPrefs();
+ renderDesignChrome();
+ if (designState.open && designState.present === null && !designState.loading) {
+ fetchDesignSystem();
+ }
+ }
+
+ async function fetchDesignSystem() {
+ designState.loading = true;
+ designState.error = null;
+ renderDesignBody();
+ try {
+ const [jsonRes, rawRes] = await Promise.all([
+ fetch(`http://localhost:${PORT}/design-system.json?token=${TOKEN}`, { cache: 'no-store' }),
+ fetch(`http://localhost:${PORT}/design-system/raw?token=${TOKEN}`, { cache: 'no-store' }),
+ ]);
+ const jsonData = await jsonRes.json();
+ designState.present = jsonData.present === true;
+ designState.mode = jsonData.mode || null;
+ designState.model = jsonData.model || null;
+ designState.parsedMd = jsonData.parsedMd || null;
+ designState.mdNewerThanJson = !!jsonData.mdNewerThanJson;
+ designState.raw = designState.present && rawRes.ok ? await rawRes.text() : null;
+ designState.error = jsonData.error || null;
+ } catch (err) {
+ designState.error = err?.message || 'Failed to load design system.';
+ } finally {
+ designState.loading = false;
+ renderDesignChrome(); // refresh title from data
+ }
+ }
+
+ function renderDesignBody() {
+ const body = designShadow.querySelector('#panel-body');
+ if (!body) return;
+ body.innerHTML = '';
+
+ if (designState.loading) {
+ body.appendChild(msgDiv('loading', 'Loading design system…'));
+ return;
+ }
+ if (designState.error) {
+ body.appendChild(msgDiv('error', designState.error));
+ return;
+ }
+ if (designState.present === false) {
+ const empty = document.createElement('div');
+ empty.className = 'empty';
+ empty.innerHTML = `No DESIGN.md yet Create one by running /impeccable document in your terminal, then re-open this panel.`;
+ body.appendChild(empty);
+ return;
+ }
+
+ if (designState.tab === 'raw') {
+ renderRawTab(body, designState.raw || '');
+ return;
+ }
+
+ // Visual tab
+ if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
+
+ if (designState.mode === 'sidecar' && designState.model) {
+ renderSidecarVisual(body, designState.model);
+ } else if (designState.mode === 'parsed-md' && designState.parsedMd) {
+ body.appendChild(renderParsedMdCta());
+ renderParsedMdVisual(body, designState.parsedMd);
+ } else {
+ body.appendChild(msgDiv('empty', 'No design system data available.'));
+ }
+ }
+
+ function msgDiv(cls, text) {
+ const d = document.createElement('div');
+ d.className = cls;
+ d.textContent = text;
+ return d;
+ }
+
+ function renderStaleHint() {
+ const box = document.createElement('div');
+ box.className = 'stale';
+ box.innerHTML = `
+
+ DESIGN.md is newer than DESIGN.json. Run /impeccable document to refresh the sidecar.
+ `;
+ return box;
+ }
+
+ function renderParsedMdCta() {
+ const box = document.createElement('div');
+ box.className = 'parsed-md-cta';
+ box.innerHTML = `Basic view Running /impeccable document generates DESIGN.json alongside your DESIGN.md, which lets this panel render your project's actual button, input, and nav primitives — not generic approximations.`;
+ return box;
+ }
+
+ // --- Sidecar (DESIGN.json) rendering --------------------------------------
+
+ function renderSidecarVisual(body, model) {
+ const tokens = model.tokens || {};
+ if (tokens.colors?.length) renderColorTiles(body, tokens.colors);
+ if (tokens.typography?.length) renderTypeTiles(body, tokens.typography);
+ if (tokens.radii?.length) renderRadiiTile(body, tokens.radii);
+ if (tokens.shadows?.length) renderShadowTiles(body, tokens.shadows);
+ if (Array.isArray(model.components) && model.components.length) {
+ renderComponentTiles(body, model.components);
+ }
+
+ // Narrative → collapsibles (closed by default)
+ const n = model.narrative || {};
+ if (n.rules?.length) body.appendChild(renderRulesCollapsible(n.rules));
+ if ((n.dos?.length || n.donts?.length)) body.appendChild(renderDosDontsCollapsible(n));
+ if (n.overview || n.northStar || n.keyCharacteristics?.length) {
+ body.appendChild(renderOverviewCollapsible(n));
+ }
+ }
+
+ function renderColorTiles(body, colors) {
+ for (const c of colors) {
+ const tile = document.createElement('div');
+ tile.className = 'tile c-tile';
+ tile.title = 'Click to copy';
+ tile.addEventListener('click', () => copyToClipboard(c.value));
+
+ const meta = document.createElement('div');
+ meta.className = 'tile-meta';
+ meta.innerHTML = `${escapeHtml(c.name || c.role || 'Color')} ${escapeHtml(c.value || '')} `;
+ tile.appendChild(meta);
+
+ const hero = document.createElement('div');
+ hero.className = 'c-hero';
+ hero.style.background = c.value;
+ tile.appendChild(hero);
+
+ const ramp = synthesizeRamp(c);
+ if (ramp.length) {
+ const r = document.createElement('div');
+ r.className = 'c-ramp';
+ r.innerHTML = ramp.map((v) => ` `).join('');
+ tile.appendChild(r);
+ }
+
+ if (c.description) {
+ const d = document.createElement('div');
+ d.className = 'c-desc';
+ d.textContent = c.description;
+ tile.appendChild(d);
+ }
+ body.appendChild(tile);
+ }
+ }
+
+ function synthesizeRamp(c) {
+ if (c.tonalRamp?.length) return c.tonalRamp;
+ // If base value is OKLCH, synthesize an 8-step ramp across lightness.
+ const m = typeof c.value === 'string' && c.value.match(/^oklch\(\s*([\d.]+)%\s+([\d.]+)\s+([\d.]+)\s*(?:\/\s*([\d.]+))?\s*\)$/i);
+ if (!m) return [];
+ const [, , chroma, hue] = m;
+ const steps = [20, 32, 44, 56, 68, 80, 90, 96];
+ return steps.map((l) => `oklch(${l}% ${chroma} ${hue})`);
+ }
+
+ function renderTypeTiles(body, types) {
+ for (const t of types) {
+ const tile = document.createElement('div');
+ tile.className = 'tile t-tile';
+
+ const meta = document.createElement('div');
+ meta.className = 'tile-meta';
+ meta.innerHTML = `${escapeHtml(t.role || '')} ${escapeHtml(t.weight || '')} ${escapeHtml(t.style === 'italic' ? 'italic' : '')} `;
+ tile.appendChild(meta);
+
+ const specimen = document.createElement('div');
+ specimen.className = 't-specimen';
+ specimen.textContent = 'Aa';
+ specimen.style.fontFamily = fontStack(t);
+ specimen.style.fontWeight = String(t.weight || 400);
+ specimen.style.fontStyle = t.style || 'normal';
+ specimen.style.fontSize = '56px'; // Fixed specimen size — compare faces, not scales.
+ specimen.style.letterSpacing = 'normal';
+ specimen.style.textTransform = 'none';
+ tile.appendChild(specimen);
+
+ // The system's actual sample size for this role, shown as small mono meta below.
+ if (t.sampleSize) {
+ const scale = document.createElement('div');
+ scale.style.cssText = 'font-family:' + MONO + '; font-size: 10px; color:' + DP.meta + '; margin-top: 2px;';
+ scale.textContent = t.sampleSize;
+ tile.appendChild(scale);
+ }
+
+ const family = document.createElement('div');
+ family.className = 't-family';
+ family.textContent = t.family || t.name || '';
+ tile.appendChild(family);
+
+ if (t.purpose) {
+ const p = document.createElement('div');
+ p.className = 't-purpose';
+ p.textContent = t.purpose;
+ tile.appendChild(p);
+ }
+ body.appendChild(tile);
+ }
+ }
+
+ function fontStack(t) {
+ const fam = t.family || '';
+ const fb = t.fallback || '';
+ if (fam && /[,\s]/.test(fam) && !fam.includes("'") && !fam.includes('"')) {
+ return `"${fam}", ${fb}`;
+ }
+ return fam && fb ? `"${fam}", ${fb}` : (fam || fb);
+ }
+
+ function renderRadiiTile(body, radii) {
+ const tile = document.createElement('div');
+ tile.className = 'tile';
+ const meta = document.createElement('div');
+ meta.className = 'tile-meta';
+ meta.innerHTML = `Corner Radii ${radii.length} `;
+ tile.appendChild(meta);
+
+ const strip = document.createElement('div');
+ strip.className = 'r-strip';
+ for (const r of radii) {
+ const item = document.createElement('div');
+ item.className = 'r-item';
+ const s = document.createElement('div');
+ s.className = 'r-sample';
+ s.style.borderRadius = r.value || '0';
+ item.appendChild(s);
+ const lbl = document.createElement('div');
+ lbl.className = 'r-label';
+ lbl.textContent = r.name || '';
+ item.appendChild(lbl);
+ const val = document.createElement('div');
+ val.className = 'r-val';
+ val.textContent = r.value || '';
+ item.appendChild(val);
+ strip.appendChild(item);
+ }
+ tile.appendChild(strip);
+ body.appendChild(tile);
+ }
+
+ function renderShadowTiles(body, shadows) {
+ for (const sh of shadows) {
+ const tile = document.createElement('div');
+ tile.className = 'tile s-tile';
+
+ const meta = document.createElement('div');
+ meta.className = 'tile-meta';
+ meta.innerHTML = `${escapeHtml(sh.name || 'Shadow')} Elevation `;
+ tile.appendChild(meta);
+
+ const surface = document.createElement('div');
+ surface.className = 's-surface';
+ surface.style.boxShadow = sh.value || 'none';
+ tile.appendChild(surface);
+
+ const val = document.createElement('div');
+ val.className = 's-value';
+ val.textContent = sh.value || '';
+ tile.appendChild(val);
+
+ if (sh.purpose) {
+ const p = document.createElement('div');
+ p.className = 's-purpose';
+ p.textContent = sh.purpose;
+ tile.appendChild(p);
+ }
+ body.appendChild(tile);
+ }
+ }
+
+ function renderComponentTiles(body, components) {
+ // Group consecutive components that share a kind into one tile. This avoids
+ // a pile of one-component tiles (e.g., three button variants = three tiles)
+ // and reads more like a proper category.
+ const groups = groupByKind(components);
+
+ for (const group of groups) {
+ const tile = document.createElement('div');
+ tile.className = 'tile cmp-tile';
+
+ const meta = document.createElement('div');
+ meta.className = 'tile-meta';
+ const groupTitle = group.length === 1
+ ? (group[0].name || group[0].kind || 'Component')
+ : titleForKind(group[0].kind, group.length);
+ meta.innerHTML = `${escapeHtml(groupTitle)} ${escapeHtml(group[0].kind || '')} `;
+ tile.appendChild(meta);
+
+ for (const c of group) {
+ const stage = document.createElement('div');
+ stage.className = 'cmp-stage';
+
+ // Render the component in its own shadow root so its CSS can't bleed.
+ const host = document.createElement('div');
+ const sub = host.attachShadow({ mode: 'open' });
+ const style = document.createElement('style');
+ style.textContent = c.css || '';
+ sub.appendChild(style);
+ const container = document.createElement('div');
+ container.innerHTML = c.html || '';
+ sub.appendChild(container);
+ stage.appendChild(host);
+
+ // Show component name as a sublabel only when the tile groups >1 item,
+ // or when the component's display name differs from its kind.
+ const showSublabel = group.length > 1;
+ if (showSublabel) {
+ const lbl = document.createElement('div');
+ lbl.className = 'cmp-sublabel';
+ lbl.textContent = c.name || '';
+ stage.appendChild(lbl);
+ }
+ tile.appendChild(stage);
+ }
+
+ // Single shared description if all items carry the same one; otherwise
+ // skip — per-item descriptions clutter a grouped tile.
+ if (group.length === 1 && group[0].description) {
+ const d = document.createElement('div');
+ d.className = 'c-desc';
+ d.textContent = group[0].description;
+ tile.appendChild(d);
+ }
+ body.appendChild(tile);
+ }
+ }
+
+ function groupByKind(components) {
+ const groups = [];
+ for (const c of components) {
+ const last = groups[groups.length - 1];
+ if (last && last[0].kind && c.kind === last[0].kind) {
+ last.push(c);
+ } else {
+ groups.push([c]);
+ }
+ }
+ return groups;
+ }
+
+ function titleForKind(kind, count) {
+ const labels = {
+ button: 'Buttons',
+ input: 'Inputs',
+ nav: 'Navigation',
+ chip: 'Chips',
+ card: 'Cards',
+ custom: 'Components',
+ };
+ return labels[kind] || (kind ? kind.charAt(0).toUpperCase() + kind.slice(1) + 's' : 'Components');
+ }
+
+ // --- Collapsibles ---------------------------------------------------------
+
+ function buildCollapsible(key, label, count) {
+ const wrap = document.createElement('div');
+ wrap.className = 'coll';
+ wrap.setAttribute('data-open', designState.collapsed[key] ? 'false' : 'true');
+
+ const head = document.createElement('button');
+ head.className = 'coll-head';
+ head.innerHTML = `
+
+ ${escapeHtml(label)}
+ ${count != null ? `${escapeHtml(String(count))} ` : ''}
+ `;
+ head.addEventListener('click', () => {
+ designState.collapsed[key] = !designState.collapsed[key];
+ saveDesignPrefs();
+ renderDesignBody();
+ });
+ wrap.appendChild(head);
+
+ const body = document.createElement('div');
+ body.className = 'coll-body';
+ wrap.appendChild(body);
+ return { wrap, body };
+ }
+
+ function renderRulesCollapsible(rules) {
+ const { wrap, body } = buildCollapsible('rules', 'Named Rules', rules.length);
+ for (const r of rules) {
+ const card = document.createElement('div');
+ card.className = 'rule-card';
+ const name = document.createElement('div');
+ name.className = 'name';
+ name.innerHTML = `${escapeHtml(r.name)}${r.section ? `${escapeHtml(r.section)} ` : ''}`;
+ card.appendChild(name);
+ const b = document.createElement('div');
+ b.className = 'body';
+ b.textContent = r.body || '';
+ card.appendChild(b);
+ body.appendChild(card);
+ }
+ return wrap;
+ }
+
+ function renderDosDontsCollapsible(n) {
+ const total = (n.dos?.length || 0) + (n.donts?.length || 0);
+ const { wrap, body } = buildCollapsible('dosdonts', "Do's and Don'ts", total);
+ const grid = document.createElement('div');
+ grid.className = 'dos';
+ for (const d of n.dos || []) {
+ const el = document.createElement('div');
+ el.className = 'do';
+ el.innerHTML = inlineMd(d);
+ grid.appendChild(el);
+ }
+ for (const d of n.donts || []) {
+ const el = document.createElement('div');
+ el.className = 'dont';
+ el.innerHTML = inlineMd(d);
+ grid.appendChild(el);
+ }
+ body.appendChild(grid);
+ return wrap;
+ }
+
+ function renderOverviewCollapsible(n) {
+ const { wrap, body } = buildCollapsible('overview', 'Overview', null);
+ const ov = document.createElement('div');
+ ov.className = 'overview-body';
+ if (n.northStar) {
+ const star = document.createElement('span');
+ star.className = 'north-star';
+ star.textContent = '“' + n.northStar + '”';
+ ov.appendChild(star);
+ }
+ if (n.overview) {
+ const p = document.createElement('p');
+ p.innerHTML = inlineMd(n.overview);
+ ov.appendChild(p);
+ }
+ if (n.keyCharacteristics?.length) {
+ const ul = document.createElement('ul');
+ ul.innerHTML = n.keyCharacteristics.map((k) => `${inlineMd(k)} `).join('');
+ ov.appendChild(ul);
+ }
+ body.appendChild(ov);
+ return wrap;
+ }
+
+ // --- Parsed-md fallback visual (limited view: no live components) ---------
+
+ function renderParsedMdVisual(body, md) {
+ // Reuse sidecar renderers by projecting parsed-md output into the model shape.
+ const pseudoColors = (md.colors?.groups || []).flatMap((g) =>
+ (g.colors || []).map((c) => ({ role: g.role, name: c.name, value: c.value, description: c.description }))
+ );
+ if (pseudoColors.length) renderColorTiles(body, pseudoColors);
+
+ const pseudoTypes = Object.entries(md.typography?.fonts || {}).map(([role, f]) => ({
+ role, name: f.family, family: f.family, fallback: f.fallback, weight: 400,
+ purpose: f.purpose,
+ }));
+ if (pseudoTypes.length) renderTypeTiles(body, pseudoTypes);
+
+ if (md.elevation?.shadows?.length) renderShadowTiles(body, md.elevation.shadows);
+
+ const n = {
+ northStar: md.overview?.creativeNorthStar,
+ overview: (md.overview?.philosophy || []).join(' '),
+ keyCharacteristics: md.overview?.keyCharacteristics || [],
+ rules: [
+ ...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
+ ...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
+ ...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
+ ],
+ dos: md.dosDonts?.dos || [],
+ donts: md.dosDonts?.donts || [],
+ };
+ if (n.rules.length) body.appendChild(renderRulesCollapsible(n.rules));
+ if (n.dos.length || n.donts.length) body.appendChild(renderDosDontsCollapsible(n));
+ if (n.overview || n.northStar || n.keyCharacteristics.length) {
+ body.appendChild(renderOverviewCollapsible(n));
+ }
+ }
+
+ function cssSafe(v) {
+ // Strip anything outside valid CSS value chars to prevent injection via
+ // DESIGN.json values rendered into inline style strings.
+ return String(v).replace(/[<>"'`\n]/g, '');
+ }
+
+ // --- Raw tab: minimal markdown renderer (subset) --------------------------
+
+ function renderRawTab(body, md) {
+ const wrap = document.createElement('div');
+ wrap.className = 'md';
+ wrap.innerHTML = renderMarkdown(md);
+ body.appendChild(wrap);
+ }
+
+ function renderMarkdown(md) {
+ const lines = md.split(/\r?\n/);
+ const out = [];
+ let i = 0;
+ let inCode = false;
+ let codeBuf = [];
+ let paraBuf = [];
+ let listBuf = []; // array of { indent, html }
+ let listType = null; // 'ul' | 'ol'
+
+ const flushPara = () => {
+ if (paraBuf.length) {
+ out.push(`${inlineMd(paraBuf.join(' '))}
`);
+ paraBuf = [];
+ }
+ };
+ const flushList = () => {
+ if (listBuf.length) {
+ out.push(buildListHtml(listBuf, listType));
+ listBuf = [];
+ listType = null;
+ }
+ };
+ const flushAll = () => { flushPara(); flushList(); };
+
+ for (; i < lines.length; i++) {
+ const line = lines[i];
+
+ // Code fence
+ const fence = line.match(/^```(\w*)\s*$/);
+ if (fence) {
+ if (!inCode) { flushAll(); inCode = true; codeBuf = []; }
+ else {
+ out.push(`${escapeHtml(codeBuf.join('\n'))} `);
+ inCode = false;
+ }
+ continue;
+ }
+ if (inCode) { codeBuf.push(line); continue; }
+
+ if (line.trim() === '') { flushAll(); continue; }
+
+ const hr = line.match(/^\s*(?:---+|\*\*\*+)\s*$/);
+ if (hr) { flushAll(); out.push(' '); continue; }
+
+ const heading = line.match(/^(#{1,4})\s+(.+)$/);
+ if (heading) {
+ flushAll();
+ const lvl = heading[1].length;
+ out.push(`${inlineMd(heading[2])} `);
+ continue;
+ }
+
+ const bullet = line.match(/^(\s*)([-*])\s+(.+)$/);
+ const ordered = line.match(/^(\s*)(\d+)\.\s+(.+)$/);
+ if (bullet || ordered) {
+ flushPara();
+ const m = bullet || ordered;
+ const indent = Math.floor(m[1].length / 2);
+ const t = bullet ? 'ul' : 'ol';
+ if (listType && listType !== t) flushList();
+ listType = t;
+ listBuf.push({ indent, html: inlineMd(m[3]) });
+ continue;
+ }
+
+ paraBuf.push(line);
+ }
+ flushAll();
+ if (inCode && codeBuf.length) {
+ out.push(`${escapeHtml(codeBuf.join('\n'))} `);
+ }
+ return out.join('\n');
+ }
+
+ function buildListHtml(items, type) {
+ // Nest by indent (one level deep is plenty for DESIGN.md).
+ let html = `<${type}>`;
+ let lastIndent = 0;
+ for (const it of items) {
+ if (it.indent > lastIndent) html += `<${type}>`;
+ else if (it.indent < lastIndent) html += `${type}>`.repeat(lastIndent - it.indent);
+ html += `${it.html} `;
+ lastIndent = it.indent;
+ }
+ html += `${type}>`.repeat(lastIndent + 1);
+ return html;
+ }
+
+ function inlineMd(text) {
+ // Order matters: escape first, then re-inject tags.
+ let s = escapeHtml(text);
+ // Code spans
+ s = s.replace(/`([^`]+)`/g, (_, code) => `${code}`);
+ // Links [text](url)
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `${t} `);
+ // Bold
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1 ');
+ // Italic (only single *…*, skip if inside bold already handled)
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2 ');
+ return s;
+ }
+
+ function highlightBold(text) {
+ return inlineMd(text);
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function copyToClipboard(text) {
+ if (!text) return;
+ try {
+ navigator.clipboard.writeText(text);
+ showToast('Copied: ' + text);
+ } catch { /* ignore */ }
+ }
+
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
@@ -1509,6 +2592,7 @@
initBar();
initActionPicker();
initGlobalBar();
+ initDesignPanel();
document.addEventListener('mousemove', handleMouseMove, true);
document.addEventListener('click', handleClick, true);
document.addEventListener('keydown', handleKeyDown, true);
diff --git a/source/skills/impeccable/scripts/live-server.mjs b/source/skills/impeccable/scripts/live-server.mjs
index 9fc581257..5df033aaa 100644
--- a/source/skills/impeccable/scripts/live-server.mjs
+++ b/source/skills/impeccable/scripts/live-server.mjs
@@ -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');