create-pmos 0.494.0 → 0.496.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 (80) hide show
  1. package/package.json +75 -27
  2. package/payload/AGENTS.md +2 -0
  3. package/payload/KIT-VERSION +2 -2
  4. package/payload/planning/evals/eval-calibration.md +1 -1
  5. package/payload/planning/okrs/health-budgets.md +1 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/payload/state/log.md +1 -1
  8. package/workspace/adler-moments-test.js +3 -1
  9. package/workspace/board-programs-test.js +6 -6
  10. package/workspace/board-shared-ui-test.js +2 -2
  11. package/workspace/briefing-premium-adddoc-test.js +3 -49
  12. package/workspace/briefing-premium-board-test.js +23 -23
  13. package/workspace/briefing-premium-initiative-test.js +2 -1
  14. package/workspace/changelog.json +74 -0
  15. package/workspace/guide/adler.md +1 -1
  16. package/workspace/guide/briefing-premium.md +3 -1
  17. package/workspace/guide/claude-proposes.md +6 -2
  18. package/workspace/guide/data-freshness.md +2 -0
  19. package/workspace/guide/home.md +1 -1
  20. package/workspace/guide/initiatives-screen.md +1 -1
  21. package/workspace/guide/published-board.md +1 -1
  22. package/workspace/guide/report-slides.md +6 -6
  23. package/workspace/guide/reports.md +1 -1
  24. package/workspace/guide/workflows.md +18 -5
  25. package/workspace/ini-workflow-test.js +2 -1
  26. package/workspace/lib/board-premium.js +17 -2
  27. package/workspace/lib/engine/workflows.js +19 -6
  28. package/workspace/lib/initiative-chat.js +4 -1
  29. package/workspace/lib/initiative-cli.js +15 -1
  30. package/workspace/lib/open-mission.js +14 -0
  31. package/workspace/lib/proposals.js +18 -4
  32. package/workspace/lib/report-export.js +5 -3
  33. package/workspace/lib/report-pyramid.js +134 -33
  34. package/workspace/lib/report-templates.js +33 -3
  35. package/workspace/lib/routes-board-premium.js +2 -2
  36. package/workspace/lib/routes-boards.js +35 -2
  37. package/workspace/lib/routes-brief-page.js +2 -1
  38. package/workspace/lib/routes-initiatives.js +1 -0
  39. package/workspace/lib/routes-proposals.js +14 -2
  40. package/workspace/lib/routes-report-design.js +1 -1
  41. package/workspace/lib/routes-report-flow.js +11 -4
  42. package/workspace/lib/routes-report-setups.js +3 -2
  43. package/workspace/lib/routes-workflows.js +6 -1
  44. package/workspace/lib/split-merge-suggest.js +37 -3
  45. package/workspace/lib/split-merge.js +10 -4
  46. package/workspace/lib/task-cli.js +5 -1
  47. package/workspace/lib/workflow-cli.js +70 -15
  48. package/workspace/lib/workflows-store.js +15 -1
  49. package/workspace/metric-drafts-ui-test.js +2 -1
  50. package/workspace/package-lock.json +2 -2
  51. package/workspace/package.json +1 -1
  52. package/workspace/premium-board-actions-test.js +9 -10
  53. package/workspace/premium-core-doc-issue-test.js +9 -38
  54. package/workspace/premium-vw-backlog-test.js +3 -2
  55. package/workspace/premium-vw-item-test.js +3 -2
  56. package/workspace/premium-vw-jira-test.js +3 -2
  57. package/workspace/program-panel-test.js +2 -1
  58. package/workspace/public/pm/cozy-board.css +17 -5
  59. package/workspace/public/pm/cozy-home.css +1 -0
  60. package/workspace/public/pm/home-app.js +15 -5
  61. package/workspace/public/pm/home-chat.js +49 -8
  62. package/workspace/public/pm/home-today.js +11 -4
  63. package/workspace/public/pm/ini-view.js +2 -2
  64. package/workspace/public/pm/load-shapes.css +57 -0
  65. package/workspace/public/pm/load-shapes.js +57 -0
  66. package/workspace/public/pm/premium-board.js +82 -57
  67. package/workspace/public/pm/premium-shell.js +57 -6
  68. package/workspace/public/pm/premium.css +1 -0
  69. package/workspace/public/pm/readonly.js +3 -3
  70. package/workspace/public/pm/split-merge.js +3 -2
  71. package/workspace/public/pm/wf-page.css +8 -0
  72. package/workspace/public/pm/wf-page.js +33 -5
  73. package/workspace/public/premium-board.html +7 -10
  74. package/workspace/public/premium.html +1 -0
  75. package/workspace/skills/report-template.skill +4 -2
  76. package/workspace/split-merge-ui-test.js +4 -3
  77. package/workspace/stage-card-test.js +2 -1
  78. package/workspace/task-press-test.js +2 -1
  79. package/workspace/test-lists/page-open-speed.json +1 -1
  80. package/workspace/tk-tasks-test.js +2 -1
@@ -55,7 +55,8 @@ import { statusOf as ivStatus } from './ini-view-model.js';
55
55
  import { initiativeRename, taskRename } from './rename-press.js';
56
56
  import { setSay } from './cozy-xv.js';
57
57
  import { narrowForm } from './pn-narrow.js';
58
- import * as BP2 from './bd-pane.js'; // the Board as the first panel: fit(), the rows that open beside, the open row's mark
58
+ import * as BP2 from './bd-pane.js';
59
+ import * as LS from './load-shapes.js'; // the skeleton and the slow-case line while the board is read (page-open-speed, SpD) // the Board as the first panel: fit(), the rows that open beside, the open row's mark
59
60
 
60
61
  const VIEW_KEY = 'pmos.board.view', PVIEW_KEY = 'pmos.board.pview', SPAN_KEY = 'pmos.board.span', GROUPING_KEY = 'pmos.board.grouping';
61
62
  let M = null; // the model
@@ -68,6 +69,8 @@ let span = SPANS.some(([k]) => k === store.get(SPAN_KEY)) ? store.get(SPAN_KEY)
68
69
  let tab = 'ini';
69
70
  let SH_N = 0; let SH_FULL = false; let SH_LOADED = false; // the Shared tab's number, whether it has read the teams, whether the first count was asked
70
71
  let readTimer = null;
72
+ const SKEL = $('#ibBody').innerHTML; // the grey rows the page opens with (premium-board.html), put back if a failure line replaced them
73
+ LS.slowLine($('#ibBody'), () => M);
71
74
  let readTries = 0; // reads that answered "reading" in a row: the next ask waits longer each time
72
75
  let foldOpen = false; // the Monitor and Evaluate rows under the table
73
76
  let sprintSet = false; // "This sprint" starts on, once, when the board has a sprint
@@ -101,7 +104,7 @@ const I = {
101
104
  flag: 'M5 21V4M5 4h11l-2 4 2 4H5', note: 'M4 5h16v11h-9l-5 4v-4H4z', spark: 'M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z',
102
105
  doc: 'M6 3h9l4 4v14H6zM14 3v5h5', link: 'M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1', chev: 'M7 10l5 5 5-5', chevUp: 'M7 14l5-5 5 5',
103
106
  move: 'M4 12h12M12 6l6 6-6 6M20 5v14', hold: 'M9 5v14M15 5v14', archive: 'M4 5h16v4H4zM6 9v10h12V9M10 13h4',
104
- track: 'M3 5h18v4H3zM3 11h18v4H3zM3 17h12v3H3z', time: 'M4 6h9M8 12h11M6 18h7',
107
+ track: 'M3 5h18v4H3zM3 11h18v4H3zM3 17h12v3H3z', time: 'M4 6h9M8 12h11M6 18h7', info: 'M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18M12 11v5M12 8h.01',
105
108
  };
106
109
  const plural = (n, a, b) => (n === 1 ? a : b);
107
110
  const ago = (iso, short) => { const t = Date.parse(iso || ''); if (!t) return ''; const m = Math.max(0, Math.round((Date.now() - t) / 60000)); return m < 1 ? 'just now' : m < 60 ? m + ' min' + (short ? '' : ' ago') : m < 60 * 36 ? Math.round(m / 60) + ' h' + (short ? '' : ' ago') : Math.round(m / 1440) + ' days' + (short ? '' : ' ago'); };
@@ -242,52 +245,77 @@ function drawHead() {
242
245
  setPageGaps(((M.header && M.header.missing) || []).length);
243
246
  }
