designlang 12.7.1 → 12.8.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.8.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.8.0",
5
5
  "author": {
6
6
  "name": "Manavarya Singh",
7
7
  "url": "https://github.com/Manavarya09"
package/CHANGELOG.md CHANGED
@@ -1,5 +1,65 @@
1
1
  # Changelog
2
2
 
3
+ ## [12.8.0] — 2026-05-10
4
+
5
+ **Pair — fuse two extracted designs into a single hybrid identity.**
6
+
7
+ \`designlang pair <urlA> <urlB>\` extracts both sites in parallel, then
8
+ mixes their design systems along seven configurable axes (colours,
9
+ typography, spacing, shape, motion, voice, components). Default split is
10
+ "visuals from A, voice + type + components from B" — i.e. the most
11
+ distinctive crossover. Override any axis with \`--<axis>-from a|b\`.
12
+
13
+ \`\`\`bash
14
+ npx designlang pair stripe.com linear.app
15
+ \`\`\`
16
+
17
+ \`\`\`
18
+ stripe.com × linear.app
19
+ A Colours · stripe.com
20
+ B Typography · linear.app
21
+ A Spacing · stripe.com
22
+ A Shape · stripe.com
23
+ A Motion · stripe.com
24
+ B Voice · linear.app
25
+ B Components · linear.app
26
+
27
+ ✓ stripe-com-x-linear-app.pair.html
28
+ ✓ stripe-com-x-linear-app.pair.md
29
+ ✓ stripe-com-x-linear-app.pair.json
30
+ \`\`\`
31
+
32
+ ### Added
33
+
34
+ - New CLI command: \`designlang pair <urlA> <urlB>\` with \`--colors-from\`,
35
+ \`--typography-from\`, \`--spacing-from\`, \`--shape-from\`, \`--motion-from\`,
36
+ \`--voice-from\`, \`--components-from\`, plus \`--brand\` to also emit a
37
+ full brand-guidelines book of the fused identity.
38
+ - New module \`src/fuse.js\` — \`fuseDesigns(a, b, opts)\` deep-clones both
39
+ inputs, picks each axis from the requested source, synthesises a
40
+ pair-specific meta URL (\`pair://<a>-x-<b>\`), and strips score /
41
+ cssHealth (those belong to the source extractions, not the fusion).
42
+ - New formatter \`src/formatters/pair.js\` — editorial pair card with a
43
+ three-card crossover (A · B · Fused), per-axis source matrix table,
44
+ and a fused specimen using the *real headline* from whichever site
45
+ contributed the voice axis.
46
+ - Plus \`formatPairMarkdown\` for diff-friendly summaries.
47
+ - 12 new tests covering default split, per-axis overrides, score-stripping,
48
+ meta synthesis, immutability of source designs, HTML rendering, voice
49
+ carry-through to the specimen, XSS escaping, and missing-input errors.
50
+
51
+ ### Plugin
52
+
53
+ \`/pair\` is the 8th slash command in the Claude Code plugin
54
+ (\`/extract\`, \`/grade\`, \`/battle\`, \`/remix\`, \`/pack\`, \`/theme-swap\`,
55
+ \`/brand\`, \`/pair\`). Plugin manifests bumped to 12.8.0.
56
+
57
+ ### Why
58
+
59
+ \`battle\` answers "which is better"; \`pair\` answers "what would the
60
+ intersection look like". Same parallel extraction, opposite operation.
61
+ Pure logic, no LLM, no new dependencies.
62
+
3
63
  ## [12.7.1] — 2026-05-09
4
64
 
5
65
  **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.8.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": {
@@ -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 };