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 +31 -0
- package/README.md +18 -6
- package/bin/design-extract.js +93 -1
- package/package.json +1 -1
- package/src/formatters/badge.js +90 -0
- package/src/formatters/battle.js +338 -0
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
|
|
29
|
-
npx designlang grade https://stripe.com
|
|
30
|
-
npx designlang
|
|
31
|
-
npx designlang
|
|
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
|
+

|
|
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 (
|
|
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
|
package/bin/design-extract.js
CHANGED
|
@@ -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.
|
|
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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
+
}
|