create-pmos 0.125.0 → 0.127.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.
@@ -0,0 +1,176 @@
1
+ /* pm/c3-daypart.js — the time of day for console3: the day part from the clock, the sun and moon on a daylight arc, and the gate that
2
+ * lets a new day part re-rank the Briefing only when the page is idle. No network and no geolocation prompt: the place is the browser's
3
+ * time zone (longitude, 15 degrees an hour) and one default latitude. The pure functions take the clock and the zone as arguments, so
4
+ * workspace/c3-daypart.test.js checks them without a browser. The DOM part (mountDaypart, applyDayPart) only runs in a page. */
5
+
6
+ export const DAYPARTS = ['morning', 'midday', 'afternoon', 'evening', 'night'];
7
+ /* Default latitude, degrees north, used when the page has no better idea. Mid-latitude, so day length is neither extreme. */
8
+ export const DEFAULT_LATITUDE = 45;
9
+ export const RECHECK_MS = 5 * 60 * 1000;
10
+ export const IDLE_MS = 60 * 1000;
11
+
12
+ /** The day part of a clock hour: morning 5 to 11, midday 11 to 14, afternoon 14 to 17, evening 17 to 21, night 21 to 5. */
13
+ export function dayPartOf(date) {
14
+ const d = date instanceof Date ? date : new Date(date);
15
+ const h = d.getHours();
16
+ return h >= 5 && h < 11 ? 'morning' : h >= 11 && h < 14 ? 'midday' : h >= 14 && h < 17 ? 'afternoon' : h >= 17 && h < 21 ? 'evening' : 'night';
17
+ }
18
+
19
+ /** East longitude in degrees from a Date.getTimezoneOffset() value (minutes, positive west of UTC). Rough: a zone is 15 degrees wide. */
20
+ export function longitudeFromOffset(offsetMinutes) {
21
+ const o = Number(offsetMinutes);
22
+ return isFinite(o) ? Math.max(-180, Math.min(180, -o / 4)) : 0;
23
+ }
24
+
25
+ const RAD = Math.PI / 180;
26
+ const DAY_MS = 86400000;
27
+
28
+ /** Local calendar day [y, m, d] of an instant in a zone given as getTimezoneOffset minutes. */
29
+ function localYmd(ms, offsetMinutes) {
30
+ const d = new Date(ms - offsetMinutes * 60000);
31
+ return [d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()];
32
+ }
33
+
34
+ /** Sunrise and sunset (ms since epoch) of the local day that holds `ms`. `polar` is 'day' or 'night' when the sun never sets or rises. */
35
+ export function sunTimes(ms, lat, lon, offsetMinutes) {
36
+ const [y, m, d] = localYmd(ms, offsetMinutes);
37
+ const jdNoon = Date.UTC(y, m, d, 12) / DAY_MS + 2440587.5; // the sunrise equation (NOAA): n counts days from J2000
38
+ const n = Math.round(jdNoon - 2451545.0 + 0.0008);
39
+ const jStar = n - lon / 360;
40
+ const M = (357.5291 + 0.98560028 * jStar) % 360;
41
+ const C = 1.9148 * Math.sin(M * RAD) + 0.02 * Math.sin(2 * M * RAD) + 0.0003 * Math.sin(3 * M * RAD);
42
+ const lam = (M + C + 180 + 102.9372) % 360;
43
+ const jTransit = 2451545.0 + jStar + 0.0053 * Math.sin(M * RAD) - 0.0069 * Math.sin(2 * lam * RAD);
44
+ const sinDec = Math.sin(lam * RAD) * Math.sin(23.4397 * RAD);
45
+ const cosDec = Math.cos(Math.asin(sinDec));
46
+ const cosW = (Math.sin(-0.833 * RAD) - Math.sin(lat * RAD) * sinDec) / (Math.cos(lat * RAD) * cosDec);
47
+ const toMs = (jd) => Math.round((jd - 2440587.5) * DAY_MS);
48
+ if (cosW > 1) return { sunrise: null, sunset: null, polar: 'night', transit: toMs(jTransit) };
49
+ if (cosW < -1) return { sunrise: null, sunset: null, polar: 'day', transit: toMs(jTransit) };
50
+ const w = Math.acos(cosW) / RAD;
51
+ return { sunrise: toMs(jTransit - w / 360), sunset: toMs(jTransit + w / 360), polar: null, transit: toMs(jTransit) };
52
+ }
53
+
54
+ /** Where the sun or moon is now. body 'sun' while the sun is up, else 'moon'; t runs 0 to 1 along the arc of that body (sun: rise to set;
55
+ * moon: sunset to the next sunrise). In polar day or night t follows the clock around the transit. */
56
+ export function arcState(ms, lat, lon, offsetMinutes) {
57
+ const today = sunTimes(ms, lat, lon, offsetMinutes);
58
+ const out = { sunrise: today.sunrise, sunset: today.sunset, polar: today.polar, body: 'sun', t: 0.5, up: true };
59
+ if (today.polar) {
60
+ out.up = today.polar === 'day'; out.body = out.up ? 'sun' : 'moon';
61
+ out.t = Math.max(0, Math.min(1, ((ms - today.transit) / DAY_MS) + 0.5));
62
+ return out;
63
+ }
64
+ if (ms >= today.sunrise && ms <= today.sunset) {
65
+ out.t = (ms - today.sunrise) / (today.sunset - today.sunrise);
66
+ return out;
67
+ }
68
+ out.body = 'moon'; out.up = false;
69
+ if (ms < today.sunrise) {
70
+ const prev = sunTimes(ms - DAY_MS, lat, lon, offsetMinutes);
71
+ const from = prev.sunset == null ? today.sunrise - 12 * 3600000 : prev.sunset;
72
+ out.t = Math.max(0, Math.min(1, (ms - from) / (today.sunrise - from)));
73
+ } else {
74
+ const next = sunTimes(ms + DAY_MS, lat, lon, offsetMinutes);
75
+ const to = next.sunrise == null ? today.sunset + 12 * 3600000 : next.sunrise;
76
+ out.t = Math.max(0, Math.min(1, (ms - today.sunset) / (to - today.sunset)));
77
+ }
78
+ return out;
79
+ }
80
+
81
+ /** Point on a half-ellipse arc from (pad, h - pad) over the top to (w - pad, h - pad). t 0 to 1. */
82
+ export function arcPoint(t, w, h, pad) {
83
+ const p = pad == null ? 4 : pad;
84
+ const a = Math.PI * (1 - Math.max(0, Math.min(1, t)));
85
+ const rx = (w - 2 * p) / 2;
86
+ return { x: +(w / 2 + rx * Math.cos(a)).toFixed(2), y: +(h - p - (h - 2 * p) * Math.sin(a)).toFixed(2) };
87
+ }
88
+
89
+ /** "06:42" in the zone given as getTimezoneOffset minutes. */
90
+ export function hhmm(ms, offsetMinutes) {
91
+ if (ms == null) return '--:--';
92
+ const d = new Date(ms - offsetMinutes * 60000);
93
+ return String(d.getUTCHours()).padStart(2, '0') + ':' + String(d.getUTCMinutes()).padStart(2, '0');
94
+ }
95
+
96
+ /** The words for the strip, short sentences. */
97
+ export function stripLabel(st, offsetMinutes) {
98
+ if (st.polar === 'day') return 'The sun stays up all day.';
99
+ if (st.polar === 'night') return 'The sun stays down all day.';
100
+ return 'Sunrise ' + hhmm(st.sunrise, offsetMinutes) + '. Sunset ' + hhmm(st.sunset, offsetMinutes) + '.';
101
+ }
102
+
103
+ /* ── the idle gate: a new day part re-ranks the Briefing only when nobody is reading ── */
104
+
105
+ /** Pure core. `part(ms)` gives the part for that time. `busy()` says a card is open. Call tick(ms) on a timer and touch(ms) on any
106
+ * pointer or key activity. `apply(part)` runs once per change, only when idle for idleMs and not busy. A part that arrives while
107
+ * busy waits (pending) and a later tick applies it. */
108
+ export function createIdleGate({ current, part, busy, apply, idleMs = IDLE_MS }) {
109
+ let shown = current; let pending = null; let last = -Infinity;
110
+ return {
111
+ touch(ms) { last = ms; },
112
+ tick(ms) {
113
+ const p = part(ms);
114
+ pending = p !== shown ? p : null;
115
+ if (pending && !(busy && busy()) && ms - last >= idleMs) { shown = pending; pending = null; apply(shown); return true; }
116
+ return false;
117
+ },
118
+ get shown() { return shown; },
119
+ get pending() { return pending; },
120
+ };
121
+ }
122
+
123
+ /** Browser wiring of the gate: listens for activity on the document, checks every 5 minutes. Returns { dispose }. */
124
+ export function watchIdle(gate) {
125
+ const touch = () => gate.touch(Date.now());
126
+ const evs = ['pointerdown', 'pointermove', 'keydown', 'wheel', 'touchstart'];
127
+ evs.forEach((e) => document.addEventListener(e, touch, { passive: true, capture: true }));
128
+ gate.touch(Date.now());
129
+ const timer = setInterval(() => gate.tick(Date.now()), RECHECK_MS);
130
+ return { dispose() { clearInterval(timer); evs.forEach((e) => document.removeEventListener(e, touch, { capture: true })); } };
131
+ }
132
+
133
+ /* ── DOM (browser only) ── */
134
+
135
+ /** Set data-daypart on an element. */
136
+ export function applyDayPart(elm, date) {
137
+ const p = dayPartOf(date || new Date());
138
+ if (elm && elm.getAttribute('data-daypart') !== p) elm.setAttribute('data-daypart', p);
139
+ return p;
140
+ }
141
+
142
+ const SVGNS = 'http://www.w3.org/2000/svg';
143
+ const sv = (tag, attrs) => { const e = document.createElementNS(SVGNS, tag); for (const k in attrs) e.setAttribute(k, attrs[k]); return e; };
144
+
145
+ /** The daylight strip: a small arc with the sun or moon and the sunrise and sunset times. About 22px tall. Appended to `host`.
146
+ * It sets data-daypart on `root` now and every 5 minutes. Returns { dispose }. */
147
+ export function mountDaypart(root, host, opts) {
148
+ const o = opts || {};
149
+ const lat = o.lat == null ? DEFAULT_LATITUDE : o.lat;
150
+ const W = 56; const H = 18;
151
+ const arc = sv('path', { class: 'dl-arc', d: 'M4 14 A24 12 0 0 1 52 14', fill: 'none' });
152
+ const base = sv('line', { class: 'dl-base', x1: 2, y1: 14.5, x2: 54, y2: 14.5 });
153
+ const body = sv('circle', { class: 'dl-body', r: 3, cx: 4, cy: 14 });
154
+ const svg = sv('svg', { class: 'dl-svg', viewBox: '0 0 ' + W + ' ' + H, width: W, height: H, 'aria-hidden': 'true', focusable: 'false' });
155
+ svg.append(base, arc, body);
156
+ const txt = document.createElement('span'); txt.className = 'dl-txt';
157
+ const strip = document.createElement('div');
158
+ strip.className = 'c3pg-daylight'; strip.setAttribute('data-c3pg', 'daylight'); strip.setAttribute('role', 'img');
159
+ strip.append(svg, txt);
160
+ host.append(strip);
161
+ const draw = () => {
162
+ const now = new Date(); const off = now.getTimezoneOffset();
163
+ applyDayPart(root, now);
164
+ const st = arcState(now.getTime(), lat, longitudeFromOffset(off), off);
165
+ const pt = arcPoint(st.t, W, H - 2, 4);
166
+ body.setAttribute('cx', pt.x); body.setAttribute('cy', pt.y);
167
+ strip.setAttribute('data-body', st.body);
168
+ const label = stripLabel(st, off);
169
+ txt.textContent = label;
170
+ strip.setAttribute('aria-label', (st.up ? 'Day. ' : 'Night. ') + label);
171
+ strip.title = (st.up ? 'The sun is up. ' : 'The moon is up. ') + label + ' Worked out on this device from the date and the time zone.';
172
+ };
173
+ draw();
174
+ const timer = setInterval(draw, RECHECK_MS);
175
+ return { dispose() { clearInterval(timer); strip.remove(); } };
176
+ }
@@ -12,6 +12,7 @@
12
12
  * The workspace id is ?ws= (default: the first of GET /api/workspaces). The views read some routes without a ws, so a GET to /api/
