create-pmos 0.241.0 → 0.242.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 (39) 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/state/discoveries.md +1 -1
  6. package/workspace/guide/console-calendar.md +14 -0
  7. package/workspace/guide/notes.md +22 -1
  8. package/workspace/lib/meeting-material.js +151 -0
  9. package/workspace/lib/meeting-replay.js +170 -0
  10. package/workspace/lib/notes-screen.js +128 -8
  11. package/workspace/lib/route-modules.js +2 -0
  12. package/workspace/lib/routes-meeting-focus.js +5 -3
  13. package/workspace/lib/routes-meeting-material.js +143 -0
  14. package/workspace/lib/routes-meeting-replay.js +75 -0
  15. package/workspace/lib/routes-notes.js +37 -4
  16. package/workspace/package-lock.json +2 -2
  17. package/workspace/package.json +1 -1
  18. package/workspace/public/pm/c3-calendar.js +15 -3
  19. package/workspace/public/pm/c3-demo-material.js +58 -0
  20. package/workspace/public/pm/c3-demo.js +5 -1
  21. package/workspace/public/pm/c3-focus.js +83 -16
  22. package/workspace/public/pm/c3-listen.css +10 -0
  23. package/workspace/public/pm/c3-material.css +22 -0
  24. package/workspace/public/pm/c3-material.js +311 -0
  25. package/workspace/public/pm/c3-notes.js +12 -4
  26. package/workspace/public/pm/c3-replay.css +127 -0
  27. package/workspace/public/pm/c3-replay.js +413 -0
  28. package/workspace/public/pm/dest-notes.js +4 -2
  29. package/workspace/public/pm/listen-sheet.js +94 -0
  30. package/workspace/public/pm/listen-sources.js +82 -0
  31. package/workspace/public/pm/notes-aside.js +62 -0
  32. package/workspace/public/pm/notes-screen-capture.js +23 -10
  33. package/workspace/public/pm/notes-screen-view.js +18 -5
  34. package/workspace/public/pm/notes-shots.js +55 -0
  35. package/workspace/public/pm/shell-recording.js +10 -2
  36. package/workspace/public/pm/view-notes.js +48 -8
  37. package/workspace/test-lists/meeting-material.json +6 -0
  38. package/workspace/test-lists/meeting-replay.json +6 -0
  39. package/workspace/test-lists/mm-capture.json +15 -0
