launchframe 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/bin/launchframe.mjs +46 -0
- package/package.json +64 -0
- package/packages/extract/browser-extract.ts +237 -0
- package/packages/extract/emit.ts +466 -0
- package/packages/extract/extract.ts +372 -0
- package/packages/extract/package.json +13 -0
- package/packages/extract/synthesize.ts +551 -0
- package/packages/extract/types.ts +194 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Evan Gruhlkey and Launchframe contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# launchframe
|
|
2
|
+
|
|
3
|
+
> Point Launchframe at SaaS sites you admire. Get back a drop-in
|
|
4
|
+
> shadcn/ui design system you can build your own UI on top of —
|
|
5
|
+
> with a ready-made handoff for Cursor or Claude Code.
|
|
6
|
+
|
|
7
|
+
`launchframe` opens each URL in headless Chromium, harvests the
|
|
8
|
+
**computed appearance** of the rendered page (colors, type, spacing,
|
|
9
|
+
radii, shadows), and synthesizes an original design system as
|
|
10
|
+
`tailwind.config.ts` + `globals.css` + `tokens.json` + a Markdown
|
|
11
|
+
report and an AI-handoff file.
|
|
12
|
+
|
|
13
|
+
It is **not** a website cloning tool. It does not store HTML, JS, CSS,
|
|
14
|
+
brand assets, logos, illustrations, or copywriting. Proprietary type
|
|
15
|
+
families are substituted with open-source equivalents. See the
|
|
16
|
+
[anti-clone policy](./rules/anti-clone-policy.md).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## Quick start (any folder)
|
|
21
|
+
|
|
22
|
+
The design system is written to **`./output/<runId>/`** in whatever
|
|
23
|
+
directory you run the command from — not inside the package.
|
|
24
|
+
|
|
25
|
+
**One time per machine** (Chromium for Playwright):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
npx playwright install chromium
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
**Every time you want a new theme:**
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
cd path/to/your-app-or-empty-folder
|
|
35
|
+
npx launchframe@latest https://site-a.example https://site-b.example
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
When it finishes, open **`output/<runId>/FOR_AI.md`** — it tells you
|
|
39
|
+
exactly how to attach the folder in **Cursor** or **Claude Code** so
|
|
40
|
+
the model follows your tokens when building UI.
|
|
41
|
+
|
|
42
|
+
```txt
|
|
43
|
+
output/<runId>/
|
|
44
|
+
├── FOR_AI.md ← paste / @-attach this for your AI (handoff instructions)
|
|
45
|
+
├── tokens.json ← every value, machine-readable
|
|
46
|
+
├── tailwind.config.ts ← drop-in Tailwind theme
|
|
47
|
+
├── globals.css ← drop-in shadcn-compatible CSS variables
|
|
48
|
+
├── theme-preview.tsx ← render this to eyeball the system
|
|
49
|
+
├── REPORT.md ← what was extracted, from where, why
|
|
50
|
+
├── run.json ← full run metadata (sources, timing, status)
|
|
51
|
+
├── screenshots/ ← captured PNGs
|
|
52
|
+
└── raw/ ← per-site raw token observations
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
---
|
|
56
|
+
|
|
57
|
+
## Hand the output to your AI
|
|
58
|
+
|
|
59
|
+
1. Run the command above so `output/<runId>/` exists.
|
|
60
|
+
2. Either:
|
|
61
|
+
- **Cursor:** `@`-attach the folder (or `FOR_AI.md` + `REPORT.md` + `tokens.json`) and paste the instruction block from `FOR_AI.md` into Composer, or
|
|
62
|
+
- **Claude Code:** copy the `output/<runId>/` folder into your project and attach it.
|
|
63
|
+
3. The AI's authority order is **REPORT.md → tokens.json → merge tailwind.config.ts and globals.css into the app**. It must use semantic tokens (`bg-background`, `text-muted-foreground`, `bg-primary`, …) and write **original copy only**.
|
|
64
|
+
|
|
65
|
+
---
|
|
66
|
+
|
|
67
|
+
## CLI reference
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npx launchframe <url> [<url> ...] [options]
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
| Flag | Default | Notes |
|
|
74
|
+
| -------------- | -------------------- | -------------------------------------------------------- |
|
|
75
|
+
| `--out <dir>` | `./output/<runId>` (under **current working directory**) | Absolute or relative path for the run folder |
|
|
76
|
+
| `--name <slug>`| _(unset)_ | Append a slug to the runId for findability |
|
|
77
|
+
| `--no-robots` | _(off)_ | Skip robots.txt check (not recommended) |
|
|
78
|
+
| `--rate <n>` | `15` | Per-domain requests per minute |
|
|
79
|
+
| `--width <px>` | `1440` | Viewport width |
|
|
80
|
+
| `--height <px>`| `900` | Viewport height |
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
npx launchframe https://example.com --name my-brand
|
|
84
|
+
npx launchframe https://a.example https://b.example https://c.example --width 1280
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
---
|
|
88
|
+
|
|
89
|
+
## What the extractor actually does
|
|
90
|
+
|
|
91
|
+
For each URL:
|
|
92
|
+
|
|
93
|
+
1. Open the page in headless Chromium at a 1440 × 900 desktop viewport.
|
|
94
|
+
2. Take a full-page screenshot.
|
|
95
|
+
3. Walk the rendered DOM and harvest **computed styles** for every
|
|
96
|
+
visible element:
|
|
97
|
+
- Text and background colors, weighted by area
|
|
98
|
+
- Font families, sizes, weights, line-heights, letter-spacing
|
|
99
|
+
- Padding, gap, and margin values (snapped to a 4 px grid)
|
|
100
|
+
- Border radii (mode-picked across the page)
|
|
101
|
+
- Box-shadow stacks
|
|
102
|
+
- Dominant container width (the layout signal)
|
|
103
|
+
4. Save the raw observations as JSON.
|
|
104
|
+
|
|
105
|
+
After every site is captured, the synthesizer:
|
|
106
|
+
|
|
107
|
+
1. Clusters all colors into a representative palette and derives a full
|
|
108
|
+
shadcn-compatible ramp (`--background`, `--foreground`, `--primary`,
|
|
109
|
+
…) for both light and dark themes.
|
|
110
|
+
2. Picks a body base size from the count-weighted mode of body-range
|
|
111
|
+
font sizes, then fits a single scale ratio that lands the largest
|
|
112
|
+
observed heading at the `6xl` step. Substitutes proprietary type
|
|
113
|
+
families (e.g. SF Pro, Söhne, Circular, Graphik) with open-source
|
|
114
|
+
equivalents.
|
|
115
|
+
3. Snaps spacing values to a 4 px scale, takes the most-used buckets,
|
|
116
|
+
and computes a recommended container width from the median dominant
|
|
117
|
+
block width across the corpus.
|
|
118
|
+
4. Picks a representative radius and emits a tasteful three-stop shadow
|
|
119
|
+
scale.
|
|
120
|
+
5. Writes drop-in files plus a Markdown report attributing every source.
|
|
121
|
+
|
|
122
|
+
```
|
|
123
|
+
URLs ──▶ Playwright ──▶ raw tokens.json ──▶ synthesize ──▶ DesignSystem ──▶ emit
|
|
124
|
+
(per site) (one corpus)
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
---
|
|
128
|
+
|
|
129
|
+
## Run inside this repo (contributors)
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
git clone https://github.com/evangruhlkey/launchframe
|
|
133
|
+
cd launchframe
|
|
134
|
+
npm install
|
|
135
|
+
npx playwright install chromium
|
|
136
|
+
npm run extract -- https://site-a.example https://site-b.example
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The repo is a monorepo that also contains a research framework for
|
|
140
|
+
classifying SaaS UI patterns and generating original shadcn blocks:
|
|
141
|
+
|
|
142
|
+
```txt
|
|
143
|
+
launchframe/
|
|
144
|
+
├── apps/
|
|
145
|
+
│ └── studio/ # Next.js dashboard for browsing patterns/blocks
|
|
146
|
+
├── packages/
|
|
147
|
+
│ ├── extract/ # ← the published CLI
|
|
148
|
+
│ ├── capture/ # Playwright screenshot capture (lower level)
|
|
149
|
+
│ ├── analysis/ # Layout-tree extraction & section classifier
|
|
150
|
+
│ ├── patterns/ # Typed pattern schemas + atlas registry loader
|
|
151
|
+
│ ├── blocks/ # Original shadcn/ui blocks across families
|
|
152
|
+
│ └── evaluation/ # Coherence / clone-risk / a11y evaluator
|
|
153
|
+
├── pattern-atlas/ # Formalized pattern catalog per category
|
|
154
|
+
├── prompts/ # Markdown prompts for AI agents
|
|
155
|
+
├── rules/ # Design / copy / anti-clone / a11y policy
|
|
156
|
+
├── registry/ # shadcn-compatible custom registry manifest
|
|
157
|
+
└── output/ # ← every `extract` run lands here
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
Other commands (repo-only):
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
npm run studio # Next.js dashboard at localhost:3000
|
|
164
|
+
npm run capture # Lower-level Playwright capture pipeline
|
|
165
|
+
npm run analyze # Run section classifier on captured screenshots
|
|
166
|
+
npm run formalize # Validate the pattern-atlas/*.json files
|
|
167
|
+
npm run evaluate # Grade a generated page (coherence/clone/a11y)
|
|
168
|
+
npm run typecheck # Project-wide TypeScript check
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
173
|
+
## What this is not
|
|
174
|
+
|
|
175
|
+
- **Not a scraper.** It captures only what is publicly rendered, stores
|
|
176
|
+
no HTML, and never republishes site content.
|
|
177
|
+
- **Not a clone tool.** Anti-clone policy is enforced by capture-side
|
|
178
|
+
policy and synthesis-side normalization.
|
|
179
|
+
- **Not a component library replacement.** It sits *on top* of
|
|
180
|
+
shadcn/ui and produces theme files for it.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
## Anti-clone policy in one paragraph
|
|
185
|
+
|
|
186
|
+
Launchframe captures publicly rendered pages, reads the **computed
|
|
187
|
+
appearance** of those pages, and synthesizes an original design system
|
|
188
|
+
from aggregate signals. It never stores HTML, JS, CSS, brand assets,
|
|
189
|
+
illustrations, logos, or copy. Proprietary type families are
|
|
190
|
+
substituted with open-source equivalents. Generated pages and design
|
|
191
|
+
systems are checked against captured corpora for structural and
|
|
192
|
+
token-level overlap; anything above the configured threshold fails the
|
|
193
|
+
build. Full policy:
|
|
194
|
+
[`rules/anti-clone-policy.md`](./rules/anti-clone-policy.md).
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## License
|
|
199
|
+
|
|
200
|
+
MIT. See [`LICENSE`](./LICENSE).
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI entry for `npx launchframe` / `npm exec launchframe`.
|
|
4
|
+
* Spawns the TypeScript extract pipeline with the same Node that installed
|
|
5
|
+
* this package. Output defaults to `./output/<runId>/` in the *current*
|
|
6
|
+
* working directory (where the user ran the command), not inside the
|
|
7
|
+
* package install path.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { spawnSync } from "node:child_process";
|
|
11
|
+
import { createRequire } from "node:module";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
|
|
15
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
16
|
+
const __dirname = dirname(__filename);
|
|
17
|
+
const pkgRoot = join(__dirname, "..");
|
|
18
|
+
const pkgJsonPath = join(pkgRoot, "package.json");
|
|
19
|
+
|
|
20
|
+
const require = createRequire(pkgJsonPath);
|
|
21
|
+
|
|
22
|
+
let tsxCli;
|
|
23
|
+
try {
|
|
24
|
+
const tsxPkg = require.resolve("tsx/package.json", { paths: [pkgRoot] });
|
|
25
|
+
tsxCli = join(dirname(tsxPkg), "dist", "cli.mjs");
|
|
26
|
+
} catch {
|
|
27
|
+
console.error(
|
|
28
|
+
"launchframe: could not resolve the `tsx` runtime. Re-install: npm install -g launchframe",
|
|
29
|
+
);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const extractScript = join(pkgRoot, "packages", "extract", "extract.ts");
|
|
34
|
+
|
|
35
|
+
const result = spawnSync(
|
|
36
|
+
process.execPath,
|
|
37
|
+
[tsxCli, extractScript, ...process.argv.slice(2)],
|
|
38
|
+
{
|
|
39
|
+
cwd: process.cwd(),
|
|
40
|
+
stdio: "inherit",
|
|
41
|
+
env: process.env,
|
|
42
|
+
shell: false,
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
process.exit(result.status === null ? 1 : result.status);
|
package/package.json
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "launchframe",
|
|
3
|
+
"version": "0.1.0",
|
|
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
|
+
"license": "MIT",
|
|
6
|
+
"author": "Evan Gruhlkey",
|
|
7
|
+
"homepage": "https://github.com/evangruhlkey/launchframe#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/evangruhlkey/launchframe.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/evangruhlkey/launchframe/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"launchframe",
|
|
17
|
+
"shadcn",
|
|
18
|
+
"shadcn-ui",
|
|
19
|
+
"tailwind",
|
|
20
|
+
"design-system",
|
|
21
|
+
"design-tokens",
|
|
22
|
+
"playwright",
|
|
23
|
+
"cursor",
|
|
24
|
+
"claude-code",
|
|
25
|
+
"ai",
|
|
26
|
+
"cli"
|
|
27
|
+
],
|
|
28
|
+
"bin": {
|
|
29
|
+
"launchframe": "bin/launchframe.mjs"
|
|
30
|
+
},
|
|
31
|
+
"files": [
|
|
32
|
+
"bin",
|
|
33
|
+
"packages/extract",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"workspaces": [
|
|
38
|
+
"apps/*",
|
|
39
|
+
"packages/*"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"extract": "tsx packages/extract/extract.ts",
|
|
43
|
+
"studio": "npm run dev --workspace apps/studio",
|
|
44
|
+
"studio:build": "npm run build --workspace apps/studio",
|
|
45
|
+
"capture": "tsx packages/capture/screenshot-site.ts",
|
|
46
|
+
"analyze": "tsx packages/analysis/analyze-screenshot.ts",
|
|
47
|
+
"formalize": "tsx packages/patterns/pattern-registry.ts",
|
|
48
|
+
"evaluate": "tsx packages/evaluation/evaluate-page.ts",
|
|
49
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
50
|
+
"format:check": "prettier --check ."
|
|
51
|
+
},
|
|
52
|
+
"dependencies": {
|
|
53
|
+
"playwright": "^1.48.0",
|
|
54
|
+
"tsx": "^4.19.0"
|
|
55
|
+
},
|
|
56
|
+
"devDependencies": {
|
|
57
|
+
"@types/node": "^22.7.0",
|
|
58
|
+
"prettier": "^3.3.3",
|
|
59
|
+
"typescript": "^5.6.0"
|
|
60
|
+
},
|
|
61
|
+
"engines": {
|
|
62
|
+
"node": ">=20.0.0"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
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
|
+
}
|