create-pmos 0.186.0 → 0.188.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 (43) hide show
  1. package/package.json +1 -1
  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/scripts/workflow-state.js +2 -1
  6. package/payload/state/discoveries.md +1 -1
  7. package/workspace/c3-ib-triage-backlog-test.js +20 -13
  8. package/workspace/c3-ini-life-test.js +167 -0
  9. package/workspace/c3-initiatives-test.js +6 -6
  10. package/workspace/c3-phone-test.js +67 -16
  11. package/workspace/c3-quiet-test.js +2 -1
  12. package/workspace/guide/initiative-boards.md +37 -2
  13. package/workspace/guide/my-work.md +1 -0
  14. package/workspace/guide/phone-companion.md +19 -4
  15. package/workspace/lib/day-run.js +4 -3
  16. package/workspace/lib/engine/boards.js +73 -18
  17. package/workspace/lib/engine/workflow-state.js +2 -1
  18. package/workspace/lib/initiative-rows.js +2 -2
  19. package/workspace/lib/my-work.js +2 -1
  20. package/workspace/lib/routes-boards.js +119 -6
  21. package/workspace/lib/routes-meeting-focus.js +19 -7
  22. package/workspace/lib/vendor/scripts/workflow-state.js +2 -1
  23. package/workspace/lib/work-assigned.js +1 -1
  24. package/workspace/package-lock.json +2 -2
  25. package/workspace/package.json +1 -1
  26. package/workspace/public/pm/c3-demo-boards.js +82 -17
  27. package/workspace/public/pm/c3-demo-focus.js +39 -1
  28. package/workspace/public/pm/c3-focus.css +53 -0
  29. package/workspace/public/pm/c3-focus.js +188 -44
  30. package/workspace/public/pm/c3-ib-initiatives.js +127 -60
  31. package/workspace/public/pm/c3-ib-items.js +2 -2
  32. package/workspace/public/pm/c3-ini-life.css +32 -0
  33. package/workspace/public/pm/c3-ini-life.js +379 -0
  34. package/workspace/public/pm/c3-ini-ui.js +12 -7
  35. package/workspace/public/pm/c3-initiatives.js +14 -4
  36. package/workspace/public/pm/c3-phone.css +3 -1
  37. package/workspace/public/pm/c3-phone.js +28 -8
  38. package/workspace/public/pm/console3.css +3 -0
  39. package/workspace/public/pm/pm-work-view.js +11 -5
  40. package/workspace/public/pm/run-card.js +12 -3
  41. package/workspace/public/pm/sessions-panel.js +2 -2
  42. package/workspace/server.js +25 -3
  43. package/workspace/test-lists/c3-ini-life.json +9 -0
@@ -1,5 +1,5 @@
1
1
  /* pm/c3-focus.js — the Meeting focus page of console3 (initiative c3-meeting-focus; the mockup the PM approved on 2026-10-12, version 7).
2
- * openFocus({ event, date, pop, back, meet, mtitle }) closeFocus()
2
+ * openFocus({ event, date, pop, back, phone, note, meet, mtitle }) closeFocus()
3
3
  * One calm full-screen page over console3, inside #c3-root: it covers the header, the tabs and the dock (they stay in the DOM, set inert) and Leave
4
4
  * focus gives them back. Recording is not touched by leaving. It reads GET /api/meeting-focus every 3 seconds while it is open:
5
5
  * header title, time, people, initiative; the clock (starts in, or elapsed and left); REC with Start recording or End meeting (the note dock);
@@ -15,7 +15,12 @@
15
15
  * the captured items are not drawn, and there is no Leave focus. With back (the phone companion opens it so) the head and the foot have
16
16
  * "Back to your day" in place of Open focus in PMOS, which closes the page as Leave focus does. ?focus=now first asks GET /api/meeting-focus/now (meet = the Meet code, mtitle = the tab's title) which
17
17
  * meeting the call is; when none fits, the page says so and lists today's meetings to pick from. Fold shows the head only and tells the frame's parent with one
18
- * message, { pmosFocus: 'fold' | 'open' }, that carries no data. */
18
+ * message, { pmosFocus: 'fold' | 'open' }, that carries no data.
19
+ * Phone mode (phone, initiative c3-phone-companion, mockup version 5): the phone companion opens this page when a recording starts. It is the full
20
+ * page in one column: a head with "Back to your day" (recording goes on), the title, the clock and the bar of the talk; the now line; chips that open
21
+ * sheets (Topics, Captured, Note, and Your slides or Material for the stage); What's being said; the conversation; and a foot with Ask PMOS quietly
22
+ * and Stop. Nothing on it leads to the desktop pages. A recording that is not on the calendar (a talk in the corridor) opens with note and no
23
+ * event: GET /api/meeting-focus?note= answers it with no topics, no brief and no stage. The address is not changed. */
19
24
  import { el } from './dom.js';
20
25
  import { pmGet, pmPost } from './http.js';
21
26
  import { ensureC3Css } from './pm-work-view.js';
