tenbo-dashboard 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 (155) hide show
  1. package/README.md +32 -0
  2. package/bin/tenbo-dashboard.mjs +67 -0
  3. package/index.html +12 -0
  4. package/package.json +75 -0
  5. package/scripts/compute-metrics.test.ts +35 -0
  6. package/scripts/compute-metrics.ts +46 -0
  7. package/scripts/next-id.test.ts +128 -0
  8. package/scripts/next-id.ts +183 -0
  9. package/scripts/validate-cli.test.ts +152 -0
  10. package/scripts/validate-cli.ts +181 -0
  11. package/src/App.tsx +152 -0
  12. package/src/ErrorBoundary.tsx +25 -0
  13. package/src/api/client.ts +41 -0
  14. package/src/api/lib/frontmatterScan.ts +37 -0
  15. package/src/api/lib/health/agingTodos.test.ts +32 -0
  16. package/src/api/lib/health/agingTodos.ts +97 -0
  17. package/src/api/lib/health/archCompliance.test.ts +26 -0
  18. package/src/api/lib/health/archCompliance.ts +53 -0
  19. package/src/api/lib/health/collectAll.test.ts +49 -0
  20. package/src/api/lib/health/collectAll.ts +78 -0
  21. package/src/api/lib/health/config.test.ts +45 -0
  22. package/src/api/lib/health/config.ts +54 -0
  23. package/src/api/lib/health/coupling.test.ts +40 -0
  24. package/src/api/lib/health/coupling.ts +66 -0
  25. package/src/api/lib/health/deadCode.test.ts +23 -0
  26. package/src/api/lib/health/deadCode.ts +76 -0
  27. package/src/api/lib/health/docDrift.test.ts +54 -0
  28. package/src/api/lib/health/docDrift.ts +99 -0
  29. package/src/api/lib/health/hotspotFiles.test.ts +49 -0
  30. package/src/api/lib/health/hotspotFiles.ts +70 -0
  31. package/src/api/lib/health/importGraph.test.ts +29 -0
  32. package/src/api/lib/health/importGraph.ts +51 -0
  33. package/src/api/lib/health/layerFiles.test.ts +46 -0
  34. package/src/api/lib/health/layerFiles.ts +40 -0
  35. package/src/api/lib/health/redundancy.test.ts +24 -0
  36. package/src/api/lib/health/redundancy.ts +162 -0
  37. package/src/api/lib/health/testCoverage.test.ts +33 -0
  38. package/src/api/lib/health/testCoverage.ts +55 -0
  39. package/src/api/lib/health/types.test.ts +29 -0
  40. package/src/api/lib/health/types.ts +85 -0
  41. package/src/api/lib/http.ts +34 -0
  42. package/src/api/lib/metrics.test.ts +81 -0
  43. package/src/api/lib/metrics.ts +78 -0
  44. package/src/api/lib/metricsRefresh.test.ts +54 -0
  45. package/src/api/lib/metricsRefresh.ts +147 -0
  46. package/src/api/lib/phases.test.ts +76 -0
  47. package/src/api/lib/phases.ts +48 -0
  48. package/src/api/lib/relationships.ts +65 -0
  49. package/src/api/lib/repoRoot.ts +12 -0
  50. package/src/api/lib/tenboFs.layerContent.test.ts +36 -0
  51. package/src/api/lib/tenboFs.ts +237 -0
  52. package/src/api/lib/tenboFs.v2.test.ts +86 -0
  53. package/src/api/lib/tenboFs.workspaceContent.test.ts +42 -0
  54. package/src/api/lib/validator.docUpdate.test.ts +99 -0
  55. package/src/api/lib/validator.phases.test.ts +98 -0
  56. package/src/api/lib/validator.relationships.test.ts +126 -0
  57. package/src/api/lib/validator.specLinks.test.ts +96 -0
  58. package/src/api/lib/validator.ts +311 -0
  59. package/src/api/lib/validator.v2.test.ts +55 -0
  60. package/src/api/lib/yamlOrdered.ts +60 -0
  61. package/src/api/plugin.ts +31 -0
  62. package/src/api/routes/items.ts +25 -0
  63. package/src/api/routes/layerContent.ts +22 -0
  64. package/src/api/routes/layerDocs.ts +22 -0
  65. package/src/api/routes/open.ts +18 -0
  66. package/src/api/routes/related.ts +11 -0
  67. package/src/api/routes/reorder.ts +15 -0
  68. package/src/api/routes/state.ts +23 -0
  69. package/src/api/routes/watch.ts +31 -0
  70. package/src/css-modules.d.ts +4 -0
  71. package/src/hooks/useApiPatch.ts +9 -0
  72. package/src/hooks/useLayerContent.ts +25 -0
  73. package/src/hooks/usePrompt.ts +23 -0
  74. package/src/hooks/useTenboState.ts +35 -0
  75. package/src/main.tsx +13 -0
  76. package/src/prompts/populateLayer.ts +12 -0
  77. package/src/router/routes.ts +98 -0
  78. package/src/router/useHashRoute.test.ts +67 -0
  79. package/src/router/useHashRoute.ts +15 -0
  80. package/src/styles.css +228 -0
  81. package/src/test-setup.ts +1 -0
  82. package/src/types.ts +202 -0
  83. package/src/ui/DotGrid.module.css +7 -0
  84. package/src/ui/DotGrid.tsx +87 -0
  85. package/src/ui/EmptyState.module.css +11 -0
  86. package/src/ui/EmptyState.tsx +10 -0
  87. package/src/ui/FindingModal/EvidenceSection.tsx +51 -0
  88. package/src/ui/FindingModal/FindingModal.module.css +31 -0
  89. package/src/ui/FindingModal/FindingModal.test.tsx +36 -0
  90. package/src/ui/FindingModal/HeadlineSection.tsx +17 -0
  91. package/src/ui/FindingModal/RelatedFindingsSection.tsx +29 -0
  92. package/src/ui/FindingModal/SuggestionSection.tsx +13 -0
  93. package/src/ui/FindingModal/index.tsx +36 -0
  94. package/src/ui/HealthPage/Digest.tsx +29 -0
  95. package/src/ui/HealthPage/FindingRow.tsx +21 -0
  96. package/src/ui/HealthPage/HealthPage.module.css +45 -0
  97. package/src/ui/HealthPage/HealthPage.test.tsx +49 -0
  98. package/src/ui/HealthPage/HealthPage.tsx +66 -0
  99. package/src/ui/HealthPage/LayerCard.tsx +36 -0
  100. package/src/ui/HealthPage/SeverityIcon.tsx +29 -0
  101. package/src/ui/HealthPage/SeverityLegend.tsx +27 -0
  102. package/src/ui/HealthPage/severity.test.ts +46 -0
  103. package/src/ui/HealthPage/severity.ts +21 -0
  104. package/src/ui/ItemCard.module.css +131 -0
  105. package/src/ui/ItemCard.tsx +117 -0
  106. package/src/ui/ItemModal/DescriptionField.tsx +22 -0
  107. package/src/ui/ItemModal/EnrichmentSections.tsx +49 -0
  108. package/src/ui/ItemModal/ItemModal.module.css +200 -0
  109. package/src/ui/ItemModal/LinksSection.tsx +26 -0
  110. package/src/ui/ItemModal/NotesSection.tsx +18 -0
  111. package/src/ui/ItemModal/PhasesSection.tsx +79 -0
  112. package/src/ui/ItemModal/RelatedSection.tsx +26 -0
  113. package/src/ui/ItemModal/RelationshipsSection.tsx +69 -0
  114. package/src/ui/ItemModal/StatusLayerControls.tsx +43 -0
  115. package/src/ui/ItemModal/TitleField.tsx +21 -0
  116. package/src/ui/ItemModal/index.tsx +117 -0
  117. package/src/ui/KanbanColumn.module.css +20 -0
  118. package/src/ui/KanbanColumn.tsx +36 -0
  119. package/src/ui/LayerDetailPage/LayerDetailPage.module.css +11 -0
  120. package/src/ui/LayerDetailPage/LayerDetailPage.test.tsx +30 -0
  121. package/src/ui/LayerDetailPage/LayerDetailPage.tsx +59 -0
  122. package/src/ui/LayerDetailPage/SignalSection.tsx +33 -0
  123. package/src/ui/LayerDrawer.module.css +60 -0
  124. package/src/ui/LayerDrawer.tsx +69 -0
  125. package/src/ui/LayerKanban.module.css +45 -0
  126. package/src/ui/LayerKanban.tsx +65 -0
  127. package/src/ui/LayerPage/FillThisInButton.tsx +11 -0
  128. package/src/ui/LayerPage/LayerPage.module.css +52 -0
  129. package/src/ui/LayerPage/LayerPage.tsx +76 -0
  130. package/src/ui/LayerPage/MetricsBadges.tsx +18 -0
  131. package/src/ui/LayerPage/isTemplateOnly.test.ts +45 -0
  132. package/src/ui/LayerPage/isTemplateOnly.ts +19 -0
  133. package/src/ui/LayerPage/tabs/CodeMapTab.tsx +19 -0
  134. package/src/ui/LayerPage/tabs/DeepDivesTab.tsx +35 -0
  135. package/src/ui/LayerPage/tabs/IntentTab.tsx +19 -0
  136. package/src/ui/LayerPage/tabs/PlainEnglishTab.tsx +19 -0
  137. package/src/ui/Markdown.module.css +115 -0
  138. package/src/ui/Markdown.tsx +27 -0
  139. package/src/ui/RoadmapPage/RoadmapPage.module.css +93 -0
  140. package/src/ui/RoadmapPage/RoadmapPage.tsx +255 -0
  141. package/src/ui/ScopePage/ScopePage.module.css +72 -0
  142. package/src/ui/ScopePage/ScopePage.tsx +131 -0
  143. package/src/ui/SummaryStrip.module.css +18 -0
  144. package/src/ui/SummaryStrip.tsx +16 -0
  145. package/src/ui/TaskList.module.css +136 -0
  146. package/src/ui/TaskList.tsx +91 -0
  147. package/src/ui/TopBar.module.css +78 -0
  148. package/src/ui/TopBar.tsx +48 -0
  149. package/src/ui/WorkspacePage/WorkspacePage.module.css +24 -0
  150. package/src/ui/WorkspacePage/WorkspacePage.tsx +36 -0
  151. package/src/ui/WorkspacePage/tabs/GlossaryTab.tsx +6 -0
  152. package/src/ui/WorkspacePage/tabs/OverviewTab.tsx +28 -0
  153. package/src/ui/WorkspacePage/tabs/PrinciplesTab.tsx +6 -0
  154. package/tsconfig.json +18 -0
  155. package/vite.config.ts +9 -0
