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 CHANGED
@@ -1,5 +1,76 @@
1
1
  # Changelog
2
2
 
3
+ ## [12.3.0] — 2026-05-05
4
+
5
+ **Remix — restyle any site in a different design vocabulary.**
6
+
7
+ A genuinely new product surface: take an extracted page-shape (sections,
8
+ voice, page-intent, anatomy) and re-render it under one of six
9
+ opinionated design vocabularies. "What would stripe.com look like if it
10
+ had been designed brutalist? Or art-deco? Or cyberpunk?"
11
+
12
+ ### Added
13
+
14
+ - **`designlang remix <url> --as <vocab>`** — re-renders the audited page
15
+ using the host's *own copy* (headings, ledes, CTA verbs from voice) but
16
+ styled in another vocabulary. Six built-ins:
17
+ - `brutalist` — hard edges, mono type, single screaming accent
18
+ - `swiss` — Helvetica, grids, restraint (post-Bauhaus default)
19
+ - `art-deco` — gold on ink, geometric ornament, vertical type
20
+ - `cyberpunk` — neon on midnight, scanlines, mono with glitch energy
21
+ - `soft-ui` — cushioned shapes, low contrast, Vision-OS-adjacent
22
+ - `editorial` — broadsheet serifs, generous whitespace, ink on paper
23
+ - `--all` flag emits one HTML per vocabulary in a single extraction.
24
+ - `--list` prints the vocabulary registry with blurbs.
25
+ - New formatter: `src/formatters/remix.js` — maps every section role
26
+ (hero, feature-grid, pricing-table, stats, testimonial, faq,
27
+ logo-wall, steps, cta) to vocabulary-styled markup.
28
+ - New module: `src/vocabularies/` — six self-contained vocab definitions
29
+ (tokens + font stack + signature CSS) plus `index.js` registry.
30
+ - Hero-deduplication: real-world section walkers (especially on SPA
31
+ marketing pages) often emit a hero wrapper + an inner hero with the
32
+ same h1. Remix now dedupes by heading and excludes claimed headings
33
+ from the voice pool, so heading-less sections (cta bands, logo walls)
34
+ don't re-render an already-claimed heading.
35
+ - 14 new tests (350 total, all passing). Cover registry shape,
36
+ per-vocab token validity, dedup, XSS escaping, missing-input errors.
37
+
38
+ Why: Grade (v12.1) is the audit, Battle (v12.2) is the comparison,
39
+ Remix is the *transformation*. Pure visual moat — no competitor
40
+ (Dembrandt, Superposition, html.to.design, Builder Visual Copilot)
41
+ ships site-shape-preserving vocabulary swap.
42
+
43
+ ## [12.2.0] — 2026-05-02
44
+
45
+ **Battle cards + design score badges — distribution + virality on top of Grade.**
46
+
47
+ ### Added
48
+
49
+ - **`designlang battle <urlA> <urlB>`** — head-to-head graded battle card.
50
+ Single shareable HTML pitting two sites against each other, dimension by
51
+ dimension, with a verdict line and a per-dimension bar table. Both sites
52
+ are extracted in parallel. Emits `*.battle.html`, `*.battle.md`, and
53
+ `*.battle.json`.
54
+ - **`designlang grade --badge`** — also emit `*.grade.svg`, a shields.io-style
55
+ SVG badge (`design · B · 87`) coloured by letter grade. Drop into any
56
+ README.
57
+ - **Live badge endpoint** at `https://designlang.app/badge/<host>.svg` (with
58
+ rewrites from `/badge/<host>` and `/api/badge/<host>`). Reuses the same
59
+ blob cache the `/api/extract` route writes to, so the first hit warms the
60
+ cache and every subsequent hit is served from edge cache in ~50ms. 6h
61
+ fresh / 24h stale-while-revalidate / 7d max — friendly to the GitHub image
62
+ proxy.
63
+ - New formatters: `src/formatters/battle.js`, `src/formatters/badge.js`,
64
+ with exports `formatBattle`, `formatBattleMarkdown`, `compareScores`,
65
+ `formatBadge`, `formatScoreBadge`.
66
+ - 13 new tests (battle markup, score comparison thresholds, SVG escaping,
67
+ grade-color mapping, missing-score handling).
68
+
69
+ Why this exists: the v12.1 Grade card was the differentiator. Battle is the
70
+ viral content layer ("Stripe vs Vercel — guess who lost"). The badge is the
71
+ distribution layer — every README that adopts it is a permanent backlink to
72
+ a public grade page.
73
+
3
74
  ## [12.1.0] — 2026-04-29
