codecartographer-pi 0.8.0 → 0.9.1

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.
@@ -1,33 +1,30 @@
1
- // Pure HTML dashboard renderer for a CodeCartographer workspace. No I/O —
2
- // the writer (extensions/codecarto/dashboard-writer.ts) gathers all inputs
3
- // and calls renderDashboard() to produce a self-contained HTML document.
4
- //
5
- // Self-contained: embedded <style>, no JavaScript, no external assets.
6
- // Works opened directly from a file:// URL. Light/dark via
7
- // prefers-color-scheme. Mobile via a single max-width: 720px collapse rule.
8
- //
9
- // HTML safety: every disk-sourced value (phase IDs, owner notes, open-question
10
- // descriptions, carry-forward target_phase, closeout filenames, paths inside
11
- // href attributes) passes through escapeHtml. Href path segments also get
12
- // URL-encoded before escaping.
1
+ // Pure-ish HTML dashboard renderer for a CodeCartographer workspace. The
2
+ // writer gathers disk state and calls renderDashboard() to produce a
3
+ // self-contained, file://-friendly report. The report includes a small embedded
4
+ // script for local-only filtering and export; it has no external assets.
13
5
  import { formatMillis, formatTokenCount } from "./utils.js";
14
6
  import { computePerPhaseTotals, computeTotals } from "./usage.js";
15
7
  export const DASHBOARD_RELATIVE_PATH = "dashboard.html";
16
8
  export const NARRATION_CACHE_RELATIVE_PATH = ".dashboard-narration.local.md";
17
9
  const TIMELINE_VISIBLE_COUNT = 10;
