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 +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/briefing-cards.md +40 -0
- package/workspace/guide/index.md +1 -0
- package/workspace/lib/needs-overdue.js +41 -0
- package/workspace/lib/routes-briefing.js +10 -1
- package/workspace/package-lock.json +2 -2
- package/workspace/package.json +1 -1
- package/workspace/public/pm/briefing-deck.js +16 -18
- package/workspace/public/pm/briefing-model.js +36 -1
- package/workspace/public/pm/c3-case.js +15 -3
- package/workspace/public/pm/c3-daypart.js +176 -0
- package/workspace/public/pm/c3-shell.js +50 -13
- package/workspace/public/pm/c3-swipe.js +92 -0
- package/workspace/public/pm/calendar-views.js +4 -5
- package/workspace/public/pm/console3.css +67 -0
- package/workspace/public/pm/pm-work-view.js +2 -26
- package/workspace/test-files.json +13 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "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": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-11T02:
|
|
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:
|
|
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:
|
|
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.
|
package/workspace/guide/index.md
CHANGED
|
@@ -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.
|
|
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.
|
|
9
|
+
"version": "0.126.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@xterm/addon-fit": "^0.11.0",
|
package/workspace/package.json
CHANGED
|
@@ -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
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
333
|
-
listenButton(() => { const x = S.data[S.lens]; return x && x.ok ? x.cards[S.i] || x.cards[0] : null; })
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
})
|
|
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(
|
|
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)) {
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
el('span', {
|
|
317
|
-
|
|
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
|
-
|
|
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 = '
|
|
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
|
|
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
|
|
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
|
|
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' },
|
|
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
|
}
|