From 85f84bf620745b2c017fba78aef86f09489105ca Mon Sep 17 00:00:00 2001 From: CypherPoet <46851636+CypherPoet@users.noreply.github.com> Date: Mon, 3 Aug 2026 16:59:00 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B=20Fix=20DESIGN.md=20Layout=20and?= =?UTF-8?q?=20Shapes=20parsing=20(#481)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🐛 Fix DESIGN.md Layout and Shapes parsing Prepared with AI assistance. * ♻️ Refine canonical design parser coverage Prepared with AI assistance. --- skill/scripts/lib/design-parser.mjs | 41 +++++++++--- skill/scripts/live-browser.js | 2 + skill/scripts/live-server.mjs | 2 +- tests/design-parser.test.mjs | 88 +++++++++++++++++++++++++- tests/live-browser-regression.test.mjs | 16 +++++ 5 files changed, 138 insertions(+), 11 deletions(-) diff --git a/skill/scripts/lib/design-parser.mjs b/skill/scripts/lib/design-parser.mjs index f82370bb2..7b060eeca 100644 --- a/skill/scripts/lib/design-parser.mjs +++ b/skill/scripts/lib/design-parser.mjs @@ -2,15 +2,20 @@ // the live-mode design-system panel can render. Deterministic, dependency-free. // // Two-layer: YAML frontmatter (machine-readable tokens) + markdown body -// (prose with six canonical H2 sections). When frontmatter is present, it's +// (prose with eight canonical H2 sections). When frontmatter is present, it's // exposed on `model.frontmatter` alongside the prose-scraped sections; // consumers can prefer frontmatter values and fall back to prose. +// Array order is also match precedence: matchCanonicalSection's keyword-contained +// pass returns the first entry a heading contains, so reordering this changes +// which section an ambiguous heading resolves to. const CANONICAL_SECTIONS = [ 'Overview', 'Colors', 'Typography', + 'Layout', 'Elevation', + 'Shapes', 'Components', "Do's and Don'ts", ]; @@ -662,11 +667,19 @@ function parseTypeBullet(bullet) { }; } -function extractElevation(section) { +function extractGuidance(section) { if (!section) return null; const subs = splitSubsections(section.lines); + return { + subtitle: section.subtitle, + description: collectParagraphs(subs[0].lines).join(' ') || null, + rules: extractNamedRules(section.lines), + }; +} - const description = collectParagraphs(subs[0].lines).join(' ') || null; +function extractElevation(section) { + const guidance = extractGuidance(section); + if (!guidance) return null; const shadows = []; const seen = new Set(); @@ -691,12 +704,7 @@ function extractElevation(section) { for (const inline of extractInlineShadows(b)) dedupe(inline); } - return { - subtitle: section.subtitle, - description, - shadows, - rules: extractNamedRules(section.lines), - }; + return { ...guidance, shadows }; } function extractInlineShadows(text) { @@ -828,6 +836,15 @@ function extractDosDonts(section) { // ---------- Coverage assessment ---------- +// Sections whose model is description-plus-rules only (see extractGuidance). +const guidanceCoverage = (guidance) => + guidance + ? { + description: Boolean(guidance.description), + rules: guidance.rules.length, + } + : 'missing'; + function assessCoverage(model) { const report = {}; @@ -856,6 +873,8 @@ function assessCoverage(model) { } : 'missing'; + report.layout = guidanceCoverage(model.layout); + report.elevation = model.elevation ? { shadows: model.elevation.shadows.length, @@ -864,6 +883,8 @@ function assessCoverage(model) { } : 'missing'; + report.shapes = guidanceCoverage(model.shapes); + report.components = model.components ? { count: model.components.components.length, @@ -893,7 +914,9 @@ export function parseDesignMd(md) { overview: extractOverview(sections['Overview']), colors: extractColors(sections['Colors']), typography: extractTypography(sections['Typography']), + layout: extractGuidance(sections['Layout']), elevation: extractElevation(sections['Elevation']), + shapes: extractGuidance(sections['Shapes']), components: extractComponents(sections['Components']), dosDonts: extractDosDonts(sections["Do's and Don'ts"]), }; diff --git a/skill/scripts/live-browser.js b/skill/scripts/live-browser.js index 0b8debeca..aa9bd759b 100644 --- a/skill/scripts/live-browser.js +++ b/skill/scripts/live-browser.js @@ -11975,7 +11975,9 @@ void main() { rules: [ ...(md.colors?.rules || []).map((r) => ({ ...r, section: 'colors' })), ...(md.typography?.rules || []).map((r) => ({ ...r, section: 'typography' })), + ...(md.layout?.rules || []).map((r) => ({ ...r, section: 'layout' })), ...(md.elevation?.rules || []).map((r) => ({ ...r, section: 'elevation' })), + ...(md.shapes?.rules || []).map((r) => ({ ...r, section: 'shapes' })), ], dos: md.dosDonts?.dos || [], donts: md.dosDonts?.donts || [], diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index 29ed19bcb..86b7777be 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -873,7 +873,7 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // { present, parsed, sidecar, hasMd, hasSidecar, // mdNewerThanJson, parseError?, sidecarError? } // - parsed: output of parseDesignMd (frontmatter - // + six canonical sections) when DESIGN.md exists. + // + the canonical sections) when DESIGN.md exists. // - sidecar: .impeccable/design.json contents when present. // Expected shape: schemaVersion 2, carrying // extensions + components + narrative. diff --git a/tests/design-parser.test.mjs b/tests/design-parser.test.mjs index 7987841fd..db78d4f12 100644 --- a/tests/design-parser.test.mjs +++ b/tests/design-parser.test.mjs @@ -5,7 +5,7 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; -import { parseDesignMd } from '../skill/scripts/lib/design-parser.mjs'; +import { assessCoverage, parseDesignMd } from '../skill/scripts/lib/design-parser.mjs'; describe('parseDesignMd frontmatter branch', () => { it('returns null frontmatter when the file has no YAML header', () => { @@ -227,3 +227,89 @@ describe('parseDesignMd overview branch', () => { assert.deepEqual(overview.philosophy, []); }); }); + +describe('parseDesignMd canonical sections', () => { + it('preserves content and named rules from all eight canonical sections', () => { + const md = `# Design System: Complete + +## Overview + +**Creative North Star: "Structured clarity"** + +## Colors + +### Primary +- **Ink** (#111111): Primary text. + +## Typography + +**Body Font:** Inter (with sans-serif) + +## Layout: Responsive rhythm + +Primary regions use a twelve-column grid that collapses to one column on narrow screens. + +### Named Rules +**The Spatial Hierarchy Rule.** Primary content must remain visually dominant. + +## Elevation & Depth + +Surfaces use tonal layering instead of shadows. + +## Shapes + +Selected objects use a double outline and clipped corners. + +### The "Recognizable Silhouette" Rule +Repeated geometry must remain recognizable without color. + +## Components + +### Button +- **Primary:** Uses the accent color. + +## Do's and Don'ts + +### Do +- Preserve the spatial hierarchy. + +### Don't +- Flatten every surface. +`; + const model = parseDesignMd(md); + + assert.equal(model.layout.subtitle, 'Responsive rhythm'); + assert.equal( + model.layout.description, + 'Primary regions use a twelve-column grid that collapses to one column on narrow screens.', + ); + assert.deepEqual(model.layout.rules, [{ + name: 'The Spatial Hierarchy Rule', + body: 'Primary content must remain visually dominant.', + }]); + assert.equal(model.shapes.description, 'Selected objects use a double outline and clipped corners.'); + assert.deepEqual(model.shapes.rules, [{ + name: 'The Recognizable Silhouette Rule', + body: 'Repeated geometry must remain recognizable without color.', + }]); + + const coverage = assessCoverage(model); + assert.deepEqual( + Object.entries(coverage).filter(([, v]) => v === 'missing').map(([k]) => k), + [], + 'a section present in the markdown must not be reported as missing', + ); + assert.deepEqual(Object.keys(coverage), [ + 'overview', + 'colors', + 'typography', + 'layout', + 'elevation', + 'shapes', + 'components', + 'dosDonts', + ]); + assert.deepEqual(coverage.layout, { description: true, rules: 1 }); + assert.deepEqual(coverage.shapes, { description: true, rules: 1 }); + }); +}); diff --git a/tests/live-browser-regression.test.mjs b/tests/live-browser-regression.test.mjs index 4811f20c4..04688c3b3 100644 --- a/tests/live-browser-regression.test.mjs +++ b/tests/live-browser-regression.test.mjs @@ -1333,6 +1333,22 @@ describe('live-browser.js regression guards', () => { ); }); + it('includes named rules from every canonical narrative section', () => { + const narrativeStart = SOURCE.indexOf(' function synthesizeNarrative(parsed) {'); + const narrativeEnd = SOURCE.indexOf('\n function renderColorTiles', narrativeStart); + assert.notEqual(narrativeStart, -1, 'synthesizeNarrative must exist'); + assert.notEqual(narrativeEnd, -1, 'synthesizeNarrative must end before renderColorTiles'); + const narrativeSource = SOURCE.slice(narrativeStart, narrativeEnd); + + for (const section of ['colors', 'typography', 'layout', 'elevation', 'shapes']) { + assert.match( + narrativeSource, + new RegExp(`md\\.${section}\\?\\.rules[^\\n]*section: '${section}'`), + `the design panel must carry named rules tagged with their ${section} section`, + ); + } + }); + it('editing focus timeout does not read a stale inline edit row', () => { assert.doesNotMatch( SOURCE,