create-pmos 0.125.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.125.0",
3
+ "version": "0.126.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": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@d18afe7
1
+ kernel-source: pmos@ef9256d
2
2
  assembled: 2026-10-11
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-11T02:26:05Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-11T02:44:29Z"}
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-10-11T02:26:05Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-11T02:44:29Z"}
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-10-11T02:26:05Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-11T02:44:29Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -0,0 +1,40 @@
1
+ ---
2
+ type: Guide
3
+ title: "Briefing: cards, time of day, Needs you and Portfolio pulse"
4
+ description: "How the Briefing cards keep one height and flip in place, how to confirm an act on a large screen, how the day part follows the clock, how a Portfolio pulse row opens inline, and how overdue items reach Needs you."
5
+ timestamp: 2026-10-11
6
+ ---
7
+
8
+ # Briefing: cards, time of day, Needs you and Portfolio pulse
9
+
10
+ ## Cards
11
+
12
+ Every card in a deck has the same height. Text that does not fit is cut short. Press a card to turn it over. The card turns in place in about a third of a second. No other card moves, and nothing below the deck moves. The back has the same size as the front and scrolls inside when it is long. With reduced motion on, the card changes at once.
13
+
14
+ The icon of a card comes from fixed rules over the kind of the card. A kind the rules do not know gets a neutral icon.
15
+
16
+ ## Confirm an act
17
+
18
+ On a screen with a mouse or trackpad, an act shows a compact button.
19
+
20
+ - An act that sends something out, or that cannot be undone, asks you to press and hold. A ring fills for 0.6 seconds, and the act runs when it is full.
21
+ - Any other act runs on one click.
22
+ - With the keyboard, press Enter or Space on the button, then choose **Confirm** or **Cancel**. Escape closes the step.
23
+
24
+ On a touch screen the slide stays. It is at most 320 pixels wide.
25
+
26
+ ## Time of day
27
+
28
+ There is no selector for the part of the day. The Briefing reads the clock and checks it every five minutes. When the part of the day changes, the cards are ranked again only after you have not touched the page for a minute and no card is open, or the next time the page loads. A thin strip in the top bar shows the sun or the moon on the day's arc, with sunrise and sunset. It is worked out on this machine from the date and the time zone. It makes no network request.
29
+
30
+ ## Portfolio pulse
31
+
32
+ Press a row of the Portfolio pulse to see it open under the row, on the Briefing. The tab does not change. The open row shows the promised date, the forecast and the slip. Press **Open initiative** inside it to go to the Initiatives tab.
33
+
34
+ ## Needs you
35
+
36
+ Needs you also lists your own items that are overdue or due today. Overdue items come first, the most overdue first, then items due today. A row says how many days overdue it is. Done items, parked items, items waiting on someone and items without a date are not listed. At most five show.
37
+
38
+ ## Work
39
+
40
+ The Work page no longer shows **Flow today**. **Jobs to be done** stays.
@@ -26,6 +26,7 @@ On a desktop the workspace is one frame. [Layout](layout.md) explains each part.
26
26
  | The initiatives of a repository, or of every repository | Initiatives. Choose **All repositories** in Repository ▾ for every repository. |
27
27
  | The sessions of a repository, by initiative | The agent dock. ⌘K → **Branches and sessions** lists the branches. |
28
28
  | The items of an initiative | Board, or Breakdown in the initiative detail |
29
+ | How the Briefing cards, day part, Portfolio pulse and overdue items work | [Briefing](briefing-cards.md) |
29
30
  | Run reports | Reports |
30
31
  | Whether a document is the same as its Google Docs or Confluence copy | Docs: ⌘K → **Docs**, or press `g d` |
