feat(live): v2 sidecar upgrade + preserve per-project config on build

Unify the design-system panel's data shape around DESIGN.md frontmatter
as the primary source of truth; the sidecar carries only what Stitch's
frontmatter schema can't (extensions + live component HTML + narrative).
Also fix a long-standing build bug that destroyed per-project config.

Shape changes:
- Server /design-system.json now returns { parsed, sidecar, hasMd,
  hasSidecar, mdNewerThanJson, parseError?, sidecarError? }. No more
  mode switching; both layers ship when present and the panel merges.
- Panel consolidates renderSidecarVisual + renderParsedMdVisual into a
  single renderDesignVisual that merges frontmatter primitives with
  sidecar extensions.colorMeta / typographyMeta. Helpers for color,
  typography, radii model-building. Parsed-md narrative synthesis
  survives as a fallback when no sidecar.
- DESIGN.json rewritten at schemaVersion 2: extensions.{colorMeta,
  typographyMeta, shadows, motion, breakpoints} + components (with
  refersTo pointing back to frontmatter component keys) + narrative.
  Token primitives no longer duplicated in the sidecar.

Build fix:
- scripts/build.js:634 wiped .claude/skills/ (and every other harness
  dir) on each rebuild, then recopied from dist. After commit b0feed0
  unbundled per-project config.json from dist, the sync destroyed the
  user's live-mode config on every build without replacing it.
- Added stashPerProjectArtifacts / restorePerProjectArtifacts in
  scripts/lib/utils.js. Hoisted PER_PROJECT_SCRIPT_ARTIFACTS to a
  module-level export so build.js and readSourceFiles share one
  source of truth. Build now preserves config.json across the sync.