@@ -0,0 +1,311 @@
1
+ /* pm/c3-material.js — the material of a meeting (initiative meeting-material-replay, part 1; the mockup the PM approved on 2026-10-14, hashes #material, #material-add, #focus-material).
2
+ *
3
+ * materialSection({ event, date, ws, level, brief, back }) → Node the Material part of the meeting card: each linked item with its kind, topic and source,
4
+ * Present or Open, remove, and Add material
5
+ * openAddMaterial({ event, date, ws, level, brief, back }) the Add material sheet: Suggested from the meeting's level, Find in PMOS, Or paste a link; For,
6
+ * Topic and You (Present it / Only read it)
7
+ * openItem(item) · presentItem(item) · audienceItem(item) Open in PMOS or in a new tab; Present (a PMOS deck in the presenter view, Google Slides in present
8
+ * mode); the audience view of a PMOS deck
9
+ * classifyLink(url) · KIND · pmosUrl(item)
10
+ * window.C3Material = { open(event, opts), section, openItem, presentItem, audienceItem } for any page that wants to add or show material
11
+ *
12
+ * It reads and writes GET and POST /api/meeting-material (lib/routes-meeting-material.js). PMOS keeps the link on its own record of the meeting; the calendar invite is not
13
+ * changed. A change fires `c3-material-changed` on window, so the card and Focus read again. Nothing is sent anywhere. */
14
+ import { el } from './dom.js';
15
+ import { pmGet, pmPost } from './http.js';
16
+ import { ensureC3Css } from './pm-work-view.js';
17
+ import { ensureComponentsCss } from './primitives.js';
18
+ import { icon, pbtn, sect, put, lk, ib, list, empty } from './c3-ini-ui.js';
19
+ import { openSheet, closeSheet } from './c3-sheet.js';
20
+ import { toast } from './c3-toast.js';
21
+
22
+ /** What each kind of item is called, and which icon it takes. `deck`: PMOS presents it itself. `present`: it can be presented at all. */
23
+ export const KIND = {
24
+ 'pmos-deck': { tag: 'PMOS deck', ic: 'report', deck: true, present: true },
25
+ 'pmos-doc': { tag: 'PMOS document', ic: 'doc' },
26
+ report: { tag: 'PMOS report', ic: 'report' },
27
+ slides: { tag: 'Google Slides', ic: 'out', present: true },
28
+ gdoc: { tag: 'Google Docs', ic: 'out' },
29
+ conf: { tag: 'Confluence', ic: 'out' },
30
+ link: { tag: 'Link', ic: 'out' },
31
+ };
32
+ const BY = { invite: 'from the invite', brief: 'found by today’s brief', you: 'added by you' };
33
+ const isPmos = (k) => k === 'pmos-deck' || k === 'pmos-doc' || k === 'report';
34
+
35
+ function ensureCss() {
36
+ ensureC3Css(); ensureComponentsCss();
37
+ if (typeof document === 'undefined' || document.querySelector('link[data-c3-material]')) return;
38
+ const l = document.createElement('link');
39
+ l.rel = 'stylesheet'; l.href = new URL('./c3-material.css', import.meta.url).href; l.setAttribute('data-c3-material', '');
40
+ document.head.appendChild(l);
41
+ }
42
+ const wsParam = () => { try { return new URLSearchParams(location.search).get('ws') || ''; } catch { return ''; } };
43
+ const pad = (n) => String(n).padStart(2, '0');
44
+ const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
45
+ const dateText = (iso) => { const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(iso || '')); return m ? (+m[3]) + ' ' + MON[+m[2] - 1] : ''; };
46
+ const timeText = (iso) => { const d = new Date(iso); return Number.isFinite(d.getTime()) ? pad(d.getHours()) + ':' + pad(d.getMinutes()) : ''; };
47
+
48
+ /** What an address is, as the server decides it (lib/meeting-material.js classifyUrl). null for anything that is not an https address. */
49
+ export function classifyLink(raw) {
50
+ const s = String(raw || '').trim();
51
+ if (!s || /\s/.test(s)) return null;
52
+ let u; try { u = new URL(/^[a-z][a-z0-9+.-]*:/i.test(s) ? s : 'https://' + s); } catch { return null; }
53
+ if (u.protocol !== 'https:' || !u.hostname.includes('.')) return null;
54
+ if (u.hostname === 'docs.google.com' && /^\/presentation\/d\//.test(u.pathname)) return { kind: 'slides', url: u.href, title: 'Google Slides deck', meta: 'read through your Google Drive connection' };
55
+ if (u.hostname === 'docs.google.com' && /^\/document\/d\//.test(u.pathname)) return { kind: 'gdoc', url: u.href, title: 'Google Docs page', meta: 'read through your Google Drive connection' };
56
+ if (/\.atlassian\.net$/.test(u.hostname) && u.pathname.startsWith('/wiki')) return { kind: 'conf', url: u.href, title: 'Confluence page', meta: 'read through your Atlassian connection' };
57
+ return { kind: 'link', url: u.href, title: u.hostname, meta: 'opens in a new tab · PMOS does not read it' };
58
+ }
59
+ /** The pseudo address Focus gives a PMOS item, so its link list can hold it beside web links (c3-focus.js classify). */
60
+ export const pmosUrl = (item) => 'pmos:' + item.kind + ':' + (item.ws || '') + ':' + item.ref;
61
+
62
+ /* ── what is stored ───────────────────────────────────────────────────────────────────────────────────────────────── */
63
+
64
+ const evParams = (event, extra) => {
65
+ const e = event || {};
66
+ const p = new URLSearchParams({ title: String(e.title || e.id || '') });
67
+ if (e.id) p.set('event', String(e.id));
68
+ if (e.series) p.set('series', String(e.series));
69
+ const date = (extra && extra.date) || String(e.at || e.start || '').slice(0, 10);
70
+ if (date) p.set('date', date);
71
+ const ws = (extra && extra.ws) || wsParam(); if (ws) p.set('ws', ws);
72
+ if (extra && extra.topics) p.set('topics', '1');
73
+ return p;
74
+ };
75
+ const evKey = (e) => (e && (e.id || e.title)) + '|' + ((e && e.series) || '');
76
+ const CACHE = new Map(); // event key → { items, at }
77
+ const FRESH_MS = 4000;
78
+
79
+ async function readItems(event, extra) {
80
+ const r = await pmGet('/api/meeting-material?' + evParams(event, extra).toString()).catch(() => null);
81
+ if (r && r.ok && Array.isArray(r.items)) { CACHE.set(evKey(event), { items: r.items, at: Date.now() }); return r; }
82
+ return null;
83
+ }
84
+ const changed = (event) => { try { window.dispatchEvent(new CustomEvent('c3-material-changed', { detail: { key: evKey(event) } })); } catch { /* no window */ } };
85
+
86
+ async function save(event, body) {
87
+ const e = event || {};
88
+ const r = await pmPost('/api/meeting-material', { event: { id: e.id || undefined, title: e.title || e.id, series: e.series || undefined }, ...body }).catch((err) => ({ ok: false, error: String((err && err.message) || err) }));
89
+ if (!r || !r.ok) { toast({ text: 'The material was not saved: ' + ((r && (r.error || r.reason)) || 'no answer') + '.', tone: 'error', timeout: 6000 }); return null; }
90
+ CACHE.set(evKey(event), { items: r.items || [], at: Date.now() });
91
+ changed(event);
92
+ return r;
93
+ }
94
+
95
+ /* ── open and present ─────────────────────────────────────────────────────────────────────────────────────────────── */
96
+
97
+ function openWeb(url) { try { window.open(url, '_blank', 'noopener,noreferrer'); } catch { /* a blocked pop-up */ } }
98
+ const slidesPresentUrl = (url) => { const m = /^(https:\/\/docs\.google\.com\/presentation\/d\/[A-Za-z0-9_-]+)/.exec(String(url || '')); return m ? m[1] + '/present' : url; };
99
+
100
+ async function openDocument(item) {
101
+ try {
102
+ const { openDocSheet } = await import('./c3-doc-sheet.js');
103
+ const ws = item.ws || wsParam();
104
+ openDocSheet({ ws, doc: { path: item.ref, name: item.title || item.ref, ty: 'Document', v: '', st: 'current', ini: '', iniName: '' },
105
+ library: () => Promise.resolve(pmGet('/api/docs/library' + (ws ? '?ws=' + encodeURIComponent(ws) : ''))).catch(() => ({ ok: true, files: [], edges: [], kinds: [], categories: [], initiatives: {} })) });
106
+ } catch { toast('The document sheet could not open. The file is ' + item.ref + '.'); }
107
+ }
108
+ async function openReport(item) {
109
+ try {
110
+ const store = await import('./c3-report-store.js');
111
+ const setup = await import('./c3-report-setup.js');
112
+ await store.loadReports(item.ws || wsParam(), true);
113
+ const d = store.RS.docs.find((x) => x.path === item.ref);
114
+ if (d) { setup.openReportDoc(d); return; }
115
+ } catch { /* fall back to the plain document */ }
116
+ openDocument(item);
117
+ }
118
+ /** Open an item: a PMOS item in PMOS (the document sheet), a link in a new tab. */
119
+ export function openItem(item) {
120
+ if (!item) return;
121
+ if (item.kind === 'pmos-doc') return openDocument(item);
122
+ if (isPmos(item.kind)) return openReport(item);
123
+ if (item.url) openWeb(item.url);
124
+ }
125
+
126
+ /** The presenter view (or the audience view) of a PMOS deck: the report deck's own (c3-report-deck.js), with your speaker notes. */
127
+ async function showDeck(item, mode) {
128
+ let deck = null;
129
+ try {
130
+ const { createDeck } = await import('./c3-report-deck.js');
131
+ deck = createDeck({ ws: item.ws || wsParam(), path: item.ref, name: item.title || 'Deck', cycle: '' });
132
+ const t0 = Date.now();
133
+ while (!deck.state.loaded && Date.now() - t0 < 10000) await new Promise((r) => setTimeout(r, 60));
134
+ } catch { deck = null; }
135
+ const S = deck && deck.state;
136
+ if (!S || !S.loaded || !S.cyc || !S.cyc.designed || !S.R || S.R.format !== 'deck') {
137
+ toast('This report has no designed deck yet, so it opens as a document.');
138
+ return openItem(item);
139
+ }
140
+ deck.present(mode);
141
+ }
142
+ export function presentItem(item) {
143
+ if (!item) return;
144
+ if (item.kind === 'slides') return openWeb(slidesPresentUrl(item.url));
145
+ if (item.kind === 'pmos-deck') return showDeck(item, 'pres');
146
+ return openItem(item);
147
+ }
148
+ export function audienceItem(item) { if (item && item.kind === 'pmos-deck') return showDeck(item, 'aud'); return openItem(item); }
149
+
150
+ /* ── the Material part of the meeting card ────────────────────────────────────────────────────────────────────────── */
151
+
152
+ /** The links the invite and today's brief already carry, as read-only rows. */
153
+ function givenRows(event, brief) {
154
+ const seen = new Set(); const rows = [];
155
+ const add = (label, url, from) => {
156
+ const c = classifyLink(url); if (!c || seen.has(c.url)) return;
157
+ seen.add(c.url); rows.push({ id: from + ':' + c.url, kind: c.kind, url: c.url, title: label || c.title, topic: null, present: false, scope: null, from });
158
+ };
159
+ for (const l of (event && Array.isArray(event.links) ? event.links : [])) add(l && l.label, l && l.url, 'invite');
160
+ for (const l of (brief && Array.isArray(brief.links) ? brief.links : [])) add(l && l.label, l && l.url, 'brief');
161
+ for (const r of (brief && Array.isArray(brief.refs) ? brief.refs : [])) if (r && r.link) add(r.label, r.link, 'brief');
162
+ return rows;
163
+ }
164
+
165
+ function rowOf(item, o) {
166
+ const k = KIND[item.kind] || KIND.link;
167
+ const canPresent = !!item.present && k.present;
168
+ const where = item.scope === 'series' ? 'every meeting in the series' : item.scope === 'meeting' ? 'this meeting' : '';
169
+ const meta = [item.topic ? 'for ' + item.topic : null, BY[item.from] || BY.you, where || null, canPresent ? 'you present it' : null].filter(Boolean).join(' · ');
170
+ const act = canPresent
171
+ ? lk({ 'data-mm-act': 'present', title: item.kind === 'slides' ? 'Opens it in Google Slides in present mode. Share that tab in the call.' : 'Opens the presenter view in PMOS. Press Audience view before you share the screen.', onclick: () => presentItem(item) }, icon('play', 'sm'), 'Present')
172
+ : lk({ 'data-mm-act': 'open', title: isPmos(item.kind) ? 'Opens it in PMOS' : 'Opens it in a new tab', onclick: () => openItem(item) }, icon('out', 'sm'), 'Open');
173
+ const rm = item.from === 'you' ? ib('Remove ' + item.title, 'x', { 'data-mm-act': 'remove', title: 'Remove from this meeting. The document itself is not changed.',
174
+ onclick: async () => {
175
+ const r = await save(o.event, { op: 'remove', scope: item.scope, item: isPmos(item.kind) ? { kind: item.kind, ref: item.ref, ws: item.ws || undefined } : { url: item.url } });
176
+ if (r) toast('Removed from the meeting. The document itself is not changed.');
177
+ } }) : null;
178
+ return el('li', { class: 'cal3-ci mm-row', 'data-mm-item': item.kind, 'data-mm-from': item.from, ...(item.topic ? { 'data-mm-topic': item.topic } : {}) },
179
+ el('span', { class: 'tag' }, k.tag), el('span', { class: 'cal3-ct' }, item.title, el('span', { class: 'dim mm-meta' }, meta)), act, rm);
180
+ }
181
+
182
+ function sectionBody(o, items) {
183
+ const rows = [...givenRows(o.event, o.brief), ...(items || [])];
184
+ return [
185
+ el('h4', null, icon('doc', 'sm'), 'Material ', el('span', { class: 'bdg g', 'data-mm': 'count' }, String(rows.length))),
186
+ rows.length ? el('ul', { class: 'cal3-cl', role: 'list' }, ...rows.map((r) => rowOf(r, o))) : el('div', { class: 'fold', 'data-mm': 'none' }, icon('info', 'sm'), items ? 'Nothing is linked to this meeting yet.' : 'Reading the material of this meeting.'),
187
+ el('div', { class: 'chips2 mm-foot' }, pbtn('outline', 'Add material', 'plus', { 'data-mm': 'add', onclick: () => openAddMaterial(o) }), el('span', { class: 'dim cal3-note' }, 'Shows in Focus, by topic. Your calendar invite is not changed.')),
188
+ ];
189
+ }
190
+
191
+ /** The Material part of the meeting card (before the meeting). It draws what it holds at once and reads again when it is older than a few seconds. */
192
+ export function materialSection(o) {
193
+ ensureCss();
194
+ const box = el('div', { class: 'cal3-ctx mm', 'data-mm': 'section', 'data-cal3-ctx': 'material' });
195
+ const key = evKey(o.event);
196
+ const draw = () => { const c = CACHE.get(key); put(box, ...sectionBody(o, c ? c.items : null)); };
197
+ draw();
198
+ const heard = () => { if (!box.isConnected) return; draw(); };
199
+ const onChanged = (ev) => { if (!box.isConnected) { window.removeEventListener('c3-material-changed', onChanged); return; } if (!ev.detail || ev.detail.key === key) draw(); };
200
+ window.addEventListener('c3-material-changed', onChanged);
201
+ const c = CACHE.get(key);
202
+ if (!c || Date.now() - c.at > FRESH_MS) readItems(o.event, { date: o.date, ws: o.ws }).then((r) => { if (r) heard(); });
203
+ return box;
204
+ }
205
+
206
+ /* ── the Add material sheet ───────────────────────────────────────────────────────────────────────────────────────── */
207
+
208
+ const levelIni = (o) => {
209
+ const lv = o.level; const b = o.brief; const e = o.event || {};
210
+ if (lv && lv.kind === 'initiative' && lv.id) return lv.id;
211
+ if (lv && lv.kind) return '';
212
+ return (b && b.initiative && b.initiative.id) || (typeof e.initiative === 'string' ? e.initiative : '') || '';
213
+ };
214
+
215
+ function pickRow(it, on, onPick) {
216
+ const k = KIND[it.kind] || KIND.link;
217
+ return el('li', { class: 'nrow mm-opt' + (on ? ' on' : ''), 'data-mm-opt': it.kind },
218
+ el('span', { class: 'ki' }, icon(k.ic)),
219
+ el('div', { class: 'nb' }, el('span', { class: 'tt' }, it.title),
220
+ el('div', { class: 'chips' }, el('span', { class: 'chip2' }, icon(k.ic, 'sm'), k.tag), it.meta ? el('span', { class: 'tag' }, it.meta) : null,
221
+ it.why ? el('span', { class: 'tag mm-why' }, icon('spark', 'sm'), it.why) : null)),
222
+ el('div', { class: 'acts' }, on ? el('span', { class: 'tag mm-sel' }, icon('check', 'sm'), 'Picked') : pbtn('ghost', 'Pick', null, { 'data-mm-pick': '', onclick: () => onPick(it) })));
223
+ }
224
+
225
+ /** Add material: Suggested from the meeting's level, Find in PMOS, Or paste a link; then For, Topic and You. */
226
+ export function openAddMaterial(o) {
227
+ ensureCss();
228
+ const event = o.event || {};
229
+ const S = { pick: null, scope: 'meeting', topic: null, present: true, q: '', link: '', sug: null, found: [], topics: [], busy: false, token: 0 };
230
+ const root = el('div', { class: 'mm-sheet', 'data-mm': 'sheet' });
231
+ const sugBox = el('div', { 'data-mm': 'suggested' });
232
+ const foundBox = el('div', { 'data-mm': 'found' });
233
+ const pasteBox = el('div', { 'data-mm': 'pasted' });
234
+ const optsBox = el('div', { 'data-mm': 'options' });
235
+ const findIn = el('input', { class: 'mm-in', type: 'search', 'data-mm': 'find', placeholder: 'A document, report or deck', 'aria-label': 'Find a document, report or deck in PMOS', autocomplete: 'off' });
236
+ const linkIn = el('input', { class: 'mm-in', type: 'url', 'data-mm': 'link', placeholder: 'Google Slides, Google Docs, Confluence or any web page', 'aria-label': 'Paste a link', autocomplete: 'off' });
237
+ let timer = null;
238
+
239
+ const pasted = () => { const c = classifyLink(S.link); return c ? { kind: c.kind, url: c.url, title: c.title, meta: c.meta, present: c.kind === 'slides' } : null; };
240
+ const same = (a, b) => !!a && !!b && a.kind === b.kind && (a.ref || a.url) === (b.ref || b.url);
241
+ const choose = (it) => { S.pick = it; S.present = !!(KIND[it.kind] && KIND[it.kind].present) && it.present !== false; paint(); };
242
+
243
+ function paint() {
244
+ put(sugBox, S.sug == null ? el('div', { class: 'fold' }, icon('info', 'sm'), 'Looking in PMOS.') : S.sug.length ? list(S.sug.map((it) => pickRow(it, same(it, S.pick), choose)), { role: 'list' }) : empty('Nothing to suggest yet. Search below, or paste a link.'));
245
+ put(foundBox, S.q.length >= 2 ? (S.found.length ? list(S.found.map((it) => pickRow(it, same(it, S.pick), choose)), { role: 'list' }) : el('div', { class: 'fold' }, icon('info', 'sm'), S.busy ? 'Searching.' : 'Nothing in PMOS matches that.')) : null);
246
+ const p = pasted();
247
+ put(pasteBox, p ? list([pickRow(p, same(p, S.pick), choose)], { role: 'list' }) : el('p', { class: 'dim cal3-note' }, S.link ? 'Paste an https address.' : 'Google Slides gets Present. A web page opens in a new tab and PMOS does not read it.'));
248
+ put(optsBox, S.pick ? settings() : null);
249
+ }
250
+ const seg = (label, rows, cur, key) => el('div', { class: 'seg mm-wrap', role: 'group', 'aria-label': label }, ...rows.map(([v, l]) => pbtn('ghost', l, null, { 'aria-pressed': String(cur === v), [key]: String(v), onclick: () => { S[key === 'data-mm-scope' ? 'scope' : key === 'data-mm-topic' ? 'topic' : 'present'] = v; paint(); } })));
251
+ function settings() {
252
+ const pick = S.pick; const k = KIND[pick.kind] || KIND.link;
253
+ const canPresent = !!k.present;
254
+ const topicName = S.topic;
255
+ const where = event.series ? 'Every meeting in the series' : 'Every meeting with this title';
256
+ const note = canPresent
257
+ ? (pick.kind === 'slides' ? 'Present opens it in Google Slides in present mode. You share that tab in the call. ' : 'Present opens the presenter view in PMOS with your speaker notes. The presenter view and the audience view are one switch: press Audience view, then share the screen in the call. ')
258
+ : '';
259
+ const focusNote = topicName ? 'Focus brings it up when the room gets to ' + topicName + '.' : 'Focus keeps it under Material for the whole meeting.';
260
+ return sect({ ic: 'settings', title: 'How it shows', attrs: { class: 'mm-set', 'data-mm': 'settings' } },
261
+ el('div', { class: 'mm-f' }, el('span', { class: 'mk-lbl dim mono' }, 'For'),
262
+ seg('For', [['meeting', 'This meeting · ' + [dateText(String(event.at || event.start || o.date || '')), timeText(event.at || event.start)].filter(Boolean).join(' ')], ['series', where]], S.scope, 'data-mm-scope')),
263
+ S.topics.length ? el('div', { class: 'mm-f' }, el('span', { class: 'mk-lbl dim mono' }, 'Topic'),
264
+ el('div', { class: 'seg mm-wrap', role: 'group', 'aria-label': 'Topic' },
265
+ ...S.topics.map((t, i) => pbtn('ghost', (i + 1) + '. ' + t.text, null, { 'aria-pressed': String(S.topic === t.text), 'data-mm-topic': t.id, onclick: () => { S.topic = t.text; paint(); } })),
266
+ pbtn('ghost', 'Whole meeting', null, { 'aria-pressed': String(S.topic == null), 'data-mm-topic': 'none', onclick: () => { S.topic = null; paint(); } }))) : null,
267
+ canPresent ? el('div', { class: 'mm-f' }, el('span', { class: 'mk-lbl dim mono' }, 'You'), seg('Do you present it', [[true, 'Present it'], [false, 'Only read it']], S.present, 'data-mm-pres')) : null,
268
+ el('p', { class: 'dim cal3-note' }, note + focusNote),
269
+ el('div', { class: 'iacts' }, pbtn('primary', 'Add to the meeting', 'plus', { 'data-mm': 'do', onclick: add }), pbtn('ghost', 'Cancel', null, { 'data-mm': 'cancel', onclick: () => { S.pick = null; paint(); } })));
270
+ }
271
+ async function add() {
272
+ const pick = S.pick; if (!pick) return;
273
+ const item = isPmos(pick.kind) ? { kind: pick.kind, ref: pick.ref, title: pick.title, ws: pick.ws || undefined } : { url: pick.url, title: pick.title };
274
+ const r = await save(event, { op: 'add', scope: S.scope, item, topic: S.topic || undefined, present: S.present && !!(KIND[pick.kind] || {}).present });
275
+ if (!r) return;
276
+ closeSheet();
277
+ toast('Added to ' + (S.scope === 'series' ? 'every meeting in the series' : 'this meeting') + '. Focus shows it' + (S.topic ? ' at ' + S.topic : '') + '.');
278
+ }
279
+
280
+ findIn.addEventListener('input', () => {
281
+ S.q = findIn.value.trim(); S.busy = S.q.length >= 2; S.found = []; paint();
282
+ clearTimeout(timer);
283
+ if (!S.busy) return;
284
+ const my = ++S.token;
285
+ timer = setTimeout(async () => {
286
+ const p = new URLSearchParams({ q: S.q }); const ws = o.ws || wsParam(); if (ws) p.set('ws', ws);
287
+ const r = await pmGet('/api/meeting-material/suggest?' + p.toString()).catch(() => null);
288
+ if (my !== S.token) return;
289
+ S.busy = false; S.found = r && r.ok && Array.isArray(r.found) ? r.found : []; paint();
290
+ }, 220);
291
+ });
292
+ linkIn.addEventListener('input', () => { S.link = linkIn.value.trim(); const p = pasted(); if (p) S.pick = p; else if (S.pick && !S.pick.ref && S.pick.url) S.pick = null; paint(); });
293
+
294
+ put(root,
295
+ el('p', { class: 'dim cal3-note' }, (event.title || 'This meeting') + (event.at || event.start ? ' · ' + dateText(event.at || event.start) + ' ' + timeText(event.at || event.start) : '') + '. PMOS keeps the link on its record of this meeting. The invite in your calendar is not changed.'),
296
+ sect({ ic: 'spark', title: 'Suggested for this meeting', attrs: { 'data-mm': 'suggest-sect' } }, sugBox),
297
+ sect({ ic: 'search', title: 'Find in PMOS' }, findIn, foundBox),
298
+ sect({ ic: 'out', title: 'Or paste a link' }, linkIn, pasteBox),
299
+ optsBox);
300
+ paint();
301
+ openSheet({ title: 'Add material', icon: 'doc', wide: true, body: [el('div', { class: 'c3x' }, root)], onClose: () => { clearTimeout(timer); if (typeof o.back === 'function') { try { o.back(); } catch { /* the page is gone */ } } } });
302
+
303
+ const ini = levelIni(o);
304
+ const p = new URLSearchParams(); const ws = o.ws || wsParam(); if (ws) p.set('ws', ws); if (ini) p.set('initiative', ini);
305
+ pmGet('/api/meeting-material/suggest?' + p.toString()).then((r) => { S.sug = r && r.ok && Array.isArray(r.suggested) ? r.suggested : []; paint(); }).catch(() => { S.sug = []; paint(); });
306
+ readItems(event, { date: o.date, ws: o.ws, topics: true }).then((r) => { S.topics = r && Array.isArray(r.topics) ? r.topics : []; paint(); });
307
+ }
308
+
309
+ if (typeof window !== 'undefined') {
310
+ window.C3Material = { open: (event, opts) => openAddMaterial({ event, ...(opts || {}) }), section: materialSection, openItem, presentItem, audienceItem, KIND };
311
+ }
@@ -111,7 +111,7 @@ async function read(s) {
111
111
  draw(s); return;
112
112
  }
113
113
  if (!s.open && s.rows.length) { s.open = s.rows[0].id; return read(s); }
114
- if (one && one.ok && one.note && one.note.id === s.open) { s.note = one.note; s.shots = (one.screen && one.screen.frames) || []; s.rec = one.recording || null; s.place = one.place || null; }
114
+ if (one && one.ok && one.note && one.note.id === s.open) { s.note = one.note; s.shots = (one.screen && one.screen.frames) || []; s.aside = (one.screen && one.screen.aside) || 0; s.rec = one.recording || null; s.place = one.place || null; }
115
115
  draw(s);
116
116
  }
117
117
 
@@ -124,7 +124,7 @@ async function loadBoards(s) {
124
124
 
125
125
  function choose(s, id, phase) {
126
126
  if (s.open === id && !s.ser) return;
127
- s.open = id; s.note = null; s.phase = phase || null; s.shots = []; s.rec = null; s.place = null; s.ser = null; s.thread = null;
127
+ s.open = id; s.note = null; s.phase = phase || null; s.shots = []; s.aside = 0; s.rec = null; s.place = null; s.ser = null; s.thread = null;
128
128
  draw(s); read(s);
129
129
  }
130
130
  /* open a meeting series on the right (the series page) */
@@ -376,6 +376,13 @@ function replayOf(s, n) {
376
376
  s.audio = { key, el: a };
377
377
  return a;
378
378
  }
379
+ /* "Replay": the full-screen replay of the meeting (c3-replay.js): the topics, key points, shots and conversation along the audio */
380
+ function openReplayBtn(s, n) {
381
+ return btn('chip2 lk', { 'data-cal3-notes': 'open-replay', title: 'Open the meeting replay: the topics, the key points, the screen and the conversation along the audio' }, [c3ic('play', 'sm'), 'Replay'], () => {
382
+ if (s.audio && s.audio.el) { try { s.audio.el.pause(); } catch { /* none */ } }
383
+ import('./c3-replay.js').then((m) => m.openReplay({ note: n.id })).catch(() => toast('The replay could not load.'));
384
+ });
385
+ }
379
386
  function seek(s, ms) {
380
387
  const a = s.audio && s.audio.el;
381
388
  if (!a) return;
@@ -464,7 +471,7 @@ function after(s, n, mode) {
464
471
  const kids = [
465
472
  el('div', { class: 'c3cv-head' }, el('h3', null, c3ic('notes'), 'Notes'), recordButton(s, n, mode)),
466
473
  el('div', { class: 'chips2' }, el('b', null, n.title || 'Untitled note'), el('span', { class: 'dim mono cal3-sm' }, [dayOf(startOf(n)), mins(lenOf(n))].filter(Boolean).join(' · ')), ...names.map(avatar)),
467
- ...(() => { const rp = mode ? null : replayOf(s, n); return rp ? [el('div', { class: 'cal3-replay', 'data-cal3-notes': 'replay' }, el('h4', null, 'Replay'), rp, el('p', { class: 'dim cal3-sm' }, 'Kept on this machine.'))] : []; })(),
474
+ ...(() => { const rp = mode ? null : replayOf(s, n); return rp ? [el('div', { class: 'cal3-replay', 'data-cal3-notes': 'replay' }, el('div', { class: 'c3cv-head' }, el('h4', null, 'Replay'), openReplayBtn(s, n)), rp, el('p', { class: 'dim cal3-sm' }, 'Kept on this machine.'))] : []; })(),
468
475
  ...cf.map((c) => el('div', { class: 'fold w', 'data-cal3-notes': 'conflict' }, c3ic('alert', 'sm'), '“' + c.text + '” differs from ', el('span', { class: 'mono' }, c.doc + (c.line ? ':' + c.line : '')))),
469
476
  p.overview || !mode ? editable(true, 'p', p.overview, 'Summary', (t) => saveFinal(s, n, 'overview', 0, t), { class: 'cal3-ov', 'data-cal3-notes': p.overview ? 'overview' : 'no-overview' }, p.from === 'final' ? null : 'Write a summary of the meeting')
470
477
  : el('p', { class: 'dim cal3-ov', 'data-cal3-notes': 'no-overview' }, 'The overview is written when you stop recording.'),
@@ -491,7 +498,8 @@ function after(s, n, mode) {
491
498
  if (!p.overview && !p.decisions.length && !p.actions.length && !p.questions.length && !hl.length && !cf.length && !(s.shots || []).length) kids.push(fold('moon', mode ? 'Decisions and next steps show here as they are caught, and in full after you stop.' : 'No decisions, next steps or questions are held for this note.', { 'data-cal3-notes': 'nothing' }));
492
499
  // the screenshots of the shared screen, after Stop, as the main app's Notes shows them (notes-screen-view.js)
493
500
  const shots = mode || n.private ? [] : s.shots || [];
494
- if (shots.length) kids.push(el('div', { 'data-cal3-notes': 'screen' }, screenStrip({ id: n.id, frames: shots, seek: s.audio ? (ms) => seek(s, ms) : null, replay: s.audio ? s.audio.el : null })));
501
+ const asideN = mode || n.private ? 0 : s.aside || 0; // shots set aside as another app: "N set aside" opens their review
502
+ if (shots.length || asideN) kids.push(el('div', { 'data-cal3-notes': 'screen' }, screenStrip({ id: n.id, frames: shots, aside: asideN, seek: s.audio ? (ms) => seek(s, ms) : null, replay: s.audio ? s.audio.el : null })));
495
503
  const tr = transcriptOf(n);
496
504
  if (!mode && tr.length) kids.push(el('details', { 'data-cal3-notes': 'transcript-fold' }, el('summary', { class: 'dim cal3-sm' }, 'Transcript · ' + tr.length + (tr.length === 1 ? ' line' : ' lines')),
497
505
  el('div', { class: 'term cal3-term' }, tr.map((x) => V.fmtTime(x.t || 0) + ' ' + x.text).join('\n'))));
@@ -0,0 +1,127 @@
1
+ /* pm/c3-replay.css — the meeting replay (initiative meeting-material-replay, part 2; the approved mockup #replay). Scoped under .c3replay.
2
+ * It is Focus played back, so the header, the track, the stage and the conversation follow c3-focus.css. Tokens only. */
3
+ .c3.rpl-open { container-type: normal; }
4
+ .c3replay { position: fixed; inset: 0; z-index: 50; overflow: auto; background: var(--background); color: var(--foreground); }
5
+ .c3replay [hidden] { display: none !important; }
6
+ .c3replay .rpl-page { display: grid; gap: var(--sp-2); max-width: 1480px; margin: 0 auto; padding: var(--sp-3) var(--sp-4) var(--sp-6, 32px); box-sizing: border-box; min-width: 0; }
7
+ .c3replay .rpl-page > * { min-width: 0; }
8
+ .c3replay .rpl-grow { flex: 1 1 auto; }
9
+ .c3replay .tag { display: inline-flex; align-items: center; gap: 4px; }
10
+ .c3replay .tag.rpl-warn, .c3replay .rpl-warn { color: var(--warning); }
11
+ .c3replay audio { display: none; }
12
+
13
+ /* header: who and when, the clock, the play controls, then the track across the full width */
14
+ .c3replay .rpl-top { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; gap: 6px var(--sp-4); align-items: center; padding: var(--sp-2) var(--sp-3); }
15
+ .c3replay .rpl-id { display: flex; gap: var(--sp-3); align-items: flex-start; min-width: 0; }
16
+ .c3replay .rpl-tt { min-width: 0; display: grid; gap: 4px; }
17
+ .c3replay .rpl-tt h1 { margin: 0; font-size: var(--fs-4); font-weight: 600; line-height: 1.2; overflow-wrap: anywhere; }
18
+ .c3replay .rpl-tt .chips2 { flex-wrap: wrap; gap: 6px; font-size: var(--fs-1); display: flex; align-items: center; }
19
+ .c3replay .rpl-warnc { color: var(--warning); }
20
+ .c3replay .rpl-clock > div { display: grid; justify-items: start; }
21
+ .c3replay .rpl-clock b { font-size: var(--fs-5); font-weight: 500; line-height: 1; font-variant-numeric: tabular-nums; }
22
+ .c3replay .rpl-clock .dim { font-size: var(--fs-1); }
23
+ .c3replay .rpl-acts { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: flex-end; }
24
+
25
+ /* the track is the scrub bar: marks above, the stretches of talk, PMOS's notes below, the playhead across */
26
+ .c3replay .rpl-track { grid-column: 1 / -1; position: relative; }
27
+ .c3replay .rpl-rail { position: relative; cursor: pointer; }
28
+ .c3replay .rpl-mks { position: relative; height: 26px; }
29
+ .c3replay .rpl-runs { position: relative; height: 18px; border-radius: 6px; background: var(--muted); }
30
+ .c3replay .rpl-pms { position: relative; height: 10px; }
31
+ .c3replay .rpl-run { position: absolute; top: 0; bottom: 0; left: var(--fc-left, 0%); width: var(--fc-width, 0%); min-width: 0; min-height: 0; height: auto; padding: 0; margin: 0; box-sizing: border-box;
32
+ border: 0; border-right: 2px solid var(--card); border-radius: 6px; cursor: pointer; display: flex; align-items: center; justify-content: center; overflow: hidden;
33
+ background: color-mix(in oklab, var(--acc) 26%, var(--muted)); color: var(--foreground); font: 500 var(--fs-1) var(--mono); }
34
+ @media (hover: hover) { .c3replay .rpl-run:hover { background: color-mix(in oklab, var(--acc) 40%, var(--muted)); } }
35
+ .c3replay .rpl-run.c { background: var(--acc); color: var(--background); }
36
+ .c3replay .rpl-mkdot { position: absolute; top: 2px; left: var(--fc-left, 0%); transform: translateX(-50%); display: inline-grid; place-items: center; width: 20px; height: 20px; min-width: 0; min-height: 0; padding: 0; margin: 0;
37
+ border-radius: 50%; border: 1px solid var(--border); background: var(--card); color: var(--foreground); cursor: pointer; z-index: 1; }
38
+ .c3replay .rpl-mkdot .ic { width: 12px; height: 12px; }
39
+ .c3replay .rpl-mkdot.k-decision { border-color: var(--acc); color: var(--acc); }
40
+ .c3replay .rpl-mkdot.warn { border-color: var(--warning); color: var(--warning); }
41
+ .c3replay .rpl-mkdot.pm { top: 0; width: 8px; height: 8px; border: 0; background: color-mix(in oklab, var(--acc) 55%, transparent); }
42
+ .c3replay .rpl-now { position: absolute; top: 22px; bottom: 6px; width: 2px; margin-left: -1px; left: var(--fc-left, 0%); background: var(--foreground); pointer-events: none; transition: left .5s linear; }
43
+ .c3replay .rpl-ticks { position: relative; height: 14px; margin-top: 2px; font-size: var(--fs-1); }
44
+ .c3replay .rpl-tick { position: absolute; left: var(--fc-left, 0%); }
45
+ .c3replay .rpl-ticks .end { position: absolute; right: 0; }
46
+ .c3replay .rpl-legend { grid-column: 1 / -1; display: flex; gap: 14px; flex-wrap: wrap; font-size: var(--fs-1); }
47
+ .c3replay .rpl-lg { display: inline-flex; align-items: center; gap: 4px; }
48
+ .c3replay .rpl-lg.k-decision { color: var(--acc); }
49
+ .c3replay .rpl-lgdot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
50
+ .c3replay .rpl-lg.pm .rpl-lgdot { background: color-mix(in oklab, var(--acc) 55%, transparent); }
51
+ .c3replay .rpl-lg.warn .rpl-lgdot { border: 1px solid var(--warning); }
52
+ .c3replay .rpl-run:focus-visible, .c3replay .rpl-mkdot:focus-visible, .c3replay .rpl-lb:focus-visible, .c3replay .rpl-shotb:focus-visible { outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground)); outline-offset: 2px; }
53
+
54
+ /* the now line */
55
+ .c3replay .rpl-nowl { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; padding: 0 var(--sp-1); min-height: 28px; }
56
+ .c3replay .rpl-nowt { display: inline-flex; align-items: center; gap: 8px; min-width: 0; font-size: var(--fs-3); flex-wrap: wrap; }
57
+ .c3replay .rpl-nowt .ki { width: 22px; height: 22px; }
58
+ .c3replay .rpl-nowt .mono { font-size: var(--fs-2); font-variant-numeric: tabular-nums; }
59
+
60
+ /* stage, key points, topics */
61
+ .c3replay .rpl-main { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 380px); gap: var(--sp-3); align-items: start; }
62
+ .c3replay .rpl-s { display: grid; gap: var(--sp-3); min-width: 0; }
63
+ .c3replay .rpl-stagewrap, .c3replay .rpl-keywrap, .c3replay .rpl-topwrap { min-width: 0; }
64
+ .c3replay .rpl-stagewrap:empty, .c3replay .rpl-keywrap:empty, .c3replay .rpl-topwrap:empty { display: none; }
65
+ .c3replay .rpl-stage .sh2 h2 { text-transform: none; letter-spacing: 0; font-size: var(--fs-3); font-weight: 600; color: var(--foreground); }
66
+ .c3replay .rpl-screen { display: grid; margin-top: var(--sp-2); }
67
+ .c3replay .rpl-shotb { display: block; width: 100%; height: auto; min-height: 0; padding: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; background: var(--muted); cursor: pointer; }
68
+ .c3replay .rpl-shot { display: block; width: 100%; height: auto; max-height: 56vh; object-fit: contain; background: var(--muted); }
69
+ .c3replay .rpl-strip { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin: var(--sp-2) 0 0; }
70
+ .c3replay .rpl-strip .chip2.on { background: var(--background); color: var(--foreground); box-shadow: 0 0 0 1px var(--acc); }
71
+ .c3replay .rpl-cap { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: var(--sp-3); }
72
+ .c3replay .rpl-col { min-width: 0; }
73
+ .c3replay .rpl-lbl { display: flex; align-items: center; gap: 6px; font-size: var(--fs-1); padding: var(--sp-2) 0 4px; }
74
+ .c3replay .rpl-none { font-size: var(--fs-2); padding: 4px 0; }
75
+ .c3replay .rpl-src { margin: var(--sp-2) 0 0; font-size: var(--fs-1); line-height: 1.5; }
76
+ .c3replay .rpl-key.cur, .c3replay .rpl-topic.cur { background: color-mix(in oklab, var(--acc) 8%, transparent); border-radius: 8px; }
77
+ .c3replay .rpl-key .chips, .c3replay .rpl-topic .chips { flex-wrap: wrap; }
78
+ .c3 .c3replay .nrow .nb > .chips > :nth-child(n+3) { display: inline-flex !important; } /* c3-quiet.css shows two chips a row; a key point and a topic show all */
79
+
80
+ /* the conversation: transcript lines and PMOS's notes in time order, the current line marked and followed */
81
+ .c3replay .rpl-w { position: sticky; top: var(--sp-3); min-width: 0; }
82
+ .c3replay .rpl-cv { display: grid; grid-template-rows: auto minmax(0, 1fr) auto; max-height: min(760px, calc(100vh - 140px)); min-height: 280px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); overflow: hidden; }
83
+ .c3replay .rpl-cvh { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: var(--fs-2); flex-wrap: wrap; }
84
+ .c3replay .rpl-cvh .seg button { font-size: var(--fs-1); }
85
+ .c3replay .rpl-cvs { list-style: none; margin: 0; padding: 8px; display: grid; gap: 2px; overflow: auto; align-content: start; min-height: 0; position: relative; }
86
+ .c3replay .rpl-lb { display: grid; gap: 2px; width: 100%; height: auto; min-height: 0; padding: 6px 8px; border: 0; border-radius: 6px; background: none; color: inherit; text-align: left; font: inherit; white-space: normal; cursor: pointer; justify-content: stretch; }
87
+ .c3replay .rpl-lb > .rpl-lt, .c3replay .rpl-lb > .rpl-pb { display: block; overflow: visible; }
88
+ .c3replay .rpl-lb > .rpl-lh, .c3replay .rpl-lb > .rpl-cvwho { display: flex; overflow: visible; }
89
+ @media (hover: hover) { .c3replay .rpl-lb:hover { background: var(--muted); } }
90
+ .c3replay .rpl-l.on .rpl-lb { background: color-mix(in oklab, var(--acc) 10%, transparent); box-shadow: inset 2px 0 0 var(--acc); }
91
+ .c3replay .rpl-l.fut, .c3replay .rpl-pm.fut { opacity: .55; }
92
+ .c3replay .rpl-lh { display: flex; gap: 6px; align-items: center; font-size: var(--fs-1); }
93
+ .c3replay .rpl-lh .tag { margin-left: auto; }
94
+ .c3replay .rpl-lt { font-size: var(--fs-3); line-height: 1.45; }
95
+ .c3replay .rpl-kt.k-decision { color: var(--acc); }
96
+ .c3replay .rpl-pm .rpl-lb { background: color-mix(in oklab, var(--acc) 6%, transparent); }
97
+ .c3replay .rpl-cvwho { display: flex; align-items: center; gap: 6px; font-size: var(--fs-1); color: var(--muted-foreground); }
98
+ .c3replay .rpl-cvwho b { color: var(--acc); font-weight: 600; }
99
+ .c3replay .rpl-cvwho .rpl-time { margin-left: auto; }
100
+ .c3replay .fc-avdot { display: inline-grid; place-items: center; width: 16px; height: 16px; border-radius: 50%; background: color-mix(in oklab, var(--acc) 14%, transparent); color: var(--acc); flex: 0 0 auto; }
101
+ .c3replay .fc-avdot .ic { width: 10px; height: 10px; }
102
+ .c3replay .rpl-cat::before { content: "· "; }
103
+ .c3replay .rpl-pb { font-size: var(--fs-2); color: var(--muted-foreground); line-height: 1.45; }
104
+ .c3replay .rpl-cvin { display: flex; gap: 6px; align-items: center; padding: 8px 10px; border-top: 1px solid var(--border); }
105
+ .c3replay .rpl-cvin input { flex: 1; min-width: 0; height: 32px; padding: 0 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--background); color: var(--foreground); font: inherit; font-size: var(--fs-2); }
106
+ .c3replay .rpl-cvin input:focus-visible { outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground)); outline-offset: 1px; }
107
+
108
+ @media (max-width: 1100px) { .c3replay .rpl-main { grid-template-columns: minmax(0, 1fr) minmax(280px, 340px); } }
109
+ @media (max-width: 900px) {
110
+ .c3replay .rpl-page { padding: var(--sp-2) 16px var(--sp-5, 24px); }
111
+ .c3replay .rpl-main { grid-template-columns: minmax(0, 1fr); }
112
+ .c3replay .rpl-w { position: static; }
113
+ .c3replay .rpl-cv { max-height: 520px; }
114
+ .c3replay .rpl-cap { grid-template-columns: minmax(0, 1fr); }
115
+ }
116
+ @media (max-width: 760px) {
117
+ .c3replay .rpl-top { grid-template-columns: minmax(0, 1fr); }
118
+ .c3replay .rpl-id { flex-direction: column; gap: var(--sp-2); }
119
+ .c3replay .rpl-acts { justify-content: flex-start; }
120
+ .c3replay .rpl-ticks .rpl-tick:not(:first-child) { display: none; }
121
+ }
122
+ @media (pointer: coarse) {
123
+ .c3replay .rpl-mkdot { width: 28px; height: 28px; top: 0; }
124
+ .c3replay .rpl-mkdot.pm { width: 12px; height: 12px; }
125
+ .c3replay .rpl-lb { min-height: 44px; }
126
+ }
127
+ @media (prefers-reduced-motion: reduce) { .c3replay .rpl-now { transition: none; } }