designlang 12.1.0 → 12.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [12.2.0] — 2026-05-02
4
+
5
+ **Battle cards + design score badges — distribution + virality on top of Grade.**
6
+
7
+ ### Added
8
+
9
+ - **`designlang battle <urlA> <urlB>`** — head-to-head graded battle card.
10
+ Single shareable HTML pitting two sites against each other, dimension by
11
+ dimension, with a verdict line and a per-dimension bar table. Both sites
12
+ are extracted in parallel. Emits `*.battle.html`, `*.battle.md`, and
13
+ `*.battle.json`.
14
+ - **`designlang grade --badge`** — also emit `*.grade.svg`, a shields.io-style
15
+ SVG badge (`design · B · 87`) coloured by letter grade. Drop into any
16
+ README.
17
+ - **Live badge endpoint** at `https://designlang.app/badge/<host>.svg` (with
18
+ rewrites from `/badge/<host>` and `/api/badge/<host>`). Reuses the same
19
+ blob cache the `/api/extract` route writes to, so the first hit warms the
20
+ cache and every subsequent hit is served from edge cache in ~50ms. 6h
21
+ fresh / 24h stale-while-revalidate / 7d max — friendly to the GitHub image
22
+ proxy.
23
+ - New formatters: `src/formatters/battle.js`, `src/formatters/badge.js`,
24
+ with exports `formatBattle`, `formatBattleMarkdown`, `compareScores`,
25
+ `formatBadge`, `formatScoreBadge`.
26
+ - 13 new tests (battle markup, score comparison thresholds, SVG escaping,
27
+ grade-color mapping, missing-score handling).
28
+
29
+ Why this exists: the v12.1 Grade card was the differentiator. Battle is the
30
+ viral content layer ("Stripe vs Vercel — guess who lost"). The badge is the
31
+ distribution layer — every README that adopts it is a permanent backlink to
32
+ a public grade page.
33
+
3
34
  ## [12.1.0] — 2026-04-29
4
35
 
5
36
  **Design Report Card — a shareable audit page, generated from any URL.**
package/README.md CHANGED
@@ -25,10 +25,17 @@ It also goes where extractors don't: **layout patterns**, **responsive behavior
25
25
  ## Quick start
26
26
 
27
27
  ```bash
28
- npx designlang https://stripe.com # extract everything
29
- npx designlang grade https://stripe.com # shareable HTML report card ← v12.1
30
- npx designlang clone https://stripe.com # working Next.js starter
31
- npx designlang --full https://stripe.com # screenshots + responsive + interactions
28
+ npx designlang https://stripe.com # extract everything
29
+ npx designlang grade https://stripe.com --badge # report card + SVG badge ← v12.2
30
+ npx designlang battle stripe.com vercel.com # head-to-head graded fight ← v12.2
31
+ npx designlang clone https://stripe.com # working Next.js starter
32
+ npx designlang --full https://stripe.com # screenshots + responsive + interactions
33
+ ```
34
+
35
+ Drop a live design-score badge in any README:
36
+
37
+ ```markdown
38
+ ![Design Score](https://designlang.app/badge/stripe.com.svg)
32
39
  ```
33
40
 
34
41
  ## Install
@@ -56,6 +63,8 @@ Each run writes 17+ files to `./design-extract-output/`. The headline outputs:
56
63
  | `*-prompts/` | Paste-ready prompts for v0, Lovable, Cursor, Claude Artifacts |
57
64
  | `*-mcp.json` | Disk-backed MCP server payload |
58
65
  | `*-grade.html` | **v12.1** Shareable Design Report Card (letter grade + evidence) |
66
+ | `*-grade.svg` | **v12.2** Shields.io-style design-score badge (drop into any README) |
67
+ | `*-battle.html` | **v12.2** Head-to-head graded battle card from `designlang battle` |
59
68
 
60
69
  Multi-platform (`--platforms web,ios,android,flutter,wordpress,all`) adds `ios/`, `android/`, `flutter/`, and a WordPress block theme. `--emit-agent-rules` adds Cursor / Claude Code / generic agent rule files.
61
70
 
@@ -114,7 +123,9 @@ designlang mcp # stdio MCP server for Cursor / Clau
114
123
  | Apply | `designlang apply <url>` | Auto-detect framework and write tokens to your project |
115
124
  | Clone | `designlang clone <url>` | Generate a working Next.js starter with extracted design |
116
125
  | Score | `designlang score <url>` | Rate design quality with visual bar chart breakdown |
117
- | Grade (NEW v12.1) | `designlang grade <url>` | Generate a shareable HTML "Design Report Card" — letter grade, 8 dimensions, evidence (palette, type, rhythm), strengths + fixes |
126
+ | Grade (v12.1) | `designlang grade <url>` | Shareable HTML "Design Report Card" — letter grade, 8 dimensions, evidence, strengths + fixes |
127
+ | Battle (NEW v12.2) | `designlang battle <A> <B>` | Head-to-head graded battle card with verdict, dimension table, palette comparison |
128
+ | Badge (NEW v12.2) | `designlang grade --badge` | Shields.io-style SVG badge — `design · B · 87` — drop into any README. Live endpoint: `designlang.app/badge/<host>.svg` |
118
129
  | Watch | `designlang watch <url>` | Monitor for design changes on interval |
