create-pmos 0.528.0 → 0.529.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 (31) hide show
  1. package/package.json +26 -14
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/briefing-premium-calendar-3-test.js +11 -9
  7. package/workspace/briefing-premium-calendar-test.js +1 -1
  8. package/workspace/changelog.json +25 -0
  9. package/workspace/fixtures/premium-viewer-fixtures.js +4 -2
  10. package/workspace/guide/idle-lab.md +1 -1
  11. package/workspace/guide/notes.md +19 -7
  12. package/workspace/guide/reports-set-up.md +3 -1
  13. package/workspace/lib/bg-queue.js +5 -1
  14. package/workspace/lib/meeting-replay.js +11 -2
  15. package/workspace/lib/meeting-suggest.js +105 -0
  16. package/workspace/lib/meeting-terms.js +57 -17
  17. package/workspace/lib/report-chat.js +3 -3
  18. package/workspace/lib/routes-lab.js +44 -2
  19. package/workspace/lib/routes-meeting-points.js +27 -3
  20. package/workspace/lib/routes-report-flow.js +35 -5
  21. package/workspace/package-lock.json +2 -2
  22. package/workspace/package.json +1 -1
  23. package/workspace/premium-vw-meet-test.js +17 -11
  24. package/workspace/public/pm/cozy-lab-ins.js +13 -4
  25. package/workspace/public/pm/cozy-vw-mtg.css +122 -24
  26. package/workspace/public/pm/premium-meeting.js +2 -1
  27. package/workspace/public/pm/premium-mtg-replay.js +149 -73
  28. package/workspace/public/pm/premium-report-tab.js +33 -4
  29. package/workspace/public/pm/premium-vw-mtg.js +176 -53
  30. package/workspace/public/pm/report-chat.js +1 -0
  31. package/workspace/public/pm/report-flow.js +7 -3
@@ -40,7 +40,7 @@ import { ownTab, bring, tabUrl } from './rec-tab-core.js';
40
40
  import { noteSheet, aboutChoices, aboutBody, aboutNow } from './premium-vw-meet.js';
41
41
  import { weekView, noteOfRow, sameMeeting, recordable, prevOf, taskEvent, linkBody, mmss, focusView, replayModel, RECORD_LINE, FOCUS_LINE, STOP_LINE, CONSENT_LINE, recCore } from './premium-meeting.js';
42
42
  import { APPS, recall, remember } from './listen-sources.js';
43
- import { replayView, phoneHtml, editHtml, mentions, phraseOf as phraseOfPage, deskHtml, leftHtml, wx, placesOf, viewTexts, wordFieldHtml } from './premium-mtg-replay.js';
43
+ import { replayView, phoneHtml, editHtml, mentions, phraseOf as phraseOfPage, deskHtml, leftHtml, wx, placesBy, wordPopHtml, posOf, lineAtPos, captionHtml } from './premium-mtg-replay.js';
44
44
  import { announce } from './rec-state.js';
45
45
  import { presentInRecording } from './cozy-rec-bridge.js'; // while a meeting records, Present goes to the recording tab (batch 12 item 1, rule 10)
46
46
 
@@ -298,7 +298,7 @@ const SV = { link: '<path d="M10 14a4 4 0 0 0 6 0l3-3a4 4 0 0 0-6-6l-1 1M14 10a4
298
298
  prod: '<path d="M5 20V11M12 20V5M19 20v-6"/>', ticket: '<path d="M4 7h16v4a2 2 0 0 0 0 4v4H4v-4a2 2 0 0 0 0-4z"/>', warn: '<path d="M12 4l9 16H3zM12 10v4M12 17v.01"/>',
299
299
  rec: '<circle cx="12" cy="12" r="5"/>', star: '<path d="M12 3.8l2.5 5.2 5.7.8-4.1 4 1 5.7L12 16.8l-5.1 2.7 1-5.7-4.1-4 5.7-.8z"/>',
300
300
  img: '<path d="M4 4.5h16v15H4zM9 10a1.6 1.6 0 1 0 0-.01M20 16l-5-5-8 8"/>', up: '<path d="M12 16V4M7 9l5-5 5 5M5 20h14"/>',
301
- tab: '<path d="M14 4h6v6M20 4l-8 8M18 14v6H4V6h6"/>', present: '<path d="M3 4h18v12H3zM10 8.5l4 2.5-4 2.5zM8 20h8M12 16v4"/>' };
301
+ tab: '<path d="M14 4h6v6M20 4l-8 8M18 14v6H4V6h6"/>', present: '<path d="M3 4h18v12H3zM10 8.5l4 2.5-4 2.5zM8 20h8M12 16v4"/>', undo: '<path d="M9 14L4 9l5-5M4 9h9a6 6 0 0 1 0 12h-3"/>' };
302
302
  const svg = (k, w, extra) => `<svg aria-hidden="true" width="${w}" height="${w}" viewBox="0 0 24 24"${extra || ''}>${SV[k]}</svg>`;
303
303
  /* bars split by weight (live topics): an SVG, so each length is an attribute and not an inline style */