4
75
 
5
76
  **Design Report Card — a shareable audit page, generated from any URL.**
package/README.md CHANGED
@@ -25,10 +25,19 @@ 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 remix stripe.com --as cyberpunk # restyle in another vocabulary ← v12.3
30
+ npx designlang remix stripe.com --all # emit all 6 vocabs at once ← v12.3
31
+ npx designlang grade https://stripe.com --badge # report card + SVG badge ← v12.2
32
+ npx designlang battle stripe.com vercel.com # head-to-head graded fight ← v12.2
33
+ npx designlang clone https://stripe.com # working Next.js starter
34
+ npx designlang --full https://stripe.com # screenshots + responsive + interactions
35
+ ```
36
+
37
+ Drop a live design-score badge in any README:
38
+
39
+ ```markdown
40
+ ![Design Score](https://designlang.app/badge/stripe.com.svg)
32
41
  ```
33
42
 
34
43
  ## Install
@@ -56,6 +65,9 @@ Each run writes 17+ files to `./design-extract-output/`. The headline outputs:
56
65
  | `*-prompts/` | Paste-ready prompts for v0, Lovable, Cursor, Claude Artifacts |
57
66
  | `*-mcp.json` | Disk-backed MCP server payload |
58
67
  | `*-grade.html` | **v12.1** Shareable Design Report Card (letter grade + evidence) |
68
+ | `*-grade.svg` | **v12.2** Shields.io-style design-score badge (drop into any README) |
69
+ | `*-battle.html` | **v12.2** Head-to-head graded battle card from `designlang battle` |
70
+ | `*-remix.<vocab>.html` | **v12.3** Site restyled in another vocabulary — brutalist / swiss / art-deco / cyberpunk / soft-ui / editorial |
59
71
 
60
72
  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
73
 
@@ -114,7 +126,10 @@ designlang mcp # stdio MCP server for Cursor / Clau
114
126
  | Apply | `designlang apply <url>` | Auto-detect framework and write tokens to your project |
115
127
  | Clone | `designlang clone <url>` | Generate a working Next.js starter with extracted design |
116
128
  | 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 |
129
+ | Grade (v12.1) | `designlang grade <url>` | Shareable HTML "Design Report Card" — letter grade, 8 dimensions, evidence, strengths + fixes |
130
+ | Battle (v12.2) | `designlang battle <A> <B>` | Head-to-head graded battle card with verdict, dimension table, palette comparison |
131
+ | Badge (v12.2) | `designlang grade --badge` | Shields.io-style SVG badge — `design · B · 87` — drop into any README. Live endpoint: `designlang.app/badge/<host>.svg` |
132
+ | Remix (NEW v12.3) | `designlang remix <url> --as <vocab>` | Restyle the audited page in another vocabulary (brutalist / swiss / art-deco / cyberpunk / soft-ui / editorial). `--all` emits all 6 |
118
133
  | Watch | `designlang watch <url>` | Monitor for design changes on interval |
119
134
  | Diff | `designlang diff <A> <B>` | Compare two sites (MD + HTML) |
120
135
  | Multi-brand | `designlang brands <urls...>` | N-site comparison matrix |
@@ -167,7 +182,9 @@ Commands:
167
182
  apply <url> Extract and apply design directly to your project
168
183
  clone <url> Generate a working Next.js starter from extracted design
169
184
  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)
185
+ grade <url> Generate a shareable HTML Design Report Card (--format html|md|json|svg|all, --badge, --open)
186
+ battle <urlA> <urlB> Head-to-head graded battle card (--format html|md|json|all, --open)
187
+ remix <url> Restyle in another vocabulary (--as brutalist|swiss|art-deco|cyberpunk|soft-ui|editorial, --all, --list, --open)
171
188
  watch <url> Monitor for design changes on interval
172
189
  diff <urlA> <urlB> Compare two sites' design languages
173
190
  brands <urls...> Multi-brand comparison matrix
@@ -43,9 +43,12 @@ import { syncDesign } from '../src/sync.js';
43
43
  import { compareBrands, formatBrandMatrix, formatBrandMatrixHtml } from '../src/multibrand.js';
44
44
  import { generateClone } from '../src/clone.js';
45
45
  import { watchSite } from '../src/watch.js';
46
- import { diffDarkMode } from '../src/darkdiff.js';
47
46
  import { applyDesign } from '../src/apply.js';
48
47
  import { formatGrade, formatGradeMarkdown } from '../src/formatters/grade.js';
48
+ import { formatBattle, formatBattleMarkdown } from '../src/formatters/battle.js';
49
+ import { formatScoreBadge } from '../src/formatters/badge.js';
50
+ import { formatRemix } from '../src/formatters/remix.js';
51
+ import { VOCABULARIES, getVocabulary, listVocabularies } from '../src/vocabularies/index.js';
49
52
  import { nameFromUrl } from '../src/utils.js';
50
53
 
51
54
  function validateUrl(url) {
@@ -941,7 +944,8 @@ program
941
944
  .description('Generate a shareable Design Report Card (HTML + JSON + Markdown)')
942
945
  .option('-o, --out <dir>', 'output directory', './design-extract-output')
943
946
  .option('-n, --name <name>', 'output file prefix (default: derived from URL)')
944
- .option('--format <fmt>', 'output format: html, md, json, all', 'all')
947
+ .option('--format <fmt>', 'output format: html, md, json, svg, all', 'all')
948
+ .option('--badge', 'also emit *-badge.svg (shields.io-style) — implies adding svg to format')
945
949
  .option('--open', 'open the HTML report in the default browser')
946
950
  .action(async (url, opts) => {
947
951
  if (!url.startsWith('http')) url = `https://${url}`;
@@ -957,6 +961,7 @@ program
957
961
  mkdirSync(outDir, { recursive: true });
958
962
  const prefix = opts.name || nameFromUrl(url);
959
963
  const written = [];
964
+ const wantSvg = opts.badge || opts.format === 'svg' || opts.format === 'all';
960
965
 
961
966
  if (opts.format === 'all' || opts.format === 'html') {
962
967
  const html = formatGrade(design, { version: PKG_VERSION });
@@ -984,6 +989,12 @@ program
984
989
  }, null, 2));
