@seanmars/tospec 0.23.0 → 0.24.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 (59) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README.md +17 -3
  3. package/assets/claude-mod/tospec-dashboard/.claude-plugin/plugin.json +7 -0
  4. package/assets/claude-mod/tospec-dashboard/hooks/hooks.json +1 -0
  5. package/assets/claude-mod/tospec-dashboard/hooks/register.ts +695 -0
  6. package/assets/claude-mod/tospec-dashboard/tests/register.test.ts +448 -0
  7. package/assets/claude-mod/tospec-dashboard/types/index.d.ts +8 -0
  8. package/assets/dashboard/app.js +218 -1180
  9. package/assets/dashboard/index.html +31 -22
  10. package/assets/dashboard/lib/charts.js +355 -0
  11. package/assets/dashboard/lib/derive.js +369 -0
  12. package/assets/dashboard/lib/dom.js +121 -0
  13. package/assets/dashboard/lib/routes.js +52 -0
  14. package/assets/dashboard/lib/store.js +324 -0
  15. package/assets/dashboard/style.css +3103 -664
  16. package/assets/dashboard/views/alerts.js +170 -0
  17. package/assets/dashboard/views/change.js +232 -0
  18. package/assets/dashboard/views/docs.js +214 -0
  19. package/assets/dashboard/views/home.js +357 -0
  20. package/assets/dashboard/views/project.js +168 -0
  21. package/assets/dashboard/views/session.js +270 -0
  22. package/assets/dashboard/views/sidebar.js +131 -0
  23. package/dist/cli/index.d.ts.map +1 -1
  24. package/dist/cli/index.js +74 -95
  25. package/dist/cli/index.js.map +1 -1
  26. package/dist/commands/dashboard-relay.d.ts +11 -0
  27. package/dist/commands/dashboard-relay.d.ts.map +1 -0
  28. package/dist/commands/dashboard-relay.js +88 -0
  29. package/dist/commands/dashboard-relay.js.map +1 -0
  30. package/dist/commands/dashboard.d.ts +62 -102
  31. package/dist/commands/dashboard.d.ts.map +1 -1
  32. package/dist/commands/dashboard.js +375 -609
  33. package/dist/commands/dashboard.js.map +1 -1
  34. package/dist/core/dashboard-hub.d.ts +258 -0
  35. package/dist/core/dashboard-hub.d.ts.map +1 -0
  36. package/dist/core/dashboard-hub.js +643 -0
  37. package/dist/core/dashboard-hub.js.map +1 -0
  38. package/dist/core/dashboard-routes.d.ts +56 -0
  39. package/dist/core/dashboard-routes.d.ts.map +1 -0
  40. package/dist/core/dashboard-routes.js +329 -0
  41. package/dist/core/dashboard-routes.js.map +1 -0
  42. package/dist/core/init.d.ts.map +1 -1
  43. package/dist/core/init.js +4 -1
  44. package/dist/core/init.js.map +1 -1
  45. package/dist/core/local-server.d.ts +1 -1
  46. package/dist/core/local-server.js +1 -1
  47. package/dist/core/shared/dashboard-mod.d.ts +24 -0
  48. package/dist/core/shared/dashboard-mod.d.ts.map +1 -0
  49. package/dist/core/shared/dashboard-mod.js +44 -0
  50. package/dist/core/shared/dashboard-mod.js.map +1 -0
  51. package/dist/core/shared/index.d.ts +1 -0
  52. package/dist/core/shared/index.d.ts.map +1 -1
  53. package/dist/core/shared/index.js +1 -0
  54. package/dist/core/shared/index.js.map +1 -1
  55. package/dist/core/update.d.ts.map +1 -1
  56. package/dist/core/update.js +4 -1
  57. package/dist/core/update.js.map +1 -1
  58. package/package.json +3 -2
  59. package/assets/dashboard/chart.umd.js +0 -14
@@ -1,34 +1,43 @@
1
1
  <!doctype html>
2
- <html lang="en">
2
+ <html lang="zh-Hant-TW">
3
3
  <head>
