create-pmos 0.157.0 → 0.159.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.157.0",
3
+ "version": "0.159.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@078081c
1
+ kernel-source: pmos@83408fe
2
2
  assembled: 2026-10-12
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-12T05:27:25Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T06:01:43Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-12T05:27:25Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T06:01:43Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-12T05:27:25Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-12T06:01:43Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -118,6 +118,17 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
118
118
  const M = await open({ viewport: { width: 400, height: 900 } });
119
119
  ok('R6 at 400 wide the Briefing does not scroll sideways', M.up && await M.page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1 && document.getElementById('c3-root').scrollWidth <= innerWidth + 1), 'scrollWidth');
120
120
  ok('R6 at 400 wide the stance and Needs you are on the page, with no day-part switch', await M.page.evaluate(() => !!document.querySelector('.stance') && document.querySelectorAll('[data-pg-dp]').length === 0 && !!document.querySelector('[data-c3pg="needs"]')));
121
+ /* c3-phone-layout: the five tabs share the width, the tool buttons fit on one row, and an avatar keeps its round look */
122
+ const ph = await M.page.evaluate(() => {
123
+ const W = innerWidth;
124
+ const tabs = [...document.querySelectorAll('.c3tabs [data-c3-tab]')].map((b) => b.getBoundingClientRect());
125
+ const tools = [...document.querySelectorAll('.c3top .tools > .ib, .c3top .tools > .psess, .c3top .tools > .c3pg-bell, .c3top .tools > .c3pg-menuwrap')].map((b) => Math.round(b.getBoundingClientRect().top));
126
+ const av = [...document.querySelectorAll('.nrow .av')].map((a) => a.getBoundingClientRect()).filter((r) => r.width);
127
+ return { tabs: tabs.length, inside: tabs.every((r) => r.left >= 0 && r.right <= W + 1 && r.height >= 44), rows: new Set(tools).size, toolsN: tools.length, av: av.length, round: av.every((r) => Math.abs(r.width - r.height) < 1) };
128
+ });
129
+ ok('phone: the five tabs are all on screen and each is a 44px target', ph.tabs === 5 && ph.inside, JSON.stringify(ph));
130
+ ok('phone: the tool buttons sit on one row', ph.toolsN >= 4 && ph.rows === 1, JSON.stringify(ph));
131
+ ok('phone: a person avatar is as wide as it is tall', ph.round, JSON.stringify(ph));
121
132
  ok('0 no page error (400 wide)', M.errors.length === 0, M.errors.join(' | '));
122
133
  await M.ctx.close();
123
134
  return finish();
@@ -90,7 +90,8 @@ const { harness } = require('./c3-ini-harness');
90
90
  await page.click('[data-ini="new-initiative"]');
91
91
  await until(() => st(() => !!document.querySelector('[data-ini="new-ini-form"]')));
92
92
  await page.fill('[data-ini="ni-title"]', 'Partner sign-up page');
93
- await page.fill('[data-ini="ni-anchor"]', 'KR 2.1 Activation to 45%');
93
+ await until(() => st(() => { const s = document.querySelector('select[data-ini="ni-anchor"]'); return !!s && !s.disabled; }));
94
+ await page.selectOption('[data-ini="ni-anchor"]', 'KR 2.1 Activation to 45%');
94
95
  await page.click('[data-ini="ni-create"]');
95
96
  await until(() => st(() => !!document.querySelector('[data-ini-crumb="board"]')));
96
97
  await page.click('[data-ini-crumb="board"]');
@@ -106,7 +107,12 @@ const { harness } = require('./c3-ini-harness');
106
107
  await page.fill('[data-ini="ni-title"]', 'Passwordless login with email OTP');
107
108
  await page.click('[data-ini="ni-create"]');
108
109
  ok('NI1 an initiative without an anchor is refused, and the sheet says why', /anchor/.test(await st(() => (document.querySelector('[data-ini="new-ini-form"] [data-ini="error"]') || {}).textContent || '')));
109
- await page.fill('[data-ini="ni-anchor"]', 'KR 2.1 Activation to 45%');
110
+ await until(() => st(() => { const s = document.querySelector('select[data-ini="ni-anchor"]'); return !!s && !s.disabled; }));
111
+ await page.selectOption('[data-ini="ni-anchor"]', 'KR 2.1 Activation to 45%');
112
+ const ap = await st(() => { const s = document.querySelector('select[data-ini="ni-anchor"]'); return { groups: [...s.querySelectorAll('optgroup')].map((g) => g.label), health: [...s.options].filter((o) => /^health:/.test(o.value)).length }; });
113
+ ok('NI3 the anchor is picked from the declared key results and health budgets, not typed', ap.groups.includes('Health budgets') && ap.health === 3 && ap.groups.some((g) => /^Objective/.test(g)), JSON.stringify(ap));
114
+ const nul = await st(() => [...document.querySelector('[data-ini="new-ini-form"]').childNodes].filter((n) => n.nodeType === 3 && /null/.test(n.textContent)).length);
115
+ ok('NI4 the New initiative sheet shows no stray "null" line', nul === 0, String(nul));
110
116
  await page.click('[data-ini="new-ini-form"] [data-ini-tpl="research"]');
111
117
  await page.click('[data-ini="ni-create"]');
112
118
  await until(() => st(() => !!document.querySelector('[data-ini-page]') && !!document.querySelector('[data-ini="stepper"]')));
@@ -80,3 +80,5 @@ An item has its actions in one place only. On the Briefing, Today and Portfolio
80
80
  ## On a phone
81
81
 
82
82
  At 400 pixels wide the Briefing is one column (greeting, cards, Needs you, Today, Portfolio pulse) with no sideways scroll. Each button in an opened row is 44 pixels high.
83
+
84
+ On a phone, every console page has the same top. The page title has Record at its right. The sunrise line is under it, then the tool buttons in one row. The five tabs share the width, and each tab shows an icon over its name, so Initiatives and Reports are always on screen. A count shows at the top right of its tab's icon. A person in a row shows as a small round letter, and you can tap anywhere in the 44-pixel square around it. A line that has a sentence and a due date wraps like normal text.
@@ -26,7 +26,7 @@ The tabs are **Summary**, **Initiatives**, **Triage**, **Backlog**, **Board**, *
26
26
 
27
27
  - **Summary** shows the initiatives at a glance and one bar per category. At a glance lists up to six open initiatives. Initiatives with work, and Now initiatives that are not on track, come first. The others follow by end date. Press a bar to open the Initiatives tab with that category.
28
28
  - **Initiatives** lists the initiatives of the board, 25 on each page. Use the saved views (Now and next, Mine, At risk, No owner, All), the filters and the sort. Press **Move** on a row to move the initiative to a stage of its own workflow.
29
- - **Triage** lists the new asks. Add an ask, then open it to see its draft. Press **Accept** to start an initiative. An initiative needs an anchor (a KR or a health budget). Press **Ask for more** to ask a question, or **Decline** and give a reason.
29
+ - **Triage** lists the new asks. Add an ask, then open it to see its draft. Press **Accept** to start an initiative. An initiative needs an anchor (a KR or a health budget). On a board, **New initiative** lists the anchors that `planning/okrs/` declares. Pick one from the list. Press **Ask for more** to ask a question, or **Decline** and give a reason.
30
30
  - **Backlog**, **Board** and **Timeline** show the epics and items. Jira items and PMOS tasks are mixed. The **Initiative** and **Source** filters are the same on the three tabs.
31
31
  - In Backlog, add a PMOS task to an initiative.
32
32
  - In Board, drag a PMOS task to a column, or use its menu. A Jira item has a lock: change it in Jira.
@@ -11,7 +11,7 @@
11
11
  * answerSig(r) → the records part of a GET's sig, from the answer used (a stale answer is marked)
12
12
  * cachedSig(root) → the cache entry as a string; it changes each time a derivation lands
13
13
  * recordsSig(root) → sha1 of lib/board-cache.js signature (HEAD, the records, lifecycle and acceptances)
14
- * anchorsOf(root) → { resolve(raw) → anchor info, period, error }
14
+ * anchorsOf(root) → { resolve(raw) → anchor info, list() → [{ value, kind, group, label }], period, error }
15
15
  * workGroupOf(root) → { [lane]: group } from scripts/lane-defs.js through the engine (never a list here)
16
16
  *
17
17
  * A ROW is one line of `node scripts/workflow-state.js --json` (every record, uncapped), narrowed to what the planning
@@ -197,7 +197,15 @@ function anchorsOf(root) {
197
197
  try {
198
198
  const file = engine.layout(root).budgets;
199
199
  budgets = { ok: true, text: fs.readFileSync(file, 'utf8'), path: path.relative(root, file) };
200
- } catch { budgets = A.readBudgets(root); }
200
+ } catch {
201
+ // Not at the engine's path. A kit vendors the registry to pmos/planning/okrs/ (build-kit.sh; server.js budgetsFor), so
202
+ // look there too: without it every health:<id> in a kit repository was refused as undeclared.
203
+ budgets = A.readBudgets(root);
204
+ if (!budgets || !budgets.ok) {
205
+ const kit = A.readBudgets(path.join(root, 'pmos'));
206
+ if (kit && kit.ok) budgets = { ...kit, path: path.join('pmos', kit.path) };
207
+ }
208
+ }
201
209
  } else error = 'the anchor resolver is not present in this install';
202
210
  const period = index && index.ok ? periodLabel(index.period) : null;
