designlang 12.0.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/.claude/launch.json +6 -0
- package/CHANGELOG.md +53 -0
- package/README.md +82 -476
- package/bin/design-extract.js +168 -0
- package/package.json +1 -1
- package/src/formatters/badge.js +90 -0
- package/src/formatters/battle.js +338 -0
- package/src/formatters/grade.js +404 -0
package/bin/design-extract.js
CHANGED
|
@@ -45,6 +45,9 @@ import { generateClone } from '../src/clone.js';
|
|
|
45
45
|
import { watchSite } from '../src/watch.js';
|
|
46
46
|
import { diffDarkMode } from '../src/darkdiff.js';
|
|
47
47
|
import { applyDesign } from '../src/apply.js';
|
|
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';
|
|
48
51
|
import { nameFromUrl } from '../src/utils.js';
|
|
49
52
|
|
|
50
53
|
function validateUrl(url) {
|
|
@@ -934,6 +937,171 @@ program
|
|
|
934
937
|
}
|
|
935
938
|
});
|
|
936
939
|
|
|
940
|
+
// ── Grade command — shareable HTML report card ─────────────
|
|
941
|
+
program
|
|
942
|
+
.command('grade <url>')
|
|
943
|
+
.description('Generate a shareable Design Report Card (HTML + JSON + Markdown)')
|
|
944
|
+
.option('-o, --out <dir>', 'output directory', './design-extract-output')
|
|
945
|
+
.option('-n, --name <name>', 'output file prefix (default: derived from URL)')
|
|
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')
|
|
948
|
+
.option('--open', 'open the HTML report in the default browser')
|
|
949
|
+
.action(async (url, opts) => {
|
|
950
|
+
if (!url.startsWith('http')) url = `https://${url}`;
|
|
951
|
+
validateUrl(url);
|
|
952
|
+
|
|
953
|
+
const spinner = ora('Auditing design system...').start();
|
|
954
|
+
try {
|
|
955
|
+
const design = await extractDesignLanguage(url);
|
|
956
|
+
const s = design.score;
|
|
957
|
+
if (!s) throw new Error('scoring failed — cannot grade');
|
|
958
|
+
|
|
959
|
+
const outDir = resolve(opts.out);
|
|
960
|
+
mkdirSync(outDir, { recursive: true });
|
|
961
|
+
const prefix = opts.name || nameFromUrl(url);
|
|
962
|
+
const written = [];
|
|
963
|
+
const wantSvg = opts.badge || opts.format === 'svg' || opts.format === 'all';
|
|
964
|
+
|
|
965
|
+
if (opts.format === 'all' || opts.format === 'html') {
|
|
966
|
+
const html = formatGrade(design, { version: PKG_VERSION });
|
|
967
|
+
const p = join(outDir, `${prefix}.grade.html`);
|
|
968
|
+
writeFileSync(p, html);
|
|
969
|
+
written.push(p);
|
|
970
|
+
}
|
|
971
|
+
if (opts.format === 'all' || opts.format === 'md') {
|
|
972
|
+
const md = formatGradeMarkdown(design);
|
|
973
|
+
const p = join(outDir, `${prefix}.grade.md`);
|
|
974
|
+
writeFileSync(p, md);
|
|
975
|
+
written.push(p);
|
|
976
|
+
}
|
|
977
|
+
if (opts.format === 'all' || opts.format === 'json') {
|
|
978
|
+
const p = join(outDir, `${prefix}.grade.json`);
|
|
979
|
+
writeFileSync(p, JSON.stringify({
|
|
980
|
+
url: design.meta?.url,
|
|
981
|
+
title: design.meta?.title,
|
|
982
|
+
timestamp: design.meta?.timestamp,
|
|
983
|
+
grade: s.grade,
|
|
984
|
+
overall: s.overall,
|
|
985
|
+
scores: s.scores,
|
|
986
|
+
strengths: s.strengths,
|
|
987
|
+
issues: s.issues,
|
|
988
|
+
}, null, 2));
|
|
989
|
+
written.push(p);
|
|
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
|
+
}
|
|
997
|
+
|
|
998
|
+
spinner.stop();
|
|
999
|
+
const gradeColor = s.grade === 'A' ? chalk.green : s.grade === 'B' ? chalk.cyan : s.grade === 'C' ? chalk.yellow : chalk.red;
|
|
1000
|
+
console.log('');
|
|
1001
|
+
console.log(` ${gradeColor.bold(`Grade ${s.grade}`)} ${chalk.gray('·')} ${chalk.bold(`${s.overall}/100`)} ${chalk.gray('·')} ${chalk.gray(url)}`);
|
|
1002
|
+
console.log('');
|
|
1003
|
+
for (const f of written) console.log(` ${chalk.green('✓')} ${chalk.gray(f)}`);
|
|
1004
|
+
console.log('');
|
|
1005
|
+
console.log(chalk.gray(` Share: open the .grade.html in a browser, post the URL.`));
|
|
1006
|
+
console.log('');
|
|
1007
|
+
|
|
1008
|
+
if (opts.open) {
|
|
1009
|
+
const htmlPath = written.find(p => p.endsWith('.html'));
|
|
1010
|
+
if (htmlPath) {
|
|
1011
|
+
const { spawn } = await import('child_process');
|
|
1012
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
1013
|
+
spawn(cmd, [htmlPath], { detached: true, stdio: 'ignore' }).unref();
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
} catch (err) {
|
|
1017
|
+
spinner.fail('Grade failed');
|
|
1018
|
+
console.error(chalk.red(`\n ${err.message}\n`));
|
|
1019
|
+
process.exit(1);
|
|
1020
|
+
}
|
|
1021
|
+
});
|
|
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
|
+
|
|
937
1105
|
// ── Apply command ──────────────────────────────────────────
|
|
938
1106
|
program
|
|
939
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
|
+
}
|