create-pmos 0.75.0 → 0.77.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/layout.md +10 -11
- package/workspace/guide/notes.md +15 -10
- package/workspace/lib/mobile-nav.js +9 -2
- package/workspace/lib/notes-whisper.js +94 -22
- package/workspace/lib/notes.js +559 -59
- package/workspace/lib/routes-notes.js +276 -46
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/index.html +14 -20
- package/workspace/public/pm/components.css +154 -8
- package/workspace/public/pm/mobile.css +77 -93
- package/workspace/public/pm/mobile.js +6 -2
- package/workspace/public/pm/note-dock.js +12 -6
- package/workspace/public/pm/phone-deck.js +67 -143
- package/workspace/public/pm/view-notes.js +245 -102
- package/workspace/public/vendor/lucide-sprite.js +9 -2
- package/workspace/server.js +12 -1
- package/workspace/third-party/README.md +1 -1
|
@@ -1,51 +1,57 @@
|
|
|
1
|
-
/* pm/phone-deck.js — a phone tab
|
|
1
|
+
/* pm/phone-deck.js — the head above a phone tab's list (initiative workspace-phone-scroll-layout).
|
|
2
2
|
*
|
|
3
|
-
* At a tab's root on a phone (≤760px), the
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* At a tab's root on a phone (≤760px), the tab's place draws its own list, top to bottom, and only that list scrolls. This
|
|
4
|
+
* module draws the line above it (#mobDeck): how many things the list holds and, where the tab has a second view, a
|
|
5
|
+
* two-button switch to it, each button an icon and a short word.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* WHY NOT A DECK ANY MORE. Until 2026-10-07 this module turned the list into a deck of full-screen cards, side by side with
|
|
8
|
+
* scroll snapping (initiative workspace-phone-one-job). The PM reported that scrolling down a card "pages up" by itself,
|
|
9
|
+
* and that the empty space scrolls with the content. Both came from the deck: each card was a fixed-height box with its own
|
|
10
|
+
* vertical scroller inside a horizontal snap scroller, the deck re-snapped to "the card on screen" on every window resize
|
|
11
|
+
* (and a soft keyboard or Safari's toolbar resizes the window), and a short card left empty space that still scrolled. The
|
|
12
|
+
* PM chose one vertical list from a clickable mockup (its link is in the initiative record). Nothing here
|
|
13
|
+
* moves a scroll position.
|
|
10
14
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
* Initiatives the Roadmap's cards, Now then Next then Later. Second view: All, the List.
|
|
14
|
-
* Board the cards of the column on screen; the initiative picker and the column switch stay above them.
|
|
15
|
-
* Docs the copies that need you. Second view: All, every copy.
|
|
16
|
-
* Reports and Agent have no deck: the report reads as a page, and Agent is the dock with its sessions.
|
|
15
|
+
* ONE RENDERER. Nothing here draws a row. The destinations draw exactly what they draw on a desktop (pm/mobile.js, "ONE
|
|
16
|
+
* renderer, two shells"); pm/mobile.css only sizes them for a thumb.
|
|
17
17
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* Agenda Needs you (the Today cards) or My day (today-day.js: the goals, the calendar and the jobs).
|
|
19
|
+
* Initiatives Roadmap (Now, then Next, then Later) or All (the List).
|
|
20
|
+
* Board Board or Reports. Reports is a destination of its own with no tab (lib/mobile-nav.js TAB_OF), so this
|
|
21
|
+
* switch changes the destination, and the Board tab stays lit.
|
|
22
|
+
* Docs Out of sync (the copies that need you) or All (every copy).
|
|
23
|
+
* Agent has no head: it is the dock with its sessions.
|
|
24
|
+
*
|
|
25
|
+
* The view a tab shows lives in memory for this page only, so a phone never changes what a desktop opens on.
|
|
21
26
|
*/
|
|
22
27
|
|
|
23
28
|
import { setDayForced } from './today-day.js';
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
where: (
|
|
30
|
-
|
|
31
|
-
where: (
|
|
32
|
-
|
|
33
|
-
where: (
|
|
29
|
+
import { icon } from './icons.js';
|
|
30
|
+
|
|
31
|
+
/* views: [id, word, icon]. A view whose id is a destination (Board's) switches the destination instead of the view. */
|
|
32
|
+
const HEADS = {
|
|
33
|
+
today: { views: [['cards', 'Needs you', 'circle-alert'], ['day', 'My day', 'calendar']], scroller: '[data-today="needs"]', item: '[data-today-id]',
|
|
34
|
+
where: (n) => (n ? `${n} need${n === 1 ? 's' : ''} you` : 'Nothing needs you'), second: 'Your day' },
|
|
35
|
+
initiatives: { views: [['cards', 'Roadmap', 'target'], ['all', 'All', 'list']], scroller: '[data-in-cols]', item: '[data-in-card], [data-in-unrecorded-card]',
|
|
36
|
+
where: (n) => (n ? `${n} initiative${n === 1 ? '' : 's'}` : 'No initiatives'), second: 'All initiatives' },
|
|
37
|
+
board: { views: [['board', 'Board', 'square-kanban'], ['reports', 'Reports', 'chart-column']], dests: true, scroller: '.bd-col--shown .bd-col__list', item: '[data-item]',
|
|
38
|
+
where: (n) => (n ? `${n} item${n === 1 ? '' : 's'}` : 'No item here') },
|
|
39
|
+
reports: { views: [['board', 'Board', 'square-kanban'], ['reports', 'Reports', 'chart-column']], dests: true,
|
|
40
|
+
where: () => 'Reports' },
|
|
41
|
+
docs: { views: [['cards', 'Out of sync', 'circle-alert'], ['all', 'All', 'list']], scroller: '.rp-docs-body', item: '.rp-docs-row',
|
|
42
|
+
where: (n) => (n ? `${n} differ` : 'Every copy is the same'), second: 'Every copy' },
|
|
34
43
|
};
|
|
35
|
-
const label = (h) => h.charAt(0).toUpperCase() + h.slice(1);
|
|
36
44
|
|
|
37
45
|
const view = { today: 'cards', initiatives: 'cards', docs: 'cards' };
|
|
38
|
-
|
|
39
|
-
let live = null; // { dest, scroller } the deck being shown
|
|
46
|
+
let shown = null; // the destination whose head is drawn
|
|
40
47
|
let wasOn = false;
|
|
41
|
-
let ctx = null; // { rerender, place: () => the place panel }
|
|
48
|
+
let ctx = null; // { rerender, apply, go(dest), place: () => the place panel }
|
|
42
49
|
let observer = null;
|
|
43
50
|
let queued = false;
|
|
44
51
|
|
|
45
52
|
const $ = (id) => document.getElementById(id);
|
|
46
53
|
|
|
47
|
-
/** The view a tab shows
|
|
48
|
-
* All filter, however it was reached: the switch, ⌘K or the desktop's saved choice); Today's lives here. */
|
|
54
|
+
/** The view a tab shows. Initiatives and Docs read their own switch, so a change made in the place itself is honoured. */
|
|
49
55
|
export function phoneView(dest) {
|
|
50
56
|
if (dest === 'initiatives') {
|
|
51
57
|
const body = document.querySelector('[data-dest="initiatives"] [data-in-body]');
|
|
@@ -55,12 +61,13 @@ export function phoneView(dest) {
|
|
|
55
61
|
const on = document.querySelector('[data-dest="docs"] [data-docs-filter] [aria-selected="true"]');
|
|
56
62
|
return on && on.getAttribute('data-tab') === 'all' ? 'all' : 'cards';
|
|
57
63
|
}
|
|
64
|
+
if (dest === 'board' || dest === 'reports') return dest;
|
|
58
65
|
return view[dest] || 'cards';
|
|
59
66
|
}
|
|
60
67
|
|
|
61
|
-
/* Put the destination in the view the PM chose. Only in-memory state is touched: the desktop's saved choices (the
|
|
62
|
-
* Initiatives view in localStorage, the day's fold) are left as they are. */
|
|
63
68
|
function setView(dest, v) {
|
|
69
|
+
const cfg = HEADS[dest];
|
|
70
|
+
if (cfg && cfg.dests) { if (v !== dest && ctx && ctx.go) ctx.go(v); return; }
|
|
64
71
|
if (phoneView(dest) === v) return;
|
|
65
72
|
view[dest] = v;
|
|
66
73
|
if (dest === 'today') setDayForced(v === 'day');
|
|
@@ -73,143 +80,73 @@ function setView(dest, v) {
|
|
|
73
80
|
refresh();
|
|
74
81
|
}
|
|
75
82
|
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
|
|
83
|
+
function count(dest, cfg) {
|
|
84
|
+
if (!cfg.scroller) return 0;
|
|
85
|
+
const host = document.querySelector(`[data-fr-host="${dest}"]`);
|
|
86
|
+
const scroller = host && host.querySelector(cfg.scroller);
|
|
87
|
+
return scroller ? [...scroller.querySelectorAll(cfg.item)].filter((n) => !n.closest('[hidden]')).length : 0;
|
|
88
|
+
}
|
|
79
89
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
const
|
|
83
|
-
const
|
|
84
|
-
const i = scroller ? Math.min(indexNow(scroller), Math.max(0, n - 1)) : 0;
|
|
90
|
+
function paint(dest, cfg) {
|
|
91
|
+
const v = phoneView(dest);
|
|
92
|
+
const first = !cfg.views || cfg.dests || v === cfg.views[0][0];
|
|
93
|
+
const text = first ? cfg.where(count(dest, cfg)) : (cfg.second || '');
|
|
85
94
|
const where = $('mobDeckWhere');
|
|
86
|
-
|
|
87
|
-
if (where) where.textContent = deck ? cfg.where(i, n, list[i]) : (cfg.second || '');
|
|
88
|
-
const pager = $('mobPager');
|
|
89
|
-
if (pager) pager.hidden = !deck || n < 2;
|
|
90
|
-
const prev = $('mobPrev'); const next = $('mobNext'); const fill = $('mobPagerFill');
|
|
91
|
-
if (prev) prev.disabled = i <= 0;
|
|
92
|
-
if (next) next.disabled = i >= n - 1;
|
|
93
|
-
if (fill) fill.style.setProperty('--fr-deck-fill', n ? ((i + 1) / n * 100).toFixed(2) + '%' : '0');
|
|
94
|
-
list.forEach((node, k) => { const v = k === i ? 'true' : 'false'; if (node.getAttribute('data-ph-on') !== v) node.setAttribute('data-ph-on', v); });
|
|
95
|
-
if (n) at[dest] = { key: keyOf(cfg, list[i], i), i };
|
|
95
|
+
if (where && where.textContent !== text) where.textContent = text;
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
function views(dest, cfg) {
|
|
99
99
|
const box = $('mobDeckViews');
|
|
100
100
|
if (!box) return;
|
|
101
|
-
const
|
|
101
|
+
const list = cfg.views || [];
|
|
102
|
+
const want = list.map(([id, l]) => id + ':' + l + ':' + (phoneView(dest) === id)).join('|');
|
|
102
103
|
if (box.dataset.sig === dest + want) return;
|
|
103
104
|
box.dataset.sig = dest + want;
|
|
104
|
-
box.replaceChildren(...
|
|
105
|
+
box.replaceChildren(...list.map(([id, l, ic]) => {
|
|
105
106
|
const b = document.createElement('button');
|
|
106
107
|
b.type = 'button';
|
|
107
108
|
b.className = 'mob-deck__view';
|
|
108
|
-
b.
|
|
109
|
+
const g = icon(ic); g.classList.add('mob-deck__i'); b.append(g, document.createTextNode(l));
|
|
109
110
|
b.setAttribute('aria-pressed', phoneView(dest) === id ? 'true' : 'false');
|
|
110
111
|
b.dataset.view = id;
|
|
111
112
|
b.dataset.phViewTo = dest;
|
|
112
113
|
return b;
|
|
113
114
|
}));
|
|
114
|
-
box.hidden = !
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
/* The height a card gets: the screen between the top of the deck and the pager, so a card fills it and its own text
|
|
118
|
-
* scrolls inside it when it is longer. */
|
|
119
|
-
function fit(scroller) {
|
|
120
|
-
const top = scroller.getBoundingClientRect().top;
|
|
121
|
-
const menu = document.getElementById('mobMenu');
|
|
122
|
-
const bottom = menu ? menu.getBoundingClientRect().top : window.innerHeight;
|
|
123
|
-
const h = Math.max(220, Math.floor(bottom - top - 12));
|
|
124
|
-
if (scroller.style.getPropertyValue('--fr-deck-h') !== h + 'px') scroller.style.setProperty('--fr-deck-h', h + 'px');
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
function attach(dest, cfg, scroller) {
|
|
128
|
-
if (scroller.dataset.phDeck === dest) return false;
|
|
129
|
-
scroller.dataset.phDeck = dest;
|
|
130
|
-
let t = null;
|
|
131
|
-
scroller.addEventListener('scroll', () => {
|
|
132
|
-
if (t) cancelAnimationFrame(t);
|
|
133
|
-
t = requestAnimationFrame(() => { if (live && live.scroller === scroller) paint(dest, cfg, scroller); });
|
|
134
|
-
}, { passive: true });
|
|
135
|
-
return true;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
/* Bring the remembered card back on screen after the list was drawn again. */
|
|
139
|
-
function restore(dest, cfg, scroller) {
|
|
140
|
-
const list = items(cfg, scroller);
|
|
141
|
-
const was = at[dest];
|
|
142
|
-
// the first card stays first: a card that comes in at the front (the day's Prepare card, a new cause) is the one to see
|
|
143
|
-
let k = !was || was.i === 0 ? 0 : list.findIndex((n, j) => keyOf(cfg, n, j) === was.key);
|
|
144
|
-
if (k < 0) k = Math.min(was.i, Math.max(0, list.length - 1));
|
|
145
|
-
const left = k * scroller.clientWidth;
|
|
146
|
-
if (Math.abs(scroller.scrollLeft - left) > 2) scroller.scrollTo({ left, behavior: 'instant' });
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function step(by) {
|
|
150
|
-
if (!live) return;
|
|
151
|
-
const { scroller } = live;
|
|
152
|
-
const w = scroller.clientWidth || 1;
|
|
153
|
-
scroller.scrollTo({ left: (indexNow(scroller) + by) * w, behavior: 'smooth' });
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
/* The deck's marks come off every node, so a primitive's deck size (components.css .pmc-card.ph-card) applies only
|
|
157
|
-
* while a deck is on screen. */
|
|
158
|
-
function strip() {
|
|
159
|
-
for (const n of document.querySelectorAll('.ph-deck')) n.classList.remove('ph-deck');
|
|
160
|
-
for (const n of document.querySelectorAll('.ph-card')) { n.classList.remove('ph-card'); n.removeAttribute('data-ph-on'); }
|
|
115
|
+
box.hidden = !list.length;
|
|
161
116
|
}
|
|
162
117
|
|
|
163
118
|
function off() {
|
|
164
|
-
strip();
|
|
165
119
|
const shell = $('mobDeck'); if (shell) shell.hidden = true;
|
|
166
|
-
const pager = $('mobPager'); if (pager) pager.hidden = true;
|
|
167
120
|
delete document.body.dataset.phDeck;
|
|
168
121
|
delete document.body.dataset.phView;
|
|
169
|
-
|
|
122
|
+
shown = null;
|
|
170
123
|
}
|
|
171
124
|
|
|
172
|
-
/**
|
|
173
|
-
* Draw the deck for the screen on show. `screen` is the reducer's current screen; `on` is false above the breakpoint.
|
|
174
|
-
* Called by pm/mobile.js at the end of every shell render, and again whenever the place's own list is drawn again.
|
|
175
|
-
*/
|
|
125
|
+
/** Called by pm/mobile.js on every phone render, with whether the frame is up and the screen on top. */
|
|
176
126
|
export function applyDeck(on, screen) {
|
|
177
|
-
// Above the breakpoint nothing here runs: the desktop's own fold applies, the marks come off once, and the place is
|
|
178
|
-
// not watched (a subtree observer on the desktop's place, terminals included, costs every render).
|
|
179
127
|
if (!on) { if (wasOn) { wasOn = false; setDayForced(null); off(); if (observer) { observer.disconnect(); observer = null; } } return; }
|
|
180
128
|
wasOn = true;
|
|
181
129
|
watch();
|
|
182
130
|
const dest = screen && screen.kind === 'dest' ? screen.id : null;
|
|
183
|
-
const cfg = dest ?
|
|
131
|
+
const cfg = dest ? HEADS[dest] : null;
|
|
184
132
|
if (!cfg) { off(); return; }
|
|
185
133
|
if (dest === 'today') setDayForced(view.today === 'day');
|
|
186
134
|
document.body.dataset.phDeck = dest;
|
|
187
135
|
document.body.dataset.phView = phoneView(dest);
|
|
188
136
|
const shell = $('mobDeck'); if (shell) shell.hidden = false;
|
|
137
|
+
shown = dest;
|
|
189
138
|
views(dest, cfg);
|
|
190
|
-
|
|
191
|
-
const scroller = phoneView(dest) === 'cards' && host ? host.querySelector(cfg.scroller) : null;
|
|
192
|
-
if (!scroller) { strip(); live = null; paint(dest, cfg, null); return; }
|
|
193
|
-
scroller.classList.add('ph-deck');
|
|
194
|
-
for (const n of items(cfg, scroller)) n.classList.add('ph-card');
|
|
195
|
-
const fresh = attach(dest, cfg, scroller) || !live || live.scroller !== scroller;
|
|
196
|
-
live = { dest, scroller };
|
|
197
|
-
fit(scroller);
|
|
198
|
-
if (fresh || scroller.dataset.phCount !== String(items(cfg, scroller).length)) restore(dest, cfg, scroller);
|
|
199
|
-
scroller.dataset.phCount = String(items(cfg, scroller).length);
|
|
200
|
-
paint(dest, cfg, scroller);
|
|
139
|
+
paint(dest, cfg);
|
|
201
140
|
}
|
|
202
141
|
|
|
203
|
-
/* A destination that draws on its own (the Roadmap's read, the Board's poll) changes the place without a shell render:
|
|
204
|
-
* draw the deck again on the next frame. Only child lists are watched, and the deck writes attributes, so it cannot
|
|
205
|
-
* wake itself. */
|
|
206
142
|
function refresh() {
|
|
207
143
|
if (queued) return;
|
|
208
144
|
queued = true;
|
|
209
145
|
requestAnimationFrame(() => { queued = false; if (ctx && ctx.apply) ctx.apply(); });
|
|
210
146
|
}
|
|
211
147
|
|
|
212
|
-
/* The place
|
|
148
|
+
/* The place rebuilds its list on a poll; the count in the head follows it. A recount reads the DOM and writes one text
|
|
149
|
+
* node, and never touches a scroll position. */
|
|
213
150
|
function watch() {
|
|
214
151
|
if (observer || !ctx || !ctx.place || !('MutationObserver' in window)) return;
|
|
215
152
|
const place = ctx.place();
|
|
@@ -218,26 +155,13 @@ function watch() {
|
|
|
218
155
|
observer.observe(place, { childList: true, subtree: true });
|
|
219
156
|
}
|
|
220
157
|
|
|
221
|
-
/** Wire the pager once, at boot. `context` is { rerender, apply, place }: apply() re-runs applyDeck with the current screen. */
|
|
222
158
|
export function mountDeck(context) {
|
|
223
159
|
ctx = context || null;
|
|
224
|
-
const prev = $('mobPrev'); const next = $('mobNext');
|
|
225
|
-
// one listener for the deck's own buttons (the view switch, the pager), read from their data attributes
|
|
226
160
|
document.addEventListener('click', (e) => {
|
|
227
|
-
const t = e.target instanceof Element ? e.target.closest('#mobDeckViews [data-ph-view-to]
|
|
161
|
+
const t = e.target instanceof Element ? e.target.closest('#mobDeckViews [data-ph-view-to]') : null;
|
|
228
162
|
if (!t || t.disabled) return;
|
|
229
|
-
|
|
230
|
-
else if (t.id === 'mobNext') step(1);
|
|
231
|
-
else setView(t.dataset.phViewTo, t.dataset.view);
|
|
232
|
-
});
|
|
233
|
-
window.addEventListener('resize', () => { if (live) { fit(live.scroller); restore(live.dest, DECKS[live.dest], live.scroller); } });
|
|
234
|
-
document.addEventListener('keydown', (e) => {
|
|
235
|
-
if (!live || e.altKey || e.metaKey || e.ctrlKey) return;
|
|
236
|
-
const t = e.target;
|
|
237
|
-
if (t && t.closest && t.closest('input, textarea, select, [contenteditable="true"], .xterm')) return;
|
|
238
|
-
if (e.key === 'ArrowRight') { e.preventDefault(); step(1); } else if (e.key === 'ArrowLeft') { e.preventDefault(); step(-1); }
|
|
163
|
+
setView(t.dataset.phViewTo, t.dataset.view);
|
|
239
164
|
});
|
|
240
165
|
}
|
|
241
166
|
|
|
242
|
-
|
|
243
|
-
window.pmDeck = { phoneView, setView, live: () => (live ? { dest: live.dest, index: indexNow(live.scroller), count: items(DECKS[live.dest], live.scroller).length } : null) };
|
|
167
|
+
window.pmDeck = { phoneView, setView, live: () => (shown ? { dest: shown, view: phoneView(shown), count: HEADS[shown] ? count(shown, HEADS[shown]) : 0 } : null) };
|