launchframe 0.1.0 → 0.1.1

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchframe",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Point Launchframe at SaaS sites you admire and get back a drop-in shadcn/ui design system (tokens, Tailwind theme, CSS variables, AI handoff) you can build your own UI on top of.",
5
5
  "license": "MIT",
6
6
  "author": "Evan Gruhlkey",
@@ -1,466 +1,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, with the
13
- * anti-clone disclaimer
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, with the
13
+ * anti-clone disclaimer
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
+ }