create-pmos 0.241.0 → 0.243.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.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/c3-report-design-real-test.js +13 -2
- package/workspace/c3-report-design-test.js +25 -3
- package/workspace/c3-report-structure-real-test.js +38 -1
- package/workspace/c3-report-structure-test.js +21 -6
- package/workspace/guide/console-calendar.md +15 -1
- package/workspace/guide/notes.md +26 -1
- package/workspace/guide/report-structure.md +3 -3
- package/workspace/guide/reports-page.md +3 -1
- package/workspace/guide/reports-set-up.md +5 -1
- package/workspace/lib/headless.js +7 -2
- package/workspace/lib/meeting-material.js +151 -0
- package/workspace/lib/meeting-replay.js +184 -0
- package/workspace/lib/notes-screen.js +128 -8
- package/workspace/lib/report-structure.js +49 -5
- package/workspace/lib/route-modules.js +2 -0
- package/workspace/lib/routes-meeting-focus.js +5 -3
- package/workspace/lib/routes-meeting-material.js +143 -0
- package/workspace/lib/routes-meeting-replay.js +75 -0
- package/workspace/lib/routes-notes.js +37 -4
- package/workspace/lib/routes-report-setups.js +16 -2
- package/workspace/lib/routes-report-structure.js +176 -5
- package/workspace/lib/routes-reports.js +10 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/c3-audience-link.js +21 -0
- package/workspace/public/pm/c3-audience.js +46 -0
- package/workspace/public/pm/c3-boot.js +2 -1
- package/workspace/public/pm/c3-calendar.js +15 -3
- package/workspace/public/pm/c3-demo-material.js +58 -0
- package/workspace/public/pm/c3-demo.js +5 -1
- package/workspace/public/pm/c3-focus.js +83 -16
- package/workspace/public/pm/c3-listen.css +10 -0
- package/workspace/public/pm/c3-material.css +22 -0
- package/workspace/public/pm/c3-material.js +321 -0
- package/workspace/public/pm/c3-notes.js +12 -4
- package/workspace/public/pm/c3-replay.css +133 -0
- package/workspace/public/pm/c3-replay.js +429 -0
- package/workspace/public/pm/c3-report-deck.css +5 -0
- package/workspace/public/pm/c3-report-deck.js +41 -5
- package/workspace/public/pm/c3-report-setup.css +5 -0
- package/workspace/public/pm/c3-report-setup.js +20 -2
- package/workspace/public/pm/c3-report-store.js +24 -2
- package/workspace/public/pm/c3-report-structure.css +4 -0
- package/workspace/public/pm/c3-report-structure.js +26 -8
- package/workspace/public/pm/c3-shell.js +2 -0
- package/workspace/public/pm/dest-notes.js +4 -2
- package/workspace/public/pm/dest-reports.js +10 -1
- package/workspace/public/pm/listen-sheet.js +94 -0
- package/workspace/public/pm/listen-sources.js +82 -0
- package/workspace/public/pm/notes-aside.js +62 -0
- package/workspace/public/pm/notes-screen-capture.js +23 -10
- package/workspace/public/pm/notes-screen-view.js +18 -5
- package/workspace/public/pm/notes-shots.js +55 -0
- package/workspace/public/pm/report-view.js +23 -1
- package/workspace/public/pm/reports-copy.js +5 -0
- package/workspace/public/pm/shell-recording.js +10 -2
- package/workspace/public/pm/view-notes.js +48 -8
- package/workspace/server.js +8 -1
- package/workspace/skills/report-structure-pull.skill +45 -0
- package/workspace/test-lists/meeting-material.json +6 -0
- package/workspace/test-lists/meeting-replay.json +6 -0
- package/workspace/test-lists/mm-capture.json +15 -0
- package/workspace/test-lists/report-structure-gaps.json +5 -0
|
@@ -156,3 +156,8 @@
|
|
|
156
156
|
.c3 .rd-dks-chat { position: static; max-height: none; }
|
|
157
157
|
.c3 .rd-dks-log { max-height: 420px; }
|
|
158
158
|
}
|
|
159
|
+
|
|
160
|
+
/* meeting-material-replay-gaps: the audience window shows the slides only, so the toolbar is hidden; a window with no deck says why in one line */
|
|
161
|
+
body.c3-aud-win { margin: 0; }
|
|
162
|
+
body.c3-aud-win .rd-ov .rp-deck__bar { display: none; }
|
|
163
|
+
.c3.aud-msg { display: grid; place-items: center; min-height: 100vh; padding: var(--sp-3); text-align: center; }
|
|
@@ -20,6 +20,7 @@ import { icon, pbtn, btn, chip, sect, put, lk } from './c3-ini-ui.js';
|
|
|
20
20
|
import { pmGet, pmPost } from './http.js';
|
|
21
21
|
import { toast } from './c3-toast.js';
|
|
22
22
|
import { ensureC3Css } from './pm-work-view.js';
|
|
23
|
+
import { openAudienceWindow } from './c3-audience-link.js';
|
|
23
24
|
|
|
24
25
|
export const W = 1280;
|
|
25
26
|
export const H = 720;
|
|
@@ -266,7 +267,7 @@ export function createDeck(o) {
|
|
|
266
267
|
btn({ 'data-rd-act': 'mode', 'data-v': 'cmp', 'aria-pressed': String(S.mode === 'cmp'), onclick: () => { S.mode = 'cmp'; paint(); } }, 'Side by side with ' + prevWord())) : null),
|
|
267
268
|
el('div', { class: 'chips2 rd-wrap' },
|
|
268
269
|
isDeck() ? pbtn('primary', 'Present', 'play', { 'data-rd-act': 'present', onclick: () => present('pres') }) : null,
|
|
269
|
-
isDeck() ? pbtn('outline', 'Audience view', 'eye', { 'data-rd-act': 'audience', onclick: () =>
|
|
270
|
+
isDeck() ? pbtn('outline', 'Audience view', 'eye', { 'data-rd-act': 'audience', onclick: () => audienceView() }) : null,
|
|
270
271
|
pbtn('outline', 'Refine the design', 'spark', { 'data-rd-act': 'studio', onclick: () => o.onStudio && o.onStudio() }),
|
|
271
272
|
pbtn('ghost', 'Print / PDF', 'pdf', { 'data-rd-act': 'print', onclick: () => doPrint() }),
|
|
272
273
|
isDeck() ? pbtn('ghost', 'Download .pptx', 'drop', { 'data-rd-act': 'pptx', onclick: () => S.api && S.api.download && S.api.download('pptx') }) : null));
|
|
@@ -483,9 +484,36 @@ export function createDeck(o) {
|
|
|
483
484
|
|
|
484
485
|
/* ── Present: the presenter view and the audience view, on the .rp-deck stage ── */
|
|
485
486
|
let OV = null;
|
|
487
|
+
/* The audience window (initiative meeting-material-replay-gaps): a page of its own that shows the slides only and follows this view's slide. */
|
|
488
|
+
let AUD = null;
|
|
489
|
+
function closeAud() {
|
|
490
|
+
if (!AUD) return;
|
|
491
|
+
try { if (AUD.ch) { AUD.ch.postMessage({ t: 'close' }); AUD.ch.close(); } } catch { /* gone */ }
|
|
492
|
+
try { if (AUD.win && !AUD.win.closed) AUD.win.close(); } catch { /* gone */ }
|
|
493
|
+
AUD = null;
|
|
494
|
+
}
|
|
495
|
+
/** Open the audience window. → true, or false when the browser blocked it (the caller falls back to the full-screen view in this window). */
|
|
496
|
+
function openAudience() {
|
|
497
|
+
if (!isDeck()) return false;
|
|
498
|
+
if (AUD && AUD.win && !AUD.win.closed) { try { AUD.win.focus(); } catch { /* blocked */ } return true; }
|
|
499
|
+
const w = openAudienceWindow({ ws, path, name: (o.name || 'Report') + (o.cycle ? ' · ' + o.cycle : ''), start: OV ? OV.i : S.cur });
|
|
500
|
+
if (!w) return false;
|
|
501
|
+
AUD = w;
|
|
502
|
+
if (AUD.ch) AUD.ch.onmessage = (e) => {
|
|
503
|
+
const m = e && e.data; if (!m || typeof m !== 'object') return;
|
|
504
|
+
if (m.t === 'hello') AUD.ch.postMessage({ t: 'slide', i: OV ? OV.i : S.cur });
|
|
505
|
+
else if (m.t === 'slide' && Number.isInteger(m.i)) { if (OV) ovGo(m.i, { quiet: true }); else { S.cur = Math.max(0, Math.min(count() - 1, m.i)); } }
|
|
506
|
+
};
|
|
507
|
+
return true;
|
|
508
|
+
}
|
|
509
|
+
/** The Audience view press: the window, else (blocked) the full-screen view in this window with a notice that says why. */
|
|
510
|
+
function audienceView() {
|
|
511
|
+
if (openAudience()) return;
|
|
512
|
+
toast({ text: 'The audience window was blocked. Allow pop-ups for this page, or use the full-screen view here.', actionLabel: 'Full-screen view here', timeout: 10000, onAction: () => present('aud') });
|
|
513
|
+
}
|
|
486
514
|
function closeOV() {
|
|
487
515
|
if (!OV) return; clearInterval(OV.timer); window.removeEventListener('keydown', OV.key, true); window.removeEventListener('resize', OV.fit);
|
|
488
|
-
OV.host.remove(); const i = OV.i; OV = null; S.cur = i; paint(); const b = root.querySelector('[data-rd-act="present"]'); if (b) b.focus();
|
|
516
|
+
OV.host.remove(); const i = OV.i; OV = null; S.cur = i; closeAud(); if (o.standalone) { try { window.close(); } catch { /* not ours to close */ } return; } paint(); const b = root.querySelector('[data-rd-act="present"]'); if (b) b.focus();
|
|
489
517
|
}
|
|
490
518
|
function present(kind, start) {
|
|
491
519
|
if (!isDeck()) return;
|
|
@@ -513,7 +541,13 @@ export function createDeck(o) {
|
|
|
513
541
|
else if (k === 'Home') { e.preventDefault(); e.stopImmediatePropagation(); ovGo(0); }
|
|
514
542
|
else if (k === 'End') { e.preventDefault(); e.stopImmediatePropagation(); ovGo(count() - 1); }
|
|
515
543
|
}
|
|
516
|
-
function ovGo(j
|
|
544
|
+
function ovGo(j, opt) {
|
|
545
|
+
if (!OV) return; const n = Math.max(0, Math.min(count() - 1, j)); if (n === OV.i) return; OV.i = n; paintOV();
|
|
546
|
+
if (!(opt && opt.quiet)) {
|
|
547
|
+
if (AUD && AUD.ch) { try { AUD.ch.postMessage({ t: 'slide', i: n }); } catch { /* closed */ } }
|
|
548
|
+
if (typeof o.onSlide === 'function') o.onSlide(n);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
517
551
|
const clock = () => { const s = Math.floor((Date.now() - OV.t0) / 1000); return pad2(Math.floor(s / 60)) + ':' + pad2(s % 60); };
|
|
518
552
|
function fitOV() {
|
|
519
553
|
if (!OV) return;
|
|
@@ -525,7 +559,7 @@ export function createDeck(o) {
|
|
|
525
559
|
btn({ class: 'ib', 'aria-label': 'Previous slide', 'data-rd-ovact': 'prev', disabled: OV.i === 0, onclick: () => ovGo(OV.i - 1) }, icon('prev')),
|
|
526
560
|
el('p', { class: 'rp-deck__count', 'data-rd-count': '' }, (OV.i + 1) + ' / ' + N),
|
|
527
561
|
btn({ class: 'ib', 'aria-label': 'Next slide', 'data-rd-ovact': 'next', disabled: OV.i === N - 1, onclick: () => ovGo(OV.i + 1) }, icon('next')),
|
|
528
|
-
OV.kind === 'pres' ? pbtn('ghost', 'Audience view', 'eye', { 'data-rd-ovact': 'aud', onclick: () =>
|
|
562
|
+
OV.kind === 'pres' ? pbtn('ghost', 'Audience view', 'eye', { 'data-rd-ovact': 'aud', onclick: () => audienceView() }) : pbtn('ghost', 'Presenter view', 'mic', { 'data-rd-ovact': 'pres', onclick: () => { OV.kind = 'pres'; paintOV(); } }),
|
|
529
563
|
btn({ class: 'ib', 'aria-label': 'Close the slides (Escape)', 'data-rd-ovact': 'close', onclick: () => closeOV() }, icon('x')));
|
|
530
564
|
}
|
|
531
565
|
function paintOV() {
|
|
@@ -557,6 +591,8 @@ export function createDeck(o) {
|
|
|
557
591
|
state: S,
|
|
558
592
|
node(api) { S.api = api; if (!S.loaded || !S.cyc || !S.cyc.designed) return null; paint(); return root; },
|
|
559
593
|
tools() { return S.loaded && S.cyc && S.cyc.designed && isDeck() ? [btn({ class: 'ib sm', 'aria-label': 'Present', title: 'Present', 'data-rd-act': 'present-top', onclick: () => present('pres') }, icon('play', 'sm'))] : []; },
|
|
560
|
-
present, reload: load, close: closeOV,
|
|
594
|
+
present, reload: load, close: closeOV, audience: audienceView, openAudience,
|
|
595
|
+
/** Move to slide j (the audience window follows the presenter this way). opt.quiet: do not tell the other window. */
|
|
596
|
+
goTo(j, opt) { if (OV) ovGo(j, opt); else S.cur = Math.max(0, Math.min(count() - 1, j)); },
|
|
561
597
|
};
|
|
562
598
|
}
|
|
@@ -60,3 +60,8 @@
|
|
|
60
60
|
|
|
61
61
|
.c3 .rs-min0 { min-width: 0; }
|
|
62
62
|
.c3 .rs-kv .chip2 + .rs-help { margin-left: 6px; }
|
|
63
|
+
|
|
64
|
+
/* report-structure-gaps: a report whose draft a session is making (Drafting…), waiting for a check, or whose session ended */
|
|
65
|
+
.c3 .rs-dr { display: grid; gap: 4px; }
|
|
66
|
+
.c3 .rs-dot { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: currentColor; animation: pmblink 1.4s step-end infinite; }
|
|
67
|
+
@media (prefers-reduced-motion: reduce) { .c3 .rs-dot { animation: none; } }
|
|
@@ -224,16 +224,34 @@ export function mountReportSetup(host, ctx) {
|
|
|
224
224
|
toast('Drafting ' + s.name + ' now. Nothing is sent.');
|
|
225
225
|
draftNow(s).then((nd) => { if (nd) openReportDoc(nd); });
|
|
226
226
|
}
|
|
227
|
+
/** The line under a report that is being drafted by a session (report-structure-gaps), or that lost its session. */
|
|
228
|
+
function draftChips(s, dr) {
|
|
229
|
+
const d = s.draft;
|
|
230
|
+
if (dr && dr.state === 'drafting') return el('div', { class: 'rs-dr', 'data-rs-drafting': '' },
|
|
231
|
+
el('div', { class: 'chips' }, el('span', { class: 'tag acc' }, el('span', { class: 'rs-dot', 'aria-hidden': 'true' }), 'Drafting…'), chip(icon('check', 'sm'), 'Following saved structure' + (dr.saved != null ? ' S' + dr.saved : '')), chip(icon('clock', 'sm'), 'Started ' + timeText(dr.since))),
|
|
232
|
+
el('div', { class: 'dim rs-help' }, 'Reading the sources and working out the charts. This takes a few minutes. You can leave this page.'));
|
|
233
|
+
if (dr && dr.state === 'held') return el('div', { class: 'rs-dr', 'data-rs-drafting': 'held' },
|
|
234
|
+
el('div', { class: 'chips' }, el('span', { class: 'tag warn' }, 'Waiting for a check'), (dr.sources || []).map((n) => chip(icon('shield', 'sm'), n + ' needs you')), chip(icon('clock', 'sm'), 'Drafts without it after 4 hours')),
|
|
235
|
+
el('div', { class: 'dim rs-help' }, 'The draft starts when the check passes, or after 4 hours without that source. Open Shape the report to check it again.'));
|
|
236
|
+
if (d && d.state === 'ended') return el('div', { class: 'rs-dr', 'data-rs-drafting': 'ended' },
|
|
237
|
+
el('div', { class: 'chips' }, el('span', { class: 'tag bad' }, 'Draft did not finish')), el('div', { class: 'dim rs-help' }, d.reason || 'The drafting session ended before a draft was written.'));
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
const timeText = (iso) => { const t = new Date(iso); return Number.isNaN(t.getTime()) ? '' : String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0'); };
|
|
227
241
|
function setupRow(s) {
|
|
228
242
|
const paused = s.status === 'paused'; const cy = docsOf(RS.docs, s.id); const n = s.cycles != null ? s.cycles : cy.length; const open = RS.cyc.has(s.id);
|
|
229
|
-
|
|
243
|
+
const dr = s.draft && (s.draft.state === 'drafting' || s.draft.state === 'held') ? s.draft : null;
|
|
244
|
+
return el('li', { class: 'nrow', 'data-rs-reg': s.id, 'data-rs-draft': dr ? dr.state : (s.draft ? s.draft.state : '') }, el('span', { class: 'ki' }, icon('report')),
|
|
230
245
|
el('div', { class: 'nb' }, btn({ class: 'tt', 'data-rs-act': 'open-reg', onclick: () => openSetup(s) }, s.name),
|
|
246
|
+
draftChips(s, dr),
|
|
231
247
|
el('div', { class: 'chips' }, RS.newIds.has(s.id) ? el('span', { class: 'tag acc' }, 'New') : null, srcChip(s.source), outChip(s.output), copyChip(s.output, s.copy), paused ? el('span', { class: 'tag warn' }, 'Paused') : null,
|
|
232
248
|
chip(icon('cal', 'sm'), s.label || scheduleLabel(s.schedule)), paused ? null : chip(icon('clock', 'sm'), 'Next draft ' + (s.next_draft ? dayText(s.next_draft) : 'not set yet')),
|
|
233
249
|
chip(icon('mail', 'sm'), 'Last issued ' + (s.last_issued ? dayText(s.last_issued) : 'not yet'))),
|
|
234
250
|
open ? cycRows(s) : null),
|
|
235
|
-
el('div', { class: 'acts' },
|
|
251
|
+
el('div', { class: 'acts' }, dr ? pbtn('outline', 'Drafting…', 'spark', { 'data-rs-act': 'draft-now', disabled: true, title: dr.state === 'held' ? 'The draft waits for a check of a source.' : 'A draft is being made. Wait for it to land.' })
|
|
252
|
+
: cy.length || paused || s.source !== 'work' ? pbtn('outline', 'Open', 'out', { 'data-rs-act': 'open-reg', onclick: () => openSetup(s) })
|
|
236
253
|
: pbtn('outline', 'Draft it now', 'spark', { 'data-rs-act': 'draft-now', onclick: () => openSetup(s) }),
|
|
254
|
+
dr && dr.state === 'drafting' && dr.session ? pbtn('ghost', 'Watch the session', 'eye', { 'data-rs-act': 'watch-draft', onclick: () => import('./c3-session-term.js').then((m) => m.openSessionTerm({ id: dr.session, name: 'Draft ' + s.name })).catch(() => {}) }) : null,
|
|
237
255
|
s.source === 'work' ? pbtn('ghost', 'Shape the report', 'todo', { 'data-rs-act': 'shape', onclick: () => openStructure({ ws: RS.ws, id: s.id, name: s.name }) }) : null,
|
|
238
256
|
s.source === 'work' ? pbtn('ghost', 'Design how it looks', 'spark', { 'data-rs-act': 'design', onclick: () => openStudio({ ws: RS.ws, id: s.id, name: s.name }) }) : null,
|
|
239
257
|
pbtn('ghost', 'All cycles (' + n + ')', 'fold', { 'data-rs-act': 'cyc', 'aria-expanded': String(open), onclick: () => { if (RS.cyc.has(s.id)) RS.cyc.delete(s.id); else RS.cyc.add(s.id); drawSetups(); } }),
|
|
@@ -69,13 +69,16 @@ export function normProposal(p) {
|
|
|
69
69
|
fixes: Array.isArray(q.fixes) ? q.fixes : [], sections: Array.isArray(q.sections) ? q.sections : [], sources: Array.isArray(q.sources) ? q.sources : [], found: Array.isArray(q.found) ? q.found : [] };
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/** The setups whose draft a session is making (report-structure-gaps): a draft that follows a saved structure, or one held for a check. */
|
|
73
|
+
export const draftingSetups = () => (RS.setups || []).filter((x) => x.draft && (x.draft.state === 'drafting' || x.draft.state === 'held'));
|
|
74
|
+
|
|
72
75
|
/* ── the state ── */
|
|
73
76
|
export const RS = { ws: '', setups: [], findings: [], asks: [], finder: null, docs: [], loaded: false, err: '', hidden: new Set(), newIds: new Set(), askText: '', docF: { state: 'all', src: 'all' }, cyc: new Set(),
|
|
74
77
|
edit: {}, chg: {}, openFinding: null, names: (k) => k };
|
|
75
78
|
const subs = new Set(); let timer = null; let inflight = null;
|
|
76
79
|
export const onReports = (fn) => { subs.add(fn); return () => { subs.delete(fn); if (!subs.size) stopPoll(); }; };
|
|
77
80
|
const emit = () => { for (const f of [...subs]) { try { f(); } catch { /* one view's error is its own */ } } };
|
|
78
|
-
const running = () => RS.asks.some((a) => a.state === 'running');
|
|
81
|
+
const running = () => RS.asks.some((a) => a.state === 'running') || draftingSetups().length > 0;
|
|
79
82
|
function stopPoll() { if (timer) { clearInterval(timer); timer = null; } }
|
|
80
83
|
function poll() { if (!timer && running() && subs.size) timer = setInterval(() => { if (!running() || !subs.size) { stopPoll(); return; } loadReports(RS.ws, true); }, 2500); }
|
|
81
84
|
|
|
@@ -88,11 +91,24 @@ export function loadReports(ws, force) {
|
|
|
88
91
|
inflight = null;
|
|
89
92
|
if (a && a.ok !== false && Array.isArray(a.setups)) { RS.setups = a.setups; RS.findings = a.findings || []; RS.asks = a.asks || []; RS.finder = a.finder || RS.finder; RS.err = ''; } else RS.err = (a && (a.error || a.reason)) || 'The report set-ups could not be read.';
|
|
90
93
|
if (b && b.ok !== false && Array.isArray(b.docs)) RS.docs = b.docs; else if (!RS.err) RS.err = (b && (b.error || b.reason)) || 'The report documents could not be read.';
|
|
91
|
-
RS.loaded = true; emit(); poll(); return RS;
|
|
94
|
+
RS.loaded = true; landedDrafts(); emit(); poll(); return RS;
|
|
92
95
|
});
|
|
93
96
|
inflight = run; return run;
|
|
94
97
|
}
|
|
95
98
|
|
|
99
|
+
/** A draft a session was making is no longer drafting: say it is ready (with Open), or why it ended. Remembers the names between reads. */
|
|
100
|
+
const wasDrafting = new Map();
|
|
101
|
+
function landedDrafts() {
|
|
102
|
+
const now = new Map(draftingSetups().map((x) => [x.id, x.name]));
|
|
103
|
+
for (const [id, name] of wasDrafting) {
|
|
104
|
+
if (now.has(id)) continue;
|
|
105
|
+
const st = (RS.setups || []).find((x) => x.id === id);
|
|
106
|
+
if (st && st.draft && st.draft.state === 'ended') toast({ text: 'The draft of ' + name + ' did not finish. ' + (st.draft.reason || ''), tone: 'error', timeout: 8000 });
|
|
107
|
+
else openDraftToast(id, 'Draft ready. ' + name + '. Nothing is sent.');
|
|
108
|
+
}
|
|
109
|
+
wasDrafting.clear(); for (const [id, name] of now) wasDrafting.set(id, name);
|
|
110
|
+
}
|
|
111
|
+
|
|
96
112
|
/* ── the presses ── */
|
|
97
113
|
const post = (path, body) => Promise.resolve(pmPost('/api/report-setups/' + path, { ws: RS.ws, ...body })).catch((e) => ({ ok: false, error: String((e && e.message) || e) }));
|
|
98
114
|
const fail = (r, what) => toast({ text: what + ': ' + ((r && (r.error || r.reason)) || 'the workspace did not answer') + '.', tone: 'error', timeout: 6000 });
|
|
@@ -127,6 +143,12 @@ function openDraftToast(id, text) {
|
|
|
127
143
|
/** Draft it now: the next cycle of a set-up report. → the new draft document, or null. */
|
|
128
144
|
export async function draftNow(setup) {
|
|
129
145
|
const r = await post('draft', { id: setup.id }); if (!r || !r.ok) { fail(r, 'It was not drafted'); return null; }
|
|
146
|
+
// A report with a saved structure is drafted by a session: the answer is "started". The row shows Drafting… and the poll announces the file.
|
|
147
|
+
if (r.drafting) {
|
|
148
|
+
toast(r.held ? 'The draft of ' + setup.name + ' waits for a check of a source. It starts when the check passes.' : 'Drafting ' + setup.name + ' now. It follows the saved structure. Nothing is sent.');
|
|
149
|
+
await loadReports(RS.ws, true);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
130
152
|
await loadReports(RS.ws, true);
|
|
131
153
|
return (RS.docs || []).find((x) => x.setup === setup.id && x.slot === r.drafted.slot) || null;
|
|
132
154
|
}
|
|
@@ -38,3 +38,7 @@
|
|
|
38
38
|
.c3 .rs-st .rd-dks-chat { position: static; max-height: none; }
|
|
39
39
|
.c3 .rs-st .rd-dks-log { max-height: 420px; }
|
|
40
40
|
}
|
|
41
|
+
|
|
42
|
+
/* report-structure-gaps: a source row while Test pull reads it (the same dot as a report row that is being drafted) */
|
|
43
|
+
.c3 .rs-dot { display: inline-block; width: 6px; height: 6px; margin-right: 6px; border-radius: 50%; background: currentColor; animation: pmblink 1.4s step-end infinite; }
|
|
44
|
+
@media (prefers-reduced-motion: reduce) { .c3 .rs-dot { animation: none; } }
|
|
@@ -34,6 +34,7 @@ export const RSX = {
|
|
|
34
34
|
remove: (b) => post('report-structure/remove', b),
|
|
35
35
|
check: (b) => post('report-structure/check', b),
|
|
36
36
|
runDry: (b) => post('report-structure/dry', b),
|
|
37
|
+
pull: (b) => post('report-structure/pull', b),
|
|
37
38
|
};
|
|
38
39
|
const failed = (r, what) => toast({ text: what + ': ' + ((r && (r.error || r.reason)) || 'the workspace did not answer') + '.', tone: 'error', timeout: 6000 });
|
|
39
40
|
|
|
@@ -89,6 +90,10 @@ export function openStructure({ ws, id, name, view, onClose } = {}) {
|
|
|
89
90
|
const running = () => !!(S.D && S.D.running);
|
|
90
91
|
const dryRunning = () => running() && /dry/.test(JSON.stringify(S.D.job || ''));
|
|
91
92
|
const checksRunning = () => Object.values((S.D && S.D.checks) || {}).some((c) => c && c.running);
|
|
93
|
+
const pulling = (sid) => !!(S.D && Array.isArray(S.D.pulling) && S.D.pulling.includes(sid));
|
|
94
|
+
const pullsRunning = () => !!(S.D && Array.isArray(S.D.pulling) && S.D.pulling.length);
|
|
95
|
+
const pullOf = (sid) => (S.D && S.D.pulls && S.D.pulls[sid]) || null;
|
|
96
|
+
const localHm = (iso) => { const t = new Date(iso); return Number.isNaN(t.getTime()) ? '' : String(t.getHours()).padStart(2, '0') + ':' + String(t.getMinutes()).padStart(2, '0'); };
|
|
92
97
|
const scrollLog = () => requestAnimationFrame(() => { const l = body.querySelector('.rd-dks-log'); if (l) l.scrollTop = l.scrollHeight; });
|
|
93
98
|
|
|
94
99
|
/* follow: show the current version when it changed (a job landed). A read that changed nothing paints nothing: the poll reads the structure
|
|
@@ -100,7 +105,7 @@ export function openStructure({ ws, id, name, view, onClose } = {}) {
|
|
|
100
105
|
const prev = S.D ? S.D.current : null;
|
|
101
106
|
S.D = r.structure; S.setup = r.setup || { id, name };
|
|
102
107
|
if (K.ver == null || (S.D.current != null && !ver(K.ver)) || (follow && S.D.current !== prev && S.D.current != null)) K.ver = S.D.current;
|
|
103
|
-
if (running() || checksRunning()) poll(); else clearTimeout(S.timer);
|
|
108
|
+
if (running() || checksRunning() || pullsRunning()) poll(); else clearTimeout(S.timer);
|
|
104
109
|
await Promise.all([need(K.ver), need(K.ver > 1 ? K.ver - 1 : null), readDry(), readCharts()]);
|
|
105
110
|
paint(false);
|
|
106
111
|
}
|
|
@@ -255,9 +260,10 @@ export function openStructure({ ws, id, name, view, onClose } = {}) {
|
|
|
255
260
|
S.dry = null; await load(true);
|
|
256
261
|
}
|
|
257
262
|
async function testPull(s) {
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
263
|
+
// Test pull reads the one source into its row. It is not a turn: no line in the conversation, no new version (report-structure-gaps).
|
|
264
|
+
const r = await RSX.pull({ ws, id, source: s.id });
|
|
265
|
+
if (!r || !r.ok) { failed(r, 'The test pull did not start'); return; }
|
|
266
|
+
toast('PMOS is reading ' + s.name + '. The answer shows in its row.'); await load(true);
|
|
261
267
|
}
|
|
262
268
|
function openIt(s) {
|
|
263
269
|
closeSheet();
|
|
@@ -273,15 +279,23 @@ export function openStructure({ ws, id, name, view, onClose } = {}) {
|
|
|
273
279
|
const uses = (p.charts || []).filter((c) => c.source === s.id).length; const pm = s.group === 'pmos';
|
|
274
280
|
const [tone, word] = STATE_TAG[s.state] || ['', s.state];
|
|
275
281
|
const eye = s.kind === 'chrome' || s.kind === 'computer';
|
|
282
|
+
const pl = !pm && !eye ? pullOf(s.id) : null; const plNow = pl && pl.n === K.ver ? pl : null; const busyPull = pulling(s.id);
|
|
276
283
|
const act = pm ? pbtn('ghost', 'Open', 'out', { 'data-st-act': 'openpm', 'data-v': s.id, onclick: () => openIt(s) })
|
|
277
284
|
: eye ? pbtn('ghost', 'Check it works', 'shield', { 'data-st-act': 'chkopen', 'data-v': s.id, onclick: () => check(s.id) })
|
|
278
|
-
: s.state === 'reached' ? pbtn('ghost', 'Test pull', 'play', { 'data-st-act': 'test', 'data-v': s.id, onclick: () => testPull(s) })
|
|
285
|
+
: s.state === 'reached' ? pbtn('ghost', busyPull ? 'Reading…' : pl ? 'Test pull again' : 'Test pull', 'play', { 'data-st-act': 'test', 'data-v': s.id, disabled: busyPull, onclick: () => testPull(s) })
|
|
279
286
|
: pbtn('ghost', 'How to connect', 'info', { 'data-st-act': 'connect', 'data-v': s.id, onclick: () => toast('Connect ' + s.name + ' in your Claude connectors (Settings, Connectors), then come back and press Test pull. PMOS reads it read only.') });
|
|
280
287
|
const li = el('li', { class: 'nrow', 'data-st-src': s.id }, el('span', { class: 'ki' }, icon(KIND_IC[s.kind] || 'doc')),
|
|
281
288
|
el('div', { class: 'nb' }, el('span', { class: 'tt' }, s.name),
|
|
282
289
|
el('div', { class: 'chips' }, el('span', { class: 'tag' + (tone ? ' ' + tone : ''), 'data-st-state': s.state }, word), s.via ? chip(icon(s.state === 'reached' ? 'lock' : 'info', 'sm'), s.via) : null,
|
|
290
|
+
busyPull ? el('span', { class: 'tag acc', 'data-st-pulling': '' }, el('span', { class: 'rs-dot', 'aria-hidden': 'true' }), 'Reading…') : null,
|
|
291
|
+
plNow && !busyPull ? el('span', { class: 'tag ' + (plNow.ok ? 'ok' : 'bad'), 'data-st-pulled': plNow.ok ? 'ok' : 'none' }, icon(plNow.ok ? 'check' : 'alert', 'sm'), plNow.ok ? 'Pulled ' + localHm(plNow.at) : 'Returned nothing') : null,
|
|
292
|
+
plNow && !busyPull && plNow.ok ? chip(icon('cal', 'sm'), plNow.period) : null, plNow && !busyPull && !plNow.ok ? chip(icon('clock', 'sm'), 'Tried ' + localHm(plNow.at)) : null,
|
|
293
|
+
plNow && !busyPull && plNow.ok ? chip(icon('report', 'sm'), plNow.got + ' of ' + plNow.charts + (plNow.charts === 1 ? ' chart returned a number' : ' charts returned a number')) : null,
|
|
283
294
|
uses ? chip(icon('report', 'sm'), uses + (uses === 1 ? ' chart' : ' charts')) : null, eye ? chip(icon('clock', 'sm'), 'Re-checked 1 hour before each draft') : null),
|
|
284
|
-
|
|
295
|
+
busyPull ? el('div', { class: 'dim rs-st-small', 'data-st-pullnote': '' }, 'Reading the last complete period for the ' + (uses === 1 ? 'chart' : uses + ' charts') + ' that use it. Read only.')
|
|
296
|
+
: plNow && !plNow.ok ? el('div', { class: 'dim rs-st-small', 'data-st-pullnote': '' }, plNow.reason || 'The source returned nothing.')
|
|
297
|
+
: !pm && !eye && s.state === 'reached' && !plNow ? el('div', { class: 'dim rs-st-small', 'data-st-pullnote': '' }, 'Not tried on this version.')
|
|
298
|
+
: s.use ? el('div', { class: 'dim rs-st-small' }, s.use) : null),
|
|
285
299
|
el('div', { class: 'acts' }, act, pbtn('ghost', 'Remove', 'trash', { 'data-st-act': 'dropsrc', 'data-v': s.id, onclick: () => drop('source', s.id, s.name) })));
|
|
286
300
|
return li;
|
|
287
301
|
}
|
|
@@ -298,8 +312,12 @@ export function openStructure({ ws, id, name, view, onClose } = {}) {
|
|
|
298
312
|
}
|
|
299
313
|
function chartDetail(c, p) {
|
|
300
314
|
const dv = S.dry && S.dry.n === K.ver ? (S.dry.values || []).find((v) => v.chart === c.id) : null;
|
|
301
|
-
const
|
|
302
|
-
|
|
315
|
+
const sp = pullOf(c.source); const pv = sp && sp.n === K.ver ? (sp.values || []).find((v) => v.chart === c.id) : null;
|
|
316
|
+
const srcName = ((planShown() || {}).sources || []).find((x) => x.id === c.source);
|
|
317
|
+
const val = (v) => [el('b', null, v.value), ' ', el('span', { class: 'dim' }, v.was ? 'was ' + v.was : ''), ' ', v.delta ? el('span', { class: 'chip2 mono' }, v.delta) : null, ' ', v.flag ? el('span', { class: 'tag warn' }, 'Flagged') : null];
|
|
318
|
+
const tp = pv ? [...val(pv), el('br'), el('span', { class: 'dim rs-st-small', 'data-st-pulled-from': '' }, 'Pulled from ' + (srcName ? srcName.name : 'the source') + ' at ' + localHm(sp.at) + ', ' + sp.period + '. Not saved in the report.')]
|
|
319
|
+
: dv ? val(dv)
|
|
320
|
+
: ['Not tried on this version. Press Test pull on its source, or Try it with real data.'];
|
|
303
321
|
const f = (lab, ...c2) => el('div', null, el('span', { class: 'dim mono' }, lab), ...c2);
|
|
304
322
|
return el('tr', { class: 'rs-st-det', 'data-st-detail': c.id }, el('td', { colspan: '5' }, el('div', { class: 'rs-st-detb' },
|
|
305
323
|
f('QUESTION IT ANSWERS', el('p', { 'data-st-q': '' }, c.question || '')), f('USED IN', el('p', { 'data-st-used': '' }, usersOf(c))),
|
|
@@ -612,6 +612,8 @@ async function boot() {
|
|
|
612
612
|
/* ?studio=<report id> opens that report's design studio over the Reports tab (the PMOS Chrome extension's Open the studio). */
|
|
613
613
|
if (q.get('studio') && /^[a-z][a-z0-9-]{0,39}$/.test(q.get('studio'))) { if (!start) start = 'rep'; import('./c3-design-studio.js').then((m) => m.openStudio({ ws: S.ws, id: q.get('studio'), name: '' })).catch(() => { /* the studio is not available */ }); }
|
|
614
614
|
/* ?structure=<report id> opens that report's Structure step (the studio's step 1) over the Reports tab. */
|
|
615
|
+
// ?replay=<note id> opens the meeting replay of that note (initiative meeting-material-replay-gaps). An id that is not a note id opens the replay page's own error, and makes no request.
|
|
616
|
+
if (q.get('replay')) import('./c3-replay.js').then((m) => m.openReplay(m.NOTE_ID_RE.test(q.get('replay')) ? { note: q.get('replay') } : { note: '' })).catch(() => {});
|
|
615
617
|
if (q.get('structure') && /^[a-z][a-z0-9-]{0,39}$/.test(q.get('structure'))) { if (!start) start = 'rep'; import('./c3-report-structure.js').then((m) => m.openStructure({ ws: S.ws, id: q.get('structure'), name: '' })).catch(() => { /* the structure step is not available */ }); }
|
|
616
618
|
if (!S.userNav) showTab(start || 'brief', false); // a press during the read above (the gear, a tab) wins over the start page
|
|
617
619
|
new ResizeObserver(moveInd).observe(S.tabNav);
|
|
@@ -224,6 +224,7 @@ async function loadOpen(inst) {
|
|
|
224
224
|
inst.note = r && r.ok ? r.note : null;
|
|
225
225
|
inst.rec = r && r.ok ? r.recording || null : null;
|
|
226
226
|
inst.shots = r && r.ok && r.screen ? r.screen.frames || [] : [];
|
|
227
|
+
inst.aside = r && r.ok && r.screen ? r.screen.aside || 0 : 0;
|
|
227
228
|
drawRight(inst);
|
|
228
229
|
}
|
|
229
230
|
/** The replay of the note's kept audio: one <audio> per note, kept across redraws so playback goes on. Null when no audio is kept. */
|
|
@@ -282,7 +283,7 @@ function drawRight(inst, force) {
|
|
|
282
283
|
const n = inst.note;
|
|
283
284
|
const live = dock.recordState();
|
|
284
285
|
const held = !!n && live.mode !== 'idle' && live.id === n.id;
|
|
285
|
-
const sig = JSON.stringify([inst.open, n && [n.updated_at, n.title, n.final_notes && n.final_notes.at, (n.transcript || []).length, (n.highlights || []).length, n.agent && n.agent.markdown, iniName(n.initiative), n.state, n.resume_until, n.ended_at, (n.sessions || []).length, n.meeting && n.meeting.context && n.meeting.context.taken_at], inst.rec && [inst.rec.bytes, inst.rec.cleaned], (inst.shots || []).length, live.mode, live.id, !!inst.rows, inst.ctxOpen, inst.info]);
|
|
286
|
+
const sig = JSON.stringify([inst.open, n && [n.updated_at, n.title, n.final_notes && n.final_notes.at, (n.transcript || []).length, (n.highlights || []).length, n.agent && n.agent.markdown, iniName(n.initiative), n.state, n.resume_until, n.ended_at, (n.sessions || []).length, n.meeting && n.meeting.context && n.meeting.context.taken_at], inst.rec && [inst.rec.bytes, inst.rec.cleaned], (inst.shots || []).length, inst.aside || 0, live.mode, live.id, !!inst.rows, inst.ctxOpen, inst.info]);
|
|
286
287
|
// a held note draws again on every render: the clock, the lines and the paused minutes move. Nothing else draws without a change.
|
|
287
288
|
if (!force && !held && sig === inst.rightSig) return;
|
|
288
289
|
// a field being typed in is not redrawn under the cursor, except in the live block, whose fields are kept across redraws
|
|
@@ -417,7 +418,8 @@ function noteParts(inst, n, live) {
|
|
|
417
418
|
el('p', { class: 'nt-hint' }, 'Kept on this machine. Press a time in the discussion to play from there.')));
|
|
418
419
|
// the screenshots of the shared screen, on the same clock as the replay and the transcript (notes-screen-capture)
|
|
419
420
|
const shots = held || n.private ? [] : inst.shots || [];
|
|
420
|
-
|
|
421
|
+
const asideN = held || n.private ? 0 : inst.aside || 0;
|
|
422
|
+
if (shots.length || asideN) out.push(screenStrip({ id: n.id, frames: shots, aside: asideN, seek: replay ? (ms) => seekReplay(inst, ms) : null, replay }));
|
|
421
423
|
|
|
422
424
|
const cf = conflictsOf(n);
|
|
423
425
|
if (cf.length) {
|
|
@@ -23,7 +23,7 @@ import { pmGet, pmPost } from './http.js';
|
|
|
23
23
|
import { watchSig } from './sig-watch.js';
|
|
24
24
|
import { wsOf } from './data.js';
|
|
25
25
|
import { COPY, cadenceName } from './reports-copy.js';
|
|
26
|
-
import { openIssueSheet, openNotesSheet, draftNow, printReport, copyMarkdown, clearPrint, ensurePrintCss } from './report-view.js';
|
|
26
|
+
import { openIssueSheet, openNotesSheet, draftNow, watchDraft, printReport, copyMarkdown, clearPrint, ensurePrintCss } from './report-view.js';
|
|
27
27
|
import { openSlides, slideCards } from './report-slides.js';
|
|
28
28
|
/* The report families and the outbox load on demand: they are only drawn when the PM opens the toggle, and their module tree (the c3
|
|
29
29
|
* panels) must not weigh on the first paint of the Reports page. `lazy()` resolves both modules once. */
|
|
@@ -184,6 +184,14 @@ function draftCadence(inst) {
|
|
|
184
184
|
const knownCadences = (inst) => [...new Set([...inst.reports.map((r) => r.cadence), ...inst.schedules.map((s) => cadenceOfSchedule(s)).filter(Boolean)])].sort();
|
|
185
185
|
|
|
186
186
|
async function afterDraft(inst, r) {
|
|
187
|
+
// A draft a session is making (a report with a saved structure): hold the button as Drafting… and watch for the file.
|
|
188
|
+
if (r && r.ok && r.drafting) {
|
|
189
|
+
const cad = r.cadence || draftCadence(inst);
|
|
190
|
+
inst.drafting = inst.drafting || new Set(); inst.drafting.add(cad);
|
|
191
|
+
watchDraft({ ws: inst.ws, cadence: cad, onDone: async (o) => { inst.drafting.delete(cad); await refresh(inst); if (o.state === 'landed') toast({ text: COPY.draftLanded(cad), timeout: 6000 }); } });
|
|
192
|
+
await refresh(inst);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
187
195
|
if (r && r.ok && r.slot) inst.sel = (r.cadence || draftCadence(inst)) + ':' + r.slot;
|
|
188
196
|
await refresh(inst);
|
|
189
197
|
}
|
|
@@ -195,6 +203,7 @@ function primaryAction(inst) {
|
|
|
195
203
|
onClick: () => openIssueSheet({ ws: inst.ws, cadence: r.cadence, slot: r.slot, onDone: () => refresh(inst) }) });
|
|
196
204
|
}
|
|
197
205
|
const cadence = draftCadence(inst);
|
|
206
|
+
if (inst.drafting && inst.drafting.has(cadence)) return button({ label: COPY.draftingLabel, variant: 'primary', disabled: true, attrs: { 'data-rp-act': 'draft-now', 'data-rp-key': 'primary', 'data-rp-drafting': cadence } });
|
|
198
207
|
return button({ label: COPY.draftNow, variant: 'primary', attrs: { 'data-rp-act': 'draft-now', 'data-rp-key': 'primary' },
|
|
199
208
|
onClick: async () => afterDraft(inst, await draftNow({ ws: inst.ws, cadence })) });
|
|
200
209
|
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/* listen-sheet.js — the sheets Listen shows before it asks the browser to share (initiative meeting-material-replay, part 3).
|
|
2
|
+
*
|
|
3
|
+
* askListen({ shots }) → Promise<{ app, picture, shots } | null> "Where is the meeting?": Zoom app, Google Meet in Chrome or Another app,
|
|
4
|
+
* what PMOS listens to and sees, and Start listening or Sound only. null when the PM closes it.
|
|
5
|
+
* `shots: false` means Capture the shared screen is off in Settings: the sheet offers sound only.
|
|
6
|
+
* askWindow(getStream) → Promise<MediaStream | null> the second share: "Pick the meeting window". A browser lets a page share only
|
|
7
|
+
* right after a press, so the button here makes the call itself (getStream). null when skipped.
|
|
8
|
+
*
|
|
9
|
+
* The last choice is remembered in localStorage (listen-sources.js recall / remember), wrapped so a blocked store changes nothing.
|
|
10
|
+
* The words are the mockup's (46-capture.js). Nothing here sends anything: it only draws and answers.
|
|
11
|
+
*/
|
|
12
|
+
import { el } from './dom.js';
|
|
13
|
+
import { button } from './primitives.js';
|
|
14
|
+
import { openSheet, closeSheet } from './c3-sheet.js';
|
|
15
|
+
import { icon, sect, pbtn, plural } from './c3-ini-ui.js';
|
|
16
|
+
import { APPS, planListen, rowsOf, recall, remember } from './listen-sources.js';
|
|
17
|
+
import { ensureListenCss } from './notes-shots.js';
|
|
18
|
+
|
|
19
|
+
const store = () => { try { return window.localStorage; } catch { return null; } };
|
|
20
|
+
|
|
21
|
+
const tag = (t, cls) => el('span', { class: 'tag' + (cls ? ' ' + cls : '') }, t);
|
|
22
|
+
function row(r, act) {
|
|
23
|
+
return el('li', { class: 'nrow', 'data-ls-row': r.icon },
|
|
24
|
+
el('span', { class: 'ki' + (r.ok ? '' : ' hot') }, icon(r.icon)),
|
|
25
|
+
el('div', { class: 'nb' }, el('span', { class: 'tt' }, r.title), el('div', { class: 'chips' }, r.tags.map((t, k) => (k === 0 && r.ok ? el('span', { class: 'tag lsn-ok' }, icon('check', 'sm'), t) : tag(t, !r.ok && k === 0 ? 'lsn-warn' : ''))))),
|
|
26
|
+
act ? el('div', { class: 'acts' }, act) : el('span'));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function askListen({ shots = true } = {}) {
|
|
30
|
+
ensureListenCss();
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const c = recall(store());
|
|
33
|
+
if (!shots) c.shots = false;
|
|
34
|
+
let done = false;
|
|
35
|
+
const finish = (v) => { if (done) return; done = true; resolve(v); };
|
|
36
|
+
const body = el('div', { class: 'lsn', 'data-ls': 'listen' });
|
|
37
|
+
openSheet({ title: 'Listen to the meeting', icon: 'mic', wide: true, body, onClose: () => finish(null) });
|
|
38
|
+
const go = (withShots) => {
|
|
39
|
+
const choice = { app: c.app, picture: c.picture, shots: withShots };
|
|
40
|
+
remember(store(), { app: c.app, picture: c.picture, shots: true }); // the place, not the pictures: Sound only is for this meeting
|
|
41
|
+
finish(choice);
|
|
42
|
+
closeSheet();
|
|
43
|
+
};
|
|
44
|
+
const draw = () => {
|
|
45
|
+
const plan = planListen(c);
|
|
46
|
+
const meet = c.app === 'meet';
|
|
47
|
+
const seg = el('div', { class: 'seg', role: 'group', 'aria-label': 'Meeting app' }, APPS.map((a) => button({ label: a.label, variant: 'ghost', size: 'sm', onClick: () => { c.app = a.id; draw(); }, attrs: { 'aria-pressed': String(c.app === a.id), 'data-ls-app': a.id } })));
|
|
48
|
+
const rows = rowsOf(plan).map((r) => {
|
|
49
|
+
if (r.icon === 'eye' && !meet && c.shots) return row(r, pbtn('ghost', 'Change', null, { 'data-ls-pic': 'screen', onclick: () => { c.picture = 'screen'; draw(); } }));
|
|
50
|
+
if (r.icon === 'alert') return row(r, pbtn('outline', 'Use the meeting window', null, { 'data-ls-pic': 'window', onclick: () => { c.picture = 'window'; draw(); } }));
|
|
51
|
+
return row(r);
|
|
52
|
+
});
|
|
53
|
+
const n = plan.shares.length;
|
|
54
|
+
const help = !c.shots ? 'PMOS takes no pictures. It still hears the meeting.'
|
|
55
|
+
: meet ? 'With the PMOS extension in Chrome, Listen starts on the Meet tab in one click, with no sharing window.'
|
|
56
|
+
: c.picture === 'window' ? 'Pick the meeting window in the second sharing window. When you present, the call shows your slides, so the shots keep them too. Your other apps are never seen.'
|
|
57
|
+
: 'Shots of other apps are set aside after the meeting for you to keep or delete. They never reach the note, the replay or the agent.';
|
|
58
|
+
body.replaceChildren(
|
|
59
|
+
el('p', { class: 'dim cal3-note' }, 'The browser asks you to pick what to share' + (n > 1 ? ', once for each line below' : '') + '. Nothing leaves this machine.'),
|
|
60
|
+
sect({ ic: 'cal', title: 'Where is the meeting?', right: seg }),
|
|
61
|
+
sect({ ic: 'eye', title: 'What PMOS listens to and sees', right: el('span', { class: 'dim mono' }, plural(n, 'share')) },
|
|
62
|
+
el('ul', { class: 'list', role: 'list' }, ...rows), el('p', { class: 'dim lsn-help', 'data-ls-help': '' }, help)),
|
|
63
|
+
sect({ ic: 'clock', title: 'While it runs' },
|
|
64
|
+
el('ul', { class: 'list', role: 'list' },
|
|
65
|
+
row({ icon: 'stop', title: 'Pause shots', tags: ['stops the pictures, keeps the sound', 'on the REC chip and in Focus'], ok: false }),
|
|
66
|
+
row({ icon: 'lock', title: 'Private', tags: ['stops sound and pictures until you resume'], ok: false }))),
|
|
67
|
+
el('div', { class: 'iacts' },
|
|
68
|
+
pbtn('primary', 'Start listening', 'mic', { 'data-ls-go': 'start', onclick: () => go(c.shots) }),
|
|
69
|
+
shots ? pbtn('ghost', 'Sound only, no shots', null, { 'data-ls-go': 'sound', onclick: () => go(false) }) : null));
|
|
70
|
+
};
|
|
71
|
+
draw();
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The second share. The button makes the browser call itself, because that call needs a press of its own. */
|
|
76
|
+
export function askWindow(getStream) {
|
|
77
|
+
ensureListenCss();
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
let done = false;
|
|
80
|
+
const finish = (v) => { if (done) return; done = true; resolve(v); };
|
|
81
|
+
const msg = el('p', { class: 'dim lsn-help', role: 'status', 'data-ls-msg': '' }, 'The sound is on. Now pick the meeting\'s window, so PMOS can take shots of it only. Your other apps are never seen.');
|
|
82
|
+
const pick = pbtn('primary', 'Pick the meeting window', 'eye', { 'data-ls-pick': '', onclick: async () => {
|
|
83
|
+
pick.disabled = true;
|
|
84
|
+
let s = null;
|
|
85
|
+
try { s = await getStream(); } catch { s = null; }
|
|
86
|
+
if (!s) { msg.textContent = 'No window was picked. Pick it again, or listen to the sound only.'; pick.disabled = false; return; }
|
|
87
|
+
finish(s);
|
|
88
|
+
closeSheet();
|
|
89
|
+
} });
|
|
90
|
+
const body = el('div', { class: 'lsn', 'data-ls': 'window' }, msg,
|
|
91
|
+
el('div', { class: 'iacts' }, pick, pbtn('ghost', 'Sound only, no shots', null, { 'data-ls-skip': '', onclick: () => { finish(null); closeSheet(); } })));
|
|
92
|
+
openSheet({ title: 'Pick the meeting window', icon: 'eye', body, onClose: () => finish(null) });
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/* listen-sources.js — where the meeting is, and which browser shares Listen asks for (initiative meeting-material-replay, part 3).
|
|
2
|
+
*
|
|
3
|
+
* APPS the three places a meeting can be: Zoom app, Google Meet in Chrome, Another app
|
|
4
|
+
* normalize(choice) → { app, picture, shots } a choice with every field in its range
|
|
5
|
+
* planListen(choice) → plan the shares to ask for and what each one is used for
|
|
6
|
+
* surfaceOf(track, planned) → 'window' | 'browser' | 'monitor' | null what the picture really is
|
|
7
|
+
* recall(storage) · remember(storage, choice) the last choice, kept on this machine
|
|
8
|
+
* rowsOf(plan) → [{ icon, title, tags, ok }] the lines the Listen sheet shows
|
|
9
|
+
*
|
|
10
|
+
* WHY SEPARATE SHARES. Only the whole screen carries the sound of Zoom, but its picture holds every app the PM switches to. So for Zoom
|
|
11
|
+
* or another app the SOUND comes from one share of the whole screen with system audio (its picture is never used) and the PICTURE from a
|
|
12
|
+
* second share of the meeting's window only, which stays captured while the PM works in other apps. The second share needs its own press,
|
|
13
|
+
* and it can be skipped (sound only). Google Meet is a tab: one share of that tab carries both. The PM can still choose the whole screen
|
|
14
|
+
* as the picture; then the safety net in lib/notes-screen.js sets aside the shots that look like another app.
|
|
15
|
+
*
|
|
16
|
+
* Pure: no DOM and no network, so the unit test (listen-sources.test.js) imports it from node.
|
|
17
|
+
*/
|
|
18
|
+
export const APPS = [
|
|
19
|
+
{ id: 'zoom', label: 'Zoom app' },
|
|
20
|
+
{ id: 'meet', label: 'Google Meet in Chrome' },
|
|
21
|
+
{ id: 'other', label: 'Another app' },
|
|
22
|
+
];
|
|
23
|
+
export const KEY = 'pmos.listen.where';
|
|
24
|
+
const SURFACES = ['window', 'browser', 'monitor'];
|
|
25
|
+
const AUDIO = { echoCancellation: false, noiseSuppression: false, autoGainControl: false };
|
|
26
|
+
|
|
27
|
+
/** The three options of one choice, each in its range. The default is the Zoom app with the picture from its window. */
|
|
28
|
+
export function normalize(c) {
|
|
29
|
+
const o = c && typeof c === 'object' ? c : {};
|
|
30
|
+
return {
|
|
31
|
+
app: APPS.some((a) => a.id === o.app) ? o.app : 'zoom',
|
|
32
|
+
picture: o.picture === 'screen' ? 'screen' : 'window',
|
|
33
|
+
shots: o.shots !== false,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** getDisplayMedia options for one share. Chrome ignores a hint it does not know. */
|
|
38
|
+
export function shareOptions(role, app) {
|
|
39
|
+
if (role === 'picture') return { video: { displaySurface: 'window' }, audio: false, monitorTypeSurfaces: 'exclude', surfaceSwitching: 'exclude', selfBrowserSurface: 'exclude' };
|
|
40
|
+
if (app === 'meet') return { video: { displaySurface: 'browser' }, audio: { ...AUDIO }, monitorTypeSurfaces: 'exclude', surfaceSwitching: 'include', selfBrowserSurface: 'exclude' };
|
|
41
|
+
return { video: { displaySurface: 'monitor' }, audio: { ...AUDIO }, systemAudio: 'include', monitorTypeSurfaces: 'include', surfaceSwitching: 'include', selfBrowserSurface: 'exclude' };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The shares Listen asks for. Each share says its role ('sound' | 'picture' | 'both'), whether its picture and its sound are used,
|
|
45
|
+
* and whether the PM may skip it. `surface` is what the pictures show: the meeting's window, the Meet tab, the whole screen, or null (no pictures). */
|
|
46
|
+
export function planListen(choice) {
|
|
47
|
+
const c = normalize(choice);
|
|
48
|
+
const share = (role, keepVideo, keepAudio, optional = false) => ({ role, options: shareOptions(role, c.app), keepVideo, keepAudio, optional });
|
|
49
|
+
if (c.app === 'meet') return { ...c, shares: [share('both', c.shots, true)], surface: c.shots ? 'browser' : null };
|
|
50
|
+
if (!c.shots) return { ...c, shares: [share('sound', false, true)], surface: null };
|
|
51
|
+
if (c.picture === 'screen') return { ...c, shares: [share('both', true, true)], surface: 'monitor' };
|
|
52
|
+
return { ...c, shares: [share('sound', false, true), share('picture', true, false, true)], surface: 'window' };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** What the picture really is: the browser's own word for the share when it gives one, else what was asked for. Null when neither is known. */
|
|
56
|
+
export function surfaceOf(track, planned) {
|
|
57
|
+
let s = null;
|
|
58
|
+
try { s = track && typeof track.getSettings === 'function' ? track.getSettings().displaySurface : null; } catch { s = null; }
|
|
59
|
+
if (SURFACES.includes(s)) return s;
|
|
60
|
+
return SURFACES.includes(planned) ? planned : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The last choice, from a Storage (localStorage). Anything wrong with storage gives the default. */
|
|
64
|
+
export function recall(storage) {
|
|
65
|
+
try { const raw = storage && storage.getItem(KEY); return normalize(raw ? JSON.parse(raw) : null); } catch { return normalize(null); }
|
|
66
|
+
}
|
|
67
|
+
export function remember(storage, choice) {
|
|
68
|
+
try { if (!storage) return false; storage.setItem(KEY, JSON.stringify(normalize(choice))); return true; } catch { return false; }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The lines the sheet shows for a choice: what is heard and what is seen. */
|
|
72
|
+
export function rowsOf(plan) {
|
|
73
|
+
const p = plan && plan.shares ? plan : planListen(plan);
|
|
74
|
+
const win = p.app === 'zoom' ? 'Zoom Meeting' : 'meeting app\'s';
|
|
75
|
+
if (p.app === 'meet') {
|
|
76
|
+
return [{ icon: 'mic', title: p.shots ? 'Sound and picture: the Meet tab' : 'Sound: the Meet tab', tags: ['tab audio on', p.shots ? 'only this tab is seen' : 'no pictures'], ok: true }];
|
|
77
|
+
}
|
|
78
|
+
const sound = { icon: 'vol', title: 'Sound: Entire screen, with system audio', tags: ['hears every app, so ' + (p.app === 'zoom' ? 'Zoom' : 'your meeting app') + ' too', p.picture === 'screen' && p.shots ? 'its picture is kept' : 'its picture is not kept'], ok: true };
|
|
79
|
+
if (!p.shots) return [sound, { icon: 'eye', title: 'Picture: none', tags: ['sound only, no shots'], ok: true }];
|
|
80
|
+
if (p.picture === 'screen') return [sound, { icon: 'alert', title: 'Picture: Entire screen', tags: ['every app you switch to is on it', 'PMOS keeps only shots that look like the call'], ok: false }];
|
|
81
|
+
return [sound, { icon: 'eye', title: 'Picture: the ' + win + ' window', tags: ['only this window is seen', 'still captured while you are in other apps'], ok: true }];
|
|
82
|
+
}
|