launchframe 0.2.0 → 0.2.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.
Files changed (72) hide show
  1. package/README.md +143 -183
  2. package/bin/launchframe.mjs +234 -30
  3. package/package.json +52 -67
  4. package/template/.aider.conf.yml +3 -0
  5. package/template/.amazonq/cli-agents/clone-website.json +9 -0
  6. package/template/.amazonq/rules/project.md +156 -0
  7. package/template/.augment/commands/clone-website.md +516 -0
  8. package/template/.claude/skills/clone-website/SKILL.md +515 -0
  9. package/template/.clinerules +156 -0
  10. package/template/.codex/skills/clone-website/SKILL.md +515 -0
  11. package/template/.continue/commands/clone-website.md +517 -0
  12. package/template/.continue/rules/project.md +160 -0
  13. package/template/.cursor/commands/clone-website.md +512 -0
  14. package/template/.cursor/rules/project.mdc +7 -0
  15. package/template/.dockerignore +60 -0
  16. package/template/.gemini/commands/clone-website.toml +518 -0
  17. package/template/.gitattributes +9 -0
  18. package/template/.github/ISSUE_TEMPLATE/bug_report.yml +86 -0
  19. package/template/.github/ISSUE_TEMPLATE/config.yml +5 -0
  20. package/template/.github/ISSUE_TEMPLATE/feature_request.yml +50 -0
  21. package/template/.github/PULL_REQUEST_TEMPLATE.md +19 -0
  22. package/template/.github/copilot-instructions.md +156 -0
  23. package/template/.github/copilot-setup-steps.yml +3 -0
  24. package/template/.github/skills/clone-website/SKILL.md +515 -0
  25. package/template/.github/workflows/ci.yml +36 -0
  26. package/template/.nvmrc +1 -0
  27. package/template/.opencode/commands/clone-website.md +515 -0
  28. package/template/.windsurf/workflows/clone-website.md +512 -0
  29. package/template/.windsurfrules +2 -0
  30. package/template/AGENTS.md +74 -0
  31. package/template/CHANGELOG.md +80 -0
  32. package/template/CLAUDE.md +1 -0
  33. package/template/Dockerfile +114 -0
  34. package/template/Dockerfile.dev +15 -0
  35. package/template/GEMINI.md +1 -0
  36. package/template/README.md +129 -0
  37. package/template/components.json +25 -0
  38. package/template/docker-compose.yml +53 -0
  39. package/template/docs/design-references/.gitkeep +0 -0
  40. package/template/docs/design-references/comparison.png +0 -0
  41. package/template/docs/research/INSPECTION_GUIDE.md +80 -0
  42. package/template/eslint.config.mjs +18 -0
  43. package/template/next.config.ts +8 -0
  44. package/template/package.json +59 -0
  45. package/template/postcss.config.mjs +7 -0
  46. package/template/public/images/.gitkeep +0 -0
  47. package/template/public/seo/.gitkeep +0 -0
  48. package/template/public/videos/.gitkeep +0 -0
  49. package/template/scripts/.gitkeep +0 -0
  50. package/template/scripts/sync-agent-rules.sh +88 -0
  51. package/template/scripts/sync-skills.mjs +111 -0
  52. package/template/src/app/favicon.ico +0 -0
  53. package/template/src/app/globals.css +130 -0
  54. package/template/src/app/layout.tsx +33 -0
  55. package/template/src/app/page.tsx +9 -0
  56. package/template/src/components/ui/button.tsx +60 -0
  57. package/template/src/hooks/.gitkeep +0 -0
  58. package/template/src/lib/utils.ts +6 -0
  59. package/template/src/types/.gitkeep +0 -0
  60. package/template/tsconfig.json +34 -0
  61. package/packages/extract/automated-clone-pass.ts +0 -353
  62. package/packages/extract/browser-extract.ts +0 -237
  63. package/packages/extract/cloner-research-emit.ts +0 -270
  64. package/packages/extract/dom-crawler.ts +0 -521
  65. package/packages/extract/emit.ts +0 -553
  66. package/packages/extract/extract.ts +0 -548
  67. package/packages/extract/host-slug.ts +0 -5
  68. package/packages/extract/mirror-emit.ts +0 -620
  69. package/packages/extract/package.json +0 -13
  70. package/packages/extract/reference-dump.ts +0 -431
  71. package/packages/extract/synthesize.ts +0 -551
  72. package/packages/extract/types.ts +0 -316
