@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,1242 +1,280 @@
1
- // tospec dashboard — vanilla SPA. No framework, no build, no external resources.
2
- // Charts: vendored Chart.js (assets/chart.umd.js), themed via CSS variables.
1
+ // tospec dashboard: one hub, every project drawn as a line on a shared
2
+ // stage grid. Vanilla ES modules, no build step, no external resources.
3
+
4
+ import { el, icon, paintLine } from './lib/dom.js';
5
+ import { store, onStoreChange, loadProjects, loadAllProjects, loadDetail, subscribe, requestRefresh, projectByKey, slotOf } from './lib/store.js';
6
+ import { parseRoute, legacyRoute, routes } from './lib/routes.js';
7
+ import { releaseCharts } from './lib/charts.js';
8
+ import { renderHome } from './views/home.js';
9
+ import { renderProject } from './views/project.js';
10
+ import { renderChange } from './views/change.js';
11
+ import { renderSpec, renderDecisions, clearRenderCache } from './views/docs.js';
12
+ import { renderSidebar } from './views/sidebar.js';
13
+ import { updateSessionPanel } from './views/session.js';
14
+ import { checkSessionAlerts, setupNotifyButton } from './views/alerts.js';
3
15
 
4
16
  const view = document.getElementById('view');
5
- const rootPathEl = document.getElementById('root-path');
6
- const connEl = document.getElementById('conn-status');
7
- const refreshBtn = document.getElementById('refresh-btn');
17
+ const signbar = document.getElementById('signbar');
18
+ const crumbs = document.getElementById('crumbs');
19
+ const crumbBack = document.getElementById('crumb-back');
20
+ const hubStatus = document.getElementById('hub-status');
8
21
  const themeSelect = document.getElementById('theme-select');
22
+ const refreshBtn = document.getElementById('refresh-btn');
23
+ const sidebar = document.getElementById('sidebar');
24
+ const navToggle = document.getElementById('nav-toggle');
25
+ const navScrim = document.getElementById('nav-scrim');
9
26
 
10
- let overview = null; // cached /api/overview payload
11
- let activity = null; // cached /api/activity payload (git series + health)
12
- let activityRange = '30'; // '30' | '90' | '365' | 'all' — overview chart window
13
-
14
- // --- theme (persisted choice: system | light | dark | dracula) -------------
27
+ // --- theme (persisted choice: system | light | dark | dracula) ------------------
15
28
 
16
29
  const THEME_KEY = 'tospec-theme';
17
30
  const systemDark = window.matchMedia('(prefers-color-scheme: dark)');
18
31
 
19
- // Apply a user choice: 'system' resolves to light/dark from the OS; the rest
20
- // map straight to a data-theme value.
32
+ // Every colour, chart marks included, reads a theme variable, so a switch
33
+ // needs no re-render.
21
34
  function applyTheme(choice) {
22
35
  const resolved = choice === 'system' ? (systemDark.matches ? 'dark' : 'light') : choice;
23
36
  document.documentElement.setAttribute('data-theme', resolved);
24
37
  themeSelect.value = choice;
25
38
  }
26
39
 
27
- const savedTheme = localStorage.getItem(THEME_KEY) || 'system';
28
- applyTheme(savedTheme);
29
-
30
- // Theme switches rebuild the route: charts read CSS variables at build time,
31
- // so they must be recreated to pick up the new palette (rare event, morph
32
- // loss is fine). The initial applyTheme above must NOT rebuild — chart state
33
- // (liveCharts, ov) is declared further down and would still be in its TDZ.
40
+ applyTheme(localStorage.getItem(THEME_KEY) || 'system');
34
41
  systemDark.addEventListener('change', () => {
35
- if ((localStorage.getItem(THEME_KEY) || 'system') === 'system') {
36
- applyTheme('system');
37
- rebuildRoute();
38
- }
42
+ if ((localStorage.getItem(THEME_KEY) || 'system') === 'system') applyTheme('system');
39
43
  });
40
44
  themeSelect.addEventListener('change', () => {
41
45
  localStorage.setItem(THEME_KEY, themeSelect.value);
42
46
  applyTheme(themeSelect.value);
43
- rebuildRoute();
44
47
  });
45
48
 
