create-pmos 0.15.0 → 0.16.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 (36) hide show
  1. package/README.md +38 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +1 -1
  4. package/workspace/README.md +44 -12
  5. package/workspace/design-parity.js +358 -0
  6. package/workspace/lib/find-browser.js +60 -0
  7. package/workspace/lib/four-panel.js +345 -0
  8. package/workspace/lib/git-info.js +28 -1
  9. package/workspace/lib/mobile-nav.js +32 -2
  10. package/workspace/package-lock.json +29 -2
  11. package/workspace/package.json +13 -5
  12. package/workspace/public/index.html +79 -409
  13. package/workspace/public/pm/app.js +242 -0
  14. package/workspace/public/pm/data.js +877 -0
  15. package/workspace/public/pm/dom.js +116 -0
  16. package/workspace/public/pm/fixture.js +979 -0
  17. package/workspace/public/pm/mobile.css +200 -0
  18. package/workspace/public/pm/mobile.js +403 -0
  19. package/workspace/public/pm/modes.js +1076 -0
  20. package/workspace/public/pm/panel-detail.js +648 -0
  21. package/workspace/public/pm/panel-initiatives.js +426 -0
  22. package/workspace/public/pm/panel-repos.js +460 -0
  23. package/workspace/public/pm/panel-tabs.js +487 -0
  24. package/workspace/public/pm/state.js +164 -0
  25. package/workspace/public/pm/tab-doc.js +279 -0
  26. package/workspace/public/pm/tab-task.js +83 -0
  27. package/workspace/public/pm/tab-term.js +560 -0
  28. package/workspace/public/pm/tokens.css +73 -0
  29. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  30. package/workspace/server.js +158 -8
  31. package/workspace/focus.test.js +0 -650
  32. package/workspace/handoff.test.js +0 -443
  33. package/workspace/profiles.test.js +0 -362
  34. package/workspace/public/app.js +0 -5560
  35. package/workspace/public/styles.css +0 -2258
  36. package/workspace/repo-scan.test.js +0 -327