985
990
  written.push(p);
986
991
  }
992
+ if (wantSvg) {
993
+ const svg = formatScoreBadge(s);
994
+ const p = join(outDir, `${prefix}.grade.svg`);
995
+ writeFileSync(p, svg);
996
+ written.push(p);
997
+ }
987
998
 
988
999
  spinner.stop();
989
1000
  const gradeColor = s.grade === 'A' ? chalk.green : s.grade === 'B' ? chalk.cyan : s.grade === 'C' ? chalk.yellow : chalk.red;
@@ -1010,6 +1021,157 @@ program
1010
1021
  }
1011
1022
  });
1012
1023
 
1024
+ // ── Battle command — head-to-head graded comparison ────────
1025
+ program
1026
+ .command('battle <urlA> <urlB>')
1027
+ .description('Generate a head-to-head graded battle card (HTML + JSON + Markdown)')
1028
+ .option('-o, --out <dir>', 'output directory', './design-extract-output')
1029
+ .option('-n, --name <name>', 'output file prefix (default: a-vs-b)')
1030
+ .option('--format <fmt>', 'output format: html, md, json, all', 'all')
1031
+ .option('--open', 'open the battle card in the default browser')
1032
+ .action(async (urlA, urlB, opts) => {
1033
+ if (!urlA.startsWith('http')) urlA = `https://${urlA}`;
1034
+ if (!urlB.startsWith('http')) urlB = `https://${urlB}`;
1035
+ validateUrl(urlA);
1036
+ validateUrl(urlB);
1037
+
1038
+ const spinner = ora(`Auditing ${urlA} and ${urlB} in parallel...`).start();
1039
+ try {
1040
+ const [designA, designB] = await Promise.all([
1041
+ extractDesignLanguage(urlA),
1042
+ extractDesignLanguage(urlB),
1043
+ ]);
1044
+ if (!designA.score || !designB.score) throw new Error('scoring failed for one or both sites');
1045
+
1046
+ const outDir = resolve(opts.out);
1047
+ mkdirSync(outDir, { recursive: true });
1048
+ const prefix = opts.name || `${nameFromUrl(urlA)}-vs-${nameFromUrl(urlB)}`;
1049
+ const written = [];
1050
+
1051
+ if (opts.format === 'all' || opts.format === 'html') {
1052
+ const html = formatBattle(designA, designB, { version: PKG_VERSION });
1053
+ const p = join(outDir, `${prefix}.battle.html`);
1054
+ writeFileSync(p, html);
1055
+ written.push(p);
1056
+ }
1057
+ if (opts.format === 'all' || opts.format === 'md') {
1058
+ const md = formatBattleMarkdown(designA, designB);
1059
+ const p = join(outDir, `${prefix}.battle.md`);
1060
+ writeFileSync(p, md);
1061
+ written.push(p);
1062
+ }
1063
+ if (opts.format === 'all' || opts.format === 'json') {
1064
+ const p = join(outDir, `${prefix}.battle.json`);
1065
+ writeFileSync(p, JSON.stringify({
1066
+ a: { url: designA.meta?.url, grade: designA.score.grade, overall: designA.score.overall, scores: designA.score.scores },
1067
+ b: { url: designB.meta?.url, grade: designB.score.grade, overall: designB.score.overall, scores: designB.score.scores },
1068
+ timestamp: new Date().toISOString(),
1069
+ }, null, 2));
1070
+ written.push(p);
1071
+ }
1072
+
1073
+ spinner.stop();
1074
+ const aGrade = designA.score.grade, bGrade = designB.score.grade;
1075
+ const aColor = aGrade === 'A' ? chalk.green : aGrade === 'B' ? chalk.cyan : aGrade === 'C' ? chalk.yellow : chalk.red;
1076
+ const bColor = bGrade === 'A' ? chalk.green : bGrade === 'B' ? chalk.cyan : bGrade === 'C' ? chalk.yellow : chalk.red;
1077
+ console.log('');
1078
+ console.log(` ${aColor.bold(`${aGrade} · ${designA.score.overall}`)} ${chalk.gray(designA.meta?.url || urlA)}`);
1079
+ console.log(` ${chalk.gray('vs')}`);
1080
+ console.log(` ${bColor.bold(`${bGrade} · ${designB.score.overall}`)} ${chalk.gray(designB.meta?.url || urlB)}`);
1081
+ console.log('');
1082
+ const winner =
1083
+ designA.score.overall - designB.score.overall >= 3 ? `${chalk.bold(designA.meta?.url || urlA)} wins`
1084
+ : designB.score.overall - designA.score.overall >= 3 ? `${chalk.bold(designB.meta?.url || urlB)} wins`
1085
+ : 'Too close to call';
1086
+ console.log(` Verdict: ${winner}`);
1087
+ console.log('');
1088
+ for (const f of written) console.log(` ${chalk.green('✓')} ${chalk.gray(f)}`);
1089
+ console.log('');
1090
+
1091
+ if (opts.open) {
1092
+ const htmlPath = written.find(p => p.endsWith('.html'));
1093
+ if (htmlPath) {
1094
+ const { spawn } = await import('child_process');
1095
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1096
+ spawn(cmd, [htmlPath], { detached: true, stdio: 'ignore' }).unref();
1097
+ }
1098
+ }
1099
+ } catch (err) {
1100
+ spinner.fail('Battle failed');
1101
+ console.error(chalk.red(`\n ${err.message}\n`));
1102
+ process.exit(1);
1103
+ }
1104
+ });
1105
+
1106
+ // ── Remix command — restyle an extracted page in another vocabulary ─
1107
+ program
1108
+ .command('remix <url>')
1109
+ .description('Restyle a site in a different design vocabulary (brutalist, swiss, art-deco, cyberpunk, soft-ui, editorial)')
1110
+ .option('-o, --out <dir>', 'output directory', './design-extract-output')
1111
+ .option('-n, --name <name>', 'output file prefix (default: derived from URL)')
1112
+ .option('--as <vocab>', 'vocabulary id (run `designlang remix --list` to see all)', 'brutalist')
1113
+ .option('--list', 'list all vocabularies and exit')
1114
+ .option('--all', 'emit one HTML per vocabulary (six files at once)')
1115
+ .option('--open', 'open the result in the default browser')
1116
+ .action(async (url, opts) => {
1117
+ if (opts.list) {
1118
+ console.log('');
1119
+ console.log(chalk.bold(' Vocabularies'));
1120
+ console.log('');
1121
+ for (const v of listVocabularies()) {
1122
+ console.log(` ${chalk.cyan(v.id.padEnd(14))} ${chalk.gray(v.blurb)}`);
1123
+ }
1124
+ console.log('');
1125
+ console.log(chalk.gray(` Use: designlang remix <url> --as <id>`));
1126
+ console.log('');
1127
+ return;
1128
+ }
1129
+ if (!url.startsWith('http')) url = `https://${url}`;
1130
+ validateUrl(url);
1131
+
1132
+ const vocabIds = opts.all ? Object.keys(VOCABULARIES) : [opts.as];
1133
+ // Validate vocab early so we fail before extraction.
1134
+ for (const id of vocabIds) getVocabulary(id);
1135
+
1136
+ const spinner = ora(`Extracting ${url}...`).start();
1137
+ try {
1138
+ const design = await extractDesignLanguage(url);
1139
+
1140
+ const outDir = resolve(opts.out);
1141
+ mkdirSync(outDir, { recursive: true });
1142
+ const prefix = opts.name || nameFromUrl(url);
1143
+ const written = [];
1144
+
1145
+ for (const id of vocabIds) {
1146
+ spinner.text = `Rendering ${id}...`;
1147
+ const vocab = getVocabulary(id);
1148
+ const html = formatRemix(design, vocab, { vocabId: id, version: PKG_VERSION });
1149
+ const p = join(outDir, `${prefix}.remix.${id}.html`);
1150
+ writeFileSync(p, html);
1151
+ written.push(p);
1152
+ }
1153
+
1154
+ spinner.stop();
1155
+ console.log('');
1156
+ console.log(` ${chalk.bold('Remixed')} ${chalk.gray('·')} ${chalk.cyan(vocabIds.join(', '))} ${chalk.gray('·')} ${chalk.gray(url)}`);
1157
+ console.log('');
1158
+ for (const f of written) console.log(` ${chalk.green('✓')} ${chalk.gray(f)}`);
1159
+ console.log('');
1160
+ console.log(chalk.gray(` Open the .html in a browser. One file per vocabulary, fully self-contained.`));
1161
+ console.log('');
1162
+
1163
+ if (opts.open && written.length > 0) {
1164
+ const { spawn } = await import('child_process');
1165
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open';
1166
+ spawn(cmd, [written[0]], { detached: true, stdio: 'ignore' }).unref();
1167
+ }
1168
+ } catch (err) {
1169
+ spinner.fail('Remix failed');
1170
+ console.error(chalk.red(`\n ${err.message}\n`));
1171
+ process.exit(1);
1172
+ }
1173
+ });
1174
+
1013
1175
  // ── Apply command ──────────────────────────────────────────
