iriai-build 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/bin/iriai-build.js +78 -0
  2. package/bridge-v3.js +98 -0
  3. package/cli/bootstrap.js +83 -0
  4. package/cli/commands/implementation.js +64 -0
  5. package/cli/commands/index.js +46 -0
  6. package/cli/commands/launch.js +153 -0
  7. package/cli/commands/plan.js +117 -0
  8. package/cli/commands/setup.js +80 -0
  9. package/cli/commands/slack.js +97 -0
  10. package/cli/commands/transfer.js +111 -0
  11. package/cli/config.js +92 -0
  12. package/cli/display.js +121 -0
  13. package/cli/terminal-input.js +666 -0
  14. package/cli/wait.js +82 -0
  15. package/index.js +1488 -0
  16. package/lib/agent-process.js +170 -0
  17. package/lib/bridge-state.js +126 -0
  18. package/lib/constants.js +137 -0
  19. package/lib/health-monitor.js +113 -0
  20. package/lib/prompt-builder.js +565 -0
  21. package/lib/signal-watcher.js +215 -0
  22. package/lib/slack-helpers.js +224 -0
  23. package/lib/state-machines/feature-lead.js +408 -0
  24. package/lib/state-machines/operator-agent.js +173 -0
  25. package/lib/state-machines/planning-role.js +161 -0
  26. package/lib/state-machines/role-agent.js +186 -0
  27. package/lib/state-machines/team-orchestrator.js +160 -0
  28. package/package.json +31 -0
  29. package/v3/.handover-html-evidence.md +35 -0
  30. package/v3/KICKOFF-HTML-EVIDENCE.md +98 -0
  31. package/v3/PLAN-HTML-EVIDENCE-HARDENING.md +603 -0
  32. package/v3/adapters/desktop-adapter.js +78 -0
  33. package/v3/adapters/interface.js +146 -0
  34. package/v3/adapters/slack-adapter.js +608 -0
  35. package/v3/adapters/slack-helpers.js +179 -0
  36. package/v3/adapters/terminal-adapter.js +249 -0
  37. package/v3/agent-supervisor.js +320 -0
  38. package/v3/artifact-portal.js +1184 -0
  39. package/v3/bridge.db +0 -0
  40. package/v3/constants.js +170 -0
  41. package/v3/db.js +76 -0
  42. package/v3/file-io.js +216 -0
  43. package/v3/helpers.js +174 -0
  44. package/v3/operator.js +364 -0
  45. package/v3/orchestrator.js +2886 -0
  46. package/v3/plan-compiler.js +440 -0
  47. package/v3/prompt-builder.js +849 -0
  48. package/v3/queries.js +461 -0
  49. package/v3/recovery.js +508 -0
  50. package/v3/review-sessions.js +360 -0
  51. package/v3/roles/accessibility-auditor/CLAUDE.md +50 -0
  52. package/v3/roles/analytics-engineer/CLAUDE.md +40 -0
  53. package/v3/roles/architect/CLAUDE.md +809 -0
  54. package/v3/roles/backend-implementer/CLAUDE.md +97 -0
  55. package/v3/roles/code-reviewer/CLAUDE.md +89 -0
  56. package/v3/roles/database-implementer/CLAUDE.md +97 -0
  57. package/v3/roles/deployer/CLAUDE.md +42 -0
  58. package/v3/roles/designer/CLAUDE.md +386 -0
  59. package/v3/roles/documentation/CLAUDE.md +40 -0
  60. package/v3/roles/feature-lead/CLAUDE.md +233 -0
  61. package/v3/roles/frontend-implementer/CLAUDE.md +97 -0
  62. package/v3/roles/implementer/CLAUDE.md +97 -0
  63. package/v3/roles/integration-tester/CLAUDE.md +174 -0
  64. package/v3/roles/observability-engineer/CLAUDE.md +40 -0
  65. package/v3/roles/operator/CLAUDE.md +322 -0
  66. package/v3/roles/orchestrator/CLAUDE.md +288 -0
  67. package/v3/roles/package-implementer/CLAUDE.md +47 -0
  68. package/v3/roles/performance-analyst/CLAUDE.md +49 -0
  69. package/v3/roles/plan-compiler/CLAUDE.md +163 -0
  70. package/v3/roles/planning-lead/CLAUDE.md +41 -0
  71. package/v3/roles/pm/CLAUDE.md +806 -0
  72. package/v3/roles/regression-tester/CLAUDE.md +135 -0
  73. package/v3/roles/release-manager/CLAUDE.md +43 -0
  74. package/v3/roles/security-auditor/CLAUDE.md +90 -0
  75. package/v3/roles/smoke-tester/CLAUDE.md +97 -0
  76. package/v3/roles/test-author/CLAUDE.md +42 -0
  77. package/v3/roles/verifier/CLAUDE.md +90 -0
  78. package/v3/schema.sql +134 -0
  79. package/v3/slack-adapter.js +510 -0
  80. package/v3/slack-helpers.js +346 -0
