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
@@ -6,19 +6,27 @@
6
6
  * are one row, the To do under it with Make task. One ✓ per row confirms it. No checkbox, no filter, no bulk bar.
7
7
  * Under the points, ONE strip: Left out, Next steps, Since the last one, Facts, Names and terms, Prep and material,
8
8
  * Transcript (premium-vw-mtg.js fills the ones it owns). One opens at a time.
9
- * RIGHT the evidence of the selected point: the screen at that moment, Play from its time, Fix (the edit card), Not right,
10
- * the quote, and a filmstrip of the screen captures near it (+N shows all). Jev for Images is not in PMOS yet, so the
11
- * filmstrip shows every kept capture, not only the shared-screen frames.
12
- * DOCK the timeline at the bottom: Play, the time of the length, the full chapter names over the strip, a dot per kept point.
13
- * WORD FIX (MvF) a word heard wrong is fixed in place: the PM selects it (or presses an underlined one), types the fix, ✓ or Enter.
14
- * It shows fixed in every place this note shows it, and other notes underline the heard words with the fix as the guess.
15
- * Undo forgets it. lib/meeting-learn.js term keeps it; the note's own files are not rewritten.
16
- * Phone: the phone's Replay (phoneHtml), unchanged.
9
+ * RIGHT the player (batch 27 item 12, "Meeting player": MpD1, MpD2, MpD4; adopted from music and podcast players). The meeting is
10
+ * audio plus screen captures, so the capture at the playhead is the artwork, with whose screen and when, and an icon that
11
+ * shows the whole frame. Under it the chapter's name and "chapter N of M"; ONE thin bar where the chapters are gaps (no text
12
+ * on the bar) and each kept point is its kind's icon inside its chapter; hovering the bar shows the capture there, the
13
+ * chapter and the time. Elapsed left, total right. One centred row: ‹ back 15 s, play, forward 15 s ›; the speed chip
14
+ * (1×, 1.5×, 2×) left; a captures icon (the row of captures, hidden by default) and a chapters icon (the list: name, its
15
+ * kept points, its start; press one to play from there) right. Under it the caption: the line now, larger, with its
16
+ * speaker, the lines before and after faded, as a music player shows lyrics. The old bottom dock is gone.
17
+ * JEV CROP (MpJ) when Jev for images read a capture, the artwork and the captures row show only the box around the slide or
18
+ * document, and a capture of faces only is left out (the card keeps the last slide). Every crop keeps the whole-frame icon:
19
+ * Jev's own confidence cannot flag a bad box. A capture Jev did not read shows whole, as before.
20
+ * WORD FIX (MvF, batch 27 word fix A: MpW1, MpW2) a word heard wrong is fixed in place: the PM selects it or double-clicks it in any
21
+ * text of the notes; a popup offers the ✦ spelling when one is found, the places it changes, ✓. It shows fixed in every
22
+ * place this note shows it. Remembering it for later notes waits for the ✦ ask in Names and terms. Undo forgets it.
23
+ * lib/meeting-terms.js keeps it; the note's own files are not rewritten.
24
+ * Phone (MpD3): the player without the captures icon and the chapter steps, then the kept points.
17
25
  *
18
26
  * DATA (nothing is invented): rep = premium-meeting.js replayModel(GET /api/meeting-replay) with rep.raw = the answer's keys (for the owner,
19
27
  * due and state); n = the note (GET /api/notes/one) for the remarks of the live transcript; points, edits, lessons, terms = GET /api/meeting-points.
20
28
  * replayView(rep, { n, points, edits, lessons, terms, now }) → the view model
21
- * replayHtml(V, ui) → the desk and the phone. ui = { pos, playing, speed, pt (the selected point), strip, allShots, leftOpen, edit, draft } */
29
+ * replayHtml(V, ui) → the desk and the phone. ui = { pos, playing, speed, pt (the selected point), strip, caps, chaps, allShots, edit, draft } */
22
30
 
23
31
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
24
32
  const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
