create-pmos 0.528.0 → 0.529.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/package.json +26 -14
  2. package/payload/KIT-VERSION +1 -1
  3. package/payload/planning/evals/eval-calibration.md +1 -1
  4. package/payload/planning/okrs/health-budgets.md +1 -1
  5. package/payload/state/discoveries.md +1 -1
  6. package/workspace/briefing-premium-calendar-3-test.js +11 -9
  7. package/workspace/briefing-premium-calendar-test.js +1 -1
  8. package/workspace/changelog.json +25 -0
  9. package/workspace/fixtures/premium-viewer-fixtures.js +4 -2
  10. package/workspace/guide/idle-lab.md +1 -1
  11. package/workspace/guide/notes.md +19 -7
  12. package/workspace/guide/reports-set-up.md +3 -1
  13. package/workspace/lib/bg-queue.js +5 -1
  14. package/workspace/lib/meeting-replay.js +11 -2
  15. package/workspace/lib/meeting-suggest.js +105 -0
  16. package/workspace/lib/meeting-terms.js +57 -17
  17. package/workspace/lib/report-chat.js +3 -3
  18. package/workspace/lib/routes-lab.js +44 -2
  19. package/workspace/lib/routes-meeting-points.js +27 -3
  20. package/workspace/lib/routes-report-flow.js +35 -5
  21. package/workspace/package-lock.json +2 -2
  22. package/workspace/package.json +1 -1
  23. package/workspace/premium-vw-meet-test.js +17 -11
  24. package/workspace/public/pm/cozy-lab-ins.js +13 -4
  25. package/workspace/public/pm/cozy-vw-mtg.css +122 -24
  26. package/workspace/public/pm/premium-meeting.js +2 -1
  27. package/workspace/public/pm/premium-mtg-replay.js +149 -73
  28. package/workspace/public/pm/premium-report-tab.js +33 -4
  29. package/workspace/public/pm/premium-vw-mtg.js +176 -53
  30. package/workspace/public/pm/report-chat.js +1 -0
  31. package/workspace/public/pm/report-flow.js +7 -3
@@ -35,19 +35,61 @@ function titles(w) {
35
35
  try { const b = require('./routes-boards.js').baseRows(w); if (b && b.ok) for (const r of b.rows || []) if (r && r.id) m.set(r.id, { title: r.title || r.id, board: r.board || '' }); } catch { /* none */ }
36
36
  return m;
37
37
  }
38
+ // Why a grill run started, in plain words (lib/grill-runs.js why), for "Checked <initiative> for risks · <why> · <what it found>"
39
+ // (Wawan, 2026-11-04: "I don't understand" "Grill · … why: monthly").
40
+ const GRILL_WHY = { monthly: 'monthly check', ask: 'you asked', exit: 'it passed a stage', forecast: 'its forecast moved', move: 'its tasks or end date moved', plan: 'its PRD changed' };
41
+ const plural = (n, one, many) => n + ' ' + (n === 1 ? one : many);
42
+ /** A grill run's findings: filed by the run (proposals.js propose lines carry the run), and the ones still waiting for the PM. */
43
+ function grillFound(P, run) {
44
+ let filed = 0, waiting = 0;
45
+ if (!P) return { filed, waiting };
46
+ const kept = PR.keptIds(P);
47
+ for (const p of P.props.values()) if (p.run === run) { filed++; if (!kept.has(p.id) && !P.drops.has(PR.sigOf(p))) waiting++; }
48
+ return { filed, waiting };
49
+ }
50
+ /** A report job of the shared queue (origin report-design:<id>), named with the report: "Redrew TSC Monthly in its saved look". */
51
+ function reportRow(x, root, runtimeDir) {
52
+ let name = '';
53
+ // a line written before the queue kept `ref`: the session's own record still names its origin
54
+ if (!x.ref && x.session && runtimeDir && /^[A-Za-z0-9-]{1,40}$/.test(x.session)) {
55
+ try { const o = JSON.parse(fs.readFileSync(require('path').join(runtimeDir, 'sessions', x.session + '.json'), 'utf8')).origin; if (typeof o === 'string' && o.startsWith('report-design:')) x = { ...x, ref: o.slice(14) }; } catch { /* the session record is gone */ }
56
+ }
57
+ // or the cycle render it wrote: a <slot>.design.json written within two minutes of the job's end
58
+ if (!x.ref && root) {
59
+ try {
60
+ const path = require('path'), dir = path.join(root, require('./report-draft.js').stateRel(root), 'reports'), end = Date.parse(x.at);
61
+ let best = null;
62
+ for (const id of fs.readdirSync(dir)) {
63
+ let files; try { files = fs.readdirSync(path.join(dir, id)); } catch { continue; }
64
+ for (const f of files) if (f.endsWith('.design.json')) { const d = Math.abs(fs.statSync(path.join(dir, id, f)).mtimeMs - end); if (d <= 120000 && (!best || d < best.d)) best = { id, d }; }
65
+ }
66
+ if (best) x = { ...x, ref: best.id };
67
+ } catch { /* no reports folder */ }
68
+ }
69
+ try { if (x.ref && root) { const s = require('./report-setups.js').stateOf(root).setups.get(x.ref); name = s && s.name ? s.name : ''; } } catch { name = ''; }
70
+ const what = /cycle/.test(x.title || '') ? 'Redrew ' + (name || 'a report') + ' in its saved look' : 'Made the next look of ' + (name || 'a report');
71
+ return { job: x.job, title: what, meta: '', at: x.at };
72
+ }
38
73
  /** What the other background jobs did this week, as record rows. Each source that cannot be read is left out. */
39
74
  function others(ctx, w, dir, T, now) {
40
75
  const out = [], since = now - WEEK, name = (ini) => (T.get(ini) || {}).title || ini;
41
76
  try {
42
77
  const G = require('./grill.js'), F = ctx.runtimeDir ? G.read(ctx.runtimeDir) : null;
43
- for (const r of F ? F.runs.values() : []) if (r.ended && r.ok !== false && r.ws === w.id && (Date.parse(r.endAt || r.at) || 0) >= since) out.push({ job: 'grill', title: 'Grill · ' + name(r.ini), meta: 'why: ' + r.why, at: r.endAt || r.at, ini: r.ini, board: r.board || '' });
78
+ const P = (() => { try { return dir ? PR.read(dir) : null; } catch { return null; } })();
79
+ for (const r of F ? F.runs.values() : []) if (r.ended && r.ok !== false && r.ws === w.id && (Date.parse(r.endAt || r.at) || 0) >= since) {
80
+ const f = grillFound(P, r.run);
81
+ const found = !f.filed ? 'nothing found' : f.waiting === f.filed ? plural(f.filed, 'finding', 'findings') + ' waiting'
82
+ : plural(f.filed, 'finding', 'findings') + (f.waiting ? ', ' + f.waiting + ' waiting' : ', all seen');
83
+ // the initiative chip only when something waits there for the PM
84
+ out.push({ job: 'grill', title: 'Checked ' + name(r.ini) + ' for risks', meta: [GRILL_WHY[r.why] || r.why, found].filter(Boolean).join(' · '), at: r.endAt || r.at, ...(f.waiting ? { ini: r.ini, board: r.board || '' } : {}) });
85
+ }
44
86
  } catch { /* no grill log */ }
45
87
  try {
46
88
  // the AI jobs' audit (lib/ai-guard.js AUDIT), read as a file: this page files nothing through the guard
47
89
  const audit = dir ? fs.readFileSync(require('path').join(dir, 'ai-audit.jsonl'), 'utf8').split('\n').slice(-400).map((s) => { try { return JSON.parse(s); } catch { return null; } }).filter(isObj) : [];
48
90
  for (const l of audit) if (l.event === 'end' && (Date.parse(l.at) || 0) >= since) out.push({ job: l.job, title: (JOB_NAMES[l.job] || l.job), meta: l.filed != null ? l.filed + ' filed' : '', at: l.at });
49
91
  } catch { /* no audit */ }
50
- try { if (ctx.bgQueue && typeof ctx.bgQueue.record === 'function') for (const x of ctx.bgQueue.record({ ws: w.id, since }) || []) if (isObj(x) && x.job !== 'grill') out.push(x); /* grill runs are read above, from their own log */ } catch { /* the queue says nothing */ }
92
+ try { if (ctx.bgQueue && typeof ctx.bgQueue.record === 'function') for (const x of ctx.bgQueue.record({ ws: w.id, since }) || []) if (isObj(x) && x.job !== 'grill') out.push(x.job === 'report-design' ? reportRow(x, w.root, ctx.runtimeDir) : x); /* grill runs are read above, from their own log */ } catch { /* the queue says nothing */ }
51
93
  return out;
52
94
  }
