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 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
  [![designlang on npm](https://pkgfolio.vercel.app/embed/pkg/designlang?v=2)](https://www.npmjs.com/package/designlang)
@@ -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.11.0",
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
+ }