mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-13 00:19:03 +02:00
feat(lib): open DESIGN.md reader/writer + gstack-design-md bin
lib/design-md.ts implements the open DESIGN.md format (google-labs-code/
design.md, Apache-2.0): YAML front matter with the five token groups (colors,
typography, rounded, spacing, components) and eight canonical `##` sections
in spec order (Overview, Colors, Typography, Layout, Elevation & Depth,
Shapes, Components, Do's and Don'ts), aliases mapped, extras preserved after
them in their original order. parseDesignMd never throws (unparsable front
matter → `unknown` with a reason); renderDesignMd re-emits the preserved front
matter bytes and only `convert` writes fresh YAML through a small block-style
emitter (Bun.YAML.stringify is flow style); upsertSection splices the body
only; tokensFlat resolves `{path}` references to primitives and reports group,
self, dangling, and cyclic refs as DESIGN_MD_TOKEN_REF_INVALID. convertLegacy
turns gstack's pre-spec DESIGN.md into the open format: Product Context and
Aesthetic Direction fold into Overview, Typography roles become
display/body/label/mono tokens (mono carries fontFeature: tnum), Color hexes
become colors (mode-qualified labels keep their qualifier; strategy lines are
not colors), the Spacing scale and Layout radii become spacing and rounded,
Motion / Grain Texture / Decisions Log survive as extras. The format marker
lives inside the file: a YAML comment on line 2 of a spec file, an HTML
comment on line 1 of a legacy file.
bin/gstack-design-md.ts: `check` (DESIGN_MD_FORMAT + marker), `convert
[--write]` (backup to DESIGN.md.legacy.bak, temp+rename, refuses ambiguous
input with DESIGN_MD_CONVERT_REFUSED), `tokens` (flat JSON), `mark
<spec|legacy-keep>`. Exit 3 + DESIGN_MD_INTERNAL_ERROR is a gstack bug.
design/src/memory.ts: updateDesignMd upserts "Extracted Design Language"
through the lib (front matter bytes untouched, canonical order kept, section
replaced on rerun) and creates a spec skeleton with tokens from the extraction
when no file exists; readDesignConstraints leads with the flat tokens and the
Overview for spec files. The design binary still bundles.
test/design-md.test.ts pins all of it against gstack's own DESIGN.md.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
d3c3ea27b9
commit
e49d1eea49
+52
-21
@@ -15,6 +15,7 @@ import fs from "fs";
|
||||
import path from "path";
|
||||
import { requireApiKey } from "./auth";
|
||||
import { receiptedFetch } from "./receipted-fetch";
|
||||
import { parseDesignMd, detectFormat, renderDesignMd, upsertSection, specSkeleton, tokensFlat } from "../../lib/design-md";
|
||||
|
||||
export interface ExtractedDesign {
|
||||
colors: { name: string; hex: string; usage: string }[];
|
||||
@@ -100,8 +101,13 @@ function defaultDesign(): ExtractedDesign {
|
||||
|
||||
/**
|
||||
* Write or update DESIGN.md with extracted design patterns.
|
||||
* If DESIGN.md exists, appends an "Extracted from mockup" section.
|
||||
* If not, creates a new one.
|
||||
*
|
||||
* Existing file (spec, legacy, or anything with `##` sections): the
|
||||
* "## Extracted Design Language" section is upserted through lib/design-md.ts,
|
||||
* which splices the body only — front matter bytes are never re-emitted, and a
|
||||
* spec file keeps its canonical section order (the extracted section is an extra
|
||||
* after them). New file: a spec-format skeleton whose tokens come from the
|
||||
* extraction (colors by name, typography by role) plus the extracted section.
|
||||
*/
|
||||
export function updateDesignMd(
|
||||
repoRoot: string,
|
||||
@@ -110,30 +116,46 @@ export function updateDesignMd(
|
||||
): void {
|
||||
const designPath = path.join(repoRoot, "DESIGN.md");
|
||||
const timestamp = new Date().toISOString().split("T")[0];
|
||||
|
||||
const section = formatExtractedSection(extracted, sourceMockup, timestamp);
|
||||
const heading = "Extracted Design Language";
|
||||
const body = section.split("\n").slice(1).join("\n"); // drop the "## Extracted Design Language" line
|
||||
|
||||
const write = (content: string) => {
|
||||
const tmp = `${designPath}.tmp-${process.pid}`;
|
||||
fs.writeFileSync(tmp, content);
|
||||
fs.renameSync(tmp, designPath);
|
||||
};
|
||||
|
||||
if (fs.existsSync(designPath)) {
|
||||
// Append to existing DESIGN.md
|
||||
const existing = fs.readFileSync(designPath, "utf-8");
|
||||
|
||||
// Check if there's already an extracted section, replace it
|
||||
const marker = "## Extracted Design Language";
|
||||
if (existing.includes(marker)) {
|
||||
const before = existing.split(marker)[0];
|
||||
fs.writeFileSync(designPath, before.trimEnd() + "\n\n" + section);
|
||||
} else {
|
||||
fs.writeFileSync(designPath, existing.trimEnd() + "\n\n" + section);
|
||||
}
|
||||
const doc = parseDesignMd(fs.readFileSync(designPath, "utf-8"));
|
||||
write(renderDesignMd(upsertSection(doc, heading, body)));
|
||||
console.error(`Updated DESIGN.md with extracted design language`);
|
||||
} else {
|
||||
// Create new DESIGN.md
|
||||
const content = `# Design System
|
||||
|
||||
${section}`;
|
||||
fs.writeFileSync(designPath, content);
|
||||
console.error(`Created DESIGN.md with extracted design language`);
|
||||
return;
|
||||
}
|
||||
|
||||
const colors: Record<string, string> = {};
|
||||
for (const c of extracted.colors) {
|
||||
const key = c.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
if (key && /^#[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 = t.role.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
||||
if (!role || typography[role]) continue;
|
||||
const entry: Record<string, string> = { fontFamily: t.family };
|
||||
if (t.size) entry.fontSize = t.size;
|
||||
if (t.weight) entry.fontWeight = t.weight;
|
||||
typography[role] = entry;
|
||||
}
|
||||
const frontmatter: Record<string, unknown> = {};
|
||||
if (Object.keys(colors).length) frontmatter.colors = colors;
|
||||
if (Object.keys(typography).length) frontmatter.typography = typography;
|
||||
const doc = specSkeleton("Design System", frontmatter, [
|
||||
{ heading: "Overview", body: `${extracted.mood}\n\nCreated by the gstack designer from an approved mockup (${path.basename(sourceMockup)}) on ${timestamp}.` },
|
||||
{ heading, body },
|
||||
]);
|
||||
write(renderDesignMd(doc));
|
||||
console.error(`Created DESIGN.md with extracted design language`);
|
||||
}
|
||||
|
||||
function formatExtractedSection(
|
||||
@@ -198,6 +220,15 @@ export function readDesignConstraints(repoRoot: string): string | null {
|
||||
if (!fs.existsSync(designPath)) return null;
|
||||
|
||||
const content = fs.readFileSync(designPath, "utf-8");
|
||||
const doc = parseDesignMd(content);
|
||||
if (detectFormat(doc).format === "spec") {
|
||||
// Spec file: the normative tokens first, then the Overview prose. Both fit
|
||||
// the brief far better than the first 2000 bytes of YAML would.
|
||||
const { tokens } = tokensFlat(doc.frontmatter);
|
||||
const tokenLines = Object.entries(tokens).map(([k, v]) => `${k}: ${v}`).join("; ");
|
||||
const overview = doc.sections.find((s) => s.canonical === "Overview")?.body ?? "";
|
||||
return `Tokens: ${tokenLines}. ${overview}`.slice(0, 2000);
|
||||
}
|
||||
// Truncate to first 2000 chars to keep brief reasonable
|
||||
return content.slice(0, 2000);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user