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
@@ -15,6 +15,7 @@
15
15
  const path = require('path');
16
16
  const { spawn } = require('child_process');
17
17
  const { findBrowser } = require('./lib/find-browser');
18
+ const { openMission } = require('./lib/open-mission');
18
19
  const T = require('./test-run').configure({ name: 'program-panel' });
19
20
  const BP = require('./lib/board-premium.js');
20
21
  const F = require('./fixtures/board-premium-facts.js');
@@ -46,7 +47,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
46
47
  browser = await chromium.launch({ executablePath: exe });
47
48
 
48
49
  const open = async ({ w = 1440, h = 900, qs = '?ws=idpf&program=project-ascent', ctx = null, programs = F.programs() } = {}) => {
49
- const c = ctx || await browser.newContext({ viewport: { width: w, height: h } });
50
+ const c = ctx || await openMission(await browser.newContext({ viewport: { width: w, height: h } }));
50
51
  const page = await c.newPage();
51
52
  const errors = [], posts = [];
52
53
  page.on('pageerror', (e) => errors.push(String((e && e.stack) || e)));
@@ -115,9 +115,22 @@ body.bd-page main.bd-main { position: relative; max-width: none; margin: 16px; p
115
115
  .bd-chip em { font-style: normal; font-weight: 600; color: var(--cz-ink3); margin-left: 2px; }
116
116
  .bd-chip:disabled { opacity: .6; }
117
117
  .bd-sp { margin-left: auto; }
118
- .bd-spans button { height: 28px; padding: 0 11px; border-radius: 999px; border: 0; background: none; font: 600 var(--fs-2) var(--cz-sans); color: var(--cz-ink2); cursor: pointer; }
119
- .bd-spans button[aria-pressed="true"] { background: var(--cz-card); color: var(--cz-ink); font-weight: 800; box-shadow: 0 1px 3px rgba(42,46,53,.12); }
120
- .bd-spans[hidden] { display: none; }
118
+ /* the calmer top bar (batch 24 item 7, canvas BbB): one pill for what needs a look, the legend behind (i), Jira as Adler's face */
119
+ .bd-chip.bd-need { background: var(--cz-well); border-color: transparent; gap: 10px; padding: 0 10px 0 12px; font-weight: 800; color: var(--cz-ink); }
120
+ .bd-chip.bd-need > span { display: inline-flex; align-items: center; gap: 5px; }
121
+ .bd-chip.bd-need.on { background: var(--cz-bluebg); color: var(--cz-blueink); }
122
+ .bd-needm { min-width: 220px; }
123
+ .bd-needm .bd-mlbl, .bd-lgdm .bd-mlbl { font: 800 var(--fs-1) var(--cz-sans); color: var(--cz-ink3); padding: 6px 10px 4px; letter-spacing: .5px; text-transform: uppercase; white-space: nowrap; }
124
+ .bd-needm .bd-nr { display: flex; align-items: center; gap: 10px; }
125
+ .bd-needm .bd-nr .k { margin-left: auto; color: var(--cz-ink3); font-weight: 700; }
126
+ .bd-needm .bd-nr.on { background: var(--cz-bluebg); color: var(--cz-blueink); }
127
+ .bd-needm .cz-dot { width: 7px; height: 7px; }
128
+ .bd-lgdm { min-width: 220px; padding: 6px 12px 10px; }
129
+ .bd-lgdm .tg-lgd { position: static; flex-direction: column; gap: 9px; padding: 4px 0 0; background: none; font-weight: 600; color: var(--cz-ink2); white-space: nowrap; }
130
+ .bd-chip.bd-jf { width: 36px; padding: 0; justify-content: center; border-color: transparent; background: transparent; position: relative; }
131
+ .bd-chip.bd-jf:hover { background: var(--cz-well); }
132
+ .bd-chip.bd-jf svg.ad-svg { width: 24px; height: 24px; }
133
+ .bd-pip { position: absolute; right: 3px; top: 3px; width: 9px; height: 9px; border-radius: 50%; background: var(--cz-risk); border: 2px solid var(--cz-card); }
121
134
 
122
135
  /* ── the "Waiting for design" card, where the shell has none yet; messages ── */
123
136
  .bd-wait { border: 1px dashed var(--cz-hair); border-radius: 18px; padding: 22px; color: var(--cz-ink3); font-size: var(--fs-2); }
@@ -1195,7 +1208,6 @@ html[data-theme="dark"] body.bd-page { --bd-prog: rgb(180,170,240); --bd-progbg:
1195
1208
  .sc .cur .eta { top: 19px; }
1196
1209
  .sc .eta.late { color: var(--cz-late); }
1197
1210
  .bd-kb.lanes .bd-kstrip span { margin-top: 58px; }
1198
- .bd-chipset .tg-lgd { position: static; padding: 0; background: none; font-weight: 600; white-space: nowrap; }
1199
1211
  /* the phone (BdPhone): a program is a head card with its next milestone; its initiatives and the product heads follow as cards */
1200
1212
  @media (max-width: 720px) {
1201
1213
  .bt-pg { display: grid; grid-template-columns: minmax(0, 1fr); border: 0; border-radius: 14px; padding: 8px 6px 10px 4px; min-height: 0; }
@@ -1218,7 +1230,7 @@ html[data-theme="dark"] body.bd-page { --bd-prog: rgb(180,170,240); --bd-progbg:
1218
1230
  .bd-spw .bd-spk svg { width: 14px; height: 14px; }
1219
1231
  body.bd-spick .bd-hd .bd-title { display: none; }
1220
1232
  body.bd-spick .bd-hd .bd-tools { margin-left: auto; }
1221
- @media (max-width: 720px) { .bd-spw .bd-spk, .bd-spw .bd-grpm button { min-height: 44px; } }
1233
+ @media (max-width: 720px) { .bd-spw .bd-spk, .bd-spw .bd-grpm button, .bd-needm .bd-nr { min-height: 44px; } .bd-chip.bd-jf { width: 44px; } .bd-chip.bd-jf::before { display: none; } }
1222
1234
  /* programs first on every span (PgTimeline's rule): the program row brackets its initiatives' bars and carries its milestones */
1223
1235
  .sp-pg { background: var(--bd-progbg); min-height: 56px; }
1224
1236
  .sp-pg .sp-n { flex-direction: row; align-items: center; justify-content: flex-start; }
@@ -232,6 +232,7 @@ body.cz-home { min-height: 100vh; overflow: hidden; }
232
232
  .hm-u { display: flex; flex-direction: column; align-items: flex-end; gap: 4px; }
233
233
  .hm-bub { max-width: 80%; padding: 10px 16px; border-radius: 18px 18px 8px 18px; background: var(--cz-bubble); font-size: var(--fs-3); line-height: 1.5; color: var(--cz-ink); }
234
234
  .hm-you { font-size: var(--fs-1); color: var(--cz-ink3); }
235
+ .hm-pend.sending .hm-bub { opacity: .72; } /* batch 24 item 2: the bubble shows at once, a touch lighter until PMOS takes it */
235
236
  .hm-worked { padding-left: 38px; }
236
237
  .hm-wk { display: flex; align-items: center; gap: 8px; max-width: 100%; min-height: 32px; padding: 0 4px; border: 0; border-radius: 8px; background: none; color: var(--cz-ink3); font-size: var(--fs-2); cursor: pointer; text-align: left; } /* a plain grey line (AgChatAsk) */
237
238
  .hm-wk:hover { color: var(--cz-ink2); }
@@ -48,7 +48,8 @@ import * as AD from './cozy-adler.js'; // Adler, Claude's face (batch 16 item
48
48
  import * as OKL from './okr-list.js'; // the product page's Goals line (batch 16 item 6, GsProduct)
49
49
  import { canDictate, dictate } from './home-dictate.js';
50
50
  import { mountIni, iniList, iniOverview, iniPage, iniAddTask } from './home-ini.js'; // the initiative's page, its stage move, Tasks and Core documents (premium-uncovered-features, HbC)
51
- import * as MEN from './home-mentions.js'; // @Mentions, the permanent row at the top of Home and its Viewer (Viewer kit, XvANew)
51
+ import * as MEN from './home-mentions.js';
52
+ import * as LS from './load-shapes.js'; // the last view, the grey shapes and the held Viewer column while Home reads (page-open-speed) // @Mentions, the permanent row at the top of Home and its Viewer (Viewer kit, XvANew)
52
53
  import { mountRecRow, clockOf as recClock } from './home-rec-row.js';
53
54
  import { mountSesName } from './home-ses-name.js'; // session names A: tap the session's name to rename it (canvas XmsNames)
54
55
  import * as SM from './split-merge.js'; // split one initiative in two, or merge two: Claude suggests, the PM approves (batch 11 item 14)
@@ -173,13 +174,14 @@ async function getRetry(path, tries = 20) {
173
174
  }
174
175
  return { ok: false, error: 'still reading' };
175
176
  }
177
+ const LAST_KEY = (WS || '') + '|' + (BOARD || ''); // the last view Home drew, by its product and board (SpA)
176
178
  let pickGen = 0; // bumped by every saved pick, so a read that started before the save does not put the old pick back
177
179
  async function load() {
178
180
  const gen = pickGen;
179
181
  const [brief, board, ses, desk, today, sch, work, day, rev, progs, , pick] = await Promise.all([
180
182
  getRetry('/api/briefing-premium' + wsQ()), getRetry('/api/board-premium' + wsQ() + boardQ(WS ? '&' : '?')), pmGet('/api/sessions'), pmGet('/api/home-desk' + wsQ()),
181
183
  pmGet('/api/brief-page' + wsQ()), pmGet('/api/schedules' + wsQ()), pmGet('/api/my-work-premium' + wsQ()), pmGet('/api/phone-day'), pmGet('/api/home-review' + wsQ()),
182
- pmGet('/api/programs'), MEN.load(menCtx()), PP.readPick(), SO.load(),
184
+ pmGet('/api/programs'), MEN.load(menCtx()).then((x) => { S.menIn = true; if (!S.ready) drawList(); return x; }), PP.readPick(), SO.load(),
183
185
  ]);
184
186
  const D = S.D;
185
187
  D.brief = brief && brief.ok && brief.model ? brief.model : null;
@@ -345,7 +347,8 @@ function homeListHtml() {
345
347
  S.lo = { sel, ses: S.ses, restOpen: (k) => S.rest.has(k), pin: S.pin, phone: false }; // the folded rail's card draws Home's rows with these
346
348
  return TD.listHtml(M, todayModel(), {
347
349
  sel, selHead: S.head, ses: S.ses, pOpen, here: isPhone() ? null : TD.groupOfOpen(M, sel) || S.head, restOpen: (k) => S.rest.has(k), cap: S.cap, pin: S.pin, avHtml, ready: S.ready, err: S.D.err.length ? S.D.err[0] : '',
348
- pick: S.D.pick, tones: pickTones(), review: R, mic: canDictate(), phone: isPhone(), day: S.day, news: S.news && S.news.ok ? S.news.count : 0, top: S.ready ? RECROW.html() + MEN.rowHtml(S.D, { sel: S.sel }) + (S.impact ? IMP.rowHtml(S.impact.c, { esc, sel: S.sel === 'impact' }) : '') : '',
350
+ pick: S.D.pick, tones: pickTones(), review: R, mic: canDictate(), phone: isPhone(), day: S.day, news: S.news && S.news.ok ? S.news.count : 0, top: S.ready ? RECROW.html() + MEN.rowHtml(S.D, { sel: S.sel }) + (S.impact ? IMP.rowHtml(S.impact.c, { esc, sel: S.sel === 'impact' }) : '') : S.menIn ? '<div class="ls-stale" inert>' + LS.inert(MEN.rowHtml(S.D, {})) + '</div>' : LS.menShape(), // Mentions on its own clock, seen but not pressed until Home is fresh (SpG)
351
+ last: S.ready ? '' : LS.last(LAST_KEY), keep: S.ready && S.filter === 'today' && !S.ini ? (b) => LS.keepLast(LAST_KEY, b) : null,
349
352
  imFace: IM.faceHtml(S.im, { joy: S.imJoy }), grill: S.ready ? grillNews() : null,
350
353
  });
351
354
  }
@@ -843,7 +846,9 @@ function sessionTarget(s) {
843
846
  // a program's or a product's own chat says so in its scope, never "Loose" (batch 22 item 17)
844
847
  const pm = /^chat:program:([^:]+)/.exec(key || ''), pr = pm ? (S.D.programs || []).find((p) => p && p.id === pm[1]) : null;
845
848
  const prod = !ini && !pr && /^product(:|$)/.test(key || '') ? H.productName(S.D) : '';
846
- const home = pr ? { text: pr.title, icon: TD.P.layers } : prod ? { text: prod, icon: I.home } : null;
849
+ // a New workflow chat (Settings › Workflows) says so, never "Loose" (batch 24 item 4); its draft stays on the Workflows list
850
+ const wfNew = !ini && !pr && !prod && /^pop:workflow:/.test(key || '');
851
+ const home = pr ? { text: pr.title, icon: TD.P.layers } : prod ? { text: prod, icon: I.home } : wfNew ? { text: 'New workflow', icon: I.chat } : null;
847
852
  const t = { title: H.nameOf(s), ses: s.id, sub: [role.name, ini ? iniTitle(ini) : home ? home.text : 'Loose'].join(' · '), ini, role, scope: ini ? scopeOf(ini) : home || { text: 'Loose', icon: I.chat, loose: true }, rich: rich(), to: rich() ? role : null, also: alsoOf(ini) };
848
853
  if (key && /^(decision|blocked|waited|src|ev|work|board|init|doc|ins|chat|pop):/.test(key)) { t.key = key; t.sid = undefined; } else t.sid = s.id;
849
854
  if (!t.key) t.sid = s.id;
@@ -1310,7 +1315,10 @@ addEventListener('pp:changed', (e) => { if (WS) { location.assign('/premium.html
1310
1315
 
1311
1316
  /* ── the Viewer: needs rows, the weekly update, the overview, files, people, routines ── */
1312
1317
  let lastViewer = null;
1313
- VW.onChange((d) => { if (d.open) lastViewer = d.id; chat.refresh(); document.body.classList.toggle('hm-vw-open', d.open); });
1318
+ VW.onChange((d) => { if (d.open) { lastViewer = d.id; LS.holdViewer($('.hm-vw'), false); } chat.refresh(); document.body.classList.toggle('hm-vw-open', d.open); });
1319
+ // the Viewer's column held from the first frame when Home opened it last time (SpJ): the chat has its final width at once
1320
+ const VW_HELD = 'pmos.home.vwheld';
1321
+ if (!isPhone() && store.get(VW_HELD) === '1' && !VW.isOpen()) LS.holdViewer($('.hm-vw'), true);
1314
1322
  function reopenViewer() {
1315
1323
  if (/^vw:/.test(lastViewer || '')) { import('./premium-viewer.js').then((M) => { if (!M.vwReopen()) { lastViewer = null; reopenViewer(); } }); return; } // the last file, chart or report shown
1316
1324
  const r = S.sel ? rowById(S.sel) : null;
@@ -1989,6 +1997,8 @@ load().then(() => {
1989
1997
  if (say) setTimeout(() => { const ta = $('#hmIn'); if (ta) { ta.value = say; ta.dispatchEvent(new Event('input', { bubbles: true })); ta.focus(); ta.setSelectionRange(say.length, say.length); } }, 0);
1990
1998
  if (mt) openMeeting({ note: mt });
1991
1999
  if (/^(prop|men|pulse|scan|rep|src)$/.test(P.get('around') || '')) MORE.openAround(P.get('around')); // ?around=<row>: that Around you view (home-around.js)
2000
+ // the Viewer opens now or not at all: the held column goes either way, and the next open holds it only if it opened (SpJ)
2001
+ setTimeout(() => { if (!isPhone()) store.set(VW_HELD, VW.isOpen() ? '1' : null); LS.holdViewer($('.hm-vw'), false); }, 0);
1992
2002
  poll();
1993
2003
  });
1994
2004
  addEventListener('resize', () => { if (!isPhone() && S.sheet) sheet(false); drawList(); });
@@ -58,7 +58,7 @@ export const adlerAvHtml = (mood) => `<span class="hm-av hm-adler" aria-hidden="
58
58
 
59
59
  export function mountChat(el, host) {
60
60
  const esc = host.esc;
61
- const C = { t: null, th: null, conv: null, timer: null, show: 12, plOpen: false, plAll: false, open: new Set(), seq: 0, sending: false, files: [], seenAt: null, lastKey: null, picked: {}, auto: { key: null, ctx: new Set(), rep: null, goal: null }, slashAt: 0 };
61
+ const C = { t: null, th: null, conv: null, timer: null, show: 12, plOpen: false, plAll: false, open: new Set(), seq: 0, sending: false, files: [], seenAt: null, lastKey: null, picked: {}, pend: [], auto: { key: null, ctx: new Set(), rep: null, goal: null }, slashAt: 0 };
62
62
  el.innerHTML = `<button type="button" class="hm-reopen" id="hmReopen" aria-label="Open the Viewer" title="Open the Viewer" hidden>${PANE}</button>`
63
63
  + '<div class="hm-chd" id="hmCh" hidden></div><div class="hm-sug" id="hmSug" hidden></div><div class="hm-plw" id="hmPl" hidden></div><div class="hm-log" id="hmLog" role="log" aria-live="polite"></div><div class="hm-foot" id="hmFoot"></div>';
64
64
  const log = el.querySelector('#hmLog'), foot = el.querySelector('#hmFoot'), reopen = el.querySelector('#hmReopen'), head = el.querySelector('#hmCh'), sug = el.querySelector('#hmSug'), pl = el.querySelector('#hmPl');
@@ -175,6 +175,32 @@ export function mountChat(el, host) {
175
175
  return q.filter((x) => !read(x)).map((x) => `<div class="hm-q"><div class="hm-qb">${userHtml(x.text)}</div><span class="hm-qn">${esc(role().name)} reads it after this tool call, in seconds<button type="button" class="hm-qx" data-hm-qdel="${esc(x.at)}" aria-label="Take it back" title="Take it back">${SVG(XX, 13)}</button></span></div>`).join('');
176
176
  }
177
177
  const sid = () => (C.t && C.t.sid) || (C.th && C.th.session) || null;
178
+ /* A sent message shows at once (batch 24 item 2: "enter the prompt is so long to appear on the chat"). Its bubble draws the moment the PM
179
+ * presses Enter, says "Sending…" until PMOS takes it, then "You · HH:MM". It leaves when the transcript holds the PM's turn (the same words,
180
+ * or any PM turn from that moment on), and after 3 minutes at most, so a bubble never stays as a ghost. A message that was not sent leaves at
181
+ * once and its words go back in the box. */
182
+ const PEND_MAX = 180000;
183
+ function settlePend() {
184
+ if (!C.pend.length) return;
185
+ const key = (t) => splitFiles(shownUser(t)).text.replace(/\s+/g, ' ').trim();
186
+ const users = ((C.conv && C.conv.messages) || []).filter((m) => m.role === 'user');
187
+ const lastUser = users.reduce((n, m) => Math.max(n, Date.parse(m.at) || 0), 0), now = Date.now();
188
+ // a message for a working session is queued by the host and drawn as "reads it after this tool call": the bubble gives way to it
189
+ const queued = new Set((host.queued ? host.queued(sid()) : []).map((x) => x.text));
190
+ C.pend = C.pend.filter((p) => {
191
+ if (!p.sent) return true;
192
+ if (queued.has(p.text)) return false;
193
+ const t0 = Date.parse(p.at);
194
+ if (now - t0 > PEND_MAX) return false;
195
+ if (lastUser && lastUser >= t0 - 2000) return false;
196
+ return !users.slice(p.n).some((m) => key(m.text) === key(p.text));
197
+ });
198
+ }
199
+ function pendHtml() {
200
+ settlePend();
201
+ return C.pend.map((p) => `<div class="hm-u hm-pend${p.sent ? '' : ' sending'}"><div class="hm-bub">${filesHtml(p.files)}${p.text ? userHtml(p.text) : ''}</div>`
202
+ + `<span class="hm-you" role="status">${p.sent ? 'You · ' + esc(hm(p.at)) : 'Sending…'}</span></div>`).join('');
203
+ }
178
204
  // A loose chat that reads like one initiative gets one quiet chip at its top: "Looks like <initiative> · Link it ×" (XvALinkSuggest).
179
205
  function drawSug() {
180
206
  const t = C.t, g = t && t.scope && t.scope.loose && t.sid && host.suggestLink ? host.suggestLink(t, firstUser()) : null;
@@ -205,7 +231,7 @@ export function mountChat(el, host) {
205
231
  const goal = C.th && C.th.goal;
206
232
  const parts = items.map((it, i) => msgHtml(it, i, R));
207
233
  placeDivider(parts, items, goal);
208
- let html = (t.intro && !(t.intro.onlyEmpty && items.length) ? introHtml(t.intro, !items.length) : '') + parts.join('') + askHtml(R) + (R.stop && R.lastAgent < 0 ? dividerHtml(R.stop) : '') + queuedHtml();
234
+ let html = (t.intro && !(t.intro.onlyEmpty && items.length) ? introHtml(t.intro, !items.length) : '') + parts.join('') + askHtml(R) + (R.stop && R.lastAgent < 0 ? dividerHtml(R.stop) : '') + pendHtml() + queuedHtml();
209
235
  html += GU.choiceHtml(); // /goal while a goal works here: In a new chat, or Here instead
210
236
  if (C.th && C.th.working) html += `<p class="hm-note" role="status">The agent is answering. You can write again when it has.</p>`;
211
237
  if (C.conv && C.conv.state === 'ended') html += '<p class="hm-note">This session has ended. Your conversation is kept here, and your next message picks it up.</p>';
@@ -418,20 +444,31 @@ export function mountChat(el, host) {
418
444
  async function send(text) {
419
445
  const t = C.t; text = String(text || '').trim();
420
446
  if (!t || (!text && !C.files.length) || C.sending) return false;
447
+ if (t.key && C.files.some((f) => f.state === 'saving')) { host.toast('A file is still being saved. Send again in a moment.'); return false; }
421
448
  C.sending = true;
422
449
  let r;
423
450
  const was = running();
451
+ // the bubble first, then the server (batch 24 item 2): the files leave the box with the words, and come back if the send fails
452
+ const sentFiles = C.files;
453
+ const p = { text, files: sentFiles.map((f) => f.path || f.saved).filter(Boolean), at: new Date().toISOString(), sent: false, n: ((C.conv && C.conv.messages) || []).filter((m) => m.role === 'user').length };
454
+ C.pend.push(p); C.files = []; C.jump = true; draw();
424
455
  if (t.key) {
425
- if (C.files.some((f) => f.state === 'saving')) { C.sending = false; host.toast('A file is still being saved. Send again in a moment.'); return false; }
426
- const files = C.files.map((f) => f.saved);
456
+ const files = sentFiles.map((f) => f.saved);
427
457
  r = await host.pmPost('/api/briefing-premium/ask' + (t.ws ? '?ws=' + encodeURIComponent(t.ws) : ''), files.length ? { key: t.key, text, files, ...(t.context ? { context: t.context } : {}) } : { key: t.key, text, ...(t.context ? { context: t.context } : {}) }).catch(() => null);
428
458
  } else r = await host.pmPost('/api/session/say', { id: t.sid, text }).catch(() => null);
429
459
  C.sending = false;
430
- if (!r || r.ok !== true) { host.toast((r && r.error) || 'The message was not sent.'); return false; }
460
+ if (!r || r.ok !== true) {
461
+ C.pend = C.pend.filter((x) => x !== p);
462
+ if (C.t === t && !C.files.length) C.files = sentFiles;
463
+ if (C.t === t) draw();
464
+ host.toast((r && r.error) || 'The message was not sent.'); return false;
465
+ }
431
466
  const s = sid() || r.session;
432
467
  if (host.touch) host.touch(s);
433
468
  if (was && s && host.queue) host.queue(s, text);
434
- C.files.forEach((f) => { if (f.url) URL.revokeObjectURL(f.url); }); C.files = []; C.jump = true;
469
+ p.sent = true;
470
+ sentFiles.forEach((f) => { if (f.url) URL.revokeObjectURL(f.url); }); C.jump = true;
471
+ if (C.t === t) draw();
435
472
  if (t.onSent) t.onSent(r);
436
473
  read();
437
474
  return true;
@@ -527,7 +564,11 @@ export function mountChat(el, host) {
527
564
  drawFoot({ role: role() });
528
565
  }
529
566
  };
530
- el.addEventListener('submit', (e) => { e.preventDefault(); const ta = foot.querySelector('textarea'); const v = ta.value; if (GU.submit(v)) { ta.value = ''; grow(ta); return; } send(v).then((ok) => { if (ok) { ta.value = ''; grow(ta); } }); });
567
+ el.addEventListener('submit', (e) => { e.preventDefault(); const ta = foot.querySelector('textarea'); const v = ta.value; if (GU.submit(v)) { ta.value = ''; grow(ta); return; }
568
+ // the box empties at once and the bubble shows (batch 24 item 2); a send that fails puts the words back, unless the PM typed again
569
+ if (!v.trim() && !C.files.length) return;
570
+ ta.value = ''; grow(ta);
571
+ send(v).then((ok) => { const box = foot.querySelector('textarea'); if (!ok && box && !box.value) { box.value = v; grow(box); } }); });
531
572
  const shutAdd = (focus) => { if (!C.addOpen) return; C.addOpen = false; drawFoot({ role: role() }); if (focus) { const a = foot.querySelector('#hmAdd'); if (a) a.focus(); } };
532
573
  document.addEventListener('click', (e) => { if (C.addOpen && e.target.isConnected && !e.target.closest('.hm-addw')) shutAdd(false); }, true);
533
574
  el.addEventListener('keydown', (e) => {
@@ -623,7 +664,7 @@ export function mountChat(el, host) {
623
664
  if (notesM) notesM.then((M) => setTimeout(() => M.syncNotes(), 0)); // the Notes open in the Viewer belonged to the conversation before
624
665
  const same = C.t && t && C.t.key === t.key && C.t.sid === t.sid;
625
666
  C.t = t;
626
- if (!same) { C.addOpen = false; C.th = null; C.conv = null; C.show = 12; C.open = new Set(); C.files = []; C.marked = null; C.touched = null; C.picked = {}; C.plOpen = false; C.plAll = false; C.seenAt = t.key ? host.seen(t.key) : null; put(log, introHtml(t.intro)); put(pl, ''); pl.hidden = true; C.jump = true; peek(null); }
667
+ if (!same) { C.addOpen = false; C.th = null; C.conv = null; C.show = 12; C.open = new Set(); C.files = []; C.marked = null; C.touched = null; C.picked = {}; C.pend = []; C.plOpen = false; C.plAll = false; C.seenAt = t.key ? host.seen(t.key) : null; put(log, introHtml(t.intro)); put(pl, ''); pl.hidden = true; C.jump = true; peek(null); }
627
668
  draw(); read();
628
669
  },
629
670
  refresh() { if (C.t) draw(); },
@@ -18,7 +18,8 @@ import { gripHtml, sugHtml } from './prog-order.js'; // a program's order: the
18
18
  import * as SO from './sort-order.js'; // the sort button and ONE order with the Board (batch 14 item 8, canvas SoAHomePhone)
19
19
  import { prodKey } from './order-model.js';
20
20
  import * as CW from './ctx-wedge.js'; // how full a session's context is: the pie, and Claude's ✦ Compact (batch 13 item 8, XctxA1Desk)
21
- import * as AD from './cozy-adler.js'; // Adler greets on Home (batch 16 item 2, canvas AdH1): the M face, honest when the read failed
21
+ import * as AD from './cozy-adler.js';
22
+ import * as LS from './load-shapes.js'; // the ring, the grey shapes and the last view while Home reads (page-open-speed, SpA SpG SpM) // Adler greets on Home (batch 16 item 2, canvas AdH1): the M face, honest when the read failed
22
23
 
23
24
  export const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
24
25
  export const P = {
@@ -321,8 +322,11 @@ export function listHtml(M, T, o) {
321
322
  // the blue dot after "Good morning": something is new for you, in Your day's New for you fold (feedback batch 16 item 5b, FmA4Desk)
322
323
  const nw = o.news > 0 ? '. ' + o.news + ' new for you' : '';
323
324
  // Your impact (batch 16 item 7, GmA1Home): with it on, the greeting's face is its own press, Adler with the week's leaves (pm/impact.js faceHtml)
324
- const face = o.imFace && !o.err ? '' : AD.adler(o.err ? 'honest' : 'handled', 36, { label: false, cls: 'hv-ad' });
325
- const head = `<div class="hm-hd hv-bar">${o.imFace && !o.err ? o.imFace : ''}<button type="button" class="hv-hi" data-hm-day aria-expanded="${!!o.day}" aria-label="${esc(M.word || 'Today')}${nw}. Open your day">${face}<b>${esc(M.word || 'Today')}</b>${o.news > 0 ? '<i class="hv-nd" aria-hidden="true"></i>' : ''}${svg(P.chevD, 18)}</button><span class="hv-acts">`
325
+ // Adler beside the greeting from the first frame (SpM): thinking while Home reads, handled when it is in, honest if the read failed.
326
+ // While Home reads, a hollow ring follows the greeting (SpA); a fresh page shows no sign of having been checked
327
+ const own = o.imFace && !o.err && o.ready;
328
+ const face = own ? '' : AD.adler(o.err ? 'honest' : o.ready ? 'handled' : 'thinking', 36, { label: false, cls: 'hv-ad' });
329
+ const head = `<div class="hm-hd hv-bar">${own ? o.imFace : ''}<button type="button" class="hv-hi" ${o.ready ? 'data-hm-day' : 'aria-disabled="true"'} aria-expanded="${!!o.day}" aria-label="${esc(M.word || 'Today')}${nw}. Open your day">${face}<b>${esc(M.word || 'Today')}</b>${o.ready ? '' : LS.ringHtml()}${o.news > 0 ? '<i class="hv-nd" aria-hidden="true"></i>' : ''}${svg(P.chevD, 18)}</button><span class="hv-acts">`
326
330
  // the product chip, always (it is also the way to add one): the ticked products' tiles, or All (canvas XhpA2States)
327
331
  + (o.pick ? chipHtml(o.pick, { id: 'hmPick', attrs: 'data-hm-pick', label: 'Products on Home', tones: o.tones }) : '')
328
332
  + (o.phone ? '' : `<button type="button" class="ut-ib hv-ib${flt ? ' on' : ''}" data-hm-flt aria-haspopup="menu" aria-label="Show: Today, Kept or Done${flt ? '. Now ' + esc(flt.label) : ''}" title="Show: Today, Kept or Done">${svg(P.filter, 18)}</button>`)
@@ -336,7 +340,9 @@ export function listHtml(M, T, o) {
336
340
  // the Today card only while the day has a moment; the plain day (its meetings and load) is in Your day
337
341
  const card = MOMENT.has(T.mode) ? todayHtml(T, { greet: M.greet, sub: '', phone: o.phone }) : '';
338
342
  // start-up and errors (canvas AdU9, AdU6): Adler wears his glasses while Home reads, and says a failure plainly; the greeting's face hides
339
- if (!o.ready) return head + card + `<div class="hm-none hm-ad" role="status">${AD.adler('thinking', 'l', { label: false })}<p>Reading your work.</p></div>`;
343
+ // start-up (SpA, SpG): the list Home drew last, drawn at once and inert until the fresh read is in; with none kept, grey rows that claim
344
+ // nothing. o.top is Mentions (or its grey shape) on its own clock. Errors (canvas AdU6): Adler says a failure plainly
345
+ if (!o.ready) return head + card + `<p class="vh" role="status">Reading your work.</p><div class="hv-body">${o.top || ''}${o.last ? `<div class="ls-stale" inert>${o.last}</div>` : LS.listShapes()}</div>`;
340
346
  const errLine = `<div class="hm-none hm-ad" role="status">${AD.adler('honest', 48, { label: false })}<p>Home could not read the repository: ${esc(o.err)}.</p></div>`;
341
347
  if (o.err && !M.groups.some((g) => (g.kind === 'product' && g.count) || (g.rows && g.rows.length))) return head + card + errLine;
342
348
  // the repository's read failed but sessions or notes still answered: say so above what did come back, never an empty product as if calm
@@ -353,6 +359,7 @@ export function listHtml(M, T, o) {
353
359
  if (o.cap) body += `<form class="hm-cap" id="hmCapF"><label class="vh" for="hmCap">Add a task to your own list</label><input id="hmCap" autocomplete="off" maxlength="250" placeholder="A task for your own list. Esc to cancel">`
354
360
  + (o.mic ? `<button type="button" class="ut-ib" data-hm-capmic aria-label="Say it instead" title="Say it instead">${svg(P.mic, 16)}</button>` : '') + '</form>';
355
361
  }
362
+ if (o.keep && !o.err && !M.list && !o.cap) o.keep(body); // the last view, for the next open (SpA)
356
363
  return head + card + `<div class="hv-body">${o.top || ''}${body}</div>` + (o.phone && o.ses && !M.list ? sesSheetHtml(M, o) : ''); // o.top: the pinned recording row and Mentions (home-rec-row.js, home-mentions.js)
357
364
  }
358
365
  /** Your day (HvAOpen): a sheet over the list from "Good morning". The date, the day's meetings and their load (a press opens the calendar,
@@ -75,11 +75,11 @@ const SHOWN = new Map(); // id → the view last drawn, for the Kanban's stage
75
75
  const MCS = new Map(); // id → the PM's own changes to { mc, gx, ab } (true = open), kept in the browser
76
76
  const MCK = 'pmos.iv.mc.';
77
77
  const store = () => { try { return typeof localStorage !== 'undefined' ? localStorage : null; } catch { return null; } };
78
- /** The three folds of one initiative: the PM's own, else the defaults (the way to finish is open on a wide screen, About is folded; on a phone both are folded; About opens by itself while a success measure waits on a ✓). */
78
+ /** The three folds of one initiative: the PM's own, else the defaults (the telemetry card starts hidden in its banner; the way to finish is open on a wide screen, About is folded; on a phone both are folded; About opens by itself while a success measure waits on a ✓). */
79
79
  export function mcState(id, o = {}) {
80
80
  let mine = MCS.get(id);
81
81
  if (!mine) { mine = {}; try { const s = store(), j = s && JSON.parse(s.getItem(MCK + id) || 'null'); if (j && typeof j === 'object') for (const k of ['mc', 'gx', 'ab']) if (typeof j[k] === 'boolean') mine[k] = j[k]; } catch { mine = {}; } MCS.set(id, mine); }
82
- const dflt = { mc: true, gx: !phone(), ab: !!o.proposal };
82
+ const dflt = { mc: false, gx: !phone(), ab: !!o.proposal }; // the telemetry starts hidden in its banner (Wawan, batch 24 item 1)
83
83
  return { mc: 'mc' in mine ? mine.mc : dflt.mc, gx: 'gx' in mine ? mine.gx : dflt.gx, ab: 'ab' in mine ? mine.ab : dflt.ab };
84
84
  }
85
85
  export function mcToggle(id, key, o) {
@@ -0,0 +1,57 @@
1
+ /* Loading shapes (initiative page-open-speed; canvas boards SpA, SpD, SpG, SpJ, SpM, agreed A 2026-11-02).
2
+ * Honest state: grey shapes say only "something goes here" (no names, no counts); a hollow ring says Home is checking; nothing moves;
3
+ * no bar and no percent. Every shape is static (principle 12: no motion on data). */
4
+
5
+ /* the hollow ring beside "Good morning" while Home checks (SpA) */
6
+ .ls-ringdot { display: inline-block; flex: none; width: 9px; height: 9px; margin-left: 6px; border-radius: 50%; border: 1.6px solid var(--cz-blue); box-sizing: border-box; }
7
+
8
+ /* grey shapes */
9
+ .ls-sk { display: block; height: 9px; border-radius: 6px; background: var(--cz-well2); }
10
+ .ls-dot { flex: none; width: 12px; height: 12px; border-radius: 50%; background: var(--cz-well2); }
11
+ .ls-box { flex: none; width: 12px; height: 12px; border-radius: 6px; background: var(--cz-well2); }
12
+ .ls-row { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 12px; box-sizing: border-box; }
13
+ .ls-row + .ls-row { border-top: 1px solid var(--cz-hair); }
14
+ .ls-head { display: flex; align-items: center; min-height: 32px; padding: 0 12px; }
15
+ .ls-group + .ls-group { margin-top: 14px; }
16
+ .ls-men { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 0 12px; margin-bottom: 12px; border-radius: 8px; background: var(--cz-card); box-sizing: border-box; }
17
+
18
+ /* the last view, drawn at once while the fresh read runs (SpA): it can be read, not acted on; a press that acts is dashed */
19
+ .ls-stale { pointer-events: none; }
20
+ .ls-stale .hm-cta, .ls-stale .hv-cta, .ls-stale .solid, .ls-stale [data-ls-was].solid { background: transparent !important; color: var(--cz-ink3) !important; border: 1px dashed var(--cz-ink3) !important; box-shadow: none !important; }
21
+ .ls-stale .hr-bd { visibility: hidden; }
22
+
23
+ /* the Viewer's column held from frame one (SpJ): a dashed empty panel, the same width the Viewer opens at, no text, no icon */
24
+ .ls-vw { flex: none; border: 1.5px dashed var(--cz-hair); border-radius: 8px; box-sizing: border-box; width: min(500px, max(380px, calc(100vw - 808px))); }
25
+ @media (max-width: 760px) { .ls-vw { display: none; } }
26
+
27
+ /* the Board's skeleton (SpD): the real toolbar and tabs are the page's own; the body is grey rows shaped like programs and initiatives */
28
+ .ls-bd { display: flex; flex-direction: column; }
29
+ .ls-bd .ls-bh { display: grid; grid-template-columns: minmax(160px, 260px) 1fr minmax(120px, 220px); gap: 16px; align-items: center; min-height: 36px; padding: 0 14px; border-radius: 8px; background: var(--cz-well); }
30
+ .ls-bd .ls-stg { display: flex; justify-content: space-between; gap: 6px; }
31
+ .ls-bd .ls-stg .ls-sk { width: 28px; height: 7px; }
32
+ .ls-bd .ls-br { display: grid; grid-template-columns: minmax(160px, 260px) 1fr minmax(120px, 220px); gap: 16px; align-items: center; min-height: 46px; padding: 0 14px; }
33
+ .ls-bd .ls-br.pg { min-height: 40px; margin-top: 8px; border-radius: 8px; background: var(--cz-well); }
34
+ .ls-bd .ls-trk { position: relative; display: flex; align-items: center; justify-content: space-between; height: 12px; }
35
+ .ls-bd .ls-trk::before { content: ""; position: absolute; left: 4px; right: 4px; top: 5px; height: 2px; background: var(--cz-well2); }
36
+ .ls-bd .ls-trk i { position: relative; width: 7px; height: 7px; background: var(--cz-well2); }
37
+ .ls-bd .ls-trk i.ls-tdot { border-radius: 50%; }
38
+ .ls-bd .ls-trk i.on { width: 12px; height: 12px; }
39
+ .ls-slow { margin: 10px 14px 4px; font-size: var(--fs-2); font-weight: 700; color: var(--cz-ink3); }
40
+ @media (max-width: 760px) {
41
+ .ls-bd .ls-bh, .ls-bd .ls-br { grid-template-columns: 1fr; gap: 6px; }
42
+ .ls-bd .ls-bh .ls-stg, .ls-bd .ls-br .ls-trk, .ls-bd .ls-br > .ls-sk:last-child { display: none; }
43
+ }
44
+ .ls-stale > .hm-pg + .hm-pg { margin-top: 12px; }
45
+ .ls-stale > .hm-pg.loose { margin-top: 0; }
46
+ .ls-w30 { width: 30%; }
47
+ .ls-w34 { width: 34%; }
48
+ .ls-w38 { width: 38%; }
49
+ .ls-w40 { width: 40%; }
50
+ .ls-w55 { width: 55%; }
51
+ .ls-w60 { width: 60%; }
52
+ .ls-w70 { width: 70%; }
53
+ .ls-w75 { width: 75%; }
54
+ .ls-w48 { width: 48%; }
55
+ .ls-w54 { width: 54%; }
56
+ .ls-w62 { width: 62%; }
57
+ .ls-w78 { width: 78%; }
@@ -0,0 +1,57 @@
1
+ /* Loading shapes (initiative page-open-speed; canvas SpA, SpD, SpG, SpJ, SpM, agreed A 2026-11-02).
2
+ *
3
+ * ringHtml() the hollow ring beside "Good morning" while Home checks
4
+ * listShapes() Home's list as grey rows, before the first read and with no last view kept
5
+ * menShape() the Mentions row's grey shape, until Mentions is read
6
+ * keepLast(key, html) keep the list Home drew last in this browser (not over 300 KB), for the next open
7
+ * last(key) that list, or ''
8
+ * holdViewer(host, on) the Viewer's column as a dashed empty panel, so the chat never narrows when the Viewer opens
9
+ * slowLine(host, M, ms) 'Still reading the board.' once the wait runs past ms, while there is still no model
10
+ *
11
+ * Grey shapes carry no names and no counts, and nothing moves. The last view is drawn inert: it can be read, not acted on, until the
12
+ * fresh read is in. */
13
+
14
+ const store = {
15
+ get(k) { try { return localStorage.getItem(k); } catch { return null; } },
16
+ set(k, v) { try { if (v == null) localStorage.removeItem(k); else localStorage.setItem(k, v); } catch { /* a private window, or full */ } },
17
+ };
18
+ const LAST = 'pmos.home.last:';
19
+ const MAX = 300000;
20
+
21
+ export const ringHtml = () => '<span class="ls-ringdot" role="img" aria-label="Checking"></span>';
22
+
23
+ const W = [62, 78, 54, 70, 48];
24
+ const row = (w, box) => `<div class="ls-row">${box ? '<i class="ls-box"></i>' : '<i class="ls-dot"></i>'}<i class="ls-sk ls-w${w}"></i></div>`;
25
+ export function listShapes() {
26
+ return '<div class="ls-list" aria-hidden="true">'
27
+ + `<div class="ls-group"><div class="ls-head"><i class="ls-sk ls-w38"></i></div><div class="hv-card">${W.map((w, i) => row(w, i > 0)).join('')}</div></div>`
28
+ + `<div class="ls-group"><div class="ls-head"><i class="ls-sk ls-w30"></i></div><div class="hv-card">${W.slice(0, 3).map((w) => row(w, true)).join('')}</div></div>`
29
+ + '</div>';
30
+ }
31
+ export const menShape = () => '<div class="ls-men" aria-hidden="true"><i class="ls-dot"></i><i class="ls-sk ls-w34"></i></div>';
32
+
33
+ const kept = new Map(); // what this page wrote last, so a redraw that changed nothing writes nothing
34
+ export function keepLast(key, html) { if (kept.get(key) === html) return; kept.set(key, html); store.set(LAST + key, html && html.length <= MAX ? html : null); }
35
+ /** The last view, drawn as a picture of itself: no button, link or field in it, so nothing in it can be pressed or found as a control
36
+ * until the fresh read is in (SpA: it can be read, not acted on). */
37
+ export function last(key) { return inert(store.get(LAST + key) || ''); }
38
+ /** html with its buttons, links and fields drawn as spans, and its press marks renamed: seen, not pressed, until the fresh read is in. */
39
+ export function inert(html) {
40
+ return String(html || '').replace(/<(\/?)(button|a|input|select|textarea)\b/gi, (m, end, tag) => '<' + end + 'span' + (end ? '' : ' data-ls-was="' + tag.toLowerCase() + '"'))
41
+ .replace(/\sdata-(hm|hr)-/g, ' data-ls-'); // and without the presses' own marks, so nothing reads it as the live list
42
+ }
43
+
44
+ /** on: a dashed empty panel in the host (once); off: the panel goes. */
45
+ export function holdViewer(host, on) {
46
+ if (!host) return;
47
+ const was = host.querySelector(':scope > .ls-vw');
48
+ if (on && !was) { const p = document.createElement('div'); p.className = 'ls-vw'; p.setAttribute('aria-hidden', 'true'); host.prepend(p); }
49
+ if (!on && was) was.remove();
50
+ }
51
+
52
+ /** The Board's quiet line past ms of waiting (SpD): it says only what is true, and never shows once the board is in (M() is set). */
53
+ export function slowLine(host, M, ms = 2000) {
54
+ const show = () => { if (M() || !host || !host.querySelector('.ls-bd') || host.querySelector('.ls-slow')) return; host.insertAdjacentHTML('afterbegin', '<p class="ls-slow" role="status">Still reading the board.</p>'); };
55
+ const left = ms - (typeof performance !== 'undefined' ? performance.now() : 0);
56
+ if (left <= 0) show(); else setTimeout(show, left);
57
+ }