13
13
  * that names none gets the page's ws added (see withWs). No dialog is used. Presses are the views' own. */
14
14
  import { el } from './dom.js';
15
+ import { row } from './primitives.js';
15
16
  import { pmGet } from './http.js';
16
17
  import { ensureC3Css, c3ic, c3Phone, mountPmWork } from './pm-work-view.js';
17
18
  import { mountBriefing, showCard, flipBack } from './briefing-deck.js';
@@ -22,7 +23,8 @@ import { mountReleases } from './releases-view.js';
22
23
  import { mountReportFamilies } from './report-families-view.js';
23
24
  import { mountOutbox } from './outbox-panel.js';
24
25
  import { createDock } from './c3-dock.js';
25
- import { belowLine, reviewSection, handledRow, trustCard, footLine, needRow, rowApi, BX, toast, setNeedsTitle, hooks as briefHooks } from './c3-brief.js';
26
+ import { belowLine, reviewSection, handledRow, trustCard, footLine, needRow, rowApi, BX, toast, setNeedsTitle, caseFile, hooks as briefHooks } from './c3-brief.js';
27
+ import { makeXp, attachExpand } from './c3-expand.js';
26
28
  import { stopCard } from './c3-misc-panels.js';
27
29
  import { openSheet } from './c3-sheet.js';