53
95
 
@@ -10,10 +10,14 @@
10
10
  * POST /api/meeting-points/forget { lesson } → { ok } Undo of what PMOS learned, and of a word fix (its term id)
11
11
  * POST /api/meeting-points/term { note, heard, means } → { ok, term, terms } a word fixed in place, in every place the note shows it
12
12
  * The GET answers { terms } too: the word fixes that hold now (lib/meeting-terms.js).
13
+ * POST /api/meeting-points/keep { lesson, keep } → { ok, term, terms } the ✦ ask: remember a word fix for the next meetings (true) or not
14
+ * GET /api/meeting-points/suggest?note=<id>&heard=<words>&invite=<text>…&people=<name>… → { ok, suggest: { means, from, file?, alts } | null }
15
+ * the spelling a word fix offers (lib/meeting-suggest.js): a fix remembered before, the invite the page sends, the workspace's repository
13
16
  * The POSTs are page presses (lib/pm-guard.js requirePagePress). Handlers are pure over (q | body, ctx) so a test needs no server. */
14
17
  const MP = require('./meeting-points.js');
15
18
  const ML = require('./meeting-learn.js');
16
19
  const MT = require('./meeting-terms.js');
20
+ const MS = require('./meeting-suggest.js');
17
21
  const guard = require('./pm-guard.js');
18
22
 
19
23
  const fail = (status, error) => ({ status, body: { ok: false, error } });
