@thebattlearena/corner 0.9.1-beta.70 → 0.10.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.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/ring/agents/cornerman.md +20 -3
  4. package/ring/agents/wrestler.md +11 -0
  5. package/ring/hooks/hooks.json +20 -0
  6. package/ring/scripts/bout-stats-render.mjs +366 -0
  7. package/ring/scripts/bout-stats.mjs +360 -0
  8. package/ring/scripts/config.mjs +1 -1
  9. package/ring/scripts/contract-endpoints.mjs +163 -0
  10. package/ring/scripts/contract-preview.mjs +34 -14
  11. package/ring/scripts/fight.mjs +27 -0
  12. package/ring/scripts/findings.mjs +3 -1
  13. package/ring/scripts/format-gate.mjs +723 -0
  14. package/ring/scripts/format-worker.mjs +120 -0
  15. package/ring/scripts/front-routes.mjs +345 -0
  16. package/ring/scripts/judges-card.mjs +324 -3
  17. package/ring/scripts/lookout.mjs +22 -4
  18. package/ring/scripts/mask-guard.mjs +34 -1
  19. package/ring/scripts/page-embed.mjs +112 -0
  20. package/ring/scripts/play-by-play.mjs +155 -7
  21. package/ring/scripts/preparation-collect.mjs +177 -0
  22. package/ring/scripts/preparation.mjs +547 -0
  23. package/ring/scripts/round-bell.mjs +9 -2
  24. package/ring/scripts/tech-plan-log.mjs +81 -0
  25. package/ring/scripts/tech-plan.mjs +191 -0
  26. package/ring/scripts/verdicts-log.mjs +75 -0
  27. package/ring/scripts/verdicts.mjs +205 -0
  28. package/ring/skills/play-by-play/SKILL.md +235 -33
  29. package/ring/skills/play-by-play/templates/blocks.css +196 -0
  30. package/ring/skills/play-by-play/templates/chronicle.html +358 -55
  31. package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
  32. package/ring/skills/play-by-play/templates/interview-form.js +516 -0
  33. package/ring/skills/play-by-play/templates/now-panel.js +719 -0
  34. package/ring/skills/referee/SKILL.md +136 -42
  35. package/ring/skills/referee/references/api-diagram.md +56 -0
  36. package/ring/skills/referee/references/precedents.md +11 -0
  37. package/ring/skills/referee/references/rulebook.md +65 -4
  38. package/ring/skills/referee/templates/api-diagram.html +115 -0
  39. package/ring/skills/referee/templates/escalation.md +7 -1
  40. package/ring/skills/referee/templates/round-bell-body.md +4 -0
  41. package/ring/skills/referee/templates/wrestler-brief.md +3 -0
  42. package/ring/skills/sparring/SKILL.md +10 -2
  43. package/ring/skills/sparring/templates/contrato.md +29 -0
  44. package/ring/skills/sparring/templates/rudo-brief.md +7 -0
  45. package/ring/skills/weigh-in/SKILL.md +6 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thebattlearena/corner",
3
- "version": "0.9.1-beta.70",
3
+ "version": "0.10.0",
4
4
  "description": "Corner: tu esquina de Battle Arena en Claude Code (el plugin `battlearena`). Hooks de tracking, objetivo del turno, plan de batalla /battlearena:conquer, Ringside (el panel de tu día), el ring y la radio. Zero-dependency.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "battlearena",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Corner, tu esquina de Battle Arena en Claude Code: tus sesiones al colector, el objetivo del turno, el plan de batalla /battlearena:conquer y Ringside, el panel de tu día junto al chat.",