4
4
  <meta charset="utf-8" />
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1" />
6
- <title>tospec dashboard</title>
6
+ <meta name="color-scheme" content="light dark" />
7
+ <title>tospec</title>
8
+ <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Ccircle cx='8' cy='8' r='7.5' fill='%23262b30'/%3E%3Ccircle cx='8' cy='8' r='3.6' fill='none' stroke='%23fff' stroke-width='2.4'/%3E%3C/svg%3E" />
7
9
  <link rel="stylesheet" href="/assets/style.css" />
8
10
  </head>
9
11
  <body>
10
- <header class="topbar">
11
- <div class="topbar-left">
12
- <h1 class="brand">tospec <span class="muted">dashboard</span></h1>
13
- <nav class="tabs">
14
- <a href="#/" class="tab" data-tab="overview">Overview</a>
15
- <a href="#/decisions" class="tab" data-tab="decisions">Decisions</a>
16
- </nav>
17
- <span id="root-path" class="root-path mono"></span>
18
- </div>
19
- <div class="topbar-right">
20
- <span id="conn-status" class="conn"></span>
21
- <select id="theme-select" title="Theme">
22
- <option value="system">System</option>
23
- <option value="light">Light</option>
24
- <option value="dark">Dark</option>
25
- <option value="dracula">Dracula</option>
26
- </select>
27
- <button id="refresh-btn" type="button" title="Refresh">&#x21bb; Refresh</button>
12
+ <header class="signbar" id="signbar">
13
+ <button id="nav-toggle" type="button" class="sign-control sign-button nav-toggle" aria-controls="sidebar" aria-expanded="false" title="Projects"></button>
14
+ <a class="wordmark" href="#/" aria-label="tospec 總覽">tospec</a>
15
+ <nav class="crumbs-nav" aria-label="目前位置">
16
+ <ol id="crumbs" class="crumbs"></ol>
17
+ <a id="crumb-back" class="crumb-back" hidden></a>
18
+ </nav>
19
+ <div class="signbar-tools">
20
+ <span id="hub-status" class="hub-status" role="status"></span>
21
+ <label class="theme-field">
22
+ <span class="sr-only">主題</span>
23
+ <select id="theme-select" class="sign-control">
24
+ <option value="system">跟隨系統</option>
25
+ <option value="light">淺色</option>
26
+ <option value="dark">深色</option>
27
+ <option value="dracula">Dracula</option>
28
+ </select>
29
+ </label>
30
+ <button id="notify-btn" type="button" class="sign-control sign-button" aria-pressed="false"></button>
31
+ <button id="refresh-btn" type="button" class="sign-control sign-button" title="向連線中的 session 要求新快照">重新整理</button>
28
32
  </div>
29
33
  </header>
30
- <main id="view" class="view"></main>
31
- <script src="/assets/chart.umd.js"></script>
34
+ <div class="shell">
35
+ <aside id="sidebar" class="sidebar" aria-label="Projects"></aside>
36
+ <main id="view" class="view" tabindex="-1"></main>
37
+ </div>
38
+ <div id="nav-scrim" class="nav-scrim" hidden></div>
39
+ <aside id="session-panel" class="session-panel" hidden></aside>
40
+ <div id="toasts" class="toasts" aria-live="polite"></div>
32
41
  <script type="module" src="/assets/app.js"></script>
33
42
  </body>
34
43
  </html>