@@ -26,7 +30,7 @@ function read(q, ctx) {
26
30
  function learn(req, body, ctx, op) {
27
31
  const g = guard.requirePagePress(req, body);
28
32
  if (!g.ok) return fail(g.status, g.error);
29
- const r = op === 'forget' ? ML.forget(ctx.runtimeDir, body) : op === 'term' ? MT.term(ctx.runtimeDir, body) : ML.edit(ctx.runtimeDir, body);
33
+ const r = op === 'forget' ? ML.forget(ctx.runtimeDir, body) : op === 'term' ? MT.term(ctx.runtimeDir, body) : op === 'keep' ? MT.keep(ctx.runtimeDir, body) : ML.edit(ctx.runtimeDir, body);
30
34
  if (!r.ok) return fail(r.status || 400, r.error);
31
35
  return { status: 200, body: r };
32
36
  }
@@ -37,13 +41,33 @@ function write(req, body, ctx) {
37
41
  return r.ok ? { status: 200, body: { ok: true, points: r.points } } : fail(r.status || 400, r.error);
38
42
  }
39
43
 
44
+ /** The ✦ spelling for heard words: the remembered fixes, the invite's words the page sends, and the note's workspace repository. */
45
+ function suggest(q, ctx, all = {}) {
46
+ const note = String(q.note || ''), heard = String(q.heard || '').replace(/\s+/g, ' ').trim();
47
+ if (!/^[A-Za-z0-9_-]{1,80}$/.test(note)) return fail(400, 'note is the id of a meeting note');
48
+ if (!heard || heard.length > MT.MAX_WORD) return fail(400, 'heard is the words to fix, at most ' + MT.MAX_WORD + ' characters');
49
+ const list = (k) => (Array.isArray(all[k]) ? all[k] : []).map((x) => String(x).slice(0, 300)).slice(0, 60);
50
+ let repo = [];
51
+ try {
52
+ const n = require('./notes.js').load(ctx.runtimeDir, note);
53
+ const w = n && n.ws && typeof ctx.getWs === 'function' ? ctx.getWs(n.ws) : null;
54
+ if (w && w.root) repo = MS.repoWords(w.root, { exclude: require('./typesafe-settings.js').getRepo(ctx.runtimeDir, w.root).exclude || [] });
55
+ } catch { repo = []; } // no repository to read: the other places still answer
56
+ const fixes = MT.terms(ctx.runtimeDir).filter((x) => x.keep === 'yes' || x.note === note);
57
+ return { status: 200, body: { ok: true, suggest: MS.suggest({ heard, fixes, invite: list('invite'), people: list('people'), repo }) } };
58
+ }
59
+
40
60
  const respond = (ctx, res, r) => { ctx.json(res, r.body, r.status); return true; };
41
61
 
42
62
  module.exports = {
43
63
  prefixes: ['/api/meeting-points'],
44
- read, write, learn,
64
+ read, write, learn, suggest,
45
65
  handle: async (req, res, url, ctx) => {
46
- const op = url.pathname === '/api/meeting-points/edit' ? 'edit' : url.pathname === '/api/meeting-points/forget' ? 'forget' : url.pathname === '/api/meeting-points/term' ? 'term' : '';
66
+ if (url.pathname === '/api/meeting-points/suggest') {
67
+ if (req.method !== 'GET') return respond(ctx, res, fail(405, 'GET only'));
68
+ return respond(ctx, res, suggest(Object.fromEntries(url.searchParams), ctx, { invite: url.searchParams.getAll('invite'), people: url.searchParams.getAll('people') }));
69
+ }
70
+ const op = { '/api/meeting-points/edit': 'edit', '/api/meeting-points/forget': 'forget', '/api/meeting-points/term': 'term', '/api/meeting-points/keep': 'keep' }[url.pathname] || '';
47
71
  if (op) {
48
72
  if (req.method !== 'POST') return respond(ctx, res, fail(405, 'POST only'));
49
73
  const got = await ctx.readJson(req);
@@ -55,6 +55,19 @@ function cyclesOf(root, id) {
55
55
  }
56
56
  function readText(root, rel) { try { return fs.readFileSync(path.join(path.resolve(String(root)), rel), 'utf8'); } catch { return null; } }
57
57
  function sessionFile(root, id) { return path.join(path.dirname(RT.ledgerOf(root, id)), 'session.json'); }
58
+ // The drafting session's answer to a change (Wawan, 2026-11-04: it declined a look change in its own terminal and the panel said "drafting it"
59
+ // for good). ask() writes ask.json { at, change } and asks the session to write answer.json { text } when it is done or does nothing;
60
+ // stateOf() shows the answer once it is newer than the ask, and says the change is with the session until then.
61
+ const askFile = (root, id) => path.join(path.dirname(RT.ledgerOf(root, id)), 'ask.json');
62
+ const answerFile = (root, id) => path.join(path.dirname(RT.ledgerOf(root, id)), 'answer.json');
63
+ function readJson(f) { try { const o = JSON.parse(fs.readFileSync(f, 'utf8')); return isObj(o) ? o : null; } catch { return null; } }
64
+ function answerOf(root, id) {
65
+ const a = readJson(askFile(root, id)); if (!a || !a.at) return { asking: null, answer: null };
66
+ let st = null; try { st = fs.statSync(answerFile(root, id)); } catch { st = null; }
67
+ const ans = st && st.mtimeMs >= Date.parse(a.at) ? readJson(answerFile(root, id)) : null;
68
+ const txt = ans && typeof ans.text === 'string' ? ans.text.replace(/\s+/g, ' ').trim().slice(0, 400) : '';
69
+ return txt ? { asking: null, answer: { text: txt, at: new Date(st.mtimeMs).toISOString(), asked: a.at } } : { asking: { at: a.at }, answer: null };
70
+ }
58
71
  function readSession(root, id) { try { const o = JSON.parse(fs.readFileSync(sessionFile(root, id), 'utf8')); return isObj(o) && typeof o.session === 'string' ? o : null; } catch { return null; } }
59
72
  const alive = (ctx, sid) => !!sid && !!(ctx.chatSessions && typeof ctx.chatSessions.alive === 'function' && ctx.chatSessions.alive(sid));
60
73
  function draftingOf(ctx, w, id) { try { return typeof ctx.structureDraftState === 'function' ? ctx.structureDraftState(w, id) || null : null; } catch { return null; } }
@@ -81,6 +94,7 @@ function stateOf(ctx, w, id, now = Date.now()) {
81
94
  template: { current: f.current, versions: f.versions.map((v) => ({ ...v, words: RT.settingsWords(v.settings, names), cycles: counts.get(v.n) || 0 })) },
82
95
  cycles, latest, drafting,
83
96
  session: sess ? { id: sess.session, alive: alive(ctx, sess.session), since: sess.since || null } : null,
97
+ ...answerOf(root, id),
84
98
  review,
85
99
  sections: { saved: live.structure, titles: names.sections(live.structure) || (s && Array.isArray(s.sections) ? s.sections.slice() : []) },
86
100
  look: lookOf(root, id, live.design, names),
@@ -152,18 +166,23 @@ function draftWords(id, rel) {
152
166
  return rel && slot ? `The draft: ${rel} (slot ${slot}). Change that file in place: edit only the note or notes the change is about, and keep every caveat. `
153
167
  + `Do not redraft for a change to the words. Only when the PM asks for fresh numbers, run \`pmos report draft ${id} ${slot} --redraft\`; never \`pmos report draft ${id}\` without the slot, and do not draft a new slot.` : '';
154
168
  }
169
+ /** The last line of every change: where the session says what it did, so the PM reads it beside the chat (never only in this terminal). */
170
+ function answerWords(root, id) {
171
+ return `When you are done, or if you change nothing, write ONE JSON object {"text":"<one or two plain sentences for the PM: what you changed, or why you did not>"} to ${answerFile(root, id)}. A look change (palette, tables, a Gantt style) is not yours: say it goes in the report's look, which the PM keeps with ✓ beside the chat.`;
172
+ }
155
173
  /** The words the report's session gets for one change. */
156
- function askPrompt(skill, id, rel, change) {
174
+ function askPrompt(skill, id, rel, change, root) {
157
175
  return `${skill}\n\nThe PM's request for this run, given when it was scheduled (do not ask again): ${id}\n\n`
158
176
  + `This is the report's own drafting session (the PMOS report flow), not the PM's chat. ${rel ? draftWords(id, rel) + ' ' : ''}`
159
- + `The change the PM asked for: ${change}\nChange only the draft. Never issue, send or share it: the PM issues it on the page.`;
177
+ + `The change the PM asked for: ${change}\nChange only the draft. Never issue, send or share it: the PM issues it on the page. ${answerWords(root, id)}`;
160
178
  }
161
179
  function ask(ctx, w, id, change, rel) {
162
180
  const root = w.root;
163
181
  const d = draftingOf(ctx, w, id);
164
182
  const sess = readSession(root, id);
165
183
  const target = d && d.state === 'drafting' && alive(ctx, d.session) ? d.session : sess && alive(ctx, sess.session) ? sess.session : null;
166
- const msg = `[PMOS report change] ${rel ? draftWords(id, rel) + ' ' : ''}The change: ${change}. Change only the draft; never issue, send or share it.`;
184
+ try { fs.mkdirSync(path.dirname(askFile(root, id)), { recursive: true }); fs.writeFileSync(askFile(root, id), JSON.stringify({ at: new Date().toISOString(), change: String(change).slice(0, 600) }) + '\n'); } catch { /* the panel then shows no answer */ }
185
+ const msg = `[PMOS report change] ${rel ? draftWords(id, rel) + ' ' : ''}The change: ${change}. Change only the draft; never issue, send or share it. ${answerWords(root, id)}`;
167
186
  if (target) {
168
187
  const sent = typeof ctx.sendToSession === 'function' && ctx.sendToSession(target, msg);
169
188
  if (sent) return { ok: true, session: target, path: rel, passed: true };
@@ -174,7 +193,7 @@ function ask(ctx, w, id, change, rel) {
174
193
  const origin = 'report-session:' + id;
175
194
  if (typeof ctx.endSupersededRuns === 'function') { try { ctx.endSupersededRuns(origin); } catch { /* the old one ends by itself */ } }
176
195
  const s = setupOf(root, id);
177
- const sp = ctx.spawnSession({ name: 'Report · ' + ((s && s.name) || id), agent: 'claude', ws: w.id, origin, initialInput: askPrompt(skill, id, rel, change) + '\r' });
196
+ const sp = ctx.spawnSession({ name: 'Report · ' + ((s && s.name) || id), agent: 'claude', ws: w.id, origin, initialInput: askPrompt(skill, id, rel, change, root) + '\r' });
178
197
  if (!sp || sp.state === 'exited' || !sp.id) return { ok: false, status: 409, error: 'The report\'s session did not start: ' + ((sp && sp.note) || 'no session') };
179
198
  try { fs.mkdirSync(path.dirname(sessionFile(root, id)), { recursive: true }); fs.writeFileSync(sessionFile(root, id), JSON.stringify({ session: sp.id, since: new Date().toISOString() }) + '\n'); } catch { /* shown as no session */ }
180
199
  return { ok: true, session: sp.id, path: rel, started: true };
@@ -250,7 +269,18 @@ async function handle(req, res, url, ctx) {
250
269
  if (!ar.ok) return fail(ar);
251
270
  const names = RT.namesOf(w.root, id);
252
271
  const v = RT.addVersion(w.root, id, next, { names, why: RT.diffWords(was, next, names).join('; ').slice(0, 200) });
253
- return v.ok ? send({ status: 200, body: { ok: true, n: v.n, state: stateOf(ctx, w, id) } }) : fail(v);
272
+ if (!v.ok) return fail(v);
273
+ // a new look kept with ✓ redraws the open draft in it at once, as the design studio's "This is it" does (Wawan, 2026-11-04: the look asked
274
+ // for in the chat never reached the draft). Only the look is redrawn: the text and its numbers stay; an issued cycle keeps its look.
275
+ let redraw = null;
276
+ if (ar.done && ar.done.design && next.design != null) {
277
+ const cur = cyclesOf(w.root, id)[0];
278
+ if (cur && cur.state === 'draft') {
279
+ let r; try { r = require('./routes-report-design.js').startCycle(ctx, w, id, cur.path); } catch (e) { r = { ok: false, error: String((e && e.message) || e) }; }
280
+ redraw = { path: cur.path, slot: cur.slot, started: !!(r && r.ok), ...(r && r.ok ? {} : { reason: String((r && r.error) || '') }) };
281
+ } else if (cur) redraw = { path: cur.path, slot: cur.slot, started: false, reason: 'sent' };
282
+ }
283
+ return send({ status: 200, body: { ok: true, n: v.n, redraw, state: stateOf(ctx, w, id) } });
254
284
  }
255
285
  if (sub === 'restore') {
256
286
  const f = RT.backfill(w.root, id, RT.fold(RT.readLines(w.root, id))), v = f.versions.find((x) => x.n === +body.n);
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.528.0",
3
+ "version": "0.529.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.528.0",
9
+ "version": "0.529.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.528.0",
3
+ "version": "0.529.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -60,7 +60,7 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
60
60
  const s1 = await sum(p);
61
61
  /* batch 25 item 11 (MvC): the summary first in the left column, the kept points with Confirm N in their header, the evidence, one strip, the docked timeline */
62
62
  ok('S1 the summary: the overview and the meta line, no primary (Confirm sits in the kept header)', /opens next to the chat/.test(s1.text) && !s1.pri, JSON.stringify(s1));
63
- ok('S1 the left column starts with the summary, then the kept points: "N kept" and Confirm N; the evidence and the docked timeline', await p.evaluate(() => { const l = document.querySelector('#czViewer .vzr-l'); return !!l && l.firstElementChild.classList.contains('czs-sum') && /^\d+ kept$/.test(l.querySelector('.vzr-ph h3').textContent) && /^Confirm \d+$/.test(l.querySelector('[data-a="confirmall"]').textContent) && !!document.querySelector('#czViewer .vzr-ev') && !!document.querySelector('#czViewer .vzp-card .vzr-dock.vzp-pl'); }));
63
+ ok('S1 the left column starts with the summary, then the kept points: "N kept" and Confirm N; one player (MpD1), no dock', await p.evaluate(() => { const l = document.querySelector('#czViewer .vzr-l'); return !!l && l.firstElementChild.classList.contains('czs-sum') && /^\d+ kept$/.test(l.querySelector('.vzr-ph h3').textContent) && /^Confirm \d+$/.test(l.querySelector('[data-a="confirmall"]').textContent) && !!document.querySelector('#czViewer .vzp-card .vzr-np') && !document.querySelector('#czViewer .vzr-dock'); }));
64
64
  const chips = await p.evaluate(() => [...document.querySelectorAll('#czViewer .vzr-fold [data-a="strip"]')].map((x) => x.dataset.k).join());
65
65
  ok('S1 one strip: Left out, Next steps, Since the last one, Facts, Names and terms, Prep and material, Transcript; no tabs, no checkbox, no filter', chips === 'left,steps,since,facts,names,prep,tr' && !(await p.$('#czViewer [role="tablist"]')) && !(await p.$('#czViewer .vzr [role="checkbox"]')) && !(await p.$('#czViewer [data-a="pf"]')), chips);
66
66
  ok('S1 the Viewer opens wide for the two columns', await p.evaluate(() => document.getElementById('czViewer').offsetWidth >= 900 && getComputedStyle(document.querySelector('#czViewer .vzr-cols')).gridTemplateColumns.split(' ').length === 2));
@@ -80,7 +80,9 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
80
80
  const strip = (k) => press(p, `.vzr-fold [data-a="strip"][data-k="${k}"]`);
81
81
  await strip('since'); const since = await body(p); await strip('facts'); const facts = await body(p);
82
82
  ok('S3 Since the last one and Facts mentioned, Matches and Not checked', /Raised again/.test(since) && /Matches/.test(facts) && /Not checked/.test(facts), facts.slice(0, 600));
83
- ok('S3 the evidence has the screen captures, with no Waiting for design marker', await until(() => p.evaluate(() => document.querySelectorAll('#czViewer .vzp-card .vzp-th').length > 0 && !document.querySelector('#czViewer [data-cz-placeholder]'))));
83
+ ok('S3 MpD4: the captures row is hidden until its icon is pressed', !(await p.$('#czViewer .vzp-card .vzp-th')));
84
+ await press(p, '.vzr-np:not(.vzr-ph1) [data-a="caps"]');
85
+ ok('S3 the captures icon shows the screen captures, with no Waiting for design marker', await until(() => p.evaluate(() => document.querySelectorAll('#czViewer .vzp-card .vzp-th').length > 0 && !document.querySelector('#czViewer [data-cz-placeholder]'))));
84
86
  await press(p, '.vzp-card .vzp-th[data-a="seekto"]');
85
87
  ok('S3 a thumbnail moves the replay to its time', await until(() => p.evaluate(() => /^03:10/.test((document.querySelector('#czViewer [data-clk="pos"]') || {}).textContent || ''))), await p.evaluate(() => (document.querySelector('#czViewer [data-clk="pos"]') || {}).textContent));
86
88
  await strip('tr');
@@ -98,9 +100,9 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
98
100
 
99
101
  /* S5 a correction, and what PMOS learns (XvAReplayEdit) */
100
102
  const pid = await p.evaluate(() => { const r = document.querySelectorAll('#czViewer .vzr-row [data-a="psel"]')[1]; r.click(); return r.dataset.id; });
101
- ok('S5 pressing a row selects it and the evidence follows it: its time, its quote', await until(() => p.evaluate((id) => !!document.querySelector(`#czViewer .vzr-row.sel [data-a="psel"][data-id="${id}"]`) && !!document.querySelector(`#czViewer .vzr-ev [data-a="pedit"][data-id="${id}"]`), pid)), pid);
102
- await press(p, '.vzr-ev [data-a="pedit"]');
103
- ok('S5 Fix in the evidence opens the editor in place: the kinds, the words, what PMOS will remember', !!pid && await until(() => p.evaluate(() => !!document.querySelector('#czViewer .vzp-ed [data-a="etext"]') && document.querySelectorAll('#czViewer .vzp-ed [data-a="ekind"]').length === 3 && document.querySelector('#czViewer .vzp-ed [data-a="esave"]').disabled)), pid);
103
+ ok('S5 pressing a row selects it; the selected row has Fix and Not right', await until(() => p.evaluate((id) => !!document.querySelector(`#czViewer .vzr-row.sel [data-a="psel"][data-id="${id}"]`) && !!document.querySelector(`#czViewer .vzr-row.sel [data-a="pedit"][data-id="${id}"]`) && !!document.querySelector(`#czViewer .vzr-row.sel [data-a="pwrong"][data-id="${id}"]`), pid)), pid);
104
+ await press(p, '.vzr-row.sel [data-a="pedit"]');
105
+ ok('S5 Fix on the row opens the editor in place: the kinds, the words, what PMOS will remember', !!pid && await until(() => p.evaluate(() => !!document.querySelector('#czViewer .vzp-ed [data-a="etext"]') && document.querySelectorAll('#czViewer .vzp-dt .vzp-ed [data-a="ekind"]').length === 3 && document.querySelector('#czViewer .vzp-ed [data-a="esave"]').disabled)), pid);
104
106
  const kNow = await p.evaluate(() => document.querySelector('#czViewer .vzp-ed [data-a="ekind"][aria-checked="true"]').dataset.k);
105
107
  const kNew = kNow === 'question' ? 'action' : 'question';
106
108
  await press(p, `.vzp-ed [data-a="ekind"][data-k="${kNew}"]`);
@@ -111,12 +113,16 @@ process.on('unhandledRejection', (e) => { console.error(e); finish(1); });
111
113
  ok('S5 the page says Fixed and what it will remember, with Undo', await until(async () => /Fixed\./.test(await body(p)) && !!(await p.$('#czViewer .vz-msg [data-a="msgundo"]'))), (await body(p)).slice(0, 300));
112
114
  await press(p, '.vz-msg [data-a="msgundo"]');
113
115
  ok('S5 Undo forgets the lesson (POST /api/meeting-points/forget)', await until(() => A.S.posts.some((x) => x.path === '/api/meeting-points/forget' && x.body.lesson === 'l0123456789')), JSON.stringify(A.S.posts.map((x) => x.path)));
114
- /* MvF: a word heard wrong, selected in the quote, fixed in every place; Undo forgets it */
115
- await p.evaluate(() => { const q = document.querySelector('#czViewer .vzr-ev .vzr-q'); const tn = [...q.childNodes].find((x) => x.nodeType === 3 && /print/.test(x.textContent)) || q.firstChild; const i = tn.textContent.indexOf('print'); const r = document.createRange(); r.setStart(tn, i); r.setEnd(tn, i + 5); const s = getSelection(); s.removeAllRanges(); s.addRange(r); q.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); });
116
- ok('S5 a selected word becomes a field with ✓', await until(() => p.evaluate(() => { const f = document.querySelector('#czViewer .vzr-fe'); return !!f && f.dataset.w === 'print' && document.activeElement === f.querySelector('input'); })));
117
- await p.fill('#czViewer .vzr-fe input', 'Print'); await p.press('#czViewer .vzr-fe input', 'Enter');
118
- ok('S5 Enter posts the word fix and it shows fixed, with Undo', await until(() => A.S.posts.some((x) => x.path === '/api/meeting-points/term' && x.body.heard === 'print' && x.body.means === 'Print')) && await until(async () => !!(await p.$('#czViewer .vzr-fx')) && /Fixed/.test(await body(p)) && !!(await p.$('#czViewer .vz-msg [data-a="msgundo"]'))));
119
- await press(p, '.vz-msg [data-a="msgundo"]');
116
+ /* MpW1, MpW2 (batch 27 item 12): words heard wrong, selected in Next steps, fixed in every place; the ✦ ask waits; Undo forgets it */
117
+ await strip('steps');
118
+ await until(() => p.evaluate(() => [...document.querySelectorAll('#czViewer .vzm-step .vzm-grow')].some((x) => /print/.test(x.textContent))));
119
+ await p.evaluate(() => { const g = [...document.querySelectorAll('#czViewer .vzm-step .vzm-grow')].find((x) => /print/.test(x.textContent)); const w = document.createTreeWalker(g, NodeFilter.SHOW_TEXT); let tn; while ((tn = w.nextNode()) && !/print/.test(tn.textContent)); const i = tn.textContent.indexOf('print'); const r = document.createRange(); r.setStart(tn, i); r.setEnd(tn, i + 5); const s = getSelection(); s.removeAllRanges(); s.addRange(r); g.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); });
120
+ ok('S5 selected words open the popup: Heard as, the fix field with ✓, the places; no ✦ line without a real match', await until(() => p.evaluate(() => { const f = document.querySelector('.vzr-wpop'); return !!f && f.dataset.w === 'print' && /Heard as “print”/.test(f.textContent) && !!f.querySelector('[data-a="wfok"]') && /in this meeting/.test(f.textContent) && !f.querySelector('.vzr-sug'); })));
121
+ await p.fill('.vzr-wpop [data-a="wfin"]', 'Print'); await p.press('.vzr-wpop [data-a="wfin"]', 'Enter');
122
+ ok('S5 Enter posts the word fix: it shows fixed, a toast with Undo, and Names and terms asks to remember it', await until(() => A.S.posts.some((x) => x.path === '/api/meeting-points/term' && x.body.heard === 'print' && x.body.means === 'Print'))
123
+ && await until(async () => !!(await p.$('#czViewer .vzr-fx')) && !!(await p.$('#czViewer .vzr-toast [data-a="msgundo"]')) && !!(await p.$('#czViewer [data-a="wkeep"][data-v="1"]'))));
124
+ ok('S5 remembering waits for the ✓: nothing was posted to keep yet', !A.S.posts.some((x) => x.path === '/api/meeting-points/keep'));
125
+ await press(p, '.vzr-toast [data-a="msgundo"]');
120
126
  ok('S5 Undo forgets the word fix', await until(() => A.S.posts.some((x) => x.path === '/api/meeting-points/forget' && x.body.lesson === 't0123456789')) && await until(async () => !(await p.$('#czViewer .vzr-fx'))));
121
127
  await press(p, '[data-a="confirmall"]');
122
128
  ok('S5 Confirm N in the kept header confirms what is left to confirm', await until(() => A.S.posts.some((x) => x.path === '/api/meeting-points' && x.body.verdict === 'confirmed' && x.body.ids.length > 0)), JSON.stringify(A.S.posts.filter((x) => x.path === '/api/meeting-points')));
@@ -33,16 +33,25 @@ function pillOf(r) {
33
33
  if (r.ini) return `<a class="lab-pill y" href="${esc(S.H.iniHref(r.ini))}">✦ on initiative</a>`;
34
34
  return '';
35
35
  }
36
- function recRow(r) {
36
+ /** When a job ran, short: "19:37" today, "Tue 14:10" this week (Wawan, 2026-11-04: the rows said what ran but not when). */
37
+ export function whenOf(at, now = Date.now()) {
38
+ const d = new Date(at); if (Number.isNaN(d.getTime())) return '';
39
+ const hm = String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
40
+ return new Date(now).toDateString() === d.toDateString() ? hm : ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'][d.getDay()] + ' ' + hm;
41
+ }
42
+ /** One row of the record. A background job says what Claude did and what came out (routes-lab.js others); its pill shows only when a result
43
+ * waits on the initiative, and says so. */
44
+ export function recRow(r, now = Date.now()) {
37
45
  const open = r.compare ? ib(`data-lab-cmp="${esc(r.id)}" data-lab-board="${esc(r.board || '')}"`, 'Open in the compare view', LC.I.open) : '';
38
- const pill = r.kind === 'job' ? (r.ini ? `<a class="lab-pill" href="${esc(S.H.iniHref(r.ini))}">initiative</a>` : '') : pillOf(r);
39
- return `<div class="lab-row"><span class="lab-ico">${SVG(ICON[r.kind] || ICON.job, 18)}</span><span class="w"><b>${esc(r.title)}</b><small>${esc(r.meta || '')}</small></span>${pill}${open}</div>`;
46
+ const pill = r.kind === 'job' ? (r.ini && S.H ? `<a class="lab-pill" href="${esc(S.H.iniHref(r.ini))}">See what it found</a>` : '') : pillOf(r);
47
+ const meta = r.kind === 'job' ? [r.meta, whenOf(r.at, now)].filter(Boolean).join(' · ') : r.meta || '';
48
+ return `<div class="lab-row"><span class="lab-ico">${SVG(ICON[r.kind] || ICON.job, 18)}</span><span class="w"><b>${esc(r.title)}</b><small>${esc(meta)}</small></span>${pill}${open}</div>`;
40
49
  }
41
50
  function html(L) {
42
51
  const k = L.kept;
43
52
  const keptCard = k ? `<div class="lab-card"><small>You kept</small><b class="lab-big">${k.kept} of ${k.filed}</b><small>${[k.proto[1] ? 'prototypes ' + k.proto[0] + ' of ' + k.proto[1] : '', k.scan[1] ? 'scans ' + k.scan[0] + ' of ' + k.scan[1] : ''].filter(Boolean).join(' · ')}. Claude learns from each reason you give.</small></div>` : '';
44
53
  const next = L.next.length ? `<div class="lab-card"><b>Next time you step away</b>${L.next.map((x) => `<div class="lab-row"><span class="lab-dot open"></span><span class="w">${esc(x.title)}${x.why ? `<small>${esc(x.why)}</small>` : ''}</span></div>`).join('')}<small>Claude picks these by itself. To skip one, say so in chat.</small></div>` : '';
45
- const rec = L.record.length ? `<div class="lab-card lab-rec"><div class="lab-bar"><b>What Claude did in the background</b><small>this week, every job</small></div>${L.record.map(recRow).join('')}${L.more ? `<small>and ${L.more} more</small>` : ''}</div>` : '';
54
+ const rec = L.record.length ? `<div class="lab-card lab-rec"><div class="lab-bar"><b>What Claude did in the background</b><small>this week, every job</small></div>${L.record.map((r) => recRow(r)).join('')}${L.more ? `<small>and ${L.more} more</small>` : ''}</div>` : '';
46
55
  return `<h2 class="ins-h3" id="hLab">Lab</h2>`
47
56
  + (L.say ? `<p class="lab-say"><span aria-hidden="true">✦</span> ${esc(L.say)}</p>` : '')
48
57
  + L.loose.map(looseHtml).join('')
@@ -235,7 +235,7 @@
235
235
  .vzr { container-type: inline-size; padding: 0; background: none; gap: 14px; }
236
236
  .vzr-mbonly { display: none; }
237
237
  .vzr-cols { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px; align-items: start; }
238
- @container (min-width: 860px) { .vzr-cols { grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr); gap: 20px; } .vzr-ev { position: sticky; top: 0; } }
238
+ @container (min-width: 860px) { .vzr-cols { grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); gap: 22px; } .vzr-np:not(.vzr-ph1) { position: sticky; top: 0; } }
239
239
  .vzr-l { display: flex; flex-direction: column; gap: 14px; min-width: 0; }
240
240
  .vzr-sum .czs-p { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; font-size: var(--fs-body); line-height: 1.55; margin: 0; }
241
241
  .vzr-sum.all .czs-p { display: block; }
@@ -275,40 +275,138 @@
275
275
  .vzr-ch[aria-expanded="true"] { background: var(--cz-ink) !important; color: var(--cz-card); }
276
276
  .vzr-ch[aria-expanded="true"] b, .vzr-ch[aria-expanded="true"] span { color: var(--cz-card); }
277
277
  .vzr-panel { display: flex; flex-direction: column; gap: 6px; padding: 12px 14px; border-radius: 18px; border: 1px solid var(--cz-hair); background: var(--cz-card); }
278
- .vzr-ev { display: flex; flex-direction: column; gap: 12px; padding: 14px; border-radius: 20px; border: 1px solid var(--cz-hair); background: var(--cz-card); min-width: 0; }
279
- .vzr-shot { aspect-ratio: 16 / 9; background: var(--cz-well) !important; }
280
278
  .vzr-noshot { display: grid; place-items: center; min-height: 120px; border-radius: 12px; background: var(--cz-well); padding: 12px; text-align: center; }
281
- .vzr-acts { display: flex; align-items: center; gap: 10px; }
282
- .vzr-pri { width: 40px !important; height: 40px !important; background: var(--cz-blueink) !important; color: var(--cz-card) !important; }
283
- .vzr-at { font-size: var(--fs-row); font-weight: var(--fw-strong); color: var(--cz-blueink); font-variant-numeric: tabular-nums; line-height: var(--lh-row); }
284
- .vzr-q { margin: 0; font-size: var(--fs-body); line-height: 1.5; color: var(--cz-ink2); }
285
- .vzr-film { display: flex; gap: 6px; align-items: center; overflow: hidden; }
286
- .vzr-film.all { flex-wrap: wrap; }
287
- .vzr-film .vzp-th { width: 72px; height: 44px; }
288
- .vzr-more { height: 44px; min-width: 44px; padding: 0 8px !important; border-radius: 6px; background: var(--cz-well) !important; color: var(--cz-ink2); font-size: var(--fs-label); font-weight: var(--fw-strong); display: inline-grid !important; place-items: center; line-height: var(--lh-label); }
289
- .vzr-dock { position: sticky; bottom: 0; z-index: 2; flex-direction: row !important; align-items: center; gap: 14px !important; padding: 10px 16px !important; border-radius: 22px; background: var(--cz-card) !important; box-shadow: 0 18px 44px -18px color-mix(in oklab, var(--cz-ink) 40%, transparent), 0 0 0 1px var(--cz-hair); }
290
- .vzr-dock .vzp-ctl { flex: none; }
291
- .vzr-dock .vzp-ctl .vzp-mut { flex: none; }
292
- .vzr-tl { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
293
- .vzr-tops { align-items: flex-end; }
294
- .vzr-tops .vzp-top { white-space: normal; text-overflow: clip; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-height: 1.2; font-weight: var(--fw-strong); color: var(--cz-ink2); padding: 0 6px 0 0 !important; text-align: left !important; }
295
- .vzr-tops .vzp-top[aria-current="true"] { color: var(--cz-ink); font-weight: var(--fw-strong); }
296
279
  .vzp-kd[data-k="decision"] { fill: var(--cz-ok); }
297
280
  .vzp-kd[data-k="action"] { fill: var(--cz-blue); }
298
281
  .vzp-kd[data-k="question"] { fill: var(--cz-late); }
299
282
  /* a word fixed in place (MvF): fixed in green, a guess underlined dotted, the field in place */
300
- .vzr-fx { background: var(--cz-okbg); border-radius: 5px; padding: 0 3px; font-weight: var(--fw-strong); color: var(--cz-ink); }
283
+ .vzr-pts svg, .vzr-cfall svg, .vzr-panel .vzm-ib svg { fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
301
284
  .vzr-fl { display: inline !important; padding: 0 !important; font: inherit; color: var(--cz-ink); font-weight: var(--fw-strong); border-bottom: 2px dotted var(--cz-late) !important; border-radius: 0 !important; cursor: pointer; }
302
- .vzr-fe { display: inline-flex; align-items: center; gap: 4px; padding: 0 2px 0 8px; border-radius: 10px; background: var(--cz-card); box-shadow: 0 0 0 2px var(--cz-blue); vertical-align: middle; }
303
- .vzr-fe input { width: 12ch; height: 28px; border: 0; outline: 0; background: transparent; font: inherit; font-weight: var(--fw-strong); color: var(--cz-ink); }
304
- .vzr-ok { width: 26px; height: 26px; border-radius: 50%; display: inline-grid !important; place-items: center; background: var(--cz-blueink) !important; color: var(--cz-card); }
305
- .vzr-ok svg, .vzr-ev .vzp-ib svg, .vzr-pts svg, .vzr-cfall svg { fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
306
- .vzr-ev .vzp-fill, .vzr-pri svg { fill: currentColor; stroke: none; }
307
285
  @media (max-width: 640px) {
308
286
  .vzr-mbonly { display: block; }
309
287
  .vzr-mbonly > .czs-secs { margin-top: 0; }
310
288
  }
311
289
 
290
+ /* ── the player (batch 27 item 12: MpD1 now playing, MpD2 chapters, MpD4 captures shown, MpD3 phone) ── */
291
+ .vzr-np { position: relative; display: flex; flex-direction: column; gap: 14px; min-width: 0; }
292
+ .vzr-np svg { fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; flex: none; }
293
+ .vzr-np svg.vzr-fill, .vzr-np .vzr-big svg { fill: currentColor; stroke: none; }
294
+ .vzr-art { position: relative; border-radius: 20px; overflow: hidden; background: var(--cz-well); box-shadow: 0 0 0 1px var(--cz-hair), 0 18px 40px -24px color-mix(in oklab, var(--cz-ink) 40%, transparent); }
295
+ .vzr-crop { position: relative; display: block; width: 100%; aspect-ratio: 16 / 9; overflow: hidden; }
296
+ .vzr-crop img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; display: block; }
297
+ .vzr-crop.boxed img { inset: auto; object-fit: fill; max-width: none; }
298
+ .vzr-art .vzr-crop { --mh: 340px; max-height: var(--mh); }
299
+ .vzr-art .vzr-crop.boxed { max-height: none; width: min(100%, calc(var(--mh) * var(--ar, 1.78))); margin: 0 auto; }
300
+ .vzr-tag { position: absolute; left: 12px; bottom: 12px; padding: 3px 10px; border-radius: 999px; background: color-mix(in oklab, var(--cz-card) 92%, transparent); color: var(--cz-ink2); font-size: var(--fs-label); font-weight: var(--fw-strong); line-height: var(--lh-label); box-shadow: 0 0 0 1px var(--cz-hair); }
301
+ .vzr-full { position: absolute !important; right: 10px; top: 10px; width: 36px; height: 36px; border-radius: 50%; display: inline-grid !important; place-items: center; background: color-mix(in oklab, var(--cz-card) 92%, transparent) !important; color: var(--cz-ink2); box-shadow: 0 0 0 1px var(--cz-hair); }
302
+ .vzr-noshot { display: grid; place-items: center; min-height: 120px; border-radius: 20px; background: var(--cz-well); padding: 12px; text-align: center; }
303
+ .vzr-nt { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
304
+ .vzr-nt b { font-size: var(--fs-panel); font-weight: var(--fw-strong); letter-spacing: -.01em; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--cz-ink); line-height: 1.3; }
305
+ .vzr-nt span { font-size: var(--fs-meta); color: var(--cz-ink3); white-space: nowrap; line-height: var(--lh-meta); }
306
+ .vzr-barw { position: relative; padding-top: 2px; }
307
+ .vzr-bar { display: block; height: 26px; }
308
+ .vzr-bar svg { overflow: visible; }
309
+ .vzr-sg { stroke: none !important; fill: color-mix(in oklab, var(--cz-ink) 13%, var(--cz-card)); }
310
+ .vzr-sg[data-k="past"] { fill: color-mix(in oklab, var(--cz-blue) 50%, var(--cz-card)); }
311
+ .vzr-sg[data-k="cur"] { fill: color-mix(in oklab, var(--cz-blue) 20%, var(--cz-card)); }
312
+ .vzr-prog { stroke: none !important; fill: var(--cz-blue); }
313
+ .vzr-head { stroke: none !important; fill: var(--cz-blue); }
314
+ .vzr-mk { color: var(--cz-ink2); stroke-width: 3; }
315
+ .vzr-mk[data-k="question"] { color: var(--cz-late); }
316
+ .vzr-mk.on { color: var(--cz-ink); }
317
+ .vzr-md { fill: var(--cz-ok); stroke: none !important; }
318
+ .vzr-hov { position: absolute; bottom: 34px; z-index: 6; width: 160px; padding: 6px; border-radius: 14px; background: var(--cz-card); box-shadow: 0 16px 36px -14px color-mix(in oklab, var(--cz-ink) 50%, transparent), 0 0 0 1px var(--cz-hair); pointer-events: none; }
319
+ .vzr-hov[hidden] { display: none; }
320
+ .vzr-hth { display: block; border-radius: 9px; overflow: hidden; box-shadow: 0 0 0 1px var(--cz-hair); background: var(--cz-well); }
321
+ .vzr-hth .vzr-crop { aspect-ratio: 16 / 9; }
322
+ .vzr-hov b { display: block; margin: 6px 4px 0; font-size: var(--fs-meta); font-weight: var(--fw-strong); line-height: 1.25; color: var(--cz-ink); }
323
+ .vzr-hov small { display: block; margin: 0 4px 2px; font-size: var(--fs-badge); font-weight: var(--fw-strong); color: var(--cz-ink3); }
324
+ .vzr-times { display: flex; justify-content: space-between; margin-top: -6px; font-size: var(--fs-meta); font-weight: var(--fw-strong); color: var(--cz-ink3); font-variant-numeric: tabular-nums; line-height: var(--lh-meta); }
325
+ .vzr-tr { position: relative; display: flex; align-items: center; justify-content: center; gap: 8px; min-height: 60px; }
326
+ .vzr-L { position: absolute; left: 0; }
327
+ .vzr-R { position: absolute; right: 0; display: flex; gap: 6px; }
328
+ .vzr-speed { height: 28px; padding: 0 8px !important; border-radius: 999px; color: var(--cz-ink2); font-size: var(--fs-label); font-weight: var(--fw-strong); display: inline-flex !important; align-items: center; line-height: var(--lh-label); }
329
+ .vzr-ib { width: 36px; height: 36px; border-radius: 50%; display: inline-grid !important; place-items: center; color: var(--cz-ink2); flex: none; }
330
+ .vzr-ib.vzr-o { box-shadow: inset 0 0 0 1px var(--cz-hair2, var(--cz-hair)); }
331
+ .vzr-ib.on, .vzr-ib[aria-pressed="true"], .vzr-ib[aria-expanded="true"] { background: var(--cz-bluebg) !important; color: var(--cz-blueink); box-shadow: none; }
332
+ .vzr-skip { width: 46px; height: 46px; border-radius: 50%; display: inline-grid !important; place-items: center; color: var(--cz-ink); }
333
+ .vzr-skip text { font-family: var(--cz-sans); font-size: var(--fs-label); font-weight: var(--fw-strong); }
334
+ .vzr-big { width: 52px; height: 52px; border-radius: 50%; display: inline-grid !important; place-items: center; color: var(--cz-ink); }
335
+ .vzr-big svg { width: 22px; height: 22px; }
336
+ .vzr-big:hover { background: var(--cz-well) !important; }
337
+ .vzr-ib:hover, .vzr-skip:hover, .vzr-speed:hover { background: var(--cz-well) !important; }
338
+ .vzr-cap { display: flex; flex-direction: column; gap: 3px; padding: 2px 2px 0; }
339
+ .vzr-cp, .vzr-cx { font-size: var(--fs-body); color: var(--cz-ink3); line-height: var(--lh-body); }
340
+ .vzr-cn { font-size: var(--fs-panel); font-weight: var(--fw-strong); color: var(--cz-ink); line-height: 1.35; }
341
+ .vzr-cw { display: inline-block; margin-right: 8px; padding: 1px 9px; border-radius: 999px; background: var(--cz-bluebg); color: var(--cz-blueink); font-size: var(--fs-badge); font-weight: var(--fw-strong); vertical-align: 1px; line-height: var(--lh-badge); }
342
+ .vzr-film { display: flex; gap: 8px; align-items: center; overflow: hidden; }
343
+ .vzr-film.all { flex-wrap: wrap; }
344
+ .vzr-th { width: 84px !important; height: 52px !important; border-radius: 9px !important; border: 0 !important; box-shadow: 0 0 0 1px var(--cz-hair); background: var(--cz-well) !important; overflow: hidden; }
345
+ .vzr-th .vzr-crop { height: 100%; aspect-ratio: auto !important; }
346
+ .vzr-th.on { box-shadow: 0 0 0 2.5px var(--cz-blue); }
347
+ .vzr-more { min-height: 44px; padding: 0 6px !important; font-size: var(--fs-meta); font-weight: var(--fw-strong); color: var(--cz-ink3); line-height: var(--lh-meta); }
348
+ .vzr-chw { position: relative; height: 0; z-index: 7; }
349
+ .vzr-chs { position: absolute; right: 0; top: -46px; width: min(520px, 100%); max-height: 352px; overflow: auto; display: flex; flex-direction: column; gap: 1px; padding: 8px; border-radius: 22px; background: var(--cz-card); box-shadow: 0 24px 60px -24px color-mix(in oklab, var(--cz-ink) 50%, transparent), 0 0 0 1px var(--cz-hair); }
350
+ .vzr-chh { display: flex; align-items: center; gap: 8px; padding: 6px 12px; }
351
+ .vzr-lbl { font-size: var(--fs-badge); font-weight: var(--fw-strong); color: var(--cz-ink3); line-height: var(--lh-badge); }
352
+ .vzr-hint0 { font-size: var(--fs-meta); color: var(--cz-ink3); line-height: var(--lh-meta); }
353
+ .vzr-chr { display: grid !important; grid-template-columns: minmax(0, 1fr) auto auto; gap: 10px; align-items: center; min-height: 34px; padding: 7px 10px !important; border-radius: 12px; font-size: var(--fs-body); text-align: left !important; line-height: var(--lh-body); }
354
+ .vzr-chr b { font-weight: var(--fw-strong); color: var(--cz-ink); }
355
+ .vzr-chr.cur { background: var(--cz-bluebg) !important; }
356
+ .vzr-chr.cur b { font-weight: var(--fw-strong); }
357
+ .vzr-chr:hover { background: var(--cz-well) !important; }
358
+ .vzr-chm { display: inline-flex; gap: 5px; align-items: center; min-width: 30px; justify-content: flex-end; }
359
+ .vzr-chm .vzr-k { width: 14px; height: 14px; }
360
+ .vzr-chm .vzr-k svg { width: 14px; height: 14px; }
361
+ .vzr-cht { font-size: var(--fs-meta); color: var(--cz-ink3); font-variant-numeric: tabular-nums; line-height: var(--lh-meta); }
362
+ .vzr-ra { grid-column: 2 / 5; display: flex; justify-content: flex-end; gap: 6px; margin-top: 6px; }
363
+ .vzr-edit { grid-column: 1 / -1; margin-top: 8px; }
364
+ .vzr-row .vzp-kt { cursor: pointer; user-select: text; -webkit-user-select: text; }
365
+ .vzr-row .vzp-kt:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; border-radius: 6px; }
366
+ .vzr-hint { margin: -4px 6px 0; font-size: var(--fs-meta); color: var(--cz-ink3); line-height: var(--lh-meta); }
367
+ .vzr-phk { display: flex; flex-direction: column; gap: 4px; }
368
+ .vzr-phk .vzr-cf { display: none !important; }
369
+ .vzr-phk .vzp-kr.vzr-row { grid-template-columns: 20px minmax(0, 1fr) auto; }
370
+
371
+ /* ── word fix A (MpW1 popup, MpW2 after, MpWPhone sheet): a selected word, the popup, the toast, Names and terms ── */
372
+ .vzr-fx { color: var(--cz-ok); font-weight: var(--fw-strong); border-bottom: 2px dotted color-mix(in oklab, var(--cz-ok) 50%, transparent); }
373
+ .vzr-selw { background: color-mix(in oklab, var(--cz-blue) 28%, transparent); border-radius: 4px; padding: 0 1px; box-shadow: 0 0 0 1px color-mix(in oklab, var(--cz-blue) 60%, transparent); }
374
+ .vzr-wpop { position: fixed; z-index: 40; display: flex; flex-direction: column; gap: 9px; width: 380px; padding: 12px 12px 12px 14px; border-radius: 20px; background: var(--cz-card); color: var(--cz-ink); box-shadow: 0 24px 60px -24px color-mix(in oklab, var(--cz-ink) 50%, transparent), 0 0 0 1px var(--cz-hair); font-size: var(--fs-body); line-height: var(--lh-body); }
375
+ .vzr-wpop svg { fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
376
+ .vzr-wpop p { margin: 0; }
377
+ .vzr-grab { display: none; }
378
+ .vzr-wl { font-size: var(--fs-badge); font-weight: var(--fw-strong); color: var(--cz-ink3); line-height: var(--lh-badge); }
379
+ .vzr-wf { display: flex; align-items: center; gap: 8px; height: 46px; padding: 4px 4px 4px 14px; border-radius: 14px; background: var(--cz-well); }
380
+ .vzr-wf input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; font: inherit; font-size: var(--fs-field); font-weight: var(--fw-strong); color: var(--cz-ink); }
381
+ .vzr-go { width: 38px; height: 38px; border-radius: 50%; display: inline-grid !important; place-items: center; flex: none; color: var(--cz-ink); border: 0 !important; box-shadow: none !important; }
382
+ .vzr-go:hover { background: var(--cz-card) !important; }
383
+ .vzr-sug { display: flex; gap: 8px; align-items: flex-start; font-size: var(--fs-meta); color: var(--cz-ink2); line-height: var(--lh-meta); }
384
+ .vzr-star { color: var(--cz-blueink); font-weight: var(--fw-strong); font-size: var(--fs-section); line-height: 1.2; }
385
+ .vzr-alt { display: flex; gap: 6px; flex-wrap: wrap; }
386
+ .vzr-alt button { min-height: 28px; padding: 0 11px !important; border-radius: 999px; background: var(--cz-well) !important; color: var(--cz-ink2); font-size: var(--fs-meta); font-weight: var(--fw-strong); display: inline-flex !important; align-items: center; line-height: var(--lh-meta); border: 0 !important; box-shadow: none !important; }
387
+ .vzr-alt button[aria-pressed="true"] { background: var(--cz-bluebg) !important; color: var(--cz-blueink); }
388
+ .vzr-scope { font-size: var(--fs-meta); color: var(--cz-ink2); line-height: var(--lh-meta); }
389
+ .vzr-scope b { font-weight: var(--fw-strong); color: var(--cz-ink); }
390
+ .vzr-brk { display: flex; gap: 6px; flex-wrap: wrap; }
391
+ .vzr-brk span { padding: 2px 9px; border-radius: 999px; background: var(--cz-well); font-size: var(--fs-badge); font-weight: var(--fw-strong); color: var(--cz-ink3); line-height: var(--lh-badge); }
392
+ .vzr-arr { position: absolute; left: var(--arr, 34px); top: -6px; width: 12px; height: 12px; background: var(--cz-card); transform: rotate(45deg); box-shadow: -1px -1px 0 0 var(--cz-hair); }
393
+ .vzr-wpop.up .vzr-arr { top: auto; bottom: -6px; box-shadow: 1px 1px 0 0 var(--cz-hair); }
394
+ .vzr-wpop.sheet { left: 0 !important; right: 0; bottom: 0; top: auto !important; width: auto !important; padding: 10px 16px 26px; border-radius: 28px 28px 0 0; box-shadow: 0 -20px 50px -20px color-mix(in oklab, var(--cz-ink) 35%, transparent), 0 0 0 100vmax color-mix(in oklab, var(--cz-ink) 12%, transparent); }
395
+ .vzr-wpop.sheet .vzr-grab { display: block; align-self: center; width: 40px; height: 5px; border-radius: 3px; background: var(--cz-hair); }
396
+ .vzr-wpop.sheet .vzr-arr { display: none; }
397
+ .vzr-toastw { position: sticky; bottom: 16px; z-index: 30; display: flex; justify-content: center; pointer-events: none; margin-top: 8px; }
398
+ .vzr-toast { pointer-events: auto; display: inline-flex; align-items: center; gap: 12px; margin: 0; padding: 6px 6px 6px 18px; border-radius: 999px; background: var(--cz-ink); color: var(--cz-card); font-size: var(--fs-body); white-space: nowrap; box-shadow: 0 10px 30px -10px color-mix(in oklab, var(--cz-ink) 60%, transparent); line-height: var(--lh-body); }
399
+ .vzr-toast:not(:has(button)) { padding-right: 18px; }
400
+ .vzr-toast button { min-height: 34px; padding: 0 14px !important; border-radius: 999px; background: color-mix(in oklab, var(--cz-card) 16%, transparent) !important; color: var(--cz-card); font-weight: var(--fw-strong); }
401
+ .vzr-trow { align-items: center; }
402
+ .vzr-fxn { color: var(--cz-ok); }
403
+ .vzr-ask { display: flex; align-items: center; gap: 10px; padding: 9px 10px 9px 14px; border-radius: 16px; background: var(--cz-bluebg); font-size: var(--fs-body); line-height: var(--lh-body); }
404
+ .vzr-ask .vzr-star { font-size: var(--fs-panel); }
405
+ .vzr-ask svg, .vzr-trow svg { fill: none; stroke: currentColor; stroke-width: 1.9; stroke-linecap: round; stroke-linejoin: round; }
406
+ .vzr-a { width: 34px; height: 34px; border-radius: 50%; display: inline-grid !important; place-items: center; flex: none; background: var(--cz-card) !important; color: var(--cz-ink2); box-shadow: 0 0 0 1px var(--cz-hair); }
407
+ .vzr-a.y { background: var(--cz-blueink) !important; color: var(--cz-card); box-shadow: none; }
408
+ @media (max-width: 640px) { .vzr-art .vzr-crop { --mh: 240px; } .vzr-big { width: 48px; height: 48px; } }
409
+
312
410
  /* one Replay at a time: the phone's below 640px, else the desk's */
313
411
  .vzp.vzp-mb { display: none; }
314
412
  @media (max-width: 640px) {
@@ -317,7 +317,8 @@ export function replayModel(rep) {
317
317
  const stretches = (Array.isArray(rep.runs) ? rep.runs : []).filter((x) => Number.isFinite(x.from_ms) && Number.isFinite(x.to_ms) && x.to_ms > x.from_ms).map((x) => { const t = topics.find((y) => y.id === x.topic); return { n: x.n || (t && t.n) || 0, text: t ? t.text : '', from: Math.max(0, x.from_ms), to: Math.min(len, x.to_ms) }; }).filter((x) => x.to > x.from);
318
318
  const keys = (Array.isArray(rep.keys) ? rep.keys : []).filter((k) => k && ['decision', 'action', 'question'].includes(k.k)).map((k, i) => ({ id: k.id || 'k' + i, k: k.k, text: clip(k.text, 240), t: Number.isFinite(k.t) ? k.t : null, owner: clip(k.owner, 60), due: k.due || '', by: clip(k.by, 60), state: k.state || '',
319
319
  need: !!k.warn || k.state === 'owner' || k.state === 'open' }));
320
- const shots = (Array.isArray(rep.shots) ? rep.shots : []).filter((x) => Number.isFinite(x.t)).map((x) => ({ t: x.t, why: clip(x.why, 120) })).sort((a, b) => a.t - b.t);
320
+ const shots = (Array.isArray(rep.shots) ? rep.shots : []).filter((x) => Number.isFinite(x.t)).map((x) => ({ t: x.t, why: clip(x.why, 120), w: Number(x.w) || 0, h: Number(x.h) || 0,
321
+ ...(x.jev && (x.jev.kind === 'content' || x.jev.kind === 'faces') ? { jev: { kind: x.jev.kind, ...(Array.isArray(x.jev.box) && x.jev.box.length === 4 ? { box: x.jev.box.map(Number) } : {}) } } : {}) })).sort((a, b) => a.t - b.t);
321
322
  /* PMOS's notes (the meeting companion's, from the Focus file), the gaps where the recording was off, and the line each key point was caught from */
322
323
  const pmos = (Array.isArray(rep.notes) ? rep.notes : []).filter((x) => x && Number.isFinite(x.t) && String(x.body || '').trim()).map((x) => ({ t: Math.max(0, Math.min(len, x.t)), cat: clip(x.cat, 30), body: clip(x.body, 4000) })).sort((a, b) => a.t - b.t);
323
324
  const gaps = (Array.isArray(rep.gaps) ? rep.gaps : []).filter((g) => Number.isFinite(g.from_ms) && Number.isFinite(g.to_ms) && g.to_ms > g.from_ms).map((g) => ({ from: g.from_ms, to: Math.min(len, g.to_ms) }));