46
- // --- tiny helpers ----------------------------------------------------------
47
-
48
- async function api(path, options) {
49
- const res = await fetch(path, options);
50
- if (!res.ok) {
51
- let detail = 'HTTP ' + res.status;
52
- try {
53
- const body = await res.json();
54
- if (body && body.error) detail = body.error;
55
- } catch {
56
- // no JSON body; keep the status line
57
- }
58
- const err = new Error(detail);
59
- err.status = res.status;
60
- throw err;
61
- }
62
- return res.json();
63
- }
64
-
65
- /** Writes a task checkbox back to its markdown file. */
66
- function postTask(file, line, done) {
67
- return api('/api/task', {
68
- method: 'POST',
69
- headers: { 'content-type': 'application/json' },
70
- body: JSON.stringify({ file, line, done }),
71
- });
72
- }
73
-
74
- /** Minimal DOM builder; all text goes in as textContent (XSS-safe). */
75
- function el(tag, props = {}, children = []) {
76
- const node = document.createElement(tag);
77
- for (const [k, v] of Object.entries(props)) {
78
- if (v == null) continue;
79
- if (k === 'class') node.className = v;
80
- else if (k === 'text') node.textContent = v;
81
- else node.setAttribute(k, v);
82
- }
83
- for (const child of [].concat(children)) {
84
- if (child == null) continue;
85
- node.append(child.nodeType ? child : document.createTextNode(String(child)));
86
- }
87
- return node;
88
- }
89
-
90
- function emptyNote(text) {
91
- return el('p', { class: 'empty', text });
92
- }
93
-
94
- function relTime(iso) {
95
- const then = new Date(iso).getTime();
96
- if (Number.isNaN(then)) return '';
97
- const mins = Math.floor((Date.now() - then) / 60000);
98
- const hours = Math.floor(mins / 60);
99
- const days = Math.floor(hours / 24);
100
- if (days > 30) return new Date(iso).toLocaleDateString();
101
- if (days > 0) return days + 'd ago';
102
- if (hours > 0) return hours + 'h ago';
103
- if (mins > 0) return mins + 'm ago';
104
- return 'just now';
105
- }
106
-
107
- /** `20260713_123649` -> `2026-07-13 12:36`. */
108
- function fmtStamp(s) {
109
- const m = /^(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})(\d{2})$/.exec(s || '');
110
- return m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}` : s || '';
111
- }
112
-
113
- function typeBadge(type) {
114
- const label = type === 'requirement' ? 'sdd' : type || '?';
115
- return el('span', { class: 'badge badge-' + (type || 'unknown'), text: label });
116
- }
117
-
118
- function artifactBlocks(done, total) {
119
- const wrap = el('span', { class: 'blocks' });
120
- for (let i = 0; i < total; i++) {
121
- wrap.append(el('span', { class: 'block ' + (i < done ? 'block-done' : 'block-todo') }));
122
- }
123
- return wrap;
124
- }
125
-
126
- function setConnected(ok) {
127
- connEl.textContent = ok ? '' : 'disconnected';
128
- connEl.classList.toggle('disconnected', !ok);
129
- }
130
-
131
- // --- charts (Chart.js, themed via CSS variables) ----------------------------
132
-
133
- const hasChart = typeof Chart !== 'undefined';
134
- const liveCharts = new Set();
135
-
136
- function cssVar(name) {
137
- return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
138
- }
139
-
140
- function newChart(canvas, config) {
141
- const chart = new Chart(canvas, config);
142
- liveCharts.add(chart);
143
- return chart;
144
- }
145
-
146
- function destroyCharts() {
147
- for (const chart of liveCharts) chart.destroy();
148
- liveCharts.clear();
149
- }
150
-
151
- /** Shared recessive axes/legend styling read from the active theme. */
152
- function chartTheme() {
153
- return {
154
- text: cssVar('--text'),
155
- muted: cssVar('--muted'),
156
- grid: cssVar('--border'),
157
- surface: cssVar('--surface'),
158
- series1: cssVar('--chart-1'),
159
- series2: cssVar('--chart-2'),
160
- done: cssVar('--done'),
161
- ready: cssVar('--ready'),
162
- blocked: cssVar('--blocked'),
163
- track: cssVar('--border'),
164
- };
165
- }
166
-
167
- /** Doughnut center text (e.g. "68%"), drawn in the theme's text color. */
168
- const centerTextPlugin = {
169
- id: 'centerText',
170
- afterDraw(chart, _args, opts) {
171
- if (!opts || !opts.text) return;
172
- const meta = chart.getDatasetMeta(0);
173
- const arc = meta.data[0];
174
- if (!arc) return;
175
- const { ctx } = chart;
176
- ctx.save();
177
- ctx.font = '600 16px system-ui, sans-serif';
178
- ctx.fillStyle = opts.color;
179
- ctx.textAlign = 'center';
180
- ctx.textBaseline = 'middle';
181
- ctx.fillText(opts.text, arc.x, arc.y);
182
- ctx.restore();
183
- },
184
- };
185
- if (hasChart) Chart.register(centerTextPlugin);
186
-
187
- /** 30-day activity line chart: solid tospec series + dashed repo context. */
188
- function buildActivityChart(canvas, tospecLabel) {
189
- const t = chartTheme();
190
- return newChart(canvas, {
191
- type: 'line',
192
- data: {
193
- labels: [],
194
- datasets: [
195
- {
196
- label: tospecLabel,
197
- data: [],
198
- borderColor: t.series1,
199
- backgroundColor: t.series1,
200
- borderWidth: 2,
201
- pointRadius: 2,
202
- pointHitRadius: 10,
203
- tension: 0.2,
204
- },
205
- {
206
- label: 'all commits',
207
- data: [],
208
- borderColor: t.series2,
209
- backgroundColor: t.series2,
210
- borderWidth: 2,
211
- borderDash: [5, 4], // secondary encoding: identity is never color-alone
212
- pointRadius: 2,
213
- pointHitRadius: 10,
214
- tension: 0.2,
215
- },
216
- ],
217
- },
218
- options: {
219
- responsive: true,
220
- maintainAspectRatio: false,
221
- interaction: { mode: 'index', intersect: false },
222
- plugins: {
223
- legend: { labels: { color: t.muted, boxWidth: 18, boxHeight: 2 } },
224
- tooltip: { intersect: false },
225
- },
226
- scales: {
227
- x: {
228
- ticks: { color: t.muted, maxTicksLimit: 8, maxRotation: 0 },
229
- grid: { display: false },
230
- },
231
- y: {
232
- beginAtZero: true,
233
- ticks: { color: t.muted, precision: 0 },
234
- grid: { color: t.grid },
235
- },
236
- },
237
- },
238
- });
239
- }
240
-
241
- function updateActivityChart(chart, days) {
242
- // mm-dd is ambiguous once the window spans more than a year.
243
- const short = days.length <= 366;
244
- chart.data.labels = days.map((d) => (short ? d.date.slice(5) : d.date));
245
- chart.data.datasets[0].data = days.map((d) => d.tospec);
246
- chart.data.datasets[1].data = days.map((d) => d.repo);
247
- chart.update();
248
- }
49
+ // --- routing -------------------------------------------------------------------
249
50
 
250
- /** Status doughnut (done/ready/blocked) with a completion % in the center. */
251
- function buildStatusDoughnut(canvas, labels, colors) {
252
- const t = chartTheme();
253
- return newChart(canvas, {
254
- type: 'doughnut',
255
- data: {
256
- labels,
257
- datasets: [
258
- {
259
- data: labels.map(() => 0),
260
- backgroundColor: colors,
261
- borderColor: t.surface, // 2px surface gap between segments
262
- borderWidth: 2,
263
- },
264
- ],
265
- },
266
- options: {
267
- responsive: true,
268
- maintainAspectRatio: false,
269
- cutout: '70%',
270
- plugins: {
271
- legend: { position: 'bottom', labels: { color: t.muted, boxWidth: 10 } },
272
- centerText: { text: '', color: t.text },
273
- },
274
- },
275
- });
276
- }
51
+ const legacy = legacyRoute(location.search, location.hash);
52
+ if (legacy) history.replaceState(null, '', location.pathname + legacy);
277
53
 
278
- function updateStatusDoughnut(chart, values, centerText) {
279
- chart.data.datasets[0].data = values;
280
- chart.options.plugins.centerText.text = centerText;
281
- chart.update();
282
- }
54
+ /** Views that follow live pushes; reading views keep their place instead. */
55
+ const LIVE_VIEWS = new Set(['home', 'project', 'change']);
283
56
 
284
- // --- dynamics (count-up, flash) ---------------------------------------------
57
+ let lastRoute = null;
58
+ let viewAbort = null;
59
+ /** A reading view that rendered before its data arrived re-renders once it lands. */
60
+ let waitingForData = false;
285
61
 
286
- /** Animates an element's number from its last shown value to `to` (~400ms). */
287
- function countUp(node, to) {
288
- const from = Number(node.dataset.value || 0);
289
- node.dataset.value = String(to);
290
- if (from === to) {
291
- node.textContent = String(to);
62
+ function render({ live = false } = {}) {
63
+ const route = parseRoute(location.hash);
64
+ const id = JSON.stringify(route);
65
+ const navigation = id !== lastRoute;
66
+ if (live && !navigation && !LIVE_VIEWS.has(route.view) && !waitingForData) {
67
+ renderChrome(route);
292
68
  return;
293
69
  }
294
- const start = performance.now();
295
- const dur = 400;
296
- function tick(now) {
297
- const p = Math.min(1, (now - start) / dur);
298
- const eased = 1 - (1 - p) * (1 - p);
299
- node.textContent = String(Math.round(from + (to - from) * eased));
300
- if (p < 1) requestAnimationFrame(tick);
301
- }
302
- requestAnimationFrame(tick);
303
- }
304
-
305
- /** Briefly highlights a row after an SSE-driven change. */
306
- function flash(node) {
307
- node.classList.add('flash');
308
- node.addEventListener('animationend', () => node.classList.remove('flash'), { once: true });
309
- }
70
+ lastRoute = id;
71
+ const trains = navigation ? null : captureTrains();
72
+ releaseCharts();
73
+ if (viewAbort) viewAbort.abort();
74
+ viewAbort = new AbortController();
75
+ const ctx = { signal: viewAbort.signal };
310
76
 
311
- // Previous snapshots keyed for change detection (what to flash after updates).
312
- let prevChangeStamps = null; // Map<name, lastModified>
313
- let prevTicketFiles = null; // Set<file>
77
+ let page;
78
+ if (route.view === 'project') page = renderProject(route, ctx);
79
+ else if (route.view === 'change') page = renderChange(route, ctx);
80
+ else if (route.view === 'spec') page = renderSpec(route, ctx);
81
+ else if (route.view === 'decisions') page = renderDecisions(route, ctx);
82
+ else page = renderHome(ctx);
83
+ waitingForData = !LIVE_VIEWS.has(route.view) && Boolean(ctx.waiting);
84
+ view.replaceChildren(page);
85
+ renderChrome(route);
314
86
 
315
- // --- data loading ------------------------------------------------------------
316
-
317
- async function loadOverview() {
318
- try {
319
- overview = await api('/api/overview');
320
- setConnected(true);
321
- rootPathEl.textContent = overview.root;
322
- rootPathEl.setAttribute('title', overview.root);
323
- if (isOverviewRoute()) renderOverview();
324
- } catch (e) {
325
- setConnected(false);
326
- if (isOverviewRoute() && !overview) {
327
- const msg = e.status
328
- ? `Failed to load overview: ${e.message}`
329
- : 'Cannot reach the dashboard server.';
330
- view.replaceChildren(el('p', { class: 'error', text: msg }));
331
- }
87
+ if (navigation) {
88
+ arrivals.clear();
89
+ window.scrollTo(0, 0);
90
+ view.focus({ preventScroll: true });
91
+ } else if (trains) {
92
+ animateTrains(trains);
332
93
  }
333
94
  }
334
95
 
335
- async function loadActivity() {
336
- try {
337
- activity = await api('/api/activity?days=' + activityRange);
338
- } catch {
339
- activity = null; // health strip and charts degrade below
340
- }
341
- if (isOverviewRoute()) renderOverview();
342
- }
96
+ // --- signature move: a train slides to its new station -----------------------------
343
97
 
344
- // --- overview: skeleton + update ---------------------------------------------
345
-
346
- // The overview keeps one DOM skeleton alive across data refreshes so chart
347
- // instances survive and Chart.js morphs to new values (SSE-driven dynamics).
348
- let ov = null;
349
-
350
- function card(label) {
351
- const count = el('span', { class: 'card-count', text: '0' });
352
- const node = el('div', { class: 'card' }, [
353
- count,
354
- el('span', { class: 'card-label', text: label }),
355
- ]);
356
- return { node, count };
357
- }
98
+ const TRAVEL_MS = 900;
99
+ const ARRIVAL_MS = 2600;
100
+ /**
101
+ * Trains still travelling or glowing, keyed by `<project>/<change>`. One push
102
+ * sets off several renders (overview, then activity, archive, details as they
103
+ * land), and each replaces the DOM: without this, the second render would cut
104
+ * the journey off a few milliseconds after it began.
105
+ */
106
+ const arrivals = new Map();
358
107
 
359
- function chartPanel(title, height) {
360
- const canvas = el('canvas');
361
- const wrap = el('div', { class: 'chart-wrap' }, [canvas]);
362
- wrap.style.height = height + 'px';
363
- const note = el('p', { class: 'empty chart-note', text: '' });
364
- note.style.display = 'none';
365
- const panel = el('div', { class: 'chart-panel' }, [
366
- el('h2', { class: 'section-title', text: title }),
367
- wrap,
368
- note,
369
- ]);
370
- return { panel, canvas, wrap, note };
108
+ function captureTrains() {
109
+ const seen = new Map();
110
+ for (const node of view.querySelectorAll('[data-train]')) {
111
+ seen.set(node.dataset.train, { station: node.dataset.station, rect: node.getBoundingClientRect() });
112
+ }
113
+ return seen;
371
114
  }
372
115
 
373
- // --- task progress -----------------------------------------------------------
374
-
375
- // Names of changes whose task list is expanded. Module-level so an SSE refresh,
376
- // which rebuilds these rows, doesn't collapse what the user opened.
377
- const expandedTasks = new Set();
378
-
379
116
  /**
380
- * One task, as a checkbox that writes straight back to its markdown file. The
381
- * file is the source of truth: a failed write reverts the box rather than
382
- * leaving the UI claiming something the file doesn't say.
117
+ * FLIP: the new render puts the train at its new station; it is drawn back at
118
+ * its old position and released, so it travels along the line. Under reduced
119
+ * motion it only marks its arrival.
383
120
  */
384
- function taskItem(task, locked) {
385
- const item = el('label', { class: 'task-item' + (task.done ? ' task-item-done' : '') });
386
- // Mirror the file's own nesting; 2 spaces of markdown indent reads as one step.
387
- if (task.indent) item.style.marginLeft = task.indent * 8 + 'px';
388
- const box = el('input', { type: 'checkbox', class: 'task-box' });
389
- box.checked = task.done;
390
- // A disabled input never fires `change`, so the listener below stays inert.
391
- if (locked) {
392
- box.disabled = true;
393
- item.classList.add('task-item-locked');
394
- item.title = 'archiving: sync 已通過, task 已凍結';
395
- }
396
- item.append(box, el('span', { class: 'task-text', text: task.text }));
397
-
398
- box.addEventListener('change', async () => {
399
- const want = box.checked;
400
- box.disabled = true;
401
- item.querySelectorAll('.task-error').forEach((n) => n.remove());
402
- try {
403
- await postTask(task.file, task.line, want);
404
- item.classList.toggle('task-item-done', want);
405
- // The file watcher normally refreshes us; reload anyway so the counts move
406
- // even where the watch is unavailable.
407
- await loadOverview();
408
- } catch (e) {
409
- box.checked = !want;
410
- item.append(el('span', { class: 'task-error', text: 'save failed: ' + e.message }));
411
- } finally {
412
- box.disabled = false;
121
+ function animateTrains(before) {
122
+ const now = performance.now();
123
+ for (const node of view.querySelectorAll('[data-train]')) {
124
+ const prev = before.get(node.dataset.train);
125
+ if (!prev || prev.station === node.dataset.station) continue;
126
+ const rect = node.getBoundingClientRect();
127
+ arrivals.set(node.dataset.train, { dx: prev.rect.left - rect.left, dy: prev.rect.top - rect.top, start: now });
128
+ }
129
+ const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
130
+ for (const node of view.querySelectorAll('[data-train]')) {
131
+ const arrival = arrivals.get(node.dataset.train);
132
+ if (!arrival) continue;
133
+ const elapsed = now - arrival.start;
134
+ if (elapsed >= ARRIVAL_MS) {
135
+ arrivals.delete(node.dataset.train);
136
+ continue;
413
137
  }
414
- });
415
- return item;
416
- }
417
-
418
- /** A change's tasks, headed by their file path only when they span several. */
419
- function taskListBody(change) {
420
- const body = el('div', { class: 'task-body' });
421
- const locked = change.phase === 'archiving';
422
- if (locked) {
423
- body.append(
424
- el('p', {
425
- class: 'task-locked-note',
426
- text: '此 change 已通過 sync, 正在歸檔中, task 暫停編輯',
427
- })
138
+ node.style.setProperty('animation-delay', `-${Math.round(elapsed)}ms`);
139
+ node.classList.add('just-arrived');
140
+ if (reduce || elapsed >= TRAVEL_MS || typeof node.animate !== 'function') continue;
141
+ const journey = node.animate(
142
+ [{ transform: `translate(${arrival.dx}px, ${arrival.dy}px)` }, { transform: 'none' }],
143
+ { duration: TRAVEL_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' }
428
144
  );
429
- }
430
- const files = [...new Set(change.tasks.map((t) => t.file))];
431
- for (const file of files) {
432
- if (files.length > 1) body.append(el('p', { class: 'task-file mono', text: file }));
433
- for (const task of change.tasks) {
434
- if (task.file === file) body.append(taskItem(task, locked));
145
+ journey.currentTime = elapsed;
146
+ }
147
+ }
148
+
149
+ // --- chrome: breadcrumbs, hub status, title --------------------------------------
150
+
151
+ function renderChrome(route) {
152
+ renderSidebar(sidebar, route);
153
+ updateSessionPanel();
154
+ const project = route.key ? projectByKey(route.key) : null;
155
+ const items = [route.view === 'home'
156
+ ? el('li', {}, [el('span', { 'aria-current': 'page', text: '總覽' })])
157
+ : el('li', {}, [el('a', { href: routes.home(), text: '總覽' })])];
158
+ const titleParts = ['總覽'];
159
+ if (route.key) {
160
+ const name = project ? project.name : route.key;
161
+ titleParts.push(name);
162
+ const crumb = paintLine(el('li', { class: 'crumb-project' }), slotOf(route.key));
163
+ const swatch = el('span', { class: 'line-swatch', 'aria-hidden': 'true' });
164
+ const label = el('span', { class: 'crumb-text', text: name });
165
+ crumb.append(route.view === 'project'
166
+ ? el('span', { 'aria-current': 'page' }, [swatch, label])
167
+ : el('a', { href: routes.project(route.key) }, [swatch, label]));
168
+ items.push(crumb);
169
+ const leaf = route.view === 'change' ? route.name : route.view === 'spec' ? route.id : route.view === 'decisions' ? 'Decisions' : null;
170
+ if (leaf) {
171
+ titleParts.push(leaf);
172
+ items.push(el('li', {}, [el('span', { class: 'crumb-text', 'aria-current': 'page', text: leaf })]));
435
173
  }
436
174
  }
437
- return body;
438
- }
439
-
440
- /** Collapsed: name + bar + `x/y`. Expanded: every task, each one clickable. */
441
- function taskChangeRow(c) {
442
- const { completed, total } = c.taskProgress;
443
- if (total === 0) {
444
- return el('div', { class: 'row task-none' }, [
445
- el('span', { class: 'row-name', text: c.name }),
446
- el('span', { class: 'row-meta', text: 'no tasks' }),
447
- ]);
448
- }
449
- const details = el('details', { class: 'task-change' });
450
- if (expandedTasks.has(c.name)) details.setAttribute('open', '');
451
- details.append(
452
- el('summary', {}, [
453
- el('span', { class: 'row-name', text: c.name }),
454
- el('progress', { class: 'task-bar', value: String(completed), max: String(total) }),
455
- el('span', { class: 'task-count', text: completed + '/' + total }),
456
- c.artifactSummary.blocked > 0
457
- ? el('span', { class: 'badge badge-blocked-warn', text: 'blocked' })
458
- : null,
459
- c.phase === 'archiving'
460
- ? el('span', { class: 'badge badge-archiving', text: 'archiving' })
461
- : null,
462
- c.phase === 'ready'
463
- ? el('span', { class: 'badge badge-ready', text: 'ready' })
464
- : null,
465
- ]),
466
- taskListBody(c)
467
- );
468
- details.addEventListener('toggle', () => {
469
- if (details.open) expandedTasks.add(c.name);
470
- else expandedTasks.delete(c.name);
471
- });
472
- return details;
473
- }
474
-
475
- function updateTaskProgress() {
476
- const s = ov.taskSection;
477
- s.replaceChildren(el('h2', { class: 'section-title', text: 'Task progress' }));
478
- if (!overview.changes.length) {
479
- s.append(emptyNote('No active changes.'));
480
- return;
481
- }
482
- for (const c of overview.changes) s.append(taskChangeRow(c));
483
- }
484
-
485
- function buildOverviewSkeleton() {
486
- destroyCharts();
487
- ov = {
488
- health: el('div', { class: 'health' }),
489
- cards: {
490
- changes: card('Changes'),
491
- tickets: card('Tickets'),
492
- specs: card('Specs'),
493
- archived: card('Archived'),
494
- },
495
- activity: chartPanel('Activity', 180),
496
- doughnut: chartPanel('Artifacts', 180),
497
- taskSection: el('section', { class: 'panel', id: 'task-progress-panel' }),
498
- changesSection: el('section', { class: 'panel', id: 'changes-panel' }),
499
- ticketsSection: el('section', { class: 'panel', id: 'tickets-panel' }),
500
- specsSection: el('section', { class: 'panel' }),
501
- archiveSlot: el('div'),
502
- archiveCount: -1,
503
- };
504
-
505
- const cards = el('section', { class: 'cards' }, [
506
- ov.cards.changes.node,
507
- ov.cards.tickets.node,
508
- ov.cards.specs.node,
509
- ov.cards.archived.node,
510
- ]);
511
- const chartsGrid = el('section', { class: 'charts-grid' }, [
512
- ov.activity.panel,
513
- ov.doughnut.panel,
514
- ]);
515
-
516
- view.replaceChildren(
517
- ov.health,
518
- cards,
519
- ov.changesSection,
520
- ov.taskSection,
521
- ov.ticketsSection,
522
- chartsGrid,
523
- ov.specsSection,
524
- ov.archiveSlot
525
- );
526
-
527
- // Window selector lives in the panel title; changing it refetches the series.
528
- const range = el('select', { class: 'range-select', title: 'Activity window' }, [
529
- el('option', { value: '30', text: '30 days' }),
530
- el('option', { value: '90', text: '90 days' }),
531
- el('option', { value: '365', text: '1 year' }),
532
- el('option', { value: 'all', text: 'All history' }),
533
- ]);
534
- range.value = activityRange;
535
- range.addEventListener('change', () => {
536
- activityRange = range.value;
537
- loadActivity();
538
- });
539
- ov.activity.panel.firstChild.append(range);
540
-
541
- if (hasChart) {
542
- const t = chartTheme();
543
- ov.activityChart = buildActivityChart(ov.activity.canvas, 'tospec/');
544
- ov.doughnutChart = buildStatusDoughnut(
545
- ov.doughnut.canvas,
546
- ['done', 'ready', 'blocked'],
547
- [t.done, t.ready, t.blocked]
175
+ crumbs.replaceChildren(...items);
176
+ document.title = route.view === 'home' ? '總覽 · tospec' : titleParts.slice(1).reverse().concat('tospec').join(' · ');
177
+
178
+ // Narrow bars show one step up instead of a trail cut to "n…": the page
179
+ // title already names where you are.
180
+ crumbBack.hidden = route.view === 'home';
181
+ if (route.view === 'project') {
182
+ crumbBack.href = routes.home();
183
+ crumbBack.replaceChildren(icon('back', 14), el('span', { class: 'crumb-text', text: '總覽' }));
184
+ } else if (route.key) {
185
+ crumbBack.href = routes.project(route.key);
186
+ paintLine(crumbBack, slotOf(route.key));
187
+ crumbBack.replaceChildren(
188
+ icon('back', 14),
189
+ el('span', { class: 'line-swatch', 'aria-hidden': 'true' }),
190
+ el('span', { class: 'crumb-text', text: project ? project.name : route.key })
548
191
  );
549
- } else {
550
- ov.activity.wrap.style.display = 'none';
551
- ov.doughnut.wrap.style.display = 'none';
552
- }
553
- }
554
-
555
- /** True when a change's last modification is older than the stale threshold. */
556
- function isStale(change, staleDays) {
557
- const age = Date.now() - new Date(change.lastModified).getTime();
558
- return age > staleDays * 86400000;
559
- }
560
-
561
- function healthItem(text, targetId) {
562
- const item = el('a', { class: 'health-item', href: '#', text });
563
- item.addEventListener('click', (e) => {
564
- e.preventDefault();
565
- const target = document.getElementById(targetId);
566
- if (target) target.scrollIntoView({ behavior: 'smooth' });
567
- });
568
- return item;
569
- }
570
-
571
- function updateHealthStrip(staleDays) {
572
- const items = [];
573
- const staleChanges = overview.changes.filter((c) => isStale(c, staleDays));
574
- if (staleChanges.length) {
575
- items.push(
576
- healthItem(
577
- `${staleChanges.length} change(s) inactive > ${staleDays} days`,
578
- 'changes-panel'
579
- )
580
- );
581
- }
582
- const blocked = overview.changes.reduce((n, c) => n + c.artifactSummary.blocked, 0);
583
- if (blocked) {
584
- items.push(healthItem(`${blocked} blocked artifact(s)`, 'changes-panel'));
585
- }
586
- const parseErrors = overview.tickets.filter((t) => t.parseError).length;
587
- if (parseErrors) {
588
- items.push(healthItem(`${parseErrors} ticket parse error(s)`, 'tickets-panel'));
589
- }
590
- const unreadable = overview.changes.filter((c) => c.unreadable).length;
591
- if (unreadable) {
592
- items.push(healthItem(`${unreadable} change(s) with unreadable metadata`, 'changes-panel'));
593
- }
594
- if (activity && activity.available && activity.specDrift) {
595
- items.push(el('span', { class: 'health-item', text: 'spec drift: code moved, tospec/ did not (7d)' }));
596
- }
597
-
598
- ov.health.classList.toggle('health-warn', items.length > 0);
599
- ov.health.classList.toggle('health-ok', items.length === 0);
600
- if (items.length === 0) {
601
- ov.health.replaceChildren(el('span', { class: 'health-item', text: 'All good — no stale changes, blocked artifacts, or parse errors' }));
602
- } else {
603
- ov.health.replaceChildren(...items);
604
- }
605
- }
606
-
607
- function changeRow(c, staleDays, changed) {
608
- const row = el('a', { class: 'row change-row', href: '#/changes/' + encodeURIComponent(c.name) });
609
- row.append(el('span', { class: 'row-name', text: c.name }));
610
- row.append(typeBadge(c.type));
611
- if (c.phase === 'applying') {
612
- row.classList.add('row-applying');
613
- row.append(el('span', { class: 'badge badge-applying', text: 'applying' }));
614
- } else if (c.phase === 'archiving') {
615
- row.classList.add('row-archiving');
616
- row.append(el('span', { class: 'badge badge-archiving', text: 'archiving' }));
617
- } else if (c.phase === 'ready') {
618
- row.classList.add('row-ready');
619
- row.append(el('span', { class: 'badge badge-ready', text: 'ready' }));
620
- }
621
- if (c.unreadable) {
622
- // Ahead of every other badge: the counts beside it were all computed from
623
- // metadata that would not parse, so they are zeros, not measurements.
624
- const badge = el('span', { class: 'badge badge-unreadable', text: 'unreadable' });
625
- badge.title = c.unreadable;
626
- row.append(badge);
627
- }
628
- if (c.artifactSummary.blocked > 0) {
629
- row.append(el('span', { class: 'badge badge-blocked-warn', text: 'blocked' }));
630
- }
631
- if (isStale(c, staleDays)) {
632
- row.append(el('span', { class: 'badge badge-stale', text: 'stale' }));
633
- }
634
- row.append(artifactBlocks(c.artifactSummary.done, c.artifactSummary.total));
635
- row.append(el('span', { class: 'row-meta', text: `${c.artifactSummary.done}/${c.artifactSummary.total} artifacts` }));
636
- row.append(el('span', { class: 'row-meta', text: `tasks ${c.taskProgress.completed}/${c.taskProgress.total}` }));
637
- row.append(el('span', { class: 'row-time', text: relTime(c.lastModified) }));
638
- if (changed) flash(row);
639
- return row;
640
- }
641
-
642
- function updateChangesSection(staleDays) {
643
- const s = ov.changesSection;
644
- s.replaceChildren(el('h2', { class: 'section-title', text: 'Changes' }));
645
- if (!overview.changes.length) {
646
- s.append(emptyNote('No active changes. Create one with: tospec new change <name>'));
647
- return;
648
- }
649
- const changedNames = new Set();
650
- if (prevChangeStamps) {
651
- for (const c of overview.changes) {
652
- const prev = prevChangeStamps.get(c.name);
653
- if (prev !== undefined && prev !== c.lastModified) changedNames.add(c.name);
654
- }
655
- }
656
- for (const c of overview.changes) {
657
- s.append(changeRow(c, staleDays, changedNames.has(c.name)));
658
- }
659
- prevChangeStamps = new Map(overview.changes.map((c) => [c.name, c.lastModified]));
660
- }
661
-
662
- function updateTicketsSection() {
663
- const s = ov.ticketsSection;
664
- s.replaceChildren(el('h2', { class: 'section-title', text: 'Tickets' }));
665
- if (!overview.tickets.length) {
666
- s.append(emptyNote('No tickets.'));
667
- return;
668
- }
669
- for (const t of overview.tickets) {
670
- const row = el('div', { class: 'row ticket-row' + (t.parseError ? ' row-error' : '') });
671
- row.append(el('span', { class: 'row-time mono', text: fmtStamp(t.date) }));
672
- row.append(el('span', { class: 'row-name', text: t.title }));
673
- if (t.type) row.append(el('span', { class: 'badge badge-' + t.type, text: t.type }));
674
- if (t.change) {
675
- row.append(el('a', {
676
- class: 'ticket-change',
677
- href: '#/changes/' + encodeURIComponent(t.change),
678
- text: t.change,
679
- }));
680
- }
681
- if (t.parseError) row.append(el('span', { class: 'parse-error', text: 'parse error' }));
682
- else if (t.summary) row.append(el('span', { class: 'ticket-summary', text: t.summary }));
683
- if (prevTicketFiles && !prevTicketFiles.has(t.file)) flash(row);
684
- s.append(row);
685
- }
686
- prevTicketFiles = new Set(overview.tickets.map((t) => t.file));
687
- }
688
-
689
- function updateSpecsSection() {
690
- const s = ov.specsSection;
691
- s.replaceChildren(el('h2', { class: 'section-title', text: 'Specs' }));
692
- if (!overview.specs.length) {
693
- s.append(emptyNote('No specs.'));
694
- return;
695
- }
696
- for (const sp of overview.specs) {
697
- const row = el('a', { class: 'row spec-row', href: '#/specs/' + encodeURIComponent(sp.id) });
698
- row.append(el('span', { class: 'row-name mono', text: sp.id }));
699
- row.append(el('span', { class: 'row-meta', text: `requirements ${sp.requirementCount}` }));
700
- s.append(row);
701
- }
702
- }
703
-
704
- function renderArchive(body, data) {
705
- const changes = data.changes || [];
706
- const tickets = data.tickets || [];
707
- body.replaceChildren();
708
- if (!changes.length && !tickets.length) {
709
- body.append(emptyNote('Nothing archived.'));
710
- return;
711
- }
712
- if (changes.length) {
713
- body.append(el('h4', { text: 'Changes' }));
714
- for (const c of changes) {
715
- body.append(el('div', { class: 'row archive-row' }, [
716
- el('span', { class: 'row-name', text: c.name || c.dirName }),
717
- el('span', { class: 'row-time mono', text: fmtStamp(c.stamp) }),
718
- ]));
719
- }
720
- }
721
- if (tickets.length) {
722
- body.append(el('h4', { text: 'Tickets' }));
723
- for (const t of tickets) {
724
- body.append(el('div', { class: 'row archive-row' }, [
725
- el('span', { class: 'row-time mono', text: fmtStamp(t.date) }),
726
- el('span', { class: 'row-name', text: t.title }),
727
- ]));
728
- }
729
- }
730
- }
731
-
732
- function archiveSection(count) {
733
- const details = el('details', { class: 'archive' });
734
- details.append(el('summary', {}, ['Archive (' + count + ')']));
735
- const body = el('div', { class: 'archive-body' });
736
- body.append(emptyNote('Loading...'));
737
- details.append(body);
738
- let loaded = false;
739
- details.addEventListener('toggle', async () => {
740
- if (!details.open || loaded) return;
741
- loaded = true;
742
- try {
743
- renderArchive(body, await api('/api/archive'));
744
- } catch {
745
- loaded = false;
746
- body.replaceChildren(emptyNote('Failed to load archive.'));
747
- }
748
- });
749
- return details;
750
- }
751
-
752
- function updateOverview() {
753
- const staleDays = (activity && activity.staleDays) || 14;
754
-
755
- updateHealthStrip(staleDays);
756
-
757
- countUp(ov.cards.changes.count, overview.counts.changes);
758
- countUp(ov.cards.tickets.count, overview.counts.tickets);
759
- countUp(ov.cards.specs.count, overview.counts.specs);
760
- countUp(ov.cards.archived.count, overview.counts.archived);
761
-
762
- // Activity chart (hidden with a note when git is unavailable — ADR D6).
763
- if (hasChart) {
764
- if (activity && activity.available) {
765
- ov.activity.wrap.style.display = '';
766
- ov.activity.note.style.display = 'none';
767
- updateActivityChart(ov.activityChart, activity.days);
768
- } else {
769
- ov.activity.wrap.style.display = 'none';
770
- ov.activity.note.textContent = activity
771
- ? `No git activity (${activity.reason || 'unavailable'}).`
772
- : 'Loading activity...';
773
- ov.activity.note.style.display = '';
774
- }
775
-
776
- const done = overview.changes.reduce((n, c) => n + c.artifactSummary.done, 0);
777
- const blocked = overview.changes.reduce((n, c) => n + c.artifactSummary.blocked, 0);
778
- const total = overview.changes.reduce((n, c) => n + c.artifactSummary.total, 0);
779
- const ready = total - done - blocked;
780
- if (total > 0) {
781
- ov.doughnut.wrap.style.display = '';
782
- ov.doughnut.note.style.display = 'none';
783
- updateStatusDoughnut(
784
- ov.doughnutChart,
785
- [done, ready, blocked],
786
- Math.round((done / total) * 100) + '%'
787
- );
788
- } else {
789
- ov.doughnut.wrap.style.display = 'none';
790
- ov.doughnut.note.textContent = 'No artifacts.';
791
- ov.doughnut.note.style.display = '';
792
- }
793
-
794
- }
795
-
796
- updateTaskProgress();
797
- updateChangesSection(staleDays);
798
- updateTicketsSection();
799
- updateSpecsSection();
800
-
801
- // The archive accordion lazy-loads on expand; rebuild only when the count
802
- // changes so an open accordion isn't collapsed by every SSE refresh.
803
- if (ov.archiveCount !== overview.counts.archived) {
804
- ov.archiveCount = overview.counts.archived;
805
- ov.archiveSlot.replaceChildren(archiveSection(overview.counts.archived));
806
- }
807
- }
808
-
809
- function renderOverview() {
810
- if (!overview) {
811
- view.replaceChildren(emptyNote('Loading...'));
812
- return;
813
- }
814
- if (!ov || !view.contains(ov.health)) buildOverviewSkeleton();
815
- updateOverview();
816
- }
817
-
818
- // --- detail views ----------------------------------------------------------
819
-
820
- function leaveOverview() {
821
- ov = null;
822
- destroyCharts();
823
- prevChangeStamps = null;
824
- prevTicketFiles = null;
825
- }
826
-
827
- function backLink() {
828
- return el('a', { class: 'back', href: '#/', text: '← back' });
829
- }
830
-
831
- function metaItem(label, value) {
832
- return el('span', { class: 'meta-item' }, [
833
- el('span', { class: 'meta-label', text: label }),
834
- el('span', { class: 'meta-value', text: value }),
835
- ]);
836
- }
837
-
838
- /** File accordion: renders `/api/render` HTML lazily on first expand. */
839
- function fileAccordion(file) {
840
- const details = el('details', { class: 'file' });
841
- details.append(el('summary', {}, [
842
- el('span', { class: 'file-path mono', text: file.path }),
843
- file.artifactId ? el('span', { class: 'badge badge-artifact', text: file.artifactId }) : null,
844
- ]));
845
- const body = el('div', { class: 'rendered' });
846
- details.append(body);
847
- let loaded = false;
848
- details.addEventListener('toggle', async () => {
849
- if (!details.open || loaded) return;
850
- loaded = true;
851
- body.textContent = 'Loading...';
852
- try {
853
- const data = await api('/api/render?file=' + encodeURIComponent(file.path));
854
- body.innerHTML = data.html; // sanitized by core/markdown-render.ts - raw HTML is escaped there, not here
855
- } catch (e) {
856
- loaded = false;
857
- body.textContent = 'Failed to render (' + (e.status || 'error') + ').';
858
- }
859
- });
860
- return details;
861
- }
862
-
863
- /** Detail-page chart block: task ring + artifact stacked bar + change activity. */
864
- function detailCharts(detail, name) {
865
- if (!hasChart) return null;
866
- const t = chartTheme();
867
- const grid = el('section', { class: 'charts-grid charts-grid-3' });
868
-
869
- // Task completion ring.
870
- const task = chartPanel('Tasks', 150);
871
- grid.append(task.panel);
872
- const taskChart = buildStatusDoughnut(task.canvas, ['completed', 'remaining'], [t.series1, t.track]);
873
- const { completed, total } = detail.taskProgress;
874
- if (total > 0) {
875
- updateStatusDoughnut(taskChart, [completed, total - completed], Math.round((completed / total) * 100) + '%');
876
- } else {
877
- task.wrap.style.display = 'none';
878
- task.note.textContent = 'No tasks.';
879
- task.note.style.display = '';
880
- }
881
-
882
- // Artifact status stacked bar (single row, status palette).
883
- const art = chartPanel('Artifacts', 150);
884
- grid.append(art.panel);
885
- const counts = { done: 0, ready: 0, blocked: 0 };
886
- for (const a of detail.artifacts) counts[a.status] = (counts[a.status] || 0) + 1;
887
- if (detail.artifacts.length > 0) {
888
- newChart(art.canvas, {
889
- type: 'bar',
890
- data: {
891
- labels: [''],
892
- datasets: [
893
- { label: 'done', data: [counts.done], backgroundColor: t.done, borderRadius: 4, barThickness: 18 },
894
- { label: 'ready', data: [counts.ready], backgroundColor: t.ready, borderRadius: 4, barThickness: 18 },
895
- { label: 'blocked', data: [counts.blocked], backgroundColor: t.blocked, borderRadius: 4, barThickness: 18 },
896
- ],
897
- },
898
- options: {
899
- indexAxis: 'y',
900
- responsive: true,
901
- maintainAspectRatio: false,
902
- plugins: { legend: { position: 'bottom', labels: { color: t.muted, boxWidth: 10 } } },
903
- scales: {
904
- x: { stacked: true, ticks: { color: t.muted, precision: 0 }, grid: { display: false } },
905
- y: { stacked: true, ticks: { display: false }, grid: { display: false } },
906
- },
907
- },
908
- });
909
- } else {
910
- art.wrap.style.display = 'none';
911
- art.note.textContent = 'No artifacts.';
912
- art.note.style.display = '';
913
192
  }
914
193
 
915
- // This change's 30-day git activity (lazy: fetched after mount).
916
- const act = chartPanel('Activity (30 days)', 150);
917
- grid.append(act.panel);
918
- act.wrap.style.display = 'none';
919
- act.note.textContent = 'Loading activity...';
920
- act.note.style.display = '';
921
- api('/api/activity?change=' + encodeURIComponent(name))
922
- .then((data) => {
923
- if (!view.contains(act.panel)) return; // user navigated away
924
- if (data.available) {
925
- act.note.style.display = 'none';
926
- act.wrap.style.display = '';
927
- updateActivityChart(buildActivityChart(act.canvas, 'this change'), data.days);
928
- } else {
929
- act.note.textContent = `No git activity (${data.reason || 'unavailable'}).`;
930
- }
931
- })
932
- .catch(() => {
933
- act.note.textContent = 'Failed to load activity.';
934
- });
194
+ if (route.key) paintLine(signbar, slotOf(route.key));
195
+ signbar.classList.toggle('has-line', Boolean(route.key));
935
196
 
936
- return grid;
937
- }
938
-
939
- async function renderChangeDetail(name) {
940
- leaveOverview();
941
- view.replaceChildren(backLink(), emptyNote('Loading...'));
942
- let detail;
943
- try {
944
- detail = await api('/api/changes/' + encodeURIComponent(name));
945
- } catch (e) {
946
- const msg = e.status === 404 ? `Change "${name}" not found.` : 'Failed to load change.';
947
- view.replaceChildren(backLink(), emptyNote(msg));
948
- return;
949
- }
950
-
951
- const linkedDecisions = detail.metadata.decisions || [];
952
- const meta = el('div', { class: 'meta' }, [
953
- metaItem('schema', detail.metadata.schema || '—'),
954
- detail.metadata.type ? metaItem('type', detail.metadata.type) : null,
955
- detail.metadata.created ? metaItem('created', detail.metadata.created) : null,
956
- detail.metadata.goal ? metaItem('goal', detail.metadata.goal) : null,
957
- // Linked ADRs (metadata `decisions`) — each links into the decisions view.
958
- linkedDecisions.length
959
- ? el('span', { class: 'meta-item' }, [
960
- el('span', { class: 'meta-label', text: 'decisions' }),
961
- el('span', { class: 'meta-value' }, linkedDecisions.map((f, i) => el('span', {}, [
962
- i > 0 ? ', ' : null,
963
- el('a', { href: '#/decisions/' + encodeURIComponent(f), text: f }),
964
- ]))),
965
- ])
966
- : null,
967
- ]);
968
-
969
- const artifacts = el('section', { class: 'panel' });
970
- artifacts.append(el('h3', { text: 'Artifacts' }));
971
- for (const a of detail.artifacts) {
972
- const row = el('div', { class: 'artifact-row' }, [
973
- el('span', { class: 'dot dot-' + a.status }),
974
- el('span', { class: 'artifact-id mono', text: a.id }),
975
- el('span', { class: 'artifact-status', text: a.status }),
976
- ]);
977
- if (a.status === 'blocked' && a.missingDeps && a.missingDeps.length) {
978
- row.append(el('span', { class: 'blocked-by', text: 'blocked by: ' + a.missingDeps.join(', ') }));
979
- }
980
- artifacts.append(row);
981
- }
982
-
983
- const files = el('section', { class: 'panel' });
984
- files.append(el('h3', { text: 'Files' }));
985
- if (!detail.files.length) files.append(emptyNote('No files yet.'));
986
- for (const f of detail.files) files.append(fileAccordion(f));
987
-
988
- const nodes = [
989
- backLink(),
990
- el('h2', { text: detail.name }),
991
- meta,
992
- el('p', { class: 'task-progress', text: `Tasks ${detail.taskProgress.completed}/${detail.taskProgress.total}` }),
993
- detailCharts(detail, name),
994
- artifacts,
995
- files,
996
- ].filter(Boolean);
997
- view.replaceChildren(...nodes);
998
- }
999
-
1000
- async function renderSpecDetail(id) {
1001
- leaveOverview();
1002
- view.replaceChildren(backLink(), el('h2', { text: id }), emptyNote('Loading...'));
1003
- const nodes = [backLink(), el('h2', { text: id })];
1004
-
1005
- try {
1006
- const spec = await api('/api/specs/' + encodeURIComponent(id));
1007
- nodes.push(el('p', { class: 'muted', text: `requirements ${spec.requirements.length}` }));
1008
- } catch (e) {
1009
- if (e.status === 404) {
1010
- view.replaceChildren(backLink(), emptyNote(`Spec "${id}" not found.`));
1011
- return;
1012
- }
1013
- }
1014
-
1015
- const body = el('div', { class: 'rendered' });
1016
- body.textContent = 'Loading...';
1017
- nodes.push(body);
1018
- view.replaceChildren(...nodes);
1019
-
1020
- try {
1021
- const data = await api('/api/render?file=' + encodeURIComponent(`tospec/specs/${id}/spec.md`));
1022
- body.innerHTML = data.html; // sanitized by core/markdown-render.ts - raw HTML is escaped there, not here
1023
- } catch {
1024
- body.textContent = 'Failed to render spec.';
1025
- }
1026
- }
1027
-
1028
- // --- decisions ---------------------------------------------------------------
1029
-
1030
- let decisions = []; // cached /api/decisions payload (refetched on every visit)
1031
- let decisionsQuery = ''; // search box text, kept across re-renders of this route
1032
-
1033
- /** Full-text match: title, topic, status, and the raw markdown body. */
1034
- function decisionMatches(d, q) {
1035
- if (!q) return true;
1036
- return (d.title + ' ' + d.topic + ' ' + d.status + ' ' + d.content).toLowerCase().includes(q);
1037
- }
1038
-
1039
- function decisionIndexRow(d, active) {
1040
- const row = el('a', {
1041
- class: 'decision-row' + (active ? ' decision-row-active' : ''),
1042
- href: '#/decisions/' + encodeURIComponent(d.file),
1043
- });
1044
- row.append(el('span', { class: 'decision-title', text: d.title }));
1045
- row.append(el('div', { class: 'decision-meta' }, [
1046
- el('span', { class: 'mono', text: fmtStamp(d.date) }),
1047
- el('span', { class: 'badge badge-' + d.status, text: d.status }),
1048
- ]));
1049
- return row;
1050
- }
1051
-
1052
- function renderDecisionIndexList(container, list, activeFile) {
1053
- container.replaceChildren();
1054
- if (!list.length) {
1055
- container.append(emptyNote('No matching decisions.'));
1056
- return;
1057
- }
1058
- for (const d of list) container.append(decisionIndexRow(d, d.file === activeFile));
1059
- }
1060
-
1061
- /** Renders one decision's rendered markdown into the content pane. */
1062
- async function renderDecisionContent(pane, file) {
1063
- const d = decisions.find((x) => x.file === file);
1064
- if (!d) {
1065
- pane.replaceChildren(emptyNote('Decision not found.'));
1066
- return;
1067
- }
1068
- const body = el('div', { class: 'rendered' });
1069
- body.textContent = 'Loading...';
1070
- pane.replaceChildren(
1071
- el('h2', { text: d.title }),
1072
- el('div', { class: 'meta' }, [metaItem('date', fmtStamp(d.date)), metaItem('status', d.status)]),
1073
- body
1074
- );
1075
- try {
1076
- const data = await api('/api/render?file=' + encodeURIComponent(d.path));
1077
- body.innerHTML = data.html; // sanitized by core/markdown-render.ts - raw HTML is escaped there, not here
1078
- enhanceDecisionQA(body, d.content); // 決策過程 → Q/A cards (client-side, graceful fallback)
1079
- } catch {
1080
- body.textContent = 'Failed to render decision.';
1081
- }
1082
- }
1083
-
1084
- /** Extracts `{ q, a }` pairs from the `## 決策過程` section of raw markdown.
1085
- * Canonical shape: a `**Q:**` line, then the answer in the following
1086
- * paragraph(s), until the next `**Q:**` or the next `## ` heading. Old-format
1087
- * or empty sections yield `[]`, so the caller leaves marked's output as-is. */
1088
- function parseDecisionQA(markdown) {
1089
- if (!markdown) return [];
1090
- const start = markdown.search(/^##\s+決策過程\s*$/m);
1091
- if (start < 0) return [];
1092
- const rest = markdown.slice(start).replace(/^##\s+決策過程\s*$/m, '');
1093
- const next = rest.search(/^##\s/m); // stop at the following section, if any
1094
- const section = next >= 0 ? rest.slice(0, next) : rest;
1095
- const pairs = [];
1096
- for (const block of section.split(/^\*\*Q:\*\*/m).slice(1)) {
1097
- const nl = block.indexOf('\n');
1098
- const q = (nl < 0 ? block : block.slice(0, nl)).trim();
1099
- const a = (nl < 0 ? '' : block.slice(nl + 1)).trim();
1100
- if (q) pairs.push({ q, a });
1101
- }
1102
- return pairs;
1103
- }
1104
-
1105
- /** Swaps the rendered 決策過程 body (always the last section) for Q/A cards, keeping its heading. */
1106
- function enhanceDecisionQA(body, markdown) {
1107
- const pairs = parseDecisionQA(markdown);
1108
- if (!pairs.length) return;
1109
- const heading = [...body.querySelectorAll('h2')].find((h) => h.textContent.trim() === '決策過程');
1110
- if (!heading) return;
1111
- while (heading.nextSibling) heading.nextSibling.remove(); // drop marked's raw Q&A, keep the heading
1112
- heading.after(
1113
- el(
1114
- 'section',
1115
- { class: 'qa-cards' },
1116
- pairs.map((p) =>
1117
- el('div', { class: 'qa-card' }, [
1118
- el('div', { class: 'qa-q', text: p.q }),
1119
- el('div', { class: 'qa-a', text: p.a }),
1120
- ])
1121
- )
1122
- )
197
+ // A live region: rewrite it only when the link state flips, or every push
198
+ // would announce "connected" again.
199
+ const state = store.hubConnected ? 'up' : 'down';
200
+ if (hubStatus.dataset.state === state) return;
201
+ hubStatus.dataset.state = state;
202
+ hubStatus.replaceChildren(
203
+ el('span', { class: 'hub-dot', 'aria-hidden': 'true' }),
204
+ // Always names the hub: a bare "connected" read as the project's session
205
+ // on an offline project's page, which says the opposite.
206
+ el('span', { class: 'hub-text', text: store.hubConnected ? 'hub 已連線' : 'hub 中斷' })
1123
207
  );
208
+ hubStatus.classList.toggle('is-down', !store.hubConnected);
209
+ hubStatus.title = store.hubConnected
210
+ ? '即時更新中: session 推送快照時畫面會跟著更新.'
211
+ : '與 dashboard hub 的連線中斷. 畫面停在最後收到的資料, 連線恢復後會自動更新.';
1124
212
  }
1125
213
 
1126
- /**
1127
- * Decisions tab: a floating, always-visible index (search + list) on the left
1128
- * and the selected decision's rendered content on the right. Selecting an
1129
- * entry navigates to `#/decisions/<file>` so it's linkable and survives
1130
- * refresh; the index itself never leaves the viewport.
1131
- */
1132
- async function renderDecisionsList(activeFile) {
1133
- leaveOverview();
1134
- view.replaceChildren(emptyNote('Loading...'));
1135
- try {
1136
- decisions = await api('/api/decisions');
1137
- } catch {
1138
- view.replaceChildren(el('p', { class: 'error', text: 'Failed to load decisions.' }));
1139
- return;
1140
- }
214
+ // --- the sidebar as a drawer (below 960px) ------------------------------------------
1141
215
 
1142
- const search = el('input', {
1143
- type: 'search',
1144
- class: 'decisions-search',
1145
- placeholder: 'Search decisions (title, status, content)...',
1146
- });
1147
- search.value = decisionsQuery;
1148
- const list = el('div', { class: 'decisions-list' });
1149
- const index = el('div', { class: 'decisions-index' }, [search, list]);
1150
- const content = el('div', { class: 'decisions-content panel' });
1151
-
1152
- function refreshList() {
1153
- decisionsQuery = search.value;
1154
- const q = decisionsQuery.trim().toLowerCase();
1155
- renderDecisionIndexList(list, decisions.filter((d) => decisionMatches(d, q)), activeFile);
216
+ function setNavOpen(open) {
217
+ sidebar.classList.toggle('is-open', open);
218
+ navScrim.hidden = !open;
219
+ navToggle.setAttribute('aria-expanded', String(open));
220
+ if (open) {
221
+ const current = sidebar.querySelector('[aria-current="page"]') || sidebar.querySelector('a');
222
+ if (current) current.focus({ preventScroll: true });
1156
223
  }
1157
- search.addEventListener('input', refreshList);
1158
- refreshList();
1159
-
1160
- view.replaceChildren(index, content);
224
+ }
1161
225
 
1162
- if (activeFile) {
1163
- await renderDecisionContent(content, activeFile);
1164
- } else if (decisions.length) {
1165
- content.append(emptyNote('Select a decision from the index on the left.'));
1166
- } else {
1167
- content.append(emptyNote('No decisions yet. Create one with: tospec decision new <topic>'));
226
+ navToggle.replaceChildren(icon('menu', 16), el('span', { class: 'sr-only', text: 'Projects' }));
227
+ navToggle.addEventListener('click', () => setNavOpen(!sidebar.classList.contains('is-open')));
228
+ navScrim.addEventListener('click', () => setNavOpen(false));
229
+ window.addEventListener('keydown', (event) => {
230
+ if (event.key === 'Escape' && sidebar.classList.contains('is-open')) {
231
+ setNavOpen(false);
232
+ navToggle.focus();
1168
233
  }
1169
- }
234
+ });
1170
235
 
1171
- // --- routing ---------------------------------------------------------------
236
+ // --- wiring ----------------------------------------------------------------------
1172
237
 
1173
- /** Highlights the active top-level tab based on the current hash. */
1174
- function updateActiveTab(hash) {
1175
- const isDecisions = hash.startsWith('#/decisions');
1176
- document.querySelectorAll('.tab').forEach((t) => {
1177
- t.classList.toggle('tab-active', (t.dataset.tab === 'decisions') === isDecisions);
1178
- });
1179
- }
238
+ onStoreChange(() => {
239
+ render({ live: true });
240
+ checkSessionAlerts();
241
+ });
242
+ setupNotifyButton(document.getElementById('notify-btn'));
243
+ window.addEventListener('hashchange', () => {
244
+ // A link in the drawer was followed: the page it opened is what to show.
245
+ setNavOpen(false);
246
+ render();
247
+ });
1180
248
 
1181
- function isOverviewRoute() {
1182
- const h = location.hash;
1183
- return h === '' || h === '#' || h === '#/';
1184
- }
249
+ refreshBtn.replaceChildren(icon('refresh', 16), el('span', { class: 'btn-text', text: '重新整理' }));
250
+ refreshBtn.addEventListener('click', async () => {
251
+ refreshBtn.disabled = true;
252
+ const route = parseRoute(location.hash);
253
+ // Live sessions push fresh snapshots (arriving as `change` events); the
254
+ // reload below re-reads what the hub already holds, offline projects included.
255
+ requestRefresh(route.key ? [route.key] : store.projects.map((p) => p.key));
256
+ await loadProjects();
257
+ await loadAllProjects();
258
+ refreshBtn.disabled = false;
259
+ });
1185
260
 
1186
- function router() {
1187
- const h = location.hash;
1188
- updateActiveTab(h);
1189
- view.classList.toggle('view-decisions', h.startsWith('#/decisions'));
1190
- if (h.startsWith('#/changes/')) {
1191
- renderChangeDetail(decodeURIComponent(h.slice('#/changes/'.length)));
1192
- } else if (h.startsWith('#/specs/')) {
1193
- renderSpecDetail(decodeURIComponent(h.slice('#/specs/'.length)));
1194
- } else if (h.startsWith('#/decisions/')) {
1195
- renderDecisionsList(decodeURIComponent(h.slice('#/decisions/'.length)));
1196
- } else if (h === '#/decisions') {
1197
- renderDecisionsList(null);
1198
- } else {
1199
- renderOverview();
1200
- }
1201
- }
261
+ // Relative times and the idle threshold move with the clock, not with pushes.
262
+ setInterval(() => {
263
+ if (LIVE_VIEWS.has(parseRoute(location.hash).view)) render({ live: true });
264
+ }, 60000);
1202
265
 
1203
- /** Full route rebuild (theme change): drop the skeleton so charts re-theme. */
1204
- function rebuildRoute() {
1205
- if (!view) return;
1206
- if (isOverviewRoute()) {
1207
- ov = null;
1208
- destroyCharts();
1209
- if (overview) renderOverview();
1210
- } else {
1211
- router();
1212
- }
266
+ /** A push from `key`: rendered files go stale, and an open change page re-reads its detail. */
267
+ function onProjectPush(key) {
268
+ clearRenderCache(key);
269
+ const route = parseRoute(location.hash);
270
+ if (route.view === 'change' && route.key === key) loadDetail(key, route.name, true);
1213
271
  }
1214
272
 
1215
- window.addEventListener('hashchange', router);
1216
- refreshBtn.addEventListener('click', () => {
1217
- if (isOverviewRoute()) {
1218
- loadOverview();
1219
- loadActivity();
1220
- } else {
1221
- router();
1222
- }
1223
- });
1224
-
1225
- // Live updates: the server pushes a `change` event on any tospec file edit, so
1226
- // the UI refreshes itself without polling or a manual reload. EventSource
1227
- // reconnects on its own; we only reflect the link state.
1228
- function subscribe() {
1229
- const es = new EventSource('/api/events');
1230
- es.addEventListener('change', () => {
1231
- loadOverview(); // refreshes counts/lists/charts (morph + flash on overview)
1232
- loadActivity();
1233
- if (!isOverviewRoute()) router(); // refresh the open detail view too
1234
- });
1235
- es.onopen = () => setConnected(true);
1236
- es.onerror = () => setConnected(false);
273
+ async function start() {
274
+ subscribe(onProjectPush);
275
+ render();
276
+ await loadProjects();
277
+ await loadAllProjects();
1237
278
  }
1238
279
 
1239
- router();
1240
- loadOverview();
1241
- loadActivity();
1242
- subscribe();
280
+ start();