203
211
  function resolve(raw) {
@@ -218,7 +226,29 @@ function anchorsOf(root) {
218
226
  // Written but not resolved (or no resolver): shown as written, in a group of its own, never dropped.
219
227
  return { anchor: text, kind: /^health:/i.test(text) ? 'health' : 'kr', key: `raw:${text}`, objective: null, title: text, token: text, status: r ? r.status : 'unknown' };
220
228
  }
221
- return { resolve, period, error, ok: !!(index && index.ok) };
229
+ /** Every anchor the OKR files and the health budgets declare, for a picker: [{ value, kind, group, label }]. `value` is
230
+ * what resolve() and the writer accept; an entry that does not resolve is left out, so the list never offers a refusal. */
231
+ function list() {
232
+ const out = [];
233
+ if (index && index.ok) {
234
+ for (const o of index.objectives) {
235
+ const group = o.name ? `${o.token} ${o.name}` : o.token;
236
+ for (const k of o.krs) out.push({ value: `${o.token}.${k.token}`, kind: 'kr', group, label: k.name ? `${k.token} ${k.name}` : k.token });
237
+ }
238
+ }
239
+ const seen = new Set();
240
+ for (const m of String((budgets && budgets.text) || '').matchAll(/^\|\s*`?health:([a-z0-9_-]+)`?\s*\|\s*([^|]*?)\s*\|/gim)) {
241
+ const id = m[1].toLowerCase();
242
+ if (seen.has(id)) continue;
243
+ seen.add(id);
244
+ out.push({ value: `health:${id}`, kind: 'health', group: 'Health budgets', label: m[2] ? `health:${id} (${m[2]})` : `health:${id}` });
245
+ }
246
+ for (const d of (budgets && budgets.domains && budgets.domains.ok && budgets.domains.rows) || []) {
247
+ out.push({ value: `domain:${d.id}`, kind: 'domain', group: 'Domains', label: d.goal ? `domain:${d.id} (${d.goal})` : `domain:${d.id}` });
248
+ }
249
+ return out.filter((a) => resolve(a.value).status === 'resolved');
250
+ }
251
+ return { resolve, list, period, error, ok: !!(index && index.ok) };
222
252
  }
223
253
 
224
254
  module.exports = { deriveSync, rowsFor, rescan, peek, setBackground, isDeriving, deriveNow, answerSig, cachedSig, recordsSig, invalidate, anchorsOf, workGroupOf, periodLabel, narrow };
@@ -11,6 +11,8 @@
11
11
  * GET /api/boards/asks?ws&board&state → { ok, asks } state: open (default) | accepted | declined | all
12
12
  * GET /api/boards/sprints?ws&board → { ok, source: jira|none, readAt, sprints }
13
13
  * GET /api/boards/items?ws&board → { ok, build, jira, items, initiatives } PMOS tasks + Jira items (Build and deliver only)
14
+ * GET /api/boards/anchors?ws&board → { ok, anchors: [{ value, kind, group, label }], error } the declared KRs,
15
+ * health budgets and domains a new initiative can anchor to (D59)
14
16
  * POST /api/boards/create { ws, name, domain?, template } → { ok, board }
15
17
  * POST /api/boards/workflow { ws, board, stages, moveRemoved?, sprints? } → { ok, board, moved }
16
18
  * POST /api/boards/move { ws, board, id, to, why? } → { ok, row, warnings }
@@ -437,6 +439,12 @@ const READS = {
437
439
  .sort((a, b) => String(b.at || '').localeCompare(String(a.at || ''))).map(pageAsk);
438
440
  ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, asks });
439
441
  },
442
+ '/api/boards/anchors': (ctx, res, url) => {
443
+ const r = boardRead(ctx, res, url, { rows: false });
444
+ if (!r) return;
445
+ const A = rowsLib.anchorsOf(r.w.root);
446
+ ctx.json(res, { ok: true, ws: r.w.id, board: r.board.id, anchors: A.list(), error: A.error || null });
447
+ },
440
448
  '/api/boards/items': (ctx, res, url) => {
441
449
  const r = boardRead(ctx, res, url);
442
450
  if (!r) return;
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.157.0",
3
+ "version": "0.159.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.157.0",
9
+ "version": "0.159.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.157.0",
3
+ "version": "0.159.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -341,6 +341,12 @@ export function handle(S, method, path, p, body) {
341
341
  const state = p.state || 'open';
342
342
  return okb({ asks: S.asks.filter((a) => (!b || a.board === b.id) && (state === 'all' || a.state === state)).map((a) => ({ ...a })) });
343
343
  }
