designlang 12.11.0 → 12.14.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/CHANGELOG.md +130 -0
- package/README.md +2 -2
- package/bin/design-extract.js +57 -1
- package/package.json +7 -1
- package/src/api.js +171 -0
- package/src/formatters/agent-prompt.js +214 -0
- package/src/formatters/css-reset.js +125 -0
- package/src/formatters/gradients.js +88 -0
- package/src/formatters/tailwind-v4.js +104 -0
- package/src/formatters/ts-defs.js +125 -0
- package/src/utils/palette-compress.js +152 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,135 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [12.14.0] — 2026-05-17
|
|
4
|
+
|
|
5
|
+
**Real downloadable PDFs everywhere + a one-shot agent prompt every AI can paste.**
|
|
6
|
+
|
|
7
|
+
Two big shipping themes: every extraction now produces a real
|
|
8
|
+
print-ready PDF on demand (not just an HTML brand book), and every
|
|
9
|
+
extraction writes a self-contained agent prompt that drops into any
|
|
10
|
+
LLM — Claude, GPT, Gemini, Cursor, Windsurf, v0.
|
|
11
|
+
|
|
12
|
+
- **`--pdf` flag in the main `extract` command** — renders the
|
|
13
|
+
brand-book HTML to a 13-chapter PDF (chapter bookmarks, running
|
|
14
|
+
footer, embedded fonts, selectable text). Verified ~440KB per brand.
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
npx designlang stripe.com --pdf --paper letter --landscape
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
- **One-shot agent prompt (`<host>-AGENT.md`)** — a single file you
|
|
21
|
+
paste into any AI agent. Includes colour roles, type, spacing, radii,
|
|
22
|
+
motion, voice (tone / pronoun / CTA verbs / real headlines),
|
|
23
|
+
component anatomy, WCAG score, 7 build rules ("never invent a hex",
|
|
24
|
+
"snap spacing to scale", etc.), and a manifest of every other
|
|
25
|
+
artefact designlang produced for context.
|
|
26
|
+
|
|
27
|
+
- **Server-side PDF endpoint** — `GET /api/pdf/<hash>` renders the
|
|
28
|
+
cached extraction's brand book to PDF via the same Browserless /
|
|
29
|
+
Chromium path the `/api/extract` route uses. Cached for an hour on
|
|
30
|
+
CDN edge with 24h stale-while-revalidate.
|
|
31
|
+
|
|
32
|
+
- **Website UI** — `/gallery/[slug]` hero CTA is now
|
|
33
|
+
`Download brand book PDF`; new full-width "Agent prompt" section
|
|
34
|
+
with a `Copy NN.NKB prompt` button. Post-extraction share row gains
|
|
35
|
+
`Download brand PDF` and `Copy agent prompt`.
|
|
36
|
+
|
|
37
|
+
- **Pre-rendered static assets** — all 8 featured brand books in
|
|
38
|
+
`website/public/gallery/<slug>/` now include the new `.brand.pdf`,
|
|
39
|
+
`-AGENT.md`, `-reset.css`, `-gradients.css/.json` files so the
|
|
40
|
+
static gallery has working downloads from minute one.
|
|
41
|
+
|
|
42
|
+
## [12.13.0] — 2026-05-16
|
|
43
|
+
|
|
44
|
+
**Six new emitters + a public programmatic API.**
|
|
45
|
+
|
|
46
|
+
Every extraction now writes five additional first-class files, and downstream
|
|
47
|
+
tools can wire into the engine through a stable surface.
|
|
48
|
+
|
|
49
|
+
- **Tailwind v4 (`<host>-tailwind-v4.css`)** — CSS-first `@theme {}` block
|
|
50
|
+
with the full HSL colour scales, neutrals, type, spacing, radii, shadows
|
|
51
|
+
and motion. Parallel to the v3 `tailwind.config.js`, so v4 users get a
|
|
52
|
+
drop-in file.
|
|
53
|
+
- **TypeScript token types (`<host>-tokens.d.ts`)** — strict literal
|
|
54
|
+
unions for `ColorRole`, `ColorHex`, `FontFamilyToken`, `FontWeightToken`,
|
|
55
|
+
`SpacingToken`, `RadiusToken`, `DurationToken`, `EasingToken` plus a
|
|
56
|
+
`DesignTokens` roll-up interface. Component props typed against the real
|
|
57
|
+
extracted brand, not generic `string`.
|
|
58
|
+
- **Brand-aware CSS reset (`<host>-reset.css`)** — modern reset wired to
|
|
59
|
+
the extracted background / foreground / links / accent / selection.
|
|
60
|
+
Honours `prefers-reduced-motion`.
|
|
61
|
+
- **Gradients (`<host>-gradients.css` + `.json`)** — surfaces every
|
|
62
|
+
extracted gradient as `:root --grad-N` vars plus `.grad-N` background
|
|
63
|
+
and `.grad-text-N` background-clip utility classes.
|
|
64
|
+
- **`--palette <n>` flag** — compresses noisy 60–200-colour extractions
|
|
65
|
+
to N perceptually-distinct tokens via weighted LAB-space k-means.
|
|
66
|
+
Returns cluster medoids — never invents a hex that wasn't on the page.
|
|
67
|
+
Verified: stripe.com 29 → 8.
|
|
68
|
+
|
|
69
|
+
**Public programmatic API at `designlang/api`.**
|
|
70
|
+
|
|
71
|
+
A frozen surface other packages, scripts and AI agents can import without
|
|
72
|
+
reaching into internal modules.
|
|
73
|
+
|
|
74
|
+
```js
|
|
75
|
+
import { extract, render, renderAll, RENDERERS } from 'designlang/api';
|
|
76
|
+
|
|
77
|
+
const design = await extract('https://stripe.com', { palette: 8 });
|
|
78
|
+
const tailwind = render('tailwind-v4', design);
|
|
79
|
+
const files = renderAll(design); // { 'stripe-com-tokens.d.ts': '...', ... }
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`package.json` now exposes:
|
|
83
|
+
- `designlang` — main entry
|
|
84
|
+
- `designlang/api` — the new public API (19 renderer ids)
|
|
85
|
+
- `designlang/mcp` — the existing MCP server entry
|
|
86
|
+
|
|
87
|
+
## [12.12.0] — 2026-05-15
|
|
88
|
+
|
|
89
|
+
**Website launch — designlang.app, fully rebuilt.**
|
|
90
|
+
|
|
91
|
+
The marketing/demo site at [designlang.app](https://designlang.app) has been
|
|
92
|
+
rewritten from the ground up. Highlights:
|
|
93
|
+
|
|
94
|
+
- **Hero with WebGL Grainient background** (red/black, ogl-powered, GPU-paused
|
|
95
|
+
when offscreen) and a terminal output card showing real CLI execution.
|
|
96
|
+
- **Live demo showcase** (`See it work.`) — paste any URL into a glass
|
|
97
|
+
stage, suggestion chips for stripe.com / linear.app / vercel.com /
|
|
98
|
+
notion.so / figma.com, click-to-copy `npx designlang stripe.com` in the
|
|
99
|
+
hero.
|
|
100
|
+
- **Real gallery** — eight pre-rendered brand books for stripe.com,
|
|
101
|
+
linear.app, vercel.com, notion.so, figma.com, apple.com, arc.net and
|
|
102
|
+
spotify.com. Each card is the actual extraction the CLI produced; the
|
|
103
|
+
cover gradient uses the extracted primary + accent tokens.
|
|
104
|
+
- **Floating glass nav** with a custom logo, segmented pill links, real
|
|
105
|
+
GitHub star count fetched from the API (server-rendered, 30 min revalidate),
|
|
106
|
+
and an `npm i designlang` CTA matching the hero gradient.
|
|
107
|
+
- **Auto-scrolling reddit testimonial marquee** (3 cols, motion/react)
|
|
108
|
+
with the real comments from the r/ClaudeAI launch thread.
|
|
109
|
+
- **Polished footer** with brand block, 4 link columns, decorative giant
|
|
110
|
+
outlined wordmark, version + author + license strip.
|
|
111
|
+
- **Aggressive SEO** — keyword-dense title and description, ~75-keyword
|
|
112
|
+
list; JSON-LD graph with `SoftwareApplication`, `Organization`,
|
|
113
|
+
`WebSite + SearchAction`, `BreadcrumbList`, `FAQPage`, `HowTo`;
|
|
114
|
+
`aggregateRating` from real GitHub stars; visible FAQ section + ~250-word
|
|
115
|
+
about block; sitemap includes the 8 gallery brand books; rewritten
|
|
116
|
+
`llms.txt` for AI-search citability (allows GPTBot, ClaudeBot,
|
|
117
|
+
PerplexityBot, Google-Extended, Applebot-Extended, etc.).
|
|
118
|
+
|
|
119
|
+
**Web extractor parity with the CLI.**
|
|
120
|
+
|
|
121
|
+
- `/api/extract`'s file output now includes the brand-book HTML
|
|
122
|
+
(`<host>.brand.html`) for every run.
|
|
123
|
+
- `formatBrandBook` is shared between the CLI and the website's
|
|
124
|
+
`lib/build-files.js`.
|
|
125
|
+
|
|
126
|
+
**Other**
|
|
127
|
+
|
|
128
|
+
- Fixed the README so the logo and "designlang in action" image render
|
|
129
|
+
on the npm package page (relative paths swapped for raw GitHub URLs).
|
|
130
|
+
- Removed all decorative status dots from the website per design feedback;
|
|
131
|
+
status indicators are now typed text chips.
|
|
132
|
+
|
|
3
133
|
## [12.11.0] — 2026-05-15
|
|
4
134
|
|
|
5
135
|
**`brand --pdf` ships native PDF brand guides.**
|
package/README.md
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src="website/public/logo-specimen.svg" alt="designlang — reads a website the way a developer reads a stylesheet" width="900">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/Manavarya09/design-extract/main/website/public/logo-specimen.svg" alt="designlang — reads a website the way a developer reads a stylesheet" width="900">
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
---
|
|
14
14
|
|
|
15
15
|
<p align="center">
|
|
16
|
-
<img src="designlang.png" alt="designlang in action" width="100%">
|
|
16
|
+
<img src="https://raw.githubusercontent.com/Manavarya09/design-extract/main/designlang.png" alt="designlang in action — extracts DTCG tokens, Tailwind config, Figma variables, brand book PDF" width="100%">
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
19
|
[](https://www.npmjs.com/package/designlang)
|
package/bin/design-extract.js
CHANGED
|
@@ -22,6 +22,11 @@ import { formatMarkdown } from '../src/formatters/markdown.js';
|
|
|
22
22
|
import { formatTokens } from '../src/formatters/tokens.js';
|
|
23
23
|
import { formatDtcgTokens } from '../src/formatters/dtcg-tokens.js';
|
|
24
24
|
import { formatTailwind } from '../src/formatters/tailwind.js';
|
|
25
|
+
import { formatTailwindV4 } from '../src/formatters/tailwind-v4.js';
|
|
26
|
+
import { formatTsDefs } from '../src/formatters/ts-defs.js';
|
|
27
|
+
import { formatCssReset } from '../src/formatters/css-reset.js';
|
|
28
|
+
import { formatGradientsCss, formatGradientsJson } from '../src/formatters/gradients.js';
|
|
29
|
+
import { formatAgentPrompt } from '../src/formatters/agent-prompt.js';
|
|
25
30
|
import { formatCssVars } from '../src/formatters/css-vars.js';
|
|
26
31
|
import { formatPreview } from '../src/formatters/preview.js';
|
|
27
32
|
import { formatFigma } from '../src/formatters/figma.js';
|
|
@@ -108,6 +113,10 @@ program
|
|
|
108
113
|
.option('--no-design-md', 'skip writing the agent-native DESIGN.md (single-file, 8-section, YAML front matter)')
|
|
109
114
|
.option('--responsive-shots', 'capture full-page PNGs at 4 breakpoints × (light,dark)')
|
|
110
115
|
.option('--perf', 'measure Core Web Vitals + bundle profile (LCP/CLS/INP, JS/CSS/font/img bytes, third-party count)')
|
|
116
|
+
.option('--palette <n>', 'compress the extracted palette to N perceptually distinct colours via LAB-space k-means (default: emit every unique colour)', parseInt)
|
|
117
|
+
.option('--pdf', 'also render a print-ready brand-book PDF (chapter bookmarks, running footer, embedded tokens)')
|
|
118
|
+
.option('--paper <size>', 'PDF paper size when --pdf is set: a4 | letter | tabloid', 'a4')
|
|
119
|
+
.option('--landscape', 'PDF landscape orientation when --pdf is set')
|
|
111
120
|
.option('--json', 'output raw JSON to stdout (for CI/CD)')
|
|
112
121
|
.option('--json-pretty', 'output formatted JSON to stdout')
|
|
113
122
|
.option('--no-history', 'skip saving to history')
|
|
@@ -307,6 +316,21 @@ program
|
|
|
307
316
|
// Drop the internal raw stash before JSON/output serialization.
|
|
308
317
|
delete design._raw;
|
|
309
318
|
|
|
319
|
+
// Optional palette compression — perceptual LAB k-means down to N colours.
|
|
320
|
+
const paletteTarget = parseInt(opts.palette, 10);
|
|
321
|
+
if (paletteTarget > 0 && Array.isArray(design.colors?.all)) {
|
|
322
|
+
try {
|
|
323
|
+
const { compressPalette } = await import('../src/utils/palette-compress.js');
|
|
324
|
+
const before = design.colors.all.length;
|
|
325
|
+
const compressed = compressPalette(design.colors.all, paletteTarget);
|
|
326
|
+
design.colors.all = compressed;
|
|
327
|
+
design.colors._compressed = { from: before, to: compressed.length, target: paletteTarget };
|
|
328
|
+
spinner.text = `Compressed palette: ${before} → ${compressed.length} colours`;
|
|
329
|
+
} catch (e) {
|
|
330
|
+
console.warn(`(palette compress skipped: ${e.message})`);
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
310
334
|
// JSON mode: output and exit
|
|
311
335
|
if (jsonMode) {
|
|
312
336
|
const output = opts.jsonPretty ? JSON.stringify(design, null, 2) : JSON.stringify(design);
|
|
@@ -320,7 +344,13 @@ program
|
|
|
320
344
|
const files = [
|
|
321
345
|
{ name: `${prefix}-design-language.md`, content: formatMarkdown(design), label: 'Markdown (AI-optimized)' },
|
|
322
346
|
{ name: `${prefix}-design-tokens.json`, content: merged.tokensLegacy ? formatTokens(design) : JSON.stringify(formatDtcgTokens(design), null, 2), label: merged.tokensLegacy ? 'Design Tokens (legacy)' : 'Design Tokens (DTCG v1)' },
|
|
323
|
-
{ name: `${prefix}-tailwind.config.js`, content: formatTailwind(design), label: 'Tailwind Config' },
|
|
347
|
+
{ name: `${prefix}-tailwind.config.js`, content: formatTailwind(design), label: 'Tailwind Config (v3)' },
|
|
348
|
+
{ name: `${prefix}-tailwind-v4.css`, content: formatTailwindV4(design), label: 'Tailwind v4 @theme (CSS-first)' },
|
|
349
|
+
{ name: `${prefix}-tokens.d.ts`, content: formatTsDefs(design), label: 'TypeScript token types' },
|
|
350
|
+
{ name: `${prefix}-reset.css`, content: formatCssReset(design), label: 'Brand-aware CSS reset' },
|
|
351
|
+
{ name: `${prefix}-gradients.css`, content: formatGradientsCss(design), label: 'Extracted gradients (utility classes)' },
|
|
352
|
+
{ name: `${prefix}-gradients.json`, content: formatGradientsJson(design), label: 'Extracted gradients (structured)' },
|
|
353
|
+
{ name: `${prefix}-AGENT.md`, content: formatAgentPrompt(design), label: 'Agent system prompt (paste into Claude/GPT/Cursor)' },
|
|
324
354
|
{ name: `${prefix}-variables.css`, content: formatCssVars(design), label: 'CSS Variables' },
|
|
325
355
|
{ name: `${prefix}-preview.html`, content: formatPreview(design), label: 'Visual Preview' },
|
|
326
356
|
{ name: `${prefix}-figma-variables.json`, content: formatFigma(design), label: 'Figma Variables' },
|
|
@@ -423,6 +453,32 @@ program
|
|
|
423
453
|
writeFileSync(join(outDir, file.name), file.content, 'utf-8');
|
|
424
454
|
}
|
|
425
455
|
|
|
456
|
+
// Brand book — always emit the HTML (cheap, ~100KB). Optionally
|
|
457
|
+
// render it to PDF behind --pdf (needs Playwright, ~3–5s).
|
|
458
|
+
try {
|
|
459
|
+
const brandHtml = formatBrandBook(design, { version: PKG_VERSION });
|
|
460
|
+
const brandHtmlPath = join(outDir, `${prefix}.brand.html`);
|
|
461
|
+
writeFileSync(brandHtmlPath, brandHtml, 'utf-8');
|
|
462
|
+
files.push({ name: `${prefix}.brand.html`, label: 'Brand book (HTML)' });
|
|
463
|
+
|
|
464
|
+
if (opts.pdf) {
|
|
465
|
+
spinner.text = 'Rendering brand book PDF...';
|
|
466
|
+
const pdfPath = join(outDir, `${prefix}.brand.pdf`);
|
|
467
|
+
await htmlToPdf(brandHtml, {
|
|
468
|
+
paper: opts.paper || 'a4',
|
|
469
|
+
landscape: !!opts.landscape,
|
|
470
|
+
outPath: pdfPath,
|
|
471
|
+
metadata: {
|
|
472
|
+
title: `${new URL(design?.meta?.url || `https://${prefix}`).hostname} brand guidelines`,
|
|
473
|
+
subject: `${prefix} brand guidelines`,
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
files.push({ name: `${prefix}.brand.pdf`, label: 'Brand book (PDF · print-ready)' });
|
|
477
|
+
}
|
|
478
|
+
} catch (e) {
|
|
479
|
+
if (!merged.quiet) console.warn(`(brand book skipped: ${e.message})`);
|
|
480
|
+
}
|
|
481
|
+
|
|
426
482
|
// Multi-platform emission (v7.0). web is already emitted above.
|
|
427
483
|
const platforms = merged.platforms || ['web'];
|
|
428
484
|
const dtcgTokens = formatDtcgTokens(design);
|
package/package.json
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.14.0",
|
|
4
4
|
"description": "Extract the complete design language from any website and ship it — clone to a working Next.js starter, guard tokens with a CI drift bot, or browse everything in a local studio. Outputs W3C DTCG tokens, motion tokens, typed anatomy stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"designlang": "./bin/design-extract.js"
|
|
8
8
|
},
|
|
9
9
|
"main": "src/index.js",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.js",
|
|
12
|
+
"./api": "./src/api.js",
|
|
13
|
+
"./mcp": "./src/mcp/index.js",
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
10
16
|
"scripts": {
|
|
11
17
|
"postinstall": "npx playwright install chromium --with-deps 2>/dev/null || npx playwright install chromium",
|
|
12
18
|
"start": "node bin/design-extract.js",
|
package/src/api.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Public programmatic API for designlang.
|
|
2
|
+
//
|
|
3
|
+
// Stable surface other packages / scripts / agents can import. Anything
|
|
4
|
+
// not re-exported here is internal and may change without notice.
|
|
5
|
+
//
|
|
6
|
+
// Example:
|
|
7
|
+
// import { extract, render, RENDERERS } from 'designlang/api';
|
|
8
|
+
// const design = await extract('https://stripe.com');
|
|
9
|
+
// const tailwind = render('tailwind', design);
|
|
10
|
+
// const dts = render('ts-defs', design);
|
|
11
|
+
|
|
12
|
+
import { extractDesignLanguage } from './index.js';
|
|
13
|
+
|
|
14
|
+
// Formatters
|
|
15
|
+
import { formatMarkdown } from './formatters/markdown.js';
|
|
16
|
+
import { formatDesignMd } from './formatters/design-md.js';
|
|
17
|
+
import { formatTokens } from './formatters/tokens.js';
|
|
18
|
+
import { formatDtcgTokens } from './formatters/dtcg-tokens.js';
|
|
19
|
+
import { formatTailwind } from './formatters/tailwind.js';
|
|
20
|
+
import { formatTailwindV4 } from './formatters/tailwind-v4.js';
|
|
21
|
+
import { formatTsDefs } from './formatters/ts-defs.js';
|
|
22
|
+
import { formatCssVars } from './formatters/css-vars.js';
|
|
23
|
+
import { formatCssReset } from './formatters/css-reset.js';
|
|
24
|
+
import { formatPreview } from './formatters/preview.js';
|
|
25
|
+
import { formatFigma } from './formatters/figma.js';
|
|
26
|
+
import { formatReactTheme,
|
|
27
|
+
formatShadcnTheme } from './formatters/theme.js';
|
|
28
|
+
import { formatVueTheme } from './formatters/vue-theme.js';
|
|
29
|
+
import { formatSvelteTheme } from './formatters/svelte-theme.js';
|
|
30
|
+
import { formatIosSwiftUI } from './formatters/ios-swiftui.js';
|
|
31
|
+
import { formatAndroidCompose } from './formatters/android-compose.js';
|
|
32
|
+
import { formatFlutterDart } from './formatters/flutter-dart.js';
|
|
33
|
+
import { formatWordPress,
|
|
34
|
+
formatWordPressTheme } from './formatters/wordpress.js';
|
|
35
|
+
import { formatBrandBook } from './formatters/brand-book.js';
|
|
36
|
+
import { formatGradientsCss,
|
|
37
|
+
formatGradientsJson } from './formatters/gradients.js';
|
|
38
|
+
import { formatAgentRules } from './formatters/agent-rules.js';
|
|
39
|
+
|
|
40
|
+
// Utils
|
|
41
|
+
import { compressPalette } from './utils/palette-compress.js';
|
|
42
|
+
import { diffDesigns,
|
|
43
|
+
formatDiffMarkdown } from './diff.js';
|
|
44
|
+
|
|
45
|
+
// ─── Stable surface ───────────────────────────────────────────────
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Run the full extraction pipeline against a URL. Returns the live
|
|
49
|
+
* `design` object — every downstream renderer takes this shape.
|
|
50
|
+
*
|
|
51
|
+
* @param {string} url
|
|
52
|
+
* @param {object} [opts]
|
|
53
|
+
* @returns {Promise<object>}
|
|
54
|
+
*/
|
|
55
|
+
export async function extract(url, opts = {}) {
|
|
56
|
+
const design = await extractDesignLanguage(url, opts);
|
|
57
|
+
delete design._raw; // never leak internals over the public API
|
|
58
|
+
if (opts.palette && opts.palette > 0 && Array.isArray(design?.colors?.all)) {
|
|
59
|
+
design.colors.all = compressPalette(design.colors.all, opts.palette);
|
|
60
|
+
}
|
|
61
|
+
return design;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Map of every available renderer id → renderer function. Each function
|
|
66
|
+
* takes a `design` object and returns a string (for text formats) or
|
|
67
|
+
* an object (for multi-file payloads).
|
|
68
|
+
*/
|
|
69
|
+
export const RENDERERS = Object.freeze({
|
|
70
|
+
// markdown
|
|
71
|
+
'design-md': (d) => formatDesignMd(d),
|
|
72
|
+
'design-language-md': (d) => formatMarkdown(d),
|
|
73
|
+
|
|
74
|
+
// tokens
|
|
75
|
+
'tokens': (d) => formatTokens(d),
|
|
76
|
+
'dtcg': (d) => JSON.stringify(formatDtcgTokens(d), null, 2),
|
|
77
|
+
|
|
78
|
+
// web emitters
|
|
79
|
+
'tailwind': (d) => formatTailwind(d),
|
|
80
|
+
'tailwind-v4': (d) => formatTailwindV4(d),
|
|
81
|
+
'css-vars': (d) => formatCssVars(d),
|
|
82
|
+
'css-reset': (d) => formatCssReset(d),
|
|
83
|
+
'ts-defs': (d) => formatTsDefs(d),
|
|
84
|
+
'figma': (d) => formatFigma(d),
|
|
85
|
+
'preview-html': (d) => formatPreview(d),
|
|
86
|
+
'brand-book-html': (d) => formatBrandBook(d),
|
|
87
|
+
'gradients-css': (d) => formatGradientsCss(d),
|
|
88
|
+
'gradients-json': (d) => formatGradientsJson(d),
|
|
89
|
+
|
|
90
|
+
// frameworks
|
|
91
|
+
'react-theme': (d) => formatReactTheme(d),
|
|
92
|
+
'shadcn-theme': (d) => formatShadcnTheme(d),
|
|
93
|
+
'vue-theme': (d) => formatVueTheme(d),
|
|
94
|
+
'svelte-theme': (d) => formatSvelteTheme(d),
|
|
95
|
+
|
|
96
|
+
// native
|
|
97
|
+
'ios-swiftui': (d) => formatIosSwiftUI(formatDtcgTokens(d)),
|
|
98
|
+
'android-compose': (d) => formatAndroidCompose(formatDtcgTokens(d)),
|
|
99
|
+
'flutter-dart': (d) => formatFlutterDart(formatDtcgTokens(d)),
|
|
100
|
+
|
|
101
|
+
// WordPress
|
|
102
|
+
'wordpress': (d) => formatWordPress(d),
|
|
103
|
+
'wordpress-theme': (d) => formatWordPressTheme(formatDtcgTokens(d), d),
|
|
104
|
+
|
|
105
|
+
// Agent rules (returns a multi-file object)
|
|
106
|
+
'agent-rules': (d) => formatAgentRules({ design: d, tokens: formatDtcgTokens(d), url: d?.meta?.url || '' }),
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Render a single emitter by id.
|
|
111
|
+
*
|
|
112
|
+
* @param {keyof typeof RENDERERS} id
|
|
113
|
+
* @param {object} design
|
|
114
|
+
* @returns {string|object}
|
|
115
|
+
*/
|
|
116
|
+
export function render(id, design) {
|
|
117
|
+
const fn = RENDERERS[id];
|
|
118
|
+
if (!fn) {
|
|
119
|
+
throw new Error(`Unknown renderer "${id}". Known: ${Object.keys(RENDERERS).join(', ')}.`);
|
|
120
|
+
}
|
|
121
|
+
return fn(design);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Render every emitter and return a `{ filename: contents }` map.
|
|
126
|
+
*
|
|
127
|
+
* @param {object} design
|
|
128
|
+
* @param {object} [opts]
|
|
129
|
+
* @param {string} [opts.prefix] file-name prefix (default: derived from URL or 'design')
|
|
130
|
+
* @returns {Record<string, string>}
|
|
131
|
+
*/
|
|
132
|
+
export function renderAll(design, opts = {}) {
|
|
133
|
+
const prefix = opts.prefix || (design?.meta?.url ? new URL(design.meta.url).hostname.replace(/\./g, '-').replace(/^www-/, '') : 'design');
|
|
134
|
+
const out = {};
|
|
135
|
+
const ext = {
|
|
136
|
+
'design-md': 'DESIGN.md',
|
|
137
|
+
'design-language-md': 'design-language.md',
|
|
138
|
+
'tokens': 'tokens.legacy.json',
|
|
139
|
+
'dtcg': 'design-tokens.json',
|
|
140
|
+
'tailwind': 'tailwind.config.js',
|
|
141
|
+
'tailwind-v4': 'tailwind-v4.css',
|
|
142
|
+
'css-vars': 'variables.css',
|
|
143
|
+
'css-reset': 'reset.css',
|
|
144
|
+
'ts-defs': 'tokens.d.ts',
|
|
145
|
+
'figma': 'figma-variables.json',
|
|
146
|
+
'preview-html': 'preview.html',
|
|
147
|
+
'brand-book-html': 'brand.html',
|
|
148
|
+
'gradients-css': 'gradients.css',
|
|
149
|
+
'gradients-json': 'gradients.json',
|
|
150
|
+
'react-theme': 'theme.js',
|
|
151
|
+
'shadcn-theme': 'shadcn-theme.css',
|
|
152
|
+
'vue-theme': 'theme.vue.js',
|
|
153
|
+
'svelte-theme': 'theme.svelte.js',
|
|
154
|
+
'wordpress': 'wordpress.json',
|
|
155
|
+
};
|
|
156
|
+
for (const [id, suffix] of Object.entries(ext)) {
|
|
157
|
+
try {
|
|
158
|
+
const body = RENDERERS[id]?.(design);
|
|
159
|
+
if (typeof body === 'string' && body.length > 0) out[`${prefix}-${suffix}`] = body;
|
|
160
|
+
} catch { /* skip emitter on error */ }
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Re-exports for advanced consumers
|
|
166
|
+
export {
|
|
167
|
+
extractDesignLanguage,
|
|
168
|
+
compressPalette,
|
|
169
|
+
diffDesigns,
|
|
170
|
+
formatDiffMarkdown,
|
|
171
|
+
};
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// One-shot agent prompt — `<host>-AGENT.md`.
|
|
2
|
+
//
|
|
3
|
+
// Drop the contents into Claude / GPT / Gemini / Cursor / Windsurf
|
|
4
|
+
// and the agent will build any UI you ask for *in the extracted brand*.
|
|
5
|
+
//
|
|
6
|
+
// Distinct from the existing prompt-pack (which targets v0 / Lovable /
|
|
7
|
+
// Cursor with tool-specific syntax). This file is one self-contained
|
|
8
|
+
// system-prompt that works in any context window — every token, every
|
|
9
|
+
// component anatomy slot, every brand voice rule, ready to paste.
|
|
10
|
+
|
|
11
|
+
function hex(c) { return c?.hex || c || null; }
|
|
12
|
+
function pickHex(role, design) { return hex(design?.colors?.[role]) || null; }
|
|
13
|
+
|
|
14
|
+
function topN(arr, n) {
|
|
15
|
+
if (Array.isArray(arr)) return arr.slice(0, n);
|
|
16
|
+
if (arr && typeof arr === 'object') return Object.values(arr).slice(0, n);
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function listColors(design) {
|
|
21
|
+
const out = [];
|
|
22
|
+
for (const role of ['primary', 'secondary', 'accent', 'background', 'foreground']) {
|
|
23
|
+
const v = pickHex(role, design);
|
|
24
|
+
if (v) out.push(`- ${role.padEnd(11)} ${v}`);
|
|
25
|
+
}
|
|
26
|
+
const neutrals = topN(design?.colors?.neutrals, 6).map((n) => hex(n)).filter(Boolean);
|
|
27
|
+
if (neutrals.length) out.push(`- neutrals ${neutrals.join(' · ')}`);
|
|
28
|
+
return out.join('\n');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function listType(design) {
|
|
32
|
+
const fams = topN(design?.typography?.families, 4).map((f) => f?.name || f).filter(Boolean);
|
|
33
|
+
const weights = topN(design?.typography?.weights, 6).map((w) => w?.weight || w?.value || w).filter(Boolean);
|
|
34
|
+
const base = design?.typography?.base || 16;
|
|
35
|
+
return [
|
|
36
|
+
fams.length ? `- families ${fams.join(' · ')}` : null,
|
|
37
|
+
weights.length ? `- weights ${weights.join(' · ')}` : null,
|
|
38
|
+
`- base size ${base}px`,
|
|
39
|
+
].filter(Boolean).join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function listSpacing(design) {
|
|
43
|
+
const scale = topN(design?.spacing?.scale, 12).map((px) => `${px}px`);
|
|
44
|
+
if (!scale.length) return null;
|
|
45
|
+
return `- scale ${scale.join(' · ')}`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function listRadii(design) {
|
|
49
|
+
const radii = topN(design?.borders?.radii, 6).map((r) => `${typeof r === 'object' ? r.value : r}px`);
|
|
50
|
+
if (!radii.length) return null;
|
|
51
|
+
return `- scale ${radii.join(' · ')}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function listMotion(design) {
|
|
55
|
+
const durs = topN(design?.motion?.durations, 4).map((d) => `${typeof d === 'object' ? (d.value || d.ms) : d}ms`);
|
|
56
|
+
const eas = topN(design?.motion?.easings, 4).map((e) => typeof e === 'object' ? e.value : e).filter(Boolean);
|
|
57
|
+
return [
|
|
58
|
+
durs.length ? `- durations ${durs.join(' · ')}` : null,
|
|
59
|
+
eas.length ? `- easings ${eas.join(' · ')}` : null,
|
|
60
|
+
].filter(Boolean).join('\n');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function listVoice(design) {
|
|
64
|
+
const v = design?.voice || {};
|
|
65
|
+
const ctas = topN(v.ctaVerbs, 6).map((c) => c?.value || c).filter(Boolean);
|
|
66
|
+
const headings = topN(v.headlines || v.headings, 3).map((h) => h?.text || h).filter(Boolean);
|
|
67
|
+
return [
|
|
68
|
+
v.tone ? `- tone ${v.tone}` : null,
|
|
69
|
+
v.pronoun ? `- pronoun ${v.pronoun}` : null,
|
|
70
|
+
v.headingStyle ? `- headings ${v.headingStyle}` : null,
|
|
71
|
+
ctas.length ? `- CTA verbs ${ctas.join(' · ')}` : null,
|
|
72
|
+
headings.length ? `- real headlines:\n${headings.map(h => ` > "${String(h).slice(0, 120)}"`).join('\n')}` : null,
|
|
73
|
+
].filter(Boolean).join('\n');
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function listAnatomy(design) {
|
|
77
|
+
const list = design?.componentAnatomy || design?.componentClusters || [];
|
|
78
|
+
if (!Array.isArray(list) || list.length === 0) return null;
|
|
79
|
+
return topN(list, 8).map((c) => {
|
|
80
|
+
const kind = String(c?.kind || c?.name || 'component');
|
|
81
|
+
const variants = topN(c?.variants, 4).map(String).join(' · ') || '—';
|
|
82
|
+
const slots = topN(c?.slots, 4).map(String).join(' · ') || '—';
|
|
83
|
+
return `- ${kind.padEnd(10)} variants: ${variants} · slots: ${slots}`;
|
|
84
|
+
}).join('\n');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function listA11y(design) {
|
|
88
|
+
const a = design?.accessibility || {};
|
|
89
|
+
const score = a.score ?? null;
|
|
90
|
+
const fails = a.failCount ?? (a.remediation?.length ?? 0);
|
|
91
|
+
return `- WCAG score ${score == null ? '—' : `${score}%`} · failing pairs: ${fails}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function formatAgentPrompt(design) {
|
|
95
|
+
const host = design?.meta?.url ? new URL(design.meta.url).hostname.replace(/^www\./, '') : 'this site';
|
|
96
|
+
const title = design?.meta?.title || host;
|
|
97
|
+
const intent = design?.pageIntent?.label || 'landing';
|
|
98
|
+
const material = design?.materialLanguage?.label || 'flat';
|
|
99
|
+
const library = design?.componentLibrary?.label || null;
|
|
100
|
+
const grade = design?.score?.grade || null;
|
|
101
|
+
|
|
102
|
+
const blocks = [];
|
|
103
|
+
blocks.push(
|
|
104
|
+
`# You are building UI in the ${host} design system.`,
|
|
105
|
+
'',
|
|
106
|
+
`Source: ${design?.meta?.url || '—'}`,
|
|
107
|
+
`Extracted by designlang on ${new Date().toISOString().slice(0, 10)}.`,
|
|
108
|
+
'',
|
|
109
|
+
'## Brand at a glance',
|
|
110
|
+
'',
|
|
111
|
+
`- title ${title}`,
|
|
112
|
+
`- page intent ${intent}`,
|
|
113
|
+
`- material ${material}`,
|
|
114
|
+
library ? `- library ${library}` : null,
|
|
115
|
+
grade ? `- design grade ${grade}` : null,
|
|
116
|
+
'',
|
|
117
|
+
'## Colour',
|
|
118
|
+
'',
|
|
119
|
+
listColors(design) || '_(no colour roles detected)_',
|
|
120
|
+
'',
|
|
121
|
+
'## Typography',
|
|
122
|
+
'',
|
|
123
|
+
listType(design),
|
|
124
|
+
'',
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const spacing = listSpacing(design);
|
|
128
|
+
if (spacing) blocks.push('## Spacing', '', spacing, '');
|
|
129
|
+
|
|
130
|
+
const radii = listRadii(design);
|
|
131
|
+
if (radii) blocks.push('## Radii', '', radii, '');
|
|
132
|
+
|
|
133
|
+
const motion = listMotion(design);
|
|
134
|
+
if (motion) blocks.push('## Motion', '', motion, '');
|
|
135
|
+
|
|
136
|
+
const voice = listVoice(design);
|
|
137
|
+
if (voice) blocks.push('## Voice', '', voice, '');
|
|
138
|
+
|
|
139
|
+
const anatomy = listAnatomy(design);
|
|
140
|
+
if (anatomy) blocks.push('## Component anatomy', '', anatomy, '');
|
|
141
|
+
|
|
142
|
+
blocks.push('## Accessibility', '', listA11y(design), '');
|
|
143
|
+
|
|
144
|
+
blocks.push(
|
|
145
|
+
'## Build rules',
|
|
146
|
+
'',
|
|
147
|
+
'1. Use the colours above. **Never invent a new hex.** If you need a',
|
|
148
|
+
' shade between two existing colours, derive it via HSL adjustment',
|
|
149
|
+
' from the closest extracted colour and call out the derivation.',
|
|
150
|
+
'2. Use the extracted typography families. If you need a missing weight,',
|
|
151
|
+
' pick the nearest available weight from the list and note it.',
|
|
152
|
+
'3. Snap spacing values to the scale above. No off-scale paddings or',
|
|
153
|
+
' margins.',
|
|
154
|
+
'4. Snap border radii to the scale above.',
|
|
155
|
+
'5. Match the voice: same tone, same pronoun stance, same heading',
|
|
156
|
+
' style. Reuse the listed CTA verbs.',
|
|
157
|
+
'6. Aim for WCAG AA contrast minimum. When the brand colours fail,',
|
|
158
|
+
' prefer the foreground colour on the background colour rather than',
|
|
159
|
+
' mid-tone neutrals.',
|
|
160
|
+
'7. Reuse component anatomy when it exists — do not invent novel',
|
|
161
|
+
' structures for things the site already has.',
|
|
162
|
+
'',
|
|
163
|
+
'## Available context files',
|
|
164
|
+
'',
|
|
165
|
+
'designlang wrote these alongside this prompt. Reach for them when',
|
|
166
|
+
'you need ground truth:',
|
|
167
|
+
'',
|
|
168
|
+
'- `<host>-design-tokens.json` — DTCG primitive · semantic · composite tokens',
|
|
169
|
+
'- `<host>-tailwind.config.js` — Tailwind v3 config',
|
|
170
|
+
'- `<host>-tailwind-v4.css` — Tailwind v4 `@theme` block',
|
|
171
|
+
'- `<host>-tokens.d.ts` — TypeScript literal-union types',
|
|
172
|
+
'- `<host>-variables.css` — bare CSS custom properties',
|
|
173
|
+
'- `<host>-reset.css` — brand-aware base styles',
|
|
174
|
+
'- `<host>-gradients.css` — `.grad-N` utility classes',
|
|
175
|
+
'- `<host>-anatomy.tsx` — typed React component scaffolds',
|
|
176
|
+
'- `<host>-shadcn-theme.css` — shadcn/ui theme',
|
|
177
|
+
'- `<host>-theme.js` — React / Vue / Svelte theme object',
|
|
178
|
+
'- `<host>-mcp.json` — MCP server payload (load via stdio)',
|
|
179
|
+
'- `<host>.brand.pdf` — print-ready 13-chapter brand book',
|
|
180
|
+
'',
|
|
181
|
+
'When you reference the system in code, prefer importing from these',
|
|
182
|
+
'files over hard-coding values.',
|
|
183
|
+
'',
|
|
184
|
+
'## Output expectations',
|
|
185
|
+
'',
|
|
186
|
+
'When asked to "build a pricing page" or "make a card" or any UI:',
|
|
187
|
+
'',
|
|
188
|
+
'- Produce a single self-contained component file in the appropriate',
|
|
189
|
+
' framework (React / Vue / Svelte — match what the user is using).',
|
|
190
|
+
'- Use Tailwind utility classes wired to the v4 `@theme` if Tailwind',
|
|
191
|
+
' is available; otherwise use the CSS custom properties from',
|
|
192
|
+
' `variables.css`.',
|
|
193
|
+
'- Write the headline copy using the brand voice; do not invent',
|
|
194
|
+
' generic Lorem.',
|
|
195
|
+
'- Annotate any choice where you had to bend the system, with a',
|
|
196
|
+
' one-line `// note:` comment explaining what and why.',
|
|
197
|
+
'',
|
|
198
|
+
`## One-line install`,
|
|
199
|
+
'',
|
|
200
|
+
'```bash',
|
|
201
|
+
`npx designlang ${host}`,
|
|
202
|
+
'```',
|
|
203
|
+
'',
|
|
204
|
+
'Run this against any other URL to extract its system in the same',
|
|
205
|
+
'shape as the one above.',
|
|
206
|
+
'',
|
|
207
|
+
'---',
|
|
208
|
+
'',
|
|
209
|
+
`Generated by designlang. Re-extract by running \`npx designlang ${host}\`.`,
|
|
210
|
+
'',
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
return blocks.filter((b) => b !== null).join('\n');
|
|
214
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// CSS reset emitter — modern, brand-aware base styles.
|
|
2
|
+
//
|
|
3
|
+
// Most CSS resets (normalize, sanitize, Josh Comeau) are generic. This
|
|
4
|
+
// one is wired to the brand we just extracted: body uses the brand
|
|
5
|
+
// background / foreground, headings use the brand display family, links
|
|
6
|
+
// use the brand primary, selection uses the brand accent.
|
|
7
|
+
//
|
|
8
|
+
// Drop at the top of your stylesheet, then import Tailwind / your CSS.
|
|
9
|
+
|
|
10
|
+
const RESET = `/* ──────────────────────────────────────────────────────────────
|
|
11
|
+
Brand-aware reset — generated by designlang
|
|
12
|
+
{site}
|
|
13
|
+
{generated}
|
|
14
|
+
────────────────────────────────────────────────────────────── */
|
|
15
|
+
|
|
16
|
+
*, *::before, *::after { box-sizing: border-box; }
|
|
17
|
+
|
|
18
|
+
html, body, h1, h2, h3, h4, h5, h6, p, ul, ol, li, blockquote, figure,
|
|
19
|
+
form, fieldset, table, hr {
|
|
20
|
+
margin: 0;
|
|
21
|
+
padding: 0;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
html {
|
|
25
|
+
-webkit-text-size-adjust: 100%;
|
|
26
|
+
-moz-text-size-adjust: 100%;
|
|
27
|
+
text-size-adjust: 100%;
|
|
28
|
+
-webkit-tap-highlight-color: transparent;
|
|
29
|
+
font-family: {fontSans}, system-ui, -apple-system, 'Segoe UI', sans-serif;
|
|
30
|
+
font-size: {fontBase}px;
|
|
31
|
+
line-height: 1.5;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
body {
|
|
35
|
+
background-color: {bg};
|
|
36
|
+
color: {fg};
|
|
37
|
+
font-weight: 400;
|
|
38
|
+
-webkit-font-smoothing: antialiased;
|
|
39
|
+
-moz-osx-font-smoothing: grayscale;
|
|
40
|
+
text-rendering: optimizeLegibility;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
h1, h2, h3, h4, h5, h6 {
|
|
44
|
+
font-family: {fontDisplay}, system-ui, -apple-system, sans-serif;
|
|
45
|
+
font-weight: 600;
|
|
46
|
+
line-height: 1.15;
|
|
47
|
+
letter-spacing: -0.015em;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
a {
|
|
51
|
+
color: {primary};
|
|
52
|
+
text-decoration: none;
|
|
53
|
+
background-color: transparent;
|
|
54
|
+
}
|
|
55
|
+
a:hover { text-decoration: underline; }
|
|
56
|
+
a:focus-visible { outline: 2px solid {accent}; outline-offset: 2px; border-radius: 4px; }
|
|
57
|
+
|
|
58
|
+
img, picture, video, canvas, svg {
|
|
59
|
+
display: block;
|
|
60
|
+
max-width: 100%;
|
|
61
|
+
height: auto;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
input, textarea, select, button {
|
|
65
|
+
font: inherit;
|
|
66
|
+
color: inherit;
|
|
67
|
+
}
|
|
68
|
+
button { cursor: pointer; background: none; border: 0; padding: 0; }
|
|
69
|
+
|
|
70
|
+
code, pre, kbd, samp {
|
|
71
|
+
font-family: {fontMono}, ui-monospace, 'SF Mono', Menlo, monospace;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
hr {
|
|
75
|
+
border: 0;
|
|
76
|
+
border-top: 1px solid {hairline};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
::selection {
|
|
80
|
+
background: {accent};
|
|
81
|
+
color: {bg};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@media (prefers-reduced-motion: reduce) {
|
|
85
|
+
*, *::before, *::after {
|
|
86
|
+
animation-duration: 0.001ms !important;
|
|
87
|
+
transition-duration: 0.001ms !important;
|
|
88
|
+
scroll-behavior: auto !important;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
function pickFamily(design, kind) {
|
|
94
|
+
const fams = design?.typography?.families || [];
|
|
95
|
+
if (kind === 'mono') {
|
|
96
|
+
const m = fams.find((f) => /mono|code|courier/i.test(f.name || f));
|
|
97
|
+
return m ? `"${m.name || m}"` : 'ui-monospace';
|
|
98
|
+
}
|
|
99
|
+
if (kind === 'display') {
|
|
100
|
+
const d = fams.find((f) => /display|serif|headline/i.test(f.name || f));
|
|
101
|
+
return d ? `"${d.name || d}"` : `"${fams[0]?.name || fams[0] || 'system'}"`;
|
|
102
|
+
}
|
|
103
|
+
return fams[0] ? `"${fams[0].name || fams[0]}"` : 'system-ui';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pickHex(design, role, fallback) {
|
|
107
|
+
return design?.colors?.[role]?.hex || fallback;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function formatCssReset(design) {
|
|
111
|
+
const subs = {
|
|
112
|
+
site: design?.meta?.title || design?.meta?.url || '—',
|
|
113
|
+
generated: new Date().toISOString(),
|
|
114
|
+
fontSans: pickFamily(design, 'sans'),
|
|
115
|
+
fontMono: pickFamily(design, 'mono'),
|
|
116
|
+
fontDisplay: pickFamily(design, 'display'),
|
|
117
|
+
fontBase: design?.typography?.base || 16,
|
|
118
|
+
bg: pickHex(design, 'background', '#ffffff') || (design?.colors?.backgrounds?.[0] || '#ffffff'),
|
|
119
|
+
fg: pickHex(design, 'foreground', '#000000') || (design?.colors?.text?.[0] || '#000000'),
|
|
120
|
+
primary: pickHex(design, 'primary', '#0070f3'),
|
|
121
|
+
accent: pickHex(design, 'accent', pickHex(design, 'primary', '#0070f3')),
|
|
122
|
+
hairline: pickHex(design, 'secondary', '#e5e5e5'),
|
|
123
|
+
};
|
|
124
|
+
return RESET.replace(/\{(\w+)\}/g, (_, k) => subs[k] ?? `var(--${k})`);
|
|
125
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Gradients emitter — surface the gradients the colour extractor already
|
|
2
|
+
// captured as a ready-to-paste CSS file with utility classes.
|
|
3
|
+
//
|
|
4
|
+
// Outputs two files:
|
|
5
|
+
// <host>-gradients.css — :root vars + .grad-N utility classes
|
|
6
|
+
// <host>-gradients.json — structured list (type, direction, stops)
|
|
7
|
+
//
|
|
8
|
+
// Designers like gradients. Most sites have 3–20 unique ones. Surfacing
|
|
9
|
+
// them as utility classes lets engineers replicate hero / card / button
|
|
10
|
+
// backgrounds without eye-balling rgba stops out of devtools.
|
|
11
|
+
|
|
12
|
+
function cleanRaw(raw) {
|
|
13
|
+
if (!raw || typeof raw !== 'string') return null;
|
|
14
|
+
// Drop browser duplicates, collapse whitespace, trim quotes.
|
|
15
|
+
return raw.replace(/\s+/g, ' ').trim().replace(/^"|"$/g, '');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function uniqGradients(design) {
|
|
19
|
+
const list = design?.gradients?.gradients
|
|
20
|
+
|| (Array.isArray(design?.gradients) ? design.gradients : [])
|
|
21
|
+
|| (design?.colors?.gradients || []);
|
|
22
|
+
const out = [];
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
for (const g of list) {
|
|
25
|
+
const raw = cleanRaw(g?.raw || g?.value || g);
|
|
26
|
+
if (!raw || !raw.includes('gradient')) continue;
|
|
27
|
+
if (seen.has(raw)) continue;
|
|
28
|
+
seen.add(raw);
|
|
29
|
+
out.push({
|
|
30
|
+
raw,
|
|
31
|
+
type: g?.type || (raw.match(/^(linear|radial|conic)-gradient/i)?.[1] || 'linear').toLowerCase(),
|
|
32
|
+
direction: g?.direction || null,
|
|
33
|
+
stops: Array.isArray(g?.stops) ? g.stops : [],
|
|
34
|
+
classification: g?.classification || null,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function formatGradientsCss(design) {
|
|
41
|
+
const grads = uniqGradients(design);
|
|
42
|
+
if (grads.length === 0) {
|
|
43
|
+
return [
|
|
44
|
+
'/* designlang — no gradients detected on the source page. */',
|
|
45
|
+
'/* Run with --interactions to capture hover backgrounds. */',
|
|
46
|
+
'',
|
|
47
|
+
].join('\n');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const lines = [
|
|
51
|
+
'/* ──────────────────────────────────────────────────────────────',
|
|
52
|
+
` ${grads.length} gradients — generated by designlang`,
|
|
53
|
+
` ${design?.meta?.url || ''}`,
|
|
54
|
+
` ${new Date().toISOString()}`,
|
|
55
|
+
' ────────────────────────────────────────────────────────────── */',
|
|
56
|
+
'',
|
|
57
|
+
':root {',
|
|
58
|
+
];
|
|
59
|
+
grads.forEach((g, i) => { lines.push(` --grad-${i + 1}: ${g.raw};`); });
|
|
60
|
+
lines.push('}', '');
|
|
61
|
+
|
|
62
|
+
grads.forEach((g, i) => {
|
|
63
|
+
lines.push(
|
|
64
|
+
`/* ${g.type}${g.classification ? ` · ${g.classification}` : ''} */`,
|
|
65
|
+
`.grad-${i + 1} { background-image: var(--grad-${i + 1}); }`,
|
|
66
|
+
`.grad-text-${i + 1} {`,
|
|
67
|
+
` background-image: var(--grad-${i + 1});`,
|
|
68
|
+
` -webkit-background-clip: text;`,
|
|
69
|
+
` background-clip: text;`,
|
|
70
|
+
` color: transparent;`,
|
|
71
|
+
`}`,
|
|
72
|
+
''
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
return lines.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function formatGradientsJson(design) {
|
|
80
|
+
const grads = uniqGradients(design);
|
|
81
|
+
return JSON.stringify({
|
|
82
|
+
$schema: 'https://designlang.app/schemas/gradients.json',
|
|
83
|
+
source: design?.meta?.url || null,
|
|
84
|
+
generated: new Date().toISOString(),
|
|
85
|
+
count: grads.length,
|
|
86
|
+
gradients: grads,
|
|
87
|
+
}, null, 2);
|
|
88
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Tailwind v4 emitter — CSS-first configuration.
|
|
2
|
+
//
|
|
3
|
+
// Tailwind v4 moved away from the JS config file in favour of a CSS-native
|
|
4
|
+
// `@theme { ... }` block. This emitter produces that block plus a `@layer
|
|
5
|
+
// base` reset wired to the extracted brand. Drop the file at the top of
|
|
6
|
+
// your `tailwind.css`, import Tailwind under it, done.
|
|
7
|
+
//
|
|
8
|
+
// See: https://tailwindcss.com/blog/tailwindcss-v4
|
|
9
|
+
|
|
10
|
+
import { rgbToHsl } from '../utils.js';
|
|
11
|
+
|
|
12
|
+
function scaleFromHex(hex, parsed) {
|
|
13
|
+
const { h, s } = parsed.hsl ?? rgbToHsl(parsed.rgb);
|
|
14
|
+
const levels = [
|
|
15
|
+
['50', 97], ['100', 94], ['200', 86], ['300', 76], ['400', 64],
|
|
16
|
+
['500', 50], ['600', 40], ['700', 32], ['800', 24], ['900', 16], ['950', 10],
|
|
17
|
+
];
|
|
18
|
+
return Object.fromEntries(levels.map(([name, l]) => [name, `hsl(${h} ${s}% ${l}%)`]));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function kvBlock(prefix, obj) {
|
|
22
|
+
return Object.entries(obj)
|
|
23
|
+
.map(([k, v]) => ` ${prefix}-${k}: ${v};`)
|
|
24
|
+
.join('\n');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatTailwindV4(design) {
|
|
28
|
+
const lines = [
|
|
29
|
+
'/* ──────────────────────────────────────────────────────────────',
|
|
30
|
+
' Tailwind v4 @theme — generated by designlang',
|
|
31
|
+
` site: ${design.meta?.title || design.meta?.url || '—'}`,
|
|
32
|
+
` ${new Date().toISOString()}`,
|
|
33
|
+
' Drop this above `@import "tailwindcss";`.',
|
|
34
|
+
' ────────────────────────────────────────────────────────────── */',
|
|
35
|
+
'',
|
|
36
|
+
'@import "tailwindcss";',
|
|
37
|
+
'',
|
|
38
|
+
'@theme {',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
const tokens = {};
|
|
42
|
+
|
|
43
|
+
// brand colours with generated scales
|
|
44
|
+
for (const role of ['primary', 'secondary', 'accent']) {
|
|
45
|
+
const c = design.colors?.[role];
|
|
46
|
+
if (!c?.hex) continue;
|
|
47
|
+
const scale = scaleFromHex(c.hex, c);
|
|
48
|
+
for (const [step, val] of Object.entries(scale)) tokens[`color-${role}-${step}`] = val;
|
|
49
|
+
tokens[`color-${role}`] = c.hex;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// neutrals
|
|
53
|
+
(design.colors?.neutrals || []).slice(0, 10).forEach((n, i) => {
|
|
54
|
+
tokens[`color-neutral-${i * 100 || 50}`] = n.hex;
|
|
55
|
+
});
|
|
56
|
+
if (design.colors?.backgrounds?.[0]) tokens['color-background'] = design.colors.backgrounds[0];
|
|
57
|
+
if (design.colors?.text?.[0]) tokens['color-foreground'] = design.colors.text[0];
|
|
58
|
+
|
|
59
|
+
// typography
|
|
60
|
+
const fams = design.typography?.families || [];
|
|
61
|
+
if (fams[0]) tokens['font-sans'] = `"${fams[0].name || fams[0]}", system-ui, sans-serif`;
|
|
62
|
+
const mono = fams.find((f) => /mono|code|courier/i.test(f.name || f));
|
|
63
|
+
if (mono) tokens['font-mono'] = `"${mono.name || mono}", ui-monospace, monospace`;
|
|
64
|
+
|
|
65
|
+
// spacing
|
|
66
|
+
(design.spacing?.scale || []).slice(0, 12).forEach((px, i) => {
|
|
67
|
+
tokens[`spacing-${i + 1}`] = `${px}px`;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// radii
|
|
71
|
+
const radii = design.borders?.radii || [];
|
|
72
|
+
const radiusNames = ['sm', 'md', 'lg', 'xl', '2xl', 'full'];
|
|
73
|
+
radii.slice(0, radiusNames.length).forEach((r, i) => {
|
|
74
|
+
const px = typeof r === 'object' ? r.value : r;
|
|
75
|
+
tokens[`radius-${radiusNames[i]}`] = px >= 999 ? '9999px' : `${px}px`;
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// shadows
|
|
79
|
+
const shadows = design.shadows?.values || design.shadows || [];
|
|
80
|
+
(Array.isArray(shadows) ? shadows : []).slice(0, 4).forEach((sh, i) => {
|
|
81
|
+
const value = typeof sh === 'object' ? sh.value || sh.raw : sh;
|
|
82
|
+
tokens[`shadow-${['sm', 'md', 'lg', 'xl'][i]}`] = String(value);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// motion
|
|
86
|
+
const durations = design.motion?.durations || [];
|
|
87
|
+
durations.slice(0, 4).forEach((d, i) => {
|
|
88
|
+
tokens[`animate-duration-${['fast', 'base', 'slow', 'slower'][i]}`] = typeof d === 'object' ? d.value : `${d}ms`;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
for (const [k, v] of Object.entries(tokens)) {
|
|
92
|
+
lines.push(` --${k}: ${v};`);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
lines.push('}', '');
|
|
96
|
+
lines.push('/* Optional base reset that maps body/heading to the extracted tokens. */');
|
|
97
|
+
lines.push('@layer base {');
|
|
98
|
+
lines.push(' html { font-family: var(--font-sans); }');
|
|
99
|
+
if (tokens['color-background']) lines.push(' body { background-color: var(--color-background); color: var(--color-foreground, currentColor); }');
|
|
100
|
+
lines.push('}');
|
|
101
|
+
lines.push('');
|
|
102
|
+
|
|
103
|
+
return lines.join('\n');
|
|
104
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// TypeScript token-types emitter.
|
|
2
|
+
//
|
|
3
|
+
// Writes `<host>-tokens.d.ts` — strict literal-union types for every
|
|
4
|
+
// extracted token so component props like `color?: ColorToken` get
|
|
5
|
+
// autocompleted and type-checked against the actual brand, not a
|
|
6
|
+
// generic `string`. Pairs with the `<host>-theme.js` JS theme object.
|
|
7
|
+
|
|
8
|
+
function safeKey(s) {
|
|
9
|
+
return String(s || '')
|
|
10
|
+
.replace(/[^a-zA-Z0-9_$]/g, '_')
|
|
11
|
+
.replace(/^([0-9])/, '_$1')
|
|
12
|
+
.toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function asScalar(v) {
|
|
16
|
+
if (v == null) return null;
|
|
17
|
+
if (typeof v === 'string' || typeof v === 'number') return String(v);
|
|
18
|
+
if (typeof v === 'object') {
|
|
19
|
+
if (typeof v.value !== 'undefined') return asScalar(v.value);
|
|
20
|
+
if (typeof v.px !== 'undefined') return String(v.px);
|
|
21
|
+
if (typeof v.weight !== 'undefined') return String(v.weight);
|
|
22
|
+
if (typeof v.ms !== 'undefined') return String(v.ms);
|
|
23
|
+
if (typeof v.hex !== 'undefined') return String(v.hex);
|
|
24
|
+
if (typeof v.name !== 'undefined') return String(v.name);
|
|
25
|
+
return null; // unknown shape — drop
|
|
26
|
+
}
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function quoteUnion(values) {
|
|
31
|
+
const unique = [...new Set(values.map(asScalar).filter(Boolean))];
|
|
32
|
+
if (unique.length === 0) return 'never';
|
|
33
|
+
return unique.map((v) => `'${v.replace(/'/g, "\\'")}'`).join(' | ');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function formatTsDefs(design) {
|
|
37
|
+
const lines = [
|
|
38
|
+
'// Auto-generated by designlang. Do not edit by hand.',
|
|
39
|
+
'// Re-extract with `npx designlang <url>` to refresh.',
|
|
40
|
+
'//',
|
|
41
|
+
'// Import next to your token JSON to get type-safe access:',
|
|
42
|
+
'//',
|
|
43
|
+
'// import type { ColorToken, SpacingToken } from \'./<host>-tokens\';',
|
|
44
|
+
'// import tokens from \'./<host>-design-tokens.json\';',
|
|
45
|
+
'//',
|
|
46
|
+
`// Source: ${design.meta?.url || '—'}`,
|
|
47
|
+
`// Generated: ${new Date().toISOString()}`,
|
|
48
|
+
'',
|
|
49
|
+
'export type Hex = `#${string}`;',
|
|
50
|
+
'export type Px = `${number}px`;',
|
|
51
|
+
'export type Ms = `${number}ms`;',
|
|
52
|
+
'',
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// colour role union (primary | secondary | ...)
|
|
56
|
+
const colourRoles = ['primary', 'secondary', 'accent']
|
|
57
|
+
.filter((r) => design.colors?.[r]?.hex);
|
|
58
|
+
lines.push(`export type ColorRole = ${quoteUnion(colourRoles) || quoteUnion(['primary'])};`);
|
|
59
|
+
|
|
60
|
+
// every unique hex
|
|
61
|
+
const palette = (design.colors?.all || []).map((c) => c.hex).filter(Boolean);
|
|
62
|
+
lines.push(`export type ColorHex = ${quoteUnion(palette) || 'Hex'};`);
|
|
63
|
+
lines.push('export type ColorToken = ColorRole | ColorHex;');
|
|
64
|
+
lines.push('');
|
|
65
|
+
|
|
66
|
+
// typography
|
|
67
|
+
const fonts = (design.typography?.families || []).map((f) => f.name || f).filter(Boolean);
|
|
68
|
+
lines.push(`export type FontFamilyToken = ${quoteUnion(fonts) || quoteUnion(['system'])};`);
|
|
69
|
+
|
|
70
|
+
const sizes = (design.typography?.sizes || []).map((s) => `${typeof s === 'object' ? s.value || s.px : s}px`).filter(Boolean);
|
|
71
|
+
lines.push(`export type FontSizeToken = ${quoteUnion(sizes) || 'Px'};`);
|
|
72
|
+
|
|
73
|
+
const weights = (design.typography?.weights || []);
|
|
74
|
+
lines.push(`export type FontWeightToken = ${quoteUnion(weights) || `'400' | '500' | '600' | '700'`};`);
|
|
75
|
+
lines.push('');
|
|
76
|
+
|
|
77
|
+
// spacing
|
|
78
|
+
const spacing = (design.spacing?.scale || []).map((px) => `${px}px`).filter(Boolean);
|
|
79
|
+
lines.push(`export type SpacingToken = ${quoteUnion(spacing) || 'Px'};`);
|
|
80
|
+
lines.push('');
|
|
81
|
+
|
|
82
|
+
// radii
|
|
83
|
+
const radii = (design.borders?.radii || []).map((r) => `${typeof r === 'object' ? r.value : r}px`).filter(Boolean);
|
|
84
|
+
lines.push(`export type RadiusToken = ${quoteUnion(radii) || 'Px'};`);
|
|
85
|
+
lines.push('');
|
|
86
|
+
|
|
87
|
+
// motion
|
|
88
|
+
const durations = (design.motion?.durations || []).map((d) => `${typeof d === 'object' ? (d.value || d.ms) : d}ms`).filter(Boolean);
|
|
89
|
+
lines.push(`export type DurationToken = ${quoteUnion(durations) || 'Ms'};`);
|
|
90
|
+
|
|
91
|
+
const easings = (design.motion?.easings || []).map((e) => typeof e === 'object' ? e.value : e).filter(Boolean);
|
|
92
|
+
lines.push(`export type EasingToken = ${quoteUnion(easings) || 'string'};`);
|
|
93
|
+
lines.push('');
|
|
94
|
+
|
|
95
|
+
// semantic intent
|
|
96
|
+
if (design.pageIntent?.label) {
|
|
97
|
+
lines.push(`export type PageIntent = ${quoteUnion([design.pageIntent.label])};`);
|
|
98
|
+
}
|
|
99
|
+
if (design.materialLanguage?.label) {
|
|
100
|
+
lines.push(`export type MaterialLanguage = ${quoteUnion([design.materialLanguage.label])};`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// a roll-up interface for the whole token tree
|
|
104
|
+
lines.push('');
|
|
105
|
+
lines.push('export interface DesignTokens {');
|
|
106
|
+
lines.push(' color: { [role in ColorRole]: ColorHex };');
|
|
107
|
+
lines.push(' typography: {');
|
|
108
|
+
lines.push(' family: FontFamilyToken;');
|
|
109
|
+
lines.push(' size: FontSizeToken;');
|
|
110
|
+
lines.push(' weight: FontWeightToken;');
|
|
111
|
+
lines.push(' };');
|
|
112
|
+
lines.push(' spacing: ReadonlyArray<SpacingToken>;');
|
|
113
|
+
lines.push(' radius: ReadonlyArray<RadiusToken>;');
|
|
114
|
+
lines.push(' motion: {');
|
|
115
|
+
lines.push(' duration: ReadonlyArray<DurationToken>;');
|
|
116
|
+
lines.push(' easing: ReadonlyArray<EasingToken>;');
|
|
117
|
+
lines.push(' };');
|
|
118
|
+
lines.push('}');
|
|
119
|
+
lines.push('');
|
|
120
|
+
lines.push('declare const tokens: DesignTokens;');
|
|
121
|
+
lines.push('export default tokens;');
|
|
122
|
+
lines.push('');
|
|
123
|
+
|
|
124
|
+
return lines.join('\n');
|
|
125
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// Smart palette compression via LAB-space k-means.
|
|
2
|
+
//
|
|
3
|
+
// Raw extractions often yield 60–200 unique colours (every minor RGB
|
|
4
|
+
// variation, every transparent overlay's blended result). That's noise,
|
|
5
|
+
// not a system. This module reduces a long input list to a short,
|
|
6
|
+
// perceptually distinct output palette by:
|
|
7
|
+
//
|
|
8
|
+
// 1. Converting every hex to CIELAB (perceptually uniform)
|
|
9
|
+
// 2. Weighting each colour by its usage count (frequency)
|
|
10
|
+
// 3. Running weighted k-means with k+ seeded by the most-used colours
|
|
11
|
+
// 4. Returning the cluster medoids (the actual real colour closest to
|
|
12
|
+
// each cluster centre — not a synthesised average that doesn't
|
|
13
|
+
// exist on the page).
|
|
14
|
+
//
|
|
15
|
+
// Pure functions, no dependencies. ~150 LOC.
|
|
16
|
+
|
|
17
|
+
function hexToRgb(hex) {
|
|
18
|
+
const s = String(hex || '').trim().replace(/^#/, '');
|
|
19
|
+
const full = s.length === 3 ? s.split('').map((c) => c + c).join('') : s.slice(0, 6);
|
|
20
|
+
if (!/^[0-9a-f]{6}$/i.test(full)) return null;
|
|
21
|
+
return {
|
|
22
|
+
r: parseInt(full.slice(0, 2), 16),
|
|
23
|
+
g: parseInt(full.slice(2, 4), 16),
|
|
24
|
+
b: parseInt(full.slice(4, 6), 16),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// sRGB → linear → XYZ → CIELAB (D65)
|
|
29
|
+
function rgbToLab(rgb) {
|
|
30
|
+
const srgb = [rgb.r, rgb.g, rgb.b].map((v) => v / 255);
|
|
31
|
+
const lin = srgb.map((v) => (v > 0.04045 ? ((v + 0.055) / 1.055) ** 2.4 : v / 12.92));
|
|
32
|
+
const [r, g, b] = lin;
|
|
33
|
+
const x = (r * 0.4124564 + g * 0.3575761 + b * 0.1804375) / 0.95047;
|
|
34
|
+
const y = (r * 0.2126729 + g * 0.7151522 + b * 0.0721750) / 1.00000;
|
|
35
|
+
const z = (r * 0.0193339 + g * 0.1191920 + b * 0.9503041) / 1.08883;
|
|
36
|
+
const f = (t) => (t > 0.008856 ? Math.cbrt(t) : 7.787 * t + 16 / 116);
|
|
37
|
+
const fx = f(x), fy = f(y), fz = f(z);
|
|
38
|
+
return {
|
|
39
|
+
L: 116 * fy - 16,
|
|
40
|
+
a: 500 * (fx - fy),
|
|
41
|
+
b: 200 * (fy - fz),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function dist2(p, q) {
|
|
46
|
+
const dL = p.L - q.L, da = p.a - q.a, db = p.b - q.b;
|
|
47
|
+
return dL * dL + da * da + db * db;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function weightedCentroid(members) {
|
|
51
|
+
let wL = 0, wa = 0, wb = 0, w = 0;
|
|
52
|
+
for (const m of members) {
|
|
53
|
+
w += m.weight;
|
|
54
|
+
wL += m.lab.L * m.weight;
|
|
55
|
+
wa += m.lab.a * m.weight;
|
|
56
|
+
wb += m.lab.b * m.weight;
|
|
57
|
+
}
|
|
58
|
+
return w > 0 ? { L: wL / w, a: wa / w, b: wb / w } : members[0]?.lab || { L: 0, a: 0, b: 0 };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function pickFurthestSeeds(points, k) {
|
|
62
|
+
if (points.length <= k) return points.map((p) => p.lab);
|
|
63
|
+
// Seed with the most-used colour, then pick the k-1 colours furthest from
|
|
64
|
+
// any already-seeded colour (max-min). Deterministic, no randomness, so
|
|
65
|
+
// the same input gives the same output every run.
|
|
66
|
+
const seeds = [];
|
|
67
|
+
const sorted = [...points].sort((x, y) => y.weight - x.weight);
|
|
68
|
+
seeds.push(sorted[0].lab);
|
|
69
|
+
while (seeds.length < k) {
|
|
70
|
+
let best = null, bestDist = -1;
|
|
71
|
+
for (const p of points) {
|
|
72
|
+
const minD = seeds.reduce((m, s) => Math.min(m, dist2(p.lab, s)), Infinity);
|
|
73
|
+
const score = minD * Math.log1p(p.weight); // bias toward heavy + far
|
|
74
|
+
if (score > bestDist) { bestDist = score; best = p; }
|
|
75
|
+
}
|
|
76
|
+
if (!best) break;
|
|
77
|
+
seeds.push(best.lab);
|
|
78
|
+
}
|
|
79
|
+
return seeds;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function kmeans(points, k, { maxIter = 60 } = {}) {
|
|
83
|
+
if (points.length === 0) return [];
|
|
84
|
+
if (points.length <= k) return points.map((p) => [p]);
|
|
85
|
+
|
|
86
|
+
let centres = pickFurthestSeeds(points, k);
|
|
87
|
+
let clusters = Array.from({ length: k }, () => []);
|
|
88
|
+
|
|
89
|
+
for (let iter = 0; iter < maxIter; iter++) {
|
|
90
|
+
clusters = Array.from({ length: k }, () => []);
|
|
91
|
+
for (const p of points) {
|
|
92
|
+
let bestI = 0, bestD = Infinity;
|
|
93
|
+
for (let i = 0; i < centres.length; i++) {
|
|
94
|
+
const d = dist2(p.lab, centres[i]);
|
|
95
|
+
if (d < bestD) { bestD = d; bestI = i; }
|
|
96
|
+
}
|
|
97
|
+
clusters[bestI].push(p);
|
|
98
|
+
}
|
|
99
|
+
const newCentres = clusters.map((members, i) =>
|
|
100
|
+
members.length > 0 ? weightedCentroid(members) : centres[i]
|
|
101
|
+
);
|
|
102
|
+
// converged?
|
|
103
|
+
const moved = newCentres.reduce((sum, c, i) => sum + dist2(c, centres[i]), 0);
|
|
104
|
+
centres = newCentres;
|
|
105
|
+
if (moved < 0.5) break;
|
|
106
|
+
}
|
|
107
|
+
return clusters.filter((c) => c.length > 0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* @param {Array<{ hex: string, count?: number }>} input
|
|
112
|
+
* @param {number} k target palette size
|
|
113
|
+
*/
|
|
114
|
+
export function compressPalette(input, k = 12) {
|
|
115
|
+
const points = [];
|
|
116
|
+
for (const c of input || []) {
|
|
117
|
+
const rgb = hexToRgb(c.hex);
|
|
118
|
+
if (!rgb) continue;
|
|
119
|
+
points.push({
|
|
120
|
+
hex: c.hex,
|
|
121
|
+
rgb,
|
|
122
|
+
lab: rgbToLab(rgb),
|
|
123
|
+
weight: Math.max(1, c.count || c.weight || 1),
|
|
124
|
+
original: c,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
if (points.length === 0) return [];
|
|
128
|
+
if (points.length <= k) return points.map((p) => ({ ...p.original, hex: p.hex, count: p.weight, clusterSize: 1 }));
|
|
129
|
+
|
|
130
|
+
const clusters = kmeans(points, k);
|
|
131
|
+
// For each cluster, return the medoid — the real colour in the
|
|
132
|
+
// cluster closest to the centroid. Never invent a hex that wasn't
|
|
133
|
+
// on the page.
|
|
134
|
+
return clusters
|
|
135
|
+
.map((members) => {
|
|
136
|
+
const centre = weightedCentroid(members);
|
|
137
|
+
let best = members[0], bestD = Infinity;
|
|
138
|
+
for (const m of members) {
|
|
139
|
+
const d = dist2(m.lab, centre);
|
|
140
|
+
if (d < bestD) { bestD = d; best = m; }
|
|
141
|
+
}
|
|
142
|
+
const totalCount = members.reduce((s, m) => s + m.weight, 0);
|
|
143
|
+
return {
|
|
144
|
+
...best.original,
|
|
145
|
+
hex: best.hex,
|
|
146
|
+
count: totalCount,
|
|
147
|
+
clusterSize: members.length,
|
|
148
|
+
clusterMembers: members.map((m) => m.hex),
|
|
149
|
+
};
|
|
150
|
+
})
|
|
151
|
+
.sort((x, y) => (y.count || 0) - (x.count || 0));
|
|
152
|
+
}
|