create-pmos 0.60.2 → 0.61.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/initiatives-timeline.md +2 -2
- package/workspace/guide/layout.md +24 -3
- package/workspace/lib/mobile-nav.js +6 -5
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/index.html +23 -5
- package/workspace/public/pm/app.js +6 -6
- package/workspace/public/pm/components.css +9 -10
- package/workspace/public/pm/dest-initiatives.js +7 -0
- package/workspace/public/pm/dest-today.js +25 -4
- package/workspace/public/pm/frame-copy.js +0 -1
- package/workspace/public/pm/mobile.css +141 -5
- package/workspace/public/pm/mobile.js +22 -7
- package/workspace/public/pm/panel-tabs.js +3 -0
- package/workspace/public/pm/phone-deck.js +243 -0
- package/workspace/public/pm/tab-term.js +24 -4
- package/workspace/public/pm/today-copy.js +8 -0
- package/workspace/public/pm/today-day.js +17 -2
- package/workspace/server.js +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-pmos",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.61.0",
|
|
4
4
|
"description": "Scaffold the PMOS portable kit — run the PMOS product-management workflow (initiative → lane-fit spec → contract → build → three gates, with work-type lanes for features, research, maintenance, incidents, and content) entirely inside your own repo, with all state as git-versioned files. Includes `workflow-sweep`, which audits the state every board derives from and separates real defects from work you simply have not done. `create-pmos workspace` also installs + launches the local Terminal Workspace GUI. No database, no network, no telemetry.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"bin": {
|
package/payload/KIT-VERSION
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
type: Reference
|
|
3
3
|
title: "Eval calibration log"
|
|
4
4
|
description: "Append-only record of every PM override of an evaluator verdict."
|
|
5
|
-
generated: {by: process:create-pmos, at: "2026-10-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T16:25:40Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T16:25:40Z"}
|
|
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-
|
|
5
|
+
generated: {by: process:create-pmos, at: "2026-10-06T16:25:40Z"}
|
|
6
6
|
status: stable
|
|
7
7
|
---
|
|
8
8
|
|
|
@@ -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-
|
|
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
|
-
|
|
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,12 +91,33 @@ 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.
|
|
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
|
-
|
|
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
|
|
|
119
|
+
You can have the same session open on your phone and your computer. A session has one size, so the screen you type in or tap last sets it. The other screen keeps showing the session and takes the size back as soon as you type in it.
|
|
120
|
+
|
|
100
121
|
## The older layout
|
|
101
122
|
|
|
102
123
|
The address options `?layout=legacy` and `?fixture=cockpit` keep the older layouts. The rest of this page describes them. In the cockpit, the workspace has four columns: the repository rail, the list, the detail and the tabs. With `?layout=legacy` it has three columns: navigation, detail and tabs.
|
|
@@ -66,15 +66,15 @@ const DETAIL_TITLE = {
|
|
|
66
66
|
addprod: 'add repository', new: 'file new',
|
|
67
67
|
};
|
|
68
68
|
|
|
69
|
-
/* ── the frame's
|
|
70
|
-
* Today · Initiatives · Board · Reports · Agent. The first
|
|
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
|
package/workspace/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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"
|
|
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)
|
|
118
|
-
Agent. The first
|
|
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,
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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. */
|
|
@@ -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:
|
|
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
|
|
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
|
-
/*
|
|
203
|
-
.mobnav { display: grid; grid-template-columns: repeat(
|
|
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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* terminal control-key accessory bar that writes real PTY
|
|
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) };
|
|
@@ -378,10 +378,25 @@ function handle(m) {
|
|
|
378
378
|
* The grid is therefore PUSHED here, unconditionally, the moment there is a session to push it
|
|
379
379
|
* to. `sent*` is cleared first so a re-attach after a reconnect re-states it. */
|
|
380
380
|
rec.sentCols = null; rec.sentRows = null;
|
|
381
|
-
syncSize(rec);
|
|
381
|
+
syncSize(rec, true);
|
|
382
382
|
refitVisible();
|
|
383
383
|
return;
|
|
384
384
|
}
|
|
385
|
+
if (m.t === 'size') {
|
|
386
|
+
/* ANOTHER SCREEN RESIZED THIS SESSION. A session has one PTY and one grid, and the last screen
|
|
387
|
+
* to state a grid wins. Without this, a pane never learned it had lost: its `sent*` still said
|
|
388
|
+
* its own grid, so it never stated it again, and the agent went on drawing for the other
|
|
389
|
+
* screen's grid inside this one — Claude Code's whole frame drawn twice, one under the other.
|
|
390
|
+
* The pane records the grid the PTY really has and YIELDS: it does not take the size back on
|
|
391
|
+
* its own (two open screens would then trade the size on every render), only when the person
|
|
392
|
+
* types into it, focuses it, or its own grid changes. See syncSize. */
|
|
393
|
+
for (const rec of panes.values()) {
|
|
394
|
+
if (rec.kind !== 'real' || rec.sessionId !== m.id || rec.attachedId !== m.id) continue;
|
|
395
|
+
rec.sentCols = m.cols; rec.sentRows = m.rows;
|
|
396
|
+
rec.yielded = !!rec.term && (rec.term.cols !== m.cols || rec.term.rows !== m.rows);
|
|
397
|
+
}
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
385
400
|
if (m.t === 'data') {
|
|
386
401
|
const rec = recFor(m.id);
|
|
387
402
|
if (rec) { try { rec.term.write(m.data); } catch { /* ignore */ } }
|
|
@@ -673,9 +688,11 @@ function attach(rec) {
|
|
|
673
688
|
* rather than on what xterm reports, because the server holds the authoritative copy and a resize
|
|
674
689
|
* this connection never sent is a resize the shell never heard.
|
|
675
690
|
*/
|
|
676
|
-
function syncSize(rec) {
|
|
691
|
+
function syncSize(rec, claim) {
|
|
677
692
|
if (!rec || rec.kind !== 'real' || !rec.term) return;
|
|
678
693
|
if (!rec.sessionId || rec.attachedId !== rec.sessionId) return;
|
|
694
|
+
// A pane that another screen resized waits until the person uses it (`claim`). See `size`.
|
|
695
|
+
if (claim) rec.yielded = false; else if (rec.yielded) return;
|
|
679
696
|
const cols = rec.term.cols, rows = rec.term.rows;
|
|
680
697
|
if (!cols || !rows) return;
|
|
681
698
|
if (rec.sentCols === cols && rec.sentRows === rows) return;
|
|
@@ -876,8 +893,11 @@ function buildReal(tab) {
|
|
|
876
893
|
const rec = {
|
|
877
894
|
kind: 'real', tab, host, term, fit, sessionId: null, attachedId: null, noted: null, ro: null,
|
|
878
895
|
};
|
|
879
|
-
|
|
880
|
-
|
|
896
|
+
// Typing, focusing or re-sizing THIS pane takes the session's grid back from another screen, and
|
|
897
|
+
// the grid goes out before the keystroke so the agent answers it at this pane's size.
|
|
898
|
+
term.onData((d) => { if (rec.sessionId) { syncSize(rec, true); send({ t: 'input', id: rec.sessionId, data: d }); } });
|
|
899
|
+
term.onResize(() => syncSize(rec, true));
|
|
900
|
+
host.addEventListener('focusin', () => syncSize(rec, true));
|
|
881
901
|
if (typeof ResizeObserver === 'function') {
|
|
882
902
|
rec.ro = new ResizeObserver(() => refitVisible());
|
|
883
903
|
try { rec.ro.observe(host); } catch { /* ignore */ }
|
|
@@ -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. */
|
|
@@ -805,6 +805,21 @@ function detail(ctx) {
|
|
|
805
805
|
|
|
806
806
|
/* ── the section ──────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
807
807
|
|
|
808
|
+
/* The phone's view of Today (pm/phone-deck.js): Day opens the section and Cards folds it, without touching the fold the
|
|
809
|
+
* PM chose on a desktop. null is no phone override. */
|
|
810
|
+
let forced = null;
|
|
811
|
+
export function setDayForced(open) { forced = open === null || open === undefined ? null : !!open; }
|
|
812
|
+
|
|
813
|
+
/** Is today's day prepared, and is a press in flight? For the phone's first card on Today. */
|
|
814
|
+
export function dayState(ws) {
|
|
815
|
+
const c = cache.get(ws) || {};
|
|
816
|
+
const m = model(c.data, { here: null, all: false });
|
|
817
|
+
const press = runs.get(ws);
|
|
818
|
+
return { known: !!c.data, prepared: !!m.run, busy: !!press && press.state === 'busy' };
|
|
819
|
+
}
|
|
820
|
+
/** Press "Prepare my day" for `ws`, the same press as the day section's own button. */
|
|
821
|
+
export function pressPrepare(ws) { return prepareDay(ws); }
|
|
822
|
+
|
|
808
823
|
function fold(open) {
|
|
809
824
|
ui.open = open;
|
|
810
825
|
store.set(OPEN_KEY, open ? 'true' : 'false');
|
|
@@ -821,7 +836,7 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
|
|
|
821
836
|
ensure(ws);
|
|
822
837
|
const c = cache.get(ws) || {};
|
|
823
838
|
const m = model(c.data, { here: here || null, all: !!all });
|
|
824
|
-
const open = ui.open !== null ? ui.open : !!m.run;
|
|
839
|
+
const open = forced !== null ? forced : ui.open !== null ? ui.open : !!m.run;
|
|
825
840
|
if (!open) {
|
|
826
841
|
return el('section', { class: 'td-day', 'data-today': 'day', 'data-open': 'false', 'aria-label': D.title },
|
|
827
842
|
button({ label: D.toggle(m.needs), variant: 'ghost', iconEnd: 'chevron-right', attrs: { 'data-day': 'toggle', 'aria-expanded': 'false' }, onClick: () => fold(true) }));
|
|
@@ -854,6 +869,6 @@ export function daySection({ ws, setState, openIni, owed, here, all } = {}) {
|
|
|
854
869
|
|
|
855
870
|
export function daySig(ws) {
|
|
856
871
|
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])
|
|
872
|
+
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
873
|
+ dataSig(c.data) + goalsSig() + agendaSig();
|
|
859
874
|
}
|
package/workspace/server.js
CHANGED
|
@@ -8393,7 +8393,15 @@ wss.on('connection', (ws) => {
|
|
|
8393
8393
|
} else if (m.t === 'input') {
|
|
8394
8394
|
const s = sessions.get(m.id); if (s && s.pty) s.pty.write(m.data);
|
|
8395
8395
|
} else if (m.t === 'resize') {
|
|
8396
|
-
const s = sessions.get(m.id);
|
|
8396
|
+
const s = sessions.get(m.id);
|
|
8397
|
+
if (s && s.pty) {
|
|
8398
|
+
try { s.pty.resize(m.cols, m.rows); s.cols = m.cols; s.rows = m.rows; } catch {}
|
|
8399
|
+
// A session has ONE grid. Every other screen showing it is told the grid it now has, so a
|
|
8400
|
+
// pane drawn for a different size knows it lost the size and takes it back when used —
|
|
8401
|
+
// a phone and a desktop on one session otherwise each kept drawing the other's frames.
|
|
8402
|
+
const frame = JSON.stringify({ t: 'size', id: s.id, cols: s.cols, rows: s.rows });
|
|
8403
|
+
for (const c of s.clients || []) if (c !== ws && c.readyState === 1) c.send(frame);
|
|
8404
|
+
}
|
|
8397
8405
|
} else if (m.t === 'new') {
|
|
8398
8406
|
// `initiative` is what the client has always held and never sent: `+ session` is clicked on
|
|
8399
8407
|
// an initiative row, the tab carries its `slug`, and the spawn message named three other
|