fix(design-md): markdown edge cases: rule-opened legacy files, spaced fences, ~~~ blocks, duplicate headings, YAML 1.2 numerics

- insertMarker keyed on "starts with ---", so a legacy file opening with a
  horizontal rule got a `# gstack:` line rendered as a heading that the
  parser then never read back (the conversion question re-asked every run).
  It keys on parsed front matter.
- A closing front-matter fence with trailing spaces (`---  `) made a valid
  spec file `unknown`; the closer is any whole `---` line.
- `~~~` fences hid nothing, so a `## ` inside one was a section boundary and
  a splice corrupted the fence; both fence kinds are tracked and only the
  same kind closes an opener.
- convertLegacy silently kept the first of two `## Layout` bodies (and one
  of `## Color` / `## Colors`); it refuses with DESIGN_MD_CONVERT_REFUSED and
  the bin leaves the file and writes no backup.
- needsQuotes covers 0x / 0o / .inf / .nan (YAML 1.2 numerics that changed
  type on round-trip); emitYamlBlock throws on an object inside an array
  instead of writing "[object Object]".
- The design binary coerces the model's extraction JSON at the parse
  boundary (null names, missing arrays) so the paid call's result survives.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-08 18:55:37 +00:00
co-authored by Claude Fable 5.1
parent 3994ddfdd7
commit 69b0856d42
4 changed files with 110 additions and 19 deletions
+16 -5
View File
@@ -84,7 +84,18 @@ Extract real values from what you see. Be specific about hex colors and font siz
const data = await response.json() as any;
const content = data.choices?.[0]?.message?.content?.trim() || "";
return JSON.parse(content) as ExtractedDesign;
// The model's JSON is unvalidated: default the arrays and coerce the strings so a null name
// cannot throw after the paid vision call.
const raw = JSON.parse(content) as Partial<Record<keyof ExtractedDesign, unknown>>;
const list = (v: unknown) => (Array.isArray(v) ? v : []);
const str = (v: unknown) => (v === null || v === undefined ? "" : String(v));
return {
colors: list(raw.colors).map((c) => ({ name: str((c as Record<string, unknown>)?.name), hex: str((c as Record<string, unknown>)?.hex), usage: str((c as Record<string, unknown>)?.usage) })),
typography: list(raw.typography).map((t) => ({ role: str((t as Record<string, unknown>)?.role), family: str((t as Record<string, unknown>)?.family), size: str((t as Record<string, unknown>)?.size), weight: str((t as Record<string, unknown>)?.weight) })),
spacing: list(raw.spacing).map(str),
layout: list(raw.layout).map(str),
mood: str(raw.mood),
};
} catch (err: any) {
console.error(`Design extraction error: ${err.message}`);
return defaultDesign();
@@ -138,13 +149,13 @@ export function updateDesignMd(
const designPath = linkPath;
const colors: Record<string, string> = {};
for (const c of extracted.colors) {
const key = slug(c.name);
for (const c of extracted.colors ?? []) {
const key = slug(String(c.name ?? ""));
if (key !== "token" && /^#[0-9a-fA-F]{3,8}$/.test(c.hex) && !(key in colors)) colors[key] = c.hex;
}
const typography: Record<string, Record<string, string>> = {};
for (const t of extracted.typography) {
const role = slug(t.role);
for (const t of extracted.typography ?? []) {
const role = slug(String(t.role ?? ""));
if (role === "token" || typography[role]) continue;
const entry: Record<string, string> = { fontFamily: t.family };
if (t.size) entry.fontSize = t.size;