designlang 12.11.0 → 12.13.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 +91 -0
- package/README.md +2 -2
- package/bin/design-extract.js +26 -1
- package/package.json +7 -1
- package/src/api.js +171 -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,96 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [12.13.0] — 2026-05-16
|
|
4
|
+
|
|
5
|
+
**Six new emitters + a public programmatic API.**
|
|
6
|
+
|
|
7
|
+
Every extraction now writes five additional first-class files, and downstream
|
|
8
|
+
tools can wire into the engine through a stable surface.
|
|
9
|
+
|
|
10
|
+
- **Tailwind v4 (`<host>-tailwind-v4.css`)** — CSS-first `@theme {}` block
|
|
11
|
+
with the full HSL colour scales, neutrals, type, spacing, radii, shadows
|
|
12
|
+
and motion. Parallel to the v3 `tailwind.config.js`, so v4 users get a
|
|
13
|
+
drop-in file.
|
|
14
|
+
- **TypeScript token types (`<host>-tokens.d.ts`)** — strict literal
|
|
15
|
+
unions for `ColorRole`, `ColorHex`, `FontFamilyToken`, `FontWeightToken`,
|
|
16
|
+
`SpacingToken`, `RadiusToken`, `DurationToken`, `EasingToken` plus a
|
|
17
|
+
`DesignTokens` roll-up interface. Component props typed against the real
|
|
18
|
+
extracted brand, not generic `string`.
|
|
19
|
+
- **Brand-aware CSS reset (`<host>-reset.css`)** — modern reset wired to
|
|
20
|
+
the extracted background / foreground / links / accent / selection.
|
|
21
|
+
Honours `prefers-reduced-motion`.
|
|
22
|
+
- **Gradients (`<host>-gradients.css` + `.json`)** — surfaces every
|
|
23
|
+
extracted gradient as `:root --grad-N` vars plus `.grad-N` background
|
|
24
|
+
and `.grad-text-N` background-clip utility classes.
|
|
25
|
+
- **`--palette <n>` flag** — compresses noisy 60–200-colour extractions
|
|
26
|
+
to N perceptually-distinct tokens via weighted LAB-space k-means.
|
|
27
|
+
Returns cluster medoids — never invents a hex that wasn't on the page.
|
|
28
|
+
Verified: stripe.com 29 → 8.
|
|
29
|
+
|
|
30
|
+
**Public programmatic API at `designlang/api`.**
|
|
31
|
+
|
|
32
|
+
A frozen surface other packages, scripts and AI agents can import without
|
|
33
|
+
reaching into internal modules.
|
|
34
|
+
|
|
35
|
+
```js
|
|
36
|
+
import { extract, render, renderAll, RENDERERS } from 'designlang/api';
|
|
37
|
+
|
|
38
|
+
const design = await extract('https://stripe.com', { palette: 8 });
|
|
39
|
+
const tailwind = render('tailwind-v4', design);
|
|
40
|
+
const files = renderAll(design); // { 'stripe-com-tokens.d.ts': '...', ... }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`package.json` now exposes:
|
|
44
|
+
- `designlang` — main entry
|
|
45
|
+
- `designlang/api` — the new public API (19 renderer ids)
|
|
46
|
+
- `designlang/mcp` — the existing MCP server entry
|
|
47
|
+
|
|
48
|
+
## [12.12.0] — 2026-05-15
|
|
49
|
+
|
|
50
|
+
**Website launch — designlang.app, fully rebuilt.**
|
|
51
|
+
|
|
52
|
+
The marketing/demo site at [designlang.app](https://designlang.app) has been
|
|
53
|
+
rewritten from the ground up. Highlights:
|
|
54
|
+
|
|
55
|
+
- **Hero with WebGL Grainient background** (red/black, ogl-powered, GPU-paused
|
|
56
|
+
when offscreen) and a terminal output card showing real CLI execution.
|
|
57
|
+
- **Live demo showcase** (`See it work.`) — paste any URL into a glass
|
|
58
|
+
stage, suggestion chips for stripe.com / linear.app / vercel.com /
|
|
59
|
+
notion.so / figma.com, click-to-copy `npx designlang stripe.com` in the
|
|
60
|
+
hero.
|
|
61
|
+
- **Real gallery** — eight pre-rendered brand books for stripe.com,
|
|
62
|
+
linear.app, vercel.com, notion.so, figma.com, apple.com, arc.net and
|
|
63
|
+
spotify.com. Each card is the actual extraction the CLI produced; the
|
|
64
|
+
cover gradient uses the extracted primary + accent tokens.
|
|
65
|
+
- **Floating glass nav** with a custom logo, segmented pill links, real
|
|
66
|
+
GitHub star count fetched from the API (server-rendered, 30 min revalidate),
|
|
67
|
+
and an `npm i designlang` CTA matching the hero gradient.
|
|
68
|
+
- **Auto-scrolling reddit testimonial marquee** (3 cols, motion/react)
|
|
69
|
+
with the real comments from the r/ClaudeAI launch thread.
|
|
70
|
+
- **Polished footer** with brand block, 4 link columns, decorative giant
|
|
71
|
+
outlined wordmark, version + author + license strip.
|
|
72
|
+
- **Aggressive SEO** — keyword-dense title and description, ~75-keyword
|
|
73
|
+
list; JSON-LD graph with `SoftwareApplication`, `Organization`,
|
|
74
|
+
`WebSite + SearchAction`, `BreadcrumbList`, `FAQPage`, `HowTo`;
|
|
75
|
+
`aggregateRating` from real GitHub stars; visible FAQ section + ~250-word
|
|
76
|
+
about block; sitemap includes the 8 gallery brand books; rewritten
|
|
77
|
+
`llms.txt` for AI-search citability (allows GPTBot, ClaudeBot,
|
|
78
|
+
PerplexityBot, Google-Extended, Applebot-Extended, etc.).
|
|
79
|
+
|
|
80
|
+
**Web extractor parity with the CLI.**
|
|
81
|
+
|
|
82
|
+
- `/api/extract`'s file output now includes the brand-book HTML
|
|
83
|
+
(`<host>.brand.html`) for every run.
|
|
84
|
+
- `formatBrandBook` is shared between the CLI and the website's
|
|
85
|
+
`lib/build-files.js`.
|
|
86
|
+
|
|
87
|
+
**Other**
|
|
88
|
+
|
|
89
|
+
- Fixed the README so the logo and "designlang in action" image render
|
|
90
|
+
on the npm package page (relative paths swapped for raw GitHub URLs).
|
|
91
|
+
- Removed all decorative status dots from the website per design feedback;
|
|
92
|
+
status indicators are now typed text chips.
|
|
93
|
+
|
|
3
94
|
## [12.11.0] — 2026-05-15
|
|
4
95
|
|
|
5
96
|
**`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,10 @@ 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';
|
|
25
29
|
import { formatCssVars } from '../src/formatters/css-vars.js';
|
|
26
30
|
import { formatPreview } from '../src/formatters/preview.js';
|
|
27
31
|
import { formatFigma } from '../src/formatters/figma.js';
|
|
@@ -108,6 +112,7 @@ program
|
|
|
108
112
|
.option('--no-design-md', 'skip writing the agent-native DESIGN.md (single-file, 8-section, YAML front matter)')
|
|
109
113
|
.option('--responsive-shots', 'capture full-page PNGs at 4 breakpoints × (light,dark)')
|
|
110
114
|
.option('--perf', 'measure Core Web Vitals + bundle profile (LCP/CLS/INP, JS/CSS/font/img bytes, third-party count)')
|
|
115
|
+
.option('--palette <n>', 'compress the extracted palette to N perceptually distinct colours via LAB-space k-means (default: emit every unique colour)', parseInt)
|
|
111
116
|
.option('--json', 'output raw JSON to stdout (for CI/CD)')
|
|
112
117
|
.option('--json-pretty', 'output formatted JSON to stdout')
|
|
113
118
|
.option('--no-history', 'skip saving to history')
|
|
@@ -307,6 +312,21 @@ program
|
|
|
307
312
|
// Drop the internal raw stash before JSON/output serialization.
|
|
308
313
|
delete design._raw;
|
|
309
314
|
|
|
315
|
+
// Optional palette compression — perceptual LAB k-means down to N colours.
|
|
316
|
+
const paletteTarget = parseInt(opts.palette, 10);
|
|
317
|
+
if (paletteTarget > 0 && Array.isArray(design.colors?.all)) {
|
|
318
|
+
try {
|
|
319
|
+
const { compressPalette } = await import('../src/utils/palette-compress.js');
|
|
320
|
+
const before = design.colors.all.length;
|
|
321
|
+
const compressed = compressPalette(design.colors.all, paletteTarget);
|
|
322
|
+
design.colors.all = compressed;
|
|
323
|
+
design.colors._compressed = { from: before, to: compressed.length, target: paletteTarget };
|
|
324
|
+
spinner.text = `Compressed palette: ${before} → ${compressed.length} colours`;
|
|
325
|
+
} catch (e) {
|
|
326
|
+
console.warn(`(palette compress skipped: ${e.message})`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
310
330
|
// JSON mode: output and exit
|
|
311
331
|
if (jsonMode) {
|
|
312
332
|
const output = opts.jsonPretty ? JSON.stringify(design, null, 2) : JSON.stringify(design);
|
|
@@ -320,7 +340,12 @@ program
|
|
|
320
340
|
const files = [
|
|
321
341
|
{ name: `${prefix}-design-language.md`, content: formatMarkdown(design), label: 'Markdown (AI-optimized)' },
|
|
322
342
|
{ 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' },
|
|
343
|
+
{ name: `${prefix}-tailwind.config.js`, content: formatTailwind(design), label: 'Tailwind Config (v3)' },
|
|
344
|
+
{ name: `${prefix}-tailwind-v4.css`, content: formatTailwindV4(design), label: 'Tailwind v4 @theme (CSS-first)' },
|
|
345
|
+
{ name: `${prefix}-tokens.d.ts`, content: formatTsDefs(design), label: 'TypeScript token types' },
|
|
346
|
+
{ name: `${prefix}-reset.css`, content: formatCssReset(design), label: 'Brand-aware CSS reset' },
|
|
347
|
+
{ name: `${prefix}-gradients.css`, content: formatGradientsCss(design), label: 'Extracted gradients (utility classes)' },
|
|
348
|
+
{ name: `${prefix}-gradients.json`, content: formatGradientsJson(design), label: 'Extracted gradients (structured)' },
|
|
324
349
|
{ name: `${prefix}-variables.css`, content: formatCssVars(design), label: 'CSS Variables' },
|
|
325
350
|
{ name: `${prefix}-preview.html`, content: formatPreview(design), label: 'Visual Preview' },
|
|
326
351
|
{ name: `${prefix}-figma-variables.json`, content: formatFigma(design), label: 'Figma Variables' },
|
package/package.json
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "designlang",
|
|
3
|
-
"version": "12.
|
|
3
|
+
"version": "12.13.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,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
|
+
}
|