1014
1176
  program
1015
1177
  .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.3.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": {
package/src/chat.js CHANGED
@@ -22,20 +22,6 @@ function isHex(s) {
22
22
  return typeof s === 'string' && /^#[0-9a-f]{3,8}$/i.test(s.trim());
23
23
  }
24
24
 
25
- function hexToRgb(hex) {
26
- const m = String(hex).trim().toLowerCase().replace(/^#/, '');
27
- const full = m.length === 3 ? m.split('').map((c) => c + c).join('') : m.slice(0, 6);
28
- return {
29
- r: parseInt(full.slice(0, 2), 16) || 0,
30
- g: parseInt(full.slice(2, 4), 16) || 0,
31
- b: parseInt(full.slice(4, 6), 16) || 0,
32
- };
33
- }
34
-
35
- function rgbToHex({ r, g, b }) {
36
- return '#' + [r, g, b].map((v) => Math.max(0, Math.min(255, Math.round(v))).toString(16).padStart(2, '0')).join('');
37
- }
38
-
39
25
  function opSharpenRadii(design, factor = 0.5) {
40
26
  const radii = design.borders?.radii || [];
41
27
  const next = radii.map((r) => ({ ...r, value: Math.max(0, Math.round((r.value || 0) * factor)) }));
@@ -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
+ }