Verified in browser: panel renders 10 colors, 9 typography roles, 3
shadows, 6 grouped components, 9 rules, 25 do/don't items, all merged
correctly from frontmatter + v2 sidecar with zero console errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-23 12:09:55 -07:00
co-authored by Claude Opus 4.7
parent a5cd7bf859
commit c160ffc38d
27 changed files with 2063 additions and 1170 deletions
+129 -66
View File
@@ -3450,9 +3450,10 @@ void main() {
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
parsed: null, // parseDesignMd output (frontmatter + body sections)
sidecar: null, // DESIGN.json v2 payload (extensions + components + narrative)
hasMd: false,
hasSidecar: false,
present: null, // true/false once fetch resolves
raw: null, // raw DESIGN.md for the raw tab
mdNewerThanJson: false, // stale-hint flag
@@ -3885,12 +3886,13 @@ void main() {
]);
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.parsed = jsonData.parsed || null;
designState.sidecar = jsonData.sidecar || null;
designState.hasMd = !!jsonData.hasMd;
designState.hasSidecar = !!jsonData.hasSidecar;
designState.mdNewerThanJson = !!jsonData.mdNewerThanJson;
designState.raw = designState.present && rawRes.ok ? await rawRes.text() : null;
designState.error = jsonData.error || null;
designState.error = jsonData.parseError || jsonData.sidecarError || null;
} catch (err) {
designState.error = err?.message || 'Failed to load design system.';
} finally {
@@ -3925,17 +3927,12 @@ void main() {
return;
}
// Visual tab
// Visual tab — single unified render path.
if (designState.mdNewerThanJson) body.appendChild(renderStaleHint());
if (designState.mode === 'sidecar' && designState.model) {
renderSidecarVisual(body, designState.model);
} else if (designState.mode === 'parsed-md' && designState.parsedMd) {
if (designState.hasMd && !designState.hasSidecar) {
body.appendChild(renderParsedMdCta());
renderParsedMdVisual(body, designState.parsedMd);
} else {
body.appendChild(msgDiv('empty', 'No design system data available.'));
}
renderDesignVisual(body, designState.parsed, designState.sidecar);
}
function msgDiv(cls, text) {
@@ -3962,27 +3959,129 @@ void main() {
return box;
}
// --- Sidecar (DESIGN.json) rendering --------------------------------------
// --- Unified render: merge parsed DESIGN.md frontmatter with sidecar v2 ---
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);
function renderDesignVisual(body, parsed, sidecar) {
const frontmatter = parsed?.frontmatter || {};
const extensions = sidecar?.extensions || {};
const proseColors = parsed?.colors || null;
const colors = buildColorModels(frontmatter.colors, extensions.colorMeta, proseColors);
if (colors.length) renderColorTiles(body, colors);
const types = buildTypographyModels(frontmatter.typography, extensions.typographyMeta);
if (types.length) renderTypeTiles(body, types);
const radii = buildRadiiModels(frontmatter.rounded);
if (radii.length) renderRadiiTile(body, radii);
if (extensions.shadows?.length) renderShadowTiles(body, extensions.shadows);
const components = sidecar?.components || [];
if (components.length) renderComponentTiles(body, components);
// Narrative: sidecar wins if present (richer, agent-curated). Otherwise
// synthesize from prose sections.
const narrative = sidecar?.narrative || synthesizeNarrative(parsed);
if (narrative.rules?.length) body.appendChild(renderRulesCollapsible(narrative.rules));
if ((narrative.dos?.length || narrative.donts?.length)) body.appendChild(renderDosDontsCollapsible(narrative));
if (narrative.overview || narrative.northStar || narrative.keyCharacteristics?.length) {
body.appendChild(renderOverviewCollapsible(narrative));
}
// 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));
if (body.childElementCount === 0) {
body.appendChild(msgDiv('empty', 'No design system data available.'));
}
}
// Frontmatter primitives + sidecar colorMeta → tile-ready color models.
// A matching prose bullet (when the slug sits in the bullet text) supplies
// description as a last-resort fallback.
function buildColorModels(fmColors, colorMeta, proseColors) {
if (!fmColors) return [];
const meta = colorMeta || {};
return Object.entries(fmColors).map(([key, value]) => {
const m = meta[key] || {};
return {
role: m.role || humanizeKey(key),
name: m.displayName || humanizeKey(key),
value: value,
canonical: m.canonical || null,
description: m.description || findProseDescription(proseColors, key, m.displayName),
tonalRamp: m.tonalRamp || null,
};
});
}
function buildTypographyModels(fmTypography, typographyMeta) {
if (!fmTypography) return [];
const meta = typographyMeta || {};
return Object.entries(fmTypography).map(([key, spec]) => {
const m = meta[key] || {};
const { family, fallback } = splitFontFamily(spec?.fontFamily);
return {
role: key,
name: m.displayName || humanizeKey(key),
family,
fallback,
weight: spec?.fontWeight ?? 400,
// fontStyle isn't in Stitch's frontmatter schema; the sidecar carries
// it when a role is rendered in italic (e.g. display italic).
style: m.style || 'normal',
sampleSize: spec?.fontSize || '1rem',
lineHeight: spec?.lineHeight != null ? String(spec.lineHeight) : '',
letterSpacing: spec?.letterSpacing,
purpose: m.purpose,
};
});
}
function buildRadiiModels(fmRounded) {
if (!fmRounded) return [];
return Object.entries(fmRounded).map(([name, value]) => ({ name, value }));
}
function splitFontFamily(stack) {
if (!stack || typeof stack !== 'string') return { family: '', fallback: '' };
const parts = stack.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, ''));
return { family: parts[0] || '', fallback: parts.slice(1).join(', ') };
}
function humanizeKey(k) {
return String(k || '').replace(/[-_]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
}
function findProseDescription(proseColors, key, displayName) {
if (!proseColors || !proseColors.groups) return null;
const needles = [key, displayName].filter(Boolean).map((s) => s.toLowerCase());
for (const g of proseColors.groups) {
for (const c of g.colors || []) {
const hay = String(c.name || '').toLowerCase();
if (hay && needles.some((n) => hay.includes(n) || n.includes(hay))) {
return c.description || null;
}
}
}
return null;
}
function synthesizeNarrative(parsed) {
if (!parsed) return {};
const md = parsed;
return {
northStar: md.overview?.creativeNorthStar,
overview: (md.overview?.philosophy || []).join(' '),
keyCharacteristics: md.overview?.keyCharacteristics || [],
rules: [
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
],
dos: md.dosDonts?.dos || [],
donts: md.dosDonts?.donts || [],
};
}
function renderColorTiles(body, colors) {
for (const c of colors) {
const tile = document.createElement('div');
@@ -4314,42 +4413,6 @@ void main() {
return wrap;
}
// --- Parsed-md fallback visual (limited view: no live components) ---------
function renderParsedMdVisual(body, md) {
// Reuse sidecar renderers by projecting parsed-md output into the model shape.
const pseudoColors = (md.colors?.groups || []).flatMap((g) =>
(g.colors || []).map((c) => ({ role: g.role, name: c.name, value: c.value, description: c.description }))
);
if (pseudoColors.length) renderColorTiles(body, pseudoColors);
const pseudoTypes = Object.entries(md.typography?.fonts || {}).map(([role, f]) => ({
role, name: f.family, family: f.family, fallback: f.fallback, weight: 400,
purpose: f.purpose,
}));
if (pseudoTypes.length) renderTypeTiles(body, pseudoTypes);
if (md.elevation?.shadows?.length) renderShadowTiles(body, md.elevation.shadows);
const n = {
northStar: md.overview?.creativeNorthStar,
overview: (md.overview?.philosophy || []).join(' '),
keyCharacteristics: md.overview?.keyCharacteristics || [],
rules: [
...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })),
...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })),
...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })),
],
dos: md.dosDonts?.dos || [],
donts: md.dosDonts?.donts || [],
};
if (n.rules.length) body.appendChild(renderRulesCollapsible(n.rules));
if (n.dos.length || n.donts.length) body.appendChild(renderDosDontsCollapsible(n));
if (n.overview || n.northStar || n.keyCharacteristics.length) {
body.appendChild(renderOverviewCollapsible(n));
}
}
function cssSafe(v) {
// Strip anything outside valid CSS value chars to prevent injection via
// DESIGN.json values rendered into inline style strings.
@@ -300,9 +300,16 @@ function createRequestHandler({ detectScript, livePath }) {
return;
}
// --- Design system sidecar + raw ---
// /design-system.json prefers DESIGN.json; falls back to parsed DESIGN.md
// returns { mode, model, mdNewerThanJson, ... }
// --- Design system (unified v2 response) + raw ---
// /design-system.json returns both parsed DESIGN.md and DESIGN.json
// sidecar when present. Panel merges them:
// { present, parsed, sidecar, hasMd, hasSidecar,
// mdNewerThanJson, parseError?, sidecarError? }
// - parsed: output of parseDesignMd (frontmatter
// + six canonical sections) when DESIGN.md exists.
// - sidecar: DESIGN.json contents when present.
// Expected shape: schemaVersion 2, carrying
// extensions + components + narrative.
// /design-system/raw returns DESIGN.md markdown verbatim
if (p === '/design-system.json' || p === '/design-system/raw') {
const token = url.searchParams.get('token');
@@ -326,33 +333,31 @@ function createRequestHandler({ detectScript, livePath }) {
return;
}
// Prefer DESIGN.json — it's the richer source (live component HTML).
if (jsonStat) {
let model;
const response = {
present: true,
hasMd: !!mdStat,
hasSidecar: !!jsonStat,
mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000),
};
if (mdStat) {
try {
model = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8'));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: true, error: 'Failed to parse DESIGN.json: ' + err.message }));
return;
response.parseError = err.message;
}
const mdNewerThanJson = !!(mdStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: true, mode: 'sidecar', model, mdNewerThanJson }));
return;
}
// Fallback: DESIGN.md present but no sidecar. Panel shows a "basic mode"
// view + a CTA to run /impeccable document for the full visualization.
try {
const raw = fs.readFileSync(mdPath, 'utf-8');
const parsedMd = parseDesignMd(raw);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: true, mode: 'parsed-md', parsedMd }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ present: true, error: err.message }));
if (jsonStat) {
try {
response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
} catch (err) {
response.sidecarError = 'Failed to parse DESIGN.json: ' + err.message;
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(response));
return;
}