@@ -0,0 +1,29 @@
1
+ import type { Finding } from '../../api/lib/health/types';
2
+ import styles from './FindingModal.module.css';
3
+
4
+ interface Props {
5
+ finding: Finding;
6
+ allFindings: Finding[];
7
+ onSelect: (f: Finding) => void;
8
+ }
9
+
10
+ export function RelatedFindingsSection({ finding, allFindings, onSelect }: Props) {
11
+ const related = allFindings.filter(f =>
12
+ f.id !== finding.id && (f.target === finding.target || (f.layer === finding.layer && f.signal === finding.signal))
13
+ ).slice(0, 5);
14
+ if (related.length === 0) return null;
15
+ return (
16
+ <section className={styles.section}>
17
+ <h3 className={styles.sectionTitle}>Related findings</h3>
18
+ <ul className={styles.relatedList}>
19
+ {related.map(f => (
20
+ <li key={f.id}>
21
+ <button type="button" className={styles.relatedLink} onClick={() => onSelect(f)}>
22
+ {f.headline}
23
+ </button>
24
+ </li>
25
+ ))}
26
+ </ul>
27
+ </section>
28
+ );
29
+ }
@@ -0,0 +1,13 @@
1
+ import type { Finding } from '../../api/lib/health/types';
2
+ import styles from './FindingModal.module.css';
3
+
4
+ export function SuggestionSection({ finding }: { finding: Finding }) {
5
+ return (
6
+ <section className={styles.section}>
7
+ <h3 className={styles.sectionTitle}>Suggestion</h3>
8
+ <p className={styles.suggestionSummary}>{finding.suggestion.summary}</p>
9
+ <p className={styles.suggestionRationale}>{finding.suggestion.rationale}</p>
10
+ <p className={styles.actionKind}>action_kind: <code>{finding.suggestion.action_kind}</code></p>
11
+ </section>
12
+ );
13
+ }
@@ -0,0 +1,36 @@
1
+ import { X } from 'lucide-react';
2
+ import type { Finding } from '../../api/lib/health/types';
3
+ import { HeadlineSection } from './HeadlineSection';
4
+ import { SuggestionSection } from './SuggestionSection';
5
+ import { EvidenceSection } from './EvidenceSection';
6
+ import { RelatedFindingsSection } from './RelatedFindingsSection';
7
+ import styles from './FindingModal.module.css';
8
+
9
+ interface Props {
10
+ finding: Finding | null;
11
+ allFindings: Finding[];
12
+ onClose: () => void;
13
+ onOpenFile: (path: string) => void;
14
+ onSelectFinding?: (f: Finding) => void;
15
+ }
16
+
17
+ export function FindingModal({ finding, allFindings, onClose, onOpenFile, onSelectFinding }: Props) {
18
+ if (!finding) return null;
19
+ return (
20
+ <>
21
+ <div onClick={onClose} className={`overlay-backdrop ${styles.backdrop}`} />
22
+ <div className={styles.modal}>
23
+ <button onClick={onClose} className="close-x" aria-label="Close">
24
+ <X size={18} strokeWidth={1.75} />
25
+ </button>
26
+ <HeadlineSection finding={finding} />
27
+ <button type="button" className={styles.targetLink} onClick={() => onOpenFile(finding.target)}>
28
+ {finding.target}
29
+ </button>
30
+ <SuggestionSection finding={finding} />
31
+ <EvidenceSection finding={finding} />
32
+ {onSelectFinding && <RelatedFindingsSection finding={finding} allFindings={allFindings} onSelect={onSelectFinding} />}
33
+ </div>
34
+ </>
35
+ );
36
+ }
@@ -0,0 +1,29 @@
1
+ import type { Finding, Signal } from '../../api/lib/health/types';
2
+ import { sortFindings } from './severity';
3
+ import { FindingRow } from './FindingRow';
4
+ import styles from './HealthPage.module.css';
5
+
6
+ interface Props {
7
+ findings: Finding[];
8
+ signalWeights: readonly Signal[];
9
+ onSelect: (finding: Finding) => void;
10
+ limit?: number;
11
+ }
12
+
13
+ export function Digest({ findings, signalWeights, onSelect, limit = 10 }: Props) {
14
+ const top = sortFindings(findings, signalWeights).slice(0, limit);
15
+ return (
16
+ <section className={styles.section}>
17
+ <h2 className={styles.sectionTitle}>Top findings</h2>
18
+ {top.length === 0 ? (
19
+ <p className={styles.emptyState}>No findings — everything looks healthy.</p>
20
+ ) : (
21
+ <ul className={styles.findingList}>
22
+ {top.map(f => (
23
+ <li key={f.id}><FindingRow finding={f} onClick={onSelect} /></li>
24
+ ))}
25
+ </ul>
26
+ )}
27
+ </section>
28
+ );
29
+ }
@@ -0,0 +1,21 @@
1
+ import type { Finding } from '../../api/lib/health/types';
2
+ import { SeverityIcon } from './SeverityIcon';
3
+ import styles from './HealthPage.module.css';
4
+
5
+ interface Props {
6
+ finding: Finding;
7
+ onClick: (finding: Finding) => void;
8
+ showLayer?: boolean;
9
+ }
10
+
11
+ export function FindingRow({ finding, onClick, showLayer = true }: Props) {
12
+ return (
13
+ <button type="button" className={styles.findingRow} onClick={() => onClick(finding)}>
14
+ <span className={styles.findingSev}><SeverityIcon severity={finding.severity} /></span>
15
+ <span className={styles.findingSignal}>{finding.signal}</span>
16
+ {showLayer && <span className={styles.findingLayer}>· {finding.layer}</span>}
17
+ <span className={styles.findingHeadline}>{finding.headline}</span>
18
+ <span className={styles.findingSuggestion}>→ {finding.suggestion.summary}</span>
19
+ </button>
20
+ );
21
+ }
@@ -0,0 +1,45 @@
1
+ /* tenbo-dashboard/src/ui/HealthPage/HealthPage.module.css */
2
+ .page { padding: 24px 32px; max-width: 1200px; margin: 0 auto; }
3
+ .pageTitle { font-size: 24px; font-weight: 600; margin-bottom: 8px; }
4
+ .totals { color: var(--muted, #888); font-size: 14px; }
5
+
6
+ .section { margin-bottom: 32px; }
7
+ .sectionTitle { font-size: 14px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted, #888); margin-bottom: 12px; }
8
+
9
+ .findingList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; }
10
+ .findingRow {
11
+ display: grid;
12
+ grid-template-columns: 24px max-content max-content 1fr max-content;
13
+ gap: 8px; align-items: baseline;
14
+ padding: 10px 12px; border: 1px solid var(--border, #2a2a2a); border-radius: 6px;
15
+ background: var(--console-soot); color: inherit; cursor: pointer; text-align: left; width: 100%;
16
+ font-family: inherit; font-size: 13px;
17
+ }
18
+ .findingRow:hover { background: var(--console-iron); }
19
+ .findingSignal { font-weight: 500; text-transform: capitalize; }
20
+ .findingLayer { color: var(--muted, #888); }
21
+ .findingHeadline { color: var(--text, #ddd); }
22
+ .findingSuggestion { color: var(--muted, #888); }
23
+
24
+ .layerGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
25
+ .layerCard {
26
+ display: flex; flex-direction: column; gap: 8px;
27
+ padding: 16px; border: 1px solid var(--border, #2a2a2a); border-radius: 8px;
28
+ background: var(--console-soot); color: inherit; cursor: pointer; text-align: left;
29
+ font-family: inherit;
30
+ }
31
+ .layerCard:hover { background: var(--console-iron); }
32
+ .layerCardTitle { font-size: 15px; font-weight: 600; margin: 0; }
33
+ .layerCardStats { display: flex; gap: 8px; color: var(--muted, #888); font-size: 13px; }
34
+ .layerCardCounts { display: flex; gap: 8px; font-size: 13px; flex-wrap: wrap; }
35
+ .layerCardLink { color: var(--muted, #888); font-size: 12px; }
36
+ .emptyState { color: var(--muted, #888); font-style: italic; }
37
+
38
+ .findingSev { display: inline-flex; align-items: center; }
39
+
40
+ .severityChip { display: inline-flex; align-items: center; gap: 4px; }
41
+
42
+ .severityLegend { display: inline-flex; gap: 16px; align-items: center; flex-wrap: wrap; }
43
+ .severityLegendItem { display: inline-flex; align-items: center; gap: 6px; }
44
+ .severityLegendCount { font-weight: 600; color: var(--text, #ddd); }
45
+ .severityLegendLabel { color: var(--muted, #888); text-transform: lowercase; }
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { HealthPage } from './HealthPage';
4
+ import type { TenboState } from '../../types';
5
+ import type { Finding } from '../../api/lib/health/types';
6
+
7
+ const finding = (over: Partial<Finding>): Finding => ({
8
+ id: 'editor.hotspot-files.foo', signal: 'hotspot-files', severity: 'critical',
9
+ confidence: 'high', layer: 'visual-canvas', target: 'apps/editor/src/foo.ts',
10
+ headline: 'foo.ts (1500 LOC)',
11
+ suggestion: { summary: 'Split foo.ts', rationale: '', action_kind: 'split-file' },
12
+ details: { kind: 'hotspot-files', loc: 1500, top_functions: [], commits_30d: 0, split_candidates: [] },
13
+ ...over,
14
+ });
15
+
16
+ const state: TenboState = {
17
+ scopes: [{
18
+ id: 'editor', path: 'apps/editor', description: 'Editor', layers: [
19
+ { id: 'visual-canvas', name: 'Visual Canvas', description: '', files: ['src/**'], dependencies: { inbound: [], outbound: [], external: [] } },
20
+ ],
21
+ items: [],
22
+ }],
23
+ crossCutting: [], narratives: {}, workspaceContent: {} as any,
24
+ metrics: {
25
+ editor: {
26
+ generated_at: '2026-05-02T00:00:00Z',
27
+ layers: { 'visual-canvas': { file_count: 100, total_lines: 5000, outbound_deps: 0, deep_dive_count: 0, intent_age_days: null, pct_roadmap_in_now: 0 } },
28
+ findings: [finding({})],
29
+ },
30
+ },
31
+ };
32
+
33
+ describe('HealthPage', () => {
34
+ it('renders digest section with finding headline', () => {
35
+ render(<HealthPage state={state} onSelectLayer={vi.fn()} onSelectFinding={vi.fn()} />);
36
+ expect(screen.getByText(/foo\.ts \(1500 LOC\)/)).toBeInTheDocument();
37
+ });
38
+
39
+ it('renders one layer card per layer', () => {
40
+ render(<HealthPage state={state} onSelectLayer={vi.fn()} onSelectFinding={vi.fn()} />);
41
+ expect(screen.getByText('Visual Canvas')).toBeInTheDocument();
42
+ expect(screen.getByText(/100 files/)).toBeInTheDocument();
43
+ });
44
+
45
+ it('does not render the old "limit"-suffix violation messages', () => {
46
+ render(<HealthPage state={state} onSelectLayer={vi.fn()} onSelectFinding={vi.fn()} />);
47
+ expect(screen.queryByText(/\(limit \d+\)/)).toBeNull();
48
+ });
49
+ });
@@ -0,0 +1,66 @@
1
+ import { useMemo } from 'react';
2
+ import type { TenboState } from '../../types';
3
+ import type { Finding } from '../../api/lib/health/types';
4
+ import { SIGNAL_WEIGHTS_DEFAULT } from '../../api/lib/health/types';
5
+ import { Digest } from './Digest';
6
+ import { LayerCard } from './LayerCard';
7
+ import { SeverityLegend } from './SeverityLegend';
8
+ import styles from './HealthPage.module.css';
9
+
10
+ interface Props {
11
+ state: TenboState;
12
+ onSelectLayer: (scopeId: string, layerId: string) => void;
13
+ onSelectFinding: (finding: Finding) => void;
14
+ }
15
+
16
+ export function HealthPage({ state, onSelectLayer, onSelectFinding }: Props) {
17
+ const allFindings = useMemo<Finding[]>(() => {
18
+ const out: Finding[] = [];
19
+ for (const m of Object.values(state.metrics ?? {})) out.push(...(m.findings ?? []));
20
+ return out;
21
+ }, [state.metrics]);
22
+
23
+ const totals = { critical: 0, warning: 0, info: 0 };
24
+ for (const f of allFindings) totals[f.severity]++;
25
+
26
+ return (
27
+ <div className={styles.page}>
28
+ <section className={styles.section}>
29
+ <h1 className={styles.pageTitle}>Health overview</h1>
30
+ <div className={styles.totals}>
31
+ <SeverityLegend counts={totals} />
32
+ </div>
33
+ </section>
34
+
35
+ <Digest
36
+ findings={allFindings}
37
+ signalWeights={SIGNAL_WEIGHTS_DEFAULT}
38
+ onSelect={onSelectFinding}
39
+ />
40
+
41
+ <section className={styles.section}>
42
+ <h2 className={styles.sectionTitle}>Layers</h2>
43
+ <div className={styles.layerGrid}>
44
+ {state.scopes.flatMap(scope =>
45
+ scope.layers.map(layer => {
46
+ const metrics = state.metrics?.[scope.id]?.layers[layer.id];
47
+ const findings = (state.metrics?.[scope.id]?.findings ?? []).filter(f => f.layer === layer.id);
48
+ if (!metrics) return null;
49
+ return (
50
+ <LayerCard
51
+ key={`${scope.id}/${layer.id}`}
52
+ scopeId={scope.id}
53
+ layerId={layer.id}
54
+ layerName={layer.name}
55
+ metrics={metrics}
56
+ findings={findings}
57
+ onSelect={() => onSelectLayer(scope.id, layer.id)}
58
+ />
59
+ );
60
+ })
61
+ )}
62
+ </div>
63
+ </section>
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,36 @@
1
+ import type { Finding } from '../../api/lib/health/types';
2
+ import type { LayerMetrics } from '../../types';
3
+ import { Check } from 'lucide-react';
4
+ import { SeverityIcon } from './SeverityIcon';
5
+ import styles from './HealthPage.module.css';
6
+
7
+ interface Props {
8
+ scopeId: string;
9
+ layerId: string;
10
+ layerName: string;
11
+ metrics: LayerMetrics;
12
+ findings: Finding[];
13
+ onSelect: () => void;
14
+ }
15
+
16
+ export function LayerCard({ layerName, metrics, findings, onSelect }: Props) {
17
+ const counts = { critical: 0, warning: 0, info: 0 };
18
+ for (const f of findings) counts[f.severity]++;
19
+ return (
20
+ <button type="button" className={styles.layerCard} onClick={onSelect}>
21
+ <h3 className={styles.layerCardTitle}>{layerName}</h3>
22
+ <div className={styles.layerCardStats}>
23
+ <span>{metrics.file_count.toLocaleString()} files</span>
24
+ <span>·</span>
25
+ <span>{metrics.total_lines.toLocaleString()} LOC</span>
26
+ </div>
27
+ <div className={styles.layerCardCounts}>
28
+ {counts.critical > 0 && <span className={styles.severityChip}><SeverityIcon severity="critical" /> {counts.critical}</span>}
29
+ {counts.warning > 0 && <span className={styles.severityChip}><SeverityIcon severity="warning" /> {counts.warning}</span>}
30
+ {counts.info > 0 && <span className={styles.severityChip}><SeverityIcon severity="info" /> {counts.info}</span>}
31
+ {counts.critical === 0 && counts.warning === 0 && counts.info === 0 && <span className={styles.severityChip}><Check size={14} strokeWidth={2} color="var(--success, #48bb78)" aria-label="healthy" /></span>}
32
+ </div>
33
+ <span className={styles.layerCardLink}>drill in →</span>
34
+ </button>
35
+ );
36
+ }
@@ -0,0 +1,29 @@
1
+ import { AlertOctagon, AlertTriangle, Info, type LucideIcon } from 'lucide-react';
2
+ import type { Severity } from '../../api/lib/health/types';
3
+ import { severityColor } from './severity';
4
+
5
+ const ICONS: Record<Severity, LucideIcon> = {
6
+ critical: AlertOctagon,
7
+ warning: AlertTriangle,
8
+ info: Info,
9
+ };
10
+
11
+ interface Props {
12
+ severity: Severity;
13
+ size?: number;
14
+ className?: string;
15
+ 'aria-label'?: string;
16
+ }
17
+
18
+ export function SeverityIcon({ severity, size = 14, className, ...rest }: Props) {
19
+ const Icon = ICONS[severity];
20
+ return (
21
+ <Icon
22
+ size={size}
23
+ strokeWidth={2}
24
+ color={severityColor(severity)}
25
+ className={className}
26
+ aria-label={rest['aria-label'] ?? severity}
27
+ />
28
+ );
29
+ }
@@ -0,0 +1,27 @@
1
+ import { SeverityIcon } from './SeverityIcon';
2
+ import styles from './HealthPage.module.css';
3
+
4
+ interface Props {
5
+ counts: { critical: number; warning: number; info: number };
6
+ /** When true (default), severity tiers with a count of 0 are still shown so the legend reads as a key. */
7
+ showZero?: boolean;
8
+ }
9
+
10
+ export function SeverityLegend({ counts, showZero = true }: Props) {
11
+ const items: Array<{ severity: 'critical' | 'warning' | 'info'; count: number; label: string }> = [
12
+ { severity: 'critical', count: counts.critical, label: 'critical' },
13
+ { severity: 'warning', count: counts.warning, label: 'warning' },
14
+ { severity: 'info', count: counts.info, label: 'info' },
15
+ ];
16
+ return (
17
+ <span className={styles.severityLegend}>
18
+ {items.filter(i => showZero || i.count > 0).map(({ severity, count, label }) => (
19
+ <span key={severity} className={styles.severityLegendItem}>
20
+ <SeverityIcon severity={severity} />
21
+ <span className={styles.severityLegendCount}>{count}</span>
22
+ <span className={styles.severityLegendLabel}>{label}</span>
23
+ </span>
24
+ ))}
25
+ </span>
26
+ );
27
+ }
@@ -0,0 +1,46 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { sortFindings, severityColor } from './severity';
3
+ import type { Finding } from '../../api/lib/health/types';
4
+
5
+ const f = (over: Partial<Finding>): Finding => ({
6
+ id: 'x', signal: 'hotspot-files', severity: 'warning', confidence: 'high',
7
+ layer: 'l', target: 't', headline: 'h',
8
+ suggestion: { summary: '', rationale: '', action_kind: 'split-file' },
9
+ details: { kind: 'hotspot-files', loc: 0, top_functions: [], commits_30d: 0, split_candidates: [] },
10
+ ...over,
11
+ });
12
+
13
+ describe('sortFindings', () => {
14
+ const weights = ['dead-code', 'hotspot-files', 'aging-todos'] as const;
15
+ it('orders by severity tier first', () => {
16
+ const sorted = sortFindings([
17
+ f({ id: 'a', severity: 'info' }),
18
+ f({ id: 'b', severity: 'critical' }),
19
+ f({ id: 'c', severity: 'warning' }),
20
+ ], weights as any);
21
+ expect(sorted.map(x => x.id)).toEqual(['b', 'c', 'a']);
22
+ });
23
+
24
+ it('breaks ties by signal weight', () => {
25
+ const sorted = sortFindings([
26
+ f({ id: 'a', severity: 'critical', signal: 'aging-todos' }),
27
+ f({ id: 'b', severity: 'critical', signal: 'dead-code' }),
28
+ ], weights as any);
29
+ expect(sorted.map(x => x.id)).toEqual(['b', 'a']);
30
+ });
31
+
32
+ it('breaks remaining ties by confidence', () => {
33
+ const sorted = sortFindings([
34
+ f({ id: 'a', severity: 'critical', signal: 'dead-code', confidence: 'low' }),
35
+ f({ id: 'b', severity: 'critical', signal: 'dead-code', confidence: 'high' }),
36
+ ], weights as any);
37
+ expect(sorted.map(x => x.id)).toEqual(['b', 'a']);
38
+ });
39
+ });
40
+
41
+ describe('severityColor', () => {
42
+ it('returns distinct strings per severity', () => {
43
+ expect(severityColor('critical')).not.toBe(severityColor('warning'));
44
+ expect(severityColor('warning')).not.toBe(severityColor('info'));
45
+ });
46
+ });
@@ -0,0 +1,21 @@
1
+ import { CONFIDENCE_RANK, SEVERITY_RANK, type Finding, type Severity, type Signal } from '../../api/lib/health/types';
2
+
3
+ export function sortFindings(findings: Finding[], signalWeights: readonly Signal[]): Finding[] {
4
+ const weightOf = (s: Signal): number => {
5
+ const i = signalWeights.indexOf(s);
6
+ return i === -1 ? signalWeights.length : i;
7
+ };
8
+ return findings.slice().sort((a, b) => {
9
+ const sev = SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity];
10
+ if (sev !== 0) return sev;
11
+ const sig = weightOf(a.signal) - weightOf(b.signal);
12
+ if (sig !== 0) return sig;
13
+ return CONFIDENCE_RANK[b.confidence] - CONFIDENCE_RANK[a.confidence];
14
+ });
15
+ }
16
+
17
+ export function severityColor(s: Severity): string {
18
+ return s === 'critical' ? 'var(--health-critical, #f56565)'
19
+ : s === 'warning' ? 'var(--health-warning, #ecc94b)'
20
+ : 'var(--health-info, #4299e1)';
21
+ }
@@ -0,0 +1,131 @@
1
+ .card {
2
+ padding: 10px;
3
+ background: var(--console-iron);
4
+ border-radius: var(--r-md);
5
+ margin-bottom: 4px;
6
+ cursor: grab;
7
+ position: relative;
8
+ transition: background var(--dur-state) var(--ease);
9
+ }
10
+ .card:hover { background: var(--console-mist); }
11
+ .card:focus-visible {
12
+ outline: 2px solid var(--signal-cyan);
13
+ outline-offset: 2px;
14
+ }
15
+
16
+ .id {
17
+ font-size: 11px;
18
+ font-weight: 500;
19
+ letter-spacing: 0.04em;
20
+ color: var(--console-fog);
21
+ margin-bottom: 4px;
22
+ display: flex;
23
+ align-items: center;
24
+ flex-wrap: wrap;
25
+ gap: 4px;
26
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
27
+ }
28
+
29
+ /* Priority pill — single-letter, color carries meaning. */
30
+ .priority {
31
+ font-size: 10px;
32
+ font-weight: 600;
33
+ padding: 1px 6px;
34
+ border-radius: var(--r-full);
35
+ letter-spacing: 0.04em;
36
+ font-family: 'Inter', sans-serif;
37
+ }
38
+ .p0 { background: var(--halt-red); color: var(--console-black); }
39
+ .p1 { background: var(--slow-amber); color: var(--console-black); }
40
+ .p2 { background: var(--console-mist); color: var(--console-fog); }
41
+ .p3 { background: transparent; color: var(--console-smoke); border: 1px solid var(--console-mist); }
42
+
43
+ /* Relationship chip — shows spawned_from, related count, etc. */
44
+ .relChip {
45
+ display: inline-flex;
46
+ align-items: center;
47
+ gap: 3px;
48
+ font-size: 10px;
49
+ font-weight: 500;
50
+ letter-spacing: 0.02em;
51
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
52
+ color: var(--console-smoke);
53
+ background: var(--console-soot);
54
+ padding: 1px 6px;
55
+ border-radius: var(--r-sm);
56
+ white-space: nowrap;
57
+ }
58
+
59
+ .titleInput {
60
+ width: 100%;
61
+ font: inherit;
62
+ font-weight: 600;
63
+ font-size: 13px;
64
+ color: var(--console-snow);
65
+ background: transparent;
66
+ border: none;
67
+ padding: 0;
68
+ }
69
+
70
+ .title {
71
+ font-weight: 600;
72
+ font-size: 13px;
73
+ color: var(--console-snow);
74
+ overflow: hidden;
75
+ text-overflow: ellipsis;
76
+ white-space: nowrap;
77
+ }
78
+
79
+ .descInput {
80
+ width: 100%;
81
+ font: inherit;
82
+ font-size: 12px;
83
+ margin-top: 4px;
84
+ min-height: 40px;
85
+ color: var(--console-snow);
86
+ background: var(--console-soot);
87
+ border: 1px solid var(--console-mist);
88
+ border-radius: var(--r-md);
89
+ padding: 6px 8px;
90
+ }
91
+
92
+ .desc {
93
+ font-size: 12px;
94
+ font-weight: 400;
95
+ line-height: 1.45;
96
+ color: var(--console-fog);
97
+ margin-top: 4px;
98
+ display: -webkit-box;
99
+ -webkit-line-clamp: 2;
100
+ -webkit-box-orient: vertical;
101
+ overflow: hidden;
102
+ }
103
+
104
+ .phaseProgress {
105
+ display: flex;
106
+ align-items: center;
107
+ gap: 6px;
108
+ margin-top: 8px;
109
+ }
110
+ .phaseChip {
111
+ font-size: 10px;
112
+ font-weight: 500;
113
+ letter-spacing: 0.04em;
114
+ color: var(--console-fog);
115
+ background: transparent;
116
+ padding: 0;
117
+ white-space: nowrap;
118
+ }
119
+ .phaseBar {
120
+ flex: 1;
121
+ height: 2px;
122
+ background: var(--console-mist);
123
+ border-radius: var(--r-full);
124
+ overflow: hidden;
125
+ }
126
+ .phaseBarFill {
127
+ display: block;
128
+ height: 100%;
129
+ background: var(--signal-cyan);
130
+ transition: width var(--dur-entry) var(--ease);
131
+ }