@@ -1,353 +0,0 @@
1
- /**
2
- * Automated recon pass inspired by ai-website-cloner-template Phase 1–2:
3
- * multi-viewport screenshots, scroll sweep frames, header probe, interaction
4
- * hints, and optional media downloads — no manual Browser MCP required.
5
- */
6
-
7
- import { createHash } from "node:crypto";
8
- import { mkdirSync, readFileSync, existsSync, writeFileSync } from "node:fs";
9
- import { dirname, extname, join } from "node:path";
10
-
11
- import type { Page } from "playwright";
12
-
13
- import { hostSlug } from "./host-slug.js";
14
-
15
- const RESPONSIVE_PRESETS = [
16
- { name: "tablet", width: 768, height: 900 },
17
- { name: "mobile", width: 390, height: 844 },
18
- ] as const;
19
-
20
- export interface AutomatedClonePassOptions {
21
- page: Page;
22
- host: string;
23
- outDir: string;
24
- primaryViewport: { width: number; height: number };
25
- referenceDir?: string;
26
- userAgent: string;
27
- /** When false, skip downloading binaries from media.json (faster / lighter). */
28
- downloadAssets: boolean;
29
- }
30
-
31
- /**
32
- * Runs while the Playwright page is still open (after reference + layout).
33
- * Writes under docs/design-references/<hostSlug>/ and optionally downloaded_assets/.
34
- */
35
- export async function runAutomatedClonePass(opts: AutomatedClonePassOptions): Promise<string[]> {
36
- const written: string[] = [];
37
- const slug = hostSlug(opts.host);
38
- const designRoot = join(opts.outDir, "docs", "design-references", slug);
39
- mkdirSync(designRoot, { recursive: true });
40
-
41
- const primary = opts.primaryViewport;
42
-
43
- await opts.page.setViewportSize({ width: primary.width, height: primary.height });
44
- await opts.page.waitForTimeout(200);
45
-
46
- const viewportPrimary = join(designRoot, "viewport-desktop.png");
47
- await opts.page.screenshot({ path: viewportPrimary, fullPage: false, type: "png" });
48
- written.push(viewportPrimary);
49
-
50
- const fullPrimarySrc = join(opts.outDir, "screenshots", `${opts.host}.png`);
51
- const fullPrimaryDest = join(designRoot, "full-page-desktop.png");
52
- if (existsSync(fullPrimarySrc)) {
53
- writeFileSync(fullPrimaryDest, readFileSync(fullPrimarySrc));
54
- written.push(fullPrimaryDest);
55
- }
56
-
57
- for (const preset of RESPONSIVE_PRESETS) {
58
- await opts.page.setViewportSize({ width: preset.width, height: preset.height });
59
- await opts.page.waitForTimeout(350);
60
- const fp = join(designRoot, `full-page-${preset.name}.png`);
61
- await opts.page.screenshot({ path: fp, fullPage: true, type: "png" });
62
- written.push(fp);
63
- const vp = join(designRoot, `viewport-${preset.name}.png`);
64
- await opts.page.screenshot({ path: vp, fullPage: false, type: "png" });
65
- written.push(vp);
66
- }
67
-
68
- await opts.page.setViewportSize({ width: primary.width, height: primary.height });
69
- await opts.page.waitForTimeout(200);
70
-
71
- const scrollWritten = await scrollSweepScreenshots(opts.page, designRoot);
72
- written.push(...scrollWritten);
73
-
74
- await opts.page.evaluate(() => window.scrollTo(0, 0));
75
- await opts.page.waitForTimeout(150);
76
-
77
- const probePath = join(designRoot, "header-scroll-probe.json");
78
- writeFileSync(probePath, JSON.stringify(await headerScrollProbe(opts.page), null, 2) + "\n");
79
- written.push(probePath);
80
-
81
- const hintsPath = join(designRoot, "interaction-hints.json");
82
- writeFileSync(
83
- hintsPath,
84
- JSON.stringify(await captureInteractionHints(opts.page), null, 2) + "\n",
85
- );
86
- written.push(hintsPath);
87
-
88
- const readmePath = join(designRoot, "README.md");
89
- writeFileSync(readmePath, emitDesignReferencesReadme(opts.host, slug));
90
- written.push(readmePath);
91
-
92
- if (opts.downloadAssets && opts.referenceDir && existsSync(join(opts.referenceDir, "media.json"))) {
93
- const assetDir = join(opts.outDir, "downloaded_assets", slug);
94
- const manifestPath = join(assetDir, "manifest.json");
95
- const manifest = await downloadMediaFromReference({
96
- mediaJsonPath: join(opts.referenceDir, "media.json"),
97
- destDir: join(assetDir, "files"),
98
- userAgent: opts.userAgent,
99
- baseUrl: opts.page.url(),
100
- });
101
- mkdirSync(dirname(manifestPath), { recursive: true });
102
- writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\n");
103
- written.push(manifestPath);
104
- }
105
-
106
- return written;
107
- }
108
-
109
- async function scrollSweepScreenshots(page: Page, designRoot: string): Promise<string[]> {
110
- const written: string[] = [];
111
- await page.evaluate(() => window.scrollTo(0, 0));
112
- await page.waitForTimeout(150);
113
-
114
- const scrollHeight = await page.evaluate(() =>
115
- Math.max(document.documentElement.scrollHeight, document.body.scrollHeight),
116
- );
117
- const vh = await page.evaluate(() => window.innerHeight);
118
- const steps = Math.min(10, Math.max(4, Math.ceil(scrollHeight / Math.max(1, vh))));
119
- const positions: number[] = [];
120
- for (let i = 0; i < steps; i++) {
121
- const y =
122
- steps <= 1 ? 0 : Math.floor(((scrollHeight - vh) * i) / (steps - 1 || 1));
123
- positions.push(Math.max(0, y));
124
- }
125
-
126
- for (let i = 0; i < positions.length; i++) {
127
- const y = positions[i]!;
128
- await page.evaluate((yy) => window.scrollTo(0, yy), y);
129
- await page.waitForTimeout(220);
130
- const path = join(designRoot, `scroll-${String(i).padStart(2, "0")}-y${y}.png`);
131
- await page.screenshot({ path, fullPage: false, type: "png" });
132
- written.push(path);
133
- }
134
-
135
- await page.evaluate(() => window.scrollTo(0, 0));
136
- await page.waitForTimeout(120);
137
- return written;
138
- }
139
-
140
- async function headerScrollProbe(page: Page): Promise<unknown> {
141
- await page.evaluate(() => window.scrollTo(0, 0));
142
- await page.waitForTimeout(100);
143
- const top = await probePrimaryChrome(page);
144
- await page.evaluate(() => window.scrollTo(0, 420));
145
- await page.waitForTimeout(180);
146
- const scrolled = await probePrimaryChrome(page);
147
- await page.evaluate(() => window.scrollTo(0, 0));
148
- return {
149
- capturedAt: new Date().toISOString(),
150
- scrollY0: top,
151
- scrollY420: scrolled,
152
- note: "Compare boxShadow/background/position — header transitions suggest scroll-driven chrome.",
153
- };
154
- }
155
-
156
- async function probePrimaryChrome(page: Page): Promise<unknown> {
157
- return page.evaluate(() => {
158
- const pick =
159
- (document.querySelector("header") as HTMLElement | null) ??
160
- (document.querySelector('[role="banner"]') as HTMLElement | null) ??
161
- (document.querySelector("nav") as HTMLElement | null);
162
- if (!pick || !(pick instanceof HTMLElement)) return null;
163
- const cs = getComputedStyle(pick);
164
- const r = pick.getBoundingClientRect();
165
- return {
166
- tag: pick.tagName,
167
- position: cs.position,
168
- top: Math.round(r.top),
169
- height: Math.round(r.height),
170
- boxShadow: cs.boxShadow,
171
- backgroundColor: cs.backgroundColor,
172
- backdropFilter: cs.backdropFilter,
173
- borderBottomWidth: cs.borderBottomWidth,
174
- borderBottomColor: cs.borderBottomColor,
175
- };
176
- });
177
- }
178
-
179
- async function captureInteractionHints(page: Page): Promise<unknown> {
180
- return page.evaluate(() => {
181
- const html = document.documentElement;
182
- const body = document.body;
183
- const hStyle = getComputedStyle(html);
184
- const bStyle = getComputedStyle(body);
185
- const stickySelectors = ["header", "nav", '[role="banner"]'];
186
- let stickyCount = 0;
187
- for (const sel of stickySelectors) {
188
- for (const el of Array.from(document.querySelectorAll(sel))) {
189
- const p = getComputedStyle(el).position;
190
- if (p === "sticky" || p === "fixed") stickyCount++;
191
- }
192
- }
193
- return {
194
- capturedAt: new Date().toISOString(),
195
- likelyLenis:
196
- !!document.querySelector(
197
- ".lenis, [data-lenis-prevent], [class*='lenis'], .locomotive-scroll",
198
- ),
199
- htmlScrollSnapType: hStyle.scrollSnapType,
200
- bodyScrollSnapType: bStyle.scrollSnapType,
201
- videoCount: document.querySelectorAll("video").length,
202
- iframeCount: document.querySelectorAll("iframe").length,
203
- stickyHeaderOrNavCount: stickyCount,
204
- reducedMotionMedia: window.matchMedia("(prefers-reduced-motion: reduce)").matches,
205
- };
206
- });
207
- }
208
-
209
- function emitDesignReferencesReadme(host: string, slug: string): string {
210
- return `# Design references — ${host}
211
-
212
- Automated captures (Landingfram \`automated-clone-pass\`), analogous to **ai-website-cloner-template** \`docs/design-references/\`.
213
-
214
- | Artifact | Purpose |
215
- | -------- | ------- |
216
- | \`full-page-desktop.png\` | Full-page desktop capture (copy of ../screenshots/${host}.png when present). |
217
- | \`viewport-desktop.png\` | Above-the-fold desktop viewport. |
218
- | \`full-page-tablet.png\` / \`viewport-tablet.png\` | 768×900 pass. |
219
- | \`full-page-mobile.png\` / \`viewport-mobile.png\` | 390×844 pass. |
220
- | \`scroll-*.png\` | Viewport screenshots while scrolling — quick motion / scroll-snap sanity check. |
221
- | \`header-scroll-probe.json\` | Header/nav computed styles at scroll Y≈0 vs Y≈420. |
222
- | \`interaction-hints.json\` | Heuristic signals (Lenis-like classes, scroll-snap, sticky chrome counts). |
223
-
224
- Section crops for builders can be derived from these plus \`../research/${slug}/PAGE_TOPOLOGY.md\`.
225
-
226
- Compliance: use for structure learning on sites you are allowed to analyze; replace assets when shipping your product.
227
- `;
228
- }
229
-
230
- interface DownloadManifest {
231
- capturedAt: string;
232
- baseUrl: string;
233
- downloaded: Array<{ url: string; relativePath: string; bytes: number }>;
234
- failed: Array<{ url: string; reason: string }>;
235
- skippedDataUrls: number;
236
- }
237
-
238
- async function downloadMediaFromReference(opts: {
239
- mediaJsonPath: string;
240
- destDir: string;
241
- userAgent: string;
242
- baseUrl: string;
243
- }): Promise<DownloadManifest> {
244
- const manifest: DownloadManifest = {
245
- capturedAt: new Date().toISOString(),
246
- baseUrl: opts.baseUrl,
247
- downloaded: [],
248
- failed: [],
249
- skippedDataUrls: 0,
250
- };
251
-
252
- const raw = JSON.parse(readFileSync(opts.mediaJsonPath, "utf8")) as {
253
- media?: Array<
254
- | { type: string; src?: string | null; poster?: string | null }
255
- | { type: string; src: string }
256
- >;
257
- };
258
-
259
- const urls = new Set<string>();
260
- for (const m of raw.media ?? []) {
261
- if (m.type === "img" && "src" in m && m.src) urls.add(m.src);
262
- if (m.type === "video") {
263
- if ("src" in m && m.src) urls.add(m.src);
264
- if ("poster" in m && m.poster) urls.add(m.poster);
265
- }
266
- if (m.type === "source" && "src" in m && m.src) urls.add(m.src);
267
- }
268
-
269
- const MAX_FILES = 72;
270
- const MAX_BYTES = 12 * 1024 * 1024;
271
- let n = 0;
272
- const concurrency = 4;
273
- const queue = [...urls].filter((u) => {
274
- if (u.startsWith("data:")) {
275
- manifest.skippedDataUrls++;
276
- return false;
277
- }
278
- try {
279
- const parsed = new URL(u);
280
- return parsed.protocol === "https:" || parsed.protocol === "http:";
281
- } catch {
282
- return false;
283
- }
284
- });
285
-
286
- mkdirSync(opts.destDir, { recursive: true });
287
-
288
- async function worker(batch: string[]) {
289
- for (const url of batch) {
290
- if (n >= MAX_FILES) break;
291
- const ok = await downloadOne(url, opts.destDir, opts.userAgent, MAX_BYTES);
292
- if (ok.ok) {
293
- manifest.downloaded.push({ url, relativePath: ok.relativePath, bytes: ok.bytes });
294
- n++;
295
- } else {
296
- manifest.failed.push({ url, reason: ok.reason });
297
- }
298
- }
299
- }
300
-
301
- for (let i = 0; i < queue.length; i += concurrency) {
302
- await worker(queue.slice(i, i + concurrency));
303
- }
304
-
305
- return manifest;
306
- }
307
-
308
- async function downloadOne(
309
- url: string,
310
- destDir: string,
311
- userAgent: string,
312
- maxBytes: number,
313
- ): Promise<
314
- { ok: true; relativePath: string; bytes: number } | { ok: false; reason: string }
315
- > {
316
- try {
317
- const res = await fetch(url, {
318
- headers: { "User-Agent": userAgent, Accept: "*/*" },
319
- redirect: "follow",
320
- });
321
- if (!res.ok) return { ok: false, reason: `HTTP ${res.status}` };
322
- const buf = Buffer.from(await res.arrayBuffer());
323
- if (buf.length > maxBytes) return { ok: false, reason: "too large" };
324
- const hash = createHash("sha256").update(url).digest("hex").slice(0, 12);
325
- const ext = guessExt(url, res.headers.get("content-type"));
326
- const fileName = `${hash}${ext}`;
327
- const path = join(destDir, fileName);
328
- writeFileSync(path, buf);
329
- const rel = `files/${fileName}`;
330
- return { ok: true, relativePath: rel.replace(/\\/g, "/"), bytes: buf.length };
331
- } catch (e) {
332
- return { ok: false, reason: (e as Error).message };
333
- }
334
- }
335
-
336
- function guessExt(url: string, contentType: string | null): string {
337
- try {
338
- const fromPath = extname(new URL(url).pathname.split("?")[0] ?? "");
339
- if (/^\.\w{2,5}$/.test(fromPath)) return fromPath.slice(0, 10);
340
- } catch {
341
- /* ignore */
342
- }
343
-
344
- if (!contentType) return ".bin";
345
- if (contentType.includes("jpeg")) return ".jpg";
346
- if (contentType.includes("png")) return ".png";
347
- if (contentType.includes("webp")) return ".webp";
348
- if (contentType.includes("gif")) return ".gif";
349
- if (contentType.includes("svg")) return ".svg";
350
- if (contentType.includes("mp4")) return ".mp4";
351
- if (contentType.includes("webm")) return ".webm";
352
- return ".bin";
353
- }
@@ -1,237 +0,0 @@
1
- /**
2
- * Browser-side token harvester.
3
- *
4
- * The exported `harvestTokens` is serialized by Playwright and run inside
5
- * the page via `page.evaluate`. It walks the rendered DOM, collects
6
- * computed styles, and returns a JSON-serializable raw observations
7
- * record. It does NOT capture HTML, scripts, or third-party assets — it
8
- * only reports values the browser already computed.
9
- *
10
- * The function is written as a single self-contained block so Playwright
11
- * can serialize it without surprises. Helpers are inlined.
12
- */
13
-
14
- import type { Page } from "playwright";
15
-
16
- import type { RawTokens } from "./types.js";
17
-
18
- /** Public entry: invoke the harvester inside the page. */
19
- export async function harvestTokens(
20
- page: Page,
21
- url: string,
22
- viewport: { width: number; height: number },
23
- ): Promise<RawTokens> {
24
- // tsx/esbuild transpiles nested function declarations with a `__name`
25
- // helper for nicer stack traces. Playwright serializes the function to
26
- // a string and evaluates it in the browser, where `__name` is
27
- // undefined. Define it as an identity function before invoking the
28
- // harvester so the wrapped declarations resolve cleanly.
29
- await page.evaluate(() => {
30
- const g = globalThis as unknown as { __name?: (fn: unknown) => unknown };
31
- if (typeof g.__name === "undefined") g.__name = (fn: unknown) => fn;
32
- });
33
-
34
- const partial = await page.evaluate(harvestInPage);
35
- return {
36
- url,
37
- capturedAt: new Date().toISOString(),
38
- viewport,
39
- ...partial,
40
- };
41
- }
42
-
43
- /**
44
- * The harvester. Plain JS so Playwright can serialize it.
45
- * Intentionally kept dependency-free.
46
- */
47
- function harvestInPage(): Omit<RawTokens, "url" | "capturedAt" | "viewport"> {
48
- const colorObs: Array<{
49
- hex: string;
50
- role: "text" | "background" | "border" | "shadow";
51
- area: number;
52
- }> = [];
53
- const typeAgg = new Map<
54
- string,
55
- {
56
- fontFamily: string;
57
- fontSize: number;
58
- fontWeight: number;
59
- lineHeight: number;
60
- letterSpacing: number;
61
- count: number;
62
- }
63
- >();
64
- const spacingAgg = new Map<string, { axis: "padding" | "gap" | "margin"; px: number; count: number }>();
65
- const radiiAgg = new Map<number, number>();
66
- const shadowsAgg = new Map<string, number>();
67
-
68
- const all = document.querySelectorAll<HTMLElement>("body *");
69
-
70
- /* ---------------- color helpers ---------------- */
71
- const toHex = (rgb: string): string | null => {
72
- if (!rgb || rgb === "transparent") return null;
73
- const m = rgb.match(/rgba?\(([^)]+)\)/);
74
- if (!m) return null;
75
- const parts = m[1]!.split(",").map((s) => s.trim());
76
- const r = parseInt(parts[0]!, 10);
77
- const g = parseInt(parts[1]!, 10);
78
- const b = parseInt(parts[2]!, 10);
79
- const a = parts[3] !== undefined ? parseFloat(parts[3]) : 1;
80
- if (a < 0.05) return null;
81
- if ([r, g, b].some((n) => Number.isNaN(n))) return null;
82
- const h = (n: number) => n.toString(16).padStart(2, "0");
83
- return `#${h(r)}${h(g)}${h(b)}`;
84
- };
85
-
86
- const recordColor = (
87
- hex: string | null,
88
- role: "text" | "background" | "border" | "shadow",
89
- area: number,
90
- ) => {
91
- if (!hex || area <= 0) return;
92
- colorObs.push({ hex, role, area });
93
- };
94
-
95
- /* ---------------- spacing helpers ---------------- */
96
- const recordSpacing = (axis: "padding" | "gap" | "margin", px: number) => {
97
- if (!Number.isFinite(px) || px <= 0 || px > 256) return;
98
- const key = `${axis}:${Math.round(px)}`;
99
- const existing = spacingAgg.get(key);
100
- if (existing) existing.count += 1;
101
- else spacingAgg.set(key, { axis, px: Math.round(px), count: 1 });
102
- };
103
-
104
- /* ---------------- container width tracking ------- */
105
- let dominantContainerPx: number | null = null;
106
- let dominantContainerArea = 0;
107
-
108
- /* ---------------- main pass ---------------- */
109
- for (const el of Array.from(all)) {
110
- const style = getComputedStyle(el);
111
- if (style.visibility === "hidden" || style.display === "none") continue;
112
-
113
- const rect = el.getBoundingClientRect();
114
- if (rect.width <= 0 || rect.height <= 0) continue;
115
- const area = rect.width * rect.height;
116
-
117
- // Background
118
- recordColor(toHex(style.backgroundColor), "background", area);
119
-
120
- // Border (read each side; the four-side shorthand is typical)
121
- const borderColor = toHex(style.borderTopColor);
122
- const borderWidth =
123
- parseFloat(style.borderTopWidth) || parseFloat(style.borderBottomWidth) || 0;
124
- if (borderWidth > 0) recordColor(borderColor, "border", borderWidth * (rect.width + rect.height) * 2);
125
-
126
- // Box shadow color
127
- const shadow = style.boxShadow;
128
- if (shadow && shadow !== "none") {
129
- shadowsAgg.set(shadow, (shadowsAgg.get(shadow) ?? 0) + 1);
130
- const colorMatch = shadow.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/);
131
- if (colorMatch) recordColor(toHex(colorMatch[0]), "shadow", area * 0.05);
132
- }
133
-
134
- // Text content + color (only if the element has direct text)
135
- const directText = directTextLength(el);
136
- if (directText > 0) {
137
- const fontSize = parseFloat(style.fontSize) || 16;
138
- const textArea = directText * fontSize * fontSize * 0.45;
139
- recordColor(toHex(style.color), "text", textArea);
140
-
141
- const fontFamily = simplifyFontFamily(style.fontFamily);
142
- const fontWeight = normalizeWeight(style.fontWeight);
143
- const lineHeight =
144
- style.lineHeight === "normal"
145
- ? Math.round(fontSize * 1.4)
146
- : parseFloat(style.lineHeight) || Math.round(fontSize * 1.4);
147
- const letterSpacing =
148
- style.letterSpacing === "normal" ? 0 : parseFloat(style.letterSpacing) || 0;
149
-
150
- const k = `${fontFamily}|${fontSize}|${fontWeight}`;
151
- const existing = typeAgg.get(k);
152
- if (existing) existing.count += directText;
153
- else
154
- typeAgg.set(k, {
155
- fontFamily,
156
- fontSize: Math.round(fontSize),
157
- fontWeight,
158
- lineHeight: Math.round(lineHeight),
159
- letterSpacing,
160
- count: directText,
161
- });
162
- }
163
-
164
- // Spacing
165
- for (const side of ["padding-top", "padding-right", "padding-bottom", "padding-left"]) {
166
- recordSpacing("padding", parseFloat(style.getPropertyValue(side)));
167
- }
168
- if ((style as CSSStyleDeclaration).gap) recordSpacing("gap", parseFloat(style.gap));
169
- if ((style as CSSStyleDeclaration).rowGap)
170
- recordSpacing("gap", parseFloat(style.rowGap));
171
- if ((style as CSSStyleDeclaration).columnGap)
172
- recordSpacing("gap", parseFloat(style.columnGap));
173
-
174
- // Border radius
175
- const radius = parseFloat(style.borderTopLeftRadius);
176
- if (Number.isFinite(radius) && radius > 0 && radius < 64) {
177
- const r = Math.round(radius);
178
- radiiAgg.set(r, (radiiAgg.get(r) ?? 0) + 1);
179
- }
180
-
181
- // Container candidate: a wide horizontally-centered block element
182
- const tag = el.tagName;
183
- const isLayout =
184
- tag === "MAIN" || tag === "SECTION" || tag === "DIV" || tag === "ARTICLE";
185
- if (
186
- isLayout &&
187
- rect.width >= 720 &&
188
- rect.width <= 1600 &&
189
- rect.height >= 200 &&
190
- area > dominantContainerArea
191
- ) {
192
- dominantContainerArea = area;
193
- dominantContainerPx = Math.round(rect.width);
194
- }
195
- }
196
-
197
- return {
198
- colors: colorObs,
199
- typography: Array.from(typeAgg.values()),
200
- spacing: Array.from(spacingAgg.values()),
201
- radii: Array.from(radiiAgg, ([px, count]) => ({ px, count })),
202
- shadows: Array.from(shadowsAgg, ([value, count]) => ({ value, count })),
203
- dominantContainerPx,
204
- };
205
-
206
- /* ----- inlined helpers ----- */
207
-
208
- function directTextLength(el: Element): number {
209
- let total = 0;
210
- for (const child of Array.from(el.childNodes)) {
211
- if (child.nodeType === 3) {
212
- const text = (child.nodeValue ?? "").trim();
213
- if (text) total += text.length;
214
- }
215
- }
216
- return total;
217
- }
218
-
219
- function simplifyFontFamily(raw: string): string {
220
- if (!raw) return "system-ui";
221
- const first = raw.split(",")[0]!.trim().replace(/^["']|["']$/g, "");
222
- return first || "system-ui";
223
- }
224
-
225
- function normalizeWeight(raw: string): number {
226
- const named: Record<string, number> = {
227
- normal: 400,
228
- bold: 700,
229
- lighter: 300,
230
- bolder: 700,
231
- };
232
- if (named[raw] !== undefined) return named[raw]!;
233
- const n = parseInt(raw, 10);
234
- if (Number.isFinite(n)) return Math.max(100, Math.min(900, n));
235
- return 400;
236
- }
237
- }