@@ -0,0 +1,648 @@
1
+ /* pm/panel-detail.js — panel 3, the initiative detail.
2
+ *
3
+ * A PORT of the prototype's column 2 (template lines 330–448 for the `dInit` branch, derivation
4
+ * lines 1699–1806). Every inline `style` string below is copied from that file character for
5
+ * character and every literal string is the prototype's literal. The acceptance bar for this
6
+ * surface is a mechanical diff of text, computed style and geometry, so a re-worded label or a
7
+ * "tidied" padding is a defect, not an improvement.
8
+ *
9
+ * ── What this panel is ──────────────────────────────────────────────────────────────────────
10
+ * Panel 3 answers one question about one initiative: what is the state of the work, and what can
11
+ * I open? Top to bottom: the slug + tag + pills header, the TASK PHASES (collapsible, each phase's
12
+ * status derived from its own tasks), LINKS (external references that are deliberately NOT repo
13
+ * files), ARTIFACTS (repo files, each optionally linked to an external destination with a
14
+ * repo-is-source sync panel), and the derived footer line.
15
+ *
16
+ * ── Two structural facts the port has to honour ─────────────────────────────────────────────
17
+ *
18
+ * 1. INTERPOLATIONS ARE WRAPPED. The prototype's runtime renders every `{{ expr }}` as a
19
+ * `<span>` INSIDE the styled element, while literal template text stays a bare text node. The
20
+ * comparator indexes nodes by their own text and then diffs computed style and geometry, so a
21
+ * value placed directly on the styled element would be measured as a different box. `iv()`
22
+ * reproduces the wrapper; bare text is used exactly where the template has bare text — `+`,
23
+ * `⇄`, `·`, `→ repo`, `adopt`, `link`, `next`, the note lines and the two headings.
24
+ *
25
+ * 2. NOTHING DERIVABLE IS STORED (D33). Phases, task statuses, phase counts, the active-row
26
+ * highlight, link lists and the footer line are all computed at read time, every render, from
27
+ * the initiative record plus the four state maps the prototype keeps (`collapsedPhases`,
28
+ * `initLinks`, `artLinks`, `artSyncOpen`/`artSyncState`). A cached phase status is a board that
29
+ * lies the moment a task moves.
30
+ *
31
+ * ── Where the data comes from ───────────────────────────────────────────────────────────────
32
+ * Fixture mode reads `fixture.js` directly. Normal mode is the same code path with the optional
33
+ * overrides the data layer publishes into state — `state.records` ({slug: record}) and
34
+ * `state.board` ({repoId: group[]}). In normal mode the seed data is NEVER reachable: fixture rows
35
+ * rendering in the real app is "the beautiful shell" the PRD names as a failure mode.
36
+ *
37
+ * ── The one place the design's fiction is refused ────────────────────────────────────────────
38
+ * The artifact sync panel renders exactly as designed — the repo-is-source statement, the
39
+ * difference summary, the two approvals. In FIXTURE mode the summary is the prototype's copy
40
+ * verbatim. In real mode with no connector configured it says so, and the two approval buttons are
41
+ * inert, because a panel that reported "2 sections differ" without having read anything would be
42
+ * precisely the watermelon this repo has a word for (PRD, "External sync").
43
+ */
44
+
45
+ import { el, setChildren } from './dom.js';
46
+ import { state, setState, FIXTURE } from './state.js';
47
+ import * as FX from './fixture.js';
48
+ import { openDoc, openTab, fileNewModel } from './panel-initiatives.js';
49
+ import { addProductBody } from './panel-repos.js';
50
+
51
+ /* ── the prototype's small helpers ─────────────────────────────────────────────────────────── */
52
+
53
+ /** `{{ expr }}` — an interpolated value, wrapped the way the prototype's runtime wraps it. */
54
+ const iv = (v) => el('span', null, v === null || v === undefined ? '' : String(v));
55
+
56
+ /* ── late-bound siblings ────────────────────────────────────────────────────────────────────
57
+ * panel-tabs.js owns tab creation and data.js owns the file-mode append. Both are imported
58
+ * LAZILY: a static import of a module that is not on disk yet takes this whole panel down, and a
59
+ * blank panel 3 is worse than a panel that opens a tab through the shared `openTab` fallback.
60
+ * Neither import changes what is rendered — only who records the side effect.
61
+ */
62
+ const mods = { tabs: undefined, data: undefined }; // undefined = untried · null = unavailable
63
+
64
+ /**
65
+ * Resolve a sibling module without a boot-time request. app.js already imports both at boot and
66
+ * publishes them on `window.pmApp.mods`, so the common path costs nothing; the dynamic import is
67
+ * only the fallback for a page that came up without the shell's loader. A module that is not on
68
+ * disk yet resolves to null and the caller degrades — it never takes this panel down.
69
+ */
70
+ function sibling(key, path) {
71
+ if (mods[key] !== undefined) return mods[key];
72
+ const shell = window.pmApp && window.pmApp.mods && window.pmApp.mods[key];
73
+ if (shell) { mods[key] = shell; return shell; }
74
+ mods[key] = null;
75
+ import(path).then((m) => { mods[key] = m; }).catch(() => { mods[key] = null; });
76
+ return null;
77
+ }
78
+
79
+ /** Open a task tab: panel 4's own opener when it is reachable, the shared one otherwise. */
80
+ function openTask(tab) {
81
+ const tabs = sibling('tabs', './panel-tabs.js');
82
+ if (tabs && typeof tabs.openTask === 'function') { tabs.openTask(tab); return; }
83
+ openTab(tab);
84
+ }
85
+
86
+ /**
87
+ * Record a link declaration in file-mode state, if the data layer offers that write. A link is a
88
+ * DECLARATION — `{artifact_path, kind, target}` — never an outbound call: PMOS holds no credential
89
+ * and makes no external request (PRD, "External sync").
90
+ */
91
+ function declare(fn, payload) {
92
+ if (FIXTURE) return; // the fixture is a render, never a write
93
+ const data = sibling('data', './data.js');
94
+ const write = data && data[fn];
95
+ if (typeof write === 'function') { try { write(payload); } catch { /* the UI still shows it */ } }
96
+ }
97
+
98
+ /* ── the board, read the same way panel 2 reads it ─────────────────────────────────────────── */
99
+
100
+ const boardOf = () =>
101
+ (state.board && typeof state.board === 'object' ? state.board : (FIXTURE ? FX.ALLGROUPS : {}));
102
+
103
+ function records() {
104
+ const base = state.records && typeof state.records === 'object'
105
+ ? state.records
106
+ : (FIXTURE ? FX.DATA : {});
107
+ return { ...base, ...state.userInits };
108
+ }
109
+
110
+ function currentProduct() {
111
+ if (state.product) return state.product;
112
+ const keys = Object.keys(boardOf());
113
+ return keys.length ? keys[0] : 'workspace';
114
+ }
115
+
116
+ function baseGroups(product) {
117
+ const board = boardOf();
118
+ if (board[product]) return board[product];
119
+ return FIXTURE && !state.board ? (FX.ALLGROUPS[product] || FX.ALLGROUPS.workspace) : [];
120
+ }
121
+
122
+ /** The initiative showing when nothing has been picked yet (line 1397). */
123
+ function selectedSlug() {
124
+ if (state.sel !== null && state.sel !== undefined) return state.sel;
125
+ const g0 = baseGroups(currentProduct())[0];
126
+ return g0 && g0.slugs && g0.slugs.length ? g0.slugs[0] : null;
127
+ }
128
+
129
+ /** Which repository owns each slug (lines 1963–1965) — tabs are scoped by it. */
130
+ function repoOf(slug) {
131
+ const boards = boardOf();
132
+ for (const prod of Object.keys(boards)) {
133
+ for (const g of boards[prod] || []) if ((g.slugs || []).includes(slug)) return prod;
134
+ }
135
+ for (const key of Object.keys(state.addedInits || {})) {
136
+ if ((state.addedInits[key] || []).includes(slug)) return key.split('|')[0];
137
+ }
138
+ return currentProduct();
139
+ }
140
+
141
+ /* ── connector honesty (PRD, "External sync") ───────────────────────────────────────────────
142
+ * Fixture mode renders the design's copy so the diff is like-for-like. Real mode asks the data
143
+ * layer whether a connector for this destination exists; with none, the summary says nothing was
144
+ * read and the two approvals are inert. `state.connectors` accepts either shape a data layer is
145
+ * likely to publish — a list of kinds, or a map of kind → truthy.
146
+ */
147
+ function hasConnector(kind) {
148
+ if (FIXTURE) return true;
149
+ const c = state.connectors;
150
+ if (Array.isArray(c)) {
151
+ return c.some((x) => (typeof x === 'string' ? x : x && x.kind) === kind
152
+ && (typeof x === 'string' || x.configured !== false));
153
+ }
154
+ if (c && typeof c === 'object') return !!c[kind];
155
+ return false;
156
+ }
157
+
158
+ const NO_CONNECTOR = 'no connector configured — nothing was read, and nothing will be written.';
159
+
160
+ /* ── the sticky detail header (lines 332–335) ───────────────────────────────────────────────
161
+ * Built once and re-used, so panel 3's chrome keeps its identity across renders the way panel 2's
162
+ * header and search box do.
163
+ */
164
+ // `<span>{{ detailTitle }}</span>` — the OUTER span is the header's flex item (and is therefore
165
+ // blockified); the interpolation's own span sits inside it and stays inline. Collapsing the two
166
+ // would change the measured box, so the wrapper is kept and only the inner text is rewritten.
167
+ const headerTitleText = iv('initiative');
168
+ const headerTitle = el('span', null, headerTitleText);
169
+
170
+ const stickyHeader = el('div', {
171
+ style: 'position:sticky;top:0;z-index:2;background:var(--sidebar);border-bottom:1px solid var(--border);padding:6px 8px 6px 14px;font-size:12px;font-weight:500;color:var(--muted-foreground);display:flex;align-items:center;gap:8px',
172
+ },
173
+ headerTitle,
174
+ el('button', {
175
+ onClick: () => setState({ col2: false }),
176
+ style: 'margin-left:auto;color:var(--muted-foreground);padding:3px 7px;font-size:12px',
177
+ hover: 'color:var(--foreground)',
178
+ }, '⟨'),
179
+ );
180
+
181
+ /* ── 1 · HEADER — slug, tag, pills (lines 338–346) ──────────────────────────────────────────── */
182
+
183
+ function headerBlock(name, tag, pills) {
184
+ return el('div', { style: 'padding:13px 14px 11px;border-bottom:1px solid var(--border)' },
185
+ el('div', { style: 'font-family:var(--mono);font-size:15px;font-weight:600;letter-spacing:-.01em;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(name)),
186
+ el('div', { style: 'font-size:12px;color:var(--muted-foreground);margin-top:2px' }, iv(tag)),
187
+ el('div', { style: 'display:flex;gap:5px;flex-wrap:wrap;margin-top:8px' },
188
+ pills.map((pl) => el('span', {
189
+ style: 'font-family:var(--mono);font-size:10px;padding:2px 6px;background:var(--secondary);border:1px solid var(--border);color:' + pl.c,
190
+ }, iv(pl.t)))),
191
+ );
192
+ }
193
+
194
+ /* ── 2 · READ FAILURE (lines 347–352) ────────────────────────────────────────────────────────
195
+ * A record that would not parse renders the panel RED and carries the parse error verbatim. It
196
+ * replaces the phases, links and artifacts — the panel refuses to pretend the initiative is empty,
197
+ * which is a different and much worse claim than "this file is broken".
198
+ */
199
+ function errorBlock(readError) {
200
+ return el('div', {
201
+ style: 'margin:12px;padding:11px 13px;border:1px solid var(--destructive);background:color-mix(in oklab, var(--destructive) 12%, transparent)',
202
+ },
203
+ el('div', { style: 'font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:var(--destructive);margin-bottom:4px;font-weight:500' }, 'read failed'),
204
+ el('div', { style: 'font-size:12.5px;line-height:1.55' }, iv(readError)),
205
+ );
206
+ }
207
+
208
+ /* ── 3 · NO RECORD — the "next" banner + the note (lines 353–356) ───────────────────────────── */
209
+
210
+ function noLadderBlock(ansBg, ansC, ansText, noLadderText) {
211
+ return [
212
+ el('div', { style: 'margin:12px 12px 0;padding:9px 11px;background:' + ansBg + ';border-left:2px solid ' + ansC + ';font-size:13px;line-height:1.5' },
213
+ el('span', { style: 'font-family:var(--mono);font-size:10px;letter-spacing:.14em;text-transform:uppercase;color:' + ansC + ';margin-right:8px;font-weight:500' }, 'next'),
214
+ iv(ansText)),
215
+ el('div', { style: 'margin:10px 12px 0;padding:9px 11px;border:1px dashed var(--input);font-size:12px;line-height:1.5;color:var(--muted-foreground)' }, iv(noLadderText)),
216
+ ];
217
+ }
218
+
219
+ /* ── 4 · TASK PHASES (lines 357–378, derivation 1719–1756) ──────────────────────────────────
220
+ * The phase set is the initiative's workflow — one phase per engine rung. Its tasks are the
221
+ * record's authored `tasks[<phase>]` when present, otherwise the three derived from the rung's own
222
+ * evidence (`synthTasks`). Nothing is invented per initiative: a rung with no evidence reads
223
+ * `todo`, never `done`.
224
+ *
225
+ * Phase status is derived from its tasks, in this order: any blocked → blocked; any doing → doing;
226
+ * all done → done; some done → doing; else todo. Collapse is per phase PER INITIATIVE (`pkey`), so
227
+ * folding `build` on one initiative does not fold it on the next.
228
+ */
229
+ function phaseBlock(slug, rec) {
230
+ const phases = FX.phasesFor(rec, slug);
231
+ if (!phases.length) return null;
232
+ const collP = state.collapsedPhases || {};
233
+ const repo = repoOf(slug);
234
+
235
+ return el('div', { style: 'padding:2px 0 6px' }, phases.map((ph) => {
236
+ const pkey = slug + '|' + ph.key;
237
+ const open = !collP[pkey];
238
+ const [phDot, phColor] = FX.TDOT[ph.status];
239
+
240
+ const head = el('button', {
241
+ onClick: () => setState((s) => ({
242
+ collapsedPhases: { ...s.collapsedPhases, [pkey]: !((s.collapsedPhases || {})[pkey]) },
243
+ })),
244
+ style: 'display:flex;align-items:center;gap:8px;width:100%;padding:9px 14px 8px',
245
+ hover: 'background:var(--accent)',
246
+ },
247
+ el('span', { style: 'flex:none;width:10px;font-size:9px;color:var(--muted-foreground)' }, iv(open ? '▾' : '▸')),
248
+ el('span', { style: 'flex:none;width:13px;text-align:center;font-size:11px;color:' + phColor }, iv(phDot)),
249
+ el('span', { style: 'font-family:var(--mono);font-size:10px;letter-spacing:.12em;text-transform:uppercase;color:var(--foreground)' }, iv(ph.label)),
250
+ el('span', { style: 'margin-left:auto;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(ph.count)),
251
+ );
252
+
253
+ const rows = open ? ph.tasks.map((t, ti) => {
254
+ const tid = FX.taskTabId(slug, ph.key, ti);
255
+ const active = state.active === tid;
256
+ const [dot, color] = FX.TDOT[t.status] || FX.TDOT.todo;
257
+ return el('button', {
258
+ onClick: () => openTask({
259
+ id: tid, kind: 'task', role: 'TASK', title: t.title, slug, repo, phase: ph.key,
260
+ status: t.status, instruction: t.instruction, input: t.input, expect: t.expect,
261
+ evalNote: t.evalNote, output: t.output,
262
+ }),
263
+ style: 'display:flex;align-items:center;gap:9px;width:100%;padding:6px 14px 6px 32px;min-height:34px;border-top:1px solid oklch(1 0 0 / 4%);background:'
264
+ + (active ? 'var(--accent)' : 'transparent')
265
+ + ';border-left:2px solid ' + (active ? 'var(--brand)' : 'transparent'),
266
+ hover: 'background:var(--accent)',
267
+ },
268
+ el('span', { style: 'flex:none;width:13px;text-align:center;font-size:11px;color:' + color }, iv(dot)),
269
+ el('span', { style: 'flex:1;min-width:0;font-size:12.5px;color:var(--foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(t.title)),
270
+ el('span', { style: 'flex:none;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, '↗'),
271
+ );
272
+ }) : [];
273
+
274
+ return el('div', { style: 'border-bottom:1px solid oklch(1 0 0 / 5%)' }, head, rows);
275
+ }));
276
+ }
277
+
278
+ /* ── 5a · LINKS (lines 380–398, derivation 1757–1766, 1797–1798) ────────────────────────────
279
+ * External references — NOT repo files. Sessions add them automatically; the three + buttons let
280
+ * the PM add one by hand, and adding one opens its preview immediately so the reference is
281
+ * verified by looking at it rather than by trusting the row. Multiple links of a kind are allowed:
282
+ * the Jira numbering counts the ones already filed rather than replacing them.
283
+ */
284
+ function linksRow(slug) {
285
+ const rec = records()[slug] || {};
286
+ const manual = (state.initLinks || {})[slug] || [];
287
+ const links = [...(rec.links || []), ...manual];
288
+
289
+ const addLink = (src) => {
290
+ const link = FX.makeLink(slug, src, manual);
291
+ setState(
292
+ (s) => ({ initLinks: { ...s.initLinks, [slug]: [...((s.initLinks || {})[slug] || []), link] } }),
293
+ () => {
294
+ declare('recordInitiativeLink', { slug, ...link });
295
+ openDoc(slug, link.path, link.kind.toUpperCase());
296
+ },
297
+ );
298
+ };
299
+
300
+ const rows = links.map((lk) => el('button', {
301
+ onClick: () => openDoc(slug, lk.path, String(lk.kind).toUpperCase()),
302
+ style: 'display:flex;gap:8px;align-items:center;padding:5px 6px;margin:0 -6px;min-height:34px',
303
+ hover: 'background:var(--accent)',
304
+ },
305
+ el('span', { style: 'flex:none;width:76px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;text-transform:uppercase;color:var(--info)' }, iv(lk.kind)),
306
+ el('span', { style: 'font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(lk.label)),
307
+ el('span', { style: 'margin-left:auto;flex:none;font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' }, iv(lk.st)),
308
+ ));
309
+
310
+ const addRow = el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;margin-top:4px' },
311
+ el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'link'),
312
+ FX.LINK_SRCS.map((ls) => el('button', {
313
+ onClick: () => addLink(ls.src),
314
+ style: 'display:flex;gap:5px;align-items:center;font-family:var(--mono);font-size:10.5px;padding:4px 9px;min-height:28px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
315
+ hover: 'border-color:var(--ring)',
316
+ }, '+ ', iv(ls.l))),
317
+ );
318
+
319
+ return el('div', { style: 'display:grid;grid-template-columns:76px 1fr;gap:10px;padding:8px 14px;border-bottom:1px solid var(--border)' },
320
+ el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground);padding-top:2px' }, 'links'),
321
+ el('div', { style: 'display:flex;flex-direction:column;gap:2px;min-width:0' },
322
+ rows,
323
+ addRow,
324
+ el('span', { style: 'font-size:11px;color:var(--muted-foreground);margin-top:3px;line-height:1.5' },
325
+ 'references — not in the repo. sessions add these automatically; link one yourself when you need it.'),
326
+ ),
327
+ );
328
+ }
329
+
330
+ /* ── 5b · ARTIFACTS (lines 399–437, derivation 1779–1795) ───────────────────────────────────
331
+ * Repo files, each with its role and path; clicking one opens the document. An artifact may carry
332
+ * an external DESTINATION — recorded as a declaration, never as an outbound call — and once linked
333
+ * it grows the ⇄ pill, a `sync` button, and the inline sync panel behind it.
334
+ *
335
+ * The sync panel is where this surface is at its most careful. The repo is the source of truth,
336
+ * stated first; then the difference summary; then the two approvals, whose chosen result persists
337
+ * as the status line beside the pill. With no connector, the summary says nothing was read and
338
+ * both approvals are inert.
339
+ */
340
+ function artifactRow(slug, a) {
341
+ const key = FX.artKey(slug, a.path);
342
+ const dest = (state.artLinks || {})[key] || a.dest || null;
343
+ const syncState = (state.artSyncState || {})[key] || null;
344
+ const syncOpen = !!(state.artSyncOpen || {})[key];
345
+
346
+ const head = el('button', {
347
+ onClick: () => openDoc(slug, a.path, a.role),
348
+ style: 'display:flex;gap:8px;align-items:center;width:100%;padding:4px 6px;margin:0 -6px;min-width:0;min-height:30px',
349
+ hover: 'background:var(--accent)',
350
+ },
351
+ el('span', { style: 'flex:none;width:52px;font-family:var(--mono);font-size:10px;letter-spacing:.1em;color:var(--info)' }, iv(a.role)),
352
+ el('span', { style: 'font-family:var(--mono);font-size:10px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(a.path)),
353
+ );
354
+
355
+ if (!dest) {
356
+ // Unlinked: the three destinations it could be linked to.
357
+ const linkRow = el('div', { style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap;padding:3px 0 0' },
358
+ el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.1em;text-transform:uppercase;color:var(--muted-foreground)' }, 'link'),
359
+ FX.ART_LINK_SRCS.map((ls) => el('button', {
360
+ onClick: () => {
361
+ const d = FX.linkArtDest(key, ls.kind);
362
+ setState((s) => ({ artLinks: { ...s.artLinks, [key]: d } }));
363
+ declare('recordArtifactLink', { artifact_path: a.path, slug, kind: d.kind, target: d.target });
364
+ },
365
+ style: 'font-family:var(--mono);font-size:10px;padding:3px 8px;min-height:24px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
366
+ hover: 'border-color:var(--ring)',
367
+ }, '+ ', iv(ls.l))),
368
+ );
369
+ return el('div', { style: 'min-width:0' }, head, linkRow);
370
+ }
371
+
372
+ // Linked: the destination pill, the sync button, and the persisted result of the last approval.
373
+ const pillRow = el('div', { style: 'display:flex;gap:7px;align-items:center;flex-wrap:wrap;padding:3px 0 0 0' },
374
+ el('span', { style: 'font-family:var(--mono);font-size:10px;padding:1px 6px;background:var(--brand-wash);color:var(--brand)' },
375
+ '⇄ ', iv(dest.kind), ' · ', iv(dest.target)),
376
+ el('button', {
377
+ onClick: () => setState((s) => ({ artSyncOpen: { ...s.artSyncOpen, [key]: !s.artSyncOpen[key] } })),
378
+ style: 'font-family:var(--mono);font-size:10.5px;padding:3px 10px;min-height:26px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)',
379
+ hover: 'border-color:var(--ring)',
380
+ }, 'sync'),
381
+ syncState && el('span', { style: 'font-family:var(--mono);font-size:10px;color:' + syncState.color }, iv(syncState.text)),
382
+ );
383
+
384
+ let panel = null;
385
+ if (syncOpen) {
386
+ const live = hasConnector(dest.kind);
387
+ const inert = !live;
388
+ const decide = (make) => () => {
389
+ if (inert) return;
390
+ setState((s) => ({
391
+ artSyncState: { ...s.artSyncState, [key]: make(dest) },
392
+ artSyncOpen: { ...s.artSyncOpen, [key]: false },
393
+ }));
394
+ };
395
+ const off = inert ? ';opacity:.5;cursor:not-allowed' : '';
396
+ panel = el('div', { style: 'margin-top:6px;padding:9px 11px;border:1px solid var(--border);background:var(--background)' },
397
+ el('div', { style: 'font-size:12px;line-height:1.5;color:var(--muted-foreground)' },
398
+ iv(live ? FX.artDiffText(dest) : NO_CONNECTOR)),
399
+ el('div', { style: 'display:flex;gap:6px;margin-top:8px;flex-wrap:wrap' },
400
+ el('button', {
401
+ onClick: decide(FX.artSyncPushed), disabled: inert,
402
+ style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-weight:500' + off,
403
+ }, 'repo is source → push'),
404
+ el('button', {
405
+ onClick: decide(FX.artSyncPulled), disabled: inert,
406
+ style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:var(--secondary);border:1px solid var(--border);color:var(--foreground)' + off,
407
+ hover: inert ? null : 'border-color:var(--ring)',
408
+ }, 'adopt ', iv(dest.kind), ' → repo'),
409
+ ),
410
+ );
411
+ }
412
+
413
+ return el('div', { style: 'min-width:0' }, head, pillRow, panel);
414
+ }
415
+
416
+ function artifactsRow(slug, rec) {
417
+ const arts = rec.arts || [];
418
+ return el('div', { style: 'display:grid;grid-template-columns:76px 1fr;gap:10px;padding:8px 14px' },
419
+ el('span', { style: 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground);padding-top:2px' }, 'artifacts'),
420
+ el('div', { style: 'display:flex;flex-direction:column;gap:8px;min-width:0' },
421
+ arts.map((a) => artifactRow(slug, a)),
422
+ arts.length === 0
423
+ ? el('span', { style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' }, 'none filed')
424
+ : null,
425
+ ),
426
+ );
427
+ }
428
+
429
+ /* ── 6 · FOOTER — the derivation line (line 447) ─────────────────────────────────────────────
430
+ * `margin-top:auto` pins it to the bottom of panel 3's flex column. It states what the panel above
431
+ * was derived FROM: the working tree, or the absence of one, or a failed read.
432
+ */
433
+ /* THE FOOTER OWNS NO CLOCK. `rec.asOf` is stamped in data.js at the moment `/api/initiative`
434
+ * answers, and this function prints it. Calling a clock here instead — which is what the first fix
435
+ * for the fabricated `12:41:03` did — measures the render, and panel 3 re-renders on every session
436
+ * poll while its data is read once per page life: the line advanced while the reading behind it did
437
+ * not. In fixture mode `undefined` is passed deliberately so `branchLine` falls back to the design's
438
+ * own literal; a live clock inside a render the comparator diffs could never be called clean. A real
439
+ * record that has not been hydrated yet has no `asOf`, and `null` makes the line say so rather than
440
+ * borrow the design's number. */
441
+ function footer(rec) {
442
+ const asOf = FIXTURE ? undefined : ((rec && rec.asOf) || null);
443
+ return el('div', {
444
+ style: 'margin-top:auto;padding:8px 14px;border-top:1px solid var(--border);font-family:var(--mono);font-size:10px;color:var(--muted-foreground)',
445
+ }, iv(FX.branchLine(rec, asOf)));
446
+ }
447
+
448
+ /* ── the `dInit` branch, assembled in the prototype's order ─────────────────────────────────── */
449
+
450
+ function initBody(slug) {
451
+ const rec = slug ? records()[slug] : null;
452
+ if (!rec) return [noneBody()];
453
+
454
+ const out = [];
455
+
456
+ if (rec.readError) {
457
+ // Header (slug, empty tag, no pills) → the red block → the footer. Nothing else (line 1703).
458
+ out.push(headerBlock(slug, '', []));
459
+ out.push(errorBlock(rec.readError));
460
+ out.push(footer(rec));
461
+ return out;
462
+ }
463
+
464
+ out.push(headerBlock(slug, rec.tag || '', rec.pills || []));
465
+
466
+ if (rec.noLadder) {
467
+ const ansC = FX.TONE[(rec.answer && rec.answer.tone) || 'info'] || FX.C.mut;
468
+ out.push(...noLadderBlock(FX.wash(ansC), ansC, (rec.answer && rec.answer.text) || '', rec.noLadder));
469
+ }
470
+
471
+ const phases = phaseBlock(slug, rec);
472
+ if (phases) out.push(phases);
473
+
474
+ out.push(el('div', { style: 'border-top:1px solid var(--border)' },
475
+ linksRow(slug),
476
+ artifactsRow(slug, rec),
477
+ ));
478
+
479
+ out.push(footer(rec));
480
+ return out;
481
+ }
482
+
483
+ /* ── `dNone` (lines 838–840) ────────────────────────────────────────────────────────────────── */
484
+
485
+ function noneBody() {
486
+ return el('div', { style: 'flex:1;display:grid;place-items:center;color:var(--muted-foreground);font-size:12.5px;padding:20px;text-align:center' },
487
+ 'select something on the left');
488
+ }
489
+
490
+ /* ── `dNew` — the file-new form (lines 665–728) ─────────────────────────────────────────────
491
+ * The form's MODEL belongs to panel 2 — every field it edits and every record it files lands on
492
+ * that board — so it is imported from there (`fileNewModel`) rather than re-derived here. Only the
493
+ * rendering is this panel's, because panel 3 is the host.
494
+ */
495
+ const NEW_LABEL = 'font-family:var(--mono);font-size:9.5px;letter-spacing:.12em;text-transform:uppercase;color:var(--muted-foreground)';
496
+ const NEW_FIELD_LABEL = NEW_LABEL + ';padding-top:8px';
497
+ const NEW_ROW = 'display:grid;grid-template-columns:76px 1fr;gap:10px;padding:10px 14px';
498
+
499
+ /**
500
+ * A form field, caret-safe. `oninput` writes straight onto the state object WITHOUT a render — a
501
+ * repaint between keystrokes would rebuild the input and take the caret with it — and `onchange`
502
+ * commits through the model's own handler so everything derived from the field catches up. This is
503
+ * the same contract panel 1's `field()` uses; the prototype's React `onChange` did both at once.
504
+ */
505
+ function newField(key, value, commit, placeholder, css) {
506
+ return el('input', {
507
+ value, placeholder, style: css,
508
+ onInput: (e) => { state[key] = e.target.value; },
509
+ onChange: commit,
510
+ });
511
+ }
512
+
513
+ function anchorButton(an) {
514
+ return el('button', {
515
+ onClick: an.on,
516
+ style: 'display:flex;gap:8px;align-items:center;min-height:34px;padding:0 9px;background:' + an.bg + ';border:1px solid ' + an.bc,
517
+ hover: 'border-color:var(--ring)',
518
+ },
519
+ el('span', { style: 'font-family:var(--mono);font-size:11px;color:' + an.color }, iv(an.label)),
520
+ el('span', { style: 'font-size:11px;color:var(--muted-foreground);overflow:hidden;text-overflow:ellipsis;white-space:nowrap' }, iv(an.sub)),
521
+ );
522
+ }
523
+
524
+ function fileNewBody() {
525
+ const nw = fileNewModel();
526
+ const out = [];
527
+
528
+ out.push(el('div', { style: 'padding:13px 14px 11px;border-bottom:1px solid var(--border)' },
529
+ el('div', { style: 'font-family:var(--mono);font-size:15px;font-weight:600' }, 'file new'),
530
+ el('div', { style: 'font-size:12px;color:var(--muted-foreground);margin-top:2px' }, 'objectives and KRs are structure — initiatives are work'),
531
+ ));
532
+
533
+ out.push(el('div', { style: 'display:flex;gap:4px;padding:10px 14px 2px' },
534
+ nw.typeBtns.map((tb) => el('button', {
535
+ onClick: tb.on,
536
+ style: 'font-family:var(--mono);font-size:10.5px;padding:5px 10px;min-height:28px;background:' + tb.bg + ';border:1px solid ' + tb.bc + ';color:' + tb.color,
537
+ hover: 'border-color:var(--ring)',
538
+ }, iv(tb.l))),
539
+ ));
540
+
541
+ if (nw.isObj) {
542
+ out.push(el('div', { style: NEW_ROW },
543
+ el('span', { style: NEW_FIELD_LABEL }, 'name'),
544
+ newField('newName', nw.name, nw.onName, 'what this quarter is for…', 'font-size:12.5px;padding:6px 9px;border:1px solid var(--input);min-height:32px'),
545
+ ));
546
+ out.push(el('div', { style: 'padding:2px 14px 8px;font-size:11.5px;line-height:1.5;color:var(--muted-foreground)' },
547
+ 'An objective is a file — ',
548
+ el('span', { style: 'font-family:var(--mono)' }, 'planning/objectives/<id>.md'),
549
+ '. KRs and initiatives anchor upward to it.'));
550
+ }
551
+
552
+ if (nw.isKr) {
553
+ out.push(el('div', { style: 'padding:10px 14px 4px;' + NEW_LABEL }, 'under objective'));
554
+ out.push(el('div', { style: 'display:flex;flex-direction:column;gap:2px;padding:2px 14px 4px' },
555
+ nw.anchors.map(anchorButton),
556
+ nw.noKrTargets
557
+ ? el('span', { style: 'font-size:11.5px;color:var(--muted-foreground);font-style:italic' }, 'every objective here already has its KR — add an objective first')
558
+ : null,
559
+ ));
560
+ out.push(el('div', { style: 'display:grid;grid-template-columns:76px 1fr;gap:10px;padding:6px 14px' },
561
+ el('span', { style: NEW_FIELD_LABEL }, 'name'),
562
+ newField('newName', nw.name, nw.onName, 'measurable result…', 'font-size:12.5px;padding:6px 9px;border:1px solid var(--input);min-height:32px'),
563
+ ));
564
+ }
565
+
566
+ if (nw.isInit) {
567
+ out.push(el('div', { style: NEW_ROW },
568
+ el('span', { style: NEW_FIELD_LABEL }, 'slug'),
569
+ newField('newSlug', nw.slug, nw.onSlug, 'my-new-initiative', 'font-family:var(--mono);font-size:11.5px;padding:6px 9px;border:1px solid var(--input);min-height:32px'),
570
+ ));
571
+ out.push(el('div', { style: 'padding:2px 14px 4px;' + NEW_LABEL }, 'anchor · parent_kr'));
572
+ out.push(el('div', { style: 'display:flex;flex-direction:column;gap:2px;padding:2px 14px 4px' },
573
+ nw.anchors.map(anchorButton)));
574
+ if (nw.showD59) {
575
+ out.push(el('div', { style: 'margin:4px 14px;padding:8px 11px;border:1px solid var(--destructive);background:color-mix(in oklab, var(--destructive) 10%, transparent);font-size:11.5px;line-height:1.5' },
576
+ 'No anchor — it will be filed, then ',
577
+ el('span', { style: 'color:var(--destructive)' }, 'rejected at intake under D59'),
578
+ ', listed under unanchored, never hidden.'));
579
+ }
580
+ out.push(el('div', { style: 'padding:4px 14px 4px;' + NEW_LABEL }, 'workflow'));
581
+ out.push(el('div', { style: 'display:flex;gap:4px;flex-wrap:wrap;padding:2px 14px 6px' },
582
+ nw.flowsBtns.map((fb) => el('button', {
583
+ onClick: fb.on,
584
+ style: 'font-family:var(--mono);font-size:10.5px;padding:4px 8px;min-height:26px;background:' + fb.bg + ';border:1px solid ' + fb.bc + ';color:' + fb.color,
585
+ hover: 'border-color:var(--ring)',
586
+ }, iv(fb.l))),
587
+ ));
588
+ }
589
+
590
+ out.push(el('div', { style: 'padding:8px 14px 12px;border-bottom:1px solid var(--border)' },
591
+ el('button', {
592
+ onClick: nw.onCreate,
593
+ style: 'padding:5px 12px;min-height:30px;background:var(--brand);color:oklch(0.987 0.002 197.1);font-size:12px;font-weight:500',
594
+ }, iv(nw.createLabel)),
595
+ ));
596
+
597
+ out.push(el('div', { style: 'margin-top:auto;padding:8px 14px;border-top:1px solid var(--border);font-family:var(--mono);font-size:10px;color:var(--muted-foreground)' },
598
+ 'pmos new <objective | kr | initiative> — creates the file; the board derives'));
599
+
600
+ return out;
601
+ }
602
+
603
+ /* ── the secondary detail kinds ─────────────────────────────────────────────────────────────
604
+ * `flow · sched · skill · know · agent · mem · prof · intg` are modes.js's bodies, not this
605
+ * panel's. modes.js is loaded on first use; until it answers — or if it never does — the panel
606
+ * falls back to the prototype's own empty copy rather than rendering nothing at all.
607
+ */
608
+ let modesMod; // undefined = not tried · null = loading or unavailable · module = ready
609
+
610
+ function modesModule() {
611
+ if (modesMod !== undefined) return modesMod;
612
+ modesMod = null;
613
+ import('./modes.js')
614
+ .then((m) => { modesMod = m; setState({}); })
615
+ .catch(() => { modesMod = null; });
616
+ return null;
617
+ }
618
+
619
+ function modeDetailBody(kind) {
620
+ const mod = modesModule();
621
+ const fn = mod && (mod.detailBody || mod.renderDetailBody);
622
+ if (typeof fn === 'function') {
623
+ try {
624
+ const nodes = fn(kind, state.detail);
625
+ if (Array.isArray(nodes)) return nodes;
626
+ if (nodes) return [nodes];
627
+ } catch (e) { console.error('[pm] modes.detailBody failed:', e); }
628
+ }
629
+ return [noneBody()];
630
+ }
631
+
632
+ /* ── render ────────────────────────────────────────────────────────────────────────────────── */
633
+
634
+ export function renderDetail(host) {
635
+ const kind = (state.detail && state.detail.kind) || 'init';
636
+ headerTitleText.textContent = FX.detailTitle(kind);
637
+
638
+ let body;
639
+ if (kind === 'init') body = initBody(selectedSlug());
640
+ else if (kind === 'addprod') body = addProductBody();
641
+ else if (kind === 'new') body = fileNewBody();
642
+ else if (kind === 'none') body = [noneBody()];
643
+ else body = modeDetailBody(kind);
644
+
645
+ return setChildren(host, [stickyHeader, ...body]);
646
+ }
647
+
648
+ export default renderDetail;