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
@@ -196,7 +196,17 @@ export function createStore() {
196
196
  'GRO-02': { problem: R({ text: 'Partner admins only log in when something breaks, so they miss new features and usage trends.' }, -1) },
197
197
  'OFF-01': { launch: R({ items: [{ id: 'l1', text: 'Three bids compared', done: true }, { id: 'l2', text: 'Legal review of the contract', done: false }, { id: 'l3', text: 'Contract signed', done: false }] }, -3), learnings: R({ items: [{ id: 'n1', text: 'Ask for fixed-price bids first: two of three changed their price after the site visit.', date: D(-6) }] }, -6) },
198
198
  };
199
- return { boards, rows, asks, releases, events, jira, tasks, records, seq: 0 };
199
+ /* c3-initiative-lifecycle: a likely duplicate, one on hold, one cancelled, two archived (one merged into IDPF-07) */
200
+ const L = (id, title, stage, owner, anchor, horizon, status, s, e, progress, extra) => Object.assign(I(id, 'idpf', title, stage, owner, anchor, horizon, status, s, e, progress, 'feature', false), { featured: false }, extra || {});
201
+ rows.push(
202
+ L('IDPF-151', 'Guided self-serve setup for new workspaces', 'Discover', 'Lina Kusuma', 'KR 2.1 Activation to 45%', 'next', 'on track', -3, 60, 0.05, { dupOf: 'IDPF-12' }),
203
+ L('IDPF-152', 'SAML SSO for enterprise trials', 'Plan', 'You', 'KR 3.2 Net revenue retention 115%', 'now', 'on hold', -30, 40, 0.3, { was: 'on track', stopped: { reason: 'Waiting for the Q1 budget decision', at: at(-11, 10), back: D(19) } }),
204
+ L('IDPF-153', 'Sunset the v1 SDK', 'Build & Deliver', 'You', 'KR 1.3 Partner uptime 99.9%', 'now', 'cancelled', -50, 10, 0.4, { was: 'at risk', stopped: { reason: 'Not worth it now: three partners still on v1', at: at(-6, 10), back: null } }),
205
+ L('IDPF-154', 'Refresh the marketing site footer', 'Discover', '', 'KR 2.3 WAU partners +20%', 'later', 'on track', -60, -20, 0, { archived: { at: at(-2, 10), by: 'You', reason: 'Not related to this board', into: null, moved: { epics: [], tasks: [] } } }),
206
+ L('IDPF-155', 'Throttle bursty partner API calls', 'Discover', 'You', 'KR 1.3 Partner uptime 99.9%', 'now', 'on track', -20, 30, 0.05, { archived: { at: at(-4, 10), by: 'You', reason: 'Merged into Partner API rate limits', into: 'IDPF-07', moved: { epics: [], tasks: [] } } }),
207
+ );
208
+ tasks.push({ id: 'task-demo-l1', board: 'idpf', initiative: 'IDPF-151', title: 'Sketch the setup checklist', owner: 'Lina Kusuma', state: 'To do', start: D(1), due: D(8), epic: null, kind: 'Task', history: [] });
209
+ return { boards, rows, asks, releases, events, jira, tasks, records, seq: 0, notDup: [] };
200
210
  }
201
211
 
202
212
  /* ── reads ── */
@@ -205,16 +215,22 @@ const wfOf = (S, r) => r.workflow || boardOf(S, r.board).workflow;
205
215
  const catIn = (wf, stage) => ((wf.find((s) => s.name === stage) || {}).cat) || 'Plan';
206
216
  const catOfRow = (S, r) => catIn(wfOf(S, r), r.stage);
207
217
  const tplEdited = (b) => !INI_TEMPLATES[b.template] || !sameFlow(tplStages(b.template), b.workflow);