119
130
  | Diff | `designlang diff <A> <B>` | Compare two sites (MD + HTML) |
120
131
  | Multi-brand | `designlang brands <urls...>` | N-site comparison matrix |
@@ -167,7 +178,8 @@ Commands:
167
178
  apply <url> Extract and apply design directly to your project
168
179
  clone <url> Generate a working Next.js starter from extracted design
169
180
  score <url> Rate design quality (7 categories, A-F, bar chart)
170
- grade <url> Generate a shareable HTML Design Report Card (--format html|md|json|all, --open)
181
+ grade <url> Generate a shareable HTML Design Report Card (--format html|md|json|svg|all, --badge, --open)
182
+ battle <urlA> <urlB> Head-to-head graded battle card (--format html|md|json|all, --open)
171
183
  watch <url> Monitor for design changes on interval
172
184
  diff <urlA> <urlB> Compare two sites' design languages
173
185
  brands <urls...> Multi-brand comparison matrix
@@ -46,6 +46,8 @@ import { watchSite } from '../src/watch.js';
46
46
  import { diffDarkMode } from '../src/darkdiff.js';
47
47
  import { applyDesign } from '../src/apply.js';
48
48
  import { formatGrade, formatGradeMarkdown } from '../src/formatters/grade.js';
49
+ import { formatBattle, formatBattleMarkdown } from '../src/formatters/battle.js';
50
+ import { formatScoreBadge } from '../src/formatters/badge.js';
49
51
  import { nameFromUrl } from '../src/utils.js';
50
52
 
51
53
  function validateUrl(url) {
@@ -941,7 +943,8 @@ program
941
943
  .description('Generate a shareable Design Report Card (HTML + JSON + Markdown)')
942
944
  .option('-o, --out <dir>', 'output directory', './design-extract-output')
943
945
  .option('-n, --name <name>', 'output file prefix (default: derived from URL)')
944
- .option('--format <fmt>', 'output format: html, md, json, all', 'all')
946
+ .option('--format <fmt>', 'output format: html, md, json, svg, all', 'all')
947
+ .option('--badge', 'also emit *-badge.svg (shields.io-style) — implies adding svg to format')
945
948
  .option('--open', 'open the HTML report in the default browser')
946
949
  .action(async (url, opts) => {
947
950
  if (!url.startsWith('http')) url = `https://${url}`;
@@ -957,6 +960,7 @@ program
957
960
  mkdirSync(outDir, { recursive: true });
958
961
  const prefix = opts.name || nameFromUrl(url);
959
962
  const written = [];
963
+ const wantSvg = opts.badge || opts.format === 'svg' || opts.format === 'all';
960
964
 
961
965
  if (opts.format === 'all' || opts.format === 'html') {
962
966
  const html = formatGrade(design, { version: PKG_VERSION });
@@ -984,6 +988,12 @@ program
984
988
  }, null, 2));
985
989
  written.push(p);
986
990
  }
991
+ if (wantSvg) {
992
+ const svg = formatScoreBadge(s);
993
+ const p = join(outDir, `${prefix}.grade.svg`);
994
+ writeFileSync(p, svg);
995
+ written.push(p);
996
+ }
987
997
 
988
998
  spinner.stop();
989
999
  const gradeColor = s.grade === 'A' ? chalk.green : s.grade === 'B' ? chalk.cyan : s.grade === 'C' ? chalk.yellow : chalk.red;
@@ -1010,6 +1020,88 @@ program
1010
1020
  }
1011
1021
  });
1012
1022
 
