create-pmos 0.60.3 → 0.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pmos",
3
- "version": "0.60.3",
3
+ "version": "0.61.1",
4
4
  "description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
5
5
  "license": "MIT",
6
6
  "bin": {
@@ -1,3 +1,3 @@
1
- kernel-source: pmos@dad3c31
1
+ kernel-source: pmos@05e2d82
2
2
  assembled: 2026-10-06
3
3
  mode: vendor
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Eval calibration log"
4
4
  description: "Append-only record of every PM override of an evaluator verdict."
5
- generated: {by: process:create-pmos, at: "2026-10-06T16:06:01Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T16:49:07Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Health budgets"
4
4
  description: "Closed registry of health-budget anchors (D59), edited only by the PM."
5
- generated: {by: process:create-pmos, at: "2026-10-06T16:06:01Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T16:49:07Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -2,7 +2,7 @@
2
2
  type: Reference
3
3
  title: "Discoveries and friction log"
4
4
  description: "Append-only record of what runs discovered and where the process rubbed."
5
- generated: {by: process:create-pmos, at: "2026-10-06T16:06:01Z"}
5
+ generated: {by: process:create-pmos, at: "2026-10-06T16:49:07Z"}
6
6
  status: stable
7
7
  ---
8
8
 
@@ -23,7 +23,10 @@
23
23
  // specimen did not change.
24
24
  const SPECIMEN_A = Object.freeze({ today: 17, initiatives: 24, board: 33, reports: 14 });
25
25
  const BAR_DOCS = 1;
26
- const EXPECT = Object.freeze({ today: 17 + BAR_DOCS, initiatives: 24 + BAR_DOCS, board: 33 + BAR_DOCS, reports: 15 + BAR_DOCS });
26
+ // Agenda opens on the month (the PM's Agenda mockup v5, 2026-10-06): the month adds its own controls to Today's budget,
27
+ // at most six week rows of a week button and seven days, the two month arrows and the five lenses.
28
+ const AGENDA_MONTH = 6 * 8 + 2 + 5;
29
+ const EXPECT = Object.freeze({ today: 17 + BAR_DOCS + AGENDA_MONTH, initiatives: 24 + BAR_DOCS, board: 33 + BAR_DOCS, reports: 15 + BAR_DOCS });
27
30
 
28
31
  /* ── shoot.js audit(), copied verbatim (scratchpad/specimens2/shoot.js, 2026-10-05) ─────────────────────────────── */
29
32
  // runs in the page
@@ -149,4 +152,4 @@ function installCounter(page) {
149
152
  return page.addInitScript({ content: 'window.__countIn = ' + countIn.toString() + ';' });
150
153
  }
151
154
 
152
- module.exports = { EXPECT, SPECIMEN_A, audit, countIn, stableCount, installCounter };
155
+ module.exports = { AGENDA_MONTH, EXPECT, SPECIMEN_A, audit, countIn, stableCount, installCounter };
@@ -2,7 +2,7 @@
2
2
  type: Guide
3
3
  title: "Initiatives › Timeline: when each initiative runs"
4
4
  description: "How to read the Timeline of Initiatives, what draws a bar, how to reorder rows, and what to do on a phone."
5
- timestamp: 2026-10-05
5
+ timestamp: 2026-10-06
6
6
  ---
7
7
 
8
8
  # Initiatives › Timeline
@@ -50,7 +50,7 @@ Moving a row inside Now is a placement into Now. If the workspace asks for your
50
50
 
51
51
  ## On a phone
52
52
 
53
- At 390 px the chart scrolls sideways inside its own box. The page does not scroll sideways. The name of each row stays at the left edge while the months move under it. Every control on a row, the grip, the arrow and the **⋯** button, is at least 44 px high and wide.
53
+ A phone, or a window 760 px wide or less, does not show the Timeline. There, Initiatives shows the Roadmap, or the List from the search (see [the layout guide](layout.md)). Use a wider window to see the Timeline.
54
54
 
55
55
  ## Keep the view current
56
56
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  type: Guide
3
3
  title: "Guide: layout"
4
- description: "The frame: one top bar, the place on screen, the detail and the agent dock; the phone tabs; the keys; and the older layout."
4
+ description: "The frame: one top bar, the place on screen, the detail and the agent dock; the phone tabs and decks; the keys; and the older layout."
5
5
  timestamp: 2026-10-06
6
6
  ---
7
7
 
@@ -91,9 +91,28 @@ A key that you type in a field, or in a terminal, goes to the field or the termi
91
91
 
92
92
  ## On a phone
93
93
 
94
- A phone shows one panel at a time. The tabs at the bottom are **Agenda, Initiatives, Board, Reports and Agent**. Agent shows the dock, with your terminals. Search or capture ⌘K is pinned at the top of Agenda.
94
+ A phone shows one panel at a time. The tabs at the bottom are **Agenda, Initiatives, Board, Reports, Docs and Agent**. Agent shows the dock, with your terminals.
95
95
 
96
- The bar at the top has Back (when you are deeper than a tab), Repository, the name of the screen, ⌘K, Sync (when the repository is not in sync, or has not fetched in the last 10 minutes) and Settings. Settings opens over the current tab. Back returns to the tab.
96
+ Each tab on a phone shows one thing at a time. Where a tab has a list, the list is a deck of cards: one card fills the screen, and you swipe sideways to go to the next card. The line above the deck says where you are, for example "3 of 12". The bar at the bottom of the deck has a back arrow, a progress line and a forward arrow. On a keyboard, the left and right arrow keys move between cards. When you leave a tab and come back, the deck opens at the card you left.
97
+
98
+ Some tabs have a second view. The buttons next to the "3 of 12" line change the view:
99
+
100
+ | Tab | The deck | The other view | Not on a phone |
101
+ | --- | --- | --- | --- |
102
+ | Agenda | **Cards**: one card for each thing that needs you. When your day is not prepared yet, the first card is **Prepare your day**. Press **Prepare my day** to start it, or **See the day** to open Day. | **Day**: your calendar, your goals and the jobs the agent does for the day. | The Repository / All repositories switch (use Repository in the bar). |
103
+ | Initiatives | **Cards**: one initiative for each card, in Roadmap order. | **All**: the list of every initiative. Press one to open it. | The title, New initiative, the count line and the drag grips. |
104
+ | Board | One item for each card, in the column you choose. Choose the initiative in the menu at the top, and the column with the column buttons. | None. | The item count. |
105
+ | Reports | No deck. Read a report, or draft one. | None. | The title and Report schedules. |
106
+ | Docs | **Cards**: one document that needs you for each card. | **All**: every document, in its groups. | The title. |
107
+ | Agent | No deck. Talk to a session. | None. | The scratchpad bar when no terminal is in front. |
108
+
109
+ When you open a terminal, the terminal fills the screen. The tabs at the bottom are not shown, so the output has the most space. The control keys stay at the bottom. Press Back to return to the tabs.
110
+
111
+ Nothing is lost. New initiative is in the search. To move a card on the Roadmap, press its **⋯**. To set up report schedules or use the Timeline, use a desktop.
112
+
113
+ The bar at the top has Back (when you are deeper than a tab), Repository, the search button, Sync (when the repository is not in sync, or has not fetched in the last 10 minutes) and Settings, which is the gear. The search button opens ⌘K, where you search and capture. At a tab, the bar shows no screen name, because the tabs at the bottom show where you are. When you open something over a tab, such as an initiative, a document or Settings, the bar shows its name. Settings opens over the current tab. Back returns to the tab.
114
+
115
+ In the initiative sheet on a phone, the Breakdown shows the tree of items. It does not show the Board and Timeline switch or the list of starter items. Use the Board tab to see the items as a board.
97
116
 
98
117
  Every row has a visible ⋯ button. Every control is at least 44 pixels high. Nothing works only when you hover.
99
118
 
@@ -30,11 +30,11 @@ An agent job can stop with its session still open, for example when the agent as
30
30
 
31
31
  Each job session is named "Job ·" and the first words of the job, so the session list stays short.
32
32
 
33
- **Agent results**, at the top of the left column, counts the jobs and lists each finished one with its draft summary or the reason it failed. Your goals for today come under it, above the calendar. The other things that wait for you, such as outcome checks, report drafts and sessions, are in **Also waiting for you** at the end of the left column. When the day is folded, they show under it.
33
+ The Agenda month is at the top of the left column. **Agent results** comes under it, counts the jobs and lists each finished one with its draft summary or the reason it failed. Your goals for today come under Agent results. The day is open when you go to Agenda, also before you press **Prepare my day**. The other things that wait for you, such as outcome checks, report drafts and sessions, are in **Also waiting for you** at the end of the left column. When the day is folded, they show under it.
34
34
 
35
35
  ### The Agenda month
36
36
 
37
- The menu calls this page **Agenda**. At the top of the left column is the month, with today marked. Each week starts with its number, for example W41.
37
+ The menu calls this page **Agenda**. At the top of the left column is the month, with today marked. When a day shows no meetings yet, press **Prepare my day** (or **Prepare again**): the morning-brief session reads your calendar and fills the month. Each week starts with its number, for example W41.
38
38
 
39
39
  - Press a day to see its agenda under the month. Three tiles open the agenda: the time in meetings, the meeting that matters most, and your prep.
40
40
  - Press a week number to see the agenda of that week, day by day.
@@ -66,15 +66,15 @@ const DETAIL_TITLE = {
66
66
  addprod: 'add repository', new: 'file new',
67
67
  };
68
68
 
69
- /* ── the frame's five phone tabs (pm-os-absorb Run 3, slice 3A; PRD R15) ─────────────────────────────────────────
70
- * Today · Initiatives · Board · Reports · Agent. The first four are the frame's destinations (a `dest` screen, drawn
71
- * in the frame's place, panel index 0); Agent is the dock (an `agent` screen, panel 4, the live terminals). Settings
69
+ /* ── the frame's six phone tabs (pm-os-absorb Run 3, slice 3A; PRD R15; Docs joined at the PM's word, 2026-10-06) ──
70
+ * Today · Initiatives · Board · Reports · Docs · Agent, the desktop bar's order. The first five are the frame's
71
+ * destinations (a `dest` screen, drawn in the frame's place, panel index 0); Agent is the dock (an `agent` screen, panel 4, the live terminals). Settings
72
72
  * is not a tab: it is pushed on top of the current tab as a `dest` screen with id `settings`, so Back returns.
73
73
  * The old cockpit's list modes still map to a tab (MOBILE_DEST), for the old shell (fixture mode, ?layout=legacy). */
74
- const MOBILE_DESTS = ['today', 'initiatives', 'board', 'reports', 'agent'];
74
+ const MOBILE_DESTS = ['today', 'initiatives', 'board', 'reports', 'docs', 'agent'];
75
75
  // What each tab and Settings is called. The same words as the desktop bar (public/pm/frame-copy.js COPY.nav), which
76
76
  // MOBNAV-P7 holds equal.
77
- const DEST_TITLE = { today: 'Agenda', initiatives: 'Initiatives', board: 'Board', reports: 'Reports', agent: 'Agent', settings: 'Settings' };
77
+ const DEST_TITLE = { today: 'Agenda', initiatives: 'Initiatives', board: 'Board', reports: 'Reports', docs: 'Docs', agent: 'Agent', settings: 'Settings' };
78
78
  // Every old MODES key has a tab. The suite ITERATES this map, so a mode without a phone path fails.
79
79
  const MOBILE_DEST = { product: 'initiatives', scheduled: 'reports', agent: 'agent', memory: 'initiatives',
80
80
  workflow: 'initiatives', skills: 'initiatives', knowledge: 'initiatives' };
@@ -83,6 +83,7 @@ const MOBILE_ROOT = {
83
83
  initiatives: { kind: 'dest', id: 'initiatives' },
84
84
  board: { kind: 'dest', id: 'board' },
85
85
  reports: { kind: 'dest', id: 'reports' },
86
+ docs: { kind: 'dest', id: 'docs' },
86
87
  agent: { kind: 'agent', id: null },
87
88
  };
88
89
  // The tab every stored value of the retired five-tab nav (board · scheduled · agents · memory · more) and of the
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.60.3",
3
+ "version": "0.61.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmos-workspace",
3
- "version": "0.60.3",
3
+ "version": "0.61.1",
4
4
  "private": true,
5
5
  "description": "PMOS Terminal Workspace — local companion hosting real PTYs beside the library and gates (D62)",
6
6
  "license": "MIT",
@@ -89,7 +89,7 @@
89
89
  breakpoint this is absent from the accessibility tree rather than merely invisible: a desktop
90
90
  PM must not be able to tab into a second navigation system they cannot see. -->
91
91
 
92
- <!-- The top bar: back · Repository · the screen's name · the ⌘K palette · Settings (the frame; the old shell shows its
92
+ <!-- The top bar: back · Repository · the screen's name (on a pushed screen only) · search · Settings (the frame; the old shell shows its
93
93
  brand and theme in place of Repository and Settings). It sits ABOVE .body in
94
94
  DOM order because it is the row above the content on screen, and putting it beside the bottom
95
95
  nav would read the app backwards. -->
@@ -106,16 +106,27 @@
106
106
  guess at. A control the PM cannot see is the hover-only trap in another costume. The
107
107
  palette is a jump control for every destination, so it belongs in the bar that is always
108
108
  on screen. -->
109
- <button class="mob-pal" id="mobPalette" type="button" aria-label="Open the jump palette (⌘K)">⌘K</button>
109
+ <!-- A magnifier, not the letters ⌘K: a phone has no ⌘ key. It opens the same palette as ⌘K on a desktop, where you
110
+ search and capture. It replaced the "Search or capture" control that was pinned above Today, so a phone screen
111
+ now leads with its one job and search is in one place on every tab. -->
112
+ <button class="mob-pal" id="mobPalette" type="button" aria-label="Search or capture" aria-keyshortcuts="Meta+K Control+K"><svg class="pmc-icon" width="16" height="16" aria-hidden="true" focusable="false" data-icon="search"><use href="#lucide-search"></use></svg></button>
110
113
  <button class="mob-theme" id="mobTheme" type="button" aria-label="Toggle theme"><svg class="pmc-icon" width="16" height="16" aria-hidden="true" focusable="false" data-icon="moon"><use href="#lucide-moon"></use></svg></button>
111
114
  <button class="mob-sync" id="mobSync" type="button" hidden>Sync</button>
112
- <button class="mob-set" id="mobSettings" type="button" hidden aria-current="false">Settings</button>
115
+ <button class="mob-set" id="mobSettings" type="button" hidden aria-current="false" aria-label="Settings"><svg class="pmc-icon" width="18" height="18" aria-hidden="true" focusable="false" data-icon="settings"><use href="#lucide-settings"></use></svg></button>
113
116
  </header>
117
+ <!-- The deck head (pm/phone-deck.js): at a tab's root, which card of how many is on screen and, where the tab has a
118
+ second view, the switch to it (Today: Cards or Day; Initiatives and Docs: Cards or All). Shown only on a tab
119
+ whose place draws a deck. -->
120
+ <div class="mob-deck" id="mobDeck" hidden>
121
+ <span class="mob-deck__where" id="mobDeckWhere" aria-live="polite"></span>
122
+ <span class="mob-deck__views" id="mobDeckViews" role="group" aria-label="View"></span>
123
+ </div>
114
124
  </div>
115
125
 
116
126
  <!-- The bottom block: a terminal control-key accessory bar (terminal screen only — phones have no
117
- Esc/Tab/Ctrl/arrows) over the five tabs (pm-os-absorb Run 3, slice 3A): Today · Initiatives · Board · Reports ·
118
- Agent. The first four are the frame's places; Agent is the dock with its live terminals. The nav is a <nav> of
127
+ Esc/Tab/Ctrl/arrows), the deck's pager (a tab's root only), and the six tabs (pm-os-absorb Run 3, slice 3A):
128
+ Today · Initiatives · Board · Reports · Docs · Agent. The first five are the frame's places; Agent is the dock with
129
+ its live terminals. On a terminal the tabs are not drawn: the session takes the screen, and Back returns. The nav is a <nav> of
119
130
  buttons carrying aria-current, NOT a tablist: there is no tabpanel to point at. Selecting a tab resets it to its
