create-pmos 0.52.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +2 -2
- package/payload/mods/pmos-cockpit/README.md +13 -0
- package/payload/mods/pmos-cockpit/hooks/lib/commands.mjs +2 -1
- package/payload/mods/pmos-cockpit/hooks/lib/grants.mjs +41 -11
- package/payload/mods/pmos-cockpit/hooks/lib/guard.mjs +228 -8
- package/payload/mods/pmos-cockpit/hooks/register.tsx +11 -3
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/scripts/plan-items-stage.js +271 -0
- package/payload/scripts/pmos-log.sh +18 -7
- package/payload/scripts/workflow-state.js +28 -2
- package/payload/state/discoveries.md +1 -1
- package/payload/state/log.md +1 -1
- package/workspace/bin/pmos.js +15 -1
- package/workspace/cut-stub.js +40 -0
- package/workspace/first-paint-audit.js +145 -0
- package/workspace/fixtures/capture/requirements/capture-2026-10-04-1.md +10 -0
- package/workspace/fixtures/initiatives-api/list.json +176 -0
- package/workspace/fixtures/initiatives-api/post-403.json +5 -0
- package/workspace/fixtures/initiatives-api/post-ok.json +10 -0
- package/workspace/fixtures/initiatives-api/roadmap.json +208 -0
- package/workspace/fixtures/initiatives-api/sheet.json +112 -0
- package/workspace/fixtures/report-api/draft.json +177 -0
- package/workspace/fixtures/reports-page/draft-biweekly-2026-10-16.md +97 -0
- package/workspace/fixtures/today-api/provider-rows.json +204 -0
- package/workspace/fixtures/today-api/seam-probe-kinds.json +5 -0
- package/workspace/guide/board.md +81 -0
- package/workspace/guide/breakdown.md +154 -0
- package/workspace/guide/command-palette.md +75 -0
- package/workspace/guide/index.md +27 -17
- package/workspace/guide/initiatives-roadmap.md +130 -0
- package/workspace/guide/initiatives-screen.md +89 -0
- package/workspace/guide/initiatives-timeline.md +57 -0
- package/workspace/guide/integrated-work.md +94 -0
- package/workspace/guide/knowledge-graph.md +3 -68
- package/workspace/guide/layout.md +98 -32
- package/workspace/guide/live-notes.md +3 -3
- package/workspace/guide/pm-only-acts.md +89 -0
- package/workspace/guide/pm-only-lines.md +61 -0
- package/workspace/guide/reports-page.md +80 -0
- package/workspace/guide/reports.md +73 -0
- package/workspace/guide/timeline.md +70 -0
- package/workspace/guide/today.md +85 -0
- package/workspace/guide/validate-with-typesafe.md +1 -1
- package/workspace/lib/actor.js +91 -0
- package/workspace/lib/board-cache.js +32 -2
- package/workspace/lib/board-cli.js +108 -0
- package/workspace/lib/board-fold.js +232 -0
- package/workspace/lib/breakdown-export.js +80 -0
- package/workspace/lib/capture.js +98 -0
- package/workspace/lib/engine/plan-items-stage.js +271 -0
- package/workspace/lib/engine/workflow-state.js +28 -2
- package/workspace/lib/factory-join.js +158 -0
- package/workspace/lib/grant-mint.js +113 -0
- package/workspace/lib/health-rules-factory.js +92 -0
- package/workspace/lib/health-rules-plan.js +164 -0
- package/workspace/lib/health.js +79 -0
- package/workspace/lib/initiative-cli.js +222 -0
- package/workspace/lib/initiative-display.js +183 -0
- package/workspace/lib/initiative-needs.js +127 -0
- package/workspace/lib/initiative-rows.js +206 -0
- package/workspace/lib/initiative-writer.js +141 -0
- package/workspace/lib/issues.js +206 -0
- package/workspace/lib/item-cli.js +195 -0
- package/workspace/lib/item-spans.js +137 -0
- package/workspace/lib/measure-cli.js +100 -0
- package/workspace/lib/measurements.js +189 -0
- package/workspace/lib/mobile-nav.js +57 -53
- package/workspace/lib/needs-providers.js +120 -0
- package/workspace/lib/placements.js +440 -0
- package/workspace/lib/plan-items.js +922 -0
- package/workspace/lib/plan-ledgers.js +85 -0
- package/workspace/lib/plan-rules.js +72 -0
- package/workspace/lib/plan-tree.js +367 -0
- package/workspace/lib/pm-guard.js +174 -0
- package/workspace/lib/pm-lines-needs.js +331 -0
- package/workspace/lib/pmos-verbs.js +66 -0
- package/workspace/lib/proactive.js +7 -4
- package/workspace/lib/report-cli.js +152 -0
- package/workspace/lib/report-draft.js +579 -0
- package/workspace/lib/report-needs.js +213 -0
- package/workspace/lib/route-modules.js +128 -0
- package/workspace/lib/routes-board.js +91 -0
- package/workspace/lib/routes-capture.js +52 -0
- package/workspace/lib/routes-initiatives.js +371 -0
- package/workspace/lib/routes-reports.js +163 -0
- package/workspace/lib/routes-timeline.js +197 -0
- package/workspace/lib/routes-today.js +32 -0
- package/workspace/lib/today-cli.js +99 -0
- package/workspace/lib/today.js +333 -0
- package/workspace/lib/triage.js +163 -0
- package/workspace/lib/verify-work-cli.js +81 -0
- package/workspace/lib/work-check-needs.js +99 -0
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +4 -5
- package/workspace/public/index.html +29 -19
- package/workspace/public/pm/app.js +299 -30
- package/workspace/public/pm/bd-board.js +113 -0
- package/workspace/public/pm/bd-drag.js +59 -0
- package/workspace/public/pm/bd-model.js +152 -0
- package/workspace/public/pm/bd-timeline.js +233 -0
- package/workspace/public/pm/bd-tree.js +244 -0
- package/workspace/public/pm/board-copy.js +57 -0
- package/workspace/public/pm/breakdown-api.js +58 -0
- package/workspace/public/pm/breakdown-copy.js +138 -0
- package/workspace/public/pm/components.css +3054 -0
- package/workspace/public/pm/cycle-review.js +125 -0
- package/workspace/public/pm/data.js +40 -187
- package/workspace/public/pm/dest-board.js +438 -0
- package/workspace/public/pm/dest-initiatives.js +134 -0
- package/workspace/public/pm/dest-reports.js +270 -0
- package/workspace/public/pm/dest-settings.js +217 -0
- package/workspace/public/pm/dest-today.js +176 -0
- package/workspace/public/pm/dialogs.js +88 -0
- package/workspace/public/pm/display-word.js +56 -0
- package/workspace/public/pm/dock.js +105 -0
- package/workspace/public/pm/fixture.js +8 -227
- package/workspace/public/pm/frame-copy.js +94 -0
- package/workspace/public/pm/handoff-box.js +82 -0
- package/workspace/public/pm/http.js +88 -0
- package/workspace/public/pm/icons.js +53 -0
- package/workspace/public/pm/initiative-list.js +218 -0
- package/workspace/public/pm/initiative-sheet.js +261 -0
- package/workspace/public/pm/initiatives-api.js +59 -0
- package/workspace/public/pm/initiatives-copy.js +215 -0
- package/workspace/public/pm/initiatives-timeline-copy.js +59 -0
- package/workspace/public/pm/initiatives-timeline.js +410 -0
- package/workspace/public/pm/initiatives-ui.js +123 -0
- package/workspace/public/pm/item-sheet.js +251 -0
- package/workspace/public/pm/keep-card.js +204 -0
- package/workspace/public/pm/lifecycle.js +7 -1
- package/workspace/public/pm/mobile.css +49 -20
- package/workspace/public/pm/mobile.js +157 -29
- package/workspace/public/pm/modes-live.js +7 -883
- package/workspace/public/pm/modes.js +32 -1024
- package/workspace/public/pm/new-initiative-sheet.js +74 -0
- package/workspace/public/pm/next-step-ui.js +3 -2
- package/workspace/public/pm/palette-copy.js +51 -0
- package/workspace/public/pm/palette.js +312 -0
- package/workspace/public/pm/panel-breakdown.js +702 -0
- package/workspace/public/pm/panel-cockpit.js +63 -295
- package/workspace/public/pm/panel-detail.js +25 -172
- package/workspace/public/pm/panel-initiatives.js +1 -19
- package/workspace/public/pm/panel-repos.js +31 -81
- package/workspace/public/pm/panel-tabs.js +197 -110
- package/workspace/public/pm/panel-trees.js +5 -2
- package/workspace/public/pm/portfolio-report.js +61 -0
- package/workspace/public/pm/primitives.js +656 -0
- package/workspace/public/pm/print.css +33 -0
- package/workspace/public/pm/provider-cards.js +136 -0
- package/workspace/public/pm/registry.js +194 -0
- package/workspace/public/pm/release-card.js +110 -0
- package/workspace/public/pm/report-view.js +309 -0
- package/workspace/public/pm/reports-copy.js +114 -0
- package/workspace/public/pm/roadmap.js +271 -0
- package/workspace/public/pm/sig-watch.js +41 -0
- package/workspace/public/pm/state.js +8 -4
- package/workspace/public/pm/status-banner.js +13 -5
- package/workspace/public/pm/tab-doc.js +2 -4
- package/workspace/public/pm/tab-term.js +12 -2
- package/workspace/public/pm/tab-web.js +3 -2
- package/workspace/public/pm/timeline-copy.js +48 -0
- package/workspace/public/pm/today-cards.js +244 -0
- package/workspace/public/pm/today-copy.js +97 -0
- package/workspace/public/pm/tokens.css +16 -18
- package/workspace/public/pm/topbar.js +102 -0
- package/workspace/public/pm/triage-card.js +60 -0
- package/workspace/public/pm/undo-hold.js +103 -0
- package/workspace/public/pm/view-addrepo.js +2 -1
- package/workspace/public/pm/view-livenotes.js +3 -1
- package/workspace/public/pm/view-today.js +7 -162
- package/workspace/public/vendor/Sortable.min.js +2 -0
- package/workspace/public/vendor/lucide-sprite.js +79 -0
- package/workspace/replaced-tests.json +2864 -0
- package/workspace/run-tests.js +56 -5
- package/workspace/server.js +312 -36
- package/workspace/sheet-answer.js +61 -0
- package/workspace/skills/measure.skill +41 -0
- package/workspace/skills/report-biweekly.skill +23 -25
- package/workspace/skills/tracker-refresh.skill +50 -0
- package/workspace/test-files.json +19 -3
- package/workspace/test-lists/base.json +6 -0
- package/workspace/test-lists/run2-a.json +6 -0
- package/workspace/test-lists/run2-b.json +13 -0
- package/workspace/test-lists/run2-c.json +19 -0
- package/workspace/test-lists/run2-d.json +22 -0
- package/workspace/test-lists/run3-0.json +5 -0
- package/workspace/test-lists/run3-a.json +5 -0
- package/workspace/test-lists/run3-b.json +6 -0
- package/workspace/test-lists/run3-c.json +15 -0
- package/workspace/test-lists/run3-d.json +6 -0
- package/workspace/test-lists/run4-a.json +8 -0
- package/workspace/test-lists/run4-b.json +6 -0
- package/workspace/test-lists/run4-c.json +13 -0
- package/workspace/test-lists/run4-d.json +9 -0
- package/workspace/test-lists/run5-a.json +6 -0
- package/workspace/test-lists/run5-b.json +6 -0
- package/workspace/test-lists/run5-c.json +15 -0
- package/workspace/test-run.js +27 -1
- package/workspace/third-party/README.md +44 -0
- package/workspace/third-party/lucide-static-1.52.0.LICENSE +43 -0
- package/workspace/third-party/sortablejs-1.15.7.LICENSE +21 -0
- package/workspace/design-parity.js +0 -673
- package/workspace/public/pm/tab-task.js +0 -86
- package/workspace/public/pm/view-guide.js +0 -141
- package/workspace/public/pm/view-knowledge.js +0 -225
- package/workspace/public/pm/view-memory.js +0 -122
- package/workspace/public/pm/view-portfolio.js +0 -153
- package/workspace/public/pm/view-reports.js +0 -190
- package/workspace/public/pm/view-scheduled.js +0 -161
- package/workspace/public/pm/view-skills.js +0 -216
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
/* pm/primitives.js — the R27 primitives the Breakdown needs (pm-os-absorb Run 1).
|
|
2
|
+
*
|
|
3
|
+
* Every `pmc-` class name is written in this file (and the icon class in icons.js) and nowhere else. A module that needs
|
|
4
|
+
* to find a primitive in the DOM uses the selector constants in `SEL`; a module that needs a primitive calls a factory.
|
|
5
|
+
* The conformance gate (workspace/conformance-gate.js, R28-9) fails any other module that writes `pmc-`, so a primitive
|
|
6
|
+
* cannot be restyled or rebuilt by hand somewhere else.
|
|
7
|
+
*
|
|
8
|
+
* The look is components.css's primitives section: radius 0, neutral primary, uppercase tracked labels on buttons and
|
|
9
|
+
* tabs, underlined fields, 32px controls and 36px rows, 44px and 48px under a coarse pointer. Colour comes only from
|
|
10
|
+
* tokens.css.
|
|
11
|
+
*
|
|
12
|
+
* Overlays (the sheet, menus, toasts) live in one layer, `body > .pmc-layer > .pmc-layer__theme`, which mirrors the app
|
|
13
|
+
* root's `data-pm-theme` so the tokens resolve to the same theme. The sheet is a modal <dialog>; menus and toasts are
|
|
14
|
+
* popovers. Each Escape handler here stops propagation, because app.js closes the active tab on an Escape that reaches
|
|
15
|
+
* the window.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { el } from './dom.js';
|
|
19
|
+
import { icon } from './icons.js';
|
|
20
|
+
|
|
21
|
+
/** Selectors for the primitives, for the modules that query the DOM (drag handles, filters, tests). */
|
|
22
|
+
export const SEL = Object.freeze({
|
|
23
|
+
layer: '.pmc-layer',
|
|
24
|
+
theme: '.pmc-layer__theme',
|
|
25
|
+
sprite: '.pmc-sprite',
|
|
26
|
+
icon: 'svg.pmc-icon',
|
|
27
|
+
btn: '.pmc-btn',
|
|
28
|
+
primary: '.pmc-btn--primary',
|
|
29
|
+
row: '.pmc-row',
|
|
30
|
+
owed: '.pmc-row--owed',
|
|
31
|
+
rowOpen: '.pmc-row__open',
|
|
32
|
+
rowTitle: '.pmc-row__title',
|
|
33
|
+
rowMeta: '.pmc-row__meta',
|
|
34
|
+
card: '.pmc-card',
|
|
35
|
+
cardOpen: '.pmc-card__open',
|
|
36
|
+
cardTitle: '.pmc-card__title',
|
|
37
|
+
cardMeta: '.pmc-card__meta',
|
|
38
|
+
title: '.pmc-row__title, .pmc-card__title',
|
|
39
|
+
meta: '.pmc-row__meta, .pmc-card__meta',
|
|
40
|
+
signal: '.pmc-signal',
|
|
41
|
+
drag: '.pmc-drag',
|
|
42
|
+
tabs: '.pmc-tabs',
|
|
43
|
+
tab: '.pmc-tabs__tab',
|
|
44
|
+
menu: '.pmc-menu',
|
|
45
|
+
menuItem: '.pmc-menu__item',
|
|
46
|
+
sheet: '.pmc-sheet',
|
|
47
|
+
sheetBody: '.pmc-sheet__body',
|
|
48
|
+
sheetActions: '.pmc-sheet__actions',
|
|
49
|
+
toast: '.pmc-toast',
|
|
50
|
+
fieldInput: '.pmc-field__input',
|
|
51
|
+
needsDot: '.pmc-needs-dot',
|
|
52
|
+
needsCount: '.pmc-needs-count',
|
|
53
|
+
meter: '.pmc-meter',
|
|
54
|
+
tlbar: '.pmc-tlbar',
|
|
55
|
+
tlbarSeg: '.pmc-tlbar__seg',
|
|
56
|
+
tlbarEnd: '.pmc-tlbar__end',
|
|
57
|
+
tlbarMilestone: '.pmc-tlbar__milestone',
|
|
58
|
+
tlbarToday: '.pmc-tlbar__today',
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/* ── stylesheet and layer ─────────────────────────────────────────────────────────────────────────────────────────── */
|
|
62
|
+
|
|
63
|
+
/** Link /pm/components.css once. index.html is not edited: the sheet arrives with the first Breakdown. */
|
|
64
|
+
export function ensureComponentsCss() {
|
|
65
|
+
if (typeof document === 'undefined' || document.querySelector('link[data-pmc]')) return;
|
|
66
|
+
const l = document.createElement('link');
|
|
67
|
+
l.rel = 'stylesheet';
|
|
68
|
+
l.href = '/pm/components.css';
|
|
69
|
+
l.setAttribute('data-pmc', '');
|
|
70
|
+
document.head.appendChild(l);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
let layerTheme = null;
|
|
74
|
+
let themeWatch = null;
|
|
75
|
+
|
|
76
|
+
function themeSource() {
|
|
77
|
+
return document.querySelector('body > [data-pm-theme]') || document.querySelector('[data-pm-theme]:not(.pmc-layer__theme)');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The overlay layer: `body > div.pmc-layer > div.pmc-layer__theme[data-pm-theme]`, created once. The outer div carries no
|
|
82
|
+
* theme attribute, because mobile.css places `body > [data-pm-theme]` in the phone grid. Returns the theme div.
|
|
83
|
+
*/
|
|
84
|
+
export function layer() {
|
|
85
|
+
if (typeof document === 'undefined' || !document.body) return null;
|
|
86
|
+
if (layerTheme && layerTheme.isConnected) return layerTheme;
|
|
87
|
+
const outer = document.createElement('div');
|
|
88
|
+
outer.className = 'pmc-layer';
|
|
89
|
+
layerTheme = document.createElement('div');
|
|
90
|
+
layerTheme.className = 'pmc-layer__theme';
|
|
91
|
+
outer.appendChild(layerTheme);
|
|
92
|
+
document.body.appendChild(outer);
|
|
93
|
+
const mirror = () => {
|
|
94
|
+
const src = themeSource();
|
|
95
|
+
layerTheme.setAttribute('data-pm-theme', (src && src.getAttribute('data-pm-theme')) || 'dark');
|
|
96
|
+
};
|
|
97
|
+
mirror();
|
|
98
|
+
if (themeWatch) themeWatch.disconnect();
|
|
99
|
+
const src = themeSource();
|
|
100
|
+
if (src && typeof MutationObserver !== 'undefined') {
|
|
101
|
+
themeWatch = new MutationObserver(mirror);
|
|
102
|
+
themeWatch.observe(src, { attributes: true, attributeFilter: ['data-pm-theme'] });
|
|
103
|
+
}
|
|
104
|
+
return layerTheme;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The element an overlay opened now belongs in: the open sheet (so it is not inert under the modal), else the layer. */
|
|
108
|
+
function overlayHost(anchor) {
|
|
109
|
+
const inSheet = anchor && anchor.closest ? anchor.closest('dialog.pmc-sheet[open]') : null;
|
|
110
|
+
if (inSheet) return inSheet;
|
|
111
|
+
const open = layer() && layer().querySelectorAll('dialog.pmc-sheet[open]');
|
|
112
|
+
return open && open.length ? open[open.length - 1] : layer();
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
let uid = 0;
|
|
116
|
+
const nextId = (p) => p + '-' + (++uid);
|
|
117
|
+
const cx = (...names) => names.filter(Boolean).join(' ');
|
|
118
|
+
/** Spread `attrs` onto a primitive while keeping its own classes: `attrs.class` is added to them, never put in their place. */
|
|
119
|
+
const withClass = (base, attrs) => {
|
|
120
|
+
const out = { ...(attrs || {}) };
|
|
121
|
+
const extra = out.class; delete out.class;
|
|
122
|
+
out.class = cx(base, extra);
|
|
123
|
+
return out;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/* ── button, icon button ─────────────────────────────────────────────────────────────────────────────────────────── */
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* button({ label, variant: 'primary'|'outline'|'ghost'|'danger', size: 'sm'?, icon?, iconEnd?, block?, onClick?, attrs? })
|
|
130
|
+
* A pane has at most one primary.
|
|
131
|
+
*/
|
|
132
|
+
export function button({ label, variant = 'outline', size, icon: lead, iconEnd, block, onClick, attrs, disabled } = {}) {
|
|
133
|
+
return el('button', {
|
|
134
|
+
type: 'button', onClick, disabled: !!disabled,
|
|
135
|
+
...withClass(cx('pmc-btn', 'pmc-btn--' + variant, size && 'pmc-btn--' + size, block && 'pmc-btn--block'), attrs),
|
|
136
|
+
}, lead ? icon(lead) : null, el('span', { class: 'pmc-btn__label' }, label), iconEnd ? icon(iconEnd) : null);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** iconButton({ icon, ariaLabel, onClick?, attrs?, expanded?, pressed? }). Throws without an aria-label. */
|
|
140
|
+
export function iconButton({ icon: name, ariaLabel, onClick, attrs, expanded, size } = {}) {
|
|
141
|
+
if (!ariaLabel || !String(ariaLabel).trim()) throw new Error('iconButton(' + name + ') needs an ariaLabel');
|
|
142
|
+
const b = el('button', {
|
|
143
|
+
type: 'button', 'aria-label': ariaLabel, title: ariaLabel, onClick,
|
|
144
|
+
...withClass(cx('pmc-btn', 'pmc-btn--ghost', 'pmc-btn--icon', size && 'pmc-btn--' + size), attrs),
|
|
145
|
+
}, icon(name));
|
|
146
|
+
if (expanded !== undefined) b.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
|
147
|
+
return b;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/* ── field ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* field({ label, kind: 'text'|'title'|'textarea'|'select'|'date'|'check', value, options:[{value,label}], placeholder,
|
|
154
|
+
* onCommit(value), hideLabel?, attrs? })
|
|
155
|
+
* Text commits on Enter or blur when the value changed; a textarea commits on blur; select, date and check commit on
|
|
156
|
+
* change. Escape in a text input with an edit restores the last committed value and stops there; with no edit inside a
|
|
157
|
+
* dialog it reaches the dialog, which closes. Returns the wrapper; the control is
|
|
158
|
+
* `wrapper.control`.
|
|
159
|
+
*/
|
|
160
|
+
export function field({ label, kind = 'text', value, options, placeholder, onCommit, hideLabel, attrs } = {}) {
|
|
161
|
+
const id = nextId('pmc-f');
|
|
162
|
+
let input;
|
|
163
|
+
if (kind === 'check') {
|
|
164
|
+
input = el('input', { type: 'checkbox', id, checked: !!value, ...withClass('pmc-check__box', attrs) });
|
|
165
|
+
input.addEventListener('change', () => { if (onCommit) onCommit(input.checked); });
|
|
166
|
+
const wrap = el('label', { class: 'pmc-check', for: id }, input, el('span', { class: 'pmc-check__label' }, label));
|
|
167
|
+
// a <label> already has a read-only `control` getter; an own property gives the same answer before it is attached
|
|
168
|
+
Object.defineProperty(wrap, 'control', { value: input });
|
|
169
|
+
return wrap;
|
|
170
|
+
}
|
|
171
|
+
if (kind === 'select') {
|
|
172
|
+
input = el('select', { id, ...withClass(cx('pmc-field__input', 'pmc-field__input--select'), attrs) },
|
|
173
|
+
...(options || []).map((o) => el('option', { value: o.value, selected: String(o.value) === String(value), disabled: !!o.disabled }, o.label)));
|
|
174
|
+
input.addEventListener('change', () => { if (onCommit) onCommit(input.value); });
|
|
175
|
+
} else if (kind === 'textarea') {
|
|
176
|
+
input = el('textarea', { id, rows: 3, placeholder, ...withClass(cx('pmc-field__input', 'pmc-field__input--area'), attrs) });
|
|
177
|
+
input.value = value || '';
|
|
178
|
+
} else {
|
|
179
|
+
input = el('input', {
|
|
180
|
+
id, type: kind === 'date' ? 'date' : 'text', placeholder, autocomplete: 'off',
|
|
181
|
+
...withClass(cx('pmc-field__input', kind === 'title' && 'pmc-field__input--title'), attrs),
|
|
182
|
+
});
|
|
183
|
+
input.value = value || '';
|
|
184
|
+
}
|
|
185
|
+
if (kind === 'date') input.addEventListener('change', () => { if (onCommit) onCommit(input.value || null); });
|
|
186
|
+
if (kind === 'text' || kind === 'title' || kind === 'textarea') {
|
|
187
|
+
let last = input.value;
|
|
188
|
+
const commit = () => { if (input.value !== last) { last = input.value; if (onCommit) onCommit(input.value); } };
|
|
189
|
+
input.addEventListener('blur', commit);
|
|
190
|
+
input.addEventListener('keydown', (e) => {
|
|
191
|
+
if (e.key === 'Enter' && kind !== 'textarea') { e.preventDefault(); e.stopPropagation(); commit(); }
|
|
192
|
+
else if (e.key === 'Escape') {
|
|
193
|
+
// an edit to drop: restore it and stop. Nothing to drop inside a dialog: let the dialog close.
|
|
194
|
+
if (input.value === last && input.closest('dialog')) return;
|
|
195
|
+
e.preventDefault(); e.stopPropagation(); input.value = last; input.blur();
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
input.resetCommitted = (v) => { last = v; input.value = v; };
|
|
199
|
+
}
|
|
200
|
+
const wrap = el('div', { class: 'pmc-field' },
|
|
201
|
+
el('label', { class: cx('pmc-field__label', hideLabel && 'pmc-field__label--hidden'), for: id }, label), input);
|
|
202
|
+
wrap.control = input;
|
|
203
|
+
return wrap;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/* ── badge, signal, dots ─────────────────────────────────────────────────────────────────────────────────────────── */
|
|
207
|
+
|
|
208
|
+
/** badge({ text, tone: 'muted'|'outline'|'warning'|'destructive' }): a small caps label. */
|
|
209
|
+
export function badge({ text, tone = 'muted', attrs } = {}) {
|
|
210
|
+
return el('span', withClass(cx('pmc-badge', 'pmc-badge--' + tone), attrs), text);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** signal({ text, tone: 'muted'|'warning'|'destructive', title?, wrap? }). Warning and destructive add a 6px dot.
|
|
214
|
+
* `wrap` lets a long text wrap inside its box (the Timeline's health reason), not stay on one line. */
|
|
215
|
+
export function signal({ text, tone = 'muted', title, attrs, wrap = false } = {}) {
|
|
216
|
+
return el('span', { title, ...withClass(cx('pmc-signal', 'pmc-signal--' + tone, wrap && 'pmc-signal--wrap'), attrs) },
|
|
217
|
+
tone === 'warning' || tone === 'destructive' ? el('span', { class: 'pmc-signal__dot', 'aria-hidden': 'true' }) : null,
|
|
218
|
+
el('span', null, text));
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/** The needs-you dot: with the count below, the only place the brand colour appears. */
|
|
222
|
+
export function needsDot() {
|
|
223
|
+
return el('span', { class: 'pmc-needs-dot', 'aria-hidden': 'true' });
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** The Today count beside the dot: the number in the brand colour. Everything else stays neutral. */
|
|
227
|
+
export function needsCount(text, attrs) {
|
|
228
|
+
return el('span', withClass('pmc-needs-count', attrs), text);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** A paragraph of a dialog sheet (dialogs.js): the question's own words, line breaks kept. */
|
|
232
|
+
export function dialogText(text) {
|
|
233
|
+
return el('p', { class: 'pmc-dialog__text' }, text);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** A grip for pointer dragging. Not focusable and not a control: the keyboard path is Alt+Arrow and the menus. */
|
|
237
|
+
export function dragHandle({ label } = {}) {
|
|
238
|
+
return el('span', { class: 'pmc-drag', 'aria-hidden': 'true', title: label || null }, icon('grip-vertical'));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/* ── row, card ───────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
242
|
+
|
|
243
|
+
function titleLine(cls, title, titleIcon) {
|
|
244
|
+
return el('span', { class: cls }, titleIcon ? icon(titleIcon) : null, el('span', { class: 'pmc-title__text' }, title));
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function metaLine(cls, meta, signals) {
|
|
248
|
+
const parts = (meta || []).filter((m) => m !== null && m !== undefined && m !== '');
|
|
249
|
+
const sigs = (signals || []).filter(Boolean).slice(0, 2);
|
|
250
|
+
if (!parts.length && !sigs.length) return null;
|
|
251
|
+
const kids = [];
|
|
252
|
+
parts.forEach((p, i) => { if (i) kids.push(el('span', { class: 'pmc-sep', 'aria-hidden': 'true' }, '·')); kids.push(el('span', null, p)); });
|
|
253
|
+
sigs.forEach((s) => { if (kids.length) kids.push(el('span', { class: 'pmc-sep', 'aria-hidden': 'true' }, '·')); kids.push(s); });
|
|
254
|
+
return el('span', { class: cls }, kids);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* row({ title, titleIcon?, meta?: string[], signals?: Node[] (2 at most), lead?: Node[], trail?: Node[], onOpen?, openLabel?,
|
|
259
|
+
* owed?: boolean, done?: boolean, attrs? })
|
|
260
|
+
* One title, one meta line holding at most two signals. With `onOpen` the title and meta are one button (the row's
|
|
261
|
+
* focus target); without it they are plain text.
|
|
262
|
+
*/
|
|
263
|
+
export function row({ title, titleIcon, meta, signals, lead, trail, onOpen, openLabel, owed, done, proposed, attrs, openAttrs } = {}) {
|
|
264
|
+
const body = [titleLine('pmc-row__title', title, titleIcon), metaLine('pmc-row__meta', meta, signals)];
|
|
265
|
+
const main = onOpen
|
|
266
|
+
? el('button', { type: 'button', class: 'pmc-row__open', 'aria-label': openLabel || null, onClick: onOpen, ...(openAttrs || {}) }, body)
|
|
267
|
+
: el('span', { class: 'pmc-row__main' }, body);
|
|
268
|
+
return el('div', withClass(cx('pmc-row', owed && 'pmc-row--owed', done && 'pmc-row--done', proposed && 'pmc-row--proposed'), attrs),
|
|
269
|
+
lead && lead.length ? el('span', { class: 'pmc-row__lead' }, lead) : null,
|
|
270
|
+
main,
|
|
271
|
+
trail && trail.length ? el('span', { class: 'pmc-row__trail' }, trail) : null);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** card({ title, titleIcon?, meta?, signals?, lead?, trail?, onOpen, openLabel?, attrs? }): a raised card with one open button. */
|
|
275
|
+
export function card({ title, titleIcon, meta, signals, lead, trail, onOpen, openLabel, muted, attrs, openAttrs } = {}) {
|
|
276
|
+
// the open button holds the title and the meta line, as a row's does: one column, and one tall target on a phone
|
|
277
|
+
const open = el('button', { type: 'button', class: 'pmc-card__open', 'aria-label': openLabel || null, onClick: onOpen, ...(openAttrs || {}) },
|
|
278
|
+
titleLine('pmc-card__title', title, titleIcon), metaLine('pmc-card__meta', meta, signals));
|
|
279
|
+
return el('div', withClass(cx('pmc-card', muted && 'pmc-card--muted'), attrs),
|
|
280
|
+
el('div', { class: 'pmc-card__top' },
|
|
281
|
+
lead && lead.length ? el('span', { class: 'pmc-card__lead' }, lead) : null,
|
|
282
|
+
open,
|
|
283
|
+
trail && trail.length ? el('span', { class: 'pmc-card__trail' }, trail) : null));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* ── tabs ────────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* tabs({ variant: 'segmented'|'line', role: 'tablist'|'radiogroup', items: [{ id, label, count? }], value, onChange(id),
|
|
290
|
+
* ariaLabel, full? })
|
|
291
|
+
* Arrow keys move focus between tabs. In a tablist the focused tab is also selected; in a radiogroup (the item sheet's
|
|
292
|
+
* state buttons, where a press writes) selection needs Space or Enter, so moving focus writes nothing.
|
|
293
|
+
*/
|
|
294
|
+
export function tabs({ variant = 'segmented', role = 'tablist', items, value, onChange, ariaLabel, full, attrs } = {}) {
|
|
295
|
+
const radio = role === 'radiogroup';
|
|
296
|
+
const btns = (items || []).map((it) => {
|
|
297
|
+
const on = it.id === value;
|
|
298
|
+
return el('button', {
|
|
299
|
+
type: 'button', class: 'pmc-tabs__tab', role: radio ? 'radio' : 'tab', 'data-tab': it.id,
|
|
300
|
+
'aria-selected': radio ? null : (on ? 'true' : 'false'), 'aria-checked': radio ? (on ? 'true' : 'false') : null,
|
|
301
|
+
tabindex: on || (value == null && it === items[0]) ? '0' : '-1',
|
|
302
|
+
onClick: () => { if (onChange) onChange(it.id); },
|
|
303
|
+
}, el('span', null, it.label), it.count !== undefined && it.count !== null ? el('span', { class: 'pmc-tabs__count' }, String(it.count)) : null);
|
|
304
|
+
});
|
|
305
|
+
const box = el('div', { role, 'aria-label': ariaLabel, ...withClass(cx('pmc-tabs', 'pmc-tabs--' + variant, full && 'pmc-tabs--full'), attrs) }, btns);
|
|
306
|
+
box.addEventListener('keydown', (e) => {
|
|
307
|
+
const keys = ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'];
|
|
308
|
+
if (!keys.includes(e.key) || e.altKey || e.metaKey || e.ctrlKey) return;
|
|
309
|
+
const i = btns.indexOf(document.activeElement);
|
|
310
|
+
if (i < 0) return;
|
|
311
|
+
e.preventDefault(); e.stopPropagation();
|
|
312
|
+
let j = i;
|
|
313
|
+
if (e.key === 'Home') j = 0; else if (e.key === 'End') j = btns.length - 1;
|
|
314
|
+
else j = (i + (e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? -1 : 1) + btns.length) % btns.length;
|
|
315
|
+
btns.forEach((b, k) => b.setAttribute('tabindex', k === j ? '0' : '-1'));
|
|
316
|
+
btns[j].focus();
|
|
317
|
+
if (!radio && onChange) onChange(items[j].id);
|
|
318
|
+
});
|
|
319
|
+
return box;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/* ── menu ────────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
323
|
+
|
|
324
|
+
let openMenu = null;
|
|
325
|
+
let lastMenuClose = { anchor: null, at: 0 };
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* menu({ anchor, items: [{ label, onSelect, danger?, sep? }], ariaLabel }): opens a popover menu under `anchor`.
|
|
329
|
+
* Arrow keys move, Enter or Space chooses, Escape closes and returns focus to the anchor. Pressing the anchor again
|
|
330
|
+
* closes it.
|
|
331
|
+
*/
|
|
332
|
+
export function menu({ anchor, items, ariaLabel } = {}) {
|
|
333
|
+
if (openMenu && openMenu.anchor === anchor) { openMenu.close(); return null; }
|
|
334
|
+
if (openMenu) openMenu.close();
|
|
335
|
+
if (lastMenuClose.anchor === anchor && Date.now() - lastMenuClose.at < 250) return null;
|
|
336
|
+
const host = overlayHost(anchor);
|
|
337
|
+
const btns = [];
|
|
338
|
+
const node = el('div', { class: 'pmc-menu', role: 'menu', 'aria-label': ariaLabel, popover: 'auto' });
|
|
339
|
+
let closed = false;
|
|
340
|
+
const close = (refocus) => {
|
|
341
|
+
if (closed) return; closed = true;
|
|
342
|
+
lastMenuClose = { anchor, at: Date.now() };
|
|
343
|
+
try { if (node.matches(':popover-open')) node.hidePopover(); } catch { /* not shown */ }
|
|
344
|
+
node.remove();
|
|
345
|
+
if (anchor) anchor.setAttribute('aria-expanded', 'false');
|
|
346
|
+
if (openMenu && openMenu.node === node) openMenu = null;
|
|
347
|
+
if (refocus && anchor && anchor.isConnected) anchor.focus();
|
|
348
|
+
};
|
|
349
|
+
for (const it of items || []) {
|
|
350
|
+
if (it.sep) { node.appendChild(el('div', { class: 'pmc-menu__sep', role: 'separator' })); continue; }
|
|
351
|
+
const b = el('button', {
|
|
352
|
+
type: 'button', role: 'menuitem', tabindex: '-1', class: cx('pmc-menu__item', it.danger && 'pmc-menu__item--danger'),
|
|
353
|
+
onClick: (e) => { e.stopPropagation(); close(false); if (it.onSelect) it.onSelect(); },
|
|
354
|
+
}, it.icon ? icon(it.icon) : null, el('span', null, it.label));
|
|
355
|
+
btns.push(b); node.appendChild(b);
|
|
356
|
+
}
|
|
357
|
+
node.addEventListener('toggle', (e) => { if (e.newState === 'closed') close(false); });
|
|
358
|
+
node.addEventListener('keydown', (e) => {
|
|
359
|
+
if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(true); return; }
|
|
360
|
+
if (e.key === 'Tab') { close(false); return; }
|
|
361
|
+
if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(e.key)) {
|
|
362
|
+
e.preventDefault(); e.stopPropagation();
|
|
363
|
+
const i = btns.indexOf(document.activeElement);
|
|
364
|
+
let j = e.key === 'Home' ? 0 : e.key === 'End' ? btns.length - 1 : (i + (e.key === 'ArrowUp' ? -1 : 1) + btns.length) % btns.length;
|
|
365
|
+
if (i < 0 && e.key === 'ArrowUp') j = btns.length - 1;
|
|
366
|
+
if (btns[j]) btns[j].focus();
|
|
367
|
+
}
|
|
368
|
+
});
|
|
369
|
+
host.appendChild(node);
|
|
370
|
+
try { node.showPopover(); } catch { /* popover unsupported: still positioned and shown */ }
|
|
371
|
+
const r = anchor ? anchor.getBoundingClientRect() : { left: 16, right: 16, top: 16, bottom: 16 };
|
|
372
|
+
const w = node.offsetWidth; const h = node.offsetHeight;
|
|
373
|
+
const vw = window.innerWidth; const vh = window.innerHeight;
|
|
374
|
+
const x = Math.max(8, Math.min(vw - w - 8, r.right - w));
|
|
375
|
+
const y = r.bottom + 4 + h > vh - 8 ? Math.max(8, r.top - 4 - h) : r.bottom + 4;
|
|
376
|
+
node.style.setProperty('--pmc-x', Math.round(x) + 'px');
|
|
377
|
+
node.style.setProperty('--pmc-y', Math.round(y) + 'px');
|
|
378
|
+
if (anchor) { anchor.setAttribute('aria-expanded', 'true'); anchor.setAttribute('aria-haspopup', 'menu'); }
|
|
379
|
+
if (btns[0]) btns[0].focus();
|
|
380
|
+
openMenu = { node, anchor, close };
|
|
381
|
+
return openMenu;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* ── sheet ───────────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
385
|
+
|
|
386
|
+
/** True when a sheet should come up from the bottom: the app's own phone breakpoint. */
|
|
387
|
+
export function narrowViewport() {
|
|
388
|
+
return typeof matchMedia === 'function' && matchMedia('(max-width: 760px)').matches;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* sheet({ mode: 'auto'|'side'|'bottom', title, sub, body: Node[], actions: Node[], onClose, attrs })
|
|
393
|
+
* A modal <dialog> in the layer: 420px at the side above 760px, a bottom sheet below. Escape and the backdrop close it.
|
|
394
|
+
* On a phone the sheet follows the on-screen keyboard through visualViewport. Returns { dialog, body, close, setHead }.
|
|
395
|
+
*/
|
|
396
|
+
export function sheet({ mode = 'auto', title, sub, body, actions, onClose, attrs } = {}) {
|
|
397
|
+
const m = mode === 'auto' ? (narrowViewport() ? 'bottom' : 'side') : mode;
|
|
398
|
+
const tid = nextId('pmc-sheet-t');
|
|
399
|
+
const titleEl = el('h2', { class: 'pmc-sheet__title', id: tid }, title || '');
|
|
400
|
+
const subEl = el('div', { class: 'pmc-sheet__sub' }, sub || '');
|
|
401
|
+
const bodyEl = el('div', { class: 'pmc-sheet__body' }, body || []);
|
|
402
|
+
let closed = false;
|
|
403
|
+
const dialog = el('dialog', { 'aria-modal': 'true', 'aria-labelledby': tid, tabindex: '-1', ...withClass(cx('pmc-sheet', 'pmc-sheet--' + m), attrs) },
|
|
404
|
+
m === 'bottom' ? el('div', { class: 'pmc-sheet__handle', 'aria-hidden': 'true' }) : null,
|
|
405
|
+
el('div', { class: 'pmc-sheet__head' }, el('div', { class: 'pmc-sheet__heading' }, titleEl, subEl),
|
|
406
|
+
iconButton({ icon: 'x', ariaLabel: 'Close', onClick: () => close() })),
|
|
407
|
+
bodyEl,
|
|
408
|
+
actions && actions.length ? el('div', { class: 'pmc-sheet__actions' }, actions) : null);
|
|
409
|
+
|
|
410
|
+
const vv = typeof window !== 'undefined' ? window.visualViewport : null;
|
|
411
|
+
const fit = () => {
|
|
412
|
+
if (!vv) return;
|
|
413
|
+
const kb = Math.max(0, window.innerHeight - (vv.height + vv.offsetTop));
|
|
414
|
+
dialog.style.setProperty('--pmc-kb', Math.round(kb) + 'px');
|
|
415
|
+
dialog.style.setProperty('--pmc-vvh', Math.round(vv.height) + 'px');
|
|
416
|
+
};
|
|
417
|
+
const close = () => {
|
|
418
|
+
if (closed) return; closed = true;
|
|
419
|
+
if (vv && m === 'bottom') { vv.removeEventListener('resize', fit); vv.removeEventListener('scroll', fit); }
|
|
420
|
+
try { if (dialog.open) dialog.close(); } catch { /* already closed */ }
|
|
421
|
+
// A toast raised while the sheet was open (an Undo, say) outlives the sheet: it moves to the layer.
|
|
422
|
+
for (const t of [...dialog.querySelectorAll(':scope > .pmc-toast')]) {
|
|
423
|
+
layer().appendChild(t);
|
|
424
|
+
try { t.showPopover(); } catch { /* popover unsupported */ }
|
|
425
|
+
}
|
|
426
|
+
dialog.remove();
|
|
427
|
+
if (onClose) onClose();
|
|
428
|
+
};
|
|
429
|
+
dialog.addEventListener('cancel', (e) => { e.preventDefault(); close(); });
|
|
430
|
+
dialog.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); close(); } });
|
|
431
|
+
dialog.addEventListener('click', (e) => { if (e.target === dialog) close(); });
|
|
432
|
+
layer().appendChild(dialog);
|
|
433
|
+
if (vv && m === 'bottom') { vv.addEventListener('resize', fit); vv.addEventListener('scroll', fit); fit(); }
|
|
434
|
+
dialog.showModal();
|
|
435
|
+
return {
|
|
436
|
+
dialog, body: bodyEl, close, mode: m,
|
|
437
|
+
get open() { return !closed; },
|
|
438
|
+
setHead(t, s) { titleEl.textContent = t || ''; subEl.textContent = s || ''; },
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* ── meter, toast, empty ─────────────────────────────────────────────────────────────────────────────────────────── */
|
|
443
|
+
|
|
444
|
+
/** meter({ done, total, label }): a 2px bar and its label ("7 of 12", or "no breakdown" with an empty track). */
|
|
445
|
+
export function meter({ done = 0, total = 0, label } = {}) {
|
|
446
|
+
const fill = el('span', { class: 'pmc-meter__fill' });
|
|
447
|
+
fill.style.setProperty('--pmc-value', (total > 0 ? Math.round((100 * done) / total) : 0) + '%');
|
|
448
|
+
return el('span', { class: 'pmc-meter' },
|
|
449
|
+
el('span', { class: 'pmc-meter__track', 'aria-hidden': 'true' }, fill),
|
|
450
|
+
el('span', { class: 'pmc-meter__label' }, label));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
const toasts = [];
|
|
454
|
+
function restack() {
|
|
455
|
+
toasts.forEach((t, i) => t.node.style.setProperty('--pmc-toast-i', String(toasts.length - 1 - i)));
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* toast({ text, actionLabel?, icon?, onAction?, timeout = 6000, tone? }): a short message at the bottom, stacked when
|
|
460
|
+
* several are up. Returns { close }. The action button closes the toast before it calls onAction.
|
|
461
|
+
*/
|
|
462
|
+
export function toast({ text, actionLabel, icon: ic, onAction, timeout = 6000, tone } = {}) {
|
|
463
|
+
const host = overlayHost(null);
|
|
464
|
+
let timer = null;
|
|
465
|
+
const node = el('div', { class: cx('pmc-toast', tone && 'pmc-toast--' + tone), role: 'status', 'aria-live': 'polite', popover: 'manual' },
|
|
466
|
+
el('span', { class: 'pmc-toast__text' }, text));
|
|
467
|
+
const t = { node };
|
|
468
|
+
const close = () => {
|
|
469
|
+
if (timer) clearTimeout(timer); timer = null;
|
|
470
|
+
const i = toasts.indexOf(t); if (i >= 0) toasts.splice(i, 1);
|
|
471
|
+
try { if (node.matches(':popover-open')) node.hidePopover(); } catch { /* not shown */ }
|
|
472
|
+
node.remove(); restack();
|
|
473
|
+
};
|
|
474
|
+
t.close = close;
|
|
475
|
+
if (actionLabel) {
|
|
476
|
+
node.appendChild(button({
|
|
477
|
+
label: actionLabel, variant: 'ghost', size: 'sm', icon: ic,
|
|
478
|
+
onClick: (e) => {
|
|
479
|
+
e.stopPropagation();
|
|
480
|
+
// The pressed button goes with the toast. Focus would fall to <body>, where the next Escape closes the tab:
|
|
481
|
+
// give it back to the sheet the toast sits in, so Escape closes only the sheet.
|
|
482
|
+
const home = node.closest('dialog');
|
|
483
|
+
close();
|
|
484
|
+
if (onAction) onAction();
|
|
485
|
+
if (home && home.isConnected && (document.activeElement === document.body || !document.activeElement)) home.focus({ preventScroll: true });
|
|
486
|
+
},
|
|
487
|
+
}));
|
|
488
|
+
}
|
|
489
|
+
host.appendChild(node);
|
|
490
|
+
try { node.showPopover(); } catch { /* popover unsupported */ }
|
|
491
|
+
toasts.push(t); restack();
|
|
492
|
+
if (timeout > 0) timer = setTimeout(close, timeout);
|
|
493
|
+
return t;
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** empty({ text }): the quiet line an empty list shows. */
|
|
497
|
+
export function empty({ text, attrs } = {}) {
|
|
498
|
+
return el('div', withClass('pmc-empty', attrs), text);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/* ── timeline bar ────────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
502
|
+
|
|
503
|
+
/* Calendar days, never shifted by the viewer's time zone: 'YYYY-MM-DD' -> days since 1970-01-01. */
|
|
504
|
+
const TL_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
505
|
+
const tlDay = (d) => (TL_DATE.test(String(d || '')) ? Math.round(Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10)) / 86400000) : NaN);
|
|
506
|
+
const tlDate = (n) => new Date(n * 86400000).toISOString().slice(0, 10);
|
|
507
|
+
const tlClamp = (v) => Math.max(0, Math.min(100, v));
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* timelinePct(range, date, { end }): where `date` falls on the axis `range` ({ start, end }, both days inside it), in
|
|
511
|
+
* percent of the axis width: the start of that day, or its end with `end: true`. The month ticks and the today label of
|
|
512
|
+
* a chart use it, so they line up with the bars. NaN for a bad date or range.
|
|
513
|
+
*/
|
|
514
|
+
export function timelinePct(range, date, { end = false } = {}) {
|
|
515
|
+
const a = tlDay(range && range.start); const b = tlDay(range && range.end); const d = tlDay(date);
|
|
516
|
+
if (Number.isNaN(a) || Number.isNaN(b) || Number.isNaN(d) || b < a) return NaN;
|
|
517
|
+
return ((d - a + (end ? 1 : 0)) / (b - a + 1)) * 100;
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const tlPlace = (node, range, start, due) => {
|
|
521
|
+
const l = tlClamp(timelinePct(range, start));
|
|
522
|
+
const r = tlClamp(timelinePct(range, due, { end: true }));
|
|
523
|
+
node.style.setProperty('--pmc-tl-left', l.toFixed(3) + '%');
|
|
524
|
+
node.style.setProperty('--pmc-tl-width', Math.max(0, r - l).toFixed(3) + '%');
|
|
525
|
+
node.setAttribute('data-start', start || '');
|
|
526
|
+
node.setAttribute('data-due', due || '');
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* timelineBar({ label, segments, milestones, range, today, attrs, drag }): one row's bar on a time axis, split by stage,
|
|
531
|
+
* with a ◆ for each milestone and the line for today. Breakdown › Timeline (Run 4, 4B) and Initiatives › Timeline (4C)
|
|
532
|
+
* both draw their rows with it. The bars are grey; colour appears only for health (R8: `health` 'risk' or 'off').
|
|
533
|
+
* segments [{ stage, start, due, label?, health?, done?, muted? }] dates 'YYYY-MM-DD', both days inside the
|
|
534
|
+
* segment; a done segment is drawn darker, a muted one (a proposal not kept) lighter with a dashed edge.
|
|
535
|
+
* Each segment after the first starts with a 2px gap, so stages that touch or overlap stay apart
|
|
536
|
+
* milestones [date | { date, health? }]
|
|
537
|
+
* range { start, end } the axis, both days inside it; every row of one chart passes the same range
|
|
538
|
+
* today date optional: a 1px line at the start of that day (rows stack, so the lines join)
|
|
539
|
+
* drag optional. Each segment becomes a button that moves its dates. Without it the bar is a picture (role img)
|
|
540
|
+
* and nothing on it reacts, which is how an initiative bar stays still (PRD R20).
|
|
541
|
+
* onCommit({ index, start, due, mode }) once per gesture: mode 'move' shifts both dates, 'end' changes the due
|
|
542
|
+
* onOpen(index) a press with no movement
|
|
543
|
+
* onStart(), onEnd() around a pointer gesture, so the caller can hold its re-render
|
|
544
|
+
* Pointer: drag the segment to move it, or its end (the last 12px, 24px under a coarse pointer, and at most a third of
|
|
545
|
+
* the segment) to change the due date; a segment narrower than 24px always moves whole. Under a coarse pointer a
|
|
546
|
+
* movable segment takes touches at least 44px wide, centred on its dates; what it draws keeps its true width.
|
|
547
|
+
* A day is the bar's width over the axis days; a move of less than 4px is a press. Keyboard: Left and Right move by a
|
|
548
|
+
* day, Shift with Left or Right changes the due date, Enter saves, Escape puts it back; leaving the segment with a change
|
|
549
|
+
* saves it. Each gesture calls onCommit at most once, so one drag writes one line. The segment shows the new dates while
|
|
550
|
+
* it moves.
|
|
551
|
+
*/
|
|
552
|
+
export function timelineBar({ label, segments = [], milestones = [], range, today, attrs, drag } = {}) {
|
|
553
|
+
const canDrag = !!(drag && typeof drag.onCommit === 'function' && range);
|
|
554
|
+
const bar = el('div', {
|
|
555
|
+
role: canDrag ? 'group' : 'img', 'aria-label': label || null, 'data-range': range ? range.start + '/' + range.end : null,
|
|
556
|
+
...withClass(cx('pmc-tlbar', canDrag && 'pmc-tlbar--drag'), attrs),
|
|
557
|
+
});
|
|
558
|
+
segments.forEach((g, index) => {
|
|
559
|
+
const health = g.health === 'risk' || g.health === 'off' ? g.health : null;
|
|
560
|
+
const cls = cx('pmc-tlbar__seg', g.done && 'pmc-tlbar__seg--done', g.muted && 'pmc-tlbar__seg--muted', health && 'pmc-tlbar__seg--' + health);
|
|
561
|
+
const fill = el('span', { class: 'pmc-tlbar__fill', 'aria-hidden': 'true' });
|
|
562
|
+
const node = canDrag
|
|
563
|
+
? el('button', { type: 'button', class: cls, 'aria-label': g.label || null, 'data-stage': g.stage || '', 'data-index': String(index) },
|
|
564
|
+
fill, el('span', { class: 'pmc-tlbar__end', 'aria-hidden': 'true' }))
|
|
565
|
+
: el('span', { class: cls, 'data-stage': g.stage || '', 'data-index': String(index) }, fill);
|
|
566
|
+
if (health) node.setAttribute('data-health', health);
|
|
567
|
+
if (range) tlPlace(node, range, g.start, g.due);
|
|
568
|
+
else { node.setAttribute('data-start', g.start || ''); node.setAttribute('data-due', g.due || ''); }
|
|
569
|
+
if (canDrag) tlDraggable(bar, node, g, index, range, drag);
|
|
570
|
+
bar.appendChild(node);
|
|
571
|
+
});
|
|
572
|
+
for (const m of milestones) {
|
|
573
|
+
const d = typeof m === 'string' ? m : m && m.date;
|
|
574
|
+
const health = m && typeof m === 'object' && (m.health === 'risk' || m.health === 'off') ? m.health : null;
|
|
575
|
+
const node = el('span', { class: cx('pmc-tlbar__milestone', health && 'pmc-tlbar__milestone--' + health), 'data-date': d || '', 'aria-hidden': 'true' });
|
|
576
|
+
if (range) node.style.setProperty('--pmc-tl-left', tlClamp((timelinePct(range, d) + timelinePct(range, d, { end: true })) / 2).toFixed(3) + '%');
|
|
577
|
+
bar.appendChild(node);
|
|
578
|
+
}
|
|
579
|
+
if (today && range && today >= range.start && today <= range.end) {
|
|
580
|
+
const t = el('span', { class: 'pmc-tlbar__today', 'data-date': today, 'aria-hidden': 'true' });
|
|
581
|
+
t.style.setProperty('--pmc-tl-left', tlClamp(timelinePct(range, today)).toFixed(3) + '%');
|
|
582
|
+
bar.appendChild(t);
|
|
583
|
+
}
|
|
584
|
+
return bar;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/* One segment's pointer and keyboard moves. `g` holds the saved dates; `cur` is what the segment shows. */
|
|
588
|
+
function tlDraggable(bar, node, g, index, range, drag) {
|
|
589
|
+
if (Number.isNaN(tlDay(g.start)) || Number.isNaN(tlDay(g.due))) return;
|
|
590
|
+
const total = tlDay(range.end) - tlDay(range.start) + 1;
|
|
591
|
+
let cur = { start: g.start, due: g.due, mode: null };
|
|
592
|
+
/* `days` after the saved dates: 'move' shifts both, 'end' moves the due and never before the start */
|
|
593
|
+
const show = (mode, days) => {
|
|
594
|
+
const s0 = tlDay(g.start); const d0 = tlDay(g.due);
|
|
595
|
+
const start = mode === 'move' ? tlDate(s0 + days) : g.start;
|
|
596
|
+
const due = mode === 'move' ? tlDate(d0 + days) : tlDate(Math.max(s0, d0 + days));
|
|
597
|
+
cur = { start, due, mode: start === g.start && due === g.due ? null : mode };
|
|
598
|
+
tlPlace(node, range, start, due);
|
|
599
|
+
};
|
|
600
|
+
const reset = () => { cur = { start: g.start, due: g.due, mode: null }; tlPlace(node, range, g.start, g.due); };
|
|
601
|
+
const commit = () => {
|
|
602
|
+
if (!cur.mode) return;
|
|
603
|
+
const out = { index, start: cur.start, due: cur.due, mode: cur.mode };
|
|
604
|
+
// the segment keeps the new dates until the caller draws again from the saved ledger
|
|
605
|
+
g = { ...g, start: cur.start, due: cur.due };
|
|
606
|
+
cur = { start: g.start, due: g.due, mode: null };
|
|
607
|
+
drag.onCommit(out);
|
|
608
|
+
};
|
|
609
|
+
let p = null; // the pointer gesture: { id, x0, mode, w, moved }
|
|
610
|
+
let swallowClick = false;
|
|
611
|
+
node.addEventListener('pointerdown', (e) => {
|
|
612
|
+
if (e.button !== 0 || p) return;
|
|
613
|
+
// a segment under 24px is all handle to a finger or a mouse: it moves whole, and its due date changes by keyboard
|
|
614
|
+
const end = e.target && e.target.closest && e.target.closest('.pmc-tlbar__end') && node.getBoundingClientRect().width >= 24;
|
|
615
|
+
p = { id: e.pointerId, x0: e.clientX, mode: end ? 'end' : 'move', w: bar.getBoundingClientRect().width || 1, moved: false };
|
|
616
|
+
try { node.setPointerCapture(e.pointerId); } catch { /* the pointer is gone */ }
|
|
617
|
+
});
|
|
618
|
+
node.addEventListener('pointermove', (e) => {
|
|
619
|
+
if (!p || e.pointerId !== p.id) return;
|
|
620
|
+
const dx = e.clientX - p.x0;
|
|
621
|
+
if (!p.moved && Math.abs(dx) < 4) return;
|
|
622
|
+
if (!p.moved) { p.moved = true; node.classList.add('pmc-tlbar__seg--moving'); if (typeof drag.onStart === 'function') drag.onStart(); }
|
|
623
|
+
e.preventDefault();
|
|
624
|
+
show(p.mode, Math.round((dx / p.w) * total));
|
|
625
|
+
});
|
|
626
|
+
const finish = (e, cancelled) => {
|
|
627
|
+
if (!p || e.pointerId !== p.id) return;
|
|
628
|
+
const moved = p.moved;
|
|
629
|
+
p = null;
|
|
630
|
+
node.classList.remove('pmc-tlbar__seg--moving');
|
|
631
|
+
if (!moved) return;
|
|
632
|
+
swallowClick = true;
|
|
633
|
+
setTimeout(() => { swallowClick = false; }, 0);
|
|
634
|
+
if (cancelled) reset(); else commit();
|
|
635
|
+
if (typeof drag.onEnd === 'function') drag.onEnd();
|
|
636
|
+
};
|
|
637
|
+
node.addEventListener('pointerup', (e) => finish(e, false));
|
|
638
|
+
node.addEventListener('pointercancel', (e) => finish(e, true));
|
|
639
|
+
node.addEventListener('lostpointercapture', (e) => finish(e, false));
|
|
640
|
+
node.addEventListener('click', (e) => {
|
|
641
|
+
if (swallowClick) { swallowClick = false; e.preventDefault(); return; }
|
|
642
|
+
if (!cur.mode && typeof drag.onOpen === 'function') drag.onOpen(index);
|
|
643
|
+
});
|
|
644
|
+
node.addEventListener('keydown', (e) => {
|
|
645
|
+
if (e.altKey || e.ctrlKey || e.metaKey) return;
|
|
646
|
+
if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
|
|
647
|
+
e.preventDefault(); e.stopPropagation();
|
|
648
|
+
const mode = e.shiftKey ? 'end' : 'move';
|
|
649
|
+
if (cur.mode && cur.mode !== mode) reset(); // changing what moves starts again from the saved dates
|
|
650
|
+
const step = e.key === 'ArrowLeft' ? -1 : 1;
|
|
651
|
+
show(mode, (mode === 'move' ? tlDay(cur.start) - tlDay(g.start) : tlDay(cur.due) - tlDay(g.due)) + step);
|
|
652
|
+
} else if (e.key === 'Enter' && cur.mode) { e.preventDefault(); e.stopPropagation(); commit(); }
|
|
653
|
+
else if (e.key === 'Escape' && cur.mode) { e.preventDefault(); e.stopPropagation(); reset(); }
|
|
654
|
+
});
|
|
655
|
+
node.addEventListener('blur', () => { if (cur.mode) commit(); });
|
|
656
|
+
}
|