create-pmos 0.15.0 → 0.16.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.
Files changed (36) hide show
  1. package/README.md +38 -0
  2. package/package.json +1 -1
  3. package/payload/KIT-VERSION +1 -1
  4. package/workspace/README.md +44 -12
  5. package/workspace/design-parity.js +358 -0
  6. package/workspace/lib/find-browser.js +60 -0
  7. package/workspace/lib/four-panel.js +345 -0
  8. package/workspace/lib/git-info.js +28 -1
  9. package/workspace/lib/mobile-nav.js +32 -2
  10. package/workspace/package-lock.json +29 -2
  11. package/workspace/package.json +13 -5
  12. package/workspace/public/index.html +79 -409
  13. package/workspace/public/pm/app.js +242 -0
  14. package/workspace/public/pm/data.js +877 -0
  15. package/workspace/public/pm/dom.js +116 -0
  16. package/workspace/public/pm/fixture.js +979 -0
  17. package/workspace/public/pm/mobile.css +200 -0
  18. package/workspace/public/pm/mobile.js +403 -0
  19. package/workspace/public/pm/modes.js +1076 -0
  20. package/workspace/public/pm/panel-detail.js +648 -0
  21. package/workspace/public/pm/panel-initiatives.js +426 -0
  22. package/workspace/public/pm/panel-repos.js +460 -0
  23. package/workspace/public/pm/panel-tabs.js +487 -0
  24. package/workspace/public/pm/state.js +164 -0
  25. package/workspace/public/pm/tab-doc.js +279 -0
  26. package/workspace/public/pm/tab-task.js +83 -0
  27. package/workspace/public/pm/tab-term.js +560 -0
  28. package/workspace/public/pm/tokens.css +73 -0
  29. package/workspace/public/vendor/ibm-plex-mono-500.woff2 +0 -0
  30. package/workspace/server.js +158 -8
  31. package/workspace/focus.test.js +0 -650
  32. package/workspace/handoff.test.js +0 -443
  33. package/workspace/profiles.test.js +0 -362
  34. package/workspace/public/app.js +0 -5560
  35. package/workspace/public/styles.css +0 -2258
  36. package/workspace/repo-scan.test.js +0 -327
