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.
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +115 -0
- package/README.md +4 -1
- package/bin/design-extract.js +114 -0
- package/commands/pair.md +68 -0
- package/package.json +1 -1
- package/src/extractors/colors.js +22 -1
- package/src/extractors/typography.js +38 -3
- package/src/formatters/pair.js +331 -0
- package/src/fuse.js +154 -0
- package/src/utils.js +36 -2
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
{
|
|
9
9
|
"name": "designlang",
|
|
10
10
|
"source": "./",
|
|
11
|
-
"description": "
|
|
12
|
-
"version": "12.
|
|
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.
|
|
4
|
-
"version": "12.
|
|
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 (
|
|
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
|
package/bin/design-extract.js
CHANGED
|
@@ -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>')
|
package/commands/pair.md
ADDED
|
@@ -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.
|
|
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": {
|
package/src/extractors/colors.js
CHANGED
|
@@ -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
|
|
11
|
-
if (family
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
-
|
|
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
|
|
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
|
+
}
|