designlang 12.7.1 → 12.9.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.
@@ -8,8 +8,8 @@
8
8
  {
9
9
  "name": "designlang",
10
10
  "source": "./",
11
- "description": "Seven slash commands wrapping the designlang CLI: /extract (full design language \u2192 DTCG, Tailwind, Figma), /grade (shareable HTML report card + SVG badge), /battle (head-to-head graded comparison), /remix (restyle in 6 vocabularies \u2014 brutalist, swiss, art-deco, cyberpunk, soft-ui, editorial), /pack (one downloadable design-system bundle), /theme-swap (OKLCH-correct recolour around a new brand primary), /brand (full editorial brand-guidelines book \u2014 13 chapters, hand-off-ready).",
12
- "version": "12.7.1",
11
+ "description": "Eight slash commands wrapping the designlang CLI: /extract (full design language \u2192 DTCG, Tailwind, Figma), /grade (shareable HTML report card + SVG badge), /battle (head-to-head graded comparison), /remix (restyle in 6 vocabularies \u2014 brutalist, swiss, art-deco, cyberpunk, soft-ui, editorial), /pack (one downloadable design-system bundle), /theme-swap (OKLCH-correct recolour around a new brand primary), /brand (full editorial brand-guidelines book \u2014 13 chapters, hand-off-ready), /pair (fuse two designs across configurable axes \u2014 colours from one site, typography from another).",
12
+ "version": "12.9.0",
13
13
  "author": {
14
14
  "name": "Manavarya Singh"
15
15
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "designlang",
3
- "description": "Extract any website's design language and ship it. Seven slash commands \u2014 /extract, /grade, /battle, /remix, /pack, /theme-swap, /brand \u2014 wrap the designlang CLI to pull DTCG tokens, Tailwind/shadcn/Figma vars, motion + voice, generate shareable graded report cards, head-to-head battle pages, six-vocabulary remixes, downloadable design-system bundles, OKLCH-correct theme recolouring, and full editorial brand-guidelines books.",
4
- "version": "12.7.1",
3
+ "description": "Extract any website's design language and ship it. Eight slash commands \u2014 /extract, /grade, /battle, /remix, /pack, /theme-swap, /brand, /pair \u2014 wrap the designlang CLI to pull DTCG tokens, Tailwind/shadcn/Figma vars, motion + voice, generate shareable graded report cards, head-to-head battle pages, six-vocabulary remixes, downloadable design-system bundles, OKLCH-correct theme recolouring, full editorial brand-guidelines books, and design crossovers between two sites.",
4
+ "version": "12.9.0",
5
5
  "author": {
6
6
  "name": "Manavarya Singh",
7
7
  "url": "https://github.com/Manavarya09"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,120 @@
1
1
  # Changelog
2
2
 
3
+ ## [12.9.0] — 2026-05-11
4
+
5
+ **Extraction quality pass — the core MVP, fixed.**
6
+
7
+ Eight features rode on top of the same extractor. This release fixes
8
+ four real defects in that extractor — visible across grade, battle,
9
+ remix, pack, theme-swap, brand, and pair without anyone having to
10
+ re-run the downstream code.
11
+
12
+ ### Fixed
13
+
14
+ - **Cluster representative bug** in \`clusterColors()\`. Before: the first-
15
+ encountered colour seeded each cluster, so a sparsely-used pale shade
16
+ could become the canonical hex for a cluster that mostly held a vivid
17
+ brand colour. The brand-book primary slot was reading lavender for
18
+ Stripe instead of \`#533afd\`. Fixed: representative is now the
19
+ most-counted member of the cluster.
20
+ - **Spacing base detection** missed common production scales. Before:
21
+ only \`[2, 4, 6, 8]\` were tried as base candidates, so Bootstrap-style
22
+ base-5 sites and base-7/10/12 sites returned \`base: null\`. Fixed:
23
+ expanded to \`[2, 4, 5, 6, 7, 8, 10, 12, 16]\` with a small bonus for
24
+ 4 and 8 to keep results stable for the production-default sites.
25
+ - **Typography noise**. Before: generic CSS stacks (\`sans-serif\`,
26
+ \`monospace\`, \`system-ui\`, \`inherit\`), OS UI fonts (\`-apple-system\`),
27
+ and icon fonts (Material Icons, Font Awesome, Lucide, Tabler, etc.)
28
+ polluted the \`families\` list, making the brand book mistakenly
29
+ document an icon font as the brand's body family. Fixed: explicit
30
+ generic + icon-family filter at the source.
31
+
32
+ ### Added
33
+
34
+ - **\`primary.confidence\`** (0–1) on \`design.colors.primary\`. Computed
35
+ from the score gap between rank 1 and rank 2 brand candidates — a
36
+ runaway leader scores 1.0; a near-tie scores 0.3. Downstream
37
+ consumers (brand book, grade, theme-swap) can surface uncertainty
38
+ warnings on low-confidence extractions.
39
+ - **\`asList(v)\`** helper exported from \`src/utils.js\`. Coerces
40
+ anything-shaped input (array / object / comma-string / scalar) into
41
+ a clean string array. Consolidates the per-formatter ad-hoc
42
+ defenses (brand-book, pair, pack all had their own copies).
43
+
44
+ ### Why
45
+
46
+ Verified live on \`stripe.com\`:
47
+
48
+ - Pre-v12.9: primary slot showed a lavender shade, multiple icon-font
49
+ entries in families, spacing base often \`null\`.
50
+ - v12.9: primary \`#533afd\` (count 899, confidence 0.59), \`families:
51
+ ['sohne-var']\`, spacing base detected correctly.
52
+
53
+ No new dependencies, no schema breaks, no public-API changes. 396/396
54
+ tests pass (6 new — base-5 + base-6 detectScale, cluster representative
55
+ correctness, generic-family filter, icon-family filter, asList shape
56
+ coercion).
57
+
58
+ ## [12.8.0] — 2026-05-10
59
+
60
+ **Pair — fuse two extracted designs into a single hybrid identity.**
61
+
62
+ \`designlang pair <urlA> <urlB>\` extracts both sites in parallel, then
63
+ mixes their design systems along seven configurable axes (colours,
64
+ typography, spacing, shape, motion, voice, components). Default split is
65
+ "visuals from A, voice + type + components from B" — i.e. the most
66
+ distinctive crossover. Override any axis with \`--<axis>-from a|b\`.
67
+
68
+ \`\`\`bash
69
+ npx designlang pair stripe.com linear.app
70
+ \`\`\`
71
+
72
+ \`\`\`
73
+ stripe.com × linear.app
74
+ A Colours · stripe.com
75
+ B Typography · linear.app
76
+ A Spacing · stripe.com
77
+ A Shape · stripe.com
78
+ A Motion · stripe.com
79
+ B Voice · linear.app
80
+ B Components · linear.app
81
+
82
+ ✓ stripe-com-x-linear-app.pair.html
83
+ ✓ stripe-com-x-linear-app.pair.md
84
+ ✓ stripe-com-x-linear-app.pair.json
85
+ \`\`\`
86
+
87
+ ### Added
88
+
89
+ - New CLI command: \`designlang pair <urlA> <urlB>\` with \`--colors-from\`,
90
+ \`--typography-from\`, \`--spacing-from\`, \`--shape-from\`, \`--motion-from\`,
91
+ \`--voice-from\`, \`--components-from\`, plus \`--brand\` to also emit a
92
+ full brand-guidelines book of the fused identity.
93
+ - New module \`src/fuse.js\` — \`fuseDesigns(a, b, opts)\` deep-clones both
94
+ inputs, picks each axis from the requested source, synthesises a
95
+ pair-specific meta URL (\`pair://<a>-x-<b>\`), and strips score /
96
+ cssHealth (those belong to the source extractions, not the fusion).
97
+ - New formatter \`src/formatters/pair.js\` — editorial pair card with a
98
+ three-card crossover (A · B · Fused), per-axis source matrix table,
99
+ and a fused specimen using the *real headline* from whichever site
100
+ contributed the voice axis.
101
+ - Plus \`formatPairMarkdown\` for diff-friendly summaries.
102
+ - 12 new tests covering default split, per-axis overrides, score-stripping,
103
+ meta synthesis, immutability of source designs, HTML rendering, voice
104
+ carry-through to the specimen, XSS escaping, and missing-input errors.
105
+
106
+ ### Plugin
107
+
108
+ \`/pair\` is the 8th slash command in the Claude Code plugin
109
+ (\`/extract\`, \`/grade\`, \`/battle\`, \`/remix\`, \`/pack\`, \`/theme-swap\`,
110
+ \`/brand\`, \`/pair\`). Plugin manifests bumped to 12.8.0.
111
+
112
+ ### Why
113
+
114
+ \`battle\` answers "which is better"; \`pair\` answers "what would the
115
+ intersection look like". Same parallel extraction, opposite operation.
116
+ Pure logic, no LLM, no new dependencies.
117
+
3
118
  ## [12.7.1] — 2026-05-09
4
119
 
5
120
  **Brand book — visual polish pass.**
package/README.md CHANGED
@@ -26,6 +26,7 @@ It also goes where extractors don't: **layout patterns**, **responsive behavior
26
26
 
27
27
  ```bash
28
28
  npx designlang https://stripe.com # extract everything
29
+ npx designlang pair stripe.com linear.app # fuse two designs (visuals A × voice B) ← v12.8
29
30
  npx designlang brand stripe.com # full brand-guidelines book (13 chapters) ← v12.7
30
31
  npx designlang theme-swap stripe.com --primary "#ff4800" # recolour around your brand ← v12.6
31
32
  npx designlang pack stripe.com # one polished design-system directory ← v12.4
@@ -135,7 +136,8 @@ designlang mcp # stdio MCP server for Cursor / Clau
135
136
  | Remix (v12.3) | `designlang remix <url> --as <vocab>` | Restyle the audited page in another vocabulary (brutalist / swiss / art-deco / cyberpunk / soft-ui / editorial). `--all` emits all 6 |
136
137
  | Pack (v12.4) | `designlang pack <url>` | Bundle every output (tokens / components / Storybook / starter / prompts) into one polished design-system directory |
137
138
  | Theme-swap (v12.6) | `designlang theme-swap <url> --primary <hex>` | Recolour the extracted design around a new brand primary. OKLCH hue rotation, neutrals preserved, type/spacing/motion untouched |
138
- | Brand book (NEW v12.7) | `designlang brand <url>` | Full editorial brand-guidelines document (13 chapters: cover, about, logo, colour, type, spacing, shape, iconography, motion, components, voice, a11y, tokens, how-to-use). Print-ready, dark-mode toggle, hand-off-ready |
139
+ | Brand book (v12.7) | `designlang brand <url>` | Full editorial brand-guidelines document (13 chapters: cover, about, logo, colour, type, spacing, shape, iconography, motion, components, voice, a11y, tokens, how-to-use). Print-ready, dark-mode toggle, hand-off-ready |
140
+ | Pair (NEW v12.8) | `designlang pair <urlA> <urlB>` | Fuse two designs across 7 axes (colours/type/spacing/shape/motion/voice/components). Defaults to "visuals from A, voice + type from B". `--brand` also emits a brand book of the fused identity |
139
141
  | Watch | `designlang watch <url>` | Monitor for design changes on interval |
140
142
  | Diff | `designlang diff <A> <B>` | Compare two sites (MD + HTML) |
141
143
  | Multi-brand | `designlang brands <urls...>` | N-site comparison matrix |
@@ -194,6 +196,7 @@ Commands:
194
196
  pack <url> Bundle every output into one design-system directory (--with-clone, --open)
195
197
  theme-swap <url> --primary <hex> Recolour around a new brand primary (--from, --format html|md|json|tokens|all, --open)
196
198
  brand <url> Generate a full editorial brand-guidelines book (--format html|md|json|all, --open)
199
+ pair <urlA> <urlB> Fuse two designs across 7 axes (--colors-from, --typography-from, --spacing-from, --shape-from, --motion-from, --voice-from, --components-from, --brand)
197
200
  watch <url> Monitor for design changes on interval
198
201
  diff <urlA> <urlB> Compare two sites' design languages
199
202
  brands <urls...> Multi-brand comparison matrix
@@ -53,6 +53,8 @@ import { buildPack } from '../src/pack.js';
53
53
  import { recolorDesign } from '../src/recolor.js';
54
54
  import { formatThemeSwap, formatThemeSwapMarkdown } from '../src/formatters/theme-swap.js';
55
55
  import { formatBrandBook, formatBrandBookMarkdown } from '../src/formatters/brand-book.js';
56
+ import { fuseDesigns, AXES } from '../src/fuse.js';
57
+ import { formatPair, formatPairMarkdown } from '../src/formatters/pair.js';
56
58
  import { nameFromUrl } from '../src/utils.js';
57
59
 
58
60
  function validateUrl(url) {
@@ -1406,6 +1408,118 @@ program
1406
1408
  }
1407
1409
  });
1408
1410
 
1411
+ // ── Pair command — fuse two designs across configurable axes
1412
+ program
1413
+ .command('pair <urlA> <urlB>')
1414
+ .description('Fuse two extracted designs across axes (colours/typography/spacing/shape/motion/voice/components)')
1415
+ .option('-o, --out <dir>', 'output directory', './design-extract-output')
1416
+ .option('-n, --name <name>', 'output file prefix (default: <hostA>-x-<hostB>)')
1417
+ .option('--colors-from <a|b>', 'pull colours from A or B (default: a)')
1418
+ .option('--typography-from <a|b>', 'pull typography from A or B (default: b)')
1419
+ .option('--spacing-from <a|b>', 'pull spacing from A or B (default: a)')
1420
+ .option('--shape-from <a|b>', 'pull shape (radii + shadows) from A or B (default: a)')
1421
+ .option('--motion-from <a|b>', 'pull motion from A or B (default: a)')
1422
+ .option('--voice-from <a|b>', 'pull voice from A or B (default: b)')
1423
+ .option('--components-from <a|b>', 'pull component anatomy from A or B (default: b)')
1424
+ .option('--brand', 'also emit a full brand-guidelines book of the fused identity')
1425
+ .option('--format <fmt>', 'output format: html, md, json, all', 'all')
1426
+ .option('--open', 'open the HTML pair card in the default browser')
1427
+ .action(async (urlA, urlB, opts) => {
1428
+ if (!urlA.startsWith('http')) urlA = `https://${urlA}`;
1429
+ if (!urlB.startsWith('http')) urlB = `https://${urlB}`;
1430
+ validateUrl(urlA);
1431
+ validateUrl(urlB);
1432
+
1433
+ const spinner = ora(`Extracting ${urlA} and ${urlB} in parallel...`).start();
1434
+ try {
1435
+ const [designA, designB] = await Promise.all([
1436
+ extractDesignLanguage(urlA),
1437
+ extractDesignLanguage(urlB),
1438
+ ]);
1439
+
1440
+ spinner.text = 'Fusing...';
1441
+ const { design: fused, summary } = fuseDesigns(designA, designB, {
1442
+ colorsFrom: opts.colorsFrom,
1443
+ typographyFrom: opts.typographyFrom,
1444
+ spacingFrom: opts.spacingFrom,
1445
+ shapeFrom: opts.shapeFrom,
1446
+ motionFrom: opts.motionFrom,
1447
+ voiceFrom: opts.voiceFrom,
1448
+ componentsFrom: opts.componentsFrom,
1449
+ });
1450
+
1451
+ const outDir = resolve(opts.out);
1452
+ mkdirSync(outDir, { recursive: true });
1453
+ const prefix = opts.name || `${nameFromUrl(urlA)}-x-${nameFromUrl(urlB)}.pair`;
1454
+ const written = [];
1455
+
1456
+ if (opts.format === 'all' || opts.format === 'html') {
1457
+ const html = formatPair(designA, designB, fused, summary, { version: PKG_VERSION });
1458
+ const p = join(outDir, `${prefix}.html`);
1459
+ writeFileSync(p, html);
1460
+ written.push(p);
1461
+ }
1462
+ if (opts.format === 'all' || opts.format === 'md') {
1463
+ const md = formatPairMarkdown(designA, designB, fused, summary);
1464
+ const p = join(outDir, `${prefix}.md`);
1465
+ writeFileSync(p, md);
1466
+ written.push(p);
1467
+ }
1468
+ if (opts.format === 'all' || opts.format === 'json') {
1469
+ const p = join(outDir, `${prefix}.json`);
1470
+ writeFileSync(p, JSON.stringify({
1471
+ a: { url: designA.meta?.url, host: summary.a.host },
1472
+ b: { url: designB.meta?.url, host: summary.b.host },
1473
+ axes: summary.axes,
1474
+ fused: {
1475
+ primary: fused.colors?.primary?.hex || null,
1476
+ family: (fused.typography?.families || [])[0],
1477
+ tone: fused.voice?.tone,
1478
+ },
1479
+ timestamp: new Date().toISOString(),
1480
+ }, null, 2));
1481
+ written.push(p);
1482
+ }
1483
+ if (opts.brand) {
1484
+ const html = formatBrandBook(fused, { version: PKG_VERSION });
1485
+ const p = join(outDir, `${prefix}.brand.html`);
1486
+ writeFileSync(p, html);
1487
+ written.push(p);
1488
+ }
1489
+
1490
+ spinner.stop();
1491
+ console.log('');
1492
+ console.log(` ${chalk.bold(`${summary.a.host} × ${summary.b.host}`)}`);
1493
+ console.log('');
1494
+ const axisLabels = {
1495
+ colors: 'Colours', typography: 'Typography', spacing: 'Spacing',
1496
+ shape: 'Shape', motion: 'Motion', voice: 'Voice', components: 'Components',
1497
+ };
1498
+ for (const axis of Object.keys(axisLabels)) {
1499
+ const src = summary.axes[axis];
1500
+ const fromHost = src === 'a' ? summary.a.host : summary.b.host;
1501
+ const tag = src === 'a' ? chalk.cyan('A') : chalk.magenta('B');
1502
+ console.log(` ${tag} ${axisLabels[axis].padEnd(12)} ${chalk.gray('·')} ${chalk.gray(fromHost)}`);
1503
+ }
1504
+ console.log('');
1505
+ for (const f of written) console.log(` ${chalk.green('✓')} ${chalk.gray(f)}`);
1506
+ console.log('');
1507
+
1508
+ if (opts.open) {
1509
+ const htmlPath = written.find(p => p.endsWith('.html'));
1510
+ if (htmlPath) {
1511
+ const { spawn } = await import('child_process');
1512
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1513
+ spawn(cmd, [htmlPath], { detached: true, stdio: 'ignore' }).unref();
1514
+ }
1515
+ }
1516
+ } catch (err) {
1517
+ spinner.fail('Pair failed');
1518
+ console.error(chalk.red(`\n ${err.message}\n`));
1519
+ process.exit(1);
1520
+ }
1521
+ });
1522
+
1409
1523
  // ── Apply command ──────────────────────────────────────────
1410
1524
  program
1411
1525
  .command('apply <url>')
@@ -0,0 +1,68 @@
1
+ ---
2
+ description: Fuse two extracted designs into a single hybrid identity. Pick which axis (colour, typography, spacing, shape, motion, voice, components) comes from which site. Defaults to "visual A × voice B" — same colours/spacing as the first URL, type/voice/components from the second.
3
+ argument-hint: <urlA> <urlB> [--colors-from a|b] [--typography-from a|b] [--brand]
4
+ ---
5
+
6
+ Fuse the design DNA of two sites and emit an editorial pair card showing the crossover.
7
+
8
+ ```bash
9
+ npx designlang pair $ARGUMENTS
10
+ ```
11
+
12
+ If `$ARGUMENTS` doesn't contain at least two URLs, ask the user for the second one.
13
+
14
+ Both sites are extracted in parallel (~30s total). Outputs land in `./design-extract-output/`:
15
+
16
+ - `*-x-*.pair.html` — editorial pair card with cover, axis matrix, fused specimen
17
+ - `*-x-*.pair.md` — markdown summary (axis source table)
18
+ - `*-x-*.pair.json` — structured deltas (which axis came from where)
19
+ - `*-x-*.pair.brand.html` — full brand-guidelines book of the fused identity (only with `--brand`)
20
+
21
+ After the run completes:
22
+
23
+ 1. Read the markdown to summarise: which axis came from A, which from B
24
+ 2. Highlight the most distinctive crossover (the fused specimen quotes a real headline from whichever site contributed the voice)
25
+ 3. Offer to open the HTML pair card
26
+
27
+ ## Default axis split
28
+
29
+ | Axis | Default source | Why |
30
+ |---|---|---|
31
+ | Colour | A | Brand identity tends to lead with colour |
32
+ | Spacing | A | Spacing co-varies with the visual layout |
33
+ | Shape | A | Radii + shadows belong with the visual surface |
34
+ | Motion | A | Motion is part of the visual feel |
35
+ | **Typography** | **B** | The crossover moment — different voice |
36
+ | **Voice** | **B** | Tone, headings, CTA verbs from B |
37
+ | **Components** | **B** | Anatomy from B's library |
38
+
39
+ So the default is "Site A's visuals + Site B's words". Override any axis with `--<axis>-from a|b`.
40
+
41
+ ## Useful flags
42
+
43
+ | Flag | Effect |
44
+ |---|---|
45
+ | `--colors-from <a\|b>` | Force colour source |
46
+ | `--typography-from <a\|b>` | Force typography source |
47
+ | `--spacing-from <a\|b>` | Force spacing source |
48
+ | `--shape-from <a\|b>` | Force radii + shadows source |
49
+ | `--motion-from <a\|b>` | Force motion source |
50
+ | `--voice-from <a\|b>` | Force voice / tone / heading source |
51
+ | `--components-from <a\|b>` | Force component anatomy source |
52
+ | `--brand` | Also emit a full brand-guidelines book of the fused identity |
53
+ | `--format html\|md\|json\|all` | Pick output formats |
54
+ | `--open` | Open the HTML pair card |
55
+
56
+ ## Example experiments
57
+
58
+ ```
59
+ npx designlang pair stripe.com linear.app # default split
60
+ npx designlang pair stripe.com linear.app --type-from a # Stripe everything except components/voice from Linear
61
+ npx designlang pair vercel.com apple.com --brand # fused identity as a hand-off brand book
62
+ ```
63
+
64
+ ## Pairs nicely with
65
+
66
+ - `/grade <url>` — grade either source side individually before pairing
67
+ - `/brand <url>` — generate a brand book of one source for comparison
68
+ - `/battle <urlA> <urlB>` — head-to-head graded comparison (the inverse of `pair`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "designlang",
3
- "version": "12.7.1",
3
+ "version": "12.9.0",
4
4
  "description": "Extract the complete design language from any website and ship it \u2014 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": {
@@ -106,8 +106,29 @@ export function extractColors(computedStyles) {
106
106
  return pct < 0.05 && c.members.some(m => m.contexts.has('background'));
107
107
  }) || ranked.find(c => c !== primary && c !== secondary) || null;
108
108
 
109
+ // Primary detection confidence — useful signal for downstream consumers
110
+ // that may want to warn the user when extraction is uncertain (e.g. a
111
+ // monochrome site where there's no clear brand colour). We compute it
112
+ // from the score gap between rank 1 and rank 2: a runaway leader is
113
+ // confident, a near-tie is not.
114
+ let primaryConfidence = null;
115
+ if (primary) {
116
+ const top = brandScore(primary);
117
+ const next = ranked[1] ? brandScore(ranked[1]) : 0;
118
+ if (top <= 0) {
119
+ primaryConfidence = 0;
120
+ } else if (next <= 0) {
121
+ primaryConfidence = primary.interactiveBg > 0 ? 1 : 0.6;
122
+ } else {
123
+ const gap = (top - next) / top;
124
+ // Anchor: gap >= 0.5 → 1.0 (runaway). gap 0 → 0.3 (near-tie).
125
+ primaryConfidence = Math.max(0.3, Math.min(1, 0.3 + gap * 1.4));
126
+ }
127
+ primaryConfidence = Math.round(primaryConfidence * 100) / 100;
128
+ }
129
+
109
130
  return {
110
- primary: primary ? { hex: primary.hex, rgb: primary.representative, hsl: rgbToHsl(primary.representative), count: primary.count } : null,
131
+ primary: primary ? { hex: primary.hex, rgb: primary.representative, hsl: rgbToHsl(primary.representative), count: primary.count, confidence: primaryConfidence } : null,
111
132
  secondary: secondary ? { hex: secondary.hex, rgb: secondary.representative, hsl: rgbToHsl(secondary.representative), count: secondary.count } : null,
112
133
  accent: accent ? { hex: accent.hex, rgb: accent.representative, hsl: rgbToHsl(accent.representative), count: accent.count } : null,
113
134
  neutrals: neutrals.map(c => ({ hex: c.hex, rgb: c.representative, hsl: rgbToHsl(c.representative), count: c.count })),
@@ -1,14 +1,49 @@
1
1
  import { parseCSSValue } from '../utils.js';
2
2
 
3
+ // Filter set for fonts that aren't part of the site's brand typography:
4
+ // generic CSS fallbacks, OS UI stacks, icon fonts, and inherited "no
5
+ // declaration" values. These slipped into families[] before and polluted
6
+ // the brand book + grade summary.
7
+ const GENERIC_FAMILIES = new Set([
8
+ 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy',
9
+ 'system-ui', 'ui-serif', 'ui-sans-serif', 'ui-monospace', 'ui-rounded',
10
+ 'inherit', 'initial', 'unset', 'revert', 'auto', '-apple-system',
11
+ 'blinkmacsystemfont', 'apple-system',
12
+ ]);
13
+ const ICON_FAMILY_RE = /^(material[-\s]?icons|font\s?awesome|fa-?solid|fa-?regular|fa-?brands|ionicons|glyphicons|bootstrap-icons|remixicon|feather|tabler-icons|lucide)/i;
14
+
15
+ function normaliseFamily(raw) {
16
+ if (!raw) return null;
17
+ // Strip quotes + take the first stack member (sites declare e.g.
18
+ // `"Inter", "Helvetica Neue", sans-serif` — only the first is the
19
+ // *intended* family).
20
+ const first = String(raw).replace(/["']/g, '').split(',')[0].trim();
21
+ if (!first) return null;
22
+ return first;
23
+ }
24
+
25
+ function isMeaningfulFamily(name) {
26
+ if (!name) return false;
27
+ const lower = name.toLowerCase();
28
+ if (GENERIC_FAMILIES.has(lower)) return false;
29
+ if (ICON_FAMILY_RE.test(name)) return false;
30
+ // Single-character or all-symbol names are extraction noise.
31
+ if (name.length < 2) return false;
32
+ if (!/[a-z]/i.test(name)) return false;
33
+ return true;
34
+ }
35
+
3
36
  export function extractTypography(computedStyles) {
4
37
  const familyCount = new Map();
5
38
  const sizeEntries = [];
6
39
  const weightCount = new Map();
7
40
 
8
41
  for (const el of computedStyles) {
9
- // Font families
10
- const family = el.fontFamily?.replace(/["']/g, '').split(',')[0]?.trim();
11
- if (family) familyCount.set(family, (familyCount.get(family) || 0) + 1);
42
+ // Font families — normalised first-of-stack, with noise filtered out.
43
+ const family = normaliseFamily(el.fontFamily);
44
+ if (family && isMeaningfulFamily(family)) {
45
+ familyCount.set(family, (familyCount.get(family) || 0) + 1);
46
+ }
12
47
 
13
48
  // Font sizes
14
49
  const sizeVal = parseCSSValue(el.fontSize);
@@ -0,0 +1,331 @@
1
+ // designlang pair — editorial preview HTML for a fused design.
2
+ //
3
+ // Shows both source sites + the fused result side-by-side, with a clear
4
+ // matrix of which axis came from which source. Companion to the brand
5
+ // book, which the same fused design also feeds into.
6
+
7
+ const FONT_DISPLAY = 'Instrument Serif';
8
+ const FONT_BODY = 'Inter';
9
+ const FONT_MONO = 'JetBrains Mono';
10
+
11
+ function esc(s) {
12
+ return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
13
+ }
14
+
15
+ function host(url) {
16
+ try { return new URL(url).hostname; } catch { return String(url || ''); }
17
+ }
18
+
19
+ function familyName(f) {
20
+ if (!f) return '';
21
+ if (typeof f === 'string') return f;
22
+ return f.name || f.family || '';
23
+ }
24
+
25
+ function paletteStrip(design, n = 10) {
26
+ const all = (design?.colors?.all || []).slice(0, n);
27
+ if (!all.length) return '<span class="muted">—</span>';
28
+ return all.map(c => `<span class="chip" style="background:${esc(c?.hex || c)}" title="${esc(c?.hex || c)}"></span>`).join('');
29
+ }
30
+
31
+ function primaryHex(design) {
32
+ return design?.colors?.primary?.hex
33
+ || (design?.colors?.all || []).find(c => c?.hex)?.hex
34
+ || '#141414';
35
+ }
36
+
37
+ function topFamily(design) {
38
+ const f = (design?.typography?.families || [])[0];
39
+ return familyName(f) || 'system-ui';
40
+ }
41
+
42
+ function topHeading(design) {
43
+ const headings = (design?.voice?.sampleHeadings || []).filter(h => typeof h === 'string' && h.length > 4 && h.length < 120);
44
+ return headings[0] || 'Quick brown fox jumps over the lazy dog.';
45
+ }
46
+
47
+ export function formatPair(designA, designB, fused, summary, opts = {}) {
48
+ if (!designA || !designB || !fused) throw new Error('formatPair: all three designs are required');
49
+
50
+ const hostA = host(designA.meta?.url);
51
+ const hostB = host(designB.meta?.url);
52
+ const axes = summary?.axes || fused.fusedAxes || {};
53
+ const accent = primaryHex(fused);
54
+
55
+ const date = new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
56
+ const ogTitle = `${hostA} × ${hostB} · designlang pair`;
57
+ const ogDesc = `Fused design system — ${hostA} crossed with ${hostB} along ${Object.keys(axes).length} axes.`;
58
+
59
+ // Six axes drive the matrix. We render a 6-row table so the viewer
60
+ // can see at a glance which dimension came from which source.
61
+ const axisLabels = [
62
+ ['colors', 'Colour'],
63
+ ['typography', 'Typography'],
64
+ ['spacing', 'Spacing'],
65
+ ['shape', 'Shape'],
66
+ ['motion', 'Motion'],
67
+ ['voice', 'Voice'],
68
+ ['components', 'Components'],
69
+ ];
70
+
71
+ const sampleHead = topHeading(fused);
72
+
73
+ return `<!doctype html>
74
+ <html lang="en">
75
+ <head>
76
+ <meta charset="utf-8">
77
+ <meta name="viewport" content="width=device-width,initial-scale=1">
78
+ <title>${esc(ogTitle)}</title>
79
+ <meta name="description" content="${esc(ogDesc)}">
80
+ <meta property="og:title" content="${esc(ogTitle)}">
81
+ <meta property="og:description" content="${esc(ogDesc)}">
82
+ <meta property="og:type" content="article">
83
+ <meta name="twitter:card" content="summary_large_image">
84
+ <link rel="preconnect" href="https://fonts.googleapis.com">
85
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
86
+ <link href="https://fonts.googleapis.com/css2?family=${encodeURIComponent(FONT_DISPLAY)}&family=${encodeURIComponent(FONT_BODY)}:wght@400;500;600&family=${encodeURIComponent(FONT_MONO)}:wght@400;500&display=swap" rel="stylesheet">
87
+ <style>
88
+ :root {
89
+ --paper: #f6f3ec;
90
+ --paper-2: #efebe1;
91
+ --ink: #131313;
92
+ --ink-soft: #555048;
93
+ --ink-faint: #8a8579;
94
+ --rule: #e0dccf;
95
+ --accent: ${esc(accent)};
96
+ --display: '${FONT_DISPLAY}', 'Times New Roman', serif;
97
+ --body: '${FONT_BODY}', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
98
+ --mono: '${FONT_MONO}', ui-monospace, 'SF Mono', monospace;
99
+ }
100
+ [data-theme="dark"] {
101
+ --paper: #0d0c0a;
102
+ --paper-2: #15140f;
103
+ --ink: #ece8de;
104
+ --ink-soft: #9d978a;
105
+ --ink-faint: #5b574e;
106
+ --rule: #292621;
107
+ }
108
+ * { box-sizing: border-box; }
109
+ html, body { margin: 0; padding: 0; }
110
+ body { background: var(--paper); color: var(--ink); font-family: var(--body); font-size: 16px; line-height: 1.55; -webkit-font-smoothing: antialiased; transition: background .25s, color .25s; }
111
+ a { color: var(--ink); border-bottom: 1px solid var(--rule); padding-bottom: 1px; text-decoration: none; }
112
+ a:hover { border-color: var(--ink); }
113
+ code { font-family: var(--mono); font-size: .92em; }
114
+ .muted { color: var(--ink-soft); }
115
+
116
+ .wrap { max-width: 980px; margin: 0 auto; padding: 56px 40px 96px; }
117
+ @media (max-width: 640px) { .wrap { padding: 32px 22px 64px; } }
118
+
119
+ .topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 56px; font-size: 13px; }
120
+ .brand { font-family: var(--display); font-size: 22px; }
121
+ .brand a { border-bottom: 1px solid var(--rule); }
122
+ .topbar nav { display: flex; gap: 18px; align-items: center; color: var(--ink-soft); }
123
+ .theme-btn { background: transparent; border: 1px solid var(--rule); color: var(--ink-soft); font-size: 11px; padding: 6px 12px; border-radius: 999px; cursor: pointer; letter-spacing: .12em; text-transform: uppercase; font-family: var(--body); }
124
+ .theme-btn:hover { color: var(--ink); border-color: var(--ink); }
125
+
126
+ .kicker { text-transform: uppercase; letter-spacing: .18em; font-size: 11px; color: var(--ink-soft); font-family: var(--mono); margin: 0 0 14px; }
127
+ h1.title { font-family: var(--display); font-weight: 400; font-size: clamp(40px, 6vw, 76px); line-height: 1.02; margin: 0 0 36px; letter-spacing: -.01em; }
128
+ h1.title em { font-style: italic; color: var(--ink-soft); padding: 0 .12em; }
129
+
130
+ /* — Hero crossover — */
131
+ .crossover { display: grid; grid-template-columns: 1fr auto 1fr 1fr; gap: 18px; align-items: stretch; padding: 12px 0 56px; border-bottom: 1px solid var(--rule); }
132
+ @media (max-width: 760px) { .crossover { grid-template-columns: 1fr; } .crossover .arrow { display: none; } }
133
+ .source-card, .fused-card { padding: 22px 22px 18px; border-radius: 8px; background: var(--paper-2); box-shadow: inset 0 0 0 1px var(--rule); display: flex; flex-direction: column; gap: 14px; min-height: 220px; }
134
+ .fused-card { background: var(--accent); color: ${/* readable on accent */''}; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); position: relative; overflow: hidden; }
135
+ .source-card .lbl, .fused-card .lbl { font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; opacity: .82; }
136
+ .source-card .host, .fused-card .host { font-family: var(--display); font-size: 24px; line-height: 1.1; word-break: break-all; }
137
+ .source-card .chips, .fused-card .chips { display: flex; gap: 4px; flex-wrap: wrap; }
138
+ .chip { width: 18px; height: 18px; border-radius: 3px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); flex: 0 0 auto; }
139
+ .source-card .fam, .fused-card .fam { font-family: var(--mono); font-size: 11px; opacity: .82; }
140
+ .arrow { font-family: var(--display); font-style: italic; font-size: clamp(22px, 3vw, 36px); color: var(--ink-soft); align-self: center; padding: 0 8px; }
141
+ .fused-card .lbl, .fused-card .fam, .fused-card .chip { color: white; }
142
+ .fused-card .host { color: white; }
143
+
144
+ /* — Axis matrix — */
145
+ section { padding: 56px 0; border-bottom: 1px solid var(--rule); }
146
+ section:last-of-type { border-bottom: 0; }
147
+ section > h2 { font-family: var(--display); font-weight: 400; font-size: clamp(28px, 3.5vw, 40px); line-height: 1.04; margin: 0 0 8px; letter-spacing: -.005em; }
148
+ section > h2 + .lead { color: var(--ink-soft); margin: 0 0 28px; max-width: 60ch; }
149
+
150
+ .matrix { width: 100%; border-collapse: collapse; font-size: 14px; }
151
+ .matrix th, .matrix td { padding: 14px 12px; text-align: left; border-bottom: 1px solid var(--rule); vertical-align: middle; }
152
+ .matrix th { font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase; color: var(--ink-faint); border-bottom: 1px solid var(--ink); }
153
+ .matrix .axis-name { font-family: var(--display); font-size: 18px; }
154
+ .matrix .src { font-family: var(--mono); font-size: 11px; letter-spacing: .04em; color: var(--ink-soft); }
155
+ .matrix .src.from-a { color: var(--ink); }
156
+ .matrix .src.from-b { color: var(--ink); }
157
+ .matrix .pill { display: inline-block; padding: 3px 8px; border-radius: 999px; font-family: var(--mono); font-size: 10px; letter-spacing: .12em; text-transform: uppercase; border: 1px solid var(--rule); background: var(--paper-2); }
158
+ .matrix .pill.from-a { border-color: var(--ink-soft); }
159
+ .matrix .pill.from-b { border-color: var(--accent); color: var(--ink); background: color-mix(in srgb, var(--accent) 14%, var(--paper-2)); }
160
+
161
+ /* — Specimen of the fused design — */
162
+ .specimen { padding: 32px; background: var(--paper-2); border-radius: 8px; box-shadow: inset 0 0 0 1px var(--rule); }
163
+ .spec-quote { font-family: var(--display); font-weight: 400; font-size: clamp(28px, 4vw, 48px); line-height: 1.05; margin: 0 0 16px; }
164
+ .spec-meta { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; color: var(--ink-soft); display: flex; gap: 24px; flex-wrap: wrap; }
165
+
166
+ /* — Try the fused button — */
167
+ .mock-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 18px; padding: 18px 0; }
168
+ .mock-card { padding: 24px; border-radius: 10px; background: var(--paper-2); box-shadow: inset 0 0 0 1px var(--rule); display: flex; flex-direction: column; gap: 14px; align-items: flex-start; }
169
+ .mock-eyebrow { font-family: var(--mono); font-size: 10px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-faint); }
170
+ .mock-title { font-family: var(--display); font-size: 24px; line-height: 1.1; margin: 0; }
171
+ .mock-cta { font-family: var(--body); font-weight: 500; font-size: 14px; padding: 10px 18px; border-radius: 6px; background: var(--accent); color: white; border: 0; cursor: pointer; }
172
+
173
+ /* — Footer — */
174
+ footer { padding: 48px 0 0; font-size: 13px; color: var(--ink-soft); display: flex; justify-content: space-between; align-items: end; flex-wrap: wrap; gap: 16px; }
175
+ footer .sig { font-family: var(--display); font-size: 22px; color: var(--ink); }
176
+ footer .stamp { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .14em; }
177
+
178
+ @media print {
179
+ body { background: white; color: black; }
180
+ .topbar nav, .theme-btn { display: none; }
181
+ section, .crossover { page-break-inside: avoid; border-color: #ddd; }
182
+ }
183
+ </style>
184
+ </head>
185
+ <body>
186
+ <div class="wrap">
187
+ <header class="topbar">
188
+ <div class="brand"><a href="https://designlang.app">designlang</a></div>
189
+ <nav>
190
+ <span>Pair</span>
191
+ <button class="theme-btn" id="themeBtn" type="button">Theme</button>
192
+ </nav>
193
+ </header>
194
+
195
+ <p class="kicker">Design Pair · ${esc(date)}</p>
196
+ <h1 class="title">${esc(hostA)} <em>×</em> ${esc(hostB)}</h1>
197
+
198
+ <div class="crossover">
199
+ <div class="source-card">
200
+ <span class="lbl">A</span>
201
+ <div class="host">${esc(hostA)}</div>
202
+ <div class="chips">${paletteStrip(designA)}</div>
203
+ <div class="fam">${esc(topFamily(designA))}</div>
204
+ </div>
205
+ <div class="arrow">×</div>
206
+ <div class="source-card">
207
+ <span class="lbl">B</span>
208
+ <div class="host">${esc(hostB)}</div>
209
+ <div class="chips">${paletteStrip(designB)}</div>
210
+ <div class="fam">${esc(topFamily(designB))}</div>
211
+ </div>
212
+ <div class="fused-card">
213
+ <span class="lbl">Fused</span>
214
+ <div class="host">${esc(hostA)} × ${esc(hostB)}</div>
215
+ <div class="chips">${paletteStrip(fused)}</div>
216
+ <div class="fam">${esc(topFamily(fused))}</div>
217
+ </div>
218
+ </div>
219
+
220
+ <section>
221
+ <h2>Which axis came from where</h2>
222
+ <p class="lead">Each row is one dimension of the design system. The pill shows whether the fused design inherited it from <strong>A</strong> (${esc(hostA)}) or <strong>B</strong> (${esc(hostB)}).</p>
223
+ <table class="matrix">
224
+ <thead>
225
+ <tr>
226
+ <th>Axis</th>
227
+ <th>Source</th>
228
+ <th>Inherited</th>
229
+ </tr>
230
+ </thead>
231
+ <tbody>
232
+ ${axisLabels.map(([key, label]) => {
233
+ const src = axes[key] || 'a';
234
+ const sourceHost = src === 'a' ? hostA : hostB;
235
+ return `
236
+ <tr>
237
+ <td class="axis-name">${esc(label)}</td>
238
+ <td class="src">${esc(sourceHost)}</td>
239
+ <td><span class="pill from-${src}">From ${src.toUpperCase()}</span></td>
240
+ </tr>
241
+ `;
242
+ }).join('')}
243
+ </tbody>
244
+ </table>
245
+ </section>
246
+
247
+ <section>
248
+ <h2>The fused identity</h2>
249
+ <p class="lead">Specimen of the merged design — palette + radii from one source, type + voice from the other.</p>
250
+ <div class="specimen" style="font-family: ${esc(topFamily(fused))}, '${FONT_DISPLAY}', serif;">
251
+ <p class="spec-quote">${esc(sampleHead)}</p>
252
+ <div class="spec-meta">
253
+ <span>Primary · <code>${esc(primaryHex(fused).toUpperCase())}</code></span>
254
+ <span>Display · <code>${esc(topFamily(fused))}</code></span>
255
+ <span>Tone · <code>${esc(fused.voice?.tone || 'neutral')}</code></span>
256
+ </div>
257
+ </div>
258
+
259
+ <div class="mock-row">
260
+ <div class="mock-card">
261
+ <span class="mock-eyebrow">Primary action</span>
262
+ <h4 class="mock-title">Built from the fused tokens</h4>
263
+ <button type="button" class="mock-cta">Try this style</button>
264
+ </div>
265
+ <div class="mock-card">
266
+ <span class="mock-eyebrow">Run again</span>
267
+ <h4 class="mock-title">Different axes, different fusion</h4>
268
+ <code>npx designlang pair ${esc(hostA)} ${esc(hostB)} --type-from a</code>
269
+ </div>
270
+ </div>
271
+ </section>
272
+
273
+ <footer>
274
+ <div>
275
+ <div class="sig">designlang</div>
276
+ <div>Re-run: <code>npx designlang pair ${esc(hostA)} ${esc(hostB)}</code></div>
277
+ </div>
278
+ <div class="stamp">${esc(date)} · v${esc(opts.version || '')}</div>
279
+ </footer>
280
+ </div>
281
+
282
+ <script>
283
+ (function () {
284
+ var btn = document.getElementById('themeBtn');
285
+ var saved = null;
286
+ try { saved = localStorage.getItem('dl-theme'); } catch (e) {}
287
+ if (saved) document.documentElement.setAttribute('data-theme', saved);
288
+ btn && btn.addEventListener('click', function () {
289
+ var cur = document.documentElement.getAttribute('data-theme') === 'dark' ? '' : 'dark';
290
+ if (cur) document.documentElement.setAttribute('data-theme', cur);
291
+ else document.documentElement.removeAttribute('data-theme');
292
+ try { localStorage.setItem('dl-theme', cur); } catch (e) {}
293
+ });
294
+ })();
295
+ </script>
296
+ </body>
297
+ </html>`;
298
+ }
299
+
300
+ export function formatPairMarkdown(designA, designB, fused, summary) {
301
+ const hostA = host(designA.meta?.url);
302
+ const hostB = host(designB.meta?.url);
303
+ const axes = summary?.axes || fused.fusedAxes || {};
304
+ const lines = [
305
+ `# ${hostA} × ${hostB}`,
306
+ ``,
307
+ `_Design pair fused by designlang on ${new Date().toISOString().slice(0, 10)}._`,
308
+ ``,
309
+ `## Axes`,
310
+ ``,
311
+ `| Axis | Source |`,
312
+ `|---|---|`,
313
+ `| Colour | ${axes.colors === 'b' ? hostB : hostA} |`,
314
+ `| Typography | ${axes.typography === 'a' ? hostA : hostB} |`,
315
+ `| Spacing | ${axes.spacing === 'b' ? hostB : hostA} |`,
316
+ `| Shape | ${axes.shape === 'b' ? hostB : hostA} |`,
317
+ `| Motion | ${axes.motion === 'b' ? hostB : hostA} |`,
318
+ `| Voice | ${axes.voice === 'a' ? hostA : hostB} |`,
319
+ `| Components | ${axes.components === 'a' ? hostA : hostB} |`,
320
+ ``,
321
+ `## Fused identity`,
322
+ ``,
323
+ `- Primary: \`${primaryHex(fused)}\``,
324
+ `- Display: ${topFamily(fused)}`,
325
+ `- Tone: ${fused.voice?.tone || 'neutral'}`,
326
+ ``,
327
+ `---`,
328
+ `_Re-run: \`npx designlang pair ${hostA} ${hostB}\`_`,
329
+ ];
330
+ return lines.join('\n');
331
+ }
package/src/fuse.js ADDED
@@ -0,0 +1,154 @@
1
+ // designlang pair — fuse two extracted designs across configurable axes.
2
+ //
3
+ // Picks each dimension from one source or the other so designers can run
4
+ // experiments like "Stripe colours × Linear typography × Vercel motion".
5
+ // Input: two full design objects from extractDesignLanguage(), an axis
6
+ // map, and an output URL/title for branding the fused result.
7
+ //
8
+ // We deep-clone source objects before merging so callers' originals stay
9
+ // intact. The fused design is structurally identical to a normal
10
+ // extraction so every downstream emitter (DTCG, Tailwind, shadcn,
11
+ // Figma, brand-book, pack) works on it untouched.
12
+
13
+ const AXES = ['colors', 'typography', 'spacing', 'shape', 'motion', 'voice', 'components'];
14
+
15
+ const DEFAULT_AXES = {
16
+ colors: 'a',
17
+ spacing: 'a',
18
+ shape: 'a',
19
+ motion: 'a',
20
+ typography: 'b',
21
+ voice: 'b',
22
+ components: 'b',
23
+ };
24
+
25
+ function host(url) {
26
+ try { return new URL(url).hostname; } catch { return String(url || ''); }
27
+ }
28
+
29
+ function clone(x) {
30
+ return x == null ? x : JSON.parse(JSON.stringify(x));
31
+ }
32
+
33
+ // Normalise the user's --<axis>-from flags into a single { axis: 'a'|'b' }
34
+ // map. Anything they don't specify falls through to DEFAULT_AXES, which
35
+ // is calibrated for a clean "visual A × voice B" crossover.
36
+ function resolveAxes(opts = {}) {
37
+ const out = { ...DEFAULT_AXES };
38
+ for (const axis of AXES) {
39
+ const flag = opts[`${axis}From`];
40
+ if (flag === 'a' || flag === 'A') out[axis] = 'a';
41
+ else if (flag === 'b' || flag === 'B') out[axis] = 'b';
42
+ }
43
+ return out;
44
+ }
45
+
46
+ /**
47
+ * Fuse two extracted designs along configurable axes.
48
+ *
49
+ * @param {object} a — design from extractDesignLanguage(urlA)
50
+ * @param {object} b — design from extractDesignLanguage(urlB)
51
+ * @param {object} opts
52
+ * @param {'a'|'b'} [opts.colorsFrom='a']
53
+ * @param {'a'|'b'} [opts.typographyFrom='b']
54
+ * @param {'a'|'b'} [opts.spacingFrom='a']
55
+ * @param {'a'|'b'} [opts.shapeFrom='a']
56
+ * @param {'a'|'b'} [opts.motionFrom='a']
57
+ * @param {'a'|'b'} [opts.voiceFrom='b']
58
+ * @param {'a'|'b'} [opts.componentsFrom='b']
59
+ * @returns {object} { design, summary }
60
+ */
61
+ export function fuseDesigns(a, b, opts = {}) {
62
+ if (!a || !b) throw new Error('fuseDesigns: both designs are required');
63
+ const axes = resolveAxes(opts);
64
+ const pick = (axis, sliceA, sliceB) => clone(axes[axis] === 'a' ? sliceA : sliceB);
65
+ const src = (axis) => axes[axis] === 'a' ? a : b;
66
+
67
+ // Start from a deep clone of A so meta-fields (e.g. raw crawler output)
68
+ // have a sensible default. Downstream emitters lean heavily on .meta.url
69
+ // for filenames + titles, so we synthesise a pair-specific URL.
70
+ const fused = clone(a);
71
+
72
+ // Colours — every sub-field in one block (primary, secondary, accent,
73
+ // neutrals, backgrounds, text, gradients, all). Mixing primary from
74
+ // one site and neutrals from another tends to produce off-brand greys,
75
+ // so we keep the whole palette together.
76
+ fused.colors = pick('colors', a.colors, b.colors);
77
+
78
+ // Typography — same logic. Families, scale, weights, headings, body
79
+ // travel as one unit because the type system is tightly coupled.
80
+ fused.typography = pick('typography', a.typography, b.typography);
81
+ // Pull related signals along with the type pick so the brand book
82
+ // renders coherently (specimen lines lean on voice.sampleHeadings).
83
+ if (axes.voice === axes.typography) {
84
+ // already aligned; keep voice-from picked below
85
+ }
86
+
87
+ // Spacing.
88
+ fused.spacing = pick('spacing', a.spacing, b.spacing);
89
+ // Layout often co-varies with spacing — move it together.
90
+ fused.layout = pick('spacing', a.layout, b.layout);
91
+
92
+ // Shape — radii + shadows + borders.
93
+ fused.borders = pick('shape', a.borders, b.borders);
94
+ fused.shadows = pick('shape', a.shadows, b.shadows);
95
+
96
+ // Motion.
97
+ fused.motion = pick('motion', a.motion, b.motion);
98
+ fused.animations = pick('motion', a.animations, b.animations);
99
+
100
+ // Voice.
101
+ fused.voice = pick('voice', a.voice, b.voice);
102
+
103
+ // Components — anatomy, clusters, library detection.
104
+ fused.componentAnatomy = pick('components', a.componentAnatomy, b.componentAnatomy);
105
+ fused.componentClusters = pick('components', a.componentClusters, b.componentClusters);
106
+ fused.componentLibrary = pick('components', a.componentLibrary, b.componentLibrary);
107
+ fused.components = pick('components', a.components, b.components);
108
+
109
+ // Material language and imagery style track colour by default — they
110
+ // describe the *visual* feel.
111
+ fused.materialLanguage = pick('colors', a.materialLanguage, b.materialLanguage);
112
+ fused.imageryStyle = pick('colors', a.imageryStyle, b.imageryStyle);
113
+
114
+ // CSS variables tend to mirror colour + typography. We keep the
115
+ // variables from whichever source contributed colour, since colour
116
+ // tokens are the dominant variable family.
117
+ fused.variables = pick('colors', a.variables, b.variables);
118
+
119
+ // Score, accessibility, css-health are *measurements* of the source
120
+ // sites — they don't apply to the fused design. Strip them so
121
+ // downstream consumers don't surface stale numbers.
122
+ fused.score = null;
123
+ fused.accessibility = src('colors').accessibility ? clone(src('colors').accessibility) : null;
124
+ fused.cssHealth = null;
125
+
126
+ // Synthesise meta. The pair URL is a virtual identifier so emitters
127
+ // produce non-colliding filenames (e.g. "stripe-x-linear").
128
+ const hostA = host(a.meta?.url);
129
+ const hostB = host(b.meta?.url);
130
+ fused.meta = {
131
+ ...(a.meta || {}),
132
+ url: `pair://${hostA}-x-${hostB}`,
133
+ title: `${hostA} × ${hostB}`,
134
+ pairedFrom: { a: a.meta?.url || hostA, b: b.meta?.url || hostB },
135
+ timestamp: new Date().toISOString(),
136
+ elementCount: (a.meta?.elementCount || 0) + (b.meta?.elementCount || 0),
137
+ pagesAnalyzed: 1,
138
+ fusedAxes: axes,
139
+ };
140
+ fused.fusedAxes = axes;
141
+
142
+ // Drop the raw crawler output — it belongs to a single page and
143
+ // confuses any consumer that tries to re-derive things from it.
144
+ delete fused._raw;
145
+
146
+ const summary = {
147
+ a: { url: a.meta?.url, host: hostA },
148
+ b: { url: b.meta?.url, host: hostB },
149
+ axes,
150
+ };
151
+ return { design: fused, summary };
152
+ }
153
+
154
+ export { AXES, DEFAULT_AXES };
package/src/utils.js CHANGED
@@ -188,6 +188,20 @@ export function clusterColors(colors, threshold = 15) {
188
188
  clusters.push({ representative: color.parsed, hex: color.hex, members: [color], count: color.count });
189
189
  }
190
190
  }
191
+ // The first encountered colour seeded each cluster, but that's order-of-
192
+ // iteration accident, not signal. Re-pick the representative as the
193
+ // most-used member of the cluster so downstream consumers (primary
194
+ // detection, palette display, brand book) get the dominant shade.
195
+ for (const cluster of clusters) {
196
+ if (cluster.members.length > 1) {
197
+ const dominant = cluster.members.reduce(
198
+ (best, m) => (m.count > best.count ? m : best),
199
+ cluster.members[0],
200
+ );
201
+ cluster.representative = dominant.parsed;
202
+ cluster.hex = dominant.hex;
203
+ }
204
+ }
191
205
  return clusters.sort((a, b) => b.count - a.count);
192
206
  }
193
207
 
@@ -235,13 +249,33 @@ export function nameFromUrl(url) {
235
249
 
236
250
  export function detectScale(values) {
237
251
  if (values.length < 3) return { base: null, scale: values };
238
- const candidates = [2, 4, 6, 8];
252
+ // Expanded candidate set. Real production palettes use 4/8 (Tailwind +
253
+ // Material), 5 (Bootstrap), 6 (some Apple specs), 7 (rare), 10/12/16
254
+ // (looser systems). 2 stays as a fallback for icon/component-level
255
+ // numbers. We give 4 and 8 a small head-start because they win >70%
256
+ // of the time and were the previous-only choices — keeps results
257
+ // stable for sites that worked before.
258
+ const candidates = [2, 4, 5, 6, 7, 8, 10, 12, 16];
259
+ const bonus = { 4: 0.04, 8: 0.04 };
239
260
  let bestBase = null;
240
261
  let bestScore = 0;
241
262
  for (const base of candidates) {
242
- const score = values.filter(v => v > 0 && v % base === 0).length / values.length;
263
+ const fit = values.filter(v => v > 0 && v % base === 0).length / values.length;
264
+ const score = fit + (bonus[base] || 0);
243
265
  if (score > bestScore) { bestScore = score; bestBase = base; }
244
266
  }
245
267
  if (bestScore >= 0.6) return { base: bestBase, scale: values };
246
268
  return { base: null, scale: values };
247
269
  }
270
+
271
+ // Shared "always-list" coercer for downstream consumers (formatters,
272
+ // pack, brand-book). Different extractors return slot/prop/variant
273
+ // fields as arrays, objects, or comma-separated strings depending on
274
+ // what was detected; this normalises without losing data.
275
+ export function asList(v) {
276
+ if (v == null) return [];
277
+ if (Array.isArray(v)) return v.filter(x => x != null);
278
+ if (typeof v === 'string') return v.split(',').map(s => s.trim()).filter(Boolean);
279
+ if (typeof v === 'object') return Object.keys(v).filter(k => v[k] !== false && v[k] !== null);
280
+ return [String(v)];
281
+ }