codecartographer-pi 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.codecarto/GUIDE.md +2 -0
- package/README.md +36 -16
- package/assets/logo.svg +42 -0
- package/dist/core/dashboard.d.ts +6 -0
- package/dist/core/dashboard.js +396 -285
- package/dist/core/index.d.ts +1 -0
- package/dist/core/index.js +1 -0
- package/dist/core/library.d.ts +157 -0
- package/dist/core/library.js +675 -0
- package/dist/core/orchestrator-config.d.ts +38 -0
- package/dist/core/orchestrator-config.js +86 -19
- package/dist/core/pipeline.js +15 -3
- package/dist/core/prompts.d.ts +12 -1
- package/dist/core/prompts.js +15 -6
- package/dist/core/usage.js +15 -1
- package/dist/core/utils.d.ts +8 -0
- package/dist/core/utils.js +17 -1
- package/dist/extensions/codecarto/auto-runner.d.ts +16 -0
- package/dist/extensions/codecarto/auto-runner.js +7 -3
- package/dist/extensions/codecarto/dashboard-writer.js +26 -1
- package/dist/extensions/codecarto/index.js +23 -2
- package/dist/mcp-server/server.d.ts +21 -0
- package/dist/mcp-server/server.js +305 -3
- package/package.json +3 -2
package/dist/core/dashboard.js
CHANGED
|
@@ -1,33 +1,30 @@
|
|
|
1
|
-
// Pure HTML dashboard renderer for a CodeCartographer workspace.
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
21
|
+
renderUsagePanel(inputs),
|
|
25
22
|
renderActivityTimeline(inputs.usage.runs),
|
|
26
23
|
renderOpenQuestionsRollup(inputs.status),
|
|
27
|
-
renderCloseoutsList(inputs
|
|
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>
|
|
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)}
|
|
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
|
|
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
|
|
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
|
|
153
|
-
lines.push(present
|
|
154
|
-
? `<li
|
|
155
|
-
: `<li><span class="cc-
|
|
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
|
|
160
|
-
lines.push(present
|
|
161
|
-
? `<li
|
|
162
|
-
: `<li><span class="cc-
|
|
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-
|
|
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 ? `<
|
|
202
|
-
const kind = c.kind ? `<span class="cc-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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(
|
|
239
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
309
|
-
|
|
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
|
-
|
|
332
|
-
|
|
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 (
|
|
373
|
+
if (total === 0)
|
|
336
374
|
return "";
|
|
337
|
-
|
|
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(
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
`<
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
return
|
|
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, "&")
|
|
419
|
-
.replace(/</g, "<")
|
|
420
|
-
.replace(/>/g, ">")
|
|
421
|
-
.replace(/"/g, """)
|
|
422
|
-
.replace(/'/g, "'");
|
|
526
|
+
return input.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/\"/g, """).replace(/'/g, "'");
|
|
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: #
|
|
435
|
-
--border: #
|
|
436
|
-
--status-pending: #
|
|
437
|
-
--
|
|
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: #
|
|
443
|
-
--border: #
|
|
444
|
-
--status-pending: #
|
|
445
|
-
--
|
|
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
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
.cc-
|
|
457
|
-
.cc-
|
|
458
|
-
.cc-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
.cc-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
.cc-
|
|
469
|
-
.cc-
|
|
470
|
-
.cc-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
.cc-
|
|
474
|
-
.cc-
|
|
475
|
-
.cc-
|
|
476
|
-
.cc-
|
|
477
|
-
.cc-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
.cc-
|
|
481
|
-
.cc-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
.cc-
|
|
485
|
-
.cc-
|
|
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
|
-
|
|
495
|
-
.cc-
|
|
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:
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
.cc-
|
|
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
|
-
|
|
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
|
}
|