31
32
  | The people you work with: who they are, your interactions, their tone, your notes and shared work | People: ⌘K → **People**, or press `g p` |
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+ /* needs-overdue.js — the PM's own work items that belong in "Needs you" because their date has come: overdue first, then due today.
3
+ *
4
+ * Pure. Input: the folded items of lib/pm-items.js and today as YYYY-MM-DD. Output: needs rows in the shape the Briefing's row builder
5
+ * (public/pm/c3-brief.js needRow) takes, so the route can send them beside the engine's cards without touching the engine.
6
+ *
7
+ * Left out: done items, parked items, items waiting on someone else, and items the PM already moved to Later (that is the answer to a row).
8
+ * Order: most days overdue first, then due today; ties by title. Each row says "overdue" or "due today" in plain words.
9
+ */
10
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
11
+ const dayNo = (s) => Math.round(Date.parse(s + 'T00:00:00Z') / 864e5);
12
+
13
+ function daysOverdue(due, today) { return dayNo(today) - dayNo(due); }
14
+
15
+ function wording(n) {
16
+ if (n > 0) return { time: 'overdue', ago: n === 1 ? '1 day overdue' : n + ' days overdue' };
17
+ return { time: 'due today', ago: 'due today' };
18
+ }
19
+
20
+ function overdueNeeds(items, today, opts = {}) {
21
+ if (!Array.isArray(items) || !DATE_RE.test(String(today || ''))) return [];
22
+ const limit = opts.limit > 0 ? opts.limit : 5;
23
+ const rows = [];
24
+ for (const it of items) {
25
+ if (!it || it.done || it.parked || it.waiting_on) continue;
26
+ if (it.list_override === 'later') continue;
27
+ if (!it.due || !DATE_RE.test(String(it.due))) continue;
28
+ const n = daysOverdue(it.due, today);
29
+ if (n < 0) continue;
30
+ const w = wording(n);
31
+ rows.push({
32
+ key: 'od-' + it.id, itemId: it.id, headline: it.title || '(untitled)', kind: n > 0 ? 'Overdue' : 'Due today', icon: 'clock',
33
+ color: n > 0 ? 'var(--destructive)' : 'var(--warning)', time: w.time, dueText: w.ago, src: 'my work', p: it.priority || null,
34
+ overdue: n > 0, days_overdue: Math.max(0, n), due: it.due,
35
+ });
36
+ }
37
+ rows.sort((a, b) => b.days_overdue - a.days_overdue || String(a.headline).localeCompare(String(b.headline)));
38
+ return rows.slice(0, limit);
39
+ }
40
+
41
+ module.exports = { overdueNeeds, daysOverdue };
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
  /* routes-briefing.js — GET /api/briefing?lens=Plan|Unblock|Close|Reflect&zoom=day|week|month[&now=ISO] (console3 wave C1).
3
3
  *
4
- * { ok, lens, zoom, cards, ticker, banner }
4
+ * { ok, lens, zoom, cards, ticker, banner, own_needs }
5
+ * own_needs: the PM's own items overdue or due today, as needs rows (lib/needs-overdue.js), overdue first. Additive; the page shows them above the cards.
5
6
  *
6
7
  * `cards` are the engine's selected cards (scripts/signals present() over readRepoFacts of the repo root), with the card
7
8
  * anatomy untouched. `ticker` is one line per top card, built from the card's own headline and deadline. `banner` is the
@@ -94,6 +95,14 @@ module.exports = {
94
95
  catch (e) { ctx.json(res, { ok: false, error: 'the signal engine failed: ' + (e && e.message ? e.message : String(e)) }, 500); return true; }
95
96
  const eng = loadEngine();
96
97
  const out = briefing(q, { root: useRoot, load: () => ({ present: eng.present, readRepoFacts: () => facts }) });
98
+ // additive: the PM's own overdue and due-today items, as needs rows beside the engine's cards (lib/needs-overdue.js); a read failure leaves the list empty
99
+ if (out.code === 200) {
100
+ try {
101
+ const pmItems = require('./pm-items.js');
102
+ const file = path.join(ctx.runtimeDir, 'pm-items', 'items.jsonl');
103
+ out.body.own_needs = require('./needs-overdue.js').overdueNeeds(pmItems.fold(pmItems.readLines(file)), iso);
104
+ } catch { out.body.own_needs = []; }
105
+ }
97
106
  ctx.json(res, out.body, out.code);
98
107
  return true;
99
108
  },
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.125.0",
3
+ "version": "0.126.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "pmos-workspace",
9
- "version": "0.125.0",
9
+ "version": "0.126.0",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "@xterm/addon-fit": "^0.11.0",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.125.0",
3
+ "version": "0.126.0",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -1,6 +1,6 @@
1
1
  /* pm/briefing-deck.js — the Briefing view (console3 wave C1), built on the console3 mockup's own markup and CSS.
2
2
  * The mockup (prototype/pmos-console-handoff/mockups/console3.html) is the UI: the stance bar with the day-part switch (.stance,
3
- * .seg.dp), the running line (.tick, .tkt), the card deck (.deck, .fc, .cf, .hero, .dk, .dkd), the flip to the case file (.fb,
3
+ * the lens follows the clock, no switch), the running line (.tick, .tkt), the card deck (.deck, .fc, .cf, .hero, .dk, .dkd), the flip to the case file (.fb,
4
4
  * .case, .chgl, .opts, .slt, .cft), the tilt and the flip animation. The styles are pm/console3.css (see pm/console3-lift.md).
5
5
  * This file keeps the data call (GET /api/briefing, the signal engine's cards) and the model (briefing-model.js) and draws
6
6
  * what the engine sent: at most five cards, none invented. A field the engine does not supply is left out.
@@ -11,6 +11,7 @@
11
11
  * plot, so their facts are listed as text and the kind is named).
12
12
  * Not registered with the registry (Today has no view registry): dest-today.js imports mountBriefing/disposeBriefing. */
