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
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import type { Item, Layer, Priority, RelatedDoc, Status, TenboState } from '../../types';
|
|
4
|
+
import { effectiveStatus } from '../../api/lib/phases';
|
|
5
|
+
import type { Route } from '../../router/routes';
|
|
6
|
+
import { TitleField } from './TitleField';
|
|
7
|
+
import { DescriptionField } from './DescriptionField';
|
|
8
|
+
import { StatusLayerControls } from './StatusLayerControls';
|
|
9
|
+
import { NotesSection } from './NotesSection';
|
|
10
|
+
import { LinksSection } from './LinksSection';
|
|
11
|
+
import { RelatedSection } from './RelatedSection';
|
|
12
|
+
import { RelationshipsSection } from './RelationshipsSection';
|
|
13
|
+
import { EnrichmentSections } from './EnrichmentSections';
|
|
14
|
+
import { PhasesSection } from './PhasesSection';
|
|
15
|
+
import styles from './ItemModal.module.css';
|
|
16
|
+
|
|
17
|
+
interface Props {
|
|
18
|
+
item: Item | null;
|
|
19
|
+
scopeId: string;
|
|
20
|
+
layers: Layer[];
|
|
21
|
+
relatedDocs: RelatedDoc[];
|
|
22
|
+
state?: TenboState;
|
|
23
|
+
onClose: () => void;
|
|
24
|
+
onPatch: (patch: Partial<Item>) => Promise<void>;
|
|
25
|
+
onOpenFile: (path: string) => void;
|
|
26
|
+
onPromoteRelated: (path: string) => Promise<void>;
|
|
27
|
+
onSelectItem?: (scopeId: string | undefined, item: Item) => void;
|
|
28
|
+
navigate: (r: Route) => void;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasEnrichment(item: Item): boolean {
|
|
32
|
+
return Boolean(item.affects?.length || item.done_when?.length || item.risks?.length || item.type || item.files_to_read?.length);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ItemModal({ item, scopeId, layers, relatedDocs, state, onClose, onPatch, onOpenFile, onPromoteRelated, onSelectItem, navigate }: Props) {
|
|
36
|
+
const [draft, setDraft] = useState(item);
|
|
37
|
+
useEffect(() => setDraft(item), [item]);
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const handleKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
|
|
41
|
+
window.addEventListener('keydown', handleKey);
|
|
42
|
+
return () => window.removeEventListener('keydown', handleKey);
|
|
43
|
+
}, [onClose]);
|
|
44
|
+
|
|
45
|
+
if (!item || !draft) return null;
|
|
46
|
+
const related = relatedDocs.filter(d => d.itemId === item.id);
|
|
47
|
+
const explicit = item.links ?? [];
|
|
48
|
+
|
|
49
|
+
const save = (patch: Partial<Item>) => onPatch(patch);
|
|
50
|
+
|
|
51
|
+
return (
|
|
52
|
+
<>
|
|
53
|
+
<div onClick={onClose} className={`overlay-backdrop ${styles.backdrop}`} />
|
|
54
|
+
<div className={styles.modal}>
|
|
55
|
+
<button onClick={onClose} className="close-x" aria-label="Close">
|
|
56
|
+
<X size={18} strokeWidth={1.75} />
|
|
57
|
+
</button>
|
|
58
|
+
<div className={styles.chip}>{item.id} · {item.layer ?? (item.layers || []).join(', ')}</div>
|
|
59
|
+
|
|
60
|
+
<TitleField
|
|
61
|
+
value={draft.title}
|
|
62
|
+
initial={item.title}
|
|
63
|
+
onChange={(v) => setDraft({ ...draft, title: v })}
|
|
64
|
+
onCommit={(v) => save({ title: v })}
|
|
65
|
+
/>
|
|
66
|
+
|
|
67
|
+
<DescriptionField
|
|
68
|
+
value={draft.description}
|
|
69
|
+
initial={item.description}
|
|
70
|
+
onChange={(v) => setDraft({ ...draft, description: v })}
|
|
71
|
+
onCommit={(v) => save({ description: v })}
|
|
72
|
+
/>
|
|
73
|
+
|
|
74
|
+
<StatusLayerControls
|
|
75
|
+
status={effectiveStatus(draft)}
|
|
76
|
+
layer={draft.layer ?? ''}
|
|
77
|
+
layers={layers}
|
|
78
|
+
priority={draft.priority}
|
|
79
|
+
statusDisabled={!!draft.phases?.length}
|
|
80
|
+
onStatusChange={(s: Status) => { setDraft({ ...draft, status: s }); save({ status: s }); }}
|
|
81
|
+
onLayerChange={(l) => { setDraft({ ...draft, layer: l }); save({ layer: l }); }}
|
|
82
|
+
onPriorityChange={(p: Priority | undefined) => { setDraft({ ...draft, priority: p }); save({ priority: p }); }}
|
|
83
|
+
/>
|
|
84
|
+
|
|
85
|
+
{(draft.phases?.length ?? 0) > 0 && <hr className="section-divider" />}
|
|
86
|
+
<PhasesSection
|
|
87
|
+
item={draft}
|
|
88
|
+
onPhaseStatusChange={(phaseId, status) => {
|
|
89
|
+
const updatedPhases = (draft.phases ?? []).map(p =>
|
|
90
|
+
p.id === phaseId ? { ...p, status } : p
|
|
91
|
+
);
|
|
92
|
+
setDraft({ ...draft, phases: updatedPhases });
|
|
93
|
+
save({ phases: updatedPhases });
|
|
94
|
+
}}
|
|
95
|
+
/>
|
|
96
|
+
|
|
97
|
+
{item.notes && <hr className="section-divider" />}
|
|
98
|
+
<NotesSection notes={item.notes} itemId={item.id} />
|
|
99
|
+
|
|
100
|
+
{(explicit.length > 0 || related.length > 0) && <hr className="section-divider" />}
|
|
101
|
+
<LinksSection links={explicit} onOpenFile={onOpenFile} />
|
|
102
|
+
<RelatedSection related={related} onOpenFile={onOpenFile} onPromote={onPromoteRelated} />
|
|
103
|
+
|
|
104
|
+
{state && onSelectItem && (
|
|
105
|
+
<RelationshipsSection
|
|
106
|
+
item={item}
|
|
107
|
+
state={state}
|
|
108
|
+
onSelect={(ref) => onSelectItem(ref.scopeId, ref.item)}
|
|
109
|
+
/>
|
|
110
|
+
)}
|
|
111
|
+
|
|
112
|
+
{hasEnrichment(item) && <hr className="section-divider" />}
|
|
113
|
+
<EnrichmentSections item={item} scopeId={scopeId} navigate={(r) => { onClose(); navigate(r); }} />
|
|
114
|
+
</div>
|
|
115
|
+
</>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
.column {
|
|
2
|
+
flex: 1 1 0;
|
|
3
|
+
min-width: 0;
|
|
4
|
+
padding: 6px;
|
|
5
|
+
min-height: 60px;
|
|
6
|
+
border-radius: var(--r-md);
|
|
7
|
+
transition: background var(--dur-state) var(--ease);
|
|
8
|
+
}
|
|
9
|
+
.columnOver {
|
|
10
|
+
background: var(--console-iron);
|
|
11
|
+
}
|
|
12
|
+
.label {
|
|
13
|
+
font-size: 10px;
|
|
14
|
+
font-weight: 500;
|
|
15
|
+
letter-spacing: 0.08em;
|
|
16
|
+
text-transform: uppercase;
|
|
17
|
+
color: var(--console-fog);
|
|
18
|
+
margin-bottom: 6px;
|
|
19
|
+
padding: 2px 4px;
|
|
20
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { useDroppable } from '@dnd-kit/core';
|
|
2
|
+
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
3
|
+
import { ItemCard } from './ItemCard';
|
|
4
|
+
import type { Item, Status } from '../types';
|
|
5
|
+
import styles from './KanbanColumn.module.css';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
layerId: string;
|
|
9
|
+
status: Status;
|
|
10
|
+
items: Item[];
|
|
11
|
+
onCardClick: (item: Item) => void;
|
|
12
|
+
onTitleEdit: (id: string, title: string) => void;
|
|
13
|
+
onDescEdit: (id: string, desc: string) => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const LABEL: Record<Status, string> = { now: 'NOW', next: 'NEXT', later: 'LATER', done: 'DONE' };
|
|
17
|
+
|
|
18
|
+
export function KanbanColumn({ layerId, status, items, onCardClick, onTitleEdit, onDescEdit }: Props) {
|
|
19
|
+
const { setNodeRef, isOver } = useDroppable({ id: `${layerId}::${status}`, data: { layerId, status } });
|
|
20
|
+
return (
|
|
21
|
+
<div ref={setNodeRef} className={`${styles.column} ${isOver ? styles.columnOver : ''}`}>
|
|
22
|
+
<div className={styles.label}>{LABEL[status]}</div>
|
|
23
|
+
<SortableContext items={items.map(i => i.id)} strategy={verticalListSortingStrategy}>
|
|
24
|
+
{items.map(item => (
|
|
25
|
+
<ItemCard
|
|
26
|
+
key={item.id}
|
|
27
|
+
item={item}
|
|
28
|
+
onClick={() => onCardClick(item)}
|
|
29
|
+
onTitleEdit={(t) => onTitleEdit(item.id, t)}
|
|
30
|
+
onDescEdit={(d) => onDescEdit(item.id, d)}
|
|
31
|
+
/>
|
|
32
|
+
))}
|
|
33
|
+
</SortableContext>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
.page { padding: 24px 32px; max-width: 1200px; margin: 0 auto; }
|
|
2
|
+
.back { background: transparent; border: none; color: var(--muted, #888); cursor: pointer; padding: 0; margin-bottom: 16px; font-size: 13px; }
|
|
3
|
+
.title { font-size: 24px; font-weight: 600; margin: 0 0 8px; }
|
|
4
|
+
.statsRow { display: flex; gap: 12px; align-items: baseline; color: var(--muted, #888); font-size: 14px; margin-bottom: 4px; }
|
|
5
|
+
.legendRow { margin-bottom: 24px; }
|
|
6
|
+
.signalSection { border-top: 1px solid var(--border, #2a2a2a); padding: 12px 0; }
|
|
7
|
+
.signalHeader { display: flex; gap: 8px; align-items: center; background: transparent; border: none; color: inherit; cursor: pointer; padding: 0; font: inherit; width: 100%; }
|
|
8
|
+
.caret { color: var(--muted, #888); width: 16px; }
|
|
9
|
+
.signalName { font-weight: 600; }
|
|
10
|
+
.signalCount { color: var(--muted, #888); font-size: 13px; margin-left: auto; }
|
|
11
|
+
.findingList { list-style: none; padding: 8px 0 0 24px; margin: 0; display: flex; flex-direction: column; gap: 4px; }
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { render, screen } from '@testing-library/react';
|
|
3
|
+
import { LayerDetailPage } from './LayerDetailPage';
|
|
4
|
+
import type { TenboState } from '../../types';
|
|
5
|
+
|
|
6
|
+
const state: TenboState = {
|
|
7
|
+
scopes: [{
|
|
8
|
+
id: 'editor', path: 'apps/editor', description: '', layers: [
|
|
9
|
+
{ id: 'lyr', name: 'Lyr', description: '', files: [], dependencies: { inbound: [], outbound: [], external: [] } },
|
|
10
|
+
], items: [],
|
|
11
|
+
}],
|
|
12
|
+
crossCutting: [], narratives: {}, workspaceContent: {} as any,
|
|
13
|
+
metrics: {
|
|
14
|
+
editor: {
|
|
15
|
+
generated_at: '2026-05-02T00:00:00Z',
|
|
16
|
+
layers: { lyr: { file_count: 10, total_lines: 200, outbound_deps: 0, deep_dive_count: 0, intent_age_days: null, pct_roadmap_in_now: 0 } },
|
|
17
|
+
findings: [],
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
describe('LayerDetailPage', () => {
|
|
23
|
+
it('renders the layer name and all 8 signal sections', () => {
|
|
24
|
+
render(<LayerDetailPage state={state} scopeId="editor" layerId="lyr" onBack={vi.fn()} onSelectFinding={vi.fn()} />);
|
|
25
|
+
expect(screen.getByText('Lyr')).toBeInTheDocument();
|
|
26
|
+
expect(screen.getByText('Hotspot files')).toBeInTheDocument();
|
|
27
|
+
expect(screen.getByText('Dead code')).toBeInTheDocument();
|
|
28
|
+
expect(screen.getByText('Redundancy')).toBeInTheDocument();
|
|
29
|
+
});
|
|
30
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { TenboState } from '../../types';
|
|
2
|
+
import type { Finding, Signal } from '../../api/lib/health/types';
|
|
3
|
+
import { SignalSection } from './SignalSection';
|
|
4
|
+
import { SeverityLegend } from '../HealthPage/SeverityLegend';
|
|
5
|
+
import styles from './LayerDetailPage.module.css';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
state: TenboState;
|
|
9
|
+
scopeId: string;
|
|
10
|
+
layerId: string;
|
|
11
|
+
onBack: () => void;
|
|
12
|
+
onSelectFinding: (f: Finding) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SIGNAL_LABELS: Record<Signal, string> = {
|
|
16
|
+
'hotspot-files': 'Hotspot files',
|
|
17
|
+
'dead-code': 'Dead code',
|
|
18
|
+
'coupling': 'Cross-layer coupling',
|
|
19
|
+
'doc-drift': 'Doc drift',
|
|
20
|
+
'test-coverage': 'Test coverage',
|
|
21
|
+
'aging-todos': 'Aging TODOs',
|
|
22
|
+
'architecture-compliance': 'Architecture compliance',
|
|
23
|
+
'redundancy': 'Redundancy',
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export function LayerDetailPage({ state, scopeId, layerId, onBack, onSelectFinding }: Props) {
|
|
27
|
+
const scope = state.scopes.find(s => s.id === scopeId);
|
|
28
|
+
const layer = scope?.layers.find(l => l.id === layerId);
|
|
29
|
+
const metrics = state.metrics?.[scopeId]?.layers[layerId];
|
|
30
|
+
const findings = (state.metrics?.[scopeId]?.findings ?? []).filter(f => f.layer === layerId);
|
|
31
|
+
if (!scope || !layer || !metrics) return <div className={styles.page}>Layer not found.</div>;
|
|
32
|
+
const counts = { critical: 0, warning: 0, info: 0 };
|
|
33
|
+
for (const f of findings) counts[f.severity]++;
|
|
34
|
+
const bySignal = (sig: Signal) => findings.filter(f => f.signal === sig);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<div className={styles.page}>
|
|
38
|
+
<button type="button" className={styles.back} onClick={onBack}>← Back to health</button>
|
|
39
|
+
<h1 className={styles.title}>{layer.name}</h1>
|
|
40
|
+
<div className={styles.statsRow}>
|
|
41
|
+
<span>{metrics.file_count.toLocaleString()} files</span>
|
|
42
|
+
<span>·</span>
|
|
43
|
+
<span>{metrics.total_lines.toLocaleString()} LOC</span>
|
|
44
|
+
</div>
|
|
45
|
+
<div className={styles.legendRow}>
|
|
46
|
+
<SeverityLegend counts={counts} />
|
|
47
|
+
</div>
|
|
48
|
+
{(Object.keys(SIGNAL_LABELS) as Signal[]).map(sig => (
|
|
49
|
+
<SignalSection
|
|
50
|
+
key={sig}
|
|
51
|
+
signal={sig}
|
|
52
|
+
signalLabel={SIGNAL_LABELS[sig]}
|
|
53
|
+
findings={bySignal(sig)}
|
|
54
|
+
onSelectFinding={onSelectFinding}
|
|
55
|
+
/>
|
|
56
|
+
))}
|
|
57
|
+
</div>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import type { Finding, Signal } from '../../api/lib/health/types';
|
|
3
|
+
import { FindingRow } from '../HealthPage/FindingRow';
|
|
4
|
+
import { sortFindings } from '../HealthPage/severity';
|
|
5
|
+
import styles from './LayerDetailPage.module.css';
|
|
6
|
+
|
|
7
|
+
interface Props {
|
|
8
|
+
signal: Signal;
|
|
9
|
+
signalLabel: string;
|
|
10
|
+
findings: Finding[];
|
|
11
|
+
onSelectFinding: (f: Finding) => void;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function SignalSection({ signal, signalLabel, findings, onSelectFinding }: Props) {
|
|
15
|
+
const [open, setOpen] = useState(findings.length > 0);
|
|
16
|
+
const sorted = sortFindings(findings, [signal] as Signal[]);
|
|
17
|
+
return (
|
|
18
|
+
<section className={styles.signalSection}>
|
|
19
|
+
<button type="button" className={styles.signalHeader} onClick={() => setOpen(!open)}>
|
|
20
|
+
<span className={styles.caret}>{open ? '▾' : '▸'}</span>
|
|
21
|
+
<span className={styles.signalName}>{signalLabel}</span>
|
|
22
|
+
<span className={styles.signalCount}>{findings.length === 0 ? '✅ no findings' : `${findings.length} finding${findings.length === 1 ? '' : 's'}`}</span>
|
|
23
|
+
</button>
|
|
24
|
+
{open && findings.length > 0 && (
|
|
25
|
+
<ul className={styles.findingList}>
|
|
26
|
+
{sorted.map(f => (
|
|
27
|
+
<li key={f.id}><FindingRow finding={f} onClick={onSelectFinding} showLayer={false} /></li>
|
|
28
|
+
))}
|
|
29
|
+
</ul>
|
|
30
|
+
)}
|
|
31
|
+
</section>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
.backdrop {
|
|
2
|
+
z-index: var(--z-drawer);
|
|
3
|
+
background: rgba(0, 0, 0, 0.6);
|
|
4
|
+
}
|
|
5
|
+
.drawer {
|
|
6
|
+
position: fixed;
|
|
7
|
+
top: 0;
|
|
8
|
+
right: 0;
|
|
9
|
+
bottom: 0;
|
|
10
|
+
width: min(520px, calc(100vw - 32px));
|
|
11
|
+
background: var(--console-iron);
|
|
12
|
+
box-shadow: var(--shadow-modal);
|
|
13
|
+
overflow-y: auto;
|
|
14
|
+
padding: 24px;
|
|
15
|
+
z-index: var(--z-drawer-content);
|
|
16
|
+
}
|
|
17
|
+
.title {
|
|
18
|
+
margin: 0 0 4px;
|
|
19
|
+
font-size: 18px;
|
|
20
|
+
font-weight: 600;
|
|
21
|
+
letter-spacing: -0.005em;
|
|
22
|
+
color: var(--console-snow);
|
|
23
|
+
}
|
|
24
|
+
.subtitle {
|
|
25
|
+
color: var(--console-fog);
|
|
26
|
+
font-size: 13px;
|
|
27
|
+
font-style: normal;
|
|
28
|
+
margin: 0 0 16px;
|
|
29
|
+
}
|
|
30
|
+
.narrative {
|
|
31
|
+
margin-top: 16px;
|
|
32
|
+
}
|
|
33
|
+
.missing {
|
|
34
|
+
color: var(--console-smoke);
|
|
35
|
+
font-size: 13px;
|
|
36
|
+
margin: 0;
|
|
37
|
+
}
|
|
38
|
+
.filesSummary {
|
|
39
|
+
font-size: 11px;
|
|
40
|
+
font-weight: 500;
|
|
41
|
+
letter-spacing: 0.04em;
|
|
42
|
+
color: var(--console-fog);
|
|
43
|
+
}
|
|
44
|
+
.filesList {
|
|
45
|
+
font-size: 12px;
|
|
46
|
+
color: var(--console-fog);
|
|
47
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
48
|
+
}
|
|
49
|
+
.deepDivesHeading {
|
|
50
|
+
font-size: 14px;
|
|
51
|
+
font-weight: 600;
|
|
52
|
+
color: var(--console-snow);
|
|
53
|
+
margin: 16px 0 8px;
|
|
54
|
+
}
|
|
55
|
+
.deepDivesList {
|
|
56
|
+
margin: 0;
|
|
57
|
+
padding-left: 18px;
|
|
58
|
+
font-size: 13px;
|
|
59
|
+
color: var(--console-snow);
|
|
60
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
2
|
+
import { X } from 'lucide-react';
|
|
3
|
+
import ReactMarkdown from 'react-markdown';
|
|
4
|
+
import remarkGfm from 'remark-gfm';
|
|
5
|
+
import { tenboApi } from '../api/client';
|
|
6
|
+
import type { Layer, LayerDoc } from '../types';
|
|
7
|
+
import styles from './LayerDrawer.module.css';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
scopeId: string;
|
|
11
|
+
layer: Layer | null;
|
|
12
|
+
narrative: string | null;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function LayerDrawer({ scopeId, layer, narrative, onClose }: Props) {
|
|
17
|
+
const [docs, setDocs] = useState<LayerDoc[]>([]);
|
|
18
|
+
const layerId = layer?.id;
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (!layerId) return;
|
|
21
|
+
tenboApi.getLayerDocs(scopeId, layerId).then(setDocs).catch(() => setDocs([]));
|
|
22
|
+
}, [scopeId, layerId]);
|
|
23
|
+
|
|
24
|
+
if (!layer) return null;
|
|
25
|
+
return (
|
|
26
|
+
<>
|
|
27
|
+
<div onClick={onClose} className={`dim-backdrop ${styles.backdrop}`} />
|
|
28
|
+
<aside className={styles.drawer}>
|
|
29
|
+
<button onClick={onClose} className="close-x" aria-label="Close">
|
|
30
|
+
<X size={18} strokeWidth={1.75} />
|
|
31
|
+
</button>
|
|
32
|
+
<h2 className={styles.title}>{layer.name}</h2>
|
|
33
|
+
<p className={styles.subtitle}>{layer.description}</p>
|
|
34
|
+
{narrative ? (
|
|
35
|
+
<div className={styles.narrative}>
|
|
36
|
+
<ReactMarkdown remarkPlugins={[remarkGfm]}>{narrative}</ReactMarkdown>
|
|
37
|
+
</div>
|
|
38
|
+
) : <p className={styles.missing}>No narrative file found.</p>}
|
|
39
|
+
|
|
40
|
+
{docs.length > 0 && (
|
|
41
|
+
<>
|
|
42
|
+
<hr />
|
|
43
|
+
<h3 className={styles.deepDivesHeading}>Deep dives</h3>
|
|
44
|
+
<ul className={styles.deepDivesList}>
|
|
45
|
+
{docs.map(d => (
|
|
46
|
+
<li key={d.filename}>
|
|
47
|
+
<button
|
|
48
|
+
className="link-button"
|
|
49
|
+
onClick={() => tenboApi.openFile(`.tenbo/scopes/${scopeId}/layers/${layer.id}/${d.filename}`)}
|
|
50
|
+
>
|
|
51
|
+
{d.title || d.filename}
|
|
52
|
+
</button>
|
|
53
|
+
</li>
|
|
54
|
+
))}
|
|
55
|
+
</ul>
|
|
56
|
+
</>
|
|
57
|
+
)}
|
|
58
|
+
|
|
59
|
+
<hr />
|
|
60
|
+
<details>
|
|
61
|
+
<summary className={styles.filesSummary}>Files (for the agent)</summary>
|
|
62
|
+
<ul className={styles.filesList}>
|
|
63
|
+
{layer.files.map(f => <li key={f}><code>{f}</code></li>)}
|
|
64
|
+
</ul>
|
|
65
|
+
</details>
|
|
66
|
+
</aside>
|
|
67
|
+
</>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
.wrap {
|
|
2
|
+
background: rgba(255, 255, 255, 0.03);
|
|
3
|
+
border: 1px solid rgba(255, 255, 255, 0.05);
|
|
4
|
+
border-radius: var(--r-md);
|
|
5
|
+
margin-bottom: 8px;
|
|
6
|
+
overflow: hidden;
|
|
7
|
+
}
|
|
8
|
+
.header {
|
|
9
|
+
padding: 10px 12px;
|
|
10
|
+
background: transparent;
|
|
11
|
+
display: flex;
|
|
12
|
+
align-items: center;
|
|
13
|
+
gap: 6px;
|
|
14
|
+
cursor: pointer;
|
|
15
|
+
transition: background var(--dur-state) var(--ease);
|
|
16
|
+
}
|
|
17
|
+
.header:hover { background: var(--console-iron); }
|
|
18
|
+
.headerOpen {
|
|
19
|
+
border-bottom: 1px solid var(--console-mist);
|
|
20
|
+
}
|
|
21
|
+
.toggle {
|
|
22
|
+
display: inline-flex;
|
|
23
|
+
align-items: center;
|
|
24
|
+
justify-content: center;
|
|
25
|
+
color: var(--console-fog);
|
|
26
|
+
}
|
|
27
|
+
.name {
|
|
28
|
+
font-weight: 600;
|
|
29
|
+
font-size: 14px;
|
|
30
|
+
color: var(--console-snow);
|
|
31
|
+
}
|
|
32
|
+
.counts {
|
|
33
|
+
margin-left: auto;
|
|
34
|
+
font-size: 11px;
|
|
35
|
+
font-weight: 500;
|
|
36
|
+
letter-spacing: 0.06em;
|
|
37
|
+
color: var(--console-fog);
|
|
38
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
39
|
+
}
|
|
40
|
+
.body {
|
|
41
|
+
display: flex;
|
|
42
|
+
gap: 4px;
|
|
43
|
+
padding: 8px;
|
|
44
|
+
background: var(--console-black);
|
|
45
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ChevronDown, ChevronRight } from 'lucide-react';
|
|
3
|
+
import { KanbanColumn } from './KanbanColumn';
|
|
4
|
+
import type { Item, Layer, Status } from '../types';
|
|
5
|
+
import { effectiveStatus } from '../api/lib/phases';
|
|
6
|
+
import styles from './LayerKanban.module.css';
|
|
7
|
+
|
|
8
|
+
interface Props {
|
|
9
|
+
layer: Layer;
|
|
10
|
+
items: Item[];
|
|
11
|
+
depth?: number;
|
|
12
|
+
/** When true, hides the collapse header and always shows the board (e.g. single-layer filter). */
|
|
13
|
+
alwaysOpen?: boolean;
|
|
14
|
+
onCardClick: (item: Item) => void;
|
|
15
|
+
onTitleEdit: (id: string, title: string) => void;
|
|
16
|
+
onDescEdit: (id: string, desc: string) => void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const STATUSES: Status[] = ['now', 'next', 'later', 'done'];
|
|
20
|
+
|
|
21
|
+
export function LayerKanban({ layer, items, depth = 0, alwaysOpen = false, onCardClick, onTitleEdit, onDescEdit }: Props) {
|
|
22
|
+
const counts = STATUSES.map(s => items.filter(i => effectiveStatus(i) === s).length);
|
|
23
|
+
const hasActive = counts[0] > 0 || counts[1] > 0;
|
|
24
|
+
const [open, setOpen] = useState(hasActive);
|
|
25
|
+
|
|
26
|
+
const isOpen = alwaysOpen || open;
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div className={styles.wrap} style={{ marginLeft: depth * 24 }}>
|
|
30
|
+
{!alwaysOpen && (
|
|
31
|
+
<div
|
|
32
|
+
className={`${styles.header} ${isOpen ? styles.headerOpen : ''}`}
|
|
33
|
+
role="button"
|
|
34
|
+
tabIndex={0}
|
|
35
|
+
aria-expanded={isOpen}
|
|
36
|
+
onClick={() => setOpen(o => !o)}
|
|
37
|
+
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setOpen(o => !o); } }}
|
|
38
|
+
>
|
|
39
|
+
<span className={styles.toggle} aria-hidden="true">
|
|
40
|
+
{isOpen ? <ChevronDown size={14} strokeWidth={1.75} /> : <ChevronRight size={14} strokeWidth={1.75} />}
|
|
41
|
+
</span>
|
|
42
|
+
<span className={styles.name}>{layer.name}</span>
|
|
43
|
+
<span className={styles.counts}>
|
|
44
|
+
{counts[0]} · {counts[1]} · {counts[2]} · {counts[3]}
|
|
45
|
+
</span>
|
|
46
|
+
</div>
|
|
47
|
+
)}
|
|
48
|
+
{isOpen && (
|
|
49
|
+
<div className={styles.body}>
|
|
50
|
+
{STATUSES.map(status => (
|
|
51
|
+
<KanbanColumn
|
|
52
|
+
key={status}
|
|
53
|
+
layerId={layer.id}
|
|
54
|
+
status={status}
|
|
55
|
+
items={items.filter(i => effectiveStatus(i) === status)}
|
|
56
|
+
onCardClick={onCardClick}
|
|
57
|
+
onTitleEdit={onTitleEdit}
|
|
58
|
+
onDescEdit={onDescEdit}
|
|
59
|
+
/>
|
|
60
|
+
))}
|
|
61
|
+
</div>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { usePrompt } from '../../hooks/usePrompt';
|
|
2
|
+
|
|
3
|
+
export function FillThisInButton({ label, prompt }: { label: string; prompt: string }) {
|
|
4
|
+
const { copy, toast } = usePrompt();
|
|
5
|
+
return (
|
|
6
|
+
<>
|
|
7
|
+
<button onClick={() => copy(prompt, 'Prompt')}>{label}</button>
|
|
8
|
+
{toast && <div role="status">{toast}</div>}
|
|
9
|
+
</>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
flex-shrink: 0;
|
|
35
|
+
}
|
|
36
|
+
.headerSpacer {
|
|
37
|
+
margin-left: auto;
|
|
38
|
+
}
|
|
39
|
+
.tabs {
|
|
40
|
+
display: flex;
|
|
41
|
+
gap: 0;
|
|
42
|
+
padding-bottom: 8px;
|
|
43
|
+
border-bottom: 1px solid var(--console-mist);
|
|
44
|
+
margin: 0 -32px 20px;
|
|
45
|
+
padding-left: 32px;
|
|
46
|
+
padding-right: 32px;
|
|
47
|
+
}
|
|
48
|
+
.body {
|
|
49
|
+
padding-top: 8px;
|
|
50
|
+
overflow-x: auto;
|
|
51
|
+
min-width: 0;
|
|
52
|
+
}
|