344
+ if (path === '/api/boards/anchors') {
345
+ const krs = Object.keys(KR).map((k) => ({ value: 'KR ' + k + ' ' + KR[k], kind: 'kr', group: 'Objective ' + k.split('.')[0], label: 'KR ' + k + ' ' + KR[k] }));
346
+ const health = [['tech', 'refactors, debt, tooling'], ['security', 'hardening, rotations, audits'], ['ops', 'pipelines, deploys, monitoring']]
347
+ .map(([id, covers]) => ({ value: 'health:' + id, kind: 'health', group: 'Health budgets', label: 'health:' + id + ' (' + covers + ')' }));
348
+ return okb({ board: b.id, anchors: [...krs, ...health], error: null });
349
+ }
344
350
  if (path === '/api/boards/items') {
345
351
  const list = S.rows.filter((r) => r.board === b.id);
346
352
  return okb({ board: b.id, build: b.build, jira: b.jira, items: itemsOf(S, b),
@@ -10,7 +10,7 @@
10
10
  * Nothing is invented: a board with no linked documents says so. */
11
11
  import { el } from './dom.js';
12
12
  import { registerBoardTab } from './c3-initiatives.js';
13
- import { icon, pbtn, lk, chip, tag, st, note, sect, list, empty, nrow, plural, day, ago, parseDay } from './c3-ini-ui.js';
13
+ import { icon, pbtn, lk, chip, tag, st, note, sect, list, empty, nrow, plural, day, ago, parseDay, put } from './c3-ini-ui.js';
14
14
  import { loadItems } from './c3-ini-work.js';
15
15
 
16
16
  const STALE_DAYS = 30;
@@ -50,30 +50,30 @@ export function renderDocs(host, ctx, docs, order, titles, grouped) {
50
50
  const q = input.value.trim().toLowerCase();
51
51
  const shown = docs.filter((d) => !q || (d.name + ' ' + d.path + ' ' + d.ty + ' ' + d.ini + ' ' + (titles.get(d.ini) || '')).toLowerCase().includes(q));
52
52
  count.textContent = q ? plural(shown.length, 'document') + ' match' + (shown.length === 1 ? 'es' : '') : plural(docs.length, 'document');
53
- if (!shown.length) { results.replaceChildren(empty(q ? 'No document matches "' + input.value.trim() + '".' : 'No documents.', { 'data-inb': 'docs-none' })); return; }
54
- if (!grouped) { results.replaceChildren(sect({ ic: 'doc', title: 'Documents', count: shown.length }, list(shown.map(row)))); return; }
55
- results.replaceChildren(...order.filter((id) => shown.some((d) => d.ini === id)).map((id) => {
53
+ if (!shown.length) { put(results, empty(q ? 'No document matches "' + input.value.trim() + '".' : 'No documents.', { 'data-inb': 'docs-none' })); return; }
54
+ if (!grouped) { put(results, sect({ ic: 'doc', title: 'Documents', count: shown.length }, list(shown.map(row)))); return; }
55
+ put(results, ...order.filter((id) => shown.some((d) => d.ini === id)).map((id) => {
56
56
  const g = shown.filter((d) => d.ini === id);
57
57
  return sect({ ic: 'init', title: id + ' ' + (titles.get(id) || ''), count: g.length, attrs: { 'data-inb-docgrp': id }, right: lk({ onclick: () => ctx.openInitiative(id, 'docs'), 'aria-label': 'Open ' + id }, 'Open initiative') }, list(g.map(row)));
58
58
  }));
59
59
  };
60
60
  input.addEventListener('input', draw);
61
- host.replaceChildren(el('div', { class: 'calbar' }, el('div', { class: 'form ini-grow' }, input), count), results);
61
+ put(host, el('div', { class: 'calbar' }, el('div', { class: 'form ini-grow' }, input), count), results);
62
62
  draw();
63
63
  }
64
64
 
65
65
  registerBoardTab('docs', async (host, ctx) => {
66
66
  let dead = false;
67
- host.replaceChildren(note('Reading the documents'));
67
+ put(host, note('Reading the documents'));
68
68
  const items = await loadItems(ctx);
69
69
  if (dead) return () => {};
70
- if (!items.ok) { host.replaceChildren(el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The initiatives of this board could not be read: ' + items.error + '.')); return () => {}; }
70
+ if (!items.ok) { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The initiatives of this board could not be read: ' + items.error + '.')); return () => {}; }
71
71
  const titles = new Map(items.initiatives.map((r) => [r.id, r.title]));
72
72
  const lib = await loadDocs(ctx, new Set(titles.keys()));
73
73
  if (dead) return () => {};
74
- if (!lib.ok) { host.replaceChildren(el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The documents could not be read: ' + lib.error + '.')); return () => {}; }
74
+ if (!lib.ok) { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The documents could not be read: ' + lib.error + '.')); return () => {}; }
75
75
  if (!lib.docs.length) {
76
- host.replaceChildren(empty(items.initiatives.length ? 'No documents are linked to the initiatives of ' + ctx.board.name + ' yet.' : ctx.board.name + ' has no initiatives, so there are no documents.', { 'data-inb': 'docs-empty' }));
76
+ put(host, empty(items.initiatives.length ? 'No documents are linked to the initiatives of ' + ctx.board.name + ' yet.' : ctx.board.name + ' has no initiatives, so there are no documents.', { 'data-inb': 'docs-empty' }));
77
77
  return () => { dead = true; };
78
78
  }
79
79
  const order = [...new Set(lib.docs.map((d) => d.ini))];
@@ -12,7 +12,7 @@
12
12
  * host.dataset.ibbSeq counts the answers drawn (the browser suite polls both). */
13
13
  import { el } from './dom.js';
14
14
  import { registerBoardTab } from './c3-initiatives.js';
15
- import { icon, pill, lk, ib, pbtn, chip, st, note, lbl, sect, list, empty, nrow, statusChip, ownerChip, stepsMini, progChip, dateChip, daysBetween, plural, openMenu, sw, CATS } from './c3-ini-ui.js';
15
+ import { icon, pill, lk, ib, pbtn, chip, st, note, lbl, sect, list, empty, nrow, statusChip, ownerChip, stepsMini, progChip, dateChip, daysBetween, plural, openMenu, sw, CATS, put } from './c3-ini-ui.js';
16
16
  import { wfOf, catOf, tplName } from './c3-ini-work.js';
17
17
 
18
18
  const PAGE = 25;
@@ -44,20 +44,20 @@ registerBoardTab('initiatives', (host, ctx) => {
44
44
  const err = el('div', { class: 'fold w', role: 'alert', 'data-ibb': 'error', hidden: '' });
45
45
  const body = el('div', { 'data-ibb': 'body' });
46
46
  const pager = el('div', { class: 'chips2 ini-pager', 'data-ibb': 'pager' });
47
- host.replaceChildren(el('div', { class: 'calbody', 'data-ibb': 'initiatives' }, top, filters, count, err, body, pager));
47
+ put(host, el('div', { class: 'calbody', 'data-ibb': 'initiatives' }, top, filters, count, err, body, pager));
48
48
  host.dataset.ibbState = 'loading'; host.dataset.ibbSeq = '0';
49
49
 
50
50
  const sel = (k, label, opts) => el('label', { class: 'chips2' }, lbl(label),
51
51
  el('select', { 'data-ibb-filter': k, 'aria-label': label, onchange: (e) => { Q[k] = e.target.value; Q.offset = 0; request(); } }, ...opts.map(([v, l]) => el('option', { value: v, selected: v === Q[k] }, l))));
52
52
  function drawControls() {
53
- views.replaceChildren(...VIEWS.map(([k, l]) => pill({ 'data-ibb-view': k, 'aria-pressed': String(Q.view === k), onclick: () => { Q.view = k; Q.offset = 0; request(); } }, l)));
53
+ put(views, ...VIEWS.map(([k, l]) => pill({ 'data-ibb-view': k, 'aria-pressed': String(Q.view === k), onclick: () => { Q.view = k; Q.offset = 0; request(); } }, l)));
54
54
  const fa = (S.data && S.data.facets) || {};
55
55
  const keep = (k, keys) => (Q[k] && !keys.includes(Q[k]) ? [Q[k], ...keys] : keys);
56
56
  const order = (b.workflow || []).map((s) => s.name);
57
57
  const stages = keep('stage', Object.keys(fa.stage || {}).sort((x, y) => ((order.indexOf(x) + 1 || 99) - (order.indexOf(y) + 1 || 99)) || x.localeCompare(y)));
58
58
  const owners = keep('owner', Object.keys(fa.owner || {}).filter((o) => o !== 'none').sort((x, y) => (x === 'You' ? -1 : y === 'You' ? 1 : x.localeCompare(y))));
59
59
  const n = (k, v) => (fa[k] && fa[k][v] != null ? ' (' + fa[k][v] + ')' : '');
60
- filters.replaceChildren(
60
+ put(filters,
61
61
  sel('cat', 'Category', [['', 'Any'], ...CATS.map((c) => [c, c + n('cat', c)])]),
62
62
  sel('stage', 'Stage', [['', 'Any'], ...stages.map((s) => [s, s + n('stage', s)])]),
63
63
  sel('status', 'Status', STATUS),
@@ -90,7 +90,7 @@ registerBoardTab('initiatives', (host, ctx) => {
90
90
  if (pages < 2) { pager.replaceChildren(); return; }
91
91
  const nums = []; for (let p = 0; p < pages; p++) if (p < 2 || p >= pages - 1 || Math.abs(p - cur) <= 1) nums.push(p); else if (nums[nums.length - 1] !== '…') nums.push('…');
92
92
  const to = (p) => { Q.offset = p * PAGE; request(true); };
93
- pager.replaceChildren(el('span', { class: 'dim mono ini-meta' }, (d.offset + 1) + '–' + Math.min(d.total, d.offset + d.rows.length) + ' of ' + d.total),
93
+ put(pager, el('span', { class: 'dim mono ini-meta' }, (d.offset + 1) + '–' + Math.min(d.total, d.offset + d.rows.length) + ' of ' + d.total),
94
94
  pill({ 'data-ibb': 'prev', disabled: cur === 0, onclick: () => to(cur - 1) }, 'Previous'),
95
95
  ...nums.map((p) => (p === '…' ? el('span', { class: 'dim' }, '…') : pill({ 'data-ibb-page': String(p + 1), 'aria-pressed': String(p === cur), 'aria-label': 'Page ' + (p + 1), onclick: () => to(p) }, String(p + 1)))),
96
96
  pill({ 'data-ibb': 'next', disabled: cur >= pages - 1, onclick: () => to(cur + 1) }, 'Next'));
@@ -101,8 +101,8 @@ registerBoardTab('initiatives', (host, ctx) => {
101
101
  const d = S.data;
102
102
  if (!d) return;
103
103
  const label = (VIEWS.find((v) => v[0] === Q.view) || [])[1];
104
- count.replaceChildren(plural(d.total, 'initiative') + ' · ' + label + (active() ? ' · ' : ''), active() ? lk({ 'data-ibb': 'clear', onclick: clearFilters }, 'Clear filters') : '', ' · each row shows its own workflow and stage');
105
- body.replaceChildren(sect({ ic: 'init', title: 'Initiatives', count: d.total },
104
+ put(count, plural(d.total, 'initiative') + ' · ' + label + (active() ? ' · ' : ''), active() ? lk({ 'data-ibb': 'clear', onclick: clearFilters }, 'Clear filters') : '', ' · each row shows its own workflow and stage');
105
+ put(body, sect({ ic: 'init', title: 'Initiatives', count: d.total },
106
106
  d.rows.length ? list(d.rows.map(rowOf), { 'data-ibb': 'rows' }) : empty(active() || Q.view !== 'all' ? 'No initiatives match. Clear the filters or pick All.' : b.name + ' has no initiatives yet. Press New initiative, or accept an ask in Triage.', { 'data-ibb': 'empty' })));
107
107
  drawPager(d);
108
108
  host.dataset.ibbSeq = String(++S.seq);
@@ -14,7 +14,7 @@
14
14
  * Every control is a button; the drag handlers are drag events on the cards and columns (pointer handlers stay on the document). */
15
15
  import { el } from './dom.js';
16
16
  import { registerBoardTab } from './c3-initiatives.js';
17
- import { icon, pill, lk, ib, pbtn, note, lbl, sect, card, list, empty, stepsMini, jiraLabel, plural, trunc, openMenu, mono } from './c3-ini-ui.js';
17
+ import { icon, pill, lk, ib, pbtn, note, lbl, sect, card, list, empty, stepsMini, jiraLabel, plural, trunc, openMenu, mono, put } from './c3-ini-ui.js';
18
18
  import { STATUSES, wfOf, tplName, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
19
19
  import { timeline, tlInit, tlRange, tlStill } from './c3-ini-timeline.js';
20
20
 
@@ -40,7 +40,7 @@ function mountItems(tab) {
40
40
  if (ctx.filter && ctx.filter.ini) st.ini = ctx.filter.ini;
41
41
  if (!b.build && st.src === 'jira') st.src = '';
42
42
  let dead = false; let R = null;
43
- host.replaceChildren(note('Reading the work items'));
43
+ put(host, note('Reading the work items'));
44
44
  async function reload(force) {
45
45
  R = await loadItems(ctx, force);
46
46
  if (!dead) draw();
@@ -183,10 +183,10 @@ function mountItems(tab) {
183
183
 
184
184
  function draw(focusSel) {
185
185
  if (dead) return;
186
- if (!R.ok) { host.replaceChildren(el('div', { class: 'fold w', role: 'alert', 'data-ini': 'error' }, icon('alert', 'sm'), 'The work items could not be read: ' + R.error + '.')); return; }
186
+ if (!R.ok) { put(host, el('div', { class: 'fold w', role: 'alert', 'data-ini': 'error' }, icon('alert', 'sm'), 'The work items could not be read: ' + R.error + '.')); return; }
187
187
  const all = R.items; const shown = all.filter((w) => matches(st, w));
188
188
  const body = tab === 'backlog' ? backlogBody(shown, all) : tab === 'board' ? kanbanBody(shown, all) : timelineBody(shown);
189
- host.replaceChildren(el('div', { class: 'calbody', 'data-wk-tab': tab }, ...toolbar(all, shown), ...body));
189
+ put(host, el('div', { class: 'calbody', 'data-wk-tab': tab }, ...toolbar(all, shown), ...body));
190
190
  if (focusSel) { const f = host.querySelector(focusSel); if (f) { f.focus(); if (f.setSelectionRange && f.value) f.setSelectionRange(f.value.length, f.value.length); } }
191
191
  }
192
192
 
@@ -8,7 +8,7 @@
8
8
  * its id, one with no date says "No date yet". */
9
9
  import { el } from './dom.js';
10
10
  import { registerBoardTab } from './c3-initiatives.js';
11
- import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, statusChip, jiraLabel, plural, day, daysBetween } from './c3-ini-ui.js';
11
+ import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, statusChip, jiraLabel, plural, day, daysBetween, put } from './c3-ini-ui.js';
12
12
  import { loadItems } from './c3-ini-work.js';
13
13
 
14
14
  const whenOf = (r) => String(r.release_at || r.deploy_at || r.date || '').slice(0, 10);
@@ -17,10 +17,10 @@ const relSt = (s) => (/at risk/i.test(s) ? st('warn', 'alert', 'At risk') : /rea
17
17
  registerBoardTab('releases', async (host, ctx) => {
18
18
  let dead = false;
19
19
  const b = ctx.board;
20
- host.replaceChildren(note('Reading the releases'));
20
+ put(host, note('Reading the releases'));
21
21
  const [items, rel] = await Promise.all([loadItems(ctx), ctx.api.get('/api/releases', { board: b.id })]);
22
22
  if (dead) return () => {};
23
- const fail = (t) => { host.replaceChildren(el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), t)); return () => { dead = true; }; };
23
+ const fail = (t) => { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), t)); return () => { dead = true; }; };
24
24
  if (!items.ok) return fail('The initiatives of this board could not be read: ' + items.error + '.');
25
25
  if (!rel || !rel.ok) return fail('The releases could not be read: ' + ((rel && rel.error) || 'no answer') + '.');
26
26
  const byId = new Map(items.initiatives.map((i) => [i.id, i]));
@@ -42,7 +42,7 @@ registerBoardTab('releases', async (host, ctx) => {
42
42
  : [el('span', { class: 'dim' }, 'No initiatives in scope')]))],
43
43
  attrs: { 'data-inb-release': r.id, 'data-inb-when': isPast ? 'past' : r === up[0] ? 'next' : 'later' } });
44
44
  };
45
- host.replaceChildren(cols(
45
+ put(host, cols(
46
46
  [sect({ ic: 'out', title: 'Unreleased', count: up.length, attrs: { 'data-inb': 'release-next' } }, up.length ? list(up.map((r) => row(r, false))) : empty('No release planned for ' + b.name + '.', { 'data-inb': 'release-none-next' })),
47
47
  past.length ? sect({ ic: 'check', title: 'Released', count: past.length, attrs: { 'data-inb': 'release-past' } }, list(past.map((r) => row(r, true)))) : null],
48
48
  [card({ ic: 'out', title: 'Where releases live' }, note('Releases mirror Jira ' + jiraLabel(b) + ' fix versions, because this board builds and delivers. Release dates are references: their home is the Calendar.'),
@@ -7,7 +7,7 @@
7
7
  * the stage-records change; when that route is not on this server yet, the card says so quietly). source 'none' says there is no tracker. */
8
8
  import { el } from './dom.js';
9
9
  import { registerBoardTab } from './c3-initiatives.js';
10
- import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, kv, jiraLabel, plural, day } from './c3-ini-ui.js';
10
+ import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, nrow, cols, hbs, hbr, kv, jiraLabel, plural, day, put } from './c3-ini-ui.js';
11
11
  import { loadItems } from './c3-ini-work.js';
12
12
  import { recordCards } from './c3-ini-records.js';
13
13
 
@@ -18,11 +18,11 @@ const countsOf = (sp) => { const c = sp.counts || {}; const f = (k) => (c[k] !=
18
18
  registerBoardTab('sprints', async (host, ctx) => {
19
19
  let dead = false;
20
20
  const b = ctx.board;
21
- host.replaceChildren(note('Reading the sprints'));
21
+ put(host, note('Reading the sprints'));
22
22
  const [r, items] = await Promise.all([ctx.api.get('/api/boards/sprints', { board: b.id }), loadItems(ctx)]);
23
23
  if (dead) return () => {};
24
- if (!r || !r.ok) { host.replaceChildren(el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The sprints could not be read: ' + ((r && r.error) || 'no answer') + '.')); return () => { dead = true; }; }
25
- if (r.source === 'none') { host.replaceChildren(empty('Waiting for the first sync from Jira ' + jiraLabel(b) + '. Sprints will be mirrored here, read-only.', { 'data-inb': 'sprints-none' })); return () => { dead = true; }; }
24
+ if (!r || !r.ok) { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The sprints could not be read: ' + ((r && r.error) || 'no answer') + '.')); return () => { dead = true; }; }
25
+ if (r.source === 'none') { put(host, empty('Waiting for the first sync from Jira ' + jiraLabel(b) + '. Sprints will be mirrored here, read-only.', { 'data-inb': 'sprints-none' })); return () => { dead = true; }; }
26
26
  const sprints = r.sprints || [];
27
27
  const active = sprints.find((s) => s.state === 'active');
28
28
  const others = sprints.filter((s) => s !== active);
@@ -50,6 +50,6 @@ registerBoardTab('sprints', async (host, ctx) => {
50
50
  }
51
51
  right.push(card({ ic: 'out', title: 'In Jira' }, note('Sprints, their dates and their items are Jira\'s. PMOS mirrors them after each sync.'), el('div', { class: 'chips2' }, pbtn('outline', 'Open the Board', 'cols', { onclick: () => ctx.openBoard(b.id, 'board') }))));
52
52
  if (dead) return () => {};
53
- host.replaceChildren(cols(left, right));
53
+ put(host, cols(left, right));
54
54
  return () => { dead = true; };
55
55
  });
@@ -13,7 +13,7 @@
13
13
  * anchor_suggestion, prior_rejections: [{ id, title, why }] }, initiative? (accepted), why? (declined) }. Nothing is invented. */
14
14
  import { el } from './dom.js';
15
15
  import { registerBoardTab } from './c3-initiatives.js';
16
- import { icon, pbtn, lk, chip, tag, st, note, noteA, lbl, sect, card, list, empty, nrow, cols, chgl, ago } from './c3-ini-ui.js';
16
+ import { icon, pbtn, lk, chip, tag, st, note, noteA, lbl, sect, card, list, empty, nrow, cols, chgl, ago, put } from './c3-ini-ui.js';
17
17
  import { TEMPLATES } from './c3-ini-work.js';
18
18
 
19
19
  const LANES = ['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'];
@@ -38,7 +38,7 @@ registerBoardTab('triage', async (host, ctx) => {
38
38
  chgl([el('li', null, icon('check', 'sm'), el('span', null, 'Accept: starts an initiative (' + def.name + ', ' + first.name + ')'), el('span')),
39
39
  el('li', null, icon('reply', 'sm'), el('span', null, 'Ask for more: the asker is prompted for detail'), el('span')),
40
40
  el('li', null, icon('x', 'sm'), el('span', null, 'Decline: the asker is told why'), el('span'))]));
41
- host.replaceChildren(cols(left, right));
41
+ put(host, cols(left, right));
42
42
  const setErr = (t) => { msg.textContent = t || ''; msg.hidden = !t; };
43
43
 
44
44
  function draftNodes(a) {
@@ -68,7 +68,7 @@ registerBoardTab('triage', async (host, ctx) => {
68
68
  attrs: { 'data-ibt-ask': a.id, 'data-state': state } });
69
69
  }
70
70
  function draw() {
71
- left.replaceChildren(
71
+ put(left,
72
72
  card({ ic: 'plus', title: 'Add an ask' }, el('div', { class: 'form', 'data-ibt': 'add-form' }, input, addBtn), msg),
73
73
  sect({ ic: 'inbox', title: 'New asks', count: S.open.length }, S.open.length ? list(S.open.map((a) => row(a, 'open')), { 'data-ibt': 'asks', 'aria-label': 'Open asks' }) : empty('Nothing new to triage on this board.', { 'data-ibt': 'empty' })),
74
74
  S.other.length ? sect({ ic: 'check', title: 'Decided', count: S.other.length, attrs: { 'data-ibt': 'closed' } }, list(S.other.map((x) => row(x, x.state)))) : null);
@@ -130,7 +130,7 @@ registerBoardTab('triage', async (host, ctx) => {
130
130
  ctx.toast({ text: 'Accepted as ' + id + ' in ' + (r.stage || first.name) + ', with the ' + def.name + ' workflow.', actionLabel: 'Open ' + id, onAction: () => ctx.openInitiative(id), timeout: 9000 });
131
131
  ctx.refresh();
132
132
  }
133
- body.replaceChildren(note('"' + a.line + '"'), fieldOf('TITLE', title), fieldOf('LANE', lane), fieldOf('ANCHOR', anchor), why, fail,
133
+ put(body, note('"' + a.line + '"'), fieldOf('TITLE', title), fieldOf('LANE', lane), fieldOf('ANCHOR', anchor), why, fail,
134
134
  el('div', { class: 'chips2' }, go, pbtn('ghost', 'Cancel', null, { onclick: () => sheet.close() })));
135
135
  sync();
136
136
  title.focus();
@@ -149,7 +149,7 @@ registerBoardTab('triage', async (host, ctx) => {
149
149
  ctx.toast('The asker is prompted for more detail. The ask stays in triage.');
150
150
  if (!S.dead) { a.needMore = true; a.moreNote = F.note.trim(); draw(); }
151
151
  } });
152
- body.replaceChildren(note('"' + a.line + '"' + (a.source ? ' · ' + a.source : '')), fieldOf('QUESTION', q), fail, el('div', { class: 'chips2' }, go, pbtn('ghost', 'Cancel', null, { onclick: () => sheet.close() })));
152
+ put(body, note('"' + a.line + '"' + (a.source ? ' · ' + a.source : '')), fieldOf('QUESTION', q), fail, el('div', { class: 'chips2' }, go, pbtn('ghost', 'Cancel', null, { onclick: () => sheet.close() })));
153
153
  q.focus();
154
154
  }
155
155
  function openDecline(a) {
@@ -167,7 +167,7 @@ registerBoardTab('triage', async (host, ctx) => {
167
167
  ctx.toast('Ask declined. The asker is told why.');
168
168
  ctx.refresh();
169
169
  } });
170
- body.replaceChildren(note('"' + a.line + '"'), el('label', { class: 'ini-field' }, lbl('REASON'), el('div', { class: 'form ini-text' }, why)), fail,
170
+ put(body, note('"' + a.line + '"'), el('label', { class: 'ini-field' }, lbl('REASON'), el('div', { class: 'form ini-text' }, why)), fail,
171
171
  el('div', { class: 'chips2' }, go, pbtn('ghost', 'Cancel', null, { onclick: () => sheet.close() })));
172
172
  why.focus();
173
173
  }
@@ -14,7 +14,7 @@
14
14
  * Every tab has an honest empty state and an error line when its read fails. */
15
15
  import { el } from './dom.js';
16
16
  import { registerInitiativeTab } from './c3-initiatives.js';
17
- import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs, hbr, kv, chgl, stageChip, ownerText, statusChip, jiraLabel, plural, trunc, day, dayLong, ago, PORTFOLIO } from './c3-ini-ui.js';
17
+ import { icon, pbtn, lk, chip, tag, st, note, sect, card, list, empty, cols, hbs, hbr, kv, chgl, stageChip, ownerText, statusChip, jiraLabel, plural, trunc, day, dayLong, ago, PORTFOLIO, put } from './c3-ini-ui.js';
18
18
  import { wfOf, catOf, kindOf, tplName, toolLabel, toolIcon, loadItems, dropItems, workRow, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
19
19
  import { recordCards, recordToolsFor, RECORD_META } from './c3-ini-records.js';
20
20
  import { loadDocs, renderDocs } from './c3-ib-docs.js';
@@ -27,11 +27,11 @@ const toneOf = (s) => (s === 'late' ? 'bad' : s === 'at risk' ? 'warn' : 'ok');
27
27
  function tab(id, build) {
28
28
  registerInitiativeTab(id, async (host, ctx) => {
29
29
  let dead = false;
30
- host.replaceChildren(note('Reading'));
30
+ put(host, note('Reading'));
31
31
  try {
32
32
  const nodes = await build(ctx, () => dead, host);
33
- if (!dead && nodes) host.replaceChildren(...[].concat(nodes).filter(Boolean));
34
- } catch (e) { if (!dead) host.replaceChildren(errLine('This view could not be drawn: ' + ((e && e.message) || e) + '.')); }
33
+ if (!dead && nodes) put(host, ...[].concat(nodes).filter(Boolean));
34
+ } catch (e) { if (!dead) put(host, errLine('This view could not be drawn: ' + ((e && e.message) || e) + '.')); }
35
35
  return () => { dead = true; };
36
36
  });
37
37
  }
@@ -149,7 +149,7 @@ tab('breakdown', async (ctx, isDead, host) => {
149
149
  const draw = async (force) => {
150
150
  const RR = force ? await loadItems(ctx, true) : R;
151
151
  if (isDead()) return;
152
- host.replaceChildren(...body(RR));
152
+ put(host, ...body(RR));
153
153
  };
154
154
  const changed = () => { dropItems(ctx); draw(true); };
155
155
  function body(RR) {
@@ -228,7 +228,7 @@ tab('timeline', async (ctx, isDead, host) => {
228
228
  return [sect({ ic: 'cal', title: 'Timeline', count: work.length, right: el('span', { class: 'dim mono ini-meta' }, r.start || r.end ? day(r.start) + ' to ' + day(r.end) : 'no dates yet') }, node,
229
229
  work.some((w) => !w.start || !w.end) ? note(plural(work.filter((w) => !w.start || !w.end).length, 'item has', 'items have') + ' no dates' + (ctx.board.build ? '; Jira items get theirs from Jira' : '') + '.') : null)];
230
230
  };
231
- async function changed() { dropItems(ctx); const RR = await loadItems(ctx, true); if (!isDead()) host.replaceChildren(...build(RR).filter(Boolean)); }
231
+ async function changed() { dropItems(ctx); const RR = await loadItems(ctx, true); if (!isDead()) put(host, ...build(RR).filter(Boolean)); }
232
232
  return build(R);
233
233
  });
234
234
 
@@ -15,7 +15,7 @@
15
15
  * recordCards(ctx, ids, { boardIds }) -> Promise<[section]> one card per id, each `[data-ini-tool-card=<id>]`; boardIds ranks RICE
16
16
  */
17
17
  import { el } from './dom.js';
18
- import { icon, pill, lk, pbtn, chip, note, lbl, card, chgl, kv, hbs, hbr, plural, day, ago } from './c3-ini-ui.js';
18
+ import { icon, pill, lk, pbtn, chip, note, lbl, card, chgl, kv, hbs, hbr, plural, day, ago, put } from './c3-ini-ui.js';
19
19
 
20
20
  export const RECORD_META = {
21
21
  problem: ['Problem statement', 'target'], rice: ['RICE prioritisation', 'scale'], launch: ['Launch checklist', 'out'],
@@ -60,8 +60,8 @@ export async function recordCards(ctx, ids, opts) {
60
60
  const host = el('div', { 'data-rec': id });
61
61
  out.push(card({ ic: meta[1], title: meta[0], attrs: { 'data-ini-tool-card': id, id: 'tool-' + id } }, host));
62
62
  if (id === 'burndown') { burndown(ctx, host); continue; }
63
- if (R.missing) { host.replaceChildren(quiet('Nothing recorded yet.')); continue; }
64
- if (!R.ok) { host.replaceChildren(el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The records could not be read: ' + R.error + '.')); continue; }
63
+ if (R.missing) { put(host, quiet('Nothing recorded yet.')); continue; }
64
+ if (!R.ok) { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The records could not be read: ' + R.error + '.')); continue; }
65
65
  const rec = R.records[id] || null;
66
66
  ({ problem, rice, launch, kill, learnings })[id](ctx, host, rec, opts);
67
67
  }
@@ -75,7 +75,7 @@ function problem(ctx, host, rec) {
75
75
  const ta = el('textarea', { rows: '3', 'aria-label': 'Problem statement', 'data-rec-field': 'problem', placeholder: 'Who has the problem, what it costs them, and how we know', maxlength: '4000' });
76
76
  ta.value = S.text;
77
77
  const go = pbtn('outline', 'Save', null, { 'data-rec-save': 'problem', onclick: async () => { const r = await save(ctx, 'problem', { text: ta.value.trim() }); if (r) { S.text = ta.value.trim(); S.rec = r; ctx.toast('Problem statement saved.'); draw(); } } });
78
- host.replaceChildren(el('div', { class: 'form ini-text' }, ta), el('div', { class: 'chips2' }, go, note('Write it before ideas. The evidence should back each claim.')), stamp(S.rec, ctx.today));
78
+ put(host, el('div', { class: 'form ini-text' }, ta), el('div', { class: 'chips2' }, go, note('Write it before ideas. The evidence should back each claim.')), stamp(S.rec, ctx.today));
79
79
  };
80
80
  draw();
81
81
  }
@@ -106,7 +106,7 @@ function rice(ctx, host, rec, opts) {
106
106
  if (r) { S.rec = r; ctx.toast('RICE saved: score ' + scoreOf(data) + '.'); readRank(); host.querySelector('[data-rec="stamp"]')?.replaceWith(stampNode()); }
107
107
  }
108
108
  const stampNode = () => { const n = stamp(S.rec, ctx.today) || el('span'); n.setAttribute('data-rec', 'stamp'); return n; };
109
- host.replaceChildren(
109
+ put(host,
110
110
  el('div', { class: 'chips2' }, num('reach', 'Reach', 'accounts a quarter'),
111
111
  el('label', { class: 'chips2' }, lbl('Impact'), el('select', { 'aria-label': 'Impact, 0.25 to 3', 'data-rec-rice': 'impact', onchange: (e) => { S.impact = Number(e.target.value); upd(); } }, ...IMPACT.map((v) => el('option', { value: String(v), selected: Number(S.impact) === v }, String(v))))),
112
112
  num('confidence', 'Confidence', 'percent'), num('effort', 'Effort', 'person-months')),
@@ -130,7 +130,7 @@ function listTool(ctx, host, rec, o) {
130
130
  const input = el('input', { type: 'text', placeholder: o.placeholder, 'aria-label': o.addLabel, 'data-rec-add': o.tool, maxlength: '300' });
131
131
  const add = () => { const t = input.value.trim(); if (!t) { input.focus(); return; } if (S.items.length >= o.max) { ctx.toast({ text: 'At most ' + o.max + ' items.', tone: 'error' }); return; } commit([...S.items, o.make(t)], null).then(() => { const f = host.querySelector('[data-rec-add]'); if (f) f.focus(); }); };
132
132
  input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); add(); } });
133
- host.replaceChildren(
133
+ put(host,
134
134
  S.items.length ? o.list(S.items, (next, msg) => commit(next, msg)) : note(o.empty),
135
135
  el('div', { class: 'form' }, input, pbtn('outline', 'Add', 'plus', { 'data-rec-addgo': o.tool, onclick: add })),
136
136
  o.foot ? o.foot(S.items) : null, stamp(S.rec, ctx.today));
@@ -181,13 +181,13 @@ function burnSvg(b, W, H) {
181
181
  return s;
182
182
  }
183
183
  async function burndown(ctx, host) {
184
- host.replaceChildren(note('Reading the sprint'));
184
+ put(host, note('Reading the sprint'));
185
185
  const r = await ctx.api.get('/api/stage-tools/burndown', { board: ctx.board.id, ids: ctx.row.id });
186
- if (missing(r)) { host.replaceChildren(quiet('No sprint burndown yet.')); return; }
187
- if (!r.ok) { host.replaceChildren(el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The burndown could not be read: ' + (r.error || 'no answer') + '.')); return; }
188
- if (!r.available) { host.replaceChildren(quiet(r.reason || 'The Jira snapshot has no sprint dates or points yet.')); return; }
186
+ if (missing(r)) { put(host, quiet('No sprint burndown yet.')); return; }
187
+ if (!r.ok) { put(host, el('div', { class: 'fold w', role: 'alert' }, icon('alert', 'sm'), 'The burndown could not be read: ' + (r.error || 'no answer') + '.')); return; }
188
+ if (!r.available) { put(host, quiet(r.reason || 'The Jira snapshot has no sprint dates or points yet.')); return; }
189
189
  const sp = r.sprint || {};
190
- host.replaceChildren(
190
+ put(host,
191
191
  el('div', { class: 'chips2' }, chip(icon('lock', 'sm'), 'Mirrored from Jira, read-only'), chip(icon('clock', 'sm'), (sp.name || 'Sprint') + ' · ' + day(sp.start) + ' to ' + day(sp.end))),
192
192
  burnSvg(r, 360, 140),
193
193
  kv([['Committed', String(r.committed ?? 0) + ' points'], ['Done', String(r.done ?? 0)], ['Left · ' + plural(Number(r.daysLeft) || 0, 'day'), String(r.left ?? 0)]]),
@@ -19,7 +19,7 @@
19
19
  * pans it (mouse). Pointer handlers are on the document (R28-15); every control is a button. Reduced motion turns the motion off.
20
20
  */
21
21
  import { el } from './dom.js';
22
- import { btn, icon, geo, day, parseDay } from './c3-ini-ui.js';
22
+ import { btn, icon, geo, day, parseDay, put } from './c3-ini-ui.js';
23
23
 
24
24
  const DAY = 864e5;
25
25
  export const ZOOMS = [['week', 'Week', 22], ['month', 'Month', 6], ['quarter', 'Quarter', 2]];
@@ -125,7 +125,7 @@ export function timeline({ key, rows, from, to, today, markers = [], defOpen, he
125
125
  node.classList.add('zooming');
126
126
  const finish = () => {
127
127
  geo(node, { '--in-pd': p1 }); sc.scrollLeft = center * p1 - view / 2; st.scroll = sc.scrollLeft;
128
- ax.replaceChildren(...axis(from, days, z)); gr.replaceChildren(...grid(from, days, z));
128
+ put(ax, ...axis(from, days, z)); put(gr, ...grid(from, days, z));
129
129
  node.classList.remove('zooming');
130
130
  };
131
131
  if (reduced()) { finish(); return; }
@@ -174,7 +174,7 @@ function tipShow(bar, ev, nd, extra) {
174
174
  const s = new Date(+parseDay(bar.dataset.s) + (nd || 0) * DAY); const e = new Date(+parseDay(bar.dataset.e) + (nd || 0) * DAY);
175
175
  const iso = (d) => d.getFullYear() + '-' + String(d.getMonth() + 1).padStart(2, '0') + '-' + String(d.getDate()).padStart(2, '0');
176
176
  const len = Math.max(1, Math.round((e - s) / DAY));
177
- I.tip.replaceChildren(el('b', null, t.t || r.label || ''),
177
+ put(I.tip, el('b', null, t.t || r.label || ''),
178
178
  el('div', null, icon('cal', 'sm'), day(iso(s)) + ' to ' + day(iso(e)) + ' · ' + len + ' days', nd ? el('span', { class: 'ini-tlmv' }, (nd > 0 ? '+' : '') + nd + 'd') : null),
179
179
  el('div', null, icon('up', 'sm'), (t.p || '0%') + ' done' + (t.st ? ' · ' + t.st : '')),
180
180
  t.o ? el('div', null, icon('user', 'sm'), t.o) : null,
@@ -50,6 +50,13 @@ export function icon(name, cls) {
50
50
  return c3ic(name, cls);
51
51
  }
52
52
 
53
+ /** Replace a node's children, skipping null, undefined and false. The native replaceChildren() writes a null as the text
54
+ * "null", so a form whose optional line is off showed the word "null" (new-initiative-anchor-picker). */
55
+ export function put(node, ...kids) {
56
+ node.replaceChildren(...kids.flat().filter((k) => k != null && k !== false && k !== true));
57
+ return node;
58
+ }
59
+
53
60
  /* ── controls ── */
54
61
  /** A native button drawn as a console3 control. attrs: class (the console3 class), onclick, and any attribute. */
55
62
  export function btn(attrs, ...kids) {
@@ -9,7 +9,7 @@
9
9
  * workTlRows(ctx, items, onChanged) · iniTlRow(ctx, ini, kids, board) · iniTip(ini, board) rows for c3-ini-timeline.js
10
10
  * loadItems(ctx) · dropItems(ctx) GET /api/boards/items, kept for a few seconds per board */
11
11
  import { el } from './dom.js';
12
- import { icon, chip, tag, st, note, kv, nrow, sect, list, lk, dateChip, ownerChip, ownerText, day, daysBetween, trunc, progChip, pill, plural } from './c3-ini-ui.js';
12
+ import { icon, chip, tag, st, note, kv, nrow, sect, list, lk, dateChip, ownerChip, ownerText, day, daysBetween, trunc, progChip, pill, plural, put } from './c3-ini-ui.js';
13
13
 
14
14
  const S = (name, cat) => ({ name, cat });
15
15
  /** The workflow templates (scripts/boards.js INI_TEMPLATES): name, kind, use and stages. */
@@ -96,7 +96,7 @@ export function openWorkSheet(ctx, w, all, onChanged) {
96
96
  ctx.toast('"' + trunc(w.title, 40) + '" moved to ' + s + '.');
97
97
  if (onChanged) onChanged(r.item);
98
98
  }
99
- body.replaceChildren(
99
+ put(body,
100
100
  el('div', { class: 'chips2' }, srcTag(w), chip(icon(KIND_IC[w.kind] || 'check', 'sm'), w.kind), wkStatus(w), ini ? lk({ onclick: () => { sh.close(); ctx.openInitiative(ini); } }, icon('init', 'sm'), ini) : null),
101
101
  kv([['Owner', ownerText(w.owner)], ['Dates', w.start || w.end ? (day(w.start) || 'No start') + ' to ' + (day(w.end) || 'no end') : 'No dates yet'], ['Progress', progChip(w.progress || 0, w.status === 'Done' ? 'ok' : 'acc')]]),
102
102
  kids.length ? sect({ ic: 'bolt', title: 'In this epic', count: kids.length }, list(kids.map((k) => workRow(ctx, k, { all, epic: false, onChanged })))) : null,
@@ -43,7 +43,7 @@ import { openSheet, closeSheet } from './c3-sheet.js';
43
43
  import { toast } from './c3-toast.js';
44
44
  import {
45
45
  btn, geo, icon, pbtn, pill, lk, ib, chip, tag, st, note, lbl, mono, sw, jiraLabel, plural, trunc, day, daysBetween, statusChip, healthSt, HEALTH, ownerChip, ownerText,
46
- stageChip, stepsMini, progChip, dateChip, list, empty, sect, card, nrow, crumbs, lh, segTabs, cols, hbs, hbr, kv, chgl, chgli, openMenu, closeMenu, CATS, PORTFOLIO,
46
+ stageChip, stepsMini, progChip, dateChip, list, empty, sect, card, nrow, crumbs, lh, segTabs, cols, hbs, hbr, kv, chgl, chgli, openMenu, closeMenu, CATS, PORTFOLIO, put
47
47
  } from './c3-ini-ui.js';
48
48
  import { TEMPLATES, BOARD_TEMPLATES, isBuildStage, templateBuilds, offered, wfOf, catOf, kindOf, tplName, TOOL_META, toolLabel, toolIcon, loadItems, dropItems, workTlRows, iniTlRow, rescheduleTask, lockedToast } from './c3-ini-work.js';
49
49
  import { timeline, tlInit, tlRange } from './c3-ini-timeline.js';
@@ -121,7 +121,7 @@ export function mountInitiatives(pane, opts) {
121
121
  loadTabModules();
122
122
  const P = { loc: parseLoc(new URLSearchParams(location.search).get('init')), boards: null, today: null, seq: 0, dispose: null, ini: null };
123
123
  const root = el('div', { class: 'calwrap ini', 'data-ini': 'page' });
124
- pane.replaceChildren(root);
124
+ put(pane, root);
125
125
 
126
126
  /* ── the api, with the board's ws ── */
127
127
  const apiFor = (ws) => ({
@@ -174,7 +174,7 @@ export function mountInitiatives(pane, opts) {
174
174
  throw new Error('the repository is taking long to read. Reload in a moment.');
175
175
  }
176
176
  const findBoard = (id) => { const all = (P.boards || []).filter((b) => b.id === id); return all.find((b) => b.ws === opts.ws) || all[0] || null; };
177
- function status(text) { root.replaceChildren(note(text)); root.firstChild.setAttribute('role', 'status'); root.firstChild.setAttribute('data-ini', 'status'); }
177
+ function status(text) { put(root, note(text)); root.firstChild.setAttribute('role', 'status'); root.firstChild.setAttribute('data-ini', 'status'); }
178
178
 
179
179
  async function draw() {
180
180
  const token = ++P.seq;
@@ -183,12 +183,12 @@ export function mountInitiatives(pane, opts) {
183
183
  closeMenu();
184
184
  if (!P.boards) status('Reading the boards');
185
185
  let boards;
186
- try { boards = await loadBoards(); } catch (e) { if (live()) root.replaceChildren(errLine('The boards could not be read: ' + e.message)); return; }
186
+ try { boards = await loadBoards(); } catch (e) { if (live()) put(root, errLine('The boards could not be read: ' + e.message)); return; }
187
187
  if (!live()) return;
188
188
  root.dataset.iniView = P.loc.view;
189
189
  if (P.loc.view === 'portfolio') return drawPortfolio(boards, token);
190
190
  const b = findBoard(P.loc.board);
191
- if (!b) { root.replaceChildren(pathCrumbs(null), errLine('No board "' + P.loc.board + '" is in the portfolio. It may have been renamed or removed.')); return; }
191
+ if (!b) { put(root, pathCrumbs(null), errLine('No board "' + P.loc.board + '" is in the portfolio. It may have been renamed or removed.')); return; }
192
192
  if (P.loc.view === 'board') return drawBoard(b, token);
193
193
  return drawIni(b, token);
194
194
  }
@@ -219,12 +219,12 @@ export function mountInitiatives(pane, opts) {
219
219
  await loadTabModules();
220
220
  if (token !== P.seq) return;
221
221
  const m = (ctx.row ? INITIATIVE_TABS : BOARD_TABS)[ctx.tab] || mount;
222
- if (typeof m !== 'function') { host.replaceChildren(note('The ' + label + ' view is not built yet.')); return; }
222
+ if (typeof m !== 'function') { put(host, note('The ' + label + ' view is not built yet.')); return; }
223
223
  try {
224
224
  const d = await m(host, ctx);
225
225
  if (typeof d === 'function') { if (token === P.seq) P.dispose = d; else d(); }
226
226
  } catch (e) {
227
- if (token === P.seq) host.replaceChildren(errLine('The ' + label + ' view could not draw: ' + String((e && e.message) || e)));
227
+ if (token === P.seq) put(host, errLine('The ' + label + ' view could not draw: ' + String((e && e.message) || e)));
228
228
  }
229
229
  }
230
230
 
@@ -248,7 +248,7 @@ export function mountInitiatives(pane, opts) {
248
248
  askBoards.length ? chgl(askBoards.map((b) => el('li', null, icon('inbox', 'sm'), lk({ onclick: () => openBoard(b.id, 'triage'), 'data-ini-asks': b.id }, b.name), el('span', { class: 'tag' }, plural(Number(b.asks), 'new ask')))))
249
249
  : note('No new asks on any board.')),
250
250
  ];
251
- root.replaceChildren(
251
+ put(root,
252
252
  pathCrumbs(null, null, el('div', { class: 'chips2' }, mono(plural(boards.length, 'board') + ' · ' + plural(total, 'initiative') + ' · ' + plural(asks, 'new ask')),
253
253
  pbtn('primary', 'New board', 'plus', { 'data-ini': 'new-board', onclick: () => openNewBoard() }))),
254
254
  el('div', { class: 'calbody', 'data-ini': 'portfolio' },
@@ -278,7 +278,7 @@ export function mountInitiatives(pane, opts) {
278
278
  const node = timeline({ key: 'pf', rows, from, to, today: P.today, markers: [], defOpen: boards.map((b) => 'b:' + b.id), head: 'Board > initiative > epic',
279
279
  onMove: (task, days, uid) => { const b = (reads.find(({ r }) => r.items.some((w) => w.ref === task)) || {}).b; if (b) rescheduleTask(makeCtx(b, {}), task, days, () => draw()); },
280
280
  onLocked: (k) => lockedToast(makeCtx(boards[0] || {}, {}), k) });
281
- tlHost.replaceChildren(node, note('Featured, at-risk and active initiatives with dates. ' + (more ? plural(more, 'more is', 'more are') + ' on each board\'s Timeline.' : 'Each board\'s Timeline shows all of its own.')));
281
+ put(tlHost, node, note('Featured, at-risk and active initiatives with dates. ' + (more ? plural(more, 'more is', 'more are') + ' on each board\'s Timeline.' : 'Each board\'s Timeline shows all of its own.')));
282
282
  requestAnimationFrame(() => tlInit(tlHost));
283
283
  }
284
284
  function boardRow(b) {
@@ -311,7 +311,7 @@ export function mountInitiatives(pane, opts) {
311
311
  k === 'triage' && asks ? el('span', { class: 'bdg' }, String(asks)) : k === 'initiatives' ? el('span', { class: 'bdg g' }, String(c.total || 0)) : k === 'backlog' && items.ok ? el('span', { class: 'bdg g' }, String(work.filter((w) => w.status !== 'Done' && w.kind !== 'Epic').length)) : null]);
312
312
  const host = el('div', { class: 'calbody', 'data-ini-panel': tab });
313
313
  const def = TEMPLATES[b.defTpl] || TEMPLATES[b.template] || TEMPLATES.product;
314
- root.replaceChildren(
314
+ put(root,
315
315
  pathCrumbs(b, null, el('div', { class: 'chips2' }, pbtn('outline', 'Dashboard', 'report', { onclick: () => ctx0.showTab('rep') }),
316
316
  pbtn('outline', 'Board settings', 'settings', { 'data-ini': 'board-settings', 'aria-haspopup': 'dialog', onclick: () => openBoardSettings(b) }))),
317
317
  el('div', { class: 'calbody', 'data-ini-board-page': b.id },
@@ -331,7 +331,7 @@ export function mountInitiatives(pane, opts) {
331
331
  async function mountSummary(host, ctx) {
332
332
  const b = ctx.board;
333
333
  const r = await loadItems(ctx);
334
- if (!r.ok) { host.replaceChildren(errLine('The board could not be read: ' + r.error + '.')); return; }
334
+ if (!r.ok) { put(host, errLine('The board could not be read: ' + r.error + '.')); return; }
335
335
  const inis = r.initiatives; const work = r.items; const open = inis.filter((i) => i.status !== 'done');
336
336
  const risk = inis.filter((i) => ['at risk', 'late'].includes(i.status)).sort((x, y) => String(x.end || '9').localeCompare(String(y.end || '9')));
337
337
  const withWork = new Set(work.map((w) => w.ini));
@@ -348,7 +348,7 @@ export function mountInitiatives(pane, opts) {
348
348
  const items = work.filter((w) => w.kind !== 'Epic'); const tot = Math.max(1, items.length); const nSt = (s) => items.filter((w) => w.status === s).length;
349
349
  const goIni = (filter) => ctx.openBoard(b.id, 'initiatives', filter);
350
350
  const rel = b.build && b.nextRelease;
351
- host.replaceChildren(cols(
351
+ put(host, cols(
352
352
  [sect({ ic: 'init', title: 'Initiatives at a glance', count: open.length, right: lk({ onclick: () => goIni({ view: 'all' }), 'data-ini': 'all-initiatives' }, 'All ' + plural(inis.length, 'initiative')) },
353
353
  glance.length ? list(glance.map(iniGlance), { 'data-ini': 'glance' }) : empty('No open initiatives yet. Accept an ask in Triage to start one.')),
354
354
  sect({ ic: 'alert', title: 'Needs attention', count: risk.length, right: risk.length > att.length ? lk({ onclick: () => goIni({ view: 'risk' }) }, 'See all ' + risk.length) : null },
@@ -395,7 +395,7 @@ export function mountInitiatives(pane, opts) {
395
395
  const jiraN = usage && usage.ok ? usage.items.filter((w) => w.src === 'jira').length : 0;
396
396
  const usingBuild = inis.filter((i) => wfOf(i, b).some(isBuildStage)).length;
397
397
  const jira = el('input', { type: 'text', value: B.jira, 'aria-label': 'Jira project keys', placeholder: 'Project keys, e.g. IDPF, LB', 'data-ini': 'jira-key', oninput: (e) => { B.jira = e.target.value; } });
398
- host.replaceChildren(
398
+ put(host,
399
399
  note('Settings for the board. Workflows belong to each initiative; here you choose which templates the board offers and which one new initiatives start with.'),
400
400
  checkbox(B.build, [el('b', null, 'Build and deliver'), ': this board builds a product and mirrors Jira'], { 'data-ini': 'build' }, (e) => { B.build = e.target.checked; if (B.build) Object.keys(TEMPLATES).filter(templateBuilds).forEach((k) => B.offer.add(k)); render('[data-ini="build"]'); }),
401
401
  B.build ? el('div', { class: 'ini-field' }, lbl('JIRA PROJECTS'), el('div', { class: 'form' }, jira), note('One or more Jira projects, separated by commas. Shows their epics, stories, tasks and bugs next to PMOS tasks, and adds the Sprints and Releases tabs (mirrored, read-only). Link each initiative to its Jira epics on the initiative page.'))
@@ -440,7 +440,7 @@ export function mountInitiatives(pane, opts) {
440
440
  function render(focusSel) {
441
441
  const allowed = Object.keys(TEMPLATES).filter((k) => N.build || !templateBuilds(k));
442
442
  if (!allowed.includes(N.tpl)) N.tpl = allowed[0];
443
- host.replaceChildren(
443
+ put(host,
444
444
  note('A board sits in the ' + PORTFOLIO + ' and holds initiatives. Each initiative picks its own workflow; the board only offers templates and a default.'),
445
445
  lbl('BOARD NAME'), el('div', { class: 'form' }, name),
446
446
  wsList.length > 1 ? el('label', { class: 'ini-field' }, lbl('WORKSPACE'), el('select', { 'data-ini': 'nb-ws', onchange: (e) => { N.ws = e.target.value; } }, ...wsList.map((w) => el('option', { value: w.id, selected: w.id === N.ws }, w.name || w.id)))) : null,
@@ -481,12 +481,31 @@ export function mountInitiatives(pane, opts) {
481
481
  const host = el('div', { class: 'ini-form', 'data-ini': 'new-ini-form' });
482
482
  const sh = openSheet({ title: 'New initiative · ' + b.name, icon: 'plus', body: host, wide: true });
483
483
  const title = el('input', { type: 'text', 'aria-label': 'Initiative title', placeholder: 'e.g. Enable passwordless login with email OTP', 'data-ini': 'ni-title', oninput: (e) => { F.title = e.target.value; } });
484
- const anchor = el('input', { type: 'text', 'aria-label': 'Anchor', placeholder: 'A key result or a health budget, e.g. KR 2.1 Activation to 45%', 'data-ini': 'ni-anchor', oninput: (e) => { F.anchor = e.target.value; } });
484
+ // The anchor is picked from what the OKR files and the health budgets declare (GET /api/boards/anchors), so a typed
485
+ // word the writer refuses (D59) cannot be sent. With nothing declared, or no answer, the field takes typed text.
486
+ const pick = el('select', { 'aria-label': 'Anchor', 'data-ini': 'ni-anchor', disabled: true, onchange: (e) => { F.anchor = e.target.value; } },
487
+ el('option', { value: '' }, 'Reading the key results and health budgets'));
488
+ const typed = el('input', { type: 'text', 'aria-label': 'Anchor', placeholder: 'A key result or health:<id>, e.g. health:tech', 'data-ini': 'ni-anchor', oninput: (e) => { F.anchor = e.target.value; } });
489
+ F.anchors = null;
490
+ const anchorField = () => (F.anchors && !F.anchors.length ? typed : pick);
491
+ const anchorNote = () => (F.anchors && !F.anchors.length
492
+ ? 'No key result or health budget could be read' + (F.anchorsErr ? ' (' + F.anchorsErr + ')' : '') + '. Type an anchor declared in planning/okrs/.'
493
+ : 'Every initiative anchors to a key result or a health budget. PMOS refuses one without it.');
494
+ apiFor(b.ws).get('/api/boards/anchors', { board: b.id }).then((r) => {
495
+ F.anchors = r && r.ok && Array.isArray(r.anchors) ? r.anchors : [];
496
+ F.anchorsErr = r && r.ok ? r.error || '' : (r && r.error) || 'no answer';
497
+ const groups = [];
498
+ for (const a of F.anchors) { let g = groups.find((x) => x.name === a.group); if (!g) groups.push(g = { name: a.group, items: [] }); g.items.push(a); }
499
+ put(pick, el('option', { value: '' }, 'Pick a key result or a health budget'),
500
+ ...groups.map((g) => el('optgroup', { label: g.name }, ...g.items.map((a) => el('option', { value: a.value, selected: a.value === F.anchor }, a.label)))));
501
+ pick.disabled = false;
502
+ if (host.isConnected) render();
503
+ });
485
504
  function render(focusSel) {
486
- host.replaceChildren(
505
+ put(host,
487
506
  note('Lives in ' + PORTFOLIO + ' > ' + b.name + '. It gets its own workflow: pick one of the templates this board offers. You can edit its stages later. It is recorded as an ask you accepted.'),
488
507
  lbl('TITLE'), el('div', { class: 'form' }, title),
489
- lbl('ANCHOR'), el('div', { class: 'form' }, anchor), note('Every initiative anchors to a key result or a health budget. PMOS refuses one without it.'),
508
+ anchorField() === pick ? el('label', { class: 'ini-field' }, lbl('ANCHOR'), pick) : [lbl('ANCHOR'), el('div', { class: 'form' }, typed)], note(anchorNote()),
490
509
  el('label', { class: 'ini-field' }, lbl('LANE'), el('select', { 'aria-label': 'Lane', 'data-ini': 'ni-lane', onchange: (e) => { F.lane = e.target.value; } }, ...['feature', 'discovery', 'chore', 'incident', 'content', 'prototype'].map((l) => el('option', { value: l, selected: l === F.lane }, l)))),
491
510
  lbl('WORKFLOW'), tplSeg(offer, F.tpl, (k) => { F.tpl = k; render('[data-ini-tpl="' + k + '"]'); }, 'Workflow', b.defTpl), tplCase(F.tpl),
492
511
  b.build ? null : note(b.name + ' does not build a product, so workflows with Build and Deliver are not offered.'),
@@ -496,7 +515,7 @@ export function mountInitiatives(pane, opts) {
496
515
  }
497
516
  async function create() {
498
517
  const t = F.title.trim(); const a = F.anchor.trim();
499
- if (!t || !a) { F.err = !t ? 'Give the initiative a title.' : 'Give it an anchor: a key result or a health budget.'; render(); (t ? anchor : title).focus(); return; }
518
+ if (!t || !a) { F.err = !t ? 'Give the initiative a title.' : 'Give it an anchor: a key result or a health budget.'; render(); (t ? anchorField() : title).focus(); return; }
500
519
  const api = apiFor(b.ws);
501
520
  F.busy = true; F.err = ''; render();
502
521
  const add = await api.post('/api/boards/asks/add', { board: b.id, line: t, source: 'New initiative, typed by you' });
@@ -537,7 +556,7 @@ export function mountInitiatives(pane, opts) {
537
556
  const rowsUi = stageRows(W, { current: row.stage, build: b.build !== false, onChange: () => { W.confirm = false; render(); } });
538
557
  function render(focusSel) {
539
558
  const m = mv();
540
- host.replaceChildren(
559
+ put(host,
541
560
  note('The ordered stages of ' + row.id + ' only. Other initiatives on ' + b.name + ' keep their own. Each stage sits in one category so the portfolio can compare. No conditions or validators, by design: moving work is your call.'),
542
561
  el('div', { class: 'chips2', role: 'group', 'aria-label': 'Start from a template' }, mono('Start from a template ' + b.name + ' offers'),
543
562
  ...Object.keys(TEMPLATES).map((k) => pill({ 'aria-pressed': String(W.tpl === k), 'data-wf-tpl': k, disabled: !offer.includes(k), title: offer.includes(k) ? TEMPLATES[k].use : templateBuilds(k) && b.build === false ? 'Needs Build and deliver on the board' : 'Not offered on this board',
@@ -620,7 +639,7 @@ export function mountInitiatives(pane, opts) {
620
639
  const rowsUi = stageRows(W, { build: b.build !== false, counts, onChange: () => render() });
621
640
  function render(focusSel) {
622
641
  const gone = removed().filter((n) => Number(counts[n]) > 0);
623
- host.replaceChildren(
642
+ put(host,
624
643
  note('The stages that ' + b.name + '\'s initiatives run when they have no workflow of their own. Each initiative can still pick its own from the templates the board offers.'),
625
644
  el('div', { class: 'chips2', role: 'group', 'aria-label': 'Start from a template' }, mono('Start from a template'),
626
645
  ...BOARD_TEMPLATES.map((k) => pill({ 'aria-pressed': String(W.tpl === k), 'data-wf-tpl': k, disabled: b.build === false && templateBuilds(k), onclick: () => { W.tpl = k; W.draft = TEMPLATES[k].stages.map((s) => ({ key: 'k' + (++seq), name: s.name, cat: s.cat, hint: '', was: old.includes(s.name) ? s.name : null })); render('[data-wf-tpl="' + k + '"]'); } }, TEMPLATES[k].name))),
@@ -658,7 +677,7 @@ export function mountInitiatives(pane, opts) {
658
677
  const r = await apiFor(b.ws).get('/api/boards/initiative', { board: b.id, id });
659
678
  if (token !== P.seq) return;
660
679
  if (!r || !r.ok || !r.row) {
661
- root.replaceChildren(pathCrumbs(b, { id, title: '' }), errLine(r && r.pending ? 'Reading the repository. Open the page again in a moment.' : 'The initiative could not be read: ' + ((r && r.error) || 'no answer') + '.'));
680
+ put(root, pathCrumbs(b, { id, title: '' }), errLine(r && r.pending ? 'Reading the repository. Open the page again in a moment.' : 'The initiative could not be read: ' + ((r && r.error) || 'no answer') + '.'));
662
681
  return;
663
682
  }
664
683
  P.ini = { id, board: b.id, row: r.row, history: r.history || [], tools: r.tools || [], toolsByCat: r.toolsByCat || null, b: { ...b, ...(r.board || {}), counts: b.counts, asks: b.asks, nextRelease: b.nextRelease } };
@@ -685,7 +704,7 @@ export function mountInitiatives(pane, opts) {
685
704
  const tabs = INI_TAB_LIST.filter(([k]) => k !== 'tickets' || bb.build).map(([k, l, ic]) => [k, l, ic,
686
705
  k === 'breakdown' && work.length ? el('span', { class: 'bdg g' }, String(work.length)) : k === 'tickets' ? el('span', { class: 'bdg g' }, String(work.filter((w) => w.src === 'jira').length)) : null]);
687
706
  const host = el('div', { class: 'calbody', 'data-ini-panel': tab });
688
- root.replaceChildren(
707
+ put(root,
689
708
  pathCrumbs(bb, row, el('div', { class: 'chips2' }, pbtn('outline', 'Dashboard', 'report', { onclick: () => ctxI.showTab('rep') }))),
690
709
  el('div', { class: 'calbody', 'data-ini-page': row.id },
691
710
  lh('target', row.title || row.id, [row.id, bb.name, row.anchor || 'no anchor', row.owner || 'no owner', row.start || row.end ? day(row.start) + ' to ' + day(row.end) : 'no dates yet', row.horizon ? row.horizon[0].toUpperCase() + row.horizon.slice(1) : '', Math.round((Number(row.progress) || 0) * 100) + '% done'].filter(Boolean).join(' · ')),
@@ -705,7 +724,7 @@ export function mountInitiatives(pane, opts) {
705
724
  const sh = openSheet({ title: 'Jira epics · ' + row.id, icon: 'bolt', body: host });
706
725
  function render() {
707
726
  const input = el('input', { type: 'text', value: E.text, 'aria-label': 'Jira epic keys', placeholder: 'Epic keys, e.g. IDPF-2685, LB-120', 'data-ini': 'epics-input', oninput: (e) => { E.text = e.target.value; } });
708
- host.replaceChildren(
727
+ put(host,
709
728
  note('The Jira epics this initiative runs on. The daily Jira check reads each epic and the issues under it, read-only, and shows them as its Jira items.' + (jiraLabel(b) ? ' ' + b.name + ' mirrors Jira ' + jiraLabel(b) + '.' : '')),
710
729
  row.epicsFrom === 'record' ? note('Now from the initiative record (jira: ' + row.epics.join(', ') + '). Saving here takes over from it.') : null,
711
730
  el('div', { class: 'ini-field' }, lbl('JIRA EPICS'), el('div', { class: 'form' }, input)),
@@ -2172,3 +2172,46 @@
2172
2172
  .c3 .cal3-bar i.p{background:var(--acc);opacity:.55}
2173
2173
  .c3 .cal3-bar i.t{background:var(--border)}
2174
2174
  .c3 .cal3-counts{gap:10px;font-size:var(--fs-1)}
2175
+
2176
+ /* ── Phone layout pass (initiative c3-phone-layout): at 760px and under, each console3 page fits a 390px screen ─────────────────
2177
+ * Layout only; the desktop is unchanged. The header puts Record beside the title and the tool buttons on one row; the five tabs
2178
+ * share the width, each an icon over its word; rows keep their chips, dates and avatars legible; switch rows wrap as whole groups. */
2179
+ @media (max-width:760px){
2180
+ /* header: title and Record on the first line, the sun line, then the tool buttons; the demo pill last */
2181
+ .c3.c3pg .c3top{gap:8px 6px;justify-content:flex-start}
2182
+ .c3.c3pg .c3top .tools{display:contents}
2183
+ .c3.c3pg .c3top>div:first-child{order:0;flex:1 1 auto;min-width:0}
2184
+ .c3.c3pg .c3top .c3rec{order:1;margin-left:auto}
2185
+ .c3.c3pg .c3top .c3pg-daylight{order:2;flex:1 0 100%}
2186
+ .c3.c3pg .c3top .tools>*{order:3}
2187
+ .c3.c3pg .c3top .tools>.c3rec{order:1}
2188
+ .c3.c3pg[data-c3-demo] .c3top .c3pg-demo{order:4;flex:1 0 100%;justify-content:center}
2189
+ /* tabs: five equal columns, an icon over a short word, the count on the icon's shoulder; nothing hides off the right */
2190
+ .c3.c3pg .c3tabs{gap:0;overflow-x:visible}
2191
+ .c3.c3pg .c3tabs button{position:relative;flex:1 1 0;min-width:0;flex-direction:column;justify-content:center;gap:3px;padding:6px 2px;font-size:var(--fs-1);line-height:1.1}
2192
+ .c3.c3pg .c3tabs button>svg{flex:none}
2193
+ .c3.c3pg .c3tabs .bdg{position:absolute;top:3px;left:calc(50% + 6px)}
2194
+ /* an avatar keeps its round 22px look; its 44px tap area is the transparent box around it */
2195
+ .c3.ph .nrow .av,.c3.ph .drow .av{width:44px;height:44px;min-height:44px;flex:none;border:0;background:radial-gradient(circle closest-side,var(--muted) 0 calc(100% - 12px),var(--border) calc(100% - 12px) calc(100% - 11px),transparent calc(100% - 10.5px))}
2196
+ /* a chip that holds a sentence and its date wraps as one line of text instead of two squeezed columns */
2197
+ .c3 .nrow .chips .chip2:has(>.dim){display:block;min-width:0;max-width:100%}
2198
+ .c3 .nrow .chips .chip2:has(>.dim)>svg{display:inline-block;vertical-align:-3px;margin-right:4px}
2199
+ .c3 .nrow .chips .chip2:has(>.dim)>span{display:inline}
2200
+ .c3 .nrow .chips .chip2>.dim{white-space:nowrap}
2201
+ .c3 .wrow .chips .jc{max-width:100%;white-space:normal;overflow:visible}
2202
+ /* a row's trailing button keeps its icon centred */
2203
+ .c3 .nrow .acts .ib{justify-content:center}
2204
+ /* a card head wraps its note under the title rather than squeeze both */
2205
+ .c3 .sect>.sh2,.c3 .brief .sect .sh2{flex-wrap:wrap}
2206
+ .c3 .sect>.sh2>h2,.c3 .brief .sect .sh2>h2{flex:0 1 auto;min-width:0}
2207
+ .c3 .card>h3{flex-wrap:wrap}
2208
+ /* switch rows (Calendar/Notes, Day to Yr): each switch stays in one piece and the row wraps between them */
2209
+ .c3 .calbar .lens{flex-wrap:wrap;gap:6px}
2210
+ .c3 .calbar .lens>.seg{flex-wrap:nowrap}
2211
+ /* Work's lead line: Open Briefing sits on its own line under the sentence */
2212
+ .c3 .lh .wk-min .pill{display:flex;width:max-content;margin-top:6px}
2213
+ /* Settings: search takes the row, the workspace picker the next */
2214
+ .c3.c3pg .c3set .st-hd .tools{width:100%}
2215
+ .c3.c3pg .c3set .st-hd input[type=text]{flex:1 1 100%;width:auto}
2216
+ .c3.c3pg .c3set .st-hd select{flex:1 1 0;min-width:0}
2217
+ }