120
131
  root; depth is pushed on top of it and #mobBack pops one screen. Words, not glyphs, name each tab. -->
121
132
  <div class="mobmenu" id="mobMenu" hidden>
@@ -128,11 +139,18 @@
128
139
  <button type="button" data-seq="down" aria-label="Down arrow"><svg class="pmc-icon" width="16" height="16" aria-hidden="true" focusable="false" data-icon="arrow-down"><use href="#lucide-arrow-down"></use></svg></button>
129
140
  <button type="button" data-seq="right" aria-label="Right arrow"><svg class="pmc-icon" width="16" height="16" aria-hidden="true" focusable="false" data-icon="arrow-right"><use href="#lucide-arrow-right"></use></svg></button>
130
141
  </div>
142
+ <!-- The deck's pager: the previous card, where this one is in the deck, the next card. A swipe does the same. -->
143
+ <div class="mob-pager" id="mobPager" hidden>
144
+ <button type="button" class="mob-pager__b" id="mobPrev" aria-label="Previous card"><svg class="pmc-icon" width="18" height="18" aria-hidden="true" focusable="false" data-icon="chevron-left"><use href="#lucide-chevron-left"></use></svg></button>
145
+ <span class="mob-pager__track" id="mobPagerTrack" aria-hidden="true"><span class="mob-pager__fill" id="mobPagerFill"></span></span>
146
+ <button type="button" class="mob-pager__b" id="mobNext" aria-label="Next card"><svg class="pmc-icon" width="18" height="18" aria-hidden="true" focusable="false" data-icon="chevron-right"><use href="#lucide-chevron-right"></use></svg></button>
147
+ </div>
131
148
  <nav class="mobnav" id="mobNav" aria-label="Destinations">
