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 +14 -14
- package/payload/KIT-VERSION +1 -1
- 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/workspace/changelog.json +13 -0
- package/workspace/guide/notes.md +8 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/cozy-home-rows.css +8 -0
- package/workspace/public/pm/cozy-live-pill.css +10 -0
- package/workspace/public/pm/cozy-live-pill.js +13 -5
- package/workspace/public/pm/cozy-rec-tab.js +5 -2
- package/workspace/public/pm/home-rec-row.js +11 -2
- package/workspace/public/pm/rec-state.js +30 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "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
|
}
|
package/payload/KIT-VERSION
CHANGED
|
@@ -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-
|
|
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-
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-11-03T10:44:28Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
package/workspace/changelog.json
CHANGED
|
@@ -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",
|
package/workspace/guide/notes.md
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
9
|
+
"version": "0.505.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@anthropic-ai/sdk": "^1.3.0",
|
package/workspace/package.json
CHANGED
|
@@ -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.
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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) => {
|
|
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
|
-
|
|
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).
|
|
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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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
|
|
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
|
|
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 };
|