create-pmos 0.76.0 → 0.78.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.
@@ -202,43 +202,57 @@
202
202
  }
203
203
  .ctrlbar .lbl { font-size: 11px; letter-spacing: .04em; }
204
204
 
205
- /* Six destinations, one per column, 64px tall — the prototype's bottom nav, with Docs (the desktop bar's order). */
206
- .mobnav { display: grid; grid-template-columns: repeat(6, 1fr); min-width: 0; }
205
+ /* Five tabs, one per column: an icon over a short word, the bar most phone apps draw (workspace-phone-scroll-layout).
206
+ * The tab on screen is drawn in the foreground ink, with a heavier icon stroke and a 2px rule along its top edge. The
207
+ * Agenda tab carries the count of what needs the PM beside its icon. */
208
+ .mobnav { display: grid; grid-template-columns: repeat(5, 1fr); min-width: 0; }
207
209
  .mobnav-b {
210
+ position: relative;
208
211
  display: flex;
209
212
  flex-direction: column;
210
213
  align-items: center;
211
214
  justify-content: center;
212
- gap: 3px;
215
+ gap: 4px;
213
216
  min-width: 0;
214
- min-height: 64px;
215
- padding: 0 2px;
217
+ min-height: 60px;
218
+ padding: 6px 2px 4px;
216
219
  color: var(--muted-foreground);
220
+ font-family: var(--sans);
217
221
  }
