create-pmos 0.15.0 → 0.16.1

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 (39) hide show
  1. package/README.md +65 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +2 -2
  4. package/workspace/README.md +50 -13
  5. package/workspace/bin/pmos.js +204 -51
  6. package/workspace/design-parity.js +358 -0
  7. package/workspace/doctor.js +54 -0
  8. package/workspace/lib/find-browser.js +60 -0
  9. package/workspace/lib/four-panel.js +345 -0
  10. package/workspace/lib/git-info.js +28 -1
  11. package/workspace/lib/mobile-nav.js +32 -2
  12. package/workspace/lib/server-discovery.js +127 -0
  13. package/workspace/package-lock.json +29 -2
  14. package/workspace/package.json +13 -5
  15. package/workspace/public/index.html +79 -409
  16. package/workspace/public/pm/app.js +242 -0
  17. package/workspace/public/pm/data.js +905 -0
  18. package/workspace/public/pm/dom.js +116 -0
  19. package/workspace/public/pm/fixture.js +979 -0
  20. package/workspace/public/pm/mobile.css +200 -0
  21. package/workspace/public/pm/mobile.js +403 -0
  22. package/workspace/public/pm/modes.js +1076 -0
  23. package/workspace/public/pm/panel-detail.js +648 -0
  24. package/workspace/public/pm/panel-initiatives.js +426 -0
  25. package/workspace/public/pm/panel-repos.js +553 -0
  26. package/workspace/public/pm/panel-tabs.js +487 -0
  27. package/workspace/public/pm/state.js +168 -0
  28. package/workspace/public/pm/tab-doc.js +279 -0
  29. package/workspace/public/pm/tab-task.js +83 -0
  30. package/workspace/public/pm/tab-term.js +560 -0
  31. package/workspace/public/pm/tokens.css +73 -0
  32. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  33. package/workspace/server.js +174 -11
  34. package/workspace/focus.test.js +0 -650
  35. package/workspace/handoff.test.js +0 -443
  36. package/workspace/profiles.test.js +0 -362
  37. package/workspace/public/app.js +0 -5560
  38. package/workspace/public/styles.css +0 -2258
  39. package/workspace/repo-scan.test.js +0 -327
