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,76 @@
1
+ import { ArrowRight } from 'lucide-react';
2
+ import { useLayerContent } from '../../hooks/useLayerContent';
3
+ import { PlainEnglishTab } from './tabs/PlainEnglishTab';
4
+ import { IntentTab } from './tabs/IntentTab';
5
+ import { CodeMapTab } from './tabs/CodeMapTab';
6
+ import { DeepDivesTab } from './tabs/DeepDivesTab';
7
+ import type { Route, LayerTab } from '../../router/routes';
8
+ import type { TenboState } from '../../types';
9
+ import styles from './LayerPage.module.css';
10
+
11
+ const TABS: LayerTab[] = ['overview', 'purpose', 'files', 'docs'];
12
+
13
+ const TAB_LABELS: Record<LayerTab, string> = {
14
+ 'overview': 'Overview',
15
+ 'purpose': 'What it does',
16
+ 'files': 'File guide',
17
+ 'docs': 'More docs',
18
+ };
19
+
20
+ export function LayerPage(props: {
21
+ scopeId: string;
22
+ layerId: string;
23
+ tab: LayerTab;
24
+ navigate: (r: Route) => void;
25
+ state: TenboState;
26
+ generation: number;
27
+ }) {
28
+ const { content, error } = useLayerContent(props.scopeId, props.layerId, props.generation);
29
+ const scope = props.state.scopes.find((s) => s.id === props.scopeId);
30
+ const layer = scope?.layers.find((l) => l.id === props.layerId);
31
+
32
+ if (!layer) return <div>Layer not found: {props.scopeId} / {props.layerId}.</div>;
33
+ if (error) return <div>Failed to load layer content: {error}</div>;
34
+ if (!content) return <div>Loading…</div>;
35
+
36
+ return (
37
+ <div className={styles.page}>
38
+ <nav aria-label="breadcrumb" className={styles.breadcrumb}>
39
+ <button className="link-button" onClick={() => props.navigate({ kind: 'docs-project', tab: 'overview' })}>Project</button>
40
+ {' / '}
41
+ <button className="link-button" onClick={() => props.navigate({ kind: 'docs-scope', scopeId: props.scopeId })}>{props.scopeId}</button>
42
+ {' / '}
43
+ <span>{layer.name}</span>
44
+ </nav>
45
+ <header className={styles.header}>
46
+ <h1 className={styles.title}>{layer.name}</h1>
47
+ <div className={styles.headerSpacer}>
48
+ <button
49
+ className="outlined-button icon-text"
50
+ onClick={() => props.navigate({ kind: 'roadmap', scope: props.scopeId, layer: props.layerId })}
51
+ >
52
+ View roadmap
53
+ <ArrowRight size={14} strokeWidth={1.75} />
54
+ </button>
55
+ </div>
56
+ </header>
57
+ <nav className={styles.tabs}>
58
+ {TABS.map((t) => (
59
+ <button
60
+ key={t}
61
+ aria-pressed={props.tab === t}
62
+ onClick={() => props.navigate({ kind: 'docs-layer', scopeId: props.scopeId, layerId: props.layerId, tab: t })}
63
+ >
64
+ {TAB_LABELS[t]}
65
+ </button>
66
+ ))}
67
+ </nav>
68
+ <div className={styles.body}>
69
+ {props.tab === 'overview' && <PlainEnglishTab scopeId={props.scopeId} layerId={props.layerId} content={content.readme} />}
70
+ {props.tab === 'purpose' && <IntentTab scopeId={props.scopeId} layerId={props.layerId} content={content.intentMd} />}
71
+ {props.tab === 'files' && <CodeMapTab scopeId={props.scopeId} layerId={props.layerId} content={content.codeMapMd} />}
72
+ {props.tab === 'docs' && <DeepDivesTab scopeId={props.scopeId} layerId={props.layerId} />}
73
+ </div>
74
+ </div>
75
+ );
76
+ }
@@ -0,0 +1,18 @@
1
+ import type { ScopeMetrics } from '../../types';
2
+
3
+ export function MetricsBadges({ scopeMetrics, layerId }: { scopeMetrics: ScopeMetrics | undefined; layerId: string }) {
4
+ if (!scopeMetrics) return null;
5
+ const layer = scopeMetrics.layers?.[layerId];
6
+ if (!layer) return null;
7
+ return (
8
+ <div>
9
+ <span>files: {layer.file_count}</span>
10
+ {' '}
11
+ <span>lines: {layer.total_lines}</span>
12
+ {' '}
13
+ <span>deps: {layer.outbound_deps}</span>
14
+ {' '}
15
+ <span>intent age: {layer.intent_age_days ?? 'n/a'}d</span>
16
+ </div>
17
+ );
18
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { isTemplateOnly } from './isTemplateOnly';
3
+
4
+ describe('isTemplateOnly', () => {
5
+ it('returns true for empty input', () => {
6
+ expect(isTemplateOnly('')).toBe(true);
7
+ expect(isTemplateOnly(' \n\n ')).toBe(true);
8
+ });
9
+ it('returns true for headings + html comments only', () => {
10
+ const tpl = `# layer\n\n## Purpose\n<!-- One paragraph. -->\n\n## Responsibilities\n- <!-- Verb phrases. -->\n`;
11
+ expect(isTemplateOnly(tpl)).toBe(true);
12
+ });
13
+ it('returns true for the migration scaffolding template', () => {
14
+ const tpl = `# property-editor
15
+
16
+ ## Purpose
17
+ <!-- One paragraph. Why this layer exists. The user-facing job it does. -->
18
+
19
+ ## Responsibilities
20
+ - <!-- Verb phrases. ≤ 7 items. -->
21
+
22
+ ## Boundaries
23
+ - **Upstream (depends on):** <!-- layers and what's consumed -->
24
+ - **Downstream (depended on by):** <!-- layers and what's provided -->
25
+ - **External:** <!-- libraries/services and roles -->
26
+
27
+ ## Boundary decisions
28
+ <!-- Newest first. -->
29
+ - 2026-04-26: created — initial intent draft.
30
+ `;
31
+ expect(isTemplateOnly(tpl)).toBe(true);
32
+ });
33
+ it('returns false when real prose is present', () => {
34
+ const real = `# layer\n\n## Purpose\nThis layer renders the canvas tree.\n`;
35
+ expect(isTemplateOnly(real)).toBe(false);
36
+ });
37
+ it('returns false when a list item has real content', () => {
38
+ const real = `# layer\n\n## Responsibilities\n- Render nodes.\n- Persist edits.\n`;
39
+ expect(isTemplateOnly(real)).toBe(false);
40
+ });
41
+ it('returns false when an Upstream bullet has been filled in', () => {
42
+ const real = `# layer\n\n## Boundaries\n- **Upstream (depends on):** Tokens & Themes.\n`;
43
+ expect(isTemplateOnly(real)).toBe(false);
44
+ });
45
+ });
@@ -0,0 +1,19 @@
1
+ // Returns true when a markdown file contains only template scaffolding
2
+ // (HTML comments, headings, dangling section labels like "- **Upstream:**", and
3
+ // the migration's "created — initial intent draft" marker bullet). Once an
4
+ // agent populates real prose anywhere in the file, this returns false.
5
+ const SCAFFOLDING_PATTERNS: RegExp[] = [
6
+ /^#{1,6}\s/, // headings
7
+ /^-\s*$/, // bare list markers
8
+ /^-\s+\*\*[^*]+:\*\*\s*$/, // "- **Label:**" with no body
9
+ /^-\s+\d{4}-\d{2}-\d{2}:\s*created\b/, // migration's "initial intent draft" marker
10
+ ];
11
+
12
+ export function isTemplateOnly(source: string): boolean {
13
+ const stripped = source
14
+ .replace(/<!--[\s\S]*?-->/g, '')
15
+ .split('\n')
16
+ .map((l) => l.trim())
17
+ .filter((l) => l.length > 0);
18
+ return stripped.every((l) => SCAFFOLDING_PATTERNS.some((re) => re.test(l)));
19
+ }
@@ -0,0 +1,19 @@
1
+ import { Markdown } from '../../Markdown';
2
+ import { FillThisInButton } from '../FillThisInButton';
3
+ import { populateLayerPrompt } from '../../../prompts/populateLayer';
4
+ import { isTemplateOnly } from '../isTemplateOnly';
5
+
6
+ export function CodeMapTab({ scopeId, layerId, content }: { scopeId: string; layerId: string; content: string }) {
7
+ if (isTemplateOnly(content)) {
8
+ return (
9
+ <div>
10
+ <p>No code-map recorded for this layer yet.</p>
11
+ <FillThisInButton
12
+ label="Copy prompt to populate this layer's code-map"
13
+ prompt={populateLayerPrompt('codeMap', scopeId, layerId)}
14
+ />
15
+ </div>
16
+ );
17
+ }
18
+ return <Markdown source={content} />;
19
+ }
@@ -0,0 +1,35 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { tenboApi } from '../../../api/client';
3
+ import type { LayerDoc } from '../../../types';
4
+
5
+ export function DeepDivesTab({ scopeId, layerId }: { scopeId: string; layerId: string }) {
6
+ const [docs, setDocs] = useState<LayerDoc[]>([]);
7
+ const [loading, setLoading] = useState(true);
8
+
9
+ useEffect(() => {
10
+ let cancelled = false;
11
+ setLoading(true);
12
+ tenboApi.getLayerDocs(scopeId, layerId)
13
+ .then((d) => { if (!cancelled) { setDocs(d); setLoading(false); } })
14
+ .catch(() => { if (!cancelled) { setDocs([]); setLoading(false); } });
15
+ return () => { cancelled = true; };
16
+ }, [scopeId, layerId]);
17
+
18
+ if (loading) return <div>Loading…</div>;
19
+ if (docs.length === 0) return <p>No deep-dive docs found for this layer.</p>;
20
+
21
+ return (
22
+ <ul>
23
+ {docs.map((d) => (
24
+ <li key={d.filename}>
25
+ <button
26
+ className="link-button"
27
+ onClick={() => tenboApi.openFile(`.tenbo/scopes/${scopeId}/layers/${layerId}/${d.filename}`).catch(() => {})}
28
+ >
29
+ {d.title || d.filename}
30
+ </button>
31
+ </li>
32
+ ))}
33
+ </ul>
34
+ );
35
+ }
@@ -0,0 +1,19 @@
1
+ import { Markdown } from '../../Markdown';
2
+ import { FillThisInButton } from '../FillThisInButton';
3
+ import { populateLayerPrompt } from '../../../prompts/populateLayer';
4
+ import { isTemplateOnly } from '../isTemplateOnly';
5
+
6
+ export function IntentTab({ scopeId, layerId, content }: { scopeId: string; layerId: string; content: string }) {
7
+ if (isTemplateOnly(content)) {
8
+ return (
9
+ <div>
10
+ <p>No intent recorded for this layer yet.</p>
11
+ <FillThisInButton
12
+ label="Copy prompt to populate this layer's intent"
13
+ prompt={populateLayerPrompt('intent', scopeId, layerId)}
14
+ />
15
+ </div>
16
+ );
17
+ }
18
+ return <Markdown source={content} />;
19
+ }
@@ -0,0 +1,19 @@
1
+ import { Markdown } from '../../Markdown';
2
+ import { FillThisInButton } from '../FillThisInButton';
3
+ import { populateLayerPrompt } from '../../../prompts/populateLayer';
4
+ import { isTemplateOnly } from '../isTemplateOnly';
5
+
6
+ export function PlainEnglishTab({ scopeId, layerId, content }: { scopeId: string; layerId: string; content: string }) {
7
+ if (isTemplateOnly(content)) {
8
+ return (
9
+ <div>
10
+ <p>No README recorded for this layer yet.</p>
11
+ <FillThisInButton
12
+ label="Copy prompt to populate this layer's README"
13
+ prompt={populateLayerPrompt('readme', scopeId, layerId)}
14
+ />
15
+ </div>
16
+ );
17
+ }
18
+ return <Markdown source={content} />;
19
+ }
@@ -0,0 +1,115 @@
1
+ .root :global(h1) {
2
+ font-size: 24px;
3
+ font-weight: 600;
4
+ line-height: 1.2;
5
+ letter-spacing: -0.01em;
6
+ color: var(--console-snow);
7
+ margin: 24px 0 12px;
8
+ }
9
+ .root :global(h2) {
10
+ font-size: 18px;
11
+ font-weight: 600;
12
+ line-height: 1.3;
13
+ letter-spacing: -0.005em;
14
+ color: var(--console-snow);
15
+ margin: 20px 0 8px;
16
+ }
17
+ .root :global(h3) {
18
+ font-size: 14px;
19
+ font-weight: 600;
20
+ line-height: 1.4;
21
+ color: var(--console-snow);
22
+ margin: 16px 0 4px;
23
+ }
24
+ .root :global(p) {
25
+ font-size: 13px;
26
+ line-height: 1.55;
27
+ color: var(--console-snow);
28
+ margin: 0 0 12px;
29
+ max-width: 75ch;
30
+ }
31
+ .root :global(ul), .root :global(ol) {
32
+ font-size: 13px;
33
+ line-height: 1.55;
34
+ color: var(--console-snow);
35
+ padding-left: 22px;
36
+ margin: 0 0 12px;
37
+ }
38
+ .root :global(li) { margin-bottom: 2px; }
39
+ .root :global(strong) { font-weight: 600; color: var(--console-snow); }
40
+ .root :global(em) {
41
+ font-style: normal;
42
+ color: var(--console-snow);
43
+ background: var(--console-iron);
44
+ padding: 0 4px;
45
+ border-radius: var(--r-sm);
46
+ }
47
+ .root :global(code) {
48
+ background: var(--console-iron);
49
+ color: var(--console-snow);
50
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
51
+ font-size: 12px;
52
+ padding: 1px 5px;
53
+ border-radius: var(--r-sm);
54
+ }
55
+ .root :global(pre) {
56
+ background: var(--console-soot);
57
+ border: 1px solid var(--console-mist);
58
+ border-radius: var(--r-md);
59
+ padding: 12px;
60
+ overflow-x: auto;
61
+ margin: 0 0 12px;
62
+ }
63
+ .root :global(pre code) {
64
+ display: block;
65
+ padding: 0;
66
+ background: transparent;
67
+ border: none;
68
+ font-size: 12px;
69
+ line-height: 1.5;
70
+ }
71
+ .root :global(hr) {
72
+ border: none;
73
+ border-top: 1px solid var(--console-mist);
74
+ margin: 16px 0;
75
+ }
76
+ .root :global(table) {
77
+ width: 100%;
78
+ border-collapse: collapse;
79
+ font-size: 13px;
80
+ line-height: 1.5;
81
+ margin: 0 0 12px;
82
+ table-layout: fixed;
83
+ }
84
+ .root :global(th) {
85
+ text-align: left;
86
+ font-weight: 600;
87
+ color: var(--console-snow);
88
+ padding: 6px 8px;
89
+ border-bottom: 2px solid var(--console-mist);
90
+ white-space: nowrap;
91
+ }
92
+ .root :global(td) {
93
+ padding: 6px 8px;
94
+ border-bottom: 1px solid var(--console-mist);
95
+ color: var(--console-snow);
96
+ vertical-align: top;
97
+ overflow-wrap: break-word;
98
+ word-break: break-word;
99
+ }
100
+ .root :global(td code) {
101
+ word-break: break-all;
102
+ }
103
+ .root :global(blockquote) {
104
+ border-left: 1px solid var(--console-mist);
105
+ padding-left: 12px;
106
+ margin: 0 0 12px;
107
+ color: var(--console-fog);
108
+ font-style: normal;
109
+ }
110
+ .empty {
111
+ color: var(--console-smoke);
112
+ font-style: normal;
113
+ font-size: 13px;
114
+ padding: 24px 0;
115
+ }
@@ -0,0 +1,27 @@
1
+ import { type ComponentProps } from 'react';
2
+ import ReactMarkdown from 'react-markdown';
3
+ import remarkGfm from 'remark-gfm';
4
+ import styles from './Markdown.module.css';
5
+ import { formatRoute } from '../router/routes';
6
+
7
+ const LAYER_LINK_RE = /^(?:\.\/)?scopes\/([^/]+)\/layers\/([^/]+?)(?:\.md)?$/;
8
+
9
+ function rewriteLayerLinks({ href, children, ...rest }: ComponentProps<'a'>) {
10
+ if (typeof href === 'string') {
11
+ const m = LAYER_LINK_RE.exec(href);
12
+ if (m) {
13
+ const target = formatRoute({ kind: 'docs-layer', scopeId: m[1], layerId: m[2], tab: 'overview' });
14
+ return <a href={target} {...rest}>{children}</a>;
15
+ }
16
+ }
17
+ return <a href={href} {...rest}>{children}</a>;
18
+ }
19
+
20
+ export function Markdown({ source }: { source: string }) {
21
+ if (!source.trim()) return <div className={styles.empty}>This document is empty.</div>;
22
+ return (
23
+ <div className={styles.root}>
24
+ <ReactMarkdown remarkPlugins={[remarkGfm]} components={{ a: rewriteLayerLinks }}>{source}</ReactMarkdown>
25
+ </div>
26
+ );
27
+ }
@@ -0,0 +1,93 @@
1
+ /* Sticky secondary nav — sits flush below the 48px TopBar */
2
+ .stickyBar {
3
+ position: sticky;
4
+ top: 48px;
5
+ z-index: 40;
6
+ background: var(--console-black);
7
+ }
8
+
9
+ /* Filter + view-toggle row */
10
+ .filterRow {
11
+ display: flex;
12
+ align-items: center;
13
+ gap: 12px;
14
+ padding: 8px 16px;
15
+ border-bottom: 1px solid var(--console-mist);
16
+ }
17
+
18
+ .filterLabel {
19
+ font-size: 12px;
20
+ color: var(--console-smoke);
21
+ white-space: nowrap;
22
+ }
23
+
24
+ .filterDivider {
25
+ width: 1px;
26
+ height: 18px;
27
+ background: var(--console-mist);
28
+ flex-shrink: 0;
29
+ }
30
+
31
+ .filterSpacer {
32
+ flex: 1;
33
+ }
34
+
35
+ .searchInput {
36
+ width: 180px;
37
+ /* inherit padding/border/bg from global input rule; only override what differs */
38
+ background: var(--console-iron);
39
+ transition: border-color var(--dur-state) var(--ease), width var(--dur-state) var(--ease);
40
+ }
41
+ .searchInput:focus-visible {
42
+ border-color: var(--signal-cyan);
43
+ width: 260px;
44
+ }
45
+
46
+ /* Item counts — far right of filter row */
47
+ .counts {
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 16px;
51
+ font-size: 12px;
52
+ font-weight: 500;
53
+ color: var(--console-smoke);
54
+ white-space: nowrap;
55
+ }
56
+ .counts strong {
57
+ font-weight: 700;
58
+ margin-left: 3px;
59
+ }
60
+ .countNow strong { color: var(--live-green); }
61
+ .countNext strong { color: var(--signal-cyan); }
62
+ .countLater strong { color: var(--console-fog); }
63
+ .countDone strong { color: var(--console-smoke); }
64
+
65
+ /* Kanban / List toggle */
66
+ .viewToggle {
67
+ display: flex;
68
+ gap: 2px;
69
+ background: var(--console-iron);
70
+ border-radius: var(--r-md);
71
+ padding: 2px;
72
+ }
73
+
74
+ .viewBtn {
75
+ display: inline-flex;
76
+ align-items: center;
77
+ gap: 5px;
78
+ padding: 4px 10px;
79
+ border-radius: calc(var(--r-md) - 2px);
80
+ font-size: 12px;
81
+ font-weight: 500;
82
+ color: var(--console-fog);
83
+ transition: background var(--dur-state) var(--ease), color var(--dur-state) var(--ease);
84
+ }
85
+
86
+ .viewBtnActive {
87
+ background: var(--console-mist);
88
+ color: var(--console-snow);
89
+ }
90
+
91
+ .viewBtn:hover:not(.viewBtnActive) {
92
+ color: var(--console-snow);
93
+ }