@@ -101,7 +109,10 @@ export function replayView(rep, { n = null, points = {}, edits = {}, lessons = [
101
109
  const tot = rep.stretches.reduce((s, x) => s + (x.to - x.from), 0) || 1;
102
110
  const strip = rep.stretches.map((x, i) => ({ from: x.from, to: x.to, w: Math.max(1, x.to - x.from) / tot, c: i % 2, text: clip(x.text || 'Topic ' + (i + 1), 40), min: Math.max(1, Math.round((x.to - x.from) / 60000)) }));
103
111
  const whoAt = (t) => { let w = ''; for (const l of rep.lines) { if (l.t <= t + NEAR) w = l.who; else break; } return w; };
104
- const shots = rep.shots.map((s, i) => ({ i, t: s.t, time: mmss(s.t), why: clip(s.why, 80), who: whoAt(s.t) }));
112
+ /* a capture Jev called faces only is left out; one it boxed shows only the box (MpJ). Jev not asked: the capture shows whole, as before */
113
+ const shots = rep.shots.map((s, i) => ({ i, t: s.t, time: mmss(s.t), why: clip(s.why, 80), who: whoAt(s.t), w: s.w || 0, h: s.h || 0,
114
+ faces: !!(s.jev && s.jev.kind === 'faces'), box: s.jev && Array.isArray(s.jev.box) && s.jev.box.length === 4 ? s.jev.box : null }));
115
+ const caps = shots.filter((x) => !x.faces);
105
116
  /* a decision and the To do that follows it are one row: the To do said within FOLLOW_MS after it, in the same topic */
106
117
  const topOf = (t) => strip.find((x) => t >= x.from && t < x.to) || null;
107
118
  const rows = [], under = new Map();
@@ -115,14 +126,16 @@ export function replayView(rep, { n = null, points = {}, edits = {}, lessons = [
115
126
  const noteId = rep.noteId || (n && n.id) || '';
116
127
  return {
117
128
  rows: rows.map((r) => ({ ...r, follow: under.get(r.id) || null })), W: wordsOf(terms, noteId),
118
- len: rep.len, lenText: mmss(rep.len), noteId, audio: rep.audio, strip, shots,
129
+ len: rep.len, lenText: mmss(rep.len), noteId, audio: rep.audio, strip, shots, caps,
130
+ lines: (rep.lines || []).filter((l) => l && Number.isFinite(l.t) && l.text).map((l) => ({ t: l.t, who: clip(l.who, 30), text: clip(l.text, 400) })),
119
131
  points: kept, left, raised, toConfirm: kept.filter((x) => !x.verdict).length,
120
132
  seg: [['all', 'All', kept.length], ['decision', 'Decided', count('decision')], ['action', 'To do', count('action')], ['question', 'Open', count('question')]],
121
133
  keptLine: raised > kept.length ? 'Kept ' + kept.length + ' of ' + raised + ' raised: ' + KEPT_WHY : kept.length ? 'Kept ' + kept.length + ': ' + KEPT_WHY : 'Nothing was agreed, owned or left open',
122
134
  leftLine: left.length ? 'Left out · ' + left.length + (left.length === 1 ? ' remark' : ' remarks') : '',
123
135
  leftEg: left.length ? 'e.g. “' + clip(left[0].text, 50) + '”, ' + left[0].why : '',
124
136
  stretchAt: (pos) => strip.find((x) => pos >= x.from && pos < x.to) || strip[strip.length - 1] || null,
125
- shotAt: (pos) => { let k = -1; shots.forEach((s, i) => { if (s.t <= pos) k = i; }); return k < 0 && shots.length ? 0 : k; },
137
+ shotAt: (pos) => { let k = -1; shots.forEach((s, i) => { if (s.t <= pos && !s.faces) k = i; }); return k < 0 && caps.length ? caps[0].i : k; },
138
+ chapterAt: (pos) => { const i = strip.findIndex((x) => pos >= x.from && pos < x.to); return i >= 0 ? i : strip.length ? (pos >= strip[strip.length - 1].from ? strip.length - 1 : 0) : -1; },
126
139
  };
127
140
  }
128
141
 
@@ -132,7 +145,7 @@ export function wordsOf(terms, noteId) {
132
145
  const all = (Array.isArray(terms) ? terms : []).filter((x) => x && x.heard && x.means);
133
146
  const fix = all.filter((x) => x.note === noteId), seen = new Set(fix.map((x) => x.heard.toLowerCase()));
134
147
  const guess = [];
135
- for (const x of [...all].reverse()) { const k = x.heard.toLowerCase(); if (x.note === noteId || seen.has(k)) continue; seen.add(k); guess.push(x); }
148
+ for (const x of [...all].reverse()) { const k = x.heard.toLowerCase(); if (x.note === noteId || seen.has(k) || (x.keep && x.keep !== 'yes')) continue; seen.add(k); guess.push(x); } // only a fix the PM said to remember reaches other notes
136
149
  return { fix, guess };
137
150
  }
138
151
  const reEsc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -147,7 +160,7 @@ export function wx(W, text) {
147
160
  t.replace(wordRe(all.map((x) => x.heard)), (m, pre, word, off) => {
148
161
  const at = off + pre.length, x = all.find((y) => y.heard.toLowerCase() === word.toLowerCase());
149
162
  out += esc(t.slice(last, at));
150
- out += x.k === 'fix' ? `<span class="vzr-fx" title="${esc('Heard as “' + word + '”')}">${esc(x.means)}</span>`
163
+ out += x.k === 'fix' ? `<span class="vzr-fx" data-w="${esc(word)}" title="${esc('Heard as “' + word + '”')}">${esc(x.means)}</span>`
151
164
  : `<button type="button" class="vzr-fl" data-a="wfix" data-w="${esc(word)}" data-g="${esc(x.means)}" title="${esc('Heard as “' + word + '”. Likely ' + x.means + ', a word you fixed before.')}">${esc(word)}</button>`;
152
165
  last = at + word.length;
153
166
  return m;
@@ -159,23 +172,54 @@ export function placesOf(texts, heard) {
159
172
  const h = String(heard || '').trim(); if (!h) return 0;
160
173
  return (texts || []).reduce((n, t) => n + (String(t == null ? '' : t).match(wordRe([h])) || []).length, 0);
161
174
  }
162
- /** The texts of the view that a word fix reaches: the points, their follow-ups and quotes, the chapter names, Left out. */
163
- export const viewTexts = (V) => V ? [].concat(V.points.map((p) => p.text), V.points.map((p) => p.quote), V.strip.map((s) => s.text), V.left.map((x) => x.text)) : [];
164
- /** The inline field of a word fix: the best guess, ✓ (Enter) fixes it in every place, Escape leaves it. */
165
- export const wordFieldHtml = (heard, guess, places) => `<span class="vzr-fe" data-w="${esc(heard)}"><input data-a="wfin" aria-label="${esc('Fix “' + heard + '”')}" value="${esc(guess || heard)}" maxlength="60" spellcheck="false"><button type="button" class="vzr-ok" data-a="wfok" aria-label="${esc(places > 1 ? 'Fix it in ' + places + ' places' : 'Fix it')}" title="${esc(places > 1 ? 'Fix it in ' + places + ' places' : 'Fix it')}">${ic('chk', 14)}</button></span>`;
175
+ /** The texts of the view that a word fix reaches: the points, their follow-ups, the chapter names, Left out. */
176
+ export const viewTexts = (V) => V ? [].concat(V.points.map((p) => p.text), V.strip.map((s) => s.text), V.left.map((x) => x.text)) : [];
177
+ const KIND_PLACE = [['points', 'kept point', 'kept points'], ['steps', 'next step', 'next steps'], ['facts', 'fact', 'facts'], ['left', 'Left out', 'Left out'],
178
+ ['lines', 'transcript line', 'transcript lines'], ['summary', 'in the summary', 'in the summary'], ['chapters', 'chapter name', 'chapter names']];
179
+ /** Where a word fix changes the words (MpW1 "12 places in this meeting" and its breakdown): the count per kind of text. o = the page's own
180
+ * texts { summary, steps, facts, lines }. → { n, by: [{ k, n, text }] } with kinds that hold none left out (zero is silence). Pure. */
181
+ export function placesBy(V, o, heard) {
182
+ const T = { points: V ? V.points.map((p) => p.text) : [], chapters: V ? V.strip.map((x) => x.text) : [], left: V ? V.left.map((x) => x.text) : [],
183
+ summary: [o && o.summary || ''], steps: (o && o.steps) || [], facts: (o && o.facts) || [], lines: (o && o.lines) || [] };
184
+ const by = KIND_PLACE.map(([k, one, many]) => { const n = placesOf(T[k], heard); return { k, n, text: k === 'summary' ? many : n + ' ' + (n === 1 ? one : many) }; }).filter((x) => x.n);
185
+ return { n: by.reduce((a, x) => a + x.n, 0), by };
186
+ }
187
+ /** The word fix's popup (MpW1; a bottom sheet on a phone, MpWPhone): "Heard as …", the field with the ✦ spelling when one was found, ✓; the
188
+ * ✦ line with where the spelling comes from and two other spellings to press; how many places in this meeting, by kind. Escape leaves it.
189
+ * sug = lib/meeting-suggest.js suggest() (null: no ✦ line, the field holds the heard words), P = placesBy(). */
190
+ export function wordPopHtml(heard, sug, P) {
191
+ const h = String(heard || ''), best = sug && sug.means ? sug.means : '', n = P ? P.n : 0;
192
+ const from = sug ? [sug.from.includes('fix') ? 'you fixed it that way before' : '', sug.from.includes('invite') ? 'the invite' : '', sug.from.includes('repo') ? (sug.file ? sug.file : 'your repository') : ''].filter(Boolean) : [];
193
+ const why = !best ? '' : sug.from.includes('fix') && from.length === 1 ? 'you fixed it that way before.' : from.filter((x) => !/^you fixed/.test(x)).join(' and ') + (from.filter((x) => !/^you fixed/.test(x)).length > 1 ? ' spell' : ' spells') + ' it that way' + (sug.from.includes('fix') ? ', and you fixed it that way before.' : '.');
194
+ const alts = best ? [best].concat(sug.alts || [], [h]).filter((x, i, a) => x && a.indexOf(x) === i).slice(0, 3) : [];
195
+ const label = n > 1 ? 'Fix it in ' + n + ' places' : 'Fix it';
196
+ return `<div class="vzr-wpop" role="dialog" aria-label="${esc('Fix “' + h + '”')}" data-w="${esc(h)}"><span class="vzr-grab" aria-hidden="true"></span><p class="vzr-wl">Heard as “${esc(h)}”</p>`
197
+ + `<div class="vzr-wf"><input data-a="wfin" aria-label="${esc('Fix “' + h + '”')}" value="${esc(best || h)}" maxlength="60" spellcheck="false"><button type="button" class="vzr-go" data-a="wfok" aria-label="${esc(label)}" title="${esc(label)}">${ic('chk', 16)}</button></div>`
198
+ + (best ? `<p class="vzr-sug"><span class="vzr-star" aria-hidden="true">✦</span><span>Claude suggests <b>${esc(best)}</b>: ${esc(why)}</span></p><div class="vzr-alt" role="group" aria-label="Other spellings">${alts.map((x, i) => `<button type="button" data-a="walt" data-v="${esc(x)}" aria-pressed="${i === 0}">${esc(x)}</button>`).join('')}</div>` : '')
199
+ + (n ? `<p class="vzr-scope"><b>${n === 1 ? '1 place' : n + ' places'}</b> in this meeting</p>${P.by.length > 1 || (P.by[0] && P.by[0].k !== 'summary') ? `<div class="vzr-brk">${P.by.map((x) => `<span>${esc(x.text)}</span>`).join('')}</div>` : ''}` : '')
200
+ + '<i class="vzr-arr" aria-hidden="true"></i></div>';
201
+ }
166
202
 
167
203
  /* ── html (pure) ── */
168
- const P = { play: 'M8 5v14l11-7z', pause: 'M8 5h3v14H8zM13 5h3v14h-3z', big: 'M14 4h6v6M10 20H4v-6M20 4l-7 7M4 20l7-7', chev: 'M10 7l5 5-5 5', chk: 'M5 12l5 5 9-10', task: 'M5 12l4 4L19 6', x: 'M6 6l12 12M18 6L6 18', bin: 'M5 7h14M10 7V4h4v3M7 7l1 13h8l1-13',
169
- box: 'M5 5h14v14H5z', ask: 'M9.2 9.2a2.8 2.8 0 1 1 3.9 2.6c-.7.3-1.1 1-1.1 1.7v.5M12 17.2v.01', pen: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4', no: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM5.6 5.6l12.8 12.8', more: 'M5 12h.01M12 12h.01M19 12h.01' };
204
+ const P = { play: 'M8 5v14l11-7z', pause: 'M8 5h3v14H8zM13 5h3v14h-3z', big: 'M14 4h6v6M10 20H4v-6M20 4l-7 7M4 20l7-7', chev: 'M10 7l5 5-5 5', prev: 'M14 7l-5 5 5 5', chk: 'M5 12l5 5 9-10', task: 'M5 12l4 4L19 6', x: 'M6 6l12 12M18 6L6 18', bin: 'M5 7h14M10 7V4h4v3M7 7l1 13h8l1-13',
205
+ box: 'M5 5h14v14H5z', ask: 'M9.2 9.2a2.8 2.8 0 1 1 3.9 2.6c-.7.3-1.1 1-1.1 1.7v.5M12 17.2v.01', pen: 'M4 20h4L19 9l-4-4L4 16zM13.5 6.5l4 4', no: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18zM5.6 5.6l12.8 12.8', more: 'M5 12h.01M12 12h.01M19 12h.01',
206
+ caps: 'M4 6h16v12H4zM4 16l4.5-4.5 4 4 3-3L20 17M9 9.5h.01', chaps: 'M9 6h11M9 12h11M9 18h11M4.5 6h.01M4.5 12h.01M4.5 18h.01', undo: 'M9 14L4 9l5-5M4 9h9a6 6 0 0 1 0 12h-3' };
170
207
  const ic = (k, w = 16, cls = '') => `<svg aria-hidden="true" width="${w}" height="${w}" viewBox="0 0 24 24"${cls ? ` class="${cls}"` : ''}><path d="${P[k]}"/></svg>`;
208
+ /** Back or forward 15 seconds (MpD1): the round arrow with 15 in it. */
209
+ const skipIc = (fwd) => `<svg aria-hidden="true" width="28" height="28" viewBox="0 0 24 24"><g${fwd ? ' transform="translate(24 0) scale(-1 1)"' : ''}><path d="M4.5 12a7.5 7.5 0 1 0 2.4-5.5"/><path d="M4 3.5v4h4"/></g><g transform="scale(0.577)"><text x="20.8" y="26.3" text-anchor="middle" fill="currentColor" stroke="none">15</text></g></svg>`;
171
210
  const pct = (x) => Math.min(100, Math.max(0, x)).toFixed(2);
172
- const img = (V, t) => (V.noteId ? `<img src="/api/notes/screen?id=${encodeURIComponent(V.noteId)}&amp;t=${t}" alt="" loading="lazy">` : '');
211
+ /** A capture as an image. A capture Jev boxed carries its box; the page crops it to the box (premium-vw-mtg.js crop). */
212
+ const img = (V, t, sh) => (V.noteId ? `<img src="/api/notes/screen?id=${encodeURIComponent(V.noteId)}&amp;t=${t}" alt="" loading="lazy"${sh && sh.box ? ` data-box="${sh.box.join(',')}" data-wh="${sh.w || 16},${sh.h || 9}"` : ''}>` : '');
173
213
  const KIND_IC = { decision: '', action: 'box', question: 'ask' };
174
214
  const kindIcon = (k) => `<span class="vzr-k" data-k="${k}" role="img" aria-label="${KIND[k].label}">${KIND_IC[k] ? ic(KIND_IC[k], 16) : ''}</span>`;
175
215
  /** The selected point: the one the PM pressed, else the first row. */
176
216
  export const selOf = (V, ui = {}) => (V && (V.points.find((p) => p.id === ui.pt) || V.rows[0])) || null;
217
+ /** Where the replay is: the playhead, else the selected point's time. */
218
+ export const posOf = (V, ui = {}) => { const p = selOf(V, ui); return ui.pos != null ? ui.pos : p && p.t != null ? p.t : 0; };
219
+ export const WORD_HINT = 'Select a word or a few words anywhere in the notes, or double-click one.';
177
220
 
178
- /** One kept point as a lean row (MvC): its kind as an icon, its words, its time, ✓; a decision's follow-up To do under it. */
221
+ /** One kept point as a lean row (MvC): its kind as an icon, its words (selectable: a word in them can be fixed), its time, ✓; a decision's
222
+ * follow-up To do under it. The selected row also holds Fix (the edit card opens under it) and Not right. */
179
223
  function rowHtml(V, p, ui, sel) {
180
224
  const W = V.W, f = p.follow, on = p.verdict === 'confirmed' && (!f || f.verdict === 'confirmed');
181
225
  const sub = p.kind === 'question' ? [p.by, p.open ? 'no answer at the end' : ''].filter(Boolean).join(' · ') : p.kind === 'action' ? [p.owner, p.due ? dueText(p.due) : ''].filter(Boolean).join(' · ') : '';
@@ -184,11 +228,14 @@ function rowHtml(V, p, ui, sel) {
184
228
  : sub ? `<div class="vzr-fu vzr-sub"><span class="vzr-who">${esc(sub)}</span></div>` : '';
185
229
  const chk = p.check ? `<p class="vzr-chk">Check: you said “${esc(p.check.phrase)}” means ${esc(p.check.meaning)}</p>` : '';
186
230
  const ids = [p.id].concat(f ? [f.id] : []).join(',');
231
+ const acts = sel ? `<div class="vzr-ra"><button type="button" class="vzp-ib vzr-ghost" data-a="pedit" data-id="${esc(p.id)}" aria-label="Fix its kind or its words" title="Fix its kind or its words">${ic('pen', 15)}</button>`
232
+ + `<button type="button" class="vzp-ib vzr-ghost" data-a="pwrong" data-id="${esc(p.id)}" aria-label="Not right: move it to Left out" title="Not right">${ic('no', 15)}</button></div>` : '';
233
+ const edit = sel && ui.edit === p.id ? `<div class="vzr-edit">${editHtml(p, ui.draft || {}, ui.also || 0)}</div>` : '';
187
234
  return `<div class="vzp-kr vzr-row${sel ? ' sel' : ''}" data-id="${esc(p.id)}">${kindIcon(p.kind)}`
188
- + `<button type="button" class="vzp-kt" data-a="psel" data-id="${esc(p.id)}" aria-pressed="${sel}"><b>${wx(W, p.text)}</b></button>`
235
+ + `<div class="vzp-kt" data-a="psel" data-id="${esc(p.id)}" tabindex="0" role="button" aria-pressed="${sel}"><b>${wx(W, p.text)}</b></div>`
189
236
  + (p.time ? `<button type="button" class="vzp-tm${p.open ? ' risk' : ''}" data-a="seekto" data-t="${p.t}" aria-label="Play from ${p.time}">${p.time}</button>` : '<span></span>')
190
237
  + `<button type="button" class="vzr-cf${on ? ' on' : ''}" data-a="pconf" data-ids="${esc(ids)}" aria-pressed="${on}" aria-label="${on ? (p.edited ? 'Fixed and confirmed. Press to undo' : 'Confirmed. Press to undo') : 'Confirm this point'}" title="${on ? 'Confirmed' : 'Confirm'}">${ic('chk', 15)}</button>`
191
- + fu + chk + '</div>';
238
+ + fu + chk + acts + edit + '</div>';
192
239
  }
193
240
  /** The kept points (MvC): "N kept", a count per kind, Confirm N in the header; the rows. */
194
241
  export function pointsCardHtml(V, ui = {}) {
@@ -210,46 +257,83 @@ export function stripHtml(items, ui = {}) {
210
257
  return `<div class="vzr-fold" role="group" aria-label="More from this meeting">${list.map((x) => `<button type="button" class="vzr-ch" data-a="strip" data-k="${esc(x.k)}" aria-expanded="${!!open && open.k === x.k}"><b>${esc(x.title)}</b>${x.n ? `<span>${esc(x.n)}</span>` : ''}</button>`).join('')}</div>`
211
258
  + (open ? `<div class="vzr-panel" data-k="${esc(open.k)}">${open.html}</div>` : '');
212
259
  }
213
- /** The evidence of the selected point (MvC right): the screen at the replay's place, Play from the point, Fix, Not right, the quote
214
- * (or the edit card), and the filmstrip of the captures near it. */
215
- export function evidenceHtml(V, ui = {}) {
216
- const p = selOf(V, ui);
217
- const pos = ui.pos != null ? ui.pos : p && p.t != null ? p.t : 0, si = V.shotAt(pos), shot = si >= 0 ? V.shots[si] : null;
218
- const big = shot ? `<div class="vzp-shot vzr-shot"><span class="vzp-img">${img(V, shot.t)}</span><span class="vzp-who">${esc((shot.who ? shot.who + '’s screen' : 'Shared screen') + ' · ' + shot.time)}</span><button type="button" class="vzp-ib vzp-big" data-a="shotbig" data-t="${shot.t}" aria-label="Open the screen capture large">${ic('big', 14)}</button></div>`
219
- : '<div class="vzr-noshot"><p class="vzp-mut vzp-sm">No screen was shared, so there are no captures.</p></div>';
220
- const acts = p ? `<div class="vzr-acts">${p.time ? `<button type="button" class="vzp-ib vzr-pri" data-a="pplay" data-t="${p.t}" aria-label="Play from ${p.time}">${ic('play', 16, 'vzp-fill')}</button><b class="vzr-at">${p.time}</b>` : ''}<span class="vzp-sp"></span>`
221
- + `<button type="button" class="vzp-ib vzr-ghost" data-a="pedit" data-id="${esc(p.id)}" aria-label="Fix its kind or its words" title="Fix its kind or its words">${ic('pen', 16)}</button>`
222
- + `<button type="button" class="vzp-ib vzr-ghost" data-a="pwrong" data-id="${esc(p.id)}" aria-label="Not right: move it to Left out" title="Not right">${ic('no', 16)}</button></div>` : '';
223
- const body = p && ui.edit === p.id ? editHtml(p, ui.draft || {}, ui.also || 0)
224
- : p ? (p.quote ? `<p class="vzr-q">${wx(V.W, p.quote)}</p>` : '') + (p.check ? `<p class="vzr-chk">Check: you said “${esc(p.check.phrase)}” means ${esc(p.check.meaning)}</p>` : '') : '';
225
- let film = '';
226
- if (V.shots.length) {
227
- const N = 7, all = !!ui.allShots || V.shots.length <= N + 1, from = all ? 0 : Math.max(0, Math.min(Math.max(si, 0) - 3, V.shots.length - N));
228
- const seen = all ? V.shots : V.shots.slice(from, from + N);
229
- film = `<div class="vzr-film${all ? ' all' : ''}" role="group" aria-label="${esc('Screen captures · ' + V.shots.length)}">${seen.map((s) => `<button type="button" class="vzp-th${s.i === si ? ' on' : ''}" data-a="seekto" data-t="${s.t}" aria-label="${esc('Screen at ' + s.time + (s.why ? ': ' + s.why : ''))}" aria-pressed="${s.i === si}">${img(V, s.t)}</button>`).join('')}`
230
- + (V.shots.length > N + 1 ? `<button type="button" class="vzr-more" data-a="allshots" aria-expanded="${all}" aria-label="${all ? 'Show only the captures near this point' : 'All ' + V.shots.length + ' screen captures'}">${all ? ic('x', 14) : '+' + (V.shots.length - seen.length)}</button>` : '') + '</div>';
231
- }
232
- return `<section class="vzr-ev" aria-label="Evidence for the selected point">${big}${acts}${body}${film}</section>`;
260
+
261
+ /* ── the player (MpD1, MpD2, MpD4, MpD3) ── */
262
+ /** The kept points' icons inside the bar: a decision's dot, a To do's box, a question's ?. An SVG in the bar's SVG, at the point's place. */
263
+ function markHtml(p, on) {
264
+ const g = p.kind === 'decision' ? '<circle cx="7" cy="7" r="4.5" class="vzr-md"/>' : `<g transform="scale(0.5833)"><path d="${P[KIND_IC[p.kind]]}"/></g>`;
265
+ return `<svg x="${pct(p.at)}%" y="0" width="1" height="1" overflow="visible" class="vzr-mk${on ? ' on' : ''}" data-k="${p.kind}"><title>${esc(p.time + ' · ' + p.text)}</title><g transform="translate(-7 0)">${g}</g></svg>`;
266
+ }
267
+ /** ONE thin bar: the chapters are gaps (no text on it), the part played darker, the kept points as icons in their chapter, the playhead. */
268
+ export function barHtml(V, ui = {}) {
269
+ const pos = posOf(V, ui), ci = V.chapterAt(pos), sel = selOf(V, ui), len = V.len || 1;
270
+ const bars = V.strip.length ? V.strip : [{ from: 0, to: V.len }];
271
+ const gap = bars.length > 1 ? 0.45 : 0;
272
+ const rects = bars.map((b, i) => {
273
+ const x = b.from / len * 100, w = Math.max(0.3, (b.to - b.from) / len * 100 - gap), k = i < ci ? 'past' : i === ci ? 'cur' : 'next';
274
+ const prog = i === ci ? `<rect x="${x.toFixed(2)}%" y="13" width="${Math.max(0, Math.min(w, (pos - b.from) / len * 100)).toFixed(2)}%" height="10" rx="5" class="vzr-prog" data-clk="prog" data-x="${x.toFixed(2)}" data-w="${w.toFixed(2)}"/>` : '';
275
+ return `<rect x="${x.toFixed(2)}%" y="${k === 'cur' ? 13 : 14}" width="${w.toFixed(2)}%" height="${k === 'cur' ? 10 : 8}" rx="${k === 'cur' ? 5 : 4}" class="vzr-sg" data-k="${k}"/>` + prog;
276
+ }).join('');
277
+ const marks = V.points.filter((p) => p.at != null).map((p) => markHtml(p, !!sel && sel.id === p.id)).join('');
278
+ const head = `<svg x="${pct(pos / len * 100)}%" y="0" width="1" height="1" overflow="visible" data-clk="head"><rect x="-2" y="11" width="4" height="14" rx="2" class="vzr-head"/></svg>`;
279
+ const st = ci >= 0 ? V.strip[ci] : null;
280
+ return `<div class="vzr-barw"><div class="vzp-strip vzr-bar" data-a="seek" role="slider" tabindex="0" aria-label="Where the replay is" aria-valuemin="0" aria-valuemax="${Math.round(V.len / 1000)}" aria-valuenow="${Math.round(pos / 1000)}" aria-valuetext="${esc(mmss(pos) + (st && st.text ? ', ' + st.text : ''))}"><svg width="100%" height="26" aria-hidden="true">${rects}${marks}${head}</svg></div>`
281
+ + `<div class="vzr-hov" hidden aria-hidden="true"><span class="vzr-hth"></span><b></b><small></small></div></div>`;
233
282
  }
234
- /** The timeline docked at the bottom (MvC): Play, the time of the length, the full chapter names over the strip, a dot per kept point. */
235
- export function dockHtml(V, ui = {}) {
236
- const pos = ui.pos || 0, st = V.stretchAt(pos), speed = ui.speed || 1;
237
- const bars = V.strip.length ? V.strip : [{ w: 1, c: 0, text: '', min: 0, from: 0, to: V.len }];
238
- let x = 0;
239
- const rects = bars.map((b) => { const w = b.w * 100 - (bars.length > 1 ? 0.4 : 0), r = `<rect x="${x.toFixed(2)}%" y="9" width="${Math.max(0.5, w).toFixed(2)}%" height="8" rx="4" class="vzp-st" data-c="${b.c}"/>`; x += b.w * 100; return r; }).join('');
240
- const dots = V.points.filter((p) => p.at != null).map((p) => `<circle cx="${pct(p.at)}%" cy="5" r="4.5" class="vzp-kd" data-k="${p.kind}"/>`).join('');
241
- const head = `<rect class="vzp-head" data-clk="head" x="${pct(pos / V.len * 100)}%" y="0" width="2" height="26" rx="1"/>`;
242
- const names = V.strip.length ? `<div class="vzp-tops vzr-tops" data-n="${V.strip.length}">${V.strip.map((b) => `<button type="button" class="vzp-top" data-a="seekto" data-t="${b.from}" data-w="${(b.w * 1000).toFixed(0)}" title="${esc(b.text + ' · ' + b.min + ' min')}"${st === b ? ' aria-current="true"' : ''}>${wx(V.W, b.text)}</button>`).join('')}</div>` : '';
243
- return `<div class="vzp-pl vzr-dock" role="group" aria-label="Replay"><button type="button" class="vzp-ib vzp-solid" data-a="play" aria-label="${ui.playing ? 'Pause the recording' : 'Play from ' + mmss(pos)}">${ic(ui.playing ? 'pause' : 'play', 16, 'vzp-fill')}</button>`
244
- + `<div class="vzp-ctl"><b data-clk="pos">${mmss(pos)}</b><span class="vzp-mut">of ${V.lenText}</span></div>`
245
- + `<div class="vzr-tl">${names}<div class="vzp-strip" data-a="seek" role="slider" tabindex="0" aria-label="Where the replay is" aria-valuemin="0" aria-valuemax="${Math.round(V.len / 1000)}" aria-valuenow="${Math.round(pos / 1000)}" aria-valuetext="${esc(mmss(pos) + (st && st.text ? ', ' + st.text : ''))}"><svg width="100%" height="26" aria-hidden="true">${rects}${dots}${head}</svg></div></div>`
246
- + `<button type="button" class="vzp-speed" data-a="speed" aria-label="Playback speed ${speed} times. Change">${speed}×</button></div>`;
283
+ /** The caption under the player, as a music player shows lyrics: the line now, larger, with its speaker; the lines before and after faded. */
284
+ export function captionHtml(V, pos) {
285
+ const L = V.lines || [];
286
+ let i = -1; for (let k = 0; k < L.length; k++) { if (L[k].t <= pos + 500) i = k; else break; }
287
+ if (i < 0) i = L.length ? 0 : -1;
288
+ if (i < 0) return '';
289
+ const pv = L[i - 1], cur = L[i], nx = L[i + 1];
290
+ return (pv ? `<span class="vzr-cp">${wx(V.W, pv.text)}</span>` : '') + `<span class="vzr-cn">${cur.who ? `<span class="vzr-cw">${esc(cur.who)}</span>` : ''}<span class="vzr-said">${wx(V.W, cur.text)}</span></span>`
291
+ + (nx ? `<span class="vzr-cx">${wx(V.W, nx.text)}</span>` : '');
247
292
  }
248
- /** The desk (MvC): the left column (the summary the page gives, the kept points, the strip), the evidence, and the docked timeline.
249
- * o = { summary: html, strip: [{ k, title, n, html }] } from the page; Left out is this module's own part of the strip. */
293
+ /** The line of the caption now: the page draws the caption again only when it changes. */
294
+ export const lineAtPos = (V, pos) => { const L = V.lines || []; let i = -1; for (let k = 0; k < L.length; k++) { if (L[k].t <= pos + 500) i = k; else break; } return i; };
295
+ /** The captures row (MpD4): hidden by default, the captures icon shows it. Five near the playhead and "+N more"; all of them when pressed. */
296
+ function capsHtml(V, ui, si) {
297
+ if (!V.caps.length) return '';
298
+ const N = 5, all = !!ui.allShots || V.caps.length <= N + 1, ci = Math.max(0, V.caps.findIndex((x) => x.i === si));
299
+ const from = all ? 0 : Math.max(0, Math.min(ci - 2, V.caps.length - N)), seen = all ? V.caps : V.caps.slice(from, from + N);
300
+ return `<div class="vzr-film${all ? ' all' : ''}" role="group" aria-label="${esc('Screen captures · ' + V.caps.length)}">${seen.map((s) => `<button type="button" class="vzp-th vzr-th${s.i === si ? ' on' : ''}" data-a="seekto" data-t="${s.t}" aria-label="${esc('Screen at ' + s.time + (s.why ? ': ' + s.why : ''))}" aria-pressed="${s.i === si}"><span class="vzr-crop">${img(V, s.t, s)}</span></button>`).join('')}`
301
+ + (V.caps.length > N + 1 ? `<button type="button" class="vzr-more" data-a="allshots" aria-expanded="${all}" aria-label="${all ? 'Show only the captures near the playhead' : 'All ' + V.caps.length + ' screen captures'}">${all ? ic('x', 14) : '+' + (V.caps.length - seen.length) + ' more'}</button>` : '') + '</div>';
302
+ }
303
+ /** The chapters list (MpD2): its name, the kept points in it, its start; the chapter now highlighted; press one to play from there. */
304
+ function chaptersHtml(V, ui, ci) {
305
+ return `<div class="vzr-chs" role="dialog" aria-label="Chapters"><div class="vzr-chh"><span class="vzr-lbl">Chapters · ${V.strip.length}</span><span class="vzp-sp"></span><span class="vzr-hint0">Press one to play from there</span></div>`
306
+ + V.strip.map((b, i) => { const ks = V.points.filter((p) => p.t != null && p.t >= b.from && p.t < b.to).slice(0, 3);
307
+ return `<button type="button" class="vzr-chr${i === ci ? ' cur' : ''}" data-a="chgo" data-t="${b.from}"${i === ci ? ' aria-current="true"' : ''}><b>${wx(V.W, b.text)}</b><span class="vzr-chm">${ks.map((p) => kindIcon(p.kind)).join('')}</span><span class="vzr-cht">${mmss(b.from)}</span></button>`; }).join('') + '</div>';
308
+ }
309
+ /** The player (MpD1): the capture as the artwork, the chapter, the bar, the times, the controls, the caption. phone: MpD3 (no chapter steps,
310
+ * no captures icon, no caption). */
311
+ export function playerHtml(V, ui = {}, phone = false) {
312
+ const pos = posOf(V, ui), si = V.shotAt(pos), shot = si >= 0 ? V.shots[si] : null, ci = V.chapterAt(pos), st = ci >= 0 ? V.strip[ci] : null, speed = ui.speed || 1;
313
+ const art = shot ? `<div class="vzr-art"><span class="vzr-crop">${img(V, shot.t, shot)}</span><span class="vzr-tag">${esc((shot.who ? shot.who + '’s screen' : 'Shared screen') + ' · ' + shot.time)}</span><button type="button" class="vzr-full" data-a="shotbig" data-t="${shot.t}" aria-label="Show the whole frame" title="Show the whole frame">${ic('big', 16)}</button></div>`
314
+ : '<div class="vzr-noshot"><p class="vzp-mut vzp-sm">No screen was shared, so there are no captures.</p></div>';
315
+ const title = st ? `<div class="vzr-nt"><b data-clk="chap">${wx(V.W, st.text)}</b><span>chapter ${ci + 1} of ${V.strip.length}</span></div>` : '';
316
+ const nextSp = SPEEDS[(SPEEDS.indexOf(speed) + 1) % SPEEDS.length];
317
+ const ctl = `<div class="vzr-tr"><span class="vzr-L"><button type="button" class="vzr-speed" data-a="speed" aria-label="${esc('Speed ' + speed + ' times, press for ' + nextSp)}">${speed}×</button></span>`
318
+ + (!phone && V.strip.length > 1 ? `<button type="button" class="vzr-ib" data-a="chprev" aria-label="Previous chapter" title="Previous chapter">${ic('prev', 20)}</button>` : '')
319
+ + `<button type="button" class="vzr-skip" data-a="skip" data-d="-15000" aria-label="Back 15 seconds" title="Back 15 seconds">${skipIc(false)}</button>`
320
+ + `<button type="button" class="vzr-big" data-a="play" aria-label="${ui.playing ? 'Pause' : 'Play from ' + mmss(pos)}">${ic(ui.playing ? 'pause' : 'play', 24, 'vzr-fill')}</button>`
321
+ + `<button type="button" class="vzr-skip" data-a="skip" data-d="15000" aria-label="Forward 15 seconds" title="Forward 15 seconds">${skipIc(true)}</button>`
322
+ + (!phone && V.strip.length > 1 ? `<button type="button" class="vzr-ib" data-a="chnext" aria-label="Next chapter" title="Next chapter">${ic('chev', 20)}</button>` : '')
323
+ + `<span class="vzr-R">${!phone && V.caps.length ? `<button type="button" class="vzr-ib vzr-o${ui.caps ? ' on' : ''}" data-a="caps" aria-pressed="${!!ui.caps}" aria-label="${esc('Screen captures, ' + V.caps.length)}" title="Screen captures">${ic('caps', 18)}</button>` : ''}`
324
+ + (V.strip.length ? `<button type="button" class="vzr-ib vzr-o${ui.chaps ? ' on' : ''}" data-a="chaps" aria-expanded="${!!ui.chaps}" aria-label="${esc('Chapters, ' + V.strip.length)}" title="Chapters">${ic('chaps', 18)}</button>` : '') + '</span></div>';
325
+ const cap = phone ? '' : captionHtml(V, pos);
326
+ return `<section class="vzr-np${phone ? ' vzr-ph1' : ''}" aria-label="Replay">${art}`
327
+ + (ui.chaps && V.strip.length ? `<div class="vzr-chw">${chaptersHtml(V, ui, ci)}</div>` : '')
328
+ + (!phone && ui.caps ? capsHtml(V, ui, si) : '') + title + barHtml(V, ui)
329
+ + `<div class="vzr-times"><span data-clk="pos">${mmss(pos)}</span><span>${V.lenText}</span></div>` + ctl
330
+ + (cap ? `<div class="vzr-cap" data-pl="cap" aria-live="off">${cap}</div>` : '') + '</section>';
331
+ }
332
+ /** The desk (MvC + MpD1): the left column (the summary the page gives, the kept points, the strip, the one-time word hint) and the player.
333
+ * o = { summary: html, strip: [{ k, title, n, html }], hint: bool } from the page; Left out is this module's own part of the strip. */
250
334
  export function deskHtml(V, ui = {}, o = {}) {
251
335
  const items = [V.left.length ? { k: 'left', title: 'Left out', n: String(V.left.length), html: leftHtml(V) } : null].concat(o.strip || []);
252
- return `<div class="vzp vzp-card vzp-dt vzr"><div class="vzr-cols"><div class="vzr-l">${o.summary || ''}${pointsCardHtml(V, ui)}${stripHtml(items, ui)}</div>${evidenceHtml(V, ui)}</div>${dockHtml(V, ui)}</div>`;
336
+ return `<div class="vzp vzp-card vzp-dt vzr"><div class="vzr-cols"><div class="vzr-l">${o.summary || ''}${pointsCardHtml(V, ui)}${stripHtml(items, ui)}${o.hint ? `<p class="vzr-hint">${esc(WORD_HINT)}</p>` : ''}</div>${playerHtml(V, ui)}</div></div>`;
253
337
  }
254
338
  const stop = (t) => (/[.!?…”]$/.test(t) ? t : t + '.');
255
339
  /** The correction (XvAReplayEdit): the kind, the old words struck, the new words, what PMOS will remember, the trash (Delete: no confirm,
@@ -269,20 +353,12 @@ export function editHtml(p, d = {}, also = 0) {
269
353
  + `<div class="vzp-ef"><span class="vzp-mut vzp-sm">${esc([p.time, p.by].filter(Boolean).join(' · '))}</span><span class="vzp-sp"></span><button type="button" class="vzp-ib vzp-del" data-a="edel" data-id="${esc(p.id)}" aria-label="Delete" title="Delete">${ic('bin', 16)}</button><button type="button" class="vzp-ib vzp-del" data-a="pwrong" data-id="${esc(p.id)}" aria-label="Not right: move it to Left out" title="Not right">${ic('no', 16)}</button><button type="button" class="vzp-gbtn" data-a="ecancel">Cancel</button><button type="button" class="vzp-dark" data-a="esave"${changed && text.trim() ? '' : ' disabled'}>Save</button></div></div>`;
270
354
  }
271
355
 
272
- /** The phone's Replay (MtPhone.dc.html): the player without the capture, "Now: <topic>", and the points grouped by kind. made = { [pointId]: true } */
356
+ /** The phone's Replay (MpD3, MpWPhone): the player, then "N kept" with Confirm N and the kept points (their ✓ is on the desk). */
273
357
  export function phoneHtml(V, ui = {}) {
274
- const pos = ui.pos || 0, st = V.stretchAt(pos);
275
- const h = [`<div class="vzp-pl vzp-phpl"><div class="vzp-ctl"><button type="button" class="vzp-ib vzp-solid" data-a="play" aria-label="${ui.playing ? 'Pause' : 'Play'}">${ic(ui.playing ? 'pause' : 'play', 16, 'vzp-fill')}</button><b data-clk="pos">${mmss(pos)}</b><span class="vzp-mut">of ${V.lenText}</span></div>`
276
- + `<div class="vzp-strip" data-a="seek" role="slider" tabindex="0" aria-label="Where the replay is" aria-valuemin="0" aria-valuemax="${Math.round(V.len / 1000)}" aria-valuenow="${Math.round(pos / 1000)}"><svg width="100%" height="12" aria-hidden="true">${(() => { let x = 0; return (V.strip.length ? V.strip : [{ w: 1, c: 0 }]).map((b) => { const r = `<rect x="${x.toFixed(2)}%" y="2" width="${Math.max(0.5, b.w * 100 - 0.4).toFixed(2)}%" height="8" rx="4" class="vzp-st" data-c="${b.c}"/>`; x += b.w * 100; return r; }).join(''); })()}</svg></div>`
277
- + (st && st.text ? `<p class="vzp-mut vzp-sm">Now: ${esc(st.text + ' · ' + st.min + ' min')}</p>` : '') + '</div>'];
278
- const toConfirm = V.points.filter((p) => !p.verdict);
279
- h.push(`<div class="vzp-phh"><p class="vzp-lbl">Key points · ${V.points.length}</p>${toConfirm.length ? `<button type="button" class="vzp-dark" data-a="confirmall">Confirm all</button>` : ''}</div>`);
280
- for (const k of ['decision', 'action', 'question']) {
281
- const rows = V.points.filter((p) => p.kind === k); if (!rows.length) continue;
282
- h.push(`<p class="vzp-h3">${KIND[k].many}</p>` + rows.map((p) => `<div class="vzp-prow"><button type="button" class="vzp-tm${p.open ? ' risk' : ''}" data-a="seekto" data-t="${p.t == null ? '' : p.t}"${p.time ? '' : ' disabled'}>${p.time || '–'}</button><span>${esc(p.text)}</span>${k === 'action' ? (ui.pmade && ui.pmade[p.id] ? '<span class="vzp-pill" data-k="you">Task</span>' : `<button type="button" class="vzp-soft" data-a="ptask" data-id="${esc(p.id)}">Make task</button>`) : ''}</div>`).join(''));
283
- }
284
- if (!V.points.length) h.push('<p class="vzp-empty">Nothing was agreed, owned or left open in this meeting.</p>');
285
- return h.join('');
358
+ const sel = selOf(V, ui);
359
+ const rows = V.rows.map((p) => rowHtml(V, p, ui, !!sel && sel.id === p.id)).join('');
360
+ return playerHtml(V, ui, true) + `<div class="vzr-phk"><div class="vzr-ph"><h3>${V.points.length} kept</h3><span class="vzp-sp"></span>${V.toConfirm ? `<button type="button" class="vzp-dark vzr-cfall" data-a="confirmall">${ic('chk', 14)}Confirm ${V.toConfirm}</button>` : ''}</div>`
361
+ + (rows ? `<div class="vzp-kl">${rows}</div>` : '<p class="vzp-empty">Nothing was agreed, owned or left open in this meeting.</p>') + '</div>';
286
362
  }
287
363
 
288
364
  /** The whole of it: the desk and the phone; the stylesheet shows one (cozy-vw-mtg.css). o: deskHtml's (the summary and the strip). */
@@ -93,7 +93,7 @@ export function openReportTab(block, o = {}) {
93
93
  // itself shows the report as it is
94
94
  if (block.id && block.change && !t.passed && (!o.auto || linked) && !PASSED.has(JSON.stringify(block))) { PASSED.add(JSON.stringify(block)); t.passed = true; passChange(t, !!o.auto); return show(t); }
95
95
  if (block.id && t.panel === 'current') return current(t);
96
- if (block.id && t.panel === 'template') { const v = show(t); loadFlow(t).then(() => { redraw(t); if (!o.auto) makeLook(t); }); return v; }
96
+ if (block.id && t.panel === 'template') { const v = show(t); loadFlow(t).then(() => { redraw(t); if (!o.auto || linked) makeLook(t); }); return v; }
97
97
  return show(t);
98
98
  }
99
99
  /** The set-up report shown beside the chat `chatKey`, by id, or null (Wawan, 2026-11-04). The chat sends it with each message, so feedback in any
@@ -112,6 +112,13 @@ async function makeLook(t) {
112
112
  if (!words || PASSED.has(key) || !t.F || !t.F.ok) return;
113
113
  PASSED.add(key);
114
114
  const had = t.F.look && t.F.look.newer ? t.F.look.newer.n : null;
115
+ // the file the PM attached to follow (a deck, a screenshot) is added to the look's samples first (Wawan, 2026-11-04)
116
+ if (t.block.sample) {
117
+ const sm = t.block.sample, ext = (sm.split('.').pop() || '').toLowerCase(), name = sm.split('/').pop();
118
+ const data = KIND_OF[ext] ? await fileB64(sm) : null;
119
+ const a = data ? await post('/api/report-design/sample', { id: t.block.id, name, kind: KIND_OF[ext], data }) : null;
120
+ if (!a || !a.ok) { t.msg = 'The look could not read ' + name + (a ? ': ' + why(a, 'no answer') : '') + '. It follows your words only.'; redraw(t); }
121
+ }
115
122
  const r = await post('/api/report-design/turn', { id: t.block.id, text: words, points: [] });
116
123
  if (!r || !r.ok) { t.msg = 'The look was not started: ' + why(r, 'the page could not reach PMOS') + '.'; redraw(t); return; }
117
124
  t.making = words; redraw(t);
@@ -165,13 +172,32 @@ async function passChange(t, auto) {
165
172
  let said;
166
173
  if (to.frozen) said = `The ${to.frozen} report is issued, so it is frozen. The change was not passed on: change the template for the next cycle.`;
167
174
  else {
175
+ const since = Date.now() - 5000;
168
176
  const r = await post('/api/report-flow/ask', { id: t.block.id, text: t.block.change, ...(to.path ? { path: to.path } : {}) });
177
+ if (r && r.ok) setTimeout(() => answerOf(t, since), 0);
169
178
  const which = to.slot ? `the ${to.slot} draft` : 'the draft';
170
179
  said = r && r.ok ? (r.started ? `The report's session started with your change to ${which}. It shows in the report as it writes it. Nothing is issued.` : `Passed to the report's session for ${which}. It shows in the report as it writes it. Nothing is issued.`)
171
180
  : 'The change was not passed on: ' + why(r, 'the page could not reach PMOS') + '.';
172
181
  }
182
+ const look = t.block.design ? { kind: 'report', id: t.block.id, design: t.block.design, ...(t.block.sample ? { sample: t.block.sample } : {}) } : null;
173
183
  t.block = { kind: 'report', id: t.block.id }; t.passed = false;
174
- return current(t, said);
184
+ const v = await current(t, said);
185
+ // one answer that asked for the look as well: the look starts its own version, kept with ✓ (the text change went to the session above)
186
+ if (look) openReportTab(look, { chat: t.chat, auto: true });
187
+ return v;
188
+ }
189
+ /** The report's session's answer to a change, once it writes one (answer.json, read by GET /api/report-flow): it shows beside the chat, so a
190
+ * change the session did not make never looks like it is still drafting (Wawan, 2026-11-04). Reads every few seconds, 15 minutes at most. */
191
+ async function answerOf(t, since) {
192
+ const end = Date.now() + 15 * 60000;
193
+ while (Date.now() < end && T.get(t.id) === t) {
194
+ await new Promise((ok) => setTimeout(ok, POLL_MS * 2));
195
+ const F = await get('/api/report-flow', { id: t.block.id });
196
+ if (F && F.ok && F.answer && Date.parse(F.answer.at) >= since) {
197
+ if (t.panel === 'current') return current(t, 'The report\'s session: ' + F.answer.text);
198
+ t.msg = 'The report\'s session: ' + F.answer.text; redraw(t); return;
199
+ }
200
+ }
175
201
  }
176
202
  /** The chat's proposal names a report that is set up already (an earlier page, or a reload): open that report instead of the proposal. */
177
203
  async function adoptSetUp(id, name) {
@@ -416,7 +442,10 @@ async function press(t, e) {
416
442
  if (!r || !r.ok) return 'The template did not change: ' + why(r, 'the page could not reach PMOS') + '.';
417
443
  if (body.sections) t.block = Object.assign({}, t.block, { sections: undefined, structure: undefined });
418
444
  if (r.state) t.F = r.state; await loadFlow(t);
419
- return 'T' + r.n + ' is the current template. The next cycle follows it; the open draft stays as it is, and the earlier cycles keep their look.';
445
+ const rd = r.redraw;
446
+ if (rd && /report engine/.test(rd.reason || '')) return 'T' + r.n + ' is the current template. The open ' + (rd.slot ? rd.slot + ' ' : '') + 'draft draws in the new look now; its numbers stay the same. Nothing is issued.';
447
+ return 'T' + r.n + ' is the current template. ' + (rd && rd.started ? 'The open ' + (rd.slot ? rd.slot + ' ' : '') + 'draft is being redrawn in the new look; its numbers stay the same. Nothing is issued.'
448
+ : 'The next cycle follows it' + (rd && rd.reason === 'sent' ? '; the open cycle is issued, so it keeps its look.' : '; the open draft stays as it is.') + ' The earlier cycles keep their look.');
420
449
  });
421
450
  return;
422
451
  }
@@ -485,7 +514,7 @@ const KIND_OF = { pdf: 'pdf', pptx: 'pptx', docx: 'docx', png: 'image', jpg: 'im
485
514
  /** A workspace file as base64, read through /api/asset. null when it cannot be read. */
486
515
  async function fileB64(path) {
487
516
  try {
488
- const res = await fetch('/api/asset?' + qs({ ws: ws(), path }));
517
+ const res = await fetch(String(path).startsWith('/') ? '/api/chat-file?' + qs({ path }) : '/api/asset?' + qs({ ws: ws(), path }));
489
518
  if (!res.ok) return null;
490
519
  const u8 = new Uint8Array(await res.arrayBuffer());
491
520
  let bin = ''; for (let i = 0; i < u8.length; i += 0x8000) bin += String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000));