create-pmos 0.528.0 → 0.530.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 (40) hide show
  1. package/package.json +39 -35
  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 +38 -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 +20 -8
  12. package/workspace/guide/reports-set-up.md +3 -1
  13. package/workspace/lib/bg-queue.js +5 -1
  14. package/workspace/lib/jev-notes.js +58 -3
  15. package/workspace/lib/jev-screen.js +110 -0
  16. package/workspace/lib/meeting-replay.js +14 -2
  17. package/workspace/lib/meeting-suggest.js +105 -0
  18. package/workspace/lib/meeting-terms.js +57 -17
  19. package/workspace/lib/notes-screen.js +15 -1
  20. package/workspace/lib/report-chat.js +3 -3
  21. package/workspace/lib/routes-lab.js +44 -2
  22. package/workspace/lib/routes-meeting-points.js +27 -3
  23. package/workspace/lib/routes-meeting-replay.js +13 -2
  24. package/workspace/lib/routes-notes.js +25 -4
  25. package/workspace/lib/routes-report-flow.js +35 -5
  26. package/workspace/lib/typesafe.js +71 -5
  27. package/workspace/package-lock.json +2 -2
  28. package/workspace/package.json +1 -1
  29. package/workspace/premium-vw-meet-test.js +17 -11
  30. package/workspace/public/pm/cozy-lab-ins.js +13 -4
  31. package/workspace/public/pm/cozy-vw-mtg.css +122 -24
  32. package/workspace/public/pm/notes-screen-capture.js +5 -4
  33. package/workspace/public/pm/premium-meeting.js +2 -1
  34. package/workspace/public/pm/premium-mtg-replay.js +149 -73
  35. package/workspace/public/pm/premium-report-tab.js +33 -4
  36. package/workspace/public/pm/premium-vw-mtg.js +191 -53
  37. package/workspace/public/pm/report-chat.js +1 -0
  38. package/workspace/public/pm/report-flow.js +7 -3
  39. package/workspace/public/pm/view-notes.js +2 -2
  40. package/workspace/test-files.json +1 -0
@@ -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');
@@ -635,6 +644,21 @@ async function load(me, quiet) {
635
644
  edits: pts && pts.ok ? pts.edits || {} : (me.D && me.D.edits) || {}, lessons: pts && pts.ok ? pts.lessons || [] : (me.D && me.D.lessons) || [],
636
645
  terms: pts && pts.ok ? pts.terms || [] : (me.D && me.D.terms) || [] };
637
646
  draw(me);
647
+ if (rp && rp.ok && rp.jev_running) jevWatch(me, n.id);
648
+ }
649
+ /** Jev is cutting a past meeting's captures to the slide (MpJ): read the replay again every few seconds until it ends, so the
650
+ * crops show on this open, not the next. At most JEV_TRIES reads; the page draws the shots it has meanwhile. */
651
+ const JEV_EVERY = 4000, JEV_TRIES = 45;
652
+ function jevWatch(me, id, tries = 0) {
653
+ clearTimeout(me.jevT);
654
+ me.jevT = setTimeout(async () => {
655
+ if (MT !== me || !me.D || !me.n || me.n.id !== id) return;
656
+ const rp = await pmGet('/api/meeting-replay?' + q({ note: id }));
657
+ if (MT !== me || !me.D || !(rp && rp.ok)) return;
658
+ const rep = replayModel(rp); rep.raw = rp.keys || [];
659
+ me.D.rep = rep; draw(me);
660
+ if (rp.jev_running && tries + 1 < JEV_TRIES) jevWatch(me, id, tries + 1);
661
+ }, JEV_EVERY);
638
662
  }
639
663
  async function keyOf(text) {
640
664
  try {
@@ -706,14 +730,17 @@ function paint(me, body) {
706
730
  const line = body.querySelector('.czs-sum .czs-line > span:not(.czs-dot):not(.czs-r)'); if (line) line.dataset.clk = 'state';
707
731
  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
732
  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
733
+ crop(body);
734
+ hoverBar(me, body);
710
735
  body.onclick = (e) => press(me, e);
711
736
  body.onchange = (e) => change(me, e);
712
737
  // 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
738
  body.querySelectorAll('input[type="file"][data-a="matfile"]').forEach((i) => { i.onchange = (e) => { e.stopPropagation(); change(me, e); }; });
714
739
  body.oninput = (e) => input(me, e);
715
740
  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)
741
+ 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)
742
+ 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)
743
+ if (me.ui.wpop) placeWordPop(me, body);
717
744
  }
718
745
  /** The open popover goes in its anchor, drawn once per paint (its list is redrawn on its own while the PM types). */
