create-pmos 0.124.0 → 0.126.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/scripts/nine-stages.js +81 -0
- package/payload/scripts/workflow-state.js +2 -0
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/c3-one-home-test.js +131 -0
- package/workspace/fixtures/initiatives-api/list.json +171 -3
- package/workspace/fixtures/initiatives-api/roadmap.json +228 -4
- package/workspace/fixtures/initiatives-api/sheet.json +57 -1
- package/workspace/fixtures/one-home-baseline.json +83 -0
- package/workspace/guide/briefing-cards.md +40 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/guide/layout.md +2 -0
- package/workspace/lib/engine/nine-stages.js +81 -0
- package/workspace/lib/engine/workflow-state.js +2 -0
- package/workspace/lib/facts-async.js +51 -0
- package/workspace/lib/item-steps.js +52 -0
- package/workspace/lib/jira-status.js +8 -3
- package/workspace/lib/jobs-to-be-done.js +52 -0
- package/workspace/lib/needs-overdue.js +41 -0
- package/workspace/lib/outbox-store.js +87 -0
- package/workspace/lib/pm-items.js +115 -0
- package/workspace/lib/route-modules.js +21 -0
- package/workspace/lib/routes-briefing.js +109 -0
- package/workspace/lib/routes-c3-init.js +53 -0
- package/workspace/lib/routes-c3-life.js +119 -0
- package/workspace/lib/routes-c3-strategy.js +169 -0
- package/workspace/lib/routes-c3-timeline.js +191 -0
- package/workspace/lib/routes-calendar-import.js +60 -0
- package/workspace/lib/routes-calendar-views.js +61 -0
- package/workspace/lib/routes-doc-health.js +79 -0
- package/workspace/lib/routes-doc-sheet.js +169 -0
- package/workspace/lib/routes-forecast.js +85 -0
- package/workspace/lib/routes-hooks.js +121 -0
- package/workspace/lib/routes-initiatives.js +3 -0
- package/workspace/lib/routes-intake.js +24 -0
- package/workspace/lib/routes-outbox.js +97 -0
- package/workspace/lib/routes-pm-items.js +216 -0
- package/workspace/lib/routes-records.js +61 -0
- package/workspace/lib/routes-releases.js +129 -0
- package/workspace/lib/routes-report-facts.js +84 -0
- package/workspace/lib/routes-report-families.js +32 -0
- package/workspace/lib/routes-roadmap-ops.js +141 -0
- package/workspace/lib/routes-runs.js +119 -0
- package/workspace/lib/routes-signals.js +50 -0
- package/workspace/lib/run-answers.js +69 -0
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/c3.html +26 -0
- package/workspace/public/pm/briefing-deck.js +413 -0
- package/workspace/public/pm/briefing-model.js +211 -0
- package/workspace/public/pm/c3-breakdown.js +240 -0
- package/workspace/public/pm/c3-brief.js +278 -0
- package/workspace/public/pm/c3-cal-strategy.js +528 -0
- package/workspace/public/pm/c3-case.js +185 -0
- package/workspace/public/pm/c3-daypart.js +176 -0
- package/workspace/public/pm/c3-demo-brief-ix.js +350 -0
- package/workspace/public/pm/c3-demo-cal-ix.js +74 -0
- package/workspace/public/pm/c3-demo-cal2.js +66 -0
- package/workspace/public/pm/c3-demo-cal3.js +173 -0
- package/workspace/public/pm/c3-demo-data.js +560 -0
- package/workspace/public/pm/c3-demo-init.js +373 -0
- package/workspace/public/pm/c3-demo-life.js +77 -0
- package/workspace/public/pm/c3-demo-misc2.js +46 -0
- package/workspace/public/pm/c3-demo-plan2.js +30 -0
- package/workspace/public/pm/c3-demo-rep-ix.js +126 -0
- package/workspace/public/pm/c3-demo-rep2.js +92 -0
- package/workspace/public/pm/c3-demo-term.js +153 -0
- package/workspace/public/pm/c3-demo-time.js +49 -0
- package/workspace/public/pm/c3-demo-work-ix.js +80 -0
- package/workspace/public/pm/c3-demo-work2.js +13 -0
- package/workspace/public/pm/c3-demo.js +113 -0
- package/workspace/public/pm/c3-doc-sheet.js +314 -0
- package/workspace/public/pm/c3-dock.js +180 -0
- package/workspace/public/pm/c3-docs.js +290 -0
- package/workspace/public/pm/c3-expand.js +96 -0
- package/workspace/public/pm/c3-init-extras.js +170 -0
- package/workspace/public/pm/c3-lifecycle-panels.js +476 -0
- package/workspace/public/pm/c3-misc-panels.js +89 -0
- package/workspace/public/pm/c3-plan-panels.js +163 -0
- package/workspace/public/pm/c3-plan.js +192 -0
- package/workspace/public/pm/c3-roadmap.js +249 -0
- package/workspace/public/pm/c3-sheet.js +107 -0
- package/workspace/public/pm/c3-shell.js +674 -0
- package/workspace/public/pm/c3-signals.js +196 -0
- package/workspace/public/pm/c3-strategy-panels.js +45 -0
- package/workspace/public/pm/c3-swipe.js +230 -0
- package/workspace/public/pm/c3-terminal-data.js +26 -0
- package/workspace/public/pm/c3-terminal-edge.js +91 -0
- package/workspace/public/pm/c3-terminal-icons.js +41 -0
- package/workspace/public/pm/c3-terminal-results.js +141 -0
- package/workspace/public/pm/c3-terminal.js +52 -0
- package/workspace/public/pm/c3-tickets.js +163 -0
- package/workspace/public/pm/c3-timeline.js +201 -0
- package/workspace/public/pm/c3-toast.js +16 -0
- package/workspace/public/pm/calendar-views.js +1014 -0
- package/workspace/public/pm/console3-lift.md +220 -0
- package/workspace/public/pm/console3.css +1547 -0
- package/workspace/public/pm/dest-initiatives.js +16 -3
- package/workspace/public/pm/dest-mywork.js +8 -1
- package/workspace/public/pm/dest-reports.js +47 -1
- package/workspace/public/pm/dest-today.js +55 -1
- package/workspace/public/pm/doc-health-panel.js +110 -0
- package/workspace/public/pm/doc-rail.js +9 -3
- package/workspace/public/pm/lifecycle-lens-model.js +228 -0
- package/workspace/public/pm/lifecycle-lens.js +261 -0
- package/workspace/public/pm/new-initiative-sheet.js +116 -21
- package/workspace/public/pm/outbox-panel.js +125 -0
- package/workspace/public/pm/pm-work-view.js +895 -0
- package/workspace/public/pm/releases-view.js +248 -0
- package/workspace/public/pm/report-families-view.js +503 -0
- package/workspace/public/pm/run-card.js +244 -0
- package/workspace/public/pm/sessions-panel.js +149 -0
- package/workspace/public/pm/shell-bell.js +162 -0
- package/workspace/public/pm/shell-model.js +83 -0
- package/workspace/public/pm/shell-phone.js +124 -0
- package/workspace/public/pm/shell-readaloud.js +53 -0
- package/workspace/public/pm/shell-recording.js +110 -0
- package/workspace/public/pm/shell-ticker.js +90 -0
- package/workspace/public/pm/slide-confirm.js +163 -0
- package/workspace/public/pm/timeline-forecast-view.js +164 -0
- package/workspace/public/pm/today-day.js +8 -2
- package/workspace/public/pm/tokens.css +1 -0
- package/workspace/server.js +5 -2
- package/workspace/test-files.json +21 -3
- package/workspace/test-lists/browser-suites-green.json +5 -0
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/* pm/shell-model.js — the pure logic behind the shell pieces of console3 wave E2b (ticker, bell, banner, recording, read-aloud,
|
|
2
|
+
* haptics, phone tiles). No DOM, no network: scripts/tests/shell-model.test.js imports this file under plain node.
|
|
3
|
+
*
|
|
4
|
+
* tickerLines(briefing) the running-text lines, from the briefing's own `ticker` ([] when it has none)
|
|
5
|
+
* bellCount(briefing) how many cards the bell counts: the number of cards, nothing else
|
|
6
|
+
* bellItems(briefing) { key, tag, headline } for each card, in the engine's order
|
|
7
|
+
* bannerOf(briefing, seen) the pinned Critical card, or null; null too when its key is in `seen` (dismissed this session)
|
|
8
|
+
* hapticFor(kind, reduced) a vibration pattern (array of ms) for 'critical' | 'confirm' | 'tick'; null when reduced or unknown
|
|
9
|
+
* readText(card) what read-aloud says: the card's own tag, headline, facts and deadline text; absent fields dropped
|
|
10
|
+
* recView(state, reduced) what the recording group draws for a recorder state { mode: idle | recording | paused, title, started }
|
|
11
|
+
* clockOf(ms) m:ss or h:mm:ss
|
|
12
|
+
*
|
|
13
|
+
* Source: console3.html lines 2475 (buzz), 2478 (tickItems), 2518 to 2530 (listen), 4385 to 4400 (recCtl). The mockup invents its
|
|
14
|
+
* ticker lines and its spoken text; here both come from the engine's card fields only.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
const str = (v) => (typeof v === 'string' ? v.trim() : '');
|
|
18
|
+
const cardsOf = (b) => (b && Array.isArray(b.cards) ? b.cards.filter((c) => c && typeof c === 'object') : []);
|
|
19
|
+
|
|
20
|
+
/** One line per top card, as /api/briefing builds them. Nothing when the briefing has none (the ticker is then not drawn). */
|
|
21
|
+
export function tickerLines(briefing) {
|
|
22
|
+
const t = briefing && Array.isArray(briefing.ticker) ? briefing.ticker : [];
|
|
23
|
+
return t.map(str).filter(Boolean).slice(0, 5);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* demo only: a briefing may carry `unread` (the mockup's S.unread = 2); the real route never sends it */
|
|
27
|
+
export const bellCount = (briefing) => (briefing && Number.isInteger(briefing.unread) && briefing.unread >= 0 ? briefing.unread : cardsOf(briefing).length);
|
|
28
|
+
|
|
29
|
+
export function bellItems(briefing) {
|
|
30
|
+
return cardsOf(briefing).map((c) => ({ key: c.key == null ? null : String(c.key), tag: str(c.tag), headline: str(c.headline) })).filter((x) => x.headline);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The banner is the pinned Critical card the server names. A dismissed one (its key is in `seen`) does not come back this session. */
|
|
34
|
+
export function bannerOf(briefing, seen) {
|
|
35
|
+
const b = briefing && briefing.banner;
|
|
36
|
+
if (!b || typeof b !== 'object' || !str(b.headline)) return null;
|
|
37
|
+
const key = b.key == null ? str(b.headline) : String(b.key);
|
|
38
|
+
const gone = seen && (typeof seen.has === 'function' ? seen.has(key) : Array.isArray(seen) && seen.includes(key));
|
|
39
|
+
return gone ? null : { key, tag: str(b.tag) || 'Critical', headline: str(b.headline) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const PATTERNS = { critical: [30, 60, 30], confirm: [12, 40, 12], tick: [8] };
|
|
43
|
+
|
|
44
|
+
/** The vibration pattern for a kind. Reduced motion asks for none. */
|
|
45
|
+
export function hapticFor(kind, reduced) {
|
|
46
|
+
if (reduced) return null;
|
|
47
|
+
const p = PATTERNS[kind];
|
|
48
|
+
return p ? p.slice() : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The spoken text of one card: its tag, headline and facts, then its deadline text. Only the card's own fields; a missing one is left out. */
|
|
52
|
+
export function readText(card) {
|
|
53
|
+
const c = card && typeof card === 'object' ? card : {};
|
|
54
|
+
const end = (s) => (/[.!?]$/.test(s) ? s : s + '.');
|
|
55
|
+
const parts = [];
|
|
56
|
+
if (str(c.tag)) parts.push(end(str(c.tag)));
|
|
57
|
+
if (str(c.headline)) parts.push(end(str(c.headline)));
|
|
58
|
+
for (const f of Array.isArray(c.facts) ? c.facts : []) if (str(f)) parts.push(end(str(f)));
|
|
59
|
+
const d = c.deadline && typeof c.deadline === 'object' ? str(c.deadline.text) : '';
|
|
60
|
+
if (d) parts.push(end(d));
|
|
61
|
+
return parts.join(' ');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function clockOf(ms) {
|
|
65
|
+
const t = Math.max(0, Math.floor((Number(ms) || 0) / 1000));
|
|
66
|
+
const m = Math.floor(t / 60);
|
|
67
|
+
const s = String(t % 60).padStart(2, '0');
|
|
68
|
+
return m >= 60 ? Math.floor(m / 60) + ':' + String(m % 60).padStart(2, '0') + ':' + s : m + ':' + s;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What the recording group shows. Idle draws nothing; the dot pulses only while recording and motion is allowed. */
|
|
72
|
+
export function recView(state, reduced) {
|
|
73
|
+
const st = state && typeof state === 'object' ? state : {};
|
|
74
|
+
const mode = st.mode === 'recording' || st.mode === 'paused' ? st.mode : 'idle';
|
|
75
|
+
if (mode === 'idle') return { mode, shown: false, dot: 'none', tabDot: false, canMark: false, canPause: false, canResume: false, canStop: false };
|
|
76
|
+
return {
|
|
77
|
+
mode, shown: true,
|
|
78
|
+
dot: mode === 'recording' && !reduced ? 'pulse' : 'static',
|
|
79
|
+
tabDot: true,
|
|
80
|
+
canMark: mode === 'recording', canPause: mode === 'recording', canResume: mode === 'paused', canStop: true,
|
|
81
|
+
title: str(st.title) || 'Untitled note',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/* pm/shell-phone.js — the shell strip and the phone's one-screen head (console3 wave E2b).
|
|
2
|
+
*
|
|
3
|
+
* mountShell({ parent, after, go }) → dispose() builds the strip under the top bar and mounts the ticker, the recording
|
|
4
|
+
* group, the speaker and the bell in it; on a phone it adds the four tiles
|
|
5
|
+
* disposeShell() the same dispose, by name
|
|
6
|
+
*
|
|
7
|
+
* The strip is `.c3.c3e.c3shell`. On a desktop it is one row: the running text, then the recording group, the speaker and the bell.
|
|
8
|
+
* On a phone (the same `(max-width:760px)` query as pm/mobile.js isMobile(); the strip carries the mockup's `ph` class) it is the
|
|
9
|
+
* mockup's `.tickrow` (bell, running text, speaker) with the four tiles under it: Briefing, Calendar, Work and More. A tile opens a
|
|
10
|
+
* popup (a bottom sheet) and never scrolls the page. The tabs below keep their own lists; this head does not replace them.
|
|
11
|
+
*
|
|
12
|
+
* Lifted: console3.html `tile` (line 2486) and `vBriefPhone` (2487 to 2492): `.tray > button.tl > ic + b.n + span.l`. Not lifted: the
|
|
13
|
+
* mockup's tiles Needs you, Review, Next meeting and Handled, whose counts come from its sample data; the four here are the four places
|
|
14
|
+
* the phone has (Briefing, Calendar, Work, More) and their counts are the real ones: cards in the briefing, items to brief in My work.
|
|
15
|
+
* Briefing and Calendar are both on Today, so both open it.
|
|
16
|
+
*/
|
|
17
|
+
import { el } from './dom.js';
|
|
18
|
+
import { ensureC3Css, c3ic } from './pm-work-view.js';
|
|
19
|
+
import { TAG_STYLE } from './briefing-model.js';
|
|
20
|
+
import { bellItems, bellCount, bannerOf } from './shell-model.js';
|
|
21
|
+
import { mountTicker, disposeTicker, onBriefing, loadBriefing } from './shell-ticker.js';
|
|
22
|
+
import { mountBell, disposeBell, openBellList, openPopup } from './shell-bell.js';
|
|
23
|
+
import { mountRecording, disposeRecording } from './shell-recording.js';
|
|
24
|
+
import { listenButton, stopAloud } from './shell-readaloud.js';
|
|
25
|
+
|
|
26
|
+
const MORE = [['initiatives', 'Initiatives', 'target'], ['board', 'Board', 'cols'], ['reports', 'Reports', 'report'], ['docs', 'Docs', 'doc'],
|
|
27
|
+
['notes', 'Notes', 'notes'], ['people', 'People', 'user'], ['settings', 'Settings', 'menu']];
|
|
28
|
+
|
|
29
|
+
let disposer = null;
|
|
30
|
+
export function disposeShell() { if (disposer) { const d = disposer; disposer = null; d(); } }
|
|
31
|
+
|
|
32
|
+
function rowButton(icon, text, onclick, extra) {
|
|
33
|
+
return el('button', { type: 'button', class: 'nli', onclick }, el('span', { class: 'ki' }, c3ic(icon)), el('span', { class: 't' }, text), el('span', { class: 'w' }, extra || ''));
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function mountShell({ parent, after, go } = {}) {
|
|
37
|
+
disposeShell();
|
|
38
|
+
if (!parent) return () => {};
|
|
39
|
+
ensureC3Css();
|
|
40
|
+
const root = el('div', { class: 'c3 c3e c3shell', 'data-shell': 'strip' });
|
|
41
|
+
const tickSlot = el('div', { class: 'shell-tick' });
|
|
42
|
+
const tools = el('div', { class: 'shell-tools' });
|
|
43
|
+
const tray = el('div', { class: 'tray', hidden: true, 'data-shell': 'tiles' });
|
|
44
|
+
root.append(tickSlot, tools, tray);
|
|
45
|
+
parent.insertBefore(root, after ? after.nextSibling : parent.firstChild);
|
|
46
|
+
|
|
47
|
+
let brief = { cards: [], ticker: [], banner: null };
|
|
48
|
+
let workN = 0;
|
|
49
|
+
const topCard = () => (brief.cards && brief.cards[0]) || null;
|
|
50
|
+
const listen = listenButton(topCard);
|
|
51
|
+
tools.appendChild(listen);
|
|
52
|
+
mountRecording(tools);
|
|
53
|
+
mountBell(tools, { go });
|
|
54
|
+
mountTicker(tickSlot, { onOpen: () => openBellList() });
|
|
55
|
+
|
|
56
|
+
// The strip appears only when it has something to show: a phone (the tiles), ticker lines, bell items, a banner, or a live
|
|
57
|
+
// recording. On an empty page it is hidden, so it adds no controls (the Q5 control budget) and no row to the frame.
|
|
58
|
+
const syncVisible = () => {
|
|
59
|
+
const rg = tools.querySelector('.recg');
|
|
60
|
+
// Hidden on a phone for now: the phone frame is a separate layout whose destinations are full-screen layers, and the strip sat
|
|
61
|
+
// over their top controls (pm-initiatives-dest-phone). The phone tiles wait for the one-screen phone rework.
|
|
62
|
+
root.hidden = root.classList.contains('ph') || !(bellCount(brief) > 0 || (brief.ticker && brief.ticker.length) || bannerOf(brief, null) || (rg && !rg.hidden));
|
|
63
|
+
};
|
|
64
|
+
const watch = typeof MutationObserver === 'function' ? new MutationObserver(syncVisible) : null;
|
|
65
|
+
if (watch) watch.observe(tools, { subtree: true, attributes: true, attributeFilter: ['hidden'], childList: true });
|
|
66
|
+
/* phone: the same query as mobile.js isMobile(); the mockup's `ph` class */
|
|
67
|
+
const mq = typeof matchMedia === 'function' ? matchMedia('(max-width:760px)') : null;
|
|
68
|
+
const onMq = () => { const on = !!(mq && mq.matches); root.classList.toggle('ph', on); tray.hidden = !on; syncVisible(); };
|
|
69
|
+
onMq();
|
|
70
|
+
if (mq) { if (mq.addEventListener) mq.addEventListener('change', onMq); else mq.addListener(onMq); }
|
|
71
|
+
|
|
72
|
+
const tile = (key, icon, label, opts) => {
|
|
73
|
+
const b = el('button', { type: 'button', class: 'tl', 'aria-label': label, 'data-shell-tile': key, onclick: opts.open },
|
|
74
|
+
c3ic(icon), el('b', { class: 'n' }, ''), el('span', { class: 'l' }, label));
|
|
75
|
+
return b;
|
|
76
|
+
};
|
|
77
|
+
const tiles = {
|
|
78
|
+
brief: tile('briefing', 'inbox', 'Briefing', { open: () => openTile('briefing') }),
|
|
79
|
+
cal: tile('calendar', 'cal', 'Calendar', { open: () => openTile('calendar') }),
|
|
80
|
+
work: tile('work', 'todo', 'Work', { open: () => openTile('work') }),
|
|
81
|
+
more: tile('more', 'more', 'More', { open: () => openTile('more') }),
|
|
82
|
+
};
|
|
83
|
+
tray.append(tiles.brief, tiles.cal, tiles.work, tiles.more);
|
|
84
|
+
const setN = (t, n) => { const b = t.querySelector('.n'); b.textContent = n == null ? '' : String(n); b.hidden = n == null; };
|
|
85
|
+
const paintTiles = () => {
|
|
86
|
+
setN(tiles.brief, bellCount(brief)); setN(tiles.cal, null); setN(tiles.work, workN); setN(tiles.more, null);
|
|
87
|
+
tiles.brief.classList.toggle('hot', !!bannerOf(brief, null));
|
|
88
|
+
};
|
|
89
|
+
paintTiles();
|
|
90
|
+
|
|
91
|
+
function openTile(k) {
|
|
92
|
+
if (k === 'briefing') {
|
|
93
|
+
const items = bellItems(brief);
|
|
94
|
+
const body = el('div', null, ...(items.length ? items.map((x) => {
|
|
95
|
+
const st = TAG_STYLE[x.tag] || { icon: 'bolt', color: 'var(--muted-foreground)' };
|
|
96
|
+
return el('button', { type: 'button', class: 'nli', onclick: () => { p.close(); if (go) go('today'); } }, el('span', { class: 'ki', style: 'color:' + st.color }, c3ic(st.icon)),
|
|
97
|
+
el('span', { class: 't' }, el('b', { class: 'mono', style: 'font-size:var(--fs-1);letter-spacing:.06em;text-transform:uppercase;color:' + st.color }, x.tag), el('br'), x.headline), el('span', { class: 'w' }));
|
|
98
|
+
}) : [el('div', { class: 'fold' }, c3ic('check', 'sm'), 'Nothing is waiting for you')]));
|
|
99
|
+
var p = openPopup({ title: 'Briefing', icon: 'inbox', body });
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
const spec = {
|
|
103
|
+
calendar: { title: 'Calendar', icon: 'cal', rows: [['cal', 'Open the calendar', 'today']] },
|
|
104
|
+
work: { title: 'Work', icon: 'todo', rows: [['todo', workN ? 'Open My work: ' + workN + ' to brief' : 'Open My work', 'mywork']] },
|
|
105
|
+
more: { title: 'More', icon: 'menu', rows: MORE.map(([id, label, ic]) => [ic, label, id]) },
|
|
106
|
+
}[k];
|
|
107
|
+
const q = openPopup({ title: spec.title, icon: spec.icon, body: el('div', null, ...spec.rows.map(([ic, text, id]) => rowButton(ic, text, () => { q.close(); if (go) go(id); }))) });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const off = onBriefing((b) => { brief = b || brief; paintTiles(); syncVisible(); });
|
|
111
|
+
const onWork = (e) => { workN = Math.max(0, Number(e && e.detail && e.detail.brief) || 0); paintTiles(); };
|
|
112
|
+
window.addEventListener('pmos:mywork', onWork);
|
|
113
|
+
loadBriefing();
|
|
114
|
+
syncVisible();
|
|
115
|
+
|
|
116
|
+
disposer = () => {
|
|
117
|
+
off(); if (watch) watch.disconnect(); window.removeEventListener('pmos:mywork', onWork);
|
|
118
|
+
if (mq) { if (mq.removeEventListener) mq.removeEventListener('change', onMq); else mq.removeListener(onMq); }
|
|
119
|
+
stopAloud();
|
|
120
|
+
disposeTicker(tickSlot); disposeBell(tools); disposeRecording(tools);
|
|
121
|
+
root.remove();
|
|
122
|
+
};
|
|
123
|
+
return disposeShell;
|
|
124
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* pm/shell-readaloud.js — read the open card aloud (console3 wave E2b).
|
|
2
|
+
*
|
|
3
|
+
* readAloud(card, btn) speak readText(card) through window.speechSynthesis; a second press stops it
|
|
4
|
+
* stopAloud() cancel what is being said
|
|
5
|
+
* listenButton(getCard) the speaker `.ib`: the mockup's `data-a=listen` control (the equaliser shows while speaking)
|
|
6
|
+
*
|
|
7
|
+
* Local only: speechSynthesis runs in the browser and sends nothing. The spoken text is shell-model.readText(card): the card's own tag,
|
|
8
|
+
* headline, facts and deadline, and nothing else. Lifted: console3.html `listen` and `syncEq` (lines 2518 to 2530, 2518). Not lifted:
|
|
9
|
+
* the mockup's spoken day-part intro and "N more items need you", which it makes up.
|
|
10
|
+
*/
|
|
11
|
+
import { el } from './dom.js';
|
|
12
|
+
import { ensureC3Css, c3ic } from './pm-work-view.js';
|
|
13
|
+
import { readText } from './shell-model.js';
|
|
14
|
+
|
|
15
|
+
let speaking = null; // the button whose text is being said
|
|
16
|
+
|
|
17
|
+
function paint(btn, on) {
|
|
18
|
+
if (!btn) return;
|
|
19
|
+
btn.replaceChildren(on ? el('span', { class: 'eq' }, el('i'), el('i'), el('i')) : c3ic('vol'));
|
|
20
|
+
btn.title = on ? 'Stop' : 'Hear this card';
|
|
21
|
+
btn.setAttribute('aria-label', btn.title);
|
|
22
|
+
btn.setAttribute('aria-pressed', on ? 'true' : 'false');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function stopAloud() {
|
|
26
|
+
const sy = typeof window !== 'undefined' ? window.speechSynthesis : null;
|
|
27
|
+
if (sy) sy.cancel();
|
|
28
|
+
const b = speaking; speaking = null; paint(b, false);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function readAloud(card, btn) {
|
|
32
|
+
const sy = typeof window !== 'undefined' ? window.speechSynthesis : null;
|
|
33
|
+
if (!sy || typeof SpeechSynthesisUtterance !== 'function') return false;
|
|
34
|
+
if (speaking) { const same = speaking === btn; stopAloud(); if (same) return true; }
|
|
35
|
+
const text = readText(card);
|
|
36
|
+
if (!text) return false;
|
|
37
|
+
const u = new SpeechSynthesisUtterance(text);
|
|
38
|
+
u.rate = 1.02;
|
|
39
|
+
const done = () => { if (speaking === btn) { speaking = null; paint(btn, false); } };
|
|
40
|
+
u.onend = u.onerror = done;
|
|
41
|
+
speaking = btn; paint(btn, true);
|
|
42
|
+
sy.speak(u);
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function listenButton(getCard) {
|
|
47
|
+
ensureC3Css();
|
|
48
|
+
const b = el('button', { type: 'button', class: 'ib', 'data-shell': 'listen' });
|
|
49
|
+
paint(b, false);
|
|
50
|
+
if (typeof window === 'undefined' || !window.speechSynthesis) { b.disabled = true; b.title = 'Audio is not available in this browser'; return b; }
|
|
51
|
+
b.addEventListener('click', () => readAloud(getCard(), b));
|
|
52
|
+
return b;
|
|
53
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/* pm/shell-recording.js — the recording indicator beside the bell (console3 wave E2b).
|
|
2
|
+
*
|
|
3
|
+
* mountRecording(host) / disposeRecording(host)
|
|
4
|
+
*
|
|
5
|
+
* It starts nothing. It reads the real recorder through note-dock.js (recordState) and offers only the controls the Record control in
|
|
6
|
+
* the top bar already has: open the note, mark a moment (highlightNow), pause, resume and stop (pauseNow, resumeNow, stopNow). When
|
|
7
|
+
* nothing is recording or paused the group is hidden. While one is, a red dot sits on the Notes tab (`[data-fr-dest="notes"]` in the
|
|
8
|
+
* frame's bar; a phone tab `[data-dest="notes"]` if one is drawn). Lifted: console3.html `.recg` and `recCtl` (lines 916 to 924, 4385
|
|
9
|
+
* to 4400). The group shows the recorder's state under reduced motion as a static dot. The clock is the recorder's own start time.
|
|
10
|
+
*/
|
|
11
|
+
import { el } from './dom.js';
|
|
12
|
+
import { ensureC3Css, c3ic } from './pm-work-view.js';
|
|
13
|
+
import * as dock from './note-dock.js';
|
|
14
|
+
import { recView, clockOf } from './shell-model.js';
|
|
15
|
+
import { reducedMotion } from './shell-ticker.js';
|
|
16
|
+
import { toast } from './c3-toast.js';
|
|
17
|
+
|
|
18
|
+
/* the red dot sits on the Notes tab (the app's frame) and, on /c3.html, on the Calendar tab, where the meeting is recorded (the mockup's
|
|
19
|
+
* `#tabbtns [data-k=cal]` .tabdot, line 4395) */
|
|
20
|
+
const TAB = '[data-fr-dest="notes"], #mobNav [data-dest="notes"], [data-c3-tab="cal"]';
|
|
21
|
+
|
|
22
|
+
/* ── demo mode: a SIMULATED recorder ──────────────────────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
* A page cannot record (nothing on a page may record). On /c3.html?demo=1 the Record button of the Calendar's During and After stages drives
|
|
24
|
+
* this stand-in, so the top bar shows the mockup's recording group: a pulsing dot, the clock, mark moment, pause or resume, and stop.
|
|
25
|
+
* It keeps a clock (performance.now: the demo freezes Date) and nothing else. No microphone is opened and nothing is saved.
|
|
26
|
+
* Real mode never reaches it: `isDemo()` reads the page's data-c3-demo mark, which only the demo sets. */
|
|
27
|
+
export const isDemo = () => typeof document !== 'undefined' && !!document.querySelector('[data-c3-demo]');
|
|
28
|
+
const SIM = { mode: 'idle', id: 'sim', title: '', started: 0, pausedAt: 0 };
|
|
29
|
+
const tick = () => performance.now();
|
|
30
|
+
const emit = () => window.dispatchEvent(new CustomEvent('c3-sim-rec'));
|
|
31
|
+
const simClock = () => clockOf(Math.max(0, (SIM.mode === 'paused' ? SIM.pausedAt : tick()) - SIM.started));
|
|
32
|
+
export const simState = () => ({ mode: SIM.mode, id: SIM.id, title: SIM.title, started: SIM.started });
|
|
33
|
+
export function simStart(title) {
|
|
34
|
+
if (SIM.mode !== 'idle') return;
|
|
35
|
+
Object.assign(SIM, { mode: 'recording', title: title || 'Customer interview', started: tick(), pausedAt: 0 });
|
|
36
|
+
toast('Recording started. Notes stay on this machine. (Demo: nothing is recorded.)'); emit();
|
|
37
|
+
}
|
|
38
|
+
export const simDock = {
|
|
39
|
+
recordState: simState,
|
|
40
|
+
now: tick,
|
|
41
|
+
openDock() { window.dispatchEvent(new CustomEvent('c3-sim-open')); },
|
|
42
|
+
highlightNow() { toast('Moment marked at ' + simClock()); },
|
|
43
|
+
pauseNow() { if (SIM.mode !== 'recording') return; SIM.mode = 'paused'; SIM.pausedAt = tick(); toast('Paused. Nothing is captured until you resume.'); emit(); },
|
|
44
|
+
resumeNow() { if (SIM.mode !== 'paused') return; SIM.started += tick() - SIM.pausedAt; SIM.mode = 'recording'; toast('Resumed.'); emit(); },
|
|
45
|
+
stopNow() { if (SIM.mode === 'idle') return; SIM.mode = 'idle'; toast('Saved. The summary is being written.'); emit(); },
|
|
46
|
+
};
|
|
47
|
+
const live = new WeakMap();
|
|
48
|
+
|
|
49
|
+
export function mountRecording(host, opts) {
|
|
50
|
+
if (!host) return;
|
|
51
|
+
disposeRecording(host);
|
|
52
|
+
ensureC3Css();
|
|
53
|
+
const d = (opts && opts.dock) || (isDemo() ? simDock : dock);
|
|
54
|
+
const nowMs = () => (d.now ? d.now() : Date.now());
|
|
55
|
+
const grp = el('span', { class: 'recg', role: 'group', 'aria-label': 'Recording', hidden: true, 'data-shell': 'recording' });
|
|
56
|
+
host.insertBefore(grp, host.firstChild);
|
|
57
|
+
const rec = { grp, key: '', clock: null, tabs: [] };
|
|
58
|
+
const tabDot = (on) => {
|
|
59
|
+
for (const t of document.querySelectorAll(TAB)) {
|
|
60
|
+
let dot = t.querySelector('.shell-recdot');
|
|
61
|
+
if (!dot) { dot = el('span', { class: 'shell-recdot', 'aria-hidden': 'true' }); t.appendChild(dot); }
|
|
62
|
+
dot.hidden = !on;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
/* the page's own sprite (c3i-*) draws these; it has no pause glyph, so that one is two bars (the mockup's recCtl, line 4389) */
|
|
66
|
+
const NS = 'http://www.w3.org/2000/svg';
|
|
67
|
+
const bars = () => { const s = document.createElementNS(NS, 'svg'); s.setAttribute('class', 'ic sm'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); const pth = document.createElementNS(NS, 'path'); pth.setAttribute('d', 'M8 5v14M16 5v14'); s.appendChild(pth); return s; };
|
|
68
|
+
const GL = { highlighter: () => c3ic('flag', 'sm'), pause: bars, play: () => c3ic('next', 'sm'), square: () => c3ic('stop', 'sm') };
|
|
69
|
+
const btn = (label, name, fn, attr) => el('button', { type: 'button', title: label, 'aria-label': label, [attr]: '', onclick: fn }, GL[name]());
|
|
70
|
+
const sync = () => {
|
|
71
|
+
let st; try { st = d.recordState(); } catch { st = { mode: 'idle' }; }
|
|
72
|
+
const v = recView(st, reducedMotion());
|
|
73
|
+
const key = [v.mode, st.id, v.title, v.dot].join(':');
|
|
74
|
+
if (key !== rec.key) {
|
|
75
|
+
rec.key = key;
|
|
76
|
+
grp.hidden = !v.shown;
|
|
77
|
+
grp.classList.toggle('paused', v.mode === 'paused');
|
|
78
|
+
grp.setAttribute('aria-label', v.shown ? (v.mode === 'recording' ? 'Recording ' : 'Paused: ') + v.title : 'Recording');
|
|
79
|
+
if (!v.shown) { grp.replaceChildren(); rec.clock = null; }
|
|
80
|
+
else {
|
|
81
|
+
rec.clock = el('span', { class: 'recclk', 'data-shell-rec-clock': '' });
|
|
82
|
+
const open = el('button', { type: 'button', title: (v.mode === 'recording' ? 'Recording: ' : 'Paused: ') + v.title + '. Open the live note', 'aria-label': 'Open the note: ' + v.title, 'data-shell-rec-open': '', onclick: () => d.openDock(st.id) },
|
|
83
|
+
el('span', { class: 'recdot' + (v.dot === 'static' ? ' static' : ''), 'data-dot': v.dot }), rec.clock, el('span', { class: 'rt' }, v.title));
|
|
84
|
+
const kids = [open];
|
|
85
|
+
if (v.canMark) kids.push(btn('Mark this moment', 'highlighter', () => d.highlightNow(), 'data-shell-rec-mark'));
|
|
86
|
+
if (v.canPause) kids.push(btn('Pause', 'pause', () => d.pauseNow(), 'data-shell-rec-pause'));
|
|
87
|
+
if (v.canResume) kids.push(btn('Resume', 'play', () => d.resumeNow(), 'data-shell-rec-resume'));
|
|
88
|
+
if (v.canStop) kids.push(btn('Stop and save', 'square', () => d.stopNow(), 'data-shell-rec-stop'));
|
|
89
|
+
grp.replaceChildren(...kids);
|
|
90
|
+
}
|
|
91
|
+
tabDot(v.tabDot);
|
|
92
|
+
}
|
|
93
|
+
if (rec.clock) rec.clock.textContent = v.mode === 'recording' && st.started ? clockOf(nowMs() - st.started) : v.mode === 'paused' ? 'paused' : '';
|
|
94
|
+
};
|
|
95
|
+
rec.timer = setInterval(sync, 1000);
|
|
96
|
+
rec.onSim = () => sync();
|
|
97
|
+
window.addEventListener('c3-sim-rec', rec.onSim);
|
|
98
|
+
sync();
|
|
99
|
+
live.set(host, rec);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function disposeRecording(host) {
|
|
103
|
+
const rec = host && live.get(host);
|
|
104
|
+
if (!rec) return;
|
|
105
|
+
clearInterval(rec.timer);
|
|
106
|
+
if (rec.onSim) window.removeEventListener('c3-sim-rec', rec.onSim);
|
|
107
|
+
rec.grp.remove();
|
|
108
|
+
for (const t of document.querySelectorAll(TAB)) { const dot = t.querySelector('.shell-recdot'); if (dot) dot.remove(); }
|
|
109
|
+
live.delete(host);
|
|
110
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/* pm/shell-ticker.js — the running text under the top bar (console3 wave E2b), and the one shared read of GET /api/briefing that the
|
|
2
|
+
* bell, the banner and the phone tiles use too.
|
|
3
|
+
*
|
|
4
|
+
* mountTicker(host, { onOpen }) / disposeTicker(host) draw the lines into `host`; nothing when the briefing has none
|
|
5
|
+
* loadBriefing(force) → Promise of the briefing body (kept 60 s) onBriefing(fn) → off() called after every read
|
|
6
|
+
*
|
|
7
|
+
* Lifted: the mockup's `.tick > .tkt > .tki` drawn twice for the loop, tag in `b` (console3.html lines 539 to 545, 2478 to 2484). Hover
|
|
8
|
+
* pauses it (the stylesheet). Under prefers-reduced-motion the lines show one at a time, without movement.
|
|
9
|
+
*/
|
|
10
|
+
import { el } from './dom.js';
|
|
11
|
+
import { ensureC3Css, c3ic } from './pm-work-view.js';
|
|
12
|
+
import { TAG_STYLE } from './briefing-model.js';
|
|
13
|
+
import { tickerLines } from './shell-model.js';
|
|
14
|
+
|
|
15
|
+
const TTL = 60000;
|
|
16
|
+
let cache = null; // { at, body }
|
|
17
|
+
let inflight = null;
|
|
18
|
+
const subs = new Set();
|
|
19
|
+
|
|
20
|
+
export function onBriefing(fn) { subs.add(fn); if (cache) fn(cache.body); return () => subs.delete(fn); }
|
|
21
|
+
|
|
22
|
+
export function loadBriefing(force) {
|
|
23
|
+
if (!force && cache && Date.now() - cache.at < TTL) return Promise.resolve(cache.body);
|
|
24
|
+
if (inflight) return inflight;
|
|
25
|
+
inflight = fetch('/api/briefing').then((r) => (r.ok ? r.json() : null)).catch(() => null).then((body) => {
|
|
26
|
+
inflight = null;
|
|
27
|
+
if (!body || body.ok === false) return cache ? cache.body : { cards: [], ticker: [], banner: null };
|
|
28
|
+
cache = { at: Date.now(), body };
|
|
29
|
+
for (const fn of [...subs]) { try { fn(body); } catch { /* one listener's error is its own */ } }
|
|
30
|
+
return body;
|
|
31
|
+
});
|
|
32
|
+
return inflight;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const reducedMotion = () => typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
36
|
+
|
|
37
|
+
/** "Critical: Sign off (3d)" → the tag word in `b`, coloured by the tag's own token; other text as it is. */
|
|
38
|
+
function itemOf(line) {
|
|
39
|
+
const i = line.indexOf(': ');
|
|
40
|
+
const tag = i > 0 ? line.slice(0, i) : '';
|
|
41
|
+
const st = TAG_STYLE[tag];
|
|
42
|
+
const color = st ? st.color : 'var(--muted-foreground)';
|
|
43
|
+
return el('span', { class: 'tki' }, el('span', { style: 'color:' + color }, c3ic(st ? st.icon : 'bolt', 'sm')),
|
|
44
|
+
st ? el('b', { style: 'color:' + color }, tag) : null, st ? line.slice(i + 2) : line);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const live = new WeakMap(); // host → { off, timer, mq }
|
|
48
|
+
|
|
49
|
+
export function mountTicker(host, opts) {
|
|
50
|
+
if (!host) return;
|
|
51
|
+
disposeTicker(host);
|
|
52
|
+
ensureC3Css();
|
|
53
|
+
const onOpen = opts && opts.onOpen;
|
|
54
|
+
const box = el('div', { class: 'tick', role: 'button', tabindex: '0', 'aria-label': 'Open notifications', 'data-shell': 'ticker' });
|
|
55
|
+
const press = () => { if (onOpen) onOpen(); };
|
|
56
|
+
box.addEventListener('click', press);
|
|
57
|
+
box.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); press(); } });
|
|
58
|
+
host.appendChild(box);
|
|
59
|
+
const rec = { timer: null, lines: [], i: 0, box };
|
|
60
|
+
const draw = () => {
|
|
61
|
+
clearInterval(rec.timer); rec.timer = null;
|
|
62
|
+
const lines = rec.lines;
|
|
63
|
+
box.hidden = lines.length === 0;
|
|
64
|
+
box.replaceChildren();
|
|
65
|
+
if (!lines.length) return;
|
|
66
|
+
if (reducedMotion()) {
|
|
67
|
+
box.classList.add('static');
|
|
68
|
+
const show = () => { box.replaceChildren(el('div', { class: 'tkt', 'aria-live': 'off' }, itemOf(lines[rec.i % lines.length]))); rec.i++; };
|
|
69
|
+
rec.i = 0; show();
|
|
70
|
+
if (lines.length > 1) rec.timer = setInterval(show, 5000);
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
box.classList.remove('static');
|
|
74
|
+
const one = () => lines.map(itemOf);
|
|
75
|
+
const second = el('span', { style: 'display:contents', 'aria-hidden': 'true' }, one());
|
|
76
|
+
box.appendChild(el('div', { class: 'tkt' }, el('span', { style: 'display:contents' }, one()), second));
|
|
77
|
+
};
|
|
78
|
+
rec.off = onBriefing((b) => { const next = tickerLines(b); if (next.join('\n') !== rec.lines.join('\n') || !box.childNodes.length) { rec.lines = next; draw(); } });
|
|
79
|
+
box.hidden = true;
|
|
80
|
+
live.set(host, rec);
|
|
81
|
+
loadBriefing();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function disposeTicker(host) {
|
|
85
|
+
const rec = host && live.get(host);
|
|
86
|
+
if (!rec) return;
|
|
87
|
+
clearInterval(rec.timer); if (rec.off) rec.off();
|
|
88
|
+
if (rec.box.parentNode) rec.box.remove();
|
|
89
|
+
live.delete(host);
|
|
90
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/* pm/slide-confirm.js — the slide-to-confirm control, on the console3 mockup's own markup and CSS (R4b).
|
|
2
|
+
*
|
|
3
|
+
* mountSlide(host, { label, hash, onConfirm }) draws the control into host; onConfirm(hash) fires once, only after the
|
|
4
|
+
* thumb is dragged all the way (pointer), or Enter is held / ArrowRight is
|
|
5
|
+
* pressed to the end (keyboard). Releasing early snaps back.
|
|
6
|
+
* disposeSlide(host) removes the control and its listeners
|
|
7
|
+
*
|
|
8
|
+
* The markup is the mockup's caseHtml slide (console3.html line 2290): `.slt > span.lb + button.th`, and the drag is the mockup's
|
|
9
|
+
* (lines 2439 to 2447): the thumb follows the pointer with transform, --p fills the track, a short drag nudges the thumb and
|
|
10
|
+
* says a tap is not enough. The styles are pm/console3.css (`.slt`). What the mockup does not have and this control keeps: the
|
|
11
|
+
* slide is created FOR a hash and hands the same hash back, so a confirm is bound to the content the PM saw (mount again with
|
|
12
|
+
* the new hash after an edit); the keyboard paths (hold Enter, or ArrowRight four times) with role=slider; reduced motion.
|
|
13
|
+
* The mockup's `.slt` takes its colour from --tc on the case file; a bare slide has no case file, so the control sets --tc. */
|
|
14
|
+
import { ensureC3Css, c3ic } from './pm-work-view.js';
|
|
15
|
+
import { toast } from './c3-toast.js';
|
|
16
|
+
|
|
17
|
+
const FULL = 0.96; // share of the track the thumb must cover
|
|
18
|
+
const HOLD_MS = 900; // Enter held this long counts as a full slide
|
|
19
|
+
const STEP = 0.25; // one ArrowRight press
|
|
20
|
+
|
|
21
|
+
const reduced = () => {
|
|
22
|
+
try { return typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches; } catch { return false; }
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const state = new WeakMap(); // host → { root, dispose }
|
|
26
|
+
|
|
27
|
+
export function disposeSlide(host) {
|
|
28
|
+
const s = state.get(host);
|
|
29
|
+
if (!s) return;
|
|
30
|
+
s.dispose();
|
|
31
|
+
state.delete(host);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function mountSlide(host, { label = 'Slide to confirm', hash = null, onConfirm } = {}) {
|
|
35
|
+
disposeSlide(host);
|
|
36
|
+
ensureC3Css();
|
|
37
|
+
const root = document.createElement('div');
|
|
38
|
+
root.className = 'c3 c3e';
|
|
39
|
+
const track = document.createElement('div');
|
|
40
|
+
track.className = 'slt';
|
|
41
|
+
track.style.setProperty('--tc', 'var(--accent)');
|
|
42
|
+
const lb = document.createElement('span');
|
|
43
|
+
lb.className = 'lb';
|
|
44
|
+
lb.textContent = label + ' ›››';
|
|
45
|
+
const th = document.createElement('button');
|
|
46
|
+
th.type = 'button';
|
|
47
|
+
th.className = 'th';
|
|
48
|
+
th.appendChild(c3ic('next'));
|
|
49
|
+
th.setAttribute('role', 'slider');
|
|
50
|
+
th.setAttribute('aria-label', label + '. Drag to the end, or hold Enter, or press the right arrow four times.');
|
|
51
|
+
th.setAttribute('aria-valuemin', '0');
|
|
52
|
+
th.setAttribute('aria-valuemax', '100');
|
|
53
|
+
th.setAttribute('aria-valuenow', '0');
|
|
54
|
+
const hint = document.createElement('div');
|
|
55
|
+
hint.className = 'slt-hint';
|
|
56
|
+
hint.setAttribute('aria-live', 'polite');
|
|
57
|
+
track.append(lb, th);
|
|
58
|
+
root.append(track, hint);
|
|
59
|
+
host.appendChild(root);
|
|
60
|
+
|
|
61
|
+
let v = 0; // 0..1
|
|
62
|
+
let dragging = null; // { x, max }
|
|
63
|
+
let fired = false;
|
|
64
|
+
let holdTimer = null;
|
|
65
|
+
const maxPx = () => Math.max(1, track.clientWidth - th.offsetWidth - 6);
|
|
66
|
+
|
|
67
|
+
function show(p) {
|
|
68
|
+
v = Math.max(0, Math.min(1, p));
|
|
69
|
+
th.style.transform = `translateX(${(v * maxPx()).toFixed(1)}px)`;
|
|
70
|
+
track.style.setProperty('--p', v.toFixed(3));
|
|
71
|
+
th.setAttribute('aria-valuenow', String(Math.round(v * 100)));
|
|
72
|
+
}
|
|
73
|
+
function nudge() {
|
|
74
|
+
th.style.animation = '';
|
|
75
|
+
th.classList.remove('nd'); void th.offsetWidth; th.classList.add('nd');
|
|
76
|
+
}
|
|
77
|
+
function fire() {
|
|
78
|
+
if (fired) return;
|
|
79
|
+
fired = true;
|
|
80
|
+
track.classList.add('done');
|
|
81
|
+
th.disabled = true;
|
|
82
|
+
show(1);
|
|
83
|
+
try { if (typeof onConfirm === 'function') onConfirm(hash); } catch { /* the caller reports its own errors */ }
|
|
84
|
+
}
|
|
85
|
+
function release() {
|
|
86
|
+
if (fired) return;
|
|
87
|
+
if (v >= FULL) { fire(); return; }
|
|
88
|
+
th.style.transition = '';
|
|
89
|
+
th.style.animation = '';
|
|
90
|
+
if (v < 0.03) { toast('Slide to confirm. A tap is not enough.'); nudge(); }
|
|
91
|
+
show(0);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const onDown = (e) => {
|
|
95
|
+
if (fired) return;
|
|
96
|
+
e.preventDefault();
|
|
97
|
+
dragging = { x: e.clientX - v * maxPx(), max: maxPx() };
|
|
98
|
+
th.style.transition = 'none';
|
|
99
|
+
th.style.animation = 'none';
|
|
100
|
+
hint.textContent = '';
|
|
101
|
+
try { th.setPointerCapture(e.pointerId); } catch { /* not capturable */ }
|
|
102
|
+
};
|
|
103
|
+
const onMove = (e) => { if (dragging) show((e.clientX - dragging.x) / dragging.max); };
|
|
104
|
+
const onUp = () => {
|
|
105
|
+
if (!dragging) return;
|
|
106
|
+
dragging = null;
|
|
107
|
+
release();
|
|
108
|
+
};
|
|
109
|
+
const onCancel = () => {
|
|
110
|
+
if (!dragging) return;
|
|
111
|
+
dragging = null;
|
|
112
|
+
th.style.transition = '';
|
|
113
|
+
th.style.animation = '';
|
|
114
|
+
show(0);
|
|
115
|
+
};
|
|
116
|
+
const clearHold = () => { if (holdTimer) { clearInterval(holdTimer); holdTimer = null; } };
|
|
117
|
+
const onKeyDown = (e) => {
|
|
118
|
+
if (fired) return;
|
|
119
|
+
if (e.key === 'ArrowRight') { e.preventDefault(); hint.textContent = ''; show(v + STEP); if (v >= FULL) fire(); }
|
|
120
|
+
else if (e.key === 'ArrowLeft') { e.preventDefault(); show(v - STEP); }
|
|
121
|
+
else if (e.key === 'Enter' && !e.repeat) {
|
|
122
|
+
e.preventDefault();
|
|
123
|
+
hint.textContent = 'Keep Enter held to confirm.';
|
|
124
|
+
const t0 = Date.now();
|
|
125
|
+
clearHold();
|
|
126
|
+
holdTimer = setInterval(() => {
|
|
127
|
+
const p = (Date.now() - t0) / HOLD_MS;
|
|
128
|
+
if (reduced() && p < 1) return; // no animation: the thumb jumps to the end when the hold completes
|
|
129
|
+
show(p);
|
|
130
|
+
if (p >= 1) { clearHold(); fire(); }
|
|
131
|
+
}, 40);
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
const onKeyUp = (e) => {
|
|
135
|
+
if (e.key !== 'Enter') return;
|
|
136
|
+
clearHold();
|
|
137
|
+
if (!fired) { show(0); hint.textContent = 'Released early. Nothing was sent.'; }
|
|
138
|
+
};
|
|
139
|
+
const onBlur = () => { clearHold(); if (!fired && !dragging) show(0); };
|
|
140
|
+
|
|
141
|
+
th.addEventListener('pointerdown', onDown);
|
|
142
|
+
th.addEventListener('pointermove', onMove);
|
|
143
|
+
th.addEventListener('pointerup', onUp);
|
|
144
|
+
th.addEventListener('pointercancel', onCancel);
|
|
145
|
+
th.addEventListener('keydown', onKeyDown);
|
|
146
|
+
th.addEventListener('keyup', onKeyUp);
|
|
147
|
+
th.addEventListener('blur', onBlur);
|
|
148
|
+
|
|
149
|
+
state.set(host, {
|
|
150
|
+
root,
|
|
151
|
+
dispose() {
|
|
152
|
+
clearHold();
|
|
153
|
+
th.removeEventListener('pointerdown', onDown);
|
|
154
|
+
th.removeEventListener('pointermove', onMove);
|
|
155
|
+
th.removeEventListener('pointerup', onUp);
|
|
156
|
+
th.removeEventListener('pointercancel', onCancel);
|
|
157
|
+
th.removeEventListener('keydown', onKeyDown);
|
|
158
|
+
th.removeEventListener('keyup', onKeyUp);
|
|
159
|
+
th.removeEventListener('blur', onBlur);
|
|
160
|
+
root.remove();
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
}
|