132
149
  <button type="button" class="mobnav-b" data-dest="today" aria-current="page"><span class="l">Agenda</span><span class="mobnav-n" id="mobTodayCount" hidden></span></button>
133
150
  <button type="button" class="mobnav-b" data-dest="initiatives" aria-current="false"><span class="l">Initiatives</span></button>
134
151
  <button type="button" class="mobnav-b" data-dest="board" aria-current="false"><span class="l">Board</span></button>
135
152
  <button type="button" class="mobnav-b" data-dest="reports" aria-current="false"><span class="l">Reports</span></button>
153
+ <button type="button" class="mobnav-b" data-dest="docs" aria-current="false"><span class="l">Docs</span></button>
136
154
  <button type="button" class="mobnav-b" data-dest="agent" aria-current="false"><span class="l">Agent</span></button>
137
155
  </nav>
138
156
  </div>
@@ -39,7 +39,7 @@ import { isMobile, mountMobile, applyMobile } from './mobile.js';
39
39
  /* The frame's parts (Run 3, slice 3A). Small modules with no effect at import: a fixture page loads them and calls none. */
40
40
  import { createTopbar, unsyncedOf } from './topbar.js';
41
41
  import { createDockRail } from './dock.js';
42
- import { SEL, ensureComponentsCss, iconButton, button, menu, toast } from './primitives.js';
42
+ import { SEL, ensureComponentsCss, iconButton, menu, toast } from './primitives.js';
43
43
  import { ensureSprite } from './icons.js';
44
44
  import { COPY as FC } from './frame-copy.js';
45
45
  import { registerCommand } from './registry.js';