@@ -0,0 +1,1184 @@
1
+ // artifact-portal.js — Browsable HTTP portal for all feature planning artifacts.
2
+ // Serves on a single port (default 8900) with feature index, tabbed detail, and individual artifact views.
3
+ // Reuses plan-compiler.js CSS/rendering patterns (Charter fonts, frosted glass, marked.js + mermaid.js).
4
+
5
+ import { createServer } from "node:http";
6
+ import fs from "node:fs";
7
+ import path from "node:path";
8
+ import { IMPL_BASE } from "./constants.js";
9
+ import * as queries from "./queries.js";
10
+
11
+ export class ArtifactPortal {
12
+ constructor({ reviewSessions } = {}) {
13
+ this._reviewSessions = reviewSessions;
14
+ this._server = null;
15
+ }
16
+
17
+ async start(port) {
18
+ this._server = createServer((req, res) => this._handleRequest(req, res));
19
+ return new Promise((resolve) => {
20
+ this._server.listen(port, () => {
21
+ console.log(`Artifact Portal: http://localhost:${port}`);
22
+ resolve();
23
+ });
24
+ });
25
+ }
26
+
27
+ async stop() {
28
+ if (!this._server) return;
29
+ return new Promise((resolve) => {
30
+ this._server.close(() => resolve());
31
+ });
32
+ }
33
+
34
+ // ─── Request Router ────────────────────────────────────────────────
35
+
36
+ _handleRequest(req, res) {
37
+ const url = new URL(req.url, `http://${req.headers.host}`);
38
+ const pathname = url.pathname;
39
+
40
+ try {
41
+ if (pathname === "/" || pathname === "") {
42
+ return this._serveIndex(res);
43
+ }
44
+
45
+ if (pathname === "/api/features") {
46
+ return this._serveApiFeaturesJson(res);
47
+ }
48
+
49
+ const featureMatch = pathname.match(/^\/features\/([^/]+)$/);
50
+ if (featureMatch) {
51
+ return this._serveFeatureDetail(res, featureMatch[1]);
52
+ }
53
+
54
+ const rawMatch = pathname.match(/^\/features\/([^/]+)\/raw\/(.+)$/);
55
+ if (rawMatch) {
56
+ return this._serveRawFile(res, rawMatch[1], rawMatch[2]);
57
+ }
58
+
59
+ const artifactMatch = pathname.match(/^\/features\/([^/]+)\/plans\/(.+)$/);
60
+ if (artifactMatch) {
61
+ return this._serveArtifact(res, artifactMatch[1], artifactMatch[2]);
62
+ }
63
+
64
+ res.writeHead(404, { "Content-Type": "text/html" });
65
+ res.end(this._render404());
66
+ } catch (err) {
67
+ console.error("[artifact-portal] Error:", err.message);
68
+ res.writeHead(500, { "Content-Type": "text/plain" });
69
+ res.end("Internal Server Error");
70
+ }
71
+ }
72
+
73
+ // ─── Route Handlers ────────────────────────────────────────────────
74
+
75
+ _serveIndex(res) {
76
+ const features = this._getAllFeaturesWithArtifacts();
77
+ res.writeHead(200, { "Content-Type": "text/html" });
78
+ res.end(this._renderIndex(features));
79
+ }
80
+
81
+ _serveApiFeaturesJson(res) {
82
+ const features = this._getAllFeaturesWithArtifacts();
83
+ res.writeHead(200, { "Content-Type": "application/json" });
84
+ res.end(JSON.stringify(features, null, 2));
85
+ }
86
+
87
+ _serveFeatureDetail(res, slug) {
88
+ const feature = queries.getFeatureBySlug(slug);
89
+ if (!feature) {
90
+ res.writeHead(404, { "Content-Type": "text/html" });
91
+ res.end(this._render404());
92
+ return;
93
+ }
94
+
95
+ const planDir = path.join(IMPL_BASE, "features", slug, "plans");
96
+ const artifacts = this._scanArtifactTree(planDir);
97
+ const reviewSessions = this._getReviewSessionsForFeature(feature.id);
98
+
99
+ res.writeHead(200, { "Content-Type": "text/html" });
100
+ res.end(this._renderFeatureDetail(feature, artifacts, reviewSessions));
101
+ }
102
+
103
+ _serveArtifact(res, slug, relativePath) {
104
+ const resolved = this._resolveAndValidatePath(slug, relativePath);
105
+ if (!resolved) {
106
+ res.writeHead(403, { "Content-Type": "text/plain" });
107
+ res.end("Forbidden: path traversal detected");
108
+ return;
109
+ }
110
+
111
+ if (!fs.existsSync(resolved)) {
112
+ res.writeHead(404, { "Content-Type": "text/html" });
113
+ res.end(this._render404());
114
+ return;
115
+ }
116
+
117
+ const stat = fs.statSync(resolved);
118
+ if (stat.isDirectory()) {
119
+ res.writeHead(400, { "Content-Type": "text/plain" });
120
+ res.end("Cannot view directory");
121
+ return;
122
+ }
123
+
124
+ const feature = queries.getFeatureBySlug(slug);
125
+ const content = fs.readFileSync(resolved, "utf-8");
126
+ const ext = path.extname(resolved).toLowerCase();
127
+
128
+ res.writeHead(200, { "Content-Type": "text/html" });
129
+ res.end(this._renderArtifact(feature, relativePath, content, ext));
130
+ }
131
+
132
+ _serveRawFile(res, slug, relativePath) {
133
+ const resolved = this._resolveAndValidatePath(slug, relativePath);
134
+ if (!resolved) {
135
+ res.writeHead(403, { "Content-Type": "text/plain" });
136
+ res.end("Forbidden: path traversal detected");
137
+ return;
138
+ }
139
+
140
+ if (!fs.existsSync(resolved)) {
141
+ res.writeHead(404, { "Content-Type": "text/plain" });
142
+ res.end("Not found");
143
+ return;
144
+ }
145
+
146
+ const ext = path.extname(resolved).toLowerCase();
147
+ const mimeTypes = {
148
+ ".html": "text/html", ".md": "text/markdown", ".yaml": "text/yaml",
149
+ ".yml": "text/yaml", ".json": "application/json", ".txt": "text/plain",
150
+ };
151
+ const contentType = mimeTypes[ext] || "application/octet-stream";
152
+ const content = fs.readFileSync(resolved);
153
+
154
+ res.writeHead(200, {
155
+ "Content-Type": contentType,
156
+ "Content-Disposition": `inline; filename="${path.basename(resolved)}"`,
157
+ });
158
+ res.end(content);
159
+ }
160
+
161
+ // ─── Data Helpers ──────────────────────────────────────────────────
162
+
163
+ _getAllFeaturesWithArtifacts() {
164
+ const features = queries.getAllFeatures();
165
+ return features.map(f => {
166
+ const planDir = path.join(IMPL_BASE, "features", f.slug, "plans");
167
+ const artifacts = this._scanArtifactTree(planDir);
168
+ return {
169
+ ...f,
170
+ artifactCount: this._countFiles(artifacts),
171
+ lastArtifactUpdate: this._latestMtime(artifacts),
172
+ };
173
+ });
174
+ }
175
+
176
+ _scanArtifactTree(dirPath) {
177
+ if (!fs.existsSync(dirPath)) return [];
178
+
179
+ try {
180
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
181
+ return entries
182
+ .filter(e => !e.name.startsWith("."))
183
+ .map(e => {
184
+ const fullPath = path.join(dirPath, e.name);
185
+ const stat = fs.statSync(fullPath);
186
+ if (e.isDirectory()) {
187
+ return {
188
+ name: e.name,
189
+ path: e.name,
190
+ type: "directory",
191
+ mtime: stat.mtimeMs,
192
+ children: this._scanArtifactTree(fullPath).map(child => ({
193
+ ...child,
194
+ path: e.name + "/" + child.path,
195
+ })),
196
+ };
197
+ }
198
+ return {
199
+ name: e.name,
200
+ path: e.name,
201
+ size: stat.size,
202
+ mtime: stat.mtimeMs,
203
+ type: path.extname(e.name).slice(1) || "file",
204
+ };
205
+ })
206
+ .sort((a, b) => {
207
+ if (a.type === "directory" && b.type !== "directory") return -1;
208
+ if (a.type !== "directory" && b.type === "directory") return 1;
209
+ return a.name.localeCompare(b.name);
210
+ });
211
+ } catch {
212
+ return [];
213
+ }
214
+ }
215
+
216
+ _countFiles(artifacts) {
217
+ let count = 0;
218
+ for (const a of artifacts) {
219
+ if (a.type === "directory") count += this._countFiles(a.children || []);
220
+ else count++;
221
+ }
222
+ return count;
223
+ }
224
+
225
+ _latestMtime(artifacts) {
226
+ let latest = 0;
227
+ for (const a of artifacts) {
228
+ if (a.type === "directory") {
229
+ const sub = this._latestMtime(a.children || []);
230
+ if (sub > latest) latest = sub;
231
+ } else if (a.mtime > latest) {
232
+ latest = a.mtime;
233
+ }
234
+ }
235
+ return latest || null;
236
+ }
237
+
238
+ _flattenArtifacts(artifacts) {
239
+ const flat = [];
240
+ for (const a of artifacts) {
241
+ if (a.type === "directory") {
242
+ flat.push(...this._flattenArtifacts(a.children || []));
243
+ } else {
244
+ flat.push(a);
245
+ }
246
+ }
247
+ return flat;
248
+ }
249
+
250
+ _resolveAndValidatePath(slug, relativePath) {
251
+ const planDir = path.join(IMPL_BASE, "features", slug, "plans");
252
+ const resolved = path.resolve(planDir, relativePath);
253
+ if (!resolved.startsWith(planDir + path.sep) && resolved !== planDir) {
254
+ return null;
255
+ }
256
+ return resolved;
257
+ }
258
+
259
+ _getReviewSessionsForFeature(featureId) {
260
+ try {
261
+ return queries.getReviewSessionsByFeature(featureId);
262
+ } catch {
263
+ return [];
264
+ }
265
+ }
266
+
267
+ // ─── Shared CSS ────────────────────────────────────────────────────
268
+
269
+ _sharedCss() {
270
+ return `
271
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
272
+
273
+ :root {
274
+ --text-primary: #1a1a2e;
275
+ --text-secondary: #555;
276
+ --text-muted: #888;
277
+ --bg-page: #faf9f7;
278
+ --bg-header: rgba(250, 249, 247, 0.92);
279
+ --border-subtle: rgba(0, 0, 0, 0.06);
280
+ --accent: #2d5be3;
281
+ --accent-faint: rgba(45, 91, 227, 0.06);
282
+ --content-width: 720px;
283
+ --layout-width: 1080px;
284
+ --gutter: clamp(20px, 5vw, 48px);
285
+ }
286
+
287
+ html {
288
+ font-size: 18px;
289
+ -webkit-font-smoothing: antialiased;
290
+ text-rendering: optimizeLegibility;
291
+ scroll-behavior: smooth;
292
+ }
293
+
294
+ body {
295
+ font-family: Charter, Georgia, 'Times New Roman', serif;
296
+ color: var(--text-primary);
297
+ background: var(--bg-page);
298
+ line-height: 1.72;
299
+ min-height: 100vh;
300
+ }
301
+
302
+ ::selection {
303
+ background: rgba(45, 91, 227, 0.18);
304
+ color: inherit;
305
+ }
306
+
307
+ .header {
308
+ background: var(--bg-header);
309
+ backdrop-filter: blur(16px);
310
+ border-bottom: 1px solid var(--border-subtle);
311
+ padding: 0 var(--gutter);
312
+ position: sticky;
313
+ top: 0;
314
+ z-index: 100;
315
+ }
316
+
317
+ .header-inner {
318
+ max-width: var(--layout-width);
319
+ margin: 0 auto;
320
+ display: flex;
321
+ align-items: center;
322
+ justify-content: space-between;
323
+ height: 56px;
324
+ }
325
+
326
+ .header h1 {
327
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
328
+ font-size: 16px;
329
+ font-weight: 600;
330
+ color: var(--text-primary);
331
+ letter-spacing: -0.01em;
332
+ }
333
+
334
+ .header h1 a {
335
+ color: inherit;
336
+ text-decoration: none;
337
+ }
338
+
339
+ .header .badge {
340
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
341
+ background: var(--accent);
342
+ color: #fff;
343
+ padding: 3px 10px;
344
+ border-radius: 12px;
345
+ font-size: 12px;
346
+ font-weight: 500;
347
+ }
348
+
349
+ .breadcrumb {
350
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
351
+ font-size: 13px;
352
+ color: var(--text-muted);
353
+ }
354
+
355
+ .breadcrumb a {
356
+ color: var(--text-muted);
357
+ text-decoration: none;
358
+ }
359
+
360
+ .breadcrumb a:hover {
361
+ color: var(--accent);
362
+ }
363
+
364
+ .breadcrumb .sep {
365
+ margin: 0 6px;
366
+ opacity: 0.5;
367
+ }
368
+
369
+ @keyframes fade-up {
370
+ from { opacity: 0; transform: translateY(12px); }
371
+ to { opacity: 1; transform: translateY(0); }
372
+ }`;
373
+ }
374
+
375
+ _markdownCss() {
376
+ return `
377
+ .markdown-body {
378
+ padding: 36px 0 120px;
379
+ min-width: 0;
380
+ }
381
+
382
+ .markdown-body h1 {
383
+ font-size: 1.9rem; font-weight: 700; line-height: 1.25;
384
+ letter-spacing: -0.015em; margin: 2.4em 0 0.6em;
385
+ padding-bottom: 0.3em; border-bottom: 1px solid var(--border-subtle);
386
+ color: var(--text-primary);
387
+ }
388
+ .markdown-body h1:first-child { margin-top: 0; }
389
+
390
+ .markdown-body h2 {
391
+ font-size: 1.45rem; font-weight: 700; line-height: 1.3;
392
+ letter-spacing: -0.01em; margin: 2.2em 0 0.5em;
393
+ padding-bottom: 0.25em; border-bottom: 1px solid var(--border-subtle);
394
+ color: var(--text-primary);
395
+ }
396
+
397
+ .markdown-body h3 {
398
+ font-size: 1.15rem; font-weight: 700; line-height: 1.35;
399
+ margin: 1.8em 0 0.4em; color: var(--text-primary);
400
+ }
401
+
402
+ .markdown-body h4, .markdown-body h5, .markdown-body h6 {
403
+ font-weight: 700; line-height: 1.4; margin: 1.6em 0 0.4em;
404
+ color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.04em;
405
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
406
+ font-size: 0.78rem;
407
+ }
408
+
409
+ .markdown-body p { margin-bottom: 1.4em; }
410
+
411
+ .markdown-body a { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; text-decoration-thickness: 1px; }
412
+ .markdown-body a:hover { color: #1a3fa0; }
413
+
414
+ .markdown-body strong { font-weight: 700; }
415
+ .markdown-body em { font-style: italic; }
416
+
417
+ .markdown-body blockquote {
418
+ margin: 1.6em 0; padding: 12px 0 12px 24px;
419
+ border-left: 3px solid var(--accent); color: var(--text-secondary);
420
+ font-style: italic; background: var(--accent-faint); border-radius: 0 8px 8px 0;
421
+ }
422
+ .markdown-body blockquote p:last-child { margin-bottom: 0; }
423
+
424
+ .markdown-body ul, .markdown-body ol { margin: 0 0 1.4em; padding-left: 1.6em; }
425
+ .markdown-body li { margin-bottom: 0.4em; }
426
+ .markdown-body li > ul, .markdown-body li > ol { margin-top: 0.4em; margin-bottom: 0; }
427
+ .markdown-body li::marker { color: var(--text-muted); }
428
+
429
+ .markdown-body code {
430
+ font-family: 'SF Mono', Menlo, Consolas, monospace; font-size: 0.85em;
431
+ background: var(--accent-faint); padding: 2px 6px; border-radius: 4px;
432
+ color: #1a3fa0; word-break: break-word;
433
+ }
434
+ .markdown-body pre {
435
+ margin: 1.6em 0; padding: 20px 24px; background: #1a1a2e;
436
+ border-radius: 10px; overflow-x: auto; line-height: 1.55;
437
+ }
438
+ .markdown-body pre code { background: none; padding: 0; color: #c8d0e0; font-size: 0.82rem; }
439
+
440
+ .markdown-body hr { border: none; height: 1px; background: var(--border-subtle); margin: 2.4em 0; }
441
+
442
+ .markdown-body table {
443
+ width: 100%; border-collapse: collapse; margin: 1.6em 0;
444
+ font-size: 0.92rem; border: 1px solid var(--border-subtle); border-radius: 8px; overflow: hidden;
445
+ }
446
+ .markdown-body th, .markdown-body td { padding: 10px 14px; text-align: left; border-bottom: 1px solid var(--border-subtle); }
447
+ .markdown-body th {
448
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
449
+ font-weight: 600; font-size: 0.78rem; text-transform: uppercase;
450
+ letter-spacing: 0.04em; color: var(--text-muted); background: rgba(0,0,0,0.02);
451
+ }
452
+ .markdown-body tr:last-child td { border-bottom: none; }
453
+ .markdown-body tr:hover td { background: rgba(45, 91, 227, 0.02); }
454
+
455
+ .mermaid { background: #1a1a2e; padding: 24px; border-radius: 10px; margin: 1.6em 0; }`;
456
+ }
457
+
458
+ // ─── Page Renderers ────────────────────────────────────────────────
459
+
460
+ _renderIndex(features) {
461
+ const phaseColor = (phase) => {
462
+ const colors = {
463
+ planning: "#2d5be3",
464
+ "plan-approval": "#d97706",
465
+ impl: "#16a34a",
466
+ complete: "#059669",
467
+ failed: "#dc2626",
468
+ };
469
+ return colors[phase] || "#888";
470
+ };
471
+
472
+ const cards = features.map(f => {
473
+ const phase = f.phase || "unknown";
474
+ const role = f.active_planning_role || "";
475
+ const count = f.artifactCount || 0;
476
+ const updated = f.updated_at ? new Date(f.updated_at + "Z").toLocaleDateString("en-US", { month: "short", day: "numeric" }) : "";
477
+
478
+ return `
479
+ <a href="/features/${this._escHtml(f.slug)}" class="card">
480
+ <div class="card-header">
481
+ <span class="card-title">${this._escHtml(f.slug)}</span>
482
+ <span class="phase-badge" style="background: ${phaseColor(phase)}">${this._escHtml(phase)}</span>
483
+ </div>
484
+ <div class="card-meta">
485
+ ${role ? `<span class="meta-item">Role: ${this._escHtml(role)}</span>` : ""}
486
+ <span class="meta-item">${count} artifact${count !== 1 ? "s" : ""}</span>
487
+ ${updated ? `<span class="meta-item">Updated ${updated}</span>` : ""}
488
+ </div>
489
+ </a>`;
490
+ }).join("\n");
491
+
492
+ return `<!DOCTYPE html>
493
+ <html lang="en">
494
+ <head>
495
+ <meta charset="UTF-8">
496
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
497
+ <title>Artifact Portal</title>
498
+ <style>
499
+ ${this._sharedCss()}
500
+
501
+ .grid {
502
+ max-width: var(--layout-width);
503
+ margin: 32px auto;
504
+ padding: 0 var(--gutter);
505
+ display: grid;
506
+ grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
507
+ gap: 16px;
508
+ animation: fade-up 0.4s ease both;
509
+ }
510
+
511
+ .card {
512
+ display: block;
513
+ background: #fff;
514
+ border: 1px solid var(--border-subtle);
515
+ border-radius: 10px;
516
+ padding: 20px 24px;
517
+ text-decoration: none;
518
+ color: inherit;
519
+ transition: border-color 0.15s, box-shadow 0.15s;
520
+ }
521
+
522
+ .card:hover {
523
+ border-color: var(--accent);
524
+ box-shadow: 0 2px 12px rgba(45, 91, 227, 0.08);
525
+ }
526
+
527
+ .card-header {
528
+ display: flex;
529
+ align-items: center;
530
+ justify-content: space-between;
531
+ gap: 12px;
532
+ margin-bottom: 10px;
533
+ }
534
+
535
+ .card-title {
536
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
537
+ font-size: 15px;
538
+ font-weight: 600;
539
+ color: var(--text-primary);
540
+ overflow: hidden;
541
+ text-overflow: ellipsis;
542
+ white-space: nowrap;
543
+ }
544
+
545
+ .phase-badge {
546
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
547
+ color: #fff;
548
+ padding: 2px 10px;
549
+ border-radius: 12px;
550
+ font-size: 11px;
551
+ font-weight: 600;
552
+ text-transform: uppercase;
553
+ letter-spacing: 0.03em;
554
+ white-space: nowrap;
555
+ flex-shrink: 0;
556
+ }
557
+
558
+ .card-meta {
559
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
560
+ display: flex;
561
+ flex-wrap: wrap;
562
+ gap: 12px;
563
+ font-size: 12px;
564
+ color: var(--text-muted);
565
+ }
566
+
567
+ .empty-state {
568
+ max-width: var(--layout-width);
569
+ margin: 80px auto;
570
+ text-align: center;
571
+ color: var(--text-muted);
572
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
573
+ font-size: 15px;
574
+ }
575
+ </style>
576
+ </head>
577
+ <body>
578
+ <div class="header">
579
+ <div class="header-inner">
580
+ <h1>Artifact Portal</h1>
581
+ <span class="badge">${features.length} feature${features.length !== 1 ? "s" : ""}</span>
582
+ </div>
583
+ </div>
584
+
585
+ ${features.length > 0
586
+ ? `<div class="grid">${cards}</div>`
587
+ : `<div class="empty-state">No features found in the database.</div>`}
588
+ </body>
589
+ </html>`;
590
+ }
591
+
592
+ _renderFeatureDetail(feature, artifacts, reviewSessions) {
593
+ const flatArtifacts = this._flattenArtifacts(artifacts);
594
+ const slug = feature.slug;
595
+ const planDir = path.join(IMPL_BASE, "features", slug, "plans");
596
+
597
+ // Build tabs from known artifact patterns
598
+ const tabDefs = [
599
+ { id: "prd", label: "PRD", pattern: "prd" },
600
+ { id: "design", label: "Design", pattern: "design-decisions" },
601
+ { id: "architecture", label: "Architecture", pattern: "context" },
602
+ { id: "plan", label: "Plan.yaml", exact: "plan.yaml" },
603
+ { id: "journeys", label: "Journeys", dir: "journeys" },
604
+ { id: "phases", label: "Phases", dir: "phases" },
605
+ ];
606
+
607
+ const tabs = [];
608
+ const usedFiles = new Set();
609
+
610
+ for (const def of tabDefs) {
611
+ if (def.exact) {
612
+ const a = flatArtifacts.find(a => a.name === def.exact);
613
+ if (a) {
614
+ const content = this._safeReadFile(path.join(planDir, a.path));
615
+ if (content) {
616
+ tabs.push({ id: def.id, label: def.label, content, type: a.name.endsWith(".yaml") || a.name.endsWith(".yml") ? "yaml" : "md" });
617
+ usedFiles.add(a.path);
618
+ }
619
+ }
620
+ } else if (def.dir) {
621
+ const dirArtifact = artifacts.find(a => a.type === "directory" && a.name === def.dir);
622
+ if (dirArtifact && dirArtifact.children?.length > 0) {
623
+ const combined = dirArtifact.children
624
+ .filter(c => c.type !== "directory")
625
+ .map(c => {
626
+ usedFiles.add(c.path);
627
+ const content = this._safeReadFile(path.join(planDir, c.path));
628
+ return `## ${c.name}\n\n${content || "_Empty file_"}`;
629
+ })
630
+ .join("\n\n---\n\n");
631
+ if (combined) {
632
+ tabs.push({ id: def.id, label: def.label, content: combined, type: "md" });
633
+ }
634
+ }
635
+ } else if (def.pattern) {
636
+ const a = flatArtifacts.find(a => a.name.includes(def.pattern) && a.name.endsWith(".md"));
637
+ if (a) {
638
+ const content = this._safeReadFile(path.join(planDir, a.path));
639
+ if (content) {
640
+ tabs.push({ id: def.id, label: def.label, content, type: "md" });
641
+ usedFiles.add(a.path);
642
+ }
643
+ }
644
+ }
645
+ }
646
+
647
+ // Add remaining files as "Other" tab
648
+ const others = flatArtifacts.filter(a => !usedFiles.has(a.path) && a.name !== "HANDOVER.md");
649
+ if (others.length > 0) {
650
+ const combined = others.map(a => {
651
+ const ext = path.extname(a.name).toLowerCase();
652
+ if (ext === ".html") {
653
+ return `## ${a.name}\n\n[View Raw](/features/${slug}/raw/${a.path})`;
654
+ }
655
+ const content = this._safeReadFile(path.join(planDir, a.path));
656
+ if (ext === ".json" || ext === ".yaml" || ext === ".yml") {
657
+ return `## ${a.name}\n\n\`\`\`${ext.slice(1)}\n${content || ""}\n\`\`\``;
658
+ }
659
+ return `## ${a.name}\n\n${content || "_Empty file_"}`;
660
+ }).join("\n\n---\n\n");
661
+ tabs.push({ id: "other", label: "Other", content: combined, type: "md" });
662
+ }
663
+
664
+ // Check for mockup.html
665
+ const mockupFile = flatArtifacts.find(a => a.name === "mockup.html");
666
+ const hasMockup = !!mockupFile;
667
+
668
+ // HANDOVER.md tab
669
+ const handover = flatArtifacts.find(a => a.name === "HANDOVER.md");
670
+ if (handover) {
671
+ const content = this._safeReadFile(path.join(planDir, handover.path));
672
+ if (content) {
673
+ tabs.push({ id: "handover", label: "Handover", content, type: "md" });
674
+ }
675
+ }
676
+
677
+ if (tabs.length === 0) {
678
+ return this._renderEmptyFeature(feature);
679
+ }
680
+
681
+ const phaseColor = {
682
+ planning: "#2d5be3", "plan-approval": "#d97706",
683
+ impl: "#16a34a", complete: "#059669", failed: "#dc2626",
684
+ };
685
+
686
+ const tabButtons = tabs.map((t, i) =>
687
+ `<button class="tab-btn${i === 0 ? " active" : ""}" onclick="showTab('${t.id}')" id="btn-${t.id}">${this._escHtml(t.label)}</button>`
688
+ ).join("\n ");
689
+
690
+ const tabPanels = tabs.map((t, i) => {
691
+ const escapedContent = this._escapeForTemplate(t.content);
692
+ const wrapType = t.type === "yaml" ? "yaml" : "md";
693
+ const rawContent = wrapType === "yaml"
694
+ ? "```yaml\n" + t.content + "\n```"
695
+ : t.content;
696
+
697
+ return `<div class="tab-panel${i === 0 ? " active" : ""}" id="panel-${t.id}" data-type="${wrapType}">
698
+ <div class="markdown-body">${this._escapeForTemplate(rawContent)}</div>
699
+ <nav class="tab-toc" id="toc-${t.id}"></nav>
700
+ </div>`;
701
+ }).join("\n ");
702
+
703
+ // Review session links
704
+ const sessionLinks = reviewSessions.map(s =>
705
+ `<a href="http://localhost:${s.qa_port || s.port}" class="session-link" target="_blank">Review: ${s.doc_path ? path.basename(s.doc_path) : s.decision_id}</a>`
706
+ ).join("");
707
+
708
+ return `<!DOCTYPE html>
709
+ <html lang="en">
710
+ <head>
711
+ <meta charset="UTF-8">
712
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
713
+ <title>${this._escHtml(slug)} - Artifact Portal</title>
714
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"><\/script>
715
+ <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"><\/script>
716
+ <style>
717
+ ${this._sharedCss()}
718
+ ${this._markdownCss()}
719
+
720
+ .status-bar {
721
+ max-width: var(--layout-width);
722
+ margin: 0 auto;
723
+ padding: 16px var(--gutter);
724
+ display: flex;
725
+ align-items: center;
726
+ gap: 16px;
727
+ flex-wrap: wrap;
728
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
729
+ font-size: 13px;
730
+ color: var(--text-muted);
731
+ border-bottom: 1px solid var(--border-subtle);
732
+ }
733
+
734
+ .status-badge {
735
+ color: #fff;
736
+ padding: 2px 10px;
737
+ border-radius: 12px;
738
+ font-size: 11px;
739
+ font-weight: 600;
740
+ text-transform: uppercase;
741
+ }
742
+
743
+ .tabs {
744
+ display: flex;
745
+ gap: 0;
746
+ background: var(--bg-header);
747
+ border-bottom: 1px solid var(--border-subtle);
748
+ padding: 0 var(--gutter);
749
+ position: sticky;
750
+ top: 56px;
751
+ z-index: 99;
752
+ }
753
+
754
+ .tabs-inner {
755
+ max-width: var(--layout-width);
756
+ margin: 0 auto;
757
+ display: flex;
758
+ width: 100%;
759
+ overflow-x: auto;
760
+ }
761
+
762
+ .tab-btn {
763
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
764
+ background: none;
765
+ border: none;
766
+ color: var(--text-muted);
767
+ padding: 12px 20px;
768
+ font-size: 14px;
769
+ cursor: pointer;
770
+ border-bottom: 2px solid transparent;
771
+ transition: all 0.15s;
772
+ white-space: nowrap;
773
+ }
774
+
775
+ .tab-btn:hover { color: var(--text-primary); }
776
+ .tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
777
+
778
+ .tab-panel {
779
+ display: none;
780
+ max-width: var(--layout-width);
781
+ margin: 0 auto;
782
+ padding: 0 var(--gutter);
783
+ }
784
+
785
+ .tab-panel.active {
786
+ display: grid;
787
+ grid-template-columns: var(--content-width) 1fr;
788
+ gap: 0;
789
+ animation: fade-up 0.4s ease both;
790
+ }
791
+
792
+ .tab-toc {
793
+ position: sticky;
794
+ top: 128px;
795
+ align-self: start;
796
+ max-height: calc(100vh - 144px);
797
+ overflow-y: auto;
798
+ padding: 36px 0 36px 32px;
799
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
800
+ border-left: 1px solid var(--border-subtle);
801
+ }
802
+
803
+ .tab-toc:empty { display: none; }
804
+
805
+ .tab-toc .toc-title {
806
+ font-size: 11px; font-weight: 700; text-transform: uppercase;
807
+ letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 12px;
808
+ }
809
+
810
+ .tab-toc ol { list-style: none; padding: 0; margin: 0; }
811
+ .tab-toc li { margin: 0; }
812
+
813
+ .tab-toc a {
814
+ display: block; padding: 4px 0 4px 12px; font-size: 13px; line-height: 1.4;
815
+ color: var(--text-muted); text-decoration: none;
816
+ border-left: 2px solid transparent; margin-left: -1px; transition: color 0.15s;
817
+ }
818
+
819
+ .tab-toc a:hover { color: var(--text-primary); }
820
+ .tab-toc a.active { color: var(--accent); border-left-color: var(--accent); font-weight: 600; }
821
+ .tab-toc .toc-h3 { padding-left: 24px; font-size: 12px; }
822
+
823
+ .session-link {
824
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
825
+ display: inline-block;
826
+ padding: 4px 12px;
827
+ background: var(--accent-faint);
828
+ color: var(--accent);
829
+ border-radius: 6px;
830
+ font-size: 12px;
831
+ text-decoration: none;
832
+ margin-left: 4px;
833
+ }
834
+
835
+ .session-link:hover { background: rgba(45, 91, 227, 0.12); }
836
+
837
+ .mockup-btn {
838
+ display: inline-block;
839
+ padding: 4px 12px;
840
+ background: #059669;
841
+ color: #fff;
842
+ border-radius: 6px;
843
+ font-size: 12px;
844
+ font-weight: 500;
845
+ text-decoration: none;
846
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
847
+ }
848
+
849
+ .mockup-btn:hover { background: #047857; }
850
+
851
+ .progress { position: fixed; top: 56px; left: 0; width: 0%; height: 2px; background: var(--accent); z-index: 101; transition: width 0.1s linear; }
852
+
853
+ @media (max-width: 960px) {
854
+ .tab-panel.active { grid-template-columns: 1fr; }
855
+ .tab-toc { display: none; }
856
+ }
857
+
858
+ @media (max-width: 600px) {
859
+ html { font-size: 16px; }
860
+ .header-inner { height: 48px; }
861
+ .tabs { top: 48px; }
862
+ .tab-btn { padding: 10px 14px; font-size: 13px; }
863
+ }
864
+ </style>
865
+ </head>
866
+ <body>
867
+ <div class="progress" id="progress"></div>
868
+
869
+ <div class="header">
870
+ <div class="header-inner">
871
+ <h1><a href="/">Artifact Portal</a></h1>
872
+ <div class="breadcrumb">
873
+ <a href="/">Portal</a><span class="sep">/</span><span>${this._escHtml(slug)}</span>
874
+ </div>
875
+ </div>
876
+ </div>
877
+
878
+ <div class="status-bar">
879
+ <span class="status-badge" style="background: ${phaseColor[feature.phase] || "#888"}">${this._escHtml(feature.phase || "unknown")}</span>
880
+ ${feature.active_planning_role ? `<span>Role: ${this._escHtml(feature.active_planning_role)}</span>` : ""}
881
+ ${feature.gate_number ? `<span>Gate: ${feature.gate_number}</span>` : ""}
882
+ ${hasMockup ? `<a href="/features/${this._escHtml(slug)}/raw/mockup.html" class="mockup-btn" target="_blank">View Mockup</a>` : ""}
883
+ ${sessionLinks}
884
+ </div>
885
+
886
+ <div class="tabs">
887
+ <div class="tabs-inner">
888
+ ${tabButtons}
889
+ </div>
890
+ </div>
891
+
892
+ <div class="content">
893
+ ${tabPanels}
894
+ </div>
895
+
896
+ <script>
897
+ mermaid.initialize({ startOnLoad: false, theme: 'dark' });
898
+
899
+ function showTab(id) {
900
+ document.querySelectorAll('.tab-panel').forEach(function(p) { p.classList.remove('active'); });
901
+ document.querySelectorAll('.tab-btn').forEach(function(b) { b.classList.remove('active'); });
902
+ document.getElementById('panel-' + id).classList.add('active');
903
+ document.getElementById('btn-' + id).classList.add('active');
904
+ }
905
+
906
+ document.querySelectorAll('.tab-panel').forEach(function(panel) {
907
+ var body = panel.querySelector('.markdown-body');
908
+ var tocEl = panel.querySelector('.tab-toc');
909
+ if (!body) return;
910
+
911
+ var raw = body.textContent;
912
+ body.innerHTML = marked.parse(raw);
913
+
914
+ body.querySelectorAll('pre code.language-mermaid').forEach(function(code) {
915
+ var pre = code.parentElement;
916
+ var div = document.createElement('div');
917
+ div.className = 'mermaid';
918
+ div.textContent = code.textContent;
919
+ pre.replaceWith(div);
920
+ });
921
+
922
+ if (!tocEl) return;
923
+ var headings = body.querySelectorAll('h1, h2, h3');
924
+ if (headings.length < 3) return;
925
+
926
+ var html = '<div class="toc-title">On this page</div><ol>';
927
+ headings.forEach(function(h, i) {
928
+ var id = panel.id + '-s' + i;
929
+ h.setAttribute('id', id);
930
+ var level = h.tagName.toLowerCase();
931
+ var cls = level === 'h3' ? ' class="toc-h3"' : '';
932
+ html += '<li><a href="#' + id + '"' + cls + '>' + h.textContent + '</a></li>';
933
+ });
934
+ html += '</ol>';
935
+ tocEl.innerHTML = html;
936
+ });
937
+
938
+ mermaid.run();
939
+
940
+ window.addEventListener('scroll', function() {
941
+ var h = document.documentElement.scrollHeight - window.innerHeight;
942
+ document.getElementById('progress').style.width = h > 0 ? ((window.scrollY / h) * 100) + '%' : '0%';
943
+ }, { passive: true });
944
+ <\/script>
945
+ </body>
946
+ </html>`;
947
+ }
948
+
949
+ _renderArtifact(feature, relativePath, content, ext) {
950
+ const slug = feature?.slug || "unknown";
951
+ const filename = path.basename(relativePath);
952
+ const isHtml = ext === ".html" || ext === ".htm";
953
+ const isYaml = ext === ".yaml" || ext === ".yml";
954
+ const isJson = ext === ".json";
955
+
956
+ let bodyContent;
957
+ if (isHtml) {
958
+ const encoded = this._escHtml(content);
959
+ bodyContent = `<div class="iframe-wrapper">
960
+ <iframe srcdoc="${encoded}" sandbox="allow-scripts allow-same-origin" style="width:100%;height:80vh;border:1px solid var(--border-subtle);border-radius:10px;"></iframe>
961
+ </div>`;
962
+ } else if (isYaml || isJson) {
963
+ const lang = isYaml ? "yaml" : "json";
964
+ bodyContent = `<div class="markdown-body"><div class="raw-content">\`\`\`${lang}\n${this._escapeForTemplate(content)}\n\`\`\`</div></div>
965
+ <nav class="doc-toc" id="doc-toc"></nav>`;
966
+ } else {
967
+ bodyContent = `<div class="markdown-body"><div class="raw-content">${this._escapeForTemplate(content)}</div></div>
968
+ <nav class="doc-toc" id="doc-toc"></nav>`;
969
+ }
970
+
971
+ return `<!DOCTYPE html>
972
+ <html lang="en">
973
+ <head>
974
+ <meta charset="UTF-8">
975
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
976
+ <title>${this._escHtml(filename)} - ${this._escHtml(slug)}</title>
977
+ <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"><\/script>
978
+ <script src="https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.min.js"><\/script>
979
+ <style>
980
+ ${this._sharedCss()}
981
+ ${this._markdownCss()}
982
+
983
+ .doc-layout {
984
+ max-width: var(--layout-width);
985
+ margin: 0 auto;
986
+ padding: 0 var(--gutter);
987
+ display: grid;
988
+ grid-template-columns: var(--content-width) 1fr;
989
+ gap: 0;
990
+ animation: fade-up 0.4s ease both;
991
+ }
992
+
993
+ .doc-toc {
994
+ position: sticky;
995
+ top: 80px;
996
+ align-self: start;
997
+ max-height: calc(100vh - 96px);
998
+ overflow-y: auto;
999
+ padding: 36px 0 36px 32px;
1000
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1001
+ border-left: 1px solid var(--border-subtle);
1002
+ }
1003
+
1004
+ .doc-toc:empty { display: none; }
1005
+
1006
+ .doc-toc .toc-title {
1007
+ font-size: 11px; font-weight: 700; text-transform: uppercase;
1008
+ letter-spacing: 0.08em; color: var(--text-muted); margin-bottom: 12px;
1009
+ }
1010
+
1011
+ .doc-toc ol { list-style: none; padding: 0; margin: 0; }
1012
+ .doc-toc li { margin: 0; }
1013
+
1014
+ .doc-toc a {
1015
+ display: block; padding: 4px 0 4px 12px; font-size: 13px; line-height: 1.4;
1016
+ color: var(--text-muted); text-decoration: none;
1017
+ border-left: 2px solid transparent; margin-left: -1px; transition: color 0.15s;
1018
+ }
1019
+
1020
+ .doc-toc a:hover { color: var(--text-primary); }
1021
+ .doc-toc a.active { color: var(--accent); border-left-color: var(--accent); font-weight: 600; }
1022
+ .doc-toc .toc-h3 { padding-left: 24px; font-size: 12px; }
1023
+
1024
+ .iframe-wrapper {
1025
+ max-width: var(--layout-width);
1026
+ margin: 24px auto;
1027
+ padding: 0 var(--gutter);
1028
+ }
1029
+
1030
+ .back-link {
1031
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
1032
+ font-size: 13px;
1033
+ color: var(--accent);
1034
+ text-decoration: none;
1035
+ }
1036
+
1037
+ .back-link:hover { text-decoration: underline; }
1038
+
1039
+ @media (max-width: 960px) {
1040
+ .doc-layout { grid-template-columns: 1fr; }
1041
+ .doc-toc { display: none; }
1042
+ }
1043
+ </style>
1044
+ </head>
1045
+ <body>
1046
+ <div class="header">
1047
+ <div class="header-inner">
1048
+ <h1><a href="/">Artifact Portal</a></h1>
1049
+ <div class="breadcrumb">
1050
+ <a href="/">Portal</a><span class="sep">/</span><a href="/features/${this._escHtml(slug)}">${this._escHtml(slug)}</a><span class="sep">/</span><span>${this._escHtml(filename)}</span>
1051
+ </div>
1052
+ </div>
1053
+ </div>
1054
+
1055
+ <div class="${isHtml ? "" : "doc-layout"}">
1056
+ ${bodyContent}
1057
+ </div>
1058
+
1059
+ <script>
1060
+ mermaid.initialize({ startOnLoad: false, theme: 'dark' });
1061
+
1062
+ document.querySelectorAll('.raw-content').forEach(function(el) {
1063
+ var raw = el.textContent;
1064
+ var parent = el.parentElement;
1065
+ parent.innerHTML = marked.parse(raw);
1066
+ });
1067
+
1068
+ // Build TOC for single doc
1069
+ var tocEl = document.getElementById('doc-toc');
1070
+ if (tocEl) {
1071
+ var body = document.querySelector('.markdown-body');
1072
+ if (body) {
1073
+ var headings = body.querySelectorAll('h1, h2, h3');
1074
+ if (headings.length >= 3) {
1075
+ var html = '<div class="toc-title">On this page</div><ol>';
1076
+ headings.forEach(function(h, i) {
1077
+ var id = 'doc-s' + i;
1078
+ h.setAttribute('id', id);
1079
+ var level = h.tagName.toLowerCase();
1080
+ var cls = level === 'h3' ? ' class="toc-h3"' : '';
1081
+ html += '<li><a href="#' + id + '"' + cls + '>' + h.textContent + '</a></li>';
1082
+ });
1083
+ html += '</ol>';
1084
+ tocEl.innerHTML = html;
1085
+ }
1086
+ }
1087
+ }
1088
+
1089
+ // Render mermaid
1090
+ document.querySelectorAll('pre code.language-mermaid').forEach(function(code) {
1091
+ var pre = code.parentElement;
1092
+ var div = document.createElement('div');
1093
+ div.className = 'mermaid';
1094
+ div.textContent = code.textContent;
1095
+ pre.replaceWith(div);
1096
+ });
1097
+
1098
+ mermaid.run();
1099
+ <\/script>
1100
+ </body>
1101
+ </html>`;
1102
+ }
1103
+
1104
+ _renderEmptyFeature(feature) {
1105
+ return `<!DOCTYPE html>
1106
+ <html lang="en">
1107
+ <head>
1108
+ <meta charset="UTF-8">
1109
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1110
+ <title>${this._escHtml(feature.slug)} - Artifact Portal</title>
1111
+ <style>${this._sharedCss()}
1112
+ .empty { max-width: var(--layout-width); margin: 80px auto; text-align: center;
1113
+ color: var(--text-muted); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 15px; }
1114
+ .empty a { color: var(--accent); }
1115
+ </style>
1116
+ </head>
1117
+ <body>
1118
+ <div class="header">
1119
+ <div class="header-inner">
1120
+ <h1><a href="/">Artifact Portal</a></h1>
1121
+ <div class="breadcrumb">
1122
+ <a href="/">Portal</a><span class="sep">/</span><span>${this._escHtml(feature.slug)}</span>
1123
+ </div>
1124
+ </div>
1125
+ </div>
1126
+ <div class="empty">
1127
+ <p>No planning artifacts found for <strong>${this._escHtml(feature.slug)}</strong>.</p>
1128
+ <p style="margin-top: 12px;"><a href="/">Back to Portal</a></p>
1129
+ </div>
1130
+ </body>
1131
+ </html>`;
1132
+ }
1133
+
1134
+ _render404() {
1135
+ return `<!DOCTYPE html>
1136
+ <html lang="en">
1137
+ <head>
1138
+ <meta charset="UTF-8">
1139
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
1140
+ <title>Not Found - Artifact Portal</title>
1141
+ <style>${this._sharedCss()}
1142
+ .empty { max-width: var(--layout-width); margin: 80px auto; text-align: center;
1143
+ color: var(--text-muted); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: 15px; }
1144
+ .empty a { color: var(--accent); }
1145
+ </style>
1146
+ </head>
1147
+ <body>
1148
+ <div class="header">
1149
+ <div class="header-inner">
1150
+ <h1><a href="/">Artifact Portal</a></h1>
1151
+ </div>
1152
+ </div>
1153
+ <div class="empty">
1154
+ <p>Page not found.</p>
1155
+ <p style="margin-top: 12px;"><a href="/">Back to Portal</a></p>
1156
+ </div>
1157
+ </body>
1158
+ </html>`;
1159
+ }
1160
+
1161
+ // ─── Utilities ─────────────────────────────────────────────────────
1162
+
1163
+ _safeReadFile(filePath) {
1164
+ try {
1165
+ return fs.readFileSync(filePath, "utf-8");
1166
+ } catch {
1167
+ return null;
1168
+ }
1169
+ }
1170
+
1171
+ _escHtml(str) {
1172
+ if (!str) return "";
1173
+ return String(str)
1174
+ .replace(/&/g, "&amp;")
1175
+ .replace(/</g, "&lt;")
1176
+ .replace(/>/g, "&gt;")
1177
+ .replace(/"/g, "&quot;");
1178
+ }
1179
+
1180
+ _escapeForTemplate(content) {
1181
+ if (!content) return "";
1182
+ return content.replace(/<\/script>/gi, "<\\/script>");
1183
+ }
1184
+ }