@@ -0,0 +1,200 @@
1
+ /* pm/mobile.css — the ≤760px phone surface of the four-panel workspace.
2
+ *
3
+ * Built to the PM's mobile companion prototype (planning/design/four-panel/prototype-mobile.html):
4
+ * stack navigation rather than columns, a 52px / 1fr / 64px frame at most 430px wide, and a
5
+ * five-item bottom nav. Every colour here is either a token from pm/tokens.css or one of the two
6
+ * hard-coded bar inks the prototype uses for its chrome — the same two the desktop shell uses, so
7
+ * the top bar is the same ink on both surfaces.
8
+ *
9
+ * ── Why the phone frame is built out of `body` ────────────────────────────────────────────────
10
+ * pm/app.js appends ONE root element to <body> and gives it an inline `height:100vh` and its own
11
+ * three rows. The phone needs a different frame around the same element, and the one thing it must
12
+ * never do to get one is move anything: panel 4 holds a live xterm, and a re-parented terminal
13
+ * comes back with a stale grid. So <body> itself becomes the phone's grid — the static chrome in
14
+ * row 1 and row 3, app.js's untouched root in row 2 — and the root's own rows collapse to the one
15
+ * that matters. Nothing is moved, nothing is rebuilt; a media query changes which boxes are drawn.
16
+ *
17
+ * ── Why `!important` ──────────────────────────────────────────────────────────────────────────
18
+ * app.js writes its geometry as an inline `style` attribute, which is a deliberate part of its
19
+ * contract with the panel modules (the prototype's style strings are copied verbatim, so they can
20
+ * be diffed). An author-origin rule cannot beat an inline declaration without `!important`, and
21
+ * the alternative — a phone branch inside app.js's style strings — would put the breakpoint in two
22
+ * places at once. The overrides are confined to the media query, so the desktop cascade is exactly
23
+ * what it was.
24
+ *
25
+ * Sharp corners throughout: nothing here sets a border-radius.
26
+ */
27
+
28
+ /* Above the breakpoint the phone chrome does not exist. `hidden` (set by pm/mobile.js) keeps it out
29
+ * of the accessibility tree as well — both, not either. */
30
+ .mob, .mobmenu { display: none; }
31
+
32
+ @media (max-width: 760px) {
33
+
34
+ /* The prototype's frame: 52px top bar, the screen, a 64px bottom nav, at most 430px wide and
35
+ * centred, with a hairline on each side so the phone shape is legible on a wide-ish tablet. */
36
+ body {
37
+ display: grid;
38
+ grid-template-rows: 52px minmax(0, 1fr) auto;
39
+ height: 100vh;
40
+ max-width: 430px;
41
+ margin: 0 auto;
42
+ overflow: hidden;
43
+ border-left: 1px solid var(--border);
44
+ border-right: 1px solid var(--border);
45
+ }
46
+
47
+ /* Explicit placement, never auto-placement: app.js appends its root LAST, so source order would
48
+ * put the screen underneath the bottom nav. */
49
+ .mob { display: block; grid-row: 1; min-width: 0; }
50
+ .mobmenu { display: block; grid-row: 3; min-width: 0; }
51
+
52
+ /* app.js's root, in row 2. Its 44px top bar and 26px status bar are the desktop's chrome and are
53
+ * replaced here by the phone's own — kept in the DOM (the shell suite indexes root.children) and
54
+ * simply not drawn, so the root is left with the single row that holds the panels. */
55
+ body > [data-pm-theme] {
56
+ grid-row: 2;
57
+ height: auto !important;
58
+ min-height: 0;
59
+ grid-template-rows: minmax(0, 1fr) !important;
60
+ }
61
+ body > [data-pm-theme] > *:first-child,
62
+ body > [data-pm-theme] > *:last-child { display: none; }
63
+
64
+ /* ONE panel at a time. `data-mpanel` carries the index pm/mobile.js derived from the reducer, and
65
+ * the rules below draw exactly that child of the body grid — which is the whole exclusivity
66
+ * guarantee, expressed as CSS rather than as something the reducer has to remember to enforce.
67
+ * The hidden panels keep their hosts mounted and their subtrees intact; a live terminal in panel
68
+ * 4 goes on running while the PM is reading the board. Each panel is restored to the `display` its
69
+ * own host was built with, because forcing them all to one value would relay their contents. */
70
+ body > [data-pm-theme] > *:nth-child(2) {
71
+ grid-template-columns: minmax(0, 1fr) !important;
72
+ min-height: 0;
73
+ }
74
+ [data-mpanel] > * { display: none !important; }
75
+ [data-mpanel="0"] > *:nth-child(1) { display: flex !important; }
76
+ [data-mpanel="1"] > *:nth-child(2) { display: block !important; }
77
+ [data-mpanel="2"] > *:nth-child(3) { display: flex !important; }
78
+ [data-mpanel="3"] > *:nth-child(4) { display: flex !important; }
79
+
80
+ /* ── the top bar ─────────────────────────────────────────────────────────────────────────── */
81
+
82
+ .mob-bar {
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 10px;
86
+ height: 52px;
87
+ padding: 0 14px;
88
+ background: oklch(0.1 0.004 228.8);
89
+ color: oklch(0.987 0.002 197.1);
90
+ border-bottom: 1px solid oklch(1 0 0 / 10%);
91
+ }
92
+ /* 44px minimums on every control: the phone's targets are thumbs, not a pointer. */
93
+ .mob-back {
94
+ flex: none;
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 6px;
98
+ min-width: 44px;
99
+ min-height: 44px;
100
+ margin-left: -6px;
101
+ color: oklch(0.85 0.01 214);
102
+ font-size: 14px;
103
+ }
104
+ .mob-brand {
105
+ flex: none;
106
+ display: grid;
107
+ place-items: center;
108
+ width: 26px;
109
+ height: 26px;
110
+ background: oklch(0.488 0.243 264.376);
111
+ font-size: 14px;
112
+ line-height: 1;
113
+ }
114
+ .mob-title {
115
+ flex: 1;
116
+ min-width: 0;
117
+ text-align: center;
118
+ font-family: var(--mono);
119
+ font-size: 12px;
120
+ color: oklch(0.85 0.01 214);
121
+ overflow: hidden;
122
+ text-overflow: ellipsis;
123
+ white-space: nowrap;
124
+ }
125
+ .mob-pal, .mob-theme {
126
+ flex: none;
127
+ display: grid;
128
+ place-items: center;
129
+ min-width: 44px;
130
+ min-height: 44px;
131
+ color: oklch(0.85 0.01 214);
132
+ }
133
+ .mob-pal { font-family: var(--mono); font-size: 12px; }
134
+ .mob-theme { font-size: 15px; }
135
+ .mob-back:active, .mob-pal:active, .mob-theme:active { color: oklch(0.987 0.002 197.1); }
136
+
137
+ /* ── the bottom block ────────────────────────────────────────────────────────────────────── */
138
+
139
+ .mobmenu {
140
+ background: oklch(0.1 0.004 228.8);
141
+ border-top: 1px solid oklch(1 0 0 / 10%);
142
+ /* viewport-fit=cover is on, so the home indicator's strip is ours to pad past. */
143
+ padding-bottom: env(safe-area-inset-bottom, 0px);
144
+ }
145
+
146
+ /* The control keys a phone keyboard does not have. Present on the terminal screen and on nothing
147
+ * else — the other screens have no PTY to write to, and a bar of inert keys is worse than none. */
148
+ .ctrlbar {
149
+ display: none;
150
+ min-width: 0;
151
+ gap: 5px;
152
+ padding: 6px 8px;
153
+ overflow-x: auto;
154
+ -webkit-overflow-scrolling: touch;
155
+ background: var(--muted);
156
+ border-bottom: 1px solid oklch(1 0 0 / 10%);
157
+ }
158
+ .mobmenu[data-view="terminal"] .ctrlbar { display: flex; }
159
+ .ctrlbar button {
160
+ flex: 1 0 auto;
161
+ display: grid;
162
+ place-items: center;
163
+ min-width: 44px;
164
+ min-height: 44px;
165
+ background: var(--secondary);
166
+ border: 1px solid var(--border);
167
+ color: var(--foreground);
168
+ font-family: var(--mono);
169
+ font-size: 14px;
170
+ }
171
+ .ctrlbar button:active { background: var(--accent); }
172
+ .ctrlbar button[aria-pressed="true"] {
173
+ border-color: var(--brand);
174
+ color: var(--brand);
175
+ background: var(--brand-wash);
176
+ }
177
+ .ctrlbar .lbl { font-size: 11px; letter-spacing: .04em; }
178
+
179
+ /* Five destinations, one per column, 64px tall — the prototype's bottom nav. */
180
+ .mobnav { display: grid; grid-template-columns: repeat(5, 1fr); min-width: 0; }
181
+ .mobnav-b {
182
+ display: flex;
183
+ flex-direction: column;
184
+ align-items: center;
185
+ justify-content: center;
186
+ gap: 3px;
187
+ min-width: 0;
188
+ min-height: 64px;
189
+ padding: 0 2px;
190
+ color: oklch(0.6 0.02 213);
191
+ }
192
+ .mobnav-b .g { font-size: 17px; line-height: 1; }
193
+ .mobnav-b .l { font-size: 10px; line-height: 1; }
194
+ .mobnav-b[aria-current="page"] { color: oklch(0.987 0.002 197.1); }
195
+ }
196
+
197
+ /* `hidden` wins over every display above, in both directions of the breakpoint. Back is ABSENT at a
198
+ * destination root rather than present-and-inert: a control that looks available and does nothing is
199
+ * the thing this workspace refuses to ship. */
200
+ .mob[hidden], .mobmenu[hidden], .mob-back[hidden], .mob-brand[hidden] { display: none !important; }
@@ -0,0 +1,403 @@
1
+ /* pm/mobile.js — the phone surface of the four-panel workspace (≤760px).
2
+ *
3
+ * Owns three things and nothing else: the top bar (back · the screen's name · the ⌘K jump palette ·
4
+ * theme), the five-destination bottom nav, and the terminal control-key accessory bar that writes
5
+ * real PTY sequences. Its markup ships in public/index.html; its styling in pm/mobile.css.
6
+ *
7
+ * ── ONE renderer, two shells ──────────────────────────────────────────────────────────────────
8
+ * Nothing here draws a phone-shaped copy of a panel. The four panel modules render exactly what
9
+ * they render on a desktop; this module decides which ONE of their hosts is drawn, and dresses the
10
+ * chrome around it. That is deliberate: a second implementation of a list is a second thing to keep
11
+ * correct, and the failure it produces — a phone showing a stale shape of the truth — is invisible
12
+ * from a desktop.
13
+ *
14
+ * ── The terminal must not die ────────────────────────────────────────────────────────────────
15
+ * Panel 4 hosts a live xterm bound to a PTY. Switching screens therefore NEVER re-parents, removes
16
+ * or rebuilds a host: every one of the four stays mounted in the body grid and `data-mpanel` picks
17
+ * which is displayed (pm/mobile.css). A terminal the PM navigated away from keeps running, keeps
18
+ * its scrollback, and is still there when they come back — because it never moved.
19
+ *
20
+ * ── Navigation is the reducer's, not this file's ─────────────────────────────────────────────
21
+ * `lib/mobile-nav.js` (window.PMOSMobileNav, loaded as a classic script from index.html) is the
22
+ * source of truth for the destinations, the stack, the titles and the screen→panel mapping. It is
23
+ * DOM-free and walked in node by test.js, which is the whole reason it lives outside this file. If
24
+ * it is missing, the phone surface stays down rather than half-navigating with a local copy — a
25
+ * duplicated reducer is exactly the drift extracting it was meant to stop.
26
+ *
27
+ * ── How depth is detected ────────────────────────────────────────────────────────────────────
28
+ * The panel modules do not know a phone exists; they call `setState` the way they do on a desktop.
29
+ * So this module watches the fields that MEAN "you went somewhere" — the selected initiative, the
30
+ * detail record, the active tab, the mode — and pushes the matching screen. A change this module
31
+ * caused itself is marked and skipped, so tapping a destination cannot be read back as the PM
32
+ * navigating into one.
33
+ */
34
+
35
+ import { state, setState, writeCount, FIXTURE } from './state.js';
36
+
37
+ /* ── the breakpoint ─────────────────────────────────────────────────────────────────────────── */
38
+
39
+ const MQ = window.matchMedia('(max-width:760px)');
40
+
41
+ /** True when the phone surface is the one that should be on screen. */
42
+ export function isMobile() { return MQ.matches; }
43
+
44
+ /* ── the reducer, and the fallbacks it does NOT get ─────────────────────────────────────────── */
45
+
46
+ const NAV = window.PMOSMobileNav || null;
47
+ if (!NAV) {
48
+ console.error('[pm] /lib/mobile-nav.js did not load — the phone surface is disabled. '
49
+ + 'index.html must load it before the module graph.');
50
+ }
51
+
52
+ /** kind → which of the four panels draws it. The reducer owns the map; this is only its fallback. */
53
+ const PANEL = { more: 0, list: 1, detail: 2, terminal: 3, doc: 3 };
54
+ const panelOf = (kind) =>
55
+ (NAV && typeof NAV.panelOf === 'function') ? NAV.panelOf(kind)
56
+ : (Object.prototype.hasOwnProperty.call(PANEL, kind) ? PANEL[kind] : 1);
57
+
58
+ /* A destination's root list is a MODE. `more` has none: its root is the structure panel itself,
59
+ * which is where the repositories, the workflows, the skills, the knowledge and the connections
60
+ * are reached from — the phone's "more" and the desktop's panel 1 are the same surface. */
61
+ const DEST_MODE = { board: 'product', scheduled: 'scheduled', agents: 'agent', memory: 'memory' };
62
+ /* Two modes postdate the reducer's map (`connect` and `focus` arrived with the four-panel design).
63
+ * They are named here rather than added to `DEST_FOR_MODE`, because MOBNAV-P7 pins that map to the
64
+ * retired app.js's MODES exactly and a mode invented for a different surface would read as drift. */
65
+ const EXTRA_DEST = { connect: 'more', focus: 'board' };
66
+
67
+ const destForMode = (m) => (NAV && NAV.destForMode(m)) || EXTRA_DEST[m] || null;
68
+
69
+ /* ── module state ───────────────────────────────────────────────────────────────────────────── */
70
+
71
+ const $ = (id) => document.getElementById(id);
72
+
73
+ const DEST_STORE_KEY = 'pmos.workspace.pm.mdest';
74
+
75
+ let ctx = null; // { bodyGrid, render } — handed over by app.js at boot
76
+ let nav = null; // the reducer's state
77
+ let seen = null; // the app-state fingerprint the last screen was derived from
78
+ let selfDriven = false; // the next state change is one this module caused — do not read it as navigation
79
+ let ctrlArmed = false; // the control bar's one-shot Ctrl
80
+
81
+ /* R11 — the DESTINATION is remembered across reloads; the deeper stack deliberately is not. A
82
+ * pushed screen names a record that may no longer exist, and restoring it would mean drawing a
83
+ * screen for something PMOS has not re-read. Fixture mode reads and writes nothing, so a
84
+ * comparator run is deterministic. */
85
+ function readDest() {
86
+ if (FIXTURE) return null;
87
+ try { return localStorage.getItem(DEST_STORE_KEY); } catch { return null; }
88
+ }
89
+ function writeDest(d) {
90
+ if (FIXTURE) return;
91
+ try { localStorage.setItem(DEST_STORE_KEY, d); } catch { /* quota / private mode */ }
92
+ }
93
+
94
+ /* ── writing to the PTY ─────────────────────────────────────────────────────────────────────── */
95
+
96
+ const SEQ = { esc: '\x1b', tab: '\t', up: '\x1b[A', down: '\x1b[B', left: '\x1b[D', right: '\x1b[C' };
97
+
98
+ /* Keyboard identities for the last-resort path below. xterm turns a keydown into the same bytes
99
+ * `SEQ` holds, so the two routes cannot disagree about what "up" means. */
100
+ const KEYDOWN = {
101
+ esc: { key: 'Escape', code: 'Escape', keyCode: 27 },
102
+ tab: { key: 'Tab', code: 'Tab', keyCode: 9 },
103
+ up: { key: 'ArrowUp', code: 'ArrowUp', keyCode: 38 },
104
+ down: { key: 'ArrowDown', code: 'ArrowDown', keyCode: 40 },
105
+ left: { key: 'ArrowLeft', code: 'ArrowLeft', keyCode: 37 },
106
+ right: { key: 'ArrowRight', code: 'ArrowRight', keyCode: 39 },
107
+ };
108
+
109
+ /** The terminal panel's own send hook, if it publishes one. */
110
+ function termApi() {
111
+ const t = window.pmTerm;
112
+ if (t && typeof t.send === 'function') return t;
113
+ const mods = window.pmApp && window.pmApp.mods;
114
+ const tabs = mods && mods.tabs;
115
+ if (tabs && typeof tabs.sendInput === 'function') return { send: tabs.sendInput, focus: tabs.focusTerm };
116
+ return null;
117
+ }
118
+
119
+ /** The live xterm instance for the active tab, however the terminal panel publishes it. */
120
+ function activeTerm() {
121
+ const id = state.active;
122
+ const maps = [window.pmTerms, window.pmApp && window.pmApp.terms];
123
+ for (const m of maps) {
124
+ if (!m) continue;
125
+ const rec = typeof m.get === 'function' ? m.get(id) : m[id];
126
+ const term = rec && (rec.term || rec);
127
+ if (term && typeof term.input === 'function') return term;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ /**
133
+ * Send a raw sequence to the active PTY, then put focus back in the terminal so the soft keyboard
134
+ * stays up. Four routes, most direct first; the last one exists because panel-tabs.js may not be
135
+ * written yet and a control bar that silently does nothing is worse than one that is absent.
136
+ */
137
+ function sendSeq(name) {
138
+ const data = SEQ[name];
139
+ if (!data) return false;
140
+ const api = termApi();
141
+ if (api) { api.send(data); if (typeof api.focus === 'function') api.focus(); return true; }
142
+ const term = activeTerm();
143
+ if (term) { term.input(data, true); if (typeof term.focus === 'function') term.focus(); return true; }
144
+ // Last resort: xterm's own hidden textarea. Its keydown handler produces the sequence and emits
145
+ // it through onData, so whatever the terminal panel wired to onData is what carries it to the PTY.
146
+ const ta = document.querySelector('.xterm-helper-textarea');
147
+ const k = KEYDOWN[name];
148
+ if (!ta || !k) return false;
149
+ ta.focus();
150
+ ta.dispatchEvent(new KeyboardEvent('keydown', { ...k, bubbles: true, cancelable: true }));
151
+ return true;
152
+ }
153
+
154
+ /** Arm/disarm the one-shot Ctrl modifier and say so on the button. */
155
+ function setCtrlArmed(on) {
156
+ ctrlArmed = !!on;
157
+ const b = $('ctrlKey');
158
+ if (b) b.setAttribute('aria-pressed', String(ctrlArmed));
159
+ window.pmMobileCtrl = { armed: ctrlArmed, apply: applyCtrl };
160
+ }
161
+
162
+ /**
163
+ * The Ctrl transform, exposed so the terminal panel can apply it inside its own `onData` if it
164
+ * prefers. Ctrl is a ONE-SHOT: the next character becomes its control code and Ctrl disarms.
165
+ */
166
+ export function applyCtrl(d) {
167
+ if (!ctrlArmed) return d;
168
+ setCtrlArmed(false);
169
+ if (d.length === 1) {
170
+ const c = d.charCodeAt(0);
171
+ if (c >= 97 && c <= 122) return String.fromCharCode(c - 96); // a–z → 0x01–0x1a
172
+ if (c >= 65 && c <= 90) return String.fromCharCode(c - 64); // A–Z → 0x01–0x1a
173
+ if (c >= 91 && c <= 95) return String.fromCharCode(c - 64); // [ \ ] ^ _ → 0x1b–0x1f
174
+ if (c === 32) return '\x00'; // Ctrl-Space → NUL
175
+ }
176
+ return d;
177
+ }
178
+
179
+ /* Armed Ctrl, applied here as well as offered above: the soft keyboard's next printable key is
180
+ * intercepted in the capture phase before xterm sees it, converted, and written straight out. This
181
+ * is what makes Ctrl-C work on a phone whether or not the terminal panel knows this module exists. */
182
+ function onCaptureKeyDown(e) {
183
+ if (!ctrlArmed || !isMobile()) return;
184
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
185
+ if (typeof e.key !== 'string' || e.key.length !== 1) return;
186
+ const target = e.target;
187
+ if (!target || !target.closest || !target.closest('.xterm')) return;
188
+ const out = applyCtrl(e.key);
189
+ if (out === e.key) return;
190
+ e.preventDefault();
191
+ e.stopPropagation();
192
+ const api = termApi();
193
+ if (api) { api.send(out); return; }
194
+ const term = activeTerm();
195
+ if (term) term.input(out, true);
196
+ }
197
+
198
+ /* ── deriving the screen from the app's own state ───────────────────────────────────────────── */
199
+
200
+ function fingerprint() {
201
+ const d = state.detail || {};
202
+ return {
203
+ mode: state.mode,
204
+ sel: state.sel,
205
+ detail: String(d.kind || '') + '|' + String(d.id || ''),
206
+ active: state.active,
207
+ /* Selecting the tab that is ALREADY active is still "show me this tab" — and on a phone the
208
+ * PM may be looking at the board while that tab is the active one, so the request moves the
209
+ * screen even though the value does not move. The counter is state.js's write log; watching
210
+ * the value alone is what left the terminal screen unreachable once you had navigated off it. */
211
+ activeAsk: writeCount('active'),
212
+ };
213
+ }
214
+
215
+ /** Push a screen, replacing rather than stacking when it is the same kind as the current one. */
216
+ function go(screen) {
217
+ const cur = NAV.current(nav);
218
+ const base = (nav.stack.length && cur.kind === screen.kind) ? NAV.pop(nav) : nav;
219
+ nav = NAV.push(base, screen);
220
+ }
221
+
222
+ /**
223
+ * Read the app's state for a navigation the PM made through a panel, and push the screen it means.
224
+ * Exactly one transition is taken per render, most specific first: opening a session is a terminal
225
+ * screen even though it also changes the selection.
226
+ */
227
+ function syncFromState() {
228
+ const cur = fingerprint();
229
+ if (!seen || selfDriven) { selfDriven = false; seen = cur; return; }
230
+
231
+ if (cur.active && (cur.active !== seen.active || cur.activeAsk !== seen.activeAsk)) {
232
+ const tab = (state.tabs || []).find((t) => t && t.id === cur.active);
233
+ go({ kind: (tab && tab.kind === 'doc') ? 'doc' : 'terminal', id: cur.active, title: (tab && (tab.title || tab.name)) || String(cur.active) });
234
+ } else if (cur.detail !== seen.detail || (cur.sel && cur.sel !== seen.sel)) {
235
+ const dk = (state.detail && state.detail.kind) || '';
236
+ if (dk && dk !== 'none') go({ kind: 'detail', dk, id: (state.detail && state.detail.id) || cur.sel });
237
+ } else if (cur.mode !== seen.mode) {
238
+ const dest = destForMode(cur.mode);
239
+ if (dest && DEST_MODE[dest] === cur.mode) {
240
+ nav = NAV.switchDest(nav, dest);
241
+ writeDest(nav.dest);
242
+ } else {
243
+ if (dest && dest !== nav.dest) nav = NAV.switchDest(nav, dest);
244
+ go({ kind: 'list', mode: cur.mode });
245
+ }
246
+ }
247
+ seen = cur;
248
+ }
249
+
250
+ /* ── applying it ────────────────────────────────────────────────────────────────────────────── */
251
+
252
+ /**
253
+ * Bring the chrome and the visible panel in line with the current screen. Called at the end of
254
+ * every shell render, and on every breakpoint change.
255
+ */
256
+ export function applyMobile() {
257
+ const shell = $('mobShell');
258
+ const menu = $('mobMenu');
259
+ const on = isMobile() && !!NAV && !!ctx;
260
+
261
+ // R12 — above the breakpoint the chrome is `display:none` (stylesheet) AND `hidden` (here), so it
262
+ // is absent from the accessibility tree rather than merely invisible. Both, not either.
263
+ if (shell) shell.hidden = !on;
264
+ if (menu) menu.hidden = !on;
265
+ if (!on) {
266
+ if (ctx) ctx.bodyGrid.removeAttribute('data-mpanel');
267
+ if (ctrlArmed) setCtrlArmed(false);
268
+ return;
269
+ }
270
+
271
+ if (!nav) nav = NAV.init(NAV.migrateView(readDest()));
272
+ syncFromState();
273
+
274
+ const s = NAV.current(nav);
275
+
276
+ // The host is chosen FIRST, so a renderer that measures itself (the terminal's fit) sees the box
277
+ // it is actually going to occupy rather than the one it is leaving.
278
+ ctx.bodyGrid.dataset.mpanel = String(panelOf(s.kind));
279
+ if (menu) menu.dataset.view = s.kind;
280
+
281
+ const title = $('mobTitle');
282
+ if (title) title.textContent = NAV.titleOf(s) || '';
283
+
284
+ const deep = nav.stack.length > 0;
285
+ const back = $('mobBack');
286
+ if (back) back.hidden = !deep; // absent at a destination root, never present-and-inert
287
+ const brand = $('mobBrand');
288
+ if (brand) brand.hidden = deep; // the lockup yields to the back control, as the design has it
289
+
290
+ const theme = $('mobTheme');
291
+ if (theme) theme.textContent = state.theme === 'dark' ? '◐' : '◑';
292
+
293
+ const buttons = menu ? menu.querySelectorAll('#mobNav [data-dest]') : [];
294
+ for (const b of buttons) b.setAttribute('aria-current', b.dataset.dest === nav.dest ? 'page' : 'false');
295
+
296
+ if (s.kind !== 'terminal' && ctrlArmed) setCtrlArmed(false);
297
+ }
298
+
299
+ /* ── the chrome's own controls ──────────────────────────────────────────────────────────────── */
300
+
301
+ /** Choose a destination: reset it to its root and put the app in the mode that root shows. */
302
+ function setDest(dest) {
303
+ nav = NAV.switchDest(nav, dest);
304
+ writeDest(nav.dest);
305
+ const mode = DEST_MODE[dest];
306
+ selfDriven = true;
307
+ setState(mode ? { mode, settingsOpen: false } : { settingsOpen: false });
308
+ }
309
+
310
+ /** Back: pop exactly one screen and put the app back in the state that screen describes. */
311
+ function goBack() {
312
+ nav = NAV.pop(nav);
313
+ const s = NAV.current(nav);
314
+ selfDriven = true;
315
+ setState(s.kind === 'list' && s.mode ? { mode: s.mode } : {});
316
+ }
317
+
318
+ /** The shell's own ⌘K target — panel 2's search input, wherever that panel decided to put it. */
319
+ function focusSearch() {
320
+ const input = document.querySelector('[data-pm-search]')
321
+ || document.querySelector('input[placeholder="search everything…"]');
322
+ if (input) input.focus();
323
+ }
324
+
325
+ /**
326
+ * The jump palette. It is a jump control for EVERY destination, so it lives in the always-visible
327
+ * top bar; the control bar below scrolls, and a control that scrolls out of view at 390px is the
328
+ * hover-only trap in another costume. Search lives in panel 2, so the palette makes sure a list
329
+ * screen is the one on screen before it hands focus over.
330
+ */
331
+ function openPalette() {
332
+ if (NAV.current(nav).kind !== 'list') go({ kind: 'list', mode: state.mode });
333
+ selfDriven = true;
334
+ setState({ col1: true }, focusSearch);
335
+ }
336
+
337
+ /** The phone's theme control drives the same state as the desktop's, so the two cannot disagree. */
338
+ function toggleTheme() {
339
+ selfDriven = true;
340
+ setState((s) => ({ theme: s.theme === 'dark' ? 'light' : 'dark' }));
341
+ }
342
+
343
+ /* The terminal has to re-measure when the soft keyboard shows or hides and after a rotation, or
344
+ * xterm keeps the row count it had and the bottom of the session is clipped. The fit itself belongs
345
+ * to the terminal panel; this only tells it that the box changed. */
346
+ function refit() {
347
+ const t = window.pmTerm;
348
+ if (t && typeof t.fit === 'function') { try { t.fit(); return; } catch { /* fall through */ } }
349
+ window.dispatchEvent(new Event('resize'));
350
+ }
351
+
352
+ /* ── mount ──────────────────────────────────────────────────────────────────────────────────── */
353
+
354
+ /**
355
+ * Wire the phone chrome up, once, at boot.
356
+ * `context` is `{ bodyGrid, render }` — the body grid whose children are the four panel hosts, and
357
+ * the shell's render function, so a breakpoint change can repaint the panels the phone needs.
358
+ */
359
+ export function mountMobile(context) {
360
+ ctx = context || null;
361
+ if (!ctx || !ctx.bodyGrid || !NAV) return;
362
+
363
+ for (const b of document.querySelectorAll('#ctrlBar [data-seq]')) {
364
+ b.addEventListener('click', () => sendSeq(b.dataset.seq));
365
+ }
366
+ const ctrl = $('ctrlKey');
367
+ if (ctrl) ctrl.addEventListener('click', () => setCtrlArmed(!ctrlArmed));
368
+ window.addEventListener('keydown', onCaptureKeyDown, true);
369
+
370
+ for (const b of document.querySelectorAll('#mobNav [data-dest]')) {
371
+ b.addEventListener('click', () => setDest(b.dataset.dest));
372
+ }
373
+ const back = $('mobBack');
374
+ if (back) back.addEventListener('click', goBack);
375
+ const pal = $('mobPalette');
376
+ if (pal) pal.addEventListener('click', openPalette);
377
+ const theme = $('mobTheme');
378
+ if (theme) theme.addEventListener('click', toggleTheme);
379
+
380
+ if (window.visualViewport) {
381
+ window.visualViewport.addEventListener('resize', refit);
382
+ window.visualViewport.addEventListener('scroll', refit);
383
+ }
384
+ window.addEventListener('orientationchange', () => setTimeout(refit, 250));
385
+
386
+ // Crossing the breakpoint (rotate a tablet, drag a window narrow) has to re-decide whether the
387
+ // phone shell exists at all AND repaint the panels — a shell left mounted above 760px is a second
388
+ // navigation system on the desktop, and panels left unpainted below it are three empty screens.
389
+ const onMQ = () => {
390
+ if (isMobile()) nav = nav || NAV.init(NAV.migrateView(readDest()));
391
+ if (ctx && typeof ctx.render === 'function') ctx.render();
392
+ else applyMobile();
393
+ refit();
394
+ };
395
+ if (MQ.addEventListener) MQ.addEventListener('change', onMQ);
396
+ else if (MQ.addListener) MQ.addListener(onMQ);
397
+
398
+ setCtrlArmed(false);
399
+ }
400
+
401
+ /* A small surface for the browser suite, mirroring window.pmApp. Read-only in spirit: everything
402
+ * here is either a pure function or a snapshot. */
403
+ window.pmMobile = { isMobile, applyMobile, applyCtrl, SEQ, panelOf, nav: () => nav };