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.
- package/README.md +32 -0
- package/bin/tenbo-dashboard.mjs +67 -0
- package/index.html +12 -0
- package/package.json +75 -0
- package/scripts/compute-metrics.test.ts +35 -0
- package/scripts/compute-metrics.ts +46 -0
- package/scripts/next-id.test.ts +128 -0
- package/scripts/next-id.ts +183 -0
- package/scripts/validate-cli.test.ts +152 -0
- package/scripts/validate-cli.ts +181 -0
- package/src/App.tsx +152 -0
- package/src/ErrorBoundary.tsx +25 -0
- package/src/api/client.ts +41 -0
- package/src/api/lib/frontmatterScan.ts +37 -0
- package/src/api/lib/health/agingTodos.test.ts +32 -0
- package/src/api/lib/health/agingTodos.ts +97 -0
- package/src/api/lib/health/archCompliance.test.ts +26 -0
- package/src/api/lib/health/archCompliance.ts +53 -0
- package/src/api/lib/health/collectAll.test.ts +49 -0
- package/src/api/lib/health/collectAll.ts +78 -0
- package/src/api/lib/health/config.test.ts +45 -0
- package/src/api/lib/health/config.ts +54 -0
- package/src/api/lib/health/coupling.test.ts +40 -0
- package/src/api/lib/health/coupling.ts +66 -0
- package/src/api/lib/health/deadCode.test.ts +23 -0
- package/src/api/lib/health/deadCode.ts +76 -0
- package/src/api/lib/health/docDrift.test.ts +54 -0
- package/src/api/lib/health/docDrift.ts +99 -0
- package/src/api/lib/health/hotspotFiles.test.ts +49 -0
- package/src/api/lib/health/hotspotFiles.ts +70 -0
- package/src/api/lib/health/importGraph.test.ts +29 -0
- package/src/api/lib/health/importGraph.ts +51 -0
- package/src/api/lib/health/layerFiles.test.ts +46 -0
- package/src/api/lib/health/layerFiles.ts +40 -0
- package/src/api/lib/health/redundancy.test.ts +24 -0
- package/src/api/lib/health/redundancy.ts +162 -0
- package/src/api/lib/health/testCoverage.test.ts +33 -0
- package/src/api/lib/health/testCoverage.ts +55 -0
- package/src/api/lib/health/types.test.ts +29 -0
- package/src/api/lib/health/types.ts +85 -0
- package/src/api/lib/http.ts +34 -0
- package/src/api/lib/metrics.test.ts +81 -0
- package/src/api/lib/metrics.ts +78 -0
- package/src/api/lib/metricsRefresh.test.ts +54 -0
- package/src/api/lib/metricsRefresh.ts +147 -0
- package/src/api/lib/phases.test.ts +76 -0
- package/src/api/lib/phases.ts +48 -0
- package/src/api/lib/relationships.ts +65 -0
- package/src/api/lib/repoRoot.ts +12 -0
- package/src/api/lib/tenboFs.layerContent.test.ts +36 -0
- package/src/api/lib/tenboFs.ts +237 -0
- package/src/api/lib/tenboFs.v2.test.ts +86 -0
- package/src/api/lib/tenboFs.workspaceContent.test.ts +42 -0
- package/src/api/lib/validator.docUpdate.test.ts +99 -0
- package/src/api/lib/validator.phases.test.ts +98 -0
- package/src/api/lib/validator.relationships.test.ts +126 -0
- package/src/api/lib/validator.specLinks.test.ts +96 -0
- package/src/api/lib/validator.ts +311 -0
- package/src/api/lib/validator.v2.test.ts +55 -0
- package/src/api/lib/yamlOrdered.ts +60 -0
- package/src/api/plugin.ts +31 -0
- package/src/api/routes/items.ts +25 -0
- package/src/api/routes/layerContent.ts +22 -0
- package/src/api/routes/layerDocs.ts +22 -0
- package/src/api/routes/open.ts +18 -0
- package/src/api/routes/related.ts +11 -0
- package/src/api/routes/reorder.ts +15 -0
- package/src/api/routes/state.ts +23 -0
- package/src/api/routes/watch.ts +31 -0
- package/src/css-modules.d.ts +4 -0
- package/src/hooks/useApiPatch.ts +9 -0
- package/src/hooks/useLayerContent.ts +25 -0
- package/src/hooks/usePrompt.ts +23 -0
- package/src/hooks/useTenboState.ts +35 -0
- package/src/main.tsx +13 -0
- package/src/prompts/populateLayer.ts +12 -0
- package/src/router/routes.ts +98 -0
- package/src/router/useHashRoute.test.ts +67 -0
- package/src/router/useHashRoute.ts +15 -0
- package/src/styles.css +228 -0
- package/src/test-setup.ts +1 -0
- package/src/types.ts +202 -0
- package/src/ui/DotGrid.module.css +7 -0
- package/src/ui/DotGrid.tsx +87 -0
- package/src/ui/EmptyState.module.css +11 -0
- package/src/ui/EmptyState.tsx +10 -0
- package/src/ui/FindingModal/EvidenceSection.tsx +51 -0
- package/src/ui/FindingModal/FindingModal.module.css +31 -0
- package/src/ui/FindingModal/FindingModal.test.tsx +36 -0
- package/src/ui/FindingModal/HeadlineSection.tsx +17 -0
- package/src/ui/FindingModal/RelatedFindingsSection.tsx +29 -0
- package/src/ui/FindingModal/SuggestionSection.tsx +13 -0
- package/src/ui/FindingModal/index.tsx +36 -0
- package/src/ui/HealthPage/Digest.tsx +29 -0
- package/src/ui/HealthPage/FindingRow.tsx +21 -0
- package/src/ui/HealthPage/HealthPage.module.css +45 -0
- package/src/ui/HealthPage/HealthPage.test.tsx +49 -0
- package/src/ui/HealthPage/HealthPage.tsx +66 -0
- package/src/ui/HealthPage/LayerCard.tsx +36 -0
- package/src/ui/HealthPage/SeverityIcon.tsx +29 -0
- package/src/ui/HealthPage/SeverityLegend.tsx +27 -0
- package/src/ui/HealthPage/severity.test.ts +46 -0
- package/src/ui/HealthPage/severity.ts +21 -0
- package/src/ui/ItemCard.module.css +131 -0
- package/src/ui/ItemCard.tsx +117 -0
- package/src/ui/ItemModal/DescriptionField.tsx +22 -0
- package/src/ui/ItemModal/EnrichmentSections.tsx +49 -0
- package/src/ui/ItemModal/ItemModal.module.css +200 -0
- package/src/ui/ItemModal/LinksSection.tsx +26 -0
- package/src/ui/ItemModal/NotesSection.tsx +18 -0
- package/src/ui/ItemModal/PhasesSection.tsx +79 -0
- package/src/ui/ItemModal/RelatedSection.tsx +26 -0
- package/src/ui/ItemModal/RelationshipsSection.tsx +69 -0
- package/src/ui/ItemModal/StatusLayerControls.tsx +43 -0
- package/src/ui/ItemModal/TitleField.tsx +21 -0
- package/src/ui/ItemModal/index.tsx +117 -0
- package/src/ui/KanbanColumn.module.css +20 -0
- package/src/ui/KanbanColumn.tsx +36 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.module.css +11 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.test.tsx +30 -0
- package/src/ui/LayerDetailPage/LayerDetailPage.tsx +59 -0
- package/src/ui/LayerDetailPage/SignalSection.tsx +33 -0
- package/src/ui/LayerDrawer.module.css +60 -0
- package/src/ui/LayerDrawer.tsx +69 -0
- package/src/ui/LayerKanban.module.css +45 -0
- package/src/ui/LayerKanban.tsx +65 -0
- package/src/ui/LayerPage/FillThisInButton.tsx +11 -0
- package/src/ui/LayerPage/LayerPage.module.css +52 -0
- package/src/ui/LayerPage/LayerPage.tsx +76 -0
- package/src/ui/LayerPage/MetricsBadges.tsx +18 -0
- package/src/ui/LayerPage/isTemplateOnly.test.ts +45 -0
- package/src/ui/LayerPage/isTemplateOnly.ts +19 -0
- package/src/ui/LayerPage/tabs/CodeMapTab.tsx +19 -0
- package/src/ui/LayerPage/tabs/DeepDivesTab.tsx +35 -0
- package/src/ui/LayerPage/tabs/IntentTab.tsx +19 -0
- package/src/ui/LayerPage/tabs/PlainEnglishTab.tsx +19 -0
- package/src/ui/Markdown.module.css +115 -0
- package/src/ui/Markdown.tsx +27 -0
- package/src/ui/RoadmapPage/RoadmapPage.module.css +93 -0
- package/src/ui/RoadmapPage/RoadmapPage.tsx +255 -0
- package/src/ui/ScopePage/ScopePage.module.css +72 -0
- package/src/ui/ScopePage/ScopePage.tsx +131 -0
- package/src/ui/SummaryStrip.module.css +18 -0
- package/src/ui/SummaryStrip.tsx +16 -0
- package/src/ui/TaskList.module.css +136 -0
- package/src/ui/TaskList.tsx +91 -0
- package/src/ui/TopBar.module.css +78 -0
- package/src/ui/TopBar.tsx +48 -0
- package/src/ui/WorkspacePage/WorkspacePage.module.css +24 -0
- package/src/ui/WorkspacePage/WorkspacePage.tsx +36 -0
- package/src/ui/WorkspacePage/tabs/GlossaryTab.tsx +6 -0
- package/src/ui/WorkspacePage/tabs/OverviewTab.tsx +28 -0
- package/src/ui/WorkspacePage/tabs/PrinciplesTab.tsx +6 -0
- package/tsconfig.json +18 -0
- package/vite.config.ts +9 -0
package/src/styles.css
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* tenbo dashboard — design tokens
|
|
3
|
+
* Spec: DESIGN.md (north star: "The Side Console")
|
|
4
|
+
* Console palette is canonical. Legacy semantic vars (--bg, --fg, etc.)
|
|
5
|
+
* alias to Console tokens so existing component CSS inherits the new look.
|
|
6
|
+
*/
|
|
7
|
+
:root {
|
|
8
|
+
/* Console stack — neutrals, OKLCH hue 60 (warm rust tint) */
|
|
9
|
+
--console-black: #1a1816;
|
|
10
|
+
--console-soot: #211f1c;
|
|
11
|
+
--console-iron: #2a2724;
|
|
12
|
+
--console-mist: #36322e;
|
|
13
|
+
--console-steel: #524c46;
|
|
14
|
+
--console-smoke: #7a736d;
|
|
15
|
+
--console-fog: #aba39e;
|
|
16
|
+
--console-snow: #f1edeb;
|
|
17
|
+
|
|
18
|
+
/* Signals — single interactive accent + reserved status colors */
|
|
19
|
+
--signal-cyan: #4fc3d2;
|
|
20
|
+
--signal-cyan-deep: #2f96a3;
|
|
21
|
+
--live-green: #4dc089;
|
|
22
|
+
--slow-amber: #e8b85b;
|
|
23
|
+
--halt-red: #e0644f;
|
|
24
|
+
|
|
25
|
+
/* Geometry */
|
|
26
|
+
--r-sm: 4px;
|
|
27
|
+
--r-md: 8px;
|
|
28
|
+
--r-lg: 12px;
|
|
29
|
+
--r-full: 9999px;
|
|
30
|
+
|
|
31
|
+
/* Spacing — 8px base, with one half-step */
|
|
32
|
+
--s-half: 4px;
|
|
33
|
+
--s-1: 8px;
|
|
34
|
+
--s-2: 16px;
|
|
35
|
+
--s-3: 24px;
|
|
36
|
+
--s-4: 32px;
|
|
37
|
+
--s-6: 48px;
|
|
38
|
+
--s-8: 64px;
|
|
39
|
+
|
|
40
|
+
/* Motion */
|
|
41
|
+
--ease: cubic-bezier(0.25, 1, 0.5, 1);
|
|
42
|
+
--dur-state: 120ms;
|
|
43
|
+
--dur-entry: 200ms;
|
|
44
|
+
|
|
45
|
+
/* Elevation (modals only) */
|
|
46
|
+
--shadow-modal: 0 16px 48px -8px rgba(0, 0, 0, 0.6);
|
|
47
|
+
|
|
48
|
+
/* Legacy semantic aliases — point at Console tokens */
|
|
49
|
+
--bg: var(--console-black);
|
|
50
|
+
--bg-2: var(--console-soot);
|
|
51
|
+
--fg: var(--console-snow);
|
|
52
|
+
--muted: var(--console-fog);
|
|
53
|
+
--border: var(--console-mist);
|
|
54
|
+
--accent: var(--signal-cyan);
|
|
55
|
+
--status-now: var(--live-green);
|
|
56
|
+
--status-next: var(--signal-cyan);
|
|
57
|
+
--status-later: var(--console-steel);
|
|
58
|
+
--status-done: var(--console-smoke);
|
|
59
|
+
--error: var(--halt-red);
|
|
60
|
+
--warning: var(--slow-amber);
|
|
61
|
+
|
|
62
|
+
/* Forward-looking semantic aliases used by individual components */
|
|
63
|
+
--surface: var(--console-soot);
|
|
64
|
+
--surface-muted: var(--console-iron);
|
|
65
|
+
--foreground: var(--console-snow);
|
|
66
|
+
--text-muted: var(--console-fog);
|
|
67
|
+
--code-bg: var(--console-iron);
|
|
68
|
+
|
|
69
|
+
/* Layering — nav bars sit at 40–50; modals must clear them */
|
|
70
|
+
--z-drawer: 10;
|
|
71
|
+
--z-drawer-content: 11;
|
|
72
|
+
--z-header: 50;
|
|
73
|
+
--z-modal: 100;
|
|
74
|
+
--z-modal-content: 101;
|
|
75
|
+
--z-validate: 110;
|
|
76
|
+
--z-validate-content: 111;
|
|
77
|
+
}
|
|
78
|
+
* { box-sizing: border-box; }
|
|
79
|
+
body {
|
|
80
|
+
margin: 0;
|
|
81
|
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
|
82
|
+
font-size: 13px;
|
|
83
|
+
font-weight: 400;
|
|
84
|
+
line-height: 1.5;
|
|
85
|
+
font-feature-settings: 'cv11', 'ss03';
|
|
86
|
+
color: var(--fg);
|
|
87
|
+
background: var(--bg);
|
|
88
|
+
-webkit-font-smoothing: antialiased;
|
|
89
|
+
}
|
|
90
|
+
button {
|
|
91
|
+
font: inherit;
|
|
92
|
+
color: inherit;
|
|
93
|
+
background: transparent;
|
|
94
|
+
border: none;
|
|
95
|
+
padding: 0;
|
|
96
|
+
cursor: pointer;
|
|
97
|
+
}
|
|
98
|
+
input, select, textarea {
|
|
99
|
+
font: inherit;
|
|
100
|
+
color: var(--fg);
|
|
101
|
+
background: var(--console-soot);
|
|
102
|
+
border: 1px solid var(--border);
|
|
103
|
+
border-radius: var(--r-md);
|
|
104
|
+
padding: 6px 10px;
|
|
105
|
+
outline: none;
|
|
106
|
+
transition: border-color var(--dur-state) var(--ease);
|
|
107
|
+
}
|
|
108
|
+
input::placeholder, textarea::placeholder { color: var(--console-smoke); }
|
|
109
|
+
input:focus-visible, select:focus-visible, textarea:focus-visible {
|
|
110
|
+
border-color: var(--signal-cyan);
|
|
111
|
+
}
|
|
112
|
+
select {
|
|
113
|
+
appearance: none;
|
|
114
|
+
-webkit-appearance: none;
|
|
115
|
+
padding-right: 28px;
|
|
116
|
+
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23aba39e' stroke-width='1.75' stroke-linecap='round' stroke-linejoin='round'><polyline points='6 9 12 15 18 9'/></svg>");
|
|
117
|
+
background-repeat: no-repeat;
|
|
118
|
+
background-position: right 8px center;
|
|
119
|
+
}
|
|
120
|
+
hr { border: none; border-top: 1px solid var(--border); }
|
|
121
|
+
:focus-visible { outline: 2px solid var(--signal-cyan); outline-offset: 2px; border-radius: var(--r-sm); }
|
|
122
|
+
|
|
123
|
+
/* Anchor styling — Signal Cyan, no underline reset on bare links */
|
|
124
|
+
a {
|
|
125
|
+
color: var(--signal-cyan);
|
|
126
|
+
text-decoration: none;
|
|
127
|
+
transition: color var(--dur-state) var(--ease);
|
|
128
|
+
}
|
|
129
|
+
a:hover { color: var(--console-snow); text-decoration: underline; text-underline-offset: 3px; }
|
|
130
|
+
|
|
131
|
+
/* Tab-strip pattern: bare buttons with aria-pressed.
|
|
132
|
+
Applies wherever the codebase already uses this semantic. */
|
|
133
|
+
nav button[aria-pressed] {
|
|
134
|
+
padding: 6px 10px;
|
|
135
|
+
margin-right: 4px;
|
|
136
|
+
border-radius: var(--r-md);
|
|
137
|
+
color: var(--console-fog);
|
|
138
|
+
font-weight: 600;
|
|
139
|
+
font-size: 13px;
|
|
140
|
+
transition: background var(--dur-state) var(--ease), color var(--dur-state) var(--ease);
|
|
141
|
+
}
|
|
142
|
+
nav button[aria-pressed]:hover { color: var(--console-snow); background: var(--console-soot); }
|
|
143
|
+
nav button[aria-pressed="true"] { color: var(--console-snow); background: var(--console-iron); }
|
|
144
|
+
@media (prefers-reduced-motion: reduce) {
|
|
145
|
+
* { transition: none !important; animation: none !important; }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/* Utility classes (used across components) */
|
|
149
|
+
.link-button {
|
|
150
|
+
background: transparent;
|
|
151
|
+
border: none;
|
|
152
|
+
color: var(--accent);
|
|
153
|
+
cursor: pointer;
|
|
154
|
+
text-decoration: underline;
|
|
155
|
+
padding: 0;
|
|
156
|
+
font: inherit;
|
|
157
|
+
}
|
|
158
|
+
.muted-text {
|
|
159
|
+
color: var(--muted);
|
|
160
|
+
font-size: 12px;
|
|
161
|
+
}
|
|
162
|
+
.muted-small {
|
|
163
|
+
color: var(--muted);
|
|
164
|
+
font-size: 11px;
|
|
165
|
+
}
|
|
166
|
+
.bare-button {
|
|
167
|
+
background: transparent;
|
|
168
|
+
border: none;
|
|
169
|
+
cursor: pointer;
|
|
170
|
+
font: inherit;
|
|
171
|
+
padding: 0;
|
|
172
|
+
}
|
|
173
|
+
.outlined-button {
|
|
174
|
+
background: transparent;
|
|
175
|
+
color: var(--fg);
|
|
176
|
+
border: 1px solid var(--border);
|
|
177
|
+
border-radius: var(--r-md);
|
|
178
|
+
padding: 6px 10px;
|
|
179
|
+
cursor: pointer;
|
|
180
|
+
font: inherit;
|
|
181
|
+
transition: background var(--dur-state) var(--ease), border-color var(--dur-state) var(--ease);
|
|
182
|
+
}
|
|
183
|
+
.outlined-button:hover {
|
|
184
|
+
background: var(--console-soot);
|
|
185
|
+
border-color: var(--console-steel);
|
|
186
|
+
}
|
|
187
|
+
.close-x {
|
|
188
|
+
float: right;
|
|
189
|
+
display: inline-flex;
|
|
190
|
+
align-items: center;
|
|
191
|
+
justify-content: center;
|
|
192
|
+
width: 28px;
|
|
193
|
+
height: 28px;
|
|
194
|
+
background: transparent;
|
|
195
|
+
border: none;
|
|
196
|
+
border-radius: var(--r-md);
|
|
197
|
+
color: var(--console-fog);
|
|
198
|
+
cursor: pointer;
|
|
199
|
+
transition: background var(--dur-state) var(--ease), color var(--dur-state) var(--ease);
|
|
200
|
+
}
|
|
201
|
+
.close-x:hover { background: var(--console-iron); color: var(--console-snow); }
|
|
202
|
+
|
|
203
|
+
/* icon + label flex utility — gap matches Inter cap-height rhythm */
|
|
204
|
+
.icon-text {
|
|
205
|
+
display: inline-flex;
|
|
206
|
+
align-items: center;
|
|
207
|
+
gap: 6px;
|
|
208
|
+
}
|
|
209
|
+
/* Lucide icons inherit text color; align to baseline implicitly via flex */
|
|
210
|
+
.bare-button {
|
|
211
|
+
display: inline-flex;
|
|
212
|
+
align-items: center;
|
|
213
|
+
}
|
|
214
|
+
.overlay-backdrop {
|
|
215
|
+
position: fixed;
|
|
216
|
+
inset: 0;
|
|
217
|
+
background: rgba(0, 0, 0, 0.4);
|
|
218
|
+
}
|
|
219
|
+
.dim-backdrop {
|
|
220
|
+
position: fixed;
|
|
221
|
+
inset: 0;
|
|
222
|
+
background: rgba(0, 0, 0, 0.3);
|
|
223
|
+
}
|
|
224
|
+
hr.section-divider {
|
|
225
|
+
border: none;
|
|
226
|
+
border-top: 1px solid var(--border);
|
|
227
|
+
margin: 16px 0;
|
|
228
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import '@testing-library/jest-dom';
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import type { Finding } from './api/lib/health/types';
|
|
2
|
+
|
|
3
|
+
// Naming convention:
|
|
4
|
+
// - snake_case fields mirror on-disk JSON/YAML keys (e.g., `description` from architecture.yaml,
|
|
5
|
+
// `file_count` from metrics.json). Do not rename these — they round-trip with the file format.
|
|
6
|
+
// - camelCase fields are in-memory composed state assembled by the viewer (e.g., `crossCuttingRoadmap`,
|
|
7
|
+
// `layerDocs`). Renaming these is purely an internal refactor.
|
|
8
|
+
|
|
9
|
+
export type Status = 'now' | 'next' | 'later' | 'done';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Advisory priority marker. Does NOT affect roadmap ordering — items still execute
|
|
13
|
+
* in `status` bucket + file order (see Behavior 6). Priority is purely a flag agents
|
|
14
|
+
* and humans can use to highlight critical items in the viewer or in queries.
|
|
15
|
+
* Optional: most items leave it unset.
|
|
16
|
+
*/
|
|
17
|
+
export type Priority = 'p0' | 'p1' | 'p2' | 'p3';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Optional per-phase progress entry for multi-phase roadmap items.
|
|
21
|
+
*
|
|
22
|
+
* Phases are ordered by their position in the YAML list. The numeric `id` is for
|
|
23
|
+
* stable references (`<item-id>.p<id>`); the validator checks ids are 1..N positional.
|
|
24
|
+
*
|
|
25
|
+
* When `phases:` is present on an item, the item's top-level `status` is derived
|
|
26
|
+
* (`done` if every phase is done; `now` if any phase is `now`; otherwise `next`
|
|
27
|
+
* if any phase is `next`; otherwise `later`). See `derivePhaseStatus` in
|
|
28
|
+
* `phases.ts`.
|
|
29
|
+
*/
|
|
30
|
+
export interface Phase {
|
|
31
|
+
id: number;
|
|
32
|
+
title: string;
|
|
33
|
+
status: Status;
|
|
34
|
+
/** YYYY-MM-DD; set when status becomes `done`. */
|
|
35
|
+
completed_at?: string;
|
|
36
|
+
notes?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface Item {
|
|
40
|
+
id: string; // rm-NNN
|
|
41
|
+
title: string;
|
|
42
|
+
layer?: string; // single-layer items
|
|
43
|
+
layers?: string[]; // cross-scope items
|
|
44
|
+
status: Status;
|
|
45
|
+
description: string;
|
|
46
|
+
links?: string[];
|
|
47
|
+
notes?: string;
|
|
48
|
+
/** layer ids touched by this item (bare ids for same-scope items, "<scope>:<layer>" for cross-scope items) */
|
|
49
|
+
affects?: string[];
|
|
50
|
+
/** 1–3 plain-language bullets defining "done" */
|
|
51
|
+
done_when?: string[];
|
|
52
|
+
/** repo-relative file paths that future implementers should read first */
|
|
53
|
+
files_to_read?: string[];
|
|
54
|
+
/** freeform bullets noting known unknowns or risks */
|
|
55
|
+
risks?: string[];
|
|
56
|
+
type?: 'feature' | 'bug' | 'refactor' | 'spike';
|
|
57
|
+
priority?: Priority;
|
|
58
|
+
/** Optional multi-phase progress. When present, item-level `status` is derived. */
|
|
59
|
+
phases?: Phase[];
|
|
60
|
+
/**
|
|
61
|
+
* At-most-one parent: the id of the roadmap item that surfaced/dispatched this one.
|
|
62
|
+
* Format matches the standard item id pattern (`<prefix>-NNN` or `x-NNN`).
|
|
63
|
+
* Convention (Behavior 5 DoD): when a subagent's report surfaces a follow-up item,
|
|
64
|
+
* the new item should set `spawned_from` to the dispatching item's id.
|
|
65
|
+
*/
|
|
66
|
+
spawned_from?: string;
|
|
67
|
+
/**
|
|
68
|
+
* Peer relationships. Any number of ids in the same format. Order is not significant.
|
|
69
|
+
* Not parent–child — for that use `spawned_from`.
|
|
70
|
+
*/
|
|
71
|
+
related?: string[];
|
|
72
|
+
/**
|
|
73
|
+
* Stamped during Behavior 5 DoD when this item flips to `done`.
|
|
74
|
+
* Either an ISO date (`YYYY-MM-DD`) confirming the layer's intent.md/code-map.md
|
|
75
|
+
* was updated as part of completing this item, OR `skipped — <reason>` for
|
|
76
|
+
* internal-only changes with no architecture surface impact. The validator
|
|
77
|
+
* (x-003 phase 3) warns when an item with status:done and type ∈ {feature,
|
|
78
|
+
* refactor, bug} lacks this field.
|
|
79
|
+
*/
|
|
80
|
+
doc_update?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface Layer {
|
|
84
|
+
id: string;
|
|
85
|
+
name: string;
|
|
86
|
+
description: string;
|
|
87
|
+
files: string[];
|
|
88
|
+
dependencies?: {
|
|
89
|
+
/** layer ids in same scope that import from this layer */
|
|
90
|
+
inbound?: string[];
|
|
91
|
+
/** layer ids in same scope that this layer imports from */
|
|
92
|
+
outbound?: string[];
|
|
93
|
+
/** package names or external service identifiers (e.g., "anthropic-sdk", "supabase-js"); not layer ids */
|
|
94
|
+
external?: string[];
|
|
95
|
+
};
|
|
96
|
+
parent?: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface Scope {
|
|
100
|
+
id: string;
|
|
101
|
+
path: string;
|
|
102
|
+
description: string;
|
|
103
|
+
layers: Layer[];
|
|
104
|
+
items: Item[];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface CrossCutting {
|
|
108
|
+
id: string;
|
|
109
|
+
description: string;
|
|
110
|
+
spans: string[];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export interface LayerMetrics {
|
|
114
|
+
file_count: number;
|
|
115
|
+
total_lines: number;
|
|
116
|
+
outbound_deps: number;
|
|
117
|
+
deep_dive_count: number;
|
|
118
|
+
intent_age_days: number | null;
|
|
119
|
+
pct_roadmap_in_now: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface ScopeMetrics {
|
|
123
|
+
/** ISO 8601 UTC timestamp */
|
|
124
|
+
generated_at: string;
|
|
125
|
+
layers: Record<string, LayerMetrics>;
|
|
126
|
+
/** Findings produced by health analyzers. Empty array until Phase 1 is wired. */
|
|
127
|
+
findings: Finding[];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** In-memory file-presence flags; composed by tenboFs from filesystem checks. Not on disk. */
|
|
131
|
+
export interface LayerDocs {
|
|
132
|
+
hasIntent: boolean;
|
|
133
|
+
hasCodeMap: boolean;
|
|
134
|
+
/** epoch milliseconds (matches fs.statSync().mtimeMs and Date.now()); null if intent.md absent */
|
|
135
|
+
intentMtime: number | null;
|
|
136
|
+
/** epoch milliseconds; null if code-map.md absent */
|
|
137
|
+
codeMapMtime: number | null;
|
|
138
|
+
/** true if intent.md exists but contains no non-comment, non-heading content */
|
|
139
|
+
intentEmpty: boolean;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export interface TenboState {
|
|
143
|
+
scopes: Scope[];
|
|
144
|
+
crossCutting: CrossCutting[];
|
|
145
|
+
narratives: Record<string, string>; // key: "<scope>/<layer>", value: markdown
|
|
146
|
+
workspaceContent: WorkspaceContent;
|
|
147
|
+
crossCuttingRoadmap?: Item[];
|
|
148
|
+
layerDocs?: Record<string, LayerDocs>;
|
|
149
|
+
metrics?: Record<string, ScopeMetrics>;
|
|
150
|
+
/**
|
|
151
|
+
* Set of repo-relative paths of every file present under `.tenbo/specs/`
|
|
152
|
+
* (including `.tenbo/specs/archive/`). Populated by tenboFs.readState; used
|
|
153
|
+
* by the validator to check that `links:` entries pointing into specs
|
|
154
|
+
* resolve to a real file. Absent when `.tenbo/specs/` does not exist.
|
|
155
|
+
*/
|
|
156
|
+
specFiles?: Set<string>;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type ValidateLevel = 'error' | 'warning';
|
|
160
|
+
|
|
161
|
+
export interface ValidateIssue {
|
|
162
|
+
level: ValidateLevel;
|
|
163
|
+
message: string; // plain language
|
|
164
|
+
scope?: string;
|
|
165
|
+
layerId?: string;
|
|
166
|
+
itemId?: string;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export interface ValidateResult {
|
|
170
|
+
errors: ValidateIssue[];
|
|
171
|
+
warnings: ValidateIssue[];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export interface RelatedDoc {
|
|
175
|
+
path: string; // relative to repo root
|
|
176
|
+
itemId: string; // tenbo_item value from frontmatter
|
|
177
|
+
title?: string; // first heading or filename
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export interface LayerDoc {
|
|
181
|
+
filename: string;
|
|
182
|
+
title: string | null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
export interface WorkspaceContent {
|
|
186
|
+
overviewMd: string;
|
|
187
|
+
principlesMd: string;
|
|
188
|
+
glossaryMd: string;
|
|
189
|
+
observationsMd: string;
|
|
190
|
+
overviewMtime: number | null;
|
|
191
|
+
principlesMtime: number | null;
|
|
192
|
+
glossaryMtime: number | null;
|
|
193
|
+
observationsMtime: number | null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface LayerContent {
|
|
197
|
+
scope: string;
|
|
198
|
+
layer: string;
|
|
199
|
+
readme: string;
|
|
200
|
+
intentMd: string;
|
|
201
|
+
codeMapMd: string;
|
|
202
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react';
|
|
2
|
+
import styles from './DotGrid.module.css';
|
|
3
|
+
|
|
4
|
+
const COLS = 48;
|
|
5
|
+
const ROWS = 32;
|
|
6
|
+
const SPACING = 40;
|
|
7
|
+
const DOT_R = 1.5;
|
|
8
|
+
const ACTIVE_COUNT = 5;
|
|
9
|
+
const GLOW_DURATION = 5000;
|
|
10
|
+
|
|
11
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
12
|
+
const DOT_COLOR = '#36322e'; // --console-mist
|
|
13
|
+
const GLOW_COLOR = '#aba39e'; // --console-fog
|
|
14
|
+
|
|
15
|
+
export function DotGrid() {
|
|
16
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
const container = ref.current;
|
|
20
|
+
if (!container) return;
|
|
21
|
+
|
|
22
|
+
// Respect prefers-reduced-motion
|
|
23
|
+
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
24
|
+
if (mq.matches) return;
|
|
25
|
+
|
|
26
|
+
const svg = document.createElementNS(NS, 'svg');
|
|
27
|
+
svg.setAttribute('width', '100%');
|
|
28
|
+
svg.setAttribute('height', '100%');
|
|
29
|
+
svg.setAttribute('viewBox', `0 0 ${COLS * SPACING} ${ROWS * SPACING}`);
|
|
30
|
+
svg.setAttribute('preserveAspectRatio', 'xMidYMid slice');
|
|
31
|
+
svg.style.cssText = 'position:absolute;inset:0;';
|
|
32
|
+
|
|
33
|
+
const dots: SVGCircleElement[] = [];
|
|
34
|
+
for (let r = 0; r < ROWS; r++) {
|
|
35
|
+
for (let c = 0; c < COLS; c++) {
|
|
36
|
+
const dot = document.createElementNS(NS, 'circle');
|
|
37
|
+
dot.setAttribute('cx', String(c * SPACING + SPACING / 2));
|
|
38
|
+
dot.setAttribute('cy', String(r * SPACING + SPACING / 2));
|
|
39
|
+
dot.setAttribute('r', String(DOT_R));
|
|
40
|
+
dot.setAttribute('fill', DOT_COLOR);
|
|
41
|
+
dot.setAttribute('opacity', '0.35');
|
|
42
|
+
svg.appendChild(dot);
|
|
43
|
+
dots.push(dot);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
container.appendChild(svg);
|
|
48
|
+
|
|
49
|
+
let cancelled = false;
|
|
50
|
+
|
|
51
|
+
function pickRandom(n: number): number[] {
|
|
52
|
+
const indices = new Set<number>();
|
|
53
|
+
while (indices.size < n) {
|
|
54
|
+
indices.add(Math.floor(Math.random() * dots.length));
|
|
55
|
+
}
|
|
56
|
+
return [...indices];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function glowBatch() {
|
|
60
|
+
if (cancelled) return;
|
|
61
|
+
const chosen = pickRandom(ACTIVE_COUNT);
|
|
62
|
+
|
|
63
|
+
const animations = chosen.map((idx) =>
|
|
64
|
+
dots[idx].animate(
|
|
65
|
+
[
|
|
66
|
+
{ fill: DOT_COLOR, opacity: 0.35 },
|
|
67
|
+
{ fill: GLOW_COLOR, opacity: 0.9, offset: 0.15 },
|
|
68
|
+
{ fill: GLOW_COLOR, opacity: 0.9, offset: 0.85 },
|
|
69
|
+
{ fill: DOT_COLOR, opacity: 0.35 },
|
|
70
|
+
],
|
|
71
|
+
{ duration: GLOW_DURATION, easing: 'ease-in-out' },
|
|
72
|
+
),
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
animations[animations.length - 1].onfinish = () => glowBatch();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
glowBatch();
|
|
79
|
+
|
|
80
|
+
return () => {
|
|
81
|
+
cancelled = true;
|
|
82
|
+
container.removeChild(svg);
|
|
83
|
+
};
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
return <div ref={ref} className={styles.backdrop} />;
|
|
87
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import styles from './EmptyState.module.css';
|
|
2
|
+
|
|
3
|
+
export function EmptyState({ message }: { message: string }) {
|
|
4
|
+
return (
|
|
5
|
+
<div className={styles.empty}>
|
|
6
|
+
<p>{message}</p>
|
|
7
|
+
<p className={styles.hint}>Run <code>set up tenbo</code> in Claude Code, then reload.</p>
|
|
8
|
+
</div>
|
|
9
|
+
);
|
|
10
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { Finding } from '../../api/lib/health/types';
|
|
2
|
+
import styles from './FindingModal.module.css';
|
|
3
|
+
|
|
4
|
+
export function EvidenceSection({ finding }: { finding: Finding }) {
|
|
5
|
+
const d = finding.details;
|
|
6
|
+
return (
|
|
7
|
+
<section className={styles.section}>
|
|
8
|
+
<h3 className={styles.sectionTitle}>Evidence</h3>
|
|
9
|
+
{d.kind === 'hotspot-files' && (
|
|
10
|
+
<ul className={styles.kvList}>
|
|
11
|
+
<li><span>LOC</span><span>{d.loc.toLocaleString()}</span></li>
|
|
12
|
+
<li><span>Commits in last 30d</span><span>{d.commits_30d}</span></li>
|
|
13
|
+
{d.top_functions.length > 0 && (
|
|
14
|
+
<li><span>Top functions</span><span>{d.top_functions.map(fn => `${fn.name} (${fn.loc})`).join(', ')}</span></li>
|
|
15
|
+
)}
|
|
16
|
+
</ul>
|
|
17
|
+
)}
|
|
18
|
+
{d.kind === 'aging-todos' && (
|
|
19
|
+
<ul className={styles.kvList}>
|
|
20
|
+
<li><span>Line</span><span>{finding.target}:{d.line}</span></li>
|
|
21
|
+
<li><span>Age</span><span>{d.age_days} days</span></li>
|
|
22
|
+
<li><span>Author</span><span>{d.author}</span></li>
|
|
23
|
+
<li><span>Commit</span><span><code>{d.commit_hash.slice(0, 7)}</code></span></li>
|
|
24
|
+
<li><span>Text</span><span><code>{d.text}</code></span></li>
|
|
25
|
+
</ul>
|
|
26
|
+
)}
|
|
27
|
+
{d.kind === 'doc-drift' && (
|
|
28
|
+
<ul className={styles.kvList}>
|
|
29
|
+
<li><span>Drift type</span><span>{d.drift_type}</span></li>
|
|
30
|
+
<li><span>Doc</span><span>{d.doc_path}</span></li>
|
|
31
|
+
<li><span>Affected files</span><span>{d.affected_files.join(', ')}</span></li>
|
|
32
|
+
</ul>
|
|
33
|
+
)}
|
|
34
|
+
{d.kind === 'test-coverage' && (
|
|
35
|
+
<ul className={styles.kvList}>
|
|
36
|
+
<li><span>Suggested test path</span><span><code>{d.suggested_test_path}</code></span></li>
|
|
37
|
+
</ul>
|
|
38
|
+
)}
|
|
39
|
+
{d.kind === 'architecture-compliance' && (
|
|
40
|
+
<ul className={styles.kvList}>
|
|
41
|
+
<li><span>Expected</span><span><code>{d.expected_path_pattern}</code></span></li>
|
|
42
|
+
<li><span>Actual</span><span><code>{d.actual_path}</code></span></li>
|
|
43
|
+
<li><span>Rule</span><span>{d.rule}</span></li>
|
|
44
|
+
</ul>
|
|
45
|
+
)}
|
|
46
|
+
{(d.kind === 'dead-code' || d.kind === 'coupling' || d.kind === 'redundancy') && (
|
|
47
|
+
<pre className={styles.rawDetails}>{JSON.stringify(d, null, 2)}</pre>
|
|
48
|
+
)}
|
|
49
|
+
</section>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
.backdrop { z-index: var(--z-modal); background: rgba(0, 0, 0, 0.6); }
|
|
2
|
+
.modal {
|
|
3
|
+
position: fixed;
|
|
4
|
+
top: 8vh;
|
|
5
|
+
left: 50%;
|
|
6
|
+
transform: translateX(-50%);
|
|
7
|
+
width: min(640px, calc(100vw - 32px));
|
|
8
|
+
max-height: 84vh;
|
|
9
|
+
overflow-y: auto;
|
|
10
|
+
background: var(--console-iron, #1a1a1a);
|
|
11
|
+
border-radius: var(--r-lg, 12px);
|
|
12
|
+
padding: 20px 24px 24px;
|
|
13
|
+
box-shadow: var(--shadow-modal, 0 20px 50px rgba(0, 0, 0, 0.5));
|
|
14
|
+
z-index: var(--z-modal-content);
|
|
15
|
+
}
|
|
16
|
+
.headline { margin-bottom: 16px; }
|
|
17
|
+
.chips { display: flex; gap: 6px; margin-bottom: 8px; flex-wrap: wrap; }
|
|
18
|
+
.chip { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; padding: 2px 8px; border-radius: 12px; background: var(--hover, #1a1a1a); color: var(--muted, #aaa); }
|
|
19
|
+
.title { font-size: 18px; font-weight: 600; margin: 0; }
|
|
20
|
+
.targetLink { font-family: ui-monospace, monospace; font-size: 12px; color: var(--link, #4299e1); background: transparent; border: none; padding: 0; cursor: pointer; margin-bottom: 16px; }
|
|
21
|
+
.section { margin-top: 20px; }
|
|
22
|
+
.sectionTitle { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px; color: var(--muted, #888); margin: 0 0 8px; }
|
|
23
|
+
.suggestionSummary { font-weight: 500; margin: 4px 0; }
|
|
24
|
+
.suggestionRationale { color: var(--muted, #aaa); font-size: 13px; margin: 4px 0; }
|
|
25
|
+
.actionKind { font-size: 12px; color: var(--muted, #888); }
|
|
26
|
+
.kvList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
|
|
27
|
+
.kvList li { display: grid; grid-template-columns: 160px 1fr; gap: 12px; }
|
|
28
|
+
.kvList li > span:first-child { color: var(--muted, #888); }
|
|
29
|
+
.relatedList { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
30
|
+
.relatedLink { background: transparent; border: none; color: var(--link, #4299e1); cursor: pointer; padding: 0; text-align: left; font: inherit; font-size: 13px; }
|
|
31
|
+
.rawDetails { font-family: ui-monospace, monospace; font-size: 11px; color: var(--muted, #888); white-space: pre-wrap; }
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { FindingModal } from './index';
|
|
4
|
+
import type { Finding } from '../../api/lib/health/types';
|
|
5
|
+
|
|
6
|
+
const finding: Finding = {
|
|
7
|
+
id: 'editor.hotspot-files.foo', signal: 'hotspot-files', severity: 'critical',
|
|
8
|
+
confidence: 'high', layer: 'visual-canvas', target: 'apps/editor/src/foo.ts',
|
|
9
|
+
headline: 'foo.ts (1500 LOC)',
|
|
10
|
+
suggestion: { summary: 'Split foo.ts', rationale: 'Too big.', action_kind: 'split-file' },
|
|
11
|
+
details: { kind: 'hotspot-files', loc: 1500, top_functions: [], commits_30d: 5, split_candidates: [] },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
describe('FindingModal', () => {
|
|
15
|
+
it('renders nothing when finding is null', () => {
|
|
16
|
+
const { container } = render(<FindingModal finding={null} allFindings={[]} onClose={vi.fn()} onOpenFile={vi.fn()} />);
|
|
17
|
+
expect(container.firstChild).toBeNull();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('renders headline, target, suggestion summary, and rationale', () => {
|
|
21
|
+
render(<FindingModal finding={finding} allFindings={[finding]} onClose={vi.fn()} onOpenFile={vi.fn()} />);
|
|
22
|
+
expect(screen.getByText(/foo\.ts \(1500 LOC\)/)).toBeInTheDocument();
|
|
23
|
+
expect(screen.getByText(/apps\/editor\/src\/foo\.ts/)).toBeInTheDocument();
|
|
24
|
+
expect(screen.getByText(/Split foo\.ts/)).toBeInTheDocument();
|
|
25
|
+
expect(screen.getByText(/Too big\./)).toBeInTheDocument();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('calls onClose when backdrop is clicked', () => {
|
|
29
|
+
const onClose = vi.fn();
|
|
30
|
+
render(<FindingModal finding={finding} allFindings={[finding]} onClose={onClose} onOpenFile={vi.fn()} />);
|
|
31
|
+
const backdrop = document.querySelector('.overlay-backdrop');
|
|
32
|
+
expect(backdrop).not.toBeNull();
|
|
33
|
+
(backdrop as HTMLElement).click();
|
|
34
|
+
expect(onClose).toHaveBeenCalled();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Finding } from '../../api/lib/health/types';
|
|
2
|
+
import { SeverityIcon } from '../HealthPage/SeverityIcon';
|
|
3
|
+
import styles from './FindingModal.module.css';
|
|
4
|
+
|
|
5
|
+
export function HeadlineSection({ finding }: { finding: Finding }) {
|
|
6
|
+
return (
|
|
7
|
+
<div className={styles.headline}>
|
|
8
|
+
<div className={styles.chips}>
|
|
9
|
+
<span className={styles.chip}><SeverityIcon severity={finding.severity} /> {finding.severity}</span>
|
|
10
|
+
<span className={styles.chip}>{finding.signal}</span>
|
|
11
|
+
<span className={styles.chip}>{finding.layer}</span>
|
|
12
|
+
<span className={styles.chip}>confidence: {finding.confidence}</span>
|
|
13
|
+
</div>
|
|
14
|
+
<h2 className={styles.title}>{finding.headline}</h2>
|
|
15
|
+
</div>
|
|
16
|
+
);
|
|
17
|
+
}
|