@@ -0,0 +1,355 @@
1
+ // The project page's four charts, drawn as SVG at the container's real pixel
2
+ // width (redrawn on resize) so text stays at its set size instead of scaling
3
+ // with a viewBox. Colours are CSS classes reading theme variables, so a theme
4
+ // switch restyles a drawn chart without redrawing it.
5
+
6
+ import { el, svg, icon } from './dom.js';
7
+ import { parseLooseDate, formatDate, weeklyArchiveCounts, relTime } from './derive.js';
8
+ import { routes } from './routes.js';
9
+
10
+ const DAY_MS = 86400000;
11
+ const observers = new Set();
12
+
13
+ /** Called before every route render: charts from the previous view stop observing. */
14
+ export function releaseCharts() {
15
+ for (const ro of observers) ro.disconnect();
16
+ observers.clear();
17
+ }
18
+
19
+ function figure({ title, summary, draw, table, legend }) {
20
+ const plot = el('div', { class: 'chart-plot' });
21
+ const tip = el('div', { class: 'chart-tip', role: 'status' });
22
+ tip.hidden = true;
23
+ const fig = el('figure', { class: 'chart' }, [
24
+ el('figcaption', { class: 'chart-caption' }, [el('h3', { class: 'chart-title', text: title }), summary ? el('p', { class: 'chart-summary', text: summary }) : null]),
25
+ plot,
26
+ legend || null,
27
+ tip,
28
+ table ? el('details', { class: 'chart-table' }, [el('summary', {}, [icon('chevron', 14), '以表格檢視']), table]) : null,
29
+ ]);
30
+ if (draw) {
31
+ let lastWidth = 0;
32
+ const redraw = () => {
33
+ const width = Math.floor(plot.clientWidth);
34
+ if (!width || width === lastWidth) return;
35
+ lastWidth = width;
36
+ plot.replaceChildren(draw(width));
37
+ };
38
+ if (typeof ResizeObserver !== 'undefined') {
39
+ const ro = new ResizeObserver(() => redraw());
40
+ ro.observe(plot);
41
+ observers.add(ro);
42
+ }
43
+ wireTooltip(fig, plot, tip);
44
+ }
45
+ return fig;
46
+ }
47
+
48
+ /** Marks carry `data-tip`; one tooltip per chart follows the pointer. */
49
+ function wireTooltip(fig, plot, tip) {
50
+ plot.addEventListener('pointermove', (event) => {
51
+ const mark = event.target.closest && event.target.closest('[data-tip]');
52
+ if (!mark) {
53
+ tip.hidden = true;
54
+ return;
55
+ }
56
+ tip.textContent = mark.getAttribute('data-tip');
57
+ tip.hidden = false;
58
+ const box = fig.getBoundingClientRect();
59
+ const x = event.clientX - box.left;
60
+ const flip = x > box.width - 220;
61
+ tip.style.setProperty('left', (flip ? x - 12 : x + 12) + 'px');
62
+ tip.style.setProperty('top', event.clientY - box.top + 12 + 'px');
63
+ tip.classList.toggle('is-flipped', flip);
64
+ });
65
+ plot.addEventListener('pointerleave', () => {
66
+ tip.hidden = true;
67
+ });
68
+ }
69
+
70
+ function dataTable(headers, rows) {
71
+ return el('table', { class: 'data-table' }, [
72
+ el('thead', {}, [el('tr', {}, headers.map((h) => el('th', { scope: 'col', text: h })))]),
73
+ el('tbody', {}, rows.map((r) => el('tr', {}, r.map((c) => el('td', { text: String(c) }))))),
74
+ ]);
75
+ }
76
+
77
+ function emptyFigure(title, text) {
78
+ return el('figure', { class: 'chart chart-empty' }, [
79
+ el('figcaption', { class: 'chart-caption' }, [el('h3', { class: 'chart-title', text: title })]),
80
+ el('p', { class: 'note', text }),
81
+ ]);
82
+ }
83
+
84
+ /** Truncates an ASCII-ish label to roughly `px` at 12px; the full text rides in the tooltip and table. */
85
+ function fit(text, px) {
86
+ const max = Math.max(4, Math.floor(px / 6.8));
87
+ return text.length > max ? text.slice(0, max - 1) + '…' : text;
88
+ }
89
+
90
+ /** Column/bar with a 4px rounded data end and a square baseline end. */
91
+ function barPath(x, y, w, h, horizontal) {
92
+ const r = Math.min(4, horizontal ? w / 2 : h / 2, horizontal ? h / 2 : w / 2);
93
+ if (horizontal) {
94
+ return `M${x},${y}H${x + w - r}Q${x + w},${y} ${x + w},${y + r}V${y + h - r}Q${x + w},${y + h} ${x + w - r},${y + h}H${x}Z`;
95
+ }
96
+ return `M${x},${y + h}V${y + r}Q${x},${y} ${x + r},${y}H${x + w - r}Q${x + w},${y} ${x + w},${y + r}V${y + h}Z`;
97
+ }
98
+
99
+ function niceMax(value) {
100
+ if (value <= 4) return Math.max(1, value);
101
+ const step = Math.pow(10, Math.floor(Math.log10(value)));
102
+ return Math.ceil(value / step) * step;
103
+ }
104
+
105
+ const md = (d) => `${d.getMonth() + 1}/${d.getDate()}`;
106
+
107
+ // --- 1. in transit -------------------------------------------------------------
108
+
109
+ /**
110
+ * Each active change's journey: departed (`created`) to last seen
111
+ * (`lastModified`) as a thick run in the line colour, then dashed idle time up
112
+ * to today. A last-seen dot left of the threshold line means the change has
113
+ * gone stale.
114
+ */
115
+ export function transitChart(changes, staleDays, now) {
116
+ const title = 'change 存在天數';
117
+ if (!changes.length) return emptyFigure(title, '沒有進行中的 change.');
118
+ const rows = changes
119
+ .map((c) => ({ name: c.name, created: parseLooseDate(c.created), seen: parseLooseDate(c.lastModified) }))
120
+ .filter((r) => r.seen)
121
+ .sort((a, b) => a.seen - b.seen);
122
+ const stale = rows.filter((r) => now - r.seen.getTime() > staleDays * DAY_MS).length;
123
+ const summary = stale
124
+ ? `${rows.length} 個進行中的 change, ${stale} 個超過 ${staleDays} 天沒有更新.`
125
+ : `${rows.length} 個進行中的 change, 都在 ${staleDays} 天內有更新.`;
126
+ const threshold = now - staleDays * DAY_MS;
127
+ let tMin = Math.min(threshold - 2 * DAY_MS, ...rows.map((r) => (r.created ? r.created.getTime() : r.seen.getTime())));
128
+ tMin -= (now - tMin) * 0.03;
129
+
130
+ const draw = (width) => {
131
+ const labelW = Math.min(180, Math.round(width * 0.32));
132
+ const x0 = labelW + 12;
133
+ const x1 = width - 12;
134
+ const top = 26;
135
+ const rowH = 30;
136
+ const height = top + rows.length * rowH + 26;
137
+ const x = (t) => x0 + ((t - tMin) / (now - tMin)) * (x1 - x0);
138
+ const g = svg('svg', { class: 'chart-svg', width, height, viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': summary });
139
+
140
+ // Axis first so marks sit over it.
141
+ const axisY = top + rows.length * rowH + 4;
142
+ g.append(svg('line', { class: 'axis', x1: x0, x2: x1, y1: axisY, y2: axisY }));
143
+ const ticks = 4;
144
+ for (let i = 0; i <= ticks; i++) {
145
+ const t = tMin + ((now - tMin) * i) / ticks;
146
+ const tx = x(t);
147
+ g.append(svg('line', { class: 'grid', x1: tx, x2: tx, y1: top - 6, y2: axisY }));
148
+ g.append(svg('text', { class: 'tick', x: tx, y: axisY + 16, 'text-anchor': i === 0 ? 'start' : i === ticks ? 'end' : 'middle', text: i === ticks ? '今天' : md(new Date(t)) }));
149
+ }
150
+ const tx = x(threshold);
151
+ g.append(svg('line', { class: 'threshold', x1: tx, x2: tx, y1: top - 10, y2: axisY }));
152
+ g.append(svg('text', { class: 'threshold-label', x: tx - 4, y: top - 14, 'text-anchor': 'end', text: `閒置門檻 ${staleDays} 天` }));
153
+
154
+ rows.forEach((r, i) => {
155
+ const y = top + i * rowH + rowH / 2;
156
+ const isStale = now - r.seen.getTime() > staleDays * DAY_MS;
157
+ const idle = Math.floor((now - r.seen.getTime()) / DAY_MS);
158
+ const age = r.created ? Math.floor((now - r.created.getTime()) / DAY_MS) : null;
159
+ const tipText = `${r.name} · ${age == null ? '未記錄 created' : '存在 ' + age + ' 天'} · 最後更新 ${relTime(r.seen, now)}`;
160
+ const row = svg('g', { class: 'transit-row' + (isStale ? ' is-stale' : ''), 'data-tip': tipText });
161
+ row.append(svg('rect', { class: 'hit', x: 0, y: y - rowH / 2, width, height: rowH }));
162
+ row.append(svg('text', { class: 'row-label', x: 0, y: y + 4, text: fit(r.name, labelW) }));
163
+ if (r.created && r.created <= r.seen) {
164
+ row.append(svg('line', { class: 'run', x1: x(r.created.getTime()), x2: x(r.seen.getTime()), y1: y, y2: y }));
165
+ }
166
+ row.append(svg('line', { class: 'idle', x1: x(r.seen.getTime()), x2: x1, y1: y, y2: y }));
167
+ row.append(svg('circle', { class: 'seen', cx: x(r.seen.getTime()), cy: y, r: 4.5 }));
168
+ g.append(row);
169
+ });
170
+ return g;
171
+ };
172
+
173
+ const table = dataTable(
174
+ ['change', 'created', '最後更新', '閒置天數'],
175
+ rows.map((r) => [r.name, r.created ? formatDate(r.created) : '未記錄', formatDate(r.seen), Math.floor((now - r.seen.getTime()) / DAY_MS)])
176
+ );
177
+ return figure({ title, summary, draw, table });
178
+ }
179
+
180
+ // --- 2. weekly archives --------------------------------------------------------
181
+
182
+ export function archiveRateChart(archivedChanges, now) {
183
+ const title = '每週歸檔數';
184
+ const buckets = weeklyArchiveCounts(archivedChanges, now, 12);
185
+ const total = buckets.reduce((n, b) => n + b.count, 0);
186
+ const peak = Math.max(...buckets.map((b) => b.count));
187
+ if (total === 0) return emptyFigure(title, '近 12 週沒有歸檔任何 change.');
188
+ const summary = `近 12 週歸檔 ${total} 個 change, 單週最多 ${peak} 個.`;
189
+ const top = niceMax(peak);
190
+ const latest = buckets.length - 1;
191
+ // Label sparingly: the first peak week and the current week, nothing else;
192
+ // the axis and the tooltip carry the rest.
193
+ const labelled = new Set([buckets.findIndex((b) => b.count === peak)]);
194
+ if (buckets[latest].count > 0) labelled.add(latest);
195
+
196
+ const draw = (width) => {
197
+ const x0 = 28;
198
+ const x1 = width - 8;
199
+ const y0 = 18;
200
+ const y1 = 150;
201
+ const height = y1 + 26;
202
+ const slot = (x1 - x0) / buckets.length;
203
+ const barW = Math.min(24, slot * 0.6);
204
+ const y = (v) => y1 - (v / top) * (y1 - y0);
205
+ const g = svg('svg', { class: 'chart-svg', width, height, viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': summary });
206
+ const tickValues = top >= 4 ? [0, top / 2, top] : [0, top];
207
+ for (const v of tickValues) {
208
+ g.append(svg('line', { class: v === 0 ? 'axis' : 'grid', x1: x0, x2: x1, y1: y(v), y2: y(v) }));
209
+ g.append(svg('text', { class: 'tick', x: x0 - 8, y: y(v) + 4, 'text-anchor': 'end', text: String(v) }));
210
+ }
211
+ buckets.forEach((b, i) => {
212
+ const cx = x0 + (i + 0.5) * slot;
213
+ const col = svg('g', { class: 'column', 'data-tip': `${md(b.start)} 起的那週: ${b.count} 個` });
214
+ col.append(svg('rect', { class: 'hit', x: cx - slot / 2, y: y0 - 10, width: slot, height: y1 - y0 + 10 }));
215
+ if (b.count > 0) {
216
+ col.append(svg('path', { class: 'bar', d: barPath(cx - barW / 2, y(b.count), barW, y1 - y(b.count), false) }));
217
+ if (labelled.has(i)) {
218
+ col.append(svg('text', { class: 'value', x: cx, y: y(b.count) - 6, 'text-anchor': 'middle', text: String(b.count) }));
219
+ }
220
+ }
221
+ if (i % 3 === 0 || i === latest) {
222
+ col.append(svg('text', { class: 'tick', x: cx, y: y1 + 16, 'text-anchor': 'middle', text: i === latest ? '本週' : md(b.start) }));
223
+ }
224
+ g.append(col);
225
+ });
226
+ return g;
227
+ };
228
+
229
+ const table = dataTable(['週起始', '歸檔數'], buckets.map((b) => [formatDate(b.start), b.count]));
230
+ return figure({ title, summary, draw, table });
231
+ }
232
+
233
+ // --- 3. decision timeline ------------------------------------------------------
234
+
235
+ const ADR_SHAPES = ['accepted', 'proposed', 'superseded'];
236
+
237
+ function adrMark(status, cx, cy) {
238
+ if (status === 'accepted') return svg('circle', { class: 'adr adr-accepted', cx, cy, r: 5 });
239
+ if (status === 'proposed') return svg('circle', { class: 'adr adr-proposed', cx, cy, r: 4.5 });
240
+ if (status === 'superseded') {
241
+ return svg('path', { class: 'adr adr-superseded', d: `M${cx - 3.5},${cy - 3.5}L${cx + 3.5},${cy + 3.5}M${cx + 3.5},${cy - 3.5}L${cx - 3.5},${cy + 3.5}` });
242
+ }
243
+ return svg('path', { class: 'adr adr-other', d: `M${cx},${cy - 5}L${cx + 5},${cy}L${cx},${cy + 5}L${cx - 5},${cy}Z` });
244
+ }
245
+
246
+ function legendSwatch(status) {
247
+ return svg('svg', { width: 14, height: 14, viewBox: '0 0 14 14', 'aria-hidden': 'true', class: 'legend-swatch' }, [adrMark(status, 7, 7)]);
248
+ }
249
+
250
+ export function decisionTimeline(key, decisions, now) {
251
+ const title = 'ADR 時間軸';
252
+ if (!decisions || !decisions.length) return emptyFigure(title, '這個 project 還沒有決策記錄.');
253
+ const items = decisions
254
+ .map((d) => ({ ...d, when: parseLooseDate(d.date) }))
255
+ .filter((d) => d.when)
256
+ .sort((a, b) => a.when - b.when);
257
+ const counts = {};
258
+ for (const d of decisions) counts[d.status] = (counts[d.status] || 0) + 1;
259
+ const open = counts.proposed || 0;
260
+ const summary = `${decisions.length} 筆決策` + (open ? `, ${open} 筆仍是 proposed.` : ', 沒有懸而未決的 proposed.');
261
+ let tMin = items.length ? items[0].when.getTime() : now - 30 * DAY_MS;
262
+ tMin = Math.min(tMin, now - 30 * DAY_MS);
263
+ tMin -= (now - tMin) * 0.03;
264
+
265
+ const draw = (width) => {
266
+ const x0 = 10;
267
+ const x1 = width - 10;
268
+ const x = (t) => x0 + ((t - tMin) / (now - tMin)) * (x1 - x0);
269
+ const levels = [];
270
+ const placed = items.map((d) => {
271
+ const px = x(d.when.getTime());
272
+ let level = 0;
273
+ while (levels.some((p) => p.level === level && Math.abs(p.x - px) < 11)) level++;
274
+ levels.push({ x: px, level });
275
+ return { d, px, level };
276
+ });
277
+ const maxLevel = Math.max(0, ...placed.map((p) => p.level));
278
+ const base = 18 + maxLevel * 13 + 8;
279
+ const height = base + 30;
280
+ const g = svg('svg', { class: 'chart-svg', width, height, viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': summary });
281
+ g.append(svg('line', { class: 'axis', x1: x0, x2: x1, y1: base + 8, y2: base + 8 }));
282
+ // Month starts as ticks, thinned to keep labels apart; evenly spaced
283
+ // ticks over a short range repeated the same month.
284
+ const todayX = x(now);
285
+ let lastX = -Infinity;
286
+ const month = new Date(tMin);
287
+ month.setDate(1);
288
+ month.setHours(0, 0, 0, 0);
289
+ for (month.setMonth(month.getMonth() + 1); month.getTime() < now; month.setMonth(month.getMonth() + 1)) {
290
+ const mx = x(month.getTime());
291
+ if (mx - lastX < 64 || todayX - mx < 52) continue;
292
+ g.append(svg('line', { class: 'grid', x1: mx, x2: mx, y1: base + 4, y2: base + 12 }));
293
+ g.append(svg('text', { class: 'tick', x: mx, y: base + 26, 'text-anchor': 'middle', text: formatDate(month).slice(0, 7) }));
294
+ lastX = mx;
295
+ }
296
+ g.append(svg('text', { class: 'tick', x: todayX, y: base + 26, 'text-anchor': 'end', text: '今天' }));
297
+ for (const { d, px, level } of placed) {
298
+ const cy = base - level * 13;
299
+ const link = svg('a', { href: routes.decisions(key, d.file), 'data-tip': `${formatDate(d.when)} · ${d.status} · ${d.title}`, 'aria-label': `${d.title} (${d.status})` }, [
300
+ svg('rect', { class: 'hit', x: px - 6, y: cy - 6, width: 12, height: 12 }),
301
+ adrMark(d.status, px, cy),
302
+ ]);
303
+ g.append(link);
304
+ }
305
+ return g;
306
+ };
307
+
308
+ const statuses = [...ADR_SHAPES.filter((s) => counts[s]), ...Object.keys(counts).filter((s) => !ADR_SHAPES.includes(s))];
309
+ const legend = el('ul', { class: 'chart-legend' }, statuses.map((s) => el('li', {}, [legendSwatch(s), el('span', { text: `${s} ${counts[s]}` })])));
310
+ const table = dataTable(['日期', '標題', 'status'], items.slice().reverse().map((d) => [formatDate(d.when), d.title, d.status]));
311
+ return figure({ title, summary, draw, table, legend });
312
+ }
313
+
314
+ // --- 4. spec size ----------------------------------------------------------------
315
+
316
+ export function specSizeChart(key, specs) {
317
+ const title = 'Spec 規模';
318
+ if (!specs.length) return emptyFigure(title, '還沒有任何 spec. change 歸檔時, 它的 delta spec 會合併進 tospec/specs/.');
319
+ const sorted = specs.slice().sort((a, b) => b.requirementCount - a.requirementCount || a.id.localeCompare(b.id));
320
+ const total = sorted.reduce((n, s) => n + s.requirementCount, 0);
321
+ const summary = `${specs.length} 個 spec, 共 ${total} 條 requirement.`;
322
+ const LIMIT = 10;
323
+ const shown = sorted.slice(0, LIMIT);
324
+ const rest = sorted.slice(LIMIT);
325
+ const rows = shown.map((s) => ({ label: s.id, value: s.requirementCount, href: routes.spec(key, s.id) }));
326
+ if (rest.length) rows.push({ label: `其他 ${rest.length} 個 spec`, value: rest.reduce((n, s) => n + s.requirementCount, 0), other: true });
327
+ const max = Math.max(1, ...rows.map((r) => r.value));
328
+
329
+ const draw = (width) => {
330
+ const labelW = Math.min(200, Math.round(width * 0.38));
331
+ const x0 = labelW + 10;
332
+ const x1 = width - 36;
333
+ const rowH = 26;
334
+ const barH = 12;
335
+ const height = rows.length * rowH + 6;
336
+ const g = svg('svg', { class: 'chart-svg', width, height, viewBox: `0 0 ${width} ${height}`, role: 'img', 'aria-label': summary });
337
+ g.append(svg('line', { class: 'axis', x1: x0, x2: x0, y1: 0, y2: height - 4 }));
338
+ rows.forEach((r, i) => {
339
+ const cy = i * rowH + rowH / 2 + 2;
340
+ const w = (r.value / max) * (x1 - x0);
341
+ const parts = [
342
+ svg('rect', { class: 'hit', x: 0, y: cy - rowH / 2, width, height: rowH }),
343
+ svg('text', { class: 'row-label', x: labelW, y: cy + 4, 'text-anchor': 'end', text: fit(r.label, labelW) }),
344
+ w > 0 ? svg('path', { class: 'bar' + (r.other ? ' is-other' : ''), d: barPath(x0, cy - barH / 2, Math.max(w, 2), barH, true) }) : null,
345
+ svg('text', { class: 'value', x: x0 + w + 6, y: cy + 4, text: String(r.value) }),
346
+ ];
347
+ const tipText = `${r.label}: ${r.value} 條 requirement`;
348
+ g.append(r.href ? svg('a', { href: r.href, 'data-tip': tipText }, parts) : svg('g', { 'data-tip': tipText }, parts));
349
+ });
350
+ return g;
351
+ };
352
+
353
+ const table = dataTable(['spec', 'requirement'], sorted.map((s) => [s.id, s.requirementCount]));
354
+ return figure({ title, summary, draw, table });
355
+ }