From 66630a0ead0ebf2935aca1ac6d8f0e1765223394 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 17 Apr 2026 18:04:45 -0700 Subject: [PATCH] chore: sync skills, live tooling, and eval ignores - Ship design-parser and refreshed live scripts/document refs across provider skill trees; align live.mjs and live-poll with source - Update build/skills harness metadata, skills CLI test, and devDependencies (AI SDKs, zod) - Gitignore .codex/ harness artifacts and tests/evals-v2/ Made-with: Cursor --- .../skills/impeccable/reference/document.md | 104 +- .../impeccable/scripts/design-parser.mjs | 722 +++++++++ .../skills/impeccable/scripts/live-browser.js | 1332 ++++++++++++++++- .../skills/impeccable/scripts/live-poll.mjs | 15 +- .agents/skills/impeccable/scripts/live.mjs | 4 +- .../skills/impeccable/reference/document.md | 104 +- .../impeccable/scripts/design-parser.mjs | 722 +++++++++ .../skills/impeccable/scripts/live-browser.js | 1332 ++++++++++++++++- .../skills/impeccable/scripts/live-poll.mjs | 15 +- .claude/skills/impeccable/scripts/live.mjs | 4 +- .../skills/impeccable/reference/document.md | 104 +- .../impeccable/scripts/design-parser.mjs | 722 +++++++++ .../skills/impeccable/scripts/live-browser.js | 1332 ++++++++++++++++- .../skills/impeccable/scripts/live-poll.mjs | 15 +- .cursor/skills/impeccable/scripts/live.mjs | 4 +- .../skills/impeccable/reference/document.md | 104 +- .../impeccable/scripts/design-parser.mjs | 722 +++++++++ .../skills/impeccable/scripts/live-browser.js | 1332 ++++++++++++++++- .../skills/impeccable/scripts/live-poll.mjs | 15 +- .gemini/skills/impeccable/scripts/live.mjs | 4 +- .../skills/impeccable/reference/document.md | 104 +- .../impeccable/scripts/design-parser.mjs | 722 +++++++++ .../skills/impeccable/scripts/live-browser.js | 1332 ++++++++++++++++- .../skills/impeccable/scripts/live-poll.mjs | 15 +- .github/skills/impeccable/scripts/live.mjs | 4 +- .gitignore | 5 + .kiro/skills/impeccable/reference/document.md | 104 +- .../impeccable/scripts/design-parser.mjs | 722 +++++++++ .../skills/impeccable/scripts/live-browser.js | 1332 ++++++++++++++++- .kiro/skills/impeccable/scripts/live-poll.mjs | 15 +- .kiro/skills/impeccable/scripts/live.mjs | 4 +- .../skills/impeccable/reference/document.md | 104 +- .../impeccable/scripts/design-parser.mjs | 722 +++++++++ .../skills/impeccable/scripts/live-browser.js | 1332 ++++++++++++++++- .../skills/impeccable/scripts/live-poll.mjs | 15 +- .opencode/skills/impeccable/scripts/live.mjs | 4 +- .pi/skills/impeccable/reference/document.md | 104 +- .../impeccable/scripts/design-parser.mjs | 722 +++++++++ .pi/skills/impeccable/scripts/live-browser.js | 1332 ++++++++++++++++- .pi/skills/impeccable/scripts/live-poll.mjs | 15 +- .pi/skills/impeccable/scripts/live.mjs | 4 +- .../skills/impeccable/reference/document.md | 104 +- .../impeccable/scripts/design-parser.mjs | 722 +++++++++ .../skills/impeccable/scripts/live-browser.js | 1332 ++++++++++++++++- .../skills/impeccable/scripts/live-poll.mjs | 15 +- .rovodev/skills/impeccable/scripts/live.mjs | 4 +- .../skills/impeccable/reference/document.md | 104 +- .../impeccable/scripts/design-parser.mjs | 722 +++++++++ .../skills/impeccable/scripts/live-browser.js | 1332 ++++++++++++++++- .../skills/impeccable/scripts/live-poll.mjs | 15 +- .trae-cn/skills/impeccable/scripts/live.mjs | 4 +- .trae/skills/impeccable/reference/document.md | 104 +- .../impeccable/scripts/design-parser.mjs | 722 +++++++++ .../skills/impeccable/scripts/live-browser.js | 1332 ++++++++++++++++- .trae/skills/impeccable/scripts/live-poll.mjs | 15 +- .trae/skills/impeccable/scripts/live.mjs | 4 +- HARNESSES.md | 2 +- bin/commands/skills.mjs | 2 +- bun.lock | 268 +++- package.json | 8 +- scripts/build.js | 8 +- .../skills/impeccable/scripts/live-poll.mjs | 4 +- source/skills/impeccable/scripts/live.mjs | 4 +- tests/skills-cli.test.js | 2 +- 64 files changed, 23475 insertions(+), 775 deletions(-) create mode 100644 .agents/skills/impeccable/scripts/design-parser.mjs create mode 100644 .claude/skills/impeccable/scripts/design-parser.mjs create mode 100644 .cursor/skills/impeccable/scripts/design-parser.mjs create mode 100644 .gemini/skills/impeccable/scripts/design-parser.mjs create mode 100644 .github/skills/impeccable/scripts/design-parser.mjs create mode 100644 .kiro/skills/impeccable/scripts/design-parser.mjs create mode 100644 .opencode/skills/impeccable/scripts/design-parser.mjs create mode 100644 .pi/skills/impeccable/scripts/design-parser.mjs create mode 100644 .rovodev/skills/impeccable/scripts/design-parser.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/design-parser.mjs create mode 100644 .trae/skills/impeccable/scripts/design-parser.mjs diff --git a/.agents/skills/impeccable/reference/document.md b/.agents/skills/impeccable/reference/document.md index 6b3e78d1d..6c08cf4d5 100644 --- a/.agents/skills/impeccable/reference/document.md +++ b/.agents/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/.agents/skills/impeccable/scripts/design-parser.mjs b/.agents/skills/impeccable/scripts/design-parser.mjs new file mode 100644 index 000000000..6220f67e7 --- /dev/null +++ b/.agents/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/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js index 59e92d239..b9332e10d 100644 --- a/.agents/skills/impeccable/scripts/live-browser.js +++ b/.agents/skills/impeccable/scripts/live-browser.js @@ -891,7 +891,7 @@ switch (msg.type) { case 'connected': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); console.log('[impeccable] Live mode connected.'); if (state === 'IDLE') state = 'PICKING'; break; @@ -1293,92 +1293,227 @@ let detectCount = 0; let detectScriptLoaded = false; + // Theme-aware color palette for the global bar. We detect the page's + // ambient background and invert — dark bar on light pages, light bar on + // dark pages. This keeps the bar from fighting with the host design. + function detectPageTheme() { + try { + const bg = getComputedStyle(document.body).backgroundColor + || getComputedStyle(document.documentElement).backgroundColor; + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return 'light'; + const [, r, g, b] = m; + // Perceptual luminance (Rec. 709) + const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255; + return L > 0.55 ? 'light' : 'dark'; + } catch { return 'light'; } + } + + function barPaletteForTheme(theme) { + if (theme === 'dark') { + // Light bar on dark page + return { + surface: 'oklch(98% 0 0 / 0.92)', + hairline: 'oklch(70% 0 0 / 0.35)', + text: 'oklch(15% 0 0)', + textDim: 'oklch(45% 0 0)', + accent: 'oklch(60% 0.25 350)', + accentSoft: 'oklch(60% 0.25 350 / 0.18)', + mark: 'oklch(98% 0 0)', // logo mark fill + markText: 'oklch(15% 0 0)', // logo "/" color + exitHover: 'oklch(85% 0 0 / 0.5)', + }; + } + // Dark bar on light page. Bar is a warm charcoal, logo slab is much + // deeper so the rounded-right shape reads as a clear sculpted mark. + return { + surface: 'oklch(26% 0 0 / 0.94)', + hairline: 'oklch(42% 0 0 / 0.5)', + text: 'oklch(96% 0 0)', + textDim: 'oklch(72% 0 0)', + accent: 'oklch(72% 0.22 350)', + accentSoft: 'oklch(72% 0.22 350 / 0.22)', + mark: 'oklch(8% 0 0)', + markText: 'oklch(96% 0 0)', + exitHover: 'oklch(36% 0 0 / 0.6)', + }; + } + + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). + function brandMarkSvg(fill, ink, size = 18) { + return ``; + } + function initGlobalBar() { + const theme = detectPageTheme(); + const P = barPaletteForTheme(theme); + globalBarEl = el('div', { position: 'fixed', bottom: '14px', left: '50%', transform: 'translateX(-50%) translateY(20px)', zIndex: Z.bar + 5, - display: 'flex', alignItems: 'center', - padding: '4px 5px', gap: '2px', - background: C.paper, + display: 'flex', alignItems: 'stretch', + gap: '2px', + background: P.surface, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - border: '1px solid ' + C.mist, + border: '1px solid ' + P.hairline, borderRadius: '10px', - boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', - fontFamily: FONT, fontSize: '12px', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', + fontFamily: FONT, fontSize: '12px', lineHeight: '1', opacity: '0', + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, }); globalBarEl.id = PREFIX + '-global-bar'; + globalBarEl.dataset.theme = theme; - // Brand + // Brand mark — fills bar height on the left. Left side inherits the bar's + // rounded corner via overflow:hidden; right side is a clean hard edge since + // the near-black/charcoal contrast does the shape-defining work. const brand = el('span', { - fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em', - color: C.brand, fontFamily: MONO, - padding: '0 6px 0 4px', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + padding: '0 12px 0 14px', + background: P.mark, + color: P.markText, + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: '500', + fontSize: '18px', lineHeight: '1', }); - brand.textContent = 'Impeccable'; + brand.textContent = '/'; + brand.title = 'Impeccable'; globalBarEl.appendChild(brand); - // Detect toggle: eye icon + label + integrated badge - const detectBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: 'transparent', - color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // Inner wrapper: holds the toggles with normal bar padding. + const inner = el('div', { + display: 'flex', alignItems: 'center', + padding: '4px 5px', gap: '2px', + }); + inner.id = PREFIX + '-global-bar-inner'; + globalBarEl.appendChild(inner); + + // --- button factory: icon-only at rest, label slides in on hover/active --- + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { + const b = el('button', { + position: 'relative', + display: 'inline-flex', alignItems: 'center', + padding: '6px 8px', borderRadius: '7px', + border: 'none', background: 'transparent', + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease', + whiteSpace: 'nowrap', overflow: 'hidden', + }); + b.id = id; + b.title = ariaLabel || label || ''; + b.setAttribute('aria-label', ariaLabel || label || ''); + b.innerHTML = svg + (label + ? `${label}` + : ''); + const labelEl = b.querySelector('.icon-btn-label'); + const expand = () => { + if (!labelEl) return; + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; + }; + const collapse = () => { + if (!labelEl || b.dataset.active === 'true') return; + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; + }; + // Per-button hover only changes color (no layout). The label expand/ + // collapse is driven by the bar-level mouseenter/mouseleave so moving + // the mouse between adjacent buttons doesn't trigger per-button width + // thrashing — the whole bar grows once and shrinks once. + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); + b.addEventListener('click', onClick); + b._expandLabel = expand; + b._collapseLabel = collapse; + return b; + } + + // Pick toggle — starts active (primary intent when entering live mode). + const pickBtn = makeIconBtn({ + id: PREFIX + '-pick-toggle', + svg: '', + label: 'Pick', + ariaLabel: 'Pick element', + onClick: () => togglePick(), + }); + pickBtn.style.background = P.accentSoft; + pickBtn.style.color = P.accent; + pickBtn.dataset.active = 'true'; + pickBtn._expandLabel(); + inner.appendChild(pickBtn); + + // Detect toggle + const detectBtn = makeIconBtn({ + id: PREFIX + '-detect-toggle', + svg: '', + label: 'Detect', + ariaLabel: 'Detect anti-patterns', + onClick: () => toggleDetect(), }); - detectBtn.id = PREFIX + '-detect-toggle'; - // Eye icon (SVG) - detectBtn.innerHTML = ''; - const detectLabel = el('span', {}); - detectLabel.textContent = 'Detect'; - detectBtn.appendChild(detectLabel); - // Badge is inside the button const detectBadge = el('span', { fontSize: '10px', fontWeight: '600', padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', - background: C.brand, color: C.white, - display: 'none', fontFamily: MONO, + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', + display: 'none', fontFamily: MONO, marginLeft: '4px', }); detectBadge.id = PREFIX + '-detect-badge'; detectBtn.appendChild(detectBadge); - detectBtn.addEventListener('click', () => toggleDetect()); - detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; }); - detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; }); - globalBarEl.appendChild(detectBtn); + inner.appendChild(detectBtn); - // Pick toggle: crosshair icon + label - const pickBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: C.brandSoft, - color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // DESIGN.md panel toggle — quartet of color squares as the mark. + const designBtn = makeIconBtn({ + id: PREFIX + '-design-toggle', + svg: ` + + + + + `, + label: 'DESIGN.md', + ariaLabel: 'Toggle DESIGN.md panel', + labelFont: MONO, + onClick: () => toggleDesignPanel(), }); - pickBtn.id = PREFIX + '-pick-toggle'; - pickBtn.innerHTML = ''; - const pickLabel = el('span', {}); - pickLabel.textContent = 'Pick'; - pickBtn.appendChild(pickLabel); - pickBtn.addEventListener('click', () => togglePick()); - globalBarEl.appendChild(pickBtn); + inner.appendChild(designBtn); - // Exit (subtle × on the right) + // Thin divider before the exit button + const divider = el('span', { + width: '1px', height: '18px', + background: P.hairline, + margin: '0 4px 0 2px', + }); + inner.appendChild(divider); + + // Exit (subtle × on the right) — SVG for baseline-free centering const exitBtn = el('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: '24px', height: '24px', borderRadius: '6px', + width: '26px', height: '26px', borderRadius: '6px', border: 'none', background: 'transparent', - color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1', + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', - marginLeft: '2px', }); - exitBtn.textContent = '\u00D7'; + exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; - exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; }); - exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; }); + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); - globalBarEl.appendChild(exitBtn); + inner.appendChild(exitBtn); + + // Bar-level hover: expand every toggle's label at once; collapse on leave. + // Buttons with dataset.active="true" ignore collapse (their label stays). + const toggles = [pickBtn, detectBtn, designBtn]; + globalBarEl.addEventListener('mouseenter', () => { + toggles.forEach((t) => t._expandLabel && t._expandLabel()); + }); + globalBarEl.addEventListener('mouseleave', () => { + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); + }); document.body.appendChild(globalBarEl); @@ -1395,19 +1530,34 @@ const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); const detectBadge = document.getElementById(PREFIX + '-detect-badge'); const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); + const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const theme = globalBarEl?.dataset.theme || 'light'; + const P = barPaletteForTheme(theme); - if (detectToggle) { - detectToggle.style.background = detectActive ? C.brandSoft : 'transparent'; - detectToggle.style.color = detectActive ? C.brand : C.ash; + // Sync one toggle's active state, colors, and slide-label visibility. + function sync(btn, active) { + if (!btn) return; + btn.style.background = active ? P.accentSoft : 'transparent'; + btn.style.color = active ? P.accent : P.textDim; + btn.dataset.active = active ? 'true' : 'false'; + if (active && btn._expandLabel) btn._expandLabel(); + else if (!active && btn._collapseLabel) btn._collapseLabel(); } + sync(pickToggle, pickActive); + sync(detectToggle, detectActive); + sync(designToggle, designState.open); + + // If the bar is currently under the cursor, keep all labels expanded — + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) + // would collapse its label while the user's mouse is still on the bar. + if (globalBarEl && globalBarEl.matches(':hover')) { + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); + } + if (detectBadge) { detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; detectBadge.textContent = detectCount; } - if (pickToggle) { - pickToggle.style.background = pickActive ? C.brandSoft : 'transparent'; - pickToggle.style.color = pickActive ? C.brand : C.ash; - } // When pick is active, make detect overlays click-through so the picker works document.querySelectorAll('.impeccable-overlay').forEach(o => { @@ -1443,8 +1593,14 @@ updateGlobalBarState(); if (!pickActive) { + // Disabling pick clears any in-flight selection and UI: highlight, + // contextual bar, selectedElement. Otherwise a stale selection sits + // on screen with no obvious way to dismiss. hideHighlight(); - if (state === 'PICKING') state = 'IDLE'; + hideBar(); + hideActionPicker(); + selectedElement = null; + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; } else { if (state === 'IDLE') state = 'PICKING'; } @@ -1500,6 +1656,1057 @@ 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() { + // `open` is intentionally NOT persisted — the panel always starts closed + // so live mode doesn't auto-slide a big panel over the page on startup. + try { + const raw = localStorage.getItem(DESIGN_PREFS_KEY); + if (!raw) return; + const prefs = JSON.parse(raw); + 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({ + 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'); + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. + const theme = detectPageTheme(); + style.textContent = designPanelCss(barPaletteForTheme(theme)); + 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(BP) { + // BP = bar palette (theme-aware, matches the global bar). + // DP = internal content palette (neutral, so tiles render colors true). + 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; } + + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + .panel { + position: fixed; top: 12px; bottom: 72px; right: 12px; + width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); + background: ${BP.surface}; + border: 1px solid ${BP.hairline}; + border-radius: 14px; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); + 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: 10px 10px 10px 14px; + background: transparent; + border-bottom: 1px solid ${BP.hairline}; + } + .panel-title { + flex: 1; min-width: 0; + font-family: ${MONO}; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.02em; + color: ${BP.text}; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .panel-close { + border: none; background: transparent; color: ${BP.textDim}; + width: 26px; height: 26px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; transition: background 0.15s ease, color 0.15s ease; + } + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } + + .tabs { + display: inline-flex; padding: 2px; + background: ${BP.hairline}; + border-radius: 7px; + gap: 2px; + } + .tab { + border: none; background: transparent; + padding: 4px 10px; border-radius: 5px; + font-family: ${MONO}; + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; + color: ${BP.textDim}; cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } + + .panel-body { + flex: 1; overflow-y: auto; + padding: 12px 12px 20px; + background: ${DP.canvas}; + 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 = ''; + + // (Panel toggle lives in the global bar — no floating 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 = 'DESIGN.md'; + 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; + renderDesignChrome(); + updateGlobalBarState(); + 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 +2716,7 @@ initBar(); initActionPicker(); initGlobalBar(); + initDesignPanel(); document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); diff --git a/.agents/skills/impeccable/scripts/live-poll.mjs b/.agents/skills/impeccable/scripts/live-poll.mjs index b176d9f05..3c8088c23 100644 --- a/.agents/skills/impeccable/scripts/live-poll.mjs +++ b/.agents/skills/impeccable/scripts/live-poll.mjs @@ -3,7 +3,7 @@ * * Usage: * npx impeccable poll # Block until browser event, print JSON - * npx impeccable poll --timeout=60000 # Custom timeout (ms) + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly * npx impeccable poll --reply done # Reply "done" to event * npx impeccable poll --reply error "msg" # Reply with error */ @@ -13,6 +13,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { Agent, setGlobalDispatcher } from 'undici'; + +// Disable undici's default 300s headersTimeout so long-polls can sit open +// indefinitely (until a browser event or the server's own timeout fires). +// Without this, fetch() throws a bare "fetch failed" at 5 minutes even +// though the server would have happily kept the connection alive. +setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 })); const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -39,7 +46,7 @@ Modes: poll --reply error "msg" Reply with an error message Options: - --timeout=MS Poll timeout in milliseconds (default: 120000) + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn --help Show this help message`); process.exit(0); } @@ -93,7 +100,9 @@ Options: return; } - // Poll mode: block until browser event + // Poll mode: block until browser event. Default 10 min; undici's default + // 5-min headers-timeout is disabled at import time so this can sit open + // indefinitely without fetch errors. const timeoutArg = args.find(a => a.startsWith('--timeout=')); const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; diff --git a/.agents/skills/impeccable/scripts/live.mjs b/.agents/skills/impeccable/scripts/live.mjs index 37b6f8997..062b35ae8 100644 --- a/.agents/skills/impeccable/scripts/live.mjs +++ b/.agents/skills/impeccable/scripts/live.mjs @@ -9,7 +9,7 @@ * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: - * - Navigate the browser to the page (optional, if browser automation is available) + * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll * - Enter the poll loop: `node live-poll.mjs` * * Usage: @@ -46,7 +46,7 @@ On config_missing, prints: The agent should then: 1. If config_missing, create the config and re-run this script - 2. Optionally navigate the browser to the page + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 3. Enter the poll loop: node live-poll.mjs`); process.exit(0); } diff --git a/.claude/skills/impeccable/reference/document.md b/.claude/skills/impeccable/reference/document.md index 585352ee8..debda4c9c 100644 --- a/.claude/skills/impeccable/reference/document.md +++ b/.claude/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/.claude/skills/impeccable/scripts/design-parser.mjs b/.claude/skills/impeccable/scripts/design-parser.mjs new file mode 100644 index 000000000..6220f67e7 --- /dev/null +++ b/.claude/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/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js index 59e92d239..b9332e10d 100644 --- a/.claude/skills/impeccable/scripts/live-browser.js +++ b/.claude/skills/impeccable/scripts/live-browser.js @@ -891,7 +891,7 @@ switch (msg.type) { case 'connected': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); console.log('[impeccable] Live mode connected.'); if (state === 'IDLE') state = 'PICKING'; break; @@ -1293,92 +1293,227 @@ let detectCount = 0; let detectScriptLoaded = false; + // Theme-aware color palette for the global bar. We detect the page's + // ambient background and invert — dark bar on light pages, light bar on + // dark pages. This keeps the bar from fighting with the host design. + function detectPageTheme() { + try { + const bg = getComputedStyle(document.body).backgroundColor + || getComputedStyle(document.documentElement).backgroundColor; + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return 'light'; + const [, r, g, b] = m; + // Perceptual luminance (Rec. 709) + const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255; + return L > 0.55 ? 'light' : 'dark'; + } catch { return 'light'; } + } + + function barPaletteForTheme(theme) { + if (theme === 'dark') { + // Light bar on dark page + return { + surface: 'oklch(98% 0 0 / 0.92)', + hairline: 'oklch(70% 0 0 / 0.35)', + text: 'oklch(15% 0 0)', + textDim: 'oklch(45% 0 0)', + accent: 'oklch(60% 0.25 350)', + accentSoft: 'oklch(60% 0.25 350 / 0.18)', + mark: 'oklch(98% 0 0)', // logo mark fill + markText: 'oklch(15% 0 0)', // logo "/" color + exitHover: 'oklch(85% 0 0 / 0.5)', + }; + } + // Dark bar on light page. Bar is a warm charcoal, logo slab is much + // deeper so the rounded-right shape reads as a clear sculpted mark. + return { + surface: 'oklch(26% 0 0 / 0.94)', + hairline: 'oklch(42% 0 0 / 0.5)', + text: 'oklch(96% 0 0)', + textDim: 'oklch(72% 0 0)', + accent: 'oklch(72% 0.22 350)', + accentSoft: 'oklch(72% 0.22 350 / 0.22)', + mark: 'oklch(8% 0 0)', + markText: 'oklch(96% 0 0)', + exitHover: 'oklch(36% 0 0 / 0.6)', + }; + } + + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). + function brandMarkSvg(fill, ink, size = 18) { + return ``; + } + function initGlobalBar() { + const theme = detectPageTheme(); + const P = barPaletteForTheme(theme); + globalBarEl = el('div', { position: 'fixed', bottom: '14px', left: '50%', transform: 'translateX(-50%) translateY(20px)', zIndex: Z.bar + 5, - display: 'flex', alignItems: 'center', - padding: '4px 5px', gap: '2px', - background: C.paper, + display: 'flex', alignItems: 'stretch', + gap: '2px', + background: P.surface, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - border: '1px solid ' + C.mist, + border: '1px solid ' + P.hairline, borderRadius: '10px', - boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', - fontFamily: FONT, fontSize: '12px', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', + fontFamily: FONT, fontSize: '12px', lineHeight: '1', opacity: '0', + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, }); globalBarEl.id = PREFIX + '-global-bar'; + globalBarEl.dataset.theme = theme; - // Brand + // Brand mark — fills bar height on the left. Left side inherits the bar's + // rounded corner via overflow:hidden; right side is a clean hard edge since + // the near-black/charcoal contrast does the shape-defining work. const brand = el('span', { - fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em', - color: C.brand, fontFamily: MONO, - padding: '0 6px 0 4px', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + padding: '0 12px 0 14px', + background: P.mark, + color: P.markText, + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: '500', + fontSize: '18px', lineHeight: '1', }); - brand.textContent = 'Impeccable'; + brand.textContent = '/'; + brand.title = 'Impeccable'; globalBarEl.appendChild(brand); - // Detect toggle: eye icon + label + integrated badge - const detectBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: 'transparent', - color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // Inner wrapper: holds the toggles with normal bar padding. + const inner = el('div', { + display: 'flex', alignItems: 'center', + padding: '4px 5px', gap: '2px', + }); + inner.id = PREFIX + '-global-bar-inner'; + globalBarEl.appendChild(inner); + + // --- button factory: icon-only at rest, label slides in on hover/active --- + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { + const b = el('button', { + position: 'relative', + display: 'inline-flex', alignItems: 'center', + padding: '6px 8px', borderRadius: '7px', + border: 'none', background: 'transparent', + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease', + whiteSpace: 'nowrap', overflow: 'hidden', + }); + b.id = id; + b.title = ariaLabel || label || ''; + b.setAttribute('aria-label', ariaLabel || label || ''); + b.innerHTML = svg + (label + ? `${label}` + : ''); + const labelEl = b.querySelector('.icon-btn-label'); + const expand = () => { + if (!labelEl) return; + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; + }; + const collapse = () => { + if (!labelEl || b.dataset.active === 'true') return; + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; + }; + // Per-button hover only changes color (no layout). The label expand/ + // collapse is driven by the bar-level mouseenter/mouseleave so moving + // the mouse between adjacent buttons doesn't trigger per-button width + // thrashing — the whole bar grows once and shrinks once. + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); + b.addEventListener('click', onClick); + b._expandLabel = expand; + b._collapseLabel = collapse; + return b; + } + + // Pick toggle — starts active (primary intent when entering live mode). + const pickBtn = makeIconBtn({ + id: PREFIX + '-pick-toggle', + svg: '', + label: 'Pick', + ariaLabel: 'Pick element', + onClick: () => togglePick(), + }); + pickBtn.style.background = P.accentSoft; + pickBtn.style.color = P.accent; + pickBtn.dataset.active = 'true'; + pickBtn._expandLabel(); + inner.appendChild(pickBtn); + + // Detect toggle + const detectBtn = makeIconBtn({ + id: PREFIX + '-detect-toggle', + svg: '', + label: 'Detect', + ariaLabel: 'Detect anti-patterns', + onClick: () => toggleDetect(), }); - detectBtn.id = PREFIX + '-detect-toggle'; - // Eye icon (SVG) - detectBtn.innerHTML = ''; - const detectLabel = el('span', {}); - detectLabel.textContent = 'Detect'; - detectBtn.appendChild(detectLabel); - // Badge is inside the button const detectBadge = el('span', { fontSize: '10px', fontWeight: '600', padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', - background: C.brand, color: C.white, - display: 'none', fontFamily: MONO, + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', + display: 'none', fontFamily: MONO, marginLeft: '4px', }); detectBadge.id = PREFIX + '-detect-badge'; detectBtn.appendChild(detectBadge); - detectBtn.addEventListener('click', () => toggleDetect()); - detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; }); - detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; }); - globalBarEl.appendChild(detectBtn); + inner.appendChild(detectBtn); - // Pick toggle: crosshair icon + label - const pickBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: C.brandSoft, - color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // DESIGN.md panel toggle — quartet of color squares as the mark. + const designBtn = makeIconBtn({ + id: PREFIX + '-design-toggle', + svg: ` + + + + + `, + label: 'DESIGN.md', + ariaLabel: 'Toggle DESIGN.md panel', + labelFont: MONO, + onClick: () => toggleDesignPanel(), }); - pickBtn.id = PREFIX + '-pick-toggle'; - pickBtn.innerHTML = ''; - const pickLabel = el('span', {}); - pickLabel.textContent = 'Pick'; - pickBtn.appendChild(pickLabel); - pickBtn.addEventListener('click', () => togglePick()); - globalBarEl.appendChild(pickBtn); + inner.appendChild(designBtn); - // Exit (subtle × on the right) + // Thin divider before the exit button + const divider = el('span', { + width: '1px', height: '18px', + background: P.hairline, + margin: '0 4px 0 2px', + }); + inner.appendChild(divider); + + // Exit (subtle × on the right) — SVG for baseline-free centering const exitBtn = el('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: '24px', height: '24px', borderRadius: '6px', + width: '26px', height: '26px', borderRadius: '6px', border: 'none', background: 'transparent', - color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1', + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', - marginLeft: '2px', }); - exitBtn.textContent = '\u00D7'; + exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; - exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; }); - exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; }); + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); - globalBarEl.appendChild(exitBtn); + inner.appendChild(exitBtn); + + // Bar-level hover: expand every toggle's label at once; collapse on leave. + // Buttons with dataset.active="true" ignore collapse (their label stays). + const toggles = [pickBtn, detectBtn, designBtn]; + globalBarEl.addEventListener('mouseenter', () => { + toggles.forEach((t) => t._expandLabel && t._expandLabel()); + }); + globalBarEl.addEventListener('mouseleave', () => { + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); + }); document.body.appendChild(globalBarEl); @@ -1395,19 +1530,34 @@ const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); const detectBadge = document.getElementById(PREFIX + '-detect-badge'); const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); + const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const theme = globalBarEl?.dataset.theme || 'light'; + const P = barPaletteForTheme(theme); - if (detectToggle) { - detectToggle.style.background = detectActive ? C.brandSoft : 'transparent'; - detectToggle.style.color = detectActive ? C.brand : C.ash; + // Sync one toggle's active state, colors, and slide-label visibility. + function sync(btn, active) { + if (!btn) return; + btn.style.background = active ? P.accentSoft : 'transparent'; + btn.style.color = active ? P.accent : P.textDim; + btn.dataset.active = active ? 'true' : 'false'; + if (active && btn._expandLabel) btn._expandLabel(); + else if (!active && btn._collapseLabel) btn._collapseLabel(); } + sync(pickToggle, pickActive); + sync(detectToggle, detectActive); + sync(designToggle, designState.open); + + // If the bar is currently under the cursor, keep all labels expanded — + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) + // would collapse its label while the user's mouse is still on the bar. + if (globalBarEl && globalBarEl.matches(':hover')) { + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); + } + if (detectBadge) { detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; detectBadge.textContent = detectCount; } - if (pickToggle) { - pickToggle.style.background = pickActive ? C.brandSoft : 'transparent'; - pickToggle.style.color = pickActive ? C.brand : C.ash; - } // When pick is active, make detect overlays click-through so the picker works document.querySelectorAll('.impeccable-overlay').forEach(o => { @@ -1443,8 +1593,14 @@ updateGlobalBarState(); if (!pickActive) { + // Disabling pick clears any in-flight selection and UI: highlight, + // contextual bar, selectedElement. Otherwise a stale selection sits + // on screen with no obvious way to dismiss. hideHighlight(); - if (state === 'PICKING') state = 'IDLE'; + hideBar(); + hideActionPicker(); + selectedElement = null; + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; } else { if (state === 'IDLE') state = 'PICKING'; } @@ -1500,6 +1656,1057 @@ 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() { + // `open` is intentionally NOT persisted — the panel always starts closed + // so live mode doesn't auto-slide a big panel over the page on startup. + try { + const raw = localStorage.getItem(DESIGN_PREFS_KEY); + if (!raw) return; + const prefs = JSON.parse(raw); + 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({ + 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'); + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. + const theme = detectPageTheme(); + style.textContent = designPanelCss(barPaletteForTheme(theme)); + 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(BP) { + // BP = bar palette (theme-aware, matches the global bar). + // DP = internal content palette (neutral, so tiles render colors true). + 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; } + + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + .panel { + position: fixed; top: 12px; bottom: 72px; right: 12px; + width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); + background: ${BP.surface}; + border: 1px solid ${BP.hairline}; + border-radius: 14px; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); + 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: 10px 10px 10px 14px; + background: transparent; + border-bottom: 1px solid ${BP.hairline}; + } + .panel-title { + flex: 1; min-width: 0; + font-family: ${MONO}; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.02em; + color: ${BP.text}; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .panel-close { + border: none; background: transparent; color: ${BP.textDim}; + width: 26px; height: 26px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; transition: background 0.15s ease, color 0.15s ease; + } + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } + + .tabs { + display: inline-flex; padding: 2px; + background: ${BP.hairline}; + border-radius: 7px; + gap: 2px; + } + .tab { + border: none; background: transparent; + padding: 4px 10px; border-radius: 5px; + font-family: ${MONO}; + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; + color: ${BP.textDim}; cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } + + .panel-body { + flex: 1; overflow-y: auto; + padding: 12px 12px 20px; + background: ${DP.canvas}; + 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 = ''; + + // (Panel toggle lives in the global bar — no floating 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 = 'DESIGN.md'; + 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; + renderDesignChrome(); + updateGlobalBarState(); + 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 +2716,7 @@ initBar(); initActionPicker(); initGlobalBar(); + initDesignPanel(); document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); diff --git a/.claude/skills/impeccable/scripts/live-poll.mjs b/.claude/skills/impeccable/scripts/live-poll.mjs index b176d9f05..3c8088c23 100644 --- a/.claude/skills/impeccable/scripts/live-poll.mjs +++ b/.claude/skills/impeccable/scripts/live-poll.mjs @@ -3,7 +3,7 @@ * * Usage: * npx impeccable poll # Block until browser event, print JSON - * npx impeccable poll --timeout=60000 # Custom timeout (ms) + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly * npx impeccable poll --reply done # Reply "done" to event * npx impeccable poll --reply error "msg" # Reply with error */ @@ -13,6 +13,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { Agent, setGlobalDispatcher } from 'undici'; + +// Disable undici's default 300s headersTimeout so long-polls can sit open +// indefinitely (until a browser event or the server's own timeout fires). +// Without this, fetch() throws a bare "fetch failed" at 5 minutes even +// though the server would have happily kept the connection alive. +setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 })); const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -39,7 +46,7 @@ Modes: poll --reply error "msg" Reply with an error message Options: - --timeout=MS Poll timeout in milliseconds (default: 120000) + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn --help Show this help message`); process.exit(0); } @@ -93,7 +100,9 @@ Options: return; } - // Poll mode: block until browser event + // Poll mode: block until browser event. Default 10 min; undici's default + // 5-min headers-timeout is disabled at import time so this can sit open + // indefinitely without fetch errors. const timeoutArg = args.find(a => a.startsWith('--timeout=')); const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; diff --git a/.claude/skills/impeccable/scripts/live.mjs b/.claude/skills/impeccable/scripts/live.mjs index 37b6f8997..062b35ae8 100644 --- a/.claude/skills/impeccable/scripts/live.mjs +++ b/.claude/skills/impeccable/scripts/live.mjs @@ -9,7 +9,7 @@ * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: - * - Navigate the browser to the page (optional, if browser automation is available) + * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll * - Enter the poll loop: `node live-poll.mjs` * * Usage: @@ -46,7 +46,7 @@ On config_missing, prints: The agent should then: 1. If config_missing, create the config and re-run this script - 2. Optionally navigate the browser to the page + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 3. Enter the poll loop: node live-poll.mjs`); process.exit(0); } diff --git a/.cursor/skills/impeccable/reference/document.md b/.cursor/skills/impeccable/reference/document.md index b59b8ac8b..37a7f0980 100644 --- a/.cursor/skills/impeccable/reference/document.md +++ b/.cursor/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/.cursor/skills/impeccable/scripts/design-parser.mjs b/.cursor/skills/impeccable/scripts/design-parser.mjs new file mode 100644 index 000000000..6220f67e7 --- /dev/null +++ b/.cursor/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/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js index 59e92d239..b9332e10d 100644 --- a/.cursor/skills/impeccable/scripts/live-browser.js +++ b/.cursor/skills/impeccable/scripts/live-browser.js @@ -891,7 +891,7 @@ switch (msg.type) { case 'connected': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); console.log('[impeccable] Live mode connected.'); if (state === 'IDLE') state = 'PICKING'; break; @@ -1293,92 +1293,227 @@ let detectCount = 0; let detectScriptLoaded = false; + // Theme-aware color palette for the global bar. We detect the page's + // ambient background and invert — dark bar on light pages, light bar on + // dark pages. This keeps the bar from fighting with the host design. + function detectPageTheme() { + try { + const bg = getComputedStyle(document.body).backgroundColor + || getComputedStyle(document.documentElement).backgroundColor; + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return 'light'; + const [, r, g, b] = m; + // Perceptual luminance (Rec. 709) + const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255; + return L > 0.55 ? 'light' : 'dark'; + } catch { return 'light'; } + } + + function barPaletteForTheme(theme) { + if (theme === 'dark') { + // Light bar on dark page + return { + surface: 'oklch(98% 0 0 / 0.92)', + hairline: 'oklch(70% 0 0 / 0.35)', + text: 'oklch(15% 0 0)', + textDim: 'oklch(45% 0 0)', + accent: 'oklch(60% 0.25 350)', + accentSoft: 'oklch(60% 0.25 350 / 0.18)', + mark: 'oklch(98% 0 0)', // logo mark fill + markText: 'oklch(15% 0 0)', // logo "/" color + exitHover: 'oklch(85% 0 0 / 0.5)', + }; + } + // Dark bar on light page. Bar is a warm charcoal, logo slab is much + // deeper so the rounded-right shape reads as a clear sculpted mark. + return { + surface: 'oklch(26% 0 0 / 0.94)', + hairline: 'oklch(42% 0 0 / 0.5)', + text: 'oklch(96% 0 0)', + textDim: 'oklch(72% 0 0)', + accent: 'oklch(72% 0.22 350)', + accentSoft: 'oklch(72% 0.22 350 / 0.22)', + mark: 'oklch(8% 0 0)', + markText: 'oklch(96% 0 0)', + exitHover: 'oklch(36% 0 0 / 0.6)', + }; + } + + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). + function brandMarkSvg(fill, ink, size = 18) { + return ``; + } + function initGlobalBar() { + const theme = detectPageTheme(); + const P = barPaletteForTheme(theme); + globalBarEl = el('div', { position: 'fixed', bottom: '14px', left: '50%', transform: 'translateX(-50%) translateY(20px)', zIndex: Z.bar + 5, - display: 'flex', alignItems: 'center', - padding: '4px 5px', gap: '2px', - background: C.paper, + display: 'flex', alignItems: 'stretch', + gap: '2px', + background: P.surface, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - border: '1px solid ' + C.mist, + border: '1px solid ' + P.hairline, borderRadius: '10px', - boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', - fontFamily: FONT, fontSize: '12px', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', + fontFamily: FONT, fontSize: '12px', lineHeight: '1', opacity: '0', + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, }); globalBarEl.id = PREFIX + '-global-bar'; + globalBarEl.dataset.theme = theme; - // Brand + // Brand mark — fills bar height on the left. Left side inherits the bar's + // rounded corner via overflow:hidden; right side is a clean hard edge since + // the near-black/charcoal contrast does the shape-defining work. const brand = el('span', { - fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em', - color: C.brand, fontFamily: MONO, - padding: '0 6px 0 4px', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + padding: '0 12px 0 14px', + background: P.mark, + color: P.markText, + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: '500', + fontSize: '18px', lineHeight: '1', }); - brand.textContent = 'Impeccable'; + brand.textContent = '/'; + brand.title = 'Impeccable'; globalBarEl.appendChild(brand); - // Detect toggle: eye icon + label + integrated badge - const detectBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: 'transparent', - color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // Inner wrapper: holds the toggles with normal bar padding. + const inner = el('div', { + display: 'flex', alignItems: 'center', + padding: '4px 5px', gap: '2px', + }); + inner.id = PREFIX + '-global-bar-inner'; + globalBarEl.appendChild(inner); + + // --- button factory: icon-only at rest, label slides in on hover/active --- + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { + const b = el('button', { + position: 'relative', + display: 'inline-flex', alignItems: 'center', + padding: '6px 8px', borderRadius: '7px', + border: 'none', background: 'transparent', + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease', + whiteSpace: 'nowrap', overflow: 'hidden', + }); + b.id = id; + b.title = ariaLabel || label || ''; + b.setAttribute('aria-label', ariaLabel || label || ''); + b.innerHTML = svg + (label + ? `${label}` + : ''); + const labelEl = b.querySelector('.icon-btn-label'); + const expand = () => { + if (!labelEl) return; + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; + }; + const collapse = () => { + if (!labelEl || b.dataset.active === 'true') return; + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; + }; + // Per-button hover only changes color (no layout). The label expand/ + // collapse is driven by the bar-level mouseenter/mouseleave so moving + // the mouse between adjacent buttons doesn't trigger per-button width + // thrashing — the whole bar grows once and shrinks once. + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); + b.addEventListener('click', onClick); + b._expandLabel = expand; + b._collapseLabel = collapse; + return b; + } + + // Pick toggle — starts active (primary intent when entering live mode). + const pickBtn = makeIconBtn({ + id: PREFIX + '-pick-toggle', + svg: '', + label: 'Pick', + ariaLabel: 'Pick element', + onClick: () => togglePick(), + }); + pickBtn.style.background = P.accentSoft; + pickBtn.style.color = P.accent; + pickBtn.dataset.active = 'true'; + pickBtn._expandLabel(); + inner.appendChild(pickBtn); + + // Detect toggle + const detectBtn = makeIconBtn({ + id: PREFIX + '-detect-toggle', + svg: '', + label: 'Detect', + ariaLabel: 'Detect anti-patterns', + onClick: () => toggleDetect(), }); - detectBtn.id = PREFIX + '-detect-toggle'; - // Eye icon (SVG) - detectBtn.innerHTML = ''; - const detectLabel = el('span', {}); - detectLabel.textContent = 'Detect'; - detectBtn.appendChild(detectLabel); - // Badge is inside the button const detectBadge = el('span', { fontSize: '10px', fontWeight: '600', padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', - background: C.brand, color: C.white, - display: 'none', fontFamily: MONO, + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', + display: 'none', fontFamily: MONO, marginLeft: '4px', }); detectBadge.id = PREFIX + '-detect-badge'; detectBtn.appendChild(detectBadge); - detectBtn.addEventListener('click', () => toggleDetect()); - detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; }); - detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; }); - globalBarEl.appendChild(detectBtn); + inner.appendChild(detectBtn); - // Pick toggle: crosshair icon + label - const pickBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: C.brandSoft, - color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // DESIGN.md panel toggle — quartet of color squares as the mark. + const designBtn = makeIconBtn({ + id: PREFIX + '-design-toggle', + svg: ` + + + + + `, + label: 'DESIGN.md', + ariaLabel: 'Toggle DESIGN.md panel', + labelFont: MONO, + onClick: () => toggleDesignPanel(), }); - pickBtn.id = PREFIX + '-pick-toggle'; - pickBtn.innerHTML = ''; - const pickLabel = el('span', {}); - pickLabel.textContent = 'Pick'; - pickBtn.appendChild(pickLabel); - pickBtn.addEventListener('click', () => togglePick()); - globalBarEl.appendChild(pickBtn); + inner.appendChild(designBtn); - // Exit (subtle × on the right) + // Thin divider before the exit button + const divider = el('span', { + width: '1px', height: '18px', + background: P.hairline, + margin: '0 4px 0 2px', + }); + inner.appendChild(divider); + + // Exit (subtle × on the right) — SVG for baseline-free centering const exitBtn = el('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: '24px', height: '24px', borderRadius: '6px', + width: '26px', height: '26px', borderRadius: '6px', border: 'none', background: 'transparent', - color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1', + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', - marginLeft: '2px', }); - exitBtn.textContent = '\u00D7'; + exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; - exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; }); - exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; }); + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); - globalBarEl.appendChild(exitBtn); + inner.appendChild(exitBtn); + + // Bar-level hover: expand every toggle's label at once; collapse on leave. + // Buttons with dataset.active="true" ignore collapse (their label stays). + const toggles = [pickBtn, detectBtn, designBtn]; + globalBarEl.addEventListener('mouseenter', () => { + toggles.forEach((t) => t._expandLabel && t._expandLabel()); + }); + globalBarEl.addEventListener('mouseleave', () => { + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); + }); document.body.appendChild(globalBarEl); @@ -1395,19 +1530,34 @@ const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); const detectBadge = document.getElementById(PREFIX + '-detect-badge'); const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); + const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const theme = globalBarEl?.dataset.theme || 'light'; + const P = barPaletteForTheme(theme); - if (detectToggle) { - detectToggle.style.background = detectActive ? C.brandSoft : 'transparent'; - detectToggle.style.color = detectActive ? C.brand : C.ash; + // Sync one toggle's active state, colors, and slide-label visibility. + function sync(btn, active) { + if (!btn) return; + btn.style.background = active ? P.accentSoft : 'transparent'; + btn.style.color = active ? P.accent : P.textDim; + btn.dataset.active = active ? 'true' : 'false'; + if (active && btn._expandLabel) btn._expandLabel(); + else if (!active && btn._collapseLabel) btn._collapseLabel(); } + sync(pickToggle, pickActive); + sync(detectToggle, detectActive); + sync(designToggle, designState.open); + + // If the bar is currently under the cursor, keep all labels expanded — + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) + // would collapse its label while the user's mouse is still on the bar. + if (globalBarEl && globalBarEl.matches(':hover')) { + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); + } + if (detectBadge) { detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; detectBadge.textContent = detectCount; } - if (pickToggle) { - pickToggle.style.background = pickActive ? C.brandSoft : 'transparent'; - pickToggle.style.color = pickActive ? C.brand : C.ash; - } // When pick is active, make detect overlays click-through so the picker works document.querySelectorAll('.impeccable-overlay').forEach(o => { @@ -1443,8 +1593,14 @@ updateGlobalBarState(); if (!pickActive) { + // Disabling pick clears any in-flight selection and UI: highlight, + // contextual bar, selectedElement. Otherwise a stale selection sits + // on screen with no obvious way to dismiss. hideHighlight(); - if (state === 'PICKING') state = 'IDLE'; + hideBar(); + hideActionPicker(); + selectedElement = null; + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; } else { if (state === 'IDLE') state = 'PICKING'; } @@ -1500,6 +1656,1057 @@ 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() { + // `open` is intentionally NOT persisted — the panel always starts closed + // so live mode doesn't auto-slide a big panel over the page on startup. + try { + const raw = localStorage.getItem(DESIGN_PREFS_KEY); + if (!raw) return; + const prefs = JSON.parse(raw); + 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({ + 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'); + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. + const theme = detectPageTheme(); + style.textContent = designPanelCss(barPaletteForTheme(theme)); + 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(BP) { + // BP = bar palette (theme-aware, matches the global bar). + // DP = internal content palette (neutral, so tiles render colors true). + 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; } + + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + .panel { + position: fixed; top: 12px; bottom: 72px; right: 12px; + width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); + background: ${BP.surface}; + border: 1px solid ${BP.hairline}; + border-radius: 14px; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); + 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: 10px 10px 10px 14px; + background: transparent; + border-bottom: 1px solid ${BP.hairline}; + } + .panel-title { + flex: 1; min-width: 0; + font-family: ${MONO}; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.02em; + color: ${BP.text}; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .panel-close { + border: none; background: transparent; color: ${BP.textDim}; + width: 26px; height: 26px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; transition: background 0.15s ease, color 0.15s ease; + } + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } + + .tabs { + display: inline-flex; padding: 2px; + background: ${BP.hairline}; + border-radius: 7px; + gap: 2px; + } + .tab { + border: none; background: transparent; + padding: 4px 10px; border-radius: 5px; + font-family: ${MONO}; + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; + color: ${BP.textDim}; cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } + + .panel-body { + flex: 1; overflow-y: auto; + padding: 12px 12px 20px; + background: ${DP.canvas}; + 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 = ''; + + // (Panel toggle lives in the global bar — no floating 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 = 'DESIGN.md'; + 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; + renderDesignChrome(); + updateGlobalBarState(); + 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 +2716,7 @@ initBar(); initActionPicker(); initGlobalBar(); + initDesignPanel(); document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); diff --git a/.cursor/skills/impeccable/scripts/live-poll.mjs b/.cursor/skills/impeccable/scripts/live-poll.mjs index b176d9f05..3c8088c23 100644 --- a/.cursor/skills/impeccable/scripts/live-poll.mjs +++ b/.cursor/skills/impeccable/scripts/live-poll.mjs @@ -3,7 +3,7 @@ * * Usage: * npx impeccable poll # Block until browser event, print JSON - * npx impeccable poll --timeout=60000 # Custom timeout (ms) + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly * npx impeccable poll --reply done # Reply "done" to event * npx impeccable poll --reply error "msg" # Reply with error */ @@ -13,6 +13,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { Agent, setGlobalDispatcher } from 'undici'; + +// Disable undici's default 300s headersTimeout so long-polls can sit open +// indefinitely (until a browser event or the server's own timeout fires). +// Without this, fetch() throws a bare "fetch failed" at 5 minutes even +// though the server would have happily kept the connection alive. +setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 })); const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -39,7 +46,7 @@ Modes: poll --reply error "msg" Reply with an error message Options: - --timeout=MS Poll timeout in milliseconds (default: 120000) + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn --help Show this help message`); process.exit(0); } @@ -93,7 +100,9 @@ Options: return; } - // Poll mode: block until browser event + // Poll mode: block until browser event. Default 10 min; undici's default + // 5-min headers-timeout is disabled at import time so this can sit open + // indefinitely without fetch errors. const timeoutArg = args.find(a => a.startsWith('--timeout=')); const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; diff --git a/.cursor/skills/impeccable/scripts/live.mjs b/.cursor/skills/impeccable/scripts/live.mjs index 37b6f8997..062b35ae8 100644 --- a/.cursor/skills/impeccable/scripts/live.mjs +++ b/.cursor/skills/impeccable/scripts/live.mjs @@ -9,7 +9,7 @@ * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: - * - Navigate the browser to the page (optional, if browser automation is available) + * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll * - Enter the poll loop: `node live-poll.mjs` * * Usage: @@ -46,7 +46,7 @@ On config_missing, prints: The agent should then: 1. If config_missing, create the config and re-run this script - 2. Optionally navigate the browser to the page + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 3. Enter the poll loop: node live-poll.mjs`); process.exit(0); } diff --git a/.gemini/skills/impeccable/reference/document.md b/.gemini/skills/impeccable/reference/document.md index b59b8ac8b..37a7f0980 100644 --- a/.gemini/skills/impeccable/reference/document.md +++ b/.gemini/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/.gemini/skills/impeccable/scripts/design-parser.mjs b/.gemini/skills/impeccable/scripts/design-parser.mjs new file mode 100644 index 000000000..6220f67e7 --- /dev/null +++ b/.gemini/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/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js index 59e92d239..b9332e10d 100644 --- a/.gemini/skills/impeccable/scripts/live-browser.js +++ b/.gemini/skills/impeccable/scripts/live-browser.js @@ -891,7 +891,7 @@ switch (msg.type) { case 'connected': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); console.log('[impeccable] Live mode connected.'); if (state === 'IDLE') state = 'PICKING'; break; @@ -1293,92 +1293,227 @@ let detectCount = 0; let detectScriptLoaded = false; + // Theme-aware color palette for the global bar. We detect the page's + // ambient background and invert — dark bar on light pages, light bar on + // dark pages. This keeps the bar from fighting with the host design. + function detectPageTheme() { + try { + const bg = getComputedStyle(document.body).backgroundColor + || getComputedStyle(document.documentElement).backgroundColor; + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return 'light'; + const [, r, g, b] = m; + // Perceptual luminance (Rec. 709) + const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255; + return L > 0.55 ? 'light' : 'dark'; + } catch { return 'light'; } + } + + function barPaletteForTheme(theme) { + if (theme === 'dark') { + // Light bar on dark page + return { + surface: 'oklch(98% 0 0 / 0.92)', + hairline: 'oklch(70% 0 0 / 0.35)', + text: 'oklch(15% 0 0)', + textDim: 'oklch(45% 0 0)', + accent: 'oklch(60% 0.25 350)', + accentSoft: 'oklch(60% 0.25 350 / 0.18)', + mark: 'oklch(98% 0 0)', // logo mark fill + markText: 'oklch(15% 0 0)', // logo "/" color + exitHover: 'oklch(85% 0 0 / 0.5)', + }; + } + // Dark bar on light page. Bar is a warm charcoal, logo slab is much + // deeper so the rounded-right shape reads as a clear sculpted mark. + return { + surface: 'oklch(26% 0 0 / 0.94)', + hairline: 'oklch(42% 0 0 / 0.5)', + text: 'oklch(96% 0 0)', + textDim: 'oklch(72% 0 0)', + accent: 'oklch(72% 0.22 350)', + accentSoft: 'oklch(72% 0.22 350 / 0.22)', + mark: 'oklch(8% 0 0)', + markText: 'oklch(96% 0 0)', + exitHover: 'oklch(36% 0 0 / 0.6)', + }; + } + + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). + function brandMarkSvg(fill, ink, size = 18) { + return ``; + } + function initGlobalBar() { + const theme = detectPageTheme(); + const P = barPaletteForTheme(theme); + globalBarEl = el('div', { position: 'fixed', bottom: '14px', left: '50%', transform: 'translateX(-50%) translateY(20px)', zIndex: Z.bar + 5, - display: 'flex', alignItems: 'center', - padding: '4px 5px', gap: '2px', - background: C.paper, + display: 'flex', alignItems: 'stretch', + gap: '2px', + background: P.surface, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - border: '1px solid ' + C.mist, + border: '1px solid ' + P.hairline, borderRadius: '10px', - boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', - fontFamily: FONT, fontSize: '12px', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', + fontFamily: FONT, fontSize: '12px', lineHeight: '1', opacity: '0', + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, }); globalBarEl.id = PREFIX + '-global-bar'; + globalBarEl.dataset.theme = theme; - // Brand + // Brand mark — fills bar height on the left. Left side inherits the bar's + // rounded corner via overflow:hidden; right side is a clean hard edge since + // the near-black/charcoal contrast does the shape-defining work. const brand = el('span', { - fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em', - color: C.brand, fontFamily: MONO, - padding: '0 6px 0 4px', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + padding: '0 12px 0 14px', + background: P.mark, + color: P.markText, + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: '500', + fontSize: '18px', lineHeight: '1', }); - brand.textContent = 'Impeccable'; + brand.textContent = '/'; + brand.title = 'Impeccable'; globalBarEl.appendChild(brand); - // Detect toggle: eye icon + label + integrated badge - const detectBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: 'transparent', - color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // Inner wrapper: holds the toggles with normal bar padding. + const inner = el('div', { + display: 'flex', alignItems: 'center', + padding: '4px 5px', gap: '2px', + }); + inner.id = PREFIX + '-global-bar-inner'; + globalBarEl.appendChild(inner); + + // --- button factory: icon-only at rest, label slides in on hover/active --- + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { + const b = el('button', { + position: 'relative', + display: 'inline-flex', alignItems: 'center', + padding: '6px 8px', borderRadius: '7px', + border: 'none', background: 'transparent', + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease', + whiteSpace: 'nowrap', overflow: 'hidden', + }); + b.id = id; + b.title = ariaLabel || label || ''; + b.setAttribute('aria-label', ariaLabel || label || ''); + b.innerHTML = svg + (label + ? `${label}` + : ''); + const labelEl = b.querySelector('.icon-btn-label'); + const expand = () => { + if (!labelEl) return; + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; + }; + const collapse = () => { + if (!labelEl || b.dataset.active === 'true') return; + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; + }; + // Per-button hover only changes color (no layout). The label expand/ + // collapse is driven by the bar-level mouseenter/mouseleave so moving + // the mouse between adjacent buttons doesn't trigger per-button width + // thrashing — the whole bar grows once and shrinks once. + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); + b.addEventListener('click', onClick); + b._expandLabel = expand; + b._collapseLabel = collapse; + return b; + } + + // Pick toggle — starts active (primary intent when entering live mode). + const pickBtn = makeIconBtn({ + id: PREFIX + '-pick-toggle', + svg: '', + label: 'Pick', + ariaLabel: 'Pick element', + onClick: () => togglePick(), + }); + pickBtn.style.background = P.accentSoft; + pickBtn.style.color = P.accent; + pickBtn.dataset.active = 'true'; + pickBtn._expandLabel(); + inner.appendChild(pickBtn); + + // Detect toggle + const detectBtn = makeIconBtn({ + id: PREFIX + '-detect-toggle', + svg: '', + label: 'Detect', + ariaLabel: 'Detect anti-patterns', + onClick: () => toggleDetect(), }); - detectBtn.id = PREFIX + '-detect-toggle'; - // Eye icon (SVG) - detectBtn.innerHTML = ''; - const detectLabel = el('span', {}); - detectLabel.textContent = 'Detect'; - detectBtn.appendChild(detectLabel); - // Badge is inside the button const detectBadge = el('span', { fontSize: '10px', fontWeight: '600', padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', - background: C.brand, color: C.white, - display: 'none', fontFamily: MONO, + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', + display: 'none', fontFamily: MONO, marginLeft: '4px', }); detectBadge.id = PREFIX + '-detect-badge'; detectBtn.appendChild(detectBadge); - detectBtn.addEventListener('click', () => toggleDetect()); - detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; }); - detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; }); - globalBarEl.appendChild(detectBtn); + inner.appendChild(detectBtn); - // Pick toggle: crosshair icon + label - const pickBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: C.brandSoft, - color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // DESIGN.md panel toggle — quartet of color squares as the mark. + const designBtn = makeIconBtn({ + id: PREFIX + '-design-toggle', + svg: ` + + + + + `, + label: 'DESIGN.md', + ariaLabel: 'Toggle DESIGN.md panel', + labelFont: MONO, + onClick: () => toggleDesignPanel(), }); - pickBtn.id = PREFIX + '-pick-toggle'; - pickBtn.innerHTML = ''; - const pickLabel = el('span', {}); - pickLabel.textContent = 'Pick'; - pickBtn.appendChild(pickLabel); - pickBtn.addEventListener('click', () => togglePick()); - globalBarEl.appendChild(pickBtn); + inner.appendChild(designBtn); - // Exit (subtle × on the right) + // Thin divider before the exit button + const divider = el('span', { + width: '1px', height: '18px', + background: P.hairline, + margin: '0 4px 0 2px', + }); + inner.appendChild(divider); + + // Exit (subtle × on the right) — SVG for baseline-free centering const exitBtn = el('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: '24px', height: '24px', borderRadius: '6px', + width: '26px', height: '26px', borderRadius: '6px', border: 'none', background: 'transparent', - color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1', + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', - marginLeft: '2px', }); - exitBtn.textContent = '\u00D7'; + exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; - exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; }); - exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; }); + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); - globalBarEl.appendChild(exitBtn); + inner.appendChild(exitBtn); + + // Bar-level hover: expand every toggle's label at once; collapse on leave. + // Buttons with dataset.active="true" ignore collapse (their label stays). + const toggles = [pickBtn, detectBtn, designBtn]; + globalBarEl.addEventListener('mouseenter', () => { + toggles.forEach((t) => t._expandLabel && t._expandLabel()); + }); + globalBarEl.addEventListener('mouseleave', () => { + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); + }); document.body.appendChild(globalBarEl); @@ -1395,19 +1530,34 @@ const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); const detectBadge = document.getElementById(PREFIX + '-detect-badge'); const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); + const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const theme = globalBarEl?.dataset.theme || 'light'; + const P = barPaletteForTheme(theme); - if (detectToggle) { - detectToggle.style.background = detectActive ? C.brandSoft : 'transparent'; - detectToggle.style.color = detectActive ? C.brand : C.ash; + // Sync one toggle's active state, colors, and slide-label visibility. + function sync(btn, active) { + if (!btn) return; + btn.style.background = active ? P.accentSoft : 'transparent'; + btn.style.color = active ? P.accent : P.textDim; + btn.dataset.active = active ? 'true' : 'false'; + if (active && btn._expandLabel) btn._expandLabel(); + else if (!active && btn._collapseLabel) btn._collapseLabel(); } + sync(pickToggle, pickActive); + sync(detectToggle, detectActive); + sync(designToggle, designState.open); + + // If the bar is currently under the cursor, keep all labels expanded — + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) + // would collapse its label while the user's mouse is still on the bar. + if (globalBarEl && globalBarEl.matches(':hover')) { + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); + } + if (detectBadge) { detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; detectBadge.textContent = detectCount; } - if (pickToggle) { - pickToggle.style.background = pickActive ? C.brandSoft : 'transparent'; - pickToggle.style.color = pickActive ? C.brand : C.ash; - } // When pick is active, make detect overlays click-through so the picker works document.querySelectorAll('.impeccable-overlay').forEach(o => { @@ -1443,8 +1593,14 @@ updateGlobalBarState(); if (!pickActive) { + // Disabling pick clears any in-flight selection and UI: highlight, + // contextual bar, selectedElement. Otherwise a stale selection sits + // on screen with no obvious way to dismiss. hideHighlight(); - if (state === 'PICKING') state = 'IDLE'; + hideBar(); + hideActionPicker(); + selectedElement = null; + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; } else { if (state === 'IDLE') state = 'PICKING'; } @@ -1500,6 +1656,1057 @@ 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() { + // `open` is intentionally NOT persisted — the panel always starts closed + // so live mode doesn't auto-slide a big panel over the page on startup. + try { + const raw = localStorage.getItem(DESIGN_PREFS_KEY); + if (!raw) return; + const prefs = JSON.parse(raw); + 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({ + 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'); + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. + const theme = detectPageTheme(); + style.textContent = designPanelCss(barPaletteForTheme(theme)); + 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(BP) { + // BP = bar palette (theme-aware, matches the global bar). + // DP = internal content palette (neutral, so tiles render colors true). + 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; } + + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + .panel { + position: fixed; top: 12px; bottom: 72px; right: 12px; + width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); + background: ${BP.surface}; + border: 1px solid ${BP.hairline}; + border-radius: 14px; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); + 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: 10px 10px 10px 14px; + background: transparent; + border-bottom: 1px solid ${BP.hairline}; + } + .panel-title { + flex: 1; min-width: 0; + font-family: ${MONO}; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.02em; + color: ${BP.text}; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .panel-close { + border: none; background: transparent; color: ${BP.textDim}; + width: 26px; height: 26px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; transition: background 0.15s ease, color 0.15s ease; + } + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } + + .tabs { + display: inline-flex; padding: 2px; + background: ${BP.hairline}; + border-radius: 7px; + gap: 2px; + } + .tab { + border: none; background: transparent; + padding: 4px 10px; border-radius: 5px; + font-family: ${MONO}; + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; + color: ${BP.textDim}; cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } + + .panel-body { + flex: 1; overflow-y: auto; + padding: 12px 12px 20px; + background: ${DP.canvas}; + 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 = ''; + + // (Panel toggle lives in the global bar — no floating 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 = 'DESIGN.md'; + 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; + renderDesignChrome(); + updateGlobalBarState(); + 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 +2716,7 @@ initBar(); initActionPicker(); initGlobalBar(); + initDesignPanel(); document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); diff --git a/.gemini/skills/impeccable/scripts/live-poll.mjs b/.gemini/skills/impeccable/scripts/live-poll.mjs index b176d9f05..3c8088c23 100644 --- a/.gemini/skills/impeccable/scripts/live-poll.mjs +++ b/.gemini/skills/impeccable/scripts/live-poll.mjs @@ -3,7 +3,7 @@ * * Usage: * npx impeccable poll # Block until browser event, print JSON - * npx impeccable poll --timeout=60000 # Custom timeout (ms) + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly * npx impeccable poll --reply done # Reply "done" to event * npx impeccable poll --reply error "msg" # Reply with error */ @@ -13,6 +13,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { Agent, setGlobalDispatcher } from 'undici'; + +// Disable undici's default 300s headersTimeout so long-polls can sit open +// indefinitely (until a browser event or the server's own timeout fires). +// Without this, fetch() throws a bare "fetch failed" at 5 minutes even +// though the server would have happily kept the connection alive. +setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 })); const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -39,7 +46,7 @@ Modes: poll --reply error "msg" Reply with an error message Options: - --timeout=MS Poll timeout in milliseconds (default: 120000) + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn --help Show this help message`); process.exit(0); } @@ -93,7 +100,9 @@ Options: return; } - // Poll mode: block until browser event + // Poll mode: block until browser event. Default 10 min; undici's default + // 5-min headers-timeout is disabled at import time so this can sit open + // indefinitely without fetch errors. const timeoutArg = args.find(a => a.startsWith('--timeout=')); const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; diff --git a/.gemini/skills/impeccable/scripts/live.mjs b/.gemini/skills/impeccable/scripts/live.mjs index 37b6f8997..062b35ae8 100644 --- a/.gemini/skills/impeccable/scripts/live.mjs +++ b/.gemini/skills/impeccable/scripts/live.mjs @@ -9,7 +9,7 @@ * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: - * - Navigate the browser to the page (optional, if browser automation is available) + * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll * - Enter the poll loop: `node live-poll.mjs` * * Usage: @@ -46,7 +46,7 @@ On config_missing, prints: The agent should then: 1. If config_missing, create the config and re-run this script - 2. Optionally navigate the browser to the page + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 3. Enter the poll loop: node live-poll.mjs`); process.exit(0); } diff --git a/.github/skills/impeccable/reference/document.md b/.github/skills/impeccable/reference/document.md index b59b8ac8b..37a7f0980 100644 --- a/.github/skills/impeccable/reference/document.md +++ b/.github/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/.github/skills/impeccable/scripts/design-parser.mjs b/.github/skills/impeccable/scripts/design-parser.mjs new file mode 100644 index 000000000..6220f67e7 --- /dev/null +++ b/.github/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/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js index 59e92d239..b9332e10d 100644 --- a/.github/skills/impeccable/scripts/live-browser.js +++ b/.github/skills/impeccable/scripts/live-browser.js @@ -891,7 +891,7 @@ switch (msg.type) { case 'connected': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); console.log('[impeccable] Live mode connected.'); if (state === 'IDLE') state = 'PICKING'; break; @@ -1293,92 +1293,227 @@ let detectCount = 0; let detectScriptLoaded = false; + // Theme-aware color palette for the global bar. We detect the page's + // ambient background and invert — dark bar on light pages, light bar on + // dark pages. This keeps the bar from fighting with the host design. + function detectPageTheme() { + try { + const bg = getComputedStyle(document.body).backgroundColor + || getComputedStyle(document.documentElement).backgroundColor; + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return 'light'; + const [, r, g, b] = m; + // Perceptual luminance (Rec. 709) + const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255; + return L > 0.55 ? 'light' : 'dark'; + } catch { return 'light'; } + } + + function barPaletteForTheme(theme) { + if (theme === 'dark') { + // Light bar on dark page + return { + surface: 'oklch(98% 0 0 / 0.92)', + hairline: 'oklch(70% 0 0 / 0.35)', + text: 'oklch(15% 0 0)', + textDim: 'oklch(45% 0 0)', + accent: 'oklch(60% 0.25 350)', + accentSoft: 'oklch(60% 0.25 350 / 0.18)', + mark: 'oklch(98% 0 0)', // logo mark fill + markText: 'oklch(15% 0 0)', // logo "/" color + exitHover: 'oklch(85% 0 0 / 0.5)', + }; + } + // Dark bar on light page. Bar is a warm charcoal, logo slab is much + // deeper so the rounded-right shape reads as a clear sculpted mark. + return { + surface: 'oklch(26% 0 0 / 0.94)', + hairline: 'oklch(42% 0 0 / 0.5)', + text: 'oklch(96% 0 0)', + textDim: 'oklch(72% 0 0)', + accent: 'oklch(72% 0.22 350)', + accentSoft: 'oklch(72% 0.22 350 / 0.22)', + mark: 'oklch(8% 0 0)', + markText: 'oklch(96% 0 0)', + exitHover: 'oklch(36% 0 0 / 0.6)', + }; + } + + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). + function brandMarkSvg(fill, ink, size = 18) { + return ``; + } + function initGlobalBar() { + const theme = detectPageTheme(); + const P = barPaletteForTheme(theme); + globalBarEl = el('div', { position: 'fixed', bottom: '14px', left: '50%', transform: 'translateX(-50%) translateY(20px)', zIndex: Z.bar + 5, - display: 'flex', alignItems: 'center', - padding: '4px 5px', gap: '2px', - background: C.paper, + display: 'flex', alignItems: 'stretch', + gap: '2px', + background: P.surface, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - border: '1px solid ' + C.mist, + border: '1px solid ' + P.hairline, borderRadius: '10px', - boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', - fontFamily: FONT, fontSize: '12px', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', + fontFamily: FONT, fontSize: '12px', lineHeight: '1', opacity: '0', + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, }); globalBarEl.id = PREFIX + '-global-bar'; + globalBarEl.dataset.theme = theme; - // Brand + // Brand mark — fills bar height on the left. Left side inherits the bar's + // rounded corner via overflow:hidden; right side is a clean hard edge since + // the near-black/charcoal contrast does the shape-defining work. const brand = el('span', { - fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em', - color: C.brand, fontFamily: MONO, - padding: '0 6px 0 4px', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + padding: '0 12px 0 14px', + background: P.mark, + color: P.markText, + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: '500', + fontSize: '18px', lineHeight: '1', }); - brand.textContent = 'Impeccable'; + brand.textContent = '/'; + brand.title = 'Impeccable'; globalBarEl.appendChild(brand); - // Detect toggle: eye icon + label + integrated badge - const detectBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: 'transparent', - color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // Inner wrapper: holds the toggles with normal bar padding. + const inner = el('div', { + display: 'flex', alignItems: 'center', + padding: '4px 5px', gap: '2px', + }); + inner.id = PREFIX + '-global-bar-inner'; + globalBarEl.appendChild(inner); + + // --- button factory: icon-only at rest, label slides in on hover/active --- + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { + const b = el('button', { + position: 'relative', + display: 'inline-flex', alignItems: 'center', + padding: '6px 8px', borderRadius: '7px', + border: 'none', background: 'transparent', + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease', + whiteSpace: 'nowrap', overflow: 'hidden', + }); + b.id = id; + b.title = ariaLabel || label || ''; + b.setAttribute('aria-label', ariaLabel || label || ''); + b.innerHTML = svg + (label + ? `${label}` + : ''); + const labelEl = b.querySelector('.icon-btn-label'); + const expand = () => { + if (!labelEl) return; + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; + }; + const collapse = () => { + if (!labelEl || b.dataset.active === 'true') return; + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; + }; + // Per-button hover only changes color (no layout). The label expand/ + // collapse is driven by the bar-level mouseenter/mouseleave so moving + // the mouse between adjacent buttons doesn't trigger per-button width + // thrashing — the whole bar grows once and shrinks once. + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); + b.addEventListener('click', onClick); + b._expandLabel = expand; + b._collapseLabel = collapse; + return b; + } + + // Pick toggle — starts active (primary intent when entering live mode). + const pickBtn = makeIconBtn({ + id: PREFIX + '-pick-toggle', + svg: '', + label: 'Pick', + ariaLabel: 'Pick element', + onClick: () => togglePick(), + }); + pickBtn.style.background = P.accentSoft; + pickBtn.style.color = P.accent; + pickBtn.dataset.active = 'true'; + pickBtn._expandLabel(); + inner.appendChild(pickBtn); + + // Detect toggle + const detectBtn = makeIconBtn({ + id: PREFIX + '-detect-toggle', + svg: '', + label: 'Detect', + ariaLabel: 'Detect anti-patterns', + onClick: () => toggleDetect(), }); - detectBtn.id = PREFIX + '-detect-toggle'; - // Eye icon (SVG) - detectBtn.innerHTML = ''; - const detectLabel = el('span', {}); - detectLabel.textContent = 'Detect'; - detectBtn.appendChild(detectLabel); - // Badge is inside the button const detectBadge = el('span', { fontSize: '10px', fontWeight: '600', padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', - background: C.brand, color: C.white, - display: 'none', fontFamily: MONO, + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', + display: 'none', fontFamily: MONO, marginLeft: '4px', }); detectBadge.id = PREFIX + '-detect-badge'; detectBtn.appendChild(detectBadge); - detectBtn.addEventListener('click', () => toggleDetect()); - detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; }); - detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; }); - globalBarEl.appendChild(detectBtn); + inner.appendChild(detectBtn); - // Pick toggle: crosshair icon + label - const pickBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: C.brandSoft, - color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // DESIGN.md panel toggle — quartet of color squares as the mark. + const designBtn = makeIconBtn({ + id: PREFIX + '-design-toggle', + svg: ` + + + + + `, + label: 'DESIGN.md', + ariaLabel: 'Toggle DESIGN.md panel', + labelFont: MONO, + onClick: () => toggleDesignPanel(), }); - pickBtn.id = PREFIX + '-pick-toggle'; - pickBtn.innerHTML = ''; - const pickLabel = el('span', {}); - pickLabel.textContent = 'Pick'; - pickBtn.appendChild(pickLabel); - pickBtn.addEventListener('click', () => togglePick()); - globalBarEl.appendChild(pickBtn); + inner.appendChild(designBtn); - // Exit (subtle × on the right) + // Thin divider before the exit button + const divider = el('span', { + width: '1px', height: '18px', + background: P.hairline, + margin: '0 4px 0 2px', + }); + inner.appendChild(divider); + + // Exit (subtle × on the right) — SVG for baseline-free centering const exitBtn = el('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: '24px', height: '24px', borderRadius: '6px', + width: '26px', height: '26px', borderRadius: '6px', border: 'none', background: 'transparent', - color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1', + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', - marginLeft: '2px', }); - exitBtn.textContent = '\u00D7'; + exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; - exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; }); - exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; }); + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); - globalBarEl.appendChild(exitBtn); + inner.appendChild(exitBtn); + + // Bar-level hover: expand every toggle's label at once; collapse on leave. + // Buttons with dataset.active="true" ignore collapse (their label stays). + const toggles = [pickBtn, detectBtn, designBtn]; + globalBarEl.addEventListener('mouseenter', () => { + toggles.forEach((t) => t._expandLabel && t._expandLabel()); + }); + globalBarEl.addEventListener('mouseleave', () => { + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); + }); document.body.appendChild(globalBarEl); @@ -1395,19 +1530,34 @@ const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); const detectBadge = document.getElementById(PREFIX + '-detect-badge'); const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); + const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const theme = globalBarEl?.dataset.theme || 'light'; + const P = barPaletteForTheme(theme); - if (detectToggle) { - detectToggle.style.background = detectActive ? C.brandSoft : 'transparent'; - detectToggle.style.color = detectActive ? C.brand : C.ash; + // Sync one toggle's active state, colors, and slide-label visibility. + function sync(btn, active) { + if (!btn) return; + btn.style.background = active ? P.accentSoft : 'transparent'; + btn.style.color = active ? P.accent : P.textDim; + btn.dataset.active = active ? 'true' : 'false'; + if (active && btn._expandLabel) btn._expandLabel(); + else if (!active && btn._collapseLabel) btn._collapseLabel(); } + sync(pickToggle, pickActive); + sync(detectToggle, detectActive); + sync(designToggle, designState.open); + + // If the bar is currently under the cursor, keep all labels expanded — + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) + // would collapse its label while the user's mouse is still on the bar. + if (globalBarEl && globalBarEl.matches(':hover')) { + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); + } + if (detectBadge) { detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; detectBadge.textContent = detectCount; } - if (pickToggle) { - pickToggle.style.background = pickActive ? C.brandSoft : 'transparent'; - pickToggle.style.color = pickActive ? C.brand : C.ash; - } // When pick is active, make detect overlays click-through so the picker works document.querySelectorAll('.impeccable-overlay').forEach(o => { @@ -1443,8 +1593,14 @@ updateGlobalBarState(); if (!pickActive) { + // Disabling pick clears any in-flight selection and UI: highlight, + // contextual bar, selectedElement. Otherwise a stale selection sits + // on screen with no obvious way to dismiss. hideHighlight(); - if (state === 'PICKING') state = 'IDLE'; + hideBar(); + hideActionPicker(); + selectedElement = null; + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; } else { if (state === 'IDLE') state = 'PICKING'; } @@ -1500,6 +1656,1057 @@ 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() { + // `open` is intentionally NOT persisted — the panel always starts closed + // so live mode doesn't auto-slide a big panel over the page on startup. + try { + const raw = localStorage.getItem(DESIGN_PREFS_KEY); + if (!raw) return; + const prefs = JSON.parse(raw); + 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({ + 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'); + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. + const theme = detectPageTheme(); + style.textContent = designPanelCss(barPaletteForTheme(theme)); + 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(BP) { + // BP = bar palette (theme-aware, matches the global bar). + // DP = internal content palette (neutral, so tiles render colors true). + 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; } + + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + .panel { + position: fixed; top: 12px; bottom: 72px; right: 12px; + width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); + background: ${BP.surface}; + border: 1px solid ${BP.hairline}; + border-radius: 14px; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); + 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: 10px 10px 10px 14px; + background: transparent; + border-bottom: 1px solid ${BP.hairline}; + } + .panel-title { + flex: 1; min-width: 0; + font-family: ${MONO}; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.02em; + color: ${BP.text}; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .panel-close { + border: none; background: transparent; color: ${BP.textDim}; + width: 26px; height: 26px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; transition: background 0.15s ease, color 0.15s ease; + } + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } + + .tabs { + display: inline-flex; padding: 2px; + background: ${BP.hairline}; + border-radius: 7px; + gap: 2px; + } + .tab { + border: none; background: transparent; + padding: 4px 10px; border-radius: 5px; + font-family: ${MONO}; + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; + color: ${BP.textDim}; cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } + + .panel-body { + flex: 1; overflow-y: auto; + padding: 12px 12px 20px; + background: ${DP.canvas}; + 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 = ''; + + // (Panel toggle lives in the global bar — no floating 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 = 'DESIGN.md'; + 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; + renderDesignChrome(); + updateGlobalBarState(); + 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 +2716,7 @@ initBar(); initActionPicker(); initGlobalBar(); + initDesignPanel(); document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); diff --git a/.github/skills/impeccable/scripts/live-poll.mjs b/.github/skills/impeccable/scripts/live-poll.mjs index b176d9f05..3c8088c23 100644 --- a/.github/skills/impeccable/scripts/live-poll.mjs +++ b/.github/skills/impeccable/scripts/live-poll.mjs @@ -3,7 +3,7 @@ * * Usage: * npx impeccable poll # Block until browser event, print JSON - * npx impeccable poll --timeout=60000 # Custom timeout (ms) + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly * npx impeccable poll --reply done # Reply "done" to event * npx impeccable poll --reply error "msg" # Reply with error */ @@ -13,6 +13,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { Agent, setGlobalDispatcher } from 'undici'; + +// Disable undici's default 300s headersTimeout so long-polls can sit open +// indefinitely (until a browser event or the server's own timeout fires). +// Without this, fetch() throws a bare "fetch failed" at 5 minutes even +// though the server would have happily kept the connection alive. +setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 })); const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -39,7 +46,7 @@ Modes: poll --reply error "msg" Reply with an error message Options: - --timeout=MS Poll timeout in milliseconds (default: 120000) + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn --help Show this help message`); process.exit(0); } @@ -93,7 +100,9 @@ Options: return; } - // Poll mode: block until browser event + // Poll mode: block until browser event. Default 10 min; undici's default + // 5-min headers-timeout is disabled at import time so this can sit open + // indefinitely without fetch errors. const timeoutArg = args.find(a => a.startsWith('--timeout=')); const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; diff --git a/.github/skills/impeccable/scripts/live.mjs b/.github/skills/impeccable/scripts/live.mjs index 37b6f8997..062b35ae8 100644 --- a/.github/skills/impeccable/scripts/live.mjs +++ b/.github/skills/impeccable/scripts/live.mjs @@ -9,7 +9,7 @@ * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: - * - Navigate the browser to the page (optional, if browser automation is available) + * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll * - Enter the poll loop: `node live-poll.mjs` * * Usage: @@ -46,7 +46,7 @@ On config_missing, prints: The agent should then: 1. If config_missing, create the config and re-run this script - 2. Optionally navigate the browser to the page + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 3. Enter the poll loop: node live-poll.mjs`); process.exit(0); } diff --git a/.gitignore b/.gitignore index a19cefe67..188f0d68f 100644 --- a/.gitignore +++ b/.gitignore @@ -50,6 +50,7 @@ extension/detector/ # Evals (private, commercial) evals/ +tests/evals-v2/ # Generated sub-pages (built from source/skills + content/site at build time) public/docs/ @@ -61,3 +62,7 @@ public/visual-mode/ # are intentionally tracked. npx skills reads them from this repo at install # time, and they enable clean submodule use. Run `bun run build` to refresh # them after editing source/skills/. +# +# Codex CLI consumes `.agents/skills/`; `.codex/` is not used. Ignore it so +# local artifacts or old trees are never committed. +.codex/ diff --git a/.kiro/skills/impeccable/reference/document.md b/.kiro/skills/impeccable/reference/document.md index b59b8ac8b..37a7f0980 100644 --- a/.kiro/skills/impeccable/reference/document.md +++ b/.kiro/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/.kiro/skills/impeccable/scripts/design-parser.mjs b/.kiro/skills/impeccable/scripts/design-parser.mjs new file mode 100644 index 000000000..6220f67e7 --- /dev/null +++ b/.kiro/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/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js index 59e92d239..b9332e10d 100644 --- a/.kiro/skills/impeccable/scripts/live-browser.js +++ b/.kiro/skills/impeccable/scripts/live-browser.js @@ -891,7 +891,7 @@ switch (msg.type) { case 'connected': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); console.log('[impeccable] Live mode connected.'); if (state === 'IDLE') state = 'PICKING'; break; @@ -1293,92 +1293,227 @@ let detectCount = 0; let detectScriptLoaded = false; + // Theme-aware color palette for the global bar. We detect the page's + // ambient background and invert — dark bar on light pages, light bar on + // dark pages. This keeps the bar from fighting with the host design. + function detectPageTheme() { + try { + const bg = getComputedStyle(document.body).backgroundColor + || getComputedStyle(document.documentElement).backgroundColor; + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return 'light'; + const [, r, g, b] = m; + // Perceptual luminance (Rec. 709) + const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255; + return L > 0.55 ? 'light' : 'dark'; + } catch { return 'light'; } + } + + function barPaletteForTheme(theme) { + if (theme === 'dark') { + // Light bar on dark page + return { + surface: 'oklch(98% 0 0 / 0.92)', + hairline: 'oklch(70% 0 0 / 0.35)', + text: 'oklch(15% 0 0)', + textDim: 'oklch(45% 0 0)', + accent: 'oklch(60% 0.25 350)', + accentSoft: 'oklch(60% 0.25 350 / 0.18)', + mark: 'oklch(98% 0 0)', // logo mark fill + markText: 'oklch(15% 0 0)', // logo "/" color + exitHover: 'oklch(85% 0 0 / 0.5)', + }; + } + // Dark bar on light page. Bar is a warm charcoal, logo slab is much + // deeper so the rounded-right shape reads as a clear sculpted mark. + return { + surface: 'oklch(26% 0 0 / 0.94)', + hairline: 'oklch(42% 0 0 / 0.5)', + text: 'oklch(96% 0 0)', + textDim: 'oklch(72% 0 0)', + accent: 'oklch(72% 0.22 350)', + accentSoft: 'oklch(72% 0.22 350 / 0.22)', + mark: 'oklch(8% 0 0)', + markText: 'oklch(96% 0 0)', + exitHover: 'oklch(36% 0 0 / 0.6)', + }; + } + + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). + function brandMarkSvg(fill, ink, size = 18) { + return ``; + } + function initGlobalBar() { + const theme = detectPageTheme(); + const P = barPaletteForTheme(theme); + globalBarEl = el('div', { position: 'fixed', bottom: '14px', left: '50%', transform: 'translateX(-50%) translateY(20px)', zIndex: Z.bar + 5, - display: 'flex', alignItems: 'center', - padding: '4px 5px', gap: '2px', - background: C.paper, + display: 'flex', alignItems: 'stretch', + gap: '2px', + background: P.surface, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - border: '1px solid ' + C.mist, + border: '1px solid ' + P.hairline, borderRadius: '10px', - boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', - fontFamily: FONT, fontSize: '12px', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', + fontFamily: FONT, fontSize: '12px', lineHeight: '1', opacity: '0', + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, }); globalBarEl.id = PREFIX + '-global-bar'; + globalBarEl.dataset.theme = theme; - // Brand + // Brand mark — fills bar height on the left. Left side inherits the bar's + // rounded corner via overflow:hidden; right side is a clean hard edge since + // the near-black/charcoal contrast does the shape-defining work. const brand = el('span', { - fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em', - color: C.brand, fontFamily: MONO, - padding: '0 6px 0 4px', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + padding: '0 12px 0 14px', + background: P.mark, + color: P.markText, + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: '500', + fontSize: '18px', lineHeight: '1', }); - brand.textContent = 'Impeccable'; + brand.textContent = '/'; + brand.title = 'Impeccable'; globalBarEl.appendChild(brand); - // Detect toggle: eye icon + label + integrated badge - const detectBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: 'transparent', - color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // Inner wrapper: holds the toggles with normal bar padding. + const inner = el('div', { + display: 'flex', alignItems: 'center', + padding: '4px 5px', gap: '2px', + }); + inner.id = PREFIX + '-global-bar-inner'; + globalBarEl.appendChild(inner); + + // --- button factory: icon-only at rest, label slides in on hover/active --- + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { + const b = el('button', { + position: 'relative', + display: 'inline-flex', alignItems: 'center', + padding: '6px 8px', borderRadius: '7px', + border: 'none', background: 'transparent', + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease', + whiteSpace: 'nowrap', overflow: 'hidden', + }); + b.id = id; + b.title = ariaLabel || label || ''; + b.setAttribute('aria-label', ariaLabel || label || ''); + b.innerHTML = svg + (label + ? `${label}` + : ''); + const labelEl = b.querySelector('.icon-btn-label'); + const expand = () => { + if (!labelEl) return; + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; + }; + const collapse = () => { + if (!labelEl || b.dataset.active === 'true') return; + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; + }; + // Per-button hover only changes color (no layout). The label expand/ + // collapse is driven by the bar-level mouseenter/mouseleave so moving + // the mouse between adjacent buttons doesn't trigger per-button width + // thrashing — the whole bar grows once and shrinks once. + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); + b.addEventListener('click', onClick); + b._expandLabel = expand; + b._collapseLabel = collapse; + return b; + } + + // Pick toggle — starts active (primary intent when entering live mode). + const pickBtn = makeIconBtn({ + id: PREFIX + '-pick-toggle', + svg: '', + label: 'Pick', + ariaLabel: 'Pick element', + onClick: () => togglePick(), + }); + pickBtn.style.background = P.accentSoft; + pickBtn.style.color = P.accent; + pickBtn.dataset.active = 'true'; + pickBtn._expandLabel(); + inner.appendChild(pickBtn); + + // Detect toggle + const detectBtn = makeIconBtn({ + id: PREFIX + '-detect-toggle', + svg: '', + label: 'Detect', + ariaLabel: 'Detect anti-patterns', + onClick: () => toggleDetect(), }); - detectBtn.id = PREFIX + '-detect-toggle'; - // Eye icon (SVG) - detectBtn.innerHTML = ''; - const detectLabel = el('span', {}); - detectLabel.textContent = 'Detect'; - detectBtn.appendChild(detectLabel); - // Badge is inside the button const detectBadge = el('span', { fontSize: '10px', fontWeight: '600', padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', - background: C.brand, color: C.white, - display: 'none', fontFamily: MONO, + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', + display: 'none', fontFamily: MONO, marginLeft: '4px', }); detectBadge.id = PREFIX + '-detect-badge'; detectBtn.appendChild(detectBadge); - detectBtn.addEventListener('click', () => toggleDetect()); - detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; }); - detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; }); - globalBarEl.appendChild(detectBtn); + inner.appendChild(detectBtn); - // Pick toggle: crosshair icon + label - const pickBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: C.brandSoft, - color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // DESIGN.md panel toggle — quartet of color squares as the mark. + const designBtn = makeIconBtn({ + id: PREFIX + '-design-toggle', + svg: ` + + + + + `, + label: 'DESIGN.md', + ariaLabel: 'Toggle DESIGN.md panel', + labelFont: MONO, + onClick: () => toggleDesignPanel(), }); - pickBtn.id = PREFIX + '-pick-toggle'; - pickBtn.innerHTML = ''; - const pickLabel = el('span', {}); - pickLabel.textContent = 'Pick'; - pickBtn.appendChild(pickLabel); - pickBtn.addEventListener('click', () => togglePick()); - globalBarEl.appendChild(pickBtn); + inner.appendChild(designBtn); - // Exit (subtle × on the right) + // Thin divider before the exit button + const divider = el('span', { + width: '1px', height: '18px', + background: P.hairline, + margin: '0 4px 0 2px', + }); + inner.appendChild(divider); + + // Exit (subtle × on the right) — SVG for baseline-free centering const exitBtn = el('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: '24px', height: '24px', borderRadius: '6px', + width: '26px', height: '26px', borderRadius: '6px', border: 'none', background: 'transparent', - color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1', + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', - marginLeft: '2px', }); - exitBtn.textContent = '\u00D7'; + exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; - exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; }); - exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; }); + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); - globalBarEl.appendChild(exitBtn); + inner.appendChild(exitBtn); + + // Bar-level hover: expand every toggle's label at once; collapse on leave. + // Buttons with dataset.active="true" ignore collapse (their label stays). + const toggles = [pickBtn, detectBtn, designBtn]; + globalBarEl.addEventListener('mouseenter', () => { + toggles.forEach((t) => t._expandLabel && t._expandLabel()); + }); + globalBarEl.addEventListener('mouseleave', () => { + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); + }); document.body.appendChild(globalBarEl); @@ -1395,19 +1530,34 @@ const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); const detectBadge = document.getElementById(PREFIX + '-detect-badge'); const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); + const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const theme = globalBarEl?.dataset.theme || 'light'; + const P = barPaletteForTheme(theme); - if (detectToggle) { - detectToggle.style.background = detectActive ? C.brandSoft : 'transparent'; - detectToggle.style.color = detectActive ? C.brand : C.ash; + // Sync one toggle's active state, colors, and slide-label visibility. + function sync(btn, active) { + if (!btn) return; + btn.style.background = active ? P.accentSoft : 'transparent'; + btn.style.color = active ? P.accent : P.textDim; + btn.dataset.active = active ? 'true' : 'false'; + if (active && btn._expandLabel) btn._expandLabel(); + else if (!active && btn._collapseLabel) btn._collapseLabel(); } + sync(pickToggle, pickActive); + sync(detectToggle, detectActive); + sync(designToggle, designState.open); + + // If the bar is currently under the cursor, keep all labels expanded — + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) + // would collapse its label while the user's mouse is still on the bar. + if (globalBarEl && globalBarEl.matches(':hover')) { + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); + } + if (detectBadge) { detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; detectBadge.textContent = detectCount; } - if (pickToggle) { - pickToggle.style.background = pickActive ? C.brandSoft : 'transparent'; - pickToggle.style.color = pickActive ? C.brand : C.ash; - } // When pick is active, make detect overlays click-through so the picker works document.querySelectorAll('.impeccable-overlay').forEach(o => { @@ -1443,8 +1593,14 @@ updateGlobalBarState(); if (!pickActive) { + // Disabling pick clears any in-flight selection and UI: highlight, + // contextual bar, selectedElement. Otherwise a stale selection sits + // on screen with no obvious way to dismiss. hideHighlight(); - if (state === 'PICKING') state = 'IDLE'; + hideBar(); + hideActionPicker(); + selectedElement = null; + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; } else { if (state === 'IDLE') state = 'PICKING'; } @@ -1500,6 +1656,1057 @@ 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() { + // `open` is intentionally NOT persisted — the panel always starts closed + // so live mode doesn't auto-slide a big panel over the page on startup. + try { + const raw = localStorage.getItem(DESIGN_PREFS_KEY); + if (!raw) return; + const prefs = JSON.parse(raw); + 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({ + 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'); + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. + const theme = detectPageTheme(); + style.textContent = designPanelCss(barPaletteForTheme(theme)); + 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(BP) { + // BP = bar palette (theme-aware, matches the global bar). + // DP = internal content palette (neutral, so tiles render colors true). + 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; } + + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + .panel { + position: fixed; top: 12px; bottom: 72px; right: 12px; + width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); + background: ${BP.surface}; + border: 1px solid ${BP.hairline}; + border-radius: 14px; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); + 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: 10px 10px 10px 14px; + background: transparent; + border-bottom: 1px solid ${BP.hairline}; + } + .panel-title { + flex: 1; min-width: 0; + font-family: ${MONO}; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.02em; + color: ${BP.text}; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .panel-close { + border: none; background: transparent; color: ${BP.textDim}; + width: 26px; height: 26px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; transition: background 0.15s ease, color 0.15s ease; + } + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } + + .tabs { + display: inline-flex; padding: 2px; + background: ${BP.hairline}; + border-radius: 7px; + gap: 2px; + } + .tab { + border: none; background: transparent; + padding: 4px 10px; border-radius: 5px; + font-family: ${MONO}; + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; + color: ${BP.textDim}; cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } + + .panel-body { + flex: 1; overflow-y: auto; + padding: 12px 12px 20px; + background: ${DP.canvas}; + 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 = ''; + + // (Panel toggle lives in the global bar — no floating 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 = 'DESIGN.md'; + 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; + renderDesignChrome(); + updateGlobalBarState(); + 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 +2716,7 @@ initBar(); initActionPicker(); initGlobalBar(); + initDesignPanel(); document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); diff --git a/.kiro/skills/impeccable/scripts/live-poll.mjs b/.kiro/skills/impeccable/scripts/live-poll.mjs index b176d9f05..3c8088c23 100644 --- a/.kiro/skills/impeccable/scripts/live-poll.mjs +++ b/.kiro/skills/impeccable/scripts/live-poll.mjs @@ -3,7 +3,7 @@ * * Usage: * npx impeccable poll # Block until browser event, print JSON - * npx impeccable poll --timeout=60000 # Custom timeout (ms) + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly * npx impeccable poll --reply done # Reply "done" to event * npx impeccable poll --reply error "msg" # Reply with error */ @@ -13,6 +13,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { Agent, setGlobalDispatcher } from 'undici'; + +// Disable undici's default 300s headersTimeout so long-polls can sit open +// indefinitely (until a browser event or the server's own timeout fires). +// Without this, fetch() throws a bare "fetch failed" at 5 minutes even +// though the server would have happily kept the connection alive. +setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 })); const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -39,7 +46,7 @@ Modes: poll --reply error "msg" Reply with an error message Options: - --timeout=MS Poll timeout in milliseconds (default: 120000) + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn --help Show this help message`); process.exit(0); } @@ -93,7 +100,9 @@ Options: return; } - // Poll mode: block until browser event + // Poll mode: block until browser event. Default 10 min; undici's default + // 5-min headers-timeout is disabled at import time so this can sit open + // indefinitely without fetch errors. const timeoutArg = args.find(a => a.startsWith('--timeout=')); const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; diff --git a/.kiro/skills/impeccable/scripts/live.mjs b/.kiro/skills/impeccable/scripts/live.mjs index 37b6f8997..062b35ae8 100644 --- a/.kiro/skills/impeccable/scripts/live.mjs +++ b/.kiro/skills/impeccable/scripts/live.mjs @@ -9,7 +9,7 @@ * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: - * - Navigate the browser to the page (optional, if browser automation is available) + * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll * - Enter the poll loop: `node live-poll.mjs` * * Usage: @@ -46,7 +46,7 @@ On config_missing, prints: The agent should then: 1. If config_missing, create the config and re-run this script - 2. Optionally navigate the browser to the page + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 3. Enter the poll loop: node live-poll.mjs`); process.exit(0); } diff --git a/.opencode/skills/impeccable/reference/document.md b/.opencode/skills/impeccable/reference/document.md index f983beba8..6bfbdde7b 100644 --- a/.opencode/skills/impeccable/reference/document.md +++ b/.opencode/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/.opencode/skills/impeccable/scripts/design-parser.mjs b/.opencode/skills/impeccable/scripts/design-parser.mjs new file mode 100644 index 000000000..6220f67e7 --- /dev/null +++ b/.opencode/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/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js index 59e92d239..b9332e10d 100644 --- a/.opencode/skills/impeccable/scripts/live-browser.js +++ b/.opencode/skills/impeccable/scripts/live-browser.js @@ -891,7 +891,7 @@ switch (msg.type) { case 'connected': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); console.log('[impeccable] Live mode connected.'); if (state === 'IDLE') state = 'PICKING'; break; @@ -1293,92 +1293,227 @@ let detectCount = 0; let detectScriptLoaded = false; + // Theme-aware color palette for the global bar. We detect the page's + // ambient background and invert — dark bar on light pages, light bar on + // dark pages. This keeps the bar from fighting with the host design. + function detectPageTheme() { + try { + const bg = getComputedStyle(document.body).backgroundColor + || getComputedStyle(document.documentElement).backgroundColor; + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return 'light'; + const [, r, g, b] = m; + // Perceptual luminance (Rec. 709) + const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255; + return L > 0.55 ? 'light' : 'dark'; + } catch { return 'light'; } + } + + function barPaletteForTheme(theme) { + if (theme === 'dark') { + // Light bar on dark page + return { + surface: 'oklch(98% 0 0 / 0.92)', + hairline: 'oklch(70% 0 0 / 0.35)', + text: 'oklch(15% 0 0)', + textDim: 'oklch(45% 0 0)', + accent: 'oklch(60% 0.25 350)', + accentSoft: 'oklch(60% 0.25 350 / 0.18)', + mark: 'oklch(98% 0 0)', // logo mark fill + markText: 'oklch(15% 0 0)', // logo "/" color + exitHover: 'oklch(85% 0 0 / 0.5)', + }; + } + // Dark bar on light page. Bar is a warm charcoal, logo slab is much + // deeper so the rounded-right shape reads as a clear sculpted mark. + return { + surface: 'oklch(26% 0 0 / 0.94)', + hairline: 'oklch(42% 0 0 / 0.5)', + text: 'oklch(96% 0 0)', + textDim: 'oklch(72% 0 0)', + accent: 'oklch(72% 0.22 350)', + accentSoft: 'oklch(72% 0.22 350 / 0.22)', + mark: 'oklch(8% 0 0)', + markText: 'oklch(96% 0 0)', + exitHover: 'oklch(36% 0 0 / 0.6)', + }; + } + + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). + function brandMarkSvg(fill, ink, size = 18) { + return ``; + } + function initGlobalBar() { + const theme = detectPageTheme(); + const P = barPaletteForTheme(theme); + globalBarEl = el('div', { position: 'fixed', bottom: '14px', left: '50%', transform: 'translateX(-50%) translateY(20px)', zIndex: Z.bar + 5, - display: 'flex', alignItems: 'center', - padding: '4px 5px', gap: '2px', - background: C.paper, + display: 'flex', alignItems: 'stretch', + gap: '2px', + background: P.surface, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - border: '1px solid ' + C.mist, + border: '1px solid ' + P.hairline, borderRadius: '10px', - boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', - fontFamily: FONT, fontSize: '12px', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', + fontFamily: FONT, fontSize: '12px', lineHeight: '1', opacity: '0', + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, }); globalBarEl.id = PREFIX + '-global-bar'; + globalBarEl.dataset.theme = theme; - // Brand + // Brand mark — fills bar height on the left. Left side inherits the bar's + // rounded corner via overflow:hidden; right side is a clean hard edge since + // the near-black/charcoal contrast does the shape-defining work. const brand = el('span', { - fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em', - color: C.brand, fontFamily: MONO, - padding: '0 6px 0 4px', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + padding: '0 12px 0 14px', + background: P.mark, + color: P.markText, + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: '500', + fontSize: '18px', lineHeight: '1', }); - brand.textContent = 'Impeccable'; + brand.textContent = '/'; + brand.title = 'Impeccable'; globalBarEl.appendChild(brand); - // Detect toggle: eye icon + label + integrated badge - const detectBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: 'transparent', - color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // Inner wrapper: holds the toggles with normal bar padding. + const inner = el('div', { + display: 'flex', alignItems: 'center', + padding: '4px 5px', gap: '2px', + }); + inner.id = PREFIX + '-global-bar-inner'; + globalBarEl.appendChild(inner); + + // --- button factory: icon-only at rest, label slides in on hover/active --- + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { + const b = el('button', { + position: 'relative', + display: 'inline-flex', alignItems: 'center', + padding: '6px 8px', borderRadius: '7px', + border: 'none', background: 'transparent', + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease', + whiteSpace: 'nowrap', overflow: 'hidden', + }); + b.id = id; + b.title = ariaLabel || label || ''; + b.setAttribute('aria-label', ariaLabel || label || ''); + b.innerHTML = svg + (label + ? `${label}` + : ''); + const labelEl = b.querySelector('.icon-btn-label'); + const expand = () => { + if (!labelEl) return; + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; + }; + const collapse = () => { + if (!labelEl || b.dataset.active === 'true') return; + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; + }; + // Per-button hover only changes color (no layout). The label expand/ + // collapse is driven by the bar-level mouseenter/mouseleave so moving + // the mouse between adjacent buttons doesn't trigger per-button width + // thrashing — the whole bar grows once and shrinks once. + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); + b.addEventListener('click', onClick); + b._expandLabel = expand; + b._collapseLabel = collapse; + return b; + } + + // Pick toggle — starts active (primary intent when entering live mode). + const pickBtn = makeIconBtn({ + id: PREFIX + '-pick-toggle', + svg: '', + label: 'Pick', + ariaLabel: 'Pick element', + onClick: () => togglePick(), + }); + pickBtn.style.background = P.accentSoft; + pickBtn.style.color = P.accent; + pickBtn.dataset.active = 'true'; + pickBtn._expandLabel(); + inner.appendChild(pickBtn); + + // Detect toggle + const detectBtn = makeIconBtn({ + id: PREFIX + '-detect-toggle', + svg: '', + label: 'Detect', + ariaLabel: 'Detect anti-patterns', + onClick: () => toggleDetect(), }); - detectBtn.id = PREFIX + '-detect-toggle'; - // Eye icon (SVG) - detectBtn.innerHTML = ''; - const detectLabel = el('span', {}); - detectLabel.textContent = 'Detect'; - detectBtn.appendChild(detectLabel); - // Badge is inside the button const detectBadge = el('span', { fontSize: '10px', fontWeight: '600', padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', - background: C.brand, color: C.white, - display: 'none', fontFamily: MONO, + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', + display: 'none', fontFamily: MONO, marginLeft: '4px', }); detectBadge.id = PREFIX + '-detect-badge'; detectBtn.appendChild(detectBadge); - detectBtn.addEventListener('click', () => toggleDetect()); - detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; }); - detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; }); - globalBarEl.appendChild(detectBtn); + inner.appendChild(detectBtn); - // Pick toggle: crosshair icon + label - const pickBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: C.brandSoft, - color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // DESIGN.md panel toggle — quartet of color squares as the mark. + const designBtn = makeIconBtn({ + id: PREFIX + '-design-toggle', + svg: ` + + + + + `, + label: 'DESIGN.md', + ariaLabel: 'Toggle DESIGN.md panel', + labelFont: MONO, + onClick: () => toggleDesignPanel(), }); - pickBtn.id = PREFIX + '-pick-toggle'; - pickBtn.innerHTML = ''; - const pickLabel = el('span', {}); - pickLabel.textContent = 'Pick'; - pickBtn.appendChild(pickLabel); - pickBtn.addEventListener('click', () => togglePick()); - globalBarEl.appendChild(pickBtn); + inner.appendChild(designBtn); - // Exit (subtle × on the right) + // Thin divider before the exit button + const divider = el('span', { + width: '1px', height: '18px', + background: P.hairline, + margin: '0 4px 0 2px', + }); + inner.appendChild(divider); + + // Exit (subtle × on the right) — SVG for baseline-free centering const exitBtn = el('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: '24px', height: '24px', borderRadius: '6px', + width: '26px', height: '26px', borderRadius: '6px', border: 'none', background: 'transparent', - color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1', + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', - marginLeft: '2px', }); - exitBtn.textContent = '\u00D7'; + exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; - exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; }); - exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; }); + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); - globalBarEl.appendChild(exitBtn); + inner.appendChild(exitBtn); + + // Bar-level hover: expand every toggle's label at once; collapse on leave. + // Buttons with dataset.active="true" ignore collapse (their label stays). + const toggles = [pickBtn, detectBtn, designBtn]; + globalBarEl.addEventListener('mouseenter', () => { + toggles.forEach((t) => t._expandLabel && t._expandLabel()); + }); + globalBarEl.addEventListener('mouseleave', () => { + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); + }); document.body.appendChild(globalBarEl); @@ -1395,19 +1530,34 @@ const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); const detectBadge = document.getElementById(PREFIX + '-detect-badge'); const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); + const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const theme = globalBarEl?.dataset.theme || 'light'; + const P = barPaletteForTheme(theme); - if (detectToggle) { - detectToggle.style.background = detectActive ? C.brandSoft : 'transparent'; - detectToggle.style.color = detectActive ? C.brand : C.ash; + // Sync one toggle's active state, colors, and slide-label visibility. + function sync(btn, active) { + if (!btn) return; + btn.style.background = active ? P.accentSoft : 'transparent'; + btn.style.color = active ? P.accent : P.textDim; + btn.dataset.active = active ? 'true' : 'false'; + if (active && btn._expandLabel) btn._expandLabel(); + else if (!active && btn._collapseLabel) btn._collapseLabel(); } + sync(pickToggle, pickActive); + sync(detectToggle, detectActive); + sync(designToggle, designState.open); + + // If the bar is currently under the cursor, keep all labels expanded — + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) + // would collapse its label while the user's mouse is still on the bar. + if (globalBarEl && globalBarEl.matches(':hover')) { + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); + } + if (detectBadge) { detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; detectBadge.textContent = detectCount; } - if (pickToggle) { - pickToggle.style.background = pickActive ? C.brandSoft : 'transparent'; - pickToggle.style.color = pickActive ? C.brand : C.ash; - } // When pick is active, make detect overlays click-through so the picker works document.querySelectorAll('.impeccable-overlay').forEach(o => { @@ -1443,8 +1593,14 @@ updateGlobalBarState(); if (!pickActive) { + // Disabling pick clears any in-flight selection and UI: highlight, + // contextual bar, selectedElement. Otherwise a stale selection sits + // on screen with no obvious way to dismiss. hideHighlight(); - if (state === 'PICKING') state = 'IDLE'; + hideBar(); + hideActionPicker(); + selectedElement = null; + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; } else { if (state === 'IDLE') state = 'PICKING'; } @@ -1500,6 +1656,1057 @@ 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() { + // `open` is intentionally NOT persisted — the panel always starts closed + // so live mode doesn't auto-slide a big panel over the page on startup. + try { + const raw = localStorage.getItem(DESIGN_PREFS_KEY); + if (!raw) return; + const prefs = JSON.parse(raw); + 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({ + 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'); + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. + const theme = detectPageTheme(); + style.textContent = designPanelCss(barPaletteForTheme(theme)); + 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(BP) { + // BP = bar palette (theme-aware, matches the global bar). + // DP = internal content palette (neutral, so tiles render colors true). + 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; } + + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + .panel { + position: fixed; top: 12px; bottom: 72px; right: 12px; + width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); + background: ${BP.surface}; + border: 1px solid ${BP.hairline}; + border-radius: 14px; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); + 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: 10px 10px 10px 14px; + background: transparent; + border-bottom: 1px solid ${BP.hairline}; + } + .panel-title { + flex: 1; min-width: 0; + font-family: ${MONO}; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.02em; + color: ${BP.text}; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .panel-close { + border: none; background: transparent; color: ${BP.textDim}; + width: 26px; height: 26px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; transition: background 0.15s ease, color 0.15s ease; + } + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } + + .tabs { + display: inline-flex; padding: 2px; + background: ${BP.hairline}; + border-radius: 7px; + gap: 2px; + } + .tab { + border: none; background: transparent; + padding: 4px 10px; border-radius: 5px; + font-family: ${MONO}; + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; + color: ${BP.textDim}; cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } + + .panel-body { + flex: 1; overflow-y: auto; + padding: 12px 12px 20px; + background: ${DP.canvas}; + 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 = ''; + + // (Panel toggle lives in the global bar — no floating 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 = 'DESIGN.md'; + 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; + renderDesignChrome(); + updateGlobalBarState(); + 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 +2716,7 @@ initBar(); initActionPicker(); initGlobalBar(); + initDesignPanel(); document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); diff --git a/.opencode/skills/impeccable/scripts/live-poll.mjs b/.opencode/skills/impeccable/scripts/live-poll.mjs index b176d9f05..3c8088c23 100644 --- a/.opencode/skills/impeccable/scripts/live-poll.mjs +++ b/.opencode/skills/impeccable/scripts/live-poll.mjs @@ -3,7 +3,7 @@ * * Usage: * npx impeccable poll # Block until browser event, print JSON - * npx impeccable poll --timeout=60000 # Custom timeout (ms) + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly * npx impeccable poll --reply done # Reply "done" to event * npx impeccable poll --reply error "msg" # Reply with error */ @@ -13,6 +13,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { Agent, setGlobalDispatcher } from 'undici'; + +// Disable undici's default 300s headersTimeout so long-polls can sit open +// indefinitely (until a browser event or the server's own timeout fires). +// Without this, fetch() throws a bare "fetch failed" at 5 minutes even +// though the server would have happily kept the connection alive. +setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 })); const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -39,7 +46,7 @@ Modes: poll --reply error "msg" Reply with an error message Options: - --timeout=MS Poll timeout in milliseconds (default: 120000) + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn --help Show this help message`); process.exit(0); } @@ -93,7 +100,9 @@ Options: return; } - // Poll mode: block until browser event + // Poll mode: block until browser event. Default 10 min; undici's default + // 5-min headers-timeout is disabled at import time so this can sit open + // indefinitely without fetch errors. const timeoutArg = args.find(a => a.startsWith('--timeout=')); const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; diff --git a/.opencode/skills/impeccable/scripts/live.mjs b/.opencode/skills/impeccable/scripts/live.mjs index 37b6f8997..062b35ae8 100644 --- a/.opencode/skills/impeccable/scripts/live.mjs +++ b/.opencode/skills/impeccable/scripts/live.mjs @@ -9,7 +9,7 @@ * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: - * - Navigate the browser to the page (optional, if browser automation is available) + * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll * - Enter the poll loop: `node live-poll.mjs` * * Usage: @@ -46,7 +46,7 @@ On config_missing, prints: The agent should then: 1. If config_missing, create the config and re-run this script - 2. Optionally navigate the browser to the page + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 3. Enter the poll loop: node live-poll.mjs`); process.exit(0); } diff --git a/.pi/skills/impeccable/reference/document.md b/.pi/skills/impeccable/reference/document.md index b59b8ac8b..37a7f0980 100644 --- a/.pi/skills/impeccable/reference/document.md +++ b/.pi/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/.pi/skills/impeccable/scripts/design-parser.mjs b/.pi/skills/impeccable/scripts/design-parser.mjs new file mode 100644 index 000000000..6220f67e7 --- /dev/null +++ b/.pi/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/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js index 59e92d239..b9332e10d 100644 --- a/.pi/skills/impeccable/scripts/live-browser.js +++ b/.pi/skills/impeccable/scripts/live-browser.js @@ -891,7 +891,7 @@ switch (msg.type) { case 'connected': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); console.log('[impeccable] Live mode connected.'); if (state === 'IDLE') state = 'PICKING'; break; @@ -1293,92 +1293,227 @@ let detectCount = 0; let detectScriptLoaded = false; + // Theme-aware color palette for the global bar. We detect the page's + // ambient background and invert — dark bar on light pages, light bar on + // dark pages. This keeps the bar from fighting with the host design. + function detectPageTheme() { + try { + const bg = getComputedStyle(document.body).backgroundColor + || getComputedStyle(document.documentElement).backgroundColor; + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return 'light'; + const [, r, g, b] = m; + // Perceptual luminance (Rec. 709) + const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255; + return L > 0.55 ? 'light' : 'dark'; + } catch { return 'light'; } + } + + function barPaletteForTheme(theme) { + if (theme === 'dark') { + // Light bar on dark page + return { + surface: 'oklch(98% 0 0 / 0.92)', + hairline: 'oklch(70% 0 0 / 0.35)', + text: 'oklch(15% 0 0)', + textDim: 'oklch(45% 0 0)', + accent: 'oklch(60% 0.25 350)', + accentSoft: 'oklch(60% 0.25 350 / 0.18)', + mark: 'oklch(98% 0 0)', // logo mark fill + markText: 'oklch(15% 0 0)', // logo "/" color + exitHover: 'oklch(85% 0 0 / 0.5)', + }; + } + // Dark bar on light page. Bar is a warm charcoal, logo slab is much + // deeper so the rounded-right shape reads as a clear sculpted mark. + return { + surface: 'oklch(26% 0 0 / 0.94)', + hairline: 'oklch(42% 0 0 / 0.5)', + text: 'oklch(96% 0 0)', + textDim: 'oklch(72% 0 0)', + accent: 'oklch(72% 0.22 350)', + accentSoft: 'oklch(72% 0.22 350 / 0.22)', + mark: 'oklch(8% 0 0)', + markText: 'oklch(96% 0 0)', + exitHover: 'oklch(36% 0 0 / 0.6)', + }; + } + + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). + function brandMarkSvg(fill, ink, size = 18) { + return ``; + } + function initGlobalBar() { + const theme = detectPageTheme(); + const P = barPaletteForTheme(theme); + globalBarEl = el('div', { position: 'fixed', bottom: '14px', left: '50%', transform: 'translateX(-50%) translateY(20px)', zIndex: Z.bar + 5, - display: 'flex', alignItems: 'center', - padding: '4px 5px', gap: '2px', - background: C.paper, + display: 'flex', alignItems: 'stretch', + gap: '2px', + background: P.surface, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - border: '1px solid ' + C.mist, + border: '1px solid ' + P.hairline, borderRadius: '10px', - boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', - fontFamily: FONT, fontSize: '12px', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', + fontFamily: FONT, fontSize: '12px', lineHeight: '1', opacity: '0', + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, }); globalBarEl.id = PREFIX + '-global-bar'; + globalBarEl.dataset.theme = theme; - // Brand + // Brand mark — fills bar height on the left. Left side inherits the bar's + // rounded corner via overflow:hidden; right side is a clean hard edge since + // the near-black/charcoal contrast does the shape-defining work. const brand = el('span', { - fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em', - color: C.brand, fontFamily: MONO, - padding: '0 6px 0 4px', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + padding: '0 12px 0 14px', + background: P.mark, + color: P.markText, + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: '500', + fontSize: '18px', lineHeight: '1', }); - brand.textContent = 'Impeccable'; + brand.textContent = '/'; + brand.title = 'Impeccable'; globalBarEl.appendChild(brand); - // Detect toggle: eye icon + label + integrated badge - const detectBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: 'transparent', - color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // Inner wrapper: holds the toggles with normal bar padding. + const inner = el('div', { + display: 'flex', alignItems: 'center', + padding: '4px 5px', gap: '2px', + }); + inner.id = PREFIX + '-global-bar-inner'; + globalBarEl.appendChild(inner); + + // --- button factory: icon-only at rest, label slides in on hover/active --- + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { + const b = el('button', { + position: 'relative', + display: 'inline-flex', alignItems: 'center', + padding: '6px 8px', borderRadius: '7px', + border: 'none', background: 'transparent', + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease', + whiteSpace: 'nowrap', overflow: 'hidden', + }); + b.id = id; + b.title = ariaLabel || label || ''; + b.setAttribute('aria-label', ariaLabel || label || ''); + b.innerHTML = svg + (label + ? `${label}` + : ''); + const labelEl = b.querySelector('.icon-btn-label'); + const expand = () => { + if (!labelEl) return; + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; + }; + const collapse = () => { + if (!labelEl || b.dataset.active === 'true') return; + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; + }; + // Per-button hover only changes color (no layout). The label expand/ + // collapse is driven by the bar-level mouseenter/mouseleave so moving + // the mouse between adjacent buttons doesn't trigger per-button width + // thrashing — the whole bar grows once and shrinks once. + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); + b.addEventListener('click', onClick); + b._expandLabel = expand; + b._collapseLabel = collapse; + return b; + } + + // Pick toggle — starts active (primary intent when entering live mode). + const pickBtn = makeIconBtn({ + id: PREFIX + '-pick-toggle', + svg: '', + label: 'Pick', + ariaLabel: 'Pick element', + onClick: () => togglePick(), + }); + pickBtn.style.background = P.accentSoft; + pickBtn.style.color = P.accent; + pickBtn.dataset.active = 'true'; + pickBtn._expandLabel(); + inner.appendChild(pickBtn); + + // Detect toggle + const detectBtn = makeIconBtn({ + id: PREFIX + '-detect-toggle', + svg: '', + label: 'Detect', + ariaLabel: 'Detect anti-patterns', + onClick: () => toggleDetect(), }); - detectBtn.id = PREFIX + '-detect-toggle'; - // Eye icon (SVG) - detectBtn.innerHTML = ''; - const detectLabel = el('span', {}); - detectLabel.textContent = 'Detect'; - detectBtn.appendChild(detectLabel); - // Badge is inside the button const detectBadge = el('span', { fontSize: '10px', fontWeight: '600', padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', - background: C.brand, color: C.white, - display: 'none', fontFamily: MONO, + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', + display: 'none', fontFamily: MONO, marginLeft: '4px', }); detectBadge.id = PREFIX + '-detect-badge'; detectBtn.appendChild(detectBadge); - detectBtn.addEventListener('click', () => toggleDetect()); - detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; }); - detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; }); - globalBarEl.appendChild(detectBtn); + inner.appendChild(detectBtn); - // Pick toggle: crosshair icon + label - const pickBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: C.brandSoft, - color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // DESIGN.md panel toggle — quartet of color squares as the mark. + const designBtn = makeIconBtn({ + id: PREFIX + '-design-toggle', + svg: ` + + + + + `, + label: 'DESIGN.md', + ariaLabel: 'Toggle DESIGN.md panel', + labelFont: MONO, + onClick: () => toggleDesignPanel(), }); - pickBtn.id = PREFIX + '-pick-toggle'; - pickBtn.innerHTML = ''; - const pickLabel = el('span', {}); - pickLabel.textContent = 'Pick'; - pickBtn.appendChild(pickLabel); - pickBtn.addEventListener('click', () => togglePick()); - globalBarEl.appendChild(pickBtn); + inner.appendChild(designBtn); - // Exit (subtle × on the right) + // Thin divider before the exit button + const divider = el('span', { + width: '1px', height: '18px', + background: P.hairline, + margin: '0 4px 0 2px', + }); + inner.appendChild(divider); + + // Exit (subtle × on the right) — SVG for baseline-free centering const exitBtn = el('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: '24px', height: '24px', borderRadius: '6px', + width: '26px', height: '26px', borderRadius: '6px', border: 'none', background: 'transparent', - color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1', + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', - marginLeft: '2px', }); - exitBtn.textContent = '\u00D7'; + exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; - exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; }); - exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; }); + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); - globalBarEl.appendChild(exitBtn); + inner.appendChild(exitBtn); + + // Bar-level hover: expand every toggle's label at once; collapse on leave. + // Buttons with dataset.active="true" ignore collapse (their label stays). + const toggles = [pickBtn, detectBtn, designBtn]; + globalBarEl.addEventListener('mouseenter', () => { + toggles.forEach((t) => t._expandLabel && t._expandLabel()); + }); + globalBarEl.addEventListener('mouseleave', () => { + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); + }); document.body.appendChild(globalBarEl); @@ -1395,19 +1530,34 @@ const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); const detectBadge = document.getElementById(PREFIX + '-detect-badge'); const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); + const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const theme = globalBarEl?.dataset.theme || 'light'; + const P = barPaletteForTheme(theme); - if (detectToggle) { - detectToggle.style.background = detectActive ? C.brandSoft : 'transparent'; - detectToggle.style.color = detectActive ? C.brand : C.ash; + // Sync one toggle's active state, colors, and slide-label visibility. + function sync(btn, active) { + if (!btn) return; + btn.style.background = active ? P.accentSoft : 'transparent'; + btn.style.color = active ? P.accent : P.textDim; + btn.dataset.active = active ? 'true' : 'false'; + if (active && btn._expandLabel) btn._expandLabel(); + else if (!active && btn._collapseLabel) btn._collapseLabel(); } + sync(pickToggle, pickActive); + sync(detectToggle, detectActive); + sync(designToggle, designState.open); + + // If the bar is currently under the cursor, keep all labels expanded — + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) + // would collapse its label while the user's mouse is still on the bar. + if (globalBarEl && globalBarEl.matches(':hover')) { + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); + } + if (detectBadge) { detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; detectBadge.textContent = detectCount; } - if (pickToggle) { - pickToggle.style.background = pickActive ? C.brandSoft : 'transparent'; - pickToggle.style.color = pickActive ? C.brand : C.ash; - } // When pick is active, make detect overlays click-through so the picker works document.querySelectorAll('.impeccable-overlay').forEach(o => { @@ -1443,8 +1593,14 @@ updateGlobalBarState(); if (!pickActive) { + // Disabling pick clears any in-flight selection and UI: highlight, + // contextual bar, selectedElement. Otherwise a stale selection sits + // on screen with no obvious way to dismiss. hideHighlight(); - if (state === 'PICKING') state = 'IDLE'; + hideBar(); + hideActionPicker(); + selectedElement = null; + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; } else { if (state === 'IDLE') state = 'PICKING'; } @@ -1500,6 +1656,1057 @@ 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() { + // `open` is intentionally NOT persisted — the panel always starts closed + // so live mode doesn't auto-slide a big panel over the page on startup. + try { + const raw = localStorage.getItem(DESIGN_PREFS_KEY); + if (!raw) return; + const prefs = JSON.parse(raw); + 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({ + 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'); + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. + const theme = detectPageTheme(); + style.textContent = designPanelCss(barPaletteForTheme(theme)); + 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(BP) { + // BP = bar palette (theme-aware, matches the global bar). + // DP = internal content palette (neutral, so tiles render colors true). + 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; } + + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + .panel { + position: fixed; top: 12px; bottom: 72px; right: 12px; + width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); + background: ${BP.surface}; + border: 1px solid ${BP.hairline}; + border-radius: 14px; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); + 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: 10px 10px 10px 14px; + background: transparent; + border-bottom: 1px solid ${BP.hairline}; + } + .panel-title { + flex: 1; min-width: 0; + font-family: ${MONO}; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.02em; + color: ${BP.text}; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .panel-close { + border: none; background: transparent; color: ${BP.textDim}; + width: 26px; height: 26px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; transition: background 0.15s ease, color 0.15s ease; + } + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } + + .tabs { + display: inline-flex; padding: 2px; + background: ${BP.hairline}; + border-radius: 7px; + gap: 2px; + } + .tab { + border: none; background: transparent; + padding: 4px 10px; border-radius: 5px; + font-family: ${MONO}; + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; + color: ${BP.textDim}; cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } + + .panel-body { + flex: 1; overflow-y: auto; + padding: 12px 12px 20px; + background: ${DP.canvas}; + 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 = ''; + + // (Panel toggle lives in the global bar — no floating 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 = 'DESIGN.md'; + 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; + renderDesignChrome(); + updateGlobalBarState(); + 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 +2716,7 @@ initBar(); initActionPicker(); initGlobalBar(); + initDesignPanel(); document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); diff --git a/.pi/skills/impeccable/scripts/live-poll.mjs b/.pi/skills/impeccable/scripts/live-poll.mjs index b176d9f05..3c8088c23 100644 --- a/.pi/skills/impeccable/scripts/live-poll.mjs +++ b/.pi/skills/impeccable/scripts/live-poll.mjs @@ -3,7 +3,7 @@ * * Usage: * npx impeccable poll # Block until browser event, print JSON - * npx impeccable poll --timeout=60000 # Custom timeout (ms) + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly * npx impeccable poll --reply done # Reply "done" to event * npx impeccable poll --reply error "msg" # Reply with error */ @@ -13,6 +13,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { Agent, setGlobalDispatcher } from 'undici'; + +// Disable undici's default 300s headersTimeout so long-polls can sit open +// indefinitely (until a browser event or the server's own timeout fires). +// Without this, fetch() throws a bare "fetch failed" at 5 minutes even +// though the server would have happily kept the connection alive. +setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 })); const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -39,7 +46,7 @@ Modes: poll --reply error "msg" Reply with an error message Options: - --timeout=MS Poll timeout in milliseconds (default: 120000) + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn --help Show this help message`); process.exit(0); } @@ -93,7 +100,9 @@ Options: return; } - // Poll mode: block until browser event + // Poll mode: block until browser event. Default 10 min; undici's default + // 5-min headers-timeout is disabled at import time so this can sit open + // indefinitely without fetch errors. const timeoutArg = args.find(a => a.startsWith('--timeout=')); const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; diff --git a/.pi/skills/impeccable/scripts/live.mjs b/.pi/skills/impeccable/scripts/live.mjs index 37b6f8997..062b35ae8 100644 --- a/.pi/skills/impeccable/scripts/live.mjs +++ b/.pi/skills/impeccable/scripts/live.mjs @@ -9,7 +9,7 @@ * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: - * - Navigate the browser to the page (optional, if browser automation is available) + * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll * - Enter the poll loop: `node live-poll.mjs` * * Usage: @@ -46,7 +46,7 @@ On config_missing, prints: The agent should then: 1. If config_missing, create the config and re-run this script - 2. Optionally navigate the browser to the page + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 3. Enter the poll loop: node live-poll.mjs`); process.exit(0); } diff --git a/.rovodev/skills/impeccable/reference/document.md b/.rovodev/skills/impeccable/reference/document.md index b59b8ac8b..37a7f0980 100644 --- a/.rovodev/skills/impeccable/reference/document.md +++ b/.rovodev/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/.rovodev/skills/impeccable/scripts/design-parser.mjs b/.rovodev/skills/impeccable/scripts/design-parser.mjs new file mode 100644 index 000000000..6220f67e7 --- /dev/null +++ b/.rovodev/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/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js index 59e92d239..b9332e10d 100644 --- a/.rovodev/skills/impeccable/scripts/live-browser.js +++ b/.rovodev/skills/impeccable/scripts/live-browser.js @@ -891,7 +891,7 @@ switch (msg.type) { case 'connected': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); console.log('[impeccable] Live mode connected.'); if (state === 'IDLE') state = 'PICKING'; break; @@ -1293,92 +1293,227 @@ let detectCount = 0; let detectScriptLoaded = false; + // Theme-aware color palette for the global bar. We detect the page's + // ambient background and invert — dark bar on light pages, light bar on + // dark pages. This keeps the bar from fighting with the host design. + function detectPageTheme() { + try { + const bg = getComputedStyle(document.body).backgroundColor + || getComputedStyle(document.documentElement).backgroundColor; + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return 'light'; + const [, r, g, b] = m; + // Perceptual luminance (Rec. 709) + const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255; + return L > 0.55 ? 'light' : 'dark'; + } catch { return 'light'; } + } + + function barPaletteForTheme(theme) { + if (theme === 'dark') { + // Light bar on dark page + return { + surface: 'oklch(98% 0 0 / 0.92)', + hairline: 'oklch(70% 0 0 / 0.35)', + text: 'oklch(15% 0 0)', + textDim: 'oklch(45% 0 0)', + accent: 'oklch(60% 0.25 350)', + accentSoft: 'oklch(60% 0.25 350 / 0.18)', + mark: 'oklch(98% 0 0)', // logo mark fill + markText: 'oklch(15% 0 0)', // logo "/" color + exitHover: 'oklch(85% 0 0 / 0.5)', + }; + } + // Dark bar on light page. Bar is a warm charcoal, logo slab is much + // deeper so the rounded-right shape reads as a clear sculpted mark. + return { + surface: 'oklch(26% 0 0 / 0.94)', + hairline: 'oklch(42% 0 0 / 0.5)', + text: 'oklch(96% 0 0)', + textDim: 'oklch(72% 0 0)', + accent: 'oklch(72% 0.22 350)', + accentSoft: 'oklch(72% 0.22 350 / 0.22)', + mark: 'oklch(8% 0 0)', + markText: 'oklch(96% 0 0)', + exitHover: 'oklch(36% 0 0 / 0.6)', + }; + } + + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). + function brandMarkSvg(fill, ink, size = 18) { + return ``; + } + function initGlobalBar() { + const theme = detectPageTheme(); + const P = barPaletteForTheme(theme); + globalBarEl = el('div', { position: 'fixed', bottom: '14px', left: '50%', transform: 'translateX(-50%) translateY(20px)', zIndex: Z.bar + 5, - display: 'flex', alignItems: 'center', - padding: '4px 5px', gap: '2px', - background: C.paper, + display: 'flex', alignItems: 'stretch', + gap: '2px', + background: P.surface, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - border: '1px solid ' + C.mist, + border: '1px solid ' + P.hairline, borderRadius: '10px', - boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', - fontFamily: FONT, fontSize: '12px', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', + fontFamily: FONT, fontSize: '12px', lineHeight: '1', opacity: '0', + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, }); globalBarEl.id = PREFIX + '-global-bar'; + globalBarEl.dataset.theme = theme; - // Brand + // Brand mark — fills bar height on the left. Left side inherits the bar's + // rounded corner via overflow:hidden; right side is a clean hard edge since + // the near-black/charcoal contrast does the shape-defining work. const brand = el('span', { - fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em', - color: C.brand, fontFamily: MONO, - padding: '0 6px 0 4px', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + padding: '0 12px 0 14px', + background: P.mark, + color: P.markText, + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: '500', + fontSize: '18px', lineHeight: '1', }); - brand.textContent = 'Impeccable'; + brand.textContent = '/'; + brand.title = 'Impeccable'; globalBarEl.appendChild(brand); - // Detect toggle: eye icon + label + integrated badge - const detectBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: 'transparent', - color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // Inner wrapper: holds the toggles with normal bar padding. + const inner = el('div', { + display: 'flex', alignItems: 'center', + padding: '4px 5px', gap: '2px', + }); + inner.id = PREFIX + '-global-bar-inner'; + globalBarEl.appendChild(inner); + + // --- button factory: icon-only at rest, label slides in on hover/active --- + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { + const b = el('button', { + position: 'relative', + display: 'inline-flex', alignItems: 'center', + padding: '6px 8px', borderRadius: '7px', + border: 'none', background: 'transparent', + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease', + whiteSpace: 'nowrap', overflow: 'hidden', + }); + b.id = id; + b.title = ariaLabel || label || ''; + b.setAttribute('aria-label', ariaLabel || label || ''); + b.innerHTML = svg + (label + ? `${label}` + : ''); + const labelEl = b.querySelector('.icon-btn-label'); + const expand = () => { + if (!labelEl) return; + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; + }; + const collapse = () => { + if (!labelEl || b.dataset.active === 'true') return; + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; + }; + // Per-button hover only changes color (no layout). The label expand/ + // collapse is driven by the bar-level mouseenter/mouseleave so moving + // the mouse between adjacent buttons doesn't trigger per-button width + // thrashing — the whole bar grows once and shrinks once. + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); + b.addEventListener('click', onClick); + b._expandLabel = expand; + b._collapseLabel = collapse; + return b; + } + + // Pick toggle — starts active (primary intent when entering live mode). + const pickBtn = makeIconBtn({ + id: PREFIX + '-pick-toggle', + svg: '', + label: 'Pick', + ariaLabel: 'Pick element', + onClick: () => togglePick(), + }); + pickBtn.style.background = P.accentSoft; + pickBtn.style.color = P.accent; + pickBtn.dataset.active = 'true'; + pickBtn._expandLabel(); + inner.appendChild(pickBtn); + + // Detect toggle + const detectBtn = makeIconBtn({ + id: PREFIX + '-detect-toggle', + svg: '', + label: 'Detect', + ariaLabel: 'Detect anti-patterns', + onClick: () => toggleDetect(), }); - detectBtn.id = PREFIX + '-detect-toggle'; - // Eye icon (SVG) - detectBtn.innerHTML = ''; - const detectLabel = el('span', {}); - detectLabel.textContent = 'Detect'; - detectBtn.appendChild(detectLabel); - // Badge is inside the button const detectBadge = el('span', { fontSize: '10px', fontWeight: '600', padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', - background: C.brand, color: C.white, - display: 'none', fontFamily: MONO, + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', + display: 'none', fontFamily: MONO, marginLeft: '4px', }); detectBadge.id = PREFIX + '-detect-badge'; detectBtn.appendChild(detectBadge); - detectBtn.addEventListener('click', () => toggleDetect()); - detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; }); - detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; }); - globalBarEl.appendChild(detectBtn); + inner.appendChild(detectBtn); - // Pick toggle: crosshair icon + label - const pickBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: C.brandSoft, - color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // DESIGN.md panel toggle — quartet of color squares as the mark. + const designBtn = makeIconBtn({ + id: PREFIX + '-design-toggle', + svg: ` + + + + + `, + label: 'DESIGN.md', + ariaLabel: 'Toggle DESIGN.md panel', + labelFont: MONO, + onClick: () => toggleDesignPanel(), }); - pickBtn.id = PREFIX + '-pick-toggle'; - pickBtn.innerHTML = ''; - const pickLabel = el('span', {}); - pickLabel.textContent = 'Pick'; - pickBtn.appendChild(pickLabel); - pickBtn.addEventListener('click', () => togglePick()); - globalBarEl.appendChild(pickBtn); + inner.appendChild(designBtn); - // Exit (subtle × on the right) + // Thin divider before the exit button + const divider = el('span', { + width: '1px', height: '18px', + background: P.hairline, + margin: '0 4px 0 2px', + }); + inner.appendChild(divider); + + // Exit (subtle × on the right) — SVG for baseline-free centering const exitBtn = el('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: '24px', height: '24px', borderRadius: '6px', + width: '26px', height: '26px', borderRadius: '6px', border: 'none', background: 'transparent', - color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1', + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', - marginLeft: '2px', }); - exitBtn.textContent = '\u00D7'; + exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; - exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; }); - exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; }); + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); - globalBarEl.appendChild(exitBtn); + inner.appendChild(exitBtn); + + // Bar-level hover: expand every toggle's label at once; collapse on leave. + // Buttons with dataset.active="true" ignore collapse (their label stays). + const toggles = [pickBtn, detectBtn, designBtn]; + globalBarEl.addEventListener('mouseenter', () => { + toggles.forEach((t) => t._expandLabel && t._expandLabel()); + }); + globalBarEl.addEventListener('mouseleave', () => { + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); + }); document.body.appendChild(globalBarEl); @@ -1395,19 +1530,34 @@ const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); const detectBadge = document.getElementById(PREFIX + '-detect-badge'); const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); + const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const theme = globalBarEl?.dataset.theme || 'light'; + const P = barPaletteForTheme(theme); - if (detectToggle) { - detectToggle.style.background = detectActive ? C.brandSoft : 'transparent'; - detectToggle.style.color = detectActive ? C.brand : C.ash; + // Sync one toggle's active state, colors, and slide-label visibility. + function sync(btn, active) { + if (!btn) return; + btn.style.background = active ? P.accentSoft : 'transparent'; + btn.style.color = active ? P.accent : P.textDim; + btn.dataset.active = active ? 'true' : 'false'; + if (active && btn._expandLabel) btn._expandLabel(); + else if (!active && btn._collapseLabel) btn._collapseLabel(); } + sync(pickToggle, pickActive); + sync(detectToggle, detectActive); + sync(designToggle, designState.open); + + // If the bar is currently under the cursor, keep all labels expanded — + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) + // would collapse its label while the user's mouse is still on the bar. + if (globalBarEl && globalBarEl.matches(':hover')) { + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); + } + if (detectBadge) { detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; detectBadge.textContent = detectCount; } - if (pickToggle) { - pickToggle.style.background = pickActive ? C.brandSoft : 'transparent'; - pickToggle.style.color = pickActive ? C.brand : C.ash; - } // When pick is active, make detect overlays click-through so the picker works document.querySelectorAll('.impeccable-overlay').forEach(o => { @@ -1443,8 +1593,14 @@ updateGlobalBarState(); if (!pickActive) { + // Disabling pick clears any in-flight selection and UI: highlight, + // contextual bar, selectedElement. Otherwise a stale selection sits + // on screen with no obvious way to dismiss. hideHighlight(); - if (state === 'PICKING') state = 'IDLE'; + hideBar(); + hideActionPicker(); + selectedElement = null; + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; } else { if (state === 'IDLE') state = 'PICKING'; } @@ -1500,6 +1656,1057 @@ 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() { + // `open` is intentionally NOT persisted — the panel always starts closed + // so live mode doesn't auto-slide a big panel over the page on startup. + try { + const raw = localStorage.getItem(DESIGN_PREFS_KEY); + if (!raw) return; + const prefs = JSON.parse(raw); + 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({ + 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'); + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. + const theme = detectPageTheme(); + style.textContent = designPanelCss(barPaletteForTheme(theme)); + 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(BP) { + // BP = bar palette (theme-aware, matches the global bar). + // DP = internal content palette (neutral, so tiles render colors true). + 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; } + + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + .panel { + position: fixed; top: 12px; bottom: 72px; right: 12px; + width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); + background: ${BP.surface}; + border: 1px solid ${BP.hairline}; + border-radius: 14px; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); + 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: 10px 10px 10px 14px; + background: transparent; + border-bottom: 1px solid ${BP.hairline}; + } + .panel-title { + flex: 1; min-width: 0; + font-family: ${MONO}; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.02em; + color: ${BP.text}; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .panel-close { + border: none; background: transparent; color: ${BP.textDim}; + width: 26px; height: 26px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; transition: background 0.15s ease, color 0.15s ease; + } + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } + + .tabs { + display: inline-flex; padding: 2px; + background: ${BP.hairline}; + border-radius: 7px; + gap: 2px; + } + .tab { + border: none; background: transparent; + padding: 4px 10px; border-radius: 5px; + font-family: ${MONO}; + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; + color: ${BP.textDim}; cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } + + .panel-body { + flex: 1; overflow-y: auto; + padding: 12px 12px 20px; + background: ${DP.canvas}; + 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 = ''; + + // (Panel toggle lives in the global bar — no floating 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 = 'DESIGN.md'; + 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; + renderDesignChrome(); + updateGlobalBarState(); + 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 +2716,7 @@ initBar(); initActionPicker(); initGlobalBar(); + initDesignPanel(); document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); diff --git a/.rovodev/skills/impeccable/scripts/live-poll.mjs b/.rovodev/skills/impeccable/scripts/live-poll.mjs index b176d9f05..3c8088c23 100644 --- a/.rovodev/skills/impeccable/scripts/live-poll.mjs +++ b/.rovodev/skills/impeccable/scripts/live-poll.mjs @@ -3,7 +3,7 @@ * * Usage: * npx impeccable poll # Block until browser event, print JSON - * npx impeccable poll --timeout=60000 # Custom timeout (ms) + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly * npx impeccable poll --reply done # Reply "done" to event * npx impeccable poll --reply error "msg" # Reply with error */ @@ -13,6 +13,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { Agent, setGlobalDispatcher } from 'undici'; + +// Disable undici's default 300s headersTimeout so long-polls can sit open +// indefinitely (until a browser event or the server's own timeout fires). +// Without this, fetch() throws a bare "fetch failed" at 5 minutes even +// though the server would have happily kept the connection alive. +setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 })); const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -39,7 +46,7 @@ Modes: poll --reply error "msg" Reply with an error message Options: - --timeout=MS Poll timeout in milliseconds (default: 120000) + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn --help Show this help message`); process.exit(0); } @@ -93,7 +100,9 @@ Options: return; } - // Poll mode: block until browser event + // Poll mode: block until browser event. Default 10 min; undici's default + // 5-min headers-timeout is disabled at import time so this can sit open + // indefinitely without fetch errors. const timeoutArg = args.find(a => a.startsWith('--timeout=')); const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; diff --git a/.rovodev/skills/impeccable/scripts/live.mjs b/.rovodev/skills/impeccable/scripts/live.mjs index 37b6f8997..062b35ae8 100644 --- a/.rovodev/skills/impeccable/scripts/live.mjs +++ b/.rovodev/skills/impeccable/scripts/live.mjs @@ -9,7 +9,7 @@ * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: - * - Navigate the browser to the page (optional, if browser automation is available) + * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll * - Enter the poll loop: `node live-poll.mjs` * * Usage: @@ -46,7 +46,7 @@ On config_missing, prints: The agent should then: 1. If config_missing, create the config and re-run this script - 2. Optionally navigate the browser to the page + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 3. Enter the poll loop: node live-poll.mjs`); process.exit(0); } diff --git a/.trae-cn/skills/impeccable/reference/document.md b/.trae-cn/skills/impeccable/reference/document.md index b59b8ac8b..37a7f0980 100644 --- a/.trae-cn/skills/impeccable/reference/document.md +++ b/.trae-cn/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/.trae-cn/skills/impeccable/scripts/design-parser.mjs b/.trae-cn/skills/impeccable/scripts/design-parser.mjs new file mode 100644 index 000000000..6220f67e7 --- /dev/null +++ b/.trae-cn/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/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js index 59e92d239..b9332e10d 100644 --- a/.trae-cn/skills/impeccable/scripts/live-browser.js +++ b/.trae-cn/skills/impeccable/scripts/live-browser.js @@ -891,7 +891,7 @@ switch (msg.type) { case 'connected': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); console.log('[impeccable] Live mode connected.'); if (state === 'IDLE') state = 'PICKING'; break; @@ -1293,92 +1293,227 @@ let detectCount = 0; let detectScriptLoaded = false; + // Theme-aware color palette for the global bar. We detect the page's + // ambient background and invert — dark bar on light pages, light bar on + // dark pages. This keeps the bar from fighting with the host design. + function detectPageTheme() { + try { + const bg = getComputedStyle(document.body).backgroundColor + || getComputedStyle(document.documentElement).backgroundColor; + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return 'light'; + const [, r, g, b] = m; + // Perceptual luminance (Rec. 709) + const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255; + return L > 0.55 ? 'light' : 'dark'; + } catch { return 'light'; } + } + + function barPaletteForTheme(theme) { + if (theme === 'dark') { + // Light bar on dark page + return { + surface: 'oklch(98% 0 0 / 0.92)', + hairline: 'oklch(70% 0 0 / 0.35)', + text: 'oklch(15% 0 0)', + textDim: 'oklch(45% 0 0)', + accent: 'oklch(60% 0.25 350)', + accentSoft: 'oklch(60% 0.25 350 / 0.18)', + mark: 'oklch(98% 0 0)', // logo mark fill + markText: 'oklch(15% 0 0)', // logo "/" color + exitHover: 'oklch(85% 0 0 / 0.5)', + }; + } + // Dark bar on light page. Bar is a warm charcoal, logo slab is much + // deeper so the rounded-right shape reads as a clear sculpted mark. + return { + surface: 'oklch(26% 0 0 / 0.94)', + hairline: 'oklch(42% 0 0 / 0.5)', + text: 'oklch(96% 0 0)', + textDim: 'oklch(72% 0 0)', + accent: 'oklch(72% 0.22 350)', + accentSoft: 'oklch(72% 0.22 350 / 0.22)', + mark: 'oklch(8% 0 0)', + markText: 'oklch(96% 0 0)', + exitHover: 'oklch(36% 0 0 / 0.6)', + }; + } + + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). + function brandMarkSvg(fill, ink, size = 18) { + return ``; + } + function initGlobalBar() { + const theme = detectPageTheme(); + const P = barPaletteForTheme(theme); + globalBarEl = el('div', { position: 'fixed', bottom: '14px', left: '50%', transform: 'translateX(-50%) translateY(20px)', zIndex: Z.bar + 5, - display: 'flex', alignItems: 'center', - padding: '4px 5px', gap: '2px', - background: C.paper, + display: 'flex', alignItems: 'stretch', + gap: '2px', + background: P.surface, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - border: '1px solid ' + C.mist, + border: '1px solid ' + P.hairline, borderRadius: '10px', - boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', - fontFamily: FONT, fontSize: '12px', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', + fontFamily: FONT, fontSize: '12px', lineHeight: '1', opacity: '0', + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, }); globalBarEl.id = PREFIX + '-global-bar'; + globalBarEl.dataset.theme = theme; - // Brand + // Brand mark — fills bar height on the left. Left side inherits the bar's + // rounded corner via overflow:hidden; right side is a clean hard edge since + // the near-black/charcoal contrast does the shape-defining work. const brand = el('span', { - fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em', - color: C.brand, fontFamily: MONO, - padding: '0 6px 0 4px', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + padding: '0 12px 0 14px', + background: P.mark, + color: P.markText, + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: '500', + fontSize: '18px', lineHeight: '1', }); - brand.textContent = 'Impeccable'; + brand.textContent = '/'; + brand.title = 'Impeccable'; globalBarEl.appendChild(brand); - // Detect toggle: eye icon + label + integrated badge - const detectBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: 'transparent', - color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // Inner wrapper: holds the toggles with normal bar padding. + const inner = el('div', { + display: 'flex', alignItems: 'center', + padding: '4px 5px', gap: '2px', + }); + inner.id = PREFIX + '-global-bar-inner'; + globalBarEl.appendChild(inner); + + // --- button factory: icon-only at rest, label slides in on hover/active --- + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { + const b = el('button', { + position: 'relative', + display: 'inline-flex', alignItems: 'center', + padding: '6px 8px', borderRadius: '7px', + border: 'none', background: 'transparent', + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease', + whiteSpace: 'nowrap', overflow: 'hidden', + }); + b.id = id; + b.title = ariaLabel || label || ''; + b.setAttribute('aria-label', ariaLabel || label || ''); + b.innerHTML = svg + (label + ? `${label}` + : ''); + const labelEl = b.querySelector('.icon-btn-label'); + const expand = () => { + if (!labelEl) return; + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; + }; + const collapse = () => { + if (!labelEl || b.dataset.active === 'true') return; + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; + }; + // Per-button hover only changes color (no layout). The label expand/ + // collapse is driven by the bar-level mouseenter/mouseleave so moving + // the mouse between adjacent buttons doesn't trigger per-button width + // thrashing — the whole bar grows once and shrinks once. + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); + b.addEventListener('click', onClick); + b._expandLabel = expand; + b._collapseLabel = collapse; + return b; + } + + // Pick toggle — starts active (primary intent when entering live mode). + const pickBtn = makeIconBtn({ + id: PREFIX + '-pick-toggle', + svg: '', + label: 'Pick', + ariaLabel: 'Pick element', + onClick: () => togglePick(), + }); + pickBtn.style.background = P.accentSoft; + pickBtn.style.color = P.accent; + pickBtn.dataset.active = 'true'; + pickBtn._expandLabel(); + inner.appendChild(pickBtn); + + // Detect toggle + const detectBtn = makeIconBtn({ + id: PREFIX + '-detect-toggle', + svg: '', + label: 'Detect', + ariaLabel: 'Detect anti-patterns', + onClick: () => toggleDetect(), }); - detectBtn.id = PREFIX + '-detect-toggle'; - // Eye icon (SVG) - detectBtn.innerHTML = ''; - const detectLabel = el('span', {}); - detectLabel.textContent = 'Detect'; - detectBtn.appendChild(detectLabel); - // Badge is inside the button const detectBadge = el('span', { fontSize: '10px', fontWeight: '600', padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', - background: C.brand, color: C.white, - display: 'none', fontFamily: MONO, + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', + display: 'none', fontFamily: MONO, marginLeft: '4px', }); detectBadge.id = PREFIX + '-detect-badge'; detectBtn.appendChild(detectBadge); - detectBtn.addEventListener('click', () => toggleDetect()); - detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; }); - detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; }); - globalBarEl.appendChild(detectBtn); + inner.appendChild(detectBtn); - // Pick toggle: crosshair icon + label - const pickBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: C.brandSoft, - color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // DESIGN.md panel toggle — quartet of color squares as the mark. + const designBtn = makeIconBtn({ + id: PREFIX + '-design-toggle', + svg: ` + + + + + `, + label: 'DESIGN.md', + ariaLabel: 'Toggle DESIGN.md panel', + labelFont: MONO, + onClick: () => toggleDesignPanel(), }); - pickBtn.id = PREFIX + '-pick-toggle'; - pickBtn.innerHTML = ''; - const pickLabel = el('span', {}); - pickLabel.textContent = 'Pick'; - pickBtn.appendChild(pickLabel); - pickBtn.addEventListener('click', () => togglePick()); - globalBarEl.appendChild(pickBtn); + inner.appendChild(designBtn); - // Exit (subtle × on the right) + // Thin divider before the exit button + const divider = el('span', { + width: '1px', height: '18px', + background: P.hairline, + margin: '0 4px 0 2px', + }); + inner.appendChild(divider); + + // Exit (subtle × on the right) — SVG for baseline-free centering const exitBtn = el('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: '24px', height: '24px', borderRadius: '6px', + width: '26px', height: '26px', borderRadius: '6px', border: 'none', background: 'transparent', - color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1', + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', - marginLeft: '2px', }); - exitBtn.textContent = '\u00D7'; + exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; - exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; }); - exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; }); + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); - globalBarEl.appendChild(exitBtn); + inner.appendChild(exitBtn); + + // Bar-level hover: expand every toggle's label at once; collapse on leave. + // Buttons with dataset.active="true" ignore collapse (their label stays). + const toggles = [pickBtn, detectBtn, designBtn]; + globalBarEl.addEventListener('mouseenter', () => { + toggles.forEach((t) => t._expandLabel && t._expandLabel()); + }); + globalBarEl.addEventListener('mouseleave', () => { + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); + }); document.body.appendChild(globalBarEl); @@ -1395,19 +1530,34 @@ const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); const detectBadge = document.getElementById(PREFIX + '-detect-badge'); const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); + const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const theme = globalBarEl?.dataset.theme || 'light'; + const P = barPaletteForTheme(theme); - if (detectToggle) { - detectToggle.style.background = detectActive ? C.brandSoft : 'transparent'; - detectToggle.style.color = detectActive ? C.brand : C.ash; + // Sync one toggle's active state, colors, and slide-label visibility. + function sync(btn, active) { + if (!btn) return; + btn.style.background = active ? P.accentSoft : 'transparent'; + btn.style.color = active ? P.accent : P.textDim; + btn.dataset.active = active ? 'true' : 'false'; + if (active && btn._expandLabel) btn._expandLabel(); + else if (!active && btn._collapseLabel) btn._collapseLabel(); } + sync(pickToggle, pickActive); + sync(detectToggle, detectActive); + sync(designToggle, designState.open); + + // If the bar is currently under the cursor, keep all labels expanded — + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) + // would collapse its label while the user's mouse is still on the bar. + if (globalBarEl && globalBarEl.matches(':hover')) { + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); + } + if (detectBadge) { detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; detectBadge.textContent = detectCount; } - if (pickToggle) { - pickToggle.style.background = pickActive ? C.brandSoft : 'transparent'; - pickToggle.style.color = pickActive ? C.brand : C.ash; - } // When pick is active, make detect overlays click-through so the picker works document.querySelectorAll('.impeccable-overlay').forEach(o => { @@ -1443,8 +1593,14 @@ updateGlobalBarState(); if (!pickActive) { + // Disabling pick clears any in-flight selection and UI: highlight, + // contextual bar, selectedElement. Otherwise a stale selection sits + // on screen with no obvious way to dismiss. hideHighlight(); - if (state === 'PICKING') state = 'IDLE'; + hideBar(); + hideActionPicker(); + selectedElement = null; + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; } else { if (state === 'IDLE') state = 'PICKING'; } @@ -1500,6 +1656,1057 @@ 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() { + // `open` is intentionally NOT persisted — the panel always starts closed + // so live mode doesn't auto-slide a big panel over the page on startup. + try { + const raw = localStorage.getItem(DESIGN_PREFS_KEY); + if (!raw) return; + const prefs = JSON.parse(raw); + 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({ + 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'); + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. + const theme = detectPageTheme(); + style.textContent = designPanelCss(barPaletteForTheme(theme)); + 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(BP) { + // BP = bar palette (theme-aware, matches the global bar). + // DP = internal content palette (neutral, so tiles render colors true). + 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; } + + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + .panel { + position: fixed; top: 12px; bottom: 72px; right: 12px; + width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); + background: ${BP.surface}; + border: 1px solid ${BP.hairline}; + border-radius: 14px; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); + 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: 10px 10px 10px 14px; + background: transparent; + border-bottom: 1px solid ${BP.hairline}; + } + .panel-title { + flex: 1; min-width: 0; + font-family: ${MONO}; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.02em; + color: ${BP.text}; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .panel-close { + border: none; background: transparent; color: ${BP.textDim}; + width: 26px; height: 26px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; transition: background 0.15s ease, color 0.15s ease; + } + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } + + .tabs { + display: inline-flex; padding: 2px; + background: ${BP.hairline}; + border-radius: 7px; + gap: 2px; + } + .tab { + border: none; background: transparent; + padding: 4px 10px; border-radius: 5px; + font-family: ${MONO}; + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; + color: ${BP.textDim}; cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } + + .panel-body { + flex: 1; overflow-y: auto; + padding: 12px 12px 20px; + background: ${DP.canvas}; + 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 = ''; + + // (Panel toggle lives in the global bar — no floating 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 = 'DESIGN.md'; + 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; + renderDesignChrome(); + updateGlobalBarState(); + 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 +2716,7 @@ initBar(); initActionPicker(); initGlobalBar(); + initDesignPanel(); document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); diff --git a/.trae-cn/skills/impeccable/scripts/live-poll.mjs b/.trae-cn/skills/impeccable/scripts/live-poll.mjs index b176d9f05..3c8088c23 100644 --- a/.trae-cn/skills/impeccable/scripts/live-poll.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-poll.mjs @@ -3,7 +3,7 @@ * * Usage: * npx impeccable poll # Block until browser event, print JSON - * npx impeccable poll --timeout=60000 # Custom timeout (ms) + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly * npx impeccable poll --reply done # Reply "done" to event * npx impeccable poll --reply error "msg" # Reply with error */ @@ -13,6 +13,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { Agent, setGlobalDispatcher } from 'undici'; + +// Disable undici's default 300s headersTimeout so long-polls can sit open +// indefinitely (until a browser event or the server's own timeout fires). +// Without this, fetch() throws a bare "fetch failed" at 5 minutes even +// though the server would have happily kept the connection alive. +setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 })); const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -39,7 +46,7 @@ Modes: poll --reply error "msg" Reply with an error message Options: - --timeout=MS Poll timeout in milliseconds (default: 120000) + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn --help Show this help message`); process.exit(0); } @@ -93,7 +100,9 @@ Options: return; } - // Poll mode: block until browser event + // Poll mode: block until browser event. Default 10 min; undici's default + // 5-min headers-timeout is disabled at import time so this can sit open + // indefinitely without fetch errors. const timeoutArg = args.find(a => a.startsWith('--timeout=')); const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; diff --git a/.trae-cn/skills/impeccable/scripts/live.mjs b/.trae-cn/skills/impeccable/scripts/live.mjs index 37b6f8997..062b35ae8 100644 --- a/.trae-cn/skills/impeccable/scripts/live.mjs +++ b/.trae-cn/skills/impeccable/scripts/live.mjs @@ -9,7 +9,7 @@ * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: - * - Navigate the browser to the page (optional, if browser automation is available) + * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll * - Enter the poll loop: `node live-poll.mjs` * * Usage: @@ -46,7 +46,7 @@ On config_missing, prints: The agent should then: 1. If config_missing, create the config and re-run this script - 2. Optionally navigate the browser to the page + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 3. Enter the poll loop: node live-poll.mjs`); process.exit(0); } diff --git a/.trae/skills/impeccable/reference/document.md b/.trae/skills/impeccable/reference/document.md index b59b8ac8b..37a7f0980 100644 --- a/.trae/skills/impeccable/reference/document.md +++ b/.trae/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/.trae/skills/impeccable/scripts/design-parser.mjs b/.trae/skills/impeccable/scripts/design-parser.mjs new file mode 100644 index 000000000..6220f67e7 --- /dev/null +++ b/.trae/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/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js index 59e92d239..b9332e10d 100644 --- a/.trae/skills/impeccable/scripts/live-browser.js +++ b/.trae/skills/impeccable/scripts/live-browser.js @@ -891,7 +891,7 @@ switch (msg.type) { case 'connected': hasProjectContext = !!msg.hasProjectContext; - if (!hasProjectContext) showToast('No .impeccable.md found. Variants will be brand-agnostic.', 6000); + if (!hasProjectContext) showToast('No PRODUCT.md found. Variants will be brand-agnostic. Run /impeccable teach to generate one.', 7000); console.log('[impeccable] Live mode connected.'); if (state === 'IDLE') state = 'PICKING'; break; @@ -1293,92 +1293,227 @@ let detectCount = 0; let detectScriptLoaded = false; + // Theme-aware color palette for the global bar. We detect the page's + // ambient background and invert — dark bar on light pages, light bar on + // dark pages. This keeps the bar from fighting with the host design. + function detectPageTheme() { + try { + const bg = getComputedStyle(document.body).backgroundColor + || getComputedStyle(document.documentElement).backgroundColor; + const m = bg.match(/rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/); + if (!m) return 'light'; + const [, r, g, b] = m; + // Perceptual luminance (Rec. 709) + const L = (0.2126 * +r + 0.7152 * +g + 0.0722 * +b) / 255; + return L > 0.55 ? 'light' : 'dark'; + } catch { return 'light'; } + } + + function barPaletteForTheme(theme) { + if (theme === 'dark') { + // Light bar on dark page + return { + surface: 'oklch(98% 0 0 / 0.92)', + hairline: 'oklch(70% 0 0 / 0.35)', + text: 'oklch(15% 0 0)', + textDim: 'oklch(45% 0 0)', + accent: 'oklch(60% 0.25 350)', + accentSoft: 'oklch(60% 0.25 350 / 0.18)', + mark: 'oklch(98% 0 0)', // logo mark fill + markText: 'oklch(15% 0 0)', // logo "/" color + exitHover: 'oklch(85% 0 0 / 0.5)', + }; + } + // Dark bar on light page. Bar is a warm charcoal, logo slab is much + // deeper so the rounded-right shape reads as a clear sculpted mark. + return { + surface: 'oklch(26% 0 0 / 0.94)', + hairline: 'oklch(42% 0 0 / 0.5)', + text: 'oklch(96% 0 0)', + textDim: 'oklch(72% 0 0)', + accent: 'oklch(72% 0.22 350)', + accentSoft: 'oklch(72% 0.22 350 / 0.22)', + mark: 'oklch(8% 0 0)', + markText: 'oklch(96% 0 0)', + exitHover: 'oklch(36% 0 0 / 0.6)', + }; + } + + // Impeccable logo mark — matches the site-header SVG (rounded square + "/"). + function brandMarkSvg(fill, ink, size = 18) { + return ``; + } + function initGlobalBar() { + const theme = detectPageTheme(); + const P = barPaletteForTheme(theme); + globalBarEl = el('div', { position: 'fixed', bottom: '14px', left: '50%', transform: 'translateX(-50%) translateY(20px)', zIndex: Z.bar + 5, - display: 'flex', alignItems: 'center', - padding: '4px 5px', gap: '2px', - background: C.paper, + display: 'flex', alignItems: 'stretch', + gap: '2px', + background: P.surface, backdropFilter: 'blur(16px)', WebkitBackdropFilter: 'blur(16px)', - border: '1px solid ' + C.mist, + border: '1px solid ' + P.hairline, borderRadius: '10px', - boxShadow: '0 4px 20px oklch(0% 0 0 / 0.08), 0 1px 3px oklch(0% 0 0 / 0.06)', - fontFamily: FONT, fontSize: '12px', + boxShadow: '0 4px 20px oklch(0% 0 0 / 0.12), 0 1px 3px oklch(0% 0 0 / 0.08)', + fontFamily: FONT, fontSize: '12px', lineHeight: '1', opacity: '0', + overflow: 'hidden', // clip the full-bleed brand mark to the bar radius transition: 'opacity 0.3s ' + EASE + ', transform 0.3s ' + EASE, }); globalBarEl.id = PREFIX + '-global-bar'; + globalBarEl.dataset.theme = theme; - // Brand + // Brand mark — fills bar height on the left. Left side inherits the bar's + // rounded corner via overflow:hidden; right side is a clean hard edge since + // the near-black/charcoal contrast does the shape-defining work. const brand = el('span', { - fontWeight: '600', fontSize: '11px', letterSpacing: '0.02em', - color: C.brand, fontFamily: MONO, - padding: '0 6px 0 4px', + display: 'inline-flex', alignItems: 'center', justifyContent: 'center', + alignSelf: 'stretch', + padding: '0 12px 0 14px', + background: P.mark, + color: P.markText, + fontFamily: 'system-ui, -apple-system, sans-serif', + fontWeight: '500', + fontSize: '18px', lineHeight: '1', }); - brand.textContent = 'Impeccable'; + brand.textContent = '/'; + brand.title = 'Impeccable'; globalBarEl.appendChild(brand); - // Detect toggle: eye icon + label + integrated badge - const detectBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: 'transparent', - color: C.ash, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // Inner wrapper: holds the toggles with normal bar padding. + const inner = el('div', { + display: 'flex', alignItems: 'center', + padding: '4px 5px', gap: '2px', + }); + inner.id = PREFIX + '-global-bar-inner'; + globalBarEl.appendChild(inner); + + // --- button factory: icon-only at rest, label slides in on hover/active --- + function makeIconBtn({ id, svg, label, ariaLabel, labelFont, onClick }) { + const b = el('button', { + position: 'relative', + display: 'inline-flex', alignItems: 'center', + padding: '6px 8px', borderRadius: '7px', + border: 'none', background: 'transparent', + color: P.textDim, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', + cursor: 'pointer', + transition: 'background 0.15s ease, color 0.15s ease', + whiteSpace: 'nowrap', overflow: 'hidden', + }); + b.id = id; + b.title = ariaLabel || label || ''; + b.setAttribute('aria-label', ariaLabel || label || ''); + b.innerHTML = svg + (label + ? `${label}` + : ''); + const labelEl = b.querySelector('.icon-btn-label'); + const expand = () => { + if (!labelEl) return; + labelEl.style.maxWidth = '120px'; labelEl.style.opacity = '1'; labelEl.style.marginLeft = '6px'; + }; + const collapse = () => { + if (!labelEl || b.dataset.active === 'true') return; + labelEl.style.maxWidth = '0'; labelEl.style.opacity = '0'; labelEl.style.marginLeft = '0'; + }; + // Per-button hover only changes color (no layout). The label expand/ + // collapse is driven by the bar-level mouseenter/mouseleave so moving + // the mouse between adjacent buttons doesn't trigger per-button width + // thrashing — the whole bar grows once and shrinks once. + b.addEventListener('mouseenter', () => { if (b.dataset.active !== 'true') b.style.color = P.text; }); + b.addEventListener('mouseleave', () => { if (b.dataset.active !== 'true') b.style.color = P.textDim; }); + b.addEventListener('click', onClick); + b._expandLabel = expand; + b._collapseLabel = collapse; + return b; + } + + // Pick toggle — starts active (primary intent when entering live mode). + const pickBtn = makeIconBtn({ + id: PREFIX + '-pick-toggle', + svg: '', + label: 'Pick', + ariaLabel: 'Pick element', + onClick: () => togglePick(), + }); + pickBtn.style.background = P.accentSoft; + pickBtn.style.color = P.accent; + pickBtn.dataset.active = 'true'; + pickBtn._expandLabel(); + inner.appendChild(pickBtn); + + // Detect toggle + const detectBtn = makeIconBtn({ + id: PREFIX + '-detect-toggle', + svg: '', + label: 'Detect', + ariaLabel: 'Detect anti-patterns', + onClick: () => toggleDetect(), }); - detectBtn.id = PREFIX + '-detect-toggle'; - // Eye icon (SVG) - detectBtn.innerHTML = ''; - const detectLabel = el('span', {}); - detectLabel.textContent = 'Detect'; - detectBtn.appendChild(detectLabel); - // Badge is inside the button const detectBadge = el('span', { fontSize: '10px', fontWeight: '600', padding: '0px 5px', borderRadius: '7px', lineHeight: '16px', - background: C.brand, color: C.white, - display: 'none', fontFamily: MONO, + background: P.accent, color: P.surface.includes('18%') ? 'oklch(18% 0 0)' : 'oklch(98% 0 0)', + display: 'none', fontFamily: MONO, marginLeft: '4px', }); detectBadge.id = PREFIX + '-detect-badge'; detectBtn.appendChild(detectBadge); - detectBtn.addEventListener('click', () => toggleDetect()); - detectBtn.addEventListener('mouseenter', () => { if (!detectActive) detectBtn.style.color = C.ink; }); - detectBtn.addEventListener('mouseleave', () => { if (!detectActive) detectBtn.style.color = C.ash; }); - globalBarEl.appendChild(detectBtn); + inner.appendChild(detectBtn); - // Pick toggle: crosshair icon + label - const pickBtn = el('button', { - display: 'inline-flex', alignItems: 'center', gap: '5px', - padding: '5px 10px', borderRadius: '7px', - border: 'none', background: C.brandSoft, - color: C.brand, fontFamily: FONT, fontSize: '11.5px', fontWeight: '500', - cursor: 'pointer', transition: 'all 0.15s ease', whiteSpace: 'nowrap', + // DESIGN.md panel toggle — quartet of color squares as the mark. + const designBtn = makeIconBtn({ + id: PREFIX + '-design-toggle', + svg: ` + + + + + `, + label: 'DESIGN.md', + ariaLabel: 'Toggle DESIGN.md panel', + labelFont: MONO, + onClick: () => toggleDesignPanel(), }); - pickBtn.id = PREFIX + '-pick-toggle'; - pickBtn.innerHTML = ''; - const pickLabel = el('span', {}); - pickLabel.textContent = 'Pick'; - pickBtn.appendChild(pickLabel); - pickBtn.addEventListener('click', () => togglePick()); - globalBarEl.appendChild(pickBtn); + inner.appendChild(designBtn); - // Exit (subtle × on the right) + // Thin divider before the exit button + const divider = el('span', { + width: '1px', height: '18px', + background: P.hairline, + margin: '0 4px 0 2px', + }); + inner.appendChild(divider); + + // Exit (subtle × on the right) — SVG for baseline-free centering const exitBtn = el('button', { display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - width: '24px', height: '24px', borderRadius: '6px', + width: '26px', height: '26px', borderRadius: '6px', border: 'none', background: 'transparent', - color: C.mist, fontFamily: FONT, fontSize: '16px', lineHeight: '1', + color: P.textDim, fontFamily: FONT, fontSize: '0', lineHeight: '0', cursor: 'pointer', transition: 'color 0.12s ease, background 0.12s ease', - marginLeft: '2px', }); - exitBtn.textContent = '\u00D7'; + exitBtn.innerHTML = ''; exitBtn.title = 'Exit live mode'; - exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = C.ink; exitBtn.style.background = 'oklch(90% 0 0 / 0.5)'; }); - exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = C.mist; exitBtn.style.background = 'transparent'; }); + exitBtn.addEventListener('mouseenter', () => { exitBtn.style.color = P.text; exitBtn.style.background = P.exitHover; }); + exitBtn.addEventListener('mouseleave', () => { exitBtn.style.color = P.textDim; exitBtn.style.background = 'transparent'; }); exitBtn.addEventListener('click', () => { sendEvent({ type: 'exit' }); teardown(); }); - globalBarEl.appendChild(exitBtn); + inner.appendChild(exitBtn); + + // Bar-level hover: expand every toggle's label at once; collapse on leave. + // Buttons with dataset.active="true" ignore collapse (their label stays). + const toggles = [pickBtn, detectBtn, designBtn]; + globalBarEl.addEventListener('mouseenter', () => { + toggles.forEach((t) => t._expandLabel && t._expandLabel()); + }); + globalBarEl.addEventListener('mouseleave', () => { + toggles.forEach((t) => t._collapseLabel && t._collapseLabel()); + }); document.body.appendChild(globalBarEl); @@ -1395,19 +1530,34 @@ const detectToggle = document.getElementById(PREFIX + '-detect-toggle'); const detectBadge = document.getElementById(PREFIX + '-detect-badge'); const pickToggle = document.getElementById(PREFIX + '-pick-toggle'); + const designToggle = document.getElementById(PREFIX + '-design-toggle'); + const theme = globalBarEl?.dataset.theme || 'light'; + const P = barPaletteForTheme(theme); - if (detectToggle) { - detectToggle.style.background = detectActive ? C.brandSoft : 'transparent'; - detectToggle.style.color = detectActive ? C.brand : C.ash; + // Sync one toggle's active state, colors, and slide-label visibility. + function sync(btn, active) { + if (!btn) return; + btn.style.background = active ? P.accentSoft : 'transparent'; + btn.style.color = active ? P.accent : P.textDim; + btn.dataset.active = active ? 'true' : 'false'; + if (active && btn._expandLabel) btn._expandLabel(); + else if (!active && btn._collapseLabel) btn._collapseLabel(); } + sync(pickToggle, pickActive); + sync(detectToggle, detectActive); + sync(designToggle, designState.open); + + // If the bar is currently under the cursor, keep all labels expanded — + // otherwise clicking a toggle that deactivates (e.g. closing DESIGN.md) + // would collapse its label while the user's mouse is still on the bar. + if (globalBarEl && globalBarEl.matches(':hover')) { + [pickToggle, detectToggle, designToggle].forEach((t) => t?._expandLabel?.()); + } + if (detectBadge) { detectBadge.style.display = (detectActive && detectCount > 0) ? 'inline' : 'none'; detectBadge.textContent = detectCount; } - if (pickToggle) { - pickToggle.style.background = pickActive ? C.brandSoft : 'transparent'; - pickToggle.style.color = pickActive ? C.brand : C.ash; - } // When pick is active, make detect overlays click-through so the picker works document.querySelectorAll('.impeccable-overlay').forEach(o => { @@ -1443,8 +1593,14 @@ updateGlobalBarState(); if (!pickActive) { + // Disabling pick clears any in-flight selection and UI: highlight, + // contextual bar, selectedElement. Otherwise a stale selection sits + // on screen with no obvious way to dismiss. hideHighlight(); - if (state === 'PICKING') state = 'IDLE'; + hideBar(); + hideActionPicker(); + selectedElement = null; + if (state === 'PICKING' || state === 'CONFIGURING') state = 'IDLE'; } else { if (state === 'IDLE') state = 'PICKING'; } @@ -1500,6 +1656,1057 @@ 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() { + // `open` is intentionally NOT persisted — the panel always starts closed + // so live mode doesn't auto-slide a big panel over the page on startup. + try { + const raw = localStorage.getItem(DESIGN_PREFS_KEY); + if (!raw) return; + const prefs = JSON.parse(raw); + 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({ + 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'); + // Theme-match the bar: dark chrome on light pages, light chrome on dark pages. + const theme = detectPageTheme(); + style.textContent = designPanelCss(barPaletteForTheme(theme)); + 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(BP) { + // BP = bar palette (theme-aware, matches the global bar). + // DP = internal content palette (neutral, so tiles render colors true). + 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; } + + /* --- Panel shell: chrome matches the bar; body canvas stays neutral --- */ + .panel { + position: fixed; top: 12px; bottom: 72px; right: 12px; + width: ${DESIGN_PANEL_WIDTH}px; max-width: calc(100vw - 24px); + background: ${BP.surface}; + border: 1px solid ${BP.hairline}; + border-radius: 14px; + backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); + box-shadow: 0 20px 60px oklch(0% 0 0 / 0.18), 0 4px 12px oklch(0% 0 0 / 0.08); + 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: 10px 10px 10px 14px; + background: transparent; + border-bottom: 1px solid ${BP.hairline}; + } + .panel-title { + flex: 1; min-width: 0; + font-family: ${MONO}; + font-size: 11.5px; font-weight: 600; + letter-spacing: 0.02em; + color: ${BP.text}; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + } + .panel-close { + border: none; background: transparent; color: ${BP.textDim}; + width: 26px; height: 26px; border-radius: 7px; + display: inline-flex; align-items: center; justify-content: center; + cursor: pointer; transition: background 0.15s ease, color 0.15s ease; + } + .panel-close:hover { background: ${BP.hairline}; color: ${BP.text}; } + + .tabs { + display: inline-flex; padding: 2px; + background: ${BP.hairline}; + border-radius: 7px; + gap: 2px; + } + .tab { + border: none; background: transparent; + padding: 4px 10px; border-radius: 5px; + font-family: ${MONO}; + font-size: 10px; font-weight: 600; letter-spacing: 0.08em; + text-transform: uppercase; + color: ${BP.textDim}; cursor: pointer; + transition: background 0.15s ease, color 0.15s ease; + } + .tab[data-active="true"] { background: ${BP.surface}; color: ${BP.text}; } + + .panel-body { + flex: 1; overflow-y: auto; + padding: 12px 12px 20px; + background: ${DP.canvas}; + 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 = ''; + + // (Panel toggle lives in the global bar — no floating 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 = 'DESIGN.md'; + 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; + renderDesignChrome(); + updateGlobalBarState(); + 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 +2716,7 @@ initBar(); initActionPicker(); initGlobalBar(); + initDesignPanel(); document.addEventListener('mousemove', handleMouseMove, true); document.addEventListener('click', handleClick, true); document.addEventListener('keydown', handleKeyDown, true); diff --git a/.trae/skills/impeccable/scripts/live-poll.mjs b/.trae/skills/impeccable/scripts/live-poll.mjs index b176d9f05..3c8088c23 100644 --- a/.trae/skills/impeccable/scripts/live-poll.mjs +++ b/.trae/skills/impeccable/scripts/live-poll.mjs @@ -3,7 +3,7 @@ * * Usage: * npx impeccable poll # Block until browser event, print JSON - * npx impeccable poll --timeout=60000 # Custom timeout (ms) + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly * npx impeccable poll --reply done # Reply "done" to event * npx impeccable poll --reply error "msg" # Reply with error */ @@ -13,6 +13,13 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { fileURLToPath } from 'node:url'; +import { Agent, setGlobalDispatcher } from 'undici'; + +// Disable undici's default 300s headersTimeout so long-polls can sit open +// indefinitely (until a browser event or the server's own timeout fires). +// Without this, fetch() throws a bare "fetch failed" at 5 minutes even +// though the server would have happily kept the connection alive. +setGlobalDispatcher(new Agent({ headersTimeout: 0, bodyTimeout: 0 })); const LIVE_PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -39,7 +46,7 @@ Modes: poll --reply error "msg" Reply with an error message Options: - --timeout=MS Poll timeout in milliseconds (default: 120000) + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn --help Show this help message`); process.exit(0); } @@ -93,7 +100,9 @@ Options: return; } - // Poll mode: block until browser event + // Poll mode: block until browser event. Default 10 min; undici's default + // 5-min headers-timeout is disabled at import time so this can sit open + // indefinitely without fetch errors. const timeoutArg = args.find(a => a.startsWith('--timeout=')); const timeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600000; diff --git a/.trae/skills/impeccable/scripts/live.mjs b/.trae/skills/impeccable/scripts/live.mjs index 37b6f8997..062b35ae8 100644 --- a/.trae/skills/impeccable/scripts/live.mjs +++ b/.trae/skills/impeccable/scripts/live.mjs @@ -9,7 +9,7 @@ * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: - * - Navigate the browser to the page (optional, if browser automation is available) + * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll * - Enter the poll loop: `node live-poll.mjs` * * Usage: @@ -46,7 +46,7 @@ On config_missing, prints: The agent should then: 1. If config_missing, create the config and re-run this script - 2. Optionally navigate the browser to the page + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 3. Enter the poll loop: node live-poll.mjs`); process.exit(0); } diff --git a/HARNESSES.md b/HARNESSES.md index cc5d9ba9e..4194a1041 100644 --- a/HARNESSES.md +++ b/HARNESSES.md @@ -58,7 +58,7 @@ Notes: | Harness | Native directory | Also reads | |---------|-----------------|------------| | Claude Code | `.claude/skills/` | - | -| Cursor | `.cursor/skills/` | `.agents/skills/`, `.claude/skills/`, `.codex/skills/` | +| Cursor | `.cursor/skills/` | `.agents/skills/`, `.claude/skills/` | | Gemini CLI | `.gemini/skills/` | `.agents/skills/` | | Codex CLI | `.agents/skills/` (primary) | - | | GitHub Copilot | `.github/skills/` | `.agents/skills/`, `.claude/skills/` | diff --git a/bin/commands/skills.mjs b/bin/commands/skills.mjs index 7fb0adddb..e159c481b 100644 --- a/bin/commands/skills.mjs +++ b/bin/commands/skills.mjs @@ -20,7 +20,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const API_BASE = 'https://impeccable.style'; // Provider folder names in project roots -const PROVIDER_DIRS = ['.claude', '.cursor', '.gemini', '.codex', '.agents', '.github', '.kiro', '.opencode', '.pi', '.trae', '.trae-cn']; +const PROVIDER_DIRS = ['.claude', '.cursor', '.gemini', '.agents', '.github', '.kiro', '.opencode', '.pi', '.trae', '.trae-cn']; function ask(question) { const rl = createInterface({ input: process.stdin, output: process.stdout }); diff --git a/bun.lock b/bun.lock index c73679663..a2161c210 100644 --- a/bun.lock +++ b/bun.lock @@ -9,10 +9,16 @@ "marked": "^16.1.0", }, "devDependencies": { + "@ai-sdk/anthropic": "^3.0.69", + "@ai-sdk/openai": "^3.0.53", + "@anthropic-ai/claude-agent-sdk": "^0.2.110", + "@google/genai": "^1.50.1", + "ai": "^6.0.162", "archiver": "^7.0.1", "motion": "^12.38.0", "playwright": "^1.58.2", "wrangler": "^4.75.0", + "zod": "^4.3.6", }, "optionalDependencies": { "puppeteer": "^24.39.1", @@ -20,6 +26,20 @@ }, }, "packages": { + "@ai-sdk/anthropic": ["@ai-sdk/anthropic@3.0.69", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-LshR7X3pFugY0o41G2VKTmg1XoGpSl7uoYWfzk6zjVZLhCfeFiwgpOga+eTV4XY1VVpZwKVqRnkDbIL7K2eH5g=="], + + "@ai-sdk/gateway": ["@ai-sdk/gateway@3.0.99", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-8/UuzFY8p+T8j4XP/9m841pUb5bhnFt8cecSnJpd2zhBttNZ6GbfjZTmsqnvM/RwJOvzIsdFULZrU+E9QFREsQ=="], + + "@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], + + "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="], + + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.23", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-z8GlDaCmRSDlqkMF2f4/RFgWxdarvIbyuk+m6WXT1LYgsnGiXRJGTD2Z1+SDl3LqtFuRtGX1aghYvQLoHL/9pg=="], + + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.2.110", "", { "dependencies": { "@anthropic-ai/sdk": "^0.81.0", "@modelcontextprotocol/sdk": "^1.29.0" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "^0.34.2", "@img/sharp-darwin-x64": "^0.34.2", "@img/sharp-linux-arm": "^0.34.2", "@img/sharp-linux-arm64": "^0.34.2", "@img/sharp-linux-x64": "^0.34.2", "@img/sharp-linuxmusl-arm64": "^0.34.2", "@img/sharp-linuxmusl-x64": "^0.34.2", "@img/sharp-win32-arm64": "^0.34.2", "@img/sharp-win32-x64": "^0.34.2" }, "peerDependencies": { "zod": "^4.0.0" } }, "sha512-pS7QlPcJwQU8a87F8qChKlmnjddt3smUi6X7WvSluD0kzt72jphCe30QmKKXPJnjW3SVHu12cu6SLzfYQrWwHg=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.81.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-D4K5PvEV6wPiRtVlVsJHIUhHAmOZ6IT/I9rKlTf84gR7GyyAurPJK7z9BOf/AZqC5d1DhYQGJNKRmV+q8dGhgw=="], + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@5.0.1", "", { "dependencies": { "@csstools/css-calc": "^3.1.1", "@csstools/css-color-parser": "^4.0.2", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.2.6" } }, "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw=="], "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@7.0.3", "", { "dependencies": { "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7" } }, "sha512-Q6mU0Z6bfj6YvnX2k9n0JxiIwrCFN59x/nWmYQnAqP000ruX/yV+5bp/GRcF5T8ncvfwJQ7fgfP74DlpKExILA=="], @@ -30,6 +50,8 @@ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], + "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], "@cloudflare/kv-asset-handler": ["@cloudflare/kv-asset-handler@0.4.2", "", {}, "sha512-SIOD2DxrRRwQ+jgzlXCqoEFiKOFqaPjhnNTGKXSRLvp1HiOvapLaFG2kEr9dYQTYe8rKrd9uvDUzmAITeNyaHQ=="], @@ -116,6 +138,10 @@ "@exodus/bytes": ["@exodus/bytes@1.15.0", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ=="], + "@google/genai": ["@google/genai@1.50.1", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-YbkX7H9+1Pt8wOt7DDREy8XSoiL6fRDzZQRyaVBarFf8MR3zHGqVdvM4cLbDXqPhxqvegZShgfxb8kw9C7YhAQ=="], + + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@img/colour": ["@img/colour@1.1.0", "", {}, "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ=="], "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], @@ -174,6 +200,10 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.9", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.0.3", "@jridgewell/sourcemap-codec": "^1.4.10" } }, "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="], @@ -182,22 +212,56 @@ "@poppinss/exception": ["@poppinss/exception@1.2.3", "", {}, "sha512-dCED+QRChTVatE9ibtoaxc+WkdzOSjYTKi/+uacHWIsfodVfpsueo3+DKpgU5Px8qXjgmXkSvhXvSCz3fnP9lw=="], + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.4", "", {}, "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.0", "", {}, "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1", "@protobufjs/inquire": "^1.1.0" } }, "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/inquire": ["@protobufjs/inquire@1.1.0", "", {}, "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.0", "", {}, "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw=="], + "@puppeteer/browsers": ["@puppeteer/browsers@2.13.0", "", { "dependencies": { "debug": "^4.4.3", "extract-zip": "^2.0.1", "progress": "^2.0.3", "proxy-agent": "^6.5.0", "semver": "^7.7.4", "tar-fs": "^3.1.1", "yargs": "^17.7.2" }, "bin": { "browsers": "lib/cjs/main-cli.js" } }, "sha512-46BZJYJjc/WwmKjsvDFykHtXrtomsCIrwYQPOP7VfMJoZY2bsDF9oROBABR3paDjDcmkUye1Pb1BqdcdiipaWA=="], "@sindresorhus/is": ["@sindresorhus/is@7.2.0", "", {}, "sha512-P1Cz1dWaFfR4IR+U13mqqiGsLFf1KbayybWwdd2vfctdV6hDpUkgCY0nKOLLTMSoRd/jJNjtbqzf13K8DCCXQw=="], "@speed-highlight/core": ["@speed-highlight/core@1.2.14", "", {}, "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tootallnate/quickjs-emscripten": ["@tootallnate/quickjs-emscripten@0.23.0", "", {}, "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA=="], "@types/node": ["@types/node@25.5.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw=="], + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + "@types/yauzl": ["@types/yauzl@2.10.3", "", { "dependencies": { "@types/node": "*" } }, "sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q=="], + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + "ai": ["ai@6.0.162", "", { "dependencies": { "@ai-sdk/gateway": "3.0.99", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-1PSvNEK1PEbpUXahnFrcey6l7DJXMVWmg0ibQ8h8oMSe9V1Vx5d+R3xNu0hzBtwqfxYj21ddZo+EUYVs6GOEyA=="], + + "ajv": ["ajv@8.18.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], @@ -234,14 +298,26 @@ "bidi-js": ["bidi-js@1.0.3", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw=="], + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + "blake3-wasm": ["blake3-wasm@2.1.5", "", {}, "sha512-F1+K8EbfOZE49dtoPtmxUQrpXaBIl3ICvasLh+nJta0xkz+9kF/7uet9fLnwKqhDrmj6g+6K3Tw9yQPUg2ka5g=="], + "body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + "brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "buffer-crc32": ["buffer-crc32@1.0.0", "", {}, "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w=="], + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], "chromium-bidi": ["chromium-bidi@14.0.0", "", { "dependencies": { "mitt": "^3.0.1", "zod": "^3.24.1" }, "peerDependencies": { "devtools-protocol": "*" } }, "sha512-9gYlLtS6tStdRWzrtXaTMnqcM4dudNegMXJxkR0I/CXObHalYeYcAMPrL19eroNZHtJ8DQmu1E+ZNOYu/IXMXw=="], @@ -254,10 +330,18 @@ "compress-commons": ["compress-commons@6.0.2", "", { "dependencies": { "crc-32": "^1.2.0", "crc32-stream": "^6.0.0", "is-stream": "^2.0.1", "normalize-path": "^3.0.0", "readable-stream": "^4.0.0" } }, "sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg=="], - "cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "cosmiconfig": ["cosmiconfig@9.0.1", "", { "dependencies": { "env-paths": "^2.2.1", "import-fresh": "^3.3.0", "js-yaml": "^4.1.0", "parse-json": "^5.2.0" }, "peerDependencies": { "typescript": ">=4.9.5" }, "optionalPeers": ["typescript"] }, "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ=="], "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], @@ -268,7 +352,7 @@ "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], - "data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], @@ -278,14 +362,24 @@ "degenerator": ["degenerator@5.0.1", "", { "dependencies": { "ast-types": "^0.13.4", "escodegen": "^2.1.0", "esprima": "^4.0.1" } }, "sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "devtools-protocol": ["devtools-protocol@0.0.1581282", "", {}, "sha512-nv7iKtNZQshSW2hKzYNr46nM/Cfh5SEvE2oV0/SEGgc9XupIY5ggf84Cz8eJIkBce7S3bmTAauFD6aysMpnqsQ=="], + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], @@ -296,10 +390,18 @@ "error-stack-parser-es": ["error-stack-parser-es@1.0.5", "", {}, "sha512-5qucVt2XcuGMcEGgWI7i+yZpmpByQ8J1lHhcL7PwqCwu9FPP3VUXzT4ltHe5i2z9dePwEHcDVOAfSnHsOlCXRA=="], + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + "escodegen": ["escodegen@2.1.0", "", { "dependencies": { "esprima": "^4.0.1", "estraverse": "^5.2.0", "esutils": "^2.0.2" }, "optionalDependencies": { "source-map": "~0.6.1" }, "bin": { "esgenerate": "bin/esgenerate.js", "escodegen": "bin/escodegen.js" } }, "sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w=="], "esprima": ["esprima@4.0.1", "", { "bin": { "esparse": "./bin/esparse.js", "esvalidate": "./bin/esvalidate.js" } }, "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A=="], @@ -308,40 +410,92 @@ "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "events-universal": ["events-universal@1.0.1", "", { "dependencies": { "bare-events": "^2.7.0" } }, "sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw=="], + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.0.6", "", {}, "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.3.2", "", { "dependencies": { "ip-address": "10.1.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-77VmFeJkO0/rvimEDuUC5H30oqUC4EyOhyGccfqoLebB0oiEYfM7nwPrsDsBL1gsTpwfzX8SFy2MT3TDyRq+bg=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + "fast-fifo": ["fast-fifo@1.3.2", "", {}, "sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ=="], + "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + "framer-motion": ["framer-motion@12.38.0", "", { "dependencies": { "motion-dom": "^12.38.0", "motion-utils": "^12.36.0", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g=="], + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + "fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "gaxios": ["gaxios@7.1.4", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "get-stream": ["get-stream@5.2.0", "", { "dependencies": { "pump": "^3.0.0" } }, "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA=="], "get-uri": ["get-uri@6.0.5", "", { "dependencies": { "basic-ftp": "^5.0.2", "data-uri-to-buffer": "^6.0.2", "debug": "^4.3.4" } }, "sha512-b1O07XYq8eRuVzBNgJLstU6FYc1tS6wnMtF1I1D9lE8LxZSOGZ7LhxN54yPP6mGw5f2CkXY2BQUL9Fx41qvcIg=="], "glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="], + "google-auth-library": ["google-auth-library@10.6.2", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-e27Z6EThmVNNvtYASwQxose/G57rkRuaRbQyxM2bvYLLX/GqWZ5chWq2EBoUchJbCc57eC9ArzO5wMsEmWftCw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "hono": ["hono@4.12.14", "", {}, "sha512-am5zfg3yu6sqn5yjKBNqhnTX7Cv+m00ox+7jbaKkrLMRJ4rAdldd1xPd/JzbBWspqaQv6RSTrgFN95EsfhC+7w=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -350,12 +504,16 @@ "ip-address": ["ip-address@10.1.0", "", {}, "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q=="], + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + "is-arrayish": ["is-arrayish@0.2.1", "", {}, "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg=="], "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="], "isarray": ["isarray@1.0.0", "", {}, "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="], @@ -364,14 +522,30 @@ "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + "jose": ["jose@6.2.2", "", {}, "sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], "js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "jsdom": ["jsdom@29.0.0", "", { "dependencies": { "@asamuzakjp/css-color": "^5.0.1", "@asamuzakjp/dom-selector": "^7.0.2", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.1", "@exodus/bytes": "^1.15.0", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.2.7", "parse5": "^8.0.0", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.1", "undici": "^7.24.3", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.0.0" }, "optionalPeers": ["canvas"] }, "sha512-9FshNB6OepopZ08unmmGpsF7/qCjxGPbo3NbgfJAnPeHXnsODE9WWffXZtRFRFe0ntzaAOcSKNJFz8wiyvF1jQ=="], + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + "json-parse-even-better-errors": ["json-parse-even-better-errors@2.3.1", "", {}, "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w=="], + "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="], "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], @@ -380,12 +554,24 @@ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + "lru-cache": ["lru-cache@11.2.7", "", {}, "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA=="], "marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="], + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + "miniflare": ["miniflare@4.20260317.0", "", { "dependencies": { "@cspotcode/source-map-support": "0.8.1", "sharp": "^0.34.5", "undici": "7.24.4", "workerd": "1.20260317.1", "ws": "8.18.0", "youch": "4.1.0-beta.10" }, "bin": { "miniflare": "bootstrap.js" } }, "sha512-xuwk5Kjv+shi5iUBAdCrRl9IaWSGnTU8WuTQzsUS2GlSDIMCJuu8DiF/d9ExjMXYiQG5ml+k9SVKnMj8cRkq0w=="], "minimatch": ["minimatch@5.1.9", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw=="], @@ -402,12 +588,26 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + "negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="], + "netmask": ["netmask@2.0.2", "", {}, "sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg=="], + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + "pac-proxy-agent": ["pac-proxy-agent@7.2.0", "", { "dependencies": { "@tootallnate/quickjs-emscripten": "^0.23.0", "agent-base": "^7.1.2", "debug": "^4.3.4", "get-uri": "^6.0.1", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.6", "pac-resolver": "^7.0.1", "socks-proxy-agent": "^8.0.5" } }, "sha512-TEB8ESquiLMc0lV8vcd5Ql/JAKAoyzHFXaStwjkzpOpC5Yv+pIzLfHvjTSdf3vpa2bMiUQrg9i6276yn8666aA=="], "pac-resolver": ["pac-resolver@7.0.1", "", { "dependencies": { "degenerator": "^5.0.0", "netmask": "^2.0.2" } }, "sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg=="], @@ -420,6 +620,8 @@ "parse5": ["parse5@8.0.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA=="], + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], @@ -432,6 +634,8 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + "playwright": ["playwright@1.58.2", "", { "dependencies": { "playwright-core": "1.58.2" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A=="], "playwright-core": ["playwright-core@1.58.2", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg=="], @@ -442,6 +646,10 @@ "progress": ["progress@2.0.3", "", {}, "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA=="], + "protobufjs": ["protobufjs@7.5.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-3wY1AxV+VBNW8Yypfd1yQY9pXnqTAN+KwQxL8iYm3/BjKYMNg4i0owhEe26PWDOMaIrzeeF98Lqd5NGz4omiIg=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "proxy-agent": ["proxy-agent@6.5.0", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "http-proxy-agent": "^7.0.1", "https-proxy-agent": "^7.0.6", "lru-cache": "^7.14.1", "pac-proxy-agent": "^7.1.0", "proxy-from-env": "^1.1.0", "socks-proxy-agent": "^8.0.5" } }, "sha512-TmatMXdr2KlRiA2CyDu8GqR8EjahTG3aY3nXjdzFyoZbmB8hrBsTyMezhULIXKnC0jpfjlmiZ3+EaCzoInSu/A=="], "proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="], @@ -454,6 +662,12 @@ "puppeteer-core": ["puppeteer-core@24.39.1", "", { "dependencies": { "@puppeteer/browsers": "2.13.0", "chromium-bidi": "14.0.0", "debug": "^4.4.3", "devtools-protocol": "0.0.1581282", "typed-query-selector": "^2.12.1", "webdriver-bidi-protocol": "0.4.1", "ws": "^8.19.0" } }, "sha512-AMqQIKoEhPS6CilDzw0Gd1brLri3emkC+1N2J6ZCCuY1Cglo56M63S0jOeBZDQlemOiRd686MYVMl9ELJBzN3A=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], + + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], "readdir-glob": ["readdir-glob@1.1.3", "", { "dependencies": { "minimatch": "^5.1.0" } }, "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA=="], @@ -464,18 +678,38 @@ "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + "retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], "semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], "smart-buffer": ["smart-buffer@4.2.0", "", {}, "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg=="], @@ -488,6 +722,8 @@ "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "streamx": ["streamx@2.23.0", "", { "dependencies": { "events-universal": "^1.0.0", "fast-fifo": "^1.3.2", "text-decoder": "^1.1.0" } }, "sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg=="], "string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], @@ -516,12 +752,18 @@ "tldts-core": ["tldts-core@7.0.26", "", {}, "sha512-5WJ2SqFsv4G2Dwi7ZFVRnz6b2H1od39QME1lc2y5Ew3eWiZMAeqOAfWpRP9jHvhUl881406QtZTODvjttJs+ew=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], + "typed-query-selector": ["typed-query-selector@2.12.1", "", {}, "sha512-uzR+FzI8qrUEIu96oaeBJmd9E7CFEiQ3goA5qCVgc4s5llSubcfGHq9yUstZx/k4s9dXHVKsE35YWoFyvEqEHA=="], "undici": ["undici@7.24.4", "", {}, "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w=="], @@ -530,10 +772,16 @@ "unenv": ["unenv@2.0.0-rc.24", "", { "dependencies": { "pathe": "^2.0.3" } }, "sha512-i7qRCmY42zmCwnYlh9H2SvLEypEFGye5iRmEMKjcGi7zk9UquigRjFtTLz0TYqr0ZGLZhaMHl/foy1bZR+Cwlw=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + "webdriver-bidi-protocol": ["webdriver-bidi-protocol@0.4.1", "", {}, "sha512-ARrjNjtWRRs2w4Tk7nqrf2gBI0QXWuOmMCx2hU+1jUt6d00MjMxURrhxhGbrsoiZKJrhTSTzbIrc554iKI10qw=="], "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], @@ -554,7 +802,7 @@ "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], - "ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], @@ -574,7 +822,9 @@ "zip-stream": ["zip-stream@6.0.1", "", { "dependencies": { "archiver-utils": "^5.0.0", "compress-commons": "^6.0.2", "readable-stream": "^4.0.0" } }, "sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA=="], - "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], "@isaacs/cliui/string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], @@ -582,18 +832,26 @@ "@isaacs/cliui/wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + "chromium-bidi/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "get-uri/data-uri-to-buffer": ["data-uri-to-buffer@6.0.2", "", {}, "sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw=="], + "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "lazystream/readable-stream": ["readable-stream@2.3.8", "", { "dependencies": { "core-util-is": "~1.0.0", "inherits": "~2.0.3", "isarray": "~1.0.0", "process-nextick-args": "~2.0.0", "safe-buffer": "~5.1.1", "string_decoder": "~1.1.1", "util-deprecate": "~1.0.1" } }, "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA=="], + "miniflare/ws": ["ws@8.18.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw=="], + "path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - "puppeteer-core/ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="], + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "yauzl/buffer-crc32": ["buffer-crc32@0.2.13", "", {}, "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ=="], + "youch/cookie": ["cookie@1.1.1", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="], + "@isaacs/cliui/string-width/emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], "@isaacs/cliui/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/package.json b/package.json index b1b286886..2b2891e85 100644 --- a/package.json +++ b/package.json @@ -63,9 +63,15 @@ "puppeteer": "^24.39.1" }, "devDependencies": { + "@ai-sdk/anthropic": "^3.0.69", + "@ai-sdk/openai": "^3.0.53", + "@anthropic-ai/claude-agent-sdk": "^0.2.110", + "@google/genai": "^1.50.1", + "ai": "^6.0.162", "archiver": "^7.0.1", "motion": "^12.38.0", "playwright": "^1.58.2", - "wrangler": "^4.75.0" + "wrangler": "^4.75.0", + "zod": "^4.3.6" } } diff --git a/scripts/build.js b/scripts/build.js index 1434b15d7..1ff029dfd 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -7,7 +7,7 @@ * - Cursor: .cursor/skills/ * - Claude Code: .claude/skills/ * - Gemini: .gemini/skills/ - * - Codex: .codex/skills/ (Codex-specific compatibility bundle) + * - Codex: dist/codex/ only (OpenAI-metadata bundle; not synced to repo root) * - Agents: .agents/skills/ (Codex repo/user installs) * - GitHub: .github/skills/ (GitHub Copilot) * @@ -420,7 +420,7 @@ This folder contains skills for all supported tools: .cursor/ -> Cursor .claude/ -> Claude Code .gemini/ -> Gemini CLI - .codex/ -> Codex compatibility bundle + .codex/ -> Legacy bundle folder in this ZIP (Codex CLI uses .agents/) .agents/ -> Codex CLI .github/ -> GitHub Copilot .kiro/ -> Kiro @@ -680,7 +680,9 @@ async function build() { copyDistToBuild(DIST_DIR, buildDir); generateCFConfig(buildDir); - // Copy all provider outputs to project root for local testing + // Copy all provider outputs to project root for local testing. + // `.codex/` is intentionally excluded: Codex no longer consumes that layout; keep + // generated bundles under dist/ only. const syncConfigs = Object.values(PROVIDERS).filter(({ configDir }) => configDir !== '.codex'); for (const { provider, configDir } of syncConfigs) { diff --git a/source/skills/impeccable/scripts/live-poll.mjs b/source/skills/impeccable/scripts/live-poll.mjs index 387a0fdf5..3c8088c23 100644 --- a/source/skills/impeccable/scripts/live-poll.mjs +++ b/source/skills/impeccable/scripts/live-poll.mjs @@ -3,7 +3,7 @@ * * Usage: * npx impeccable poll # Block until browser event, print JSON - * npx impeccable poll --timeout=60000 # Custom timeout (ms) + * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly * npx impeccable poll --reply done # Reply "done" to event * npx impeccable poll --reply error "msg" # Reply with error */ @@ -46,7 +46,7 @@ Modes: poll --reply error "msg" Reply with an error message Options: - --timeout=MS Poll timeout in milliseconds (default: 120000) + --timeout=MS Long-poll timeout in ms (default: 600000). Use the default unless the user asked to pause live; never use a short timeout to end the chat turn --help Show this help message`); process.exit(0); } diff --git a/source/skills/impeccable/scripts/live.mjs b/source/skills/impeccable/scripts/live.mjs index 37b6f8997..062b35ae8 100644 --- a/source/skills/impeccable/scripts/live.mjs +++ b/source/skills/impeccable/scripts/live.mjs @@ -9,7 +9,7 @@ * 5. Print a single JSON blob with everything the agent needs * * After this, the agent's only remaining steps are: - * - Navigate the browser to the page (optional, if browser automation is available) + * - Open the project's live dev/preview URL in the browser (optional, if browser automation exists)—not `serverPort`; that port is the Impeccable helper for /live.js and /poll * - Enter the poll loop: `node live-poll.mjs` * * Usage: @@ -46,7 +46,7 @@ On config_missing, prints: The agent should then: 1. If config_missing, create the config and re-run this script - 2. Optionally navigate the browser to the page + 2. Optionally open the project's dev/preview URL in the browser (see reference/live.md—not serverPort) 3. Enter the poll loop: node live-poll.mjs`); process.exit(0); } diff --git a/tests/skills-cli.test.js b/tests/skills-cli.test.js index 8b7a386d3..1e0c71a4e 100644 --- a/tests/skills-cli.test.js +++ b/tests/skills-cli.test.js @@ -401,7 +401,7 @@ describeNpx('skills install: full e2e with npx skills', () => { // Find the provider that has skills let found = false; - for (const d of ['.claude', '.cursor', '.gemini', '.codex', '.agents', '.kiro']) { + for (const d of ['.claude', '.cursor', '.gemini', '.agents', '.kiro']) { const dir = join(tmp, d, 'skills'); if (!existsSync(dir)) continue; const skills = readdirSync(dir);