13
13
  import { el } from './dom.js';
14
+ import { createIdleGate, watchIdle, applyDayPart } from './c3-daypart.js';
14
15
  import { LENSES, TAG_STYLE, PART_STYLE, cardView, dayPartFor, nextCards, swipeDir, sizeFor, caseModelOf, rawOption } from './briefing-model.js';
15
16
  import { BX, caseFile, toast } from './c3-brief.js';
16
17
  import { ensureC3Css, c3ic, c3ring, c3Phone } from './pm-work-view.js';
@@ -154,7 +155,7 @@ const pct = (x) => Math.round(Math.max(0, Math.min(1, x)) * 100);
154
155
  function eyebrow(v) {
155
156
  const T = tagStyle(v);
156
157
  if (v.eyebrow) return [el('span', { class: 'tg', style: '--tc:var(--muted-foreground)' }, c3ic(v.eyebrow.icon, 'sm'), v.eyebrow.label), v.pov ? el('span', { class: 'tag pov' }, v.pov) : null];
157
- const tg = el('span', { class: 'tg' + (T.filled ? ' f' : ''), title: T.q }, c3ic(T.icon, 'sm'), v.tag);
158
+ const tg = el('span', { class: 'tg' + (T.filled ? ' f' : ''), title: T.q }, c3ic(v.icon || T.icon, 'sm'), v.tag);
158
159
  return v.pov ? [tg, el('span', { class: 'tag pov' }, v.pov)] : tg;
159
160
  }
160
161
 