208
- const isOpen = (r) => r.status !== 'done';
218
+ const ACTIVE = ['on track', 'at risk', 'late'];
219
+ const isOpen = (r) => ACTIVE.includes(r.status) && !r.archived; // on hold, done, cancelled and archived are not open
220
+ const live = (r) => !r.archived;
221
+ const mergedFrom = (S, id) => S.rows.filter((x) => x.archived && x.archived.into === id).map((x) => x.id);
209
222
  const nextRelease = (S, bid) => S.releases.filter((x) => x.board === bid && x.date >= TODAY).sort((a, c) => (a.date < c.date ? -1 : 1))[0] || null;
210
223
  const openAsks = (S, bid) => S.asks.filter((a) => a.board === bid && a.state === 'open').length;
211
224
  function rowOut(S, r) {
212
225
  const wf = wfOf(S, r);
213
226
  return { id: r.id, title: r.title, lane: r.lane, stage: r.stage, cat: catIn(wf, r.stage), owner: r.owner || null, anchor: r.anchor, horizon: r.horizon, status: r.status, start: r.start, end: r.end, progress: r.progress,
214
- warnings: r.warnings.slice(), template: r.template || null, workflow: wf.map((s) => ({ name: s.name, cat: s.cat })), epics: (r.epics || []).slice(), epicsFrom: r.epics ? 'pmos' : null };
227
+ warnings: r.warnings.slice(), template: r.template || null, workflow: wf.map((s) => ({ name: s.name, cat: s.cat })), epics: (r.epics || []).slice(), epicsFrom: r.epics ? 'pmos' : null,
228
+ lifecycle: r.stopped ? (r.status === 'on hold' ? 'on-hold' : 'cancelled') : null, stopped: r.stopped ? { ...r.stopped } : null,
229
+ archived: r.archived ? { ...r.archived, intoTitle: r.archived.into ? (S.rows.find((x) => x.id === r.archived.into) || {}).title || r.archived.into : undefined } : null,
230
+ mergedFrom: mergedFrom(S, r.id), dupOf: r.dupOf && !r.archived && ACTIVE.includes(r.status) && !(S.notDup || []).includes(r.id + '|' + r.dupOf) ? r.dupOf : null };
215
231
  }
216
232
  function boardOut(S, b) {
217
- const list = S.rows.filter((r) => r.board === b.id);
233
+ const list = S.rows.filter((r) => r.board === b.id && live(r));
218
234
  const byCat = Object.fromEntries(CATS.map((c) => [c, 0]));
219
235
  for (const r of list) byCat[catOfRow(S, r)]++;
220
236
  const rel = nextRelease(S, b.id);
@@ -224,24 +240,28 @@ function boardOut(S, b) {
224
240
  asks: openAsks(S, b.id), nextRelease: rel ? { id: rel.id, name: rel.name, at: rel.date, date: rel.date, status: rel.status } : null };
225
241
  }
226
242
  const H = { now: 0, next: 1, later: 2 };
227
- const SS = { late: 0, 'at risk': 1, 'on track': 2, done: 3 };
243
+ const SS = { late: 0, 'at risk': 1, 'on track': 2, 'on hold': 3, done: 4, cancelled: 5 };
228
244
  /** The saved view and the search, as the server applies them (the base the facets count). */
229
245
  function viewRows(S, b, p) {
230
246
  const q = String(p.q || '').trim().toLowerCase();
231
- const view = p.view || 'now';
247
+ const archive = p.archived === '1';
248
+ const view = archive ? 'all' : (p.view || 'now');
249
+ const given = !!p.status; // a status filter overrides the view's own status rule
250
+ const act = (r) => given || isOpen(r);
232
251
  return S.rows.filter((r) => {
233
- if (r.board !== b.id) return false;
234
- if (view === 'now' && (!['now', 'next'].includes(r.horizon) || !isOpen(r))) return false;
235
- if (view === 'mine' && (r.owner !== 'You' || !isOpen(r))) return false;
236
- if (view === 'risk' && !['at risk', 'late'].includes(r.status)) return false;
237
- if (view === 'noowner' && (r.owner || !isOpen(r))) return false;
252
+ if (r.board !== b.id || !!r.archived !== archive) return false;
253
+ if (view === 'now' && (!['now', 'next'].includes(r.horizon) || !act(r))) return false;
254
+ if (view === 'mine' && (r.owner !== 'You' || !act(r))) return false;
255
+ if (view === 'risk' && !given && !['at risk', 'late'].includes(r.status)) return false;
256
+ if (view === 'noowner' && (r.owner || !act(r))) return false;
238
257
  if (q && !(r.id + ' ' + r.title + ' ' + r.anchor + ' ' + r.owner).toLowerCase().includes(q)) return false;
239
258
  return true;
240
259
  });
241
260
  }