18
10
  export function renderDashboard(inputs) {
11
+ const projectName = inputs.status.project_name || "CodeCartographer";
19
12
  const sections = [
13
+ renderSidebar(inputs),
14
+ `<div class="cc-main">`,
20
15
  renderHeader(inputs),
16
+ renderStalenessWarning(inputs),
21
17
  inputs.narration ? renderNarration(inputs.narration, completedPhaseCount(inputs.status)) : "",
18
+ renderKeyResults(inputs),
22
19
  renderProgressBar(inputs.pipeline, inputs.status),
23
20
  renderPhaseCards(inputs),
24
- renderUsagePanel(inputs.usage),
21
+ renderUsagePanel(inputs),
25
22
  renderActivityTimeline(inputs.usage.runs),
26
23
  renderOpenQuestionsRollup(inputs.status),
27
- renderCloseoutsList(inputs.closeouts),
24
+ renderCloseoutsList(inputs),
28
25
  renderFooter(inputs),
26
+ `</div>`,
29
27
  ].filter(Boolean).join("\n");
30
- const projectName = inputs.status.project_name || "CodeCartographer";
31
28
  return [
32
29
  "<!DOCTYPE html>",
33
30
  `<html lang="en">`,
@@ -38,9 +35,12 @@ export function renderDashboard(inputs) {
38
35
  renderStyles(),
39
36
  "</head>",
40
37
  "<body>",
38
+ `<button class="cc-menu-button" type="button" data-sidebar-toggle aria-label="Toggle navigation">☰</button>`,
41
39
  `<main class="cc-dashboard">`,
42
40
  sections,
43
41
  "</main>",
42
+ renderExportData(inputs),
43
+ renderScripts(),
44
44
  "</body>",
45
45
  "</html>",
46
46
  ].join("\n");
@@ -48,56 +48,140 @@ export function renderDashboard(inputs) {
48
48
  // ----------------------------------------------------------------------------
49
49
  // Sections
50
50
  // ----------------------------------------------------------------------------
51
+ function renderSidebar(inputs) {
52
+ const phaseLinks = inputs.pipeline.phase_order.map((phaseId) => {
53
+ const state = phaseRenderState(inputs.status, phaseId);
54
+ return `<a class="cc-nav-link cc-state-${state}" href="#${phaseAnchor(phaseId)}" data-status="${escapeAttr(state)}"><span>${escapeHtml(phaseId)}</span><b>${escapeHtml(state)}</b></a>`;
55
+ }).join("\n");
56
+ return [
57
+ `<aside class="cc-sidebar" data-sidebar>`,
58
+ `<div class="cc-brand">`,
59
+ `<span class="cc-brand-mark">◇</span>`,
60
+ `<div><strong>CodeCartographer</strong><span>${escapeHtml(inputs.status.project_name || "workspace")}</span></div>`,
61
+ `</div>`,
62
+ `<label class="cc-search-label">Search dashboard</label>`,
63
+ `<input class="cc-search" type="search" placeholder="phase, output, question…" data-search>`,
64
+ `<div class="cc-filter-row" aria-label="Status filters">`,
65
+ `<button type="button" class="cc-filter is-active" data-filter="all">All</button>`,
66
+ `<button type="button" class="cc-filter" data-filter="complete">Complete</button>`,
67
+ `<button type="button" class="cc-filter" data-filter="current">Current</button>`,
68
+ `<button type="button" class="cc-filter" data-filter="pending">Pending</button>`,
69
+ `</div>`,
70
+ `<nav class="cc-nav" aria-label="Dashboard sections">`,
71
+ `<a class="cc-nav-section" href="#summary">Summary</a>`,
72
+ `<a class="cc-nav-section" href="#key-results">Key results</a>`,
73
+ `<a class="cc-nav-section" href="#phases">Phases</a>`,
74
+ phaseLinks,
75
+ `<a class="cc-nav-section" href="#usage">Usage</a>`,
76
+ `<a class="cc-nav-section" href="#closeouts">Closeouts</a>`,
77
+ `</nav>`,
78
+ `<button type="button" class="cc-export" data-export>Export dashboard JSON</button>`,
79
+ `<p class="cc-sidebar-foot">Self-contained local report. No network calls.</p>`,
80
+ `</aside>`,
81
+ ].join("\n");
82
+ }
51
83
  function renderHeader(inputs) {
52
84
  const { status, pipeline, packageVersion, generatedAt } = inputs;
53
85
  const projectName = status.project_name || "(unnamed project)";
54
86
  const pipelineLabel = pipeline.workflow_name || status.pipeline;
55
87
  const currentPhase = status.current_phase || "—";
88
+ const totals = computeTotals(inputs.usage);
89
+ const completed = completedPhaseCount(status);
90
+ const total = pipeline.phase_order.length;
91
+ const nextActions = status.next_actions?.length
92
+ ? `<ul class="cc-next-actions">${status.next_actions.map((a) => `<li>${escapeHtml(a)}</li>`).join("")}</ul>`
93
+ : `<p class="cc-muted">No next actions recorded.</p>`;
56
94
  return [
57
- `<header class="cc-header">`,
95
+ `<header class="cc-header" id="summary" data-section data-search-text="${escapeAttr([projectName, pipelineLabel, currentPhase, pipeline.workflow_goal ?? ""].join(" "))}">`,
96
+ `<div class="cc-eyebrow">CodeCartographer dashboard</div>`,
58
97
  `<h1>${escapeHtml(projectName)}</h1>`,
98
+ pipeline.workflow_goal ? `<p class="cc-goal">${escapeHtml(pipeline.workflow_goal)}</p>` : "",
99
+ `<div class="cc-stat-grid">`,
100
+ renderStat("Pipeline", pipelineLabel),
101
+ renderStat("Current phase", currentPhase),
102
+ renderStat("Progress", `${completed}/${total} phases`),
103
+ renderStat("Recorded tokens", formatTokenCount(totals.tokens.input + totals.tokens.output)),
104
+ renderStat("Tool uses", String(totals.tool_uses)),
105
+ renderStat("Package", `v${packageVersion}`),
106
+ `</div>`,
107
+ `<details class="cc-meta-details">`,
108
+ `<summary>Run metadata and next actions</summary>`,
59
109
  `<dl class="cc-header-meta">`,
60
- `<dt>Pipeline</dt><dd>${escapeHtml(pipelineLabel)}</dd>`,
61
- `<dt>Current phase</dt><dd>${escapeHtml(currentPhase)}</dd>`,
62
110
  `<dt>Status last updated</dt><dd>${escapeHtml(status.last_updated || "never")}</dd>`,
63
111
  `<dt>Dashboard generated</dt><dd>${escapeHtml(generatedAt)}</dd>`,
64
- `<dt>Package version</dt><dd>codecartographer-pi v${escapeHtml(packageVersion)}</dd>`,
112
+ `<dt>Status file</dt><dd><code>${escapeHtml(status.pipeline)}</code></dd>`,
65
113
  `</dl>`,
114
+ `<h3>Next actions</h3>`,
115
+ nextActions,
116
+ `</details>`,
66
117
  `</header>`,
67
- ].join("\n");
118
+ ].filter(Boolean).join("\n");
119
+ }
120
+ function renderStat(label, value) {
121
+ return `<div class="cc-stat"><span>${escapeHtml(label)}</span><strong>${escapeHtml(value)}</strong></div>`;
122
+ }
123
+ function renderStalenessWarning(inputs) {
124
+ if (!inputs.status.last_updated || inputs.status.last_updated <= inputs.generatedAt)
125
+ return "";
126
+ return `<section class="cc-warning" data-section><strong>Dashboard may be stale.</strong> Status was updated at ${escapeHtml(inputs.status.last_updated)} after this dashboard was generated at ${escapeHtml(inputs.generatedAt)}. Regenerate with <code>/codecarto-dashboard</code>.</section>`;
68
127
  }
69
128
  function renderNarration(narration, currentCompletedCount) {
70
129
  const runsSince = Math.max(0, currentCompletedCount - narration.phaseCountAtGeneration);
71
- const staleness = runsSince === 0
72
- ? "current"
73
- : `${runsSince} run${runsSince === 1 ? "" : "s"} since`;
130
+ const staleness = runsSince === 0 ? "current" : `${runsSince} run${runsSince === 1 ? "" : "s"} since`;
74
131
  return [
75
- `<section class="cc-narration" aria-label="Executive summary">`,
76
- `<h2>Executive summary</h2>`,
77
- `<p class="cc-narration-meta">Narrated ${escapeHtml(narration.generatedAt)} · ${escapeHtml(staleness)}.</p>`,
78
- `<div class="cc-narration-body">`,
79
- // The narration body is LLM-generated Markdown. We do not parse Markdown
80
- // to HTML here (zero-dep policy); we escape it and render in a <pre>
81
- // so the user gets readable output without an XSS vector. A future
82
- // upgrade could swap in a tiny Markdown renderer.
132
+ `<section class="cc-card cc-narration" aria-label="Executive summary" data-section data-search-text="${escapeAttr(narration.content)}">`,
133
+ `<div class="cc-section-head"><h2>Executive summary</h2><span>${escapeHtml(staleness)}</span></div>`,
134
+ `<p class="cc-narration-meta">Narrated ${escapeHtml(narration.generatedAt)}.</p>`,
83
135
  `<pre>${escapeHtml(narration.content)}</pre>`,
84
- `</div>`,
85
136
  `</section>`,
86
137
  ].join("\n");
87
138
  }
139
+ function renderKeyResults(inputs) {
140
+ const rows = [];
141
+ for (const phaseId of inputs.pipeline.phase_order) {
142
+ const phase = getPhase(inputs.pipeline, phaseId);
143
+ if (!phase?.primary_output)
144
+ continue;
145
+ const outputs = inputs.outputsPresent.get(phaseId);
146
+ const closeout = closeoutForPhase(inputs.closeouts, phaseId);
147
+ const primary = outputs?.primary;
148
+ if (primary?.exists || phaseRenderState(inputs.status, phaseId) === "complete") {
149
+ rows.push(renderArtifactRow({ phaseId, label: "Primary result", path: phase.primary_output, exists: primary?.exists ?? false, closeout }));
150
+ }
151
+ }
152
+ if (rows.length === 0)
153
+ return "";
154
+ return [
155
+ `<section class="cc-card" id="key-results" aria-label="Key results" data-section data-search-text="key results final artifacts outputs">`,
156
+ `<div class="cc-section-head"><h2>Key results</h2><span>${rows.length} artifacts</span></div>`,
157
+ `<div class="cc-artifact-list">${rows.join("\n")}</div>`,
158
+ `</section>`,
159
+ ].join("\n");
160
+ }
161
+ function renderArtifactRow(input) {
162
+ const result = input.exists
163
+ ? renderSafeLink(input.path, input.path)
164
+ : `<span class="cc-missing-path">${escapeHtml(input.path)}</span>`;
165
+ const closeout = input.closeout
166
+ ? renderSafeLink(`closeouts/${input.closeout.fileName}`, "closeout", "cc-pill")
167
+ : `<span class="cc-pill cc-pill-muted">no closeout</span>`;
168
+ return `<article class="cc-artifact-row" data-search-text="${escapeAttr(`${input.phaseId} ${input.path} ${input.label}`)}"><div><a class="cc-phase-jump" href="#${phaseAnchor(input.phaseId)}">${escapeHtml(input.phaseId)}</a><span>${escapeHtml(input.label)}</span></div><div>${result}</div><div>${input.exists ? `<span class="cc-pill cc-pill-ok">available</span>` : `<span class="cc-pill cc-pill-bad">missing</span>`}${closeout}</div></article>`;
169
+ }
88
170
  function renderProgressBar(pipeline, status) {
89
171
  const items = pipeline.phase_order.map((phaseId) => {
90
172
  const state = phaseRenderState(status, phaseId);
91
- const purpose = pipeline.phases.find((p) => p.id === phaseId)?.purpose ?? "";
173
+ const purpose = getPhase(pipeline, phaseId)?.purpose ?? "";
92
174
  return [
93
175
  `<li class="cc-progress-item cc-state-${state}" title="${escapeAttr(purpose)}">`,
176
+ `<a href="#${phaseAnchor(phaseId)}">`,
94
177
  `<span class="cc-progress-id">${escapeHtml(phaseId)}</span>`,
95
178
  `<span class="cc-progress-badge">${escapeHtml(state)}</span>`,
179
+ `</a>`,
96
180
  `</li>`,
97
181
  ].join("");
98
182
  }).join("\n");
99
183
  return [
100
- `<section class="cc-progress" aria-label="Pipeline progress">`,
184
+ `<section class="cc-progress" aria-label="Pipeline progress" data-section data-search-text="pipeline progress">`,
101
185
  `<h2>Pipeline progress</h2>`,
102
186
  `<ol class="cc-progress-list">`,
103
187
  items,
@@ -106,24 +190,28 @@ function renderProgressBar(pipeline, status) {
106
190
  ].join("\n");
107
191
  }
108
192
  function renderPhaseCards(inputs) {
109
- const { status, pipeline, usage, outputsPresent } = inputs;
193
+ const { status, pipeline, usage, outputsPresent, closeouts } = inputs;
110
194
  const perPhaseLastRun = lastRunPerPhase(usage.runs);
111
195
  const cards = pipeline.phase_order.map((phaseId) => {
112
- const phaseDef = pipeline.phases.find((p) => p.id === phaseId);
196
+ const phaseDef = getPhase(pipeline, phaseId);
113
197
  const phaseState = status.phases[phaseId];
114
198
  const renderState = phaseRenderState(status, phaseId);
115
199
  const outputs = outputsPresent.get(phaseId);
116
200
  const lastRun = perPhaseLastRun.get(phaseId);
201
+ const closeout = closeoutForPhase(closeouts, phaseId);
117
202
  const open = renderState === "running" || renderState === "current";
118
203
  const detailsAttr = open ? " open" : "";
204
+ const searchText = [phaseId, phaseDef?.purpose, phaseDef?.primary_output, ...(phaseDef?.secondary_outputs ?? []).map((s) => s.path), ...(phaseState?.owner_notes ?? []), ...(phaseState?.open_questions ?? []).map((q) => q.description)].filter(Boolean).join(" ");
119
205
  return [
120
- `<details class="cc-phase-card cc-state-${renderState}"${detailsAttr}>`,
206
+ `<details class="cc-phase-card cc-state-${renderState}" id="${phaseAnchor(phaseId)}" data-section data-phase data-status="${escapeAttr(renderState)}" data-search-text="${escapeAttr(searchText)}"${detailsAttr}>`,
121
207
  `<summary>`,
122
208
  `<span class="cc-phase-id">${escapeHtml(phaseId)}</span>`,
123
209
  `<span class="cc-phase-badge">${escapeHtml(renderState)}</span>`,
124
210
  `</summary>`,
125
211
  renderPhasePurpose(phaseDef),
212
+ renderPhaseOverview(phaseDef),
126
213
  renderPhaseOutputs(phaseDef, outputs),
214
+ renderPhaseCloseout(closeout),
127
215
  renderPhaseOpenQuestions(phaseState),
128
216
  renderPhaseCarryForward(phaseState),
129
217
  renderPhaseOwnerNotes(phaseState),
@@ -132,7 +220,7 @@ function renderPhaseCards(inputs) {
132
220
  ].filter(Boolean).join("\n");
133
221
  }).join("\n");
134
222
  return [
135
- `<section class="cc-phases" aria-label="Per-phase status">`,
223
+ `<section class="cc-phases" id="phases" aria-label="Per-phase status">`,
136
224
  `<h2>Phases</h2>`,
137
225
  cards,
138
226
  `</section>`,
@@ -143,43 +231,54 @@ function renderPhasePurpose(phase) {
143
231
  return "";
144
232
  return `<p class="cc-phase-purpose">${escapeHtml(phase.purpose)}</p>`;
145
233
  }
234
+ function renderPhaseOverview(phase) {
235
+ if (!phase)
236
+ return "";
237
+ const bits = [];
238
+ if ((phase.depends_on ?? []).length > 0)
239
+ bits.push(`<div><strong>Depends on</strong>${phase.depends_on.map((d) => `<a class="cc-pill" href="#${phaseAnchor(d)}">${escapeHtml(d)}</a>`).join("")}</div>`);
240
+ if ((phase.required_reads ?? []).length > 0)
241
+ bits.push(`<div><strong>Required reads</strong>${phase.required_reads.map((r) => `<code>${escapeHtml(r)}</code>`).join(" ")}</div>`);
242
+ if (bits.length === 0)
243
+ return "";
244
+ return `<div class="cc-phase-section cc-phase-overview">${bits.join("")}</div>`;
245
+ }
146
246
  function renderPhaseOutputs(phase, outputs) {
147
247
  if (!phase)
148
248
  return "";
149
249
  const lines = [];
150
250
  if (phase.primary_output) {
151
251
  const present = outputs?.primary?.exists ?? false;
152
- const href = relativeHref(phase.primary_output);
153
- lines.push(present
154
- ? `<li><a href="${escapeAttr(href)}">${escapeHtml(phase.primary_output)}</a> <span class="cc-tag cc-tag-present">primary</span></li>`
155
- : `<li><span class="cc-tag cc-tag-missing">primary (missing)</span> ${escapeHtml(phase.primary_output)}</li>`);
252
+ const link = renderSafeLink(phase.primary_output, phase.primary_output);
253
+ lines.push(present && link
254
+ ? `<li>${link} <span class="cc-pill cc-pill-ok">primary</span></li>`
255
+ : `<li><span class="cc-pill cc-pill-bad">primary missing</span> <span class="cc-missing-path">${escapeHtml(phase.primary_output)}</span></li>`);
156
256
  }
157
257
  for (const sec of phase.secondary_outputs ?? []) {
158
258
  const present = outputs?.secondary.find((s) => s.path === sec.path)?.exists ?? false;
159
- const href = relativeHref(sec.path);
160
- lines.push(present
161
- ? `<li><a href="${escapeAttr(href)}">${escapeHtml(sec.path)}</a> <span class="cc-tag">secondary</span></li>`
162
- : `<li><span class="cc-tag cc-tag-missing">secondary (missing)</span> ${escapeHtml(sec.path)}</li>`);
259
+ const link = renderSafeLink(sec.path, sec.path);
260
+ lines.push(present && link
261
+ ? `<li>${link} <span class="cc-pill">secondary</span></li>`
262
+ : `<li><span class="cc-pill cc-pill-bad">secondary missing</span> <span class="cc-missing-path">${escapeHtml(sec.path)}</span></li>`);
163
263
  }
164
264
  if (lines.length === 0)
165
265
  return "";
166
266
  return `<div class="cc-phase-section"><h3>Outputs</h3><ul class="cc-output-list">${lines.join("")}</ul></div>`;
167
267
  }
268
+ function renderPhaseCloseout(closeout) {
269
+ if (!closeout)
270
+ return "";
271
+ const link = renderSafeLink(`closeouts/${closeout.fileName}`, closeout.fileName);
272
+ return `<div class="cc-phase-section"><h3>Closeout</h3><p>${link ?? escapeHtml(closeout.fileName)}</p>${closeout.summary ? `<p class="cc-closeout-summary">${escapeHtml(closeout.summary)}</p>` : ""}</div>`;
273
+ }
168
274
  function renderPhaseOpenQuestions(phaseState) {
169
275
  const items = phaseState?.open_questions ?? [];
170
276
  if (items.length === 0)
171
277
  return "";
172
- return [
173
- `<div class="cc-phase-section">`,
174
- `<h3>Open questions (${items.length})</h3>`,
175
- `<ul class="cc-question-list">`,
176
- items.map(renderOpenQuestion).join(""),
177
- `</ul>`,
178
- `</div>`,
179
- ].join("");
278
+ return [`<div class="cc-phase-section">`, `<h3>Open questions (${items.length})</h3>`, `<ul class="cc-question-list">`, items.map(renderOpenQuestion).join(""), `</ul>`, `</div>`].join("");
180
279
  }
181
280
  function renderOpenQuestion(q) {
182
- const kind = q.kind ? `<span class="cc-tag">${escapeHtml(String(q.kind))}</span> ` : "";
281
+ const kind = q.kind ? `<span class="cc-pill">${escapeHtml(String(q.kind))}</span> ` : "";
183
282
  const desc = escapeHtml(q.description ?? "(no description)");
184
283
  const reason = q.deferred_reason ? `<div class="cc-question-reason">${escapeHtml(q.deferred_reason)}</div>` : "";
185
284
  return `<li>${kind}${desc}${reason}</li>`;
@@ -188,18 +287,11 @@ function renderPhaseCarryForward(phaseState) {
188
287
  const items = phaseState?.carry_forward ?? [];
189
288
  if (items.length === 0)
190
289
  return "";
191
- return [
192
- `<div class="cc-phase-section">`,
193
- `<h3>Carry-forward (${items.length})</h3>`,
194
- `<ul class="cc-carry-list">`,
195
- items.map(renderCarryForward).join(""),
196
- `</ul>`,
197
- `</div>`,
198
- ].join("");
290
+ return [`<div class="cc-phase-section">`, `<h3>Carry-forward (${items.length})</h3>`, `<ul class="cc-carry-list">`, items.map(renderCarryForward).join(""), `</ul>`, `</div>`].join("");
199
291
  }
200
292
  function renderCarryForward(c) {
201
- const target = c.target_phase ? `<span class="cc-tag cc-tag-target">→ ${escapeHtml(c.target_phase)}</span> ` : "";
202
- const kind = c.kind ? `<span class="cc-tag">${escapeHtml(String(c.kind))}</span> ` : "";
293
+ const target = c.target_phase ? `<a class="cc-pill cc-pill-target" href="#${phaseAnchor(c.target_phase)}">→ ${escapeHtml(c.target_phase)}</a> ` : "";
294
+ const kind = c.kind ? `<span class="cc-pill">${escapeHtml(String(c.kind))}</span> ` : "";
203
295
  const desc = escapeHtml(c.description ?? "(no description)");
204
296
  const reason = c.deferred_reason ? `<div class="cc-question-reason">${escapeHtml(c.deferred_reason)}</div>` : "";
205
297
  return `<li>${target}${kind}${desc}${reason}</li>`;
@@ -208,62 +300,33 @@ function renderPhaseOwnerNotes(phaseState) {
208
300
  const notes = phaseState?.owner_notes ?? [];
209
301
  if (notes.length === 0)
210
302
  return "";
211
- return [
212
- `<div class="cc-phase-section">`,
213
- `<h3>Owner notes</h3>`,
214
- `<ul class="cc-notes-list">`,
215
- notes.map((n) => `<li>${escapeHtml(n)}</li>`).join(""),
216
- `</ul>`,
217
- `</div>`,
218
- ].join("");
303
+ return [`<div class="cc-phase-section">`, `<h3>Owner notes</h3>`, `<ul class="cc-notes-list">`, notes.map((n) => `<li>${escapeHtml(n)}</li>`).join(""), `</ul>`, `</div>`].join("");
219
304
  }
220
305
  function renderPhaseLastRun(run) {
221
306
  if (!run)
222
- return "";
307
+ return `<div class="cc-phase-section"><h3>Last run</h3><p class="cc-muted">No usage record for this phase.</p></div>`;
223
308
  const tokensTotal = (run.tokens?.input ?? 0) + (run.tokens?.output ?? 0);
224
- return [
225
- `<div class="cc-phase-section">`,
226
- `<h3>Last run</h3>`,
227
- `<dl class="cc-run-meta">`,
228
- `<dt>Timestamp</dt><dd>${escapeHtml(run.timestamp)}</dd>`,
229
- `<dt>Status</dt><dd>${escapeHtml(run.status)}</dd>`,
230
- `<dt>Turns</dt><dd>${run.turn_count}</dd>`,
231
- `<dt>Tool uses</dt><dd>${run.tool_uses}</dd>`,
232
- `<dt>Tokens</dt><dd>${escapeHtml(formatTokenCount(tokensTotal))}</dd>`,
233
- `<dt>Duration</dt><dd>${escapeHtml(formatMillis(run.duration_ms))}</dd>`,
234
- `</dl>`,
235
- `</div>`,
236
- ].join("");
309
+ const sessionLink = run.session_file ? renderSafeLink(run.session_file, "transcript") : undefined;
310
+ const session = sessionLink ? `<dt>Session</dt><dd>${sessionLink}</dd>` : "";
311
+ return [`<div class="cc-phase-section">`, `<h3>Last run</h3>`, `<dl class="cc-run-meta">`, `<dt>Timestamp</dt><dd>${escapeHtml(run.timestamp)}</dd>`, `<dt>Status</dt><dd>${escapeHtml(run.status)}</dd>`, `<dt>Turns</dt><dd>${run.turn_count}</dd>`, `<dt>Tool uses</dt><dd>${run.tool_uses}</dd>`, `<dt>Tokens</dt><dd>${escapeHtml(formatTokenCount(tokensTotal))}</dd>`, `<dt>Duration</dt><dd>${escapeHtml(formatMillis(run.duration_ms))}</dd>`, session, `</dl>`, `</div>`].join("");
237
312
  }
238
- function renderUsagePanel(usage) {
239
- if (usage.runs.length === 0) {
240
- return [
241
- `<section class="cc-usage" aria-label="Token usage">`,
242
- `<h2>Usage</h2>`,
243
- `<p class="cc-empty">No phase runs recorded yet.</p>`,
244
- `</section>`,
245
- ].join("\n");
246
- }
313
+ function renderUsagePanel(inputs) {
314
+ const { usage, pipeline, status } = inputs;
247
315
  const totals = computeTotals(usage);
248
316
  const perPhase = computePerPhaseTotals(usage);
249
- const rows = [...perPhase.entries()].map(([phaseId, t]) => {
317
+ const rows = pipeline.phase_order.map((phaseId) => {
318
+ const t = perPhase.get(phaseId);
319
+ if (!t) {
320
+ const complete = status.phases[phaseId]?.status === "complete";
321
+ return `<tr class="${complete ? "cc-usage-missing" : ""}"><td><a href="#${phaseAnchor(phaseId)}">${escapeHtml(phaseId)}</a></td><td>0</td><td>—</td><td>—</td><td>${complete ? "usage not recorded" : "not run"}</td></tr>`;
322
+ }
250
323
  const tokensTotal = t.tokens.input + t.tokens.output;
251
- return [
252
- `<tr>`,
253
- `<td>${escapeHtml(phaseId)}</td>`,
254
- `<td>${t.runs}</td>`,
255
- `<td>${escapeHtml(formatTokenCount(tokensTotal))}</td>`,
256
- `<td>${t.tool_uses}</td>`,
257
- `<td>${escapeHtml(formatMillis(t.duration_ms))}</td>`,
258
- `</tr>`,
259
- ].join("");
324
+ return `<tr><td><a href="#${phaseAnchor(phaseId)}">${escapeHtml(phaseId)}</a></td><td>${t.runs}</td><td>${escapeHtml(formatTokenCount(tokensTotal))}</td><td>${t.tool_uses}</td><td>${escapeHtml(formatMillis(t.duration_ms))}</td></tr>`;
260
325
  }).join("");
261
326
  return [
262
- `<section class="cc-usage" aria-label="Token usage">`,
263
- `<h2>Usage</h2>`,
264
- `<dl class="cc-usage-totals">`,
265
- renderUsageTotalsList(totals),
266
- `</dl>`,
327
+ `<section class="cc-card cc-usage" id="usage" aria-label="Token usage" data-section data-search-text="usage tokens tool duration">`,
328
+ `<div class="cc-section-head"><h2>Usage</h2><span>${totals.runs} runs</span></div>`,
329
+ usage.runs.length === 0 ? `<p class="cc-empty">No phase runs recorded yet.</p>` : `<dl class="cc-usage-totals">${renderUsageTotalsList(totals)}</dl>`,
267
330
  `<table class="cc-usage-table">`,
268
331
  `<thead><tr><th>Phase</th><th>Runs</th><th>Tokens</th><th>Tools</th><th>Duration</th></tr></thead>`,
269
332
  `<tbody>${rows}</tbody>`,
@@ -273,13 +336,7 @@ function renderUsagePanel(usage) {
273
336
  }
274
337
  function renderUsageTotalsList(totals) {
275
338
  const tokensTotal = totals.tokens.input + totals.tokens.output;
276
- return [
277
- `<dt>Total runs</dt><dd>${totals.runs}</dd>`,
278
- `<dt>Tokens (in / out / cache)</dt><dd>${escapeHtml(formatTokenCount(totals.tokens.input))} / ${escapeHtml(formatTokenCount(totals.tokens.output))} / ${escapeHtml(formatTokenCount(totals.tokens.cache_write))}</dd>`,
279
- `<dt>Total tokens</dt><dd>${escapeHtml(formatTokenCount(tokensTotal))}</dd>`,
280
- `<dt>Tool uses</dt><dd>${totals.tool_uses}</dd>`,
281
- `<dt>Total duration</dt><dd>${escapeHtml(formatMillis(totals.duration_ms))}</dd>`,
282
- ].join("");
339
+ return [`<dt>Total runs</dt><dd>${totals.runs}</dd>`, `<dt>Tokens (in / out / cache)</dt><dd>${escapeHtml(formatTokenCount(totals.tokens.input))} / ${escapeHtml(formatTokenCount(totals.tokens.output))} / ${escapeHtml(formatTokenCount(totals.tokens.cache_write))}</dd>`, `<dt>Total tokens</dt><dd>${escapeHtml(formatTokenCount(tokensTotal))}</dd>`, `<dt>Tool uses</dt><dd>${totals.tool_uses}</dd>`, `<dt>Total duration</dt><dd>${escapeHtml(formatMillis(totals.duration_ms))}</dd>`].join("");
283
340
  }
284
341
  function renderActivityTimeline(runs) {
285
342
  if (runs.length === 0)
@@ -289,100 +346,124 @@ function renderActivityTimeline(runs) {
289
346
  const overflow = sorted.slice(TIMELINE_VISIBLE_COUNT);
290
347
  const rowOf = (run) => {
291
348
  const tokensTotal = (run.tokens?.input ?? 0) + (run.tokens?.output ?? 0);
292
- const sessionCell = run.session_file
293
- ? `<a href="${escapeAttr(run.session_file)}">session</a>`
294
- : "—";
295
- return [
296
- `<tr>`,
297
- `<td>${escapeHtml(run.timestamp)}</td>`,
298
- `<td>${escapeHtml(run.phase)}</td>`,
299
- `<td>${escapeHtml(run.status)}</td>`,
300
- `<td>${run.turn_count}</td>`,
301
- `<td>${run.tool_uses}</td>`,
302
- `<td>${escapeHtml(formatTokenCount(tokensTotal))}</td>`,
303
- `<td>${escapeHtml(formatMillis(run.duration_ms))}</td>`,
304
- `<td>${sessionCell}</td>`,
305
- `</tr>`,
306
- ].join("");
349
+ const sessionCell = run.session_file ? (renderSafeLink(run.session_file, "session") ?? "—") : "—";
350
+ return `<tr><td>${escapeHtml(run.timestamp)}</td><td><a href="#${phaseAnchor(run.phase)}">${escapeHtml(run.phase)}</a></td><td>${escapeHtml(run.status)}</td><td>${run.turn_count}</td><td>${run.tool_uses}</td><td>${escapeHtml(formatTokenCount(tokensTotal))}</td><td>${escapeHtml(formatMillis(run.duration_ms))}</td><td>${sessionCell}</td></tr>`;
307
351
  };
308
- const visibleRows = visible.map(rowOf).join("");
309
- const overflowBlock = overflow.length === 0 ? "" : [
310
- `<details class="cc-timeline-older">`,
311
- `<summary>Older runs (${overflow.length})</summary>`,
312
- `<table class="cc-timeline-table">`,
313
- `<tbody>${overflow.map(rowOf).join("")}</tbody>`,
314
- `</table>`,
315
- `</details>`,
316
- ].join("\n");
317
- return [
318
- `<section class="cc-timeline" aria-label="Activity timeline">`,
319
- `<h2>Activity timeline</h2>`,
320
- `<table class="cc-timeline-table">`,
321
- `<thead><tr><th>When</th><th>Phase</th><th>Status</th><th>⟳</th><th>Tools</th><th>Tokens</th><th>Duration</th><th>Session</th></tr></thead>`,
322
- `<tbody>${visibleRows}</tbody>`,
323
- `</table>`,
324
- overflowBlock,
325
- `</section>`,
326
- ].join("\n");
352
+ const overflowBlock = overflow.length === 0 ? "" : [`<details class="cc-timeline-older">`, `<summary>Older runs (${overflow.length})</summary>`, `<table class="cc-timeline-table">`, `<tbody>${overflow.map(rowOf).join("")}</tbody>`, `</table>`, `</details>`].join("\n");
353
+ return [`<section class="cc-card cc-timeline" aria-label="Activity timeline" data-section data-search-text="activity timeline sessions">`, `<div class="cc-section-head"><h2>Activity timeline</h2><span>newest first</span></div>`, `<table class="cc-timeline-table">`, `<thead><tr><th>When</th><th>Phase</th><th>Status</th><th>⟳</th><th>Tools</th><th>Tokens</th><th>Duration</th><th>Session</th></tr></thead>`, `<tbody>${visible.map(rowOf).join("")}</tbody>`, `</table>`, overflowBlock, `</section>`].join("\n");
327
354
  }
328
355
  function renderOpenQuestionsRollup(status) {
329
356
  const buckets = [];
357
+ const seen = new Set();
358
+ let total = 0;
330
359
  for (const [phaseId, phaseState] of Object.entries(status.phases)) {
331
- if ((phaseState.open_questions ?? []).length > 0) {
332
- buckets.push({ phaseId, questions: phaseState.open_questions });
360
+ const questions = [];
361
+ for (const q of phaseState.open_questions ?? []) {
362
+ const key = `${q.kind ?? ""}|${q.description ?? ""}|${q.deferred_reason ?? ""}`;
363
+ if (seen.has(key))
364
+ continue;
365
+ seen.add(key);
366
+ questions.push(renderOpenQuestion(q));
333
367
  }
368
+ if (questions.length === 0)
369
+ continue;
370
+ total += questions.length;
371
+ buckets.push(`<div class="cc-question-bucket"><h3>${escapeHtml(phaseId)} (${questions.length})</h3><a class="cc-pill cc-pill-target" href="#${phaseAnchor(phaseId)}">jump to phase</a><ul class="cc-question-list">${questions.join("")}</ul></div>`);
334
372
  }
335
- if (buckets.length === 0)
373
+ if (total === 0)
336
374
  return "";
337
- const sections = buckets.map(({ phaseId, questions }) => [
338
- `<div class="cc-rollup-phase">`,
339
- `<h3>${escapeHtml(phaseId)} (${questions.length})</h3>`,
340
- `<ul class="cc-question-list">`,
341
- questions.map(renderOpenQuestion).join(""),
342
- `</ul>`,
343
- `</div>`,
344
- ].join("")).join("\n");
345
- return [
346
- `<section class="cc-rollup" aria-label="Open questions roll-up">`,
347
- `<h2>Open questions</h2>`,
348
- sections,
349
- `</section>`,
350
- ].join("\n");
375
+ return [`<section class="cc-card cc-rollup" aria-label="Open questions roll-up" data-section data-search-text="open questions">`, `<div class="cc-section-head"><h2>Open questions</h2><span>${total} unique</span></div>`, buckets.join("\n"), `</section>`].join("\n");
351
376
  }
352
- function renderCloseoutsList(closeouts) {
353
- if (closeouts.length === 0) {
354
- return [
355
- `<section class="cc-closeouts" aria-label="Closeouts">`,
356
- `<h2>Closeouts</h2>`,
357
- `<p class="cc-empty">No closeouts yet.</p>`,
358
- `</section>`,
359
- ].join("\n");
360
- }
377
+ function renderCloseoutsList(inputs) {
378
+ const closeouts = inputs.closeouts;
379
+ if (closeouts.length === 0)
380
+ return [`<section class="cc-card cc-closeouts" id="closeouts" aria-label="Closeouts" data-section>`, `<h2>Closeouts</h2>`, `<p class="cc-empty">No closeouts yet.</p>`, `</section>`].join("\n");
361
381
  const sorted = [...closeouts].sort((a, b) => (a.date < b.date ? 1 : -1));
362
382
  const rows = sorted.map((c) => {
363
- const href = relativeHref(`closeouts/${c.fileName}`);
364
- return [
365
- `<li>`,
366
- `<span class="cc-closeout-date">${escapeHtml(c.date)}</span> `,
367
- `<span class="cc-closeout-phase">${escapeHtml(c.phaseOrModule)}</span> `,
368
- `— <a href="${escapeAttr(href)}">${escapeHtml(c.fileName)}</a>`,
369
- `</li>`,
370
- ].join("");
371
- }).join("");
372
- return [
373
- `<section class="cc-closeouts" aria-label="Closeouts">`,
374
- `<h2>Closeouts</h2>`,
375
- `<ul class="cc-closeouts-list">${rows}</ul>`,
376
- `</section>`,
377
- ].join("\n");
383
+ const phase = getPhase(inputs.pipeline, c.phaseOrModule);
384
+ const outputs = inputs.outputsPresent.get(c.phaseOrModule);
385
+ const outputLinks = renderCloseoutOutputLinks(phase, outputs);
386
+ const closeoutLink = renderSafeLink(`closeouts/${c.fileName}`, "summary");
387
+ const summary = c.summary ? `<p class="cc-closeout-summary">${escapeHtml(c.summary)}</p>` : "";
388
+ return `<article class="cc-closeout-row" data-search-text="${escapeAttr(`${c.date} ${c.phaseOrModule} ${c.fileName} ${c.summary ?? ""}`)}"><div><span class="cc-closeout-date">${escapeHtml(c.date)}</span><a class="cc-phase-jump" href="#${phaseAnchor(c.phaseOrModule)}">${escapeHtml(c.phaseOrModule)}</a></div><div>${closeoutLink ?? "summary"}<code>${escapeHtml(c.fileName)}</code>${summary}</div><div class="cc-closeout-results">${outputLinks}</div></article>`;
389
+ }).join("\n");
390
+ return [`<section class="cc-card cc-closeouts" id="closeouts" aria-label="Closeouts" data-section data-search-text="closeouts summaries results">`, `<div class="cc-section-head"><h2>Closeouts</h2><span>${closeouts.length} summaries</span></div>`, `<div class="cc-closeouts-list">${rows}</div>`, `</section>`].join("\n");
391
+ }
392
+ function renderCloseoutOutputLinks(phase, outputs) {
393
+ if (!phase)
394
+ return `<span class="cc-pill cc-pill-muted">non-phase closeout</span>`;
395
+ const links = [];
396
+ if (phase.primary_output) {
397
+ const exists = outputs?.primary?.exists ?? false;
398
+ links.push(exists ? (renderSafeLink(phase.primary_output, "primary result", "cc-pill cc-pill-ok") ?? `<span class="cc-pill cc-pill-bad">primary unsafe</span>`) : `<span class="cc-pill cc-pill-bad">primary missing</span>`);
399
+ }
400
+ for (const sec of phase.secondary_outputs ?? []) {
401
+ const exists = outputs?.secondary.find((s) => s.path === sec.path)?.exists ?? false;
402
+ if (exists)
403
+ links.push(renderSafeLink(sec.path, "secondary", "cc-pill") ?? `<span class="cc-pill cc-pill-bad">secondary unsafe</span>`);
404
+ }
405
+ return links.length ? links.join("") : `<span class="cc-pill cc-pill-muted">no outputs</span>`;
378
406
  }
379
407
  function renderFooter(inputs) {
380
- return [
381
- `<footer class="cc-footer">`,
382
- `<p>Generated by codecartographer-pi v${escapeHtml(inputs.packageVersion)} at ${escapeHtml(inputs.generatedAt)}.</p>`,
383
- `<p class="cc-footer-hint">Regenerate via <code>/codecarto-dashboard</code> · narrate via <code>/codecarto-dashboard --narrate</code>.</p>`,
384
- `</footer>`,
385
- ].join("\n");
408
+ return [`<footer class="cc-footer">`, `<p>Generated by codecartographer-pi v${escapeHtml(inputs.packageVersion)} at ${escapeHtml(inputs.generatedAt)}.</p>`, `<p class="cc-footer-hint">Regenerate via <code>/codecarto-dashboard</code> · narrate via <code>/codecarto-dashboard --narrate</code>.</p>`, `</footer>`].join("\n");
409
+ }
410
+ // ----------------------------------------------------------------------------
411
+ // Data export and JS
412
+ // ----------------------------------------------------------------------------
413
+ function renderExportData(inputs) {
414
+ const phases = inputs.pipeline.phase_order.map((phaseId) => {
415
+ const phase = getPhase(inputs.pipeline, phaseId);
416
+ const outputs = inputs.outputsPresent.get(phaseId);
417
+ return {
418
+ id: phaseId,
419
+ status: phaseRenderState(inputs.status, phaseId),
420
+ purpose: phase?.purpose ?? "",
421
+ primary_output: phase?.primary_output ?? null,
422
+ primary_output_exists: outputs?.primary?.exists ?? false,
423
+ secondary_outputs: outputs?.secondary ?? [],
424
+ };
425
+ });
426
+ const data = { project: inputs.status.project_name, generatedAt: inputs.generatedAt, packageVersion: inputs.packageVersion, phases, usage: inputs.usage, closeouts: inputs.closeouts };
427
+ return `<script id="cc-dashboard-data" type="application/json">${escapeJsonForScript(data)}</script>`;
428
+ }
429
+ function renderScripts() {
430
+ return `<script>
431
+ (() => {
432
+ const q = document.querySelector('[data-search]');
433
+ const filters = [...document.querySelectorAll('[data-filter]')];
434
+ const sections = [...document.querySelectorAll('[data-section]')];
435
+ let active = 'all';
436
+ function textFor(el){ return ((el.dataset.searchText || '') + ' ' + el.textContent).toLowerCase(); }
437
+ function apply(){
438
+ const terms = (q?.value || '').toLowerCase().trim().split(/\\s+/).filter(Boolean);
439
+ for (const el of sections) {
440
+ const status = el.dataset.status;
441
+ const statusOk = active === 'all' || !status || status === active;
442
+ const searchOk = terms.every(t => textFor(el).includes(t));
443
+ el.hidden = !(statusOk && searchOk);
444
+ }
445
+ }
446
+ q?.addEventListener('input', apply);
447
+ for (const btn of filters) btn.addEventListener('click', () => {
448
+ active = btn.dataset.filter || 'all';
449
+ filters.forEach(b => b.classList.toggle('is-active', b === btn));
450
+ apply();
451
+ });
452
+ document.querySelector('[data-export]')?.addEventListener('click', () => {
453
+ const raw = document.getElementById('cc-dashboard-data')?.textContent || '{}';
454
+ const blob = new Blob([JSON.stringify(JSON.parse(raw), null, 2) + '\\n'], { type: 'application/json' });
455
+ const a = document.createElement('a');
456
+ a.href = URL.createObjectURL(blob);
457
+ a.download = 'codecartographer-dashboard.json';
458
+ a.click();
459
+ URL.revokeObjectURL(a.href);
460
+ });
461
+ document.querySelector('[data-sidebar-toggle]')?.addEventListener('click', () => document.body.classList.toggle('cc-sidebar-open'));
462
+ window.addEventListener('keydown', (event) => {
463
+ if (event.key === 'Escape') { if (q) q.value = ''; active = 'all'; filters.forEach(b => b.classList.toggle('is-active', b.dataset.filter === 'all')); apply(); document.body.classList.remove('cc-sidebar-open'); }
464
+ });
465
+ })();
466
+ </script>`;
386
467
  }
387
468
  // ----------------------------------------------------------------------------
388
469
  // Helpers
@@ -391,6 +472,8 @@ function phaseRenderState(status, phaseId) {
391
472
  const phaseStatus = status.phases[phaseId]?.status;
392
473
  if (phaseStatus === "complete")
393
474
  return "complete";
475
+ if (phaseStatus === "running")
476
+ return "running";
394
477
  if (status.current_phase === phaseId)
395
478
  return "current";
396
479
  return "pending";
@@ -407,19 +490,40 @@ function lastRunPerPhase(runs) {
407
490
  }
408
491
  return out;
409
492
  }
410
- function relativeHref(path) {
411
- // The dashboard lives at <workspaceDir>/dashboard.html. Workspace-relative
412
- // paths in pipeline definitions and closeouts are relative to the
413
- // workspaceDir, so they resolve from the dashboard's URL directly.
414
- return path.split("/").map((seg) => encodeURIComponent(seg)).join("/");
493
+ function getPhase(pipeline, phaseId) {
494
+ return pipeline.phases.find((p) => p.id === phaseId);
495
+ }
496
+ function closeoutForPhase(closeouts, phaseId) {
497
+ return [...closeouts].filter((c) => c.phaseOrModule === phaseId).sort((a, b) => (a.date < b.date ? 1 : -1))[0];
498
+ }
499
+ function phaseAnchor(phaseId) {
500
+ return `phase-${phaseId.replace(/[^a-zA-Z0-9_-]/g, "-")}`;
501
+ }
502
+ function renderSafeLink(path, label, className) {
503
+ const href = safeRelativeHref(path);
504
+ if (!href)
505
+ return undefined;
506
+ const classAttr = className ? ` class="${escapeAttr(className)}"` : "";
507
+ return `<a${classAttr} href="${escapeAttr(href)}">${escapeHtml(label)}</a>`;
508
+ }
509
+ function safeRelativeHref(path) {
510
+ if (!path || path.startsWith("/") || path.startsWith("\\") || /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(path))
511
+ return undefined;
512
+ const parts = path.split("/");
513
+ if (parts.some((seg) => !seg || seg === "." || seg === ".."))
514
+ return undefined;
515
+ return parts.map((seg) => encodeURIComponent(seg)).join("/");
516
+ }
517
+ function escapeJsonForScript(value) {
518
+ return JSON.stringify(value)
519
+ .replace(/&/g, "\\u0026")
520
+ .replace(/</g, "\\u003c")
521
+ .replace(/>/g, "\\u003e")
522
+ .replace(/\u2028/g, "\\u2028")
523
+ .replace(/\u2029/g, "\\u2029");
415
524
  }
416
525
  export function escapeHtml(input) {
417
- return input
418
- .replace(/&/g, "&amp;")
419
- .replace(/</g, "&lt;")
420
- .replace(/>/g, "&gt;")
421
- .replace(/"/g, "&quot;")
422
- .replace(/'/g, "&#39;");
526
+ return input.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\"/g, "&quot;").replace(/'/g, "&#39;");
423
527
  }
424
528
  function escapeAttr(input) {
425
529
  return escapeHtml(input);
@@ -430,97 +534,104 @@ function escapeAttr(input) {
430
534
  function renderStyles() {
431
535
  return `<style>
432
536
  :root {
433
- --s-1: 4px; --s-2: 8px; --s-3: 16px; --s-4: 24px; --s-5: 40px;
434
- --fg: #1a1a1a; --fg-dim: #5a5a5a; --bg: #fafafa; --bg-card: #ffffff;
435
- --border: #e3e3e3; --accent: #2563eb; --accent-fg: #ffffff;
436
- --status-pending: #94a3b8; --status-current: #2563eb; --status-running: #f59e0b;
437
- --status-complete: #16a34a; --status-error: #dc2626;
438
- --code-bg: #f1f5f9;
537
+ --sidebar-width: 300px; --s-1: 4px; --s-2: 8px; --s-3: 16px; --s-4: 24px; --s-5: 40px;
538
+ --fg: #1f2328; --fg-dim: #667085; --bg: #f6f4ee; --bg-card: #fffdf7; --bg-soft: #ece7da;
539
+ --border: #ddd5c4; --accent: #b8432f; --accent-2: #2c5862; --accent-3: #63a77d;
540
+ --status-pending: #8a8f98; --status-current: #2c5862; --status-running: #b7791f; --status-complete: #2f855a; --status-error: #c53030;
541
+ --code-bg: #eee8da; --shadow: 0 14px 45px rgba(22, 18, 12, 0.10);
439
542
  }
440
543
  @media (prefers-color-scheme: dark) {
441
544
  :root {
442
- --fg: #f1f5f9; --fg-dim: #94a3b8; --bg: #0f172a; --bg-card: #1e293b;
443
- --border: #334155; --accent: #60a5fa; --accent-fg: #0f172a;
444
- --status-pending: #64748b; --status-current: #60a5fa; --status-running: #fbbf24;
445
- --status-complete: #4ade80; --status-error: #f87171;
446
- --code-bg: #1e293b;
545
+ --fg: #f3ead7; --fg-dim: #a79f90; --bg: #101211; --bg-card: #181b19; --bg-soft: #20251f;
546
+ --border: #30362f; --accent: #f1a84f; --accent-2: #71c4cf; --accent-3: #63e6a4;
547
+ --status-pending: #7d8790; --status-current: #71c4cf; --status-running: #f1a84f; --status-complete: #63e6a4; --status-error: #ff7768;
548
+ --code-bg: #242820; --shadow: 0 14px 45px rgba(0, 0, 0, 0.28);
447
549
  }
448
550
  }
449
- body {
450
- font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
451
- background: var(--bg); color: var(--fg); margin: 0; padding: var(--s-4);
452
- line-height: 1.5; font-size: 15px;
453
- }
454
- .cc-dashboard { max-width: 1100px; margin: 0 auto; }
455
- .cc-header h1 { margin: 0 0 var(--s-3) 0; font-size: 28px; }
456
- .cc-header-meta { display: grid; grid-template-columns: max-content 1fr; gap: var(--s-1) var(--s-3); margin: 0 0 var(--s-4) 0; }
457
- .cc-header-meta dt { color: var(--fg-dim); font-weight: 500; }
458
- .cc-header-meta dd { margin: 0; }
459
- h2 { font-size: 18px; margin: var(--s-4) 0 var(--s-2) 0; padding-bottom: var(--s-1); border-bottom: 1px solid var(--border); }
460
- h3 { font-size: 14px; margin: var(--s-2) 0 var(--s-1) 0; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.04em; }
461
- a { color: var(--accent); text-decoration: none; }
462
- a:hover { text-decoration: underline; }
463
- code { background: var(--code-bg); padding: 1px 4px; border-radius: 3px; font-size: 13px; }
464
- section { margin-bottom: var(--s-4); }
465
- .cc-empty { color: var(--fg-dim); font-style: italic; }
466
-
467
- /* Narration */
468
- .cc-narration { background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; padding: var(--s-3); }
469
- .cc-narration-meta { color: var(--fg-dim); font-size: 13px; margin: 0 0 var(--s-2) 0; }
470
- .cc-narration-body pre { white-space: pre-wrap; word-wrap: break-word; margin: 0; font-family: inherit; font-size: 14px; line-height: 1.6; }
471
-
472
- /* Progress bar */
473
- .cc-progress-list { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: var(--s-1); }
474
- .cc-progress-item { flex: 1 1 120px; padding: var(--s-2); border: 1px solid var(--border); border-radius: 4px; background: var(--bg-card); display: flex; flex-direction: column; gap: 2px; }
475
- .cc-progress-id { font-weight: 600; }
476
- .cc-progress-badge { font-size: 12px; color: var(--fg-dim); text-transform: uppercase; letter-spacing: 0.04em; }
477
- .cc-state-complete .cc-progress-badge, .cc-state-complete .cc-phase-badge { color: var(--status-complete); }
478
- .cc-state-current .cc-progress-badge, .cc-state-current .cc-phase-badge { color: var(--status-current); font-weight: 600; }
479
- .cc-state-running .cc-progress-badge, .cc-state-running .cc-phase-badge { color: var(--status-running); font-weight: 600; }
480
- .cc-state-pending .cc-progress-badge, .cc-state-pending .cc-phase-badge { color: var(--status-pending); }
481
- .cc-state-current { border-left: 3px solid var(--status-current); }
482
-
483
- /* Phase cards */
484
- .cc-phase-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; padding: 0; margin-bottom: var(--s-2); }
485
- .cc-phase-card summary { padding: var(--s-2) var(--s-3); cursor: pointer; display: flex; gap: var(--s-3); align-items: baseline; }
551
+ * { box-sizing: border-box; }
552
+ html { scroll-behavior: smooth; }
553
+ body { margin: 0; background: radial-gradient(circle at top left, color-mix(in srgb, var(--accent-2) 15%, transparent), transparent 32rem), var(--bg); color: var(--fg); font: 14px/1.55 ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
554
+ a { color: var(--accent-2); text-decoration: none; }
555
+ a:hover { color: var(--accent); text-decoration: underline; }
556
+ code { background: var(--code-bg); padding: 2px 5px; border-radius: 5px; font: 12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
557
+ [hidden] { display: none !important; }
558
+ .cc-dashboard { display: grid; grid-template-columns: var(--sidebar-width) minmax(0, 1fr); min-height: 100vh; }
559
+ .cc-sidebar { position: sticky; top: 0; height: 100vh; overflow: auto; padding: var(--s-3); border-right: 1px solid var(--border); background: color-mix(in srgb, var(--bg-card) 92%, transparent); backdrop-filter: blur(12px); }
560
+ .cc-main { max-width: 1180px; width: 100%; padding: var(--s-4); }
561
+ .cc-brand { display: flex; gap: var(--s-2); align-items: center; margin-bottom: var(--s-3); }
562
+ .cc-brand-mark { display: grid; place-items: center; width: 34px; height: 34px; border-radius: 10px; background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: white; font-weight: 800; }
563
+ .cc-brand div { display: grid; line-height: 1.2; } .cc-brand span:last-child { color: var(--fg-dim); font-size: 12px; }
564
+ .cc-search-label { display: block; color: var(--fg-dim); font-size: 12px; margin-bottom: var(--s-1); }
565
+ .cc-search { width: 100%; padding: 10px 11px; border: 1px solid var(--border); border-radius: 10px; background: var(--bg); color: var(--fg); }
566
+ .cc-filter-row { display: flex; flex-wrap: wrap; gap: var(--s-1); margin: var(--s-2) 0 var(--s-3); }
567
+ .cc-filter, .cc-export { border: 1px solid var(--border); background: var(--bg-soft); color: var(--fg); border-radius: 999px; padding: 6px 9px; cursor: pointer; font: inherit; font-size: 12px; }
568
+ .cc-filter.is-active, .cc-export:hover { border-color: var(--accent); color: var(--accent); }
569
+ .cc-export { width: 100%; border-radius: 10px; margin-top: var(--s-3); }
570
+ .cc-nav { display: grid; gap: 3px; }
571
+ .cc-nav-link, .cc-nav-section { display: flex; justify-content: space-between; gap: var(--s-2); padding: 7px 8px; border-radius: 8px; color: var(--fg); }
572
+ .cc-nav-link:hover, .cc-nav-section:hover { background: var(--bg-soft); text-decoration: none; }
573
+ .cc-nav-link b { color: var(--fg-dim); font-size: 11px; text-transform: uppercase; }
574
+ .cc-nav-section { color: var(--fg-dim); font-weight: 700; margin-top: var(--s-2); }
575
+ .cc-sidebar-foot { color: var(--fg-dim); font-size: 12px; }
576
+ .cc-menu-button { display: none; position: fixed; top: 12px; right: 12px; z-index: 20; border: 1px solid var(--border); border-radius: 10px; padding: 8px 10px; background: var(--bg-card); color: var(--fg); }
577
+ .cc-header, .cc-card, .cc-phase-card, .cc-warning { background: var(--bg-card); border: 1px solid var(--border); border-radius: 18px; box-shadow: var(--shadow); }
578
+ .cc-header { padding: var(--s-4); margin-bottom: var(--s-4); }
579
+ .cc-eyebrow { color: var(--accent); text-transform: uppercase; letter-spacing: .14em; font-size: 12px; font-weight: 800; }
580
+ h1 { font-size: clamp(30px, 5vw, 56px); line-height: 1; margin: var(--s-2) 0; }
581
+ h2 { margin: 0; font-size: 18px; } h3 { margin: var(--s-2) 0 var(--s-1); color: var(--fg-dim); text-transform: uppercase; letter-spacing: .06em; font-size: 12px; }
582
+ .cc-goal { max-width: 70ch; color: var(--fg-dim); font-size: 16px; }
583
+ .cc-stat-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--s-2); margin-top: var(--s-3); }
584
+ .cc-stat { padding: var(--s-2); border: 1px solid var(--border); border-radius: 12px; background: var(--bg); }
585
+ .cc-stat span { display: block; color: var(--fg-dim); font-size: 12px; } .cc-stat strong { display: block; font-size: 18px; }
586
+ .cc-meta-details { margin-top: var(--s-3); } .cc-header-meta, .cc-run-meta, .cc-usage-totals { display: grid; grid-template-columns: max-content 1fr; gap: var(--s-1) var(--s-3); margin: var(--s-2) 0; } dt { color: var(--fg-dim); } dd { margin: 0; }
587
+ .cc-card, .cc-warning { padding: var(--s-3); margin-bottom: var(--s-4); }
588
+ .cc-warning { border-color: var(--status-running); background: color-mix(in srgb, var(--status-running) 9%, var(--bg-card)); }
589
+ .cc-section-head { display: flex; justify-content: space-between; gap: var(--s-2); align-items: baseline; padding-bottom: var(--s-2); margin-bottom: var(--s-2); border-bottom: 1px solid var(--border); }
590
+ .cc-section-head span, .cc-muted, .cc-empty { color: var(--fg-dim); }
591
+ .cc-narration pre { white-space: pre-wrap; word-wrap: break-word; margin: 0; font-family: inherit; }
592
+ .cc-artifact-list, .cc-closeouts-list { display: grid; gap: var(--s-2); }
593
+ .cc-artifact-row, .cc-closeout-row { display: grid; grid-template-columns: minmax(170px, .8fr) minmax(0, 1.5fr) minmax(180px, .8fr); gap: var(--s-2); align-items: start; padding: var(--s-2); border: 1px solid var(--border); border-radius: 12px; background: var(--bg); }
594
+ .cc-artifact-row > div:first-child, .cc-closeout-row > div:first-child { display: grid; gap: 2px; }
595
+ .cc-closeout-row code { display: block; margin-top: 3px; width: fit-content; }
596
+ .cc-closeout-summary { color: var(--fg-dim); margin: var(--s-1) 0 0; }
597
+ .cc-closeout-results { display: flex; flex-wrap: wrap; gap: var(--s-1); }
598
+ .cc-progress-list { list-style: none; padding: 0; margin: 0; display: grid; grid-template-columns: repeat(auto-fit, minmax(135px, 1fr)); gap: var(--s-2); }
599
+ .cc-progress-item a { display: grid; gap: 2px; padding: var(--s-2); border: 1px solid var(--border); border-radius: 12px; background: var(--bg-card); color: var(--fg); }
600
+ .cc-progress-item a:hover { text-decoration: none; border-color: var(--accent); }
601
+ .cc-progress-id, .cc-phase-id { font-weight: 800; } .cc-progress-badge, .cc-phase-badge { text-transform: uppercase; letter-spacing: .06em; font-size: 11px; color: var(--fg-dim); }
602
+ .cc-state-complete .cc-progress-badge, .cc-state-complete .cc-phase-badge { color: var(--status-complete); } .cc-state-current .cc-progress-badge, .cc-state-current .cc-phase-badge { color: var(--status-current); } .cc-state-running .cc-progress-badge, .cc-state-running .cc-phase-badge { color: var(--status-running); } .cc-state-pending .cc-progress-badge, .cc-state-pending .cc-phase-badge { color: var(--status-pending); }
603
+ .cc-phase-card { margin-bottom: var(--s-2); scroll-margin-top: var(--s-3); overflow: hidden; }
604
+ .cc-phase-card summary { padding: var(--s-3); cursor: pointer; display: flex; justify-content: space-between; gap: var(--s-3); align-items: baseline; }
486
605
  .cc-phase-card[open] summary { border-bottom: 1px solid var(--border); }
487
- .cc-phase-id { font-weight: 600; font-size: 16px; }
488
606
  .cc-phase-purpose { padding: var(--s-2) var(--s-3) 0; color: var(--fg-dim); }
489
607
  .cc-phase-section { padding: var(--s-2) var(--s-3); }
490
608
  .cc-phase-section ul { margin: 0; padding-left: var(--s-3); }
609
+ .cc-phase-overview { display: grid; gap: var(--s-1); color: var(--fg-dim); }
491
610
  .cc-output-list li, .cc-question-list li, .cc-carry-list li, .cc-notes-list li { margin: var(--s-1) 0; }
492
611
  .cc-question-reason { color: var(--fg-dim); font-size: 13px; margin-left: var(--s-2); }
493
-
494
- /* Tags */
495
- .cc-tag { display: inline-block; padding: 1px 6px; border-radius: 10px; font-size: 11px; background: var(--code-bg); color: var(--fg-dim); border: 1px solid var(--border); }
496
- .cc-tag-present { background: color-mix(in srgb, var(--status-complete) 15%, transparent); color: var(--status-complete); border-color: var(--status-complete); }
497
- .cc-tag-missing { background: color-mix(in srgb, var(--status-error) 15%, transparent); color: var(--status-error); border-color: var(--status-error); }
498
- .cc-tag-target { background: color-mix(in srgb, var(--accent) 15%, transparent); color: var(--accent); border-color: var(--accent); }
499
-
500
- /* Run meta dl */
501
- .cc-run-meta { display: grid; grid-template-columns: max-content 1fr; gap: var(--s-1) var(--s-3); margin: 0; }
502
- .cc-run-meta dt { color: var(--fg-dim); font-size: 13px; }
503
- .cc-run-meta dd { margin: 0; font-size: 13px; }
504
-
505
- /* Usage */
506
- .cc-usage-totals { display: grid; grid-template-columns: max-content 1fr; gap: var(--s-1) var(--s-3); margin: 0 0 var(--s-3) 0; }
507
- .cc-usage-totals dt { color: var(--fg-dim); }
508
- .cc-usage-totals dd { margin: 0; font-variant-numeric: tabular-nums; }
612
+ .cc-pill { display: inline-block; padding: 2px 7px; border-radius: 999px; font-size: 11px; background: var(--code-bg); color: var(--fg-dim); border: 1px solid var(--border); margin: 0 3px 3px 0; }
613
+ .cc-pill-ok { background: color-mix(in srgb, var(--status-complete) 14%, transparent); color: var(--status-complete); border-color: var(--status-complete); } .cc-pill-bad { background: color-mix(in srgb, var(--status-error) 14%, transparent); color: var(--status-error); border-color: var(--status-error); } .cc-pill-target { color: var(--accent-2); border-color: var(--accent-2); } .cc-pill-muted, .cc-missing-path { color: var(--fg-dim); }
614
+ .cc-phase-jump { font-weight: 800; }
509
615
  .cc-usage-table, .cc-timeline-table { width: 100%; border-collapse: collapse; font-size: 13px; }
510
616
  .cc-usage-table th, .cc-usage-table td, .cc-timeline-table th, .cc-timeline-table td { padding: var(--s-1) var(--s-2); text-align: left; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
511
- .cc-usage-table th, .cc-timeline-table th { color: var(--fg-dim); font-weight: 500; text-transform: uppercase; font-size: 11px; letter-spacing: 0.04em; }
617
+ .cc-usage-table th, .cc-timeline-table th { color: var(--fg-dim); font-weight: 700; text-transform: uppercase; font-size: 11px; letter-spacing: .04em; }
618
+ .cc-usage-missing td:last-child { color: var(--status-running); }
512
619
  .cc-timeline-older { margin-top: var(--s-2); }
513
-
514
- /* Footer */
515
620
  .cc-footer { margin-top: var(--s-5); padding-top: var(--s-3); border-top: 1px solid var(--border); color: var(--fg-dim); font-size: 13px; }
516
- .cc-footer p { margin: var(--s-1) 0; }
517
-
518
- /* Mobile */
519
- @media (max-width: 720px) {
520
- body { padding: var(--s-3); font-size: 14px; }
521
- .cc-progress-list { flex-direction: column; }
621
+ @media (max-width: 900px) {
622
+ .cc-dashboard { display: block; }
623
+ .cc-sidebar { position: fixed; inset: 0 auto 0 0; width: min(86vw, 340px); transform: translateX(-105%); transition: transform .18s ease; z-index: 10; }
624
+ body.cc-sidebar-open .cc-sidebar { transform: translateX(0); }
625
+ .cc-menu-button { display: block; }
626
+ .cc-main { padding: var(--s-3); padding-top: var(--s-5); }
627
+ .cc-stat-grid, .cc-artifact-row, .cc-closeout-row { grid-template-columns: 1fr; }
522
628
  .cc-header-meta, .cc-run-meta, .cc-usage-totals { grid-template-columns: 1fr; }
523
- .cc-header-meta dt, .cc-run-meta dt, .cc-usage-totals dt { margin-top: var(--s-1); }
629
+ table { display: block; overflow-x: auto; white-space: nowrap; }
630
+ }
631
+ @media print {
632
+ body { background: #fff; color: #111; }
633
+ .cc-sidebar, .cc-menu-button, script { display: none !important; }
634
+ .cc-dashboard { display: block; } .cc-main { max-width: none; padding: 0; } .cc-card, .cc-header, .cc-phase-card { box-shadow: none; break-inside: avoid; }
524
635
  }
525
636
  </style>`;
526
637
  }