@specship/specship-win32-arm64 0.11.9 → 0.12.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/lib/dist/bin/specship.js +9 -60
- package/lib/dist/bin/specship.js.map +1 -1
- package/lib/dist/reflect/index.d.ts +1 -1
- package/lib/dist/reflect/index.d.ts.map +1 -1
- package/lib/dist/reflect/index.js +2 -1
- package/lib/dist/reflect/index.js.map +1 -1
- package/lib/dist/reflect/miner.d.ts +8 -0
- package/lib/dist/reflect/miner.d.ts.map +1 -1
- package/lib/dist/reflect/miner.js +64 -36
- package/lib/dist/reflect/miner.js.map +1 -1
- package/lib/dist/server/cli.js +5 -49
- package/lib/dist/server/routes/claude.js +13 -5
- package/lib/dist/server/server.js +17 -2
- package/lib/dist/server/ssr/md.mjs +83 -0
- package/lib/dist/server/ssr/render.mjs +564 -0
- package/lib/dist/server/ssr/routes.js +73 -0
- package/lib/dist/server/static-handler.js +30 -0
- package/lib/node_modules/.package-lock.json +1 -1
- package/lib/package.json +3 -3
- package/package.json +1 -1
- package/lib/dist/web/chunk-2AJCHB7P.js +0 -1
- package/lib/dist/web/chunk-2CPLUFCH.js +0 -2
- package/lib/dist/web/chunk-2I7L37NS.js +0 -1
- package/lib/dist/web/chunk-2IVSOVQZ.js +0 -1
- package/lib/dist/web/chunk-2NAWAJB5.js +0 -1
- package/lib/dist/web/chunk-2OJBIPE4.js +0 -1
- package/lib/dist/web/chunk-3E2WB6D5.js +0 -1
- package/lib/dist/web/chunk-3EBFYSCH.js +0 -2
- package/lib/dist/web/chunk-3QCQ4BXS.js +0 -1
- package/lib/dist/web/chunk-42XVAQ6I.js +0 -1
- package/lib/dist/web/chunk-4G7XU55L.js +0 -1
- package/lib/dist/web/chunk-4IMMPEYM.js +0 -1
- package/lib/dist/web/chunk-4TJQJPCZ.js +0 -1
- package/lib/dist/web/chunk-4WZIHTPC.js +0 -1
- package/lib/dist/web/chunk-4YVSYOSD.js +0 -1
- package/lib/dist/web/chunk-5BQIOYKW.js +0 -1
- package/lib/dist/web/chunk-5HGWHUJA.js +0 -1
- package/lib/dist/web/chunk-5NTGUSXJ.js +0 -1
- package/lib/dist/web/chunk-5Y244R4G.js +0 -1
- package/lib/dist/web/chunk-6RRDPT5Z.js +0 -1
- package/lib/dist/web/chunk-6VKB2ZWM.js +0 -1
- package/lib/dist/web/chunk-7656MA24.js +0 -1
- package/lib/dist/web/chunk-7DMFVTU4.js +0 -1
- package/lib/dist/web/chunk-7KWBINA7.js +0 -1
- package/lib/dist/web/chunk-7SMPKVEP.js +0 -1
- package/lib/dist/web/chunk-AHI5XEVX.js +0 -1
- package/lib/dist/web/chunk-AS2GV63A.js +0 -1
- package/lib/dist/web/chunk-AZJVTPLU.js +0 -1
- package/lib/dist/web/chunk-AZUUBUEJ.js +0 -1
- package/lib/dist/web/chunk-BLBRMCN2.js +0 -1
- package/lib/dist/web/chunk-BMIAXD2V.js +0 -2
- package/lib/dist/web/chunk-BPCJLNBS.js +0 -47
- package/lib/dist/web/chunk-BUXWEHIY.js +0 -1
- package/lib/dist/web/chunk-CGVC666W.js +0 -1
- package/lib/dist/web/chunk-DLQPZWSI.css +0 -1
- package/lib/dist/web/chunk-DOKZOFKM.js +0 -1
- package/lib/dist/web/chunk-DTWIJ3GJ.js +0 -1
- package/lib/dist/web/chunk-DYRFLPJA.js +0 -1
- package/lib/dist/web/chunk-E3J3CXR5.js +0 -1
- package/lib/dist/web/chunk-E73OX2P7.js +0 -1
- package/lib/dist/web/chunk-EAXRKDLV.js +0 -1
- package/lib/dist/web/chunk-EBKKDHYI.js +0 -1
- package/lib/dist/web/chunk-EE7V7Q5P.js +0 -1
- package/lib/dist/web/chunk-EKY2FUHU.js +0 -1
- package/lib/dist/web/chunk-EP6XOPXH.js +0 -1
- package/lib/dist/web/chunk-ESGDLJOJ.js +0 -1
- package/lib/dist/web/chunk-ETJG7NCY.js +0 -1
- package/lib/dist/web/chunk-EUUEFEDI.js +0 -1
- package/lib/dist/web/chunk-EXBUKHH2.js +0 -17
- package/lib/dist/web/chunk-FC3R7YHR.js +0 -4
- package/lib/dist/web/chunk-FGNZDHTL.js +0 -11
- package/lib/dist/web/chunk-FIJW2UNJ.js +0 -1
- package/lib/dist/web/chunk-FMV5PXRC.js +0 -5
- package/lib/dist/web/chunk-G7RXFHYO.js +0 -1
- package/lib/dist/web/chunk-G7VZT5KB.js +0 -3
- package/lib/dist/web/chunk-GRZYXPSO.js +0 -7
- package/lib/dist/web/chunk-GYGPS3AN.js +0 -1
- package/lib/dist/web/chunk-H7AF7YS4.js +0 -1
- package/lib/dist/web/chunk-HDZDQILN.js +0 -1
- package/lib/dist/web/chunk-HMK6UO6N.js +0 -1
- package/lib/dist/web/chunk-IHEE5NYJ.js +0 -1
- package/lib/dist/web/chunk-IOB4JJT4.js +0 -1
- package/lib/dist/web/chunk-ISNEBICW.js +0 -1
- package/lib/dist/web/chunk-J2GZVLHH.js +0 -1
- package/lib/dist/web/chunk-JGDK6YKG.js +0 -1
- package/lib/dist/web/chunk-JLOJCDIY.js +0 -1
- package/lib/dist/web/chunk-JTFXTIPE.js +0 -903
- package/lib/dist/web/chunk-K7UD24Z4.js +0 -1
- package/lib/dist/web/chunk-KYSTCHAH.js +0 -1
- package/lib/dist/web/chunk-LB6JPLX2.js +0 -1
- package/lib/dist/web/chunk-LNSVDHCI.js +0 -1
- package/lib/dist/web/chunk-LVGIY3SO.js +0 -1
- package/lib/dist/web/chunk-LXLHIHEN.js +0 -1
- package/lib/dist/web/chunk-M42QOYAE.js +0 -1
- package/lib/dist/web/chunk-ML5ULGHY.js +0 -1
- package/lib/dist/web/chunk-MNFTHCSJ.js +0 -1
- package/lib/dist/web/chunk-N6SS4G6S.js +0 -1
- package/lib/dist/web/chunk-NKPK3BR3.js +0 -1
- package/lib/dist/web/chunk-NTBJG6SJ.js +0 -1
- package/lib/dist/web/chunk-NUDB3Q2Y.js +0 -3
- package/lib/dist/web/chunk-OTQR5VFM.js +0 -1
- package/lib/dist/web/chunk-OXEF5E3E.js +0 -1
- package/lib/dist/web/chunk-P62EJGSF.js +0 -1
- package/lib/dist/web/chunk-PCW6R7OP.js +0 -1
- package/lib/dist/web/chunk-PGGJPDJG.js +0 -1
- package/lib/dist/web/chunk-POCVU4VK.js +0 -2
- package/lib/dist/web/chunk-PS6HRYQQ.js +0 -3
- package/lib/dist/web/chunk-PUYSJNJR.js +0 -1
- package/lib/dist/web/chunk-PZSU53SS.js +0 -1
- package/lib/dist/web/chunk-Q2RVFS45.js +0 -1
- package/lib/dist/web/chunk-Q7L6LLAK.js +0 -1
- package/lib/dist/web/chunk-QCMKJIWY.js +0 -1
- package/lib/dist/web/chunk-QH6CF3M3.js +0 -1
- package/lib/dist/web/chunk-QIZ65CGB.js +0 -1
- package/lib/dist/web/chunk-QPA2REUE.js +0 -1
- package/lib/dist/web/chunk-QQ5LD7PI.js +0 -1
- package/lib/dist/web/chunk-QR6L3KAC.js +0 -1
- package/lib/dist/web/chunk-R2DLK4HO.js +0 -1
- package/lib/dist/web/chunk-RD6TVPOT.js +0 -1
- package/lib/dist/web/chunk-RKY4EJYJ.js +0 -1
- package/lib/dist/web/chunk-RONYWVY7.js +0 -1
- package/lib/dist/web/chunk-RXKXYF2C.js +0 -1
- package/lib/dist/web/chunk-S5J5WQXB.js +0 -6
- package/lib/dist/web/chunk-SQPYSOV6.js +0 -1
- package/lib/dist/web/chunk-SWKJRNYY.js +0 -1
- package/lib/dist/web/chunk-SYGIPC2P.js +0 -4
- package/lib/dist/web/chunk-T7AZ65JP.js +0 -1
- package/lib/dist/web/chunk-TCZDVOHD.js +0 -1
- package/lib/dist/web/chunk-TPTITA3V.js +0 -1
- package/lib/dist/web/chunk-TR335633.js +0 -1
- package/lib/dist/web/chunk-TWVVIWKO.js +0 -1
- package/lib/dist/web/chunk-U67LRNER.js +0 -1
- package/lib/dist/web/chunk-UR5KDXPX.js +0 -1
- package/lib/dist/web/chunk-UR6O2GEH.js +0 -1
- package/lib/dist/web/chunk-USVLLFVR.js +0 -1
- package/lib/dist/web/chunk-UTNMGWTP.js +0 -1
- package/lib/dist/web/chunk-V74ZPP4B.js +0 -1
- package/lib/dist/web/chunk-VECWMHJP.js +0 -1
- package/lib/dist/web/chunk-VUACT35R.js +0 -3
- package/lib/dist/web/chunk-VZI7H4SZ.js +0 -1
- package/lib/dist/web/chunk-W5TEIONI.js +0 -2
- package/lib/dist/web/chunk-WB6YHOD4.js +0 -1
- package/lib/dist/web/chunk-WBJ5VEB5.js +0 -1
- package/lib/dist/web/chunk-WBT64AWV.js +0 -1
- package/lib/dist/web/chunk-WFXJIXZE.js +0 -4
- package/lib/dist/web/chunk-WTGYRH3Z.js +0 -298
- package/lib/dist/web/chunk-WXTCVDTP.js +0 -1
- package/lib/dist/web/chunk-XAUQEKZX.js +0 -1
- package/lib/dist/web/chunk-XCDHWLVH.js +0 -1
- package/lib/dist/web/chunk-XKG5X37F.js +0 -1
- package/lib/dist/web/chunk-Y3H6FFUZ.js +0 -1
- package/lib/dist/web/chunk-Y4F5ULGJ.js +0 -1
- package/lib/dist/web/chunk-YEGKAAEE.js +0 -1
- package/lib/dist/web/chunk-YM2KU57F.js +0 -1
- package/lib/dist/web/chunk-YRERBP6T.js +0 -1
- package/lib/dist/web/chunk-YV5LB22R.js +0 -1
- package/lib/dist/web/chunk-YWIRU745.js +0 -1
- package/lib/dist/web/chunk-ZLV4VCDG.js +0 -3
- package/lib/dist/web/chunk-ZTVI5KFF.js +0 -1
- package/lib/dist/web/favicon-16.png +0 -0
- package/lib/dist/web/favicon-180.png +0 -0
- package/lib/dist/web/favicon-32.png +0 -0
- package/lib/dist/web/favicon-512.png +0 -0
- package/lib/dist/web/favicon-small.svg +0 -15
- package/lib/dist/web/favicon.ico +0 -0
- package/lib/dist/web/favicon.svg +0 -20
- package/lib/dist/web/icon-192.png +0 -0
- package/lib/dist/web/icon-512.png +0 -0
- package/lib/dist/web/index.html +0 -146
- package/lib/dist/web/main-ESADRXN2.css +0 -1
- package/lib/dist/web/main-JT2RR3JS.js +0 -1
- package/lib/dist/web/manifest.webmanifest +0 -15
- package/lib/dist/web/media/codicon-LN6W7LCM.ttf +0 -0
- package/lib/dist/web/sw.js +0 -69
- /package/lib/dist/{web/styles-ATJUD7TT.css → server/ssr/public/app.css} +0 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal, dependency-free Markdown → HTML for the SSR dashboard.
|
|
3
|
+
*
|
|
4
|
+
* Scope is deliberately small: the subset spec bodies actually use — headings,
|
|
5
|
+
* bold/italic/inline-code, fenced code, unordered/ordered lists, and paragraphs.
|
|
6
|
+
* Everything is HTML-escaped first, so rendering is safe for untrusted spec
|
|
7
|
+
* text (no raw HTML passthrough). This is the SSR counterpart to the Angular
|
|
8
|
+
* app's `render-md.ts`; keeping it dependency-free is what keeps the web-ssr
|
|
9
|
+
* tree under the REQ-DASHLEAN-002 package ceiling.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export function escapeHtml(s) {
|
|
13
|
+
return String(s)
|
|
14
|
+
.replace(/&/g, '&')
|
|
15
|
+
.replace(/</g, '<')
|
|
16
|
+
.replace(/>/g, '>')
|
|
17
|
+
.replace(/"/g, '"')
|
|
18
|
+
.replace(/'/g, ''');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Strip embedded `<!-- id: REQ-X -->` markers — structural noise, not prose. */
|
|
22
|
+
export function stripSpecMarkers(s) {
|
|
23
|
+
return String(s).replace(/<!--[\s\S]*?-->/g, '');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function inline(s) {
|
|
27
|
+
// Operate on already-escaped text so the markup we add is the only HTML.
|
|
28
|
+
return s
|
|
29
|
+
.replace(/`([^`]+)`/g, (_, c) => `<code>${c}</code>`)
|
|
30
|
+
.replace(/\*\*([^*]+)\*\*/g, (_, c) => `<strong>${c}</strong>`)
|
|
31
|
+
.replace(/(^|[^*])\*([^*]+)\*/g, (_, p, c) => `${p}<em>${c}</em>`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function renderMd(src) {
|
|
35
|
+
const lines = escapeHtml(stripSpecMarkers(src ?? '')).split('\n');
|
|
36
|
+
const out = [];
|
|
37
|
+
let i = 0;
|
|
38
|
+
let para = [];
|
|
39
|
+
const flushPara = () => {
|
|
40
|
+
if (para.length) { out.push(`<p>${inline(para.join(' ')).trim()}</p>`); para = []; }
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
while (i < lines.length) {
|
|
44
|
+
const line = lines[i];
|
|
45
|
+
|
|
46
|
+
// fenced code block
|
|
47
|
+
const fence = line.match(/^```(.*)$/);
|
|
48
|
+
if (fence) {
|
|
49
|
+
flushPara();
|
|
50
|
+
const body = [];
|
|
51
|
+
i++;
|
|
52
|
+
while (i < lines.length && !/^```/.test(lines[i])) { body.push(lines[i]); i++; }
|
|
53
|
+
i++; // closing fence
|
|
54
|
+
out.push(`<pre><code>${body.join('\n')}</code></pre>`);
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// heading
|
|
59
|
+
const h = line.match(/^(#{1,6})\s+(.*)$/);
|
|
60
|
+
if (h) { flushPara(); const lvl = h[1].length; out.push(`<h${lvl}>${inline(h[2].trim())}</h${lvl}>`); i++; continue; }
|
|
61
|
+
|
|
62
|
+
// list (consume a run of bullet / ordered lines)
|
|
63
|
+
if (/^\s*([-*]|\d+\.)\s+/.test(line)) {
|
|
64
|
+
flushPara();
|
|
65
|
+
const ordered = /^\s*\d+\.\s+/.test(line);
|
|
66
|
+
const items = [];
|
|
67
|
+
while (i < lines.length && /^\s*([-*]|\d+\.)\s+/.test(lines[i])) {
|
|
68
|
+
items.push(`<li>${inline(lines[i].replace(/^\s*([-*]|\d+\.)\s+/, '').trim())}</li>`);
|
|
69
|
+
i++;
|
|
70
|
+
}
|
|
71
|
+
out.push(ordered ? `<ol>${items.join('')}</ol>` : `<ul>${items.join('')}</ul>`);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// blank line ends a paragraph
|
|
76
|
+
if (/^\s*$/.test(line)) { flushPara(); i++; continue; }
|
|
77
|
+
|
|
78
|
+
para.push(line.trim());
|
|
79
|
+
i++;
|
|
80
|
+
}
|
|
81
|
+
flushPara();
|
|
82
|
+
return out.join('\n');
|
|
83
|
+
}
|
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Server-side render functions for the lean SSR dashboard (REQ-DASHLEAN-004).
|
|
3
|
+
*
|
|
4
|
+
* Pure functions: (data) -> HTML string. No client framework, no hydration —
|
|
5
|
+
* the page's content is fully present in the returned HTML (REQ-DASHLEAN-004.A1),
|
|
6
|
+
* and the only client JS is the small `islands.js` enhancement layer
|
|
7
|
+
* (REQ-DASHLEAN-004.A2). Uses the shared global design tokens/classes from
|
|
8
|
+
* `public/app.css` so the look matches the existing dashboard (REQ-DASHLEAN-005).
|
|
9
|
+
*/
|
|
10
|
+
import { escapeHtml, stripSpecMarkers, renderMd } from './md.mjs';
|
|
11
|
+
|
|
12
|
+
/** The kept read-only page set (REQ-DASHLEAN-006). Dropped routes are absent. */
|
|
13
|
+
export const NAV = [
|
|
14
|
+
{ href: '/dashboard', label: 'Dashboard' },
|
|
15
|
+
{ href: '/specs', label: 'Specs' },
|
|
16
|
+
{ href: '/graph', label: 'Graph' },
|
|
17
|
+
{ href: '/drift', label: 'Drift' },
|
|
18
|
+
{ href: '/specship-impact', label: 'Impact' },
|
|
19
|
+
{ href: '/maintainability', label: 'Maintainability' },
|
|
20
|
+
{ href: '/domain', label: 'Domain' },
|
|
21
|
+
{ href: '/mcp', label: 'MCP' },
|
|
22
|
+
{ href: '/memory', label: 'Memory' },
|
|
23
|
+
{ href: '/improvements', label: 'Improvements' },
|
|
24
|
+
{ href: '/compare', label: 'Compare' },
|
|
25
|
+
{ href: '/costs', label: 'Costs' },
|
|
26
|
+
{ href: '/heatmap', label: 'Heatmap' },
|
|
27
|
+
{ href: '/runs', label: 'Runs' },
|
|
28
|
+
{ href: '/sessions', label: 'Sessions' },
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** The full HTML document: sidebar + top bar + page slot. */
|
|
32
|
+
export function layout({ title, activeHref, body }) {
|
|
33
|
+
const nav = NAV.map(
|
|
34
|
+
(n) =>
|
|
35
|
+
`<a class="nav-link${n.href === activeHref ? ' active' : ''}" href="${n.href}" data-nav>${escapeHtml(n.label)}</a>`,
|
|
36
|
+
).join('');
|
|
37
|
+
return `<!doctype html>
|
|
38
|
+
<html lang="en" data-theme="dark">
|
|
39
|
+
<head>
|
|
40
|
+
<meta charset="utf-8">
|
|
41
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
42
|
+
<title>${escapeHtml(title)} · SpecShip</title>
|
|
43
|
+
<link rel="stylesheet" href="/app.css">
|
|
44
|
+
<style>
|
|
45
|
+
.shell{display:flex;min-height:100vh;background:var(--bg-canvas);color:var(--text-primary)}
|
|
46
|
+
.sidebar{width:200px;flex-shrink:0;border-right:1px solid var(--border-subtle,rgba(255,255,255,.08));padding:16px 10px;display:flex;flex-direction:column;gap:2px}
|
|
47
|
+
.brand{font-weight:700;font-size:14px;padding:6px 10px 12px}
|
|
48
|
+
.nav-link{display:block;padding:7px 10px;border-radius:7px;color:var(--text-muted,#9aa);text-decoration:none;font-size:13px}
|
|
49
|
+
.nav-link:hover{background:rgba(255,255,255,.05);color:var(--text-primary)}
|
|
50
|
+
.nav-link.active{background:rgba(255,255,255,.08);color:var(--accent);font-weight:600}
|
|
51
|
+
.main{flex:1;min-width:0;display:flex;flex-direction:column}
|
|
52
|
+
.topbar{height:48px;border-bottom:1px solid var(--border-subtle,rgba(255,255,255,.08));display:flex;align-items:center;padding:0 20px;font-size:12px;color:var(--text-muted,#9aa)}
|
|
53
|
+
.page{padding:22px;overflow:auto}
|
|
54
|
+
.ro-badge{margin-left:auto;font-size:11px;color:var(--text-muted,#9aa);border:1px solid var(--border-subtle,rgba(255,255,255,.12));padding:2px 8px;border-radius:999px}
|
|
55
|
+
</style>
|
|
56
|
+
</head>
|
|
57
|
+
<body>
|
|
58
|
+
<div class="shell">
|
|
59
|
+
<nav class="sidebar" aria-label="Primary">
|
|
60
|
+
<div class="brand">SpecShip</div>
|
|
61
|
+
${nav}
|
|
62
|
+
</nav>
|
|
63
|
+
<div class="main">
|
|
64
|
+
<header class="topbar"><span>${escapeHtml(title)}</span><span class="ro-badge" title="This dashboard is read-only">read-only</span></header>
|
|
65
|
+
<main class="page" id="page">${body}</main>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
<script type="module" src="/islands.js"></script>
|
|
69
|
+
</body>
|
|
70
|
+
</html>`;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stateDotColor(state) {
|
|
74
|
+
const map = {
|
|
75
|
+
verified: 'var(--state-verified,#3fb950)', implemented: 'var(--state-implemented,#58a6ff)',
|
|
76
|
+
drifted: 'var(--state-drifted,#d29922)', broken: 'var(--state-broken,#f85149)',
|
|
77
|
+
orphaned: 'var(--state-orphaned,#f85149)',
|
|
78
|
+
};
|
|
79
|
+
return map[state] || 'var(--text-muted,#9aa)';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** REQ-DASHLEAN-006: the Specs list, grouped by source file. */
|
|
83
|
+
export function renderSpecs(specs) {
|
|
84
|
+
const real = (specs || []).filter((s) => s.kind !== 'brief');
|
|
85
|
+
if (!real.length) {
|
|
86
|
+
return `<div class="page-head"><h1>Specs</h1></div><p class="muted">No specs indexed yet. Run <code class="mono">specship sync</code>.</p>`;
|
|
87
|
+
}
|
|
88
|
+
const groups = new Map();
|
|
89
|
+
for (const s of real) {
|
|
90
|
+
const key = s.sourcePath || '(unknown)';
|
|
91
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
92
|
+
groups.get(key).push(s);
|
|
93
|
+
}
|
|
94
|
+
const sections = [...groups.entries()].map(([path, rows]) => {
|
|
95
|
+
const items = rows
|
|
96
|
+
.map(
|
|
97
|
+
(s) => `<a class="req-row" href="/specs/${encodeURIComponent(s.id)}" data-nav>
|
|
98
|
+
<span class="pill-dot" style="background:${stateDotColor(s.linkState)}"></span>
|
|
99
|
+
<span class="grow" style="min-width:0">
|
|
100
|
+
<span class="mono req-id">${escapeHtml(s.id)}</span>
|
|
101
|
+
<span class="muted req-title">${escapeHtml(stripSpecMarkers(s.title || '').trim())}</span>
|
|
102
|
+
</span>
|
|
103
|
+
</a>`,
|
|
104
|
+
)
|
|
105
|
+
.join('');
|
|
106
|
+
return `<section class="spec-group"><h2 class="group-title mono">${escapeHtml(path.replace(/^specs\//, ''))}</h2>${items}</section>`;
|
|
107
|
+
});
|
|
108
|
+
return `<div class="page-head"><h1>Specs</h1><span class="muted">${real.length} requirements</span></div>
|
|
109
|
+
<div class="spec-tree">${sections.join('')}</div>
|
|
110
|
+
<style>
|
|
111
|
+
.page-head{display:flex;align-items:baseline;gap:10px;margin-bottom:16px}
|
|
112
|
+
.page-head h1{font-size:18px;margin:0}
|
|
113
|
+
.spec-group{margin-bottom:18px}
|
|
114
|
+
.group-title{font-size:11px;color:var(--text-muted,#9aa);letter-spacing:.5px;margin:0 0 6px}
|
|
115
|
+
.req-row{display:flex;gap:8px;align-items:center;padding:7px 8px;border-radius:7px;text-decoration:none}
|
|
116
|
+
.req-row:hover{background:rgba(255,255,255,.05)}
|
|
117
|
+
.pill-dot{width:8px;height:8px;border-radius:999px;flex-shrink:0}
|
|
118
|
+
.req-id{font-size:12px;color:var(--node-spec,#a371f7);margin-right:8px}
|
|
119
|
+
.req-title{font-size:12.5px}
|
|
120
|
+
</style>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** REQ-DASHLEAN-003 + 006: read-only spec detail — rendered markdown, no editor. */
|
|
124
|
+
export function renderSpecDetail(detail) {
|
|
125
|
+
const spec = detail?.spec || detail;
|
|
126
|
+
if (!spec) return `<div class="page-head"><h1>Spec not found</h1></div><p><a href="/specs" data-nav>← Back to specs</a></p>`;
|
|
127
|
+
const links = detail?.links || [];
|
|
128
|
+
const bodyHtml = renderMd(spec.body || '');
|
|
129
|
+
const linkRows = links.length
|
|
130
|
+
? `<h2>Linked code</h2><ul class="links">${links
|
|
131
|
+
.map((l) => `<li><span class="pill-dot" style="background:${stateDotColor(l.state)}"></span> <span class="mono">${escapeHtml(l.targetName || l.target || '')}</span> <span class="muted">${escapeHtml(l.state || '')}</span></li>`)
|
|
132
|
+
.join('')}</ul>`
|
|
133
|
+
: '';
|
|
134
|
+
return `<div class="page-head">
|
|
135
|
+
<a class="muted back" href="/specs" data-nav>← Specs</a>
|
|
136
|
+
<span class="mono" style="color:var(--node-spec,#a371f7)">${escapeHtml(spec.id)}</span>
|
|
137
|
+
<span class="pill">${escapeHtml(spec.kind || '')}</span>
|
|
138
|
+
${spec.priority ? `<span class="pill">${escapeHtml(spec.priority)}</span>` : ''}
|
|
139
|
+
</div>
|
|
140
|
+
<h1 class="spec-title">${escapeHtml(stripSpecMarkers(spec.title || '').trim())}</h1>
|
|
141
|
+
<article class="spec-body">${bodyHtml}</article>
|
|
142
|
+
${linkRows}
|
|
143
|
+
<div class="actions"><a class="btn" href="/graph?focus=spec:${encodeURIComponent(spec.id)}" data-nav>Show in graph</a></div>
|
|
144
|
+
<style>
|
|
145
|
+
.page-head{display:flex;align-items:center;gap:10px;margin-bottom:14px;flex-wrap:wrap}
|
|
146
|
+
.page-head .back{text-decoration:none;font-size:12px}
|
|
147
|
+
.spec-title{font-size:19px;margin:0 0 14px;max-width:820px}
|
|
148
|
+
.pill{font-size:11px;border:1px solid var(--border-subtle,rgba(255,255,255,.15));padding:1px 8px;border-radius:999px;color:var(--text-muted,#9aa)}
|
|
149
|
+
.spec-body{max-width:820px;line-height:1.6}
|
|
150
|
+
.spec-body h1{font-size:20px}.spec-body h2{font-size:15px;margin-top:20px}
|
|
151
|
+
.spec-body code{background:var(--bg-elevated,rgba(255,255,255,.06));padding:1px 5px;border-radius:5px;font-size:12.5px}
|
|
152
|
+
.spec-body pre{background:var(--bg-elevated,rgba(255,255,255,.06));padding:12px;border-radius:8px;overflow:auto}
|
|
153
|
+
.links{list-style:none;padding:0}.links li{display:flex;align-items:center;gap:8px;padding:4px 0}
|
|
154
|
+
.actions{margin-top:20px}
|
|
155
|
+
.btn{display:inline-block;padding:6px 12px;border-radius:7px;border:1px solid var(--border-subtle,rgba(255,255,255,.15));color:var(--text-primary);text-decoration:none;font-size:12.5px}
|
|
156
|
+
</style>`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- shared building blocks -------------------------------------------------
|
|
160
|
+
|
|
161
|
+
function pageHead(title, meta) {
|
|
162
|
+
return `<div class="page-head"><h1>${escapeHtml(title)}</h1>${meta ? `<span class="muted">${escapeHtml(meta)}</span>` : ''}</div>`;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** A grid of KPI tiles: [{ label, value, sub? }]. */
|
|
166
|
+
function statTiles(tiles) {
|
|
167
|
+
const cells = tiles
|
|
168
|
+
.map(
|
|
169
|
+
(t) => `<div class="stat-tile">
|
|
170
|
+
<div class="stat-value">${escapeHtml(String(t.value))}</div>
|
|
171
|
+
<div class="stat-label">${escapeHtml(t.label)}</div>
|
|
172
|
+
${t.sub ? `<div class="stat-sub muted">${escapeHtml(t.sub)}</div>` : ''}
|
|
173
|
+
</div>`,
|
|
174
|
+
)
|
|
175
|
+
.join('');
|
|
176
|
+
return `<div class="stat-grid">${cells}</div>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** A simple read-only table: cols = [labels], rows = [[cell,...]] (cells are pre-escaped HTML). */
|
|
180
|
+
function table(cols, rows) {
|
|
181
|
+
if (!rows.length) return `<p class="muted">Nothing to show.</p>`;
|
|
182
|
+
const head = cols.map((c) => `<th>${escapeHtml(c)}</th>`).join('');
|
|
183
|
+
const body = rows.map((r) => `<tr>${r.map((c) => `<td>${c}</td>`).join('')}</tr>`).join('');
|
|
184
|
+
return `<table class="ro-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table>`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const SHARED_CSS = `<style>
|
|
188
|
+
.page-head{display:flex;align-items:baseline;gap:10px;margin-bottom:16px}
|
|
189
|
+
.page-head h1{font-size:18px;margin:0}
|
|
190
|
+
.stat-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-bottom:22px}
|
|
191
|
+
.stat-tile{border:1px solid var(--border-subtle,rgba(255,255,255,.1));border-radius:10px;padding:14px 16px;background:var(--bg-elevated,rgba(255,255,255,.03))}
|
|
192
|
+
.stat-value{font-size:22px;font-weight:700}
|
|
193
|
+
.stat-label{font-size:12px;color:var(--text-muted,#9aa);margin-top:2px}
|
|
194
|
+
.stat-sub{font-size:11px;margin-top:4px}
|
|
195
|
+
.ro-table{width:100%;border-collapse:collapse;font-size:12.5px}
|
|
196
|
+
.ro-table th{text-align:left;color:var(--text-muted,#9aa);font-weight:600;padding:6px 10px;border-bottom:1px solid var(--border-subtle,rgba(255,255,255,.1))}
|
|
197
|
+
.ro-table td{padding:6px 10px;border-bottom:1px solid var(--border-subtle,rgba(255,255,255,.05))}
|
|
198
|
+
.pill-dot{display:inline-block;width:8px;height:8px;border-radius:999px;margin-right:6px;vertical-align:middle}
|
|
199
|
+
.sec-title{font-size:13px;margin:22px 0 8px}
|
|
200
|
+
</style>`;
|
|
201
|
+
|
|
202
|
+
// --- ported pages -----------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/** REQ-DASHLEAN-006: the landing dashboard — index + Claude Code KPIs. */
|
|
205
|
+
export function renderDashboard(status, stats) {
|
|
206
|
+
const s = status || {};
|
|
207
|
+
const st = stats || {};
|
|
208
|
+
const cost = st.lastSessionCost?.value;
|
|
209
|
+
const tiles = statTiles([
|
|
210
|
+
{ label: 'Nodes', value: (s.nodeCount ?? 0).toLocaleString?.() ?? s.nodeCount ?? 0 },
|
|
211
|
+
{ label: 'Edges', value: (s.edgeCount ?? 0).toLocaleString?.() ?? s.edgeCount ?? 0 },
|
|
212
|
+
{ label: 'Files', value: (s.fileCount ?? 0).toLocaleString?.() ?? s.fileCount ?? 0 },
|
|
213
|
+
{ label: 'Drifted links', value: s.drift ?? 0 },
|
|
214
|
+
{ label: 'Last session cost', value: cost != null ? '$' + Number(cost).toFixed(2) : '—' },
|
|
215
|
+
{ label: 'Tool calls', value: st.toolCalls?.value != null ? Number(st.toolCalls.value).toLocaleString() : '—' },
|
|
216
|
+
]);
|
|
217
|
+
const kinds = Object.entries(s.nodesByKind || {}).sort((a, b) => b[1] - a[1]);
|
|
218
|
+
const kindRows = kinds.map(([k, n]) => [escapeHtml(k), String(n)]);
|
|
219
|
+
return `${pageHead('Dashboard', s.backend ? 'backend: ' + s.backend : '')}
|
|
220
|
+
${tiles}
|
|
221
|
+
<h2 class="sec-title">Nodes by kind</h2>
|
|
222
|
+
${table(['Kind', 'Count'], kindRows)}
|
|
223
|
+
${SHARED_CSS}`;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** REQ-DASHLEAN-006: the drift queue — links whose code moved off their spec. */
|
|
227
|
+
export function renderDrift(drift) {
|
|
228
|
+
const links = (drift?.links || []).filter((l) => ['drifted', 'broken', 'orphaned'].includes(l.state));
|
|
229
|
+
const rows = links.map((l) => [
|
|
230
|
+
`<a class="mono" href="/specs/${encodeURIComponent(l.specId)}" data-nav style="color:var(--node-spec,#a371f7)">${escapeHtml(l.specId)}</a>`,
|
|
231
|
+
`<span class="mono">${escapeHtml(l.targetQualifiedName || '')}</span>`,
|
|
232
|
+
`<span class="muted">${escapeHtml(l.targetFilePath || '')}</span>`,
|
|
233
|
+
escapeHtml(l.kind || ''),
|
|
234
|
+
`<span class="pill-dot" style="background:${stateDotColor(l.state)}"></span>${escapeHtml(l.state || '')}`,
|
|
235
|
+
]);
|
|
236
|
+
return `${pageHead('Drift', `${links.length} out of alignment`)}
|
|
237
|
+
${table(['Spec', 'Target', 'File', 'Kind', 'State'], rows)}
|
|
238
|
+
${SHARED_CSS}`;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** REQ-DASHLEAN-006: maintainability — oversized symbols, god files, cycles, dead code. */
|
|
242
|
+
export function renderMaintainability(m) {
|
|
243
|
+
const d = m || {};
|
|
244
|
+
const tiles = statTiles([
|
|
245
|
+
{ label: 'Oversized', value: (d.oversized || []).length },
|
|
246
|
+
{ label: 'God files', value: (d.godFiles || []).length },
|
|
247
|
+
{ label: 'Cycles', value: (d.cycles || []).length },
|
|
248
|
+
{ label: 'Dead code', value: (d.deadCode || []).length },
|
|
249
|
+
]);
|
|
250
|
+
const oversized = table(
|
|
251
|
+
['Symbol', 'Lines', 'File'],
|
|
252
|
+
(d.oversized || []).slice(0, 50).map((o) => [
|
|
253
|
+
`<span class="mono">${escapeHtml(o.qualifiedName || o.name || '')}</span>`,
|
|
254
|
+
String(o.lines ?? ''),
|
|
255
|
+
`<span class="muted">${escapeHtml(o.filePath || '')}</span>`,
|
|
256
|
+
]),
|
|
257
|
+
);
|
|
258
|
+
const gods = table(
|
|
259
|
+
['File', 'Symbols'],
|
|
260
|
+
(d.godFiles || []).slice(0, 50).map((g) => [`<span class="mono">${escapeHtml(g.filePath || '')}</span>`, String(g.symbolCount ?? '')]),
|
|
261
|
+
);
|
|
262
|
+
return `${pageHead('Maintainability', d.clean ? 'clean' : '')}
|
|
263
|
+
${tiles}
|
|
264
|
+
<h2 class="sec-title">Oversized symbols</h2>${oversized}
|
|
265
|
+
<h2 class="sec-title">God files</h2>${gods}
|
|
266
|
+
${SHARED_CSS}`;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** REQ-DASHLEAN-006: domain knowledge — confirmed facts by type + coverage. */
|
|
270
|
+
export function renderDomain(d) {
|
|
271
|
+
const byType = d?.factsByType || {};
|
|
272
|
+
const cov = d?.coverage || {};
|
|
273
|
+
const tiles = statTiles([
|
|
274
|
+
{ label: 'Documented', value: cov.documented ?? 0 },
|
|
275
|
+
{ label: 'Gaps', value: cov.gaps ?? 0 },
|
|
276
|
+
...Object.entries(byType).map(([t, arr]) => ({ label: t, value: Array.isArray(arr) ? arr.length : arr })),
|
|
277
|
+
]);
|
|
278
|
+
const groups = Object.entries(byType)
|
|
279
|
+
.filter(([, arr]) => Array.isArray(arr) && arr.length)
|
|
280
|
+
.map(([t, arr]) => {
|
|
281
|
+
const rows = arr.slice(0, 40).map((f) => [
|
|
282
|
+
`<span class="mono">${escapeHtml(f.id || f.name || '')}</span>`,
|
|
283
|
+
`<span class="muted">${escapeHtml((f.title || f.body || '').slice(0, 120))}</span>`,
|
|
284
|
+
]);
|
|
285
|
+
return `<h2 class="sec-title">${escapeHtml(t)}</h2>${table(['Fact', 'Summary'], rows)}`;
|
|
286
|
+
})
|
|
287
|
+
.join('');
|
|
288
|
+
return `${pageHead('Domain', `${cov.documented ?? 0} documented · ${cov.gaps ?? 0} gaps`)}
|
|
289
|
+
${tiles}${groups}
|
|
290
|
+
${SHARED_CSS}`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** REQ-DASHLEAN-006: memory — CLAUDE.md instruction/note footprint. */
|
|
294
|
+
export function renderMemory(m) {
|
|
295
|
+
const d = m || {};
|
|
296
|
+
const tiles = statTiles([
|
|
297
|
+
{ label: 'Total tokens', value: (d.totalTokens ?? 0).toLocaleString?.() ?? d.totalTokens ?? 0 },
|
|
298
|
+
{ label: 'Instructions', value: d.instructionCount ?? 0 },
|
|
299
|
+
{ label: 'Notes', value: d.noteCount ?? 0 },
|
|
300
|
+
{ label: 'Imports', value: d.importCount ?? 0 },
|
|
301
|
+
]);
|
|
302
|
+
const rows = (d.files || []).map((f) => [
|
|
303
|
+
escapeHtml(f.name || ''),
|
|
304
|
+
escapeHtml(f.level || ''),
|
|
305
|
+
escapeHtml(f.type || ''),
|
|
306
|
+
String(f.tokens ?? ''),
|
|
307
|
+
`<span class="muted mono">${escapeHtml(f.path || '')}</span>`,
|
|
308
|
+
]);
|
|
309
|
+
return `${pageHead('Memory', `${(d.totalTokens ?? 0).toLocaleString?.() ?? d.totalTokens} tokens`)}
|
|
310
|
+
${tiles}
|
|
311
|
+
${table(['File', 'Level', 'Type', 'Tokens', 'Path'], rows)}
|
|
312
|
+
${SHARED_CSS}`;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** REQ-DASHLEAN-006: improvements — reflection proposals (memory rules, tips). */
|
|
316
|
+
export function renderImprovements(reflect) {
|
|
317
|
+
const proposals = reflect?.proposals || [];
|
|
318
|
+
const rows = proposals.slice(0, 100).map((p) => [
|
|
319
|
+
`<span class="pill-dot" style="background:${p.severity === 'high' ? 'var(--state-broken,#f85149)' : p.severity === 'medium' ? 'var(--state-drifted,#d29922)' : 'var(--text-muted,#9aa)'}"></span>${escapeHtml(p.severity || '')}`,
|
|
320
|
+
escapeHtml((p.type || '').replace(/_/g, ' ')),
|
|
321
|
+
`<strong>${escapeHtml(p.title || '')}</strong><br><span class="muted">${escapeHtml((p.body || '').slice(0, 160))}</span>`,
|
|
322
|
+
]);
|
|
323
|
+
return `${pageHead('Improvements', `${proposals.length} proposals`)}
|
|
324
|
+
${table(['Severity', 'Type', 'Suggestion'], rows)}
|
|
325
|
+
${SHARED_CSS}`;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** REQ-DASHLEAN-006: workflow runs — read-only run history. */
|
|
329
|
+
export function renderRuns(data) {
|
|
330
|
+
const runs = data?.runs || [];
|
|
331
|
+
const rows = runs.slice(0, 100).map((r) => [
|
|
332
|
+
`<a class="mono" href="/runs/${encodeURIComponent(r.id)}" data-nav>${escapeHtml(r.workflowName || '')}</a>`,
|
|
333
|
+
escapeHtml(r.status || ''),
|
|
334
|
+
`<span class="muted">${escapeHtml(String(r.startedAt || r.createdAt || ''))}</span>`,
|
|
335
|
+
]);
|
|
336
|
+
return `${pageHead('Runs', `${runs.length} runs`)}
|
|
337
|
+
${table(['Workflow', 'Status', 'Started'], rows)}
|
|
338
|
+
${SHARED_CSS}`;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** REQ-DASHLEAN-006: a single workflow run — status + its event log. */
|
|
342
|
+
export function renderRunDetail(data) {
|
|
343
|
+
const run = data?.run || {};
|
|
344
|
+
const events = data?.events || [];
|
|
345
|
+
const meta = statTiles([
|
|
346
|
+
{ label: 'Workflow', value: run.workflowName || '—' },
|
|
347
|
+
{ label: 'Status', value: run.status || '—' },
|
|
348
|
+
{ label: 'Events', value: events.length },
|
|
349
|
+
]);
|
|
350
|
+
const rows = events.slice(0, 200).map((e) => [
|
|
351
|
+
`<span class="muted">${escapeHtml(String(e.ts || e.timestamp || e.at || ''))}</span>`,
|
|
352
|
+
`<span class="mono">${escapeHtml(e.type || e.kind || e.event || '')}</span>`,
|
|
353
|
+
`<span class="muted">${escapeHtml(JSON.stringify(e.data ?? e.payload ?? '').slice(0, 100))}</span>`,
|
|
354
|
+
]);
|
|
355
|
+
return `${pageHead('Run')}
|
|
356
|
+
<p><a class="muted" href="/runs" data-nav>← Runs</a></p>
|
|
357
|
+
${meta}
|
|
358
|
+
<h2 class="sec-title">Events</h2>${table(['Time', 'Type', 'Detail'], rows)}
|
|
359
|
+
${SHARED_CSS}`;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** REQ-DASHLEAN-006: Claude Code sessions — read-only transcript index. */
|
|
363
|
+
export function renderSessions(data) {
|
|
364
|
+
const sessions = data?.sessions || [];
|
|
365
|
+
const rows = sessions.slice(0, 100).map((s) => [
|
|
366
|
+
`<a class="mono" href="/sessions/${encodeURIComponent(s.id)}" data-nav>${escapeHtml((s.project_path || '').replace(/^.*\//, '') || s.id)}</a>`,
|
|
367
|
+
`<span class="muted">${escapeHtml(String(s.started_at || ''))}</span>`,
|
|
368
|
+
String(s.prompt_count ?? ''),
|
|
369
|
+
s.total_cost_usd != null ? '$' + Number(s.total_cost_usd).toFixed(2) : '',
|
|
370
|
+
`<span class="mono">${escapeHtml(s.last_model || '')}</span>`,
|
|
371
|
+
]);
|
|
372
|
+
return `${pageHead('Sessions', `${sessions.length} sessions`)}
|
|
373
|
+
${table(['Project', 'Started', 'Prompts', 'Cost', 'Model'], rows)}
|
|
374
|
+
${SHARED_CSS}`;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** REQ-DASHLEAN-006: a single Claude Code session — totals + prompts + tool calls. */
|
|
378
|
+
export function renderSessionDetail(data) {
|
|
379
|
+
const s = data?.session || {};
|
|
380
|
+
const prompts = data?.prompts || [];
|
|
381
|
+
const toolCalls = data?.toolCalls || [];
|
|
382
|
+
const tiles = statTiles([
|
|
383
|
+
{ label: 'Prompts', value: s.prompt_count ?? prompts.length },
|
|
384
|
+
{ label: 'Tool calls', value: Array.isArray(toolCalls) ? toolCalls.length : toolCalls ?? 0 },
|
|
385
|
+
{ label: 'Cost', value: s.total_cost_usd != null ? '$' + Number(s.total_cost_usd).toFixed(2) : '—' },
|
|
386
|
+
{ label: 'Model', value: s.last_model || '—' },
|
|
387
|
+
]);
|
|
388
|
+
const rows = prompts.slice(0, 100).map((p) => [
|
|
389
|
+
`<span class="muted">${escapeHtml(String(p.timestamp || p.ts || ''))}</span>`,
|
|
390
|
+
`<span class="muted">${escapeHtml(String(p.text || p.content || '').replace(/\s+/g, ' ').slice(0, 140))}</span>`,
|
|
391
|
+
]);
|
|
392
|
+
return `${pageHead('Session')}
|
|
393
|
+
<p><a class="muted" href="/sessions" data-nav>← Sessions</a></p>
|
|
394
|
+
${tiles}
|
|
395
|
+
<h2 class="sec-title">Prompts</h2>${table(['Time', 'Prompt'], rows)}
|
|
396
|
+
${SHARED_CSS}`;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** REQ-DASHLEAN-006: MCP servers — connected servers and their tools. */
|
|
400
|
+
export function renderMcp(servers) {
|
|
401
|
+
const list = servers && servers.length ? servers : MCP_SEED;
|
|
402
|
+
const cards = list
|
|
403
|
+
.map((s) => {
|
|
404
|
+
const tools = (s.tools || [])
|
|
405
|
+
.map((t) => `<li><span class="mono">${escapeHtml(t.name || '')}</span> <span class="muted">${escapeHtml((t.desc || '').slice(0, 90))}</span></li>`)
|
|
406
|
+
.join('');
|
|
407
|
+
return `<div class="mcp-card">
|
|
408
|
+
<div class="mcp-head">
|
|
409
|
+
<span class="pill-dot" style="background:${s.state === 'running' ? 'var(--state-verified,#3fb950)' : 'var(--text-muted,#9aa)'}"></span>
|
|
410
|
+
<strong class="mono">${escapeHtml(s.name || '')}</strong>
|
|
411
|
+
<span class="pill">${escapeHtml(s.scope || '')}</span>
|
|
412
|
+
<span class="pill">${escapeHtml(s.state || '')}</span>
|
|
413
|
+
<span class="muted" style="margin-left:auto">${escapeHtml(s.transport || '')} · v${escapeHtml(s.version || '?')}</span>
|
|
414
|
+
</div>
|
|
415
|
+
<div class="muted mcp-desc">${escapeHtml(s.desc || '')}</div>
|
|
416
|
+
<ul class="mcp-tools">${tools}</ul>
|
|
417
|
+
</div>`;
|
|
418
|
+
})
|
|
419
|
+
.join('');
|
|
420
|
+
return `${pageHead('MCP', `${list.length} server${list.length === 1 ? '' : 's'}`)}
|
|
421
|
+
<div class="mcp-list">${cards}</div>
|
|
422
|
+
${SHARED_CSS}
|
|
423
|
+
<style>
|
|
424
|
+
.mcp-card{border:1px solid var(--border-subtle,rgba(255,255,255,.1));border-radius:10px;padding:14px 16px;margin-bottom:12px;background:var(--bg-elevated,rgba(255,255,255,.03))}
|
|
425
|
+
.mcp-head{display:flex;align-items:center;gap:8px;font-size:14px}
|
|
426
|
+
.mcp-desc{font-size:12.5px;margin:6px 0}
|
|
427
|
+
.mcp-tools{list-style:none;padding:0;margin:8px 0 0;font-size:12.5px}
|
|
428
|
+
.mcp-tools li{padding:3px 0}
|
|
429
|
+
</style>`;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Fallback MCP data — the SpecShip server (mirrors the Angular page's seed). */
|
|
433
|
+
const MCP_SEED = [
|
|
434
|
+
{
|
|
435
|
+
id: 'specship', name: 'specship', scope: 'project', state: 'running',
|
|
436
|
+
version: '0.11.9', transport: 'stdio',
|
|
437
|
+
desc: "SpecShip's own server — structural code intelligence over the spec↔code graph.",
|
|
438
|
+
tools: [
|
|
439
|
+
{ name: 'specship_explore', desc: 'Verbatim source of the relevant symbols, grouped by file — the structural alternative to re-reading.' },
|
|
440
|
+
{ name: 'specship_search', desc: 'Indexed structural search across the graph for definitions and references.' },
|
|
441
|
+
{ name: 'specship_node', desc: 'One symbol in full — signature, callers/callees trail, and body.' },
|
|
442
|
+
{ name: 'specship_spec', desc: 'Fetch a spec/requirement, its links and drift state, or the lifecycle funnel.' },
|
|
443
|
+
{ name: 'specship_impact', desc: 'What changing a symbol would break — its impact radius.' },
|
|
444
|
+
],
|
|
445
|
+
},
|
|
446
|
+
];
|
|
447
|
+
|
|
448
|
+
/** REQ-DASHLEAN-006: costs — Claude Code spend by model + top prompts. */
|
|
449
|
+
export function renderCosts(data) {
|
|
450
|
+
const d = data || {};
|
|
451
|
+
const tiles = statTiles([
|
|
452
|
+
{ label: 'Total spend', value: d.total != null ? '$' + Number(d.total).toFixed(2) : '—' },
|
|
453
|
+
{ label: 'WoW delta', value: d.wowDelta != null ? (d.wowDelta >= 0 ? '+' : '') + (Number(d.wowDelta) * 100).toFixed(0) + '%' : '—' },
|
|
454
|
+
]);
|
|
455
|
+
const byModel = table(
|
|
456
|
+
['Model', 'Prompts', 'Cost'],
|
|
457
|
+
(d.byModel || []).map((m) => [`<span class="mono">${escapeHtml(m.model || '')}</span>`, String(m.prompts ?? ''), '$' + Number(m.cost ?? 0).toFixed(2)]),
|
|
458
|
+
);
|
|
459
|
+
return `${pageHead('Costs')}
|
|
460
|
+
${tiles}
|
|
461
|
+
<h2 class="sec-title">By model</h2>${byModel}
|
|
462
|
+
${SHARED_CSS}`;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** REQ-DASHLEAN-006: SpecShip impact — tokens saved vs spent, by tool. */
|
|
466
|
+
export function renderImpact(data) {
|
|
467
|
+
const d = data || {};
|
|
468
|
+
const tiles = statTiles([
|
|
469
|
+
{ label: 'Tokens saved', value: (d.savedTokens ?? 0).toLocaleString() },
|
|
470
|
+
{ label: 'Cost saved', value: d.savedCostUsd != null ? '$' + Number(d.savedCostUsd).toFixed(2) : '—' },
|
|
471
|
+
{ label: 'Net tokens', value: (d.netTokens ?? 0).toLocaleString() },
|
|
472
|
+
{ label: 'SpecShip calls', value: (d.totalSpecshipCalls ?? 0).toLocaleString() },
|
|
473
|
+
]);
|
|
474
|
+
const byTool = table(
|
|
475
|
+
['Tool', 'Calls', 'Spend tok', 'Saved tok'],
|
|
476
|
+
(d.byTool || []).map((t) => [
|
|
477
|
+
`<span class="mono">${escapeHtml(t.tool || '')}</span>`,
|
|
478
|
+
String(t.calls ?? ''),
|
|
479
|
+
Number(t.spendTokens ?? 0).toLocaleString(),
|
|
480
|
+
Number(t.savedTokens ?? 0).toLocaleString(),
|
|
481
|
+
]),
|
|
482
|
+
);
|
|
483
|
+
return `${pageHead('SpecShip impact')}
|
|
484
|
+
${tiles}
|
|
485
|
+
<h2 class="sec-title">By tool</h2>${byTool}
|
|
486
|
+
${SHARED_CSS}`;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** REQ-DASHLEAN-006: compare — per-project Claude Code usage side by side. */
|
|
490
|
+
export function renderCompare(data) {
|
|
491
|
+
const projects = (data?.projects || []).slice().sort((a, b) => (b.cost ?? 0) - (a.cost ?? 0));
|
|
492
|
+
const rows = projects.slice(0, 100).map((p) => [
|
|
493
|
+
`<span class="mono">${escapeHtml((p.name || p.path || '').replace(/^.*\//, ''))}</span>`,
|
|
494
|
+
String(p.sessions ?? ''),
|
|
495
|
+
String(p.prompts ?? ''),
|
|
496
|
+
p.cost != null ? '$' + Number(p.cost).toFixed(2) : '',
|
|
497
|
+
p.cacheHit != null ? (Number(p.cacheHit) * 100).toFixed(0) + '%' : '',
|
|
498
|
+
]);
|
|
499
|
+
return `${pageHead('Compare', `${projects.length} projects`)}
|
|
500
|
+
${table(['Project', 'Sessions', 'Prompts', 'Cost', 'Cache hit'], rows)}
|
|
501
|
+
${SHARED_CSS}`;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/** REQ-DASHLEAN-006: heatmap — Claude Code file-touch intensity (server-rendered). */
|
|
505
|
+
export function renderHeatmap(data) {
|
|
506
|
+
const files = (data?.files || []).slice(0, 60);
|
|
507
|
+
const max = files.reduce((m, f) => Math.max(m, f.calls || 0), 1);
|
|
508
|
+
const rows = files.map((f) => {
|
|
509
|
+
const intensity = (f.calls || 0) / max;
|
|
510
|
+
const bg = `rgba(88,166,255,${(0.12 + intensity * 0.78).toFixed(3)})`;
|
|
511
|
+
const trend = (f.trend || [])
|
|
512
|
+
.map((v) => {
|
|
513
|
+
const h = Math.max(2, Math.round((v / Math.max(1, Math.max(...(f.trend || [1])))) * 16));
|
|
514
|
+
return `<span class="spark" style="height:${h}px"></span>`;
|
|
515
|
+
})
|
|
516
|
+
.join('');
|
|
517
|
+
return `<div class="hm-row">
|
|
518
|
+
<span class="hm-cell" style="background:${bg}" title="${escapeHtml(String(f.calls || 0))} calls"></span>
|
|
519
|
+
<span class="hm-name mono muted">${escapeHtml((f.path || '').replace(/^.*\/([^/]+\/[^/]+)$/, '$1'))}</span>
|
|
520
|
+
<span class="hm-calls">${f.calls || 0}</span>
|
|
521
|
+
<span class="hm-trend">${trend}</span>
|
|
522
|
+
</div>`;
|
|
523
|
+
});
|
|
524
|
+
return `${pageHead('Heatmap', `${files.length} hottest files`)}
|
|
525
|
+
<div class="hm-list">${rows.join('')}</div>
|
|
526
|
+
${SHARED_CSS}
|
|
527
|
+
<style>
|
|
528
|
+
.hm-row{display:flex;align-items:center;gap:10px;padding:3px 0}
|
|
529
|
+
.hm-cell{width:26px;height:14px;border-radius:3px;flex-shrink:0}
|
|
530
|
+
.hm-name{flex:1;min-width:0;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
531
|
+
.hm-calls{width:44px;text-align:right;font-size:12px}
|
|
532
|
+
.hm-trend{display:flex;align-items:flex-end;gap:2px;width:80px;height:18px}
|
|
533
|
+
.hm-trend .spark{width:8px;background:var(--accent,#58a6ff);border-radius:1px;opacity:.7}
|
|
534
|
+
</style>`;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/** REQ-DASHLEAN-006: graph — interactive node-link view. Data is embedded and
|
|
538
|
+
* drawn by the `/islands/graph.js` island (canvas + pan/zoom/hover). */
|
|
539
|
+
export function renderGraph(graph) {
|
|
540
|
+
const nodes = (graph?.nodes || []).slice(0, 250);
|
|
541
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
542
|
+
const edges = (graph?.edges || []).filter((e) => ids.has(e.from) && ids.has(e.to)).slice(0, 600);
|
|
543
|
+
const payload = JSON.stringify({ nodes: nodes.map((n) => ({ id: n.id, name: n.name, kind: n.kind, degree: n.degree })), edges });
|
|
544
|
+
return `${pageHead('Graph', `${graph?.shown ?? nodes.length} of ${graph?.total ?? nodes.length} nodes`)}
|
|
545
|
+
<div id="graph-host"><canvas id="graph-canvas"></canvas>
|
|
546
|
+
<div class="graph-hint muted">drag to pan · scroll to zoom · hover a node</div>
|
|
547
|
+
<div id="graph-tip" class="graph-tip"></div>
|
|
548
|
+
</div>
|
|
549
|
+
<script type="application/json" id="graph-data">${payload.replace(/</g, '\\u003c')}</script>
|
|
550
|
+
<script type="module" src="/islands/graph.js"></script>
|
|
551
|
+
${SHARED_CSS}
|
|
552
|
+
<style>
|
|
553
|
+
#graph-host{position:relative;height:72vh;border:1px solid var(--border-subtle,rgba(255,255,255,.1));border-radius:10px;overflow:hidden;background:var(--bg-elevated,rgba(255,255,255,.02))}
|
|
554
|
+
#graph-canvas{width:100%;height:100%;display:block;cursor:grab}
|
|
555
|
+
.graph-hint{position:absolute;left:12px;bottom:10px;font-size:11px;pointer-events:none}
|
|
556
|
+
.graph-tip{position:absolute;padding:4px 8px;font-size:11px;background:var(--bg-canvas,#0d1117);border:1px solid var(--border-subtle,rgba(255,255,255,.2));border-radius:6px;pointer-events:none;opacity:0;transform:translate(-50%,-140%);white-space:nowrap}
|
|
557
|
+
</style>`;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/** A generic "under migration" placeholder for kept pages not yet ported. */
|
|
561
|
+
export function renderStub(label) {
|
|
562
|
+
return `<div class="page-head"><h1>${escapeHtml(label)}</h1></div>
|
|
563
|
+
<p class="muted" style="max-width:640px">This read-only page is part of the SSR migration (REQ-DASHLEAN-006) and has not been ported from the Angular dashboard yet. Its data is already available from the same <code class="mono">/api</code> the Angular app uses.</p>`;
|
|
564
|
+
}
|