244
247
  let jiraReading = false; // a press is reading Jira again: the chip's Adler wears his glasses until the next read of the Board lands
245
- const CHIP_F = [['late', 'late', 'm-warn', (n) => n + ' late'], ['risk', 'risk', 'm-warn', (n) => n + ' at risk'], ['blocked', 'red', 'm-crit', (n) => n + ' blocked'], ['idle', 'q', '', (n) => n + ' idle 30+ days']];
248
+ const CHIP_F = [['late', 'late', 'm-warn', 'Late'], ['risk', 'risk', 'm-warn', 'At risk'], ['blocked', 'red', 'm-crit', 'Blocked'], ['idle', 'q', '', 'Idle 30+ days']];
246
249
  /** The sprint filter (the "Sprint N only" chip and Filter's Sprint group) shows on the Sprint span only among the timeline's spans (TsSprintFilter);
247
250
  * the stage track keeps its chip. */
248
251
  const curView = () => (PHONE.matches ? pview : narrow() ? nview : view);
249
252
  const sprintFilterOn = () => curView() !== 'time' || span === 'sprint';
253
+ /** What needs a look (batch 24 item 7, canvas BbB): new asks, late, at risk, blocked and idle are ONE pill of dots and numbers. A press opens the
254
+ * rows; a row opens what its chip opened (the asks, a metric's trend, whose "Show only" filters); a filter that is on turns off at once.
255
+ * A row that is zero is not listed, and the pill is not drawn when every row is zero (zero is silence). */
256
+ function needHtml(C, open) {
257
+ const rows = [];
258
+ if (C.asks) rows.push({ k: 'asks', dot: 'you m-info', n: C.asks, name: plural(C.asks, 'New ask', 'New asks'), say: C.asks + ' new ' + plural(C.asks, 'ask', 'asks') });
259
+ for (const [k, tone, mo, name] of CHIP_F) if (C[k]) rows.push({ k, dot: tone + (mo ? ' ' + mo : ''), n: C[k], name, say: C[k] + ' ' + name.toLowerCase() });
260
+ if (!rows.length) return '';
261
+ const on = rows.some((r) => F.chips.has(r.k));
262
+ const item = (r) => (r.k === 'asks'
263
+ ? `<button type="button" class="bd-nr" id="chAsks" aria-haspopup="dialog"><i class="cz-dot ${r.dot}"></i>${esc(r.name)}<span class="k">${r.n}</span></button>`
264
+ : `<button type="button" class="bd-nr${F.chips.has(r.k) ? ' on' : ''}" data-f="${r.k}" aria-pressed="${F.chips.has(r.k)}"><i class="cz-dot ${r.dot}"></i>${esc(r.name)}<span class="k">${r.n}</span></button>`);
265
+ return `<span class="bd-grpw2 bd-needw" id="bdNeedw"><button type="button" class="bd-chip bd-need${on ? ' on' : ''}" id="chNeed" aria-haspopup="true" aria-expanded="${open}" aria-controls="bdNeedMenu" aria-label="${esc(rows.map((r) => r.say).join(', '))}. Show them" title="${esc(rows.map((r) => r.say).join(' · '))}">`
266
+ + rows.map((r) => `<span><i class="cz-dot ${r.dot}"></i>${r.n}</span>`).join('') + SV(I.chev) + '</button>'
267
+ + `<span class="bd-grpm bd-needm" id="bdNeedMenu" role="group" aria-label="Needs a look"${open ? '' : ' hidden'}><span class="bd-mlbl">Needs a look</span>${rows.map(item).join('')}</span></span>`;
268
+ }
269
+ const menuOpen = (id) => !!$('#' + id) && !$('#' + id).hidden;
250
270
  function drawChips() {
251
271
  const J = M.jira || {}, C = M.chips || {}, sp = J.sprint, rel = J.release, phone = small();
252
- const jira = J.readAt ? (phone ? 'Jira ' + ago(J.readAt, true) : 'Jira · read ' + ago(J.readAt)) : phone ? 'Jira not read' : 'Jira · not read';
272
+ const jira = J.readAt ? 'Jira · read ' + ago(J.readAt) : 'Jira · not read';
253
273
  const sprint = [sp ? sp.name : null, rel ? rel.name + ' ships ' + rel.at.replace(/^(\d+) (\w+)$/, '$2 $1') : null].filter(Boolean).join(' · ') || 'No sprint on record';
254
- const asks = C.asks == null ? 'New asks · not read' : null;
255
- const flt = (keys) => CHIP_F.filter(([k]) => keys.includes(k)).map(([k, tone, mo, word]) => (C[k] ? `<button type="button" class="bd-chip gh${F.chips.has(k) ? ' on' : ''}" data-f="${k}" aria-pressed="${F.chips.has(k)}"><i class="cz-dot ${tone} ${mo}"></i>${esc(word(C[k]))}</button>` : '')).join('');
256
274
  const thisSprint = sp && (view === 'track' || view === 'time' || phone) && sprintFilterOn() ? `<button type="button" class="bd-chip${F.chips.has('sprint') ? ' on' : ''}" data-f="sprint" aria-pressed="${F.chips.has('sprint')}" title="${F.chips.has('sprint') ? 'Shows only the initiatives with work in ' + esc(sp.name) + '. Press to show all.' : 'Press to show only the initiatives with work in ' + esc(sp.name) + '.'}">${esc(sp.name)} only</button>` : ''; // it named no sprint and read like a heading (testing fixes)
257
- // All products (PgBoard): one Jira chip with the oldest read of any product, and every product's new asks; the sprint stays per product
258
- // AdM2: the chip's S face carries the read's mood: calm when Jira was read within a day and a half, honest when it was not, glasses on while
259
- // a press reads it again. Settings > Adler > Off keeps the refresh icon.
275
+ // All products (PgBoard): one Jira control with the oldest read of any product, and every product's new asks; the sprint stays per product
276
+ // AdM2: Adler's S face carries the read's mood: calm when Jira was read within a day and a half, honest when it was not, glasses on while
277
+ // a press reads it again. Settings > Adler > Off keeps the refresh icon. Batch 24 item 7 (BbB): the face alone, an amber pip when the read is
278
+ // old, the words on hover (and for a screen reader).
260
279
  const jMood = jiraReading ? 'thinking' : J.readAt && Date.now() - Date.parse(J.readAt) < 36 * 3600000 ? 'handled' : 'honest';
261
- const jiraChip = `<button type="button" class="bd-chip bd-adc" id="chJira" title="${ALL ? 'Read Jira again for every product' : 'Read Jira again'}">${faceOr(jMood, 's', SV(I.refresh), { label: false })}<span>${esc(jira)}</span></button>`;
262
- const askChip = `<button type="button" class="bd-chip" id="chAsks" aria-haspopup="dialog">${asks ? esc(asks) : `<i class="cz-dot you${C.asks ? ' m-info' : ''}"></i><b>${C.asks}</b> new ${plural(C.asks, 'ask', 'asks')}`}</button>`;
280
+ const jSay = jira + '. ' + (ALL ? 'Press to read Jira again for every product' : 'Press to read Jira again');
281
+ const jiraChip = `<button type="button" class="bd-chip bd-jf" id="chJira" title="${esc(jSay)}" aria-label="${esc(jSay)}">${faceOr(jMood, 's', SV(I.refresh), { label: false })}${jMood === 'honest' ? '<i class="bd-pip" aria-hidden="true"></i>' : ''}<span class="vh">${esc(jira)}</span></button>`;
282
+ const need = needHtml(C, menuOpen('bdNeedMenu'));
263
283
  if (phone) {
264
- const docs = M.docs && M.docs.rows ? M.docs.rows.length : 0;
265
- $('#bdChipset').innerHTML = thisSprint + jiraChip + askChip + flt(['late', 'risk', 'blocked', 'idle']) + `<button type="button" class="bd-chip" id="chDocs">Docs ${docs}</button><button type="button" class="bd-chip" id="chShared">Shared${SH_N ? ' ' + SH_N : ''}</button>`;
284
+ $('#bdChipset').innerHTML = thisSprint + jiraChip + need + `<button type="button" class="bd-chip" id="chShared">Shared${SH_N ? ' ' + SH_N : ''}</button>`;
266
285
  return;
267
286
  }
268
- // the timeline's legend sits at the end of the chip row (PgTimeline), with a program milestone only where a program is drawn and "On hold" only
269
- // where a hold is drawn (feedback batch 9 item 8: zero is silence)
270
- const lgd = view === 'time' && span !== 'sprint' ? '<span class="bd-sp"></span>' + legendHtml(!byProduct && !!groups(visible()), hasHold(visible()), { ...planKeys(visible()), ...gapKeys(visible()), range: visible().some((r) => fcRange(r)) }) : '';
271
- $('#bdChipset').innerHTML = jiraChip
272
- + (ALL ? '' : `<button type="button" class="bd-chip" id="chSprint" aria-haspopup="dialog">${SV(I.cal)}<span>${esc(sprint)}</span></button>`)
273
- + askChip + '<span class="bd-vr"></span>' + flt(['late', 'risk', 'blocked', 'idle'])
274
- + (thisSprint ? '<span class="bd-sp"></span>' + thisSprint : '') + lgd;
287
+ // the timeline's legend (PgTimeline) goes behind an info icon at the end of the row (BbB), with a program milestone only where a program is drawn
288
+ // and "On hold" only where a hold is drawn (feedback batch 9 item 8: zero is silence)
289
+ const lo = menuOpen('bdLgdMenu');
290
+ const lgd = view === 'time' && span !== 'sprint' ? `<span class="bd-grpw2 bd-lgdw"><button type="button" class="bd-ib bd-lgdb" id="bdLgdBtn" aria-haspopup="true" aria-expanded="${lo}" aria-controls="bdLgdMenu" aria-label="What the bars mean" title="What the bars mean">${SV(I.info)}</button>`
291
+ + `<span class="bd-grpm bd-lgdm" id="bdLgdMenu"${lo ? '' : ' hidden'}><span class="bd-mlbl">What the bars mean</span>${legendHtml(!byProduct && !!groups(visible()), hasHold(visible()), { ...planKeys(visible()), ...gapKeys(visible()), range: visible().some((r) => fcRange(r)) })}</span></span>` : '';
292
+ $('#bdChipset').innerHTML = (ALL ? '' : `<button type="button" class="bd-chip" id="chSprint" aria-haspopup="dialog">${SV(I.cal)}<span>${esc(sprint)}</span></button>`)
293
+ + thisSprint + '<span class="bd-sp"></span>' + need + lgd + jiraChip;
275
294
  }
