`);
+ }
+ return out.join('\n');
+ }
+
+ function buildListHtml(items, type) {
+ // Nest by indent (one level deep is plenty for DESIGN.md).
+ let html = `<${type}>`;
+ let lastIndent = 0;
+ for (const it of items) {
+ if (it.indent > lastIndent) html += `<${type}>`;
+ else if (it.indent < lastIndent) html += `${type}>`.repeat(lastIndent - it.indent);
+ html += `
${it.html}
`;
+ lastIndent = it.indent;
+ }
+ html += `${type}>`.repeat(lastIndent + 1);
+ return html;
+ }
+
+ function inlineMd(text) {
+ // Order matters: escape first, then re-inject tags.
+ let s = escapeHtml(text);
+ // Code spans
+ s = s.replace(/`([^`]+)`/g, (_, code) => `${code}`);
+ // Links [text](url)
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `${t}`);
+ // Bold
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
+ // Italic (only single *…*, skip if inside bold already handled)
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2');
+ return s;
+ }
+
+ function highlightBold(text) {
+ return inlineMd(text);
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function copyToClipboard(text) {
+ if (!text) return;
+ try {
+ navigator.clipboard.writeText(text);
+ showToast('Copied: ' + text);
+ } catch { /* ignore */ }
+ }
+
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
@@ -1509,6 +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) => `
`);
+ }
+ return out.join('\n');
+ }
+
+ function buildListHtml(items, type) {
+ // Nest by indent (one level deep is plenty for DESIGN.md).
+ let html = `<${type}>`;
+ let lastIndent = 0;
+ for (const it of items) {
+ if (it.indent > lastIndent) html += `<${type}>`;
+ else if (it.indent < lastIndent) html += `${type}>`.repeat(lastIndent - it.indent);
+ html += `
${it.html}
`;
+ lastIndent = it.indent;
+ }
+ html += `${type}>`.repeat(lastIndent + 1);
+ return html;
+ }
+
+ function inlineMd(text) {
+ // Order matters: escape first, then re-inject tags.
+ let s = escapeHtml(text);
+ // Code spans
+ s = s.replace(/`([^`]+)`/g, (_, code) => `${code}`);
+ // Links [text](url)
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `${t}`);
+ // Bold
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
+ // Italic (only single *…*, skip if inside bold already handled)
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2');
+ return s;
+ }
+
+ function highlightBold(text) {
+ return inlineMd(text);
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function copyToClipboard(text) {
+ if (!text) return;
+ try {
+ navigator.clipboard.writeText(text);
+ showToast('Copied: ' + text);
+ } catch { /* ignore */ }
+ }
+
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
@@ -1509,6 +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) => `
`);
+ }
+ return out.join('\n');
+ }
+
+ function buildListHtml(items, type) {
+ // Nest by indent (one level deep is plenty for DESIGN.md).
+ let html = `<${type}>`;
+ let lastIndent = 0;
+ for (const it of items) {
+ if (it.indent > lastIndent) html += `<${type}>`;
+ else if (it.indent < lastIndent) html += `${type}>`.repeat(lastIndent - it.indent);
+ html += `
${it.html}
`;
+ lastIndent = it.indent;
+ }
+ html += `${type}>`.repeat(lastIndent + 1);
+ return html;
+ }
+
+ function inlineMd(text) {
+ // Order matters: escape first, then re-inject tags.
+ let s = escapeHtml(text);
+ // Code spans
+ s = s.replace(/`([^`]+)`/g, (_, code) => `${code}`);
+ // Links [text](url)
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `${t}`);
+ // Bold
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
+ // Italic (only single *…*, skip if inside bold already handled)
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2');
+ return s;
+ }
+
+ function highlightBold(text) {
+ return inlineMd(text);
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function copyToClipboard(text) {
+ if (!text) return;
+ try {
+ navigator.clipboard.writeText(text);
+ showToast('Copied: ' + text);
+ } catch { /* ignore */ }
+ }
+
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
@@ -1509,6 +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) => `
`);
+ }
+ return out.join('\n');
+ }
+
+ function buildListHtml(items, type) {
+ // Nest by indent (one level deep is plenty for DESIGN.md).
+ let html = `<${type}>`;
+ let lastIndent = 0;
+ for (const it of items) {
+ if (it.indent > lastIndent) html += `<${type}>`;
+ else if (it.indent < lastIndent) html += `${type}>`.repeat(lastIndent - it.indent);
+ html += `
${it.html}
`;
+ lastIndent = it.indent;
+ }
+ html += `${type}>`.repeat(lastIndent + 1);
+ return html;
+ }
+
+ function inlineMd(text) {
+ // Order matters: escape first, then re-inject tags.
+ let s = escapeHtml(text);
+ // Code spans
+ s = s.replace(/`([^`]+)`/g, (_, code) => `${code}`);
+ // Links [text](url)
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `${t}`);
+ // Bold
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
+ // Italic (only single *…*, skip if inside bold already handled)
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2');
+ return s;
+ }
+
+ function highlightBold(text) {
+ return inlineMd(text);
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function copyToClipboard(text) {
+ if (!text) return;
+ try {
+ navigator.clipboard.writeText(text);
+ showToast('Copied: ' + text);
+ } catch { /* ignore */ }
+ }
+
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
@@ -1509,6 +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) => `
`);
+ }
+ return out.join('\n');
+ }
+
+ function buildListHtml(items, type) {
+ // Nest by indent (one level deep is plenty for DESIGN.md).
+ let html = `<${type}>`;
+ let lastIndent = 0;
+ for (const it of items) {
+ if (it.indent > lastIndent) html += `<${type}>`;
+ else if (it.indent < lastIndent) html += `${type}>`.repeat(lastIndent - it.indent);
+ html += `
${it.html}
`;
+ lastIndent = it.indent;
+ }
+ html += `${type}>`.repeat(lastIndent + 1);
+ return html;
+ }
+
+ function inlineMd(text) {
+ // Order matters: escape first, then re-inject tags.
+ let s = escapeHtml(text);
+ // Code spans
+ s = s.replace(/`([^`]+)`/g, (_, code) => `${code}`);
+ // Links [text](url)
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `${t}`);
+ // Bold
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
+ // Italic (only single *…*, skip if inside bold already handled)
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2');
+ return s;
+ }
+
+ function highlightBold(text) {
+ return inlineMd(text);
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function copyToClipboard(text) {
+ if (!text) return;
+ try {
+ navigator.clipboard.writeText(text);
+ showToast('Copied: ' + text);
+ } catch { /* ignore */ }
+ }
+
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
@@ -1509,6 +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) => `
`);
+ }
+ return out.join('\n');
+ }
+
+ function buildListHtml(items, type) {
+ // Nest by indent (one level deep is plenty for DESIGN.md).
+ let html = `<${type}>`;
+ let lastIndent = 0;
+ for (const it of items) {
+ if (it.indent > lastIndent) html += `<${type}>`;
+ else if (it.indent < lastIndent) html += `${type}>`.repeat(lastIndent - it.indent);
+ html += `
${it.html}
`;
+ lastIndent = it.indent;
+ }
+ html += `${type}>`.repeat(lastIndent + 1);
+ return html;
+ }
+
+ function inlineMd(text) {
+ // Order matters: escape first, then re-inject tags.
+ let s = escapeHtml(text);
+ // Code spans
+ s = s.replace(/`([^`]+)`/g, (_, code) => `${code}`);
+ // Links [text](url)
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `${t}`);
+ // Bold
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
+ // Italic (only single *…*, skip if inside bold already handled)
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2');
+ return s;
+ }
+
+ function highlightBold(text) {
+ return inlineMd(text);
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function copyToClipboard(text) {
+ if (!text) return;
+ try {
+ navigator.clipboard.writeText(text);
+ showToast('Copied: ' + text);
+ } catch { /* ignore */ }
+ }
+
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
@@ -1509,6 +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) => `
`);
+ }
+ return out.join('\n');
+ }
+
+ function buildListHtml(items, type) {
+ // Nest by indent (one level deep is plenty for DESIGN.md).
+ let html = `<${type}>`;
+ let lastIndent = 0;
+ for (const it of items) {
+ if (it.indent > lastIndent) html += `<${type}>`;
+ else if (it.indent < lastIndent) html += `${type}>`.repeat(lastIndent - it.indent);
+ html += `
${it.html}
`;
+ lastIndent = it.indent;
+ }
+ html += `${type}>`.repeat(lastIndent + 1);
+ return html;
+ }
+
+ function inlineMd(text) {
+ // Order matters: escape first, then re-inject tags.
+ let s = escapeHtml(text);
+ // Code spans
+ s = s.replace(/`([^`]+)`/g, (_, code) => `${code}`);
+ // Links [text](url)
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `${t}`);
+ // Bold
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
+ // Italic (only single *…*, skip if inside bold already handled)
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2');
+ return s;
+ }
+
+ function highlightBold(text) {
+ return inlineMd(text);
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function copyToClipboard(text) {
+ if (!text) return;
+ try {
+ navigator.clipboard.writeText(text);
+ showToast('Copied: ' + text);
+ } catch { /* ignore */ }
+ }
+
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
@@ -1509,6 +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) => `
`);
+ }
+ return out.join('\n');
+ }
+
+ function buildListHtml(items, type) {
+ // Nest by indent (one level deep is plenty for DESIGN.md).
+ let html = `<${type}>`;
+ let lastIndent = 0;
+ for (const it of items) {
+ if (it.indent > lastIndent) html += `<${type}>`;
+ else if (it.indent < lastIndent) html += `${type}>`.repeat(lastIndent - it.indent);
+ html += `
${it.html}
`;
+ lastIndent = it.indent;
+ }
+ html += `${type}>`.repeat(lastIndent + 1);
+ return html;
+ }
+
+ function inlineMd(text) {
+ // Order matters: escape first, then re-inject tags.
+ let s = escapeHtml(text);
+ // Code spans
+ s = s.replace(/`([^`]+)`/g, (_, code) => `${code}`);
+ // Links [text](url)
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `${t}`);
+ // Bold
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
+ // Italic (only single *…*, skip if inside bold already handled)
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2');
+ return s;
+ }
+
+ function highlightBold(text) {
+ return inlineMd(text);
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function copyToClipboard(text) {
+ if (!text) return;
+ try {
+ navigator.clipboard.writeText(text);
+ showToast('Copied: ' + text);
+ } catch { /* ignore */ }
+ }
+
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
@@ -1509,6 +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) => `
`);
+ }
+ return out.join('\n');
+ }
+
+ function buildListHtml(items, type) {
+ // Nest by indent (one level deep is plenty for DESIGN.md).
+ let html = `<${type}>`;
+ let lastIndent = 0;
+ for (const it of items) {
+ if (it.indent > lastIndent) html += `<${type}>`;
+ else if (it.indent < lastIndent) html += `${type}>`.repeat(lastIndent - it.indent);
+ html += `
${it.html}
`;
+ lastIndent = it.indent;
+ }
+ html += `${type}>`.repeat(lastIndent + 1);
+ return html;
+ }
+
+ function inlineMd(text) {
+ // Order matters: escape first, then re-inject tags.
+ let s = escapeHtml(text);
+ // Code spans
+ s = s.replace(/`([^`]+)`/g, (_, code) => `${code}`);
+ // Links [text](url)
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `${t}`);
+ // Bold
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
+ // Italic (only single *…*, skip if inside bold already handled)
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2');
+ return s;
+ }
+
+ function highlightBold(text) {
+ return inlineMd(text);
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function copyToClipboard(text) {
+ if (!text) return;
+ try {
+ navigator.clipboard.writeText(text);
+ showToast('Copied: ' + text);
+ } catch { /* ignore */ }
+ }
+
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
@@ -1509,6 +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) => `
`);
+ }
+ return out.join('\n');
+ }
+
+ function buildListHtml(items, type) {
+ // Nest by indent (one level deep is plenty for DESIGN.md).
+ let html = `<${type}>`;
+ let lastIndent = 0;
+ for (const it of items) {
+ if (it.indent > lastIndent) html += `<${type}>`;
+ else if (it.indent < lastIndent) html += `${type}>`.repeat(lastIndent - it.indent);
+ html += `
${it.html}
`;
+ lastIndent = it.indent;
+ }
+ html += `${type}>`.repeat(lastIndent + 1);
+ return html;
+ }
+
+ function inlineMd(text) {
+ // Order matters: escape first, then re-inject tags.
+ let s = escapeHtml(text);
+ // Code spans
+ s = s.replace(/`([^`]+)`/g, (_, code) => `${code}`);
+ // Links [text](url)
+ s = s.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_, t, u) => `${t}`);
+ // Bold
+ s = s.replace(/\*\*([^*]+)\*\*/g, '$1');
+ // Italic (only single *…*, skip if inside bold already handled)
+ s = s.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, '$1$2');
+ return s;
+ }
+
+ function highlightBold(text) {
+ return inlineMd(text);
+ }
+
+ function escapeHtml(s) {
+ return String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ function copyToClipboard(text) {
+ if (!text) return;
+ try {
+ navigator.clipboard.writeText(text);
+ showToast('Copied: ' + text);
+ } catch { /* ignore */ }
+ }
+
// ---------------------------------------------------------------------------
// Init
// ---------------------------------------------------------------------------
@@ -1509,6 +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) => `
`);
+ }
+ return out.join('\n');
+ }
+
+ function buildListHtml(items, type) {
+ // Nest by indent (one level deep is plenty for DESIGN.md).
+ let html = `<${type}>`;
+ let lastIndent = 0;
+ for (const it of items) {
+ if (it.indent > lastIndent) html += `<${type}>`;
+ else if (it.indent < lastIndent) html += `${type}>`.repeat(lastIndent - it.indent);
+ html += `