launchframe 0.1.6 → 0.1.8

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.
@@ -1,466 +1,534 @@
1
- /**
2
- * Output emitters.
3
- *
4
- * Given a synthesized DesignSystem, produce drop-in files the user can
5
- * copy into a fresh shadcn/ui project:
6
- *
7
- * tokens.json — every value, machine-readable
8
- * tailwind.config.ts — Tailwind theme extension
9
- * globals.css — shadcn-compatible CSS variables (light + dark)
10
- * theme-preview.tsx — a self-contained React component that renders
11
- * every token so you can eyeball the system
12
- * REPORT.md — what was extracted, from where, and how the
13
- * output is meant to be used
14
- */
15
-
16
- import { mkdirSync, writeFileSync } from "node:fs";
17
- import { join } from "node:path";
18
-
19
- import type { ColorRamp, DesignSystem, ExtractionRun } from "./types.js";
20
-
21
- export function emitAll(system: DesignSystem, run: ExtractionRun): string[] {
22
- mkdirSync(run.outputDir, { recursive: true });
23
- const written: string[] = [];
24
-
25
- written.push(write(run.outputDir, "tokens.json", JSON.stringify(system, null, 2) + "\n"));
26
- written.push(write(run.outputDir, "tailwind.config.ts", emitTailwindConfig(system)));
27
- written.push(write(run.outputDir, "globals.css", emitGlobalsCss(system)));
28
- written.push(write(run.outputDir, "theme-preview.tsx", emitThemePreview(system)));
29
- written.push(write(run.outputDir, "REPORT.md", emitReport(system, run)));
30
- written.push(write(run.outputDir, "FOR_AI.md", emitForAi(system, run)));
31
-
32
- return written;
33
- }
34
-
35
- function write(dir: string, file: string, contents: string): string {
36
- const path = join(dir, file);
37
- writeFileSync(path, contents);
38
- return path;
39
- }
40
-
41
- /* -------------------------------------------------------------------------- */
42
- /* tailwind.config.ts */
43
- /* -------------------------------------------------------------------------- */
44
-
45
- function emitTailwindConfig(system: DesignSystem): string {
46
- return `/**
47
- * Tailwind theme extracted by launchframe.
48
- * Run id: ${system.runId}
49
- *
50
- * Sources (inspirational only, no source code or assets reused):
51
- ${system.sources.map((s) => ` * - ${s.url}`).join("\n")}
52
- */
53
-
54
- import type { Config } from "tailwindcss";
55
-
56
- const config: Config = {
57
- darkMode: ["class"],
58
- content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
59
- theme: {
60
- container: {
61
- center: true,
62
- padding: "1.5rem",
63
- screens: { "2xl": "${pxToRem(system.spacing.containerPx)}rem" },
64
- },
65
- extend: {
66
- colors: {
67
- background: "hsl(var(--background))",
68
- foreground: "hsl(var(--foreground))",
69
- card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
70
- popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))" },
71
- primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
72
- secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
73
- muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
74
- accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
75
- destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
76
- border: "hsl(var(--border))",
77
- input: "hsl(var(--input))",
78
- ring: "hsl(var(--ring))",
79
- },
80
- fontFamily: {
81
- sans: ["${system.typography.fontSans}", "ui-sans-serif", "system-ui", "sans-serif"],
82
- mono: ["${system.typography.fontMono}", "ui-monospace", "SFMono-Regular", "monospace"],
83
- },
84
- fontSize: {
85
- ${Object.entries(system.typography.steps)
86
- .map(([k, v]) => ` "${k}": ["${pxToRem(v)}rem", { lineHeight: "${heightFor(k, system)}" }],`)
87
- .join("\n")}
88
- },
89
- letterSpacing: {
90
- body: "${system.typography.bodyLetterSpacingEm}em",
91
- },
92
- borderRadius: {
93
- lg: "var(--radius)",
94
- md: "calc(var(--radius) - 2px)",
95
- sm: "calc(var(--radius) - 4px)",
96
- },
97
- spacing: {
98
- ${system.spacing.steps
99
- .map((px, i) => ` "${i + 1}p": "${pxToRem(px)}rem",`)
100
- .join("\n")}
101
- },
102
- boxShadow: {
103
- sm: "${system.shadows.sm}",
104
- md: "${system.shadows.md}",
105
- lg: "${system.shadows.lg}",
106
- },
107
- },
108
- },
109
- plugins: [],
110
- };
111
-
112
- export default config;
113
- `;
114
- }
115
-
116
- function heightFor(step: string, system: DesignSystem): string {
117
- const headingSteps = new Set(["3xl", "4xl", "5xl", "6xl"]);
118
- return headingSteps.has(step)
119
- ? String(system.typography.headingLineHeight)
120
- : String(system.typography.bodyLineHeight);
121
- }
122
-
123
- /* -------------------------------------------------------------------------- */
124
- /* globals.css */
125
- /* -------------------------------------------------------------------------- */
126
-
127
- function emitGlobalsCss(system: DesignSystem): string {
128
- return `/**
129
- * Drop-in CSS variables produced by launchframe.
130
- * Compatible with shadcn/ui's --background / --foreground / etc. tokens.
131
- */
132
-
133
- @tailwind base;
134
- @tailwind components;
135
- @tailwind utilities;
136
-
137
- @layer base {
138
- :root {
139
- ${cssVarsBlock(system.light)}
140
- --radius: ${pxToRem(system.radius.basePx)}rem;
141
- }
142
-
143
- .dark {
144
- ${cssVarsBlock(system.dark)}
145
- }
146
- }
147
-
148
- @layer base {
149
- * { @apply border-border; }
150
- body {
151
- @apply bg-background text-foreground antialiased;
152
- font-feature-settings: "rlig" 1, "calt" 1;
153
- text-rendering: optimizeLegibility;
154
- }
155
- }
156
- `;
157
- }
158
-
159
- function cssVarsBlock(ramp: ColorRamp): string {
160
- const map: Array<[string, string]> = [
161
- ["--background", ramp.background],
162
- ["--foreground", ramp.foreground],
163
- ["--card", ramp.card],
164
- ["--card-foreground", ramp.cardForeground],
165
- ["--popover", ramp.popover],
166
- ["--popover-foreground", ramp.popoverForeground],
167
- ["--primary", ramp.primary],
168
- ["--primary-foreground", ramp.primaryForeground],
169
- ["--secondary", ramp.secondary],
170
- ["--secondary-foreground", ramp.secondaryForeground],
171
- ["--muted", ramp.muted],
172
- ["--muted-foreground", ramp.mutedForeground],
173
- ["--accent", ramp.accent],
174
- ["--accent-foreground", ramp.accentForeground],
175
- ["--destructive", ramp.destructive],
176
- ["--destructive-foreground", ramp.destructiveForeground],
177
- ["--border", ramp.border],
178
- ["--input", ramp.input],
179
- ["--ring", ramp.ring],
180
- ];
181
- return map.map(([k, v]) => ` ${k}: ${hexToHslTokens(v)};`).join("\n");
182
- }
183
-
184
- function hexToHslTokens(hex: string): string {
185
- const m = hex.match(/^#([0-9a-fA-F]{6})$/);
186
- if (!m) return "0 0% 0%";
187
- const num = parseInt(m[1]!, 16);
188
- const r = ((num >> 16) & 0xff) / 255;
189
- const g = ((num >> 8) & 0xff) / 255;
190
- const b = (num & 0xff) / 255;
191
- const max = Math.max(r, g, b);
192
- const min = Math.min(r, g, b);
193
- const l = (max + min) / 2;
194
- let h = 0;
195
- let s = 0;
196
- if (max !== min) {
197
- const d = max - min;
198
- s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
199
- switch (max) {
200
- case r:
201
- h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
202
- break;
203
- case g:
204
- h = ((b - r) / d + 2) * 60;
205
- break;
206
- default:
207
- h = ((r - g) / d + 4) * 60;
208
- }
209
- }
210
- return `${Math.round(h)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
211
- }
212
-
213
- /* -------------------------------------------------------------------------- */
214
- /* theme-preview.tsx */
215
- /* -------------------------------------------------------------------------- */
216
-
217
- function emitThemePreview(system: DesignSystem): string {
218
- return `/**
219
- * Theme preview for the design system extracted at ${system.runId}.
220
- *
221
- * Drop this into your app at \`components/theme-preview.tsx\` and render
222
- * it on a route to eyeball every token in light and dark mode.
223
- */
224
-
225
- export default function ThemePreview() {
226
- return (
227
- <div className="space-y-12 p-8">
228
- <header className="space-y-2">
229
- <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
230
- Design system preview
231
- </p>
232
- <h1 className="text-5xl font-semibold tracking-tight">
233
- Headline at the 5xl step
234
- </h1>
235
- <p className="max-w-prose text-lg text-muted-foreground">
236
- Body copy at the lg step demonstrating the chosen line-height and
237
- letter-spacing across a representative paragraph length.
238
- </p>
239
- </header>
240
-
241
- <section className="space-y-3">
242
- <h2 className="text-2xl font-semibold">Type scale</h2>
243
- <div className="space-y-2 font-mono text-sm">
244
- ${(["xs", "sm", "base", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl"] as const)
245
- .map(
246
- (step) =>
247
- ` <p className="text-${step}">${step} — The quick brown fox jumps over the lazy dog</p>`,
248
- )
249
- .join("\n")}
250
- </div>
251
- </section>
252
-
253
- <section className="space-y-3">
254
- <h2 className="text-2xl font-semibold">Color tokens</h2>
255
- <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
256
- ${[
257
- "background",
258
- "foreground",
259
- "primary",
260
- "primary-foreground",
261
- "secondary",
262
- "secondary-foreground",
263
- "muted",
264
- "muted-foreground",
265
- "accent",
266
- "accent-foreground",
267
- "border",
268
- "ring",
269
- ]
270
- .map(
271
- (token) =>
272
- ` <div className="rounded-md border border-border bg-${token.includes("foreground") ? "background" : token} p-4">
273
- <div className="font-mono text-xs">${token}</div>
274
- </div>`,
275
- )
276
- .join("\n")}
277
- </div>
278
- </section>
279
-
280
- <section className="space-y-3">
281
- <h2 className="text-2xl font-semibold">Radius and shadow</h2>
282
- <div className="grid grid-cols-3 gap-3">
283
- <div className="rounded-lg border border-border bg-card p-6 text-sm shadow-sm">shadow-sm + rounded-lg</div>
284
- <div className="rounded-lg border border-border bg-card p-6 text-sm shadow-md">shadow-md + rounded-lg</div>
285
- <div className="rounded-lg border border-border bg-card p-6 text-sm shadow-lg">shadow-lg + rounded-lg</div>
286
- </div>
287
- </section>
288
- </div>
289
- );
290
- }
291
- `;
292
- }
293
-
294
- /* -------------------------------------------------------------------------- */
295
- /* REPORT.md */
296
- /* -------------------------------------------------------------------------- */
297
-
298
- function emitReport(system: DesignSystem, run: ExtractionRun): string {
299
- const okCaptures = run.captures.filter((c) => c.status === "ok");
300
- const failedCaptures = run.captures.filter((c) => c.status !== "ok");
301
-
302
- return `# Design system extraction report
303
-
304
- **Run:** \`${system.runId}\`
305
- **Generated:** ${run.finishedAt}
306
-
307
- ## Inspiration sources
308
-
309
- The design system below was synthesized by analyzing the **rendered
310
- appearance** of these public pages. No source code, brand assets,
311
- illustrations, logos, or copywriting was extracted or stored.
312
-
313
- ${okCaptures.map((c) => `- ${c.url} \n screenshot: \`${relativize(c.screenshotPath, run.outputDir)}\``).join("\n")}
314
-
315
- ${
316
- failedCaptures.length > 0
317
- ? `### Skipped or failed\n\n${failedCaptures
318
- .map((c) => `- ${c.url} — ${c.status}${c.reason ? `: ${c.reason}` : ""}`)
319
- .join("\n")}`
320
- : ""
321
- }
322
-
323
- ## Synthesis decisions
324
-
325
- ${system.notes.length > 0 ? system.notes.map((n) => `- ${n}`).join("\n") : "_No special notes — defaults applied where signals were weak._"}
326
-
327
- ## Typography
328
-
329
- - **Sans family:** ${system.typography.fontSans}${system.typography.fontSansSubstituted ? " *(substituted)*" : ""}
330
- - **Mono family:** ${system.typography.fontMono}${system.typography.fontMonoSubstituted ? " *(substituted)*" : ""}
331
- - **Body base:** ${system.typography.basePx}px
332
- - **Scale ratio:** ${system.typography.scaleRatio}
333
- - **Body line-height:** ${system.typography.bodyLineHeight}
334
- - **Heading line-height:** ${system.typography.headingLineHeight}
335
- - **Body letter-spacing:** ${system.typography.bodyLetterSpacingEm}em
336
-
337
- | step | px |
338
- | ---- | -- |
339
- ${Object.entries(system.typography.steps).map(([k, v]) => `| ${k} | ${v} |`).join("\n")}
340
-
341
- ## Spacing
342
-
343
- - **Base unit:** ${system.spacing.basePx}px
344
- - **Container width:** ${system.spacing.containerPx}px
345
- - **Steps (px):** ${system.spacing.steps.join(", ")}
346
-
347
- ## Radius
348
-
349
- - **Base radius:** ${system.radius.basePx}px (used for shadcn's \`--radius\`)
350
-
351
- ## Color tokens
352
-
353
- ### Light theme
354
-
355
- | token | value |
356
- | ----- | ----- |
357
- ${rampRows(system.light)}
358
-
359
- ### Dark theme
360
-
361
- | token | value |
362
- | ----- | ----- |
363
- ${rampRows(system.dark)}
364
-
365
- ## Shadows
366
-
367
- - \`shadow-sm\` — \`${system.shadows.sm}\`
368
- - \`shadow-md\` — \`${system.shadows.md}\`
369
- - \`shadow-lg\` — \`${system.shadows.lg}\`
370
-
371
- ## Drop-in usage
372
-
373
- 1. Copy \`tailwind.config.ts\` into the root of a Next.js + Tailwind project.
374
- 2. Copy \`globals.css\` into \`app/globals.css\` (replacing the existing file).
375
- 3. Render \`theme-preview.tsx\` somewhere to eyeball the system.
376
- 4. Iterate from there. The tokens are yours; this report is a record of where
377
- they came from.
378
-
379
- ## What this report is not
380
-
381
- This is **not** a clone of any source site. It does not reproduce layouts,
382
- copy, logos, or brand identity. It records aggregate design-token signals
383
- (colors, type sizes, spacing, radii) and synthesizes an original system
384
- inspired by the corpus.
385
-
386
- If a source operator requests removal, delete the screenshot, drop the URL
387
- from the manifest, and re-run the extraction.
388
- `;
389
- }
390
-
391
- /**
392
- * Copy-paste handoff for Cursor / Claude Code / any agent.
393
- */
394
- function emitForAi(system: DesignSystem, run: ExtractionRun): string {
395
- const abs = run.outputDir.replace(/\\/g, "/");
396
- return `# Hand this folder to your AI (Cursor, Claude Code, etc.)
397
-
398
- **Run:** \`${system.runId}\`
399
- **Absolute path:** \`${abs}\`
400
-
401
- ## What to attach
402
-
403
- In **Cursor**: \`@\` this folder or add \`FOR_AI.md\`, \`REPORT.md\`, and \`tokens.json\` to context.
404
- In **Claude Code**: attach the whole \`output/${system.runId}/\` directory (or copy it into your app repo).
405
-
406
- ## Authority order
407
-
408
- 1. **REPORT.md** typography scale, spacing, radii, colors, container width, notes.
409
- 2. **tokens.json** the same system as structured JSON.
410
- 3. **tailwind.config.ts** + **globals.css** drop these into a Next.js + Tailwind + shadcn-style app.
411
-
412
- ## Instruction block (paste into chat)
413
-
414
- \`\`\`text
415
- You must follow the design system in the attached \`output/${system.runId}/\` folder.
416
-
417
- - Read REPORT.md and tokens.json before writing any UI.
418
- - Merge tailwind.config.ts and globals.css into my project (preserve my existing content paths unless I say otherwise).
419
- - Style with semantic tokens only: bg-background, text-foreground, text-muted-foreground, border-border, bg-primary, text-primary-foreground, bg-card, text-card-foreground, etc. No ad-hoc hex colors.
420
- - Use the font families and type steps from REPORT.md; load web fonts if needed.
421
- - Build a landing page for MY product (I will describe below). Sections: nav, hero, proof strip, features, pricing or CTA, footer. Original copy only — do not copy text from any reference site.
422
-
423
- My product: [describe your idea, audience, and primary CTA here]
424
- \`\`\`
425
-
426
- ## After the agent runs
427
-
428
- - Compare against **theme-preview.tsx** (optional route) to see if components match the token intent.
429
- - Iterate in the same chat; keep REPORT.md in context for every change.
430
- `;
431
- }
432
-
433
- function rampRows(ramp: ColorRamp): string {
434
- const entries: Array<[string, string]> = [
435
- ["background", ramp.background],
436
- ["foreground", ramp.foreground],
437
- ["card", ramp.card],
438
- ["card-foreground", ramp.cardForeground],
439
- ["primary", ramp.primary],
440
- ["primary-foreground", ramp.primaryForeground],
441
- ["secondary", ramp.secondary],
442
- ["secondary-foreground", ramp.secondaryForeground],
443
- ["muted", ramp.muted],
444
- ["muted-foreground", ramp.mutedForeground],
445
- ["accent", ramp.accent],
446
- ["accent-foreground", ramp.accentForeground],
447
- ["destructive", ramp.destructive],
448
- ["destructive-foreground", ramp.destructiveForeground],
449
- ["border", ramp.border],
450
- ["input", ramp.input],
451
- ["ring", ramp.ring],
452
- ];
453
- return entries.map(([k, v]) => `| \`--${k}\` | \`${v}\` |`).join("\n");
454
- }
455
-
456
- /* -------------------------------------------------------------------------- */
457
- /* Helpers */
458
- /* -------------------------------------------------------------------------- */
459
-
460
- function pxToRem(px: number): string {
461
- return (Math.round((px / 16) * 1000) / 1000).toString();
462
- }
463
-
464
- function relativize(p: string, base: string): string {
465
- return p.replace(base + "/", "").replace(base + "\\", "");
466
- }
1
+ /**
2
+ * Output emitters.
3
+ *
4
+ * Given a synthesized DesignSystem, produce drop-in files the user can
5
+ * copy into a fresh shadcn/ui project:
6
+ *
7
+ * tokens.json — every value, machine-readable
8
+ * tailwind.config.ts — Tailwind theme extension
9
+ * globals.css — shadcn-compatible CSS variables (light + dark)
10
+ * theme-preview.tsx — a self-contained React component that renders
11
+ * every token so you can eyeball the system
12
+ * REPORT.md — what was extracted, from where, and how the
13
+ * output is meant to be used
14
+ */
15
+
16
+ import { mkdirSync, writeFileSync } from "node:fs";
17
+ import { join } from "node:path";
18
+
19
+ import type { ColorRamp, DesignSystem, ExtractionRun } from "./types.js";
20
+
21
+ export function emitAll(system: DesignSystem, run: ExtractionRun): string[] {
22
+ mkdirSync(run.outputDir, { recursive: true });
23
+ const written: string[] = [];
24
+
25
+ written.push(write(run.outputDir, "tokens.json", JSON.stringify(system, null, 2) + "\n"));
26
+ written.push(write(run.outputDir, "tailwind.config.ts", emitTailwindConfig(system)));
27
+ written.push(write(run.outputDir, "globals.css", emitGlobalsCss(system)));
28
+ written.push(write(run.outputDir, "theme-preview.tsx", emitThemePreview(system)));
29
+ written.push(write(run.outputDir, "REPORT.md", emitReport(system, run)));
30
+ written.push(write(run.outputDir, "FOR_AI.md", emitForAi(system, run)));
31
+
32
+ return written;
33
+ }
34
+
35
+ function write(dir: string, file: string, contents: string): string {
36
+ const path = join(dir, file);
37
+ writeFileSync(path, contents);
38
+ return path;
39
+ }
40
+
41
+ /* -------------------------------------------------------------------------- */
42
+ /* tailwind.config.ts */
43
+ /* -------------------------------------------------------------------------- */
44
+
45
+ function emitTailwindConfig(system: DesignSystem): string {
46
+ return `/**
47
+ * Tailwind theme extracted by launchframe.
48
+ * Run id: ${system.runId}
49
+ *
50
+ * Sources (inspirational only, no source code or assets reused):
51
+ ${system.sources.map((s) => ` * - ${s.url}`).join("\n")}
52
+ */
53
+
54
+ import type { Config } from "tailwindcss";
55
+
56
+ const config: Config = {
57
+ darkMode: ["class"],
58
+ content: ["./app/**/*.{ts,tsx}", "./components/**/*.{ts,tsx}"],
59
+ theme: {
60
+ container: {
61
+ center: true,
62
+ padding: "1.5rem",
63
+ screens: { "2xl": "${pxToRem(system.spacing.containerPx)}rem" },
64
+ },
65
+ extend: {
66
+ colors: {
67
+ background: "hsl(var(--background))",
68
+ foreground: "hsl(var(--foreground))",
69
+ card: { DEFAULT: "hsl(var(--card))", foreground: "hsl(var(--card-foreground))" },
70
+ popover: { DEFAULT: "hsl(var(--popover))", foreground: "hsl(var(--popover-foreground))" },
71
+ primary: { DEFAULT: "hsl(var(--primary))", foreground: "hsl(var(--primary-foreground))" },
72
+ secondary: { DEFAULT: "hsl(var(--secondary))", foreground: "hsl(var(--secondary-foreground))" },
73
+ muted: { DEFAULT: "hsl(var(--muted))", foreground: "hsl(var(--muted-foreground))" },
74
+ accent: { DEFAULT: "hsl(var(--accent))", foreground: "hsl(var(--accent-foreground))" },
75
+ destructive: { DEFAULT: "hsl(var(--destructive))", foreground: "hsl(var(--destructive-foreground))" },
76
+ border: "hsl(var(--border))",
77
+ input: "hsl(var(--input))",
78
+ ring: "hsl(var(--ring))",
79
+ },
80
+ fontFamily: {
81
+ sans: ["${system.typography.fontSans}", "ui-sans-serif", "system-ui", "sans-serif"],
82
+ mono: ["${system.typography.fontMono}", "ui-monospace", "SFMono-Regular", "monospace"],
83
+ },
84
+ fontSize: {
85
+ ${Object.entries(system.typography.steps)
86
+ .map(([k, v]) => ` "${k}": ["${pxToRem(v)}rem", { lineHeight: "${heightFor(k, system)}" }],`)
87
+ .join("\n")}
88
+ },
89
+ letterSpacing: {
90
+ body: "${system.typography.bodyLetterSpacingEm}em",
91
+ },
92
+ borderRadius: {
93
+ lg: "var(--radius)",
94
+ md: "calc(var(--radius) - 2px)",
95
+ sm: "calc(var(--radius) - 4px)",
96
+ },
97
+ spacing: {
98
+ ${system.spacing.steps
99
+ .map((px, i) => ` "${i + 1}p": "${pxToRem(px)}rem",`)
100
+ .join("\n")}
101
+ },
102
+ boxShadow: {
103
+ sm: "${system.shadows.sm}",
104
+ md: "${system.shadows.md}",
105
+ lg: "${system.shadows.lg}",
106
+ },
107
+ },
108
+ },
109
+ plugins: [],
110
+ };
111
+
112
+ export default config;
113
+ `;
114
+ }
115
+
116
+ function heightFor(step: string, system: DesignSystem): string {
117
+ const headingSteps = new Set(["3xl", "4xl", "5xl", "6xl"]);
118
+ return headingSteps.has(step)
119
+ ? String(system.typography.headingLineHeight)
120
+ : String(system.typography.bodyLineHeight);
121
+ }
122
+
123
+ /* -------------------------------------------------------------------------- */
124
+ /* globals.css */
125
+ /* -------------------------------------------------------------------------- */
126
+
127
+ function emitGlobalsCss(system: DesignSystem): string {
128
+ return `/**
129
+ * Drop-in CSS variables produced by launchframe.
130
+ * Compatible with shadcn/ui's --background / --foreground / etc. tokens.
131
+ */
132
+
133
+ @tailwind base;
134
+ @tailwind components;
135
+ @tailwind utilities;
136
+
137
+ @layer base {
138
+ :root {
139
+ ${cssVarsBlock(system.light)}
140
+ --radius: ${pxToRem(system.radius.basePx)}rem;
141
+ }
142
+
143
+ .dark {
144
+ ${cssVarsBlock(system.dark)}
145
+ }
146
+ }
147
+
148
+ @layer base {
149
+ * { @apply border-border; }
150
+ body {
151
+ @apply bg-background text-foreground antialiased;
152
+ font-feature-settings: "rlig" 1, "calt" 1;
153
+ text-rendering: optimizeLegibility;
154
+ }
155
+ }
156
+ `;
157
+ }
158
+
159
+ function cssVarsBlock(ramp: ColorRamp): string {
160
+ const map: Array<[string, string]> = [
161
+ ["--background", ramp.background],
162
+ ["--foreground", ramp.foreground],
163
+ ["--card", ramp.card],
164
+ ["--card-foreground", ramp.cardForeground],
165
+ ["--popover", ramp.popover],
166
+ ["--popover-foreground", ramp.popoverForeground],
167
+ ["--primary", ramp.primary],
168
+ ["--primary-foreground", ramp.primaryForeground],
169
+ ["--secondary", ramp.secondary],
170
+ ["--secondary-foreground", ramp.secondaryForeground],
171
+ ["--muted", ramp.muted],
172
+ ["--muted-foreground", ramp.mutedForeground],
173
+ ["--accent", ramp.accent],
174
+ ["--accent-foreground", ramp.accentForeground],
175
+ ["--destructive", ramp.destructive],
176
+ ["--destructive-foreground", ramp.destructiveForeground],
177
+ ["--border", ramp.border],
178
+ ["--input", ramp.input],
179
+ ["--ring", ramp.ring],
180
+ ];
181
+ return map.map(([k, v]) => ` ${k}: ${hexToHslTokens(v)};`).join("\n");
182
+ }
183
+
184
+ function hexToHslTokens(hex: string): string {
185
+ const m = hex.match(/^#([0-9a-fA-F]{6})$/);
186
+ if (!m) return "0 0% 0%";
187
+ const num = parseInt(m[1]!, 16);
188
+ const r = ((num >> 16) & 0xff) / 255;
189
+ const g = ((num >> 8) & 0xff) / 255;
190
+ const b = (num & 0xff) / 255;
191
+ const max = Math.max(r, g, b);
192
+ const min = Math.min(r, g, b);
193
+ const l = (max + min) / 2;
194
+ let h = 0;
195
+ let s = 0;
196
+ if (max !== min) {
197
+ const d = max - min;
198
+ s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
199
+ switch (max) {
200
+ case r:
201
+ h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
202
+ break;
203
+ case g:
204
+ h = ((b - r) / d + 2) * 60;
205
+ break;
206
+ default:
207
+ h = ((r - g) / d + 4) * 60;
208
+ }
209
+ }
210
+ return `${Math.round(h)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
211
+ }
212
+
213
+ /* -------------------------------------------------------------------------- */
214
+ /* theme-preview.tsx */
215
+ /* -------------------------------------------------------------------------- */
216
+
217
+ function emitThemePreview(system: DesignSystem): string {
218
+ return `/**
219
+ * Theme preview for the design system extracted at ${system.runId}.
220
+ *
221
+ * Drop this into your app at \`components/theme-preview.tsx\` and render
222
+ * it on a route to eyeball every token in light and dark mode.
223
+ */
224
+
225
+ export default function ThemePreview() {
226
+ return (
227
+ <div className="space-y-12 p-8">
228
+ <header className="space-y-2">
229
+ <p className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
230
+ Design system preview
231
+ </p>
232
+ <h1 className="text-5xl font-semibold tracking-tight">
233
+ Headline at the 5xl step
234
+ </h1>
235
+ <p className="max-w-prose text-lg text-muted-foreground">
236
+ Body copy at the lg step demonstrating the chosen line-height and
237
+ letter-spacing across a representative paragraph length.
238
+ </p>
239
+ </header>
240
+
241
+ <section className="space-y-3">
242
+ <h2 className="text-2xl font-semibold">Type scale</h2>
243
+ <div className="space-y-2 font-mono text-sm">
244
+ ${(["xs", "sm", "base", "lg", "xl", "2xl", "3xl", "4xl", "5xl", "6xl"] as const)
245
+ .map(
246
+ (step) =>
247
+ ` <p className="text-${step}">${step} — The quick brown fox jumps over the lazy dog</p>`,
248
+ )
249
+ .join("\n")}
250
+ </div>
251
+ </section>
252
+
253
+ <section className="space-y-3">
254
+ <h2 className="text-2xl font-semibold">Color tokens</h2>
255
+ <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
256
+ ${[
257
+ "background",
258
+ "foreground",
259
+ "primary",
260
+ "primary-foreground",
261
+ "secondary",
262
+ "secondary-foreground",
263
+ "muted",
264
+ "muted-foreground",
265
+ "accent",
266
+ "accent-foreground",
267
+ "border",
268
+ "ring",
269
+ ]
270
+ .map(
271
+ (token) =>
272
+ ` <div className="rounded-md border border-border bg-${token.includes("foreground") ? "background" : token} p-4">
273
+ <div className="font-mono text-xs">${token}</div>
274
+ </div>`,
275
+ )
276
+ .join("\n")}
277
+ </div>
278
+ </section>
279
+
280
+ <section className="space-y-3">
281
+ <h2 className="text-2xl font-semibold">Radius and shadow</h2>
282
+ <div className="grid grid-cols-3 gap-3">
283
+ <div className="rounded-lg border border-border bg-card p-6 text-sm shadow-sm">shadow-sm + rounded-lg</div>
284
+ <div className="rounded-lg border border-border bg-card p-6 text-sm shadow-md">shadow-md + rounded-lg</div>
285
+ <div className="rounded-lg border border-border bg-card p-6 text-sm shadow-lg">shadow-lg + rounded-lg</div>
286
+ </div>
287
+ </section>
288
+ </div>
289
+ );
290
+ }
291
+ `;
292
+ }
293
+
294
+ /* -------------------------------------------------------------------------- */
295
+ /* REPORT.md */
296
+ /* -------------------------------------------------------------------------- */
297
+
298
+ function emitReport(system: DesignSystem, run: ExtractionRun): string {
299
+ const okCaptures = run.captures.filter((c) => c.status === "ok");
300
+ const failedCaptures = run.captures.filter((c) => c.status !== "ok");
301
+
302
+ const captureLines = okCaptures
303
+ .map((c) => {
304
+ const lines = [`- ${c.url}`, ` - screenshot: \`${relativize(c.screenshotPath, run.outputDir)}\``];
305
+ if (c.referenceDir) {
306
+ lines.push(
307
+ ` - reference: \`${relativize(c.referenceDir, run.outputDir)}/\` — \`page.html\`, \`visible-text.*\`, \`media.json\`, \`FOR_AI_REFERENCE.md\``,
308
+ );
309
+ }
310
+ if (c.mirrorDir) {
311
+ lines.push(
312
+ ` - mirror: \`${relativize(c.mirrorDir, run.outputDir)}/\` — \`page.tsx\`, \`MIRROR_NOTES.md\``,
313
+ );
314
+ }
315
+ return lines.join("\n");
316
+ })
317
+ .join("\n");
318
+
319
+ return `# Design system extraction report
320
+
321
+ **Run:** \`${system.runId}\`
322
+ **Generated:** ${run.finishedAt}
323
+
324
+ ## Inspiration sources
325
+
326
+ Tokens in this report were synthesized from the **rendered appearance**
327
+ (screenshots + computed styles) of these public pages.
328
+
329
+ When present, each capture also ships a **reference bundle** (\`reference/<host>/\`)
330
+ and a **layout mirror** (\`mirror/<host>/\`): serialized DOM, visible copy, a
331
+ media index, and a React page that reconstructs section structure. Use those
332
+ for structural fidelity alongside this token report — see **FOR_AI.md**.
333
+
334
+ ${captureLines}
335
+
336
+ ${
337
+ failedCaptures.length > 0
338
+ ? `### Skipped or failed\n\n${failedCaptures
339
+ .map((c) => `- ${c.url} ${c.status}${c.reason ? `: ${c.reason}` : ""}`)
340
+ .join("\n")}`
341
+ : ""
342
+ }
343
+
344
+ ## Synthesis decisions
345
+
346
+ ${system.notes.length > 0 ? system.notes.map((n) => `- ${n}`).join("\n") : "_No special notes — defaults applied where signals were weak._"}
347
+
348
+ ## Typography
349
+
350
+ - **Sans family:** ${system.typography.fontSans}${system.typography.fontSansSubstituted ? " *(substituted)*" : ""}
351
+ - **Mono family:** ${system.typography.fontMono}${system.typography.fontMonoSubstituted ? " *(substituted)*" : ""}
352
+ - **Body base:** ${system.typography.basePx}px
353
+ - **Scale ratio:** ${system.typography.scaleRatio}
354
+ - **Body line-height:** ${system.typography.bodyLineHeight}
355
+ - **Heading line-height:** ${system.typography.headingLineHeight}
356
+ - **Body letter-spacing:** ${system.typography.bodyLetterSpacingEm}em
357
+
358
+ | step | px |
359
+ | ---- | -- |
360
+ ${Object.entries(system.typography.steps).map(([k, v]) => `| ${k} | ${v} |`).join("\n")}
361
+
362
+ ## Spacing
363
+
364
+ - **Base unit:** ${system.spacing.basePx}px
365
+ - **Container width:** ${system.spacing.containerPx}px
366
+ - **Steps (px):** ${system.spacing.steps.join(", ")}
367
+
368
+ ## Radius
369
+
370
+ - **Base radius:** ${system.radius.basePx}px (used for shadcn's \`--radius\`)
371
+
372
+ ## Color tokens
373
+
374
+ ### Light theme
375
+
376
+ | token | value |
377
+ | ----- | ----- |
378
+ ${rampRows(system.light)}
379
+
380
+ ### Dark theme
381
+
382
+ | token | value |
383
+ | ----- | ----- |
384
+ ${rampRows(system.dark)}
385
+
386
+ ## Shadows
387
+
388
+ - \`shadow-sm\` — \`${system.shadows.sm}\`
389
+ - \`shadow-md\` — \`${system.shadows.md}\`
390
+ - \`shadow-lg\` — \`${system.shadows.lg}\`
391
+
392
+ ## Drop-in usage
393
+
394
+ 1. Copy \`tailwind.config.ts\` into the root of a Next.js + Tailwind project.
395
+ 2. Copy \`globals.css\` into \`app/globals.css\` (replacing the existing file).
396
+ 3. Render \`theme-preview.tsx\` somewhere to eyeball the system.
397
+ 4. Iterate from there. The tokens are yours; this report is a record of where
398
+ they came from.
399
+
400
+ ## What this report is (and is not)
401
+
402
+ - **This REPORT** is **token synthesis** (colors, type scale, spacing, radii,
403
+ shadows). It does not, by itself, define every layout detail of a source page.
404
+ - **Per-site \`reference/\` and \`mirror/\` folders** (when emitted) are the
405
+ recon inputs for **structure and copy**: map landmarks and section order from
406
+ \`page.html\`, pull strings from \`visible-text.json\` or \`visible-text.txt\`,
407
+ align media via \`media.json\`, and implement or refine UI starting from
408
+ \`mirror/<host>/page.tsx\` (\`data-mirror-section\` markers match the crawl).
409
+ - **Compliance:** Do not impersonate another company, ship their trademarks or
410
+ logos as your own, or use third-party media without permission. Use captured
411
+ copy when mirroring **your own** property or when you have rights; otherwise
412
+ replace with original product copy while keeping layout fidelity.
413
+
414
+ If a source operator requests removal, delete captured artifacts for that URL,
415
+ drop it from your manifest, and re-run the extraction.
416
+ `;
417
+ }
418
+
419
+ /**
420
+ * Copy-paste handoff for Cursor / Claude Code / any agent.
421
+ */
422
+ function emitForAi(system: DesignSystem, run: ExtractionRun): string {
423
+ const abs = run.outputDir.replace(/\\/g, "/");
424
+ const ok = run.captures.filter((c) => c.status === "ok");
425
+ const hasReference = ok.some((c) => c.referenceDir);
426
+ const hasMirror = ok.some((c) => c.mirrorDir);
427
+ const perHost = ok
428
+ .filter((c) => c.referenceDir || c.mirrorDir)
429
+ .map((c) => {
430
+ const lines = [`### ${c.url}`];
431
+ if (c.referenceDir) {
432
+ lines.push(`- **Reference:** \`${relativize(c.referenceDir, run.outputDir)}/\` — start with \`FOR_AI_REFERENCE.md\`, then \`page.html\`, \`visible-text.txt\` (or \`.json\`), \`media.json\`.`);
433
+ }
434
+ if (c.mirrorDir) {
435
+ lines.push(`- **Mirror:** \`${relativize(c.mirrorDir, run.outputDir)}/page.tsx\` — section scaffold + \`data-mirror-section\`; read \`MIRROR_NOTES.md\`.`);
436
+ }
437
+ return lines.join("\n");
438
+ })
439
+ .join("\n\n");
440
+
441
+ const structureSection =
442
+ hasReference || hasMirror
443
+ ? `
444
+
445
+ ## Per-site recon inputs (use for layout + copy)
446
+
447
+ ${perHost || "_No reference/mirror paths on this run — token-only._"}
448
+
449
+ Workflow (similar in spirit to **recon → specs → build** pipelines):
450
+
451
+ 1. **Recon:** Skim \`page.html\` for DOM landmarks (\`<header>\`, main columns, card grids, repeated list patterns). Cross-check section order with \`mirror/.../page.tsx\` (\`data-mirror-section\`).
452
+ 2. **Wire copy + media:** Map headings, buttons, and blocks from \`visible-text.*\` into the matching mirror sections (or your components). Use \`media.json\` for asset URLs; replace with licensed or original assets when shipping.
453
+ 3. **Build:** Prefer editing **mirror \`page.tsx\`** inside the user's app (or port its structure into their file tree) rather than inventing a new section order from scratch. Apply **REPORT.md** / **tokens** for colors, type, spacing, radii — mirror CSS variables under \`.mirror-root\` should converge to the same semantic palette where possible.
454
+
455
+ **Compliance:** Do not pass off another brand as the user's product. Omit or replace logos, trademarked names used as if they were the user's, and any copy the user says is off-limits. When the user **wants** fidelity to the crawled page (e.g. their own URL), use \`visible-text\` verbatim where appropriate.
456
+ `
457
+ : "";
458
+
459
+ return `# Hand this folder to your AI (Cursor, Claude Code, etc.)
460
+
461
+ **Run:** \`${system.runId}\`
462
+ **Absolute path:** \`${abs}\`
463
+
464
+ ## What to attach
465
+
466
+ Attach this entire \`output/${system.runId}/\` folder (or copy it into the app repo).
467
+
468
+ **Minimum:** \`FOR_AI.md\`, \`REPORT.md\`, \`tokens.json\`, \`tailwind.config.ts\`, \`globals.css\`.
469
+
470
+ **When rebuilding from a crawled URL, also attach (per host):**
471
+ \`reference/<host>/\` **and** \`mirror/<host>/\` — the model must see both **verbatim DOM/copy** and the **typed React scaffold**.
472
+
473
+ In **Cursor**, \`@\` those paths explicitly.
474
+
475
+ ## Authority order
476
+
477
+ 1. **Structural fidelity:** \`reference/<host>/page.html\` + \`visible-text.*\` + \`mirror/<host>/page.tsx\` — section order, composition patterns, and strings (when the user wants fidelity).
478
+ 2. **Design tokens:** **REPORT.md** and **tokens.json** — typography scale, spacing, radii, colors, container width, notes.
479
+ 3. **Integration:** **tailwind.config.ts** + **globals.css** — merge into a Next.js + Tailwind + shadcn-style app.
480
+ ${structureSection}
481
+ ## Instruction block (paste into chat)
482
+
483
+ \`\`\`text
484
+ You must use the attached \`output/${system.runId}/\` folder.
485
+
486
+ - Read REPORT.md and tokens.json before writing UI. Merge tailwind.config.ts and globals.css into my project (preserve my paths unless I say otherwise).
487
+ - Style with semantic tokens: bg-background, text-foreground, text-muted-foreground, border-border, bg-primary, text-primary-foreground, bg-card, text-card-foreground, etc. Prefer these over ad-hoc hex; mirror pages may use --mirror-* variables until merged.
488
+ - If reference/ and mirror/ exist for my source URL: treat them as mandatory context. Preserve crawled section order and layout patterns: align components to data-mirror-section and page.html landmarks. Wire copy from visible-text.* and media from media.json unless I say to rewrite for a different product.
489
+ - If I am building a NEW product unrelated to the crawl: keep layout inspiration from mirror/reference but REPLACE product names, claims, and sensitive copy with my copy. Never impersonate another brand.
490
+
491
+ My product / intent: [describe goal — faithful mirror of URL vs new product in same layout; tone and CTA]
492
+ \`\`\`
493
+
494
+ ## After the agent runs
495
+
496
+ - Compare against **theme-preview.tsx** to verify token usage.
497
+ - Iterate with REPORT.md + reference/ + mirror/ in context.
498
+ `;
499
+ }
500
+
501
+ function rampRows(ramp: ColorRamp): string {
502
+ const entries: Array<[string, string]> = [
503
+ ["background", ramp.background],
504
+ ["foreground", ramp.foreground],
505
+ ["card", ramp.card],
506
+ ["card-foreground", ramp.cardForeground],
507
+ ["primary", ramp.primary],
508
+ ["primary-foreground", ramp.primaryForeground],
509
+ ["secondary", ramp.secondary],
510
+ ["secondary-foreground", ramp.secondaryForeground],
511
+ ["muted", ramp.muted],
512
+ ["muted-foreground", ramp.mutedForeground],
513
+ ["accent", ramp.accent],
514
+ ["accent-foreground", ramp.accentForeground],
515
+ ["destructive", ramp.destructive],
516
+ ["destructive-foreground", ramp.destructiveForeground],
517
+ ["border", ramp.border],
518
+ ["input", ramp.input],
519
+ ["ring", ramp.ring],
520
+ ];
521
+ return entries.map(([k, v]) => `| \`--${k}\` | \`${v}\` |`).join("\n");
522
+ }
523
+
524
+ /* -------------------------------------------------------------------------- */
525
+ /* Helpers */
526
+ /* -------------------------------------------------------------------------- */
527
+
528
+ function pxToRem(px: number): string {
529
+ return (Math.round((px / 16) * 1000) / 1000).toString();
530
+ }
531
+
532
+ function relativize(p: string, base: string): string {
533
+ return p.replace(base + "/", "").replace(base + "\\", "");
534
+ }