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,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
+ }