mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
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:
co-authored by
Claude Opus 4.7
parent
a5cd7bf859
commit
c160ffc38d
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+118
-79
@@ -1,14 +1,13 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"generatedAt": "2026-04-15T00:00:00Z",
|
||||
"schemaVersion": 2,
|
||||
"generatedAt": "2026-04-23T00:00:00Z",
|
||||
"title": "Design System: Impeccable",
|
||||
"tokens": {
|
||||
"colors": [
|
||||
{
|
||||
"extensions": {
|
||||
"colorMeta": {
|
||||
"editorial-magenta": {
|
||||
"role": "primary",
|
||||
"name": "Editorial Magenta",
|
||||
"value": "oklch(60% 0.25 350)",
|
||||
"description": "The one vibrant voice. Primary CTAs, active navigation, live-state indicators.",
|
||||
"displayName": "Editorial Magenta",
|
||||
"description": "The one vibrant voice. Primary CTAs, active navigation, live-state indicators. Rarity is the design choice.",
|
||||
"tonalRamp": [
|
||||
"oklch(22% 0.12 350)",
|
||||
"oklch(32% 0.18 350)",
|
||||
@@ -20,11 +19,15 @@
|
||||
"oklch(94% 0.04 350)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"editorial-magenta-deep": {
|
||||
"role": "accent",
|
||||
"displayName": "Editorial Magenta Deep",
|
||||
"description": "Hover/active state for Editorial Magenta. Small darkening, confirms interaction without shouting."
|
||||
},
|
||||
"warm-ash-cream": {
|
||||
"role": "neutral",
|
||||
"name": "Warm Ash Cream",
|
||||
"value": "oklch(96% 0.005 350)",
|
||||
"description": "Primary page background. Almost-imperceptible magenta tint.",
|
||||
"displayName": "Warm Ash Cream",
|
||||
"description": "Primary page background. Almost-imperceptible magenta tint that creates subconscious cohesion with the accent.",
|
||||
"tonalRamp": [
|
||||
"oklch(15% 0.005 350)",
|
||||
"oklch(25% 0.005 350)",
|
||||
@@ -36,11 +39,15 @@
|
||||
"oklch(96% 0.005 350)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"crisp-paper-white": {
|
||||
"role": "neutral",
|
||||
"name": "Deep Graphite",
|
||||
"value": "oklch(10% 0 0)",
|
||||
"description": "Primary text, body CTAs. Softer than pure black.",
|
||||
"displayName": "Crisp Paper White",
|
||||
"description": "Pure background for inverted text moments (white-on-dark CTAs) and maximum-contrast surfaces."
|
||||
},
|
||||
"deep-graphite": {
|
||||
"role": "neutral",
|
||||
"displayName": "Deep Graphite",
|
||||
"description": "Primary text and primary-CTA background. Softer than pure black, reads as confident-but-not-aggressive on warm paper.",
|
||||
"tonalRamp": [
|
||||
"oklch(10% 0 0)",
|
||||
"oklch(25% 0 0)",
|
||||
@@ -51,64 +58,73 @@
|
||||
"oklch(92% 0 0)",
|
||||
"oklch(98% 0 0)"
|
||||
]
|
||||
},
|
||||
"soft-charcoal": {
|
||||
"role": "neutral",
|
||||
"displayName": "Soft Charcoal",
|
||||
"description": "Secondary text — taglines, hook paragraphs, supporting copy."
|
||||
},
|
||||
"mid-ash": {
|
||||
"role": "neutral",
|
||||
"displayName": "Mid Ash",
|
||||
"description": "Tertiary text — micro-labels, captions, meta lines. Reads as intentionally recessed metadata."
|
||||
},
|
||||
"paper-mist": {
|
||||
"role": "neutral",
|
||||
"displayName": "Paper Mist",
|
||||
"description": "Hairline borders, section dividers, barely-visible structural seams."
|
||||
},
|
||||
"magenta-whisper": {
|
||||
"role": "accent",
|
||||
"displayName": "Magenta Whisper",
|
||||
"description": "Diffuse glow backdrop under accent elements on hover; subtle selection highlights."
|
||||
},
|
||||
"magenta-veil": {
|
||||
"role": "accent",
|
||||
"displayName": "Magenta Veil",
|
||||
"description": "Stronger translucent tint for focus rings and emphasis shells."
|
||||
}
|
||||
],
|
||||
"typography": [
|
||||
{
|
||||
"role": "display",
|
||||
"name": "Display",
|
||||
"family": "Cormorant Garamond",
|
||||
"fallback": "Georgia, serif",
|
||||
"weight": 300,
|
||||
"style": "italic",
|
||||
"sampleSize": "4.5rem",
|
||||
"lineHeight": "1",
|
||||
"purpose": "Hero title. Light italic for an author-signature feel."
|
||||
},
|
||||
"typographyMeta": {
|
||||
"display": {
|
||||
"displayName": "Display",
|
||||
"purpose": "Hero title only. Light italic for an author-signature feel.",
|
||||
"style": "italic"
|
||||
},
|
||||
{
|
||||
"role": "body",
|
||||
"name": "Body",
|
||||
"family": "Instrument Sans",
|
||||
"fallback": "system-ui, sans-serif",
|
||||
"weight": 400,
|
||||
"style": "normal",
|
||||
"sampleSize": "1rem",
|
||||
"lineHeight": "1.6",
|
||||
"purpose": "All paragraph copy. Capped at 65–75ch."
|
||||
"headline": {
|
||||
"displayName": "Headline",
|
||||
"purpose": "Section headings. Larger editorial moments."
|
||||
},
|
||||
{
|
||||
"role": "label",
|
||||
"name": "Label",
|
||||
"family": "Instrument Sans",
|
||||
"fallback": "system-ui, sans-serif",
|
||||
"weight": 500,
|
||||
"style": "normal",
|
||||
"sampleSize": "0.9rem",
|
||||
"lineHeight": "1",
|
||||
"letterSpacing": "0.05em",
|
||||
"textTransform": "uppercase",
|
||||
"purpose": "CTA labels. Short, declarative."
|
||||
"title": {
|
||||
"displayName": "Title",
|
||||
"purpose": "Hero tagline / section leads. A quieter second display voice.",
|
||||
"style": "italic"
|
||||
},
|
||||
{
|
||||
"role": "mono",
|
||||
"name": "Mono",
|
||||
"family": "Space Grotesk",
|
||||
"fallback": "ui-monospace, monospace",
|
||||
"weight": 500,
|
||||
"style": "normal",
|
||||
"sampleSize": "0.75rem",
|
||||
"lineHeight": "1.4",
|
||||
"letterSpacing": "0.1em",
|
||||
"textTransform": "uppercase",
|
||||
"purpose": "Micro-labels: 'What's included', 'v3.0 Changelog'."
|
||||
"body": {
|
||||
"displayName": "Body",
|
||||
"purpose": "Paragraph copy. Capped at 65–75ch for readability."
|
||||
},
|
||||
"body-lead": {
|
||||
"displayName": "Body Lead",
|
||||
"purpose": "The one or two lead paragraphs on each page. Slightly relaxed leading."
|
||||
},
|
||||
"supporting": {
|
||||
"displayName": "Supporting",
|
||||
"purpose": "Captions, footnotes, supporting context."
|
||||
},
|
||||
"label": {
|
||||
"displayName": "Label",
|
||||
"purpose": "CTA labels. Short, declarative. Uppercase, letter-tracked."
|
||||
},
|
||||
"micro-label": {
|
||||
"displayName": "Micro-Label",
|
||||
"purpose": "\"Works with\", \"What's included\", \"v3.0 Changelog\"."
|
||||
},
|
||||
"mono": {
|
||||
"displayName": "Monospace Meta",
|
||||
"purpose": "Command names in inline prose, periodic-table tile labels."
|
||||
}
|
||||
],
|
||||
"radii": [
|
||||
{ "name": "sm", "value": "4px" },
|
||||
{ "name": "md", "value": "8px" },
|
||||
{ "name": "lg", "value": "12px" },
|
||||
{ "name": "xl", "value": "16px" }
|
||||
],
|
||||
},
|
||||
"shadows": [
|
||||
{
|
||||
"name": "Soft Hover Lift",
|
||||
@@ -123,23 +139,43 @@
|
||||
{
|
||||
"name": "Accent Glow",
|
||||
"value": "0 20px 60px oklch(60% 0.25 350 / 0.15)",
|
||||
"purpose": "Magenta-tinted ambient shadow. Used sparingly."
|
||||
"purpose": "Magenta-tinted ambient shadow under the rare magnetic moment. Used sparingly."
|
||||
}
|
||||
],
|
||||
"spacing": [
|
||||
{ "name": "xs", "value": "8px" },
|
||||
{ "name": "sm", "value": "16px" },
|
||||
{ "name": "md", "value": "24px" },
|
||||
{ "name": "lg", "value": "32px" },
|
||||
{ "name": "xl", "value": "48px" },
|
||||
{ "name": "2xl", "value": "80px" },
|
||||
{ "name": "3xl", "value": "120px" }
|
||||
]
|
||||
"motion": [
|
||||
{
|
||||
"name": "ease-out",
|
||||
"value": "cubic-bezier(0.16, 1, 0.3, 1)",
|
||||
"purpose": "Primary curve. Expo-out feel — objects decelerate smoothly."
|
||||
},
|
||||
{
|
||||
"name": "ease-out-quint",
|
||||
"value": "cubic-bezier(0.22, 1, 0.36, 1)",
|
||||
"purpose": "Slightly sharper alternative. Use for transforms on compact elements."
|
||||
},
|
||||
{
|
||||
"name": "duration-fast",
|
||||
"value": "0.15s",
|
||||
"purpose": "State transitions (color, opacity)."
|
||||
},
|
||||
{
|
||||
"name": "duration-base",
|
||||
"value": "0.3s",
|
||||
"purpose": "Default for transforms and non-color changes."
|
||||
},
|
||||
{
|
||||
"name": "duration-slow",
|
||||
"value": "0.6s",
|
||||
"purpose": "Orchestrated entrances."
|
||||
}
|
||||
],
|
||||
"breakpoints": []
|
||||
},
|
||||
"components": [
|
||||
{
|
||||
"name": "Primary CTA",
|
||||
"kind": "button",
|
||||
"refersTo": "button-primary",
|
||||
"description": "Sharp, squared, uppercase. The editorial signature.",
|
||||
"html": "<button class=\"ds-btn-primary\">GET STARTED</button>",
|
||||
"css": ".ds-btn-primary { display: inline-block; padding: 16px 48px; font-family: 'Instrument Sans', system-ui, sans-serif; font-size: 0.9rem; font-weight: 500; letter-spacing: 0.05em; text-transform: uppercase; color: oklch(98% 0 0); background: oklch(10% 0 0); border: none; border-radius: 0; cursor: pointer; transition: transform 0.2s ease, background 0.2s ease; } .ds-btn-primary:hover { transform: translateY(-2px); background: oklch(60% 0.25 350); }"
|
||||
@@ -154,6 +190,7 @@
|
||||
{
|
||||
"name": "Email Input",
|
||||
"kind": "input",
|
||||
"refersTo": "input-text",
|
||||
"description": "Hairline border, transparent background, magenta focus glow.",
|
||||
"html": "<input type=\"email\" class=\"ds-input-email\" placeholder=\"you@example.com\">",
|
||||
"css": ".ds-input-email { width: 100%; padding: 10px 14px; font-family: 'Instrument Sans', system-ui, sans-serif; font-size: 0.9rem; color: oklch(10% 0 0); background: transparent; border: 1px solid oklch(92% 0 0); border-radius: 6px; outline: none; transition: border-color 0.15s ease, box-shadow 0.15s ease; } .ds-input-email::placeholder { color: oklch(55% 0 0); } .ds-input-email:focus { border-color: oklch(60% 0.25 350); box-shadow: 0 0 0 3px oklch(60% 0.25 350 / 0.15); }"
|
||||
@@ -168,6 +205,7 @@
|
||||
{
|
||||
"name": "Site Navigation",
|
||||
"kind": "nav",
|
||||
"refersTo": "nav-link",
|
||||
"description": "62px compact bar. No underline at rest; accent underline on active.",
|
||||
"html": "<nav class=\"ds-nav\"><span class=\"ds-nav-brand\">/ Impeccable</span><div class=\"ds-nav-links\"><a href=\"#\" class=\"ds-nav-link ds-nav-active\">Home</a><a href=\"#\" class=\"ds-nav-link\">Docs</a><a href=\"#\" class=\"ds-nav-link\">Anti-patterns</a></div></nav>",
|
||||
"css": ".ds-nav { display: flex; align-items: center; justify-content: space-between; gap: 24px; padding: 12px 20px; background: oklch(96% 0.005 350); font-family: 'Instrument Sans', system-ui, sans-serif; } .ds-nav-brand { font-family: 'Cormorant Garamond', Georgia, serif; font-style: italic; font-size: 1.05rem; color: oklch(10% 0 0); } .ds-nav-links { display: flex; gap: 16px; } .ds-nav-link { font-size: 0.85rem; font-weight: 500; color: oklch(10% 0 0); text-decoration: none; padding-bottom: 3px; border-bottom: 1px solid transparent; transition: color 0.2s ease, border-color 0.2s ease; } .ds-nav-link:hover { color: oklch(60% 0.25 350); } .ds-nav-active { border-bottom-color: oklch(60% 0.25 350); }"
|
||||
@@ -175,6 +213,7 @@
|
||||
{
|
||||
"name": "Feature Card",
|
||||
"kind": "card",
|
||||
"refersTo": "card-feature",
|
||||
"description": "Standard card: 12px radius, hairline border, hover lift with soft shadow.",
|
||||
"html": "<article class=\"ds-card\"><h3 class=\"ds-card-title\">/impeccable polish</h3><p class=\"ds-card-body\">Sweep a site for AI-tool tells and refine typography, spacing, color in one pass.</p></article>",
|
||||
"css": ".ds-card { padding: 20px 22px; background: oklch(98% 0 0); border: 1px solid oklch(92% 0 0); border-radius: 12px; transition: transform 0.2s ease, box-shadow 0.2s ease; font-family: 'Instrument Sans', system-ui, sans-serif; } .ds-card:hover { transform: translateY(-2px); box-shadow: 0 4px 24px -4px rgba(0,0,0,0.12), 0 1px 3px rgba(0,0,0,0.06); } .ds-card-title { font-family: 'Space Grotesk', ui-monospace, monospace; font-size: 0.85rem; font-weight: 500; color: oklch(10% 0 0); margin: 0 0 6px; } .ds-card-body { font-size: 0.85rem; line-height: 1.55; color: oklch(25% 0 0); margin: 0; }"
|
||||
|
||||
+6
-1
@@ -18,7 +18,7 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { readSourceFiles, readPatterns } from './lib/utils.js';
|
||||
import { readSourceFiles, readPatterns, stashPerProjectArtifacts, restorePerProjectArtifacts } from './lib/utils.js';
|
||||
import { createTransformer, PROVIDERS } from './lib/transformers/index.js';
|
||||
import { createAllZips } from './lib/zip.js';
|
||||
import { generateSubPages } from './build-sub-pages.js';
|
||||
@@ -631,8 +631,13 @@ async function build() {
|
||||
const skillsDest = path.join(ROOT_DIR, configDir, 'skills');
|
||||
|
||||
if (fs.existsSync(skillsSrc)) {
|
||||
// Preserve per-project script artifacts (e.g. live-mode config.json)
|
||||
// across the rm + recopy. The build intentionally doesn't ship them,
|
||||
// so without this the sync destroys local state on every rebuild.
|
||||
const stashed = stashPerProjectArtifacts(skillsDest);
|
||||
if (fs.existsSync(skillsDest)) fs.rmSync(skillsDest, { recursive: true });
|
||||
copyDirSync(skillsSrc, skillsDest);
|
||||
restorePerProjectArtifacts(skillsDest, stashed);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+43
-10
@@ -1,6 +1,45 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
// Per-project artifacts live inside `scripts/` of an installed skill but
|
||||
// belong to the consuming project, not the distributable skill. The build
|
||||
// excludes them from dist, and the harness-sync step preserves them across
|
||||
// the rm+recopy so local state isn't destroyed on every rebuild.
|
||||
// - config.json: live-mode inject target list for the current project.
|
||||
// Written by the agent at first /impeccable live; tied to the project's
|
||||
// filesystem layout. Losing it resets the user's glob + exclusions.
|
||||
export const PER_PROJECT_SCRIPT_ARTIFACTS = new Set(['config.json']);
|
||||
|
||||
// Walk the harness-dir skill tree and return any per-project script
|
||||
// artifacts found, ready for restoration after a full sync rm+recopy.
|
||||
// Returns [{ relPath, content: Buffer }], where relPath is relative to
|
||||
// the passed-in rootDir (typically `<configDir>/skills`).
|
||||
export function stashPerProjectArtifacts(rootDir) {
|
||||
if (!fs.existsSync(rootDir)) return [];
|
||||
const out = [];
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) { walk(p); continue; }
|
||||
// Only preserve files inside a skill's scripts/ directory.
|
||||
if (path.basename(path.dirname(p)) !== 'scripts') continue;
|
||||
if (PER_PROJECT_SCRIPT_ARTIFACTS.has(entry.name)) {
|
||||
out.push({ relPath: path.relative(rootDir, p), content: fs.readFileSync(p) });
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(rootDir);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function restorePerProjectArtifacts(rootDir, stashed) {
|
||||
for (const { relPath, content } of stashed) {
|
||||
const target = path.join(rootDir, relPath);
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, content);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse frontmatter from markdown content
|
||||
* Returns { frontmatter: object, body: string }
|
||||
@@ -147,20 +186,14 @@ export function readSourceFiles(rootDir) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read script files if they exist.
|
||||
//
|
||||
// Per-project artifacts (state files that belong to the consuming
|
||||
// project, not the distributable skill) must be excluded here so
|
||||
// the build never bundles them into the skill that ships to users.
|
||||
// - config.json: the live-mode inject-target list for the current
|
||||
// project. Written by the agent at first /impeccable live; tied
|
||||
// to the project's filesystem layout.
|
||||
const PER_PROJECT_ARTIFACTS = new Set(['config.json']);
|
||||
// Read script files if they exist. PER_PROJECT_SCRIPT_ARTIFACTS
|
||||
// (defined at module top) are excluded from the distributable skill
|
||||
// so the build never bundles one project's state into another's.
|
||||
const scripts = [];
|
||||
const scriptsDir = path.join(entryPath, 'scripts');
|
||||
if (fs.existsSync(scriptsDir)) {
|
||||
const scriptFiles = fs.readdirSync(scriptsDir).filter(f => {
|
||||
if (PER_PROJECT_ARTIFACTS.has(f)) return false;
|
||||
if (PER_PROJECT_SCRIPT_ARTIFACTS.has(f)) return false;
|
||||
return fs.statSync(path.join(scriptsDir, f)).isFile();
|
||||
});
|
||||
for (const scriptFile of scriptFiles) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user