1023
+ // ── Battle command — head-to-head graded comparison ────────
1024
+ program
1025
+ .command('battle <urlA> <urlB>')
1026
+ .description('Generate a head-to-head graded battle card (HTML + JSON + Markdown)')
1027
+ .option('-o, --out <dir>', 'output directory', './design-extract-output')
1028
+ .option('-n, --name <name>', 'output file prefix (default: a-vs-b)')
1029
+ .option('--format <fmt>', 'output format: html, md, json, all', 'all')
1030
+ .option('--open', 'open the battle card in the default browser')
1031
+ .action(async (urlA, urlB, opts) => {
1032
+ if (!urlA.startsWith('http')) urlA = `https://${urlA}`;
1033
+ if (!urlB.startsWith('http')) urlB = `https://${urlB}`;
1034
+ validateUrl(urlA);
1035
+ validateUrl(urlB);
1036
+
1037
+ const spinner = ora(`Auditing ${urlA} and ${urlB} in parallel...`).start();
1038
+ try {
1039
+ const [designA, designB] = await Promise.all([
1040
+ extractDesignLanguage(urlA),
1041
+ extractDesignLanguage(urlB),
1042
+ ]);
1043
+ if (!designA.score || !designB.score) throw new Error('scoring failed for one or both sites');
1044
+
1045
+ const outDir = resolve(opts.out);
1046
+ mkdirSync(outDir, { recursive: true });
1047
+ const prefix = opts.name || `${nameFromUrl(urlA)}-vs-${nameFromUrl(urlB)}`;
1048
+ const written = [];
1049
+
1050
+ if (opts.format === 'all' || opts.format === 'html') {
1051
+ const html = formatBattle(designA, designB, { version: PKG_VERSION });
1052
+ const p = join(outDir, `${prefix}.battle.html`);
1053
+ writeFileSync(p, html);
1054
+ written.push(p);
1055
+ }
1056
+ if (opts.format === 'all' || opts.format === 'md') {
1057
+ const md = formatBattleMarkdown(designA, designB);
1058
+ const p = join(outDir, `${prefix}.battle.md`);
1059
+ writeFileSync(p, md);
1060
+ written.push(p);
1061
+ }
1062
+ if (opts.format === 'all' || opts.format === 'json') {
1063
+ const p = join(outDir, `${prefix}.battle.json`);
1064
+ writeFileSync(p, JSON.stringify({
1065
+ a: { url: designA.meta?.url, grade: designA.score.grade, overall: designA.score.overall, scores: designA.score.scores },
1066
+ b: { url: designB.meta?.url, grade: designB.score.grade, overall: designB.score.overall, scores: designB.score.scores },
1067
+ timestamp: new Date().toISOString(),
1068
+ }, null, 2));
1069
+ written.push(p);
1070
+ }
1071
+
1072
+ spinner.stop();
1073
+ const aGrade = designA.score.grade, bGrade = designB.score.grade;
1074
+ const aColor = aGrade === 'A' ? chalk.green : aGrade === 'B' ? chalk.cyan : aGrade === 'C' ? chalk.yellow : chalk.red;
1075
+ const bColor = bGrade === 'A' ? chalk.green : bGrade === 'B' ? chalk.cyan : bGrade === 'C' ? chalk.yellow : chalk.red;
1076
+ console.log('');
1077
+ console.log(` ${aColor.bold(`${aGrade} · ${designA.score.overall}`)} ${chalk.gray(designA.meta?.url || urlA)}`);
1078
+ console.log(` ${chalk.gray('vs')}`);
1079
+ console.log(` ${bColor.bold(`${bGrade} · ${designB.score.overall}`)} ${chalk.gray(designB.meta?.url || urlB)}`);
1080
+ console.log('');
1081
+ const winner =
1082
+ designA.score.overall - designB.score.overall >= 3 ? `${chalk.bold(designA.meta?.url || urlA)} wins`
1083
+ : designB.score.overall - designA.score.overall >= 3 ? `${chalk.bold(designB.meta?.url || urlB)} wins`
1084
+ : 'Too close to call';
1085
+ console.log(` Verdict: ${winner}`);
1086
+ console.log('');
1087
+ for (const f of written) console.log(` ${chalk.green('✓')} ${chalk.gray(f)}`);
1088
+ console.log('');
1089
+
1090
+ if (opts.open) {
1091
+ const htmlPath = written.find(p => p.endsWith('.html'));
1092
+ if (htmlPath) {
1093
+ const { spawn } = await import('child_process');
1094
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1095
+ spawn(cmd, [htmlPath], { detached: true, stdio: 'ignore' }).unref();
1096
+ }
1097
+ }
1098
+ } catch (err) {
1099
+ spinner.fail('Battle failed');
1100
+ console.error(chalk.red(`\n ${err.message}\n`));
1101
+ process.exit(1);
1102
+ }
1103
+ });
1104
+
1013
1105
  // ── Apply command ──────────────────────────────────────────
1014
1106
  program
1015
1107
  .command('apply <url>')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "designlang",
3
- "version": "12.1.0",
3
+ "version": "12.2.0",
4
4
  "description": "Extract the complete design language from any website and ship it — clone to a working Next.js starter, guard tokens with a CI drift bot, or browse everything in a local studio. Outputs W3C DTCG tokens, motion tokens, typed anatomy stubs, Tailwind config, and ready-to-paste v0 / Lovable / Cursor / Claude-Artifacts prompts.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,90 @@