218
- .mobnav-b .g { font-size: 17px; line-height: 1; }
219
- .mobnav-b .l { font-size: 10px; line-height: 1; }
222
+ .mobnav-b .mobnav-i { width: 22px; height: 22px; stroke-width: 1.75; }
223
+ .mobnav-b .l { font-size: 11px; line-height: 1; font-weight: 500; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
220
224
  .mobnav-b[aria-current="page"] { color: var(--foreground); }
221
- /* The tabs are words (the frame's five tabs); the Today tab carries the count of what needs the PM. */
222
- .mobnav-b { font-family: var(--sans); }
223
- .mobnav-b .l { font-size: 12px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
224
- .mobnav-b[aria-current="page"] .l { text-decoration: underline; text-underline-offset: 4px; }
225
- .mobnav-n { font-family: var(--mono); font-size: 11px; line-height: 1; }
225
+ .mobnav-b[aria-current="page"] .mobnav-i { stroke-width: 2.25; }
226
+ .mobnav-b[aria-current="page"]::before { content: ""; position: absolute; top: 0; left: 25%; right: 25%; height: 2px; background: var(--foreground); }
227
+ .mobnav-n {
228
+ position: absolute;
229
+ top: 6px;
230
+ left: calc(50% + 8px);
231
+ min-width: 18px;
232
+ padding: 2px 4px;
233
+ font-family: var(--mono);
234
+ font-size: 11px;
235
+ line-height: 1;
236
+ text-align: center;
237
+ background: var(--destructive);
238
+ color: var(--background);
239
+ }
226
240
 
227
241
  /* ── one job per screen, one card at a time ────────────────────────────────────────────────────
228
- * A phone is not a small desktop. At a tab's root the tab's cards are a deck, one card on the screen at a time
229
- * (pm/phone-deck.js), and the deck head above them says which card of how many it is. The controls that serve desk
230
- * work are not drawn. Nothing below removes a capability: each hidden control is a desktop one, or the deck head's
242
+ * A phone is not a small desktop. At a tab's root the tab's list runs top to bottom and only the list scrolls; the
243
+ * head above it (pm/phone-deck.js) says how many things it holds and switches the tab's second view. The controls that
244
+ * serve desk work are not drawn. Nothing below removes a capability: each hidden control is a desktop one, or the head's
231
245
  * view switch, the bar's Repository menu or ⌘K (the bar's search) reaches the same act. The nodes stay in the DOM,
232
246
  * so the renderers are the desktop's, unchanged (see pm/mobile.js, "ONE renderer, two shells").
233
247
  *
234
- * Today the Today cards. The count is in the deck head; the scope is the bar's Repository menu (it has
235
- * All repositories). Day, the deck head's second view, is the day section: goals, calendar, jobs.
248
+ * Today the Today cards. The count is in the head; the scope is the bar's Repository menu (it has
249
+ * All repositories). My day, the head's second view, is the day section: goals, calendar, jobs.
236
250
  * Initiatives the Roadmap's cards. No title, view switch, New initiative, count line, hint or drag grips: the
237
- * deck head's All is the List, ⌘K has New initiative, and a card's ⋯ moves it.
251
+ * head's All is the List, ⌘K has New initiative, and a card's ⋯ moves it.
238
252
  * Board the picked initiative's cards in the column on screen: Active or Mine, the initiative picker and
239
253
  * the column switch stay. No count, and not a second "no item" note above the column's own.
240
254
  * Reports read a report, or draft one: no title (the tab names it) and no schedule set-up.
241
- * Docs the copies that need you, one card each. All, the deck head's second view, lists every copy and
255
+ * Docs the copies that need you, one row each. All, the head's second view, lists every copy and
242
256
  * has the search.
243
257
  * An initiative (the sheet) act on its next step: the stepper and the waiting line lead. The Breakdown tab shows
244
258
  * the Tree only (the Board tab is the board) and not the starter list, which is authoring. */
@@ -261,95 +275,65 @@
261
275
  [data-in-sheet] .bd-head > [data-bd-view],
262
276
  [data-in-sheet] [data-bd-starters] { display: none !important; }
263
277
 
264
- /* ── the deck head and the pager (pm/phone-deck.js) ─────────────────────────────────────────── */
265
-
278
+ /* ── the list head (pm/phone-deck.js) ───────────────────────────────────────────────────────────
279
+ * One line above the list: how many things it holds, then the tab's two views as a segmented switch, each button an
280
+ * icon and a word. It sits in the phone's top block, so it stays put while the list scrolls under it. */
266
281
  .mob-deck {
267
282
  display: flex;
268
283
  align-items: center;
269
284
  justify-content: space-between;
270
- gap: 10px;
271
- min-height: 48px;
272
- padding: 0 14px;
285
+ gap: 12px;
286
+ min-height: 52px;
287
+ padding: 0 12px 0 16px;
273
288
  background: var(--background);
274
289
  border-bottom: 1px solid var(--border);
275
- font-family: var(--mono);
276
- font-size: 11px;
277
- letter-spacing: .06em;
278
- text-transform: uppercase;
279
- color: var(--muted-foreground);
290
+ font-family: var(--sans);
291
+ font-size: 14px;
292
+ font-weight: 600;
293
+ color: var(--foreground);
280
294
  }
281
295
  .mob-deck__where { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
282
- .mob-deck__views { display: inline-flex; flex: none; border: 1px solid var(--border); }
296
+ .mob-deck__views { display: inline-flex; flex: none; gap: 2px; padding: 2px; background: var(--muted); }
283
297
  .mob-deck__view {
298
+ display: inline-flex;
299
+ align-items: center;
300
+ gap: 6px;
284
301
  min-width: 44px;
285
302
  min-height: 44px;
286
303
  padding: 0 12px;
287
- font: inherit;
288
- letter-spacing: inherit;
289
- text-transform: inherit;
304
+ font-family: var(--sans);
305
+ font-size: 14px;
306
+ font-weight: 500;
290
307
  color: var(--muted-foreground);
291
308
  }
292
- .mob-deck__view[aria-pressed="true"] { background: var(--foreground); color: var(--background); }
293
-
294
- .mob-pager { display: grid; grid-template-columns: 48px minmax(0, 1fr) 48px; align-items: center; border-bottom: 1px solid var(--border); }
295
- .mob-pager__b { display: grid; place-items: center; min-width: 48px; min-height: 44px; color: var(--foreground); }
296
- .mob-pager__b:disabled { opacity: .3; }
297
- .mob-pager__track { height: 3px; background: var(--border); }
298
- .mob-pager__fill { display: block; height: 100%; width: var(--fr-deck-fill, 0); background: var(--foreground); transition: width .2s ease-out; }
309
+ .mob-deck__view .mob-deck__i { width: 16px; height: 16px; }
310
+ .mob-deck__view[aria-pressed="true"] { background: var(--card); color: var(--foreground); }
299
311
 
300
- /* The deck: the tab's own list, laid side by side, one card per screen width, snapping to each. The card's height is
301
- * the screen between the deck head and the pager (--fr-deck-h, measured by pm/phone-deck.js). */
302
- body[data-ph-view="cards"] .ph-deck {
303
- display: flex !important;
304
- flex-direction: row !important;
305
- flex-wrap: nowrap !important;
306
- gap: 0 !important;
307
- margin: 0 !important;
308
- padding: 0 !important;
309
- overflow-x: auto;
310
- overflow-y: hidden;
311
- scroll-snap-type: x mandatory;
312
- overscroll-behavior-x: contain;
313
- scrollbar-width: none;
314
- }
315
- body[data-ph-view="cards"] .ph-deck::-webkit-scrollbar { display: none; }
316
- /* The Roadmap's three columns and the Docs' groups hand their cards to the one deck. */
317
- body[data-ph-view="cards"] .ph-deck .in-col,
318
- body[data-ph-view="cards"] .ph-deck .in-list,
319
- body[data-ph-view="cards"] .ph-deck .rp-docs-group,
320
- body[data-ph-view="cards"] .ph-deck .rp-docs-group > [role="list"] { display: contents !important; }
321
- body[data-ph-view="cards"] .ph-deck .in-col__head,
322
- body[data-ph-view="cards"] .ph-deck .in-col__empty,
323
- body[data-ph-view="cards"] .ph-deck .in-col > :not(.in-list),
324
- body[data-ph-view="cards"] .ph-deck .in-list--dormant { display: none !important; }
325
- body[data-ph-view="cards"] .ph-deck > .rp-section__empty { flex: 0 0 100%; }
326
- body[data-ph-view="cards"] .ph-card {
327
- flex: 0 0 100% !important;
328
- width: 100% !important;
329
- max-width: none !important;
330
- height: var(--fr-deck-h, 60vh);
331
- margin: 0 !important;
332
- overflow-y: auto;
333
- scroll-snap-align: start;
334
- scroll-snap-stop: always;
335
- display: flex !important;
336
- flex-direction: column;
337
- background: var(--card);
338
- border: 1px solid var(--border);
312
+ /* ── the list: one scroller, rows sized to what they hold ───────────────────────────────────────
313
+ * The place is the only thing that scrolls. It does not hand a drag at its top or bottom to the page, so the bars never
314
+ * move and nothing empty is dragged along. Rows take the height of what they say, with one floor, so a short row and a
315
+ * long one line up as a column. */
316
+ [data-fr="main"] > .fr-dest { overscroll-behavior: contain; -webkit-overflow-scrolling: touch; }
317
+ html, body { overscroll-behavior: none; }
318
+ [data-dest="today"] .td-board { gap: 8px; padding: 12px; }
319
+ [data-dest="today"] .td-card { min-height: 64px; padding: 14px; gap: 10px; }
320
+ [data-dest="today"] .td-card--host { padding: 0; }
321
+ [data-dest="today"] .td-card__top { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; }
322
+ [data-dest="today"] .td-card__cause { font-size: 16px; line-height: 1.3; font-weight: 600; align-items: flex-start; }
323
+ [data-dest="today"] .td-card__evidence {
324
+ padding-left: 0;
325
+ font-size: 14px;
326
+ line-height: 1.4;
327
+ display: -webkit-box;
328
+ -webkit-line-clamp: 2;
329
+ -webkit-box-orient: vertical;
330
+ overflow: hidden;
339
331
  }
340
- /* A Today card: the cause leads, the evidence wraps under it, the acts sit at the bottom at full width. */
341
- body[data-ph-view="cards"] .ph-card.td-card { padding: 20px 18px; }
342
- body[data-ph-view="cards"] .ph-card .td-card__top { flex: 1; display: flex; flex-direction: column; justify-content: space-between; gap: 18px; }
343
- body[data-ph-view="cards"] .ph-card .td-card__text { display: grid; gap: 12px; align-content: start; }
344
- body[data-ph-view="cards"] .ph-card .td-card__cause { font-size: 20px; line-height: 1.3; font-weight: 600; }
345
- body[data-ph-view="cards"] .ph-card .td-card__evidence { white-space: normal; overflow: visible; font-size: 14px; line-height: 1.45; }
346
- body[data-ph-view="cards"] .ph-card .td-card__acts { display: grid !important; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; width: 100%; margin: 0; }
347
- body[data-ph-view="cards"] .ph-card .td-card__acts > :first-child { width: 100%; }
348
- body[data-ph-view="cards"] .ph-card .td-card__acts > * { min-height: 48px; justify-content: center; }
349
- body[data-ph-view="cards"] .ph-card.td-card--prep .td-card__acts { grid-template-columns: minmax(0, 1fr); }
350
- /* An initiative or a Board item: a .pmc-card primitive, so its deck size lives in components.css (.pmc-card.ph-card). */
351
- /* A copy that differs: the row's sentence and its one button. */
352
- body[data-ph-view="cards"] .ph-card.rp-docs-row { padding: 6px 4px; }
332
+ [data-dest="today"] .td-card__acts { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px; width: 100%; margin: 0; }
333
+ [data-dest="today"] .td-card__acts > * { min-height: 44px; justify-content: center; }
334
+ [data-dest="today"] .td-card--prep .td-card__acts { grid-template-columns: minmax(0, 1fr); }
335
+ /* An initiative or a Board item is a .pmc-card primitive: its phone size lives in components.css's primitives section. */
336
+ [data-dest="docs"] .rp-docs-row { min-height: 56px; }
353
337
 
354
338
  /* ── the terminal takes the screen ────────────────────────────────────────────────────────────
355
339
  * The PM's word: entering a terminal switches the whole screen to it, because it is mostly output. The tabs are not
@@ -363,7 +347,7 @@
363
347
  * the thing this workspace refuses to ship. */
364
348
  .mob[hidden], .mobmenu[hidden], .mob-back[hidden], .mob-brand[hidden] { display: none !important; }
365
349
  .mob-repo[hidden], .mob-set[hidden], .mob-sync[hidden], .mob-theme[hidden], .mobnav-n[hidden] { display: none !important; }
366
- .mob-deck[hidden], .mob-pager[hidden], .mob-deck__views[hidden] { display: none !important; }
350
+ .mob-deck[hidden], .mob-deck__views[hidden] { display: none !important; }
367
351
 
368
352
  /* ── a touch screen: the visible height, no page bounce, no zoom on focus ─────────────────────
369
353
  * pm/mobile.js sets `pm-vv` and `--fr-vvh` (the visual viewport's height) on a touch screen. The
@@ -387,7 +387,9 @@ export function applyMobile() {
387
387
  }
388
388
 
389
389
  const buttons = menu ? menu.querySelectorAll('#mobNav [data-dest]') : [];
390
- for (const b of buttons) b.setAttribute('aria-current', b.dataset.dest === nav.dest ? 'page' : 'false');
390
+ // Reports has no tab of its own: it is the Board tab's second view, so Board stays lit (lib/mobile-nav.js TAB_OF).
391
+ const lit = typeof NAV.tabOf === 'function' ? NAV.tabOf(nav.dest) : nav.dest;
392
+ for (const b of buttons) b.setAttribute('aria-current', b.dataset.dest === lit ? 'page' : 'false');
391
393
 
392
394
  if (s.kind !== 'terminal' && s.kind !== 'agent' && ctrlArmed) setCtrlArmed(false);
393
395
 
@@ -544,8 +546,9 @@ export function mountMobile(context) {
544
546
  document.addEventListener('click', onChromeClick);
545
547
 
546
548
  if (window.visualViewport) {
549
+ // resize only. A visualViewport `scroll` fires on every pan of the page, and answering it with scrollTo(0, 0) and a
550
+ // refit undid the PM's own scroll in one jump (workspace-phone-scroll-layout). The height changes only on a resize.
547
551
  window.visualViewport.addEventListener('resize', fitViewport);
548
- window.visualViewport.addEventListener('scroll', fitViewport);
549
552
  }
550
553
  if (TOUCH.addEventListener) TOUCH.addEventListener('change', fitViewport);
551
554
  window.addEventListener('orientationchange', () => setTimeout(() => { fitViewport(); refit(); }, 250));
@@ -567,6 +570,7 @@ export function mountMobile(context) {
567
570
 
568
571
  mountDeck({
569
572
  rerender: () => setState({}),
573
+ go: (dest) => setDest(dest),
570
574
  apply: () => { if (isMobile() && nav) applyDeck(frameOn(), NAV.current(nav)); },
571
575
  place: () => ctx.bodyGrid.querySelector('[data-fr="main"]'),
572
576
  });
@@ -1,51 +1,57 @@
1
- /* pm/phone-deck.js — a phone tab shows its cards one at a time (initiative workspace-phone-one-job, the PM's card model).
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 list the tab's place already draws becomes a DECK: each card fills the screen,
4
- * a swipe or the pager's arrows bring the next one, and the deck head says which card of how many is on screen. The PM's
5
- * words: "I can't really focus on one thing at a time" with a list on every screen.
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
- * ONE RENDERER. Nothing here draws a card. The destinations draw exactly what they draw on a desktop (pm/mobile.js, "ONE
8
- * renderer, two shells"); this module marks the tab's own list as the deck's scroller and its own cards as the deck's
9
- * cards, and pm/mobile.css lays them out side by side with scroll snapping. A card's buttons are the card's own buttons.
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
- * Today the Today cards (dest-today.js); the first card prepares the day while the day is not prepared.
12
- * Second view: Day, the day section (today-day.js) with the goals, the calendar and the jobs.
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
- * The view a tab shows (Cards or its second view) lives in memory for this page only, so a phone never changes what a
19
- * desktop opens on. The card on screen is remembered by its key, so a destination that rebuilds its list (a poll, an
20
- * act on the card) keeps the PM where they were, or on the card that took the place of the one they acted on.
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
- const DECKS = {
26
- today: { views: [['cards', 'Cards'], ['day', 'Day']], scroller: '[data-today="needs"]', item: '[data-today-id]', key: 'data-today-id',
27
- where: (i, n) => n ? `${i + 1} of ${n}` : 'Nothing needs you', second: 'Your day' },
28
- initiatives: { views: [['cards', 'Cards'], ['all', 'All']], scroller: '[data-in-cols]', item: '[data-in-card], [data-in-unrecorded-card]', key: 'data-in-card',
29
- where: (i, n, it) => n ? `${i + 1} of ${n}${it && it.getAttribute('data-horizon') ? ' · ' + label(it.getAttribute('data-horizon')) : ''}` : 'No initiatives', second: 'All initiatives' },
30
- board: { scroller: '.bd-col--shown .bd-col__list', item: '[data-item]', key: 'data-item',
31
- where: (i, n) => n ? `${i + 1} of ${n}` : 'No item here' },
32
- docs: { views: [['cards', 'Cards'], ['all', 'All']], scroller: '.rp-docs-body', item: '.rp-docs-row', key: 'data-docs-doc',
33
- where: (i, n) => n ? `${i + 1} of ${n} differ` : 'Every copy is the same', second: 'Every copy' },
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
- const at = {}; // dest -> { key, i }: the card on screen, by its key and its place
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 on a phone: 'cards' or its second view. Initiatives and Docs say it themselves (the List, or the
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 items(cfg, scroller) { return [...scroller.querySelectorAll(cfg.item)].filter((n) => !n.closest('[hidden]')); }
77
- function keyOf(cfg, n, k) { return (n && n.getAttribute(cfg.key)) || 'i:' + k; }
78
- function indexNow(scroller) { const w = scroller.clientWidth || 1; return Math.max(0, Math.round(scroller.scrollLeft / w)); }
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
- /* The head and the pager say where the PM is. Drawn from the scroller's own position, so a swipe and an arrow agree. */
81
- function paint(dest, cfg, scroller) {
82
- const list = scroller ? items(cfg, scroller) : [];
83
- const n = list.length;
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
- const deck = !!scroller;
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 want = (cfg.views || []).map(([id, l]) => id + ':' + l + ':' + (phoneView(dest) === id)).join('|');
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(...(cfg.views || []).map(([id, l]) => {
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.textContent = l;
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 = !(cfg.views || []).length;
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
- live = null;
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 ? DECKS[dest] : null;
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
- const host = document.querySelector(`[data-fr-host="${dest}"]`);
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's host is made by app.js; it is watched from the first deck on. */
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], #mobPrev, #mobNext') : null;
161
+ const t = e.target instanceof Element ? e.target.closest('#mobDeckViews [data-ph-view-to]') : null;
228
162
  if (!t || t.disabled) return;
229
- if (t.id === 'mobPrev') step(-1);
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
- /* For the browser suites: where the deck is, and the view switch. */
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) };