designlang 12.1.0 → 12.3.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 +71 -0
- package/README.md +23 -6
- package/bin/design-extract.js +164 -2
- package/package.json +1 -1
- package/src/chat.js +0 -14
- package/src/formatters/badge.js +90 -0
- package/src/formatters/battle.js +338 -0
- package/src/formatters/css-vars.js +0 -2
- package/src/formatters/remix.js +379 -0
- package/src/formatters/tailwind.js +1 -1
- package/src/formatters/vue-theme.js +0 -2
- package/src/history.js +1 -1
- package/src/index.js +1 -2
- package/src/studio.js +2 -2
- package/src/sync.js +17 -6
- package/src/visual-diff.js +0 -1
- package/src/vocabularies/art-deco.js +79 -0
- package/src/vocabularies/brutalist.js +72 -0
- package/src/vocabularies/cyberpunk.js +92 -0
- package/src/vocabularies/editorial.js +75 -0
- package/src/vocabularies/index.js +35 -0
- package/src/vocabularies/soft-ui.js +83 -0
- package/src/vocabularies/swiss.js +60 -0
|
@@ -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
|
+
}
|