295
+ const closeMenu = (id, btn) => { const m = $('#' + id); if (m && !m.hidden) { m.hidden = true; const b = $('#' + btn); if (b) b.setAttribute('aria-expanded', 'false'); return true; } return false; };
296
+ const toggleMenu = (id, btn) => { const m = $('#' + id), o = m.hidden; m.hidden = !o; $('#' + btn).setAttribute('aria-expanded', String(o)); if (o) { const f = m.querySelector('button'); if (f) f.focus(); } };
276
297
  $('#bdChipset').addEventListener('click', (e) => {
298
+ if (e.target.closest('#bdLgdBtn')) { closeMenu('bdNeedMenu', 'chNeed'); toggleMenu('bdLgdMenu', 'bdLgdBtn'); return; }
299
+ if (e.target.closest('#chNeed')) { closeMenu('bdLgdMenu', 'bdLgdBtn'); toggleMenu('bdNeedMenu', 'chNeed'); return; }
277
300
  const f = e.target.closest('[data-f]');
278
301
  if (f) {
279
- const k = f.dataset.f;
280
- // a metric chip opens its trend (BdPanel), whose "Show only" filters; a chip that is on turns off at once
281
- if (k !== 'sprint' && !F.chips.has(k)) { Acts.open.trend(k, f); return; }
282
- if (F.chips.has(k)) F.chips.delete(k); else F.chips.add(k); drawChips(); drawInit(); return;
302
+ const k = f.dataset.f, inNeed = !!f.closest('#bdNeedMenu');
303
+ if (inNeed) closeMenu('bdNeedMenu', 'chNeed');
304
+ // a metric row opens its trend (BdPanel), whose "Show only" filters; a row that is on turns off at once
305
+ if (k !== 'sprint' && !F.chips.has(k)) { Acts.open.trend(k, inNeed ? $('#chNeed') : f); return; }
306
+ if (F.chips.has(k)) F.chips.delete(k); else F.chips.add(k); drawChips(); drawInit(); if (inNeed) { const b = $('#chNeed') || $('#bdFilter'); b.focus(); } return;
283
307
  }
284
308
  const b = e.target.closest('button'); if (!b) return;
285
309
  if (b.id === 'chJira') refreshJira(b);
286
310
  else if (b.id === 'chSprint') Acts.open.sprint(b);
287
- else if (b.id === 'chAsks') Acts.open.asks(b);
288
- else if (b.id === 'chDocs') setTab('docs');
311
+ else if (b.id === 'chAsks') { closeMenu('bdNeedMenu', 'chNeed'); Acts.open.asks($('#chNeed')); }
289
312
  else if (b.id === 'chShared') setTab('shared');
290
313
  });
314
+ $('#bdChipset').addEventListener('keydown', (e) => {
315
+ if (e.key !== 'Escape') return;
316
+ if (closeMenu('bdNeedMenu', 'chNeed')) { e.stopPropagation(); $('#chNeed').focus(); } else if (closeMenu('bdLgdMenu', 'bdLgdBtn')) { e.stopPropagation(); $('#bdLgdBtn').focus(); }
317
+ });
318
+ document.addEventListener('click', (e) => { if (!e.target.closest('#bdNeedw')) closeMenu('bdNeedMenu', 'chNeed'); if (!e.target.closest('.bd-lgdw')) closeMenu('bdLgdMenu', 'bdLgdBtn'); });
291
319
  /** Read Jira again: the Jira source's own refresh (POST /api/sources/refresh), the one the Sources list runs. It starts a read and changes no record. */