28
30
  import { planNode, setPlanScope, dueCard, crossLinks, newInitLine, portfolioCard, readQuarter, mockPulse } from './c3-plan.js';
@@ -33,6 +35,7 @@ import { ic } from './c3-terminal-icons.js';
33
35
  import { mount as mountTickets, badge as ticketsBadge } from './c3-tickets.js';
34
36
  import { mount as mountDocs, badge as docsBadge } from './c3-docs.js';
35
37
  import { mount as mountSignals, badge as signalsBadge } from './c3-signals.js';
38
+ import { mountDaypart } from './c3-daypart.js';
36
39
  import { mountTicker } from './shell-ticker.js';
37
40
  import { mountBell, openBellList } from './shell-bell.js';
38
41
  import { onBriefing, loadBriefing } from './shell-ticker.js';
@@ -143,7 +146,7 @@ const WHYS = [
143
146
  [10, 'Gestures', 'Drag a row right to finish it, left for later. The two glyphs at the edge show both; the bright one is what the agent suggests. At rest the page has almost no buttons. Keys: e, n, a, r, j, k.'],
144
147
  [11, 'Depth', 'Elevation means priority. Raised layers are things to act on. Flat layers are things to know. Glass floats above the page because it stays while you scroll.'],
145
148
  [12, 'Two columns', 'The left column is what you do. The right column is what you need to know. On a phone they merge, with the lens right after the cards.'],
146
- [13, 'Part of the day', 'The page reads differently as the day goes: Plan, Unblock, Close, Reflect. It follows the clock, and the icons let you preview another part. The same signals are re-ranked, not replaced.'],
149
+ [13, 'Part of the day', 'The page reads differently as the day goes: Plan, Unblock, Close, Reflect. It follows the clock. A new part takes over only when you pause. The same signals are re-ranked, not replaced.'],
147
150
  [14, 'Phone: one screen', 'No scrolling on the phone. A running line and the bell carry what just arrived. The four tiles open popups for the lists, so the page itself stays still. Audio is optional: the speaker reads the top three cards aloud for when your hands are busy.'],
148
151
  ];
149
152
  const WHY_REGIONS = { 1: '[data-c3pg="deck-host"]', 13: '.stance', 3: '[data-c3pg="needs"]', 5: '[data-c3pg="review-host"] > *', 6: '[data-c3pg="handled-host"] > *', 7: '[data-c3pg="cal-host"]', 8: '[data-c3pg="pulse"]', 9: '[data-c3pg="trust-host"] > *' };
@@ -259,7 +262,8 @@ function needsList() {
259
262
  last = b;
260
263
  const inc = !!S.incident;
261
264
  const needs = b && Array.isArray(b.needs) && b.needs.length ? b.needs.filter((n) => !BX.gone[n.key] && (!inc || n.hard)) : null;
262
- const key = needs ? needs.map((n) => n.key + n.headline) : ((b && b.cards) || []).map((c) => (c.key || '') + c.headline);
265
+ const own = !needs && b && Array.isArray(b.own_needs) ? b.own_needs.filter((n) => n && !BX.gone[n.key]) : []; // the PM's own overdue and due-today items: overdue first, above the engine's cards
266
+ const key = needs ? needs.map((n) => n.key + n.headline) : own.map((n) => n.key + n.headline).concat(((b && b.cards) || []).map((c) => (c.key || '') + c.headline));
263
267
  if (needs && S.pinNeed) { const at = needs.findIndex((n) => n.key === S.pinNeed); if (at > 0) needs.unshift(...needs.splice(at, 1)); } // a need the banner or the bell named rides to the top of the list: shown, not folded
264
268
  const g = JSON.stringify([key, inc, !!(b && b.kept_out), S.pinNeed || '']);
265
269
  if (g === sig) return; sig = g;
@@ -274,12 +278,15 @@ function needsList() {
274
278
  }
275
279
  const cards = ((b && b.cards) || []).map(cardView);
276
280
  quick.remove();
277
- list.replaceChildren(...(cards.length ? cards.map((v, i) => {
281
+ const ownRows = (inc ? own.filter((n) => n.overdue) : own).map((n, i) => needRow(n, i, ctx));
282
+ const cardRows = cards.map((v, i) => {
278
283
  const T = TAG_STYLE[v.tag] || TAG_STYLE['Just in'];
279
- return needRow({ key: v.key || String(i), headline: v.headline, kind: v.tag, icon: T.icon, color: T.color, time: v.deadline && v.deadline.text ? v.deadline.text : null, src: v.tag, view: v }, i, ctx);
280
- }) : [el('li', { class: 'fold' }, c3ic('check', 'sm'), 'Nothing needs you.')]));
284
+ return needRow({ key: v.key || String(i), headline: v.headline, kind: v.tag, icon: T.icon, color: T.color, time: v.deadline && v.deadline.text ? v.deadline.text : null, src: v.tag, view: v }, i + ownRows.length, ctx);
285
+ });
286
+ const all = ownRows.concat(cardRows);
287
+ list.replaceChildren(...(all.length ? all : [el('li', { class: 'fold' }, c3ic('check', 'sm'), 'Nothing needs you.')]));
281
288
  extra.replaceChildren();
282
- count.textContent = String(cards.length);
289
+ count.textContent = String(all.length);
283
290
  };
284
291
  S.refreshNeeds = () => { sig = ''; if (last) fill(last); };
285
292
  S.needsFill = fill;
