launchframe 0.1.4 → 0.1.6

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,522 @@
1
+ /**
2
+ * Mirror emitter.
3
+ *
4
+ * Consumes a typed `SiteLayout` (produced by `dom-crawler.ts`) and writes:
5
+ * - `<dir>/page.tsx` — a Next.js page that reconstructs the source's
6
+ * section tree, grid composition, and density
7
+ * from typed primitives, with `<TextSlot>` /
8
+ * `<MediaSlot>` placeholders for content.
9
+ * - `<dir>/layout.json` — the captured `SiteLayout` model for review.
10
+ * - `<dir>/MIRROR_NOTES.md` — what was extracted, how slots are filled.
11
+ *
12
+ * The emitter never embeds the source's verbatim copy text or brand
13
+ * assets. Headings, body copy, buttons, images, and logos are rendered as
14
+ * slot placeholders that the operator fills in.
15
+ */
16
+
17
+ import { mkdirSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+
20
+ import type {
21
+ Composition,
22
+ SectionLayout,
23
+ SectionRole,
24
+ SiteLayout,
25
+ SlotCount,
26
+ SlotKind,
27
+ SiteTokens,
28
+ } from "./types.js";
29
+
30
+ export function emitMirror(layout: SiteLayout, outDir: string): string[] {
31
+ mkdirSync(outDir, { recursive: true });
32
+ const written: string[] = [];
33
+
34
+ const pagePath = join(outDir, "page.tsx");
35
+ writeFileSync(pagePath, emitPage(layout));
36
+ written.push(pagePath);
37
+
38
+ const layoutPath = join(outDir, "layout.json");
39
+ writeFileSync(layoutPath, JSON.stringify(layout, null, 2) + "\n");
40
+ written.push(layoutPath);
41
+
42
+ const notesPath = join(outDir, "MIRROR_NOTES.md");
43
+ writeFileSync(notesPath, emitNotes(layout));
44
+ written.push(notesPath);
45
+
46
+ return written;
47
+ }
48
+
49
+ /* -------------------------------------------------------------------------- */
50
+ /* page.tsx */
51
+ /* -------------------------------------------------------------------------- */
52
+
53
+ function emitPage(layout: SiteLayout): string {
54
+ const componentName = toPascalCase(layout.host.replace(/[^a-z0-9]/gi, "-")) + "Mirror";
55
+
56
+ const sectionExprs = layout.sections.map((s, i) =>
57
+ emitSection(s, layout.tokens, i),
58
+ );
59
+
60
+ return [
61
+ "/**",
62
+ ` * Mirror page for ${layout.url}`,
63
+ ` * Captured ${layout.capturedAt}`,
64
+ ` * Viewport ${layout.viewport.width}×${layout.viewport.height}`,
65
+ " *",
66
+ " * Generated by launchframe — packages/extract/mirror-emit.ts",
67
+ " *",
68
+ " * Fill in <TextSlot> and <MediaSlot> placeholders with your own copy",
69
+ " * and imagery before shipping. The visual tokens (color, type, radius,",
70
+ ' * container width) reflect the source page and are scoped to the',
71
+ ' * `.mirror-root` wrapper below — no global CSS leakage.',
72
+ " */",
73
+ "",
74
+ 'import {',
75
+ " FadeUp,",
76
+ " MediaSlot,",
77
+ " Stagger,",
78
+ " StaggerItem,",
79
+ " TextSlot,",
80
+ " cn,",
81
+ "} from \"@framework/blocks\";",
82
+ "",
83
+ `export const meta = ${JSON.stringify(
84
+ {
85
+ kind: "mirror",
86
+ source: layout.url,
87
+ capturedAt: layout.capturedAt,
88
+ sections: layout.sections.map((s) => ({
89
+ id: s.id,
90
+ role: s.role,
91
+ composition: s.composition,
92
+ density: s.density,
93
+ })),
94
+ },
95
+ null,
96
+ 2,
97
+ )} as const;`,
98
+ "",
99
+ `export default function ${componentName}() {`,
100
+ " return (",
101
+ ` <div className="mirror-root">`,
102
+ ` <style>{${JSON.stringify(emitScopedTokens(layout.tokens))}}</style>`,
103
+ ...sectionExprs.map((expr) => " " + expr.split("\n").join("\n ")),
104
+ " </div>",
105
+ " );",
106
+ "}",
107
+ "",
108
+ ].join("\n");
109
+ }
110
+
111
+ function emitScopedTokens(tokens: SiteTokens): string {
112
+ const radius = `${(tokens.radiusPx / 16).toFixed(3)}rem`;
113
+ const containerPx = tokens.containerPx ?? 1120;
114
+ return [
115
+ `.mirror-root {`,
116
+ ` --mirror-background: ${tokens.backgroundHex};`,
117
+ ` --mirror-foreground: ${tokens.foregroundHex};`,
118
+ ` --mirror-primary: ${tokens.primaryHex};`,
119
+ ` --mirror-muted: ${tokens.mutedHex};`,
120
+ ` --mirror-border: ${tokens.borderHex};`,
121
+ ` --mirror-radius: ${radius};`,
122
+ ` --mirror-container: ${containerPx}px;`,
123
+ ` --mirror-font-body: ${escapeFontFamily(tokens.bodyFontFamily)};`,
124
+ ` --mirror-font-heading: ${escapeFontFamily(tokens.headingFontFamily)};`,
125
+ ` background: var(--mirror-background);`,
126
+ ` color: var(--mirror-foreground);`,
127
+ ` font-family: var(--mirror-font-body);`,
128
+ `}`,
129
+ `.mirror-root h1, .mirror-root h2, .mirror-root h3 {`,
130
+ ` font-family: var(--mirror-font-heading);`,
131
+ `}`,
132
+ `.mirror-section { padding-block: 5rem; }`,
133
+ `@media (min-width: 768px) { .mirror-section { padding-block: 7rem; } }`,
134
+ `.mirror-container { max-width: var(--mirror-container); margin-inline: auto; padding-inline: 1.5rem; }`,
135
+ ].join("\n");
136
+ }
137
+
138
+ function escapeFontFamily(family: string): string {
139
+ const trimmed = family.replace(/^["']|["']$/g, "");
140
+ if (/^[a-zA-Z0-9_-]+$/.test(trimmed)) return `${trimmed}, system-ui, sans-serif`;
141
+ return `"${trimmed}", system-ui, sans-serif`;
142
+ }
143
+
144
+ /* -------------------------------------------------------------------------- */
145
+ /* Per-section rendering */
146
+ /* -------------------------------------------------------------------------- */
147
+
148
+ function emitSection(s: SectionLayout, tokens: SiteTokens, index: number): string {
149
+ const wrapperBg = s.styles.backgroundHex ?? tokens.backgroundHex;
150
+ const padTop = s.styles.paddingTopPx ?? null;
151
+ const padBottom = s.styles.paddingBottomPx ?? null;
152
+ const styleAttr = emitStyleAttr({
153
+ backgroundColor: wrapperBg,
154
+ paddingTop: padTop !== null ? `${padTop}px` : undefined,
155
+ paddingBottom: padBottom !== null ? `${padBottom}px` : undefined,
156
+ });
157
+
158
+ const labelId = `${s.id}-heading`;
159
+ const inner = emitSectionInner(s, labelId);
160
+
161
+ return [
162
+ `<section`,
163
+ ` aria-labelledby=${JSON.stringify(labelId)}`,
164
+ ` data-mirror-section=${JSON.stringify(`${s.id}:${s.role}:${s.composition}:${s.density}`)}`,
165
+ ` className=${JSON.stringify(sectionWrapperClass(s.role, index))}`,
166
+ styleAttr ? ` style={${styleAttr}}` : "",
167
+ `>`,
168
+ ` <div className="mirror-container">`,
169
+ inner.split("\n").map((l) => " " + l).join("\n"),
170
+ ` </div>`,
171
+ `</section>`,
172
+ ]
173
+ .filter((s) => s.length > 0)
174
+ .join("\n");
175
+ }
176
+
177
+ function emitStyleAttr(style: {
178
+ backgroundColor?: string | null;
179
+ paddingTop?: string | undefined;
180
+ paddingBottom?: string | undefined;
181
+ }): string {
182
+ const entries: string[] = [];
183
+ if (style.backgroundColor)
184
+ entries.push(`backgroundColor: ${JSON.stringify(style.backgroundColor)}`);
185
+ if (style.paddingTop) entries.push(`paddingTop: ${JSON.stringify(style.paddingTop)}`);
186
+ if (style.paddingBottom)
187
+ entries.push(`paddingBottom: ${JSON.stringify(style.paddingBottom)}`);
188
+ if (entries.length === 0) return "";
189
+ return `{ ${entries.join(", ")} }`;
190
+ }
191
+
192
+ function sectionWrapperClass(role: SectionRole, index: number): string {
193
+ const base = "mirror-section";
194
+ if (role === "nav") return `${base} border-b border-border py-4 md:py-4`;
195
+ if (role === "footer") return `${base} border-t border-border bg-muted/30`;
196
+ if (role === "hero" && index === 0) return `${base} border-b border-border`;
197
+ return `${base} border-b border-border`;
198
+ }
199
+
200
+ function emitSectionInner(s: SectionLayout, labelId: string): string {
201
+ switch (s.composition) {
202
+ case "split-2":
203
+ return emitSplitTwo(s, labelId);
204
+ case "grid-2":
205
+ case "grid-3":
206
+ case "grid-4":
207
+ return emitGrid(s, labelId);
208
+ case "logo-row":
209
+ return emitLogoRow(s);
210
+ case "list":
211
+ return emitList(s, labelId);
212
+ case "single-column":
213
+ case "unknown":
214
+ default:
215
+ return emitSingleColumn(s, labelId);
216
+ }
217
+ }
218
+
219
+ function gridCols(composition: Composition): number {
220
+ if (composition === "grid-2") return 2;
221
+ if (composition === "grid-3") return 3;
222
+ if (composition === "grid-4") return 4;
223
+ return 1;
224
+ }
225
+
226
+ /* ---------- composition templates ---------- */
227
+
228
+ function emitSingleColumn(s: SectionLayout, labelId: string): string {
229
+ const slots = slotMap(s.slots);
230
+ const head: string[] = [];
231
+
232
+ const eyebrow = slots.eyebrow ?? 0;
233
+ for (let i = 0; i < eyebrow; i++) head.push(textSlot("eyebrow"));
234
+
235
+ if ((slots["heading-1"] ?? 0) > 0) {
236
+ head.push(textSlot("heading-1", { id: labelId, count: slots["heading-1"]! }));
237
+ } else if ((slots["heading-2"] ?? 0) > 0) {
238
+ head.push(textSlot("heading-2", { id: labelId, count: 1 }));
239
+ }
240
+ const body = slots.body ?? 0;
241
+ for (let i = 0; i < Math.min(body, 3); i++) head.push(textSlot("body"));
242
+
243
+ const buttons: string[] = [];
244
+ if ((slots["button-primary"] ?? 0) > 0) buttons.push(textSlot("button-primary"));
245
+ if ((slots["button-secondary"] ?? 0) > 0) buttons.push(textSlot("button-secondary"));
246
+
247
+ const headBlock = head.length > 0 ? wrapFadeUp(head.join("\n")) : "";
248
+ const buttonsBlock =
249
+ buttons.length > 0
250
+ ? `<div className="mt-8 flex flex-wrap items-center gap-3">\n${indent(buttons.join("\n"), 2)}\n</div>`
251
+ : "";
252
+ const mediaBlock = emitMediaSlots(slots);
253
+
254
+ return [headBlock, buttonsBlock, mediaBlock].filter(Boolean).join("\n");
255
+ }
256
+
257
+ function emitSplitTwo(s: SectionLayout, labelId: string): string {
258
+ const slots = slotMap(s.slots);
259
+ const textChildren: string[] = [];
260
+ if ((slots.eyebrow ?? 0) > 0) textChildren.push(textSlot("eyebrow"));
261
+ if ((slots["heading-1"] ?? 0) > 0) {
262
+ textChildren.push(textSlot("heading-1", { id: labelId }));
263
+ } else if ((slots["heading-2"] ?? 0) > 0) {
264
+ textChildren.push(textSlot("heading-2", { id: labelId }));
265
+ }
266
+ const body = slots.body ?? 0;
267
+ for (let i = 0; i < Math.min(body, 2); i++) textChildren.push(textSlot("body"));
268
+
269
+ const bullets = slots.bullet ?? 0;
270
+ if (bullets > 0) {
271
+ const items: string[] = [];
272
+ for (let i = 0; i < Math.min(bullets, 5); i++) items.push(textSlot("bullet"));
273
+ textChildren.push(
274
+ [
275
+ '<ul className="mt-6 space-y-2 text-sm">',
276
+ ...items.map((it) => indent(it, 1)),
277
+ "</ul>",
278
+ ].join("\n"),
279
+ );
280
+ }
281
+
282
+ const buttons: string[] = [];
283
+ if ((slots["button-primary"] ?? 0) > 0) buttons.push(textSlot("button-primary"));
284
+ if ((slots["button-secondary"] ?? 0) > 0) buttons.push(textSlot("button-secondary"));
285
+ if (buttons.length > 0) {
286
+ textChildren.push(
287
+ [
288
+ '<div className="mt-8 flex flex-wrap items-center gap-3">',
289
+ ...buttons.map((b) => indent(b, 1)),
290
+ "</div>",
291
+ ].join("\n"),
292
+ );
293
+ }
294
+
295
+ const textCol = wrapFadeUp(textChildren.join("\n"));
296
+ const mediaCol = wrapFadeUp(emitMediaSlots(slots, { aspect: "video" }) || mediaSlot("image"));
297
+
298
+ return [
299
+ '<div className="grid items-center gap-12 md:grid-cols-2">',
300
+ ` <div>${"\n" + indent(textCol, 2) + "\n "}</div>`,
301
+ ` <div>${"\n" + indent(mediaCol, 2) + "\n "}</div>`,
302
+ "</div>",
303
+ ].join("\n");
304
+ }
305
+
306
+ function emitGrid(s: SectionLayout, labelId: string): string {
307
+ const slots = slotMap(s.slots);
308
+ const cols = gridCols(s.composition);
309
+
310
+ const heading =
311
+ (slots["heading-1"] ?? 0) > 0
312
+ ? textSlot("heading-1", { id: labelId })
313
+ : (slots["heading-2"] ?? 0) > 0
314
+ ? textSlot("heading-2", { id: labelId })
315
+ : "";
316
+ const introBody =
317
+ (slots.body ?? 0) > 0 ? `<div className="mt-4">${textSlot("body")}</div>` : "";
318
+
319
+ const cardHeading: SlotKind =
320
+ (slots["heading-2"] ?? 0) >= cols ? "heading-2" : "heading-3";
321
+
322
+ const card = [
323
+ '<li className="flex flex-col gap-3 rounded-lg border border-border bg-card p-6">',
324
+ ` ${mediaSlot("icon", { aspect: "square", className: "size-10" })}`,
325
+ ` ${textSlot(cardHeading)}`,
326
+ ` ${textSlot("body")}`,
327
+ "</li>",
328
+ ].join("\n");
329
+
330
+ const cards: string[] = [];
331
+ for (let i = 0; i < cols; i++) cards.push(card);
332
+ const grid = [
333
+ `<Stagger as="ul" className=${JSON.stringify(`mt-12 grid gap-6 md:grid-cols-${cols}`)}>`,
334
+ ...cards.map((c) =>
335
+ [
336
+ ' <StaggerItem as="li" className="flex flex-col gap-3 rounded-lg border border-border bg-card p-6">',
337
+ ` ${mediaSlot("icon", { aspect: "square", className: "size-10" })}`,
338
+ ` ${textSlot(cardHeading)}`,
339
+ ` ${textSlot("body")}`,
340
+ " </StaggerItem>",
341
+ ].join("\n"),
342
+ ),
343
+ "</Stagger>",
344
+ ];
345
+
346
+ const head = [
347
+ heading ? wrapFadeUp(heading) : "",
348
+ introBody,
349
+ ]
350
+ .filter(Boolean)
351
+ .join("\n");
352
+
353
+ return [head, grid.join("\n")].filter(Boolean).join("\n");
354
+ }
355
+
356
+ function emitLogoRow(s: SectionLayout): string {
357
+ const slots = slotMap(s.slots);
358
+ const count = Math.max(4, Math.min(slots["logo-mono"] ?? 6, 8));
359
+ const items: string[] = [];
360
+ for (let i = 0; i < count; i++) {
361
+ items.push(mediaSlot("logo-mono", { aspect: "auto", className: "h-7 w-24" }));
362
+ }
363
+ return [
364
+ '<div className="text-center text-xs font-semibold uppercase tracking-wider text-muted-foreground">',
365
+ " <TextSlot kind=\"eyebrow\" />",
366
+ "</div>",
367
+ '<div className="mt-8 grid grid-cols-2 items-center justify-items-center gap-x-10 gap-y-6 sm:grid-cols-3 md:grid-cols-6">',
368
+ ...items.map((it) => ` ${it}`),
369
+ "</div>",
370
+ ].join("\n");
371
+ }
372
+
373
+ function emitList(s: SectionLayout, labelId: string): string {
374
+ const slots = slotMap(s.slots);
375
+ const heading =
376
+ (slots["heading-1"] ?? 0) > 0
377
+ ? textSlot("heading-1", { id: labelId })
378
+ : (slots["heading-2"] ?? 0) > 0
379
+ ? textSlot("heading-2", { id: labelId })
380
+ : "";
381
+
382
+ const items: string[] = [];
383
+ const count = Math.max(3, Math.min(slots.bullet ?? 5, 8));
384
+ for (let i = 0; i < count; i++) items.push(textSlot("bullet"));
385
+
386
+ return [
387
+ heading ? wrapFadeUp(heading) : "",
388
+ `<ul className="mt-8 space-y-3 text-sm">`,
389
+ ...items.map((it) => ` ${it}`),
390
+ "</ul>",
391
+ ]
392
+ .filter(Boolean)
393
+ .join("\n");
394
+ }
395
+
396
+ /* ---------- slot helpers ---------- */
397
+
398
+ function slotMap(slots: SlotCount[]): Partial<Record<SlotKind, number>> {
399
+ const out: Partial<Record<SlotKind, number>> = {};
400
+ for (const s of slots) out[s.kind] = s.count;
401
+ return out;
402
+ }
403
+
404
+ function textSlot(
405
+ kind: "eyebrow" | "heading-1" | "heading-2" | "heading-3" | "body" | "bullet" | "button-primary" | "button-secondary" | "badge",
406
+ opts: { id?: string; count?: number } = {},
407
+ ): string {
408
+ const idAttr = opts.id ? ` id=${JSON.stringify(opts.id)}` : "";
409
+ return `<TextSlot kind=${JSON.stringify(kind)}${idAttr} />`;
410
+ }
411
+
412
+ function mediaSlot(
413
+ kind: "image" | "logo-mono" | "icon" | "video" | "code",
414
+ opts: { aspect?: "video" | "square" | "4/3" | "21/9" | "auto"; className?: string } = {},
415
+ ): string {
416
+ const attrs: string[] = [`kind=${JSON.stringify(kind)}`];
417
+ if (opts.aspect) attrs.push(`aspect=${JSON.stringify(opts.aspect)}`);
418
+ if (opts.className) attrs.push(`className=${JSON.stringify(opts.className)}`);
419
+ return `<MediaSlot ${attrs.join(" ")} />`;
420
+ }
421
+
422
+ function emitMediaSlots(
423
+ slots: Partial<Record<SlotKind, number>>,
424
+ opts: { aspect?: "video" | "square" | "4/3" | "21/9" | "auto" } = {},
425
+ ): string {
426
+ const parts: string[] = [];
427
+ if ((slots.image ?? 0) > 0) parts.push(mediaSlot("image", { aspect: opts.aspect ?? "video" }));
428
+ if ((slots.code ?? 0) > 0) parts.push(mediaSlot("code", { aspect: "auto" }));
429
+ if ((slots.video ?? 0) > 0) parts.push(mediaSlot("video", { aspect: opts.aspect ?? "video" }));
430
+ if (parts.length === 0) return "";
431
+ if (parts.length === 1) return `<div className="mt-12">${parts[0]}</div>`;
432
+ return [
433
+ '<div className="mt-12 grid gap-6 md:grid-cols-2">',
434
+ ...parts.map((p) => ` ${p}`),
435
+ "</div>",
436
+ ].join("\n");
437
+ }
438
+
439
+ function wrapFadeUp(children: string): string {
440
+ return [
441
+ "<FadeUp>",
442
+ indent(children, 1),
443
+ "</FadeUp>",
444
+ ].join("\n");
445
+ }
446
+
447
+ function indent(s: string, levels: number): string {
448
+ const pad = " ".repeat(levels);
449
+ return s
450
+ .split("\n")
451
+ .map((l) => (l.length > 0 ? pad + l : l))
452
+ .join("\n");
453
+ }
454
+
455
+ function toPascalCase(input: string): string {
456
+ return input
457
+ .split(/[^a-zA-Z0-9]+/)
458
+ .filter(Boolean)
459
+ .map((w) => w[0]!.toUpperCase() + w.slice(1))
460
+ .join("") || "Mirror";
461
+ }
462
+
463
+ /* -------------------------------------------------------------------------- */
464
+ /* MIRROR_NOTES.md */
465
+ /* -------------------------------------------------------------------------- */
466
+
467
+ function emitNotes(layout: SiteLayout): string {
468
+ const lines: string[] = [];
469
+ lines.push(`# Mirror notes — ${layout.host}`);
470
+ lines.push("");
471
+ lines.push(`Source URL: ${layout.url}`);
472
+ lines.push(`Captured: ${layout.capturedAt}`);
473
+ lines.push(`Viewport: ${layout.viewport.width}×${layout.viewport.height}`);
474
+ lines.push(`Rendered page height: ${layout.pageHeightPx}px`);
475
+ lines.push("");
476
+ lines.push("## What was extracted");
477
+ lines.push("");
478
+ lines.push(
479
+ "- Top-level section tree (geometry, role, composition, density)",
480
+ );
481
+ lines.push("- Slot inventory per section (counts of headings / body / buttons / images / icons / logos)");
482
+ lines.push("- Per-section background and padding");
483
+ lines.push("- Page-level tokens (fonts, primary/muted/border/foreground, radius, container width)");
484
+ lines.push("");
485
+ lines.push("## What was **not** extracted");
486
+ lines.push("");
487
+ lines.push("- Source headlines, body copy, or microcopy text");
488
+ lines.push("- Brand logos, illustrations, or product screenshots");
489
+ lines.push("- Source HTML, CSS, or class names");
490
+ lines.push("");
491
+ lines.push(
492
+ "The `page.tsx` reconstructs the section grammar from typed primitives. Headings, body copy, buttons, images, and logos appear as `<TextSlot>` / `<MediaSlot>` placeholders. Fill them with your own content before shipping.",
493
+ );
494
+ lines.push("");
495
+ lines.push("## Section breakdown");
496
+ lines.push("");
497
+ lines.push("| # | role | composition | density | slots |");
498
+ lines.push("| - | ---- | ----------- | ------- | ----- |");
499
+ for (const s of layout.sections) {
500
+ const slotSummary = s.slots
501
+ .map((sl) => `${sl.kind}×${sl.count}`)
502
+ .join(", ");
503
+ lines.push(
504
+ `| ${s.id} | ${s.role} | ${s.composition} | ${s.density} | ${slotSummary || "—"} |`,
505
+ );
506
+ }
507
+ lines.push("");
508
+ lines.push("## How to fill in slots");
509
+ lines.push("");
510
+ lines.push("```tsx");
511
+ lines.push('// Empty (renders dashed placeholder):');
512
+ lines.push('<TextSlot kind="heading-1" />');
513
+ lines.push("");
514
+ lines.push("// Filled:");
515
+ lines.push('<TextSlot kind="heading-1">The thing your product does, in plain language.</TextSlot>');
516
+ lines.push('<MediaSlot kind="image" aspect="video">');
517
+ lines.push(" <Image src=\"/hero.jpg\" alt=\"Hero photograph\" width={1200} height={675} />");
518
+ lines.push("</MediaSlot>");
519
+ lines.push("```");
520
+ lines.push("");
521
+ return lines.join("\n");
522
+ }
@@ -170,6 +170,120 @@ export interface DesignSystem {
170
170
  notes: string[];
171
171
  }
172
172
 
173
+ /* -------------------------------------------------------------------------- */
174
+ /* Layout mirror (per-site) */
175
+ /* -------------------------------------------------------------------------- */
176
+
177
+ /**
178
+ * Structural roles a top-level section can play. Inferred heuristically from
179
+ * geometry, content kinds, and document position — not from the source's
180
+ * class names.
181
+ */
182
+ export type SectionRole =
183
+ | "nav"
184
+ | "hero"
185
+ | "feature-grid"
186
+ | "feature-deep-dive"
187
+ | "proof-logos"
188
+ | "proof-quotes"
189
+ | "pricing"
190
+ | "conversion"
191
+ | "footer"
192
+ | "other";
193
+
194
+ /**
195
+ * Coarse composition shape used by the emitter to pick a wrapper layout.
196
+ */
197
+ export type Composition =
198
+ | "single-column"
199
+ | "split-2"
200
+ | "grid-2"
201
+ | "grid-3"
202
+ | "grid-4"
203
+ | "list"
204
+ | "logo-row"
205
+ | "unknown";
206
+
207
+ /**
208
+ * The kinds of content a section can hold. The emitter renders an
209
+ * appropriately-styled placeholder (`<TextSlot>` / `<MediaSlot>`) per slot
210
+ * so the user fills in their own copy and assets.
211
+ */
212
+ export type SlotKind =
213
+ | "heading-1"
214
+ | "heading-2"
215
+ | "heading-3"
216
+ | "eyebrow"
217
+ | "body"
218
+ | "bullet"
219
+ | "button-primary"
220
+ | "button-secondary"
221
+ | "image"
222
+ | "logo-mono"
223
+ | "icon"
224
+ | "code"
225
+ | "badge"
226
+ | "input"
227
+ | "video";
228
+
229
+ export interface SlotCount {
230
+ kind: SlotKind;
231
+ count: number;
232
+ }
233
+
234
+ /**
235
+ * A single top-level section in document order. Geometry is normalized to
236
+ * [0, 1] over the rendered page so the emitter can compare relative weight.
237
+ */
238
+ export interface SectionLayout {
239
+ /** Stable id assigned in document order: s1, s2, ... */
240
+ id: string;
241
+ role: SectionRole;
242
+ composition: Composition;
243
+ density: "thin" | "balanced" | "dense";
244
+ /** Bounding box [x, y, w, h] normalized to [0, 1] over the rendered page. */
245
+ bbox: [number, number, number, number];
246
+ /** Aggregated content-kind counts inside the section. */
247
+ slots: SlotCount[];
248
+ /** Per-section style hints; the emitter applies these as inline overrides. */
249
+ styles: {
250
+ backgroundHex: string | null;
251
+ foregroundHex: string | null;
252
+ paddingTopPx: number | null;
253
+ paddingBottomPx: number | null;
254
+ };
255
+ /** Free-form notes the emitter surfaces in `MIRROR_NOTES.md`. */
256
+ notes: string[];
257
+ }
258
+
259
+ /**
260
+ * Page-level computed-style tokens. These complement the synthesized
261
+ * `DesignSystem` so a mirror page can apply a site-specific theme without
262
+ * the system having to reseed the cross-corpus palette.
263
+ */
264
+ export interface SiteTokens {
265
+ bodyFontFamily: string;
266
+ headingFontFamily: string;
267
+ backgroundHex: string;
268
+ foregroundHex: string;
269
+ primaryHex: string;
270
+ mutedHex: string;
271
+ borderHex: string;
272
+ radiusPx: number;
273
+ containerPx: number | null;
274
+ }
275
+
276
+ export interface SiteLayout {
277
+ url: string;
278
+ host: string;
279
+ capturedAt: string;
280
+ viewport: { width: number; height: number };
281
+ /** Full rendered page height in CSS pixels. */
282
+ pageHeightPx: number;
283
+ sections: SectionLayout[];
284
+ tokens: SiteTokens;
285
+ }
286
+
173
287
  /* -------------------------------------------------------------------------- */
174
288
  /* Run summary */
175
289
  /* -------------------------------------------------------------------------- */
@@ -180,6 +294,10 @@ export interface SiteCapture {
180
294
  capturedAt: string;
181
295
  screenshotPath: string;
182
296
  rawTokensPath: string;
297
+ /** Path to the per-site `SiteLayout` JSON, if the mirror crawl succeeded. */
298
+ layoutPath?: string;
299
+ /** Path to the per-site mirror page directory, if emission succeeded. */
300
+ mirrorDir?: string;
183
301
  status: "ok" | "skipped" | "failed";
184
302
  reason?: string;
185
303
  }