292
320
  async function refreshJira(btn) {
293
321
  if (btn) btn.disabled = true;
@@ -517,9 +545,8 @@ function drawInit() {
517
545
  $$('#ibTabs [data-v]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.v === v)));
518
546
  { const sb = $('#bdSort'), Ls = SO.labels(); if (sb) { sb.setAttribute('aria-label', Ls.aria); sb.title = Ls.title; sb.classList.toggle('on', Ls.on); } }
519
547
  $('#bdView').innerHTML = SV(v === 'time' ? I.time : I.track); $('#bdView').setAttribute('aria-label', 'Change view. Now: ' + (v === 'time' ? 'Timeline' : 'Stage track'));
520
- $('#bdSpans').hidden = v !== 'time' || phone;
548
+ drawSpanPicker('desk', v === 'time' && !phone);
521
549
  $('#bdColsw').hidden = v !== 'time' || phone; if ($('#bdColsw').hidden) closeCols();
522
- $$('#bdSpans [data-sp]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.sp === span)));
523
550
  drawSpanPick(phone && v === 'time');
524
551
  document.body.dataset.bdView = v;
525
552
  $('#stage').dataset.bdDesk = view; // what fit() measures: the desktop's view, so the narrow form never flips it back and forth
@@ -571,22 +598,27 @@ $('#bdShowing').addEventListener('click', (e) => {
571
598
  if (u.dataset.unf === 'q') F.q = ''; else if (u.dataset.unf === 'wf') WF.setPick(''); else F[u.dataset.unf].clear(); drawChips(); drawInit();
572
599
  });
573
600
  function setSpan(s) { span = s; store.set(SPAN_KEY, span); drawChips(); drawInit(); }
574
- $('#bdSpans').addEventListener('click', (e) => { const b = e.target.closest('[data-sp]'); if (!b || !M) return; setSpan(b.dataset.sp); });
575
- /** A phone's span picker (TsCPhone, TsCPhoneSpan): the six span buttons become one picker beside the product picker, and the span row goes. */
576
- function drawSpanPick(on) {
577
- const w = $('#bdSpanPick'); document.body.classList.toggle('bd-spick', on);
601
+ /** The span picker: one chip that opens the six spans (batch 24 item 7, canvas BbB, as a phone's TsCPhone, TsCPhoneSpan). A desktop draws it in the
602
+ * chip row after the tabs (#bdSpans, items data-sp); a phone draws it beside the product picker (#bdSpanPick, items data-spick), and the page title
603
+ * gives it the room. */
604
+ const SPK = { desk: ['bdSpans', 'bdSpansBtn', 'bdSpansMenu', 'sp'], phone: ['bdSpanPick', 'bdSpanBtn', 'bdSpanMenu', 'spick'] };
605
+ function drawSpanPicker(which, on) {
606
+ const [wid, bid, mid, attr] = SPK[which], w = $('#' + wid);
578
607
  w.hidden = !on; if (!on) { w.innerHTML = ''; return; }
579
- const open = !!$('#bdSpanMenu') && !$('#bdSpanMenu').hidden, name = (SPANS.find(([k]) => k === span) || SPANS[1])[1];
580
- w.innerHTML = `<button type="button" class="bd-chip bd-spk" id="bdSpanBtn" aria-haspopup="menu" aria-expanded="${open}" aria-label="Span: ${esc(name)}. Change the span">${esc(name)}${SV(I.chev)}</button>`
581
- + `<span class="bd-grpm" id="bdSpanMenu" role="menu"${open ? '' : ' hidden'}>${SPANS.map(([k, n]) => `<button type="button" role="menuitemradio" aria-checked="${k === span}" data-spick="${k}">${esc(n)}</button>`).join('')}</span>`;
582
- }
583
- const closeSpanPick = () => { const m = $('#bdSpanMenu'); if (m && !m.hidden) { m.hidden = true; $('#bdSpanBtn').setAttribute('aria-expanded', 'false'); return true; } return false; };
584
- $('#bdSpanPick').addEventListener('click', (e) => {
585
- const k = e.target.closest('[data-spick]'); if (k) { closeSpanPick(); setSpan(k.dataset.spick); const b = $('#bdSpanBtn'); if (b) b.focus(); return; }
586
- if (e.target.closest('#bdSpanBtn')) { const m = $('#bdSpanMenu'), o = m.hidden; m.hidden = !o; $('#bdSpanBtn').setAttribute('aria-expanded', String(o)); if (o) m.querySelector('[aria-checked="true"]').focus(); }
587
- });
588
- $('#bdSpanPick').addEventListener('keydown', (e) => { if (e.key === 'Escape' && closeSpanPick()) { e.stopPropagation(); $('#bdSpanBtn').focus(); } });
589
- document.addEventListener('click', (e) => { if (!e.target.closest('#bdSpanPick')) closeSpanPick(); });
608
+ const open = menuOpen(mid), name = (SPANS.find(([k]) => k === span) || SPANS[1])[1];
609
+ w.innerHTML = `<button type="button" class="bd-chip bd-spk" id="${bid}" aria-haspopup="menu" aria-expanded="${open}" aria-label="Span: ${esc(name)}. Change the span">${esc(name)}${SV(I.chev)}</button>`
610
+ + `<span class="bd-grpm" id="${mid}" role="menu"${open ? '' : ' hidden'}>${SPANS.map(([k, n]) => `<button type="button" role="menuitemradio" aria-checked="${k === span}" data-${attr}="${k}">${esc(n)}</button>`).join('')}</span>`;
611
+ }
612
+ function drawSpanPick(on) { document.body.classList.toggle('bd-spick', on); drawSpanPicker('phone', on); }
613
+ for (const which of Object.keys(SPK)) {
614
+ const [wid, bid, mid, attr] = SPK[which], w = $('#' + wid);
615
+ w.addEventListener('click', (e) => {
616
+ const k = e.target.closest(`[data-${attr}]`); if (k && M) { closeMenu(mid, bid); setSpan(k.dataset[attr]); const b = $('#' + bid); if (b) b.focus(); return; }
617
+ if (e.target.closest('#' + bid)) { const m = $('#' + mid), o = m.hidden; m.hidden = !o; $('#' + bid).setAttribute('aria-expanded', String(o)); if (o) m.querySelector('[aria-checked="true"]').focus(); }
618
+ });
619
+ w.addEventListener('keydown', (e) => { if (e.key === 'Escape' && closeMenu(mid, bid)) { e.stopPropagation(); $('#' + bid).focus(); } });
620
+ document.addEventListener('click', (e) => { if (!e.target.closest('#' + wid)) closeMenu(mid, bid); });
621
+ }
590
622
  $('#ibBody').addEventListener('click', (e) => {
591
623
  if (e.target.closest('#fold, [data-mefold]')) { foldOpen = !foldOpen; drawInit(); const f = $('#fold') || $('#ibBody [data-mefold]'); if (f) f.focus(); return; }
592
624
  const gf = e.target.closest('[data-fold]'); if (gf) { const k = gf.dataset.fold; PG.fold(k); drawInit(); const b = $$('#ibBody [data-fold]').find((x) => x.dataset.fold === k); if (b) b.focus(); return; }
@@ -860,24 +892,20 @@ $('#bdPublish').addEventListener('click', (e) => { const btn = e.currentTarget;
860
892
  /* ── tabs: Initiatives and Documents (Documents has no frame yet: a plain list) ──────────────────────────────────────────────────────── */
861
893
  function setTab(t) {
862
894
  tab = t;
863
- $('#tabIni').setAttribute('aria-selected', String(t === 'ini')); $('#tabDocs').setAttribute('aria-selected', String(t === 'docs')); $('#tabShared').setAttribute('aria-selected', String(t === 'shared'));
864
- $('#cIni').hidden = t !== 'ini'; $('#cDocs').hidden = t !== 'docs'; $('#cShared').hidden = t !== 'shared';
865
- if (t === 'docs') { closePanel(); drawDocs(); loadDocMeta(); }
895
+ $('#tabIni').setAttribute('aria-selected', String(t === 'ini')); $('#tabShared').setAttribute('aria-selected', String(t === 'shared'));
896
+ $('#cIni').hidden = t !== 'ini'; $('#cShared').hidden = t !== 'shared';
866
897
  // Shared (initiative board-publish, slice 4): reads each joined team's host now, on opening the tab and on Refresh, and never in between
867
898
  if (t === 'shared') { closePanel(); import('./bd-shared.js').then((S) => { SH_FULL = true; S.show($('#cShared'), { onCount: setSharedCount }); }); }
868
899
  }
869
900
  $('#tabIni').onclick = () => setTab('ini');
870
- $('#tabDocs').onclick = () => setTab('docs');
871
901
  $('#tabShared').onclick = () => setTab('shared');
872
- $('.bd-tabs').addEventListener('keydown', (e) => { if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; const ord = ['ini', 'docs', 'shared']; const n = ord[(ord.indexOf(tab) + (e.key === 'ArrowRight' ? 1 : ord.length - 1)) % ord.length]; setTab(n); $('#' + { ini: 'tabIni', docs: 'tabDocs', shared: 'tabShared' }[n]).focus(); });
902
+ $('.bd-tabs').addEventListener('keydown', (e) => { if (e.key !== 'ArrowRight' && e.key !== 'ArrowLeft') return; const ord = ['ini', 'shared']; const n = ord[(ord.indexOf(tab) + (e.key === 'ArrowRight' ? 1 : ord.length - 1)) % ord.length]; setTab(n); $('#' + { ini: 'tabIni', shared: 'tabShared' }[n]).focus(); });
873
903
  /** The number on the Shared tab and on the phone's Shared chip: nothing when there is nothing (zero is silence). Your own open links at load, every open copy after a read. */
874
904
  function setSharedCount(n) { SH_N = n; $('#nShared').textContent = n ? String(n) : ''; const c = $('#chShared'); if (c) c.textContent = 'Shared' + (n ? ' ' + n : ''); }
875
905
  document.addEventListener('bd:links', (e) => { if (!SH_FULL && e.detail) setSharedCount(e.detail.n); });
876
906
 
877
- /** Documents (BdDocs): the kind chips, the rows and their history; a row opens the document's panel (pm/bd-acts.js). */
878
- function drawDocs() { if (M) $('#docBody').innerHTML = V.docsHtml(); }
879
- $('#docBody').addEventListener('click', (e) => { const k = e.target.closest('[data-dkind]'); if (k) { V.setDocKind(k.dataset.dkind); drawDocs(); const b = $(`#docBody [data-dkind="${k.dataset.dkind}"]`); if (b) b.focus(); } });
880
- /** The history of the documents on screen (GET /api/board-docs): when each last changed, by whom, its last commits. Read-only. */
907
+ /** The history of the documents (GET /api/board-docs) for the document panel of pm/bd-acts.js. Batch 24 item 7 took the Documents tab off the Board,
908
+ * so nothing on the Board opens that panel now; it is kept for a later way in (see the PR).: when each last changed, by whom, its last commits. Read-only. */
881
909
  async function loadDocMeta(list) {
882
910
  if (!M) return;
883
911
  // each document is read from its own product's workspace (All products lists every product's documents)
@@ -891,7 +919,6 @@ async function loadDocMeta(list) {
891
919
  for (const p of P) DM[ws + ':' + p] = r.docs[p] || { none: true };
892
920
  got = true;
893
921
  }
894
- if (got && tab === 'docs') drawDocs();
895
922
  }
896
923
  const docMeta = (p, ws) => { const d = DM[(ws || (M && M.board.ws) || '') + ':' + p]; return d && !d.none ? d : null; };
897
924
 
@@ -976,8 +1003,6 @@ function draw() {
976
1003
  drawHead(); drawChips(); drawInit();
977
1004
  { const g = $('#bdGone'), G = M.initiatives.gone || { cancelled: [], archived: [] }, n = [G.cancelled.length ? G.cancelled.length + ' cancelled' : '', G.archived.length ? G.archived.length + ' archived' : ''].filter(Boolean).join(' · ');
978
1005
  if (g) g.textContent = 'Cancelled and archived' + (n ? ' · ' + n : ''); } // the ⋯ menu item counts them
979
- if (tab === 'docs') drawDocs();
980
- $('#docCap').textContent = String(V.docRows().length);
981
1006
  // an open panel follows the new read
982
1007
  if (peek && peek.kind === 'ini') BI.refresh(); // a row that left the Board (cancelled, archived) closes it
983
1008
  }
@@ -1037,7 +1062,7 @@ async function readAll(q) {
1037
1062
  async function load() {
1038
1063
  // while the board is read (the pick's default board, then the picked ones) the page says so and shows no row instead of a guess
1039
1064
  const reading = () => {
1040
- const t0 = Date.now(), tick = () => { const s = 'Reading the board · ' + Math.round((Date.now() - t0) / 1000) + ' s so far'; $('#snap').textContent = s; if (!M) $('#ibBody').innerHTML = `<p class="bd-msg"><b>${esc(s)}</b> The board derives each initiative from its records. This page shows nothing until it has read, rather than guess.</p>`; };
1065
+ const t0 = Date.now(), tick = () => { const s = 'Reading the board · ' + Math.round((Date.now() - t0) / 1000) + ' s so far'; $('#snap').textContent = s; if (!M && !$('#ibBody .ls-bd')) { $('#ibBody').innerHTML = SKEL; LS.slowLine($('#ibBody'), () => M); } };
1041
1066
  tick(); readTimer = setInterval(tick, 1000);
1042
1067
  // a first read after a restart usually lands within a second: ask again soon, then less often (page-speed)
1043
1068
  setTimeout(load, Math.min(3000, 300 * 2 ** readTries++));
@@ -1153,7 +1178,7 @@ V.init({ M: () => M, wf: wfState, statusOf, usual: usualOf, trail: TI, docMeta,
1153
1178
  groups: (L) => groups(L), folded: PG.folded, laneHead: (g) => PG.laneHtml(g, g.kind === 'program' ? progState(g.program) : null), subTile: (n) => PG.tileHtml(n, 'bd-ti s'),
1154
1179
  memberIcon: (id) => PG.memberIcon(PGS, id), product: () => (M.board.product || M.board.name || ''), programOf: (r) => programOf(PGS, r.ws || M.board.ws || '', r.id), tileHtml: PG.tileHtml, progTile: PG.progTile });
1155
1180
  Asks.init({ M: () => M, load, say, panel: actsPanel, close: closePanel, rowOf, openIni: openIniAny, openDoc: viewDoc, anchors, programs: () => PGS, reloadPrograms: loadPrograms });
1156
- Acts.init({ M: () => M, wf: wfState, load, say, panel: actsPanel, close: closePanel, redraw: () => { drawChips(); drawInit(); if (tab === 'docs') drawDocs(); }, F, rowOf, statusOf, openIni: openIniAny,
1181
+ Acts.init({ M: () => M, wf: wfState, load, say, panel: actsPanel, close: closePanel, redraw: () => { drawChips(); drawInit(); }, F, rowOf, statusOf, openIni: openIniAny,
1157
1182
  openTicket: (ini, tk) => openTicket(ini, tk, $('#bdFind')), askHref, pageHref, trail: TI, docMeta, loadDocMeta, viewDoc,
1158
1183
  grouping: () => ({ on: groupingOn(), byProduct }), setGrouping, sprintFilter: sprintFilterOn, timeline: () => curView() === 'time' });
1159
1184
  BI.mount({ M: () => M, statusOf, dayTitle, usual: usualOf, fmt, md, ago, segHtml, barHtml: V.barHtml, grill: (id) => GR[id], rowOf, pageHref, askHref, refreshJira: () => refreshJira(), viewDoc,
@@ -538,14 +538,50 @@ $('#dBody').addEventListener('click', async (e) => {
538
538
  const b = e.target.closest('[data-lead="refresh"]'); if (!b || !S.key || !S.key.startsWith('pop:')) return;
539
539
  b.disabled = true; await sendText(S.key, leadAsk(S.key));
540
540
  });
541
+ /* A sent message shows at once (batch 24 item 2: "enter the prompt is so long to appear on the chat"): its turn draws the moment the PM presses
542
+ * Enter, lighter until PMOS takes it. It leaves when the transcript holds the PM's turn (the same words, or any PM turn from that moment on),
543
+ * and after 3 minutes at most. A message that was not sent leaves at once and its words go back in the box. Keyed by row key, or 'sid:' + id. */
544
+ const PEND = { key: null, list: [], th: null }, PEND_MAX = 180000;
545
+ const pendOf = (key) => { if (PEND.key !== key) { PEND.key = key; PEND.list = []; PEND.th = null; } return PEND.list; };
546
+ function pendAdd(key, text, files) {
547
+ const conv = key.startsWith('sid:') ? PEND.sidConv : S.conv;
548
+ const p = { text, files: files || [], at: new Date().toISOString(), sent: false, n: ((conv && conv.messages) || []).filter((m) => m.role === 'user').length };
549
+ pendOf(key).push(p); return p;
550
+ }
551
+ function pendHtml(key, conv) {
552
+ const list = pendOf(key); if (!list.length) return '';
553
+ const k = (t) => splitFiles(shownUser(t)).text.replace(/\s+/g, ' ').trim();
554
+ const users = ((conv && conv.messages) || []).filter((m) => m.role === 'user');
555
+ const lastUser = users.reduce((n, m) => Math.max(n, Date.parse(m.at) || 0), 0), now = Date.now();
556
+ PEND.list = list.filter((p) => {
557
+ if (!p.sent) return true;
558
+ const t0 = Date.parse(p.at);
559
+ return now - t0 <= PEND_MAX && !(lastUser && lastUser >= t0 - 2000) && !users.slice(p.n).some((m) => k(m.text) === k(p.text));
560
+ });
561
+ return PEND.list.map((p) => `<div class="turn u pend${p.sent ? '' : ' sending'}"><div class="ub">${filesHtml(p.files)}${p.text ? userHtml(p.text) : ''}</div></div>`).join('');
562
+ }
563
+ /** Draws the waiting turns now, without a read: the thread as last read plus the new turn, and the stream scrolled to its end. */
564
+ function pendDraw(key) {
565
+ if (key.startsWith('sid:')) { if (S.sid !== key.slice(4)) return; }
566
+ else if (S.key !== key) return;
567
+ const box = $('#dBody .stream'); if (!box) return;
568
+ if (!key.startsWith('sid:') && PEND.th) drawThread(PEND.th, key, S.conv);
569
+ else {
570
+ let w = $('#pendW', box); if (!w) { box.insertAdjacentHTML('beforeend', '<div id="pendW"></div>'); w = $('#pendW', box); }
571
+ putHtml(w, pendHtml(key, key.startsWith('sid:') ? PEND.sidConv : S.conv));
572
+ }
573
+ const b = $('#dBody'); b.scrollTop = b.scrollHeight;
574
+ }
541
575
  export function drawThread(th, key, conv) {
542
576
  if (S.key !== key) return;
577
+ pendOf(key); PEND.th = th;
543
578
  const box = $('#dBody .stream'); if (!box) return;
544
579
  drawLead(box, th);
545
580
  let t = $('#thr', box); if (!t) { box.insertAdjacentHTML('beforeend', '<div id="thr"></div>'); t = $('#thr', box); }
546
581
  const b = $('#dBody'), atEnd = b.scrollHeight - b.scrollTop - b.clientHeight < 80;
547
582
  S.conv = conv;
548
- putHtml(t, threadHtml(th) + sessionHtml(conv, th.goal, { answer: true }));
583
+ const pw = $('#pendW', box); if (pw) pw.remove(); // a first message drew its turn before the thread was read
584
+ putHtml(t, threadHtml(th) + sessionHtml(conv, th.goal, { answer: true }) + pendHtml(key, conv));
549
585
  ctxAuto(key, conv);
550
586
  S.goal = th.goal || null; S.draft = readyDraft(conv); S.session = th.session || null;
551
587
  syncTerm(termTarget({ live: th.session, command: th.resume, ws: new URLSearchParams(location.search).get('ws') }));
@@ -773,15 +809,29 @@ $('#dBody').addEventListener('submit', async (e) => {
773
809
  const inp = $('#askIn'), text = inp.value.trim(), key = S.key;
774
810
  if (!key && S.sid) { // premium-session-list: a session the list opened that has no row of its own
775
811
  if (!text) { $('#askNote').textContent = 'Write a message for Claude.'; return; }
776
- const id = S.sid, r = await pmPost('/api/session/say', { id, text });
777
- if (!r || !r.ok) { $('#askNote').textContent = (r && r.error) || 'The message was not sent.'; return; }
778
- inp.value = ''; $('#askNote').textContent = 'Sent.'; drawSlash(); setTimeout(() => loadSid(id), 400); return;
812
+ const id = S.sid, pk = 'sid:' + id, p = pendAdd(pk, text);
813
+ inp.value = ''; drawSlash(); growAsk(); pendDraw(pk); // the turn shows at once (batch 24 item 2)
814
+ const r = await pmPost('/api/session/say', { id, text }).catch(() => null);
815
+ if (!r || !r.ok) {
816
+ PEND.list = pendOf(pk).filter((x) => x !== p); pendDraw(pk);
817
+ if (S.sid === id && !inp.value) { inp.value = text; growAsk(); }
818
+ $('#askNote').textContent = (r && r.error) || 'The message was not sent.'; return;
819
+ }
820
+ p.sent = true; pendDraw(pk);
821
+ $('#askNote').textContent = 'Sent.'; setTimeout(() => loadSid(id), 400); return;
779
822
  }
780
823
  if (!key) { $('#askNote').textContent = 'This row has no conversation, so the message was not sent.'; return; }
781
824
  const files = attachNames();
782
825
  if (files === null) { $('#askNote').textContent = 'A file is still saving. Send when its chip shows its size.'; return; }
783
826
  if (!text && !files.length) { $('#askNote').textContent = 'Write a message for Claude.'; return; }
784
- if (await sendText(key, text, files)) { inp.value = ''; inp.placeholder = 'Message Claude, or type /'; clearAttach(); drawSlash(); growAsk(); }
827
+ // the turn shows and the box empties at once (batch 24 item 2); a send that fails takes the turn away and puts the words back
828
+ const p = pendAdd(key, text, files);
829
+ inp.value = ''; drawSlash(); growAsk(); pendDraw(key);
830
+ if (await sendText(key, text, files)) { p.sent = true; inp.placeholder = 'Message Claude, or type /'; clearAttach(); pendDraw(key); }
831
+ else {
832
+ PEND.list = pendOf(key).filter((x) => x !== p); pendDraw(key);
833
+ if (S.key === key && !inp.value) { inp.value = text; growAsk(); }
834
+ }
785
835
  });
786
836
 
787
837
  /* ── on scroll: a card's head sticks while its card is on screen. The old hero no longer folds into a dock (premium-agreed-design). ── */
@@ -1424,7 +1474,8 @@ async function loadSid(id) {
1424
1474
  syncTerm(termTarget({ live: c && c.ok && c.state !== 'ended' ? id : null }));
1425
1475
  const box = $('#dBody .stream'); if (!box) return;
1426
1476
  const b = $('#dBody'), atEnd = b.scrollHeight - b.scrollTop - b.clientHeight < 80;
1427
- putHtml(box, c && c.ok ? sessionHtml(c) : turnA(ptx('The conversation of this session could not be read.')));
1477
+ if (c && c.ok) PEND.sidConv = c;
1478
+ putHtml(box, c && c.ok ? sessionHtml(c) + pendHtml('sid:' + id, c) : turnA(ptx('The conversation of this session could not be read.')));
1428
1479
  $('#askNote').textContent = c && c.ask ? 'Claude is waiting for your answer above.' : 'Your message goes to this session.';
1429
1480
  if (atEnd) b.scrollTop = b.scrollHeight;
1430
1481
  if (c && c.ok && c.state !== 'ended' && $('#dlg').classList.contains('on')) R.sidTimer = setTimeout(() => loadSid(id), 2000);
@@ -285,6 +285,7 @@ a.round{color:inherit;text-decoration:none}
285
285
  .tc{min-width:0}
286
286
  .tt{margin:0;font-size:var(--fs-3);line-height:1.55;color:var(--ink);max-width:58ch}
287
287
  .turn.u{justify-items:end}
288
+ .turn.u.pend.sending .ub{opacity:.72} /* batch 24 item 2: a sent turn shows at once, a touch lighter until PMOS takes it */
288
289
  .ub{background:var(--solid);color:var(--on-solid);padding:11px 16px;border-radius:18px 18px 8px 18px;font-size:var(--fs-3);line-height:1.45;max-width:86%}
289
290
  .acard{margin-left:40px;background:var(--well);border-radius:var(--r-in);padding:20px 22px;display:grid;gap:14px;animation:rise .6s var(--ease) both}
290
291
  .still .turn,.still .acard{animation:none} /* a redraw of a live chat keeps its turns still (premium-shell.js putHtml) */
@@ -17,8 +17,8 @@ export const RO = !!PACK;
17
17
 
18
18
  /** What a reader can do. Each is a selector; a press inside a match is let through. */
19
19
  export const VIEW_CONTROLS = [
20
- // the Timeline: the span, a column's sort, back to today, the Deliveries lane, the folds, an initiative's name (opens its viewer)
21
- '#bdSpans button', '.tg-chd', '.tg-tback', '.dl-fb', '.dl-dn', '.dl-e[aria-label*="press to open"]', '.bd-fold', '.bd-prdb', 'label.tg-n', 'button.tg-n.ini2',
20
+ // the Timeline: the span, the legend behind (i), a column's sort, back to today, the Deliveries lane, the folds, an initiative's name (opens its viewer)
21
+ '#bdSpans button', '#bdLgdBtn', '.tg-chd', '.tg-tback', '.dl-fb', '.dl-dn', '.dl-e[aria-label*="press to open"]', '.bd-fold', '.bd-prdb', 'label.tg-n', 'button.tg-n.ini2',
22
22
  // the stage track: the switch between it and the Timeline (the two views a publisher shares), an initiative's name, the Monitor and Evaluate fold
23
23
  '#ibTabs [data-v="track"]', '#ibTabs [data-v="time"]', 'button.bt-nm', '#fold',
24
24
  // the viewer: the layout, the close, the folds, the Tasks view menu, a stage's fold and its done tasks
@@ -36,7 +36,7 @@ const HIDE = ['#ibTabs [data-v="stages"]', '#ibTabs [data-v="list"]', '#bdView',
36
36
  /** Controls the code draws that write, or lead to something a share does not hold: removed. */
37
37
  const REMOVE = [
38
38
  '.po-grip', '.bd-nxa',
39
- '#bdChipset > :not(.tg-lgd)', '#askDates', '.tg-ft',
39
+ '#bdChipset > :not(.bd-lgdw)', '#askDates', '.tg-ft',
40
40
  '.cz-vw-ib[aria-label="Discuss in chat"]', '.cz-vw-ib[aria-label="More"]', '.iv-addp', '.iv-prog', '.iv-plus', '.iv-car', '.iv-scgn', '.iv-gx-a',
41
41
  '.sm-card', '.pp-p', '.lk-chip.mv', // what Claude proposes to the publisher (batch 20 item 1): the pack carries none, and none is drawn
42
42
  // the program page's writes and what a share does not hold (batch 21 item 2): proposals, adding, the charter, a document's file, the talk line
@@ -98,9 +98,10 @@ export async function press(c, act) {
98
98
  if (act === 'no') {
99
99
  // the pressed button holds the focus inside the Viewer, and a host does not redraw a body that holds it
100
100
  if (typeof document !== 'undefined' && document.activeElement && document.activeElement.blur) document.activeElement.blur();
101
- const x = await c.pmPost('/api/initiatives/suggest', { ws: c.ws, id: r.id, of: sg.kind, fp: sg.fp }).catch(() => null);
101
+ const facts = sg.kind === 'merge' ? { into: sg.into } : { moves: [...(sg.tasks || []), ...(sg.tickets || [])] };
102
+ const x = await c.pmPost('/api/initiatives/suggest', { ws: c.ws, id: r.id, of: sg.kind, fp: sg.fp, ...facts }).catch(() => null);
102
103
  if (!x || !x.ok) { c.say((x && x.error) || 'It is still shown. The server did not say why.'); return; }
103
- c.say('Not now. Claude asks again when the tasks change.');
104
+ c.say(sg.kind === 'merge' ? 'Not now. Claude will not suggest this merge again.' : 'Not now. Claude will not suggest this split again.');
104
105
  await c.reload();
105
106
  return;
106
107
  }
@@ -23,6 +23,14 @@ body.wf-page { min-height: 100vh; overflow: hidden; font-family: var(--cz-sans);
23
23
  .wfl .t { font-size: var(--fs-3); font-weight: 700; flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
24
24
  .wfl .n { font-size: var(--fs-2); color: var(--cz-ink3); font-weight: 600; }
25
25
  .wfl.arch .t { color: var(--cz-ink3); }
26
+ .wfl .wfb { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; padding: 0; border: 0; background: none; color: inherit; font: inherit; cursor: pointer; text-align: left; }
27
+ .wfl .wfb:focus-visible, .wfl .wfs:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; border-radius: 8px; }
28
+ /* the star (batch 24 item 3, canvas DfA): filled on the default; an outline only on hover or focus elsewhere, and always on touch */
29
+ .wfl .wfs { width: 30px; height: 30px; margin: -4px 0; border-radius: 999px; display: inline-grid; place-items: center; border: 0; padding: 0; background: none; color: var(--cz-ink3); cursor: pointer; flex: none; opacity: 0; }
30
+ .wfl:hover .wfs, .wfl:focus-within .wfs, .wfl .wfs.on { opacity: 1; }
31
+ .wfl .wfs:hover { background: var(--cz-card); color: var(--cz-ink2); box-shadow: 0 0 0 1px var(--cz-hair); }
32
+ .wfl .wfs.on, .wfl .wfs.on:hover { color: var(--cz-risk); }
33
+ @media (hover: none) { .wfl .wfs { opacity: 1; } }
26
34
  .wf-sub { font-size: var(--fs-1); font-weight: 800; color: var(--cz-ink3); padding: 14px 10px 4px; }
27
35
  .wf-page .wfi { display: inline-flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: 6px; background: var(--cz-well); color: var(--cz-ink2); flex: none; }
28
36
  .wfl .wfi, .wf-head .wfi { background: var(--cz-well2); }
@@ -1,6 +1,7 @@
1
1
  /* pm/wf-page.js — Settings › Workflows (initiative workflows per initiative, D99; canvas WfAModel, WfAStage, WfACond, WfARemove, WfANew,
2
2
  * WfADraft). One page, reached from Settings:
3
- * the list every workflow with its count of initiatives and +; Product feature first, archived ones last and quiet
3
+ * the list every workflow with its count of initiatives and +; Product feature first, archived ones last and quiet. The star on a row
4
+ * (batch 24 item 3, canvas DfA) makes that workflow the default a new initiative starts on (POST /api/workflows/default)
4
5
  * the picked workflow one numbered list across the To do, In progress and Done columns; drag a stage to reorder it or to move it to
5
6
  * another column; Add a stage per column; the dashed Won't do · Archived column (in every workflow); ⋯ = Copy, Archive.
6
7
  * The name is the rename press (RnA*: tap it, one field with a check).
@@ -35,6 +36,8 @@ const DRAG = 'M9 6h.01M15 6h.01M9 12h.01M15 12h.01M9 18h.01M15 18h.01', PLUS = '
35
36
  const S = { list: null, sel: null, draft: null, edit: null, err: '', msg: '', renaming: false, renErr: '', busy: false, pop: null, chat: null, poll: 0, phoneOpen: false };
36
37
  const wfOf = (id) => (S.edit && id === S.edit.id ? S.edit : (S.list || []).find((w) => w.id === id) || null);
37
38
  const cur = () => (S.sel && S.sel !== 'new' ? wfOf(S.sel) : null);
39
+ /** A New workflow chat is still going (its key is kept until Create), or PMOS has drafted one. */
40
+ const draftLive = () => !!(store.get(CHAT_KEY) || S.draft);
38
41
  const readOnly = (wf) => !!wf && (wf.builtin || wf.id === PRODUCT_FEATURE_ID);
39
42
 
40
43
  function toast(text, undo) {
@@ -62,19 +65,30 @@ async function readDraft() {
62
65
  const d = r && r.ok && r.draft ? r.draft.draft || null : null; // { draft, note, at } | null
63
66
  const was = JSON.stringify(S.draft), now = JSON.stringify(d);
64
67
  S.draft = d;
68
+ if (was !== now && S.sel !== 'new') drawList(); // the draft row's name follows the draft
65
69
  if (was !== now && S.sel === 'new') { drawList(); if (d) showDraft(); else if (VW.current() && VW.current().id === 'wf-draft') VW.close(); }
66
70
  }
67
71
 
68
72
  /* ── the list (WfAModel's left column) ── */
73
+ // the star (batch 24 item 3, canvas DfA): gold and filled on the default, an outline on hover or focus elsewhere; archived rows have none
74
+ const STAR = 'M12 3.5l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.9 6.8 19.6l1-5.8-4.3-4.1 5.9-.9z';
75
+ function starHtml(w) {
76
+ if (w.archived) return '';
77
+ const label = w.default ? 'Default for new initiatives' : 'Make ' + w.name + ' the default for new initiatives';
78
+ return `<button type="button" class="wfs${w.default ? ' on' : ''}" data-wf-star="${esc(w.id)}" aria-pressed="${w.default ? 'true' : 'false'}" aria-label="${esc(label)}" title="${w.default ? 'Default for new initiatives' : 'Make default'}">`
79
+ + `<svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="${w.default ? 'currentColor' : 'none'}" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${STAR}"/></svg></button>`;
80
+ }
69
81
  function rowHtml(w) {
70
82
  const n = w.count > 0 ? `<span class="n">${w.count}</span>` : ''; // zero is silence
71
- return `<button type="button" class="wfl${S.sel === w.id ? ' sel' : ''}${w.archived ? ' arch' : ''}" data-wf="${esc(w.id)}"${S.sel === w.id ? ' aria-current="true"' : ''}>${wfIcon(w, 22)}<span class="t">${esc(w.name)}</span>${n}</button>`;
83
+ return `<div class="wfl${S.sel === w.id ? ' sel' : ''}${w.archived ? ' arch' : ''}"><button type="button" class="wfb" data-wf="${esc(w.id)}"${S.sel === w.id ? ' aria-current="true"' : ''}>${wfIcon(w, 22)}<span class="t">${esc(w.name)}</span></button>${starHtml(w)}${n}</div>`;
72
84
  }
73
85
  function drawList() {
74
86
  const el = $('#wfList'); if (!el) return;
75
87
  const L = S.list;
76
88
  const on = (L || []).filter((w) => !w.archived), off = (L || []).filter((w) => w.archived);
77
- const newRow = S.sel === 'new' ? `<button type="button" class="wfl sel" data-wf="new" aria-current="true"><span class="wfi new" aria-hidden="true">${svg(PLUS, 14)}</span><span class="t">${esc((S.draft && S.draft.name) || 'New workflow')}</span><span class="n">draft</span></button>` : '';
89
+ // a live draft keeps its row while another workflow is open, so its chat and draft are one press away (batch 24 item 4)
90
+ const onNew = S.sel === 'new';
91
+ const newRow = onNew || draftLive() ? `<button type="button" class="wfl${onNew ? ' sel' : ''}" data-wf="new"${onNew ? ' aria-current="true"' : ''}><span class="wfi new" aria-hidden="true">${svg(PLUS, 14)}</span><span class="t">${esc((S.draft && S.draft.name) || 'New workflow')}</span><span class="n">draft</span></button>` : '';
78
92
  el.innerHTML = `<div class="lhead"><h2>Workflows</h2><button type="button" class="ib sm" data-wf-new aria-label="New workflow: describe it and PMOS drafts it" title="New workflow: describe it and PMOS drafts it">${svg(PLUS, 16)}</button></div>`
79
93
  + (L === null ? '<p class="wf-mut wf-small wf-pad">Reading the workflows.</p>' : on.map(rowHtml).join('') + newRow
80
94
  + (off.length ? `<p class="wf-sub">Archived</p>${off.map(rowHtml).join('')}` : ''));
@@ -234,6 +248,17 @@ async function archive(wf, on) {
234
248
  await load();
235
249
  toast(on ? 'Archived ' + wf.name + '.' : 'Restored ' + wf.name + '.', () => archive(wf, !on));
236
250
  }
251
+ async function makeDefault(id, undoTo) {
252
+ const w = (S.list || []).find((x) => x.id === id);
253
+ if (!w || (w.default && !undoTo)) return;
254
+ const was = (S.list || []).find((x) => x.default);
255
+ const r = await post('/api/workflows/default', { id });
256
+ if (!(r && r.ok)) { S.msg = (r && r.error) || 'Not changed.'; drawMain(); return; }
257
+ for (const x of S.list) { if (x.id === id) x.default = true; else delete x.default; }
258
+ drawList();
259
+ const b = document.querySelector(`#wfList [data-wf-star="${CSS.escape(id)}"]`); if (b) b.focus();
260
+ if (!undoTo) toast('New initiatives start on ' + w.name + '.', was ? () => makeDefault(was.id, true) : null);
261
+ }
237
262
  async function rename(wf, name) {
238
263
  const v = String(name || '').trim();
239
264
  if (!v || v === wf.name) { S.renaming = false; S.renErr = ''; drawMain(); return; }
@@ -389,8 +414,10 @@ function bindDrag(main) {
389
414
  function bind() {
390
415
  $('#wfList').addEventListener('click', (e) => {
391
416
  if (e.target.closest('[data-wf-new]')) { startNew(); return; }
392
- const b = e.target.closest('[data-wf]'); if (!b) return;
393
- if (b.dataset.wf === 'new') return;
417
+ const star = e.target.closest('[data-wf-star]'); if (star) { makeDefault(star.dataset.wfStar); return; }
418
+ const row = e.target.closest('.wfl');
419
+ const b = e.target.closest('[data-wf]') || (row && row.querySelector('[data-wf]')); if (!b) return;
420
+ if (b.dataset.wf === 'new') { if (S.sel !== 'new') startNew(); return; } // back to the live draft: its chat and draft viewer
394
421
  select(b.dataset.wf);
395
422
  });
396
423
  const main = $('#wfMain');
@@ -431,5 +458,6 @@ export function boot() {
431
458
  draw();
432
459
  load();
433
460
  if (new URLSearchParams(location.search).get('new') === '1') startNew();
461
+ else if (store.get(CHAT_KEY)) readDraft(); // a draft from before shows as its row, by name
434
462
  }
435
463
  if (typeof document !== 'undefined' && document.getElementById('wfMain')) boot();
@@ -13,6 +13,7 @@
13
13
  <link rel="stylesheet" href="/pm/cozy.css" />
14
14
  <link rel="stylesheet" href="/pm/cozy-pick.css" />
15
15
  <link rel="stylesheet" href="/pm/cozy-board.css" />
16
+ <link rel="stylesheet" href="/pm/load-shapes.css" />
16
17
  <link rel="stylesheet" href="/pm/bd-pin-card.css" />
17
18
  <link rel="stylesheet" href="/pm/cozy-panel.css" />
18
19
  </head>
@@ -50,27 +51,23 @@
50
51
  <div class="tools tools-top"><span class="vh" id="snap" role="status">Reading the board</span></div>
51
52
  </div>
52
53
  </div>
53
- <!-- the tabs, the timeline spans and the chips: Jira read time, sprint and release, new asks, late, at risk, blocked, idle, This sprint -->
54
+ <!-- the tabs (Initiatives, Shared; batch 24 item 7 took Documents off: an initiative's Documents fold and Search's Documents filter reach them),
55
+ the span picker and the chips: sprint and release, This sprint, one pill for new asks, late, at risk, blocked and idle, the legend behind (i), Jira as Adler -->
54
56
  <div class="bd-chiprow">
55
57
  <div class="bd-tabs" role="tablist" aria-label="Board">
56
58
  <button type="button" role="tab" class="bd-tab" id="tabIni" aria-selected="true" aria-controls="cIni">Initiatives <span class="n" id="nIni"></span></button>
57
- <button type="button" role="tab" class="bd-tab" id="tabDocs" aria-selected="false" aria-controls="cDocs">Documents <span class="n" id="docCap"></span></button>
58
59
  <button type="button" role="tab" class="bd-tab" id="tabShared" aria-selected="false" aria-controls="cShared">Shared <span class="n" id="nShared"></span></button>
59
60
  </div>
60
- <div class="bd-seg bd-spans" id="bdSpans" role="group" aria-label="Span" hidden>
61
- <button type="button" data-sp="sprint" aria-pressed="false">Sprint</button><button type="button" data-sp="quarter" aria-pressed="true">Quarter</button><button type="button" data-sp="half" aria-pressed="false">Half</button><button type="button" data-sp="year" aria-pressed="false">Year</button><button type="button" data-sp="y2" aria-pressed="false">2 yr</button><button type="button" data-sp="y5" aria-pressed="false">5 yr</button>
62
- </div>
61
+ <!-- the span picker (batch 24 item 7, canvas BbB): one chip that opens Sprint to 5 yr; the timeline only -->
62
+ <span class="bd-grpw2 bd-spw" id="bdSpans" role="group" aria-label="Span" hidden></span>
63
63
  <div class="bd-chipset" id="bdChipset"></div>
64
64
  </div>
65
65
 
66
66
  <section class="bd-sec" id="cIni" role="tabpanel" aria-labelledby="tabIni">
67
67
  <h2 class="vh" id="hIni">Initiatives</h2>
68
68
  <div id="bdShowing" hidden></div>
69
- <div id="ibBody"></div>
70
- </section>
71
- <section class="bd-sec" id="cDocs" role="tabpanel" aria-labelledby="tabDocs" hidden>
72
- <h2 class="vh" id="hDocs">Documents</h2>
73
- <div id="docBody"></div>
69
+ <!-- the skeleton until the board is read (page-open-speed, SpD): grey rows shaped like programs and initiatives, no names, no counts -->
70
+ <div id="ibBody"><div class="ls-bd" aria-hidden="true"><div class="ls-bh"><i class="ls-sk ls-w40"></i><span class="ls-stg"><i class="ls-sk"></i><i class="ls-sk"></i><i class="ls-sk"></i><i class="ls-sk"></i><i class="ls-sk"></i><i class="ls-sk"></i><i class="ls-sk"></i><i class="ls-sk"></i></span><i class="ls-sk ls-w30"></i></div><div class="ls-br pg"><i class="ls-sk ls-w60"></i><span></span><i class="ls-sk ls-w55"></i></div><div class="ls-br"><i class="ls-sk ls-w75"></i><div class="ls-trk"><i class="ls-tdot"></i><i class="ls-tdot on"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i></div><i class="ls-sk ls-w70"></i></div><div class="ls-br"><i class="ls-sk ls-w75"></i><div class="ls-trk"><i class="ls-tdot"></i><i class="ls-tdot on"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i></div><i class="ls-sk ls-w70"></i></div><div class="ls-br pg"><i class="ls-sk ls-w60"></i><span></span><i class="ls-sk ls-w55"></i></div><div class="ls-br"><i class="ls-sk ls-w75"></i><div class="ls-trk"><i class="ls-tdot"></i><i class="ls-tdot on"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i></div><i class="ls-sk ls-w70"></i></div><div class="ls-br pg"><i class="ls-sk ls-w60"></i><span></span><i class="ls-sk ls-w55"></i></div><div class="ls-br"><i class="ls-sk ls-w75"></i><div class="ls-trk"><i class="ls-tdot"></i><i class="ls-tdot on"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i><i class="ls-tdot"></i></div><i class="ls-sk ls-w70"></i></div></div></div>
74
71
  </section>
75
72
  <section class="bd-sec" id="cShared" role="tabpanel" aria-labelledby="tabShared" hidden></section>
76
73
 
@@ -24,6 +24,7 @@
24
24
  <link rel="stylesheet" href="/pm/home-impact.css" />
25
25
  <link rel="stylesheet" href="/pm/cozy-panel.css" />
26
26
  <link rel="stylesheet" href="/pm/cozy-onboard.css" />
27
+ <link rel="stylesheet" href="/pm/load-shapes.css" />
27
28
  </head>
28
29
  <body class="cz cz-home">
29
30
  <header class="hm-top" id="hmTop">