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