@mandujs/core 0.44.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,555 @@
1
+ /**
2
+ * DESIGN.md parser — the 9-section heading walker.
3
+ *
4
+ * Strategy:
5
+ * 1. Optional H1 title at the top of the file.
6
+ * 2. Scan H2 headings. Match each against the 9 canonical sections
7
+ * via a fuzzy-but-bounded resolver (lowercase, strip `&` / `'`,
8
+ * collapse whitespace). Unrecognized headings land in
9
+ * `extraSections` so the file round-trips cleanly.
10
+ * 3. For each matched section, parse the body into structured
11
+ * tokens with a section-specific extractor. Extractors are
12
+ * forgiving — a malformed row is skipped, never fatal.
13
+ *
14
+ * Parsing must NEVER throw on real-world DESIGN.md files. The 69
15
+ * brand entries in awesome-design-md vary wildly in formatting; the
16
+ * parser is the contract that smooths them out for the rest of Mandu.
17
+ *
18
+ * @module core/design/parser
19
+ */
20
+
21
+ import type {
22
+ AgentPrompt,
23
+ AnyDesignSection,
24
+ ColorToken,
25
+ ComponentToken,
26
+ DesignSectionId,
27
+ DesignSpec,
28
+ DoDontRule,
29
+ ResponsiveBreakpoint,
30
+ ShadowToken,
31
+ SpacingToken,
32
+ TypographyToken,
33
+ ValidationIssue,
34
+ ValidationResult,
35
+ } from "./types";
36
+
37
+ import { DESIGN_SECTION_IDS } from "./types";
38
+
39
+ // ────────────────────────────────────────────────────────────────────
40
+ // Heading → section-id resolution
41
+ // ────────────────────────────────────────────────────────────────────
42
+
43
+ /**
44
+ * Match heading text against the 9 canonical section ids. Returns the
45
+ * id when the heading "looks like" the section by keyword, else null.
46
+ * The keyword sets are intentionally redundant to absorb the wording
47
+ * differences across the awesome-design-md catalog.
48
+ */
49
+ function resolveSectionId(heading: string): DesignSectionId | null {
50
+ const h = heading
51
+ .toLowerCase()
52
+ .replace(/[&'`*_~]/g, "")
53
+ .replace(/\s+/g, " ")
54
+ .trim();
55
+ if (/^visual|^theme|^philosophy|^vibe|^aesthetic/.test(h)) return "theme";
56
+ if (/color|palette/.test(h)) return "color-palette";
57
+ if (/typograph|typeface|font|type scale/.test(h)) return "typography";
58
+ if (/component|button|card|input/.test(h)) return "components";
59
+ if (/layout|spacing|grid|whitespace/.test(h)) return "layout";
60
+ if (/shadow|elevation|depth/.test(h)) return "shadows";
61
+ if (/do.{0,3}dont|guideline|principle|rule/.test(h)) return "dos-donts";
62
+ if (/responsive|breakpoint|mobile|tablet|desktop/.test(h)) return "responsive";
63
+ if (/agent|prompt|llm|ai/.test(h)) return "agent-prompts";
64
+ return null;
65
+ }
66
+
67
+ // ────────────────────────────────────────────────────────────────────
68
+ // H2 splitter
69
+ // ────────────────────────────────────────────────────────────────────
70
+
71
+ interface RawSection {
72
+ heading: string;
73
+ /** `1` or `2` — H1 or H2. */
74
+ level: number;
75
+ body: string;
76
+ }
77
+
78
+ /**
79
+ * Strip HTML comments (`<!-- … -->`) before structural parsing. The
80
+ * empty-skeleton template uses HTML comments to show example tokens
81
+ * without having them counted as real tokens; the parser must respect
82
+ * that convention. Multi-line comments are supported.
83
+ */
84
+ function stripHtmlComments(source: string): string {
85
+ return source.replace(/<!--[\s\S]*?-->/g, "");
86
+ }
87
+
88
+ function splitByHeadings(source: string): {
89
+ title?: string;
90
+ sections: RawSection[];
91
+ } {
92
+ const lines = stripHtmlComments(source).split(/\r?\n/);
93
+ let title: string | undefined;
94
+ const sections: RawSection[] = [];
95
+ let current: RawSection | null = null;
96
+
97
+ // H1 detection — first non-empty line is `# Foo` or first `# Foo` before
98
+ // any H2. Only the first H1 is treated as title.
99
+ let titleConsumed = false;
100
+
101
+ for (const line of lines) {
102
+ const h1 = /^#\s+(.+?)\s*$/.exec(line);
103
+ const h2 = /^##\s+(.+?)\s*$/.exec(line);
104
+ if (h1 && !titleConsumed && current === null) {
105
+ title = h1[1].trim();
106
+ titleConsumed = true;
107
+ continue;
108
+ }
109
+ if (h2) {
110
+ if (current) sections.push(current);
111
+ current = { heading: h2[1].trim(), level: 2, body: "" };
112
+ continue;
113
+ }
114
+ if (current) {
115
+ current.body += (current.body ? "\n" : "") + line;
116
+ }
117
+ }
118
+ if (current) sections.push(current);
119
+ return { title, sections };
120
+ }
121
+
122
+ // ────────────────────────────────────────────────────────────────────
123
+ // Section extractors
124
+ // ────────────────────────────────────────────────────────────────────
125
+
126
+ const COLOR_VALUE_RX =
127
+ /(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|oklch\([^)]+\)|oklab\([^)]+\))/;
128
+
129
+ function extractColorTokens(body: string): ColorToken[] {
130
+ const tokens: ColorToken[] = [];
131
+ for (const line of body.split(/\r?\n/)) {
132
+ const trimmed = line.trim().replace(/^[-*+]\s*/, "").replace(/^\|\s*/, "").replace(/\s*\|.*$/, "");
133
+ if (!trimmed) continue;
134
+ const valueMatch = COLOR_VALUE_RX.exec(line);
135
+ // Try patterns:
136
+ // - `name — #hex — role`
137
+ // - `name: #hex (role)`
138
+ // - `**name** \`#hex\` — role`
139
+ // - `| name | #hex | role |` (markdown table row)
140
+ const stripped = line
141
+ .replace(/[`*_]/g, "")
142
+ .replace(/^[\s|>-]+/, "")
143
+ .replace(/\s*\|.*$/, "");
144
+ const sepMatch = /^([^:—–\-]+?)[\s]*[:—–\-][\s]*(.+)$/.exec(stripped);
145
+ let name: string | undefined;
146
+ let role: string | undefined;
147
+ if (sepMatch) {
148
+ name = sepMatch[1].trim();
149
+ const rest = sepMatch[2];
150
+ // role is whatever isn't the colour value
151
+ if (valueMatch) {
152
+ role = rest.replace(valueMatch[0], "").replace(/[\s—–\-:|]+/g, " ").trim() || undefined;
153
+ } else {
154
+ role = rest.trim() || undefined;
155
+ }
156
+ }
157
+ if (!name && valueMatch) {
158
+ // Fallback — colour value present but no clear "name : value" split.
159
+ name = stripped.replace(valueMatch[0], "").trim() || valueMatch[0];
160
+ }
161
+ if (!name) continue;
162
+ if (name.length > 100) continue; // garbage line
163
+ // Filter out heading-like rows ("Color Palette", "Functional Roles", ...).
164
+ if (/palette|role/i.test(name) && !valueMatch) continue;
165
+ tokens.push({
166
+ name,
167
+ value: valueMatch ? valueMatch[0] : undefined,
168
+ role,
169
+ });
170
+ }
171
+ return tokens;
172
+ }
173
+
174
+ function extractTypographyTokens(body: string): TypographyToken[] {
175
+ const tokens: TypographyToken[] = [];
176
+ for (const line of body.split(/\r?\n/)) {
177
+ const trimmed = line.trim().replace(/^[-*+]\s*/, "");
178
+ if (!trimmed) continue;
179
+ if (trimmed.startsWith("#")) continue; // sub-headings
180
+ const stripped = trimmed.replace(/[`*_]/g, "");
181
+ const nameMatch = /^([^:—–]+?)[\s]*[:—–]/.exec(stripped);
182
+ const name = nameMatch ? nameMatch[1].trim() : stripped.split(/\s{2,}|—|–/)[0]?.trim();
183
+ if (!name) continue;
184
+ const fontFamily = /font[\s-]*family[:\s]+([^,;]+)/i.exec(stripped)?.[1]?.trim();
185
+ const weight = /weight[:\s]+([0-9]{3}|bold|semi-?bold|medium|regular|light|thin)/i.exec(stripped)?.[1]?.trim();
186
+ const size = /(\d+(?:\.\d+)?(?:px|rem|em|pt))/.exec(stripped)?.[1];
187
+ const lineHeight = /line[\s-]*height[:\s]+([0-9.]+)/i.exec(stripped)?.[1];
188
+ tokens.push({
189
+ name,
190
+ fontFamily,
191
+ weight,
192
+ size,
193
+ lineHeight,
194
+ usage: stripped !== name ? stripped : undefined,
195
+ });
196
+ }
197
+ return tokens;
198
+ }
199
+
200
+ function extractComponentTokens(body: string): ComponentToken[] {
201
+ // Components are typically organised under H3 sub-headings ("### Button",
202
+ // "### Card"). When present, treat each H3 as one component; otherwise
203
+ // fall back to bullet rows.
204
+ const tokens: ComponentToken[] = [];
205
+ const h3Rx = /^###\s+(.+?)\s*$/;
206
+ const lines = body.split(/\r?\n/);
207
+
208
+ let current: ComponentToken | null = null;
209
+ for (const line of lines) {
210
+ const h3 = h3Rx.exec(line);
211
+ if (h3) {
212
+ if (current) tokens.push(current);
213
+ current = { name: h3[1].trim(), variants: {}, notes: "" };
214
+ continue;
215
+ }
216
+ if (!current) continue;
217
+ // Variant detection — `variant: primary | secondary | ghost`
218
+ const variantMatch = /^[\s-]*(\w[\w\s-]*?)[\s]*:[\s]*(.+)$/.exec(line.trim());
219
+ if (variantMatch) {
220
+ const key = variantMatch[1].trim();
221
+ const valuesRaw = variantMatch[2].trim();
222
+ if (/[|,]/.test(valuesRaw) && valuesRaw.length < 200) {
223
+ const values = valuesRaw
224
+ .split(/[|,]/)
225
+ .map((v) => v.trim().replace(/[`*_"']/g, ""))
226
+ .filter(Boolean);
227
+ if (values.length >= 2) {
228
+ current.variants[key] = values;
229
+ continue;
230
+ }
231
+ }
232
+ }
233
+ if (line.trim()) {
234
+ current.notes = (current.notes ?? "") + (current.notes ? "\n" : "") + line;
235
+ }
236
+ }
237
+ if (current) tokens.push(current);
238
+
239
+ if (tokens.length === 0) {
240
+ // Fallback — flat bullet list of components.
241
+ for (const line of lines) {
242
+ const m = /^[\s-*+]+([A-Z][\w\s]+?)(?::|—|–|$)/.exec(line);
243
+ if (m) tokens.push({ name: m[1].trim(), variants: {}, notes: undefined });
244
+ }
245
+ }
246
+ return tokens;
247
+ }
248
+
249
+ function extractSpacingTokens(body: string): SpacingToken[] {
250
+ const tokens: SpacingToken[] = [];
251
+ for (const line of body.split(/\r?\n/)) {
252
+ const stripped = line.trim().replace(/^[-*+]\s*/, "").replace(/[`*_]/g, "");
253
+ if (!stripped) continue;
254
+ const m = /^([\w-]+)[\s:—–-]+(\d+(?:\.\d+)?(?:px|rem|em|%)?)/.exec(stripped);
255
+ if (m) tokens.push({ name: m[1], value: m[2] });
256
+ }
257
+ return tokens;
258
+ }
259
+
260
+ function extractShadowTokens(body: string): ShadowToken[] {
261
+ const tokens: ShadowToken[] = [];
262
+ // Shadows are typically `name: <css value>` rows. CSS values contain
263
+ // commas / parens / `0 1px 2px rgba(...)` so we don't attempt to fully
264
+ // parse them — capture the whole right-hand side verbatim.
265
+ for (const line of body.split(/\r?\n/)) {
266
+ const stripped = line.trim().replace(/^[-*+]\s*/, "").replace(/[`*_]/g, "");
267
+ if (!stripped) continue;
268
+ const m = /^([\w-]+)\s*[:—–]\s*(.+)$/.exec(stripped);
269
+ if (m && (m[2].includes("px") || m[2].includes("rgba"))) {
270
+ tokens.push({ name: m[1], value: m[2].trim() });
271
+ }
272
+ }
273
+ return tokens;
274
+ }
275
+
276
+ function extractDoDontRules(body: string): DoDontRule[] {
277
+ const rules: DoDontRule[] = [];
278
+ let mode: "do" | "dont" | null = null;
279
+ for (const line of body.split(/\r?\n/)) {
280
+ const stripped = line.trim();
281
+ if (!stripped) continue;
282
+ if (/^#{2,}\s+do\b|^\*\*do\*\*|^do['s]*[:\s]/i.test(stripped) && !/don.?t/i.test(stripped)) {
283
+ mode = "do";
284
+ continue;
285
+ }
286
+ if (/^#{2,}\s+don.?t|^\*\*don.?t\*\*|^don.?t[s]?[:\s]/i.test(stripped)) {
287
+ mode = "dont";
288
+ continue;
289
+ }
290
+ const bullet = /^[-*+]\s+(.+)$/.exec(stripped);
291
+ if (bullet && mode) {
292
+ // Preserve backticks — they mark identifier tokens
293
+ // (`btn-hard`, `shadow-hard`) that downstream consumers
294
+ // (Guard `autoFromDesignMd`) extract from the rule text.
295
+ rules.push({ kind: mode, text: bullet[1].replace(/[*_]/g, "").trim() });
296
+ } else if (bullet) {
297
+ // ✅ / ❌ inline markers.
298
+ const text = bullet[1];
299
+ if (text.startsWith("✅") || /^do\b/i.test(text)) {
300
+ rules.push({ kind: "do", text: text.replace(/^[✅do:\s]+/i, "").trim() });
301
+ } else if (text.startsWith("❌") || /^don.?t\b/i.test(text)) {
302
+ rules.push({ kind: "dont", text: text.replace(/^[❌don'?t:\s]+/i, "").trim() });
303
+ }
304
+ }
305
+ }
306
+ return rules;
307
+ }
308
+
309
+ function extractBreakpoints(body: string): ResponsiveBreakpoint[] {
310
+ const bps: ResponsiveBreakpoint[] = [];
311
+ for (const line of body.split(/\r?\n/)) {
312
+ const stripped = line.trim().replace(/^[-*+]\s*/, "").replace(/[`*_]/g, "");
313
+ if (!stripped) continue;
314
+ const m = /^([\w-]+)[\s:—–-]+(\d+(?:px|rem|em)?)\b(.*)$/.exec(stripped);
315
+ if (m) {
316
+ bps.push({
317
+ name: m[1],
318
+ value: m[2],
319
+ notes: m[3].replace(/^[\s—–-]+/, "").trim() || undefined,
320
+ });
321
+ }
322
+ }
323
+ return bps;
324
+ }
325
+
326
+ function extractAgentPrompts(body: string): AgentPrompt[] {
327
+ // Group by H3; everything else collapses into a single "default" prompt.
328
+ const prompts: AgentPrompt[] = [];
329
+ const lines = body.split(/\r?\n/);
330
+ const h3Rx = /^###\s+(.+?)\s*$/;
331
+ let current: AgentPrompt | null = null;
332
+ let buf: string[] = [];
333
+ for (const line of lines) {
334
+ const h3 = h3Rx.exec(line);
335
+ if (h3) {
336
+ if (current) {
337
+ current.body = buf.join("\n").trim();
338
+ prompts.push(current);
339
+ }
340
+ current = { title: h3[1].trim(), body: "" };
341
+ buf = [];
342
+ continue;
343
+ }
344
+ buf.push(line);
345
+ }
346
+ if (current) {
347
+ current.body = buf.join("\n").trim();
348
+ prompts.push(current);
349
+ } else if (buf.join("").trim()) {
350
+ prompts.push({ title: "default", body: buf.join("\n").trim() });
351
+ }
352
+ return prompts;
353
+ }
354
+
355
+ // ────────────────────────────────────────────────────────────────────
356
+ // Public API
357
+ // ────────────────────────────────────────────────────────────────────
358
+
359
+ function emptySections(): DesignSpec["sections"] {
360
+ return {
361
+ theme: { id: "theme", present: false, rawBody: "" },
362
+ "color-palette": { id: "color-palette", present: false, rawBody: "", tokens: [] },
363
+ typography: { id: "typography", present: false, rawBody: "", tokens: [] },
364
+ components: { id: "components", present: false, rawBody: "", tokens: [] },
365
+ layout: { id: "layout", present: false, rawBody: "", tokens: [] },
366
+ shadows: { id: "shadows", present: false, rawBody: "", tokens: [] },
367
+ "dos-donts": { id: "dos-donts", present: false, rawBody: "", rules: [] },
368
+ responsive: { id: "responsive", present: false, rawBody: "", breakpoints: [] },
369
+ "agent-prompts": { id: "agent-prompts", present: false, rawBody: "", prompts: [] },
370
+ };
371
+ }
372
+
373
+ /** Parse a DESIGN.md source string into the structured spec. Never throws. */
374
+ export function parseDesignMd(source: string): DesignSpec {
375
+ const { title, sections: rawSections } = splitByHeadings(source);
376
+ const result: DesignSpec = {
377
+ source,
378
+ title,
379
+ sections: emptySections(),
380
+ extraSections: [],
381
+ };
382
+
383
+ for (const raw of rawSections) {
384
+ const id = resolveSectionId(raw.heading);
385
+ if (!id) {
386
+ result.extraSections.push({ heading: raw.heading, body: raw.body });
387
+ continue;
388
+ }
389
+ const body = raw.body.trim();
390
+ switch (id) {
391
+ case "theme": {
392
+ const summary = body.split(/\n\s*\n/)[0]?.trim() || undefined;
393
+ result.sections.theme = {
394
+ id,
395
+ present: true,
396
+ headingText: raw.heading,
397
+ rawBody: body,
398
+ summary,
399
+ };
400
+ break;
401
+ }
402
+ case "color-palette":
403
+ result.sections["color-palette"] = {
404
+ id,
405
+ present: true,
406
+ headingText: raw.heading,
407
+ rawBody: body,
408
+ tokens: extractColorTokens(body),
409
+ };
410
+ break;
411
+ case "typography":
412
+ result.sections.typography = {
413
+ id,
414
+ present: true,
415
+ headingText: raw.heading,
416
+ rawBody: body,
417
+ tokens: extractTypographyTokens(body),
418
+ };
419
+ break;
420
+ case "components":
421
+ result.sections.components = {
422
+ id,
423
+ present: true,
424
+ headingText: raw.heading,
425
+ rawBody: body,
426
+ tokens: extractComponentTokens(body),
427
+ };
428
+ break;
429
+ case "layout":
430
+ result.sections.layout = {
431
+ id,
432
+ present: true,
433
+ headingText: raw.heading,
434
+ rawBody: body,
435
+ tokens: extractSpacingTokens(body),
436
+ };
437
+ break;
438
+ case "shadows":
439
+ result.sections.shadows = {
440
+ id,
441
+ present: true,
442
+ headingText: raw.heading,
443
+ rawBody: body,
444
+ tokens: extractShadowTokens(body),
445
+ };
446
+ break;
447
+ case "dos-donts":
448
+ result.sections["dos-donts"] = {
449
+ id,
450
+ present: true,
451
+ headingText: raw.heading,
452
+ rawBody: body,
453
+ rules: extractDoDontRules(body),
454
+ };
455
+ break;
456
+ case "responsive":
457
+ result.sections.responsive = {
458
+ id,
459
+ present: true,
460
+ headingText: raw.heading,
461
+ rawBody: body,
462
+ breakpoints: extractBreakpoints(body),
463
+ };
464
+ break;
465
+ case "agent-prompts":
466
+ result.sections["agent-prompts"] = {
467
+ id,
468
+ present: true,
469
+ headingText: raw.heading,
470
+ rawBody: body,
471
+ prompts: extractAgentPrompts(body),
472
+ };
473
+ break;
474
+ }
475
+ }
476
+ return result;
477
+ }
478
+
479
+ // ────────────────────────────────────────────────────────────────────
480
+ // Validation
481
+ // ────────────────────────────────────────────────────────────────────
482
+
483
+ /**
484
+ * Surface gaps in a parsed DESIGN.md. The validator never returns
485
+ * errors that block builds — Mandu's enforcement layer (Guard) is the
486
+ * gate. This function is the diagnostic that tells the user "your
487
+ * DESIGN.md is missing colour tokens" so they can fill it in.
488
+ */
489
+ export function validateDesignSpec(spec: DesignSpec): ValidationResult {
490
+ const issues: ValidationIssue[] = [];
491
+ for (const id of DESIGN_SECTION_IDS) {
492
+ const section = spec.sections[id] as AnyDesignSection;
493
+ if (!section.present) {
494
+ issues.push({
495
+ kind: "missing",
496
+ section: id,
497
+ message: `Section "${id}" not found. Add a "## ${humanizeSectionId(id)}" heading.`,
498
+ });
499
+ continue;
500
+ }
501
+ const empty = isSectionEmpty(section);
502
+ if (empty) {
503
+ issues.push({
504
+ kind: "empty",
505
+ section: id,
506
+ message: `Section "${id}" is present but has no structured tokens.`,
507
+ });
508
+ }
509
+ }
510
+ return { ok: issues.length === 0, issues };
511
+ }
512
+
513
+ function isSectionEmpty(section: AnyDesignSection): boolean {
514
+ switch (section.id) {
515
+ case "theme":
516
+ return !section.summary || section.summary.length === 0;
517
+ case "color-palette":
518
+ case "typography":
519
+ case "components":
520
+ case "layout":
521
+ case "shadows":
522
+ return section.tokens.length === 0;
523
+ case "dos-donts":
524
+ return section.rules.length === 0;
525
+ case "responsive":
526
+ return section.breakpoints.length === 0;
527
+ case "agent-prompts":
528
+ return section.prompts.length === 0;
529
+ }
530
+ }
531
+
532
+ function humanizeSectionId(id: DesignSectionId): string {
533
+ switch (id) {
534
+ case "theme":
535
+ return "Visual Theme & Philosophy";
536
+ case "color-palette":
537
+ return "Color Palette";
538
+ case "typography":
539
+ return "Typography";
540
+ case "components":
541
+ return "Components";
542
+ case "layout":
543
+ return "Layout";
544
+ case "shadows":
545
+ return "Depth & Elevation";
546
+ case "dos-donts":
547
+ return "Do's & Don'ts";
548
+ case "responsive":
549
+ return "Responsive";
550
+ case "agent-prompts":
551
+ return "Agent Prompts";
552
+ }
553
+ }
554
+
555
+ export { humanizeSectionId };
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Scaffold + upstream import primitives.
3
+ *
4
+ * `EMPTY_DESIGN_MD` is the canonical 9-section skeleton Mandu ships
5
+ * for `mandu design init` (no `--from`). It contains heading slots and
6
+ * a one-line hint per section so users / agents can fill it in.
7
+ *
8
+ * `fetchUpstreamDesignMd(slug)` pulls a brand DESIGN.md from VoltAgent's
9
+ * awesome-design-md repository (raw GitHub). The function is a thin
10
+ * fetch wrapper — caller decides what to do with the body (validate +
11
+ * write, dry-run + diff, etc.).
12
+ *
13
+ * @module core/design/scaffold
14
+ */
15
+
16
+ /**
17
+ * Raw GitHub base for awesome-design-md. Public, MIT licensed.
18
+ * Each brand lives at `<base>/<slug>/DESIGN.md`.
19
+ */
20
+ export const AWESOME_DESIGN_MD_RAW_BASE =
21
+ "https://raw.githubusercontent.com/VoltAgent/awesome-design-md/main";
22
+
23
+ /**
24
+ * Empty 9-section DESIGN.md skeleton. Meant to be filled in
25
+ * incrementally — Mandu's point is that DESIGN.md is a *living*
26
+ * artifact, not a one-shot deliverable.
27
+ */
28
+ export const EMPTY_DESIGN_MD = `# DESIGN.md
29
+
30
+ > Living design system spec for this project. AI agents and developers
31
+ > read this file before touching UI. See
32
+ > https://github.com/VoltAgent/awesome-design-md for examples.
33
+ >
34
+ > Fill sections incrementally — \`mandu design extract\` (coming soon)
35
+ > can propose tokens from your existing code.
36
+
37
+ ## Visual Theme & Philosophy
38
+
39
+ <!-- One-paragraph "vibe": minimal, playful, dense, premium, … -->
40
+
41
+ ## Color Palette
42
+
43
+ <!-- Each row: name — value — role.
44
+ - primary — #000000 — brand / primary action
45
+ - surface — #ffffff — page background
46
+ -->
47
+
48
+ ## Typography
49
+
50
+ <!-- Each row: name — font-family / size / weight / line-height — usage.
51
+ - display — Inter, 48px, weight 700, line-height 1.1 — hero
52
+ - body — Inter, 16px, weight 400, line-height 1.6 — paragraphs
53
+ -->
54
+
55
+ ## Components
56
+
57
+ <!-- One ### sub-heading per component. Declare variants as
58
+ \`variant: a | b | c\` so tools can index them.
59
+
60
+ ### Button
61
+ variant: primary | secondary | ghost
62
+ size: sm | md | lg
63
+
64
+ ### Card
65
+ variant: surface | bordered
66
+ -->
67
+
68
+ ## Layout
69
+
70
+ <!-- Spacing scale + grid notes.
71
+ - xs — 4px
72
+ - sm — 8px
73
+ - md — 16px
74
+ - lg — 24px
75
+ - xl — 40px
76
+ -->
77
+
78
+ ## Depth & Elevation
79
+
80
+ <!-- Shadow tokens.
81
+ - card: 0 1px 2px rgba(0,0,0,.06), 0 1px 3px rgba(0,0,0,.10)
82
+ - popover: 0 4px 12px rgba(0,0,0,.12)
83
+ -->
84
+
85
+ ## Do's & Don'ts
86
+
87
+ <!-- Add rules under ### Do and ### Don't sub-headings.
88
+ Mandu's Guard rule can lift the don't items into \`forbidInlineClasses\`.
89
+
90
+ ### Do
91
+ - Use design tokens.
92
+
93
+ ### Don't
94
+ - Inline raw colour values.
95
+ -->
96
+
97
+
98
+ ## Responsive
99
+
100
+ <!-- Breakpoints + scaling notes.
101
+ - mobile — 0–639px
102
+ - tablet — 640–1023px
103
+ - desktop — 1024px+
104
+ -->
105
+
106
+ ## Agent Prompts
107
+
108
+ <!-- Ready-made prompts the agent should use when generating UI. Each
109
+ ### sub-heading is one prompt; the body is passed verbatim.
110
+
111
+ ### Page hero
112
+ Generate a hero section using \`display\` typography and \`primary\`
113
+ color tokens. Avoid inline shadow values; use the \`card\` shadow token.
114
+ -->
115
+ `;
116
+
117
+ export interface FetchUpstreamOptions {
118
+ /** Override base URL (test fixtures, mirrors). */
119
+ baseUrl?: string;
120
+ /** AbortSignal so callers can cancel a slow fetch. */
121
+ signal?: AbortSignal;
122
+ }
123
+
124
+ /**
125
+ * Fetch a DESIGN.md from awesome-design-md by brand slug.
126
+ * Throws on HTTP error — the caller (CLI command) catches and prints
127
+ * a user-facing message rather than letting it bubble.
128
+ */
129
+ export async function fetchUpstreamDesignMd(
130
+ slugOrUrl: string,
131
+ options: FetchUpstreamOptions = {},
132
+ ): Promise<string> {
133
+ const url = isAbsoluteUrl(slugOrUrl)
134
+ ? slugOrUrl
135
+ : `${options.baseUrl ?? AWESOME_DESIGN_MD_RAW_BASE}/${encodeURIComponent(slugOrUrl)}/DESIGN.md`;
136
+ const res = await fetch(url, { signal: options.signal });
137
+ if (!res.ok) {
138
+ throw new Error(
139
+ `fetchUpstreamDesignMd: GET ${url} → HTTP ${res.status} ${res.statusText}`,
140
+ );
141
+ }
142
+ return res.text();
143
+ }
144
+
145
+ function isAbsoluteUrl(s: string): boolean {
146
+ return /^https?:\/\//i.test(s);
147
+ }