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 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
+ }