@@ -226,7 +227,7 @@ function back(S, v) {
226
227
  function card(S, v, i, n) {
227
228
  const T = tagStyle(v); const bk = back(S, v);
228
229
  const c = el('article', {
229
- class: 'fc' + (i === 0 ? ' on' : ''), tabindex: '0', role: 'button', 'aria-expanded': 'false', 'aria-description': 'card ' + (i + 1) + ' of ' + n,
230
+ class: 'fc flipcard' + (i === 0 ? ' on' : ''), tabindex: '0', role: 'button', 'aria-expanded': 'false', 'aria-description': 'card ' + (i + 1) + ' of ' + n,
230
231
  'data-a': 'cflip', 'data-bf': 'flip', 'data-bf-card': v.key || '', 'data-tag': T.key, 'data-tag-label': v.tag, style: '--tc:' + (v.eyebrow ? 'var(--muted-foreground)' : T.color),
231
232
  }, front(v), el('span', { class: 'flipg', 'aria-hidden': 'true' }, c3ic('undo', 'sm')), bk);
232
233
  bk.bind(c);
@@ -243,11 +244,10 @@ function flipTo(S, c, force) {
243
244
  if (!c.classList.contains('on') && !c.classList.contains('back')) { deck.scrollTo({ left: c.offsetLeft - deck.children[0].offsetLeft, behavior: reduced() ? 'auto' : 'smooth' }); return; }
244
245
  const toBack = force !== undefined ? force : !c.classList.contains('back');
245
246
  deck.querySelectorAll('.fc.back').forEach((o) => { if (o !== c) { o.classList.remove('back'); o.setAttribute('aria-expanded', 'false'); } });
246
- const sw = () => { c.classList.toggle('back', toBack); c.setAttribute('aria-expanded', String(toBack)); if (toBack && c.scrollIntoView) c.scrollIntoView({ block: 'nearest', behavior: reduced() ? 'auto' : 'smooth' }); };
247
+ /* R2: the card has one fixed size and both faces are in it, so the flip is a CSS transition of rotateY (console3.css .flipcard, about 350 ms).
248
+ * Nothing is measured and nothing scrolls: no sibling and nothing below the deck moves. prefers-reduced-motion turns the transition off. */
249
+ c.classList.toggle('back', toBack); c.setAttribute('aria-expanded', String(toBack));
247
250
  S.flip = toBack ? c.dataset.bfCard || '1' : null;
248
- if (reduced()) { sw(); return; }
249
- c.classList.add('fl-out');
250
- setTimeout(() => { c.classList.remove('fl-out'); sw(); c.classList.add('fl-in'); setTimeout(() => c.classList.remove('fl-in'), 320); }, 170);
251
251
  }
252
252
 
253
253
  function deckSync(S, deck) {
@@ -323,20 +323,13 @@ async function load(S, lens) {
323
323
  else { const old = S.root.querySelector('.stance'); if (old) old.replaceWith(stance(S)); } // another lens arrived: update the switch, keep the open card
324
324
  }
325
325
 
326
- const DAYPART = [['m', 'Morning', '08:12'], ['n', 'Midday', '12:30'], ['a', 'Afternoon', '16:05'], ['e', 'Evening', '18:30']]; // LENSES order: Plan, Unblock, Close, Reflect
327
- /* the mockup's stanceBar (line 2332): the lens icon, its name and line, and the day-part switch as .seg.dp icon buttons */
326
+ /* the stance bar (the mockup's stanceBar, line 2332): the lens icon and its name and line. The lens follows the clock; there is no switch (console3 R4). */
328
327
  function stance(S) {
329
328
  const P = PART_STYLE[S.lens] || PART_STYLE.Plan;
330
329
  return el('div', { class: 'stance', 'data-bf': 'lens-bar' },
331
330
  el('span', { class: 'ki' }, c3ic(P.icon)),
332
- el('div', null, el('b', { title: S.lens === S.clock ? 'Follows the clock: this is the part of the day it is now.' : null }, S.lens), el('span', { class: 'dim' }, P.sub)),
333
- listenButton(() => { const x = S.data[S.lens]; return x && x.ok ? x.cards[S.i] || x.cards[0] : null; }), // the mockup's speaker (data-a=listen) reads the card on top
334
- el('div', { class: 'seg dp', role: 'group', 'aria-label': 'Part of the day', title: 'Follows the clock. Tap to preview another part of the day.' },
335
- ...LENSES.map((l, qi) => {
336
- const Q = PART_STYLE[l]; const N = DAYPART[qi]; // the mockup's PARTS (line 2206): the part is named by its time of day, the lens by what it does
337
- return el('button', { type: 'button', 'aria-pressed': String(l === S.lens), 'data-bf-lens': l, 'data-a': 'tod', 'data-k': N[0], title: N[1] + ' · ' + N[2], 'aria-label': N[1],
338
- onClick: () => { S.lens = l; S.i = 0; S.flip = null; load(S, l); draw(S); if (S.ctx && S.ctx.onLens) S.ctx.onLens(l); } }, c3ic(Q.icon, 'sm'));
339
- })));
331
+ el('div', null, el('b', { title: 'Follows the clock.' }, S.lens), el('span', { class: 'dim' }, P.sub)),
332
+ listenButton(() => { const x = S.data[S.lens]; return x && x.ok ? x.cards[S.i] || x.cards[0] : null; })); // the mockup's speaker (data-a=listen) reads the card on top
340
333
  }
341
334
 
342
335
  /* the mockup's running line (.tick > .tkt, lines 539 to 545): the engine's ticker strings, twice so the marquee loops */
@@ -382,6 +375,11 @@ export function mountBriefing(host, ctx) {
382
375
  S.stopPhone = c3Phone(root);
383
376
  roots.set(host, S);
384
377
  draw(S);
378
+ if (!host.closest('[data-daypart]')) applyDayPart(root); // a page without the console3 shell still gets the day part tokens
379
+ /* The clock moves the lens, but never under the reader: a new part waits for 60 s without pointer or key activity and for every card to be turned back. */
380
+ const gate = createIdleGate({ current: S.lens, part: (ms) => dayPartFor(new Date(ms)), busy: () => !!S.flip || !!S.root.querySelector('.fc.back'),
381
+ apply: (l) => { if (S.dead) return; S.clock = l; S.lens = l; S.i = 0; S.flip = null; load(S, l); draw(S); if (S.ctx.onLens) S.ctx.onLens(l); } });
382
+ S.idle = watchIdle(gate);
385
383
  if (S.ctx.onLens) S.ctx.onLens(S.lens); // the page may follow the lens from the start (Reflect: "Carries over", "What I could stop")
386
384
  // the shown lens first, then the others for their preview line
387
385
  load(S, S.lens).then(() => Promise.all(LENSES.filter((l) => l !== S.lens).map((l) => load(S, l))));
@@ -410,6 +408,6 @@ export function flipBack(host) {
410
408
 
411
409
  export function disposeBriefing(host) {
412
410
  const S = roots.get(host);
413
- if (S) { S.dead = true; if (S.stopPhone) S.stopPhone(); }
411
+ if (S) { S.dead = true; if (S.stopPhone) S.stopPhone(); if (S.idle) S.idle.dispose(); }
414
412
  roots.delete(host);
415
413
  }
@@ -22,6 +22,39 @@ export const PART_STYLE = {
22
22
  Close: { icon: 'check', sub: 'Send and finish before you leave' },
23
23
  Reflect: { icon: 'moon', sub: 'What moved, what carries over' },
24
24
  };
25
+ /* R6: the icon of a card is chosen by code. Keyword rules run over the card's kind, then over its signal type; the first rule that
26
+ * matches wins, and a kind that matches no rule gets the neutral 'info' icon. Every name is a symbol of the vendored sprite
27
+ * (pm-work-view.js SPRITE, `c3i-*`). The five starting tags map to the icons TAG_STYLE gives them. No model call, no network. */
28
+ export const NEUTRAL_ICON = 'info';
29
+ export const ICON_RULES = [
30
+ [/\bsmall win\b|\bprogress\b|\bimproved?\b/, 'up'],
31
+ [/\bbig win\b|\bwin\b|\bmilestone\b|\blaunch|\bship(ped)?\b|\brelease/, 'flag'],
32
+ [/\bcritical\b|\brisks?\b|\bblock|\bincident|\boutage|\boverdue\b|\bescalat|\bregress|\bfail/, 'alert'],
33
+ [/\bsecurity\b|\bcompliance\b|\bprivacy\b|\bpolicy\b/, 'shield'],
34
+ [/\bdeadline|\bdue\b|\bmoved\b|\bslip|\bdelay|\bschedule|\bwaiting\b|\bwaits?\b/, 'clock'],
35
+ [/\bdecision|\bapprov|\btrade-?off|\bchoose|\bchoice/, 'scale'],
36
+ [/\bopportunit|\bidea\b|\binsight|\bsuggest/, 'spark'],
37
+ [/\bjira\b|\bticket|\bissue|\bnew from\b|\binbound\b/, 'inbox'],
38
+ [/\bmeeting|\bcalendar|\bevent\b|\bagenda/, 'cal'],
39
+ [/\bmail|\bemail|\breply\b|\bmessage/, 'mail'],
40
+ [/\bperson|\bpeople|\bstakeholder|\bowner\b|\bteam\b|\bcustomer/, 'user'],
41
+ [/\bokr\b|\bgoal|\bobjective|\bkey result|\btarget\b/, 'target'],
42
+ [/\bmetric|\bkpi\b|\btrend|\breport|\bnumber|\bburn/, 'report'],
43
+ [/\bpull request|\bpr\b|\bmerge|\bbranch|\bcommit|\bcode\b|\brepo/, 'branch'],
44
+ [/\bdoc\b|\bdocs\b|\bspec\b|\bprd\b|\bnote/, 'doc'],
45
+ [/\bjust in\b|\bnew\b|\bnews\b|\bupdate|\bsignal|\bchanged?\b/, 'bolt'],
46
+ ];
47
+ /** The icon name for a card of this kind and signal type. Pure. Unknown or empty gives the neutral icon. */
48
+ export function iconFor(kind, signalType) {
49
+ for (const raw of [kind, signalType]) {
50
+ const t = String(raw === null || raw === undefined ? '' : raw).toLowerCase().replace(/[_-]+/g, ' ');
51
+ if (!t.trim()) continue;
52
+ const hit = ICON_RULES.find((r) => r[0].test(t));
53
+ if (hit) return hit[1];
54
+ }
55
+ return NEUTRAL_ICON;
56
+ }
57
+
25
58
  export const HEROES = ['line', 'big-number', 'ring', 'bars', 'two-bars', 'rows', 'mini-graph'];
26
59
 
27
60
  /** 'L' at 500 and up, 'M' from 400 to 499, 'S' under 400. */
@@ -52,6 +85,8 @@ export function cardView(card) {
52
85
  const w = c.why || null;
53
86
  return {
54
87
  key: c.key || null,
88
+ kind: String(c.kind || c.tag || tag),
89
+ icon: iconFor(c.kind || c.tag || tag, c.signal || c.signalType || c.type),
55
90
  tag, mark: TAG_MARK[tag], pinned: !!c.pinned,
56
91
  headline: String(c.headline || ''),
57
92
  hero: { kind, points: Array.isArray(h.points) ? h.points.map(num).filter((x) => x !== null) : null },
@@ -136,7 +171,7 @@ export function needCaseOf(n) {
136
171
  if (n.view) return caseModelOf(n.view, { sim: !!n.sim }); // a real row is one engine card: its case is the card's own
137
172
  return {
138
173
  id: 'need:' + n.key, situation: n.headline,
139
- evidence: [{ icon: 'clock', text: 'Due ' + (n.time || 'soon'), source: n.src || '' }],
174
+ evidence: [{ icon: 'clock', text: n.dueText ? n.dueText : 'Due ' + (n.time || 'soon'), source: n.src || '' }],
140
175
  options: [], outward: true, outboxReason: NO_ROUTE,
141
176
  };
142
177
  }
@@ -15,6 +15,9 @@
15
15
  * options `.opts` buttons `.opt`, the recommended one first with `.rec`, its confidence ("Recommended · 82%") and a
16
16
  * `.conf` bar; each carries its effects (`.fx`: icon + text, tone). One is pressed (aria-pressed,
17
17
  * role=radio): the recommended one until the person picks another.
18
+ * confirm `.c3hold` R3: where the main pointer is fine, the slide below is replaced by a compact button: press and hold 600 ms
19
+ * (a ring fills) for an act that cannot be undone, one click for one that can, Enter then Confirm from
20
+ * the keyboard (c3-swipe.js mountHold, holdMode). `opts.fine` forces the choice; the default asks matchMedia.
18
21
  * slide `.slt` the slide-to-confirm (slide-confirm.js mountSlide). "Slide to choose this instead" and the grey
19
22
  * `.alt` look when the pick is not the recommended one. It is bound to the picked option: its
20
23
  * confirm hands back `hash` = `<case id>|<option id>`, the content the person saw.
@@ -26,6 +29,7 @@
26
29
  import { el } from './dom.js';
27
30
  import { c3ic } from './pm-work-view.js';
28
31
  import { mountSlide, disposeSlide } from './slide-confirm.js';
32
+ import { mountHold, holdMode, finePointer } from './c3-swipe.js';
29
33
 
30
34
  /** The claim labels of the mockup (C3.LAB): [icon, tag, tone]. */
31
35
  export const LABELS = {
@@ -62,7 +66,7 @@ function effectOf(fx) {
62
66
  function optionOf(o, i) {
63
67
  const x = o || {};
64
68
  return { id: str(x.id !== undefined ? x.id : 'o' + i), label: str(x.l !== undefined ? x.l : x.label), rec: !!(x.rec || x.recommended),
65
- effects: (x.fx || x.effects || []).map(effectOf), outward: !!x.outward, confidence: x.conf !== undefined || x.confidence !== undefined ? pct(x.conf !== undefined ? x.conf : x.confidence) : null };
69
+ effects: (x.fx || x.effects || []).map(effectOf), outward: !!x.outward, irreversible: !!x.irreversible, confidence: x.conf !== undefined || x.confidence !== undefined ? pct(x.conf !== undefined ? x.conf : x.confidence) : null };
66
70
  }
67
71
 
68
72
  /** The canonical case. Exactly one option is the recommended one: the first marked, else the first (the mockup's recId). */
@@ -127,12 +131,20 @@ export function renderCase(model, opts = {}) {
127
131
  o.rec ? el('div', { class: 'conf' }, el('i', { style: 'width:' + c.confidence + '%' })) : null);
128
132
  }));
129
133
  };
134
+ let disposeHold = null;
130
135
  const drawSlide = () => {
131
136
  disposeSlide(slideHost);
137
+ if (disposeHold) { disposeHold(); disposeHold = null; }
132
138
  slideHost.replaceChildren();
133
139
  const s = slideFor(c, picked, { outbox });
134
140
  if (s.none) return;
135
- if (s.enabled) {
141
+ const fine = opts.fine !== undefined ? !!opts.fine : finePointer();
142
+ if (s.enabled && fine) {
143
+ const trk = el('div', { class: 'c3confirm', 'data-case-slt': c.id || null });
144
+ slideHost.append(trk);
145
+ disposeHold = mountHold(trk, { label: s.option.label || s.label, mode: holdMode(s.option, c), hash: c.id + '|' + s.option.id, alt: s.option.id !== c.recId,
146
+ onConfirm: (hash) => { if (opts.onConfirm) opts.onConfirm({ option: s.option, hash, case: c }); } });
147
+ } else if (s.enabled) {
136
148
  mountSlide(slideHost, { label: s.label, hash: c.id + '|' + s.option.id, onConfirm: (hash) => { if (opts.onConfirm) opts.onConfirm({ option: s.option, hash, case: c }); } });
137
149
  const trk = slideHost.querySelector('.slt');
138
150
  if (trk && s.option.id !== c.recId) trk.classList.add('alt');
@@ -168,6 +180,6 @@ export function renderCase(model, opts = {}) {
168
180
  goBtn));
169
181
  }
170
182
  /* a handle for the caller: read or set the pick, and tear the slide down when the case leaves the page */
171
- root.c3case = { get picked() { return picked; }, pick, model: c, dispose() { if (slideHost) disposeSlide(slideHost); } };
183
+ root.c3case = { get picked() { return picked; }, pick, model: c, dispose() { if (slideHost) disposeSlide(slideHost); if (disposeHold) { disposeHold(); disposeHold = null; } } };
172
184
  return root;
173
185
  }
@@ -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
  }
@@ -59,7 +59,10 @@
59
59
  "plan-tree.test.js",
60
60
  "item-cli.test.js",
61
61
  "plan-routes.test.js",
62
- "glossary.test.js"
62
+ "glossary.test.js",
63
+ "c3-daypart.test.js",
64
+ "briefing-icons.test.js",
65
+ "needs-overdue.test.js"
63
66
  ],
64
67
  "panels": [
65
68
  "pm-shell-test.js",
@@ -116,7 +119,9 @@
116
119
  "pm-r30-test.js",
117
120
  "pm-breakdown-test.js",
118
121
  "pm-breakdown-phone-test.js",
119
- "first-paint-count.test.js"
122
+ "first-paint-count.test.js",
123
+ "pm-card-layout-test.js",
124
+ "pm-portfolio-inline-test.js"
120
125
  ],
121
126
  "ui": [
122
127
  "ui-test.js"
@@ -230,7 +235,12 @@
230
235
  "glossary.test.js": 1,
231
236
  "pm-breakdown-test.js": 62,
232
237
  "pm-breakdown-phone-test.js": 19,
233
- "first-paint-count.test.js": 15
238
+ "first-paint-count.test.js": 15,
239
+ "c3-daypart.test.js": 1,
240
+ "briefing-icons.test.js": 1,
241
+ "needs-overdue.test.js": 1,
242
+ "pm-card-layout-test.js": 20,
243
+ "pm-portfolio-inline-test.js": 15
234
244
  },
235
245
  "_comment_seconds": "Measured wall time of each suite run alone (4-CPU sandbox, 2026-10-05). Only the ORDER uses it: the slowest suites start first, and --shard deals suites to the least-loaded shard so the shards finish together. A wrong or missing number never skips a suite; a missing one counts as 10."
236
246
  }