242
261
  /** Then each named filter (a value or a comma list; 'none' for an empty owner or anchor), as scripts/boards.js filterRows does. */
262
+ const asksCancelled = (p) => p.archived === '1' || String(p.status || '').split(',').map((x) => x.trim()).includes('cancelled');
243
263
  function filterRows(S, b, p) {
244
- return viewRows(S, b, p).filter((r) => {
264
+ return viewRows(S, b, p).filter((r) => asksCancelled(p) || r.status !== 'cancelled').filter((r) => {
245
265
  const has = (want, v) => !want || String(want).split(',').map((x) => x.trim().toLowerCase()).includes(String(v || 'none').toLowerCase());
246
266
  return has(p.stage, r.stage) && has(p.cat, catOfRow(S, r)) && has(p.status, r.status) && has(p.owner, r.owner) && has(p.horizon, r.horizon) && has(p.anchor, r.anchor);
247
267
  });
@@ -305,14 +325,14 @@ const okb = (body) => ({ status: 200, body: { ok: true, ...body } });
305
325
  /** One request. Returns { status, body } or undefined when the path is not a boards route. */
306
326
  export function handle(S, method, path, p, body) {
307
327
  if (path.startsWith('/api/stage-tools')) return handleTools(S, method, path, p, body || {});
308
- if (!path.startsWith('/api/boards')) return handleMore(S, method, path, p);
328
+ if (!path.startsWith('/api/boards') && path !== '/api/lifecycle') return handleMore(S, method, path, p);
309
329
  const findBoard = (ws, id) => { const b = S.boards.find((x) => x.id === id); return b && (!ws || ws === b.ws) ? b : null; };
310
330
  if (method === 'GET') {
311
331
  if (path === '/api/boards') return okb({ today: TODAY, boards: S.boards.map((b) => boardOut(S, b)) });
312
332
  const b = findBoard(p.ws, p.board);
313
333
  if (!b && path !== '/api/boards/asks') return fail(404, 'No board "' + (p.board || '') + '" in this workspace.');
314
334
  if (path === '/api/boards/board') {
315
- const list = S.rows.filter((r) => r.board === b.id);
335
+ const list = S.rows.filter((r) => r.board === b.id && live(r));
316
336
  const byStage = {};
317
337
  for (const r of list) byStage[r.stage] = (byStage[r.stage] || 0) + 1;
318
338
  const rel = nextRelease(S, b.id);
@@ -324,7 +344,9 @@ export function handle(S, method, path, p, body) {
324
344
  const list = sortRows(S, filterRows(S, b, p), p.sort);
325
345
  const base0 = viewRows(S, b, p);
326
346
  const offset = Math.max(0, Number(p.offset) || 0); const limit = Math.min(100, Math.max(1, Number(p.limit) || 25));
327
- return okb({ view: p.view || 'now', total: list.length, offset, limit, rows: list.slice(offset, offset + limit).map((r) => rowOut(S, r)),
347
+ return okb({ view: p.archived === '1' ? 'all' : p.view || 'now', total: list.length, offset, limit, rows: list.slice(offset, offset + limit).map((r) => rowOut(S, r)),
348
+ cancelledHidden: asksCancelled(p) ? 0 : S.rows.filter((r) => r.board === b.id && live(r) && r.status === 'cancelled').length,
349
+ archived: p.archived === '1' ? list.length : S.rows.filter((r) => r.board === b.id && r.archived).length,
328
350
  facets: { stage: tally(base0, (r) => r.stage), status: tally(base0, (r) => r.status), owner: tally(base0, (r) => r.owner || 'none'), horizon: tally(base0, (r) => r.horizon),
329
351
  anchor: tally(base0, (r) => r.anchor || 'none'), cat: tally(base0, (r) => catOfRow(S, r)) } });
330
352
  }
@@ -348,7 +370,7 @@ export function handle(S, method, path, p, body) {
348
370
  return okb({ board: b.id, anchors: [...krs, ...health], error: null });
349
371
  }
350
372
  if (path === '/api/boards/items') {
351
- const list = S.rows.filter((r) => r.board === b.id);
373
+ const list = S.rows.filter((r) => r.board === b.id && live(r));
352
374
  return okb({ board: b.id, build: b.build, jira: b.jira, items: itemsOf(S, b),
353
375
  initiatives: list.map((r) => { const o = rowOut(S, r); return { id: o.id, title: o.title, stage: o.stage, cat: o.cat, status: o.status, owner: o.owner, horizon: o.horizon, start: o.start, end: o.end, progress: o.progress, template: o.template, featured: r.featured }; }) });
354
376
  }
@@ -369,6 +391,49 @@ export function handle(S, method, path, p, body) {
369
391
  }
370
392
  /* POST */
371
393
  const ws = body.ws;
394
+ if (path === '/api/lifecycle') {
395
+ // D70 in the demo: on-hold, cancelled or active (the server writes one lifecycle.jsonl line; here the row changes in memory)
396
+ const r = S.rows.find((x) => x.id === body.initiative_id);
397
+ if (!r) return fail(404, "no initiative '" + (body.initiative_id || '') + "' in demo");
398
+ if (!['on-hold', 'cancelled', 'closed', 'active'].includes(body.state)) return fail(400, 'state must be one of closed, on-hold, cancelled, active');
399
+ if (body.back != null && (body.state !== 'on-hold' || !/^\d{4}-\d{2}-\d{2}$/.test(String(body.back)))) return fail(400, 'back is a day (YYYY-MM-DD) and only goes with on-hold');
400
+ if (!r.stopped) r.was = r.status;
401
+ if (body.state === 'active') { r.status = r.was || 'on track'; r.stopped = null; }
402
+ else { r.status = body.state === 'on-hold' ? 'on hold' : body.state === 'closed' ? 'done' : 'cancelled'; r.stopped = { reason: String(body.reason || ''), at: at(0, 8), back: body.back || null }; }
403
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'lifecycle', id: r.id, state: body.state, reason: body.reason || '' });
404
+ return okb({ recorded: { initiative_id: r.id, state: body.state } });
405
+ }
406
+ if (path === '/api/boards/archive' || path === '/api/boards/merge' || path === '/api/boards/notdup') {
407
+ const bb = findBoard(ws, body.board);
408
+ if (!bb) return fail(404, 'No board "' + (body.board || '') + '" in this workspace.');
409
+ const on = (id) => S.rows.find((x) => x.id === id && x.board === bb.id);
410
+ const r = on(body.id);
411
+ if (!r) return fail(404, 'no initiative ' + (body.id || '') + ' on this board');
412
+ if (path === '/api/boards/notdup') { S.notDup.push(r.id + '|' + body.of); return okb({}); }
413
+ if (path === '/api/boards/archive') {
414
+ if (typeof body.archived !== 'boolean') return fail(400, 'archived is true (archive) or false (restore)');
415
+ if (body.archived && r.archived) return fail(409, r.id + ' is archived already');
416
+ if (!body.archived && !r.archived) return fail(409, r.id + ' is not archived');
417
+ if (!body.archived && r.archived.into) {
418
+ const t = on(r.archived.into); const mv = r.archived.moved || { epics: [], tasks: [] };
419
+ if (t && mv.epics.length) { t.epics = (t.epics || []).filter((k) => !mv.epics.includes(k)); r.epics = [...new Set([...(r.epics || []), ...mv.epics])]; }
420
+ for (const tk of S.tasks) if (mv.tasks.includes(tk.id) && tk.initiative === r.archived.into) tk.initiative = r.id;
421
+ }
422
+ r.archived = body.archived ? { at: at(0, 8), by: 'You', reason: String(body.reason || ''), into: null, moved: { epics: [], tasks: [] } } : null;
423
+ S.events.push({ at: at(0, 8), by: 'You', kind: body.archived ? 'archive' : 'restore', board: bb.id, id: r.id });
424
+ return okb({ row: rowOut(S, r) });
425
+ }
426
+ const t = on(body.into);
427
+ if (body.id === body.into) return fail(400, 'an initiative cannot be merged into itself');
428
+ if (!t) return fail(404, 'no initiative ' + (body.into || '') + ' on this board (an initiative merges into one on the same board)');
429
+ if (r.archived) return fail(409, r.id + ' is archived already');
430
+ const epics = (body.epics || []).filter((k) => (r.epics || []).includes(k)); const tks = (body.tasks || []).filter((id) => S.tasks.some((x) => x.id === id && x.initiative === r.id));
431
+ if (epics.length) { t.epics = [...new Set([...(t.epics || []), ...epics])]; r.epics = (r.epics || []).filter((k) => !epics.includes(k)); }
432
+ for (const tk of S.tasks) if (tks.includes(tk.id)) tk.initiative = t.id;
433
+ r.archived = { at: at(0, 8), by: 'You', reason: 'Merged into ' + t.title, into: t.id, moved: { epics, tasks: tks } };
434
+ S.events.push({ at: at(0, 8), by: 'You', kind: 'merge', board: bb.id, id: r.id, into: t.id });
435
+ return okb({ row: rowOut(S, r), into: rowOut(S, t) });
436
+ }
372
437
  if (path === '/api/boards/create') {
373
438
  const name = String(body.name || '').trim();
374
439
  if (!name) return fail(400, 'A board needs a name.');
@@ -667,7 +732,7 @@ export function installBoards() {
667
732
  const raw = typeof input === 'string' ? input : input instanceof URL ? input.href : input && input.url;
668
733
  const method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
669
734
  const u = new URL(raw, location.origin);
670
- if (u.origin === location.origin && (u.pathname.startsWith('/api/boards') || u.pathname.startsWith('/api/stage-tools') || u.searchParams.has('board') || u.pathname.startsWith('/api/doc'))) {
735
+ if (u.origin === location.origin && (u.pathname.startsWith('/api/boards') || u.pathname === '/api/lifecycle' || u.pathname.startsWith('/api/stage-tools') || u.searchParams.has('board') || u.pathname.startsWith('/api/doc'))) {
671
736
  let b = {};
672
737
  if (method !== 'GET') { try { b = init && init.body ? JSON.parse(init.body) : {}; } catch { b = {}; } }
673
738
  const out = handle(S, method, u.pathname, Object.fromEntries(u.searchParams), b);
@@ -4,7 +4,9 @@
4
4
  * context chips, the room's last lines, three PMOS notes and two it is holding, the captured items and a Google Slides deck.
5
5
  * Whatever meeting the page asks for, the demo answers with this one. GET /api/meeting-focus/now (the compact page of the Chrome extension) names it too;
6
6
  * the Meet code NONE_CODE answers "no meeting fits" with three of the day's meetings as choices, so that path can be walked. A correction (set, add, park) and a question change the answer
7
- * for the rest of the page's life; nothing is saved and nothing reaches the server. Real mode never loads this file. */
7
+ * for the rest of the page's life; nothing is saved and nothing reaches the server. Real mode never loads this file.
8
+ * A read by note alone (the phone's focus for a recording that is not on the calendar) answers with a corridor talk: Budi asks about the SSO copy.
9
+ * focusNote is the live note the phone's Note sheet shows (the shape of GET /api/notes/one). */
8
10
 
9
11
  const DAY = '2026-10-08';
10
12
  const at = (hm) => Date.parse(DAY + 'T' + hm + ':00');
@@ -108,9 +110,45 @@ export function focusGet(path, url) {
108
110
  const a = S.asks.find((x) => x.id === q.get('id'));
109
111
  return a ? { ok: true, id: a.id, state: 'answered' } : { ok: false, status: 404, error: 'no such question' };
110
112
  }
113
+ const q = new URL(url, location.origin).searchParams;
114
+ if (!q.get('event') && q.get('note')) return talkView(q.get('note'));
111
115
  return view();
112
116
  }
113
117
 
118
+ /* a recording that is not on the calendar: started 40 seconds ago, no topics, no brief, no stage */
119
+ const TALK_AT = NOW - 40000;
120
+ const TALK_TAIL = [['Budi', 12000, 'Does the SSO copy need legal to look at it first?'], ['You', 26000, 'I think so. Let me check.']]
121
+ .map(([who, t, text]) => ({ who, at: new Date(TALK_AT + t).toISOString(), t, text, source: who === 'You' ? 'mic' : 'meeting' }));
122
+ function talkView(noteId) {
123
+ const id = String(noteId);
124
+ const notes = [{ id: 't0', at: TALK_AT + 30000, cat: 'Answer', heard: '"Does the SSO copy need legal to look at it first?" Budi', body: 'Yes. Since the 2 Oct policy change, legal reviews all login copy. The last review took 3 working days.',
125
+ why: 'Budi asked, and the beta date depends on it.', src: 'the legal policy page in Confluence', show: 'now' }].concat(S.extra);
126
+ return {
127
+ ok: true, now_ms: NOW, date: DAY, talk: true,
128
+ event: { id: 'note:' + id, title: 'Conversation', at: new Date(TALK_AT).toISOString(), end: null, attendees: [], initiative: null, purpose: null, links: [] },
129
+ brief: null, note: { id, title: 'Conversation', state: 'recording', recorded_from: new Date(TALK_AT).toISOString(), lines: 2, sessions: [{ from: new Date(TALK_AT).toISOString(), to: null }] },
130
+ topics: [], current: null, runs: [], tail: TALK_TAIL, captured: { decisions: [], actions: [], questions: [] },
131
+ gist: 'Budi is asking whether the SSO copy needs a legal check before the Onboarding v2 beta.',
132
+ chips: [{ label: 'SSO copy', sub: 'last edited by Budi, Fri 9 Oct', detail: 'The sign-in screen copy, 4 strings. Last edited by Budi on Fri 9 Oct. Not yet reviewed by legal.', from: 'IDPF-12 in Jira' },
133
+ { label: 'Onboarding v2 beta', sub: 'in 18 days', detail: 'Beta on 30 Oct, 18 days away. At risk: QA is one tester short, and a scope cut waits on you today.', from: 'IDPF-12 and your decisions' }],
134
+ notes, asks: S.asks.map((a) => ({ ...a })),
135
+ companion: { on: true, running: false, started: false, reason: 'too soon', last_run_at: NOW - 10000, error: null },
136
+ };
137
+ }
138
+
139
+ /** The live note of the focus page's recording, for the phone's Note sheet: the shape of GET /api/notes/one, with live bullets. */
140
+ export function focusNote(id, data) {
141
+ const talk = !!(data && data.talk);
142
+ const tail = talk ? TALK_TAIL : TAIL;
143
+ const speakers = { mic: 'You' };
144
+ for (const x of tail) if (x.who !== 'You') speakers['meeting:' + x.who] = x.who;
145
+ return { ok: true, note: { id, title: talk ? 'Conversation' : EVENT.title, state: 'recording', speakers,
146
+ transcript: tail.map((x) => ({ t: x.t, at: x.at, source: x.who === 'You' ? 'mic' : 'meeting:' + x.who, text: x.text })),
147
+ bullets: talk ? [{ id: 0, cat: 'question', text: 'Does the SSO copy need a legal check before the beta?' }]
148
+ : [{ id: 0, cat: 'decision', text: 'Option B needs 30 days notice; the partner agreement allows it' }, { id: 1, cat: 'action', text: 'Chen sends the per-partner numbers to Lina' },
149
+ { id: 2, cat: 'question', text: 'Can a partner buy a temporary higher tier during the grace window?' }] } };
150
+ }
151
+
114
152
  /** POST /api/meeting-focus/topic and POST /api/meeting-focus/ask: they change the demo's answer and nothing else. */
115
153
  export function focusPost(path, body) {
116
154
  let b = {};
@@ -198,3 +198,56 @@
198
198
  .c3focus.fc-pop:not([data-fc-state="ok"]) .fc-cv, .c3focus.fc-pop:not([data-fc-state="ok"]) .fc-foot { display: none; }
199
199
  .c3focus.fc-pop.fc-pill .fc-popbody { display: none; }
200
200
  .c3focus.fc-pop.fc-pill .fc-page { padding: 6px 10px; justify-content: center; }
201
+
202
+ /* phone mode (initiative c3-phone-companion, mockup version 5): the full page in one column. A head with Back to your day, the title and the
203
+ * clock, the bar of the talk; the now line and the chips that open sheets; What's being said; the conversation; Ask PMOS quietly and Stop at the foot. */
204
+ .c3focus.fc-phone { overflow: hidden; }
205
+ .c3focus.fc-phone .fc-page { display: flex; flex-direction: column; gap: 8px; height: 100%; max-width: none; margin: 0; padding: 8px 12px 0; }
206
+ .c3focus.fc-phone .fc-phd { display: grid; gap: 6px; flex: 0 0 auto; }
207
+ .c3focus.fc-phone .fc-phid { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 8px; align-items: center; }
208
+ .c3focus.fc-phone .fc-phid .ib { width: 36px; height: 36px; min-width: 0; padding: 0; }
209
+ .c3focus.fc-phone .fc-tt { gap: 0; }
210
+ .c3focus.fc-phone .fc-tt h1 { font-size: var(--fs-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
211
+ .c3focus.fc-phone .fc-sub { font-size: var(--fs-1); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
212
+ .c3focus.fc-phone .fc-phclk { display: grid; justify-items: end; }
213
+ .c3focus.fc-phone .fc-phclk b { font-size: var(--fs-3); font-weight: 600; font-variant-numeric: tabular-nums; }
214
+ .c3focus.fc-phone .fc-phclk .fc-recd b { color: var(--destructive); }
215
+ .c3focus.fc-phone .fc-phclk .dim { font-size: var(--fs-1); font-variant-numeric: tabular-nums; }
216
+ .c3focus.fc-phone .fc-phnow { display: grid; gap: 6px; flex: 0 0 auto; min-width: 0; }
217
+ .c3focus.fc-phone .fc-nowt { flex-wrap: nowrap; }
218
+ .c3focus.fc-phone .fc-nowt b { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
219
+ .c3focus.fc-phone .fc-nowt .ki, .c3focus.fc-phone .fc-nowt .mono { flex: none; }
220
+ .c3focus.fc-phone .fc-phchips { display: flex; gap: 6px; flex-wrap: wrap; row-gap: 4px; }
221
+ .c3focus.fc-phone .fc-phchips .chip2 { flex: none; font-size: var(--fs-2); min-height: 30px; }
222
+ .c3focus.fc-phone .fc-bandwrap { flex: 0 0 auto; }
223
+ .c3focus.fc-phone .fc-gist { padding: 8px 10px; }
224
+ .c3focus.fc-phone .fc-gist p { font-size: var(--fs-2); }
225
+ .c3focus.fc-phone .fc-gl { display: block; margin: 0 0 2px; }
226
+ .c3focus.fc-phone .fc-cxs { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; }
227
+ .c3focus.fc-phone .fc-cx { flex: none; max-width: 80%; }
228
+ .c3focus.fc-phone .fc-cv { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; max-height: none; overflow: auto; }
229
+ .c3focus.fc-phone .fc-tk { padding: 6px 10px; }
230
+ .c3focus.fc-phone .fc-s2 { overflow: visible; padding: 6px 8px; }
231
+ .c3focus.fc-phone .fc-x { display: grid; opacity: 1; }
232
+ .c3focus.fc-phone .fc-acts2 button { min-height: 36px; }
233
+ .c3focus.fc-phone .fc-phft { display: flex; gap: 8px; align-items: center; flex: 0 0 auto; margin: 0 -12px; padding: 8px 12px calc(8px + env(safe-area-inset-bottom, 0px)); border-top: 1px solid var(--border); background: var(--background); }
234
+ .c3focus.fc-phone .fc-in { flex: 1 1 auto; min-width: 0; padding: 0; border: 0; }
235
+ .c3focus.fc-phone .fc-in input { height: 40px; font-size: 16px; }
236
+ .c3focus.fc-phone .fc-phact { flex: none; display: flex; }
237
+ .c3focus.fc-phone .fc-phact button { min-height: 40px; }
238
+ .c3focus.fc-phone:not([data-fc-state="ok"]) .fc-cv, .c3focus.fc-phone:not([data-fc-state="ok"]) .fc-phft { display: none; }
239
+ [data-fc="stop-sheet"] .fc-p, [data-fc="note-sheet"] .fc-p { margin: 0 0 var(--sp-2); font-size: var(--fs-3); line-height: 1.5; }
240
+ [data-fc="stop-sheet"] .iacts { display: flex; gap: 8px; flex-wrap: wrap; }
241
+ [data-fc="note-sheet"] { display: grid; gap: var(--sp-2); }
242
+ [data-fc="note-sheet"] .fc-said { list-style: none; margin: 0; padding: 0; display: grid; gap: 6px; font-size: var(--fs-2); line-height: 1.5; }
243
+ [data-fc="note-sheet"] .fc-lbl { font-size: var(--fs-1); padding-bottom: 4px; }
244
+ [data-fc="note-sheet"] .fc-note { margin: 0; font-size: var(--fs-1); }
245
+ /* the stage, when the phone opens it in a sheet (outside .c3focus) */
246
+ [data-shell="sheet"] [data-fc="stage"] .fc-frame { margin-top: var(--sp-2); border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--muted); aspect-ratio: 16 / 9; width: 100%; }
247
+ [data-shell="sheet"] [data-fc="stage"] .fc-frame iframe { display: block; width: 100%; height: 100%; border: 0; background: var(--card); }
248
+ [data-shell="sheet"] [data-fc="stage"] .fc-strip { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: var(--sp-2) 0 0; }
249
+ [data-shell="sheet"] [data-fc="stage"] .fc-src { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-1); margin: 0; }
250
+ [data-shell="sheet"] [data-fc="stage"] .fc-grow { flex: 1 1 auto; }
251
+ [data-shell="sheet"] [data-fc="stage"] .fc-card { display: flex; gap: var(--sp-2); align-items: center; margin-top: var(--sp-2); padding: var(--sp-2); border: 1px solid var(--border); border-radius: 8px; background: var(--card); flex-wrap: wrap; }
252
+ [data-shell="sheet"] [data-fc="stage"] .fc-card .nb { display: grid; gap: 2px; flex: 1 1 160px; min-width: 0; overflow-wrap: anywhere; }
253
+ [data-shell="sheet"] [data-fc="stage"] .seg { flex-wrap: wrap; }