create-pmos 0.504.0 → 0.505.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.504.0",
3
+ "version": "0.505.0",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -32,6 +32,19 @@
32
32
  "audit"
33
33
  ],
34
34
  "pmosChanges": [
35
+ {
36
+ "v": "0.505.0",
37
+ "at": "2026-11-03T10:44:31.649Z",
38
+ "f": 3,
39
+ "prs": [
40
+ {
41
+ "n": 1190,
42
+ "t": "Finish a recording from any tab that shows it",
43
+ "s": "Finish a recording from any tab that shows it",
44
+ "k": "fix"
45
+ }
46
+ ]
47
+ },
35
48
  {
36
49
  "v": "0.504.0",
37
50
  "at": "2026-11-03T05:59:07.712Z",
@@ -422,19 +435,6 @@
422
435
  "k": "new"
423
436
  }
424
437
  ]
425
- },
426
- {
427
- "v": "0.485.0",
428
- "at": "2026-11-02T02:54:02.793Z",
429
- "f": 3,
430
- "prs": [
431
- {
432
- "n": 1145,
433
- "t": "Task dates read through pmos (item 19); a report draft says why it ended (item 20)",
434
- "s": "Task dates read through pmos (item 19); a report draft says why it ended (item 20)",
435
- "k": "fix"
436
- }
437
- ]
438
438
  }
439
439
  ]