5
5
  "author": {
6
6
  "name": "Battle Arena"
@@ -38,6 +38,15 @@ Per commit of the range (`git -C <wt> show <sha>`). Output, at most **15 lines**
38
38
 
39
39
  BLOCKERS first. A commit that complies gets "<sha>: no findings".
40
40
 
41
+ **Format, in every mode:** `node "<ring>/scripts/format-gate.mjs" check <wt> --range <A>..<B>`
42
+ (read-only: it formats a copy of each file of the range as it is at B and compares; never
43
+ `sort-imports --check`, which reports false positives). Each file in `unclean` is one finding:
44
+ `<file> — format — not as sort-imports + prettier leave it — the wrestler runs format-gate fix
45
+ --range <A>..<B> and commits the result`. **Exit 2 (`verified: false`) is NOT VERIFIED, never
46
+ clean:** sort-imports or prettier is not installed (`missing`), so the files in `files` were not
47
+ checked. Report it as its own line, `format — NO VERIFICADO — falta <missing> — <files>`, never as
48
+ "no findings".
49
+
41
50
  ## Mode: concept
42
51
 
43
52
  A deep review when a concept closes. The brief lists the commits with what each claims to do and a
@@ -71,12 +80,20 @@ bout's venue). For a `backend` bout they are these 8:
71
80
 
72
81
  1. Layer and dependency direction.
73
82
  2. Coupling.
74
- 3. Duplication, looking at both places.
83
+ 3. Duplication, looking at both places: the same condition in several files is a finding even
84
+ when each piece works (R11); repeated conditionals by type, state or provider come with the
85
+ pattern that removes them or the reason none is simpler (R22).
75
86
  4. Single responsibility and the rest of SOLID.
76
- 5. Domain model (rich or anemic, value objects, invariants where they belong).
87
+ 5. Domain model (rich or anemic, value objects, invariants where they belong): every new file
88
+ with its business sentence (R15), the backend shaped by the business and not by a screen
89
+ (R16), every optional with its reason (R17), aggregates born valid (R18), no primitives
90
+ crossing commands, events or ports (R19), no new endpoint an existing one covers (R20), names
91
+ that do not collide with another context (R21). A deviation from a pattern the host's rules
92
+ fix is a BLOCKER (P14).
77
93
  6. Errors: does it fail open or closed, who catches what.
78
94
  7. Expressiveness: names, more than 3 parameters, control booleans.
79
- 8. Production risk: concurrency, idempotency, volume, reversibility.
95
+ 8. Production risk: concurrency, idempotency, volume, reversibility, and a new infrastructure
96
+ resource counted against the provider's quota over the base (F13).
80
97
 
81
98
  Return per file the findings with rule and precedent ids, then the **open design questions**,
82
99
  each with two options, their cost and your recommendation, numbered so they can be answered as
@@ -80,6 +80,17 @@ edit after approval needs a new one.
80
80
  staged, run the gates on a copy of the index under `<bout dir>/tools/`
81
81
  (`git checkout-index -a --prefix=<bout dir>/tools/staged/`), never by stashing the rest: the
82
82
  stash is shared by every worktree on the machine, and `stash@{0}` may be another session's.
83
+ **Then the format gate, always, last before staging:**
84
+ `node "<ring>/scripts/format-gate.mjs" fix <worktree>` (sort-imports with the repo's tsconfig,
85
+ then prettier, on the changed files; the repo's own `fix:changed` when it has one). Ring also
86
+ formats every file you edit or write from the shell while the bout is active, but a commit
87
+ never relies on that: run `fix` and stage what it left. If it says the binaries are not
88
+ installed, install the dependencies in your worktree or tell the referee; never commit without
89
+ it.
90
+ When a rebound says files already committed are not formatted, in this order: run
91
+ `format-gate.mjs fix <worktree> --range <base>..HEAD`, commit what it changed, and only then
92
+ `format-gate.mjs check <worktree> --range <base>..HEAD`. A check before that commit still
93
+ exits 1: it reads the range as committed, not the worktree.
83
94
  5. Commits: stage by explicit paths (never `git add -A` or `.`), a conventional message that says
84
95
  the business reason, in plain words, in the language of the branch's previous commits (read
85
96
  `git log` once before the first commit and keep it for the whole bout): never bout ids (D12, P7,
@@ -22,6 +22,26 @@
22
22
  }
23
23
  ],