@@ -77,27 +82,37 @@ export function closeFocus() {
77
82
  const host = document.getElementById('c3-root');
78
83
  if (host) host.classList.remove('fc-open');
79
84
  const grid = host && host.querySelector('.c3grid'); if (grid) grid.inert = false;
85
+ const comp = host && host.querySelector('[data-c3ph]'); if (comp) comp.inert = false;
86
+ if (f.phone) { const st = f.phone && document.querySelector('[data-c3ph-focus-bar]'); if (st) st.focus({ preventScroll: true }); return; }
80
87
  try { const u = new URL(location.href); if (u.searchParams.has('focus')) { u.searchParams.delete('focus'); u.searchParams.delete('fdate'); history.replaceState(history.state, '', u); } } catch { /* no history */ }
81
88
  if (f.opener && document.contains(f.opener) && typeof f.opener.focus === 'function') { try { f.opener.focus({ preventScroll: true }); } catch { /* not focusable any more */ } }
82
89
  }
83
90
 
84
- export function openFocus({ event, date, pop, back, meet, mtitle } = {}) {
91
+ export function openFocus({ event, date, pop, back, phone, note, meet, mtitle } = {}) {
85
92
  closeFocus();
86
93
  const ev = event || {};
87
94
  const want = String(ev.id || '');
95
+ if (phone) pop = false; // the phone has the whole page, in one column
88
96
  const resolve = !!pop && want === 'now'; // the extension names no meeting: which one this call is comes first
89
97
  const id = resolve ? '' : want;
90
98
  const day = String(date || String(ev.at || ev.start || '').slice(0, 10) || '');
91
99
  ensureCss();
92
100
  const host = document.getElementById('c3-root') || document.body;
93
- const root = el('div', { class: 'c3focus' + (pop ? ' fc-pop' : ''), role: 'region', 'aria-label': pop ? 'PMOS meeting helper' : 'Meeting focus', 'data-c3focus': '', ...(pop ? { 'data-fc-pop': '' } : {}) });
101
+ const root = el('div', { class: 'c3focus' + (pop ? ' fc-pop' : '') + (phone ? ' fc-phone' : ''), role: 'region', 'aria-label': pop ? 'PMOS meeting helper' : 'Meeting focus', 'data-c3focus': '', ...(pop ? { 'data-fc-pop': '' } : {}), ...(phone ? { 'data-fc-phone': '' } : {}) });
94
102
  const r = {};
103
+ r.form = askForm();
95
104
  const convo = el('section', { class: 'fc-cv', 'aria-label': 'The conversation, with PMOS' },
96
- pop ? null : el('div', { class: 'fc-cv-h' }, el('b', null, 'Conversation'), el('span', { class: 'dim' }, 'PMOS replies only to you')),
105
+ pop || phone ? null : el('div', { class: 'fc-cv-h' }, el('b', null, 'Conversation'), el('span', { class: 'dim' }, 'PMOS replies only to you')),
97
106
  r.tick = el('div', { class: 'fc-tk', 'aria-label': 'Last lines in the room', 'data-fc': 'ticker' }),
98
107
  r.stream = el('ol', { class: 'fc-s2', role: 'list', 'data-fc': 'stream' }),
99
- r.form = askForm());
100
- const page = pop
108
+ phone ? null : r.form);
109
+ const page = phone
110
+ ? el('div', { class: 'fc-page' },
111
+ r.top = el('header', { class: 'fc-phd', 'data-fc': 'phone-head' }),
112
+ r.now = el('div', { class: 'fc-phnow', 'data-fc': 'now-line' }),
113
+ r.band = el('div', { class: 'fc-bandwrap' }), convo,
114
+ el('div', { class: 'fc-phft', 'data-fc': 'phone-foot' }, r.form, r.act = el('span', { class: 'fc-phact' })))
115
+ : pop
101
116
  ? el('div', { class: 'fc-page' },
102
117
  r.top = el('header', { class: 'fc-ph', 'data-fc': 'pop-head' }),
103
118
  el('div', { class: 'fc-popbody', 'data-fc': 'pop-body' },
@@ -114,14 +129,15 @@ export function openFocus({ event, date, pop, back, meet, mtitle } = {}) {
114
129
  el('aside', { class: 'fc-w', 'aria-label': 'Conversation' }, convo)));
115
130
  root.append(page);
116
131
  F = { id, date: day, ev0: ev, root, r, sig: {}, data: null, offset: 0, ui: { open: new Set(), folded: new Set(), pulled: [], stage: 0, picker: false, newTopic: false, addTopic: false, heldOpen: false, pending: new Map(), failed: new Map(), pill: false },
117
- pop: !!pop, back: !!back, meet: String(meet || ''), mtitle: String(mtitle || ''), choices: null,
132
+ pop: !!pop, back: !!back, phone: !!phone, noteId: id ? '' : String(note || ''), meet: String(meet || ''), mtitle: String(mtitle || ''), choices: null,
118
133
  timers: {}, askTimers: new Map(), sheet: null, sheetOpen: false, status: 'reading', error: '',
119
134
  opener: document.activeElement && document.activeElement !== document.body ? document.activeElement : null, seenNotes: 0 };
120
135
  // a fixed box inside an element with container-type is placed against that element, not the screen: lift it while the page is open
121
136
  host.classList.add('fc-open');
122
137
  host.append(root);
123
138
  const grid = host.querySelector && host.querySelector('.c3grid'); if (grid) grid.inert = true;
124
- if (!pop) { try { const u = new URL(location.href); u.searchParams.set('focus', id); if (day) u.searchParams.set('fdate', day); history.replaceState(history.state, '', u); } catch { /* no history */ } } // the compact page keeps its address: a reload asks again which meeting it is
139
+ const comp = host.querySelector && host.querySelector('[data-c3ph]'); if (comp) comp.inert = true; // the phone companion under the page
140
+ if (!pop && !phone) { try { const u = new URL(location.href); u.searchParams.set('focus', id); if (day) u.searchParams.set('fdate', day); history.replaceState(history.state, '', u); } catch { /* no history */ } } // the compact page keeps its address: a reload asks again which meeting it is
125
141
  draw();
126
142
  F.timers.clock = setInterval(tick, 1000);
127
143
  if (resolve) resolveNow(); else load();
@@ -162,8 +178,9 @@ async function load() {
162
178
  const e = f.ev0;
163
179
  const p = new URLSearchParams();
164
180
  if (f.id) p.set('event', f.id);
165
- if (f.date) p.set('date', f.date);
166
- if (!f.id && (e.at || e.start)) { p.set('at', e.at || e.start); if (e.title) p.set('title', e.title); }
181
+ else if (f.noteId) p.set('note', f.noteId); // a recording with no meeting
182
+ if (f.date && !f.noteId) p.set('date', f.date);
183
+ if (!f.id && !f.noteId && (e.at || e.start)) { p.set('at', e.at || e.start); if (e.title) p.set('title', e.title); }
167
184
  const j = await pmGet('/api/meeting-focus?' + p.toString());
168
185
  if (F !== f) return;
169
186
  if (j && j.ok) {
@@ -199,6 +216,7 @@ function draw() {
199
216
  const d = F.data;
200
217
  F.root.dataset.fcState = d ? 'ok' : F.status;
201
218
  if (F.pop) { drawPop(d); return; }
219
+ if (F.phone) { drawPhone(d); return; }
202
220
  if (!d) {
203
221
  const title = F.ev0.title || 'Meeting';
204
222
  region('top', ['wait', F.status, F.error], () => [el('div', { class: 'fc-id' }, leaveBtn(), el('div', { class: 'fc-tt' }, el('h1', null, title),
@@ -249,6 +267,107 @@ function popMessage() {
249
267
  if (F.status === 'reading') return [el('p', { class: 'dim mono fc-popmsg', role: 'status', 'data-fc': 'pop-msg' }, 'Reading the meeting.')];
250
268
  return [el('p', { class: 'dim mono fc-popmsg', role: 'status', 'data-fc': 'pop-msg' }, F.status === 'missing' ? 'PMOS has no record of this meeting on the calendar, so there is nothing to show.' : F.error || 'The meeting could not be read.')];
251
269
  }
270
+ /* ── phone mode: the full page in one column ────────────────────────────────────────────────────────────────── */
271
+
272
+ function drawPhone(d) {
273
+ if (!d) {
274
+ const title = F.ev0.title || (F.noteId ? 'Conversation' : 'Meeting');
275
+ region('top', ['wait', F.status, F.error], () => [el('div', { class: 'fc-phid' }, backBtn(), el('div', { class: 'fc-tt' }, el('h1', null, title),
276
+ el('div', { class: 'dim mono fc-sub', role: 'status' }, F.status === 'missing' ? (F.noteId ? 'PMOS could not find the notes of this recording.' : 'PMOS has no record of this meeting on the calendar, so there is nothing to show.') : F.status === 'error' ? F.error : 'Reading the meeting.')))]);
277
+ for (const k of ['now', 'band', 'tick', 'stream', 'act']) region(k, 'wait', () => []);
278
+ return;
279
+ }
280
+ const ph = phaseOf();
281
+ drawPhTop(d, ph); drawPhNow(d, ph); drawBand(d); drawConversation(d); drawPhAct(d, ph);
282
+ tick();
283
+ }
284
+ const backBtn = () => ib('Back to your day', 'prev', { 'data-fc': 'leave', title: 'Back to your day. It keeps recording.' });
285
+ function drawPhTop(d, ph) {
286
+ const e = d.event; const rec = recState(d); const ini = (d.brief && d.brief.initiative) || null;
287
+ const sig = [e.title, e.at, e.end, e.attendees, ini, e.initiative, ph, rec, !!d.talk, d.topics.map((t) => t.id), d.runs, F.status === 'ok' && F.stale ? 1 : 0];
288
+ region('top', sig, () => {
289
+ const s = startMs(); const f = endMs();
290
+ const people = e.attendees || [];
291
+ const sub = d.talk ? [Number.isFinite(s) ? 'Started ' + hhmm(s) : null, 'not on the calendar']
292
+ : [Number.isFinite(s) ? hhmm(s) + (Number.isFinite(f) ? ' to ' + hhmm(f) : '') : null, ini ? ini.title : e.initiative, people.length ? people.length + (people.length === 1 ? ' person' : ' people') : null];
293
+ const clock = ph === 'before'
294
+ ? [el('b', { class: 'mono', 'data-fc': 'big' }, clockText((s - nowMs()) / 1000)), el('span', { class: 'dim mono' }, 'starts in')]
295
+ : [el('span', { class: 'fc-recd', 'data-fc': 'rec', 'data-rec': rec, title: rec === 'recording' ? 'Recording' : rec === 'paused' ? 'Paused' : '' }, rec === 'recording' ? el('span', { class: 'fc-dot' }) : null,
296
+ el('b', { class: 'mono', 'data-fc': 'big' }, clockText((nowMs() - s) / 1000))),
297
+ Number.isFinite(f) ? el('span', { class: 'dim mono' }, el('span', { 'data-fc': 'left' }, clockText((f - nowMs()) / 1000)), ' left') : rec === 'paused' ? el('span', { class: 'dim mono' }, 'paused') : null];
298
+ return [el('div', { class: 'fc-phid' }, backBtn(),
299
+ el('div', { class: 'fc-tt' }, el('h1', null, e.title), el('div', { class: 'dim mono fc-sub' }, sub.filter(Boolean).join(' · '))),
300
+ el('div', { class: 'fc-phclk', role: 'timer', 'aria-live': 'off' }, ...clock)),
301
+ d.talk || !d.runs.length ? null : trackEl(d),
302
+ F.stale ? el('p', { class: 'dim mono fc-stale', role: 'status' }, 'The last read failed. Showing what PMOS had.') : null];
303
+ });
304
+ }
305
+ function drawPhNow(d, ph) {
306
+ const cur = currentOf(d); const nc = d.topics.filter((t) => t.state === 'notyet').length; const cap = capCount(d);
307
+ const links = linksOf(d); const slides = links.some((l) => l.kind === 'slides');
308
+ region('now', [cur && cur.id, cur && cur.text, d.topics.length, nc, cap, d.note && d.note.id, ph, d.topics[0] && d.topics[0].text, !!d.talk, links.length, slides], () => [
309
+ d.talk ? null : cur
310
+ ? el('div', { class: 'fc-nowt' }, el('span', { class: 'ki hot' }, icon('play', 'sm')), el('b', { 'data-fc': 'topic' }, cur.text), el('span', { class: 'mono dim', 'data-fc': 'onfor' }, ''))
311
+ : el('div', { class: 'fc-nowt dim', 'data-fc': 'first' }, d.topics.length ? [ph === 'before' ? 'First up: ' : 'No topic yet. First up: ', el('b', null, d.topics[0].text)] : 'No topics yet. PMOS adds topics as they come up.'),
312
+ el('div', { class: 'fc-phchips', role: 'group', 'aria-label': 'Open beside the talk' },
313
+ d.talk ? null : lk({ 'data-fc': 'topics' }, icon('todo', 'sm'), d.topics.length + ' topic' + (d.topics.length === 1 ? '' : 's') + (cur && nc ? ' · ' + nc + ' not yet' : '')),
314
+ lk({ 'data-fc': 'captured' }, icon('check', 'sm'), 'Captured ' + cap),
315
+ d.note ? lk({ 'data-fc': 'note' }, icon('notes', 'sm'), 'Note') : null,
316
+ links.length ? lk({ 'data-fc': 'material' }, icon('doc', 'sm'), slides ? 'Your slides' : 'Material') : null)]);
317
+ }
318
+ function drawPhAct(d, ph) {
319
+ const rec = recState(d);
320
+ region('act', [rec, ph], () => (rec === 'recording' || rec === 'paused' ? [pbtn('outline', 'Stop', 'stop', { 'data-fc': 'stop', title: 'Stop recording' })]
321
+ : rec !== 'ended' && ph !== 'after' && !d.talk ? [pbtn('primary', 'Record', 'play', { 'data-fc': 'start', title: 'Records this meeting in a note' })] : []));
322
+ }
323
+ function stopBuild() {
324
+ const d = F.data; const f = endMs(); const left = Number.isFinite(f) ? Math.round((f - nowMs()) / 60000) : 0;
325
+ return [el('div', { class: 'fc-stopsheet', 'data-fc': 'stop-sheet' },
326
+ el('p', { class: 'fc-p' }, d && d.talk ? 'PMOS keeps the notes and shows you what it caught.'
327
+ : left > 0 ? left + ' min of the meeting is left. If you stop now, the notes use what was recorded so far.' : 'PMOS keeps the notes and writes them up from the recording.'),
328
+ el('div', { class: 'iacts' }, pbtn('primary', 'Stop recording', 'stop', { 'data-fc': 'stop-ok' }), pbtn('ghost', 'Keep recording', null, { 'data-fc': 'stop-keep' })))];
329
+ }
330
+ function materialBuild() { return stageBody(F.data, phaseOf()); }
331
+
332
+ /* the live note, in a sheet: what PMOS picked out so far, or the last lines of the transcript (GET /api/notes/one) */
333
+ const NOTE_MS = 6000;
334
+ async function readNote() {
335
+ const f = F; if (!f || !f.data || !f.data.note) return;
336
+ const id = f.data.note.id;
337
+ if (f.noteBusy || (f.noteDoc && f.noteDoc.id === id && Date.now() - f.noteDoc.at < NOTE_MS)) return;
338
+ f.noteBusy = true;
339
+ try {
340
+ const [dn, vn, demo] = await Promise.all([import('./dest-notes.js'), import('./view-notes.js'), isDemo() ? import('./c3-demo-focus.js') : null]);
341
+ const j = demo ? demo.focusNote(id, f.data) : await pmGet('/api/notes/one?id=' + encodeURIComponent(id));
342
+ const n = j && j.ok !== false ? j.note : null;
343
+ f.noteDoc = { id, at: Date.now(), n, parts: n ? dn.partsOf(n) : null, speakerOf: vn.speakerOf };
344
+ } catch { f.noteDoc = { id, at: Date.now(), n: null, parts: null }; }
345
+ f.noteBusy = false;
346
+ if (F === f) refreshSheet();
347
+ }
348
+ function noteBuild() {
349
+ const doc = F.noteDoc; const tab = F.ui.noteTab || 'note';
350
+ const seg = el('div', { class: 'seg', role: 'group', 'aria-label': 'Show' },
351
+ ...[['note', 'Note'], ['said', 'Transcript']].map(([k, l]) => button({ variant: 'ghost', label: l, attrs: { 'aria-pressed': String(tab === k), 'data-fc': 'note-tab', 'data-tab': k } })));
352
+ if (!doc) return [el('div', { class: 'fc-notesheet', 'data-fc': 'note-sheet' }, seg, el('p', { class: 'dim fc-note', role: 'status' }, 'Opening the note.'))];
353
+ if (!doc.n) return [el('div', { class: 'fc-notesheet', 'data-fc': 'note-sheet' }, seg, el('p', { class: 'dim fc-note', role: 'status' }, 'The note could not be read. It is kept on your computer.'))];
354
+ const body = [];
355
+ if (tab === 'said') {
356
+ const lines = (doc.n.transcript || []).filter((x) => x && x.text && !x.dropped).slice(-12);
357
+ body.push(lines.length ? el('ul', { class: 'fc-said', role: 'list', 'data-fc': 'note-said' }, ...lines.map((x) => el('li', null, el('b', null, (doc.speakerOf ? doc.speakerOf(doc.n, x.source) : x.source) + ' '), x.text)))
358
+ : el('p', { class: 'dim fc-note' }, 'Nothing is heard yet.'));
359
+ body.push(el('p', { class: 'dim fc-note' }, 'Fast lines are provisional and corrected at each pause.'));
360
+ } else {
361
+ const p = doc.parts || { overview: '', decisions: [], actions: [], questions: [] };
362
+ const list = (h, rows, fmt) => (rows.length ? el('div', { 'data-fc-part': h.toLowerCase() }, el('div', { class: 'dim mono fc-lbl' }, h + ' · ' + rows.length), el('ul', { class: 'fc-said', role: 'list' }, ...rows.map((x) => el('li', null, fmt(x))))) : null);
363
+ if (p.overview) body.push(el('p', { class: 'fc-p' }, p.overview));
364
+ body.push(list('Decisions', p.decisions || [], (x) => x.text), list('Actions', p.actions || [], (x) => [x.text, x.owner ? ' · ' + x.owner : '', x.due ? ' · due ' + x.due : ''].join('')), list('Open questions', p.questions || [], (x) => x.text));
365
+ if (!p.overview && !(p.decisions || []).length && !(p.actions || []).length && !(p.questions || []).length) body.push(el('p', { class: 'dim fc-note' }, 'PMOS writes the note as the talk goes. Nothing is picked out yet.'));
366
+ }
367
+ return [el('div', { class: 'fc-notesheet', 'data-fc': 'note-sheet' }, seg, ...body.filter(Boolean), el('p', { class: 'dim fc-note' }, 'The whole note opens on your computer.'))];
368
+ }
369
+ function openNoteSheet() { F.ui.noteTab = F.ui.noteTab || 'note'; showSheet('note', 'Live note', 'notes', noteBuild); readNote(); }
370
+
252
371
  /** The full focus page, in a new tab: the compact page's way to everything it leaves out. */
253
372
  function fullUrl() {
254
373
  const u = new URL('/c3.html', location.origin);
@@ -276,6 +395,23 @@ function leftText(d, ph) {
276
395
  return { txt: warn ? nc + ' topic' + (nc > 1 ? 's' : '') + ' left for ' + left + ' min' : '', warn };
277
396
  }
278
397
 
398
+ /** The thin bar of the stretches of talk, numbered by topic, with a mark for now. */
399
+ function trackEl(d) {
400
+ const s = startMs(); const f = endMs();
401
+ const total = Number.isFinite(s) && Number.isFinite(f) ? (f - s) / 60000 : 60;
402
+ const track = d.runs.map((r) => {
403
+ const open = r.to_min == null;
404
+ const to = open ? Math.max(r.from_min, (nowMs() - s) / 60000) : r.to_min;
405
+ const w = Math.max(0, (to - r.from_min) / total * 100);
406
+ const t = d.topics.find((x) => x.id === r.topic);
407
+ const seg = el('i', { class: open ? 'c' : 'd', ...(open ? { 'data-fc': 'currun' } : {}), title: (t ? t.text : '') + ' · ' + Math.round(to - r.from_min) + ' min' }, w >= 6 ? String(topicNo(d, r.topic)) : '');
408
+ seg.style.setProperty('--fc-left', Math.min(100, r.from_min / total * 100) + '%');
409
+ seg.style.setProperty('--fc-width', Math.min(100, w) + '%');
410
+ return seg;
411
+ });
412
+ return el('div', { class: 'fc-track', role: 'img', 'aria-label': 'What the room talked about, in order. Numbers match the topic list.' }, el('div', { class: 'fc-runs' }, ...track), el('span', { class: 'fc-nowm', 'data-fc': 'nowm' }));
413
+ }
414
+
279
415
  function drawTop(d, ph) {
280
416
  const e = d.event; const rec = recState(d); const ini = (d.brief && d.brief.initiative) || null; const lt = leftText(d, ph);
281
417
  const sig = [e.title, e.at, e.end, e.attendees, ini, e.initiative, ph, rec, lt.txt, d.topics.map((t) => t.id), d.runs, F.status === 'ok' && F.stale ? 1 : 0];
@@ -289,17 +425,6 @@ function drawTop(d, ph) {
289
425
  : [el('div', null, el('b', { class: 'mono', 'data-fc': 'big' }, clockText((nowMs() - s) / 1000)), el('span', { class: 'dim mono' }, 'elapsed')),
290
426
  Number.isFinite(f) ? el('div', null, el('b', { class: 'mono', 'data-fc': 'left' }, clockText((f - nowMs()) / 1000)), el('span', { class: 'dim mono' }, 'left')) : null];
291
427
  const recLabel = rec === 'recording' ? 'REC' : rec === 'paused' ? 'Paused' : rec === 'ended' ? 'Ended' : '';
292
- const track = d.runs.map((r) => {
293
- const total = Number.isFinite(s) && Number.isFinite(f) ? (f - s) / 60000 : 60;
294
- const open = r.to_min == null;
295
- const to = open ? Math.max(r.from_min, (nowMs() - s) / 60000) : r.to_min;
296
- const w = Math.max(0, (to - r.from_min) / total * 100);
297
- const t = d.topics.find((x) => x.id === r.topic);
298
- const seg = el('i', { class: open ? 'c' : 'd', ...(open ? { 'data-fc': 'currun' } : {}), title: (t ? t.text : '') + ' · ' + Math.round(to - r.from_min) + ' min' }, w >= 6 ? String(topicNo(d, r.topic)) : '');
299
- seg.style.setProperty('--fc-left', Math.min(100, r.from_min / total * 100) + '%');
300
- seg.style.setProperty('--fc-width', Math.min(100, w) + '%');
301
- return seg;
302
- });
303
428
  return [
304
429
  el('div', { class: 'fc-id' }, leaveBtn(),
305
430
  el('div', { class: 'fc-tt' }, el('h1', null, e.title),
@@ -310,7 +435,7 @@ function drawTop(d, ph) {
310
435
  recLabel ? el('span', { class: 'dim mono fc-recd', 'data-fc': 'rec', 'data-rec': rec, title: rec === 'recording' ? 'Recording this meeting' : '' }, rec === 'recording' ? el('span', { class: 'fc-dot' }) : null, recLabel) : null,
311
436
  rec === 'recording' || rec === 'paused' ? pbtn('outline', 'End meeting', 'stop', { 'data-fc': 'end', title: 'Stops the recording and saves the note' })
312
437
  : rec !== 'ended' && ph !== 'after' ? pbtn('primary', 'Start recording', 'play', { 'data-fc': 'start', title: 'Records this meeting in a note' }) : null),
313
- el('div', { class: 'fc-track', role: 'img', 'aria-label': 'What the room talked about, in order. Numbers match the topic list.' }, el('div', { class: 'fc-runs' }, ...track), el('span', { class: 'fc-nowm', 'data-fc': 'nowm' })),
438
+ trackEl(d),
314
439
  F.stale ? el('p', { class: 'dim mono fc-stale', role: 'status' }, 'The last read failed. Showing what PMOS had.') : null,
315
440
  ];
316
441
  });
@@ -368,29 +493,33 @@ function beforeList(d, links) {
368
493
  }
369
494
 
370
495
  function drawStage(d, ph) {
496
+ const links = linksOf(d);
497
+ const cur = currentOf(d);
498
+ const sig = [ph, links, F.ui.stage, F.ui.preview, cur && cur.text, d.brief && [d.brief.prep, d.brief.matters, d.brief.last]];
499
+ region('stage', sig, () => stageBody(d, ph));
500
+ }
501
+ /** The stage: the material the meeting links, as a card, or the loaded preview; before the start, what is prepared. The phone shows it in a sheet. */
502
+ function stageBody(d, ph) {
371
503
  const links = linksOf(d);
372
504
  const cur = currentOf(d);
373
505
  const embeddable = links.filter((l) => l.preview);
374
506
  const pick = links[F.ui.stage] || embeddable[0] || links[0] || null;
375
- const sig = [ph, links, F.ui.stage, F.ui.preview, cur && cur.text, d.brief && [d.brief.prep, d.brief.matters, d.brief.last]];
376
- region('stage', sig, () => {
377
- if (ph === 'before') {
378
- const rows = beforeList(d, links);
379
- return [sect({ ic: 'doc', title: 'Before it starts', attrs: { class: 'fc-stage', 'data-fc': 'stage', 'data-fc-stage': 'before' } },
380
- rows.length ? el('ul', { class: 'list', role: 'list' }, ...rows) : el('div', { class: 'fold' }, icon('moon', 'sm'), 'Nothing is prepared for this meeting yet. Nothing is linked in the invite.'))];
381
- }
382
- if (!pick) return [sect({ ic: 'doc', title: 'Material', attrs: { class: 'fc-stage', 'data-fc': 'stage', 'data-fc-stage': 'none' } }, el('div', { class: 'fold' }, icon('moon', 'sm'), 'No material is linked to this meeting.'))];
383
- const right = links.length > 1 ? el('div', { class: 'seg', role: 'group', 'aria-label': 'Material', 'data-fc': 'material' },
384
- ...links.map((l, i) => button({ variant: 'ghost', label: [icon(l.preview ? 'doc' : 'out', 'sm'), l.app], attrs: { 'aria-pressed': String(l === pick), 'data-fc': 'material-pick', 'data-i': String(i), title: l.label } }))) : null;
385
- const body = pick.preview && F.ui.preview === pick.url
386
- ? [el('div', { class: 'fc-frame', 'data-fc': 'preview-wrap' }, el('iframe', { src: pick.preview, title: 'Preview of ' + pick.label + ' in ' + pick.app, loading: 'lazy', referrerpolicy: 'no-referrer', allow: 'fullscreen',
387
- sandbox: 'allow-scripts allow-same-origin allow-popups allow-forms', 'data-fc': 'preview', 'data-app': pick.kind })),
388
- el('div', { class: 'fc-strip' }, el('span', { class: 'dim mono fc-src' }, icon('lock', 'sm'), 'Shown by ' + pick.app + ' itself. PMOS shows nothing to the room.'), el('span', { class: 'fc-grow' }), openBtn(pick), pick.present ? presentBtn(pick) : null)]
389
- : [el('div', { class: 'fc-card', 'data-fc': 'link-card' }, el('span', { class: 'ki' }, icon(pick.preview ? 'doc' : 'out')), el('div', { class: 'nb' }, el('b', null, pick.label), el('span', { class: 'dim mono' }, pick.app + (pick.host && pick.host !== pick.app ? ' · ' + pick.host : ''))), openBtn(pick), pick.present ? presentBtn(pick) : null),
390
- pick.preview ? el('div', { class: 'fc-strip' }, el('span', { class: 'dim mono fc-src' }, icon('lock', 'sm'), 'PMOS loads nothing from ' + pick.app + ' until you ask.'), el('span', { class: 'fc-grow' }),
391
- button({ variant: 'outline', label: [icon('eye', 'sm'), 'Show the preview here'], attrs: { 'data-fc': 'preview-on', title: 'Load the ' + pick.app + ' preview into this page' } })) : null];
392
- return [sect({ ic: 'doc', title: cur ? 'Material · ' + cur.text : 'Material', right, attrs: { class: 'fc-stage', 'data-fc': 'stage', 'data-fc-stage': pick.preview && F.ui.preview === pick.url ? pick.kind : 'card' } }, ...body)];
393
- });
507
+ if (ph === 'before') {
508
+ const rows = beforeList(d, links);
509
+ return [sect({ ic: 'doc', title: 'Before it starts', attrs: { class: 'fc-stage', 'data-fc': 'stage', 'data-fc-stage': 'before' } },
510
+ rows.length ? el('ul', { class: 'list', role: 'list' }, ...rows) : el('div', { class: 'fold' }, icon('moon', 'sm'), 'Nothing is prepared for this meeting yet. Nothing is linked in the invite.'))];
511
+ }
512
+ if (!pick) return [sect({ ic: 'doc', title: 'Material', attrs: { class: 'fc-stage', 'data-fc': 'stage', 'data-fc-stage': 'none' } }, el('div', { class: 'fold' }, icon('moon', 'sm'), 'No material is linked to this meeting.'))];
513
+ const right = links.length > 1 ? el('div', { class: 'seg', role: 'group', 'aria-label': 'Material', 'data-fc': 'material' },
514
+ ...links.map((l, i) => button({ variant: 'ghost', label: [icon(l.preview ? 'doc' : 'out', 'sm'), l.app], attrs: { 'aria-pressed': String(l === pick), 'data-fc': 'material-pick', 'data-i': String(i), title: l.label } }))) : null;
515
+ const body = pick.preview && F.ui.preview === pick.url
516
+ ? [el('div', { class: 'fc-frame', 'data-fc': 'preview-wrap' }, el('iframe', { src: pick.preview, title: 'Preview of ' + pick.label + ' in ' + pick.app, loading: 'lazy', referrerpolicy: 'no-referrer', allow: 'fullscreen',
517
+ sandbox: 'allow-scripts allow-same-origin allow-popups allow-forms', 'data-fc': 'preview', 'data-app': pick.kind })),
518
+ el('div', { class: 'fc-strip' }, el('span', { class: 'dim mono fc-src' }, icon('lock', 'sm'), 'Shown by ' + pick.app + ' itself. PMOS shows nothing to the room.'), el('span', { class: 'fc-grow' }), openBtn(pick), pick.present ? presentBtn(pick) : null)]
519
+ : [el('div', { class: 'fc-card', 'data-fc': 'link-card' }, el('span', { class: 'ki' }, icon(pick.preview ? 'doc' : 'out')), el('div', { class: 'nb' }, el('b', null, pick.label), el('span', { class: 'dim mono' }, pick.app + (pick.host && pick.host !== pick.app ? ' · ' + pick.host : ''))), openBtn(pick), pick.present ? presentBtn(pick) : null),
520
+ pick.preview ? el('div', { class: 'fc-strip' }, el('span', { class: 'dim mono fc-src' }, icon('lock', 'sm'), 'PMOS loads nothing from ' + pick.app + ' until you ask.'), el('span', { class: 'fc-grow' }),
521
+ button({ variant: 'outline', label: [icon('eye', 'sm'), 'Show the preview here'], attrs: { 'data-fc': 'preview-on', title: 'Load the ' + pick.app + ' preview into this page' } })) : null];
522
+ return [sect({ ic: 'doc', title: cur ? 'Material · ' + cur.text : 'Material', right, attrs: { class: 'fc-stage', 'data-fc': 'stage', 'data-fc-stage': pick.preview && F.ui.preview === pick.url ? pick.kind : 'card' } }, ...body)];
394
523
  }
395
524
 
396
525
  /* ── captured, under the stage in the last minutes ─────────────────────────────────────────────────────────────── */
@@ -528,8 +657,10 @@ function tick() {
528
657
 
529
658
  /* ── side sheets: Topics and Captured ─────────────────────────────────────────────────────────────────────────── */
530
659
 
531
- function refreshSheet() {
660
+ function refreshSheet(force) {
532
661
  if (!F || !F.sheetOpen || !F.sheet) return;
662
+ if (!force && (F.sheet.kind === 'material' || F.sheet.kind === 'stop' || F.sheet.kind === 'chip')) return; // a loaded preview is not loaded again on each read
663
+ if (!force && F.sheet.kind === 'note') readNote();
533
664
  const b = F.sheet.body;
534
665
  if (!b.isConnected) return;
535
666
  const a = document.activeElement;
@@ -605,6 +736,10 @@ function openRef(ref) {
605
736
  if (!ref) return;
606
737
  const t = ref.type;
607
738
  if (F && F.pop) { openFull(); return; } // the compact page is a small frame: what a note names opens in the full page
739
+ if (F && F.phone) { // the phone has no desktop pages to go to
740
+ if (t === 'note' && F.data && F.data.note && ref.id === F.data.note.id) openNoteSheet(); else toast('That opens on your computer.');
741
+ return;
742
+ }
608
743
  closeFocus();
609
744
  if (t === 'initiative') { import('./c3-init-extras.js').then((m) => { if (!m.goInitiative(ref.id)) goTab('init'); }).catch(() => goTab('init')); return; }
610
745
  if (t === 'note') { window.dispatchEvent(new CustomEvent('c3-open-note', { detail: { id: ref.id } })); return; }
@@ -619,7 +754,7 @@ function openRef(ref) {
619
754
  }
620
755
  function openChip(c) {
621
756
  if (!c) return;
622
- if (c.ref) { openRef(c.ref); return; }
757
+ if (c.ref && !(F && F.phone && (c.detail || c.sub))) { openRef(c.ref); return; }
623
758
  if (c.url) { openUrl(c.url); return; }
624
759
  showSheet('chip', c.label, 'doc', () => [el('div', { class: 'fc-chipsheet', 'data-fc': 'chip-sheet' }, el('p', { class: 'fc-p' }, c.detail || c.sub || c.label), c.from ? el('p', { class: 'dim fc-note' }, 'From ' + c.from + '.') : null)]);
625
760
  }
@@ -655,6 +790,9 @@ function onClick(ev) {
655
790
  if (!d) return;
656
791
  if (k === 'topics') { showSheet('topics', 'Topics', 'todo', topicsBuild); return; }
657
792
  if (k === 'captured') { showSheet('captured', 'Captured so far', 'check', capturedBuild); return; }
793
+ if (k === 'note' && F.phone) { openNoteSheet(); return; }
794
+ if (k === 'material') { showSheet('material', linksOf(d).some((l) => l.kind === 'slides') ? 'Your slides' : 'Material', 'doc', materialBuild); return; }
795
+ if (k === 'stop') { showSheet('stop', d.talk ? 'Stop recording?' : 'Stop recording ' + d.event.title + '?', 'stop', stopBuild); return; }
658
796
  if (k === 'note') { const id = d.note && d.note.id; closeFocus(); if (id) window.dispatchEvent(new CustomEvent('c3-open-note', { detail: { id } })); return; }
659
797
  if (k === 'chip') { openChip(F.chips[Number(t.dataset.i)]); return; }
660
798
  if (k === 'preview-on') { const ls = linksOf(d); const pk = ls[F.ui.stage] || ls.find((l) => l.preview) || ls[0]; F.ui.preview = pk && pk.url; drawStage(d, phaseOf()); return; }
@@ -687,4 +825,10 @@ document.addEventListener('click', (ev) => {
687
825
  return;
688
826
  }
689
827
  if (k === 'add-topic') { F.ui.addTopic = !F.ui.addTopic; refreshSheet(); return; }
828
+ if (k === 'open-link' || k === 'present') { openUrl(t.dataset.url); return; }
829
+ if (k === 'preview-on') { const ls = linksOf(F.data); const pk = ls[F.ui.stage] || ls.find((l) => l.preview) || ls[0]; F.ui.preview = pk && pk.url; refreshSheet(true); return; }
830
+ if (k === 'material-pick') { F.ui.stage = Number(t.dataset.i) || 0; refreshSheet(true); return; }
831
+ if (k === 'note-tab') { F.ui.noteTab = t.dataset.tab === 'said' ? 'said' : 'note'; refreshSheet(true); return; }
832
+ if (k === 'stop-keep') { closeSheet(); return; }
833
+ if (k === 'stop-ok') { closeSheet(); endMeeting().then(() => { if (F && F.phone) closeFocus(); }); return; }
690
834
  });