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
@@ -81,7 +81,7 @@ function validKey(key) {
81
81
  return typeof key === 'string' && key.length >= 8 && key.length <= 512 && !/[\s\u0000-\u001f\u007f]/.test(key);
82
82
  }
83
83
 
84
- function once({ key, body, timeoutMs, ep = endpoint() }) {
84
+ function once({ key, body, timeoutMs, ep = endpoint(), headers: extra = null }) {
85
85
  const mod = ep.protocol === 'http:' ? http : https;
86
86
  const payload = Buffer.from(body, 'utf8');
87
87
  return new Promise((resolve) => {
@@ -91,7 +91,7 @@ function once({ key, body, timeoutMs, ep = endpoint() }) {
91
91
  protocol: ep.protocol, hostname: ep.hostname, port: ep.port, path: PATH, method: 'POST',
92
92
  headers: {
93
93
  'Content-Type': 'application/json', 'Content-Length': payload.length,
94
- ...(key ? { Authorization: 'Bearer ' + key } : {}), 'User-Agent': 'pmos-workspace',
94
+ ...(key ? { Authorization: 'Bearer ' + key } : {}), 'User-Agent': 'pmos-workspace', ...(extra || {}),
95
95
  },
96
96
  timeout: timeoutMs,
97
97
  }, (res) => {
@@ -135,10 +135,10 @@ function once({ key, body, timeoutMs, ep = endpoint() }) {
135
135
  * @returns {Promise<{ok:true, model, answers, usage}|{ok:false, reason, status?, requested:boolean}>}
136
136
  * `requested` says whether a request left this process, so a caller can tell a refusal from a failure.
137
137
  */
138
- async function send({ key, body, ep, timeoutMs, retries, backoffMs }) {
138
+ async function send({ key, body, ep, timeoutMs, retries, backoffMs, headers = null }) {
139
139
  let last = null;
140
140
  for (let attempt = 0; attempt <= retries; attempt++) {
141
- last = await once({ key, body, timeoutMs, ep });
141
+ last = await once({ key, body, timeoutMs, ep, headers });
142
142
  if (last.ok) return { ...last, requested: true };
143
143
  if (!(last.status === 0 || retryable(last.status)) || attempt === retries) break;
144
144
  await sleep(backoffMs * Math.pow(2, attempt));
@@ -182,4 +182,70 @@ async function ask({ key, state, questions, model = DEFAULT_MODEL, timeoutMs = 3
182
182
  return l.ok ? { ...l, fallback: true, hosted_reason: r.reason } : { ...r, reason: r.reason + '; ' + l.reason };
183
183
  }
184
184
 
185
- module.exports = { ask, validKey, configureLocal, localTarget, validPort, validBackend, HOST, PATH, DEFAULT_MODEL, MAX_STATE_CHARS, MAX_QUESTIONS, LOCAL_HOST, LOCAL_KEY, BACKENDS, __setEndpointForTests };
185
+ /* ── images (docs.typesafe.ai/images.md, read 2026-11-04) ──────────────────────────────────────────────────────────────
186
+ * Jev 1.13 and 1.14 take still PNG or JPEG images; a Box question needs Jev 1.14 (jev-latest). Each request that holds an image
187
+ * or a Box question carries the header `TypeSafe-Danger-Zone-Images: true` (the feature is a preview). The image goes in the
188
+ * top-level `assets` map under a reference, and the reference is written in `state` where the image belongs. The service does
189
+ * not resize: each side must be 280 to 1568 px and the picture 78,400 to 2,458,624 px, so a caller sends a picture in range.
190
+ * The local servers (Winnow, Laya) take text only, so an image is never asked locally. */
191
+ const IMAGE_HEADER = { 'TypeSafe-Danger-Zone-Images': 'true' };
192
+ const IMAGE_SIDE = { min: 280, max: 1568 };
193
+ const IMAGE_PIXELS = { min: 78400, max: 2458624 };
194
+ const MAX_IMAGE_BYTES = 16 * 1024 * 1024;
195
+
196
+ /** The width and height a PNG or JPEG file says it has, from its own bytes → { format: 'png'|'jpg', w, h } | null. */
197
+ function imageSize(buf) {
198
+ if (!Buffer.isBuffer(buf) || buf.length < 24) return null;
199
+ if (buf.readUInt32BE(0) === 0x89504e47 && buf.toString('latin1', 12, 16) === 'IHDR') return { format: 'png', w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) };
200
+ if (buf[0] !== 0xff || buf[1] !== 0xd8) return null;
201
+ let i = 2;
202
+ while (i + 9 < buf.length) {
203
+ if (buf[i] !== 0xff) { i++; continue; }
204
+ const m = buf[i + 1];
205
+ if (m === 0xff) { i++; continue; }
206
+ if (m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7)) { i += 2; continue; }
207
+ const len = buf.readUInt16BE(i + 2);
208
+ // a start-of-frame marker (C0–CF, but not C4 DHT, C8 JPG, CC DAC) holds the height then the width
209
+ if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { format: 'jpg', w: buf.readUInt16BE(i + 7), h: buf.readUInt16BE(i + 5) };
210
+ i += 2 + len;
211
+ }
212
+ return null;
213
+ }
214
+ /** Why Jev would refuse this picture, or null when it is in range. */
215
+ function imageRefusal(size, bytes) {
216
+ if (!size) return 'the picture is not a PNG or a JPEG';
217
+ if (bytes > MAX_IMAGE_BYTES) return 'the picture is larger than 16 MB';
218
+ const { w, h } = size;
219
+ if (w < IMAGE_SIDE.min || h < IMAGE_SIDE.min || w > IMAGE_SIDE.max || h > IMAGE_SIDE.max) return `the picture is ${w} × ${h} px; Jev takes 280 to 1568 px a side`;
220
+ if (w * h < IMAGE_PIXELS.min || w * h > IMAGE_PIXELS.max) return `the picture has ${w * h} px; Jev takes 78,400 to 2,458,624`;
221
+ return null;
222
+ }
223
+
224
+ /**
225
+ * Ask Jev typed questions about one picture.
226
+ *
227
+ * @param {object} a
228
+ * @param {string} a.key the API key. Required; no key (or only the local handle) means no request.
229
+ * @param {Buffer} a.image a PNG or JPEG file, in Jev's size range (imageRefusal says why not).
230
+ * @param {string} a.name how `state` and the questions call the picture, e.g. 'capture'. Letters only.
231
+ * @param {object} a.questions { name: { type: 'noul'|'score'|'choice'|'box', instructions, criteria? } }
232
+ * @param {string} [a.text] words to send with the picture (no secret: the caller scans them).
233
+ * @returns {Promise<{ok:true, model, answers, usage}|{ok:false, reason, status?, requested:boolean}>}
234
+ */
235
+ async function askImage({ key, image, name = 'capture', questions, text = '', model = DEFAULT_MODEL, timeoutMs = 30000, retries = 2, backoffMs = 600 } = {}) {
236
+ if (!validKey(key) || key === LOCAL_KEY) return { ok: false, requested: false, reason: key === LOCAL_KEY ? 'the local model takes no pictures' : 'no TypeSafe key is set' };
237
+ const size = imageSize(image);
238
+ const refused = imageRefusal(size, image ? image.length : 0);
239
+ if (refused) return { ok: false, requested: false, reason: refused };
240
+ if (!/^[a-z]{1,32}$/.test(name)) return { ok: false, requested: false, reason: 'the picture needs a short name' };
241
+ const names = questions && typeof questions === 'object' ? Object.keys(questions) : [];
242
+ if (!names.length || names.length > MAX_QUESTIONS) return { ok: false, requested: false, reason: `between 1 and ${MAX_QUESTIONS} questions are required` };
243
+ // the reference is random, so no words in the questions or the text can match it by chance (images.md "References")
244
+ const ref = require('crypto').randomBytes(6).toString('hex') + '-' + name;
245
+ const words = String(text || '').slice(0, 4000);
246
+ const body = JSON.stringify({ model, state: { [name]: ref, ...(words ? { words } : {}) },
247
+ assets: { [ref]: { type: 'image', format: size.format, data_base64: image.toString('base64') } }, questions });
248
+ return send({ key, body, ep: endpoint(), timeoutMs, retries, backoffMs, headers: IMAGE_HEADER });
249
+ }
250
+
251
+ module.exports = { ask, askImage, imageSize, imageRefusal, IMAGE_SIDE, IMAGE_PIXELS, IMAGE_HEADER, validKey, configureLocal, localTarget, validPort, validBackend, HOST, PATH, DEFAULT_MODEL, MAX_STATE_CHARS, MAX_QUESTIONS, LOCAL_HOST, LOCAL_KEY, BACKENDS, __setEndpointForTests };
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.528.0",
3
+ "version": "0.530.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.530.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.530.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) {
@@ -23,7 +23,7 @@
23
23
  * pictures of the last RING_MS (20 s) in memory for this, because a line is transcribed some seconds after it is said.
24
24
  * - highlight: the PM's Highlight keeps the picture on screen at that moment.
25
25
  * A picture the last kept shot already shows is never kept again, whatever the reason. Each shot carries why it was kept.
26
- * The shot is a JPEG of the frame at most MAX_W px wide. It is posted with the meeting time, the note and the capture token, so
26
+ * The shot is a JPEG of the frame at most MAX_SIDE px on its longer side: 1568 is the most Jev for images reads (lib/jev-screen.js). It is posted with the meeting time, the note and the capture token, so
27
27
  * the server keeps it beside the transcript and refuses a second tab (lib/notes-screen.js).
28
28
  *
29
29
  * WHAT IT NEVER DOES. It sends a shot only to this workspace's own server (`post`), never anywhere else.
@@ -32,7 +32,7 @@ import { bind, unbind } from './notes-shots.js';
32
32
 
33
33
  const SMALL_W = 64;
34
34
  const SMALL_H = 36;
35
- const MAX_W = 1600;
35
+ const MAX_SIDE = 1568;
36
36
  const QUALITY = 0.72;
37
37
 
38
38
  export function changed(prev, cur, { pixel = 24, share = 0.02 } = {}) {
@@ -87,8 +87,9 @@ export function startScreen({ track, id, cap, clock, post, every = 2000, slowGap
87
87
  const s = { count: 0, lost: 0, stopped: false, paused: false, last: null, lastAt: 0, prev: null, moving: 0, still: 0, run: 0, ring: [], kept: new Map(), cues: [], heard: new Set(), chain: Promise.resolve(), timer: null };
88
88
 
89
89
  const snap = async (t, grey) => {
90
- const w = Math.min(MAX_W, video.videoWidth);
91
- const h = Math.round(video.videoHeight * (w / video.videoWidth));
90
+ const k = Math.min(1, MAX_SIDE / video.videoWidth, MAX_SIDE / video.videoHeight);
91
+ const w = Math.round(video.videoWidth * k);
92
+ const h = Math.round(video.videoHeight * k);
92
93
  big.width = w; big.height = h;
93
94
  big.getContext('2d').drawImage(video, 0, 0, w, h);
94
95
  const blob = await new Promise((res) => big.toBlob(res, 'image/jpeg', QUALITY));
@@ -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) }));