@@ -0,0 +1,426 @@
1
+ /* pm/panel-initiatives.js — panel 2, the Slack-style initiative board.
2
+ *
3
+ * A PORT of the prototype's column 1 (template lines 117–322, derivation lines 1411–1466). Every
4
+ * inline `style` string below is copied from that file character for character, and every literal
5
+ * is the prototype's literal: the acceptance bar for this surface is a mechanical diff, so a
6
+ * reworded label or a "tidied" padding is a defect, not an improvement.
7
+ *
8
+ * ── Two structural facts the port has to honour ─────────────────────────────────────────────
9
+ *
10
+ * 1. INTERPOLATIONS ARE WRAPPED. The prototype's runtime renders every `{{ expr }}` as
11
+ * `<span class="sc-interp">value</span>` INSIDE the styled element, while literal template text
12
+ * stays a bare text node. That is not cosmetic: the comparator indexes nodes by their own text
13
+ * and then diffs computed style and geometry, so the node carrying "O1" is an inline span in the
14
+ * prototype and would be a blockified 9px-wide flex item if the text were placed directly on the
15
+ * styled span. `iv()` below reproduces the wrapper; bare text is used exactly where the
16
+ * prototype's template has bare text (`#`, `●`, `+`, `⌕`, `⟨`, and the two italic lines).
17
+ *
18
+ * 2. THE SEARCH INPUT IS PERSISTENT. Typing sets `q`, which re-renders this panel; an input node
19
+ * rebuilt on every keystroke loses focus and caret after the first character. The header and the
20
+ * search box are therefore built ONCE at module load and re-used on every render, and only the
21
+ * list below them is rebuilt. `setChildren` keeps their identity in the host.
22
+ *
23
+ * ── Where the data comes from ───────────────────────────────────────────────────────────────
24
+ * Fixture mode reads `fixture.js` directly. Normal mode is the same code path with two optional
25
+ * overrides the data layer may publish into state — `state.board` ({repoId: group[]}) and
26
+ * `state.records` ({slug: record}). Nothing here caches anything derivable (D33): groups, counts,
27
+ * search hits and the selected row are computed at read time, every render.
28
+ */
29
+
30
+ import { el, setChildren } from './dom.js';
31
+ import { state, setState, FIXTURE } from './state.js';
32
+ import * as FX from './fixture.js';
33
+
34
+ /* ── the prototype's small helpers, verbatim in behaviour ──────────────────────────────────── */
35
+
36
+ /** `{{ expr }}` — an interpolated value, wrapped the way the prototype's runtime wraps it. */
37
+ const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
38
+
39
+ /** selRow (line 1402) — an active row is washed and gets the 2px brand left border. */
40
+ const selRow = (on) => ({
41
+ bg: on ? 'var(--accent)' : 'transparent',
42
+ bl: on ? 'var(--brand)' : 'transparent',
43
+ });
44
+
45
+ /* The seed board is reachable ONLY in fixture mode. In normal mode an unpublished board is an
46
+ * EMPTY board — the prototype's own copy for a repository with nothing filed — never the seed data.
47
+ * "The beautiful shell" in the PRD's failure-mode list is exactly this: fixture rows rendering in
48
+ * the real app, so the diff looks clean while nothing is connected. */
49
+ const seedBoard = () => (FIXTURE ? FX.ALLGROUPS : {});
50
+ const seedRecords = () => (FIXTURE ? FX.DATA : {});
51
+ const boardOf = () => (state.board && typeof state.board === 'object' ? state.board : seedBoard());
52
+
53
+ /** The repository whose board is showing (line 1395). */
54
+ function currentProduct() {
55
+ if (state.product) return state.product;
56
+ const keys = Object.keys(boardOf());
57
+ return keys.length ? keys[0] : 'workspace';
58
+ }
59
+
60
+ /** The Objective/KR groups a repository starts from, before user-filed additions. */
61
+ function baseGroups(product) {
62
+ const board = boardOf();
63
+ if (board[product]) return board[product];
64
+ // The prototype falls back to the workspace board for an unknown repository (line 1425). Only
65
+ // fixture mode can honour that; in normal mode an unknown repository has an empty board.
66
+ return FIXTURE && !state.board ? (FX.ALLGROUPS[product] || FX.ALLGROUPS.workspace) : [];
67
+ }
68
+
69
+ /** Every initiative record: the board's own records, with user-filed ones layered on (line 1392). */
70
+ function records() {
71
+ const base = state.records && typeof state.records === 'object' ? state.records : seedRecords();
72
+ return { ...base, ...state.userInits };
73
+ }
74
+
75
+ /** GROUPS (lines 1425–1430) — user groups appended, user KRs and user initiatives folded in. */
76
+ function groupsFor(product) {
77
+ const extraGroups = (state.userGroups && state.userGroups[product]) || [];
78
+ return [...baseGroups(product), ...extraGroups].map((g) => {
79
+ const extra = (state.addedInits && state.addedInits[product + '|' + g.gid]) || [];
80
+ const kr = state.userKrs && state.userKrs[product + '|' + g.gid];
81
+ const slugs2 = [...(g.slugs || []), ...extra];
82
+ return {
83
+ ...g,
84
+ slugs: slugs2,
85
+ ...(kr ? { hasKr: true, krid: kr.krid, krname: kr.krname, krct: String(slugs2.length) } : {}),
86
+ gct: g.dyn ? String(slugs2.length) : g.gct,
87
+ };
88
+ });
89
+ }
90
+
91
+ /** The initiative selected when nothing has been picked yet (line 1397 / line 1440). */
92
+ function firstSlugOf(product) {
93
+ const g0 = baseGroups(product)[0];
94
+ return g0 && g0.slugs && g0.slugs.length ? g0.slugs[0] : null;
95
+ }
96
+
97
+ const selectedSlug = () => (state.sel !== null && state.sel !== undefined ? state.sel : firstSlugOf(currentProduct()));
98
+
99
+ /** sessionsOf (line 1398) — a record that failed to read has no sessions, it does not throw. */
100
+ function sessionsOf(slug, DATA) {
101
+ const i = DATA[slug];
102
+ if (!i || i.readError) return [];
103
+ return [...(i.sessions || []), ...((state.extraSess && state.extraSess[slug]) || [])];
104
+ }
105
+
106
+ /** Which repository owns each slug (lines 1963–1965) — the tab-scoping key. */
107
+ function slugProduct() {
108
+ const map = {};
109
+ const boards = boardOf();
110
+ Object.keys(boards).forEach((prod) => {
111
+ (boards[prod] || []).forEach((g) => (g.slugs || []).forEach((s) => { map[s] = prod; }));
112
+ });
113
+ Object.keys(state.addedInits || {}).forEach((key) => {
114
+ const prod = key.split('|')[0];
115
+ (state.addedInits[key] || []).forEach((s) => { map[s] = prod; });
116
+ });
117
+ return map;
118
+ }
119
+
120
+ /* ── the two tab openers (lines 1380–1385, 1399–1400) ──────────────────────────────────────── */
121
+
122
+ /** openTab (line 1380) — an already-open tab is focused, never duplicated. */
123
+ export function openTab(tab) {
124
+ setState((s) => ({
125
+ tabs: s.tabs.find((t) => t.id === tab.id) ? s.tabs : [...s.tabs, tab],
126
+ active: tab.id,
127
+ }));
128
+ }
129
+
130
+ export function openTerm(slug, sess) {
131
+ openTab({
132
+ id: 'term:' + slug + ':' + sess.name, kind: 'term', title: sess.name, slug,
133
+ repo: slugProduct()[slug], sess: sess.name, state: sess.st, profile: sess.profile || 'claude-work',
134
+ });
135
+ }
136
+
137
+ export function openDoc(slug, path, role) {
138
+ openTab({
139
+ id: 'doc:' + path, kind: 'doc', title: path.split('/').pop(), slug,
140
+ repo: slugProduct()[slug], path, role: role || 'DOC',
141
+ });
142
+ }
143
+
144
+ /* ── persistent chrome: the sticky header and the search box ───────────────────────────────── */
145
+
146
+ const titleText = el('span', null, 'initiatives');
147
+
148
+ const header = el('div', {
149
+ style: 'display:flex;align-items:center;gap:8px;padding:11px 14px 10px;border-bottom:1px solid var(--border);position:sticky;top:0;background:var(--sidebar);z-index:2',
150
+ },
151
+ el('span', { style: 'font-size:14px;font-weight:600;letter-spacing:.01em' }, titleText),
152
+ el('button', {
153
+ onClick: () => setState((s) => ({ col1: !s.col1 })),
154
+ style: 'margin-left:auto;color:var(--muted-foreground);font-size:14px;padding:2px 7px;min-height:26px',
155
+ hover: 'color:var(--foreground)',
156
+ }, '⟨'),
157
+ );
158
+
159
+ /* `search everything…` — one input, filtering everything, in every mode. ⌘K focuses it, which is
160
+ * why it carries `data-pm-search`: app.js looks the target up by that attribute rather than by a
161
+ * placeholder string that a translation would break. */
162
+ const searchInput = el('input', {
163
+ onInput: (e) => setState({ q: e.target.value }),
164
+ placeholder: 'search everything…',
165
+ 'data-pm-search': '',
166
+ style: 'flex:1;min-width:0;font-size:12.5px',
167
+ });
168
+
169
+ const searchBox = el('div', { style: 'padding:8px 12px 4px' },
170
+ el('div', { style: 'display:flex;align-items:center;gap:8px;padding:6px 10px;border:1px solid var(--input);min-height:32px' },
171
+ el('span', { style: 'color:var(--muted-foreground);font-size:12px' }, '⌕'),
172
+ searchInput,
173
+ ),
174
+ );
175
+
176
+ const fileNewRow = el('button', {
177
+ onClick: () => setState({ detail: { kind: 'new' }, col2: true }),
178
+ style: 'display:flex;align-items:center;gap:7px;width:100%;padding:8px 12px;margin-top:4px;color:var(--muted-foreground)',
179
+ hover: 'color:var(--foreground);background:var(--accent)',
180
+ },
181
+ el('span', { style: 'flex:none;width:9px;text-align:center;font-size:14px' }, '+'),
182
+ el('span', { style: 'font-size:12px' }, 'add objective · KR · initiative'),
183
+ );
184
+
185
+ /* ── one group: header, optional KR sub-row, initiative rows, empty line ───────────────────── */
186
+
187
+ function groupNode(g, product) {
188
+ const gkey = product + '|' + g.gid;
189
+ // An active search FORCES every group open — a collapsed group must never hide a match.
190
+ const isOpen = state.q ? true : !state.collapsedGroups[gkey];
191
+ const gshort = g.gname.length > 34 ? g.gname.slice(0, 34).trim() + '…' : g.gname;
192
+
193
+ const groupHeader = el('button', {
194
+ onClick: () => setState((s) => ({ collapsedGroups: { ...s.collapsedGroups, [gkey]: !s.collapsedGroups[gkey] } })),
195
+ style: 'display:flex;align-items:center;gap:6px;width:100%;padding:8px 12px 5px',
196
+ hover: 'opacity:.75',
197
+ },
198
+ el('span', { style: 'flex:none;width:9px;font-size:9px;color:var(--muted-foreground)' }, iv(isOpen ? '▾' : '▸')),
199
+ el('span', { style: 'flex:none;font-family:var(--mono);font-size:10px;font-weight:600;letter-spacing:.06em;color:' + g.idc }, iv(g.gid)),
200
+ el('span', { style: 'font-family:var(--mono);font-size:10px;letter-spacing:.05em;text-transform:uppercase;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(gshort)),
201
+ el('span', { style: 'margin-left:auto;flex:none;font-size:10px;color:var(--muted-foreground)' }, iv(g.gct)),
202
+ );
203
+
204
+ const body = [];
205
+ if (isOpen) {
206
+ if (g.hasKr) {
207
+ body.push(el('div', { style: 'display:flex;align-items:baseline;gap:6px;padding:2px 12px 3px 27px' },
208
+ el('span', { style: 'flex:none;font-family:var(--mono);font-size:10px;color:var(--brand)' }, iv(g.krid)),
209
+ el('span', { style: 'font-size:10.5px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(g.krname)),
210
+ ));
211
+ }
212
+ for (const it of g.inits) {
213
+ body.push(el('button', {
214
+ onClick: () => setState({ sel: it.slug, detail: { kind: 'init' }, col2: true }),
215
+ style: 'display:flex;align-items:center;gap:7px;width:100%;padding:5px 12px 5px 27px;min-height:32px;border-left:2px solid ' + it.bl + ';background:' + it.bg,
216
+ hover: 'background:var(--accent)',
217
+ },
218
+ el('span', { style: 'flex:none;font-size:12px;color:var(--muted-foreground)' }, '#'),
219
+ el('span', { style: 'font-family:var(--mono);font-size:12.5px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(it.slug)),
220
+ el('span', { style: 'margin-left:auto;flex:none;font-size:9px;color:' + it.l2c, title: it.l2b }, '●'),
221
+ ));
222
+ }
223
+ if (g.isEmpty) {
224
+ body.push(el('div', { style: 'padding:5px 12px 6px 27px;font-size:11px;color:var(--muted-foreground);font-style:italic' },
225
+ 'nothing filed yet — a successful read of nothing'));
226
+ }
227
+ }
228
+
229
+ return el('div', { style: 'margin-top:4px' }, groupHeader, body);
230
+ }
231
+
232
+ /* ── "matches elsewhere" — artifacts and sessions that the group list cannot show ──────────── */
233
+
234
+ function searchHits(q, DATA) {
235
+ const hits = [];
236
+ if (!q) return hits;
237
+ Object.keys(DATA).forEach((slug) => {
238
+ (DATA[slug].arts || []).forEach((a) => {
239
+ if (a.path.toLowerCase().includes(q)) {
240
+ hits.push({ kind: 'artifact · ' + a.role, label: a.path, on: () => openDoc(slug, a.path, a.role) });
241
+ }
242
+ });
243
+ sessionsOf(slug, DATA).forEach((s) => {
244
+ if (s.name.toLowerCase().includes(q)) {
245
+ hits.push({
246
+ kind: 'session · ' + slug, label: s.name + ' · ' + s.label,
247
+ on: () => { setState({ sel: slug, detail: { kind: 'init' } }); openTerm(slug, s); },
248
+ });
249
+ }
250
+ });
251
+ });
252
+ return hits.slice(0, 8);
253
+ }
254
+
255
+ function hitNode(ht) {
256
+ return el('button', {
257
+ onClick: ht.on,
258
+ style: 'display:block;width:100%;padding:6px 12px;min-height:40px',
259
+ hover: 'background:var(--accent)',
260
+ },
261
+ el('span', { style: 'display:block;font-family:var(--mono);font-size:10px;color:var(--info)' }, iv(ht.kind)),
262
+ el('span', { style: 'display:block;font-family:var(--mono);font-size:11px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(ht.label)),
263
+ );
264
+ }
265
+
266
+ /* ── the file-new form's view model (prototype `nw`, lines 1620–1687) ──────────────────────────
267
+ * The form itself renders in PANEL 3 (`detail.kind === 'new'`), which panel-detail.js owns — but
268
+ * every field it edits and every record it files belongs to this board, so the model is derived
269
+ * and exported here rather than duplicated there. panel-detail.js renders `fileNewModel()`.
270
+ */
271
+ export function fileNewModel() {
272
+ const product = currentProduct();
273
+ const GROUPS = groupsFor(product);
274
+ const DATA = records();
275
+ const flowsMap = state.flowsMap || FX.FLOWS;
276
+ const ty = state.newType;
277
+ const chip = (onSel, sel) => ({
278
+ bg: sel ? 'var(--secondary)' : 'transparent',
279
+ bc: sel ? 'var(--input)' : 'var(--border)',
280
+ color: sel ? 'var(--foreground)' : 'var(--muted-foreground)',
281
+ on: onSel,
282
+ });
283
+ const krTargets = GROUPS.filter((g) => !g.hasKr && /^O\d+$/.test(g.gid));
284
+ const anch = state.newAnchor !== null && state.newAnchor !== undefined
285
+ ? state.newAnchor
286
+ : (ty === 'kr' ? (krTargets[0] ? krTargets[0].gid : null) : (GROUPS[0] ? GROUPS[0].gid : 'none'));
287
+
288
+ const anchorRows = ty === 'kr'
289
+ ? krTargets.map((g) => ({ label: g.gid, sub: g.gname, ...chip(() => setState({ newAnchor: g.gid }), anch === g.gid) }))
290
+ : [
291
+ ...GROUPS.filter((g) => g.gid !== 'unanchored').map((g) => ({
292
+ label: g.hasKr ? g.gid + ' · ' + g.krid : g.gid,
293
+ sub: g.hasKr ? g.krname : g.gname,
294
+ ...chip(() => setState({ newAnchor: g.gid }), anch === g.gid),
295
+ })),
296
+ { label: 'none', sub: 'no anchor — rejected under D59', ...chip(() => setState({ newAnchor: 'none' }), anch === 'none') },
297
+ ];
298
+
299
+ // Rejected work is filed, never hidden (D59): if the repository has no "unanchored" group yet,
300
+ // one is created so the rejection has somewhere honest to land.
301
+ const ensureUnanchored = (s) => {
302
+ const ug = s.userGroups[product] || [];
303
+ const hasBase = baseGroups(product).some((g) => g.gid === 'unanchored') || ug.some((g) => g.gid === 'unanchored');
304
+ return hasBase ? s.userGroups : { ...s.userGroups, [product]: [...ug, FX.unanchoredGroup()] };
305
+ };
306
+
307
+ return {
308
+ isObj: ty === 'obj', isKr: ty === 'kr', isInit: ty === 'init',
309
+ typeBtns: FX.NEW_TYPES.map(([id, l]) => ({ l, ...chip(() => setState({ newType: id, newAnchor: null }), ty === id) })),
310
+ name: state.newName, slug: state.newSlug,
311
+ onName: (e) => setState({ newName: e.target.value }),
312
+ onSlug: (e) => setState({ newSlug: e.target.value }),
313
+ anchors: anchorRows, noKrTargets: ty === 'kr' && krTargets.length === 0,
314
+ flowsBtns: Object.keys(flowsMap).map((fn) => ({ l: fn, ...chip(() => setState({ newFlow: fn }), state.newFlow === fn) })),
315
+ showD59: ty === 'init' && anch === 'none',
316
+ createLabel: FX.CREATE_LABELS[ty] || FX.CREATE_LABELS.init,
317
+ onCreate: () => {
318
+ if (ty === 'obj') {
319
+ const name = state.newName.trim();
320
+ if (!name) return;
321
+ const ug = state.userGroups[product] || [];
322
+ const n = GROUPS.filter((g) => /^O\d+$/.test(g.gid)).length + 1;
323
+ setState({
324
+ userGroups: { ...state.userGroups, [product]: [...ug, FX.userObjectiveGroup('O' + n, name)] },
325
+ newName: '',
326
+ });
327
+ } else if (ty === 'kr') {
328
+ const name = state.newName.trim();
329
+ if (!name || !anch) return;
330
+ setState({
331
+ userKrs: { ...state.userKrs, [product + '|' + anch]: { krid: 'KR1', krname: name } },
332
+ newName: '', newAnchor: null,
333
+ });
334
+ } else {
335
+ const slug = state.newSlug.trim().toLowerCase().replace(/\s+/g, '-');
336
+ if (!slug || DATA[slug]) return;
337
+ const anchored = anch !== 'none';
338
+ const group = GROUPS.find((x) => x.gid === anch);
339
+ const entry = FX.newInitiativeRecord({ slug, anchored, group, flow: state.newFlow, flowsMap });
340
+ const key = product + '|' + (anchored ? anch : 'unanchored');
341
+ setState((s) => ({
342
+ userInits: { ...s.userInits, [slug]: entry },
343
+ userGroups: anchored ? s.userGroups : ensureUnanchored(s),
344
+ addedInits: { ...s.addedInits, [key]: [...(s.addedInits[key] || []), slug] },
345
+ sel: slug, detail: { kind: 'init' }, newSlug: '', newAnchor: null,
346
+ }));
347
+ }
348
+ },
349
+ };
350
+ }
351
+
352
+ /* ── the secondary modes ───────────────────────────────────────────────────────────────────────
353
+ * The seven modes behind the settings drawer are modes.js's; this panel only owns its header and
354
+ * its search box, which every mode keeps. modes.js is loaded on first use rather than at boot —
355
+ * the product board is what a PM opens on, and a mode that is never entered costs nothing. It is
356
+ * handed no detail host: panel 3 is painted by its own owner, and two modules writing one host is
357
+ * the one thing the ownership table forbids. A missing modes.js leaves the header and the search
358
+ * box on screen instead of taking the panel down.
359
+ */
360
+ let modesMod; // undefined = not tried · null = loading or unavailable · module = ready
361
+
362
+ function modesModule() {
363
+ if (modesMod !== undefined) return modesMod;
364
+ modesMod = null;
365
+ import('./modes.js')
366
+ .then((m) => { modesMod = m; setState({}); })
367
+ .catch(() => { modesMod = null; });
368
+ return null;
369
+ }
370
+
371
+ /* ── render ────────────────────────────────────────────────────────────────────────────────── */
372
+
373
+ export function renderInitiatives(host) {
374
+ const mode = state.mode || 'product';
375
+ titleText.textContent = FX.modeTitle(mode);
376
+ // The input is uncontrolled while it has focus — writing `value` on every render would drop the
377
+ // caret to the end mid-word. It is only re-synced when state and DOM have actually diverged.
378
+ if (searchInput.value !== state.q) searchInput.value = state.q;
379
+
380
+ if (mode !== 'product') {
381
+ setChildren(host, [header, searchBox]);
382
+ const modes = modesModule();
383
+ if (modes && typeof modes.renderMode === 'function') {
384
+ try { modes.renderMode(host, null, mode); } catch (e) { console.error('[pm] renderMode failed:', e); }
385
+ }
386
+ return host;
387
+ }
388
+
389
+ const q = (state.q || '').toLowerCase();
390
+ const product = currentProduct();
391
+ const DATA = records();
392
+ const selSlug = selectedSlug();
393
+ const detKind = (state.detail && state.detail.kind) || '';
394
+
395
+ let total = 0;
396
+ const groups = groupsFor(product).map((g) => {
397
+ const inits = g.slugs.filter((s) => !q || s.includes(q)).map((slug) => {
398
+ const i = DATA[slug];
399
+ // The prototype reads `i.l2` unguarded; a real repository can hand back a slug with no
400
+ // record at all, and a board that throws is worse than a row that reads "no record".
401
+ const l2 = (i && i.l2) || ['', '', FX.C.mut];
402
+ return { slug, ...selRow(slug === selSlug && detKind === 'init'), l2a: l2[0], l2b: l2[1], l2c: l2[2] };
403
+ });
404
+ total += inits.length;
405
+ return { ...g, inits, isEmpty: inits.length === 0 };
406
+ }).filter((g) => g.inits.length > 0 || (g.dyn && !q));
407
+
408
+ const hits = searchHits(q, DATA);
409
+
410
+ const kids = [header, searchBox];
411
+ for (const g of groups) kids.push(groupNode(g, product));
412
+ kids.push(fileNewRow);
413
+ if (hits.length) {
414
+ kids.push(el('div', { style: 'padding:4px 12px 4px;font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground);border-bottom:1px solid var(--border)' },
415
+ 'matches elsewhere'));
416
+ for (const ht of hits) kids.push(hitNode(ht));
417
+ }
418
+ if (total === 0 && hits.length === 0) {
419
+ kids.push(el('div', { style: 'padding:10px 14px;font-size:12px;color:var(--muted-foreground);font-style:italic' },
420
+ 'nothing matches — a successful read of nothing'));
421
+ }
422
+
423
+ return setChildren(host, kids);
424
+ }
425
+
426
+ export default renderInitiatives;