create-pmos 0.47.0 → 0.49.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/package.json +1 -1
- package/payload/AGENTS-HUMAN.md +1 -1
- package/payload/AGENTS.md +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/.claude-plugin/plugin.json +1 -1
- package/payload/mods/pmos-cockpit/README.md +14 -3
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +34 -6
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +22 -3
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +16 -3
- package/payload/mods/pmos-cockpit/hooks/lib/route.mjs +95 -0
- package/payload/mods/pmos-cockpit/hooks/register.tsx +104 -19
- package/payload/mods/pmos-cockpit/types/index.d.ts +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/pmos-log.sh +18 -8
- package/payload/scripts/pmos-model.js +233 -2
- package/payload/scripts/status-facts.js +214 -0
- package/payload/scripts/workflow-state.js +59 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/README.md +2 -2
- package/workspace/bin/live-notes.js +133 -0
- package/workspace/design-parity.js +328 -21
- package/workspace/doc-category-bench.js +165 -0
- package/workspace/fixtures/doc-category-labelled.json +167 -0
- package/workspace/guide/index.md +2 -1
- package/workspace/guide/initiatives-and-next-steps.md +17 -2
- package/workspace/guide/knowledge-graph.md +94 -1
- package/workspace/guide/layout.md +31 -2
- package/workspace/guide/live-notes.md +78 -0
- package/workspace/guide/proactive-engine.md +6 -2
- package/workspace/guide/validate-with-typesafe.md +31 -2
- package/workspace/lib/claims.js +51 -2
- package/workspace/lib/doc-category-corrections.js +113 -0
- package/workspace/lib/doc-category-writeback.js +110 -0
- package/workspace/lib/doc-category.js +225 -0
- package/workspace/lib/engine/pmos-model.js +233 -2
- package/workspace/lib/engine/status-facts.js +214 -0
- package/workspace/lib/engine/workflow-state.js +59 -2
- package/workspace/lib/jev-categorize.js +433 -0
- package/workspace/lib/jev-claims.js +59 -15
- package/workspace/lib/jev-live.js +37 -0
- package/workspace/lib/jev-route.js +139 -0
- package/workspace/lib/kg-cli.js +3 -1
- package/workspace/lib/kg.js +6 -1
- package/workspace/lib/learn.js +5 -3
- package/workspace/lib/live-notes.js +1035 -0
- package/workspace/lib/typesafe-settings.js +10 -2
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +5 -4
- package/workspace/public/pm/app.js +148 -79
- package/workspace/public/pm/data.js +78 -0
- package/workspace/public/pm/fixture.js +40 -2
- package/workspace/public/pm/modes-live.js +279 -18
- package/workspace/public/pm/panel-cockpit.js +638 -0
- package/workspace/public/pm/panel-detail.js +212 -8
- package/workspace/public/pm/panel-initiatives.js +9 -9
- package/workspace/public/pm/panel-newsess.js +145 -86
- package/workspace/public/pm/panel-repos.js +34 -17
- package/workspace/public/pm/panel-tabs.js +249 -29
- package/workspace/public/pm/state.js +48 -4
- package/workspace/public/pm/tab-doc.js +244 -16
- package/workspace/public/pm/tab-term.js +11 -1
- package/workspace/public/pm/tokens.css +4 -3
- package/workspace/public/pm/view-addrepo.js +207 -0
- package/workspace/public/pm/view-files.js +197 -0
- package/workspace/public/pm/view-guide.js +141 -0
- package/workspace/public/pm/view-knowledge.js +225 -0
- package/workspace/public/pm/view-livenotes.js +397 -0
- package/workspace/public/pm/view-memory.js +122 -0
- package/workspace/public/pm/view-portfolio.js +153 -0
- package/workspace/public/pm/view-reports.js +190 -0
- package/workspace/public/pm/view-scheduled.js +161 -0
- package/workspace/public/pm/view-skills.js +216 -0
- package/workspace/public/pm/view-today.js +168 -0
- package/workspace/public/pm/view-validate.js +171 -0
- package/workspace/server.js +349 -7
- package/workspace/skills/live-notes.skill +58 -0
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/* pm/view-files.js — Files: the repository's files in the Work list (workspace-cockpit-nav Run 3, PRD R10).
|
|
2
|
+
*
|
|
3
|
+
* Board: planning/design/cockpit-mockup/ViewFilesPad.dc.html (the left column).
|
|
4
|
+
*
|
|
5
|
+
* Two exports, both placed by the Work list (panel-cockpit.js):
|
|
6
|
+
* filesToggle() — the "Initiatives | Files" pair. It writes `state.filesOpen`.
|
|
7
|
+
* renderFiles(host) — when `filesOpen` is true, the list body: a filter, the file tree, a note.
|
|
8
|
+
*
|
|
9
|
+
* THE DATA. The tree is `/api/tree?ws=<id>`, the folder lens the server already serves (server.js
|
|
10
|
+
* `libraryTree`): every folder and file of the repository, six folders deep, at most 4000 entries,
|
|
11
|
+
* with each file's reader kind from lib/preview-kind.js. Nothing here adds a server route, and
|
|
12
|
+
* nothing is written. A file opens through `openDoc` — the same doc tab the folder search opens —
|
|
13
|
+
* so a file the reader cannot show says why in its tab (the any-text-file behaviour, pm-r21).
|
|
14
|
+
*
|
|
15
|
+
* WHAT THE BOARD SHOWS THAT IS NOT READ HERE. The board says binary files show their size. The tree
|
|
16
|
+
* route does not return sizes, so a binary file shows its name and the words "not opened" only.
|
|
17
|
+
*
|
|
18
|
+
* FIXTURE MODE makes no request: the list says the files are not read in the demo.
|
|
19
|
+
*/
|
|
20
|
+
import { el, setChildren } from './dom.js';
|
|
21
|
+
import { state, setState, FIXTURE } from './state.js';
|
|
22
|
+
import { openDoc } from './panel-initiatives.js';
|
|
23
|
+
|
|
24
|
+
const TREE_MAX_NODES = 4000; // server.js TREE_MAX_NODES — the bound the note states
|
|
25
|
+
|
|
26
|
+
/* ── module state: the fetched trees, the open folders and the filter text ──────────────────── */
|
|
27
|
+
const trees = new Map(); // ws -> { roots, error, loading, at }
|
|
28
|
+
const openDirs = new Set(); // folder paths the PM has opened
|
|
29
|
+
let filterText = '';
|
|
30
|
+
|
|
31
|
+
function load(ws) {
|
|
32
|
+
const cur = trees.get(ws);
|
|
33
|
+
if (cur && cur.loading) return;
|
|
34
|
+
trees.set(ws, { roots: cur ? cur.roots : null, error: null, loading: true, at: Date.now() });
|
|
35
|
+
fetch('/api/tree?ws=' + encodeURIComponent(ws)).then((r) => {
|
|
36
|
+
if (!r.ok) throw new Error('HTTP ' + r.status);
|
|
37
|
+
return r.json();
|
|
38
|
+
}).then(
|
|
39
|
+
(j) => { trees.set(ws, { roots: Array.isArray(j && j.roots) ? j.roots : [], error: null, loading: false, at: Date.now() }); setState({}); },
|
|
40
|
+
(e) => { trees.set(ws, { roots: null, error: (e && e.message) || String(e), loading: false, at: Date.now() }); setState({}); },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Forget the cached tree, so the next draw reads the folder again. */
|
|
45
|
+
function refresh() {
|
|
46
|
+
if (state.wsId) trees.delete(state.wsId);
|
|
47
|
+
setState({});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/* ── styles, from the board, on the token system ──────────────────────────────────────────────── */
|
|
51
|
+
const BTN = 'font:500 12.5px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--input);'
|
|
52
|
+
+ 'border-radius:8px;padding:0 12px;height:28px;display:inline-grid;place-items:center';
|
|
53
|
+
const ROW = 'display:flex;align-items:center;gap:8px;width:100%;min-height:30px;padding:0 10px;border-radius:6px;'
|
|
54
|
+
+ 'font:12.5px var(--mono);text-align:left;color:var(--foreground);min-width:0';
|
|
55
|
+
const NAME = 'min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
|
|
56
|
+
const NOTE = 'color:var(--muted-foreground);font-size:12px;line-height:1.45';
|
|
57
|
+
|
|
58
|
+
/* ── the toggle ───────────────────────────────────────────────────────────────────────────────── */
|
|
59
|
+
|
|
60
|
+
/** "Initiatives | Files". Opening Files reads the folder again, so a new file shows up. */
|
|
61
|
+
export function filesToggle() {
|
|
62
|
+
const on = !!state.filesOpen;
|
|
63
|
+
const b = (label, sel, onClick, key) => el('button', {
|
|
64
|
+
onClick, 'data-files-tab': key, 'aria-pressed': sel ? 'true' : 'false',
|
|
65
|
+
style: BTN + (sel ? ';background:var(--accent)' : ';color:var(--muted-foreground)'),
|
|
66
|
+
hover: sel ? null : 'color:var(--foreground)',
|
|
67
|
+
}, label);
|
|
68
|
+
return el('div', { 'data-files': 'toggle', style: 'display:flex;gap:6px' },
|
|
69
|
+
b('Initiatives', !on, () => { if (state.filesOpen) setState({ filesOpen: false }); }, 'initiatives'),
|
|
70
|
+
b('Files', on, () => { if (!state.filesOpen) { if (state.wsId) trees.delete(state.wsId); setState({ filesOpen: true }); } }, 'files'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/* ── the filter (one node, so the caret survives a redraw) ──────────────────────────────────── */
|
|
74
|
+
const filterInput = el('input', {
|
|
75
|
+
onInput: (e) => { filterText = e.target.value; setState({}); },
|
|
76
|
+
placeholder: 'Filter files…', 'aria-label': 'filter files', 'data-files': 'filter',
|
|
77
|
+
style: 'flex:1;min-width:0;font:13px var(--sans);color:var(--foreground);background:transparent;border:0;outline:0',
|
|
78
|
+
});
|
|
79
|
+
const filterBox = el('div', {
|
|
80
|
+
style: 'flex:none;display:flex;align-items:center;gap:8px;border:1px solid var(--input);border-radius:8px;height:32px;padding:0 10px;color:var(--muted-foreground)',
|
|
81
|
+
}, filterInput,
|
|
82
|
+
el('button', {
|
|
83
|
+
onClick: refresh, 'data-files': 'refresh', title: 'Read the folder again.', 'aria-label': 'read the folder again',
|
|
84
|
+
style: 'flex:none;color:var(--muted-foreground);font-size:12px;padding:0 2px', hover: 'color:var(--foreground)',
|
|
85
|
+
}, '↻'));
|
|
86
|
+
|
|
87
|
+
/* ── the tree ─────────────────────────────────────────────────────────────────────────────────── */
|
|
88
|
+
|
|
89
|
+
const PK = () => (typeof window !== 'undefined' ? window.PreviewKind : null);
|
|
90
|
+
|
|
91
|
+
/** A file the reader will not try: a type that is binary by nature. Everything else opens as a doc tab. */
|
|
92
|
+
function isBinary(name) {
|
|
93
|
+
const pk = PK();
|
|
94
|
+
return !!(pk && typeof pk.isKnownBinary === 'function' && pk.isKnownBinary(name));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function dotColor(f) {
|
|
98
|
+
if (isBinary(f.name)) return 'var(--muted-foreground)';
|
|
99
|
+
if (f.md || f.kind === 'markdown') return 'var(--brand)';
|
|
100
|
+
return 'var(--info)';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Does this folder hold a file whose path contains `q`? */
|
|
104
|
+
function holds(dir, q) {
|
|
105
|
+
if ((dir.docs || []).some((f) => f.path.toLowerCase().includes(q))) return true;
|
|
106
|
+
return (dir.dirs || []).some((d) => holds(d, q));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function countNodes(dir) {
|
|
110
|
+
return (dir.docs || []).length + (dir.dirs || []).reduce((n, d) => n + 1 + countNodes(d), 0);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function activeDocPath() {
|
|
114
|
+
const act = (state.tabs || []).find((t) => t.id === state.active);
|
|
115
|
+
return act && act.kind === 'doc' && !act.ws ? act.path : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function fileRow(f, depth, activePath) {
|
|
119
|
+
const pad = 'padding-left:' + (10 + depth * 16) + 'px';
|
|
120
|
+
const dot = el('span', { style: 'flex:none;color:' + dotColor(f) }, '●');
|
|
121
|
+
if (isBinary(f.name)) {
|
|
122
|
+
return el('div', {
|
|
123
|
+
'data-files': 'file', 'data-path': f.path, 'data-kind': f.kind || '', 'data-openable': 'false',
|
|
124
|
+
title: f.path + '\nA binary file. It is not opened.',
|
|
125
|
+
style: ROW + ';' + pad + ';color:var(--muted-foreground)',
|
|
126
|
+
}, dot, el('span', { style: NAME }, f.name), el('span', { style: 'margin-left:auto;flex:none;font-size:11px' }, 'not opened'));
|
|
127
|
+
}
|
|
128
|
+
const on = activePath === f.path;
|
|
129
|
+
return el('button', {
|
|
130
|
+
onClick: () => openDoc(null, f.path, 'FILE'),
|
|
131
|
+
'data-files': 'file', 'data-path': f.path, 'data-kind': f.kind || '', 'data-openable': 'true',
|
|
132
|
+
'aria-current': on ? 'true' : null, title: f.path,
|
|
133
|
+
style: ROW + ';' + pad + (on ? ';background:var(--accent)' : ''),
|
|
134
|
+
hover: on ? null : 'background:var(--accent)',
|
|
135
|
+
}, dot, el('span', { style: NAME }, f.name));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function dirRows(dir, depth, q, activePath, out) {
|
|
139
|
+
if (q && !holds(dir, q)) return;
|
|
140
|
+
const open = q ? true : openDirs.has(dir.path);
|
|
141
|
+
out.push(el('button', {
|
|
142
|
+
onClick: () => { if (openDirs.has(dir.path)) openDirs.delete(dir.path); else openDirs.add(dir.path); setState({}); },
|
|
143
|
+
'data-files': 'dir', 'data-path': dir.path, 'aria-expanded': open ? 'true' : 'false',
|
|
144
|
+
style: ROW + ';padding-left:' + (10 + depth * 16) + 'px', hover: 'background:var(--accent)',
|
|
145
|
+
}, el('span', { style: 'flex:none;color:var(--muted-foreground)' }, open ? '▾' : '▸'), el('span', { style: NAME }, dir.name)));
|
|
146
|
+
if (!open) return;
|
|
147
|
+
for (const d of dir.dirs || []) dirRows(d, depth + 1, q, activePath, out);
|
|
148
|
+
for (const f of dir.docs || []) if (!q || f.path.toLowerCase().includes(q)) out.push(fileRow(f, depth + 1, activePath));
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function message(text, tone) {
|
|
152
|
+
return el('div', { 'data-files': 'message', style: 'padding:8px 10px;font-size:12px;color:' + (tone || 'var(--muted-foreground)') }, text);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** The Files body: the filter, the tree and the note. Fills `host` and returns it. */
|
|
156
|
+
export function renderFiles(host) {
|
|
157
|
+
const body = [];
|
|
158
|
+
const ws = state.wsId;
|
|
159
|
+
let total = 0;
|
|
160
|
+
if (FIXTURE) {
|
|
161
|
+
body.push(message('Files are not read in the demo. Open the workspace on a repository to see its files.'));
|
|
162
|
+
} else if (!ws) {
|
|
163
|
+
body.push(message('Select a repository to see its files.'));
|
|
164
|
+
} else {
|
|
165
|
+
const t = trees.get(ws);
|
|
166
|
+
if (!t) load(ws);
|
|
167
|
+
if (!t || (t.loading && !t.roots)) body.push(message('Reading the files…'));
|
|
168
|
+
else if (t.error) body.push(message('The files could not be read: ' + t.error, 'var(--destructive)'));
|
|
169
|
+
else {
|
|
170
|
+
const q = filterText.trim().toLowerCase();
|
|
171
|
+
const activePath = activeDocPath();
|
|
172
|
+
const roots = t.roots || [];
|
|
173
|
+
const loose = roots.filter((r) => r.path === '');
|
|
174
|
+
for (const r of roots) total += r.path === '' ? (r.docs || []).length : 1 + countNodes(r);
|
|
175
|
+
for (const r of roots) if (r.path !== '') dirRows(r, 0, q, activePath, body);
|
|
176
|
+
for (const r of loose) for (const f of r.docs || []) if (!q || f.path.toLowerCase().includes(q)) body.push(fileRow(f, 0, activePath));
|
|
177
|
+
if (!body.length) body.push(message(q ? 'No file matches the filter.' : 'This repository has no files to list.'));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const capped = total >= TREE_MAX_NODES;
|
|
181
|
+
// The frame is persistent: re-creating it would detach the filter and lose its focus on every redraw.
|
|
182
|
+
setChildren(treeNode, body);
|
|
183
|
+
setChildren(noteNode, [NOTE_TEXT, capped ? capNode : null]);
|
|
184
|
+
setChildren(host, [boardNode]);
|
|
185
|
+
if (filterInput.value !== filterText) filterInput.value = filterText;
|
|
186
|
+
return host;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const NOTE_TEXT = document.createTextNode('Any text file opens as a document tab. Binary files show their name only.');
|
|
190
|
+
const capNode = el('span', { style: 'display:block;color:var(--warning)' },
|
|
191
|
+
'The list stops at ' + TREE_MAX_NODES + ' entries. Use the search box to find other files.');
|
|
192
|
+
const treeNode = el('div', { 'data-files': 'tree', style: 'flex:1;min-height:0;overflow-y:auto;display:flex;flex-direction:column;gap:1px' });
|
|
193
|
+
const noteNode = el('div', { 'data-files': 'note', style: 'flex:none;' + NOTE });
|
|
194
|
+
const boardNode = el('div', { 'data-board': 'files', style: 'flex:1;min-height:0;display:flex;flex-direction:column;gap:10px' },
|
|
195
|
+
filterBox, treeNode, noteNode);
|
|
196
|
+
|
|
197
|
+
export default renderFiles;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/* pm/view-guide.js — the User guide view, drawn from planning/design/cockpit-mockup/ViewGuide.dc.html.
|
|
2
|
+
*
|
|
3
|
+
* PRD workspace-cockpit-nav, Run 3 (R4). The board is two panes. On the left, a 280px column with a search field and the
|
|
4
|
+
* list of pages. On the right, the page that is chosen, with its title and its text.
|
|
5
|
+
*
|
|
6
|
+
* The list is the view's existing list (modes-live.js `guideList`, from GET /api/guide), placed in the left column and
|
|
7
|
+
* restyled. No node is removed, so every `data-r18="guide-page"` row keeps its `data-page` and its press: a row still opens
|
|
8
|
+
* the page as a read-only doc tab. This module adds a search that hides the rows that do not match, and remembers the page
|
|
9
|
+
* opened last so the right pane can show it. The right pane reads the page from GET /api/guide?page=, the same route the doc
|
|
10
|
+
* tab uses, and draws it with the doc tab's markdown renderer. A link to another guide page shows that page here.
|
|
11
|
+
*
|
|
12
|
+
* The board's group labels (Start, Working, Control) and its page titles are not in the guide's data, so the column shows
|
|
13
|
+
* the pages in the order the server gives them, under one label. In the fixture the guide is not read, and the board says so.
|
|
14
|
+
*/
|
|
15
|
+
import { el } from './dom.js';
|
|
16
|
+
import { mdRender } from './markdown.js';
|
|
17
|
+
|
|
18
|
+
const TTL = 15000;
|
|
19
|
+
const pages = new Map(); // page -> { state: 'loading'|'ok'|'error', text, error, at }
|
|
20
|
+
let chosen = null; // the page opened last from this board
|
|
21
|
+
let query = ''; // the search text; kept so a redraw keeps the filter
|
|
22
|
+
|
|
23
|
+
const LBL = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
24
|
+
const ITEM = 'display:flex;align-items:center;width:100%;box-sizing:border-box;min-height:36px;padding:6px 12px;border:0;border-radius:8px;font:500 13px var(--sans);line-height:1.35;text-align:left;color:var(--foreground);background:transparent;cursor:pointer';
|
|
25
|
+
const PILL = 'font:500 11px var(--sans);border:1px solid var(--border);border-radius:999px;padding:0 8px;line-height:18px;background:transparent;color:var(--foreground);cursor:pointer';
|
|
26
|
+
const NOTE = 'color:var(--muted-foreground);font-size:13px;line-height:1.5';
|
|
27
|
+
|
|
28
|
+
/** The board's own read of one guide page, read again after 15 seconds. */
|
|
29
|
+
function readPage(ctx, page) {
|
|
30
|
+
const hit = pages.get(page);
|
|
31
|
+
if (hit && (hit.state === 'loading' || Date.now() - hit.at < TTL)) return hit;
|
|
32
|
+
const next = { state: hit ? hit.state : 'loading', text: hit ? hit.text : null, error: null, at: hit ? hit.at : 0 };
|
|
33
|
+
if (!hit || hit.state !== 'ok') next.state = 'loading';
|
|
34
|
+
pages.set(page, next);
|
|
35
|
+
fetch('/api/guide?page=' + encodeURIComponent(page))
|
|
36
|
+
.then((r) => (r.ok ? r.text() : Promise.reject(new Error('no such guide page'))))
|
|
37
|
+
.then((text) => { pages.set(page, { state: 'ok', text, error: null, at: Date.now() }); ctx.setState({}); },
|
|
38
|
+
(e) => { pages.set(page, { state: 'error', text: null, error: (e && e.message) || String(e), at: Date.now() }); ctx.setState({}); });
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Hide the rows that do not match the search. Hiding is a style; the rows stay in the list. */
|
|
43
|
+
function filter(list) {
|
|
44
|
+
const q = query.trim().toLowerCase();
|
|
45
|
+
let shown = 0;
|
|
46
|
+
for (const row of list.querySelectorAll('[data-r18="guide-page"]')) {
|
|
47
|
+
const hit = !q || (row.textContent || '').toLowerCase().includes(q) || (row.getAttribute('data-page') || '').includes(q);
|
|
48
|
+
row.style.display = hit ? '' : 'none';
|
|
49
|
+
if (hit) shown++;
|
|
50
|
+
}
|
|
51
|
+
return shown;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function restyle(list, current) {
|
|
55
|
+
const wrap = list.querySelector('[data-r18="guide-list"]');
|
|
56
|
+
if (wrap) {
|
|
57
|
+
wrap.setAttribute('style', 'display:flex;flex-direction:column;gap:2px');
|
|
58
|
+
// the list's opening line has no hook; it reads as the column's small print
|
|
59
|
+
for (const k of wrap.children) if (!k.hasAttribute('data-r18')) k.setAttribute('style', NOTE + ';font-size:12px;padding:2px 12px 8px');
|
|
60
|
+
}
|
|
61
|
+
for (const row of list.querySelectorAll('[data-r18="guide-page"]')) {
|
|
62
|
+
const on = row.getAttribute('data-page') === current;
|
|
63
|
+
row.setAttribute('style', ITEM + (on ? ';background:var(--accent)' : ''));
|
|
64
|
+
if (on) row.setAttribute('data-board-chosen', ''); else row.removeAttribute('data-board-chosen');
|
|
65
|
+
}
|
|
66
|
+
for (const n of list.querySelectorAll('[data-r18="guide-empty"]')) n.setAttribute('style', NOTE + ';padding:4px 12px');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The right pane: the chosen page, read from the server and drawn with the doc tab's renderer. */
|
|
70
|
+
function pagePane(ctx, list, page, title) {
|
|
71
|
+
if (!page) return el('div', { 'data-board-part': 'page', style: NOTE }, 'Choose a page on the left.');
|
|
72
|
+
const r = readPage(ctx, page);
|
|
73
|
+
const row = list.querySelector('[data-r18="guide-page"][data-page="' + page.replace(/"/g, '') + '"]');
|
|
74
|
+
const actions = el('div', { 'data-board-part': 'actions', style: 'display:flex;gap:8px;flex-wrap:wrap;margin-top:6px' },
|
|
75
|
+
row ? el('button', { style: PILL, hover: 'background:var(--accent)', 'data-board-action': 'open-tab', onClick: () => row.click() }, 'Open in a tab') : null,
|
|
76
|
+
el('button', { style: PILL, hover: 'background:var(--accent)', 'data-board-action': 'open-today', onClick: () => ctx.openView('today') }, 'Open Today'));
|
|
77
|
+
if (r.state === 'loading' && !r.text) {
|
|
78
|
+
return el('div', { 'data-board-part': 'page', 'data-page': page, style: 'display:flex;flex-direction:column;gap:12px' },
|
|
79
|
+
el('div', { style: LBL }, 'Guide'), el('div', { style: NOTE }, 'Reading the page …'));
|
|
80
|
+
}
|
|
81
|
+
if (r.state === 'error') {
|
|
82
|
+
return el('div', { 'data-board-part': 'page', 'data-page': page, style: 'display:flex;flex-direction:column;gap:12px' },
|
|
83
|
+
el('div', { style: LBL }, 'Guide'), el('div', { style: NOTE + ';color:var(--destructive)' }, 'The page could not be read: ' + r.error));
|
|
84
|
+
}
|
|
85
|
+
const known = new Set([...list.querySelectorAll('[data-r18="guide-page"]')].map((n) => n.getAttribute('data-page')));
|
|
86
|
+
const doc = mdRender(r.text, {
|
|
87
|
+
dir: [],
|
|
88
|
+
// a link to another guide page shows that page here; any other link is left as text by the renderer's rules
|
|
89
|
+
onDoc: (p) => { const name = String(p).replace(/\.md$/, '').split('/').pop(); if (known.has(name)) { chosen = name; ctx.setState({}); } },
|
|
90
|
+
});
|
|
91
|
+
return el('div', { 'data-board-part': 'page', 'data-page': page, style: 'display:flex;flex-direction:column;gap:12px;min-width:0' },
|
|
92
|
+
el('div', { style: LBL }, 'Guide'),
|
|
93
|
+
el('div', { 'data-board-title': '', style: 'font:600 26px var(--sans);line-height:1.3' }, doc.title || title || page),
|
|
94
|
+
actions,
|
|
95
|
+
el('div', { 'data-board-part': 'page-body', style: 'max-width:640px;font-size:14px;line-height:1.6;color:var(--foreground)' }, doc.blocks.map((b) => el('div', null, b))));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function renderBoard(inner, ctx) {
|
|
99
|
+
const { FIXTURE } = ctx;
|
|
100
|
+
|
|
101
|
+
const list = el('div', { 'data-board-section': 'pages', style: 'display:flex;flex-direction:column;gap:2px' });
|
|
102
|
+
ctx.renderMode(list);
|
|
103
|
+
// remember the page opened last; the row's own press still opens it as a tab
|
|
104
|
+
list.addEventListener('click', (e) => {
|
|
105
|
+
const row = e.target && e.target.closest ? e.target.closest('[data-r18="guide-page"]') : null;
|
|
106
|
+
if (row) chosen = row.getAttribute('data-page');
|
|
107
|
+
}, true);
|
|
108
|
+
|
|
109
|
+
const rows = [...list.querySelectorAll('[data-r18="guide-page"]')];
|
|
110
|
+
const names = rows.map((r) => r.getAttribute('data-page'));
|
|
111
|
+
if (!chosen || !names.includes(chosen)) chosen = names.includes('index') ? 'index' : (names[0] || null);
|
|
112
|
+
restyle(list, chosen);
|
|
113
|
+
const shown = filter(list);
|
|
114
|
+
|
|
115
|
+
const search = el('input', {
|
|
116
|
+
type: 'search', placeholder: 'Search the guide…', value: query, 'data-board-part': 'search', 'aria-label': 'Search the guide',
|
|
117
|
+
style: 'width:100%;box-sizing:border-box;height:32px;min-height:32px;padding:0 10px;border:1px solid var(--input);border-radius:8px;background:transparent;color:var(--foreground);font:13px var(--sans)',
|
|
118
|
+
onInput: (e) => {
|
|
119
|
+
query = e.target.value;
|
|
120
|
+
const n = filter(list);
|
|
121
|
+
const none = side.querySelector('[data-board-part="no-match"]');
|
|
122
|
+
if (none) none.style.display = n || !rows.length ? 'none' : '';
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
const noMatch = el('div', { 'data-board-part': 'no-match', style: NOTE + ';padding:4px 12px' + (shown || !rows.length ? ';display:none' : '') }, 'No page matches the search.');
|
|
126
|
+
|
|
127
|
+
const side = el('div', { 'data-board-part': 'sidebar',
|
|
128
|
+
style: 'width:280px;flex:none;box-sizing:border-box;border-right:1px solid var(--border);background:var(--sidebar);padding:14px;display:flex;flex-direction:column;gap:6px' },
|
|
129
|
+
search,
|
|
130
|
+
el('div', { style: LBL + ';padding:8px 12px 2px' }, 'Pages'),
|
|
131
|
+
FIXTURE ? el('div', { style: NOTE + ';padding:4px 12px' }, 'The guide is not read in the fixture.') : list,
|
|
132
|
+
noMatch);
|
|
133
|
+
|
|
134
|
+
const chosenRow = rows.find((r) => r.getAttribute('data-page') === chosen);
|
|
135
|
+
const main = el('div', { 'data-board-part': 'main', style: 'flex:1 1 0;min-width:0;padding:26px 40px;display:flex;flex-direction:column;gap:12px' },
|
|
136
|
+
FIXTURE ? el('div', { style: NOTE }, 'The guide is not read in the fixture.') : pagePane(ctx, list, chosen, chosenRow ? (chosenRow.textContent || '').trim() : null));
|
|
137
|
+
|
|
138
|
+
inner.appendChild(el('div', { 'data-board': 'guide',
|
|
139
|
+
style: 'display:flex;min-height:560px;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--card);color:var(--foreground);font-size:14px;line-height:1.6' },
|
|
140
|
+
side, main));
|
|
141
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/* pm/view-knowledge.js — the Knowledge view tab, drawn from planning/design/cockpit-mockup/ViewKnowledge.dc.html.
|
|
2
|
+
*
|
|
3
|
+
* PRD workspace-cockpit-nav, Run 3 (R4). The board is two panes. On the left, a 330px column with a two-way switch, a search
|
|
4
|
+
* field and the list. On the right, the detail of the chosen item.
|
|
5
|
+
*
|
|
6
|
+
* The list is the view's existing list (modes-live.js `knowledgeLive`, or the fixture's decisions), placed unchanged, so every
|
|
7
|
+
* `data-r4="know"`, `data-r11`, `data-cat` and `data-cat-filter` hook and every live behaviour stays as it was: a row still opens
|
|
8
|
+
* its document as a tab. This module only adds the frame, a search that hides rows that do not match, and a `data-path` on each
|
|
9
|
+
* row so the right pane can show the document that was opened last.
|
|
10
|
+
*
|
|
11
|
+
* The facts in the right pane come from GET /api/library, the same read the list uses: a document's name, its category and
|
|
12
|
+
* the basis that decided it, and its file. The board's "Cited by", "Supersedes" and status badge come from the knowledge graph,
|
|
13
|
+
* which this app does not read in the browser. The pane says so, and names `pmos kg` as the place to read them. The board's
|
|
14
|
+
* "Graph" switch shows the same plain note. Nothing on this board is invented.
|
|
15
|
+
*/
|
|
16
|
+
import { el } from './dom.js';
|
|
17
|
+
|
|
18
|
+
const TTL = 15000;
|
|
19
|
+
let lib = { ws: null, state: 'idle', data: null, error: null, at: 0 }; // this board's read of /api/library
|
|
20
|
+
let seg = 'docs'; // the left switch: 'docs' (the list) or 'graph' (not read here)
|
|
21
|
+
let query = ''; // the search text; kept so a redraw keeps the filter
|
|
22
|
+
const chosen = {}; // ws -> the path of the document opened last from this board
|
|
23
|
+
|
|
24
|
+
const LBL = 'font:500 10px var(--mono);letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)';
|
|
25
|
+
const BADGE = 'font:500 11px var(--sans);border:1px solid var(--border);border-radius:999px;padding:0 8px;line-height:18px;white-space:nowrap;display:inline-block;flex:none';
|
|
26
|
+
const TONE = {
|
|
27
|
+
s: ';border-color:transparent;background:color-mix(in oklab, var(--success) 16%, transparent);color:var(--success)',
|
|
28
|
+
w: ';border-color:transparent;background:color-mix(in oklab, var(--warning) 16%, transparent);color:var(--warning)',
|
|
29
|
+
};
|
|
30
|
+
const BTN = 'font:500 12.5px var(--sans);background:transparent;color:var(--foreground);border:1px solid var(--input);border-radius:8px;padding:0 12px;height:30px;display:inline-grid;place-items:center;cursor:pointer;flex:none';
|
|
31
|
+
const MUTED = 'color:var(--muted-foreground)';
|
|
32
|
+
const badge = (text, tone, attrs) => el('span', { ...(attrs || {}), style: BADGE + (tone ? TONE[tone] : '') }, String(text));
|
|
33
|
+
const rule = () => el('div', { style: 'height:1px;background:var(--border);flex:none' });
|
|
34
|
+
|
|
35
|
+
/** The board's own read of /api/library for one repository, read again after 15 seconds, like the list's. */
|
|
36
|
+
function library(ctx, ws) {
|
|
37
|
+
if (ctx.FIXTURE) return null;
|
|
38
|
+
const same = lib.ws === ws;
|
|
39
|
+
if (same && (lib.state === 'loading' || Date.now() - lib.at < TTL)) return lib;
|
|
40
|
+
const prev = same ? lib : null;
|
|
41
|
+
lib = { ws, state: 'loading', data: prev ? prev.data : null, error: null, at: prev ? prev.at : 0 };
|
|
42
|
+
fetch('/api/library?ws=' + encodeURIComponent(ws)).then((r) => r.json()).then(
|
|
43
|
+
(data) => { if (lib.ws !== ws) return; lib = { ws, state: data && data.ok === false ? 'error' : 'ok', data, error: data && data.error, at: Date.now() }; ctx.setState({}); },
|
|
44
|
+
(e) => { if (lib.ws !== ws) return; lib = { ws, state: 'error', data: null, error: (e && e.message) || String(e), at: Date.now() }; ctx.setState({}); },
|
|
45
|
+
);
|
|
46
|
+
return lib;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The same walk as the list's (modes-live.js `flatten`), so the n-th row and the n-th document are the same document. */
|
|
50
|
+
function flatten(node, out, depth, tree) {
|
|
51
|
+
for (const d of node.docs || []) out.push({ path: d.path, name: d.name, tree, category: d.category || null, basis: d.category_basis || null, why: d.category_why || null });
|
|
52
|
+
if (depth < 3) for (const sub of node.dirs || []) { out.push({ dir: true }); flatten(sub, out, depth + 1, tree); }
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
const docsOf = (trees) => {
|
|
56
|
+
const out = [];
|
|
57
|
+
for (const t of trees || []) for (const it of flatten(t, [], 0, t.path || t.name || 'knowledge')) if (!it.dir) out.push(it);
|
|
58
|
+
return out;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
/** Give each row of the list the path of its document. The list has a category filter of its own; its pressed button says
|
|
62
|
+
* which category is shown, so the rows are matched against the same subset. When the counts differ (the two reads are a
|
|
63
|
+
* moment apart) nothing is marked, and the next redraw tries again. */
|
|
64
|
+
function markRows(host, data) {
|
|
65
|
+
const know = [...host.querySelectorAll('[data-r4="know"]')];
|
|
66
|
+
const pressed = host.querySelector('[data-cat-filter][aria-pressed="true"]');
|
|
67
|
+
const cat = pressed ? pressed.getAttribute('data-cat-filter') : '';
|
|
68
|
+
const docs = docsOf(data.knowledge).filter((d) => !cat || d.category === cat);
|
|
69
|
+
if (docs.length === know.length) know.forEach((b, i) => b.setAttribute('data-path', docs[i].path));
|
|
70
|
+
const noteRows = [...host.querySelectorAll('[data-r11="note"]')];
|
|
71
|
+
const notes = docsOf(data.notes);
|
|
72
|
+
if (notes.length === noteRows.length) noteRows.forEach((b, i) => b.setAttribute('data-path', notes[i].path));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Hide the rows that do not match the search. Folder lines are hidden while a search is on, as they no longer group anything. */
|
|
76
|
+
function applySearch(host, q) {
|
|
77
|
+
const needle = q.trim().toLowerCase();
|
|
78
|
+
let shown = 0;
|
|
79
|
+
for (const b of host.querySelectorAll('[data-r4="know"], [data-r11="note"], button')) {
|
|
80
|
+
if (b.hasAttribute('data-cat-filter')) continue;
|
|
81
|
+
const hay = ((b.getAttribute('data-path') || '') + ' ' + (b.textContent || '')).toLowerCase();
|
|
82
|
+
const hit = !needle || hay.includes(needle);
|
|
83
|
+
b.style.display = hit ? '' : 'none';
|
|
84
|
+
if (hit) shown++;
|
|
85
|
+
}
|
|
86
|
+
for (const d of host.querySelectorAll('div')) {
|
|
87
|
+
if (d.attributes.length !== 1 || d.children.length !== 1) continue; // a folder line: one style, one text span
|
|
88
|
+
if (/\/$/.test((d.textContent || '').trim())) d.style.display = needle ? 'none' : '';
|
|
89
|
+
}
|
|
90
|
+
return shown;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function segButton(key, label, ctx) {
|
|
94
|
+
const on = seg === key;
|
|
95
|
+
return el('button', {
|
|
96
|
+
'data-kn-seg': key, 'aria-pressed': on ? 'true' : 'false',
|
|
97
|
+
onClick: () => { seg = key; ctx.setState({}); },
|
|
98
|
+
style: 'flex:1 1 0;text-align:center;border:0;border-radius:6px;padding:3px 0;font:12.5px var(--sans);cursor:pointer;'
|
|
99
|
+
+ (on ? 'background:var(--background);font-weight:500;color:var(--foreground)' : 'background:transparent;color:var(--muted-foreground)'),
|
|
100
|
+
}, label);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The right pane when the Graph switch is on: the graph is not read in this view. */
|
|
104
|
+
function graphPane() {
|
|
105
|
+
return [
|
|
106
|
+
el('div', { 'data-board': 'detail-head', style: 'display:flex;align-items:center;gap:10px' }, el('span', { style: LBL }, 'Graph')),
|
|
107
|
+
el('div', { 'data-board': 'title', style: 'font:600 22px var(--sans)' }, 'The knowledge graph'),
|
|
108
|
+
el('div', { 'data-kn': 'not-read', style: MUTED + ';max-width:640px' },
|
|
109
|
+
'This view does not read the knowledge graph. To see how decisions cite and supersede each other, run ',
|
|
110
|
+
el('code', { style: 'font:12px var(--mono);color:var(--foreground)' }, 'pmos kg'), ' in a terminal.'),
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The right pane for the document opened last: its name, category and file, and what is not read here. */
|
|
115
|
+
function docPane(doc, reopen) {
|
|
116
|
+
const cat = doc.category;
|
|
117
|
+
const tone = cat === 'undetermined' ? 'w' : cat ? 's' : null;
|
|
118
|
+
return [
|
|
119
|
+
el('div', { 'data-board': 'detail-head', style: 'display:flex;align-items:center;gap:10px;flex-wrap:wrap' },
|
|
120
|
+
el('span', { style: 'font:500 12px var(--mono);' + MUTED }, doc.tree),
|
|
121
|
+
cat ? badge(cat, tone, { 'data-kn': 'category' }) : null),
|
|
122
|
+
el('div', { 'data-board': 'title', style: 'font:600 22px var(--sans);overflow-wrap:anywhere' }, doc.name || doc.path),
|
|
123
|
+
el('div', { style: MUTED + ';max-width:640px' }, cat
|
|
124
|
+
? 'Category ' + cat + (doc.basis ? ', decided by ' + doc.basis : ', with no evidence') + (doc.why ? ' (' + doc.why + ')' : '') + '. The category is advice. Nothing is written.'
|
|
125
|
+
: 'This document has no category.'),
|
|
126
|
+
el('div', null, el('button', { 'data-kn': 'open', style: BTN, hover: 'background:var(--accent)', onClick: reopen }, 'Open in a tab')),
|
|
127
|
+
rule(),
|
|
128
|
+
el('div', { style: LBL }, 'Cited by and supersedes'),
|
|
129
|
+
el('div', { 'data-kn': 'not-read', style: MUTED }, 'Not read in this view. Run ',
|
|
130
|
+
el('code', { style: 'font:12px var(--mono);color:var(--foreground)' }, 'pmos kg'), ' in a terminal to see them.'),
|
|
131
|
+
el('div', { style: LBL + ';margin-top:8px' }, 'File'),
|
|
132
|
+
el('div', { 'data-kn': 'file', style: 'font:12px var(--mono);color:var(--info);overflow-wrap:anywhere' }, doc.path),
|
|
133
|
+
];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** The right pane before anything is chosen: what the list holds, counted from the read. */
|
|
137
|
+
function summaryPane(ctx, r) {
|
|
138
|
+
const out = [
|
|
139
|
+
el('div', { 'data-board': 'detail-head', style: 'display:flex;align-items:center;gap:10px' }, el('span', { style: LBL }, 'Knowledge')),
|
|
140
|
+
el('div', { 'data-board': 'title', style: 'font:600 22px var(--sans)' }, ctx.FIXTURE ? 'Decisions' : 'This repository\'s knowledge'),
|
|
141
|
+
el('div', { style: MUTED + ';max-width:640px' }, ctx.FIXTURE
|
|
142
|
+
? 'Choose a decision on the left. Its detail opens in the detail column.'
|
|
143
|
+
: 'Choose a document on the left to open it in a tab. Its facts then show here.'),
|
|
144
|
+
];
|
|
145
|
+
if (ctx.FIXTURE) return out;
|
|
146
|
+
out.push(rule());
|
|
147
|
+
if (!r || r.state === 'loading' && !r.data) { out.push(el('div', { 'data-kn': 'reading', style: MUTED }, 'Reading the knowledge …')); return out; }
|
|
148
|
+
if (r.state === 'error') { out.push(el('div', { 'data-kn': 'error', style: 'color:var(--destructive)' }, 'The knowledge could not be read: ' + (r.error || 'error'))); return out; }
|
|
149
|
+
const sets = [['knowledge', (r.data && r.data.knowledge) || []], ['notes', (r.data && r.data.notes) || []]];
|
|
150
|
+
out.push(el('div', { style: LBL }, 'In this repository'));
|
|
151
|
+
const rows = [];
|
|
152
|
+
for (const [kind, trees] of sets) {
|
|
153
|
+
for (const t of trees) {
|
|
154
|
+
const n = docsOf([t]).length;
|
|
155
|
+
rows.push(el('div', { 'data-kn-tree': kind, style: 'display:flex;gap:10px;align-items:center' },
|
|
156
|
+
badge(kind === 'notes' ? 'notes' : 'bundle'),
|
|
157
|
+
el('span', { style: 'font:12px var(--mono);flex:1 1 auto;min-width:0;overflow-wrap:anywhere' }, t.path || t.name || kind),
|
|
158
|
+
el('span', { style: MUTED + ';font-size:12px' }, n + (n === 1 ? ' document' : ' documents'))));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
if (!rows.length) rows.push(el('div', { 'data-kn': 'empty', style: MUTED }, 'This repository has no knowledge bundle and no notes.'));
|
|
162
|
+
out.push(el('div', { style: 'display:flex;flex-direction:column;gap:6px' }, rows));
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function renderBoard(inner, ctx) {
|
|
167
|
+
const { state } = ctx;
|
|
168
|
+
const ws = state.wsId || '';
|
|
169
|
+
const r = library(ctx, ws);
|
|
170
|
+
|
|
171
|
+
// The board fills the tab from edge to edge, as the mockup does: the pane's own padding is dropped for this view only.
|
|
172
|
+
const pane = inner.parentElement;
|
|
173
|
+
if (pane) { pane.style.padding = '0'; pane.style.overflow = 'hidden'; pane.style.display = 'flex'; pane.style.flexDirection = 'column'; }
|
|
174
|
+
inner.style.cssText = 'flex:1 1 auto;min-height:0;display:flex;flex-direction:row;gap:0;max-width:none;height:100%';
|
|
175
|
+
inner.setAttribute('data-board', 'knowledge');
|
|
176
|
+
|
|
177
|
+
// the list, unchanged, inside the left column
|
|
178
|
+
const host = el('div', { 'data-board-section': 'list', style: 'display:flex;flex-direction:column;gap:2px;margin:0 -14px' });
|
|
179
|
+
ctx.renderMode(host);
|
|
180
|
+
if (!host.firstChild) host.appendChild(el('div', { style: MUTED + ';padding:8px 14px;font-size:12px' }, ctx.ready ? 'This list has nothing to show.' : 'Reading the list …'));
|
|
181
|
+
if (r && r.data) markRows(host, r.data);
|
|
182
|
+
const docPath = chosen[ws] || '';
|
|
183
|
+
if (docPath) for (const b of host.querySelectorAll('[data-path]')) {
|
|
184
|
+
if (b.getAttribute('data-path') !== docPath) continue;
|
|
185
|
+
b.setAttribute('data-kn-on', '1');
|
|
186
|
+
b.style.background = 'var(--accent)';
|
|
187
|
+
}
|
|
188
|
+
// A press on a row is recorded before the row's own handler opens the document, so the right pane can show it.
|
|
189
|
+
host.addEventListener('click', (e) => {
|
|
190
|
+
const b = e.target && e.target.closest ? e.target.closest('[data-path]') : null;
|
|
191
|
+
if (b && host.contains(b)) chosen[ws] = b.getAttribute('data-path');
|
|
192
|
+
}, true);
|
|
193
|
+
|
|
194
|
+
const noMatch = el('div', { 'data-kn': 'no-match', style: MUTED + ';padding:8px 0;font-size:12px;display:none' }, 'No document matches the search.');
|
|
195
|
+
const filter = (q) => { noMatch.style.display = applySearch(host, q) || !q.trim() ? 'none' : ''; };
|
|
196
|
+
filter(query);
|
|
197
|
+
|
|
198
|
+
const wasFocused = typeof document !== 'undefined' && document.activeElement && document.activeElement.getAttribute('data-kn-search') === '1';
|
|
199
|
+
const caret = wasFocused ? document.activeElement.selectionStart : null;
|
|
200
|
+
const search = el('input', {
|
|
201
|
+
type: 'search', 'data-kn-search': '1', 'data-board': 'search', 'aria-label': ctx.FIXTURE ? 'Search decisions' : 'Search documents',
|
|
202
|
+
placeholder: ctx.FIXTURE ? 'Search decisions…' : 'Search documents…', value: query,
|
|
203
|
+
onInput: (e) => { query = e.target.value; filter(query); },
|
|
204
|
+
style: 'box-sizing:border-box;width:100%;flex:none;border:1px solid var(--input);border-radius:8px;height:32px;padding:0 10px;background:transparent;color:var(--foreground);font:13px var(--sans)',
|
|
205
|
+
});
|
|
206
|
+
if (wasFocused) requestAnimationFrame(() => { search.focus(); if (caret !== null) try { search.setSelectionRange(caret, caret); } catch { /* not a text field */ } });
|
|
207
|
+
|
|
208
|
+
const left = el('div', { 'data-board-section': 'sidebar',
|
|
209
|
+
style: 'width:330px;flex:none;box-sizing:border-box;border-right:1px solid var(--border);padding:14px;display:flex;flex-direction:column;gap:10px;background:var(--sidebar);overflow-y:auto;min-height:0' },
|
|
210
|
+
el('div', { 'data-board': 'toggle', style: 'display:flex;gap:2px;background:var(--accent);border-radius:8px;padding:3px;flex:none' },
|
|
211
|
+
segButton('docs', ctx.FIXTURE ? 'Decisions' : 'Documents', ctx), segButton('graph', 'Graph', ctx)),
|
|
212
|
+
search, host, noMatch);
|
|
213
|
+
|
|
214
|
+
let doc = null;
|
|
215
|
+
if (docPath && r && r.data) doc = docsOf(r.data.knowledge).concat(docsOf(r.data.notes)).find((d) => d.path === docPath) || null;
|
|
216
|
+
const reopen = () => {
|
|
217
|
+
const b = host.querySelector('[data-kn-on="1"]');
|
|
218
|
+
if (b) b.click();
|
|
219
|
+
};
|
|
220
|
+
const right = el('div', { 'data-board-section': 'detail',
|
|
221
|
+
style: 'flex:1 1 0;min-width:0;box-sizing:border-box;padding:22px 28px;display:flex;flex-direction:column;gap:12px;overflow-y:auto;min-height:0' },
|
|
222
|
+
seg === 'graph' ? graphPane() : doc ? docPane(doc, reopen) : summaryPane(ctx, r));
|
|
223
|
+
|
|
224
|
+
inner.append(left, right);
|
|
225
|
+
}
|