@@ -290,6 +297,33 @@ function needsList() {
290
297
 
291
298
  /* portfolio pulse: the Calendar's own gantt rows (GET /api/calendar-views, quarter) as one status bar and a row per initiative */
292
299
  const PST = { 'on time': ['ok', 'On time', 'var(--success)'], 'at risk': ['warn', 'At risk', 'var(--warning)'], delayed: ['bad', 'Delayed', 'var(--destructive)'], overdue: ['bad', 'Overdue', 'var(--destructive)'], 'no plan': ['', 'No plan', 'var(--muted-foreground)'] };
300
+ const PST_CLS = { 'on time': 'pst-ok', 'at risk': 'pst-warn', delayed: 'pst-bad', overdue: 'pst-bad', 'no plan': 'pst-none' };
301
+
302
+ /* A pulse row opens in place, like a Needs you row: the same c3-expand panel and the same case component. It never changes the tab. "Open initiative" inside the case is the only way out. */
303
+ const PULSE_OPEN = new Set();
304
+ function pulseCase(g) {
305
+ const f = (g.forecast && (g.forecast.end || g.forecast.start)) || null; const p = g.promised || null;
306
+ const ev = [];
307
+ if (p) ev.push(['clock', 'Promised ' + p, '', 'ok']);
308
+ if (f) ev.push(['clock', 'Forecast ' + f, '', g.slip_days > 0 ? 'warn' : 'ok']);
309
+ if (g.slip_days > 0) ev.push(['alert', 'Slips ' + g.slip_days + ' d past the promise.', '', 'warn']);
310
+ const why = (g.pulse && g.pulse.why) || (p ? 'Forecast ' + (f || 'not set') + ' vs promised ' + p + '.' : 'No promised date' + (f ? '; forecast ' + f : '') + '.');
311
+ return caseFile({ id: 'pulse:' + g.id, sit: why, chg: ev, go: ['goinit', g.id, 'Open initiative'] });
312
+ }
313
+ function inlineRows(list, rows, watch) {
314
+ const byId = new Map(rows.map((g) => [String(g.id), g]));
315
+ const enhance = () => list.querySelectorAll('[data-c3pg-prow]').forEach((btn) => {
316
+ if (btn.hasAttribute('data-expand')) return;
317
+ const g = byId.get(btn.getAttribute('data-c3pg-prow')); if (!g) return;
318
+ const id = String(g.id); const xp = makeXp(); let built = false;
319
+ (btn.closest('.prow') || btn).after(xp); btn.setAttribute('data-a', 'pulsexp'); btn.setAttribute('aria-label', 'Show details: ' + (g.title || g.id));
320
+ attachExpand(btn, xp, { key: 'pulse-' + id, initial: PULSE_OPEN.has(id), onToggle: (on) => {
321
+ if (on) { PULSE_OPEN.add(id); if (!built) { built = true; xp.firstChild.append(pulseCase(g)); } } else PULSE_OPEN.delete(id);
322
+ } });
323
+ });
324
+ enhance();
325
+ if (watch) new MutationObserver(enhance).observe(list, { childList: true });
326
+ }
293
327
 
294
328
  function pulse() {
295
329
  const body = el('div', null, el('p', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, 'Reading the initiatives.'));
@@ -299,7 +333,7 @@ function pulse() {
299
333
  const draw = (rows, err) => {
300
334
  if (err) { body.replaceChildren(el('p', { class: 'dim mono', style: 'font-size:var(--fs-2)', role: 'status' }, 'The initiatives could not be read: ' + err)); return; }
301
335
  if (!rows.length) { body.replaceChildren(el('div', { class: 'fold' }, c3ic('moon', 'sm'), 'No initiative has a promised date yet, so there is nothing to compare with the forecast.')); return; }
302
- if (rows.some((g) => g.pulse)) { body.replaceChildren(...mockPulse(rows, S.today, () => showTab('init', true))); return; } // demo: the mockup's pulse (its own colours, reasons and mini timelines)
336
+ if (rows.some((g) => g.pulse)) { const parts = mockPulse(rows, S.today, null); body.replaceChildren(...parts); inlineRows(parts[1], rows, true); return; } // demo: the mockup's pulse (its own colours, reasons and mini timelines)
303
337
  const noPlan = rows.filter((g) => g.status === 'no plan').length;
304
338
  rows = rows.filter((g) => g.status !== 'no plan');
305
339
  if (!rows.length) { body.replaceChildren(el('div', { class: 'fold' }, c3ic('moon', 'sm'), 'No initiative has a promised date yet, so there is nothing to compare with the forecast.'), noPlan ? el('p', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, noPlan + ' initiatives have no plan date.') : null); return; }
@@ -311,12 +345,14 @@ function pulse() {
311
345
  const rowEl = (g) => {
312
346
  const k = PST[g.status] ? g.status : 'no plan'; const f = end(g.forecast); const p = g.promised || null;
313
347
  const why = p ? 'Forecast ' + (f || 'not set') + ' vs promised ' + p : 'No promised date' + (f ? '; forecast ' + f : '');
314
- return el('button', { type: 'button', class: 'prow', 'data-c3pg-prow': g.id, 'data-a': 'goinit', 'aria-label': 'Open the initiatives: ' + (g.title || g.id), onclick: () => showTab('init', true) },
315
- el('span', { class: 'dotc ' + PST[k][0] }),
316
- el('span', { style: 'min-width:0' }, el('b', { style: 'font-size:var(--fs-3)' }, g.title || g.id), el('div', { class: 'dim', style: 'font-size:var(--fs-2);white-space:nowrap;overflow:hidden;text-overflow:ellipsis' }, why)),
317
- el('span', { class: 'mono', style: 'font-size:var(--fs-2);color:' + PST[k][2] }, PST[k][1] + (g.slip_days > 0 ? ' +' + g.slip_days + ' d' : '')));
348
+ const label = 'Show details: ' + (g.title || g.id);
349
+ return row({ title: g.title || g.id, meta: [why], lead: [el('span', { class: 'dotc ' + PST[k][0] })],
350
+ trail: [el('span', { class: 'mono pst ' + PST_CLS[k] }, PST[k][1] + (g.slip_days > 0 ? ' +' + g.slip_days + ' d' : ''))],
351
+ onOpen: () => {}, openLabel: label, compact: true, attrs: { class: 'prow' },
352
+ openAttrs: { 'data-a': 'pulsexp', 'data-c3pg-prow': g.id, 'aria-label': label } });
318
353
  };
319
- body.replaceChildren(bar, el('div', null, ...rows.slice(0, 8).map(rowEl)), rows.length > 8 ? el('div', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, '+' + (rows.length - 8) + ' more in the Calendar tab') : null, noPlan ? el('div', { class: 'dim mono', style: 'font-size:var(--fs-2)' }, noPlan + ' more have no plan date.') : null);
354
+ const list = el('div', null, ...rows.slice(0, 8).map(rowEl)); inlineRows(list, rows, false);
355
+ body.replaceChildren(bar, list, rows.length > 8 ? el('div', { class: 'dim mono pnote' }, '+' + (rows.length - 8) + ' more in the Calendar tab') : null, noPlan ? el('div', { class: 'dim mono pnote' }, noPlan + ' more have no plan date.') : null);
320
356
  };
321
357
  readQuarter().then((j) => {
322
358
  if (!j || j.ok === false) draw([], (j && j.error) || 'unreadable'); else { S.today = j.today || ''; draw(j.gantt || []); }
@@ -617,6 +653,7 @@ async function boot() {
617
653
  const main = el('div', { class: 'c3main' }, header(), S.tickHost, tabBar(), S.pageView);
618
654
  S.grid = el('div', { class: 'c3grid' }, main, ...dock());
619
655
  root.replaceChildren(S.grid);
656
+ mountDaypart(root, root.querySelector('.c3top'));
620
657
 
621
658
  S.dock.setWorkspaces(S.workspaces, S.ws);
622
659
  mountBell(S.bellHost, { go: (_, t) => goTarget(t) });
@@ -15,7 +15,13 @@
15
15
  * threshold only. The row carries tabindex=0, data-swipe, and an aria-description naming both actions.
16
16
  *
17
17
  * Pure parts (Node-testable): swipeStep, swipeEnd, revealOf, keyAction, SWIPE.
18
+ *
19
+ * R3 (console3 quick fixes): the confirm control of a case file. On a fine pointer the long slide gives way to a compact button
20
+ * (mountHold): an act that cannot be undone needs a press and hold of 600 ms with a ring that fills; an act that can be undone is one
21
+ * click; from the keyboard, Enter opens a Confirm step for both. On touch the slide stays (slide-confirm.js).
22
+ * Pure parts: HOLD, holdMode, holdProgress, finePointer.
18
23
  */
24
+ import { button } from './primitives.js';
19
25
  export const SWIPE = { threshold: 90, lock: 8, cancelY: 12, suppressMs: 80, tracking: 0.55 };
20
26
 
21
27
  /** One pointer-move. state: null | { x, y, on, dx }. Returns the next state (null = the gesture is cancelled: it is a scroll). */
@@ -136,3 +142,89 @@ export function attachSwipe(el, opts = {}) {
136
142
  el.removeAttribute('data-swipe'); el.removeAttribute('aria-description');
137
143
  };
138
144
  }
145
+
146
+ /* ── R3: the compact confirm button for a fine pointer ──────────────────────────────────────────────────────────────────────── */
147
+ export const HOLD = { ms: 600 };
148
+
149
+ /** 'hold' for an act that cannot be undone (it sends something outside, or the option says so), 'click' for one the Undo toast reverses. */
150
+ export function holdMode(option, caseModel) {
151
+ const o = option || {}; const c = caseModel || {};
152
+ return o.irreversible || o.outward || c.outward ? 'hold' : 'click';
153
+ }
154
+
155
+ /** How much of the ring is filled after `elapsed` ms of holding: 0 to 1. */
156
+ export function holdProgress(elapsed, ms = HOLD.ms) {
157
+ const e = Number(elapsed);
158
+ if (!(e > 0)) return 0;
159
+ return Math.min(1, e / ms);
160
+ }
161
+
162
+ /** True where the main pointer is fine (a mouse or a trackpad): the compact button replaces the slide. */
163
+ export function finePointer(mm) {
164
+ const f = mm || (typeof matchMedia === 'function' ? matchMedia : null);
165
+ try { return !!(f && f('(pointer: fine)').matches); } catch { return false; }
166
+ }
167
+
168
+ const SVGNS = 'http://www.w3.org/2000/svg';
169
+ const node = (tag, attrs, ...kids) => {
170
+ const n = document.createElement(tag);
171
+ for (const k of Object.keys(attrs || {})) if (attrs[k] !== null && attrs[k] !== undefined) n.setAttribute(k, attrs[k]);
172
+ for (const c of kids) if (c) n.append(c);
173
+ return n;
174
+ };
175
+ function ringIcon(iconName) {
176
+ const svg = document.createElementNS(SVGNS, 'svg');
177
+ svg.setAttribute('class', 'hr'); svg.setAttribute('viewBox', '0 0 26 26'); svg.setAttribute('aria-hidden', 'true');
178
+ for (const cls of ['tk', 'fl']) {
179
+ const c = document.createElementNS(SVGNS, 'circle');
180
+ c.setAttribute('class', cls); c.setAttribute('cx', '13'); c.setAttribute('cy', '13'); c.setAttribute('r', '10');
181
+ svg.appendChild(c);
182
+ }
183
+ const g = document.createElementNS(SVGNS, 'svg');
184
+ g.setAttribute('class', 'ic'); g.setAttribute('x', '7'); g.setAttribute('y', '7'); g.setAttribute('width', '12'); g.setAttribute('height', '12');
185
+ const u = document.createElementNS(SVGNS, 'use'); u.setAttribute('href', '#c3i-' + iconName); g.appendChild(u);
186
+ svg.appendChild(g);
187
+ return svg;
188
+ }
189
+
190
+ /** Draw the compact confirm into host. opts: { label, mode: 'hold' | 'click', hash, alt, onConfirm(hash) }. Returns a dispose function.
191
+ * onConfirm fires once. A pointer hold of HOLD.ms (mode hold) or a pointer click (mode click) confirms; Enter or Space opens a
192
+ * Confirm step first, in both modes. Escape or Cancel closes the step. */
193
+ export function mountHold(host, { label = 'Confirm', mode = 'click', hash = null, alt = false, onConfirm } = {}) {
194
+ host.replaceChildren();
195
+ const root = node('div', { class: 'c3hold', 'data-confirm-mode': mode });
196
+ const btn = button({ label, variant: 'outline', onClick: (e) => {
197
+ if (e.detail === 0) return; // a key made this click: the key path has its own step
198
+ if (mode === 'click') fire();
199
+ }, attrs: { class: 'hb' + (alt ? ' alt' : ''), 'data-hold': mode,
200
+ 'aria-label': label + (mode === 'hold' ? '. Press and hold, or press Enter and confirm.' : '. Click, or press Enter and confirm.'),
201
+ onPointerDown: (e) => start(e), onPointerUp: () => stop(), onPointerLeave: () => stop(), onPointerCancel: () => stop() } });
202
+ btn.prepend(ringIcon(mode === 'hold' ? 'lock' : 'check'));
203
+ const hint = node('span', { class: 'hint', 'aria-live': 'polite' }, document.createTextNode(mode === 'hold' ? 'Hold to confirm.' : ''));
204
+ root.append(btn, hint);
205
+ host.appendChild(root);
206
+
207
+ let fired = false; let timer = null; let stepOpen = false;
208
+ const fire = () => { if (fired) return; fired = true; clearTimer(); root.classList.add('done'); btn.disabled = true; try { if (typeof onConfirm === 'function') onConfirm(hash); } catch { /* the caller reports its own errors */ } };
209
+ function clearTimer() { if (timer) { clearTimeout(timer); timer = null; } }
210
+ const stop = () => { clearTimer(); btn.classList.remove('holding'); };
211
+ const start = (e) => {
212
+ if (fired || stepOpen || (e.button !== undefined && e.button > 0)) return;
213
+ if (mode === 'hold') { btn.classList.add('holding'); timer = setTimeout(() => { btn.classList.remove('holding'); fire(); }, HOLD.ms); }
214
+ };
215
+ const openStep = () => {
216
+ if (fired || stepOpen) return;
217
+ stepOpen = true; stop();
218
+ const close = () => { stepOpen = false; q.remove(); btn.hidden = false; hint.hidden = false; btn.focus(); };
219
+ const yes = button({ label: 'Confirm', onClick: fire, attrs: { class: 'pill', 'data-confirm': 'yes' } });
220
+ const no = button({ label: 'Cancel', onClick: close, attrs: { class: 'pill', 'data-confirm': 'no' } });
221
+ const q = node('div', { class: 'cq', role: 'group', 'aria-label': 'Confirm ' + label }, document.createTextNode(label + '?'), yes, no);
222
+ btn.hidden = true; hint.hidden = true; root.appendChild(q);
223
+ q.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); } });
224
+ yes.focus();
225
+ };
226
+ btn.addEventListener('contextmenu', (e) => { if (mode === 'hold') e.preventDefault(); });
227
+ btn.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && !e.repeat) { e.preventDefault(); openStep(); } });
228
+ btn.addEventListener('keyup', (e) => { if (e.key === ' ') e.preventDefault(); });
229
+ return function dispose() { stop(); fired = true; host.replaceChildren(); };
230
+ }
@@ -697,7 +697,7 @@ function stageNode(host, r) {
697
697
  const PULSEK = { red: 'bad', delayed: 'bad', overdue: 'bad', amber: 'warn', 'at risk': 'warn', green: 'ok', 'on time': 'ok', park: '', 'no plan': '' };
698
698
  let pulseOn = false;
699
699
  function pulseA11y(root) {
700
- root.querySelectorAll('[data-c3pg-prow]').forEach((p) => { if (!p.hasAttribute('role')) { p.setAttribute('role', 'button'); p.tabIndex = 0; p.title = 'Open the initiative'; } });
700
+ root.querySelectorAll('[data-c3pg-prow]').forEach((p) => { if (!p.hasAttribute('role')) { p.setAttribute('role', 'button'); p.tabIndex = 0; p.title = 'Show details'; } });
701
701
  root.querySelectorAll('.pbs > span').forEach((s) => { if (!s.hasAttribute('role')) { s.setAttribute('role', 'button'); s.tabIndex = 0; s.setAttribute('aria-pressed', 'false'); } });
702
702
  }
703
703
  function pulseFilter(seg) {
@@ -705,19 +705,18 @@ function pulseFilter(seg) {
705
705
  const on = seg.getAttribute('aria-pressed') !== 'true';
706
706
  bar.querySelectorAll(':scope > span').forEach((s) => { s.setAttribute('aria-pressed', String(on && s === seg)); s.style.opacity = on && s !== seg ? '.28' : ''; });
707
707
  const box = bar.closest('[data-c3pg="pulse"]');
708
- box.querySelectorAll('[data-c3pg-prow]').forEach((p) => { const d = p.querySelector('.dotc'); const k = d ? (d.classList.contains('bad') ? 'bad' : d.classList.contains('warn') ? 'warn' : d.classList.contains('ok') ? 'ok' : '') : ''; p.hidden = on && want !== undefined && k !== want; });
708
+ box.querySelectorAll('[data-c3pg-prow]').forEach((p) => { const d = p.querySelector('.dotc'); const k = d ? (d.classList.contains('bad') ? 'bad' : d.classList.contains('warn') ? 'warn' : d.classList.contains('ok') ? 'ok' : '') : ''; p.hidden = on && want !== undefined && k !== want; const x = p.nextElementSibling; if (x && x.classList.contains('xp')) x.hidden = p.hidden; });
709
709
  }
710
710
  function installPulse(host) {
711
711
  const parent = host.parentElement; if (parent) { pulseA11y(parent); new MutationObserver(() => pulseA11y(parent)).observe(parent, { childList: true, subtree: true }); }
712
712
  if (pulseOn) return; pulseOn = true;
713
713
  const act = (t) => {
714
- const row = t.closest && t.closest('[data-c3pg-prow]'); const seg = t.closest && t.closest('[data-c3pg="pulse"] .pbs > span');
715
- if (row) { const r = [...live].find((x) => x.ctx && x.ctx.ribbon && x.data) || [...live].find((x) => x.data) || null; openInit(r && r.host, r, row.getAttribute('data-c3pg-prow'), (row.querySelector('b') || {}).textContent); return true; }
714
+ const seg = t.closest && t.closest('[data-c3pg="pulse"] .pbs > span');
716
715
  if (seg) { pulseFilter(seg); return true; }
717
716
  return false;
718
717
  };
719
718
  document.addEventListener('click', (e) => { if (act(e.target)) e.preventDefault(); });
720
- document.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && e.target.matches && e.target.matches('[data-c3pg-prow],[data-c3pg="pulse"] .pbs > span') && act(e.target)) e.preventDefault(); });
719
+ document.addEventListener('keydown', (e) => { if ((e.key === 'Enter' || e.key === ' ') && e.target.matches && e.target.matches('[data-c3pg="pulse"] .pbs > span') && act(e.target)) e.preventDefault(); });
721
720
  }