@@ -543,7 +543,6 @@ if (FRAME) {
543
543
  let bar = null;
544
544
  let dockRail = null;
545
545
  let frDetail = null;
546
- let pin = null;
547
546
  const hostMain = el('div', { class: 'fr-main', 'data-fr': 'main' });
548
547
  const destHosts = {};
549
548
  for (const id of FRAME_DESTS) destHosts[id] = el('div', { class: 'fr-dest', 'data-fr-host': id, hidden: true });
@@ -559,7 +558,6 @@ function setupFrame() {
559
558
  frDetail = el('div', { class: 'fr-detail', 'data-fr': 'detail' },
560
559
  el('div', { class: 'fr-detail__bar' }, iconButton({ icon: 'x', ariaLabel: FC.detail.close, onClick: closeDetail, attrs: { 'data-fr': 'detail-close' } })),
561
560
  hostDetail);
562
- pin = button({ label: FC.phone.pin, variant: 'outline', block: true, attrs: { class: 'fr-pin', 'data-fr': 'pin', 'aria-keyshortcuts': 'Meta+K Control+K' }, onClick: openPalette });
563
561
  root.removeAttribute('style');
564
562
  root.classList.add('fr-root');
565
563
  bodyGrid.removeAttribute('style');
@@ -570,12 +568,14 @@ function setupFrame() {
570
568
  setState({ col2: false, todayScope: fr.scope });
571
569
  }
572
570
 
573
- function paintMain(phone) {
571
+ function paintMain() {
574
572
  // the state-health banner (panel-repos.js) heads every place while the runtime state is not settled: it is not
575
573
  // dismissible, and the frame has no repository panel for it to sit in
576
574
  let banner = null;
577
575
  try { banner = mods.repos && typeof mods.repos.stateHealthBanner === 'function' ? mods.repos.stateHealthBanner() : null; } catch { banner = null; }
578
- const kids = [banner, phone && fr.dest === 'today' ? pin : null, ...FRAME_DESTS.map((id) => destHosts[id])];
576
+ // On a phone, search and capture are the top bar's search control (pm/mobile.js), not a second control above Today:
577
+ // a phone screen leads with its one job.
578
+ const kids = [banner, ...FRAME_DESTS.map((id) => destHosts[id])];
579
579
  setChildren(hostMain, kids);
580
580
  for (const id of FRAME_DESTS) destHosts[id].hidden = id !== fr.dest;
581
581
  const reg = fr.reg;
@@ -610,7 +610,7 @@ function renderFrame() {
610
610
  // its grid) and a reader of the body's fourth child still finds panel 4. A closed detail stays mounted, hidden.
611
611
  frDetail.hidden = !phone && !detail;
612
612
  setChildren(bodyGrid, phone ? [hostMain, dockRail, frDetail, hostTabs] : [hostMain, frDetail, dockRail, hostTabs]);
613
- paintMain(phone);
613
+ paintMain();
614
614
  if (detail || phone) paint(mods.detail, 'renderDetail', hostDetail);
615
615
  paint(mods.tabs, 'renderTabs', hostTabs);
616
616
  paint(mods.cockpit, 'renderPalette', paletteHost);
@@ -491,6 +491,14 @@
491
491
  .pmc-card--muted .pmc-card__title {
492
492
  color: var(--muted-foreground);
493
493
  }
494
+ /* card in a phone deck (pm/phone-deck.js adds .ph-card only while a phone shows its Cards view): one tall target that
495
+ * opens it, its title at reading size */
496
+ .pmc-card.ph-card { padding: 0; }
497
+ .pmc-card.ph-card .pmc-card__top { flex: 1; align-items: stretch; min-height: 0; }
498
+ .pmc-card.ph-card .pmc-card__open { display: flex; flex-direction: column; justify-content: flex-start; gap: 14px; padding: 22px 18px; min-width: 0; text-align: left; }
499
+ .pmc-card.ph-card .pmc-card__title { font-size: 20px; line-height: 1.25; font-weight: 600; white-space: normal; overflow: visible; display: block; }
500
+ .pmc-card.ph-card .pmc-card__meta { flex-wrap: wrap; white-space: normal; font-size: 14px; line-height: 1.5; }
501
+ .pmc-card.ph-card .pmc-card__trail { align-self: flex-start; padding: 8px 6px 0 0; }
494
502
  .pmc-card:focus-visible,
495
503
  .pmc-card:has(.pmc-card__open:focus-visible) {
496
504
  outline: 2px solid color-mix(in oklab, var(--ring) 50%, var(--foreground));
@@ -2258,16 +2266,6 @@
2258
2266
  .fr-body[data-fr-dock="rail"] > .fr-dock {
2259
2267
  display: none !important;
2260
2268
  }
2261
- /* The pinned ⌘K on a phone. .pmc-btn--block sets width: 100%, which with the side margins runs 32px past the place
2262
- * (and .fr-main clips it). The column's stretch gives the width inside the margins. */
2263
- .fr-pin {
2264
- flex: none;
2265
- align-self: stretch;
2266
- width: auto;
2267
- justify-content: flex-start;
2268
- margin: 12px 16px 0;
2269
- color: var(--muted-foreground);
2270
- }
2271
2269
  /* Settings: one column of seven sections. */
2272
2270
  .fr-set {
2273
2271
  display: flex;
@@ -4592,6 +4590,7 @@
4592
4590
  font-size: 12px;
4593
4591
  color: var(--muted-foreground);
4594
4592
  }
4593
+ .lg-dock-bottom[hidden] { display: none; }
4595
4594
  .lg-dock-bottom__title {
4596
4595
  flex: 1;
4597
4596
  min-width: 0;
@@ -33,6 +33,7 @@ registerCommand({
33
33
  id: 'initiatives-new', label: COPY.newInitiative, hint: 'Add one to Later', cli: 'pmos initiative new "<title>" --anchor <kr>',
34
34
  run: () => { openDestination('initiatives'); try { window.dispatchEvent(new CustomEvent('pmos:initiatives-new')); } catch { /* no window */ } },
35
35
  });
36
+ registerCommand({ id: 'initiatives-roadmap', label: 'Initiatives as a roadmap', hint: 'Now, Next and Later', cli: 'pmos initiative list', run: () => { try { window.localStorage.setItem('pmos.initiatives.view', 'roadmap'); } catch { /* no storage */ } openDestination('initiatives'); try { window.dispatchEvent(new CustomEvent('pmos:initiatives-view', { detail: 'roadmap' })); } catch { /* no window */ } } });
36
37
  registerCommand({
37
38
  id: 'initiatives-rescan', label: COPY.rescan, hint: COPY.rescanHint,
38
39
  run: () => { openDestination('initiatives'); try { window.dispatchEvent(new CustomEvent('pmos:initiatives-rescan')); } catch { /* no window */ } },
@@ -42,6 +43,8 @@ registerCommand({ id: 'initiatives-list', label: 'Initiatives as a list', hint:
42
43
  const BUILT_IN = ['roadmap', 'list'];
43
44
  const roots = new WeakMap(); // host -> its persistent root and what it shows
44
45
 
46
+ const phone = () => { try { return window.matchMedia('(max-width:760px)').matches; } catch { return false; } };
47
+
45
48
  function savedView() { try { return window.localStorage.getItem('pmos.initiatives.view'); } catch { return null; } }
46
49
  function saveView(v) { try { window.localStorage.setItem('pmos.initiatives.view', v); } catch { /* no storage */ } }
47
50
 
@@ -96,6 +99,10 @@ function draw(host, r, ctx, repo, ws) {
96
99
  ...initiativesViews().filter((v) => !BUILT_IN.includes(v.id)).map((v) => ({ id: v.id, label: v.label || v.id })),
97
100
  ];
98
101
  if (!views.some((v) => v.id === r.view)) r.view = 'roadmap';
102
+ // A phone shows Roadmap or List only, and has no view switch (pm/mobile.css): its screen is one job, to see where the
103
+ // initiatives stand and open one. ⌘K changes between the two. Any other view falls back to Roadmap here without being
104
+ // saved, so the desktop keeps its choice.
105
+ if (phone() && !BUILT_IN.includes(r.view)) r.view = 'roadmap';
99
106
  r.key = keyOf(r, repo, ws);
100
107
 
101
108
  const head = el('div', { class: 'in-head' },
@@ -21,10 +21,28 @@ import { wsOf } from './data.js';
21
21
  import { state, setState, FIXTURE } from './state.js';
22
22
  import { COPY } from './today-copy.js';
23
23
  import { todayCard, acceptCard, rankNodes, scopeCtx, ensureFeed, feedOf, feedErrorOf, bannerOf, onFeed } from './today-cards.js';
24
- import { daySection, daySig } from './today-day.js';
24
+ import { daySection, daySig, dayState, pressPrepare } from './today-day.js';
25
25
 
26
26
  const SHOWN = 3; // cards drawn before "Show more": the first paint holds at most 17 controls (first-paint-today.test.js)
27
27
  let expanded = false;
28
+ /* A phone shows Today as a deck, one card at a time (pm/phone-deck.js): every card is in it, so there is no "Show more". */
29
+ const phone = () => { try { return window.matchMedia('(max-width:760px)').matches; } catch { return false; } };
30
+
31
+ /* The phone's first card while the day is not prepared: the day's one press, and the way to the Day view. A desktop has
32
+ * the day section's own Prepare my day in view, so this card is drawn on a phone only. */
33
+ function prepCard(ws) {
34
+ const st = dayState(ws);
35
+ if (!st.known || st.prepared) return null;
36
+ const P = COPY.prep;
37
+ return el('div', { class: 'td-card td-card--prep', 'data-today': 'card', 'data-today-kind': 'prep', 'data-today-id': 'prep' },
38
+ el('div', { class: 'td-card__top' },
39
+ el('div', { class: 'td-card__text' },
40
+ el('div', { class: 'td-card__cause', 'data-today-cause': '' }, P.cause),
41
+ el('div', { class: 'td-card__evidence' }, P.evidence)),
42
+ el('div', { class: 'td-card__acts' },
43
+ button({ label: st.busy ? P.busy : P.go, variant: 'primary', disabled: st.busy, attrs: { 'data-today-prep': 'go', 'data-today-act': '' }, onClick: () => pressPrepare(ws) }),
44
+ button({ label: P.see, variant: 'outline', attrs: { 'data-today-prep': 'day' }, onClick: () => { if (window.pmDeck) window.pmDeck.setView('today', 'day'); } }))));
45
+ }
28
46
 
29
47
  /* ── the source for the cards the server makes itself ─────────────────────────────────────── */
30
48
 
@@ -101,9 +119,10 @@ function build(ctx0) {
101
119
  const regCtx = { all: sc.all, repos: sc.repos, setState: ctx0.setState || setState, open: (r, slug) => openInitiative(r, slug) };
102
120
  const n = todayCount(regCtx);
103
121
  const nodes = rankNodes(todayCards(regCtx));
122
+ const onPhone = phone();
104
123
  const ready = sc.repos.length > 0 && sc.repos.every((r) => { const ws = wsOf(r); return !ws || feedOf(ws) !== null || feedErrorOf(ws) !== null; });
105
124
  const banner = bannerOf(sc.repos);
106
- nodes.forEach((node, i) => { if (!expanded && i >= SHOWN) node.hidden = true; });
125
+ nodes.forEach((node, i) => { if (!expanded && !onPhone && i >= SHOWN) node.hidden = true; });
107
126
 
108
127
  const head = el('div', { class: 'td-head' },
109
128
  el('h2', { class: 'td-count', 'data-today': 'count', 'aria-live': 'polite' }, countText(sc, n, ready)),
@@ -114,7 +133,7 @@ function build(ctx0) {
114
133
  const list = nodes.length
115
134
  ? el('div', { class: 'td-list', 'data-today': 'needs', role: 'list', 'aria-label': COPY.aria.list }, ...nodes)
116
135
  : el('div', { class: 'td-list', 'data-today': 'needs' }, ready && !banner.some((b) => b.source === COPY.health.todaySource) ? empty({ text: COPY.empty(nextLabel(sc.repos)), attrs: { 'data-today': 'empty' } }) : null);
117
- const more = nodes.length > SHOWN
136
+ const more = nodes.length > SHOWN && !onPhone
118
137
  ? button({ label: expanded ? COPY.fewer : COPY.more(nodes.length - SHOWN), variant: 'ghost', attrs: { 'data-today': 'more' }, onClick: () => { expanded = !expanded; setState({}); } })
119
138
  : null;
120
139
  nodes.forEach((node) => node.setAttribute('role', 'listitem'));
@@ -122,6 +141,8 @@ function build(ctx0) {
122
141
  // joined to the cards of the first repository in scope, and its initiatives open in that repository
123
142
  const dayRepo = sc.repos.length ? sc.repos[0] : null;
124
143
  const dayWs = dayRepo ? wsOf(dayRepo) : null;
144
+ const prep = onPhone && !(window.pmDeck && window.pmDeck.phoneView('today') === 'day') ? prepCard(dayWs) : null;
145
+ if (prep) { prep.setAttribute('role', 'listitem'); const none = list.querySelector('[data-today="empty"]'); if (none) none.setAttribute('data-today-id', 'empty'); list.prepend(prep); } // the empty note stays, as the deck's last card
125
146
  // the open day takes the list into its left column ("Also waiting for you"), so Today shows one layout; a folded day
126
147
  // leaves it below, as before
127
148
  const owed = { list, more, count: n };
@@ -130,7 +151,7 @@ function build(ctx0) {
130
151
  const here = sc.repo ? wsOf(sc.repo) : null;
131
152
  const day = daySection({ ws: dayWs, setState: ctx0.setState || setState, openIni: dayRepo ? (id) => openInitiative(dayRepo, id) : null, owed, here, all: sc.all });
132
153
  const inDay = !!(day && day.contains(list));
133
- return { children: inDay ? [head, day, health] : [head, day, health, list, more], sig: JSON.stringify([sc.all, sc.repo, n, ready, expanded, banner]) + daySig(dayWs) + nodes.map((x) => x.outerHTML).join('') };
154
+ return { children: inDay ? [head, day, health] : [head, day, health, list, more], sig: JSON.stringify([sc.all, sc.repo, n, ready, expanded, banner, onPhone, prep ? prep.outerHTML : null]) + daySig(dayWs) + nodes.map((x) => x.outerHTML).join('') };
134
155
  }
135
156
 
136
157
  /** The key of the control that has the focus inside the board, so it can be focused again after a rebuild. */
@@ -111,7 +111,6 @@ export const COPY = {
111
111
  detail: { close: 'Close the detail' },
112
112
  phone: {
113
113
  back: 'Back',
114
- pin: 'Search or capture',
115
114
  },
116
115
  page: {
117
116
  title: 'Settings',
@@ -35,7 +35,7 @@
35
35
  * centred, with a hairline on each side so the phone shape is legible on a wide-ish tablet. */
36
36
  body {
37
37
  display: grid;
38
- grid-template-rows: 52px minmax(0, 1fr) auto;
38
+ grid-template-rows: auto minmax(0, 1fr) auto;
39
39
  height: 100vh;
40
40
  max-width: 430px;
41
41
  margin: 0 auto;
@@ -155,7 +155,9 @@
155
155
  white-space: nowrap;
156
156
  font-weight: 600;
157
157
  }
158
- .mob-set[aria-current="page"] { text-decoration: underline; }
158
+ .mob-set { justify-content: center; color: var(--muted-foreground); }
159
+ .mob-set[aria-current="page"] { color: var(--foreground); }
160
+ .mob-sync { padding: 0 10px; }
159
161
 
160
162
  /* ── the bottom block ────────────────────────────────────────────────────────────────────── */
161
163
 
@@ -199,8 +201,8 @@
199
201
  }
200
202
  .ctrlbar .lbl { font-size: 11px; letter-spacing: .04em; }
201
203
 
202
- /* Five destinations, one per column, 64px tall — the prototype's bottom nav. */
203
- .mobnav { display: grid; grid-template-columns: repeat(5, 1fr); min-width: 0; }
204
+ /* Six destinations, one per column, 64px tall — the prototype's bottom nav, with Docs (the desktop bar's order). */
205
+ .mobnav { display: grid; grid-template-columns: repeat(6, 1fr); min-width: 0; }
204
206
  .mobnav-b {
205
207
  display: flex;
206
208
  flex-direction: column;
@@ -217,9 +219,142 @@
217
219
  .mobnav-b[aria-current="page"] { color: var(--foreground); }
218
220
  /* The tabs are words (the frame's five tabs); the Today tab carries the count of what needs the PM. */
219
221
  .mobnav-b { font-family: var(--sans); }
220
- .mobnav-b .l { font-size: 12px; }
222
+ .mobnav-b .l { font-size: 12px; max-width: 100%; overflow: hidden; text-overflow: ellipsis; }
221
223
  .mobnav-b[aria-current="page"] .l { text-decoration: underline; text-underline-offset: 4px; }
222
224
  .mobnav-n { font-family: var(--mono); font-size: 11px; line-height: 1; }
225
+
226
+ /* ── one job per screen, one card at a time ────────────────────────────────────────────────────
227
+ * 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
228
+ * (pm/phone-deck.js), and the deck head above them says which card of how many it is. The controls that serve desk
229
+ * work are not drawn. Nothing below removes a capability: each hidden control is a desktop one, or the deck head's
230
+ * view switch, the bar's Repository menu or ⌘K (the bar's search) reaches the same act. The nodes stay in the DOM,
231
+ * so the renderers are the desktop's, unchanged (see pm/mobile.js, "ONE renderer, two shells").
232
+ *
233
+ * Today the Today cards. The count is in the deck head; the scope is the bar's Repository menu (it has
234
+ * All repositories). Day, the deck head's second view, is the day section: goals, calendar, jobs.
235
+ * Initiatives the Roadmap's cards. No title, view switch, New initiative, count line, hint or drag grips: the
236
+ * deck head's All is the List, ⌘K has New initiative, and a card's ⋯ moves it.
237
+ * Board the picked initiative's cards in the column on screen: Active or Mine, the initiative picker and
238
+ * the column switch stay. No count, and not a second "no item" note above the column's own.
239
+ * Reports read a report, or draft one: no title (the tab names it) and no schedule set-up.
240
+ * Docs the copies that need you, one card each. All, the deck head's second view, lists every copy and
241
+ * has the search.
242
+ * An initiative (the sheet) act on its next step: the stepper and the waiting line lead. The Breakdown tab shows
243
+ * the Tree only (the Board tab is the board) and not the starter list, which is authoring. */
244
+ body > [data-pm-theme] [data-dest="today"] [data-today="scope"],
245
+ body > [data-pm-theme] [data-dest="today"] .td-head,
246
+ body:not([data-ph-view="day"]) > [data-pm-theme] [data-dest="today"] [data-today="day"],
247
+ body > [data-pm-theme] [data-dest="today"] [data-today="day"] [data-day="toggle"],
248
+ body > [data-pm-theme] [data-dest="initiatives"] .in-head,
249
+ body > [data-pm-theme] [data-dest="initiatives"] [data-in-nowline],
250
+ body > [data-pm-theme] [data-dest="initiatives"] [data-in-hint],
251
+ body > [data-pm-theme] [data-dest="initiatives"] .in-grip,
252
+ body > [data-pm-theme] [data-dest="board"] .bo-head__n,
253
+ body > [data-pm-theme] [data-dest="docs"] .rp-docs-top .rp-head__title,
254
+ body[data-ph-view="cards"] > [data-pm-theme] [data-dest="docs"] .rp-docs-top,
255
+ body[data-ph-view="cards"] > [data-pm-theme] [data-dest="docs"] .rp-docs-views,
256
+ body[data-ph-view="cards"] > [data-pm-theme] [data-dest="docs"] .rp-docs-group__title,
257
+ body > [data-pm-theme] [data-dest="board"] [data-bo-empty],
258
+ body > [data-pm-theme] [data-dest="reports"] .rp-head__title,
259
+ body > [data-pm-theme] [data-dest="reports"] [data-rp-section="schedules"],
260
+ [data-in-sheet] .bd-head > [data-bd-view],
261
+ [data-in-sheet] [data-bd-starters] { display: none !important; }
262
+
263
+ /* ── the deck head and the pager (pm/phone-deck.js) ─────────────────────────────────────────── */
264
+
265
+ .mob-deck {
266
+ display: flex;
267
+ align-items: center;
268
+ justify-content: space-between;
269
+ gap: 10px;
270
+ min-height: 48px;
271
+ padding: 0 14px;
272
+ background: var(--background);
273
+ border-bottom: 1px solid var(--border);
274
+ font-family: var(--mono);
275
+ font-size: 11px;
276
+ letter-spacing: .06em;
277
+ text-transform: uppercase;
278
+ color: var(--muted-foreground);
279
+ }
280
+ .mob-deck__where { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
281
+ .mob-deck__views { display: inline-flex; flex: none; border: 1px solid var(--border); }
282
+ .mob-deck__view {
283
+ min-width: 44px;
284
+ min-height: 44px;
285
+ padding: 0 12px;
286
+ font: inherit;
287
+ letter-spacing: inherit;
288
+ text-transform: inherit;
289
+ color: var(--muted-foreground);
290
+ }
291
+ .mob-deck__view[aria-pressed="true"] { background: var(--foreground); color: var(--background); }
292
+
293
+ .mob-pager { display: grid; grid-template-columns: 48px minmax(0, 1fr) 48px; align-items: center; border-bottom: 1px solid var(--border); }
294
+ .mob-pager__b { display: grid; place-items: center; min-width: 48px; min-height: 44px; color: var(--foreground); }
295
+ .mob-pager__b:disabled { opacity: .3; }
296
+ .mob-pager__track { height: 3px; background: var(--border); }
297
+ .mob-pager__fill { display: block; height: 100%; width: var(--fr-deck-fill, 0); background: var(--foreground); transition: width .2s ease-out; }
298
+
299
+ /* The deck: the tab's own list, laid side by side, one card per screen width, snapping to each. The card's height is
300
+ * the screen between the deck head and the pager (--fr-deck-h, measured by pm/phone-deck.js). */
301
+ body[data-ph-view="cards"] .ph-deck {
302
+ display: flex !important;
303
+ flex-direction: row !important;
304
+ flex-wrap: nowrap !important;
305
+ gap: 0 !important;
306
+ margin: 0 !important;
307
+ padding: 0 !important;
308
+ overflow-x: auto;
309
+ overflow-y: hidden;
310
+ scroll-snap-type: x mandatory;
311
+ overscroll-behavior-x: contain;
312
+ scrollbar-width: none;
313
+ }
314
+ body[data-ph-view="cards"] .ph-deck::-webkit-scrollbar { display: none; }
315
+ /* The Roadmap's three columns and the Docs' groups hand their cards to the one deck. */
316
+ body[data-ph-view="cards"] .ph-deck .in-col,
317
+ body[data-ph-view="cards"] .ph-deck .in-list,
318
+ body[data-ph-view="cards"] .ph-deck .rp-docs-group,
319
+ body[data-ph-view="cards"] .ph-deck .rp-docs-group > [role="list"] { display: contents !important; }
320
+ body[data-ph-view="cards"] .ph-deck .in-col__head,
321
+ body[data-ph-view="cards"] .ph-deck .in-col__empty,
322
+ body[data-ph-view="cards"] .ph-deck .in-col > :not(.in-list),
323
+ body[data-ph-view="cards"] .ph-deck .in-list--dormant { display: none !important; }
324
+ body[data-ph-view="cards"] .ph-deck > .rp-section__empty { flex: 0 0 100%; }
325
+ body[data-ph-view="cards"] .ph-card {
326
+ flex: 0 0 100% !important;
327
+ width: 100% !important;
328
+ max-width: none !important;
329
+ height: var(--fr-deck-h, 60vh);
330
+ margin: 0 !important;
331
+ overflow-y: auto;
332
+ scroll-snap-align: start;
333
+ scroll-snap-stop: always;
334
+ display: flex !important;
335
+ flex-direction: column;
336
+ background: var(--card);
337
+ border: 1px solid var(--border);
338
+ }
339
+ /* A Today card: the cause leads, the evidence wraps under it, the acts sit at the bottom at full width. */
340
+ body[data-ph-view="cards"] .ph-card.td-card { padding: 20px 18px; }
341
+ body[data-ph-view="cards"] .ph-card .td-card__top { flex: 1; display: flex; flex-direction: column; justify-content: space-between; gap: 18px; }
342
+ body[data-ph-view="cards"] .ph-card .td-card__text { display: grid; gap: 12px; align-content: start; }
343
+ body[data-ph-view="cards"] .ph-card .td-card__cause { font-size: 20px; line-height: 1.3; font-weight: 600; }
344
+ body[data-ph-view="cards"] .ph-card .td-card__evidence { white-space: normal; overflow: visible; font-size: 14px; line-height: 1.45; }
345
+ 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; }
346
+ body[data-ph-view="cards"] .ph-card .td-card__acts > :first-child { width: 100%; }
347
+ body[data-ph-view="cards"] .ph-card .td-card__acts > * { min-height: 48px; justify-content: center; }
348
+ body[data-ph-view="cards"] .ph-card.td-card--prep .td-card__acts { grid-template-columns: minmax(0, 1fr); }
349
+ /* An initiative or a Board item: a .pmc-card primitive, so its deck size lives in components.css (.pmc-card.ph-card). */
350
+ /* A copy that differs: the row's sentence and its one button. */
351
+ body[data-ph-view="cards"] .ph-card.rp-docs-row { padding: 6px 4px; }
352
+
353
+ /* ── the terminal takes the screen ────────────────────────────────────────────────────────────
354
+ * The PM's word: entering a terminal switches the whole screen to it, because it is mostly output. The tabs are not
355
+ * drawn there; the bar keeps Back and the session's name, and the control keys stay above the keyboard. */
356
+ .mobmenu[data-view="terminal"] .mobnav { display: none; }
357
+
223
358
  }
224
359
 
225
360
  /* `hidden` wins over every display above, in both directions of the breakpoint. Back is ABSENT at a
@@ -227,3 +362,4 @@
227
362
  * the thing this workspace refuses to ship. */
228
363
  .mob[hidden], .mobmenu[hidden], .mob-back[hidden], .mob-brand[hidden] { display: none !important; }
229
364
  .mob-repo[hidden], .mob-set[hidden], .mob-sync[hidden], .mob-theme[hidden], .mobnav-n[hidden] { display: none !important; }
365
+ .mob-deck[hidden], .mob-pager[hidden], .mob-deck__views[hidden] { display: none !important; }
@@ -1,9 +1,11 @@
1
1
  /* pm/mobile.js — the phone surface of the four-panel workspace (≤760px).
2
2
  *
3
- * Owns three things and nothing else: the top bar (back · Repository · the screen's name · the ⌘K
4
- * palette · Settings; the old shell shows theme in place of Repository and Settings), the five-tab
5
- * bottom nav (Today · Initiatives · Board · Reports · Agent, pm-os-absorb Run 3, slice 3A), and the
6
- * terminal control-key accessory bar that writes real PTY sequences. Its markup ships in
3
+ * Owns three things and nothing else: the top bar (back · Repository · the screen's name on a pushed
4
+ * screen · Sync while the repository is not in sync · search, which opens the ⌘K palette · Settings, a gear; the old
5
+ * shell shows theme in place of Repository and Settings), the six-tab bottom nav (Today · Initiatives · Board ·
6
+ * Reports · Docs · Agent, pm-os-absorb Run 3, slice 3A), and the terminal control-key accessory bar that writes real PTY
7
+ * sequences. At a tab's root it hands the screen to pm/phone-deck.js, which shows the tab's cards one at a time. On a
8
+ * terminal the tabs are not drawn: the session takes the screen, and Back returns. Its markup ships in
7
9
  * public/index.html; its styling in pm/mobile.css. In the frame, a tab is the frame's place (Agent is
8
10
  * the dock) and Settings is pushed over the current tab.
9
11
  *
@@ -39,6 +41,7 @@ import { state, setState, writeCount, FIXTURE } from './state.js';
39
41
  import { COPY as FC } from './frame-copy.js';
40
42
  import { ensureComponentsCss } from './primitives.js';
41
43
  import { ensureSprite, icon } from './icons.js';
44
+ import { applyDeck, mountDeck } from './phone-deck.js';
42
45
 
43
46
  /* ── the breakpoint ─────────────────────────────────────────────────────────────────────────── */
44
47
 
@@ -318,6 +321,7 @@ export function applyMobile() {
318
321
  if (!on) {
319
322
  if (ctx) ctx.bodyGrid.removeAttribute('data-mpanel');
320
323
  if (ctrlArmed) setCtrlArmed(false);
324
+ applyDeck(false, null);
321
325
  return;
322
326
  }
323
327
 
@@ -333,10 +337,12 @@ export function applyMobile() {
333
337
  const act = (state.tabs || []).find((t) => t && t.id === state.active);
334
338
  if (menu) menu.dataset.view = s.kind === 'agent' ? (act && act.kind === 'term' ? 'terminal' : 'agent') : s.kind;
335
339
 
336
- const title = $('mobTitle');
337
- if (title) title.textContent = NAV.titleOf(s) || '';
338
-
339
340
  const deep = nav.stack.length > 0;
341
+
342
+ // In the frame, a tab's root is named once: by the bottom nav, which marks the current tab. The bar names a screen
343
+ // only when one is pushed over the tab (an initiative, a document, Settings), where Back takes the left of the bar.
344
+ const title = $('mobTitle');
345
+ if (title) title.textContent = (deep || !f) ? (NAV.titleOf(s) || '') : '';
340
346
  const back = $('mobBack');
341
347
  if (back) back.hidden = !deep; // absent at a destination root, never present-and-inert
342
348
  const brand = $('mobBrand');
@@ -384,6 +390,9 @@ export function applyMobile() {
384
390
  for (const b of buttons) b.setAttribute('aria-current', b.dataset.dest === nav.dest ? 'page' : 'false');
385
391
 
386
392
  if (s.kind !== 'terminal' && s.kind !== 'agent' && ctrlArmed) setCtrlArmed(false);
393
+
394
+ // At a tab's root in the frame, the tab's cards are a deck, one card at a time (pm/phone-deck.js)
395
+ applyDeck(!!f, s);
387
396
  }
388
397
 
389
398
  /* ── the chrome's own controls ──────────────────────────────────────────────────────────────── */
@@ -524,6 +533,12 @@ export function mountMobile(context) {
524
533
  else if (MQ.addListener) MQ.addListener(onMQ);
525
534
 
526
535
  setCtrlArmed(false);
536
+
537
+ mountDeck({
538
+ rerender: () => setState({}),
539
+ apply: () => { if (isMobile() && nav) applyDeck(frameOn(), NAV.current(nav)); },
540
+ place: () => ctx.bodyGrid.querySelector('[data-fr="main"]'),
541
+ });
527
542
  }
528
543
 
529
544
  /* A small surface for the browser suite, mirroring window.pmApp. Read-only in spirit: everything
@@ -761,6 +761,9 @@ function paintBottom(act) {
761
761
  composeSend.disabled = !text.trim();
762
762
  }
763
763
  setChildren(bottomBar, compose ? [composeLine] : (act ? [bottomToggle, bottomTitle, bottomCopy] : [bottomToggle, bottomTitle]));
764
+ // A phone draws no scratchpad drawer: its pad is the compose line of a terminal. With no terminal in front the bar would
765
+ // offer a toggle that opens nothing, so on a phone it is shown for the compose line only.
766
+ bottomBar.hidden = isMobile() && !compose;
764
767
  }
765
768
 
766
769
  /* ── clipboard + scratchpad actions (lines 2098–2118) ───────────────────────────────────────── */
@@ -0,0 +1,243 @@
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).
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.
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.
10
+ *
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.
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.
21
+ */
22
+
23
+ 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' },
34
+ };
35
+ const label = (h) => h.charAt(0).toUpperCase() + h.slice(1);
36
+
37
+ 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
40
+ let wasOn = false;
41
+ let ctx = null; // { rerender, place: () => the place panel }
42
+ let observer = null;
43
+ let queued = false;
44
+
45
+ const $ = (id) => document.getElementById(id);
46
+
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. */
49
+ export function phoneView(dest) {
50
+ if (dest === 'initiatives') {
51
+ const body = document.querySelector('[data-dest="initiatives"] [data-in-body]');
52
+ return body && body.getAttribute('data-in-body') === 'list' ? 'all' : 'cards';
53
+ }
54
+ if (dest === 'docs') {
55
+ const on = document.querySelector('[data-dest="docs"] [data-docs-filter] [aria-selected="true"]');
56
+ return on && on.getAttribute('data-tab') === 'all' ? 'all' : 'cards';
57
+ }
58
+ return view[dest] || 'cards';
59
+ }
60
+
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
+ function setView(dest, v) {
64
+ if (phoneView(dest) === v) return;
65
+ view[dest] = v;
66
+ if (dest === 'today') setDayForced(v === 'day');
67
+ if (dest === 'initiatives') window.dispatchEvent(new CustomEvent('pmos:initiatives-view', { detail: v === 'all' ? 'list' : 'roadmap' }));
68
+ if (dest === 'docs') {
69
+ const tab = document.querySelector(`[data-dest="docs"] [data-docs-filter] [data-tab="${v === 'all' ? 'all' : 'needs'}"]`);
70
+ if (tab) tab.click();
71
+ }
72
+ if (ctx && ctx.rerender) ctx.rerender();
73
+ refresh();
74
+ }
75
+
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)); }
79
+
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;
85
+ 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 };
96
+ }
97
+
98
+ function views(dest, cfg) {
99
+ const box = $('mobDeckViews');
100
+ if (!box) return;
101
+ const want = (cfg.views || []).map(([id, l]) => id + ':' + l + ':' + (phoneView(dest) === id)).join('|');
102
+ if (box.dataset.sig === dest + want) return;
103
+ box.dataset.sig = dest + want;
104
+ box.replaceChildren(...(cfg.views || []).map(([id, l]) => {
105
+ const b = document.createElement('button');
106
+ b.type = 'button';
107
+ b.className = 'mob-deck__view';
108
+ b.textContent = l;
109
+ b.setAttribute('aria-pressed', phoneView(dest) === id ? 'true' : 'false');
110
+ b.dataset.view = id;
111
+ b.dataset.phViewTo = dest;
112
+ return b;
113
+ }));
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'); }
161
+ }
162
+
163
+ function off() {
164
+ strip();
165
+ const shell = $('mobDeck'); if (shell) shell.hidden = true;
166
+ const pager = $('mobPager'); if (pager) pager.hidden = true;
167
+ delete document.body.dataset.phDeck;
168
+ delete document.body.dataset.phView;
169
+ live = null;
170
+ }
171
+
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
+ */
176
+ 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
+ if (!on) { if (wasOn) { wasOn = false; setDayForced(null); off(); if (observer) { observer.disconnect(); observer = null; } } return; }
180
+ wasOn = true;
181
+ watch();
182
+ const dest = screen && screen.kind === 'dest' ? screen.id : null;
183
+ const cfg = dest ? DECKS[dest] : null;
184
+ if (!cfg) { off(); return; }
185
+ if (dest === 'today') setDayForced(view.today === 'day');
186
+ document.body.dataset.phDeck = dest;
187
+ document.body.dataset.phView = phoneView(dest);
188
+ const shell = $('mobDeck'); if (shell) shell.hidden = false;
189
+ 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);
201
+ }
202
+
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
+ function refresh() {
207
+ if (queued) return;
208
+ queued = true;
209
+ requestAnimationFrame(() => { queued = false; if (ctx && ctx.apply) ctx.apply(); });
210
+ }
211
+
212
+ /* The place's host is made by app.js; it is watched from the first deck on. */
213
+ function watch() {
214
+ if (observer || !ctx || !ctx.place || !('MutationObserver' in window)) return;
215
+ const place = ctx.place();
216
+ if (!place) return;
217
+ observer = new MutationObserver(() => { if (document.body.dataset.phDeck) refresh(); });
218
+ observer.observe(place, { childList: true, subtree: true });
219
+ }
220
+
221
+ /** Wire the pager once, at boot. `context` is { rerender, apply, place }: apply() re-runs applyDeck with the current screen. */
222
+ export function mountDeck(context) {
223
+ 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
+ document.addEventListener('click', (e) => {
227
+ const t = e.target instanceof Element ? e.target.closest('#mobDeckViews [data-ph-view-to], #mobPrev, #mobNext') : null;
228
+ 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); }
239
+ });
240
+ }
241
+
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) };
@@ -18,6 +18,14 @@ export const COPY = {
18
18
  /** The empty state of R12. `next` is the label of the next scheduled draft, or null when none is known. */
19
19
  empty: (next) => (next ? `Nothing needs you. Next: ${next}.` : 'Nothing needs you.'),
20
20
  reading: 'Reading what needs you.',
21
+ /** The first card on a phone while the day is not prepared (pm/phone-deck.js). */
22
+ prep: {
23
+ cause: 'Prepare your day',
24
+ evidence: 'The agent reads your calendar and your Slack, Jira and Confluence mentions, ties each one to a goal and does the jobs it can. You approve only what sends or decides.',
25
+ go: 'Prepare my day',
26
+ busy: 'Starting…',
27
+ see: 'See the day',
28
+ },
21
29
  more: (n) => `Show ${n} more`,
22
30
  fewer: 'Show fewer',
23
31
  /** The state-health notice: sources that could not be read are said here, never shown as an empty list. */
@@ -42,7 +42,8 @@ const store = {
42
42
  get(k) { try { return localStorage.getItem(k); } catch { return null; } },
43
43
  set(k, v) { try { localStorage.setItem(k, v); } catch { /* private window: the default applies */ } },
44
44
  };
45
- const OPEN_KEY = 'pmos.today.day.open';
45
+ // A new key: a fold stored by the old Today page must not hide the Agenda month.
46
+ const OPEN_KEY = 'pmos.agenda.day.open';
46
47
  const SEEN_KEY = 'pmos.today.day.seen';
47
48
  let seen = new Set();
48
49
  try { seen = new Set(JSON.parse(store.get(SEEN_KEY) || '[]')); } catch { seen = new Set(); }
@@ -805,6 +806,21 @@ function detail(ctx) {
805
806
 
806
807
  /* ── the section ──────────────────────────────────────────────────────────────────────────────────────────────── */
807
808
 
809
+ /* The phone's view of Today (pm/phone-deck.js): Day opens the section and Cards folds it, without touching the fold the
810
+ * PM chose on a desktop. null is no phone override. */
811
+ let forced = null;
812
+ export function setDayForced(open) { forced = open === null || open === undefined ? null : !!open; }
813
+
814
+ /** Is today's day prepared, and is a press in flight? For the phone's first card on Today. */
815
+ export function dayState(ws) {
816
+ const c = cache.get(ws) || {};
817
+ const m = model(c.data, { here: null, all: false });
818
+ const press = runs.get(ws);
819
+ return { known: !!c.data, prepared: !!m.run, busy: !!press && press.state === 'busy' };
820
+ }
821
+ /** Press "Prepare my day" for `ws`, the same press as the day section's own button. */
822
+ export function pressPrepare(ws) { return prepareDay(ws); }
823
+
808
824
  function fold(open) {
809
825
  ui.open = open;
810
826
  store.set(OPEN_KEY, open ? 'true' : 'false');
@@ -821,7 +837,7 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
821
837
  ensure(ws);
822
838
  const c = cache.get(ws) || {};
823
839
  const m = model(c.data, { here: here || null, all: !!all });
824
- const open = ui.open !== null ? ui.open : !!m.run;
840
+ const open = forced !== null ? forced : ui.open !== null ? ui.open : true;
825
841
  if (!open) {
826
842
  return el('section', { class: 'td-day', 'data-today': 'day', 'data-open': 'false', 'aria-label': D.title },
827
843
  button({ label: D.toggle(m.needs), variant: 'ghost', iconEnd: 'chevron-right', attrs: { 'data-day': 'toggle', 'aria-expanded': 'false' }, onClick: () => fold(true) }));
@@ -848,12 +864,12 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
848
864
  c.error ? el('p', { class: 'td-day__run td-day__run--failed', role: 'status', 'data-day': 'error' }, D.refused(c.error)) : null,
849
865
  m.d && m.d.legacy ? note(D.oldServer) : null,
850
866
  el('div', { class: 'td-day__cols' },
851
- el('div', { class: 'td-day__left' }, summaryPanel(ctx), goalsPanel(ctx), agenda(ctx), jobsPanel(ctx), mentionsPanel(ctx), waitingPanel(owed)),
867
+ el('div', { class: 'td-day__left' }, agenda(ctx), summaryPanel(ctx), goalsPanel(ctx), jobsPanel(ctx), mentionsPanel(ctx), waitingPanel(owed)),
852
868
  detail(ctx)));
853
869
  }
854
870
 
855
871
  export function daySig(ws) {
856
872
  const c = cache.get(ws) || {};
857
- return JSON.stringify([ui.open, ui.sel, ui.mtab, ui.openSrc, ui.showIgn, ui.showIgnDigests, [...ui.revising], [...ui.busy], c.error, runs.get(ws) || null, [...seen].length])
873
+ return JSON.stringify([forced, ui.open, ui.sel, ui.mtab, ui.openSrc, ui.showIgn, ui.showIgnDigests, [...ui.revising], [...ui.busy], c.error, runs.get(ws) || null, [...seen].length])
858
874
  + dataSig(c.data) + goalsSig() + agendaSig();
859
875
  }