719
746
  function placePop(me, body) {
@@ -727,8 +754,10 @@ function keydown(me, e, body) {
727
754
  if (e.key === 'Enter' && a === 'namein') { e.preventDefault(); nameIt(me, +e.target.dataset.i, e.target.value); }
728
755
  if (e.key === 'Enter' && a === 'minin') { e.preventDefault(); setMin(me, +e.target.dataset.i, e.target.value); }
729
756
  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); }
757
+ 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); }
758
+ if (e.key === 'Escape' && me.ui.wpop) { e.preventDefault(); e.stopPropagation(); me.ui.wpop = null; paint(me, body); return; }
759
+ if (e.key === 'Escape' && me.ui.chaps) { e.stopPropagation(); me.ui.chaps = false; paint(me, body); return; }
760
+ if (a === 'psel' && (e.key === 'Enter' || e.key === ' ')) { e.preventDefault(); selectPoint(me, e.target.dataset.id); return; }
732
761
  if (e.key === 'Escape' && me.ui.naming != null) { me.ui.naming = null; paint(me, body); }
733
762
  if (e.key === 'Escape' && me.ui.pop) { e.stopPropagation(); me.ui.pop = ''; paint(me, body); }
734
763
  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 +779,8 @@ function tick(me) {
750
779
  async function press(me, e) {
751
780
  const pa = e.target.closest('[data-czs-act]'); // the summary's one primary
752
781
  const t = pa || e.target.closest('[data-a]');
782
+ 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
783
+ if (me.ui.chaps && !e.target.closest('.vzr-chs, [data-a="chaps"]')) { me.ui.chaps = false; if (!t) return repaint(me); }
753
784
  if (!t) { if (me.ui.pop && !e.target.closest('.vzm-pop')) { me.ui.pop = ''; repaint(me); } return; }
754
785
  if (t.disabled) return;
755
786
  const a = pa ? pa.dataset.czsAct : t.dataset.a, ui = me.ui, m = me.model, C = rec();
@@ -801,13 +832,18 @@ async function press(me, e) {
801
832
  /* Replay */
802
833
  case 'play': return togglePlay(me);
803
834
  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); }
835
+ case 'skip': { const P = ensurePlay(me); if (P) seek(me, posOf(m.notes.replay, ui) + Number(t.dataset.d || 0), false); return; }
836
+ case 'chprev': case 'chnext': return stepChapter(me, a === 'chnext' ? 1 : -1);
837
+ case 'chgo': ui.chaps = false; return seek(me, +t.dataset.t, true);
838
+ case 'chaps': ui.chaps = !ui.chaps; return repaint(me);
839
+ case 'caps': ui.caps = !ui.caps; return repaint(me);
804
840
  case 'seekto': if (t.dataset.t !== '') return seek(me, +t.dataset.t, true); return;
805
841
  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
842
  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;
843
+ 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
844
  case 'leftout': ui.leftOpen = !ui.leftOpen; return repaint(me);
809
845
  /* 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);
846
+ 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
847
  case 'pplay': return seek(me, +t.dataset.t, true);
812
848
  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
849
  case 'pwrong': ui.edit = null; ui.draft = null; return verdict(me, [t.dataset.id], 'wrong', 'Moved to Left out.', true);
@@ -815,9 +851,11 @@ async function press(me, e) {
815
851
  case 'allshots': ui.allShots = !ui.allShots; return repaint(me);
816
852
  case 'sumall': ui.sumAll = !ui.sumAll; return repaint(me);
817
853
  /* 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; }
854
+ case 'wfix': return openWordPop(me, t, t.dataset.w, { means: t.dataset.g, from: ['fix'], alts: [] });
855
+ case 'wfok': { const f = t.closest('.vzr-wpop'), i = f && f.querySelector('input'); return f ? fixWord(me, f.dataset.w, i ? i.value : '') : null; }
856
+ 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
857
  case 'wundo': return forgetWord(me, t.dataset.id);
858
+ case 'wkeep': return keepWord(me, t.dataset.id, t.dataset.v === '1');
821
859
  case 'confirmall': return bulk(me, 'confirmed', m.notes.replay.points.filter((p) => !p.verdict).map((p) => p.id));
822
860
  case 'unwrong': return verdict(me, [t.dataset.id], null, 'It is back in the kept points.');
823
861
  case 'ptask': return bulk(me, 'task', [t.dataset.id]);
@@ -846,46 +884,142 @@ function selectPoint(me, id) {
846
884
  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
885
  repaint(me);
848
886
  }
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. */
887
+ /* ── 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 ── */
888
+ /** 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
889
+ * summary, Next steps, Facts, Left out, the chapter names, the transcript). */
890
+ 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';
891
+ const WORD_OK = /^[\p{L}\p{N}][\p{L}\p{N} .'&+-]*$/u;
892
+ const textsFor = (me) => ownTexts(me.model);
893
+ /** 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. */
894
+ function openWordPop(me, node, heard, sug) {
895
+ const w = String(heard || '').trim(), V = me.model && me.model.notes.replay; if (!w || !me.n || !V) return;
896
+ const r = node && node.getBoundingClientRect ? node.getBoundingClientRect() : null;
897
+ 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 };
898
+ const b = bodyEl(); if (b) placeWordPop(me, b);
899
+ if (!me.ui.wpop.sug) askSpelling(me, w);
900
+ }
901
+ function placeWordPop(me, body) {
902
+ const W = me.ui.wpop; if (!W) return;
903
+ let el = body.querySelector('.vzr-wpop');
904
+ const box = document.createElement('div'); box.innerHTML = wordPopHtml(W.heard, W.sug, W.P);
905
+ const fresh = box.firstElementChild;
906
+ if (el) el.replaceWith(fresh); else body.append(fresh);
907
+ el = fresh;
908
+ const phone = window.matchMedia && window.matchMedia('(max-width: 640px)').matches;
909
+ el.classList.toggle('sheet', !!phone);
910
+ if (!phone && W.rect) { // under the words, its arrow at their start; above them when there is no room below
911
+ const vw = window.innerWidth, vh = window.innerHeight, w = Math.min(380, vw - 24);
912
+ const left = Math.max(12, Math.min(vw - w - 12, W.rect.left - 26));
913
+ el.style.setProperty('width', w + 'px'); el.style.setProperty('left', left + 'px');
914
+ const below = W.rect.bottom + 10, h = el.offsetHeight || 260;
915
+ 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');
916
+ el.style.setProperty('--arr', Math.max(16, Math.min(w - 28, W.rect.left - left + 8)) + 'px');
917
+ }
918
+ const i = el.querySelector('input'); if (i && document.activeElement !== i) { i.focus(); i.select(); }
919
+ }
920
+ async function askSpelling(me, heard) {
921
+ const m = me.model, F = me.D && me.D.F, mats = (m && m.material) || [];
922
+ 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);
923
+ const qs = new URLSearchParams({ note: me.n.id, heard });
924
+ invite.forEach((t) => qs.append('invite', t)); (m.people || []).slice(0, 40).forEach((p) => qs.append('people', p));
925
+ const r = await pmGet('/api/meeting-points/suggest?' + qs.toString());
926
+ 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)
927
+ const b = bodyEl(), i = b && b.querySelector('.vzr-wpop input');
928
+ if (i && i.value.trim() !== heard) return; // the PM is already typing a fix
929
+ W.sug = r.suggest; if (b) placeWordPop(me, b);
930
+ }
931
+ /** A selection of one to four words in the notes: the words stay marked and the popup opens under them. */
863
932
  function selectWord(me) {
864
933
  const sel = window.getSelection && window.getSelection(); if (!sel || sel.isCollapsed || !sel.rangeCount || me.ui.edit) return;
865
934
  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;
935
+ if (tn !== r.endContainer || tn.nodeType !== 3 || !tn.parentElement || !tn.parentElement.closest(WORD_ZONES) || tn.parentElement.closest('.vzr-wpop')) return;
936
+ markWord(me, r, tn, sel);
937
+ }
938
+ /** 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). */
939
+ function wordAt(me, e) {
940
+ let r = null;
941
+ if (document.caretRangeFromPoint) r = document.caretRangeFromPoint(e.clientX, e.clientY);
942
+ else if (document.caretPositionFromPoint) { const c = document.caretPositionFromPoint(e.clientX, e.clientY); if (c) { r = document.createRange(); r.setStart(c.offsetNode, c.offset); } }
943
+ const tn = r && r.startContainer; if (!tn || tn.nodeType !== 3 || !tn.parentElement || !tn.parentElement.closest(WORD_ZONES)) return;
944
+ const t = tn.textContent, i = r.startOffset; let a = i, z = i;
945
+ while (a > 0 && /[\p{L}\p{N}'-]/u.test(t[a - 1])) a--;
946
+ while (z < t.length && /[\p{L}\p{N}'-]/u.test(t[z])) z++;
947
+ if (z <= a) return;
948
+ const rg = document.createRange(); rg.setStart(tn, a); rg.setEnd(tn, z);
949
+ markWord(me, rg, tn, window.getSelection && window.getSelection());
950
+ }
951
+ function markWord(me, r, tn, sel) {
867
952
  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;
953
+ if (!w || w.length > 60 || w.split(/\s+/).length > 4 || !WORD_OK.test(w)) return;
869
954
  const lead = raw.length - raw.trimStart().length, trail = raw.length - raw.trimEnd().length;
870
955
  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);
956
+ const mark = document.createElement('span'); mark.className = 'vzr-selw'; r.surroundContents(mark); if (sel) sel.removeAllRanges();
957
+ const fx = mark.closest('.vzr-fx');
958
+ openWordPop(me, mark, fx && fx.dataset.w ? fx.dataset.w : w, null);
873
959
  }
874
960
  async function fixWord(me, heard, means) {
875
961
  const w = String(heard || '').trim(), to = String(means || '').replace(/\s+/g, ' ').trim();
962
+ me.ui.wpop = null;
876
963
  if (!w || !to || to === w || !me.n) return repaint(me);
877
- const places = placesOf(wordTexts(me), w);
964
+ const V = me.model.notes.replay, places = placesBy(V, textsFor(me), w).n;
878
965
  const r = await pmPost('/api/meeting-points/term', { note: me.n.id, heard: w, means: to });
879
966
  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));
967
+ me.D.terms = r.terms || me.D.terms; me.ui.strip = 'names'; // the word lands in Names and terms, with the ✦ ask (MpW2)
968
+ hintSeen(me);
969
+ draw(me);
970
+ toastSay(me, places > 1 ? 'Fixed in ' + places + ' places' : 'Fixed', async () => forgetWord(me, r.term && r.term.id, true));
882
971
  }
883
972
  async function forgetWord(me, id, undo) {
884
973
  if (!id) return;
885
974
  const f = await pmPost('/api/meeting-points/forget', { lesson: id });
886
975
  if (!f || !f.ok) return say(me, 'Not undone: ' + ((f && f.error) || 'no answer'), 'bad');
887
976
  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.');
977
+ toastSay(me, undo ? 'Undone. The words show as they were heard.' : 'The fix is gone. The words show as they were heard.');
978
+ }
979
+ /** The ✦ ask: ✓ remembers the fix for the next meetings, ✕ keeps it in this meeting only. */
980
+ async function keepWord(me, id, yes) {
981
+ const r = await pmPost('/api/meeting-points/keep', { lesson: id, keep: yes });
982
+ if (!r || !r.ok) return say(me, 'Not saved: ' + ((r && r.error) || 'no answer'), 'bad');
983
+ me.D.terms = r.terms || me.D.terms; draw(me);
984
+ toastSay(me, yes ? 'Remembered. Your next meetings spell it “' + (r.term ? r.term.means : '') + '”.' : 'Kept for this meeting only.');
985
+ }
986
+ /** The word fix's message is a toast at the bottom of the card (MpW2), with Undo when there is one. */
987
+ 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); }
988
+ /** The one-time hint under the kept points, "Select a word …": shown until the first fix, then never again on this machine. */
989
+ const HINT_KEY = 'pmos.mtg.wordHint';
990
+ function hintSeen(me) { me.ui.hint = false; try { localStorage.setItem(HINT_KEY, '1'); } catch { /* no storage: it shows again next time */ } }
991
+ const hintOn = () => { try { return localStorage.getItem(HINT_KEY) !== '1'; } catch { return false; } };
992
+ /** A capture Jev boxed shows only its box (MpJ): the image is scaled and moved inside its frame so the box fills it. */
993
+ function crop(body) {
994
+ body.querySelectorAll('img[data-box]').forEach((im) => {
995
+ const [x, y, w, h] = im.dataset.box.split(',').map(Number), [fw, fh] = String(im.dataset.wh || '16,9').split(',').map(Number);
996
+ if (![x, y, w, h].every(Number.isFinite) || !(w > 0) || !(h > 0)) return;
997
+ 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); }
998
+ im.style.setProperty('width', (100 / w) + '%'); im.style.setProperty('height', (100 / h) + '%');
999
+ im.style.setProperty('left', (-x / w * 100) + '%'); im.style.setProperty('top', (-y / h * 100) + '%');
1000
+ });
1001
+ }
1002
+ /** Hovering the bar (MpD1): a small preview of the capture there, the chapter's name and the time. */
1003
+ function hoverBar(me, body) {
1004
+ const bar = body.querySelector('.vzr-np:not(.vzr-ph1) .vzr-bar') || null, V = me.model && me.model.notes.replay; if (!bar || !V) return;
1005
+ const hov = bar.parentElement.querySelector('.vzr-hov'); if (!hov) return;
1006
+ bar.onmousemove = (e) => {
1007
+ 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);
1008
+ const st = ci >= 0 ? V.strip[ci] : null, sh = si >= 0 ? V.shots[si] : null;
1009
+ hov.querySelector('b').textContent = st ? st.text : ''; hov.querySelector('small').textContent = mmss(t);
1010
+ const th = hov.querySelector('.vzr-hth');
1011
+ 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;
1012
+ hov.hidden = false;
1013
+ const w = hov.offsetWidth || 160; hov.style.setProperty('left', Math.max(0, Math.min(r.width - w, e.clientX - r.left - w / 2)) + 'px');
1014
+ };
1015
+ bar.onmouseleave = () => { hov.hidden = true; };
1016
+ }
1017
+ /** ‹ 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. */
1018
+ function stepChapter(me, d) {
1019
+ const V = me.model && me.model.notes.replay; if (!V || !V.strip.length) return;
1020
+ const pos = posOf(V, me.ui), ci = V.chapterAt(pos);
1021
+ 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)];
1022
+ seek(me, to.from, false);
889
1023
  }