722
721
 
723
722
  /* ── the Week page: "Progress and timing" and the time grid (console3 mockup progSVG line 2716, progCard 2739, weekView2 2604, weekListPh 2620) ── */
@@ -256,6 +256,12 @@
256
256
  @media (hover:hover){
257
257
  .c3 .prow:hover{background:color-mix(in oklab,var(--muted) 70%,transparent)}
258
258
  }
259
+ .c3 .pnote{font-size:var(--fs-2)}
260
+ .c3 .pst{font:var(--fs-2) var(--mono)}
261
+ .c3 .pst-ok{color:var(--foreground)}
262
+ .c3 .pst-warn{color:var(--warning)}
263
+ .c3 .pst-bad{color:var(--destructive)}
264
+ .c3 .pst-none{color:var(--muted-foreground)}
259
265
  .c3 .cmpill{border:1px solid var(--border);border-radius:16px;padding:2px 10px;font:var(--fs-2) var(--mono);white-space:nowrap}
260
266
  .c3 .cmpill.win{border-color:var(--acc);background:var(--accw)}
261
267
  .c3 .swap{animation:c3-swap .38s cubic-bezier(.2,.7,.2,1)}
@@ -1478,3 +1484,64 @@
1478
1484
  .c3 .psess:active{transform:scale(.93)}
