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 { useState } from 'react';
2
+ import { useSortable } from '@dnd-kit/sortable';
3
+ import { CSS } from '@dnd-kit/utilities';
4
+ import { CornerLeftUp, ArrowLeftRight } from 'lucide-react';
5
+ import type { Item } from '../types';
6
+ import { phaseProgress } from '../api/lib/phases';
7
+ import styles from './ItemCard.module.css';
8
+
9
+ interface Props {
10
+ item: Item;
11
+ onClick: () => void;
12
+ onTitleEdit: (newTitle: string) => void;
13
+ onDescEdit: (newDesc: string) => void;
14
+ }
15
+
16
+ export function ItemCard({ item, onClick, onTitleEdit, onDescEdit }: Props) {
17
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: item.id });
18
+ const dragStyle = {
19
+ transform: CSS.Translate.toString(transform),
20
+ transition,
21
+ opacity: isDragging ? 0.4 : 1,
22
+ };
23
+ const [editing, setEditing] = useState<null | 'title' | 'desc'>(null);
24
+ const phases = item.phases ?? [];
25
+ const progress = phases.length > 0 ? phaseProgress(phases) : null;
26
+
27
+ return (
28
+ <div
29
+ ref={setNodeRef}
30
+ className={styles.card}
31
+ style={dragStyle}
32
+ {...attributes}
33
+ {...listeners}
34
+ onClick={() => { if (!editing) onClick(); }}
35
+ onKeyDown={(e) => {
36
+ if (editing) return;
37
+ if (e.key === 'Enter') { e.preventDefault(); onClick(); }
38
+ // Space is consumed by dnd-kit for drag-pickup; don't intercept it.
39
+ }}
40
+ >
41
+ <div className={styles.id}>
42
+ {item.priority && <span className={`${styles.priority} ${styles[item.priority]}`}>{item.priority.toUpperCase()}</span>}
43
+ {item.id}
44
+ {item.spawned_from && (
45
+ <span
46
+ className={styles.relChip}
47
+ title={`Spawned from ${item.spawned_from}`}
48
+ aria-label={`Spawned from ${item.spawned_from}`}
49
+ >
50
+ <CornerLeftUp size={11} strokeWidth={1.75} /> {item.spawned_from}
51
+ </span>
52
+ )}
53
+ {item.related && item.related.length > 0 && (
54
+ <span
55
+ className={styles.relChip}
56
+ title={`Related to ${item.related.length} item${item.related.length === 1 ? '' : 's'}`}
57
+ aria-label={`Related to ${item.related.length} items`}
58
+ >
59
+ <ArrowLeftRight size={11} strokeWidth={1.75} /> {item.related.length}
60
+ </span>
61
+ )}
62
+ </div>
63
+
64
+ {editing === 'title' ? (
65
+ <input
66
+ autoFocus
67
+ defaultValue={item.title}
68
+ onBlur={(e) => { setEditing(null); if (e.target.value !== item.title) onTitleEdit(e.target.value); }}
69
+ onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur(); if (e.key === 'Escape') setEditing(null); }}
70
+ onClick={(e) => e.stopPropagation()}
71
+ className={styles.titleInput}
72
+ />
73
+ ) : (
74
+ <div
75
+ onDoubleClick={(e) => { e.stopPropagation(); setEditing('title'); }}
76
+ className={styles.title}
77
+ title={item.title}
78
+ >
79
+ {item.title}
80
+ </div>
81
+ )}
82
+
83
+ {editing === 'desc' ? (
84
+ <textarea
85
+ autoFocus
86
+ defaultValue={item.description}
87
+ onBlur={(e) => { setEditing(null); if (e.target.value !== item.description) onDescEdit(e.target.value); }}
88
+ onClick={(e) => e.stopPropagation()}
89
+ className={styles.descInput}
90
+ />
91
+ ) : (
92
+ <div
93
+ onDoubleClick={(e) => { e.stopPropagation(); setEditing('desc'); }}
94
+ className={styles.desc}
95
+ >
96
+ {item.description}
97
+ </div>
98
+ )}
99
+
100
+ {progress && (
101
+ <div
102
+ className={styles.phaseProgress}
103
+ title={`${progress.done} of ${progress.total} phases complete${progress.active ? ` · active: ${progress.active.title}` : ''}`}
104
+ aria-label={`Phase progress: ${progress.done} of ${progress.total} done`}
105
+ >
106
+ <span className={styles.phaseChip}>{progress.done}/{progress.total}</span>
107
+ <span className={styles.phaseBar} aria-hidden="true">
108
+ <span
109
+ className={styles.phaseBarFill}
110
+ style={{ width: progress.total > 0 ? `${(progress.done / progress.total) * 100}%` : '0%' }}
111
+ />
112
+ </span>
113
+ </div>
114
+ )}
115
+ </div>
116
+ );
117
+ }
@@ -0,0 +1,22 @@
1
+ import styles from './ItemModal.module.css';
2
+
3
+ interface Props {
4
+ value: string;
5
+ initial: string;
6
+ onChange: (v: string) => void;
7
+ onCommit: (v: string) => void;
8
+ }
9
+
10
+ export function DescriptionField({ value, initial, onChange, onCommit }: Props) {
11
+ return (
12
+ <>
13
+ <label className={styles.descLabel}>Brief description</label>
14
+ <textarea
15
+ value={value}
16
+ onChange={e => onChange(e.target.value)}
17
+ onBlur={e => { if (e.target.value !== initial) onCommit(e.target.value); }}
18
+ className={styles.descTextarea}
19
+ />
20
+ </>
21
+ );
22
+ }
@@ -0,0 +1,49 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { Item } from '../../types';
3
+ import type { Route } from '../../router/routes';
4
+
5
+ export function EnrichmentSections({ item, scopeId, navigate }: { item: Item; scopeId: string; navigate: (r: Route) => void }) {
6
+ const affects = item.affects ?? [];
7
+ const sections: ReactNode[] = [];
8
+
9
+ if (affects.length > 0) {
10
+ sections.push(
11
+ <Section key="affects" title="Also touches">
12
+ <ul>
13
+ {affects.map((a) => {
14
+ // Same-scope: bare layer id (e.g., "ai-assistant"). Cross-scope: "<scope>:<layer>".
15
+ const [scope, layer] = a.includes(':') ? a.split(':') : [scopeId, a];
16
+ return (
17
+ <li key={a}>
18
+ <button className="link-button" onClick={() => navigate({ kind: 'docs-layer', scopeId: scope, layerId: layer, tab: 'overview' })}>{a}</button>
19
+ </li>
20
+ );
21
+ })}
22
+ </ul>
23
+ </Section>
24
+ );
25
+ }
26
+ if (item.done_when?.length) sections.push(<Section key="done" title="What done looks like"><List items={item.done_when} /></Section>);
27
+ if (item.risks?.length) sections.push(<Section key="risks" title="Risks / unknowns"><List items={item.risks} /></Section>);
28
+ if (item.type) sections.push(<Section key="type" title="Type">{item.type}</Section>);
29
+ if (item.files_to_read?.length) sections.push(<Section key="files" title="Files to read first" engineerOnly><List items={item.files_to_read} /></Section>);
30
+
31
+ if (sections.length === 0) return null;
32
+ return <div>{sections}</div>;
33
+ }
34
+
35
+ function Section({ title, children, engineerOnly = false }: { title: string; children: ReactNode; engineerOnly?: boolean }) {
36
+ return (
37
+ <details open={!engineerOnly}>
38
+ <summary>
39
+ <strong>{title}</strong>
40
+ {engineerOnly && <span style={{ fontSize: 11, color: 'var(--muted, #888)', marginLeft: 6 }}>· for engineers</span>}
41
+ </summary>
42
+ {children}
43
+ </details>
44
+ );
45
+ }
46
+
47
+ function List({ items }: { items: string[] }) {
48
+ return <ul>{items.map((x) => <li key={x}>{x}</li>)}</ul>;
49
+ }
@@ -0,0 +1,200 @@
1
+ .backdrop {
2
+ z-index: var(--z-modal);
3
+ background: rgba(0, 0, 0, 0.6);
4
+ }
5
+
6
+ .modal {
7
+ position: fixed;
8
+ top: 8vh;
9
+ left: 50%;
10
+ transform: translateX(-50%);
11
+ width: min(640px, calc(100vw - 32px));
12
+ max-height: 84vh;
13
+ overflow-y: auto;
14
+ background: var(--console-iron);
15
+ border-radius: var(--r-lg);
16
+ padding: 20px 24px 24px;
17
+ box-shadow: var(--shadow-modal);
18
+ z-index: var(--z-modal-content);
19
+ }
20
+
21
+ /* Eyebrow chip: ID · layer name above the title */
22
+ .chip {
23
+ display: inline-block;
24
+ font-size: 11px;
25
+ font-weight: 500;
26
+ letter-spacing: 0.04em;
27
+ text-transform: uppercase;
28
+ color: var(--console-fog);
29
+ margin-bottom: 4px;
30
+ }
31
+
32
+ /* Title — editable on click. No chrome at rest; reveals on hover/focus. */
33
+ .titleHeader {
34
+ margin: 0 0 16px;
35
+ }
36
+ .titleInput {
37
+ font-family: inherit;
38
+ font-size: 18px;
39
+ font-weight: 600;
40
+ line-height: 1.3;
41
+ letter-spacing: -0.005em;
42
+ color: var(--console-snow);
43
+ width: 100%;
44
+ background: transparent;
45
+ border: none;
46
+ border-radius: var(--r-sm);
47
+ padding: 4px 6px;
48
+ margin: -4px -6px;
49
+ outline: none;
50
+ transition: background var(--dur-state) var(--ease);
51
+ }
52
+ .titleInput:hover { background: var(--console-soot); }
53
+ .titleInput:focus-visible { background: var(--console-soot); outline: 2px solid var(--signal-cyan); outline-offset: 2px; }
54
+
55
+ /* Description label + textarea — meta label, inset textarea. */
56
+ .descLabel {
57
+ display: block;
58
+ font-size: 11px;
59
+ font-weight: 500;
60
+ letter-spacing: 0.04em;
61
+ text-transform: uppercase;
62
+ color: var(--console-fog);
63
+ margin-bottom: 6px;
64
+ }
65
+ .descTextarea {
66
+ width: 100%;
67
+ min-height: 72px;
68
+ font: inherit;
69
+ font-size: 13px;
70
+ line-height: 1.5;
71
+ color: var(--console-snow);
72
+ background: var(--console-soot);
73
+ border: 1px solid var(--console-mist);
74
+ border-radius: var(--r-md);
75
+ padding: 8px 12px;
76
+ resize: vertical;
77
+ outline: none;
78
+ transition: border-color var(--dur-state) var(--ease);
79
+ }
80
+ .descTextarea:focus-visible { border-color: var(--signal-cyan); outline: 2px solid var(--signal-cyan); outline-offset: 2px; }
81
+
82
+ /* Status / Layer / Priority — label-above-select rhythm. */
83
+ .controls {
84
+ display: flex;
85
+ gap: 16px;
86
+ margin-top: 16px;
87
+ flex-wrap: wrap;
88
+ }
89
+ .controlLabel {
90
+ display: flex;
91
+ flex-direction: column;
92
+ gap: 4px;
93
+ font-size: 11px;
94
+ font-weight: 500;
95
+ letter-spacing: 0.04em;
96
+ text-transform: uppercase;
97
+ color: var(--console-fog);
98
+ flex: 1 1 120px;
99
+ min-width: 120px;
100
+ }
101
+ .controlSelect {
102
+ margin: 0;
103
+ width: 100%;
104
+ }
105
+
106
+ /* Section headings + dividers. */
107
+ .sectionH {
108
+ font-size: 14px;
109
+ font-weight: 600;
110
+ line-height: 1.4;
111
+ color: var(--console-snow);
112
+ margin: 0 0 8px;
113
+ }
114
+
115
+ .empty {
116
+ color: var(--console-fog);
117
+ font-size: 12px;
118
+ font-style: normal;
119
+ }
120
+
121
+ .promote {
122
+ font-size: 11px;
123
+ font-weight: 500;
124
+ letter-spacing: 0.04em;
125
+ color: var(--signal-cyan);
126
+ }
127
+
128
+ /* Relationships block. */
129
+ .relGroup {
130
+ margin-top: 12px;
131
+ }
132
+ .relGroupLabel {
133
+ font-size: 11px;
134
+ font-weight: 500;
135
+ letter-spacing: 0.04em;
136
+ text-transform: uppercase;
137
+ color: var(--console-fog);
138
+ margin-bottom: 4px;
139
+ }
140
+ .relList {
141
+ list-style: none;
142
+ padding: 0;
143
+ margin: 0;
144
+ display: flex;
145
+ flex-direction: column;
146
+ gap: 2px;
147
+ }
148
+ .relList li { margin: 0; padding: 0; }
149
+
150
+ /* The relationship row button — overrides .link-button utility */
151
+ .relList li > button {
152
+ display: flex;
153
+ align-items: center;
154
+ gap: 10px;
155
+ width: 100%;
156
+ background: transparent;
157
+ border: none;
158
+ border-radius: var(--r-md);
159
+ padding: 6px 8px;
160
+ text-align: left;
161
+ text-decoration: none;
162
+ color: var(--console-snow);
163
+ cursor: pointer;
164
+ transition: background var(--dur-state) var(--ease);
165
+ }
166
+ .relList li > button:hover { background: var(--console-soot); }
167
+
168
+ .relRowId {
169
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
170
+ font-size: 11px;
171
+ font-weight: 500;
172
+ letter-spacing: 0;
173
+ color: var(--console-fog);
174
+ white-space: nowrap;
175
+ }
176
+ .relRowTitle {
177
+ font-size: 13px;
178
+ font-weight: 500;
179
+ color: var(--console-snow);
180
+ flex: 1;
181
+ min-width: 0;
182
+ overflow: hidden;
183
+ text-overflow: ellipsis;
184
+ white-space: nowrap;
185
+ }
186
+ .relRowStatus {
187
+ font-size: 11px;
188
+ font-weight: 500;
189
+ letter-spacing: 0.04em;
190
+ text-transform: uppercase;
191
+ color: var(--console-black);
192
+ background: var(--console-iron);
193
+ padding: 2px 8px;
194
+ border-radius: var(--r-full);
195
+ white-space: nowrap;
196
+ }
197
+ .relRowStatus[data-status="now"] { background: var(--live-green); }
198
+ .relRowStatus[data-status="next"] { background: var(--signal-cyan); }
199
+ .relRowStatus[data-status="later"] { background: var(--console-iron); color: var(--console-fog); }
200
+ .relRowStatus[data-status="done"] { background: var(--console-soot); color: var(--console-smoke); }
@@ -0,0 +1,26 @@
1
+ import styles from './ItemModal.module.css';
2
+
3
+ interface Props {
4
+ links: string[];
5
+ onOpenFile: (path: string) => void;
6
+ }
7
+
8
+ export function LinksSection({ links, onOpenFile }: Props) {
9
+ if (links.length === 0) return null;
10
+ return (
11
+ <>
12
+ <h3 className={styles.sectionH}>Linked spec/plan</h3>
13
+ <ul>
14
+ {links.map(p => {
15
+ const archived = p.startsWith('.tenbo/specs/archive/');
16
+ return (
17
+ <li key={p}>
18
+ <button onClick={() => onOpenFile(p)} className="link-button">{p}</button>
19
+ {archived && <span style={{ marginLeft: 6, fontSize: '0.75em', opacity: 0.7 }}>(archived)</span>}
20
+ </li>
21
+ );
22
+ })}
23
+ </ul>
24
+ </>
25
+ );
26
+ }
@@ -0,0 +1,18 @@
1
+ import ReactMarkdown from 'react-markdown';
2
+ import remarkGfm from 'remark-gfm';
3
+ import styles from './ItemModal.module.css';
4
+
5
+ interface Props {
6
+ notes?: string;
7
+ itemId: string;
8
+ }
9
+
10
+ export function NotesSection({ notes }: Props) {
11
+ if (!notes) return null;
12
+ return (
13
+ <>
14
+ <h3 className={styles.sectionH}>Notes</h3>
15
+ <ReactMarkdown remarkPlugins={[remarkGfm]}>{notes}</ReactMarkdown>
16
+ </>
17
+ );
18
+ }
@@ -0,0 +1,79 @@
1
+ import type { Item, Phase, Status } from '../../types';
2
+ import { phaseProgress } from '../../api/lib/phases';
3
+
4
+ const STATUSES: Status[] = ['now', 'next', 'later', 'done'];
5
+
6
+ interface Props {
7
+ item: Item;
8
+ onPhaseStatusChange?: (phaseId: number, status: Status) => void;
9
+ }
10
+
11
+ export function PhasesSection({ item, onPhaseStatusChange }: Props) {
12
+ const phases = item.phases ?? [];
13
+ if (phases.length === 0) return null;
14
+ const { done, total } = phaseProgress(phases);
15
+ const firstPendingId = phases.find(p => p.status !== 'done')?.id;
16
+
17
+ return (
18
+ <details open>
19
+ <summary>
20
+ <strong>Phases</strong>
21
+ <span style={{ fontSize: 11, color: 'var(--muted, #888)', marginLeft: 6 }}>
22
+ · {done}/{total} done
23
+ </span>
24
+ </summary>
25
+ <ol style={{ listStyle: 'none', paddingLeft: 0, marginTop: 6 }}>
26
+ {phases.map((p) => {
27
+ const isNext = p.id === firstPendingId;
28
+ const isDone = p.status === 'done';
29
+ return (
30
+ <li
31
+ key={p.id}
32
+ style={{
33
+ display: 'flex',
34
+ alignItems: 'baseline',
35
+ gap: 8,
36
+ padding: '4px 6px',
37
+ borderLeft: `3px solid ${isNext ? 'var(--accent, #4F46E5)' : 'var(--border, #ddd)'}`,
38
+ background: isNext ? 'var(--accent-soft, rgba(79,70,229,0.06))' : 'transparent',
39
+ marginBottom: 2,
40
+ opacity: isDone ? 0.7 : 1,
41
+ }}
42
+ data-phase-status={p.status}
43
+ >
44
+ <span style={{ fontSize: 11, color: 'var(--muted)', minWidth: 18 }}>p{p.id}</span>
45
+ <span style={{ flex: 1, fontWeight: isNext ? 600 : 400, textDecoration: isDone ? 'line-through' : 'none' }}>
46
+ {p.title}
47
+ </span>
48
+ {onPhaseStatusChange ? (
49
+ <select
50
+ value={p.status}
51
+ onChange={e => onPhaseStatusChange(p.id, e.target.value as Status)}
52
+ style={{
53
+ fontSize: 11,
54
+ fontWeight: 500,
55
+ color: 'var(--console-fog)',
56
+ background: 'var(--console-soot)',
57
+ border: '1px solid var(--console-mist)',
58
+ borderRadius: 'var(--r-sm, 4px)',
59
+ padding: '2px 20px 2px 8px',
60
+ cursor: 'pointer',
61
+ appearance: 'none',
62
+ WebkitAppearance: 'none',
63
+ backgroundImage: `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' fill='none'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E")`,
64
+ backgroundRepeat: 'no-repeat',
65
+ backgroundPosition: 'right 6px center',
66
+ }}
67
+ >
68
+ {STATUSES.map(s => <option key={s} value={s}>{s.charAt(0).toUpperCase() + s.slice(1)}</option>)}
69
+ </select>
70
+ ) : (
71
+ <span style={{ fontSize: 11, color: 'var(--muted)' }}>{p.status.charAt(0).toUpperCase() + p.status.slice(1)}</span>
72
+ )}
73
+ </li>
74
+ );
75
+ })}
76
+ </ol>
77
+ </details>
78
+ );
79
+ }
@@ -0,0 +1,26 @@
1
+ import type { RelatedDoc } from '../../types';
2
+ import styles from './ItemModal.module.css';
3
+
4
+ interface Props {
5
+ related: RelatedDoc[];
6
+ onOpenFile: (path: string) => void;
7
+ onPromote: (path: string) => void;
8
+ }
9
+
10
+ export function RelatedSection({ related, onOpenFile, onPromote }: Props) {
11
+ if (related.length === 0) return null;
12
+ return (
13
+ <>
14
+ <h3 className={styles.sectionH}>Related docs</h3>
15
+ <ul>
16
+ {related.map(d => (
17
+ <li key={d.path}>
18
+ <button onClick={() => onOpenFile(d.path)} className="link-button">{d.title || d.path}</button>
19
+ {' '}
20
+ <button onClick={() => onPromote(d.path)} className={styles.promote}>promote</button>
21
+ </li>
22
+ ))}
23
+ </ul>
24
+ </>
25
+ );
26
+ }
@@ -0,0 +1,69 @@
1
+ import type { Item, TenboState } from '../../types';
2
+ import { allItems, childrenOf, findItemById, relatedItems, type ItemRef } from '../../api/lib/relationships';
3
+ import styles from './ItemModal.module.css';
4
+
5
+ interface Props {
6
+ item: Item;
7
+ state: TenboState;
8
+ onSelect: (ref: ItemRef) => void;
9
+ }
10
+
11
+ function Row({ itemRef, onSelect }: { itemRef: ItemRef; onSelect: (r: ItemRef) => void }) {
12
+ const it = itemRef.item;
13
+ return (
14
+ <li>
15
+ <button onClick={() => onSelect(itemRef)}>
16
+ <span className={styles.relRowId}>{it.id}</span>
17
+ <span className={styles.relRowTitle}>{it.title}</span>
18
+ <span className={styles.relRowStatus} data-status={it.status}>{it.status}</span>
19
+ </button>
20
+ </li>
21
+ );
22
+ }
23
+
24
+ export function RelationshipsSection({ item, state, onSelect }: Props) {
25
+ const parent = item.spawned_from ? findItemById(state, item.spawned_from) : null;
26
+ const flat = allItems(state).map((r) => r.item);
27
+ const childItems = childrenOf(flat, item.id);
28
+ // childrenOf returns Item[]; resolve back to ItemRef so we can navigate w/ scope.
29
+ const children: ItemRef[] = childItems
30
+ .map((c) => findItemById(state, c.id))
31
+ .filter((r): r is ItemRef => r !== null);
32
+ const peers = relatedItems(state, item);
33
+
34
+ if (!parent && children.length === 0 && peers.length === 0) return null;
35
+
36
+ return (
37
+ <>
38
+ <hr className="section-divider" />
39
+ <h3 className={styles.sectionH}>Relationships</h3>
40
+
41
+ {parent && (
42
+ <div className={styles.relGroup}>
43
+ <div className={styles.relGroupLabel}>Spawned from</div>
44
+ <ul className={styles.relList}>
45
+ <Row itemRef={parent} onSelect={onSelect} />
46
+ </ul>
47
+ </div>
48
+ )}
49
+
50
+ {children.length > 0 && (
51
+ <div className={styles.relGroup}>
52
+ <div className={styles.relGroupLabel}>Children</div>
53
+ <ul className={styles.relList}>
54
+ {children.map((c) => <Row key={c.item.id} itemRef={c} onSelect={onSelect} />)}
55
+ </ul>
56
+ </div>
57
+ )}
58
+
59
+ {peers.length > 0 && (
60
+ <div className={styles.relGroup}>
61
+ <div className={styles.relGroupLabel}>Related</div>
62
+ <ul className={styles.relList}>
63
+ {peers.map((p) => <Row key={p.item.id} itemRef={p} onSelect={onSelect} />)}
64
+ </ul>
65
+ </div>
66
+ )}
67
+ </>
68
+ );
69
+ }
@@ -0,0 +1,43 @@
1
+ import type { Layer, Priority, Status } from '../../types';
2
+ import styles from './ItemModal.module.css';
3
+
4
+ const STATUSES: Status[] = ['now', 'next', 'later', 'done'];
5
+ const PRIORITIES: Priority[] = ['p0', 'p1', 'p2', 'p3'];
6
+
7
+ interface Props {
8
+ status: Status;
9
+ layer: string;
10
+ layers: Layer[];
11
+ priority?: Priority;
12
+ statusDisabled?: boolean;
13
+ onStatusChange: (s: Status) => void;
14
+ onLayerChange: (l: string) => void;
15
+ onPriorityChange: (p: Priority | undefined) => void;
16
+ }
17
+
18
+ export function StatusLayerControls({ status, layer, layers, priority, statusDisabled, onStatusChange, onLayerChange, onPriorityChange }: Props) {
19
+ return (
20
+ <div className={styles.controls}>
21
+ <label className={styles.controlLabel}>Status:
22
+ <select value={status} onChange={e => onStatusChange(e.target.value as Status)} className={styles.controlSelect} disabled={statusDisabled} title={statusDisabled ? 'Derived from phases' : undefined}>
23
+ {STATUSES.map(s => <option key={s} value={s}>{s}</option>)}
24
+ </select>
25
+ </label>
26
+ <label className={styles.controlLabel}>Layer:
27
+ <select value={layer} onChange={e => onLayerChange(e.target.value)} className={styles.controlSelect}>
28
+ {layers.map(l => <option key={l.id} value={l.id}>{l.name}</option>)}
29
+ </select>
30
+ </label>
31
+ <label className={styles.controlLabel}>Priority:
32
+ <select
33
+ value={priority ?? ''}
34
+ onChange={e => onPriorityChange(e.target.value === '' ? undefined : (e.target.value as Priority))}
35
+ className={styles.controlSelect}
36
+ >
37
+ <option value="">unset</option>
38
+ {PRIORITIES.map(p => <option key={p} value={p}>{p.toUpperCase()}</option>)}
39
+ </select>
40
+ </label>
41
+ </div>
42
+ );
43
+ }
@@ -0,0 +1,21 @@
1
+ import styles from './ItemModal.module.css';
2
+
3
+ interface Props {
4
+ value: string;
5
+ initial: string;
6
+ onChange: (v: string) => void;
7
+ onCommit: (v: string) => void;
8
+ }
9
+
10
+ export function TitleField({ value, initial, onChange, onCommit }: Props) {
11
+ return (
12
+ <h2 className={styles.titleHeader}>
13
+ <input
14
+ value={value}
15
+ onChange={e => onChange(e.target.value)}
16
+ onBlur={e => { if (e.target.value !== initial) onCommit(e.target.value); }}
17
+ className={styles.titleInput}
18
+ />
19
+ </h2>
20
+ );
21
+ }