304
304
  function weightBar(cls, parts) {
@@ -359,7 +359,8 @@ function agendaHtml(P) {
359
359
  return P.agenda.length ? `<div class="vzm-agl">${P.agenda.map((a, i) => `<div class="vz-ag"><i class="vz-num" aria-hidden="true">${a.n}</i><span>${esc(a.text)}</span><button type="button" class="vz-min vz-hv" data-a="min" data-i="${i}" title="Change the minutes for this topic" aria-label="${esc(a.text)}: ${a.min} minutes. Change">${a.min} min</button></div>`).join('')}</div>`
360
360
  : '<p class="vz-empty">The invite lists no topics yet.</p>';
361
361
  }
362
- const tagRows = (rows) => rows.map((x) => `<div class="vz-tagrow"><span class="vz-tag" data-k="${x.k}">${esc(x.tag)}</span><span>${esc(x.text)} <small>${esc(x.meta || x.owner || x.src || '')}</small></span></div>`).join('');
362
+ /** Tagged rows (What matters, Since the last one, Facts). W: the word fixes, shown in the words (Facts, batch 27 item 12). */
363
+ const tagRows = (rows, W) => rows.map((x) => `<div class="vz-tagrow"><span class="vz-tag" data-k="${x.k}">${esc(x.tag)}</span><span><span class="vz-said">${W ? wx(W, x.text) : esc(x.text)}</span> <small>${esc(x.meta || x.owner || x.src || '')}</small></span></div>`).join('');
363
364
  function readyHtml(P) {
364
365
  const h = [P.ready.map((r) => `<div class="vz-ready"><span class="vz-mk" data-k="${r.ok ? 'ok' : 'no'}" aria-hidden="true">${r.ok ? svg('check', 12) : '!'}</span><span><b>${esc(r.label)}</b> <em>${esc(r.text)}</em></span></div>`).join('')];
365
366
  if (P.purpose) h.push(`<p class="vz-p"><b>What it is for:</b> ${esc(P.purpose)}</p>`);
@@ -394,32 +395,39 @@ function transcriptHtml(m, ui, lines, seek) {
394
395
  return (lines.length ? lines.map((l, i) => lineHtml(l, i, ui, seek, W)).join('') : `<p class="vz-empty">${m.private ? 'This note is private: only what you type is kept.' : m.mk === 'live' ? 'Nothing has been said yet.' : 'Nothing was transcribed.'}</p>`)
395
396
  + `<p class="vz-note vz-fine">${esc(LIVE_FOOT)}</p>`;
396
397
  }
397
- function stepHtml(a, ui) {
398
+ function stepHtml(a, ui, W) {
398
399
  const made = ui.made && ui.made[a.i];
399
400
  const act = a.made ? `<span class="vzm-pill" data-k="${a.ticket ? 'you' : ''}">${esc(a.ticket ? a.made : 'Task')}</span>`
400
401
  : `<button type="button" class="vzm-btn vzm-soft" data-a="maketask" data-i="${a.i}">Make task</button><button type="button" class="vzm-ib vz-hv" data-a="makewi" data-i="${a.i}" aria-label="Make it a work-item" title="A ticket Drafted in PMOS, shown on the Board. It goes to Jira only through the Commit gate.">${svg('ticket', 15)}</button>`;
401
- return `<div class="vzm-step"><span class="vzm-grow">${esc(a.text)}</span><span class="vzm-mut vzm-sm">${esc(a.meta.replace('No owner · No date', 'No owner, no date'))}</span>${act}</div>`
402
+ return `<div class="vzm-step"><span class="vzm-grow vz-said">${W ? wx(W, a.text) : esc(a.text)}</span><span class="vzm-mut vzm-sm">${esc(a.meta.replace('No owner · No date', 'No owner, no date'))}</span>${act}</div>`
402
403
  + (made ? `<div class="vzm-made" role="status">${svg('check', 14)}<span>${made.html}</span>${made.undo ? `<button type="button" class="vzm-btn vzm-ghost" data-a="madeundo" data-i="${a.i}">Undo</button>` : ''}</div>` : '');
403
404
  }
404
405
  const decisionsHtml = (N) => N.decisions.map((d) => `<div class="vzm-trow vzm-top"><span class="vzm-grow">${esc(d.text)}</span>${d.why ? `<span class="vzm-mut vzm-sm vzm-why">${esc(d.why)}</span>` : ''}</div>`).join('')
405
406
  + IM.meetingLine(); // Your impact (batch 16 item 7, GmA4Moments): one quiet line under the decisions, only while it is on
406
- /** Names and terms (MvN): the people of the invite, and the words fixed in place (here, and in other meetings: the guesses). */
407
+ /** The texts of the notes a word fix reaches, beyond the replay's own (the summary, Next steps, Facts, the transcript). */
408
+ const ownTexts = (m) => ({ summary: m.notes.summary, steps: m.notes.steps.map((x) => x.text), facts: m.notes.facts.map((x) => x.text), lines: m.notes.lines.map((l) => l.text) });
409
+ /** Names and terms (MvN, MpW2): the words fixed here (heard as, the places, Undo; the ✦ ask to remember it for the next meetings), the
410
+ * words fixed in other meetings and remembered, and the people of the invite. */
407
411
  function namesHtml(m, V) {
408
- const row = (name, meta, extra = '') => `<div class="vzm-trow"><span class="vzm-grow"><b>${esc(name)}</b>${meta ? ` <span class="vzm-mut vzm-sm">${esc(meta)}</span>` : ''}</span>${extra}</div>`;
409
- const rows = m.people.map((p) => row(p, 'Invite'))
410
- .concat(V.W.fix.map((x) => row(x.means, 'heard as “' + x.heard + '” · fixed here', `<button type="button" class="vzm-btn vzm-ghost" data-a="wundo" data-id="${esc(x.id)}">Undo</button>`)))
411
- .concat(V.W.guess.map((x) => row(x.means, 'heard as “' + x.heard + '” · fixed in another meeting')));
412
- if (!rows.length) return '';
413
- return `<p class="vzm-sm vzm-mut">Select a word that was heard wrong in the summary, a quote or the transcript to fix it everywhere. Later notes spell it your way.</p>` + rows.join('');
412
+ const row = (name, meta, extra = '', cls = '') => `<div class="vzm-trow vzr-trow"><span class="vzm-grow"><b${cls ? ` class="${cls}"` : ''}>${esc(name)}</b>${meta ? ` <span class="vzm-mut vzm-sm">${esc(meta)}</span>` : ''}</span>${extra}</div>`;
413
+ const T = ownTexts(m);
414
+ const fixes = V.W.fix.map((x) => { const n = placesBy(V, T, x.heard).n;
415
+ return row(x.means, 'heard as “' + x.heard + '”' + (n ? ' · ' + (n === 1 ? '1 place' : n + ' places') + ' in this meeting' : '') + (x.keep === 'yes' ? ' · remembered' : ''),
416
+ `<button type="button" class="vzm-ib vzr-ib" data-a="wundo" data-id="${esc(x.id)}" aria-label="Undo this fix" title="Undo this fix">${svg('undo', 16)}</button>`, 'vzr-fxn')
417
+ + (x.keep === 'ask' ? `<div class="vzr-ask"><span class="vzr-star" aria-hidden="true">✦</span><span class="vzm-grow">Remember “${esc(x.means)}” for your next meetings? Heard as “${esc(x.heard)}” here.</span>`
418
+ + `<button type="button" class="vzr-a" data-a="wkeep" data-id="${esc(x.id)}" data-v="0" aria-label="Not now" title="Not now">${svg('x', 15)}</button><button type="button" class="vzr-a y" data-a="wkeep" data-id="${esc(x.id)}" data-v="1" aria-label="Remember it" title="Remember it">${svg('check', 15)}</button></div>` : ''); });
419
+ const rows = fixes.concat(V.W.guess.map((x) => row(x.means, 'heard as “' + x.heard + '” · fixed in the meeting on ' + (shortDay(x.at) || 'an earlier day').replace(/^(\w+) (\d+)$/, '$2 $1') + ', remembered')))
420
+ .concat(m.people.map((p) => row(p, 'Invite')));
421
+ return rows.join('');
414
422
  }
415
423
  const namesCount = (m, V) => m.people.length + V.W.fix.length + V.W.guess.length;
416
424
  /** The one strip under the kept points (MvC): the parts this module owns. Left out is the replay module's own. */
417
425
  function stripOf(m, ui) {
418
426
  const N = m.notes, P = m.prep, L = m.live, V = N.replay;
419
427
  return [
420
- N.steps.length ? { k: 'steps', title: 'Next steps', n: String(N.stepsSub && /proposed/.test(N.stepsSub) ? N.stepsSub : N.steps.length), html: N.steps.map((a) => stepHtml(a, ui)).join('') } : null,
428
+ N.steps.length ? { k: 'steps', title: 'Next steps', n: String(N.stepsSub && /proposed/.test(N.stepsSub) ? N.stepsSub : N.steps.length), html: N.steps.map((a) => stepHtml(a, ui, V.W)).join('') } : null,
421
429
  N.since ? { k: 'since', title: 'Since the last one', n: String(N.since.rows.length), html: tagRows(N.since.rows) } : null,
422
- N.facts.length ? { k: 'facts', title: 'Facts mentioned', n: String(N.facts.length), html: tagRows(N.facts) } : null,
430
+ N.facts.length ? { k: 'facts', title: 'Facts mentioned', n: String(N.facts.length), html: tagRows(N.facts, V.W) } : null,
423
431
  namesCount(m, V) ? { k: 'names', title: 'Names and terms', n: String(namesCount(m, V)), html: namesHtml(m, V) } : null,
424
432
  { k: 'prep', title: 'Prep and material', n: m.material.length ? String(m.material.length) : '', html: (P.agenda.length ? agendaHtml(P) : '') + materialHtml(m.material, ui) },
425
433
  { k: 'tr', title: 'Transcript', n: N.lines.length ? plural(N.lines.length, 'line') : '', html: `<p class="vzm-lbl">Your notes</p>${mineHtml(L)}${L.hl.length ? '<p class="vzm-lbl">Highlights</p>' + hlHtml(L) : ''}<p class="vzm-lbl">${esc(N.trTitle)}</p>${transcriptHtml(m, ui, N.lines, true)}` },
@@ -450,9 +458,9 @@ export function sectionsOf(m, ui = {}) {
450
458
  ];
451
459
  }
452
460
  const V = N.replay;
453
- const steps = N.steps.length ? { id: 'steps', title: 'Next steps', count: N.stepsSub || N.steps.length, open: !V, html: N.steps.map((a) => stepHtml(a, ui)).join('') } : null;
461
+ const steps = N.steps.length ? { id: 'steps', title: 'Next steps', count: N.stepsSub || N.steps.length, open: !V, html: N.steps.map((a) => stepHtml(a, ui, V && V.W)).join('') } : null;
454
462
  const since = N.since ? { id: 'since', title: 'Since the last one', count: N.since.sub, html: tagRows(N.since.rows) } : null;
455
- const facts = N.facts.length ? { id: 'facts', title: 'Facts mentioned', count: N.facts.length, html: tagRows(N.facts) } : null;
463
+ const facts = N.facts.length ? { id: 'facts', title: 'Facts mentioned', count: N.facts.length, html: tagRows(N.facts, V && V.W) } : null;
456
464
  const prep = { id: 'prep', title: 'Prep and material', count: m.material.length ? plural(m.material.length, 'item') : '', html: (P.agenda.length ? agendaHtml(P) : '') + materialHtml(m.material, ui) };
457
465
  const tr = { id: 'tr', title: 'Transcript', count: N.lines.length ? plural(N.lines.length, 'line') : '', html: `<p class="vzm-lbl">Your notes</p>${mineHtml(L)}${L.hl.length ? '<p class="vzm-lbl">Highlights</p>' + hlHtml(L) : ''}<p class="vzm-lbl">${esc(N.trTitle)}</p>${transcriptHtml(m, ui, N.lines, !!V)}` };
458
466
  if (V) { // the phone: the phone's Replay lists the points; Left out and the rest fold below it
@@ -477,11 +485,12 @@ export function meetHtml(m, ui = {}) {
477
485
  for (const g of m.gaps) h.push(`<div class="vzm-warn vzm-sm"><span class="vzm-grow">${g.kind === 'off' ? '<b>Earlier:</b> ' + esc(g.text.replace(/^Earlier: /, '')) : esc(g.text)}</span>${g.kind === 'quiet' ? '<button type="button" class="vzm-btn vzm-card" data-a="src">Listen to another app</button>' : ''}</div>`);
478
486
  if (ui.src && m.mk === 'live') h.push(appsHtml(m.live));
479
487
  if (ui.why) h.push(`<div class="vz-why" role="note"><p>${esc(RECORD_LINE)}</p><p>${esc(FOCUS_LINE)}</p></div>`);
480
- if (ui.msg) h.push(`<p class="vz-msg" role="status" data-tone="${ui.msg.tone || ''}"><span>${esc(ui.msg.text)}</span>${ui.msg.undo ? '<button type="button" class="vz-link vz-hv" data-a="msgundo">Undo</button>' : ''}</p>`);
488
+ if (ui.msg && !ui.msg.toast) h.push(`<p class="vz-msg" role="status" data-tone="${ui.msg.tone || ''}"><span>${esc(ui.msg.text)}</span>${ui.msg.undo ? '<button type="button" class="vz-link vz-hv" data-a="msgundo">Undo</button>' : ''}</p>`);
481
489
  const V = m.mk === 'notes' ? m.notes.replay : null;
482
490
  if (V) { // a recording (MvC): the desk is the two columns and the docked timeline; the phone keeps its Replay and the folds
483
- h.push(deskHtml(V, ui, { summary: summaryHtml(m, ui), strip: stripOf(m, ui) }));
491
+ h.push(deskHtml(V, ui, { summary: summaryHtml(m, ui), strip: stripOf(m, ui), hint: !!ui.hint }));
484
492
  h.push(`<div class="vzp vzp-card vzp-mb">${phoneHtml(V, ui)}</div><div class="vzr-mbonly">${czSections(sectionsOf(m, ui), { key: 'meeting-' + m.mk })}</div>`);
493
+ if (ui.msg && ui.msg.toast) h.push(`<div class="vzr-toastw"><p class="vzr-toast" role="status"><span>${esc(ui.msg.text)}</span>${ui.msg.undo ? '<button type="button" data-a="msgundo">Undo</button>' : ''}</p></div>`);
485
494
  return `<div class="vzr-mbonly">${summaryHtml(m, ui)}</div><div class="vz vz-mtg vz-rec" data-mk="${m.mk}">${h.join('')}</div>`;
486
495
  }
487
496
  h.push(czSections(sectionsOf(m, ui), { key: 'meeting-' + m.mk }));
@@ -559,7 +568,7 @@ export async function openMeeting(ref, o = {}) {
559
568
  mtgStyle();
560
569
  if (!IM.cached()) IM.readWeek().catch(() => {}); // the meeting moment's switch, read once a page
561
570
  const R = refOf(ref);
562
- const me = { R, ws: o.ws || new URLSearchParams(location.search).get('ws') || '', ui: { sel: new Set(), made: {} }, D: null, n: null, row: null, alive: true, timers: [], play: null };
571
+ const me = { R, ws: o.ws || new URLSearchParams(location.search).get('ws') || '', ui: { sel: new Set(), made: {}, hint: hintOn() }, D: null, n: null, row: null, alive: true, timers: [], play: null };
563
572
  if (MT) { MT.alive = false; MT.timers.forEach(clearInterval); stopPlay(MT); }
564
573
  MT = me;
565
574
  const id = 'meeting:' + (R.note || R.event || R.title || 'new');
@@ -706,14 +715,17 @@ function paint(me, body) {
706
715
  const line = body.querySelector('.czs-sum .czs-line > span:not(.czs-dot):not(.czs-r)'); if (line) line.dataset.clk = 'state';
707
716
  if (me.ui.edit) { const ta = body.querySelector('.vzp-ed [data-a="etext"]'); if (ta && me.ui.focusEdit) { me.ui.focusEdit = false; ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } }
708
717
  placePop(me, body);
709
- body.querySelectorAll('.vzp-top[data-w]').forEach((b) => b.style.setProperty('flex-grow', b.dataset.w)); // the topic names under the strip, as wide as their stretch
718
+ crop(body);
719
+ hoverBar(me, body);
710
720
  body.onclick = (e) => press(me, e);
711
721
  body.onchange = (e) => change(me, e);
712
722
  // the file picker stays open for seconds while the card may draw again; the input's own handler still runs when it was replaced meanwhile
713
723
  body.querySelectorAll('input[type="file"][data-a="matfile"]').forEach((i) => { i.onchange = (e) => { e.stopPropagation(); change(me, e); }; });
714
724
  body.oninput = (e) => input(me, e);
715
725
  body.onkeydown = (e) => keydown(me, e, body);
716
- body.onmouseup = () => { if (me.model && me.model.notes.replay) setTimeout(() => { if (MT === me) selectWord(me); }, 0); }; // a word selected to fix (MvF)
726
+ body.onmouseup = (e) => { if (me.model && me.model.notes.replay && !e.target.closest('.vzr-wpop')) setTimeout(() => { if (MT === me) selectWord(me); }, 0); }; // a word selected to fix (MvF)
727
+ body.ondblclick = (e) => { if (me.model && me.model.notes.replay && !e.target.closest('.vzr-wpop, input, textarea, button')) wordAt(me, e); }; // a word double-clicked (MpW1)
728
+ if (me.ui.wpop) placeWordPop(me, body);
717
729
  }
718
730
  /** The open popover goes in its anchor, drawn once per paint (its list is redrawn on its own while the PM types). */
719
731
  function placePop(me, body) {
@@ -727,8 +739,10 @@ function keydown(me, e, body) {
727
739
  if (e.key === 'Enter' && a === 'namein') { e.preventDefault(); nameIt(me, +e.target.dataset.i, e.target.value); }
728
740
  if (e.key === 'Enter' && a === 'minin') { e.preventDefault(); setMin(me, +e.target.dataset.i, e.target.value); }
729
741
  if (e.key === 'Enter' && a === 'matlink') { e.preventDefault(); addLink(me, e.target.value); }
730
- if (a === 'wfin' && e.key === 'Enter') { e.preventDefault(); const f = e.target.closest('.vzr-fe'); if (f) fixWord(me, f.dataset.w, e.target.value); }
731
- if (a === 'wfin' && e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); paint(me, body); }
742
+ if (a === 'wfin' && e.key === 'Enter') { e.preventDefault(); const f = e.target.closest('.vzr-wpop'); if (f) fixWord(me, f.dataset.w, e.target.value); }
743
+ if (e.key === 'Escape' && me.ui.wpop) { e.preventDefault(); e.stopPropagation(); me.ui.wpop = null; paint(me, body); return; }
744
+ if (e.key === 'Escape' && me.ui.chaps) { e.stopPropagation(); me.ui.chaps = false; paint(me, body); return; }
745
+ if (a === 'psel' && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); selectPoint(me, e.target.dataset.id); return; }
732
746
  if (e.key === 'Escape' && me.ui.naming != null) { me.ui.naming = null; paint(me, body); }
733
747
  if (e.key === 'Escape' && me.ui.pop) { e.stopPropagation(); me.ui.pop = ''; paint(me, body); }
734
748
  if (a === 'seek' && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) { e.preventDefault(); const P = ensurePlay(me); if (P) seek(me, P.pos + (e.key === 'ArrowRight' ? 10000 : -10000), false); }
@@ -750,6 +764,8 @@ function tick(me) {
750
764
  async function press(me, e) {
751
765
  const pa = e.target.closest('[data-czs-act]'); // the summary's one primary
752
766
  const t = pa || e.target.closest('[data-a]');
767
+ if (me.ui.wpop && !e.target.closest('.vzr-wpop')) { me.ui.wpop = null; repaint(me); if (!t || t.dataset.a === 'psel') return; } // a press outside the word fix closes it
768
+ if (me.ui.chaps && !e.target.closest('.vzr-chs, [data-a="chaps"]')) { me.ui.chaps = false; if (!t) return repaint(me); }
753
769
  if (!t) { if (me.ui.pop && !e.target.closest('.vzm-pop')) { me.ui.pop = ''; repaint(me); } return; }
754
770
  if (t.disabled) return;
755
771
  const a = pa ? pa.dataset.czsAct : t.dataset.a, ui = me.ui, m = me.model, C = rec();
@@ -801,13 +817,18 @@ async function press(me, e) {
801
817
  /* Replay */
802
818
  case 'play': return togglePlay(me);
803
819
  case 'speed': { const P = ensurePlay(me); const sp = [1, 1.5, 2]; ui.speed = sp[(sp.indexOf(ui.speed || 1) + 1) % sp.length]; if (P && P.aud) P.aud.playbackRate = ui.speed; return repaint(me); }
820
+ case 'skip': { const P = ensurePlay(me); if (P) seek(me, posOf(m.notes.replay, ui) + Number(t.dataset.d || 0), false); return; }
821
+ case 'chprev': case 'chnext': return stepChapter(me, a === 'chnext' ? 1 : -1);
822
+ case 'chgo': ui.chaps = false; return seek(me, +t.dataset.t, true);
823
+ case 'chaps': ui.chaps = !ui.chaps; return repaint(me);
824
+ case 'caps': ui.caps = !ui.caps; return repaint(me);
804
825
  case 'seekto': if (t.dataset.t !== '') return seek(me, +t.dataset.t, true); return;
805
826
  case 'trseek': { seek(me, +t.dataset.t, true); const c = bodyEl() && bodyEl().querySelector('.vzp-card'); if (c && c.scrollIntoView) c.scrollIntoView({ block: 'nearest' }); return; }
806
827
  case 'seek': { const r = t.getBoundingClientRect(); return seek(me, Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)) * m.notes.replay.len, false); }
807
- case 'shotbig': if (m.notes.replay && m.notes.replay.noteId) window.open('/api/notes/screen?id=' + encodeURIComponent(m.notes.replay.noteId) + '&t=' + t.dataset.t, '_blank', 'noopener'); return;
828
+ case 'shotbig': if (m.notes.replay && m.notes.replay.noteId) window.open('/api/notes/screen?id=' + encodeURIComponent(m.notes.replay.noteId) + '&t=' + t.dataset.t, '_blank', 'noopener'); return; // the whole frame, uncropped
808
829
  case 'leftout': ui.leftOpen = !ui.leftOpen; return repaint(me);
809
830
  /* the recording view (MvC): select a point, play it, confirm it, the strip, the filmstrip, the summary */
810
- case 'psel': return selectPoint(me, t.dataset.id);
831
+ case 'psel': { const sl = window.getSelection && window.getSelection(); if (sl && !sl.isCollapsed && t.contains(sl.anchorNode)) return; return selectPoint(me, t.dataset.id); } // a selection there is a word to fix, not a press
811
832
  case 'pplay': return seek(me, +t.dataset.t, true);
812
833
  case 'pconf': { const ids = String(t.dataset.ids || '').split(',').filter(Boolean), on = t.getAttribute('aria-pressed') === 'true'; return verdict(me, ids, on ? null : 'confirmed', on ? 'No longer confirmed.' : ids.length > 1 ? 'Confirmed, with its follow-up.' : 'Confirmed.'); }
813
834
  case 'pwrong': ui.edit = null; ui.draft = null; return verdict(me, [t.dataset.id], 'wrong', 'Moved to Left out.', true);
@@ -815,9 +836,11 @@ async function press(me, e) {
815
836
  case 'allshots': ui.allShots = !ui.allShots; return repaint(me);
816
837
  case 'sumall': ui.sumAll = !ui.sumAll; return repaint(me);
817
838
  /* a word fixed in place (MvF) */
818
- case 'wfix': return openWordField(me, t, t.dataset.w, t.dataset.g);
819
- case 'wfok': { const f = t.closest('.vzr-fe'), i = f && f.querySelector('input'); return f ? fixWord(me, f.dataset.w, i ? i.value : '') : null; }
839
+ case 'wfix': return openWordPop(me, t, t.dataset.w, { means: t.dataset.g, from: ['fix'], alts: [] });
840
+ case 'wfok': { const f = t.closest('.vzr-wpop'), i = f && f.querySelector('input'); return f ? fixWord(me, f.dataset.w, i ? i.value : '') : null; }
841
+ case 'walt': { const f = t.closest('.vzr-wpop'), i = f && f.querySelector('input'); if (i) { i.value = t.dataset.v; i.focus(); } if (f) f.querySelectorAll('[data-a="walt"]').forEach((b) => b.setAttribute('aria-pressed', String(b === t))); return; }
820
842
  case 'wundo': return forgetWord(me, t.dataset.id);
843
+ case 'wkeep': return keepWord(me, t.dataset.id, t.dataset.v === '1');
821
844
  case 'confirmall': return bulk(me, 'confirmed', m.notes.replay.points.filter((p) => !p.verdict).map((p) => p.id));
822
845
  case 'unwrong': return verdict(me, [t.dataset.id], null, 'It is back in the kept points.');
823
846
  case 'ptask': return bulk(me, 'task', [t.dataset.id]);
@@ -846,46 +869,142 @@ function selectPoint(me, id) {
846
869
  if (p.t != null) { const P = ensurePlay(me); if (P) { P.pos = Math.max(0, Math.min(V.len, p.t)); if (P.aud) { try { P.aud.currentTime = P.pos / 1000; } catch { /* not loaded */ } } ui.pos = P.pos; me.shotNow = V.shotAt(P.pos); } }
847
870
  repaint(me);
848
871
  }
849
- /* ── a word fixed in place (MvF): the texts it reaches, the field, the fix, Undo ── */
850
- function wordTexts(me) {
851
- const m = me.model, V = m && m.notes.replay; if (!V) return [];
852
- return viewTexts(V).concat([m.notes.summary], m.notes.lines.map((l) => l.text));
853
- }
854
- function openWordField(me, node, heard, guess) {
855
- const w = String(heard || '').trim(); if (!w || !me.n) return;
856
- const n = placesOf(wordTexts(me), w);
857
- const box = document.createElement('span'); box.innerHTML = wordFieldHtml(w, guess || w, n);
858
- const f = box.firstElementChild;
859
- if (node.nodeType === 1) node.replaceWith(f); else return;
860
- const i = f.querySelector('input'); if (i) { i.focus(); i.select(); }
861
- }
862
- /** A selection in the summary, a quote, Left out or the transcript (one to four words): it becomes the field. */
872
+ /* ── a word fixed in place (MvF; batch 27 word fix A, MpW1/MpW2): the popup, the ✦ spelling, the fix, Undo, the ✦ ask to remember it ── */
873
+ /** Where a selection may be a word to fix: every text of the notes the page draws (the kept points, their follow-ups, the caption, the
874
+ * summary, Next steps, Facts, Left out, the chapter names, the transcript). */
875
+ const WORD_ZONES = '.czs-p, .vzp-kt, .vzr-fu .vzr-grow, .vzr-cap, .vzr-nt b, .vzr-chr b, .vz-said, .vzp-lr > span, .vzm-step .vzm-grow';
876
+ const WORD_OK = /^[\p{L}\p{N}][\p{L}\p{N} .'&+-]*$/u;
877
+ const textsFor = (me) => ownTexts(me.model);
878
+ /** The popup for heard words at a node: it opens at once with the places; the ✦ spelling is asked for and shown when it comes. */
879
+ function openWordPop(me, node, heard, sug) {
880
+ const w = String(heard || '').trim(), V = me.model && me.model.notes.replay; if (!w || !me.n || !V) return;
881
+ const r = node && node.getBoundingClientRect ? node.getBoundingClientRect() : null;
882
+ me.ui.wpop = { heard: w, sug: sug && sug.means ? sug : null, P: placesBy(V, textsFor(me), w), rect: r ? { left: r.left, top: r.top, bottom: r.bottom, width: r.width } : null };
883
+ const b = bodyEl(); if (b) placeWordPop(me, b);
884
+ if (!me.ui.wpop.sug) askSpelling(me, w);
885
+ }
886
+ function placeWordPop(me, body) {
887
+ const W = me.ui.wpop; if (!W) return;
888
+ let el = body.querySelector('.vzr-wpop');
889
+ const box = document.createElement('div'); box.innerHTML = wordPopHtml(W.heard, W.sug, W.P);
890
+ const fresh = box.firstElementChild;
891
+ if (el) el.replaceWith(fresh); else body.append(fresh);
892
+ el = fresh;
893
+ const phone = window.matchMedia && window.matchMedia('(max-width: 640px)').matches;
894
+ el.classList.toggle('sheet', !!phone);
895
+ if (!phone && W.rect) { // under the words, its arrow at their start; above them when there is no room below
896
+ const vw = window.innerWidth, vh = window.innerHeight, w = Math.min(380, vw - 24);
897
+ const left = Math.max(12, Math.min(vw - w - 12, W.rect.left - 26));
898
+ el.style.setProperty('width', w + 'px'); el.style.setProperty('left', left + 'px');
899
+ const below = W.rect.bottom + 10, h = el.offsetHeight || 260;
900
+ if (below + h > vh - 12 && W.rect.top - h - 10 > 12) { el.style.setProperty('top', (W.rect.top - h - 10) + 'px'); el.classList.add('up'); } else el.style.setProperty('top', below + 'px');
901
+ el.style.setProperty('--arr', Math.max(16, Math.min(w - 28, W.rect.left - left + 8)) + 'px');
902
+ }
903
+ const i = el.querySelector('input'); if (i && document.activeElement !== i) { i.focus(); i.select(); }
904
+ }
905
+ async function askSpelling(me, heard) {
906
+ const m = me.model, F = me.D && me.D.F, mats = (m && m.material) || [];
907
+ const invite = [m.title].concat((m.prep.agenda || []).map((a) => a.text), (F && Array.isArray(F.topics) ? F.topics.map((t) => t.text) : []), mats.map((x) => x.title)).filter(Boolean).slice(0, 40);
908
+ const qs = new URLSearchParams({ note: me.n.id, heard });
909
+ invite.forEach((t) => qs.append('invite', t)); (m.people || []).slice(0, 40).forEach((p) => qs.append('people', p));
910
+ const r = await pmGet('/api/meeting-points/suggest?' + qs.toString());
911
+ const W = me.ui.wpop; if (MT !== me || !W || W.heard !== heard || !r || !r.ok || !r.suggest) return; // nothing found: no ✦ line (zero is silence)
912
+ const b = bodyEl(), i = b && b.querySelector('.vzr-wpop input');
913
+ if (i && i.value.trim() !== heard) return; // the PM is already typing a fix
914
+ W.sug = r.suggest; if (b) placeWordPop(me, b);
915
+ }
916
+ /** A selection of one to four words in the notes: the words stay marked and the popup opens under them. */
863
917
  function selectWord(me) {
864
918
  const sel = window.getSelection && window.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount || me.ui.edit) return;
865
919
  const r = sel.getRangeAt(0), tn = r.startContainer;
866
- if (tn !== r.endContainer || tn.nodeType !== 3 || !tn.parentElement || !tn.parentElement.closest('.czs-p, .vzr-q, .vz-said, .vzp-lr > span')) return;
920
+ if (tn !== r.endContainer || tn.nodeType !== 3 || !tn.parentElement || !tn.parentElement.closest(WORD_ZONES) || tn.parentElement.closest('.vzr-wpop')) return;
921
+ markWord(me, r, tn, sel);
922
+ }
923
+ /** A word double-clicked: the word under the pointer, found again after the press drew the card (the double-click's own selection is gone). */
924
+ function wordAt(me, e) {
925
+ let r = null;
926
+ if (document.caretRangeFromPoint) r = document.caretRangeFromPoint(e.clientX, e.clientY);
927
+ else if (document.caretPositionFromPoint) { const c = document.caretPositionFromPoint(e.clientX, e.clientY); if (c) { r = document.createRange(); r.setStart(c.offsetNode, c.offset); } }
928
+ const tn = r && r.startContainer; if (!tn || tn.nodeType !== 3 || !tn.parentElement || !tn.parentElement.closest(WORD_ZONES)) return;
929
+ const t = tn.textContent, i = r.startOffset; let a = i, z = i;
930
+ while (a > 0 && /[\p{L}\p{N}'-]/u.test(t[a - 1])) a--;
931
+ while (z < t.length && /[\p{L}\p{N}'-]/u.test(t[z])) z++;
932
+ if (z <= a) return;
933
+ const rg = document.createRange(); rg.setStart(tn, a); rg.setEnd(tn, z);
934
+ markWord(me, rg, tn, window.getSelection && window.getSelection());
935
+ }
936
+ function markWord(me, r, tn, sel) {
867
937
  const raw = r.toString(), w = raw.trim();
868
- if (!w || w.length > 60 || w.split(/\s+/).length > 4 || !/^[\p{L}\p{N}][\p{L}\p{N} .'&+-]*$/u.test(w)) return;
938
+ if (!w || w.length > 60 || w.split(/\s+/).length > 4 || !WORD_OK.test(w)) return;
869
939
  const lead = raw.length - raw.trimStart().length, trail = raw.length - raw.trimEnd().length;
870
940
  if (lead) r.setStart(tn, r.startOffset + lead); if (trail) r.setEnd(tn, r.endOffset - trail);
871
- const mark = document.createElement('span'); r.deleteContents(); r.insertNode(mark); sel.removeAllRanges();
872
- openWordField(me, mark, w, w);
941
+ const mark = document.createElement('span'); mark.className = 'vzr-selw'; r.surroundContents(mark); if (sel) sel.removeAllRanges();
942
+ const fx = mark.closest('.vzr-fx');
943
+ openWordPop(me, mark, fx && fx.dataset.w ? fx.dataset.w : w, null);
873
944
  }
874
945
  async function fixWord(me, heard, means) {
875
946
  const w = String(heard || '').trim(), to = String(means || '').replace(/\s+/g, ' ').trim();
947
+ me.ui.wpop = null;
876
948
  if (!w || !to || to === w || !me.n) return repaint(me);
877
- const places = placesOf(wordTexts(me), w);
949
+ const V = me.model.notes.replay, places = placesBy(V, textsFor(me), w).n;
878
950
  const r = await pmPost('/api/meeting-points/term', { note: me.n.id, heard: w, means: to });
879
951
  if (!r || !r.ok) return say(me, 'Not fixed: ' + ((r && r.error) || 'no answer'), 'bad');
880
- me.D.terms = r.terms || me.D.terms; draw(me);
881
- say(me, (places > 1 ? 'Fixed in ' + places + ' places' : 'Fixed') + '. Later notes spell it “' + to + '”.', '', async () => forgetWord(me, r.term && r.term.id, true));
952
+ me.D.terms = r.terms || me.D.terms; me.ui.strip = 'names'; // the word lands in Names and terms, with the ✦ ask (MpW2)
953
+ hintSeen(me);
954
+ draw(me);
955
+ toastSay(me, places > 1 ? 'Fixed in ' + places + ' places' : 'Fixed', async () => forgetWord(me, r.term && r.term.id, true));
882
956
  }
883
957
  async function forgetWord(me, id, undo) {
884
958
  if (!id) return;
885
959
  const f = await pmPost('/api/meeting-points/forget', { lesson: id });
886
960
  if (!f || !f.ok) return say(me, 'Not undone: ' + ((f && f.error) || 'no answer'), 'bad');
887
961
  me.D.terms = (me.D.terms || []).filter((x) => x.id !== id); draw(me);
888
- say(me, undo ? 'Undone. The word shows as it was heard.' : 'The fix is gone. The word shows as it was heard.');
962
+ toastSay(me, undo ? 'Undone. The words show as they were heard.' : 'The fix is gone. The words show as they were heard.');
963
+ }
964
+ /** The ✦ ask: ✓ remembers the fix for the next meetings, ✕ keeps it in this meeting only. */
965
+ async function keepWord(me, id, yes) {
966
+ const r = await pmPost('/api/meeting-points/keep', { lesson: id, keep: yes });
967
+ if (!r || !r.ok) return say(me, 'Not saved: ' + ((r && r.error) || 'no answer'), 'bad');
968
+ me.D.terms = r.terms || me.D.terms; draw(me);
969
+ toastSay(me, yes ? 'Remembered. Your next meetings spell it “' + (r.term ? r.term.means : '') + '”.' : 'Kept for this meeting only.');
970
+ }
971
+ /** The word fix's message is a toast at the bottom of the card (MpW2), with Undo when there is one. */
972
+ function toastSay(me, text, undo) { me.ui.msg = { text, undo, toast: true }; repaint(me); clearTimeout(me.msgT); me.msgT = setTimeout(() => { if (me.ui.msg && me.ui.msg.text === text) { me.ui.msg = null; repaint(me); } }, 8000); }
973
+ /** The one-time hint under the kept points, "Select a word …": shown until the first fix, then never again on this machine. */
974
+ const HINT_KEY = 'pmos.mtg.wordHint';
975
+ function hintSeen(me) { me.ui.hint = false; try { localStorage.setItem(HINT_KEY, '1'); } catch { /* no storage: it shows again next time */ } }
976
+ const hintOn = () => { try { return localStorage.getItem(HINT_KEY) !== '1'; } catch { return false; } };
977
+ /** A capture Jev boxed shows only its box (MpJ): the image is scaled and moved inside its frame so the box fills it. */
978
+ function crop(body) {
979
+ body.querySelectorAll('img[data-box]').forEach((im) => {
980
+ const [x, y, w, h] = im.dataset.box.split(',').map(Number), [fw, fh] = String(im.dataset.wh || '16,9').split(',').map(Number);
981
+ if (![x, y, w, h].every(Number.isFinite) || !(w > 0) || !(h > 0)) return;
982
+ const fr = im.closest('.vzr-crop'); if (fr) { const ar = String((w * (fw || 16)) / (h * (fh || 9))); fr.classList.add('boxed'); fr.style.setProperty('aspect-ratio', ar); fr.style.setProperty('--ar', ar); }
983
+ im.style.setProperty('width', (100 / w) + '%'); im.style.setProperty('height', (100 / h) + '%');
984
+ im.style.setProperty('left', (-x / w * 100) + '%'); im.style.setProperty('top', (-y / h * 100) + '%');
985
+ });
986
+ }
987
+ /** Hovering the bar (MpD1): a small preview of the capture there, the chapter's name and the time. */
988
+ function hoverBar(me, body) {
989
+ const bar = body.querySelector('.vzr-np:not(.vzr-ph1) .vzr-bar') || null, V = me.model && me.model.notes.replay; if (!bar || !V) return;
990
+ const hov = bar.parentElement.querySelector('.vzr-hov'); if (!hov) return;
991
+ bar.onmousemove = (e) => {
992
+ const r = bar.getBoundingClientRect(), f = Math.max(0, Math.min(1, (e.clientX - r.left) / r.width)), t = f * V.len, ci = V.chapterAt(t), si = V.shotAt(t);
993
+ const st = ci >= 0 ? V.strip[ci] : null, sh = si >= 0 ? V.shots[si] : null;
994
+ hov.querySelector('b').textContent = st ? st.text : ''; hov.querySelector('small').textContent = mmss(t);
995
+ const th = hov.querySelector('.vzr-hth');
996
+ if (sh && V.noteId) { if (th.dataset.t !== String(sh.t)) { th.dataset.t = String(sh.t); th.innerHTML = `<span class="vzr-crop"><img src="/api/notes/screen?id=${encodeURIComponent(V.noteId)}&amp;t=${sh.t}" alt=""${sh.box ? ` data-box="${sh.box.join(',')}" data-wh="${sh.w || 16},${sh.h || 9}"` : ''}></span>`; crop(th); } th.hidden = false; } else th.hidden = true;
997
+ hov.hidden = false;
998
+ const w = hov.offsetWidth || 160; hov.style.setProperty('left', Math.max(0, Math.min(r.width - w, e.clientX - r.left - w / 2)) + 'px');
999
+ };
1000
+ bar.onmouseleave = () => { hov.hidden = true; };
1001
+ }
1002
+ /** ‹ and ›: the start of the chapter before or after; ‹ goes back to the start of this one first when more than 3 s of it played. */
1003
+ function stepChapter(me, d) {
1004
+ const V = me.model && me.model.notes.replay; if (!V || !V.strip.length) return;
1005
+ const pos = posOf(V, me.ui), ci = V.chapterAt(pos);
1006
+ const to = d > 0 ? V.strip[Math.min(V.strip.length - 1, ci + 1)] : pos - V.strip[ci].from > 3000 ? V.strip[ci] : V.strip[Math.max(0, ci - 1)];
1007
+ seek(me, to.from, false);
889
1008
  }
890
1009
  /** Opens the correction of one kept point where the PM pressed it: "In this part" or the All kept fold. */
891
1010
  function startEdit(me, id, where) {
@@ -1062,11 +1181,15 @@ function ensurePlay(me) {
1062
1181
  function playUi(me) {
1063
1182
  const P = me.play, T = me.model.notes.replay; me.ui.pos = P.pos; me.ui.playing = P.on;
1064
1183
  const b = bodyEl(); if (!b) return;
1065
- const sh = T.shotAt(P.pos);
1066
- if (sh !== me.shotNow || P.on !== me.wasOn) { me.shotNow = sh; me.wasOn = P.on; return repaint(me); } // a new capture or Play/Pause: draw the tab again
1067
- b.querySelectorAll('[data-clk="head"]').forEach((h) => h.setAttribute('x', Math.min(100, P.pos / T.len * 100).toFixed(2) + '%')); // an SVG attribute: where the replay is
1184
+ const sh = T.shotAt(P.pos), ch = T.chapterAt(P.pos);
1185
+ // a new capture, a new chapter or Play/Pause: draw the card again (not while the word fix is open: it would close it)
1186
+ if ((sh !== me.shotNow || ch !== me.chapNow || P.on !== me.wasOn) && !me.ui.wpop) { me.shotNow = sh; me.chapNow = ch; me.wasOn = P.on; me.lineNow = lineAtPos(T, P.pos); return repaint(me); }
1187
+ const at = Math.min(100, P.pos / T.len * 100);
1188
+ b.querySelectorAll('[data-clk="head"]').forEach((h) => h.setAttribute('x', at.toFixed(2) + '%')); // an SVG attribute: where the replay is
1189
+ b.querySelectorAll('[data-clk="prog"]').forEach((r) => r.setAttribute('width', Math.max(0, Math.min(+r.dataset.w, at - +r.dataset.x)).toFixed(2) + '%'));
1068
1190
  b.querySelectorAll('[data-clk="pos"]').forEach((p) => { p.textContent = mmss(P.pos); });
1069
- b.querySelectorAll('.vzp-top').forEach((x) => { const on = T.stretchAt(P.pos) && +x.dataset.t === T.stretchAt(P.pos).from; if (on) x.setAttribute('aria-current', 'true'); else x.removeAttribute('aria-current'); });
1191
+ const li = lineAtPos(T, P.pos); // the caption follows the line now, as lyrics do
1192
+ if (li !== me.lineNow) { me.lineNow = li; b.querySelectorAll('[data-pl="cap"]').forEach((c) => { c.innerHTML = captionHtml(T, P.pos); }); }
1070
1193
  }
1071
1194
  function togglePlay(me) {
1072
1195
  const P = ensurePlay(me); if (!P) return;
@@ -66,6 +66,7 @@ export function cleanBlock(b) {
66
66
  if (b.schedule !== undefined) { const s = cleanSchedule(b.schedule); if (s) out.schedule = s; }
67
67
  for (const k of ['structure', 'design', 'change']) { const v = clip(b[k], 600); if (v) out[k] = v; }
68
68
  const secs = cleanSections(b.sections); if (secs) out.sections = secs;
69
+ const sm = out.design ? cleanSample(b.sample) : null; if (sm) out.sample = sm; // the file a look change follows (a deck, a screenshot)
69
70
  return out;
70
71
  }
71
72
  const p = b.proposal && typeof b.proposal === 'object' ? b.proposal : null;
@@ -37,8 +37,9 @@ export function chatOf(st = {}, id = '') {
37
37
  intro: 'Tell me what to change in ' + name + '. I pass it to the report\'s own session, and the draft beside us changes. Nothing is issued.',
38
38
  placeholder: 'Change the report',
39
39
  context: { title: name, subject: 'A report set up in PMOS (report id ' + id + ')' + (settings ? ': ' + settings : ''),
40
- say: 'When the PM asks to change the draft, answer in a few lines and end with ONE fenced code block with the language tag pmos-report holding {"kind":"report","id":"' + id
41
- + '","change":"the change, in the PM\'s words, complete"}. PMOS passes it to the report\'s own session as your answer lands, and the draft beside the chat changes; say so in one line. Do not change the draft yourself. The format, readers, schedule, look and sources change with the template icon in the report\'s header.',
40
+ say: 'A change to the draft text (a date, a status, a wording): end with ONE pmos-report block {"kind":"report","id":"' + id
41
+ + '","change":"the change, complete"}; it passes by itself. A look change (palette, tables, Gantt, a file to follow): {"kind":"report","id":"' + id
42
+ + '","design":"the look","sample":"<attached path>"}; the next look starts, and when the PM keeps it the draft is redrawn. Say which in one line.',
42
43
  facts: [['Report id', id]].concat(draft ? [['Latest draft', draft]] : []), files: draft ? [draft] : [] } };
43
44
  }
44
45
 
@@ -228,7 +229,10 @@ export function waitHtml(F = {}, face = '') {
228
229
  const d = F.drafting;
229
230
  if (d && d.state === 'held') return `<div class="rf-wait">${face}<p><b>The draft waits for a check of ${esc((d.sources || []).join(', '))}.</b><span>It starts by itself when the check passes.</span></p></div>`;
230
231
  if (d && d.state === 'ended') return `<div class="rf-wait" data-k="bad">${face}<p><b>The drafting session ended before a draft was written.</b><span>${esc(d.reason || '')}</span></p><button type="button" class="rf-ib pri vz-hv" data-rt="redraft" aria-label="Draft it again" title="Draft it again">${SV(ICON.go)}</button></div>`;
231
- if ((d && d.state === 'drafting') || (F.session && F.session.alive)) return `<div class="rf-wait" role="status">${face}<p><b>The report's own session is drafting it.</b><span>It opens here when it lands. Keep talking to Claude in the chat.</span></p></div>`;
232
+ if (d && d.state === 'drafting') return `<div class="rf-wait" role="status">${face}<p><b>The report's own session is drafting it.</b><span>It opens here when it lands. Keep talking to Claude in the chat.</span></p></div>`;
233
+ // an open session is not a draft being written (Wawan, 2026-11-04: "drafting it" stayed after the session declined): say what it is doing
234
+ if (F.asking) return `<div class="rf-wait" role="status">${face}<p><b>Your change is with the report's session.</b><span>Its answer shows here when it is done.</span></p></div>`;
235
+ if (F.answer) return `<div class="rf-wait">${face}<p><b>${esc(F.answer.text)}</b><span>No draft is open. Draft it now, or it drafts on its day.</span></p><button type="button" class="rf-ib pri vz-hv" data-rt="redraft" aria-label="Draft it now" title="Draft it now">${SV(ICON.go)}</button></div>`;
232
236
  return `<div class="rf-wait">${face}<p><b>No draft yet.</b><span>Draft it now, or it drafts on its day.</span></p><button type="button" class="rf-ib pri vz-hv" data-rt="redraft" aria-label="Draft it now" title="Draft it now">${SV(ICON.go)}</button></div>`;
233
237
  }
234
238