1479
1485
  @media (max-width:760px){.c3 .psess{min-height:44px}}
1480
1486
  .c3 .psess .c{font:var(--fs-1) var(--mono)}
1487
+
1488
+ /* Time of day (console3 quick fixes R5). data-daypart is set on the page root from the clock: morning, midday, afternoon, evening or night.
1489
+ * Each part sets two tokens, both very faint: --dp-tint (a wash over the page background, alpha 6% or less) and --dp-warm (the colour of the
1490
+ * sun and moon in the daylight strip). Neither reads --brand-mark. The values are built from theme tokens, so they follow the light and dark theme. */
1491
+ .c3[data-daypart="morning"]{--dp-tint:color-mix(in oklab,var(--warning) 4%,transparent);--dp-warm:var(--warning)}
1492
+ .c3[data-daypart="midday"]{--dp-tint:color-mix(in oklab,var(--warning) 3%,transparent);--dp-warm:var(--warning)}
1493
+ .c3[data-daypart="afternoon"]{--dp-tint:color-mix(in oklab,var(--warning) 4%,transparent);--dp-warm:var(--warning)}
1494
+ .c3[data-daypart="evening"]{--dp-tint:color-mix(in oklab,var(--destructive) 4%,transparent);--dp-warm:var(--destructive)}
1495
+ .c3[data-daypart="night"]{--dp-tint:color-mix(in oklab,var(--muted-foreground) 6%,transparent);--dp-warm:var(--foreground)}
1496
+ .c3pg[data-daypart]{background-image:linear-gradient(var(--dp-tint),var(--dp-tint))}
1497
+ /* The daylight strip in the top bar: an arc, a sun or a moon on it, and the two times. 22px tall, well under the 32px limit. */
1498
+ .c3 .c3top .c3pg-daylight{order:1;display:inline-flex;align-items:center;gap:8px;height:22px;max-height:32px;overflow:hidden;font:var(--fs-1)/1 var(--mono);color:var(--muted-foreground);white-space:nowrap}
1499
+ .c3 .c3top .tools{order:2}
1500
+ .c3 .c3pg-daylight .dl-svg{width:56px;height:18px;flex:none;overflow:visible}
1501
+ .c3 .c3pg-daylight .dl-arc{stroke:var(--border);stroke-width:1.2;stroke-dasharray:2 2}
1502
+ .c3 .c3pg-daylight .dl-base{stroke:var(--border);stroke-width:1}
1503
+ .c3 .c3pg-daylight .dl-body{fill:var(--dp-warm,var(--muted-foreground));transition:cx .8s ease,cy .8s ease}
1504
+ .c3 .c3pg-daylight[data-body="moon"] .dl-body{fill:none;stroke:var(--dp-warm,var(--muted-foreground));stroke-width:1.6}
1505
+ /* Reduced motion: the strip keeps its position and times; only the glide stops. */
1506
+ @media (prefers-reduced-motion:reduce){.c3 .c3pg-daylight .dl-body{transition:none}}
1507
+ /* ── R1 and R2: every briefing card has one fixed height; the flip turns it in place ─────────────────────────────────────────────
1508
+ * The card is a fixed box (--fc-h). The front (.ff) and the back (.fb) fill it as two faces. The front clips and clamps its text;
1509
+ * the back scrolls inside. The flip turns each face on rotateY, so the box never changes size and nothing around it moves. */
1510
+ .c3 .deck{--fc-h:312px}
1511
+ @media (max-width:520px){.c3 .deck{--fc-h:372px}}
1512
+ .c3 .fc.flipcard{display:block;height:var(--fc-h);min-height:0;padding:0;overflow:hidden}
1513
+ .c3 .fc.flipcard .ff,.c3 .fc.flipcard .fb{position:absolute;inset:0;box-sizing:border-box;padding:var(--sp-4) 20px 14px;min-height:0;backface-visibility:hidden;-webkit-backface-visibility:hidden;transition:transform .35s cubic-bezier(.2,.7,.2,1),visibility 0s linear .175s}
1514
+ .c3 .fc.flipcard .ff{display:grid;overflow:hidden;transform:perspective(900px) rotateY(0deg)}
1515
+ .c3 .fc.flipcard .fb{display:grid;align-content:start;gap:12px;overflow-y:auto;overscroll-behavior:contain;visibility:hidden;transform:perspective(900px) rotateY(180deg)}
1516
+ .c3 .fc.flipcard.back .ff{display:grid;visibility:hidden;transform:perspective(900px) rotateY(-180deg)}
1517
+ .c3 .fc.flipcard.back .fb{display:grid;visibility:visible;transform:perspective(900px) rotateY(0deg)}
1518
+ .c3 .fc.flipcard.back.on{transform:none;box-shadow:var(--sh2),var(--hi)}
1519
+ @media (hover:hover){
1520
+ .c3 .fc.flipcard.back.on:hover{transform:none;box-shadow:var(--sh2),var(--hi)}
1521
+ }
1522
+ .c3 .fc.flipcard .cf{min-height:0;overflow:hidden}
1523
+ .c3 .fc.flipcard .cf h2{display:-webkit-box;-webkit-line-clamp:3;-webkit-box-orient:vertical;overflow:hidden}
1524
+ .c3 .fc.flipcard .cf .cfs{display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
1525
+ .c3 .fc.flipcard .cf .cfx{max-height:calc(2 * 1.6em);overflow:hidden}
1526
+ .c3 .fc.flipcard .cf .cff{min-width:0}
1527
+ .c3 .fc.flipcard .cf .hero{max-height:176px;overflow:hidden}
1528
+ @media (prefers-reduced-motion:reduce){
1529
+ .c3 .fc.flipcard .ff,.c3 .fc.flipcard .fb{transition:none}
1530
+ }
1531
+
1532
+ /* ── R3: with a fine pointer the slide gives way to a compact button ─────────────────────────────────────────────────────────────
1533
+ * Press and hold for 600 ms fills the ring (an act that cannot be undone); one click does the act that can be undone; the keyboard
1534
+ * path is Enter, then a Confirm button. On touch the slide stays, and it is at most 320 px wide. */
1535
+ .c3 .c3slide .slt,.c3 .c3slide .slt-hint{max-width:320px}
1536
+ .c3 .c3hold{display:flex;gap:var(--sp-2);align-items:center;flex-wrap:wrap}
1537
+ .c3 .c3hold .hb{--tc:var(--acc);position:relative;display:inline-flex;gap:8px;align-items:center;min-height:36px;padding:4px 14px 4px 6px;border-radius:6px;border:1px solid color-mix(in oklab,var(--tc) 40%,var(--border));background:color-mix(in oklab,var(--tc) 10%,var(--card));color:var(--foreground);font:500 var(--fs-2) var(--mono);cursor:pointer;user-select:none;touch-action:none}
1538
+ .c3 .c3hold .hb.alt{--tc:var(--muted-foreground)}
1539
+ .c3 .c3hold .hb:focus-visible{outline:2px solid var(--ring);outline-offset:2px}
1540
+ .c3 .c3hold .hb svg.hr{width:26px;height:26px;flex:none}
1541
+ .c3 .c3hold .hb svg.hr circle{fill:none;stroke-width:3}
1542
+ .c3 .c3hold .hb svg.hr .tk{stroke:var(--muted)}
1543
+ .c3 .c3hold .hb svg.hr .fl{stroke:var(--tc);stroke-linecap:round;stroke-dasharray:62.8;stroke-dashoffset:62.8;transform:rotate(-90deg);transform-origin:50% 50%;transition:stroke-dashoffset .2s ease-out}
1544
+ .c3 .c3hold .hb.holding svg.hr .fl{stroke-dashoffset:0;transition:stroke-dashoffset 600ms linear}
1545
+ .c3 .c3hold .hb.off{opacity:.55;cursor:not-allowed}
1546
+ .c3 .c3hold .hint{font:var(--fs-1) var(--mono);color:var(--muted-foreground)}
1547
+ .c3 .c3hold .cq{display:flex;gap:var(--sp-2);align-items:center;flex-wrap:wrap;font:var(--fs-2) var(--mono)}
@@ -15,7 +15,7 @@
15
15
  * would send something to a person is an outward option: its slide is drawn disabled ("no channel connected"); nothing is sent.
16
16
  * In demo mode (/c3.html?demo=1, window.__c3demo) the answers of the writes are laid over the fixtures in memory (`applyOv`); the
17
17
  * agent run of a row with a suggested move is a mock that only walks the steps and shows a labelled draft.
18
- * Drawn from the items: the flow card (counts) and the lens ring (done of total). The deck is ctx.plan, a node the page builds. The
18
+ * Drawn from the items: the lens ring (done of total). The deck is ctx.plan, a node the page builds. The
19
19
  * microphone is drawn disabled.
20
20
  *
21
21
  * The helpers c3ic, c3ring, ensureC3Css and c3Phone are shared with briefing-deck.js and calendar-views.js. */
@@ -110,7 +110,6 @@ export const COPY = Object.freeze({
110
110
  addJob: 'Add a job', addJobPlaceholder: 'when I plan a release, I want honest dates, so Dana can trust them',
111
111
  groupJob: 'Group by job to be done', groupTime: 'Group by time', loading: 'Reading…',
112
112
  micOff: 'Speak is off on this line: on-device dictation runs in Notes, in the main app. Type the item here.',
113
- flow: 'Flow today', flowSub: 'capture, clarify, commit, do', flowNote: 'The counts are your own items: added today, given a date, a list or a job, in Today, done today.',
114
113
  jobsNote: 'The count is every item in the job, across all lists. Tap a job to edit or delete it.',
115
114
  legend: ['open', 'done', 'migrated', 'scheduled'], readError: 'Your items could not be read.', saveError: 'Nothing was saved.',
116
115
  removed: 'Removed.', undo: 'Undo', restored: 'Restored.', tooLate: 'Too late to undo.', drop: 'Drop it',
@@ -169,7 +168,6 @@ function applyOv(base, ov) {
169
168
  j.total = (j.total || 0) + ov.added.filter((a) => a.item.job === j.id).length;
170
169
  });
171
170
  d.jobs = jobs;
172
- if (d.flow) d.flow = d.flow.map((r) => (r[0] === 'Done' ? [r[0], r[1] + ov.done.size, r[2]] : r[0] === 'Captured' ? [r[0], r[1] + ov.added.length, r[2]] : r));
173
171
  return d;
174
172
  }
175
173
 
@@ -839,28 +837,6 @@ function lensBar(inst) {
839
837
  return [seg, el('div', { class: 'chips2' }, grp)];
840
838
  }
841
839
 
842
- /* The mockup's flowCard() (line 3340): four bars, each a count of the PM's own items. Captured = added today, Clarified = has a date, a
843
- * list or a job, Committed = in Today, Done = done today. The server's `today` is a UTC date and the stamps are UTC, so they compare. */
844
- function flowCounts(d) {
845
- const items = d.items || []; const t = d.today;
846
- const open = items.filter((i) => !i.done);
847
- return [
848
- ['Captured', items.filter((i) => String(i.created_at || '').slice(0, 10) === t).length, 'var(--muted-foreground)'],
849
- ['Clarified', open.filter((i) => i.due || i.job || i.list_override).length, 'var(--acc)'],
850
- ['Committed', ((d.lists || {}).today || []).filter((i) => !i.done).length, 'var(--warning)'],
851
- ['Done', items.filter((i) => i.done && String(i.done_at || '').slice(0, 10) === t).length, 'var(--success)'],
852
- ];
853
- }
854
- function flowCard(d) {
855
- const rows = d.flow ? d.flow.map((r) => [r[0], r[1], r[2]]) : flowCounts(d); // d.flow is optional (demo): the mockup's own four counts
856
- const mx = Math.max(1, ...rows.map((r) => r[1]));
857
- return el('section', { class: 'card', 'data-pm-flow': '' },
858
- el('h3', null, c3ic('inbox'), COPY.flow + ' ', el('span', { class: 'dim', style: 'text-transform:none;letter-spacing:0' }, COPY.flowSub)),
859
- el('div', { class: 'flow' }, rows.map((r, i) => el('div', { style: '--c:' + r[2], 'data-pm-flow-row': r[0].toLowerCase() },
860
- el('span', null, r[0]), el('span', { class: 'b' }, el('i', { style: 'width:' + Math.max(4, r[1] / mx * 100) + '%;animation-delay:' + (i * 90) + 'ms' })), el('b', { class: 'mono' }, String(r[1]))))),
861
- el('div', { class: 'dim', style: small }, d.flow_note || COPY.flowNote));
862
- }
863
-
864
840
  /* The ring at the right of the lens header: done of total, over the items in the lens (the lists keep done items; the rows hide them). */
865
841
  function lensRing(inst) {
866
842
  const all = lists(inst)[inst.lens] || [];
@@ -914,6 +890,6 @@ function draw(inst) {
914
890
  el('ul', { class: 'list', role: 'list' }, nz.map((n) => itemRow(inst, n, 'triage')))));
915
891
  }
916
892
  /* The mockup's vWork() body: .calbody > .lh + .brief.sbb > .bcols > .bcol.l (the list) and .bcol.r (the jobs card). */
917
- inst.body.replaceChildren(lh, el('div', { class: 'brief sbb' }, el('div', { class: 'bcols' }, el('div', { class: 'bcol l' }, inst.ctx.plan || null, ...main), el('div', { class: 'bcol r' }, flowCard(d), jobsCard(inst)))));
893
+ inst.body.replaceChildren(lh, el('div', { class: 'brief sbb' }, el('div', { class: 'bcols' }, el('div', { class: 'bcol l' }, inst.ctx.plan || null, ...main), el('div', { class: 'bcol r' }, jobsCard(inst)))));
918
894
  if (keep) { const n = inst.root.querySelector(keep.sel); if (n && n.focus) { try { n.focus({ preventScroll: true }); } catch { /* not focusable */ } } }
919
895
  }