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,255 @@
1
+ import { useMemo, useState } from 'react';
2
+ import { Columns2, List } from 'lucide-react';
3
+ import { DndContext, type DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
4
+ import { LayerKanban } from '../LayerKanban';
5
+ import { TaskList, type FlatItem } from '../TaskList';
6
+ import { effectiveStatus } from '../../api/lib/phases';
7
+ import { tenboApi } from '../../api/client';
8
+ import type { Route } from '../../router/routes';
9
+ import type { Item, Layer, Status, TenboState } from '../../types';
10
+ import styles from './RoadmapPage.module.css';
11
+
12
+ const GENERAL_SCOPE = 'general';
13
+ const GENERAL_LAYER: Layer = {
14
+ id: 'general',
15
+ name: 'General / cross-cutting',
16
+ description: 'Items that span scopes or do not fit a single layer.',
17
+ files: [],
18
+ };
19
+
20
+ function layersInRenderOrder(layers: Layer[]): Array<{ layer: Layer; depth: number }> {
21
+ const out: Array<{ layer: Layer; depth: number }> = [];
22
+ const tops = layers.filter((l) => !l.parent);
23
+ for (const top of tops) {
24
+ out.push({ layer: top, depth: 0 });
25
+ for (const child of layers.filter((l) => l.parent === top.id)) {
26
+ out.push({ layer: child, depth: 1 });
27
+ }
28
+ }
29
+ return out;
30
+ }
31
+
32
+ interface Props {
33
+ state: TenboState;
34
+ scopeFilter: string | undefined;
35
+ layerFilter: string | undefined;
36
+ navigate: (r: Route) => void;
37
+ onCardClick: (scopeId: string, item: Item) => void;
38
+ onPatch: (scopeId: string, itemId: string, patch: Partial<Item>) => Promise<void>;
39
+ }
40
+
41
+ export function RoadmapPage({ state, scopeFilter, layerFilter, navigate, onCardClick, onPatch }: Props) {
42
+ const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } }));
43
+ const [view, setView] = useState<'kanban' | 'list'>('kanban');
44
+ const [search, setSearch] = useState('');
45
+
46
+ const itemBySource = useMemo(() => {
47
+ const m = new Map<string, { scopeId: string; item: Item }>();
48
+ for (const s of state.scopes) for (const i of s.items) m.set(i.id, { scopeId: s.id, item: i });
49
+ return m;
50
+ }, [state]);
51
+
52
+ const crossCuttingItems = state.crossCuttingRoadmap ?? [];
53
+ const showGeneral = !scopeFilter || scopeFilter === GENERAL_SCOPE;
54
+ const showScopes = !scopeFilter || scopeFilter !== GENERAL_SCOPE;
55
+ const visibleScopes = showScopes
56
+ ? (scopeFilter ? state.scopes.filter((s) => s.id === scopeFilter) : state.scopes)
57
+ : [];
58
+ const visibleGeneral = showGeneral && !layerFilter ? crossCuttingItems : [];
59
+ const q = search.toLowerCase();
60
+ const matchesSearch = (item: Item) =>
61
+ !q ||
62
+ item.id.toLowerCase().includes(q) ||
63
+ item.title.toLowerCase().includes(q) ||
64
+ (item.description ?? '').toLowerCase().includes(q);
65
+
66
+ const allItems = [
67
+ ...visibleScopes.flatMap((s) => s.items).filter((i) => (!layerFilter || i.layer === layerFilter) && matchesSearch(i)),
68
+ ...visibleGeneral.filter(matchesSearch),
69
+ ];
70
+
71
+ const flatItems = useMemo<FlatItem[]>(() => {
72
+ const result: FlatItem[] = [];
73
+ for (const scope of visibleScopes) {
74
+ const layerMap = new Map(scope.layers.map(l => [l.id, l.name]));
75
+ for (const item of scope.items) {
76
+ if (layerFilter && item.layer !== layerFilter) continue;
77
+ if (!matchesSearch(item)) continue;
78
+ result.push({ item, scopeId: scope.id, layerName: item.layer ? layerMap.get(item.layer) : undefined });
79
+ }
80
+ }
81
+ for (const item of visibleGeneral) {
82
+ if (matchesSearch(item)) result.push({ item, scopeId: GENERAL_SCOPE });
83
+ }
84
+ return result;
85
+ // eslint-disable-next-line react-hooks/exhaustive-deps
86
+ }, [state, scopeFilter, layerFilter, search]);
87
+
88
+ const handleDragEnd = async (e: DragEndEvent) => {
89
+ const activeId = String(e.active.id);
90
+ const overId = e.over?.id ? String(e.over.id) : null;
91
+ if (!overId) return;
92
+ const meta = itemBySource.get(activeId);
93
+ if (!meta) return;
94
+
95
+ if (overId.includes('::')) {
96
+ const [, status] = overId.split('::');
97
+ if (meta.item.status !== status) {
98
+ await onPatch(meta.scopeId, activeId, { status: status as Status });
99
+ }
100
+ } else {
101
+ const scope = state.scopes.find((s) => s.id === meta.scopeId)!;
102
+ const orderedIds = scope.items.map((i) => i.id);
103
+ const fromIdx = orderedIds.indexOf(activeId);
104
+ const toIdx = orderedIds.indexOf(overId);
105
+ if (fromIdx === -1 || toIdx === -1) return;
106
+ orderedIds.splice(toIdx, 0, orderedIds.splice(fromIdx, 1)[0]);
107
+ await tenboApi.reorder(meta.scopeId, orderedIds);
108
+ }
109
+ };
110
+
111
+ return (
112
+ <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
113
+ <div className={styles.stickyBar}>
114
+ <RoadmapFilterBar
115
+ scopes={state.scopes}
116
+ scopeFilter={scopeFilter}
117
+ layerFilter={layerFilter}
118
+ navigate={navigate}
119
+ view={view}
120
+ onViewChange={setView}
121
+ allItems={allItems}
122
+ search={search}
123
+ onSearchChange={setSearch}
124
+ />
125
+ </div>
126
+ <main style={{ padding: view === 'list' ? '16px 0 0' : 16 }}>
127
+ {view === 'list' ? (
128
+ <TaskList
129
+ items={flatItems}
130
+ onRowClick={(scopeId, item) => onCardClick(scopeId, item)}
131
+ />
132
+ ) : (
133
+ <>
134
+ {visibleScopes.map((scope) => (
135
+ <section key={scope.id}>
136
+ {!scopeFilter && <h3 style={{ marginTop: 16 }}>{scope.id}</h3>}
137
+ {layersInRenderOrder(scope.layers)
138
+ .filter(({ layer }) => !layerFilter || layer.id === layerFilter)
139
+ .map(({ layer, depth }) => (
140
+ <LayerKanban
141
+ key={layer.id}
142
+ layer={layer}
143
+ depth={depth}
144
+ alwaysOpen={!!layerFilter}
145
+ items={scope.items.filter((i) => i.layer === layer.id && matchesSearch(i))}
146
+ onCardClick={(item) => onCardClick(scope.id, item)}
147
+ onTitleEdit={(id, title) => onPatch(scope.id, id, { title })}
148
+ onDescEdit={(id, desc) => onPatch(scope.id, id, { description: desc })}
149
+ />
150
+ ))}
151
+ </section>
152
+ ))}
153
+ {visibleGeneral.length > 0 && (
154
+ <section>
155
+ {!scopeFilter && <h3 style={{ marginTop: 16 }}>{GENERAL_SCOPE}</h3>}
156
+ <LayerKanban
157
+ layer={GENERAL_LAYER}
158
+ items={visibleGeneral.filter(matchesSearch)}
159
+ onCardClick={(item) => onCardClick(GENERAL_SCOPE, item)}
160
+ onTitleEdit={() => {}}
161
+ onDescEdit={() => {}}
162
+ />
163
+ </section>
164
+ )}
165
+ </>
166
+ )}
167
+ </main>
168
+ </DndContext>
169
+ );
170
+ }
171
+
172
+ function RoadmapFilterBar({ scopes, scopeFilter, layerFilter, navigate, view, onViewChange, allItems, search, onSearchChange }: {
173
+ scopes: TenboState['scopes'];
174
+ scopeFilter: string | undefined;
175
+ layerFilter: string | undefined;
176
+ navigate: (r: Route) => void;
177
+ view: 'kanban' | 'list';
178
+ onViewChange: (v: 'kanban' | 'list') => void;
179
+ allItems: Item[];
180
+ search: string;
181
+ onSearchChange: (v: string) => void;
182
+ }) {
183
+ const activeScope = scopeFilter && scopeFilter !== GENERAL_SCOPE ? scopes.find((s) => s.id === scopeFilter) : undefined;
184
+ const counts = { now: 0, next: 0, later: 0, done: 0 };
185
+ for (const i of allItems) counts[effectiveStatus(i)]++;
186
+
187
+ return (
188
+ <div className={styles.filterRow}>
189
+ {/* View toggle — far left */}
190
+ <div className={styles.viewToggle} role="group" aria-label="View layout">
191
+ <button
192
+ className={`${styles.viewBtn} ${view === 'kanban' ? styles.viewBtnActive : ''}`}
193
+ onClick={() => onViewChange('kanban')}
194
+ aria-pressed={view === 'kanban'}
195
+ title="Kanban board"
196
+ >
197
+ <Columns2 size={13} strokeWidth={1.75} />
198
+ Kanban
199
+ </button>
200
+ <button
201
+ className={`${styles.viewBtn} ${view === 'list' ? styles.viewBtnActive : ''}`}
202
+ onClick={() => onViewChange('list')}
203
+ aria-pressed={view === 'list'}
204
+ title="Task list"
205
+ >
206
+ <List size={13} strokeWidth={1.75} />
207
+ List
208
+ </button>
209
+ </div>
210
+
211
+ <div className={styles.filterDivider} />
212
+
213
+ {/* Scope + layer filters */}
214
+ <span className={styles.filterLabel}>Scope</span>
215
+ <select
216
+ value={scopeFilter ?? ''}
217
+ onChange={(e) => navigate({ kind: 'roadmap', scope: e.target.value || undefined })}
218
+ >
219
+ <option value="">All</option>
220
+ {scopes.map((s) => <option key={s.id} value={s.id}>{s.id}</option>)}
221
+ <option value={GENERAL_SCOPE}>General / cross-cutting</option>
222
+ </select>
223
+ {activeScope && (
224
+ <>
225
+ <span className={styles.filterLabel}>Layer</span>
226
+ <select
227
+ value={layerFilter ?? ''}
228
+ onChange={(e) => navigate({ kind: 'roadmap', scope: activeScope.id, layer: e.target.value || undefined })}
229
+ >
230
+ <option value="">All layers</option>
231
+ {activeScope.layers.map((l) => <option key={l.id} value={l.id}>{l.name}</option>)}
232
+ </select>
233
+ </>
234
+ )}
235
+
236
+ <input
237
+ className={styles.searchInput}
238
+ type="search"
239
+ placeholder="Search tasks…"
240
+ value={search}
241
+ onChange={(e) => onSearchChange(e.target.value)}
242
+ aria-label="Search tasks by title"
243
+ />
244
+
245
+ {/* Counts — far right */}
246
+ <div className={styles.filterSpacer} />
247
+ <div className={styles.counts}>
248
+ <span className={styles.countNow}>Now <strong>{counts.now}</strong></span>
249
+ <span className={styles.countNext}>Next <strong>{counts.next}</strong></span>
250
+ <span className={styles.countLater}>Later <strong>{counts.later}</strong></span>
251
+ <span className={styles.countDone}>Done <strong>{counts.done}</strong></span>
252
+ </div>
253
+ </div>
254
+ );
255
+ }
@@ -0,0 +1,72 @@
1
+ .page {
2
+ max-width: 960px;
3
+ margin: 0 auto;
4
+ padding: 0 32px 48px;
5
+ }
6
+ .breadcrumb {
7
+ padding: 12px 0;
8
+ border-bottom: 1px solid var(--console-mist);
9
+ font-size: 12px;
10
+ font-weight: 500;
11
+ letter-spacing: 0.02em;
12
+ color: var(--console-fog);
13
+ margin: 0 -32px;
14
+ padding-left: 32px;
15
+ padding-right: 32px;
16
+ }
17
+ .breadcrumb button {
18
+ font-size: 12px;
19
+ }
20
+ .header {
21
+ display: flex;
22
+ align-items: flex-start;
23
+ gap: 16px;
24
+ padding: 20px 0 12px;
25
+ flex-wrap: wrap;
26
+ }
27
+ .title {
28
+ margin: 0;
29
+ font-size: 24px;
30
+ font-weight: 600;
31
+ letter-spacing: -0.01em;
32
+ line-height: 1.2;
33
+ color: var(--console-snow);
34
+ }
35
+ .description {
36
+ margin-top: 6px;
37
+ color: var(--console-fog);
38
+ }
39
+ .meta {
40
+ font-size: 12px;
41
+ color: var(--console-fog);
42
+ }
43
+ .headerSpacer {
44
+ margin-left: auto;
45
+ }
46
+ .layerList {
47
+ list-style: none;
48
+ padding: 0;
49
+ margin: 0;
50
+ }
51
+ .layerItem {
52
+ padding: 12px 0;
53
+ border-bottom: 1px solid var(--console-mist);
54
+ }
55
+ .layerName {
56
+ font-weight: 600;
57
+ }
58
+ .layerDesc {
59
+ font-size: 13px;
60
+ color: var(--console-fog);
61
+ margin-top: 2px;
62
+ }
63
+ .layerDeps {
64
+ font-size: 12px;
65
+ margin-top: 6px;
66
+ color: var(--console-fog);
67
+ display: grid;
68
+ gap: 2px;
69
+ }
70
+ .depLabel {
71
+ font-weight: 500;
72
+ }
@@ -0,0 +1,131 @@
1
+ import { ArrowRight } from 'lucide-react';
2
+ import type { Route } from '../../router/routes';
3
+ import type { Layer, TenboState } from '../../types';
4
+ import styles from './ScopePage.module.css';
5
+
6
+ function layersInRenderOrder(layers: Layer[]): Array<{ layer: Layer; depth: number }> {
7
+ const out: Array<{ layer: Layer; depth: number }> = [];
8
+ const tops = layers.filter((l) => !l.parent);
9
+ for (const top of tops) {
10
+ out.push({ layer: top, depth: 0 });
11
+ for (const child of layers.filter((l) => l.parent === top.id)) {
12
+ out.push({ layer: child, depth: 1 });
13
+ }
14
+ }
15
+ return out;
16
+ }
17
+
18
+ function LayerLink({ layerId, layers, scopeId, navigate }: {
19
+ layerId: string;
20
+ layers: Layer[];
21
+ scopeId: string;
22
+ navigate: (r: Route) => void;
23
+ }) {
24
+ const target = layers.find((l) => l.id === layerId);
25
+ return (
26
+ <button
27
+ className="link-button"
28
+ onClick={() => navigate({ kind: 'docs-layer', scopeId, layerId, tab: 'overview' })}
29
+ >
30
+ {target?.name ?? layerId}
31
+ </button>
32
+ );
33
+ }
34
+
35
+ function joinList(nodes: Array<React.ReactNode>): React.ReactNode {
36
+ return nodes.map((n, i) => (
37
+ <span key={i}>
38
+ {n}
39
+ {i < nodes.length - 1 ? ', ' : ''}
40
+ </span>
41
+ ));
42
+ }
43
+
44
+ export function ScopePage({ scopeId, state, navigate }: { scopeId: string; state: TenboState; navigate: (r: Route) => void }) {
45
+ const scope = state.scopes.find((s) => s.id === scopeId);
46
+ if (!scope) return <div style={{ padding: 24 }}>Scope not found: {scopeId}</div>;
47
+
48
+ const ordered = layersInRenderOrder(scope.layers);
49
+ const itemCount = scope.items.length;
50
+
51
+ return (
52
+ <div className={styles.page}>
53
+ <nav aria-label="breadcrumb" className={styles.breadcrumb}>
54
+ <button className="link-button" onClick={() => navigate({ kind: 'docs-project', tab: 'overview' })}>Project</button>
55
+ {' / '}
56
+ <span>{scope.id}</span>
57
+ </nav>
58
+
59
+ <header className={styles.header}>
60
+ <div style={{ flex: 1 }}>
61
+ <h1 className={styles.title}>{scope.id}</h1>
62
+ <p className={styles.description}>{scope.description}</p>
63
+ <div className={styles.meta}>
64
+ <code>{scope.path}</code> · {scope.layers.length} layers · {itemCount} roadmap items
65
+ </div>
66
+ </div>
67
+ <div className={styles.headerSpacer}>
68
+ <button
69
+ className="outlined-button icon-text"
70
+ onClick={() => navigate({ kind: 'roadmap', scope: scope.id })}
71
+ >
72
+ View roadmap
73
+ <ArrowRight size={14} strokeWidth={1.75} />
74
+ </button>
75
+ </div>
76
+ </header>
77
+
78
+ <section>
79
+ <h2 style={{ fontSize: 14, fontWeight: 700, marginBottom: 12 }}>Layers</h2>
80
+ <ul className={styles.layerList}>
81
+ {ordered.map(({ layer, depth }) => {
82
+ const uses = layer.dependencies?.outbound ?? [];
83
+ const usedBy = layer.dependencies?.inbound ?? [];
84
+ const external = layer.dependencies?.external ?? [];
85
+ return (
86
+ <li
87
+ key={layer.id}
88
+ className={styles.layerItem}
89
+ style={{ paddingLeft: depth * 20 }}
90
+ >
91
+ <button
92
+ className={`link-button ${styles.layerName}`}
93
+ onClick={() => navigate({ kind: 'docs-layer', scopeId: scope.id, layerId: layer.id, tab: 'overview' })}
94
+ >
95
+ {layer.name}
96
+ </button>
97
+ <div className={styles.layerDesc}>{layer.description}</div>
98
+ {(uses.length > 0 || usedBy.length > 0 || external.length > 0) && (
99
+ <div className={styles.layerDeps}>
100
+ {uses.length > 0 && (
101
+ <div>
102
+ <span className={styles.depLabel}>Uses: </span>
103
+ {joinList(uses.map((id) => (
104
+ <LayerLink key={id} layerId={id} layers={scope.layers} scopeId={scope.id} navigate={navigate} />
105
+ )))}
106
+ </div>
107
+ )}
108
+ {usedBy.length > 0 && (
109
+ <div>
110
+ <span className={styles.depLabel}>Used by: </span>
111
+ {joinList(usedBy.map((id) => (
112
+ <LayerLink key={id} layerId={id} layers={scope.layers} scopeId={scope.id} navigate={navigate} />
113
+ )))}
114
+ </div>
115
+ )}
116
+ {external.length > 0 && (
117
+ <div>
118
+ <span className={styles.depLabel}>External: </span>
119
+ <code>{external.join(', ')}</code>
120
+ </div>
121
+ )}
122
+ </div>
123
+ )}
124
+ </li>
125
+ );
126
+ })}
127
+ </ul>
128
+ </section>
129
+ </div>
130
+ );
131
+ }
@@ -0,0 +1,18 @@
1
+ .strip {
2
+ padding: 10px 16px;
3
+ background: var(--console-soot);
4
+ font-size: 12px;
5
+ font-weight: 500;
6
+ letter-spacing: 0.02em;
7
+ color: var(--console-fog);
8
+ display: flex;
9
+ gap: 24px;
10
+ border-bottom: 1px solid var(--console-mist);
11
+ }
12
+ .strip strong, .strip span[class] {
13
+ font-weight: 600;
14
+ margin-left: 4px;
15
+ }
16
+ .now { color: var(--live-green); }
17
+ .next { color: var(--signal-cyan); }
18
+ .done { color: var(--console-smoke); }
@@ -0,0 +1,16 @@
1
+ import type { Item } from '../types';
2
+ import { effectiveStatus } from '../api/lib/phases';
3
+ import styles from './SummaryStrip.module.css';
4
+
5
+ export function SummaryStrip({ items }: { items: Item[] }) {
6
+ const counts = { now: 0, next: 0, later: 0, done: 0 };
7
+ for (const i of items) counts[effectiveStatus(i)]++;
8
+ return (
9
+ <div className={styles.strip}>
10
+ <span>Now: <strong className={styles.now}>{counts.now}</strong></span>
11
+ <span>Next: <strong className={styles.next}>{counts.next}</strong></span>
12
+ <span>Later: <strong>{counts.later}</strong></span>
13
+ <span>Done: <strong className={styles.done}>{counts.done}</strong></span>
14
+ </div>
15
+ );
16
+ }
@@ -0,0 +1,136 @@
1
+ .wrap {
2
+ padding: 0 16px 32px;
3
+ overflow-x: auto;
4
+ }
5
+
6
+ .table {
7
+ width: 100%;
8
+ border-collapse: collapse;
9
+ font-size: 12px;
10
+ }
11
+
12
+ /* Header */
13
+ .table thead tr {
14
+ border-bottom: 1px solid var(--console-mist);
15
+ }
16
+ .table th {
17
+ padding: 8px 10px;
18
+ text-align: left;
19
+ font-size: 11px;
20
+ font-weight: 600;
21
+ letter-spacing: 0.06em;
22
+ text-transform: uppercase;
23
+ color: var(--console-smoke);
24
+ white-space: nowrap;
25
+ }
26
+
27
+ /* Column widths */
28
+ .thStatus { width: 76px; }
29
+ .thId { width: 110px; }
30
+ .thPriority { width: 80px; }
31
+ .thTitle { min-width: 240px; }
32
+ .thLayer { width: 180px; }
33
+ .thType { width: 90px; }
34
+
35
+ /* Data rows */
36
+ .row {
37
+ border-bottom: 1px solid var(--console-mist);
38
+ cursor: pointer;
39
+ transition: background var(--dur-state) var(--ease);
40
+ }
41
+ .row:hover { background: var(--console-soot); }
42
+ .row:focus-visible {
43
+ outline: none;
44
+ background: var(--console-soot);
45
+ box-shadow: inset 2px 0 0 var(--signal-cyan);
46
+ }
47
+ .row td {
48
+ padding: 8px 10px;
49
+ vertical-align: middle;
50
+ }
51
+
52
+ /* Status badge */
53
+ .badge {
54
+ display: inline-block;
55
+ padding: 2px 7px;
56
+ border-radius: var(--r-full);
57
+ font-size: 10px;
58
+ font-weight: 700;
59
+ letter-spacing: 0.05em;
60
+ text-transform: uppercase;
61
+ white-space: nowrap;
62
+ }
63
+ .badge_now { background: color-mix(in srgb, var(--live-green) 15%, transparent); color: var(--live-green); }
64
+ .badge_next { background: color-mix(in srgb, var(--signal-cyan) 15%, transparent); color: var(--signal-cyan); }
65
+ .badge_later { background: color-mix(in srgb, var(--console-steel) 20%, transparent); color: var(--console-fog); }
66
+ .badge_done { background: color-mix(in srgb, var(--console-smoke) 15%, transparent); color: var(--console-smoke); }
67
+
68
+ /* ID cell — inner div handles flex so <td> stays a proper table cell */
69
+ .tdId {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 4px;
73
+ white-space: nowrap;
74
+ }
75
+ .idText {
76
+ color: var(--console-fog);
77
+ font-family: 'ui-monospace', 'SFMono-Regular', monospace;
78
+ font-size: 11px;
79
+ }
80
+ .relChip {
81
+ display: inline-flex;
82
+ align-items: center;
83
+ gap: 2px;
84
+ color: var(--console-steel);
85
+ font-size: 10px;
86
+ }
87
+
88
+ /* Title cell */
89
+ .titleText {
90
+ color: var(--console-snow);
91
+ font-weight: 500;
92
+ line-height: 1.4;
93
+ }
94
+
95
+ /* Priority chip */
96
+ .priority {
97
+ display: inline-block;
98
+ padding: 1px 5px;
99
+ border-radius: var(--r-sm);
100
+ font-size: 9px;
101
+ font-weight: 800;
102
+ letter-spacing: 0.05em;
103
+ white-space: nowrap;
104
+ flex-shrink: 0;
105
+ }
106
+ .pri_p0 { background: color-mix(in srgb, var(--halt-red) 20%, transparent); color: var(--halt-red); }
107
+ .pri_p1 { background: color-mix(in srgb, var(--slow-amber) 20%, transparent); color: var(--slow-amber); }
108
+ .pri_p2 { background: color-mix(in srgb, var(--signal-cyan) 15%, transparent); color: var(--signal-cyan); }
109
+ .pri_p3 { background: var(--console-iron); color: var(--console-fog); }
110
+
111
+ /* Type cell */
112
+ .tdType {
113
+ color: var(--console-fog);
114
+ font-size: 11px;
115
+ text-transform: capitalize;
116
+ white-space: nowrap;
117
+ }
118
+
119
+ /* Layer cell */
120
+ .tdLayer {
121
+ color: var(--console-fog);
122
+ font-size: 11px;
123
+ white-space: nowrap;
124
+ overflow: hidden;
125
+ text-overflow: ellipsis;
126
+ max-width: 180px;
127
+ }
128
+
129
+ .dash { color: var(--console-steel); }
130
+
131
+ .empty {
132
+ padding: 48px 16px;
133
+ text-align: center;
134
+ color: var(--console-smoke);
135
+ font-size: 13px;
136
+ }