440
440
  }
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@c0e7ff36
1
+ kernel-source: pmos@dd7330f0
2
2
  assembled: 2026-11-03
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-11-03T05:59:04Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T10:44:28Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-11-03T05:59:04Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T10:44:28Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-11-03T05:59:04Z"}
5
+ generated: {by: process:create-pmos, at: "2026-11-03T10:44:28Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -1,4 +1,17 @@
1
1
  [
2
+ {
3
+ "v": "0.505.0",
4
+ "at": "2026-11-03T10:44:31.649Z",
5
+ "f": 3,
6
+ "prs": [
7
+ {
8
+ "n": 1190,
9
+ "t": "Finish a recording from any tab that shows it",
10
+ "s": "Finish a recording from any tab that shows it",
11
+ "k": "fix"
12
+ }
13
+ ]
14
+ },
2
15
  {
3
16
  "v": "0.504.0",
4
17
  "at": "2026-11-03T05:59:07.712Z",
@@ -66,7 +66,14 @@ The recording tab holds the recording and stays on the meeting. When you open Ho
66
66
  - **Not hearing the meeting**: an amber line with **Share again** shows when the microphone is heard and the meeting is not. The snippet on other pages says it too.
67
67
  - **The next meeting**: if you press **Record** on another meeting while one records, the recording tab asks once: **Stop Viewer sync and record Design review?**
68
68
 
69
- Only the recording tab controls the meeting. Every other page shows a snippet: the first row of the Live pill, the pinned row on Home, and the live meeting in the Viewer. A snippet shows the dot, the clock and the title. Press it to bring the recording tab forward. A snippet has no **Pause**, **Stop** or **Highlight**.
69
+ Every other page shows a snippet: the first row of the Live pill, the pinned row on Home, and the live meeting in the Viewer. A snippet shows the dot, the clock and the title. Press it to bring the recording tab forward. **Highlight** and **Pause** are only in the recording tab.
70
+
71
+ You can finish the meeting from any page that shows it:
72
+
73
+ - The Live pill's row and Home's pinned row have a red **Stop** icon beside them. Press it once and it turns solid red with **Press again to stop the recording**. Press it again within 4 seconds to stop. If you do not press again, it goes back.
74
+ - A meeting page that does not hold the recording shows **Recording in another tab.** with the red **Stop**. This happens in a second tab or after you reload the recording tab. **Stop** asks first.
75
+
76
+ When you stop from another page, the recording tab stops listening at once and shows the meeting's notes.
70
77
 
71
78
  If you close the recording tab, the browser asks first. If you leave, the meeting pauses. Press the snippet to open the tab again, then press **Resume**. On a phone, the recording view opens in the same tab.
72
79
 
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.504.0",
3
+ "version": "0.505.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.504.0",
9
+ "version": "0.505.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@anthropic-ai/sdk": "^1.3.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.504.0",
3
+ "version": "0.505.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -23,6 +23,14 @@
23
23
  .hr-rec-card:not(.paused) .hr-rdot::after { content: ""; position: absolute; inset: -1.5px; border-radius: inherit; background: var(--cz-dot-red); animation: hr-crit 2.4s ease-out infinite; pointer-events: none; }
24
24
  @keyframes hr-crit { 0% { transform: scale(1); opacity: .4; } 60% { transform: scale(2.2); opacity: 0; } 100% { transform: scale(2.2); opacity: 0; } }
25
25
  @media (prefers-reduced-motion: reduce) { .hr-rec-card .hr-rdot::after { animation: none; } }
26
+ /* the icon-only Stop beside the pinned row (finish from anywhere, 2026-11-03): quiet red square, solid red once pressed ("Press again") */
27
+ .hr-rec { display: flex; align-items: center; }
28
+ .hr-stop { flex: none; width: 40px; height: 40px; margin-right: 10px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--cz-red); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
29
+ .hr-stop svg { width: 16px; height: 16px; fill: currentColor; }
30
+ .hr-stop:hover { background: var(--cz-well); }
31
+ .hr-stop.armed { background: var(--cz-dot-red); color: var(--cz-onsolid); }
32
+ .hr-stop:disabled { opacity: .5; cursor: default; }
33
+ .hr-stop:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; }
26
34
 
27
35
  /* ── the Mentions Viewer: the round solid press and the 30-day question (kept from Mentions A+) ── */
28
36
  .hr-rp { width: 44px; height: 44px; border-radius: 999px; border: 0; background: var(--cz-solid); color: var(--cz-onsolid); display: inline-grid; place-items: center; cursor: pointer; flex: none; }
@@ -35,6 +35,16 @@
35
35
  .lv-row + .lv-row::before { content: ''; position: absolute; left: 38px; right: 12px; top: 0; height: 1px; background: var(--cz-hair); }
36
36
  .lv-row:hover { background: var(--cz-well); }
37
37
  .lv-row.cur { background: var(--cz-bluebg); }
38
+ /* the recording's row and its icon-only Stop (finish from anywhere, 2026-11-03): quiet until pressed once, then solid red ("Press again") */
39
+ .lv-rw { position: relative; display: flex; align-items: center; }
40
+ .lv-rw > .lv-row { flex: 1; min-width: 0; }
41
+ .lv-rw + .lv-row::before, .lv-row + .lv-rw::before { content: ''; position: absolute; left: 38px; right: 12px; top: 0; height: 1px; background: var(--cz-hair); }
42
+ .lv-stop { flex: none; width: 36px; height: 36px; margin-right: 4px; padding: 0; border: 0; border-radius: 50%; background: transparent; color: var(--cz-red); display: inline-flex; align-items: center; justify-content: center; cursor: pointer; }
43
+ .lv-stop svg { width: 16px; height: 16px; fill: currentColor; }
44
+ .lv-stop:hover { background: var(--cz-well); }
45
+ .lv-stop.armed { background: var(--cz-dot-red); color: var(--cz-onsolid); }
46
+ .lv-stop:disabled { opacity: .5; cursor: default; }
47
+ .lv-stop:focus-visible { outline: 2px solid var(--cz-blue); outline-offset: 2px; }
38
48
  .lv-mk { width: 24px; flex: none; display: inline-flex; justify-content: center; align-items: center; }
39
49
  .lv-t { flex: 1; min-width: 0; font-size: var(--fs-3); font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
40
50
  .lv-t small { display: block; font-size: var(--fs-2); font-weight: 600; color: var(--cz-ink3); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
@@ -16,10 +16,11 @@
16
16
  * opts.go(item) → true the page opens the item itself (Home: the session's chat, the meeting); else the row's link is followed
17
17
  * opts.open() → id the session open on this page now: its row is tinted
18
18
  * Reads GET /api/live every 10 s while the page is shown, and on the cz:live event (Home sends it after the PM opens or answers a
19
- * session); the recording comes from pm/rec-state.js. Nothing is written.
19
+ * session); the recording comes from pm/rec-state.js. The recording's row has one icon-only Stop beside it (Wawan, 2026-11-03: finish
20
+ * from anywhere): the first press asks "Press again", the second ends the meeting (rec-state.js stopPress). Nothing else is written.
20
21
  * Adler (batch 17 item 4-B, AdM1 "Live recording pill", AdH5): while a meeting records, his S face with headphones follows the clock, now in
21
22
  * the recording's row. The red dot stays: the face never replaces the row's mark. Paused, he is not shown (nobody is listening). */
22
- import { onRecording } from './rec-state.js';
23
+ import { onRecording, stopPress } from './rec-state.js';
23
24
  import { ownTab, bring } from './rec-tab-core.js';
24
25
  import { liveItems, keepOrder, pillLabel, rowLabel, clock, elapsed } from './live-model.js';
25
26
  import { wedgeHtml } from './ctx-wedge.js';
@@ -27,6 +28,7 @@ import { adler } from './cozy-adler.js'; // the same context pie as Home's row
27
28
 
28
29
  const esc = (v) => String(v == null ? '' : v).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
29
30
  const SHEET = new URL('./cozy-live-pill.css', import.meta.url).href;
31
+ const STOP = '<svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg>';
30
32
  const CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 12.5l4.5 4.5L19 7.5"/></svg>';
31
33
  const PHONE = '(max-width: 760px)';
32
34
  const mac = () => { try { return /Mac|iPhone|iPad/.test(navigator.platform || navigator.userAgent || ''); } catch { return false; } };
@@ -49,7 +51,11 @@ export function mountLivePill(doc = document, opts = {}) {
49
51
  nav.append(scrim, tray, pill);
50
52
  pill.addEventListener('click', () => set(!L.open));
51
53
  scrim.addEventListener('click', () => set(false));
52
- tray.addEventListener('click', (e) => { const a = e.target.closest('a[data-lv]'); if (a) go(a, e); });
54
+ tray.addEventListener('click', (e) => {
55
+ const s = e.target.closest('[data-lv-stop]');
56
+ if (s) { e.preventDefault(); const p = stopPress(s, s.dataset.lvStop); if (p) p.then((r) => { if (r && r.ok) set(false); }); return; }
57
+ const a = e.target.closest('a[data-lv]'); if (a) go(a, e);
58
+ });
53
59
  tray.addEventListener('keydown', keys);
54
60
  draw();
55
61
  return true;
@@ -77,11 +83,13 @@ export function mountLivePill(doc = document, opts = {}) {
77
83
  function rowsHtml(list) {
78
84
  return list.map((x, i) => {
79
85
  const was = L.marks.get(x.id), once = L.open && was && was !== x.mark;
80
- return `<a class="lv-row${x.open ? ' cur' : ''}" href="${esc(x.href)}" data-lv="${esc(x.id)}" aria-label="${esc(rowLabel(x))}" aria-keyshortcuts="${i + 1}">`
86
+ const row = `<a class="lv-row${x.open ? ' cur' : ''}" href="${esc(x.href)}" data-lv="${esc(x.id)}" aria-label="${esc(rowLabel(x))}" aria-keyshortcuts="${i + 1}">`
81
87
  + `<span class="lv-mk">${x.kind === 'rec' ? `<i class="lv-m rec${x.paused ? ' paused' : ''}"></i>` : markHtml(x.mark, once)}</span>`
82
88
  + `<span class="lv-t">${esc(x.name)}<small>${esc(x.sub)}</small></span>${wedgeHtml(x.ctx)}`
83
89
  + (x.kind === 'rec' && !x.paused ? `<span class="lv-ad">${adler('thinking', 18, { label: false, prop: 'headphones', id: 'lvrec' })}</span>` : '')
84
90
  + `<span class="lv-tm${x.kind === 'rec' && !x.paused ? ' rec' : x.mark === 'stop' ? ' stop' : ''}">${esc(x.time)}</span></a>`;
91
+ // the recording: one icon-only Stop beside its row, so the meeting can be finished from any page
92
+ return x.kind === 'rec' ? `<div class="lv-rw">${row}<button type="button" class="lv-stop" data-lv-stop="${esc(x.id.slice(4))}" aria-label="Stop the recording" title="Stop the recording">${STOP}</button></div>` : row;
85
93
  }).join('');
86
94
  }
87
95
  // the closed pill: the number of live items, and a red corner dot while a meeting records (grey while paused); no marks, no clock
@@ -103,7 +111,7 @@ export function mountLivePill(doc = document, opts = {}) {
103
111
  pill.setAttribute('aria-label', pillLabel(now, { mac: mac() }));
104
112
  pill.title = tipOf(now);
105
113
  const h = pillHtml(now); if (pill.__h !== h) { pill.__h = h; pill.innerHTML = h; }
106
- if (L.open) {
114
+ if (L.open && !tray.querySelector('.lv-stop.armed, .lv-stop:disabled')) { // a Stop waiting for its second press keeps the list as it is
107
115
  const f = doc.activeElement && tray.contains(doc.activeElement) ? doc.activeElement.dataset.lv : null;
108
116
  tray.innerHTML = rowsHtml(list);
109
117
  if (f) { const a = [...tray.querySelectorAll('a[data-lv]')].find((n) => n.dataset.lv === f); if (a) a.focus(); }
@@ -15,6 +15,8 @@
15
15
  * alert amber "Not hearing the meeting" with Share again, when the microphone is heard and the meeting is not.
16
16
  * ask Stop asks first. Record on the next meeting, pressed on another page, asks here once: "Stop X and record Y?".
17
17
  * Stop ends the meeting, and the tab then shows its notes (the Viewer, /premium-viewer.html?open=meeting).
18
+ * Finish from anywhere (Wawan, 2026-11-03): a tab that shows the meeting but does not hold the recorder ("Recording in another tab.", or the
19
+ * tab reloaded) still has the red Stop. The server ends the meeting, and the tab that holds the recorder hears 'ended' and goes to the notes.
18
20
  *
19
21
  * Slots: S2 and S3 load here if their files exist (a missing file is ignored):
20
22
  * cozy-rec-now.js mountNow({ stage, side, bar }, ctx) ctx = { ws, note(), mode(), on(evt, fn), focus(), event, rec }
@@ -118,7 +120,7 @@ function drawStage() {
118
120
  const left = S.row && S.row.resume_until ? Math.max(1, Math.ceil((Date.parse(S.row.resume_until) - Date.now()) / 60000)) : null;
119
121
  own.innerHTML = `<div class="rt-wait"><h2>Paused</h2><p>Nothing is kept until you resume${left ? '. ' + left + (left === 1 ? ' minute' : ' minutes') + ' left to resume' : ''}.</p></div>`; return;
120
122
  }
121
- if (S.mode === 'idle') { own.innerHTML = `<div class="rt-wait"><h2>${esc(title())}</h2><p>${esc(S.msg || (S.row && S.row.state === 'recording' ? 'Recording in another tab.' : 'Not recording yet.'))}</p>${S.row && S.row.state !== 'recording' ? btn('start', 'Start recording', 'rec', 'red big') : ''}</div>`; return; }
123
+ if (S.mode === 'idle') { own.innerHTML = `<div class="rt-wait"><h2>${esc(title())}</h2><p>${esc(S.msg || (S.row && S.row.state === 'recording' ? 'Recording in another tab.' : 'Not recording yet.'))}</p>${S.row && S.row.state !== 'recording' ? btn('start', 'Start recording', 'rec', 'red big') : S.row ? btn('stop', 'Stop the recording', 'stop', 'red big') : ''}</div>`; return; }
122
124
  own.innerHTML = '<div class="rt-wait"><h2>Stopped</h2><p>The notes are being written.</p></div>';
123
125
  }
124
126
  function drawLine() {
@@ -198,7 +200,7 @@ async function act(a, t) {
198
200
  case 'shots': { C.shots(!C.screen().paused); return draw(); }
199
201
  case 'stop': S.ask = { kind: 'stop' }; return drawAsk();
200
202
  case 'askno': S.ask = null; drawAsk(); return draw();
201
- case 'stopgo': S.ask = null; drawAsk(); return run(async () => { const r = await C.stop(ID); S.msg = r.ok ? '' : r.error; });
203
+ case 'stopgo': S.ask = null; drawAsk(); return run(async () => { const r = await C.stop(ID); S.msg = r.ok ? '' : r.error; if (r.ok) post({ id: ID, state: 'ended', title: title(), elapsedMs: elapsed(), silent: false }); });
202
204
  case 'switchgo': {
203
205
  const to = S.ask && S.ask.body; S.ask = null; drawAsk();
204
206
  return run(async () => {
@@ -215,6 +217,7 @@ document.addEventListener('click', (e) => { const t = e.target.closest('[data-a]
215
217
  document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && S.ask) { S.ask = null; drawAsk(); } });
216
218
 
217
219
  /* Record on the next meeting, pressed on another page while this one records: ask here once (rec-tab-core.js asks over the channel) */
220
+ listen((d) => { if (d.id === ID && d.state === 'ended' && !READING) { READING = true; read().catch(() => {}).finally(() => { READING = false; }); } }); // finished on another page
218
221
  listen((d) => { if (d.id === ID && d.switchTo && d.switchTo.body) { S.ask = { kind: 'switch', title: clip(d.switchTo.title || 'the next meeting', 80), body: d.switchTo.body }; drawAsk(); } });
219
222
 
220
223
  /* ── the slots: S2 (Now panel) and S3 (Present) mount here when their files are on the page ─────────────────── */
@@ -3,6 +3,8 @@
3
3
  * While a meeting records, a row sits above @Mentions: a red outline, a red dot, the meeting's title and "Recording · 12:40", and the
4
4
  * clock ticks. It stays while the PM opens other items. A press opens the live meeting (data-hm-recopen, which home-app.js already opens
5
5
  * in place). Paused: "Paused · 12:40", quieter, and the clock stands still. With no recording there is no row.
6
+ * Beside it, one icon-only Stop (Wawan, 2026-11-03: finish from anywhere): the first press asks "Press again", the second ends the meeting
7
+ * (rec-state.js stopPress), wherever the recorder runs.
6
8
  *
7
9
  * mountRecRow({ notes, refresh, redraw }) → { html() }
8
10
  * notes() the notes Home already reads (premium-meeting.js recCore().notes()), the fallback while rec-state.js is not on the page
@@ -10,7 +12,7 @@
10
12
  * redraw() Home draws its list again: called when the recording starts, pauses, resumes, stops or changes its title
11
13
  *
12
14
  * The state comes from rec-state.js (onRecording, the meetings workstream) when the page has it, else from the notes, read by the same rule
13
- * (a note recording or paused; meeting_ms is the meeting time heard). Nothing is written here. */
15
+ * (a note recording or paused; meeting_ms is the meeting time heard). Only the Stop writes (POST /api/notes/stop, through rec-state.js). */
14
16
  import { sheet } from './home-mentions.js';
15
17
 
16
18
  const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
@@ -36,7 +38,8 @@ export function recRowHtml(st, now = Date.now()) {
36
38
  return `<div class="hv-card hr-card hr-rec-card${st.paused ? ' paused' : ''}"><div class="hm-r hv-r hr-rec" data-id="rec:${esc(st.id)}">`
37
39
  + `<button type="button" class="hm-rm" data-hm-recopen="${esc(st.id)}" aria-label="${esc(st.title)}. ${word}. Open the meeting">`
38
40
  + `<span class="hv-mk"><span class="hr-rdot" aria-hidden="true"></span></span><span class="hm-rt"><b>${esc(st.title)}</b></span>`
39
- + `<span class="hv-st hr-rst">${word} · <span data-hr-clock>${clockOf(elapsed(st, now))}</span></span><span class="hv-xc" aria-hidden="true"></span></button></div></div>`;
41
+ + `<span class="hv-st hr-rst">${word} · <span data-hr-clock>${clockOf(elapsed(st, now))}</span></span><span class="hv-xc" aria-hidden="true"></span></button>`
42
+ + `<button type="button" class="hr-stop" data-hr-stop="${esc(st.id)}" aria-label="Stop the recording" title="Stop the recording"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/></svg></button></div></div>`;
40
43
  }
41
44
 
42
45
  export function mountRecRow({ notes, refresh, redraw } = {}) {
@@ -50,6 +53,12 @@ export function mountRecRow({ notes, refresh, redraw } = {}) {
50
53
  return st;
51
54
  };
52
55
  const state = () => live || fromNotes();
56
+ document.addEventListener('click', (e) => {
57
+ const b = e.target && e.target.closest ? e.target.closest('[data-hr-stop]') : null;
58
+ if (!b) return;
59
+ e.preventDefault(); e.stopPropagation();
60
+ import('./rec-state.js').then((M) => { const p = M.stopPress(b, b.dataset.hrStop); if (p) p.then((r) => { if (r && r.ok && refresh) Promise.resolve(refresh()).then(() => redraw && redraw()); }); }).catch(() => {});
61
+ }, true);
53
62
  const changed = (st) => { const k = st.on ? [st.id, st.paused, st.title].join('|') : ''; if (k === seen) return false; seen = k; return true; };
54
63
  import('./rec-state.js').then((M) => {
55
64
  M.onRecording((st) => { live = st && typeof st === 'object' ? st : null; if (changed(state()) && redraw) redraw(); });
@@ -4,6 +4,10 @@
4
4
  * recState({ fresh }) → Promise<State> the recording now, read from GET /api/notes (kept 5 s unless fresh)
5
5
  * onRecording(fn) → unsubscribe fn(State) now and on each change; while someone listens the page reads again every 10 s
6
6
  * announce() a page that just started, paused, resumed or stopped a recording asks for a fresh read
7
+ * finish(id) → { ok, error } end the meeting from any page (Wawan, 2026-11-03: "finish recording from anywhere").
8
+ * The server ends it; the tab that holds the recorder hears 'ended' and closes the mic.
9
+ * stopPress(el, id) → Promise|undefined the icon-only Stop on a snippet: the first press arms it (it asks "Press again to
10
+ * stop"), a second press within 4 s finishes the meeting
7
11
  * EVENT = 'cz:recording' the same State, as a CustomEvent on document, on each change
8
12
  * window.czRecording = { state: recState, on: onRecording }
9
13
  *
@@ -16,10 +20,10 @@
16
20
  * here this page holds the recorder (premium-meeting.js sets window.__premiumHolding), or is the recording tab
17
21
  * silent the recording tab hears the microphone but not the meeting ("Not hearing the meeting")
18
22
  * The recording tab also sends its state over a BroadcastChannel (rec-tab-core.js post) on each change and every 5 s: a page reads again
19
- * at once, so its snippet never runs a stale clock. The channel carries state only.
23
+ * at once, so its snippet never runs a stale clock. The channel carries state, and 'ended' when another page finished the meeting.
20
24
  * The answer comes from the notes the server keeps, so a page that did not start the recording still knows of it. Nothing is written. */
21
- import { pmGet } from './http.js';
22
- import { tabUrl, isTab, listen } from './rec-tab-core.js';
25
+ import { pmGet, pmPost } from './http.js';
26
+ import { tabUrl, isTab, listen, post } from './rec-tab-core.js';
23
27
 
24
28
  export const EVENT = 'cz:recording';
25
29
  const OFF = Object.freeze({ on: false, paused: false, id: '', title: '', startedAt: 0, elapsedMs: 0, readAt: 0, href: '', here: false, silent: false });
@@ -66,6 +70,28 @@ export function onRecording(fn) {
66
70
  /** A recording was started, paused, resumed or stopped on this page: read again now. */
67
71
  export function announce() { return recState({ fresh: true }); }
68
72
 
73
+ /** End the meeting from this page, whichever tab holds the recorder. */
74
+ export async function finish(id) {
75
+ if (!id) return { ok: false, error: 'No meeting is recording.' };
76
+ const r = await pmPost('/api/notes/stop', { id: String(id) });
77
+ const ok = !!(r && r.ok === true);
78
+ if (ok) post({ id: String(id), state: 'ended', title: '', elapsedMs: 0, silent: false }); // the recording tab stops listening at once
79
+ await announce();
80
+ return ok ? { ok: true } : { ok: false, error: (r && r.error) || 'The meeting was not stopped.' };
81
+ }
82
+ const ARM_MS = 4000;
83
+ export function stopPress(el, id) {
84
+ if (!el || el.disabled) return undefined;
85
+ const say = (t) => { el.setAttribute('aria-label', t); el.title = t; };
86
+ if (el.dataset.armed !== '1') {
87
+ el.dataset.armed = '1'; el.classList.add('armed'); say('Press again to stop the recording');
88
+ clearTimeout(el.__arm); el.__arm = setTimeout(() => { delete el.dataset.armed; el.classList.remove('armed'); say('Stop the recording'); }, ARM_MS);
89
+ return undefined;
90
+ }
91
+ clearTimeout(el.__arm); el.disabled = true;
92
+ return finish(id).then((r) => { if (!r.ok) { el.disabled = false; delete el.dataset.armed; el.classList.remove('armed'); say(r.error); } return r; });
93
+ }
94
+
69
95
  if (typeof window !== 'undefined' && !isTab()) listen((d) => { heard = { id: d.id, silent: !!d.silent }; recState({ fresh: true }); });
70
96
 
71
- if (typeof window !== 'undefined') window.czRecording = { state: recState, on: onRecording, EVENT };
97
+ if (typeof window !== 'undefined') window.czRecording = { state: recState, on: onRecording, finish, EVENT };