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": "", + "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": "", + "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": "
07Popolish
", + "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": "", + "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 yetCreate 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 viewRunning /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 += ``.repeat(lastIndent - it.indent); + html += `
  • ${it.html}
  • `; + lastIndent = it.indent; + } + html += ``.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');