890
1024
  /** Opens the correction of one kept point where the PM pressed it: "In this part" or the All kept fold. */
891
1025
  function startEdit(me, id, where) {
@@ -1062,11 +1196,15 @@ function ensurePlay(me) {
1062
1196
  function playUi(me) {
1063
1197
  const P = me.play, T = me.model.notes.replay; me.ui.pos = P.pos; me.ui.playing = P.on;
1064
1198
  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
1199
+ const sh = T.shotAt(P.pos), ch = T.chapterAt(P.pos);
1200
+ // a new capture, a new chapter or Play/Pause: draw the card again (not while the word fix is open: it would close it)
1201
+ 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); }
1202
+ const at = Math.min(100, P.pos / T.len * 100);
1203
+ b.querySelectorAll('[data-clk="head"]').forEach((h) => h.setAttribute('x', at.toFixed(2) + '%')); // an SVG attribute: where the replay is
1204
+ 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
1205
  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'); });
1206
+ const li = lineAtPos(T, P.pos); // the caption follows the line now, as lyrics do
1207
+ if (li !== me.lineNow) { me.lineNow = li; b.querySelectorAll('[data-pl="cap"]').forEach((c) => { c.innerHTML = captionHtml(T, P.pos); }); }
1070
1208
  }
1071
1209
  function togglePlay(me) {
1072
1210
  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
 
@@ -1228,8 +1228,8 @@ function openSettings(ctx, n) {
1228
1228
  const csw = field({ label: 'Check notes against this repository with Jev', kind: 'check', value: !!(j && j.check), onCommit: (v) => setSwitch(ctx, { check: v }).then(() => { h.close(); openSettings(ctx, n); }), attrs: { 'data-nt-sw-check': '' } });
1229
1229
  parts.push(el('div', { class: 'nt-set' }, el('div', { class: 'nt-set__head' }, 'Jev'), jsw, csw,
1230
1230
  el('p', { class: 'nt-hint', 'data-nt-gates': '' }, j && j.paused ? j.paused : j && j.sentence ? j.sentence + ' Lines get the local label and the local repository match.'
1231
- : j && j.check_open ? 'Each line and, for a captured line, one repository passage go to TypeSafe. Audio, names and private notes never do.'
1232
- : j && j.label_open ? 'Each line goes to TypeSafe for its label. Audio, names and private notes never do.' : 'Off. Lines get the local label and the local repository match.')));
1231
+ : j && j.check_open ? 'Each line and, for a captured line, one repository passage go to TypeSafe, and each screen capture so it can be cut to the slide. Audio, names and private notes never do.'
1232
+ : j && j.label_open ? 'Each line goes to TypeSafe for its label, and each screen capture so it can be cut to the slide. Audio, names and private notes never do.' : 'Off. Lines get the local label and the local repository match.')));
1233
1233
  }
1234
1234
  parts.push(el('div', { class: 'nt-set' }, el('div', { class: 'nt-set__head' }, 'This note'),
1235
1235
  n.private ? el('p', { class: 'nt-hint' }, 'Private: it is not recorded and nothing from it goes to Jev or to any agent.') : null,
@@ -76,6 +76,7 @@
76
76
  "jev-categorize.test.js",
77
77
  "jev-status.test.js",
78
78
  "jev-route.test.js",
79
+ "jev-screen.test.js",
79
80
  "repo-watch.test.js",
80
81
  "markdown.test.mjs",
81
82
  "start-policy.test.js",