1
+ // designlang badge — shields.io-style SVG badge for design score.
2
+ // Uses Verdana web-safe font + computed text width so it renders identically
3
+ // on GitHub, npm, and arbitrary markdown. No external font dependency.
4
+
5
+ const COLORS = {
6
+ A: '#0a8a52',
7
+ B: '#1f6feb',
8
+ C: '#b08400',
9
+ D: '#d2691e',
10
+ F: '#c43d3d',
11
+ unknown: '#555',
12
+ };
13
+
14
+ // Verdana 11px character width approximation (em units × 11). Verdana is
15
+ // chosen because it ships natively on every OS and matches shields.io's
16
+ // rendering, so widths are predictable across platforms.
17
+ const VERDANA_WIDTHS = {
18
+ ' ': 5, '!': 5, '#': 9, '$': 7, '%': 12, '&': 9, "'": 3, '(': 5, ')': 5,
19
+ '*': 7, '+': 7, ',': 5, '-': 5, '.': 5, '/': 5, '0': 7, '1': 7, '2': 7,
20
+ '3': 7, '4': 7, '5': 7, '6': 7, '7': 7, '8': 7, '9': 7, ':': 5, ';': 5,
21
+ '<': 7, '=': 7, '>': 7, '?': 7, '@': 12, 'A': 8, 'B': 8, 'C': 8, 'D': 9,
22
+ 'E': 7, 'F': 7, 'G': 9, 'H': 9, 'I': 5, 'J': 5, 'K': 8, 'L': 7, 'M': 10,
23
+ 'N': 9, 'O': 9, 'P': 7, 'Q': 9, 'R': 8, 'S': 7, 'T': 7, 'U': 9, 'V': 8,
24
+ 'W': 12, 'X': 8, 'Y': 8, 'Z': 7, '[': 5, ']': 5, '_': 7, '`': 7,
25
+ 'a': 7, 'b': 7, 'c': 6, 'd': 7, 'e': 7, 'f': 4, 'g': 7, 'h': 7, 'i': 3,
26
+ 'j': 4, 'k': 7, 'l': 3, 'm': 11, 'n': 7, 'o': 7, 'p': 7, 'q': 7, 'r': 5,
27
+ 's': 6, 't': 4, 'u': 7, 'v': 7, 'w': 10, 'x': 7, 'y': 7, 'z': 6, '|': 5,
28
+ };
29
+
30
+ function textWidth(s) {
31
+ let w = 0;
32
+ for (const ch of String(s)) w += VERDANA_WIDTHS[ch] ?? 7;
33
+ return w;
34
+ }
35
+
36
+ function esc(s) {
37
+ return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
38
+ }
39
+
40
+ /**
41
+ * Render a shields.io-style two-section SVG badge.
42
+ * formatBadge({ label: 'design', value: 'B · 87', grade: 'B' })
43
+ *
44
+ * @param {object} opts
45
+ * @param {string} opts.label — left-side label (default 'design')
46
+ * @param {string} opts.value — right-side value (e.g. 'B · 87' or 'F · 12')
47
+ * @param {string} [opts.grade] — A–F, controls right-side fill color
48
+ * @param {string} [opts.color] — explicit hex override
49
+ * @returns {string} SVG markup
50
+ */
51
+ export function formatBadge({ label = 'design', value = '—', grade, color } = {}) {
52
+ const fill = color || COLORS[grade] || COLORS.unknown;
53
+ const labelW = textWidth(label) + 12; // 6px padding each side
54
+ const valueW = textWidth(value) + 12;
55
+ const totalW = labelW + valueW;
56
+ const labelX = labelW / 2;
57
+ const valueX = labelW + valueW / 2;
58
+
59
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="20" role="img" aria-label="${esc(label)}: ${esc(value)}">
60
+ <title>${esc(label)}: ${esc(value)}</title>
61
+ <linearGradient id="s" x2="0" y2="100%">
62
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
63
+ <stop offset="1" stop-opacity=".1"/>
64
+ </linearGradient>
65
+ <clipPath id="r"><rect width="${totalW}" height="20" rx="3" fill="#fff"/></clipPath>
66
+ <g clip-path="url(#r)">
67
+ <rect width="${labelW}" height="20" fill="#555"/>
68
+ <rect x="${labelW}" width="${valueW}" height="20" fill="${fill}"/>
69
+ <rect width="${totalW}" height="20" fill="url(#s)"/>
70
+ </g>
71
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110">
72
+ <text aria-hidden="true" x="${labelX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${textWidth(label) * 10}">${esc(label)}</text>
73
+ <text x="${labelX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${textWidth(label) * 10}">${esc(label)}</text>
74
+ <text aria-hidden="true" x="${valueX * 10}" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="${textWidth(value) * 10}">${esc(value)}</text>
75
+ <text x="${valueX * 10}" y="140" transform="scale(.1)" fill="#fff" textLength="${textWidth(value) * 10}">${esc(value)}</text>
76
+ </g>
77
+ </svg>`;
78
+ }
79
+
80
+ /** Convenience: render a badge directly from a design.score object. */
81
+ export function formatScoreBadge(score, opts = {}) {
82
+ if (!score || !score.grade) {
83
+ return formatBadge({ label: opts.label || 'design', value: '—', grade: 'unknown' });
84
+ }
85
+ return formatBadge({
86
+ label: opts.label || 'design',
87
+ value: `${score.grade} · ${score.overall}`,
88
+ grade: score.grade,
89
+ });
90
+ }
@@ -0,0 +1,338 @@
1
+ // designlang battle — head-to-head graded comparison of two sites.
2
+ // Renders both audited designs against each other, dimension by dimension,
3
+ // and emits a single shareable HTML page with a verdict. Builds on the
4
+ // scoring already done in src/extractors/scoring.js — no new analysis here.
5
+
6
+ const FONT_DISPLAY = 'Instrument Serif';
7
+ const FONT_BODY = 'Inter';
8
+ const FONT_MONO = 'JetBrains Mono';
9
+
10
+ const DIMENSIONS = [
11
+ ['colorDiscipline', 'Color'],
12
+ ['typographyConsistency', 'Typography'],
13
+ ['spacingSystem', 'Spacing'],
14
+ ['shadowConsistency', 'Elevation'],
15
+ ['radiusConsistency', 'Radii'],
16
+ ['accessibility', 'A11y'],
17
+ ['tokenization', 'Tokenization'],
18
+ ['cssHealth', 'CSS Health'],
19
+ ];
20
+
21
+ function esc(s) {
22
+ return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
23
+ }
24
+
25
+ function host(url) {
26
+ try { return new URL(url).hostname; } catch { return String(url); }
27
+ }
28
+
29
+ function gradeAccent(grade) {
30
+ return ({ A: '#0a8a52', B: '#1f6feb', C: '#b08400', D: '#d2691e', F: '#c43d3d' })[grade] || '#1f1f1f';
31
+ }
32
+
33
+ function familyName(f) {
34
+ if (!f) return '';
35
+ if (typeof f === 'string') return f;
36
+ return f.name || f.family || '';
37
+ }
38
+
39
+ export function compareScores(a, b) {
40
+ // Build dimension-by-dimension comparison from two design.score objects.
41
+ const rows = [];
42
+ let aWins = 0, bWins = 0, ties = 0;
43
+ for (const [key, label] of DIMENSIONS) {
44
+ const va = a?.scores?.[key];
45
+ const vb = b?.scores?.[key];
46
+ if (va === undefined || vb === undefined) continue;
47
+ const gap = Math.round(va - vb);
48
+ let winner = 'tie';
49
+ if (gap >= 3) { winner = 'a'; aWins++; }
50
+ else if (gap <= -3) { winner = 'b'; bWins++; }
51
+ else ties++;
52
+ rows.push({ key, label, a: Math.round(va), b: Math.round(vb), gap, winner });
53
+ }
54
+ let verdict = 'tie';
55
+ if (a?.overall - b?.overall >= 3) verdict = 'a';
56
+ else if (b?.overall - a?.overall >= 3) verdict = 'b';
57
+ return { rows, aWins, bWins, ties, verdict };
58
+ }
59
+
60
+ function paletteStrip(design) {
61
+ const all = (design?.colors?.all || []).slice(0, 10);
62
+ if (!all.length) return '';
63
+ return all.map(c => `<span class="chip" style="--c:${esc(c.hex || c)}" title="${esc(c.hex || c)}"></span>`).join('');
64
+ }
65
+
66
+ export function formatBattle(designA, designB, opts = {}) {
67
+ if (!designA?.score || !designB?.score) {
68
+ throw new Error('battle: both designs must include a score (run extractDesignLanguage first)');
69
+ }
70
+
71
+ const a = {
72
+ url: designA.meta?.url || '',
73
+ host: host(designA.meta?.url),
74
+ title: designA.meta?.title || '',
75
+ score: designA.score,
76
+ family: familyName((designA.typography?.families || [])[0]),
77
+ };
78
+ const b = {
79
+ url: designB.meta?.url || '',
80
+ host: host(designB.meta?.url),
81
+ title: designB.meta?.title || '',
82
+ score: designB.score,
83
+ family: familyName((designB.typography?.families || [])[0]),
84
+ };
85
+
86
+ const cmp = compareScores(a.score, b.score);
87
+ const aAccent = gradeAccent(a.score.grade);
88
+ const bAccent = gradeAccent(b.score.grade);
89
+ const verdictText =
90
+ cmp.verdict === 'a' ? `${a.host} wins`
91
+ : cmp.verdict === 'b' ? `${b.host} wins`
92
+ : `Too close to call`;
93
+ const date = new Date().toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });
94
+ const ogTitle = `${a.host} vs ${b.host} · designlang`;
95
+ const ogDesc = `${verdictText}. ${a.score.overall} (${a.score.grade}) vs ${b.score.overall} (${b.score.grade}) across 8 design dimensions.`;
96
+
97
+ const dimRows = cmp.rows.map(r => {
98
+ const aPct = r.a;
99
+ const bPct = r.b;
100
+ const winnerClass = r.winner === 'a' ? 'win-a' : r.winner === 'b' ? 'win-b' : 'tie';
101
+ return `
102
+ <tr class="${winnerClass}">
103
+ <td class="d-label">${esc(r.label)}</td>
104
+ <td class="d-num d-num-a">${r.a}</td>
105
+ <td class="d-bar">
106
+ <div class="bar bar-a" style="width:${aPct}%; background:${aAccent}"></div>
107
+ <div class="bar bar-b" style="width:${bPct}%; background:${bAccent}"></div>
108
+ </td>
109
+ <td class="d-num d-num-b">${r.b}</td>
110
+ <td class="d-gap">${r.gap > 0 ? '+' : ''}${r.gap}</td>
111
+ </tr>`;
112
+ }).join('');
113
+
114
+ return `<!doctype html>
115
+ <html lang="en">
116
+ <head>
117
+ <meta charset="utf-8">
118
+ <meta name="viewport" content="width=device-width,initial-scale=1">
119
+ <title>${esc(ogTitle)}</title>
120
+ <meta name="description" content="${esc(ogDesc)}">
121
+ <meta property="og:title" content="${esc(ogTitle)}">
122
+ <meta property="og:description" content="${esc(ogDesc)}">
123
+ <meta property="og:type" content="article">
124
+ <meta name="twitter:card" content="summary_large_image">
125
+ <link rel="preconnect" href="https://fonts.googleapis.com">
126
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
127
+ <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">
128
+ <style>
129
+ :root {
130
+ --paper: #f7f5ef;
131
+ --ink: #141414;
132
+ --ink-soft: #555049;
133
+ --rule: #e5e1d6;
134
+ --a-accent: ${aAccent};
135
+ --b-accent: ${bAccent};
136
+ --display: '${FONT_DISPLAY}', 'Times New Roman', serif;
137
+ --body: '${FONT_BODY}', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
138
+ --mono: '${FONT_MONO}', ui-monospace, 'SF Mono', monospace;
139
+ }
140
+ [data-theme="dark"] {
141
+ --paper: #0e0d0b;
142
+ --ink: #f0ece2;
143
+ --ink-soft: #9b9589;
144
+ --rule: #2a2823;
145
+ }
146
+ * { box-sizing: border-box; }
147
+ html, body { margin: 0; padding: 0; }
148
+ 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; }
149
+ .wrap { max-width: 980px; margin: 0 auto; padding: 56px 40px 96px; }
150
+ @media (max-width: 640px) { .wrap { padding: 32px 22px 64px; } }
151
+
152
+ .topbar { display: flex; justify-content: space-between; align-items: center; margin-bottom: 56px; font-size: 13px; }
153
+ .brand { font-family: var(--display); font-size: 22px; }
154
+ .brand a { color: var(--ink); text-decoration: none; border-bottom: 1px solid var(--rule); padding-bottom: 1px; }
155
+ .topbar nav { display: flex; gap: 18px; align-items: center; color: var(--ink-soft); }
156
+ .theme-btn { background: transparent; border: 1px solid var(--rule); color: var(--ink-soft); font-size: 12px; padding: 6px 12px; border-radius: 999px; cursor: pointer; letter-spacing: .04em; text-transform: uppercase; font-family: var(--body); }
157
+ .theme-btn:hover { color: var(--ink); border-color: var(--ink); }
158
+
159
+ .kicker { text-transform: uppercase; letter-spacing: .18em; font-size: 11px; color: var(--ink-soft); margin-bottom: 14px; }
160
+ h1.title { font-family: var(--display); font-weight: 400; font-size: clamp(40px, 6vw, 72px); line-height: 1.02; margin: 0 0 36px; letter-spacing: -.01em; }
161
+ h1.title em { font-style: italic; color: var(--ink-soft); padding: 0 .12em; }
162
+
163
+ /* — Versus hero — */
164
+ .versus { display: grid; grid-template-columns: 1fr auto 1fr; gap: 24px; align-items: center; padding: 8px 0 56px; border-bottom: 1px solid var(--rule); }
165
+ .side { text-align: center; }
166
+ .side .host-name { font-family: var(--display); font-size: clamp(24px, 3vw, 32px); margin: 0 0 6px; line-height: 1.1; }
167
+ .side .host-name a { color: var(--ink); text-decoration: none; border-bottom: 2px solid currentColor; padding-bottom: 2px; }
168
+ .side .grade { font-family: var(--display); font-size: clamp(120px, 18vw, 200px); line-height: .85; font-weight: 400; letter-spacing: -.04em; margin: 8px 0; }
169
+ .side.a .grade { color: var(--a-accent); }
170
+ .side.b .grade { color: var(--b-accent); }
171
+ .side .score-num { font-family: var(--mono); font-size: 13px; color: var(--ink-soft); letter-spacing: .04em; }
172
+ .versus .vs { font-family: var(--display); font-size: clamp(28px, 4vw, 44px); color: var(--ink-soft); font-style: italic; }
173
+ @media (max-width: 640px) { .versus { grid-template-columns: 1fr; gap: 8px; } .versus .vs { padding: 8px 0; } }
174
+
175
+ /* — Verdict — */
176
+ .verdict { padding: 40px 0; border-bottom: 1px solid var(--rule); text-align: center; }
177
+ .verdict .v-label { font-family: var(--mono); font-size: 11px; letter-spacing: .18em; text-transform: uppercase; color: var(--ink-soft); margin-bottom: 16px; }
178
+ .verdict .v-text { font-family: var(--display); font-size: clamp(32px, 5vw, 56px); margin: 0; line-height: 1.05; letter-spacing: -.005em; }
179
+ .verdict .v-text em { font-style: italic; color: var(--a-accent); }
180
+ .verdict .v-text.b em { color: var(--b-accent); }
181
+ .verdict .v-tally { display: flex; justify-content: center; gap: 28px; margin-top: 18px; font-family: var(--mono); font-size: 12px; color: var(--ink-soft); text-transform: uppercase; letter-spacing: .08em; }
182
+ .verdict .v-tally .a-wins { color: var(--a-accent); }
183
+ .verdict .v-tally .b-wins { color: var(--b-accent); }
184
+
185
+ /* — Dimension table — */
186
+ section { padding: 56px 0; border-bottom: 1px solid var(--rule); }
187
+ section:last-of-type { border-bottom: 0; }
188
+ section > h2 { font-family: var(--display); font-weight: 400; font-size: 32px; margin: 0 0 8px; letter-spacing: -.005em; }
189
+ section > h2 + .lead { color: var(--ink-soft); margin: 0 0 32px; max-width: 60ch; }
190
+
191
+ table.dims { width: 100%; border-collapse: collapse; font-size: 14px; }
192
+ table.dims tr { border-bottom: 1px solid var(--rule); }
193
+ table.dims tr:last-child { border-bottom: 0; }
194
+ table.dims td { padding: 18px 8px; vertical-align: middle; }
195
+ table.dims .d-label { font-family: var(--display); font-size: 18px; width: 22%; }
196
+ table.dims .d-num { font-family: var(--mono); font-size: 16px; width: 12%; text-align: center; color: var(--ink-soft); }
197
+ table.dims .d-num-a { text-align: right; }
198
+ table.dims .d-num-b { text-align: left; }
199
+ table.dims .win-a .d-num-a { color: var(--a-accent); font-weight: 500; }
200
+ table.dims .win-b .d-num-b { color: var(--b-accent); font-weight: 500; }
201
+ table.dims .d-bar { padding: 18px 12px; }
202
+ table.dims .bar { height: 6px; border-radius: 3px; transition: opacity .15s; }
203
+ table.dims .bar-a { margin-bottom: 4px; }
204
+ table.dims .d-gap { font-family: var(--mono); font-size: 12px; text-align: right; width: 8%; color: var(--ink-soft); letter-spacing: .04em; }
205
+ table.dims .win-a .d-gap { color: var(--a-accent); }
206
+ table.dims .win-b .d-gap { color: var(--b-accent); }
207
+
208
+ /* — Palette strip — */
209
+ .palettes { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; margin-top: 24px; }
210
+ @media (max-width: 640px) { .palettes { grid-template-columns: 1fr; } }
211
+ .pal h3 { font-family: var(--display); font-weight: 400; font-size: 18px; margin: 0 0 12px; color: var(--ink-soft); }
212
+ .pal .chips { display: flex; flex-wrap: wrap; gap: 6px; }
213
+ .chip { width: 26px; height: 26px; border-radius: 4px; background: var(--c); box-shadow: inset 0 0 0 1px rgba(0,0,0,.06); }
214
+
215
+ /* — Footer — */
216
+ 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; }
217
+ footer .sig { font-family: var(--display); font-size: 22px; color: var(--ink); }
218
+ footer code { font-family: var(--mono); }
219
+ footer .stamp { font-family: var(--mono); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
220
+
221
+ @media print {
222
+ body { background: white; color: black; }
223
+ .topbar nav, .theme-btn { display: none; }
224
+ section, .versus, .verdict { page-break-inside: avoid; border-color: #ddd; }
225
+ .side .grade { color: black; }
226
+ }
227
+ </style>
228
+ </head>
229
+ <body>
230
+ <div class="wrap">
231
+ <header class="topbar">
232
+ <div class="brand"><a href="https://designlang.dev">designlang</a></div>
233
+ <nav>
234
+ <span>Battle Card</span>
235
+ <button class="theme-btn" id="themeBtn" type="button">Theme</button>
236
+ </nav>
237
+ </header>
238
+
239
+ <p class="kicker">Design Battle · ${esc(date)}</p>
240
+ <h1 class="title">${esc(a.host)} <em>versus</em> ${esc(b.host)}</h1>
241
+
242
+ <div class="versus">
243
+ <div class="side a">
244
+ <p class="host-name"><a href="${esc(a.url)}" target="_blank" rel="noopener">${esc(a.host)}</a></p>
245
+ <div class="grade">${esc(a.score.grade)}</div>
246
+ <p class="score-num">${a.score.overall} / 100</p>
247
+ </div>
248
+ <div class="vs">vs.</div>
249
+ <div class="side b">
250
+ <p class="host-name"><a href="${esc(b.url)}" target="_blank" rel="noopener">${esc(b.host)}</a></p>
251
+ <div class="grade">${esc(b.score.grade)}</div>
252
+ <p class="score-num">${b.score.overall} / 100</p>
253
+ </div>
254
+ </div>
255
+
256
+ <div class="verdict">
257
+ <p class="v-label">Verdict</p>
258
+ <p class="v-text ${cmp.verdict === 'b' ? 'b' : ''}">${
259
+ cmp.verdict === 'a' ? `<em>${esc(a.host)}</em> takes it.`
260
+ : cmp.verdict === 'b' ? `<em>${esc(b.host)}</em> takes it.`
261
+ : 'Too <em>close</em> to call.'
262
+ }</p>
263
+ <div class="v-tally">
264
+ <span class="a-wins">${cmp.aWins} ${cmp.aWins === 1 ? 'win' : 'wins'} · ${esc(a.host)}</span>
265
+ <span>${cmp.ties} ${cmp.ties === 1 ? 'tie' : 'ties'}</span>
266
+ <span class="b-wins">${cmp.bWins} ${cmp.bWins === 1 ? 'win' : 'wins'} · ${esc(b.host)}</span>
267
+ </div>
268
+ </div>
269
+
270
+ <section>
271
+ <h2>Round by round.</h2>
272
+ <p class="lead">Eight scored dimensions. Bigger bar wins. A gap under three points is called a tie.</p>
273
+ <table class="dims">
274
+ <tbody>${dimRows}</tbody>
275
+ </table>
276
+ </section>
277
+
278
+ <section>
279
+ <h2>The evidence.</h2>
280
+ <p class="lead">Palettes pulled straight from each site's live styles.</p>
281
+ <div class="palettes">
282
+ <div class="pal">
283
+ <h3>${esc(a.host)} · ${esc(a.family || '—')}</h3>
284
+ <div class="chips">${paletteStrip(designA)}</div>
285
+ </div>
286
+ <div class="pal">
287
+ <h3>${esc(b.host)} · ${esc(b.family || '—')}</h3>
288
+ <div class="chips">${paletteStrip(designB)}</div>
289
+ </div>
290
+ </div>
291
+ </section>
292
+
293
+ <footer>
294
+ <div>
295
+ <div class="sig">designlang</div>
296
+ <div>Run your own: <code>npx designlang battle ${esc(a.host)} ${esc(b.host)}</code></div>
297
+ </div>
298
+ <div class="stamp">${esc(date)} · v${esc(opts.version || '')}</div>
299
+ </footer>
300
+ </div>
301
+
302
+ <script>
303
+ (function () {
304
+ var btn = document.getElementById('themeBtn');
305
+ var saved = null;
306
+ try { saved = localStorage.getItem('dl-theme'); } catch (e) {}
307
+ if (saved) document.documentElement.setAttribute('data-theme', saved);
308
+ btn && btn.addEventListener('click', function () {
309
+ var cur = document.documentElement.getAttribute('data-theme') === 'dark' ? '' : 'dark';
310
+ if (cur) document.documentElement.setAttribute('data-theme', cur);
311
+ else document.documentElement.removeAttribute('data-theme');
312
+ try { localStorage.setItem('dl-theme', cur); } catch (e) {}
313
+ });
314
+ })();
315
+ </script>
316
+ </body>
317
+ </html>`;
318
+ }
319
+
320
+ export function formatBattleMarkdown(designA, designB) {
321
+ if (!designA?.score || !designB?.score) throw new Error('battle: both designs need scores');
322
+ const a = { host: host(designA.meta?.url), score: designA.score };
323
+ const b = { host: host(designB.meta?.url), score: designB.score };
324
+ const cmp = compareScores(a.score, b.score);
325
+ const lines = [
326
+ `# ${a.host} vs ${b.host}`,
327
+ ``,
328
+ `**Verdict:** ${cmp.verdict === 'a' ? `${a.host} wins` : cmp.verdict === 'b' ? `${b.host} wins` : 'Tie'} — ${cmp.aWins}–${cmp.bWins} (${cmp.ties} ties)`,
329
+ ``,
330
+ `| | ${a.host} | ${b.host} | Δ |`,
331
+ `|---|---|---|---|`,
332
+ `| **Overall** | ${a.score.overall} (${a.score.grade}) | ${b.score.overall} (${b.score.grade}) | ${a.score.overall - b.score.overall > 0 ? '+' : ''}${a.score.overall - b.score.overall} |`,
333
+ ...cmp.rows.map(r => `| ${r.label} | ${r.a} | ${r.b} | ${r.gap > 0 ? '+' : ''}${r.gap} |`),
334
+ ``,
335
+ `_Audited by [designlang](https://designlang.dev) · \`npx designlang battle ${a.host} ${b.host}\`_`,
336
+ ];
337
+ return lines.join('\n');
338
+ }