24
24
  "PostToolUse": [
25
+ {
26
+ "matcher": "Edit|Write|MultiEdit",
27
+ "hooks": [
28
+ {
29
+ "type": "command",
30
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/format-gate.mjs\" edit-hook",
31
+ "timeout": 30
32
+ }
33
+ ]
34
+ },
35
+ {
36
+ "matcher": "Bash",
37
+ "hooks": [
38
+ {
39
+ "type": "command",
40
+ "command": "node \"${CLAUDE_PLUGIN_ROOT}/scripts/format-gate.mjs\" shell-hook",
41
+ "timeout": 60
42
+ }
43
+ ]
44
+ },
25
45
  {
26
46
  "matcher": "Agent|Task|AskUserQuestion|Bash",
27
47
  "hooks": [
@@ -0,0 +1,366 @@
1
+ // Renders the numbers of bout-stats.mjs as the "El cierre en números" block of the chronicle, in
2
+ // the Battle Arena voice: a weigh-in (code against tests), round by round between the ropes, the
3
+ // fight clock (24 h), a CompuBox of punch types, the referee's scorecards and the highlight reel.
4
+ // Inline SVG and HTML only, colored through the template's tokens (classes cn-*), so light and dark
5
+ // both work and nothing loads from outside. PURE and zero-dependency.
6
+
7
+ const LABELS = {
8
+ es: {
9
+ title: 'El cierre en números',
10
+ tiles: {
11
+ duration: 'de pelea, del primer registro al último',
12
+ commits: 'commits del luchador',
13
+ rounds: 'rounds con su campana',
14
+ lines: 'líneas agregadas',
15
+ },
16
+ weighIn: 'La báscula',
17
+ weighInNote: (ratio) =>
18
+ `${ratio} líneas de prueba por cada línea de código.`,
19
+ red: 'Esquina roja',
20
+ blue: 'Esquina azul',
21
+ code: 'Código',
22
+ tests: 'Pruebas',
23
+ lockfile: 'Lockfile',
24
+ other: 'Config, scripts y assets',
25
+ deadWeight: 'Peso muerto',
26
+ linesUnit: 'líneas',
27
+ rounds: 'Round por round',
28
+ roundsNote: 'Commits por día, por fecha del commit.',
29
+ star: 'Round estelar',
30
+ clock: 'El reloj de la pelea',
31
+ clockNote: 'Commits por hora del día. La franja sombreada es la madrugada.',
32
+ peak: 'hora pico',
33
+ night: 'de madrugada (00–06)',
34
+ bands: ['Madrugada 00–06', 'Mañana 06–12', 'Tarde 12–18', 'Noche 18–24'],
35
+ compubox: 'CompuBox',
36
+ compuboxNote: 'Cada commit, por su tipo de golpe.',
37
+ types: {
38
+ feat: 'Golpes de poder',
39
+ fix: 'Jabs',
40
+ refactor: 'Juego de piernas',
41
+ test: 'Guardia',
42
+ docs: 'Esquina',
43
+ perf: 'Velocidad',
44
+ style: 'Estilo',
45
+ build: 'Calentamiento',
46
+ ci: 'Campana',
47
+ chore: 'Vendajes',
48
+ other: 'Otros',
49
+ },
50
+ card: 'La tarjeta del árbitro',
51
+ decisions: 'decisiones registradas',
52
+ judge: (n) => `${n} del juez`,
53
+ contracts: 'contratos firmados por el juez',
54
+ reboundsLabel: 'puntos de rebote',
55
+ reboundsSub: (closed, open) => `${closed} cerrados · ${open} abiertos`,
56
+ findings: 'hallazgos de esquinero',
57
+ findingsSub: (f) =>
58
+ `${f.applied} aplicados · ${f.discarded} descartados · ${f.ticket} a ticket`,
59
+ fouls: 'faltas que el árbitro se marcó',
60
+ reel: 'Mejores momentos',
61
+ commitsWord: 'commits',
62
+ tags: {
63
+ reclamo: 'Reclamo',
64
+ dado: 'Golpe dado',
65
+ recibido: 'Golpe recibido',
66
+ falta: 'Falta',
67
+ juez: 'Juez',
68
+ arbitro: 'Árbitro',
69
+ bateria: 'Batería',
70
+ campana: 'Campana',
71
+ },
72
+ weekday: 'es-MX',
73
+ },
74
+ en: {
75
+ title: 'The bout in numbers',
76
+ tiles: {
77
+ duration: 'of fighting, first record to last',
78
+ commits: "the wrestler's commits",
79
+ rounds: 'rounds with their bell',
80
+ lines: 'lines added',
81
+ },
82
+ weighIn: 'The weigh-in',
83
+ weighInNote: (ratio) => `${ratio} lines of tests for every line of code.`,
84
+ red: 'Red corner',
85
+ blue: 'Blue corner',
86
+ code: 'Code',
87
+ tests: 'Tests',
88
+ lockfile: 'Lockfile',
89
+ other: 'Config, scripts and assets',
90
+ deadWeight: 'Dead weight',
91
+ linesUnit: 'lines',
92
+ rounds: 'Round by round',
93
+ roundsNote: 'Commits per day, by commit date.',
94
+ star: 'Main event',
95
+ clock: 'The fight clock',
96
+ clockNote:
97
+ 'Commits per hour of the day. The shaded band is the small hours.',
98
+ peak: 'peak hour',
99
+ night: 'in the small hours (00–06)',
100
+ bands: [
101
+ 'Small hours 00–06',
102
+ 'Morning 06–12',
103
+ 'Afternoon 12–18',
104
+ 'Night 18–24',
105
+ ],
106
+ compubox: 'CompuBox',
107
+ compuboxNote: 'Every commit, by its punch type.',
108
+ types: {
109
+ feat: 'Power punches',
110
+ fix: 'Jabs',
111
+ refactor: 'Footwork',
112
+ test: 'Guard',
113
+ docs: 'Corner',
114
+ perf: 'Speed',
115
+ style: 'Style',
116
+ build: 'Warm-up',
117
+ ci: 'Bell',
118
+ chore: 'Hand wraps',
119
+ other: 'Other',
120
+ },
121
+ card: "The referee's card",
122
+ decisions: 'decisions logged',
123
+ judge: (n) => `${n} by the judge`,
124
+ contracts: 'contracts the judge signed',
125
+ reboundsLabel: 'rebound points',
126
+ reboundsSub: (closed, open) => `${closed} closed · ${open} open`,
127
+ findings: 'cornerman findings',
128
+ findingsSub: (f) =>
129
+ `${f.applied} applied · ${f.discarded} discarded · ${f.ticket} ticketed`,
130
+ fouls: 'fouls the referee called on itself',
131
+ reel: 'Highlight reel',
132
+ commitsWord: 'commits',
133
+ tags: {
134
+ reclamo: 'Claim',
135
+ dado: 'Blow landed',
136
+ recibido: 'Blow taken',
137
+ falta: 'Foul',
138
+ juez: 'Judge',
139
+ arbitro: 'Referee',
140
+ bateria: 'Battery',
141
+ campana: 'Bell',
142
+ },
143
+ weekday: 'en-US',
144
+ },
145
+ };
146
+
147
+ const esc = (text) =>
148
+ String(text)
149
+ .replaceAll('&', '&amp;')
150
+ .replaceAll('<', '&lt;')
151
+ .replaceAll('>', '&gt;')
152
+ .replaceAll('"', '&quot;');
153
+ const fmt = (n, locale) => new Intl.NumberFormat(locale).format(n);
154
+ const r1 = (n) => Math.round(n * 10) / 10;
155
+
156
+ function tiles(stats, t, locale) {
157
+ const tile = (value, label) =>
158
+ value === null || value === undefined
159
+ ? ''
160
+ : `<div><b>${esc(value)}</b><span>${esc(label)}</span></div>`;
161
+ return `<div class="cn-tiles cn-cards">${[
162
+ tile(stats.duration.label, t.tiles.duration),
163
+ tile(stats.rhythm && fmt(stats.rhythm.total, locale), t.tiles.commits),
164
+ tile(stats.book.roundBells, t.tiles.rounds),
165
+ tile(stats.lines && fmt(stats.lines.added, locale), t.tiles.lines),
166
+ ].join('')}</div>`;
167
+ }
168
+
169
+ function weighIn({ buckets }, t, locale) {
170
+ const code = buckets.code.added;
171
+ const tests = buckets.tests.added;
172
+ const both = code + tests || 1;
173
+ const ratio = code
174
+ ? new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(
175
+ tests / code,
176
+ )
177
+ : '0';
178
+ const dead = [
179
+ [t.lockfile, buckets.lockfile.added],
180
+ [t.other, buckets.other.added],
181
+ ].filter(([, n]) => n);
182
+ const corner = (side, label, name, n) =>
183
+ `<div class="cn-corner cn-${side}"><span class="cn-corner-label">${esc(label)}</span><b>${fmt(n, locale)}</b><span>${esc(name)} · ${esc(t.linesUnit)}</span></div>`;
184
+ return `<h4 class="cn-h">${esc(t.weighIn)}</h4><p class="cn-note">${esc(t.weighInNote(ratio))}</p>
185
+ <div class="cn-weigh">
186
+ <div class="cn-weigh-top">${corner('red', t.red, t.code, code)}<div class="cn-vs">VS</div>${corner('blue', t.blue, t.tests, tests)}</div>
187
+ <div class="cn-tape" role="img" aria-label="${esc(`${t.code} ${code}, ${t.tests} ${tests}`)}"><i class="cn-tape-red" style="width:${r1((code * 100) / both)}%" title="${esc(`${t.code}: ${fmt(code, locale)}`)}"></i><i class="cn-tape-blue" style="width:${r1((tests * 100) / both)}%" title="${esc(`${t.tests}: ${fmt(tests, locale)}`)}"></i></div>
188
+ ${dead.length ? `<div class="cn-dead"><span>${esc(t.deadWeight)}</span>${dead.map(([name, n]) => `<span>${esc(name)} <b>${fmt(n, locale)}</b></span>`).join('')}</div>` : ''}
189
+ </div>`;
190
+ }
191
+
192
+ function roundByRound(byDay, t, locale) {
193
+ const W = 640;
194
+ const H = 262;
195
+ const top = 72;
196
+ const base = 218;
197
+ const max = Math.max(1, ...byDay.map((d) => d.n));
198
+ const slot = (W - 60) / byDay.length;
199
+ const bar = Math.min(64, slot * 0.6);
200
+ const star = byDay.findIndex((d) => d.n === max);
201
+ const ropes = [0.25, 0.5, 0.75]
202
+ .map(
203
+ (f, i) =>
204
+ `<line class="cn-rope cn-rope-${i}" x1="22" x2="${W - 22}" y1="${r1(base - f * (base - top))}" y2="${r1(base - f * (base - top))}"/>`,
205
+ )
206
+ .join('');
207
+ const label = (day) =>
208
+ new Intl.DateTimeFormat(t.weekday, {
209
+ weekday: 'short',
210
+ day: 'numeric',
211
+ timeZone: 'UTC',
212
+ }).format(new Date(`${day}T12:00:00Z`));
213
+ const bars = byDay
214
+ .map((d, i) => {
215
+ const h = (d.n / max) * (base - top);
216
+ const x = 30 + slot * i + (slot - bar) / 2;
217
+ const y = base - h;
218
+ const cls = `cn-bar cn-fill-${i % 5}${i === star ? ' cn-bar-star' : ''}`;
219
+ return `<g><title>${esc(`${label(d.day)}: ${fmt(d.n, locale)} ${t.commitsWord}`)}</title><path class="${cls}" d="M${r1(x)},${base} V${r1(y + Math.min(4, h))} q0,-4 4,-4 h${r1(bar - 8)} q4,0 4,4 V${base} Z"/>${
220
+ d.n
221
+ ? `<text class="cn-val" x="${r1(x + bar / 2)}" y="${r1(y - 8)}">${fmt(d.n, locale)}</text>`
222
+ : ''
223
+ }<text class="cn-axis" x="${r1(x + bar / 2)}" y="${base + 22}">${esc(label(d.day))}</text>${
224
+ i === star
225
+ ? `<text class="cn-star" x="${r1(x + bar / 2)}" y="${r1(y - 40)}">${esc(t.star)}</text>`
226
+ : ''
227
+ }</g>`;
228
+ })
229
+ .join('');
230
+ return `<h4 class="cn-h">${esc(t.rounds)}</h4><p class="cn-note">${esc(t.roundsNote)}</p>
231
+ <div class="cn-chart"><svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(t.rounds)}">${ropes}<rect class="cn-post" x="14" y="${top - 6}" width="8" height="${base - top + 6}" rx="2"/><rect class="cn-post" x="${W - 22}" y="${top - 6}" width="8" height="${base - top + 6}" rx="2"/><line class="cn-canvas-line" x1="14" x2="${W - 14}" y1="${base}" y2="${base}"/>${bars}</svg></div>`;
232
+ }
233
+
234
+ function fightClock(byHour, t, locale) {
235
+ const C = 170;
236
+ const r0 = 62;
237
+ const len = 92;
238
+ const max = Math.max(1, ...byHour);
239
+ const peak = byHour.indexOf(max);
240
+ const point = (r, deg) => {
241
+ const a = ((deg - 90) * Math.PI) / 180;
242
+ return [r1(C + r * Math.cos(a)), r1(C + r * Math.sin(a))];
243
+ };
244
+ const wedge = (ri, ro, from, to) => {
245
+ const [x1, y1] = point(ri, from);
246
+ const [x2, y2] = point(ro, from);
247
+ const [x3, y3] = point(ro, to);
248
+ const [x4, y4] = point(ri, to);
249
+ const large = to - from > 180 ? 1 : 0;
250
+ return `M${x1},${y1} L${x2},${y2} A${ro},${ro} 0 ${large} 1 ${x3},${y3} L${x4},${y4} A${ri},${ri} 0 ${large} 0 ${x1},${y1} Z`;
251
+ };
252
+ const night = `<path class="cn-night" d="${wedge(r0, r0 + len + 4, 0, 90)}"/>`;
253
+ const hours = byHour
254
+ .map((n, h) => {
255
+ const ro = r0 + 3 + (n / max) * len;
256
+ const cls = `cn-hour cn-band-${Math.floor(h / 6)}${h === peak ? ' cn-hour-peak' : ''}`;
257
+ return n
258
+ ? `<path class="${cls}" d="${wedge(r0 + 3, ro, h * 15 + 2, h * 15 + 13)}"><title>${String(h).padStart(2, '0')}:00 · ${fmt(n, locale)} ${t.commitsWord}</title></path>`
259
+ : '';
260
+ })
261
+ .join('');
262
+ const ticks = [0, 6, 12, 18]
263
+ .map((h) => {
264
+ const [x, y] = point(r0 + len + 18, h * 15 + 7.5);
265
+ return `<text class="cn-axis" x="${x}" y="${y + 4}">${String(h).padStart(2, '0')}</text>`;
266
+ })
267
+ .join('');
268
+ const bandCounts = [0, 1, 2, 3].map((band) =>
269
+ byHour.slice(band * 6, band * 6 + 6).reduce((a, b) => a + b, 0),
270
+ );
271
+ const nightCount = bandCounts[0];
272
+ const legend = bandCounts
273
+ .map(
274
+ (n, band) =>
275
+ `<li><i class="cn-band-${band}"></i>${esc(t.bands[band])}<b>${fmt(n, locale)}</b></li>`,
276
+ )
277
+ .join('');
278
+ return `<h4 class="cn-h">${esc(t.clock)}</h4><p class="cn-note">${esc(t.clockNote)}</p>
279
+ <div class="cn-chart cn-clock-wrap"><svg class="cn-clock" viewBox="-24 -24 388 388" role="img" aria-label="${esc(t.clock)}"><circle class="cn-face" cx="${C}" cy="${C}" r="${r0 + len + 4}"/>${night}<circle class="cn-hub" cx="${C}" cy="${C}" r="${r0}"/>${hours}${ticks}<text class="cn-clock-big" x="${C}" y="${C + 6}">${String(peak).padStart(2, '0')}:00</text><text class="cn-clock-sub" x="${C}" y="${C + 28}">${esc(t.peak)}</text></svg>
280
+ <div class="cn-clock-facts"><div><b>${fmt(max, locale)}</b><span>${esc(t.commitsWord)} · ${String(peak).padStart(2, '0')}:00, ${esc(t.peak)}</span></div><div><b>${fmt(nightCount, locale)}</b><span>${esc(t.commitsWord)} ${esc(t.night)}</span></div><ul class="cn-bands">${legend}</ul></div></div>`;
281
+ }
282
+
283
+ function compuBox(byType, total, t, locale) {
284
+ const max = Math.max(1, ...byType.map((x) => x.n));
285
+ const rows = byType
286
+ .map(({ type, n }) => {
287
+ const cls =
288
+ {
289
+ feat: 'cn-fill-0',
290
+ fix: 'cn-fill-1',
291
+ refactor: 'cn-fill-2',
292
+ test: 'cn-fill-3',
293
+ docs: 'cn-fill-4',
294
+ }[type] ?? 'cn-ink';
295
+ const pct = total ? Math.round((n * 100) / total) : 0;
296
+ return `<div class="cn-box-row" title="${esc(`${t.types[type]} (${type}): ${fmt(n, locale)}`)}"><span class="cn-box-name">${esc(t.types[type])}<code>${esc(type)}</code></span><span class="cn-box-track"><i class="${cls}" style="width:${r1((n * 100) / max)}%"></i></span><b>${fmt(n, locale)}</b><span class="cn-box-pct">${pct}%</span></div>`;
297
+ })
298
+ .join('');
299
+ return `<h4 class="cn-h">${esc(t.compubox)}</h4><p class="cn-note">${esc(t.compuboxNote)}</p><div class="cn-chart cn-box">${rows}</div>`;
300
+ }
301
+
302
+ function refereeCard(book, t, locale) {
303
+ const f = book.findings;
304
+ let next = 0;
305
+ const card = (n, label, sub, cls = `cn-head-${next++ % 5}`) =>
306
+ `<div class="cn-score ${cls}"><span class="cn-score-head">${esc(label)}</span><b>${fmt(n, locale)}</b>${sub ? `<span>${esc(sub)}</span>` : ''}</div>`;
307
+ return `<h4 class="cn-h">${esc(t.card)}</h4><div class="cn-scores">${[
308
+ card(book.decisions, t.decisions, t.judge(book.judgeDecisions)),
309
+ card(
310
+ book.rebounds.total,
311
+ t.reboundsLabel,
312
+ t.reboundsSub(
313
+ book.rebounds.closed,
314
+ book.rebounds.total - book.rebounds.closed,
315
+ ),
316
+ ),
317
+ f.total ? card(f.total, t.findings, t.findingsSub(f)) : '',
318
+ book.contracts ? card(book.contracts, t.contracts, '') : '',
319
+ card(book.fouls, t.fouls, '', 'cn-score-foul'),
320
+ ].join('')}</div>`;
321
+ }
322
+
323
+ function reel(moments, timeZone, t) {
324
+ if (!moments.length) return '';
325
+ const first = Date.parse(moments[0].at);
326
+ const span = Math.max(1, Date.parse(moments.at(-1).at) - first);
327
+ const when = (at) =>
328
+ new Intl.DateTimeFormat(t.weekday, {
329
+ timeZone,
330
+ day: 'numeric',
331
+ hour: '2-digit',
332
+ minute: '2-digit',
333
+ hour12: false,
334
+ }).format(new Date(at));
335
+ const marks = moments
336
+ .map(
337
+ (m, i) =>
338
+ `<span class="cn-bell cn-fill-${i % 5}" style="left:${r1(3 + ((Date.parse(m.at) - first) * 94) / span)}%" title="${esc(`${when(m.at)} · ${m.title}`)}">${i + 1}</span>`,
339
+ )
340
+ .join('');
341
+ const items = moments
342
+ .map(
343
+ (m, i) =>
344
+ `<li><span class="cn-reel-n">${i + 1}</span><time>${esc(when(m.at))}</time><p><span class="tag t-${esc(m.tag)}">${esc(t.tags[m.tag] ?? m.tag)}</span>${esc(m.title)}</p></li>`,
345
+ )
346
+ .join('');
347
+ return `<h4 class="cn-h">${esc(t.reel)}</h4><div class="cn-reel"><div class="cn-reel-line">${marks}</div><ol>${items}</ol></div>`;
348
+ }
349
+
350
+ /** The whole block. `stats` is collectStats()'s output; moments come from readMoments(). PURE. */
351
+ export function renderStats(stats, { lang = 'es', moments = [] } = {}) {
352
+ const t = LABELS[lang] ?? LABELS.es;
353
+ const locale = lang === 'en' ? 'en-US' : 'es-MX';
354
+ return `<section class="round cn">
355
+ <div class="cn-picado" aria-hidden="true"></div>
356
+ <h2>${esc(t.title)}</h2>
357
+ ${tiles(stats, t, locale)}
358
+ ${stats.lines ? weighIn(stats.lines, t, locale) : ''}
359
+ ${stats.rhythm?.byDay.length ? roundByRound(stats.rhythm.byDay, t, locale) : ''}
360
+ ${stats.rhythm?.total ? fightClock(stats.rhythm.byHour, t, locale) : ''}
361
+ ${stats.rhythm?.total ? compuBox(stats.rhythm.byType, stats.rhythm.total, t, locale) : ''}
362
+ ${refereeCard(stats.book, t, locale)}
363
+ ${reel(moments, stats.timezone, t)}
364
+ <!-- CHANGELOG: the skill writes <h4 class="cn-h"> and <ul class="cn-changelog"> here -->
365
+ </section>`;
366
+ }