From e5ae1cc9bbad543de05829783db160497ea2f0be Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Fri, 22 May 2026 22:55:29 -0700 Subject: [PATCH 01/14] Add Pocket tutorial profile --- docs/specs/mobile-ui.md | 9 +- docs/specs/tutorial.md | 46 +++-- lib/src/components/MobileTerminalUi.tsx | 8 +- .../components/PocketTerminalExperience.tsx | 102 ++++++++--- website/src/lib/tut-items.ts | 163 ++++++++++++++---- website/src/lib/tut-runner.test.ts | 41 ++++- website/src/lib/tut-runner.ts | 33 ++-- website/src/lib/tutorial-state.ts | 18 +- 8 files changed, 328 insertions(+), 92 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 7dd4b58..ae6b4dd 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -22,8 +22,10 @@ tethering environment. `/playground/pocket` uses the same fake playground terminal stack as `/playground/desktop`: `PlaygroundShellRegistry` attaches a `TutorialShell` to -every spawned pane, the same fake commands dispatch to browser-side runners, and -the first pane simply auto-runs `ascii-splash` as its initial command. +every spawned pane, and the same fake commands dispatch to browser-side runners. +The first mobile session auto-runs `tut` with the Pocket tutorial profile; a +second `changelog` session auto-runs `changelog` for the tutorial's copy/paste +coverage. ## 2. Prototype Goals @@ -453,7 +455,7 @@ Prototype behavior: Build exactly this: -* One terminal playground screen. +* One mobile terminal playground screen with one visible session at a time. * Floating theme switcher using the shared Dormouse theme picker. * Touch mode selector: @@ -473,6 +475,7 @@ Sessions | Recent | Type | Draft * Draft reserve content: `WIP - this will be a place to draft prompts before pasting into the terminal`. * Type mode native mobile keyboard input. * Gesture mode radial menu for arrows, navigation keys, Esc, Tab, Enter, simple vim-like keys, confirmed Ctrl+C, confirmed Paste, and Quit breakout. +* Pocket `tut` starts directly in the Gesture navigation section, uses the title `Dormouse Pocket Tutorial`, and credits gesture items from radial-menu input callbacks rather than from native keyboard input. * Simple local playground terminal behavior. ## 13. Prototype Success Criteria diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index b46391c..7e97ec7 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -7,16 +7,21 @@ The website playground has canonical device-specific routes: - `/playground/pocket` hosts the mobile Pocket playground. On desktop it shows the temporary Pocket marketing/share page from the old `/pocket` route, including the phone preview and notify signup form. - `/pocket` temporarily redirects to `/playground/pocket`. This is a temporary launch-state redirect; the future real tethering surface should stay separate from the playground URL when it exists. -The interactive desktop TUI lives at `/playground/desktop`. Each item starts pending, the first incomplete item is marked as active, and completed items become green checks when Dormouse detects the corresponding action. +The `tut` TUI has device-specific profiles: + +- **Desktop** (`/playground/desktop`) uses `Dormouse Playground Tutorial`, starts at the top-level menu, and includes Keyboard navigation, Alert and TODO, and Copy paste. +- **Pocket** (`/playground/pocket` mobile) uses `Dormouse Pocket Tutorial`, starts directly inside Gesture navigation, removes Keyboard navigation and Alert and TODO, and includes Gesture navigation plus Copy paste. + +Each item starts pending, the first incomplete item is marked as active, and completed items become green checks when Dormouse detects the corresponding action. ## Architecture Three browser-side pieces in `website/src/lib/`, mirroring the pattern in `website/src/lib/ascii-splash-runner.ts` (xterm alt-screen + `FakePtyAdapter` boundary, no Node `terminal-kit` package): -- **`tut-runner.ts`** (`TutRunner`) — alt-screen TUI. Subscribes to `TutorialState` and re-renders whenever progress changes. Routes input bytes via `FakePtyAdapter.writePty(id, …)`. +- **`tut-runner.ts`** (`TutRunner`) — profile-aware alt-screen TUI. Subscribes to `TutorialState` and re-renders whenever progress changes. Routes input bytes via `FakePtyAdapter.writePty(id, …)`. - **`tut-detector.ts`** (`TutDetector`) — wires app events to `TutorialState.markComplete(id)`. Subscribes to `DockviewApi.onDidActivePanelChange`, the `WallEvent` stream, the `subscribeToActivity` store from `dormouse-lib/lib/terminal-registry`, and the `subscribeToMouseSelection` store from `dormouse-lib/lib/mouse-selection`. -- **`tutorial-state.ts`** (`TutorialState`) — single in-memory progress store, persisted as a JSON array of completed item ids under the `dormouse-tut-v3` localStorage key. The top-level GitHub star prompt persists separately under `dormouse-tut-star-v1`. -- **`tut-items.ts`** — section + item definitions (titles, hints) shared by runner and detector. Item ids are stable; they are the localStorage key suffixes. +- **`tutorial-state.ts`** (`TutorialState`) — single in-memory progress store, persisted as a JSON array of completed item ids under the `dormouse-tut-v3` localStorage key. Progress totals are computed from the active profile's section list. The top-level GitHub star prompt persists separately under `dormouse-tut-star-v1`. +- **`tut-items.ts`** — section + item definitions (titles, hints) plus `DESKTOP_TUTORIAL_PROFILE` and `POCKET_TUTORIAL_PROFILE`, shared by runner and detector. Item ids are stable; they are the localStorage key suffixes. ## Layout @@ -30,9 +35,16 @@ Three browser-side pieces in `website/src/lib/`, mirroring the pattern in `websi Every playground pane gets a `TutorialShell` input handler through `PlaygroundShellRegistry`. Newly split or spawned fake terminals use `SCENARIO_SHELL_PROMPT` by default. The shell dispatches by command name to a `startProgram` factory provided by the page; the factory wires `tut` → `TutRunner` and `ascii-splash` / `splash` → `AsciiSplashRunner`. +On `/playground/pocket`, `MobileWall` starts with two sessions: + +- **`pocket-tut`** — titled "tutorial", auto-launches `TutRunner` with `POCKET_TUTORIAL_PROFILE`, and is the active session. +- **`pocket-changelog`** — titled "changelog", auto-launches `ChangelogRunner` so the Copy paste section has wrapped text and a mouse-capturing TUI target. + +The Pocket page attaches `TutDetector` with the shared activity and mouse-selection stores so Copy paste detections work without desktop `Wall` events. Pocket-specific Gesture navigation detections are wired in `PocketTerminalExperience`: touch-mode changes complete `gn-touch-mode`, and `MobileTerminalUi.onGestureInput` completes `gn-arrows`, `gn-enter`, and `gn-esc` only for radial-menu generated inputs. + ## Tutorial Sections -The runner shows a top-level menu first. Selecting a section drills into its item list. Each section shows `[N/M complete]` next to its title. The menu helper below `Dormouse Playground Tutorial` shows only navigation shortcuts, not overall completion. +The desktop runner shows a top-level menu first. The Pocket runner starts directly inside `Gesture navigation`; pressing Esc returns to its top-level menu. Selecting a section drills into its item list. Each section shows `[N/M complete]` next to its title. The menu helper below the profile title shows only navigation shortcuts, not overall completion. The top-level menu also includes `Starred on GitHub`, which sits directly below `Copy paste` without a blank spacer, and shows `[not yet]` until selected and `[thanks ⭐]` after it has been resolved. Pressing Enter on that row calls `onOpenGithub`, which `/playground/desktop` and the Pocket playground wire to `window.open("https://github.com/diffplug/dormouse", "_blank", "noopener,noreferrer")`. @@ -46,7 +58,7 @@ Inside a section, items render as one of: Esc / `q` / Ctrl+C pops back one screen (section → menu → exit). Exiting the runner returns the pane to the shell prompt; running `tut` re-enters. -### Section 1 — Keyboard navigation (7 items) +### Desktop Section 1 — Keyboard navigation (7 items) | ID | Title | Detection | |---|---|---| @@ -62,7 +74,7 @@ Prose under the section: "tmux shortcuts also work — `% " d x`." Note: `-` produces a `direction: 'vertical'` split (panes stack top/bottom = horizontal divider); `|` produces `direction: 'horizontal'` (panes side by side = vertical divider). The detector maps event direction → user-facing item accordingly. -### Section 2 — Alert and TODO (6 items) +### Desktop Section 2 — Alert and TODO (6 items) The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, watchingEnabled, todo)` transitions. @@ -80,7 +92,7 @@ The detector remembers the most recent pane whose `watchingEnabled` flag is true 1. Resolves that pane to its current PTY session id, then calls `adapter.pumpActivity(sessionId, BUSY_DEMO_DURATION_MS, 800)` — drives the alert-manager's activity monitor on the same WATCHING-enabled session with **no text output**, so the bell tilts to BUSY without scrolling any scenario text. The session id is resolved at trigger time so `Cmd/Ctrl+Arrow` swaps do not leave the tutorial pumping an old pane id. If no WATCHING-enabled pane is known, the runner falls back to `PANE_BOXED` (the changelog pane). `BUSY_DEMO_DURATION_MS` is `cfg.alert.userAttention + 250` so silence begins after the attention idle window has expired, with a small scheduler-jitter guard; otherwise the "user is looking at this pane" check inside `ActivityMonitor.startNeedsAttentionConfirmTimer` would suppress the ring rather than let it fire. 2. Animates a countdown in-place where the "Press s…" hint was: `⠋ Fake task will finish in N seconds.` ticking down to 1, then a static `✓ Fake task finished. Press s to start another one.` once the activity stops. Detection is purely timing-based via the existing `ActivityMonitor`, so no shell integration is required. -### Section 3 — Copy paste (4 items) +### Shared Section — Copy paste (4 items) The detector subscribes to `subscribeToMouseSelection()` and tracks per-id transitions on `selection`, `copyFlash`, and `override`. @@ -89,7 +101,7 @@ The detector subscribes to `subscribeToMouseSelection()` and tracks per-id trans | `cp-select` | Drag-select text in any pane | `selection` transitions `null → non-null` | | `cp-raw` | Click Copy Raw | `copyFlash` transitions to `'raw'` (set by `flashCopy()` after the popup button fires) | | `cp-rewrap` | Click Copy Rewrapped on wrapped text in the changelog pane | `copyFlash` transitions to `'rewrapped'` | -| `cp-override` | Run `ascii-splash`, then click its cursor icon | `override` transitions `'off' → 'temporary' \| 'permanent'` | +| `cp-override` | Click the cursor icon in `changelog` | `override` transitions `'off' → 'temporary' \| 'permanent'` | Prose: - "Some programs trap the mouse — the cursor icon lets you override." @@ -97,19 +109,33 @@ Prose: The Copy Rewrapped step uses the wrapped item lines `ChangelogRunner` produces in the `tut-boxed` pane. The runner word-wraps each item to fit the pane width, so Rewrapped joins those lines back together while Raw preserves the wrap; clipboard contents visibly differ. The user must override mouse capture first (the `cp-override` step) before drag-selecting inside the changelog pane, since the runner enables SGR mouse-reporting. +Pocket uses the same Copy paste item ids with mobile-specific wording: the user switches to the `changelog` session through the Sessions reserve instead of using a desktop tab. + While the Copy paste section is open, pressing `p` toggles the **Place To Paste** modal — a draggable scratch box with eight pointer-event resize handles (four edges + four corners), rendered by `website/src/components/PlaceToPaste.tsx` and mounted at the page level. `TutRunner` intercepts `p`/`P` (mirroring the Alert section's `s` busy-demo intercept) and calls `onTogglePlaceToPaste`; `Playground` flips a `placeToPasteOpen` flag so the modal is portal-free and overlays the wall. The runner renders a persistent `Press \`p\` to toggle the Place To Paste …` line above the section's prose paragraph so the prompt is visible regardless of which item is active. Users paste copied text into the modal's single textarea and resize it to see whether the text reflows (Rewrapped) or stays line-broken (Raw). +The Place To Paste prompt appears only when the runner receives an `onTogglePlaceToPaste` callback. Desktop passes the callback; Pocket does not. + +### Pocket Section 1 — Gesture navigation (4 items) + +| ID | Title | Detection | +|---|---|---| +| `gn-touch-mode` | Switch between Select and Gestures | Pocket touch-mode selector visits Select, then returns to Gestures | +| `gn-arrows` | Use Gestures to send an arrow key | `MobileTerminalUi.onGestureInput` fires for `up`, `down`, `left`, or `right` | +| `gn-enter` | Use Gestures to press Enter | `MobileTerminalUi.onGestureInput` fires for `enter` | +| `gn-esc` | Use Gestures to press Esc | `MobileTerminalUi.onGestureInput` fires for `esc`; the runner then handles Esc normally and returns to the menu | + ## Lib changes added for this tutorial - **`WallEvent.kill`** and **`WallEvent.move`** — new discriminants on the `WallEvent` union (`lib/src/components/wall/wall-types.ts`). `kill` fires from `acceptKill` in `Wall.tsx`. `move` fires from `handle-pane-shortcuts.ts` after the Cmd/Ctrl-Arrow swap, via a new `fireEvent` callback added to `WallKeyboardCtx`. - **`FakePtyAdapter.pumpActivity(id, durationMs, intervalMs)`** — drives the alert-manager for a fixed duration with no data output. The runner uses this so the bell on the demo pane tilts/rings while the visible "task running" animation lives entirely inside the tutorial pane. - **`FakePtyAdapter.sendOutput(id, data)`** — pushes data through the data handlers as if the PTY produced it, also driving `alertManager.onData()`. Used by `TutRunner` and `AsciiSplashRunner` so browser-side echoes still feed the activity monitor. +- **`MobileTerminalUi.onGestureInput(input, data)`** — optional callback fired only for radial-menu input actions after the corresponding terminal byte sequence is sent. Pocket uses it to credit gesture tutorial items without confusing native keyboard input for gestures. `SCENARIO_TUTORIAL_MOTD` was removed — the runner now owns the main pane's screen. ## Storage -- Completion: `localStorage["dormouse-tut-v3"] = JSON.stringify([...completedItemIds])`. Removed on `TutorialState.reset()`. Unknown ids in a stored payload are filtered out on load, so renaming an id is a one-way reset for that item. +- Completion: `localStorage["dormouse-tut-v3"] = JSON.stringify([...completedItemIds])`. Removed on `TutorialState.reset()`. Unknown ids in a stored payload are filtered out on load, so renaming an id is a one-way reset for that item. The same key stores both desktop and Pocket item ids; profile totals count only items in that profile's sections. - GitHub star prompt: `localStorage["dormouse-tut-star-v1"] = "true"` after the user selects `Starred on GitHub`. Removed on `TutorialState.reset()`. - Flappy Term high score: `localStorage["dormouse-flappy-high-v1"] = String(score)` after each new high score. Removed on `TutorialState.reset()`. - Legacy keys `dormouse-tutorial-step-N` and `dormouse-tut-v2-*` from previous designs are not read; new playground sessions get a fresh start. diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index 313d5ce..2d20d2d 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -106,6 +106,7 @@ export interface MobileTerminalUiProps { onTouchModeChange?: (mode: MobileTerminalTouchMode) => void; cursorTouchAvailable?: boolean; onSendInput?: (data: string) => void; + onGestureInput?: (input: MobileGestureInputId, data: string) => void; onPaste?: () => void | Promise; onFocusInput?: () => void; sessions?: MobileTerminalSessionItem[]; @@ -384,6 +385,7 @@ export function MobileTerminalUi({ onTouchModeChange, cursorTouchAvailable = false, onSendInput, + onGestureInput, onPaste, onFocusInput, sessions = [], @@ -506,7 +508,9 @@ export function MobileTerminalUi({ const executeGestureAction = useCallback((action: MobileGestureAction | undefined) => { if (!action) return; if (action.kind === 'input') { - sendInput(MOBILE_TERMINAL_KEY_SEQUENCES[action.input]); + const data = MOBILE_TERMINAL_KEY_SEQUENCES[action.input]; + sendInput(data); + onGestureInput?.(action.input, data); return; } if (action.kind === 'text') { @@ -520,7 +524,7 @@ export function MobileTerminalUi({ if (action.kind === 'confirm') { setPendingGestureConfirmation(action); } - }, [onPaste, sendInput]); + }, [onGestureInput, onPaste, sendInput]); const confirmPendingGestureAction = useCallback(() => { if (!pendingGestureConfirmation) return; diff --git a/website/src/components/PocketTerminalExperience.tsx b/website/src/components/PocketTerminalExperience.tsx index 48acb1e..d9f6ec9 100644 --- a/website/src/components/PocketTerminalExperience.tsx +++ b/website/src/components/PocketTerminalExperience.tsx @@ -9,16 +9,34 @@ import { } from "dormouse-lib/lib/mouse-selection"; import { PlaygroundShellRegistry } from "../lib/playground-shells"; import { TutorialState } from "../lib/tutorial-state"; -import { BUSY_DEMO_DURATION_MS, BUSY_DEMO_INTERVAL_MS, TutRunner } from "../lib/tut-runner"; +import { TutDetector } from "../lib/tut-detector"; +import { TutRunner } from "../lib/tut-runner"; +import { POCKET_TUTORIAL_PROFILE, type ItemId } from "../lib/tut-items"; import { ChangelogRunner } from "../lib/changelog-runner"; import { POCKET_PLAYGROUND_PATH } from "../lib/playground-routing"; export const POCKET_THEME_ID = "vscode.theme-kimbie-dark.kimbie-dark"; type FakePtyAdapter = import("dormouse-lib/lib/platform/fake-adapter").FakePtyAdapter; - -const POCKET_PANE = "pocket-ascii-splash"; -const POCKET_SESSIONS: MobileWallSession[] = [{ id: POCKET_PANE, title: "ascii-splash" }]; +type MobileGestureInputId = import("dormouse-lib/lib/mobile-gesture-menu").MobileGestureInputId; + +const POCKET_TUTORIAL_PANE = "pocket-tut"; +const POCKET_CHANGELOG_PANE = "pocket-changelog"; +const POCKET_SESSIONS: MobileWallSession[] = [ + { id: POCKET_TUTORIAL_PANE, title: "tutorial" }, + { id: POCKET_CHANGELOG_PANE, title: "changelog" }, +]; +const POCKET_AUTOSTART_COMMANDS = new Map([ + [POCKET_TUTORIAL_PANE, "tut"], + [POCKET_CHANGELOG_PANE, "changelog"], +]); + +const GESTURE_ARROW_INPUTS = new Set([ + "up", + "down", + "left", + "right", +]); function usePocketTheme() { const restoredRef = useRef(false); @@ -39,10 +57,12 @@ export function PocketTerminalExperience({ const [terminalReady, setTerminalReady] = useState(false); const adapterRef = useRef(null); const shellRegistryRef = useRef(null); + const detectorRef = useRef(null); + const tutorialStateRef = useRef(null); const autoStartedRef = useRef>(new Set()); const spawnUnsubRef = useRef<(() => void) | null>(null); - const busyDemoDisposeRef = useRef<(() => void) | null>(null); - const [activePaneId, setActivePaneId] = useState(POCKET_PANE); + const sawSelectionTouchModeRef = useRef(false); + const [activePaneId, setActivePaneId] = useState(POCKET_TUTORIAL_PANE); const [touchMode, setTouchMode] = useState("gestures"); const [keyboardMode, setKeyboardMode] = useState("type"); const sessionItems = useMobileWallSessionItems(POCKET_SESSIONS, activePaneId); @@ -67,13 +87,40 @@ export function PocketTerminalExperience({ window.open(POCKET_PLAYGROUND_PATH, "_blank", "noopener,noreferrer"); }, []); + const markPocketItemComplete = useCallback((id: ItemId) => { + tutorialStateRef.current?.markComplete(id); + }, []); + + const handleTouchModeChange = useCallback((nextMode: MobileTerminalTouchMode) => { + setTouchMode(nextMode); + if (nextMode === "selection") { + sawSelectionTouchModeRef.current = true; + return; + } + if (nextMode === "gestures" && sawSelectionTouchModeRef.current) { + sawSelectionTouchModeRef.current = false; + markPocketItemComplete("gn-touch-mode"); + } + }, [markPocketItemComplete]); + + const handleGestureInput = useCallback((input: MobileGestureInputId) => { + if (GESTURE_ARROW_INPUTS.has(input)) { + markPocketItemComplete("gn-arrows"); + } else if (input === "enter") { + markPocketItemComplete("gn-enter"); + } else if (input === "esc") { + markPocketItemComplete("gn-esc"); + } + }, [markPocketItemComplete]); + const tryAutoStart = useCallback((id: string) => { - if (id !== POCKET_PANE) return; + const command = POCKET_AUTOSTART_COMMANDS.get(id); + if (!command) return; if (autoStartedRef.current.has(id)) return; const shellRegistry = shellRegistryRef.current; if (!shellRegistry) return; autoStartedRef.current.add(id); - shellRegistry.ensureShell(id).runCommand("ascii-splash"); + shellRegistry.ensureShell(id).runCommand(command); }, []); useEffect(() => { @@ -82,6 +129,7 @@ export function PocketTerminalExperience({ async function loadWall() { const platform = await import("dormouse-lib/lib/platform"); const registry = await import("dormouse-lib/lib/terminal-registry"); + const mouseSelection = await import("dormouse-lib/lib/mouse-selection"); const scenarios = await import("dormouse-lib/lib/platform/fake-scenarios"); const asciiSplash = await import("../lib/ascii-splash-runner"); await import("dormouse-lib/index.css"); @@ -93,9 +141,18 @@ export function PocketTerminalExperience({ registry.initAlertStateReceiver(); adapterRef.current = adapter; adapter.setDefaultScenario(scenarios.SCENARIO_SHELL_PROMPT); - adapter.setScenario(POCKET_PANE, { name: "none", chunks: [] }); + for (const session of POCKET_SESSIONS) { + adapter.setScenario(session.id, { name: "none", chunks: [] }); + } - const tutorialState = new TutorialState(); + const tutorialState = new TutorialState(POCKET_TUTORIAL_PROFILE.sections); + tutorialStateRef.current = tutorialState; + const detector = new TutDetector(tutorialState, registry, mouseSelection); + detector.attach({ + activePanel: { id: POCKET_TUTORIAL_PANE }, + onDidActivePanelChange: () => ({ dispose: () => {} }), + }); + detectorRef.current = detector; const shellRegistry = new PlaygroundShellRegistry( adapter, (terminalId, name, args, onExit) => { @@ -104,16 +161,8 @@ export function PocketTerminalExperience({ adapter, terminalId, state: tutorialState, + profile: POCKET_TUTORIAL_PROFILE, onExit, - onTriggerBusyDemo: () => { - busyDemoDisposeRef.current?.(); - busyDemoDisposeRef.current = adapter.pumpActivity( - terminalId, - BUSY_DEMO_DURATION_MS, - BUSY_DEMO_INTERVAL_MS, - ); - }, - onTogglePlaceToPaste: () => {}, onOpenGithub: handleOpenGithub, onOpenPocket: handleOpenPocket, }); @@ -133,13 +182,15 @@ export function PocketTerminalExperience({ }, ); shellRegistryRef.current = shellRegistry; - shellRegistry.ensureShell(POCKET_PANE); + for (const session of POCKET_SESSIONS) shellRegistry.ensureShell(session.id); spawnUnsubRef.current = adapter.onPtySpawn(({ id }) => { shellRegistry.ensureShell(id); tryAutoStart(id); }); - if (adapter.hasPty(POCKET_PANE)) tryAutoStart(POCKET_PANE); + for (const session of POCKET_SESSIONS) { + if (adapter.hasPty(session.id)) tryAutoStart(session.id); + } setTerminalReady(true); } @@ -150,11 +201,13 @@ export function PocketTerminalExperience({ cancelled = true; spawnUnsubRef.current?.(); spawnUnsubRef.current = null; - busyDemoDisposeRef.current?.(); - busyDemoDisposeRef.current = null; + detectorRef.current?.dispose(); + detectorRef.current = null; shellRegistryRef.current?.disposeAll(); shellRegistryRef.current = null; + tutorialStateRef.current = null; autoStartedRef.current.clear(); + sawSelectionTouchModeRef.current = false; adapterRef.current = null; }; }, [handleOpenGithub, handleOpenPocket, tryAutoStart]); @@ -183,13 +236,14 @@ export function PocketTerminalExperience({ interactive={interactive} fillViewport={fillViewport} activeTouchMode={touchMode} - onTouchModeChange={setTouchMode} + onTouchModeChange={handleTouchModeChange} activeKeyboardMode={keyboardMode} onKeyboardModeChange={setKeyboardMode} cursorTouchAvailable={cursorTouchAvailable} sessions={sessionItems} onSessionSelect={setActivePaneId} onSendInput={(data) => adapterRef.current?.writePty(activePaneId, data)} + onGestureInput={handleGestureInput} onPaste={async () => { const { doPaste } = await import("dormouse-lib/lib/clipboard"); await doPaste(activePaneId); diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index a1bc36f..10f82b1 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -3,7 +3,14 @@ import { cfg } from "dormouse-lib/cfg"; const USER_ATTENTION_SECS = Math.round(cfg.alert.userAttention / 1000); // Item ids are the persistence key — keep them stable across releases. -export const ITEM_IDS = [ +const GESTURE_ITEM_IDS = [ + "gn-touch-mode", + "gn-arrows", + "gn-enter", + "gn-esc", +] as const; + +const KEYBOARD_ITEM_IDS = [ "kb-mode", "kb-split-h", "kb-arrows", @@ -11,18 +18,31 @@ export const ITEM_IDS = [ "kb-min", "kb-kill", "kb-move", +] as const; + +const ALERT_ITEM_IDS = [ "al-enable", "al-busy", "al-ring", "al-todo-auto", "al-todo-clear", "al-todo-manual", +] as const; + +const COPY_ITEM_IDS = [ "cp-select", "cp-raw", "cp-rewrap", "cp-override", ] as const; +export const ITEM_IDS = [ + ...GESTURE_ITEM_IDS, + ...KEYBOARD_ITEM_IDS, + ...ALERT_ITEM_IDS, + ...COPY_ITEM_IDS, +] as const; + export type ItemId = (typeof ITEM_IDS)[number]; export interface Item { @@ -38,7 +58,87 @@ export interface Section { prose?: string[]; } -export const SECTIONS: readonly Section[] = [ +export interface TutorialProfile { + id: "desktop" | "pocket"; + title: string; + sections: readonly Section[]; + initialSectionId?: string; +} + +const GESTURE_NAVIGATION_SECTION: Section = { + id: 'gesture', + title: 'Gesture navigation', + items: [ + { + id: 'gn-touch-mode', + title: 'Switch between Select and Gestures', + hint: 'Tap `Select`, then tap `Gestures` again.', + }, + { + id: 'gn-arrows', + title: 'Use Gestures to send an arrow key', + hint: 'Drag to an arrow group, then choose its arrow option.', + }, + { + id: 'gn-enter', + title: 'Use Gestures to press Enter', + hint: 'Drag toward `Enter`, then choose `Enter`.', + }, + { + id: 'gn-esc', + title: 'Use Gestures to press Esc', + hint: 'Drag toward `Esc`, then choose `Esc`.', + }, + ], +}; + +const COPY_PASTE_SECTION: Section = { + id: 'copy', + title: 'Copy paste', + items: [ + { + id: 'cp-select', + title: 'Drag-select some text', + hint: 'The paragraph below is a good example — "Some terminal programs..."', + }, + { + id: 'cp-raw', + title: 'Copy-paste it somewhere else with "Copy Raw"', + hint: 'When you paste, notice how it keeps all the line-breaks. Gross!', + }, + { + id: 'cp-rewrap', + title: 'Copy-paste it somewhere else with "Copy Rewrapped"', + hint: + 'When you paste, notice how the line-breaks were removed, and the text rewraps neatly wherever you paste it?', + }, + { + id: 'cp-override', + title: 'Click the cursor icon in `changelog`', + hint: + 'Try to click and drag in the changelog tab - you can\'t! That\'s because you can click the versions - the Terminal User Interface traps the mouse which breaks copy-paste. Click the cursor icon in its header, which disables the mouse tracking long enough for you to do a drag-select.', + }, + ], + prose: [ + 'Some terminal programs trap the cursor, and some do not. This tutorial pane does not trap the cursor, so Dormouse does not show a cursor icon. The `ascii-splash` and `changelog` programs trap the cursor — that is how they are able to respond to mouse movement. `lazygit` is an excellent and popular program which traps the cursor.', + ], +}; + +const POCKET_COPY_PASTE_SECTION: Section = { + ...COPY_PASTE_SECTION, + items: COPY_PASTE_SECTION.items.map((item) => item.id === 'cp-override' + ? { + ...item, + hint: + 'Open the Sessions reserve, switch to `changelog`, then try to drag-select there. The changelog TUI traps the cursor, so use the cursor icon in its header to disable mouse tracking long enough for a drag-select.', + } + : item), + prose: [ + 'Some terminal programs trap the cursor, and some do not. The `changelog` session traps the cursor — that is how it responds to mouse movement. `lazygit` is an excellent and popular program which traps the cursor.', + ], +}; + +export const DESKTOP_SECTIONS: readonly Section[] = [ { id: 'keyboard', title: 'Keyboard navigation', @@ -117,41 +217,34 @@ export const SECTIONS: readonly Section[] = [ }, ], }, - { - id: 'copy', - title: 'Copy paste', - items: [ - { - id: 'cp-select', - title: 'Drag-select some text', - hint: 'The paragraph below is a good example — "Some terminal programs..."', - }, - { - id: 'cp-raw', - title: 'Copy-paste it somewhere else with "Copy Raw"', - hint: 'When you paste, notice how it keeps all the line-breaks. Gross!', - }, - { - id: 'cp-rewrap', - title: 'Copy-paste it somewhere else with "Copy Rewrapped"', - hint: - 'When you paste, notice how the line-breaks were removed, and the text rewraps neatly wherever you paste it?', - }, - { - id: 'cp-override', - title: 'Click the cursor icon in `changelog`', - hint: - 'Try to click and drag in the changelog tab - you can\'t! That\'s because you can click the versions - the Terminal User Interface traps the mouse which breaks copy-paste. Click the cursor icon in its header, which disables the mouse tracking long enough for you to do a drag-select.', - }, - ], - prose: [ - 'Some terminal programs trap the cursor, and some do not. This tutorial pane does not trap the cursor, so Dormouse does not show a cursor icon. The `ascii-splash` and `changelog` programs trap the cursor — that is how they are able to respond to mouse movement. `lazygit` is an excellent and popular program which traps the cursor.', - ], - }, + COPY_PASTE_SECTION, ]; +export const POCKET_SECTIONS: readonly Section[] = [ + GESTURE_NAVIGATION_SECTION, + POCKET_COPY_PASTE_SECTION, +]; + +export const DESKTOP_TUTORIAL_PROFILE: TutorialProfile = { + id: "desktop", + title: "Dormouse Playground Tutorial", + sections: DESKTOP_SECTIONS, +}; + +export const POCKET_TUTORIAL_PROFILE: TutorialProfile = { + id: "pocket", + title: "Dormouse Pocket Tutorial", + sections: POCKET_SECTIONS, + initialSectionId: "gesture", +}; + +export const SECTIONS = DESKTOP_SECTIONS; + export const ALL_ITEM_IDS: readonly ItemId[] = ITEM_IDS; -export function itemSection(id: ItemId): Section | undefined { - return SECTIONS.find((s) => s.items.some((i) => i.id === id)); +export function itemSection( + id: ItemId, + sections: readonly Section[] = SECTIONS, +): Section | undefined { + return sections.find((s) => s.items.some((i) => i.id === id)); } diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 1ef7253..53bad0a 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { FakePtyAdapter } from "dormouse-lib/lib/platform/fake-adapter"; -import { SECTIONS, type ItemId } from "./tut-items"; +import { + POCKET_TUTORIAL_PROFILE, + SECTIONS, + type ItemId, + type TutorialProfile, +} from "./tut-items"; import { TutRunner } from "./tut-runner"; import { TutorialState } from "./tutorial-state"; @@ -8,7 +13,7 @@ const FRAME_RESET = "\x1b[H\x1b[2J"; function mountRunner( completedIds: ItemId[] = [], - options: { onOpenGithub?: () => void } = {}, + options: { onOpenGithub?: () => void; profile?: TutorialProfile } = {}, ) { const adapter = new FakePtyAdapter(); const id = "test-pane"; @@ -18,16 +23,19 @@ function mountRunner( let exitCount = 0; adapter.onPtyData(({ data }) => frames.push(data)); - const state = new TutorialState(); + const profile = options.profile; + const state = new TutorialState(profile?.sections); for (const itemId of completedIds) state.markComplete(itemId); const runner = new TutRunner({ adapter, terminalId: id, state, + profile, onExit: () => { exitCount += 1; }, + onTogglePlaceToPaste: profile?.id === "pocket" ? undefined : () => {}, onOpenGithub: options.onOpenGithub, }); adapter.setInputHandler(id, (data) => runner.handleInput(data)); @@ -74,6 +82,33 @@ describe("TutRunner snapshots", () => { dispose(); }); + it("starts the Pocket tutorial inside Gesture navigation", () => { + const { lastFrame, dispose } = mountRunner([], { + profile: POCKET_TUTORIAL_PROFILE, + }); + + expect(lastFrame()).toContain("Gesture navigation"); + expect(lastFrame()).toContain("Switch between Select and Gestures"); + expect(lastFrame()).not.toContain("Dormouse Pocket Tutorial"); + dispose(); + }); + + it("shows the Pocket title and section list after backing out", () => { + const { sendKeys, lastFrame, dispose } = mountRunner([], { + profile: POCKET_TUTORIAL_PROFILE, + }); + + sendKeys("\x1b"); + + expect(lastFrame()).toContain("Dormouse Pocket Tutorial"); + expect(lastFrame()).toContain("Gesture navigation"); + expect(lastFrame()).toContain("Copy paste"); + expect(lastFrame()).not.toContain("Keyboard navigation"); + expect(lastFrame()).not.toContain("Alert and TODO"); + expect(lastFrame()).toContain("[LOCKED 0/8]"); + dispose(); + }); + it("renders Keyboard navigation with all items complete", () => { const allKeyboardIds = SECTIONS[0].items.map((i) => i.id); const { sendKeys, lastFrame, dispose } = mountRunner(allKeyboardIds); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 0551068..644fa85 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -13,7 +13,11 @@ import { import { cfg } from "dormouse-lib/cfg"; import type { FakePtyAdapter } from "dormouse-lib/lib/platform/fake-adapter"; import type { InteractiveProgram } from "./tutorial-shell"; -import { SECTIONS, type Item } from "./tut-items"; +import { + DESKTOP_TUTORIAL_PROFILE, + type Item, + type TutorialProfile, +} from "./tut-items"; import type { TutorialState } from "./tutorial-state"; /** @@ -107,6 +111,7 @@ interface TutRunnerOptions { adapter: FakePtyAdapter; terminalId: string; state: TutorialState; + profile?: TutorialProfile; onExit: () => void; /** Called when the user presses `s` inside the Alert section. */ onTriggerBusyDemo?: () => void; @@ -126,6 +131,7 @@ export class TutRunner implements InteractiveProgram { private adapter: FakePtyAdapter; private terminalId: string; private state: TutorialState; + private profile: TutorialProfile; private onExit: () => void; private onTriggerBusyDemo?: () => void; private onTogglePlaceToPaste?: () => void; @@ -150,11 +156,16 @@ export class TutRunner implements InteractiveProgram { this.adapter = options.adapter; this.terminalId = options.terminalId; this.state = options.state; + this.profile = options.profile ?? DESKTOP_TUTORIAL_PROFILE; this.onExit = options.onExit; this.onTriggerBusyDemo = options.onTriggerBusyDemo; this.onTogglePlaceToPaste = options.onTogglePlaceToPaste; this.onOpenGithub = options.onOpenGithub; this.onOpenPocket = options.onOpenPocket; + if (this.profile.initialSectionId) { + this.sectionId = this.profile.initialSectionId; + this.screen = "section"; + } } start(): void { @@ -388,20 +399,20 @@ export class TutRunner implements InteractiveProgram { // --- Input --- private menuLength(): number { - // SECTIONS + GitHub star + Flappy Term + the trailing "Reset progress" entry - return SECTIONS.length + 3; + // Sections + GitHub star + Flappy Term + the trailing "Reset progress" entry + return this.profile.sections.length + 3; } private starPromptIndex(): number { - return SECTIONS.length; + return this.profile.sections.length; } private flappyIndex(): number { - return SECTIONS.length + 1; + return this.profile.sections.length + 1; } private resetIndex(): number { - return SECTIONS.length + 2; + return this.profile.sections.length + 2; } private handleArrow(letter: string): void { @@ -461,7 +472,7 @@ export class TutRunner implements InteractiveProgram { this.render(); return; } - const section = SECTIONS[this.menuIndex]; + const section = this.profile.sections[this.menuIndex]; if (!section) return; this.sectionId = section.id; this.screen = "section"; @@ -560,12 +571,12 @@ export class TutRunner implements InteractiveProgram { const total = this.state.totalProgress(); const lines: string[] = []; lines.push(""); - lines.push(` ${BOLD}Dormouse Playground Tutorial${RESET}`); + lines.push(` ${BOLD}${this.profile.title}${RESET}`); lines.push( ` ${DIM}\`Esc\`/\`q\` to exit · \`Enter\` to open · \`↑↓\` to navigate${RESET}`, ); lines.push(""); - SECTIONS.forEach((section, index) => { + this.profile.sections.forEach((section, index) => { const { done, total: t } = this.state.sectionProgress(section.id); const marker = index === this.menuIndex ? `${fg(36)}❯${RESET}` : " "; const label = index === this.menuIndex @@ -762,7 +773,7 @@ export class TutRunner implements InteractiveProgram { } private renderSection(): string[] { - const section = SECTIONS.find((s) => s.id === this.sectionId); + const section = this.profile.sections.find((s) => s.id === this.sectionId); if (!section) { throw new Error(`renderSection: unknown sectionId ${this.sectionId}`); } @@ -778,7 +789,7 @@ export class TutRunner implements InteractiveProgram { lines.push(...this.renderItem(item, index, activeIndex)); }); - if (section.id === "copy") { + if (section.id === "copy" && this.onTogglePlaceToPaste) { lines.push(""); const indent = " "; const text = "Press `p` to toggle the Place To Paste."; diff --git a/website/src/lib/tutorial-state.ts b/website/src/lib/tutorial-state.ts index 33ffab6..eae8cd5 100644 --- a/website/src/lib/tutorial-state.ts +++ b/website/src/lib/tutorial-state.ts @@ -1,4 +1,4 @@ -import { ALL_ITEM_IDS, ITEM_IDS, SECTIONS, type ItemId } from "./tut-items"; +import { ITEM_IDS, SECTIONS, type ItemId, type Section } from "./tut-items"; const STORAGE_KEY = "dormouse-tut-v3"; const STAR_STORAGE_KEY = "dormouse-tut-star-v1"; @@ -11,8 +11,10 @@ export class TutorialState { private flappyHighScore = 0; private listeners = new Set<() => void>(); private storage = typeof localStorage !== "undefined" ? localStorage : null; + private sections: readonly Section[]; - constructor() { + constructor(sections: readonly Section[] = SECTIONS) { + this.sections = sections; this.starPromptResolved = this.storage?.getItem(STAR_STORAGE_KEY) === "true"; const high = this.storage?.getItem(FLAPPY_HIGH_SCORE_KEY); @@ -95,7 +97,7 @@ export class TutorialState { } sectionProgress(sectionId: string): { done: number; total: number } { - const section = SECTIONS.find((s) => s.id === sectionId); + const section = this.sections.find((s) => s.id === sectionId); if (!section) return { done: 0, total: 0 }; let done = 0; for (const item of section.items) { @@ -105,7 +107,15 @@ export class TutorialState { } totalProgress(): { done: number; total: number } { - return { done: this.completed.size, total: ALL_ITEM_IDS.length }; + let done = 0; + let total = 0; + for (const section of this.sections) { + for (const item of section.items) { + total++; + if (this.completed.has(item.id)) done++; + } + } + return { done, total }; } private notify(): void { From a782ac0377195776351bc787c918106d89bb2dde Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 23 May 2026 08:53:46 -0700 Subject: [PATCH 02/14] Fix Pocket gesture guide rendering --- docs/specs/mobile-ui.md | 4 ++++ lib/src/components/MobileTerminalUi.tsx | 2 ++ 2 files changed, 6 insertions(+) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index ae6b4dd..87f1862 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -159,6 +159,10 @@ The select circle and its eight compass-direction ticks render at full opacity. The current highlighted or selected direction uses a stronger tick so the circle and label clusters read as one gesture system. +Because the mobile composition does not mount the desktop `Wall`, +`MobileTerminalUi` must publish the shared dynamic palette variables, including +`--color-focus-ring`, before rendering gesture UI that depends on those tokens. + When the rose opens on touch-down, root labels fade in with a subtle scale-in and the select circle grows from zero radius to `RADIUS_SELECT`. This is a short state-reveal motion, not an ongoing decoration; reduced-motion users get the diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index 2d20d2d..38eee46 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -38,6 +38,7 @@ import { type MobileGesturePoint, type MobileGestureTrackingState, } from '../lib/mobile-gesture-menu'; +import { useDynamicPalette } from '../lib/themes/use-dynamic-palette'; import type { SessionStatus } from '../lib/terminal-registry'; export type MobileTerminalKeyboardMode = 'sessions' | 'recent' | 'type' | 'draft'; @@ -396,6 +397,7 @@ export function MobileTerminalUi({ terminalClassName, style, }: MobileTerminalUiProps) { + useDynamicPalette(); const resolvedDefaultKeyboardMode = defaultKeyboardMode ?? defaultSection; const [internalKeyboardMode, setInternalKeyboardMode] = useState(resolvedDefaultKeyboardMode); const [internalTouchMode, setInternalTouchMode] = useState(defaultTouchMode); From 26e98d70f314db6078346db9ab5c4fe538bdf3cf Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 23 May 2026 12:29:46 -0700 Subject: [PATCH 03/14] Suppress Pocket scroll input outside mouse mode --- docs/specs/mobile-ui.md | 5 +++++ docs/specs/mouse-and-clipboard.md | 4 ++++ lib/src/components/MobileTerminalUi.tsx | 21 ++++++++++++++++++++- lib/src/lib/terminal-mouse-router.test.ts | 14 ++++++++++++++ lib/src/lib/terminal-mouse-router.ts | 2 +- 5 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 87f1862..15bc66a 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -116,6 +116,11 @@ Default touch mode is **Gestures**. If Mouse mode is active and the active pane stops capturing mouse events, the selector must fall back to Gestures. +Wheel, trackpad-scroll, and touchmove events in the pane content are terminal +input only in Mouse mode. Gestures and Select mode must suppress those +scroll-like events before xterm can translate them into mouse reports, +alternate-screen arrow keys, or scrollback motion. + Gesture mode intentionally consumes primary mouse/trackpad clicks in addition to touch input. This keeps the `/playground/pocket` prototype usable in desktop browsers, narrow desktop viewports, and Storybook without a touchscreen. A primary diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index d6c3707..cc0fa06 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -50,6 +50,8 @@ The terminal makes the current regime visible in the pane header, provides a way Activated by clicking the Mouse icon. While the temporary override is active: - Mouse events are handled by the terminal, not forwarded to the inside program. +- Wheel events are also suppressed so xterm cannot translate scroll input into + mouse reports or alternate-screen arrow-key input for the inside program. - The Mouse icon is replaced with the No-Mouse icon. - A banner appears below the No-Mouse icon reading `Temporary mouse override until mouse-up.` followed by two buttons: **Make sticky** and **Cancel**. - The override persists until the **next mouse-up event inside the terminal content area** (live region or scrollback) that is paired with a prior mouse-down in the same area. This includes plain clicks (a mouse-down/up pair that never crossed the drag threshold) as well as completed drags. The click on the No-Mouse icon itself, the banner's buttons, and any "orphan" mouse-up from a drag that started outside the terminal do **not** count as that mouse-up. @@ -60,6 +62,8 @@ Activated by clicking the Mouse icon. While the temporary override is active: - Clicking **Make sticky** in the banner converts the temporary override into a sticky one. - The banner is dismissed. - The No-Mouse icon remains visible with its "click to restore" hover text. +- Mouse and wheel events continue to be handled by the terminal rather than the + inside program. - The override persists until the user clicks the No-Mouse icon, or until the inside program stops requesting mouse reporting. ### 2.3 Canceling the Temporary Override diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index 38eee46..996e6a6 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -373,6 +373,12 @@ function isGestureDialogTarget(target: EventTarget | null): boolean { return target instanceof Element && target.closest('[data-mobile-gesture-dialog]') !== null; } +function consumeScrollLikeTerminalEvent(event: Event): void { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); +} + export function MobileTerminalUi({ terminal, activeSection, @@ -553,6 +559,19 @@ export function MobileTerminalUi({ } }, [cursorTouchAvailable, setTouchMode, touchMode]); + useEffect(() => { + if (!interactive || touchMode === 'cursor') return; + const host = terminalHostRef.current; + if (!host) return; + const options: AddEventListenerOptions = { capture: true, passive: false }; + host.addEventListener('wheel', consumeScrollLikeTerminalEvent, options); + host.addEventListener('touchmove', consumeScrollLikeTerminalEvent, options); + return () => { + host.removeEventListener('wheel', consumeScrollLikeTerminalEvent, options); + host.removeEventListener('touchmove', consumeScrollLikeTerminalEvent, options); + }; + }, [interactive, touchMode]); + useEffect(() => { const host = terminalHostRef.current; if (!host) return; @@ -675,7 +694,7 @@ export function MobileTerminalUi({ ref={terminalHostRef} className={clsx( 'relative min-h-0 flex-1 overflow-hidden bg-terminal-bg', - touchMode === 'gestures' ? 'touch-none' : 'touch-auto', + touchMode === 'cursor' ? 'touch-auto' : 'touch-none', terminalClassName, )} onPointerDownCapture={handlePanePointerDownCapture} diff --git a/lib/src/lib/terminal-mouse-router.test.ts b/lib/src/lib/terminal-mouse-router.test.ts index 1ce80bb..74a4b0e 100644 --- a/lib/src/lib/terminal-mouse-router.test.ts +++ b/lib/src/lib/terminal-mouse-router.test.ts @@ -167,4 +167,18 @@ describe('terminal-mouse-router: override suppression', () => { expect(getMouseSelectionState('t1').override).toBe('permanent'); cleanup(); }); + + it('suppresses wheel while an override is active', () => { + const { cleanup, element } = createHarness(windowHost); + setMouseReporting('t1', 'vt200'); + setOverride('t1', 'permanent'); + + const wheel = mouseEvent(); + element.emit('wheel', wheel); + + expect(wheel.preventDefault).toHaveBeenCalledOnce(); + expect(wheel.stopPropagation).toHaveBeenCalledOnce(); + expect(wheel.stopImmediatePropagation).toHaveBeenCalledOnce(); + cleanup(); + }); }); diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index b6dd217..5803b67 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -14,7 +14,7 @@ import { detectTokenAt } from './smart-token'; import { extractSelectionText } from './selection-text'; import type { TerminalOverlayDims } from './terminal-store'; -const OVERRIDE_MOUSE_EVENTS = ['mousemove', 'mouseup', 'click', 'dblclick', 'auxclick', 'contextmenu'] as const; +const OVERRIDE_MOUSE_EVENTS = ['mousemove', 'mouseup', 'wheel', 'click', 'dblclick', 'auxclick', 'contextmenu'] as const; function consumeMouseEvent(ev: MouseEvent, stopImmediate = false): void { ev.preventDefault(); From cd66b708dbb8dbe2ac44521944e0dcd5f0f641b2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Sat, 23 May 2026 13:27:23 -0700 Subject: [PATCH 04/14] Make cardinal Pocket gestures direct --- docs/specs/mobile-ui.md | 114 +++++++++--------- .../components/MobileGestureRadialMenu.tsx | 12 +- lib/src/lib/mobile-gesture-menu.test.ts | 76 ++++++------ lib/src/lib/mobile-gesture-menu.ts | 55 ++++++--- lib/src/stories/MobileTerminalUi.stories.tsx | 20 +-- website/src/lib/tut-items.ts | 2 +- 6 files changed, 153 insertions(+), 126 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 15bc66a..b39edbd 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -185,45 +185,50 @@ opacity blends smoothly from `1` at `RADIUS_FADE_START` to that target at RADIUS_FADE_START), 0, 1)` and `opacity = 1 + (targetOpacity - 1) * fadeProgress`. -Each root compass group renders as three separate labels placed close together, -not as one combined pill. When a group is selected, those same three labels tween -from their root group positions to their exploded positions in the opposite -directions. They must not fade out and be replaced by newly spawned option -labels. - -Root labels are laid out as a square keypad, not on a circle. N, S, E, and W -use a hierarchical layout: the arrow chip sits closest to the select circle, and -the four arrow chips use one shared `GAP_CARDINAL_RING` from the select circle -edge. The two secondary chips sit just outside each arrow. N/S secondary pairs -use `GAP_CLUSTER` as the horizontal edge-to-edge gap across the axis; E/W -secondary pairs use the same `GAP_CLUSTER` as the vertical edge-to-edge gap. -Diagonal groups use an EW-dominant corner-and-stack layout: the center option's inward corner -is aligned with the diagonal tick mark at the same ring gap used by the cardinal -arrow chips, measured on screen as the same horizontal/vertical visual gap rather -than as a longer diagonal distance. The diagonal center corner contract is: SE -aligns Enter's top-left corner, NE aligns Backspace's bottom-left corner, SW -aligns Tab's top-right corner, and NW aligns Esc's bottom-right corner. NE and -SE place their secondary options relative to the center option exactly like the -E cluster places `End` and `l` relative to `▶`: both to the right of the center, -one above and one below. NW and SW place their secondary options exactly like the -W cluster places `Home` and `h` relative to `◀`: both to the left of the center, -one above and one below. Exploded option labels use the square direction anchors -directly. The root label pack stays close to the select circle, while preserving -enough room for long labels like Backspace. - -Each root cluster uses `GAP_CLUSTER = 2px`. The first option in each group is -the cluster center. For N/S/E/W groups, items to the left are right-aligned to -the center chip's left edge plus the cluster gap; items to the right are -left-aligned to the center chip's right edge plus the cluster gap. Vertical -neighbors use the same edge-and-gap rule above or below the center chip. -Diagonal groups combine the tick-corner rule above with the same secondary-stack -rule used by the matching E or W cardinal group. - -The radial menu is a two-stage gesture: +N, S, E, and W root labels render as single arrow chips. Dragging to +`RADIUS_SELECT` in one of those four cardinal directions immediately sends the +matching arrow key; there is no second-swipe confirmation. + +Diagonal root compass groups render as three separate labels placed close +together, not as one combined pill. When a diagonal group is selected, those +same three labels tween from their root group positions to their exploded +positions in the opposite directions. They must not fade out and be replaced by +newly spawned option labels. + +Root labels are laid out as a square keypad, not on a circle. The four cardinal +arrow chips use one shared `GAP_CARDINAL_RING` from the select circle edge. +Diagonal groups use an EW-dominant corner-and-stack layout: the center option's +inward corner is aligned with the diagonal tick mark at the same ring gap used +by the cardinal arrow chips, measured on screen as the same horizontal/vertical +visual gap rather than as a longer diagonal distance. The diagonal center corner +contract is: SE aligns Enter's top-left corner, NE aligns Backspace's +bottom-left corner, SW aligns Tab's top-right corner, and NW aligns Esc's +bottom-right corner. NE and SE place their secondary options to the right of the +center option, one above and one below. NW and SW place their secondary options +to the left of the center option, one above and one below. Exploded option +labels use the square direction anchors directly. The root label pack stays +close to the select circle, while preserving enough room for long labels like +Backspace. + +Each diagonal root cluster uses `GAP_CLUSTER = 2px`. The first option in each +diagonal group is the cluster center. Secondary options use the same edge-and-gap +rule above or below the center chip. + +For cardinal directions, the radial menu is a one-stage gesture: 1. Touch down to open the menu. 2. Drag to `RADIUS_HIGHLIGHT` to preview the closest compass point. -3. Drag to `RADIUS_SELECT` to choose that compass point's group. +3. Drag to `RADIUS_SELECT` on N, S, E, or W to immediately send the matching + arrow key. The app must not wait for touch release. +4. After the arrow sends, the radial menu remains for a short completion + animation: removed labels fade out, and the selected arrow label expands and + fades out for positive confirmation before the overlay clears. + +For diagonal directions, the radial menu is a two-stage gesture: + +1. Touch down to open the menu. +2. Drag to `RADIUS_HIGHLIGHT` to preview the closest compass point. +3. Drag to `RADIUS_SELECT` to choose that diagonal compass point's group. 4. The other seven compass groups fade out. 5. The compass center resets to the point where the user's drag intersected the `RADIUS_SELECT` circle. @@ -239,40 +244,37 @@ The radial menu is a two-stage gesture: If the user releases after the first group selection but before choosing one of the exploded options, the gesture is cancelled. -Exploded option directions: +Exploded option directions for diagonal groups: | Selected group | Option directions | | --- | --- | -| N | S, SW, SE | | NE | SW, W, S | -| E | W, NW, SW | | SE | NW, N, W | -| S | N, NW, NE | | SW | NE, N, E | -| W | E, NE, SE | | NW | SE, E, S | Examples: -* Right arrow: tap, drag right to choose the E group, then drag left from the reset center until it sends. -* End: tap, drag right to choose the E group, then drag up-left from the reset center until it sends. -* `l`: tap, drag right to choose the E group, then drag down-left from the reset center until it sends. +* Right arrow: tap, drag right to `RADIUS_SELECT`; it sends immediately. +* Enter: tap, drag down-right to choose the SE group, then drag up-left from + the reset center until it sends. +* Shift+Enter: tap, drag down-right to choose the SE group, then drag up from + the reset center until it sends. Root gesture menu labels use compact key glyphs: `⌃` for Ctrl, `⬆︎` for -Shift, and `▲`/`▼`/`◀`/`▶` for arrow keys. Enter, Backspace, PgUp, and PgDn -remain spelled out. +Shift, and `▲`/`▼`/`◀`/`▶` for arrow keys. Enter and Backspace remain spelled +out. Root gesture menu: ```text -Esc ⌃C* k PgUp n Backspace -Quit** ▲ Paste* +Esc ⌃C* ▲ n Backspace +Quit** Paste* -Home ◀ ▶ End -h l + ◀ ▶ -⬆︎Tab ▼ y ⬆︎Enter -Tab Space j PgDn Enter +⬆︎Tab ▼ y ⬆︎Enter +Tab Space Enter ``` `⌃C` and `Paste` require an in-pane confirmation modal before they run. @@ -297,23 +299,15 @@ Gesture action mappings: | ⌃X | `\x18` | | `:q↵` | `:q\r` | | ▲ | `\x1B[A` | -| PgUp | `\x1B[5~` | -| k | `k` | | Backspace | `\x7F` | | Paste | Existing Dormouse paste flow for the active pane | | n | `n` | | ◀ | `\x1B[D` | -| Home | `\x1B[H` | -| h | `h` | | ▶ | `\x1B[C` | -| End | `\x1B[F` | -| l | `l` | | Tab | `\x09` | | ⬆︎Tab | `\x1B[Z` | | Space | ` ` | | ▼ | `\x1B[B` | -| PgDn | `\x1B[6~` | -| j | `j` | | Enter | `\r` | | ⬆︎Enter | `\x1B[13;2u` | | y | `y` | diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index 77ecac9..d7c8dce 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -293,8 +293,9 @@ function rootOptionLayout( export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackingState }) { if (state.phase === 'idle') return null; - const phaseOrigin = state.phase === 'root' ? state.origin : state.optionOrigin; - const phaseDisplayOrigin = state.phase === 'root' ? state.displayOrigin : state.displayOptionOrigin; + const directRootComplete = state.phase === 'complete' && state.candidate.phase === 'root'; + const phaseOrigin = state.phase === 'root' || directRootComplete ? state.origin : state.optionOrigin; + const phaseDisplayOrigin = state.phase === 'root' || directRootComplete ? state.displayOrigin : state.displayOptionOrigin; const currentDisplayPoint = translatedPoint(phaseDisplayOrigin, phaseOrigin, state.currentPoint); const rootDirection = activeRootDirection(state); const tickDirection = activeTickDirection(state); @@ -320,9 +321,12 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin return group.options.map((option, index) => { const optionIndex = index as MobileGestureOptionIndex; const isCompletingRootOption = state.phase === 'complete' - && state.candidate.phase === 'options' && state.selectedDirection === direction - && state.candidate.optionIndex === optionIndex; + && state.candidate.optionIndex === optionIndex + && ( + state.candidate.phase === 'root' + || state.candidate.phase === 'options' + ); const isSelectedGroup = ( state.phase === 'options' || (state.phase === 'complete' && state.candidate.phase === 'options') diff --git a/lib/src/lib/mobile-gesture-menu.test.ts b/lib/src/lib/mobile-gesture-menu.test.ts index 23bbe87..564bc40 100644 --- a/lib/src/lib/mobile-gesture-menu.test.ts +++ b/lib/src/lib/mobile-gesture-menu.test.ts @@ -123,74 +123,80 @@ describe('mobile gesture menu state machine', () => { expect(finishMobileGesture(state).action).toBeUndefined(); }); - it('opens the option phase after the select radius', () => { - const state = updateMobileGesture(beginMobileGesture(1, ORIGIN), point(RADIUS_SELECT + 1, 0)); + it('opens the option phase for diagonals after the select radius', () => { + const state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('se')); expect(state.phase).toBe('options'); if (state.phase !== 'options') return; - expect(state.selectedDirection).toBe('e'); - expect(state.optionOrigin).toEqual(point(RADIUS_SELECT, 0)); + expect(state.selectedDirection).toBe('se'); + expect(state.optionOrigin).toEqual(optionOrigin('se')); expect(finishMobileGesture(state).action).toBeUndefined(); }); - it('selects Right by breaking east and dragging west from the option origin', () => { - expect(runGesture([rootSelectionPoint('e'), optionSelectionPoint('e', 0)])).toEqual({ kind: 'input', input: 'right' }); + it('selects cardinal arrows directly at the root select radius', () => { + expect(runGesture([rootSelectionPoint('n')])).toEqual({ kind: 'input', input: 'up' }); + expect(runGesture([rootSelectionPoint('e')])).toEqual({ kind: 'input', input: 'right' }); + expect(runGesture([rootSelectionPoint('s')])).toEqual({ kind: 'input', input: 'down' }); + expect(runGesture([rootSelectionPoint('w')])).toEqual({ kind: 'input', input: 'left' }); }); - it('makes the option action available as soon as the second select radius is crossed', () => { - let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('e')); - state = updateMobileGesture(state, optionSelectionPoint('e', 0)); - expect(state.phase).toBe('options'); - if (state.phase !== 'options') return; + it('makes the direct cardinal action available as soon as the root select radius is crossed', () => { + const state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('e')); + expect(state.phase).toBe('root'); + if (state.phase !== 'root') return; expect(state.candidate?.option.action).toEqual({ kind: 'input', input: 'right' }); }); - it('keeps a complete visual state after the second select radius is crossed', () => { - let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('e')); - state = updateMobileGesture(state, optionSelectionPoint('e', 0)); + it('keeps a complete visual state after a direct cardinal action is chosen', () => { + const state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('e')); const complete = completeMobileGesture(state); expect(complete?.phase).toBe('complete'); if (!complete || complete.phase !== 'complete') return; expect(complete.selectedDirection).toBe('e'); + expect(complete.candidate.phase).toBe('root'); expect(complete.candidate.optionIndex).toBe(0); expect(complete.candidate.option.action).toEqual({ kind: 'input', input: 'right' }); expect(updateMobileGesture(complete, optionSelectionPoint('e', 2))).toBe(complete); }); - it('selects End by breaking east and turning up', () => { - expect(runGesture([rootSelectionPoint('e'), optionSelectionPoint('e', 1)])).toEqual({ kind: 'input', input: 'end' }); + it('makes the option action available as soon as the second select radius is crossed', () => { + let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('se')); + state = updateMobileGesture(state, optionSelectionPoint('se', 0)); + expect(state.phase).toBe('options'); + if (state.phase !== 'options') return; + expect(state.candidate?.option.action).toEqual({ kind: 'input', input: 'enter' }); + }); + + it('keeps a complete visual state after the second select radius is crossed', () => { + let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('se')); + state = updateMobileGesture(state, optionSelectionPoint('se', 0)); + const complete = completeMobileGesture(state); + + expect(complete?.phase).toBe('complete'); + if (!complete || complete.phase !== 'complete') return; + expect(complete.selectedDirection).toBe('se'); + expect(complete.candidate.phase).toBe('options'); + expect(complete.candidate.optionIndex).toBe(0); + expect(complete.candidate.option.action).toEqual({ kind: 'input', input: 'enter' }); + expect(updateMobileGesture(complete, optionSelectionPoint('se', 2))).toBe(complete); }); it('clears the option highlight when the drag moves back inside the highlight radius', () => { - let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('e')); - state = updateMobileGesture(state, pointInDirection(optionOrigin('e'), 'nw', RADIUS_HIGHLIGHT + 1)); + let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('se')); + state = updateMobileGesture(state, pointInDirection(optionOrigin('se'), 'n', RADIUS_HIGHLIGHT + 1)); expect(state.phase).toBe('options'); if (state.phase !== 'options') return; expect(state.highlightedOptionIndex).toBe(1); - state = updateMobileGesture(state, optionOrigin('e')); + state = updateMobileGesture(state, optionOrigin('se')); expect(state.phase).toBe('options'); if (state.phase !== 'options') return; expect(state.highlightedOptionIndex).toBeUndefined(); expect(state.candidate).toBeUndefined(); }); - it('selects l by breaking east and turning down', () => { - expect(runGesture([rootSelectionPoint('e'), optionSelectionPoint('e', 2)])).toEqual({ kind: 'text', text: 'l' }); - }); - - it('keeps south secondary options on their root side when exploded', () => { - expect(runGesture([rootSelectionPoint('s'), optionSelectionPoint('s', 1)])).toEqual({ kind: 'text', text: 'j' }); - expect(runGesture([rootSelectionPoint('s'), optionSelectionPoint('s', 2)])).toEqual({ kind: 'input', input: 'pageDown' }); - }); - - it('keeps west secondary options on their root side when exploded', () => { - expect(runGesture([rootSelectionPoint('w'), optionSelectionPoint('w', 1)])).toEqual({ kind: 'input', input: 'home' }); - expect(runGesture([rootSelectionPoint('w'), optionSelectionPoint('w', 2)])).toEqual({ kind: 'text', text: 'h' }); - }); - - it('cancels when released in the original breakout direction', () => { - expect(runGesture([rootSelectionPoint('e')])).toBeUndefined(); + it('cancels diagonal groups when released in the original breakout direction', () => { + expect(runGesture([rootSelectionPoint('ne')])).toBeUndefined(); }); it('opens Ctrl+C confirmation from the northwest group', () => { diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index 68768e7..4c5da90 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -52,13 +52,16 @@ export interface MobileGestureOption { action: MobileGestureAction; } +export type MobileGestureOptionTriple = readonly [MobileGestureOption, MobileGestureOption, MobileGestureOption]; +export type MobileGestureOptions = readonly [MobileGestureOption] | MobileGestureOptionTriple; + export interface MobileGestureGroup { direction: MobileGestureDirection; - options: [MobileGestureOption, MobileGestureOption, MobileGestureOption]; + options: MobileGestureOptions; } export interface MobileGestureCandidate { - phase: 'options' | 'quit'; + phase: 'root' | 'options' | 'quit'; groupDirection: MobileGestureDirection; direction: MobileGestureDirection; optionIndex: MobileGestureOptionIndex; @@ -74,6 +77,7 @@ export type MobileGestureTrackingState = displayOrigin: MobileGesturePoint; currentPoint: MobileGesturePoint; highlightedDirection?: MobileGestureDirection; + candidate?: MobileGestureCandidate; } | { phase: 'options'; @@ -145,6 +149,7 @@ export const MOBILE_GESTURE_DIRECTION_VECTORS: Record(['n', 'e', 's', 'w']); export const MOBILE_GESTURE_GROUPS: Record = { nw: { @@ -162,8 +167,6 @@ export const MOBILE_GESTURE_GROUPS: Record= RADIUS_SELECT && closestDirection) { + if (MOBILE_GESTURE_DIRECT_DIRECTIONS.has(closestDirection)) { + const option = MOBILE_GESTURE_GROUPS[closestDirection].options[0]; + return { + ...state, + currentPoint: point, + highlightedDirection: closestDirection, + candidate: { + phase: 'root', + groupDirection: closestDirection, + direction: closestDirection, + optionIndex: 0, + option, + }, + }; + } const optionOrigin = pointOnRadius(state.origin, point, RADIUS_SELECT); return { phase: 'options', @@ -394,14 +406,17 @@ export function updateMobileGesture( ...state, currentPoint: point, highlightedDirection: closestDirection, + candidate: undefined, }; } if (state.phase === 'options') { + const group = MOBILE_GESTURE_GROUPS[state.selectedDirection]; + if (group.options.length !== 3) return state; const optionState = candidateForOptions( 'options', state.selectedDirection, - MOBILE_GESTURE_GROUPS[state.selectedDirection].options, + group.options, state.optionOrigin, point, ); @@ -443,7 +458,7 @@ export function updateMobileGesture( } export function finishMobileGesture(state: MobileGestureTrackingState): MobileGestureFinishResult { - const action = state.phase === 'options' || state.phase === 'quit' + const action = state.phase === 'root' || state.phase === 'options' || state.phase === 'quit' ? state.candidate?.option.action : undefined; return { @@ -453,7 +468,7 @@ export function finishMobileGesture(state: MobileGestureTrackingState): MobileGe } export function completeMobileGesture(state: MobileGestureTrackingState): MobileGestureTrackingState | undefined { - if (state.phase !== 'options' && state.phase !== 'quit') return undefined; + if (state.phase !== 'root' && state.phase !== 'options' && state.phase !== 'quit') return undefined; if (!state.candidate) return undefined; return { phase: 'complete', @@ -461,9 +476,13 @@ export function completeMobileGesture(state: MobileGestureTrackingState): Mobile origin: state.origin, displayOrigin: state.displayOrigin, currentPoint: state.currentPoint, - selectedDirection: state.phase === 'options' ? state.selectedDirection : state.parentDirection, - optionOrigin: state.optionOrigin, - displayOptionOrigin: state.displayOptionOrigin, + selectedDirection: state.phase === 'root' + ? state.candidate.groupDirection + : state.phase === 'options' + ? state.selectedDirection + : state.parentDirection, + optionOrigin: state.phase === 'root' ? state.origin : state.optionOrigin, + displayOptionOrigin: state.phase === 'root' ? state.displayOrigin : state.displayOptionOrigin, candidate: state.candidate, }; } diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index 6466fe7..7f7e3f4 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -366,20 +366,24 @@ export const GesturePrimaryNorthwest: Story = { render: () => , }; -export const GestureEastReturnRight: Story = { - render: () => , +export const GestureEastArrowComplete: Story = { + render: () => , }; -export const GestureEastReturnRightComplete: Story = { - render: () => , +export const GestureSoutheastReturnEnter: Story = { + render: () => , }; -export const GestureEastTurnUpEnd: Story = { - render: () => , +export const GestureSoutheastReturnEnterComplete: Story = { + render: () => , }; -export const GestureEastTurnDownL: Story = { - render: () => , +export const GestureSoutheastTurnUpShiftEnter: Story = { + render: () => , +}; + +export const GestureSoutheastTurnLeftY: Story = { + render: () => , }; export const GestureCtrlCConfirmation: Story = { diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index 10f82b1..8e22838 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -77,7 +77,7 @@ const GESTURE_NAVIGATION_SECTION: Section = { { id: 'gn-arrows', title: 'Use Gestures to send an arrow key', - hint: 'Drag to an arrow group, then choose its arrow option.', + hint: 'Drag directly up, down, left, or right past the circle.', }, { id: 'gn-enter', From a6e1e8c476f1b7dfadb52f6cf410996eeae399e2 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 26 May 2026 17:49:25 -0700 Subject: [PATCH 05/14] Fix playground theming and Pocket selection --- docs/specs/mobile-ui.md | 7 +- docs/specs/mouse-and-clipboard.md | 2 + docs/specs/theme.md | 12 ++ lib/src/components/ThemePicker.tsx | 11 +- lib/src/lib/terminal-mouse-router.test.ts | 99 +++++++++++- lib/src/lib/terminal-mouse-router.ts | 141 +++++++++++++++--- lib/src/lib/themes/apply.test.ts | 57 +++++++ lib/src/lib/themes/apply.ts | 15 +- lib/src/theme.css | 3 + .../components/PocketTerminalExperience.tsx | 9 +- website/src/pages/PocketPlayground.tsx | 2 +- 11 files changed, 329 insertions(+), 29 deletions(-) create mode 100644 lib/src/lib/themes/apply.test.ts diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index b39edbd..275b960 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -108,7 +108,7 @@ Touch modes: | Mode | Button label | Icon | Availability | Behavior | | --- | --- | --- | --- | --- | | Gestures | `Gestures` | `HandPointingIcon` | Always available | Pane-content touches, pen presses, and primary mouse/trackpad clicks open the Gesture mode radial menu. | -| Text selection | `Select` | `CursorTextIcon` | Always available | Touches are reserved for terminal text selection and copy/paste. If the TUI is capturing mouse events, Dormouse activates mouse override for the active pane. | +| Text selection | `Select` | `CursorTextIcon` | Always available | Pane-content touch, pen, and primary mouse/trackpad drags use the same terminal text selection and copy/paste behavior as desktop. If the TUI is capturing mouse events, Dormouse activates mouse override for the active pane. | | Mouse | `Mouse` | `CursorClickIcon` | Only when the active TUI is capturing mouse events | Touches are passed through as terminal mouse input. | Default touch mode is **Gestures**. @@ -121,6 +121,11 @@ input only in Mouse mode. Gestures and Select mode must suppress those scroll-like events before xterm can translate them into mouse reports, alternate-screen arrow keys, or scrollback motion. +Select mode must route touch and pen drags through the shared terminal +mouse-selection router, not through a mobile-only selection implementation, so +selection geometry, smart token extension, copy popups, rewrapped copy, and TUI +mouse-capture override rules match desktop behavior. + Gesture mode intentionally consumes primary mouse/trackpad clicks in addition to touch input. This keeps the `/playground/pocket` prototype usable in desktop browsers, narrow desktop viewports, and Storybook without a touchscreen. A primary diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index cc0fa06..fa35b16 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -92,6 +92,8 @@ Selection is available whenever the terminal is handling mouse events — that i ### 3.1 Initiating a Selection - A click-and-drag in the terminal content area begins a selection. A small movement threshold (~4px) separates a plain click (which only shifts pane focus) from a drag (which begins a selection). +- On touch or pen surfaces, a primary pointer tap-and-drag follows the same + terminal selection path as mouse drag. Non-primary touch pointers are ignored. - The selection is rendered by the terminal in a compositor layer **above** the cell grid, not by writing into the grid. This avoids conflicts with programs redrawing the screen. - The selection rectangle is drawn as a single perimeter outline tracing the union of selected cells. Color is taken from `--vscode-focusBorder` with fallbacks to terminal foreground and selection background. diff --git a/docs/specs/theme.md b/docs/specs/theme.md index 037e353..5b339d5 100644 --- a/docs/specs/theme.md +++ b/docs/specs/theme.md @@ -62,10 +62,22 @@ it reads host-provided variables, materializes only missing Dormouse-consumed variables on `body.style`, and removes stale materialized variables when the host starts providing a real value. +Website routes may be hydrated as a full React Router document, so React can +reconcile the server `` and remove render-time `body.style` and class +side effects. `applyTheme()` treats a same-theme call as a no-op only when the +expected inline `--vscode-*` variables and `vscode-light` / `vscode-dark` class +are still visible on `document.body`. ThemePicker also performs a browser +layout-effect restore after mount so website hydration cannot leave the picker +state saying a theme is active while xterm.js sees fallback colors. + `theme.css` declares the theme-dependent `--color-*` tokens on `body` because `--vscode-*` variables also live there. Keep the parallel `@theme` declarations so Tailwind can generate utility classes, but treat the body-level declarations as the runtime source of truth. +Dynamic palette tokens (`--color-door-bg`, `--color-door-fg`, and +`--color-focus-ring`) also have body-level baseline bindings matching the +`@theme` declarations, so direct CSS-var consumers such as the mobile gesture +SVG render visibly before `useDynamicPalette()` publishes refined values. `theme.css` must not contain hardcoded color defaults or `var(..., fallback)` chains. Runtime hosts plus the shared resolver are responsible for providing diff --git a/lib/src/components/ThemePicker.tsx b/lib/src/components/ThemePicker.tsx index b87c4cc..c62111a 100644 --- a/lib/src/components/ThemePicker.tsx +++ b/lib/src/components/ThemePicker.tsx @@ -1,4 +1,4 @@ -import { useCallback, useId, useRef, useState } from 'react'; +import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from 'react'; import { CaretDownIcon } from '@phosphor-icons/react'; import type { DormouseTheme } from '../lib/themes'; import { @@ -26,6 +26,8 @@ export interface ThemePickerProps { defaultThemeId?: string; } +const useBrowserLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect; + export function ThemePicker({ variant, className = '', defaultThemeId }: ThemePickerProps) { const currentId = useId(); // Apply the persisted theme during render initialization, before commit, so @@ -48,6 +50,13 @@ export function ThemePicker({ variant, className = '', defaultThemeId }: ThemePi const isPlayground = variant === 'playground-header'; const activeTheme = themes.find((theme) => theme.id === activeId) ?? themes[0]; + // React Router document hydration can reconcile after render-time + // theme application; repeat once after commit so xterm sees real colors. + useBrowserLayoutEffect(() => { + const theme = restoreActiveTheme(defaultThemeId); + if (theme) setActiveId(theme.id); + }, [defaultThemeId]); + const closeDropdown = useCallback(() => setOpen(false), []); useCloseOnOutsideAndEscape(open, rootRef, closeDropdown); diff --git a/lib/src/lib/terminal-mouse-router.test.ts b/lib/src/lib/terminal-mouse-router.test.ts index 74a4b0e..0c0ce8e 100644 --- a/lib/src/lib/terminal-mouse-router.test.ts +++ b/lib/src/lib/terminal-mouse-router.test.ts @@ -9,20 +9,20 @@ import { import type { TerminalOverlayDims } from './terminal-store'; class ListenerHost { - private readonly listeners = new Map void>>(); + private readonly listeners = new Map void>>(); - addEventListener(type: string, listener: (ev: MouseEvent) => void): void { + addEventListener(type: string, listener: (ev: MouseEvent | PointerEvent) => void): void { const listeners = this.listeners.get(type) ?? []; listeners.push(listener); this.listeners.set(type, listeners); } - removeEventListener(type: string, listener: (ev: MouseEvent) => void): void { + removeEventListener(type: string, listener: (ev: MouseEvent | PointerEvent) => void): void { const listeners = this.listeners.get(type) ?? []; this.listeners.set(type, listeners.filter((l) => l !== listener)); } - emit(type: string, ev: FakeMouseEvent): void { + emit(type: string, ev: FakeMouseEvent | FakePointerEvent): void { for (const listener of [...(this.listeners.get(type) ?? [])]) { listener(ev); } @@ -30,6 +30,9 @@ class ListenerHost { } class FakeElement extends ListenerHost { + setPointerCapture = vi.fn(); + releasePointerCapture = vi.fn(); + getBoundingClientRect(): Pick { return { left: 0, top: 0 }; } @@ -41,6 +44,8 @@ type FakeMouseEvent = MouseEvent & { stopImmediatePropagation: ReturnType; }; +type FakePointerEvent = PointerEvent & FakeMouseEvent; + function mouseEvent(overrides: Partial = {}): FakeMouseEvent { return { button: 0, @@ -54,6 +59,16 @@ function mouseEvent(overrides: Partial = {}): FakeMouseEvent { } as FakeMouseEvent; } +function pointerEvent(overrides: Partial = {}): FakePointerEvent { + return { + ...mouseEvent(overrides), + pointerId: 1, + pointerType: 'touch', + isPrimary: true, + ...overrides, + } as FakePointerEvent; +} + const dims: TerminalOverlayDims = { cols: 80, rows: 24, @@ -181,4 +196,80 @@ describe('terminal-mouse-router: override suppression', () => { expect(wheel.stopImmediatePropagation).toHaveBeenCalledOnce(); cleanup(); }); + + it('selects text from a touch pointer drag using the terminal selection path', () => { + const { cleanup, element, terminal } = createHarness(windowHost); + + const down = pointerEvent({ clientX: 5, clientY: 5 }); + element.emit('pointerdown', down); + expect(down.preventDefault).toHaveBeenCalledOnce(); + expect(down.stopImmediatePropagation).toHaveBeenCalledOnce(); + expect(element.setPointerCapture).toHaveBeenCalledWith(1); + expect(terminal.focus).toHaveBeenCalledOnce(); + + const move = pointerEvent({ clientX: 25, clientY: 15 }); + windowHost.emit('pointermove', move); + expect(move.preventDefault).toHaveBeenCalledOnce(); + expect(terminal.clearSelection).toHaveBeenCalledOnce(); + + const dragging = getMouseSelectionState('t1').selection; + expect(dragging).toMatchObject({ + startRow: 0, + startCol: 0, + endRow: 1, + endCol: 2, + dragging: true, + }); + + const up = pointerEvent({ clientX: 25, clientY: 15 }); + windowHost.emit('pointerup', up); + expect(up.preventDefault).toHaveBeenCalledOnce(); + expect(element.releasePointerCapture).toHaveBeenCalledWith(1); + + expect(getMouseSelectionState('t1').selection).toMatchObject({ + startRow: 0, + startCol: 0, + endRow: 1, + endCol: 2, + dragging: false, + }); + cleanup(); + }); + + it('suppresses compatibility mouse events after a touch selection starts', () => { + const { cleanup, element } = createHarness(windowHost); + + element.emit('pointerdown', pointerEvent()); + const mouseDown = mouseEvent(); + element.emit('mousedown', mouseDown); + + expect(mouseDown.preventDefault).toHaveBeenCalledOnce(); + expect(mouseDown.stopImmediatePropagation).toHaveBeenCalledOnce(); + cleanup(); + }); + + it('does not select from non-primary touch pointers', () => { + const { cleanup, element } = createHarness(windowHost); + + const down = pointerEvent({ isPrimary: false }); + element.emit('pointerdown', down); + windowHost.emit('pointermove', pointerEvent({ clientX: 25, clientY: 15 })); + + expect(down.preventDefault).not.toHaveBeenCalled(); + expect(getMouseSelectionState('t1').selection).toBeNull(); + cleanup(); + }); + + it('does not steal touch pointer drags from a mouse-reporting TUI without override', () => { + const { cleanup, element } = createHarness(windowHost); + setMouseReporting('t1', 'vt200'); + + const down = pointerEvent(); + element.emit('pointerdown', down); + windowHost.emit('pointermove', pointerEvent({ clientX: 25, clientY: 15 })); + + expect(down.preventDefault).not.toHaveBeenCalled(); + expect(getMouseSelectionState('t1').selection).toBeNull(); + cleanup(); + }); }); diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index 5803b67..2cc0674 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -7,6 +7,7 @@ import { setDragAlt, setHintToken, setOverride, + setSelection, stateRequiresNativeMouseSuppression, updateDrag, } from './mouse-selection'; @@ -16,12 +17,16 @@ import type { TerminalOverlayDims } from './terminal-store'; const OVERRIDE_MOUSE_EVENTS = ['mousemove', 'mouseup', 'wheel', 'click', 'dblclick', 'auxclick', 'contextmenu'] as const; -function consumeMouseEvent(ev: MouseEvent, stopImmediate = false): void { +function consumePointerEvent(ev: MouseEvent | PointerEvent, stopImmediate = false): void { ev.preventDefault(); ev.stopPropagation(); if (stopImmediate) ev.stopImmediatePropagation(); } +function isNonMousePointerEvent(ev: MouseEvent | PointerEvent): ev is PointerEvent { + return 'pointerType' in ev && ev.pointerType !== 'mouse'; +} + // Defer the override clear so any same-tick listener that re-reads the state // (e.g. xterm's own mouseup handler) still sees `temporary` and can emit its // trailing report before we flip back to `off`. @@ -47,7 +52,7 @@ export function attachTerminalMouseRouter({ getOverlayDims: (id: string) => TerminalOverlayDims | null; setSelectionBaseline: (baseline: string | null) => void; }): () => void { - const computeCell = (ev: MouseEvent): { row: number; col: number; startedInScrollback: boolean } => { + const computeCell = (ev: MouseEvent | PointerEvent): { row: number; col: number; startedInScrollback: boolean } => { const dims = getOverlayDims(id); if (!dims) { return { row: 0, col: 0, startedInScrollback: false }; @@ -71,22 +76,34 @@ export function attachTerminalMouseRouter({ button: number; clientX: number; clientY: number; + pointerId: number | null; + touchLike: boolean; } | null = null; + let activePointerId: number | null = null; + let suppressSyntheticMouseUntil = 0; - const onMouseDown = (ev: MouseEvent) => { + const terminalOwnsEvent = (ev: MouseEvent | PointerEvent) => { const state = getMouseSelectionState(id); const cell = computeCell(ev); const terminalOwns = state.mouseReporting === 'none' || state.override !== 'off' || cell.startedInScrollback; - if (!terminalOwns) return; + return { state, cell, terminalOwns }; + }; + + const beginPendingDrag = ( + ev: MouseEvent | PointerEvent, + opts: { pointerId: number | null; touchLike: boolean }, + ) => { + const { state, cell, terminalOwns } = terminalOwnsEvent(ev); + if (!terminalOwns) return false; const suppressNativeMouse = state.mouseReporting !== 'none'; - if (suppressNativeMouse) { - consumeMouseEvent(ev, true); + if (suppressNativeMouse || opts.touchLike) { + consumePointerEvent(ev, true); terminal.focus(); } - if (ev.button !== 0 && !suppressNativeMouse) return; + if (ev.button !== 0 && !suppressNativeMouse) return true; pendingDrag = { row: cell.row, col: cell.col, @@ -95,18 +112,20 @@ export function attachTerminalMouseRouter({ button: ev.button, clientX: ev.clientX, clientY: ev.clientY, + pointerId: opts.pointerId, + touchLike: opts.touchLike, }; + return true; }; - const onOverrideMouseEvent = (ev: MouseEvent) => { - const state = getMouseSelectionState(id); - if (state.mouseReporting === 'none' || state.override === 'off') return; - consumeMouseEvent(ev, true); - }; - - const onWindowMouseMove = (ev: MouseEvent) => { + const updatePendingOrActiveDrag = (ev: MouseEvent | PointerEvent) => { + let consumed = false; if (pendingDrag) { - if (stateRequiresNativeMouseSuppression(getMouseSelectionState(id))) consumeMouseEvent(ev, true); + const suppressNativeMouse = stateRequiresNativeMouseSuppression(getMouseSelectionState(id)); + if (suppressNativeMouse || pendingDrag.touchLike) { + consumePointerEvent(ev, true); + consumed = true; + } if (pendingDrag.button !== 0) return; const dx = ev.clientX - pendingDrag.clientX; const dy = ev.clientY - pendingDrag.clientY; @@ -123,7 +142,8 @@ export function attachTerminalMouseRouter({ if (!isDragging(id)) return; const cell = computeCell(ev); updateDrag(id, { row: cell.row, col: cell.col, altKey: ev.altKey }); - consumeMouseEvent(ev, stateRequiresNativeMouseSuppression(getMouseSelectionState(id))); + const suppressNativeMouse = stateRequiresNativeMouseSuppression(getMouseSelectionState(id)); + if (!consumed) consumePointerEvent(ev, suppressNativeMouse || isNonMousePointerEvent(ev)); const line = terminal.buffer.active.getLine(cell.row); const text = line?.translateToString(false, 0, terminal.cols); @@ -137,10 +157,11 @@ export function attachTerminalMouseRouter({ } : null); }; - const onWindowMouseUp = (ev: MouseEvent) => { + const finishPendingOrActiveDrag = (ev: MouseEvent | PointerEvent) => { if (pendingDrag) { if (ev.button !== pendingDrag.button) return; - if (stateRequiresNativeMouseSuppression(getMouseSelectionState(id))) consumeMouseEvent(ev, true); + const suppressNativeMouse = stateRequiresNativeMouseSuppression(getMouseSelectionState(id)); + if (suppressNativeMouse || pendingDrag.touchLike) consumePointerEvent(ev, true); clearTemporaryOverrideAfterMouseDispatch(id); pendingDrag = null; return; @@ -153,7 +174,81 @@ export function attachTerminalMouseRouter({ const sel = getMouseSelectionState(id).selection; setSelectionBaseline(sel ? extractSelectionText(terminal, sel) : null); clearTemporaryOverrideAfterMouseDispatch(id); - consumeMouseEvent(ev, suppressNativeMouse); + consumePointerEvent(ev, suppressNativeMouse || isNonMousePointerEvent(ev)); + }; + + const onMouseDown = (ev: MouseEvent) => { + if (Date.now() < suppressSyntheticMouseUntil) { + consumePointerEvent(ev, true); + return; + } + beginPendingDrag(ev, { pointerId: null, touchLike: false }); + }; + + const onPointerDown = (ev: PointerEvent) => { + if (ev.pointerType === 'mouse') return; + if (!ev.isPrimary) return; + const handled = beginPendingDrag(ev, { pointerId: ev.pointerId, touchLike: true }); + if (!handled) return; + activePointerId = ev.pointerId; + suppressSyntheticMouseUntil = Date.now() + 800; + try { + element.setPointerCapture(ev.pointerId); + } catch { + // Pointer capture is a best-effort continuity aid; window listeners still + // keep the drag alive in browsers that reject capture here. + } + }; + + const onOverrideMouseEvent = (ev: MouseEvent) => { + if (Date.now() < suppressSyntheticMouseUntil) { + consumePointerEvent(ev, true); + return; + } + const state = getMouseSelectionState(id); + if (state.mouseReporting === 'none' || state.override === 'off') return; + consumePointerEvent(ev, true); + }; + + const onWindowMouseMove = (ev: MouseEvent) => { + updatePendingOrActiveDrag(ev); + }; + + const onWindowMouseUp = (ev: MouseEvent) => { + finishPendingOrActiveDrag(ev); + }; + + const onWindowPointerMove = (ev: PointerEvent) => { + if (ev.pointerType === 'mouse') return; + if (activePointerId !== ev.pointerId) return; + updatePendingOrActiveDrag(ev); + }; + + const onWindowPointerUp = (ev: PointerEvent) => { + if (ev.pointerType === 'mouse') return; + if (activePointerId !== ev.pointerId) return; + finishPendingOrActiveDrag(ev); + activePointerId = null; + try { + element.releasePointerCapture(ev.pointerId); + } catch { + // See setPointerCapture comment above. + } + }; + + const onWindowPointerCancel = (ev: PointerEvent) => { + if (ev.pointerType === 'mouse') return; + if (activePointerId !== ev.pointerId) return; + pendingDrag = null; + activePointerId = null; + setSelection(id, null); + setHintToken(id, null); + consumePointerEvent(ev, true); + try { + element.releasePointerCapture(ev.pointerId); + } catch { + // See setPointerCapture comment above. + } }; const onAltChange = (ev: KeyboardEvent) => { @@ -162,21 +257,29 @@ export function attachTerminalMouseRouter({ }; element.addEventListener('mousedown', onMouseDown, true); + element.addEventListener('pointerdown', onPointerDown, true); for (const type of OVERRIDE_MOUSE_EVENTS) { element.addEventListener(type, onOverrideMouseEvent, true); } window.addEventListener('mousemove', onWindowMouseMove, true); window.addEventListener('mouseup', onWindowMouseUp, true); + window.addEventListener('pointermove', onWindowPointerMove, true); + window.addEventListener('pointerup', onWindowPointerUp, true); + window.addEventListener('pointercancel', onWindowPointerCancel, true); window.addEventListener('keydown', onAltChange, true); window.addEventListener('keyup', onAltChange, true); return () => { element.removeEventListener('mousedown', onMouseDown, true); + element.removeEventListener('pointerdown', onPointerDown, true); for (const type of OVERRIDE_MOUSE_EVENTS) { element.removeEventListener(type, onOverrideMouseEvent, true); } window.removeEventListener('mousemove', onWindowMouseMove, true); window.removeEventListener('mouseup', onWindowMouseUp, true); + window.removeEventListener('pointermove', onWindowPointerMove, true); + window.removeEventListener('pointerup', onWindowPointerUp, true); + window.removeEventListener('pointercancel', onWindowPointerCancel, true); window.removeEventListener('keydown', onAltChange, true); window.removeEventListener('keyup', onAltChange, true); }; diff --git a/lib/src/lib/themes/apply.test.ts b/lib/src/lib/themes/apply.test.ts new file mode 100644 index 0000000..6d04d83 --- /dev/null +++ b/lib/src/lib/themes/apply.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment jsdom + */ +import { beforeEach, describe, expect, it } from 'vitest'; +import { applyTheme, restoreActiveTheme } from './apply'; +import { getTheme } from './store'; + +const KIMBIE_DARK = 'vscode.theme-kimbie-dark.kimbie-dark'; + +function installStorageStub(): void { + const values = new Map(); + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: { + clear: () => values.clear(), + getItem: (key: string) => values.get(key) ?? null, + removeItem: (key: string) => values.delete(key), + setItem: (key: string, value: string) => values.set(key, value), + }, + }); +} + +describe('applyTheme', () => { + beforeEach(() => { + installStorageStub(); + document.body.removeAttribute('class'); + document.body.removeAttribute('style'); + }); + + it('reapplies the same theme when document hydration removes body styles', () => { + const theme = getTheme(KIMBIE_DARK); + expect(theme).toBeDefined(); + + applyTheme(theme!); + expect(document.body.style.getPropertyValue('--vscode-editor-background')).toBe('#221a0f'); + expect(document.body.style.getPropertyValue('--vscode-terminal-background')).toBe('#221a0f'); + expect(document.body.classList.contains('vscode-dark')).toBe(true); + + document.body.removeAttribute('class'); + document.body.removeAttribute('style'); + + applyTheme(theme!); + expect(document.body.style.getPropertyValue('--vscode-editor-background')).toBe('#221a0f'); + expect(document.body.style.getPropertyValue('--vscode-terminal-background')).toBe('#221a0f'); + expect(document.body.classList.contains('vscode-dark')).toBe(true); + }); + + it('restores the default theme after hydration strips the first render pass', () => { + restoreActiveTheme(KIMBIE_DARK); + document.body.removeAttribute('class'); + document.body.removeAttribute('style'); + + restoreActiveTheme(KIMBIE_DARK); + expect(document.body.style.getPropertyValue('--vscode-editor-background')).toBe('#221a0f'); + expect(document.body.style.getPropertyValue('--vscode-terminal-background')).toBe('#221a0f'); + }); +}); diff --git a/lib/src/lib/themes/apply.ts b/lib/src/lib/themes/apply.ts index 14867f1..812a3e1 100644 --- a/lib/src/lib/themes/apply.ts +++ b/lib/src/lib/themes/apply.ts @@ -19,11 +19,22 @@ const HOST_TYPOGRAPHY_VARS: Record = { "'SF Mono', Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace", }; +function hasVisibleTheme(snapshot: AppliedThemeSnapshot): boolean { + const body = document.body; + const expectedClass = snapshot.theme.type === 'light' ? 'vscode-light' : 'vscode-dark'; + if (!body.classList.contains(expectedClass)) return false; + + for (const [name, value] of Object.entries(snapshot.resolvedVars)) { + if (body.style.getPropertyValue(name).trim() !== value) return false; + } + return true; +} + export function applyTheme(theme: DormouseTheme): void { if (typeof document === 'undefined') return; - if (theme === appliedThemeSnapshot?.theme) return; + if (theme === appliedThemeSnapshot?.theme && hasVisibleTheme(appliedThemeSnapshot)) return; - if (appliedThemeSnapshot) { + if (appliedThemeSnapshot && theme !== appliedThemeSnapshot.theme) { for (const name of Object.keys(appliedThemeSnapshot.resolvedVars)) { document.body.style.removeProperty(name); } diff --git a/lib/src/theme.css b/lib/src/theme.css index d7c3b93..55fc245 100644 --- a/lib/src/theme.css +++ b/lib/src/theme.css @@ -113,6 +113,9 @@ body { --color-header-active-fg: var(--vscode-list-activeSelectionForeground); --color-header-inactive-bg: var(--vscode-list-inactiveSelectionBackground); --color-header-inactive-fg: var(--vscode-list-inactiveSelectionForeground); + --color-door-bg: var(--color-header-inactive-bg); + --color-door-fg: var(--color-header-inactive-fg); + --color-focus-ring: var(--vscode-focusBorder); --color-terminal-bg: var(--vscode-terminal-background); --color-terminal-fg: var(--vscode-terminal-foreground); --color-error: var(--vscode-terminal-ansiRed); diff --git a/website/src/components/PocketTerminalExperience.tsx b/website/src/components/PocketTerminalExperience.tsx index d9f6ec9..28dea14 100644 --- a/website/src/components/PocketTerminalExperience.tsx +++ b/website/src/components/PocketTerminalExperience.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from "react"; import { MobileTerminalUi, type MobileTerminalKeyboardMode, type MobileTerminalTouchMode } from "dormouse-lib/components/MobileTerminalUi"; import { MobileWall, useMobileWallSessionItems, type MobileWallSession } from "dormouse-lib/components/MobileWall"; import { restoreActiveTheme } from "dormouse-lib/lib/themes"; @@ -38,12 +38,19 @@ const GESTURE_ARROW_INPUTS = new Set([ "right", ]); +const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; + function usePocketTheme() { const restoredRef = useRef(false); if (!restoredRef.current) { restoreActiveTheme(POCKET_THEME_ID); restoredRef.current = true; } + // Repeat after document hydration so MobileWall initializes from real + // Kimbie variables even if React reconciled away render-time body styles. + useBrowserLayoutEffect(() => { + restoreActiveTheme(POCKET_THEME_ID); + }, []); } export function PocketTerminalExperience({ diff --git a/website/src/pages/PocketPlayground.tsx b/website/src/pages/PocketPlayground.tsx index d62aee3..ac68ff0 100644 --- a/website/src/pages/PocketPlayground.tsx +++ b/website/src/pages/PocketPlayground.tsx @@ -11,7 +11,7 @@ import { POCKET_PLAYGROUND_PATH, usePreferredPlayground } from "../lib/playgroun function MobilePocketPlaygroundPage() { return ( -
+
From 6620d45047487268b113ce3ae865301364c974ec Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 26 May 2026 18:06:04 -0700 Subject: [PATCH 06/14] Adjust Pocket copy tutorial --- docs/specs/tutorial.md | 8 ++-- .../components/PocketTerminalExperience.tsx | 24 +++++++++++- website/src/lib/tut-items.ts | 10 +---- website/src/lib/tut-runner.test.ts | 38 ++++++++++++++++++- website/src/lib/tut-runner.ts | 30 +++++++++++++++ 5 files changed, 95 insertions(+), 15 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index d5c41a8..4f4b3e9 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -92,10 +92,12 @@ The detector remembers the most recent pane whose `watchingEnabled` flag is true 1. Resolves that pane to its current PTY session id, then calls `adapter.pumpActivity(sessionId, BUSY_DEMO_DURATION_MS, 800)` — drives the alert-manager's activity monitor on the same WATCHING-enabled session with **no text output**, so the bell tilts to BUSY without scrolling any scenario text. The session id is resolved at trigger time so `Cmd/Ctrl+Arrow` swaps do not leave the tutorial pumping an old pane id. If no WATCHING-enabled pane is known, the runner falls back to `PANE_BOXED` (the changelog pane). `BUSY_DEMO_DURATION_MS` is `cfg.alert.userAttention + 250` so silence begins after the attention idle window has expired, with a small scheduler-jitter guard; otherwise the "user is looking at this pane" check inside `ActivityMonitor.startNeedsAttentionConfirmTimer` would suppress the ring rather than let it fire. 2. Animates a countdown in-place where the "Press s…" hint was: `⠋ Fake task will finish in N seconds.` ticking down to 1, then a static `✓ Fake task finished. Press s to start another one.` once the activity stops. Detection is purely timing-based via the existing `ActivityMonitor`, so no shell integration is required. -### Shared Section — Copy paste (4 items) +### Desktop Section 3 / Shared Behaviors — Copy paste The detector subscribes to `subscribeToMouseSelection()` and tracks per-id transitions on `selection`, `copyFlash`, and `override`. +Desktop shows four checklist items: + | ID | Title | Detection | |---|---|---| | `cp-select` | Drag-select text in any pane | `selection` transitions `null → non-null` | @@ -107,9 +109,9 @@ Prose: - "Some programs trap the mouse — the cursor icon lets you override." - "`ascii-splash` redraws every frame, so it cancels selections: looks cool, undragable." -The Copy Rewrapped step uses the wrapped item lines `ChangelogRunner` produces in the `tut-boxed` pane. The runner word-wraps each item to fit the pane width, so Rewrapped joins those lines back together while Raw preserves the wrap; clipboard contents visibly differ. The user must override mouse capture first (the `cp-override` step) before drag-selecting inside the changelog pane, since the runner enables SGR mouse-reporting. +The Copy Rewrapped step uses the wrapped item lines `ChangelogRunner` produces in the `tut-boxed` pane. The runner word-wraps each item to fit the pane width, so Rewrapped joins those lines back together while Raw preserves the wrap; clipboard contents visibly differ. On desktop, the user must override mouse capture first (the `cp-override` step) before drag-selecting inside the changelog pane, since the runner enables SGR mouse-reporting. -Pocket uses the same Copy paste item ids with mobile-specific wording: the user switches to the `changelog` session through the Sessions reserve instead of using a desktop tab. +Pocket uses the same `cp-select`, `cp-raw`, and `cp-rewrap` checklist item ids, but removes `cp-override`. It renders a non-progress live prompt before the checklist: `Tap "Select" to enable drag-to-copy` while the current touch mode is Gestures or Mouse, and `Select is active — drag-to-copy is enabled` while the current touch mode is Select. This row is not stored, not checkmarked, and not counted in section or total completion. In Select mode, Pocket sets mouse override for the active mouse-capturing pane automatically, so the tutorial does not ask the user to click the cursor icon in `changelog`. While the Copy paste section is open, pressing `p` toggles the **Place To Paste** modal — a draggable scratch box with eight pointer-event resize handles (four edges + four corners), rendered by `website/src/components/PlaceToPaste.tsx` and mounted at the page level. `TutRunner` intercepts `p`/`P` (mirroring the Alert section's `s` busy-demo intercept) and calls `onTogglePlaceToPaste`; `Playground` flips a `placeToPasteOpen` flag so the modal is portal-free and overlays the wall. The runner renders a persistent `Press \`p\` to toggle the Place To Paste …` line above the section's prose paragraph so the prompt is visible regardless of which item is active. Users paste copied text into the modal's single textarea and resize it to see whether the text reflows (Rewrapped) or stays line-broken (Raw). diff --git a/website/src/components/PocketTerminalExperience.tsx b/website/src/components/PocketTerminalExperience.tsx index 28dea14..4b9d92a 100644 --- a/website/src/components/PocketTerminalExperience.tsx +++ b/website/src/components/PocketTerminalExperience.tsx @@ -69,6 +69,8 @@ export function PocketTerminalExperience({ const autoStartedRef = useRef>(new Set()); const spawnUnsubRef = useRef<(() => void) | null>(null); const sawSelectionTouchModeRef = useRef(false); + const touchModeRef = useRef("gestures"); + const touchModeListenersRef = useRef(new Set<() => void>()); const [activePaneId, setActivePaneId] = useState(POCKET_TUTORIAL_PANE); const [touchMode, setTouchMode] = useState("gestures"); const [keyboardMode, setKeyboardMode] = useState("type"); @@ -98,7 +100,22 @@ export function PocketTerminalExperience({ tutorialStateRef.current?.markComplete(id); }, []); + const getPocketTouchMode = useCallback(() => touchModeRef.current, []); + + const subscribeToPocketTouchMode = useCallback((listener: () => void) => { + touchModeListenersRef.current.add(listener); + return () => { + touchModeListenersRef.current.delete(listener); + }; + }, []); + + const publishTouchMode = useCallback((nextMode: MobileTerminalTouchMode) => { + touchModeRef.current = nextMode; + for (const listener of touchModeListenersRef.current) listener(); + }, []); + const handleTouchModeChange = useCallback((nextMode: MobileTerminalTouchMode) => { + publishTouchMode(nextMode); setTouchMode(nextMode); if (nextMode === "selection") { sawSelectionTouchModeRef.current = true; @@ -108,7 +125,7 @@ export function PocketTerminalExperience({ sawSelectionTouchModeRef.current = false; markPocketItemComplete("gn-touch-mode"); } - }, [markPocketItemComplete]); + }, [markPocketItemComplete, publishTouchMode]); const handleGestureInput = useCallback((input: MobileGestureInputId) => { if (GESTURE_ARROW_INPUTS.has(input)) { @@ -172,6 +189,8 @@ export function PocketTerminalExperience({ onExit, onOpenGithub: handleOpenGithub, onOpenPocket: handleOpenPocket, + getPocketTouchMode, + subscribeToPocketTouchMode, }); } if (name === "ascii-splash" || name === "splash") { @@ -215,9 +234,10 @@ export function PocketTerminalExperience({ tutorialStateRef.current = null; autoStartedRef.current.clear(); sawSelectionTouchModeRef.current = false; + touchModeListenersRef.current.clear(); adapterRef.current = null; }; - }, [handleOpenGithub, handleOpenPocket, tryAutoStart]); + }, [getPocketTouchMode, handleOpenGithub, handleOpenPocket, subscribeToPocketTouchMode, tryAutoStart]); useEffect(() => { const reporting = activeMouseState?.mouseReporting ?? "none"; diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index 8e22838..2642e8f 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -126,15 +126,9 @@ const COPY_PASTE_SECTION: Section = { const POCKET_COPY_PASTE_SECTION: Section = { ...COPY_PASTE_SECTION, - items: COPY_PASTE_SECTION.items.map((item) => item.id === 'cp-override' - ? { - ...item, - hint: - 'Open the Sessions reserve, switch to `changelog`, then try to drag-select there. The changelog TUI traps the cursor, so use the cursor icon in its header to disable mouse tracking long enough for a drag-select.', - } - : item), + items: COPY_PASTE_SECTION.items.filter((item) => item.id !== 'cp-override'), prose: [ - 'Some terminal programs trap the cursor, and some do not. The `changelog` session traps the cursor — that is how it responds to mouse movement. `lazygit` is an excellent and popular program which traps the cursor.', + 'Some terminal programs trap the cursor, and some do not. In Pocket, Select mode takes over drag-to-copy even for sessions like `changelog` that normally trap the cursor. Switch back to Gestures when you want arrow, Enter, and Esc gestures again.', ], }; diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 53bad0a..46cf849 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -13,7 +13,11 @@ const FRAME_RESET = "\x1b[H\x1b[2J"; function mountRunner( completedIds: ItemId[] = [], - options: { onOpenGithub?: () => void; profile?: TutorialProfile } = {}, + options: { + onOpenGithub?: () => void; + pocketTouchMode?: "gestures" | "selection" | "cursor"; + profile?: TutorialProfile; + } = {}, ) { const adapter = new FakePtyAdapter(); const id = "test-pane"; @@ -26,6 +30,8 @@ function mountRunner( const profile = options.profile; const state = new TutorialState(profile?.sections); for (const itemId of completedIds) state.markComplete(itemId); + let pocketTouchMode = options.pocketTouchMode ?? "gestures"; + const pocketTouchModeListeners = new Set<() => void>(); const runner = new TutRunner({ adapter, @@ -37,6 +43,13 @@ function mountRunner( }, onTogglePlaceToPaste: profile?.id === "pocket" ? undefined : () => {}, onOpenGithub: options.onOpenGithub, + getPocketTouchMode: () => pocketTouchMode, + subscribeToPocketTouchMode: (listener) => { + pocketTouchModeListeners.add(listener); + return () => { + pocketTouchModeListeners.delete(listener); + }; + }, }); adapter.setInputHandler(id, (data) => runner.handleInput(data)); runner.start(); @@ -49,6 +62,10 @@ function mountRunner( const i = all.lastIndexOf(FRAME_RESET); return i >= 0 ? all.slice(i) : all; }, + setPocketTouchMode: (mode: "gestures" | "selection" | "cursor") => { + pocketTouchMode = mode; + for (const listener of pocketTouchModeListeners) listener(); + }, exitCount: () => exitCount, dispose: () => runner.dispose(), }; @@ -105,7 +122,24 @@ describe("TutRunner snapshots", () => { expect(lastFrame()).toContain("Copy paste"); expect(lastFrame()).not.toContain("Keyboard navigation"); expect(lastFrame()).not.toContain("Alert and TODO"); - expect(lastFrame()).toContain("[LOCKED 0/8]"); + expect(lastFrame()).toContain("[LOCKED 0/7]"); + dispose(); + }); + + it("renders Pocket copy paste with a live Select mode prompt", () => { + const { sendKeys, setPocketTouchMode, lastFrame, dispose } = mountRunner([], { + profile: POCKET_TUTORIAL_PROFILE, + }); + + sendKeys("\x1b\x1b[B\r"); + expect(lastFrame()).toContain("Copy paste"); + expect(lastFrame()).toContain("0/3 complete"); + expect(lastFrame()).toContain('Tap "Select" to enable drag-to-copy'); + expect(lastFrame()).not.toContain("Click the cursor icon"); + + setPocketTouchMode("selection"); + expect(lastFrame()).toContain("Select is active"); + expect(lastFrame()).not.toContain("✓"); dispose(); }); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 644fa85..508a8f7 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -121,9 +121,13 @@ interface TutRunnerOptions { onOpenGithub?: () => void; /** Called when the user presses `p` on the Flappy Term game-over screen. */ onOpenPocket?: () => void; + /** Current Pocket touch mode, used for live non-progress tutorial prompts. */ + getPocketTouchMode?: () => PocketTutorialTouchMode; + subscribeToPocketTouchMode?: (listener: () => void) => () => void; } type Screen = "menu" | "section" | "reset" | "flappy"; +type PocketTutorialTouchMode = "gestures" | "selection" | "cursor"; const RESET_CONFIRM_WORD = "reset"; @@ -137,6 +141,8 @@ export class TutRunner implements InteractiveProgram { private onTogglePlaceToPaste?: () => void; private onOpenGithub?: () => void; private onOpenPocket?: () => void; + private getPocketTouchMode?: () => PocketTutorialTouchMode; + private subscribeToPocketTouchMode?: (listener: () => void) => () => void; private screen: Screen = "menu"; private menuIndex = 0; @@ -148,6 +154,7 @@ export class TutRunner implements InteractiveProgram { private flappyTimer: ReturnType | null = null; private flappy: FlappyGameState | null = null; private stateUnsub: (() => void) | null = null; + private pocketTouchModeUnsub: (() => void) | null = null; private resizeUnsub: (() => void) | null = null; private busyDemoStart: number | null = null; private disposed = false; @@ -162,6 +169,8 @@ export class TutRunner implements InteractiveProgram { this.onTogglePlaceToPaste = options.onTogglePlaceToPaste; this.onOpenGithub = options.onOpenGithub; this.onOpenPocket = options.onOpenPocket; + this.getPocketTouchMode = options.getPocketTouchMode; + this.subscribeToPocketTouchMode = options.subscribeToPocketTouchMode; if (this.profile.initialSectionId) { this.sectionId = this.profile.initialSectionId; this.screen = "section"; @@ -171,6 +180,7 @@ export class TutRunner implements InteractiveProgram { start(): void { this.write(ENTER_ALT_SCREEN); this.stateUnsub = this.state.subscribe(() => this.render()); + this.pocketTouchModeUnsub = this.subscribeToPocketTouchMode?.(() => this.render()) ?? null; this.resizeUnsub = this.adapter.onPtyResize((d) => { if (d.id === this.terminalId) this.render(); }); @@ -784,6 +794,10 @@ export class TutRunner implements InteractiveProgram { lines.push(` ${DIM}\`Esc\` to go back${RESET}`); lines.push(""); + if (this.profile.id === "pocket" && section.id === "copy") { + lines.push(...this.renderPocketCopyModePrompt()); + } + const activeIndex = section.items.findIndex((i) => !this.state.isComplete(i.id)); section.items.forEach((item, index) => { lines.push(...this.renderItem(item, index, activeIndex)); @@ -823,6 +837,20 @@ export class TutRunner implements InteractiveProgram { return lines; } + private renderPocketCopyModePrompt(): string[] { + const mode = this.getPocketTouchMode?.() ?? "gestures"; + if (mode === "selection") { + return [ + ` ${fg(36)}${ACTIVE_ITEM_GLYPH}${RESET} ${BOLD}Select is active — drag-to-copy is enabled${RESET}`, + ` ${ITALIC}Tap \`Gestures\` when you want arrow, Enter, and Esc gestures.${RESET}`, + ]; + } + return [ + ` ${fg(33)}${ACTIVE_ITEM_GLYPH}${RESET} ${BOLD}Tap "Select" to enable drag-to-copy${RESET}`, + ` ${ITALIC}Current touch mode is \`${mode === "cursor" ? "Mouse" : "Gestures"}\`.${RESET}`, + ]; + } + private renderBusyDemoLines(): string[] { const idleHint = ` ${DIM}Press \`s\` here to start a fake busy task.${RESET}`; if (this.busyDemoStart === null) return [idleHint]; @@ -912,6 +940,8 @@ export class TutRunner implements InteractiveProgram { this.busyDemoStart = null; this.stateUnsub?.(); this.stateUnsub = null; + this.pocketTouchModeUnsub?.(); + this.pocketTouchModeUnsub = null; this.resizeUnsub?.(); this.resizeUnsub = null; this.write(LEAVE_ALT_SCREEN); From 621a28f46ccc1285351a0e75f25fcea173ec7fad Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 26 May 2026 19:26:28 -0700 Subject: [PATCH 07/14] Bridge Pocket touches to mouse mode --- docs/specs/mobile-ui.md | 7 + lib/src/components/MobileTerminalUi.test.tsx | 197 +++++++++++++++++++ lib/src/components/MobileTerminalUi.tsx | 99 +++++++++- 3 files changed, 296 insertions(+), 7 deletions(-) create mode 100644 lib/src/components/MobileTerminalUi.test.tsx diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 275b960..6434e64 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -121,6 +121,13 @@ input only in Mouse mode. Gestures and Select mode must suppress those scroll-like events before xterm can translate them into mouse reports, alternate-screen arrow keys, or scrollback motion. +In Mouse mode, primary touch and pen pointers synthesize left-button terminal +mouse input: pointerdown emits a mouse press, pointermove emits mouse motion, +and pointerup or pointercancel emits a mouse release. The wrapper suppresses +the native touch gesture while emitting those mouse events so a tap or drag is +seen by the TUI, not by browser panning, browser selection, or xterm's native +touch-scroll fallback. + Select mode must route touch and pen drags through the shared terminal mouse-selection router, not through a mobile-only selection implementation, so selection geometry, smart token extension, copy popups, rewrapped copy, and TUI diff --git a/lib/src/components/MobileTerminalUi.test.tsx b/lib/src/components/MobileTerminalUi.test.tsx new file mode 100644 index 0000000..844e00a --- /dev/null +++ b/lib/src/components/MobileTerminalUi.test.tsx @@ -0,0 +1,197 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { MobileTerminalUi, type MobileTerminalTouchMode } from './MobileTerminalUi'; + +globalThis.IS_REACT_ACT_ENVIRONMENT = true; + +function pointerEvent( + type: string, + overrides: Partial = {}, +): PointerEvent { + const event = new Event(type, { bubbles: true, cancelable: true }) as PointerEvent; + const values: Partial = { + pointerId: 7, + pointerType: 'touch', + isPrimary: true, + button: 0, + buttons: type === 'pointerup' || type === 'pointercancel' ? 0 : 1, + clientX: 10, + clientY: 12, + screenX: 110, + screenY: 112, + ctrlKey: false, + shiftKey: false, + altKey: false, + metaKey: false, + ...overrides, + }; + + for (const [key, value] of Object.entries(values)) { + Object.defineProperty(event, key, { + configurable: true, + get: () => value, + }); + } + + return event; +} + +function renderMobileTerminal({ + touchMode, + onMouseEvent, +}: { + touchMode: MobileTerminalTouchMode; + onMouseEvent: (event: MouseEvent) => void; +}): { terminal: HTMLDivElement } { + const container = document.createElement('div'); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + act(() => { + root.render( + } + />, + ); + }); + + const terminal = container.querySelector('[data-testid="terminal"]'); + if (!terminal) throw new Error('missing terminal test node'); + terminal.addEventListener('mousedown', onMouseEvent); + terminal.addEventListener('mousemove', onMouseEvent); + terminal.addEventListener('mouseup', onMouseEvent); + + return { terminal }; +} + +let roots: Root[] = []; +let setPointerCapture: ReturnType; +let releasePointerCapture: ReturnType; + +function mockElementFromPoint(element: Element): void { + Object.defineProperty(document, 'elementFromPoint', { + configurable: true, + value: vi.fn(() => element), + }); +} + +beforeEach(() => { + setPointerCapture = vi.fn(); + releasePointerCapture = vi.fn(); + Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', { + configurable: true, + value: vi.fn(() => null), + }); + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: setPointerCapture, + }); + Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', { + configurable: true, + value: releasePointerCapture, + }); +}); + +afterEach(() => { + for (const root of roots) { + act(() => root.unmount()); + } + roots = []; + document.body.replaceChildren(); + delete (document as Document & { elementFromPoint?: unknown }).elementFromPoint; + vi.restoreAllMocks(); +}); + +describe('MobileTerminalUi touch modes', () => { + it('sends primary touch pointers as left-button mouse events in Mouse mode', () => { + const received: string[] = []; + const { terminal } = renderMobileTerminal({ + touchMode: 'cursor', + onMouseEvent: (event) => { + received.push(`${event.type}:${event.button}:${event.buttons}:${event.clientX}:${event.clientY}`); + }, + }); + mockElementFromPoint(terminal); + + const down = pointerEvent('pointerdown'); + const move = pointerEvent('pointermove', { clientX: 18, clientY: 20 }); + const up = pointerEvent('pointerup', { clientX: 18, clientY: 20 }); + + terminal.dispatchEvent(down); + terminal.dispatchEvent(move); + terminal.dispatchEvent(up); + + expect(down.defaultPrevented).toBe(true); + expect(move.defaultPrevented).toBe(true); + expect(up.defaultPrevented).toBe(true); + expect(setPointerCapture).toHaveBeenCalledWith(7); + expect(releasePointerCapture).toHaveBeenCalledWith(7); + expect(received).toEqual([ + 'mousedown:0:1:10:12', + 'mousemove:0:1:18:20', + 'mouseup:0:0:18:20', + ]); + }); + + it('sends a mouse release when a Mouse mode touch is cancelled', () => { + const received: string[] = []; + const { terminal } = renderMobileTerminal({ + touchMode: 'cursor', + onMouseEvent: (event) => { + received.push(`${event.type}:${event.buttons}`); + }, + }); + mockElementFromPoint(terminal); + + terminal.dispatchEvent(pointerEvent('pointerdown')); + const cancel = pointerEvent('pointercancel'); + terminal.dispatchEvent(cancel); + + expect(cancel.defaultPrevented).toBe(true); + expect(releasePointerCapture).toHaveBeenCalledWith(7); + expect(received).toEqual(['mousedown:1', 'mouseup:0']); + }); + + it('suppresses native touch events in Mouse mode', () => { + const documentTouchMove = vi.fn(); + document.addEventListener('touchmove', documentTouchMove); + try { + const { terminal } = renderMobileTerminal({ + touchMode: 'cursor', + onMouseEvent: () => {}, + }); + + const touchMove = new Event('touchmove', { bubbles: true, cancelable: true }); + terminal.dispatchEvent(touchMove); + + expect(touchMove.defaultPrevented).toBe(true); + expect(documentTouchMove).not.toHaveBeenCalled(); + } finally { + document.removeEventListener('touchmove', documentTouchMove); + } + }); + + it('does not synthesize mouse events for touch pointers in Select mode', () => { + const received: string[] = []; + const { terminal } = renderMobileTerminal({ + touchMode: 'selection', + onMouseEvent: (event) => { + received.push(event.type); + }, + }); + mockElementFromPoint(terminal); + + terminal.dispatchEvent(pointerEvent('pointerdown')); + terminal.dispatchEvent(pointerEvent('pointermove')); + terminal.dispatchEvent(pointerEvent('pointerup')); + + expect(received).toEqual([]); + }); +}); diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index 996e6a6..653a0a2 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -373,12 +373,45 @@ function isGestureDialogTarget(target: EventTarget | null): boolean { return target instanceof Element && target.closest('[data-mobile-gesture-dialog]') !== null; } -function consumeScrollLikeTerminalEvent(event: Event): void { +function consumeNativeTouchOrScrollEvent(event: Event): void { event.preventDefault(); event.stopPropagation(); event.stopImmediatePropagation(); } +function isTouchLikePrimaryPointer(event: PointerEvent): boolean { + return event.pointerType !== 'mouse' && event.isPrimary; +} + +function targetAtPointer(event: PointerEvent): EventTarget { + const doc = event.currentTarget.ownerDocument; + return doc.elementFromPoint(event.clientX, event.clientY) ?? event.target; +} + +function dispatchMouseFromPointer( + type: 'mousedown' | 'mousemove' | 'mouseup', + pointerEvent: PointerEvent, + target: EventTarget, +): void { + const doc = pointerEvent.currentTarget.ownerDocument; + const view = doc.defaultView ?? window; + const mouseEvent = new view.MouseEvent(type, { + bubbles: true, + cancelable: true, + button: 0, + buttons: type === 'mouseup' ? 0 : 1, + clientX: pointerEvent.clientX, + clientY: pointerEvent.clientY, + screenX: pointerEvent.screenX, + screenY: pointerEvent.screenY, + ctrlKey: pointerEvent.ctrlKey, + shiftKey: pointerEvent.shiftKey, + altKey: pointerEvent.altKey, + metaKey: pointerEvent.metaKey, + }); + target.dispatchEvent(mouseEvent); +} + export function MobileTerminalUi({ terminal, activeSection, @@ -415,6 +448,8 @@ export function MobileTerminalUi({ const gestureStateRef = useRef(MOBILE_GESTURE_IDLE_STATE); const completedGesturePointerIdRef = useRef(null); const gestureCompletionTimerRef = useRef(null); + const cursorPointerIdRef = useRef(null); + const cursorPointerTargetRef = useRef(null); const [gestureState, setGestureState] = useState(MOBILE_GESTURE_IDLE_STATE); const [pendingGestureConfirmation, setPendingGestureConfirmation] = useState(null); const [inputValue, setInputValue] = useState(''); @@ -560,15 +595,27 @@ export function MobileTerminalUi({ }, [cursorTouchAvailable, setTouchMode, touchMode]); useEffect(() => { - if (!interactive || touchMode === 'cursor') return; + if (!interactive) return; const host = terminalHostRef.current; if (!host) return; const options: AddEventListenerOptions = { capture: true, passive: false }; - host.addEventListener('wheel', consumeScrollLikeTerminalEvent, options); - host.addEventListener('touchmove', consumeScrollLikeTerminalEvent, options); + if (touchMode === 'cursor') { + host.addEventListener('touchstart', consumeNativeTouchOrScrollEvent, options); + host.addEventListener('touchmove', consumeNativeTouchOrScrollEvent, options); + host.addEventListener('touchend', consumeNativeTouchOrScrollEvent, options); + host.addEventListener('touchcancel', consumeNativeTouchOrScrollEvent, options); + return () => { + host.removeEventListener('touchstart', consumeNativeTouchOrScrollEvent, options); + host.removeEventListener('touchmove', consumeNativeTouchOrScrollEvent, options); + host.removeEventListener('touchend', consumeNativeTouchOrScrollEvent, options); + host.removeEventListener('touchcancel', consumeNativeTouchOrScrollEvent, options); + }; + } + host.addEventListener('wheel', consumeNativeTouchOrScrollEvent, options); + host.addEventListener('touchmove', consumeNativeTouchOrScrollEvent, options); return () => { - host.removeEventListener('wheel', consumeScrollLikeTerminalEvent, options); - host.removeEventListener('touchmove', consumeScrollLikeTerminalEvent, options); + host.removeEventListener('wheel', consumeNativeTouchOrScrollEvent, options); + host.removeEventListener('touchmove', consumeNativeTouchOrScrollEvent, options); }; }, [interactive, touchMode]); @@ -596,6 +643,15 @@ export function MobileTerminalUi({ const handlePanePointerDownCapture = useCallback((event: PointerEvent) => { if (isGestureDialogTarget(event.target)) return; blurPaneTextInputs(); + if (interactive && touchMode === 'cursor' && isTouchLikePrimaryPointer(event)) { + event.preventDefault(); + event.stopPropagation(); + cursorPointerIdRef.current = event.pointerId; + cursorPointerTargetRef.current = targetAtPointer(event); + event.currentTarget.setPointerCapture(event.pointerId); + dispatchMouseFromPointer('mousedown', event, cursorPointerTargetRef.current); + return; + } if (!interactive || touchMode !== 'gestures') return; if (event.pointerType === 'mouse' && event.button !== 0) return; event.preventDefault(); @@ -614,6 +670,15 @@ export function MobileTerminalUi({ }, [blurPaneTextInputs, clearGestureCompletionTimer, commitGestureState, interactive, touchMode]); const handlePanePointerMoveCapture = useCallback((event: PointerEvent) => { + if (touchMode === 'cursor' && cursorPointerIdRef.current === event.pointerId && isTouchLikePrimaryPointer(event)) { + event.preventDefault(); + event.stopPropagation(); + const target = targetAtPointer(event); + cursorPointerTargetRef.current = target; + dispatchMouseFromPointer('mousemove', event, target); + return; + } + const state = gestureStateRef.current; if (state.phase === 'idle' || state.pointerId !== event.pointerId) return; event.preventDefault(); @@ -632,6 +697,16 @@ export function MobileTerminalUi({ }, [commitGestureState, executeGestureAction, scheduleGestureCompletionClear]); const handlePanePointerUpCapture = useCallback((event: PointerEvent) => { + if (touchMode === 'cursor' && cursorPointerIdRef.current === event.pointerId && isTouchLikePrimaryPointer(event)) { + event.preventDefault(); + event.stopPropagation(); + dispatchMouseFromPointer('mouseup', event, cursorPointerTargetRef.current ?? targetAtPointer(event)); + cursorPointerIdRef.current = null; + cursorPointerTargetRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + return; + } + const state = gestureStateRef.current; if (state.phase === 'complete' && state.pointerId === event.pointerId) { event.preventDefault(); @@ -666,6 +741,16 @@ export function MobileTerminalUi({ }, [blurPaneTextInputs]); const handlePanePointerCancelCapture = useCallback((event: PointerEvent) => { + if (cursorPointerIdRef.current === event.pointerId && isTouchLikePrimaryPointer(event)) { + event.preventDefault(); + event.stopPropagation(); + dispatchMouseFromPointer('mouseup', event, cursorPointerTargetRef.current ?? targetAtPointer(event)); + cursorPointerIdRef.current = null; + cursorPointerTargetRef.current = null; + event.currentTarget.releasePointerCapture(event.pointerId); + return; + } + const state = gestureStateRef.current; if (state.phase === 'complete' && state.pointerId === event.pointerId) { completedGesturePointerIdRef.current = null; @@ -694,7 +779,7 @@ export function MobileTerminalUi({ ref={terminalHostRef} className={clsx( 'relative min-h-0 flex-1 overflow-hidden bg-terminal-bg', - touchMode === 'cursor' ? 'touch-auto' : 'touch-none', + 'touch-none', terminalClassName, )} onPointerDownCapture={handlePanePointerDownCapture} From d4ed111162bcc37e631c147a261d216506c1bb22 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 26 May 2026 19:43:14 -0700 Subject: [PATCH 08/14] Reset Pocket tutorial to initial section --- docs/specs/tutorial.md | 4 ++++ website/src/lib/tut-runner.test.ts | 16 ++++++++++++++++ website/src/lib/tut-runner.ts | 13 ++++++++----- 3 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 4f4b3e9..14c101c 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -58,6 +58,10 @@ Inside a section, items render as one of: Esc / `q` / Ctrl+C pops back one screen (section → menu → exit). Exiting the runner returns the pane to the shell prompt; running `tut` re-enters. +Confirming `Reset progress` returns the runner to that profile's initial screen: +Desktop returns to the top-level menu, while Pocket returns directly to +`Gesture navigation`. + ### Desktop Section 1 — Keyboard navigation (7 items) | ID | Title | Detection | diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 46cf849..5dc318e 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -188,6 +188,22 @@ describe("TutRunner snapshots", () => { dispose(); }); + it("returns the Pocket tutorial to Gesture navigation after reset progress", () => { + const { state, sendKeys, lastFrame, dispose } = mountRunner(["gn-arrows"], { + profile: POCKET_TUTORIAL_PROFILE, + }); + state.resolveStarPrompt(); + + sendKeys("\x1b\x1b[B\x1b[B\x1b[B\x1b[B\rreset\r"); + + expect(state.isComplete("gn-arrows")).toBe(false); + expect(state.isStarPromptResolved()).toBe(false); + expect(lastFrame()).toContain("Gesture navigation"); + expect(lastFrame()).toContain("Switch between Select and Gestures"); + expect(lastFrame()).not.toContain("Dormouse Pocket Tutorial"); + dispose(); + }); + it("keeps Flappy Term locked until every tutorial task is complete", () => { const { sendKeys, lastFrame, dispose } = mountRunner(); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 508a8f7..1565da3 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -171,10 +171,7 @@ export class TutRunner implements InteractiveProgram { this.onOpenPocket = options.onOpenPocket; this.getPocketTouchMode = options.getPocketTouchMode; this.subscribeToPocketTouchMode = options.subscribeToPocketTouchMode; - if (this.profile.initialSectionId) { - this.sectionId = this.profile.initialSectionId; - this.screen = "section"; - } + this.returnToInitialScreen(); } start(): void { @@ -425,6 +422,12 @@ export class TutRunner implements InteractiveProgram { return this.profile.sections.length + 2; } + private returnToInitialScreen(): void { + this.menuIndex = 0; + this.sectionId = this.profile.initialSectionId ?? null; + this.screen = this.sectionId ? "section" : "menu"; + } + private handleArrow(letter: string): void { if (this.screen === "flappy") { if (letter === "A") this.flap(); @@ -500,7 +503,7 @@ export class TutRunner implements InteractiveProgram { this.state.reset(); this.resetBuffer = ""; this.resetMismatch = false; - this.screen = "menu"; + this.returnToInitialScreen(); this.render(); } else { this.resetBuffer = ""; From df6d188e646fd8a5a82c4701e2152fbd86c9ba85 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 26 May 2026 20:00:15 -0700 Subject: [PATCH 09/14] Show FlappyTerm title while locked --- docs/specs/tutorial.md | 12 +++++++++++- .../src/lib/__snapshots__/tut-runner.test.ts.snap | 2 +- website/src/lib/tut-runner.test.ts | 6 ++++-- website/src/lib/tut-runner.ts | 8 +++----- 4 files changed, 19 insertions(+), 9 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 14c101c..62b5049 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -48,7 +48,17 @@ The desktop runner shows a top-level menu first. The Pocket runner starts direct The top-level menu also includes `Starred on GitHub`, which sits directly below `Copy paste` without a blank spacer, and shows `[not yet]` until selected and `[thanks ⭐]` after it has been resolved. Pressing Enter on that row calls `onOpenGithub`, which `/playground/desktop` and the Pocket playground wire to `window.open("https://github.com/diffplug/dormouse", "_blank", "noopener,noreferrer")`. -After `Starred on GitHub`, the top-level menu shows the mystery row. It is `🐭 ??? 🐭` with `[LOCKED N/M]` while any section task is incomplete. `N/M` is computed from section checklist items only; `Starred on GitHub` and the mystery row do not count. When all section tasks are complete, the row becomes `🐭 Flappy Term 🐭` with a `[High score: N]` readout. Pressing Enter on the unlocked row opens Flappy Term, a runner-local mini-game: `Space`/`Up`/`Enter` flaps the bird, scoring persists as the high score, and `Esc` returns to the top-level menu. On the game-over screen, `Enter` restarts and `p` calls `onOpenPocket`, which `/playground/desktop` and the Pocket playground wire to `window.open("/playground/pocket", "_blank", "noopener,noreferrer")`. The game-over prompt reads `Read about Dormouse Pocket [p]`. +After `Starred on GitHub`, the top-level menu shows `🐭 FlappyTerm 🐭`. +It shows `[LOCKED N/M]` while any section task is incomplete. `N/M` is +computed from section checklist items only; `Starred on GitHub` and the Flappy +row do not count. When all section tasks are complete, the row shows a +`[High score: N]` readout. Pressing Enter on the unlocked row opens Flappy +Term, a runner-local mini-game: `Space`/`Up`/`Enter` flaps the bird, scoring +persists as the high score, and `Esc` returns to the top-level menu. On the +game-over screen, `Enter` restarts and `p` calls `onOpenPocket`, which +`/playground/desktop` and the Pocket playground wire to +`window.open("/playground/pocket", "_blank", "noopener,noreferrer")`. The +game-over prompt reads `Read about Dormouse Pocket [p]`. Inside a section, items render as one of: diff --git a/website/src/lib/__snapshots__/tut-runner.test.ts.snap b/website/src/lib/__snapshots__/tut-runner.test.ts.snap index d0e9ffc..0312cab 100644 --- a/website/src/lib/__snapshots__/tut-runner.test.ts.snap +++ b/website/src/lib/__snapshots__/tut-runner.test.ts.snap @@ -85,7 +85,7 @@ exports[`TutRunner snapshots > renders the top-level menu 1`] = ` Alert and TODO [0/6 complete] Copy paste [0/4 complete] Starred on GitHub [not yet] - 🐭 ??? 🐭 [LOCKED 0/17] + 🐭 FlappyTerm 🐭 [LOCKED 0/17] Reset progress diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 5dc318e..6ff2915 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -120,6 +120,7 @@ describe("TutRunner snapshots", () => { expect(lastFrame()).toContain("Dormouse Pocket Tutorial"); expect(lastFrame()).toContain("Gesture navigation"); expect(lastFrame()).toContain("Copy paste"); + expect(lastFrame()).toContain("🐭 FlappyTerm 🐭"); expect(lastFrame()).not.toContain("Keyboard navigation"); expect(lastFrame()).not.toContain("Alert and TODO"); expect(lastFrame()).toContain("[LOCKED 0/7]"); @@ -209,7 +210,8 @@ describe("TutRunner snapshots", () => { sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\r"); - expect(lastFrame()).toContain("🐭 ??? 🐭"); + expect(lastFrame()).toContain("🐭 FlappyTerm 🐭"); + expect(lastFrame()).not.toContain("???"); expect(lastFrame()).toContain("[LOCKED 0/17]"); expect(lastFrame()).toContain("Dormouse Playground Tutorial"); dispose(); @@ -222,7 +224,7 @@ describe("TutRunner snapshots", () => { // Navigate to (but don't enter) the Flappy Term row. sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B"); - expect(lastFrame()).toContain("🐭 Flappy Term 🐭"); + expect(lastFrame()).toContain("🐭 FlappyTerm 🐭"); expect(lastFrame()).toContain("[High score: 7]"); dispose(); }); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 1565da3..3a9fd4c 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -58,8 +58,7 @@ const SPINNER_INTERVAL_MS = 100; */ const ACTIVE_ITEM_GLYPH = "●"; const STAR_PROMPT_TITLE = "Starred on GitHub"; -const FLAPPY_LOCKED_TITLE = "🐭 ??? 🐭"; -const FLAPPY_UNLOCKED_TITLE = "🐭 Flappy Term 🐭"; +const FLAPPY_TITLE = "🐭 FlappyTerm 🐭"; // --- Flappy Term game constants (ported from flappy-term.html) --- const FLAPPY_TICK_MS = 60; @@ -617,11 +616,10 @@ export class TutRunner implements InteractiveProgram { const flappyIndex = this.flappyIndex(); const flappyUnlocked = total.done === total.total; const flappyMarker = this.menuIndex === flappyIndex ? `${fg(36)}❯${RESET}` : " "; - const flappyTitle = flappyUnlocked ? FLAPPY_UNLOCKED_TITLE : FLAPPY_LOCKED_TITLE; const flappyLabel = this.menuIndex === flappyIndex - ? `${BOLD}${flappyTitle}${RESET}` - : flappyTitle; + ? `${BOLD}${FLAPPY_TITLE}${RESET}` + : FLAPPY_TITLE; const flappyStatus = flappyUnlocked ? ` ${fg(32)}[High score: ${this.state.getFlappyHighScore()}]${RESET}` : ` ${DIM}[LOCKED ${total.done}/${total.total}]${RESET}`; From 3c9be14a80635b064f22f81cb0968fabcbc6d310 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 26 May 2026 21:05:13 -0700 Subject: [PATCH 10/14] Fix Pocket tutorial links --- docs/specs/tutorial.md | 13 +++-- .../components/PocketTerminalExperience.tsx | 17 +++--- website/src/lib/tut-runner.test.ts | 53 +++++++++++++++++++ website/src/lib/tut-runner.ts | 25 ++++++--- 4 files changed, 87 insertions(+), 21 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 62b5049..74cb503 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -46,7 +46,7 @@ The Pocket page attaches `TutDetector` with the shared activity and mouse-select The desktop runner shows a top-level menu first. The Pocket runner starts directly inside `Gesture navigation`; pressing Esc returns to its top-level menu. Selecting a section drills into its item list. Each section shows `[N/M complete]` next to its title. The menu helper below the profile title shows only navigation shortcuts, not overall completion. -The top-level menu also includes `Starred on GitHub`, which sits directly below `Copy paste` without a blank spacer, and shows `[not yet]` until selected and `[thanks ⭐]` after it has been resolved. Pressing Enter on that row calls `onOpenGithub`, which `/playground/desktop` and the Pocket playground wire to `window.open("https://github.com/diffplug/dormouse", "_blank", "noopener,noreferrer")`. +The top-level menu also includes `Starred on GitHub`, which sits directly below `Copy paste` without a blank spacer, and shows `[not yet]` until selected and `[thanks ⭐]` after it has been resolved. Pressing Enter on that row calls `onOpenGithub`; `/playground/desktop` wires it to `window.open("https://github.com/diffplug/dormouse", "_blank", "noopener,noreferrer")`, while the Pocket playground uses `window.location.assign("https://github.com/diffplug/dormouse")` so mobile browsers do not block the navigation as a popup. After `Starred on GitHub`, the top-level menu shows `🐭 FlappyTerm 🐭`. It shows `[LOCKED N/M]` while any section task is incomplete. `N/M` is @@ -55,10 +55,13 @@ row do not count. When all section tasks are complete, the row shows a `[High score: N]` readout. Pressing Enter on the unlocked row opens Flappy Term, a runner-local mini-game: `Space`/`Up`/`Enter` flaps the bird, scoring persists as the high score, and `Esc` returns to the top-level menu. On the -game-over screen, `Enter` restarts and `p` calls `onOpenPocket`, which -`/playground/desktop` and the Pocket playground wire to -`window.open("/playground/pocket", "_blank", "noopener,noreferrer")`. The -game-over prompt reads `Read about Dormouse Pocket [p]`. +desktop game-over screen, `Enter` restarts and `p` calls `onOpenPocket`, which +`/playground/desktop` wires to +`window.open("/playground/pocket", "_blank", "noopener,noreferrer")`; the prompt +reads `Read about Dormouse Pocket [p]`. On the Pocket game-over screen, +`Enter` restarts and `n` calls `onNotifyPocket`, which the Pocket playground +wires to `window.location.assign("https://nedshed.dev/about")`; +the prompt reads `Notify me when Pocket ships [n]`. Inside a section, items render as one of: diff --git a/website/src/components/PocketTerminalExperience.tsx b/website/src/components/PocketTerminalExperience.tsx index 4b9d92a..b7dfa76 100644 --- a/website/src/components/PocketTerminalExperience.tsx +++ b/website/src/components/PocketTerminalExperience.tsx @@ -13,7 +13,6 @@ import { TutDetector } from "../lib/tut-detector"; import { TutRunner } from "../lib/tut-runner"; import { POCKET_TUTORIAL_PROFILE, type ItemId } from "../lib/tut-items"; import { ChangelogRunner } from "../lib/changelog-runner"; -import { POCKET_PLAYGROUND_PATH } from "../lib/playground-routing"; export const POCKET_THEME_ID = "vscode.theme-kimbie-dark.kimbie-dark"; @@ -37,6 +36,8 @@ const GESTURE_ARROW_INPUTS = new Set([ "left", "right", ]); +const GITHUB_URL = "https://github.com/diffplug/dormouse"; +const POCKET_NOTIFY_URL = "https://nedshed.dev/about"; const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; @@ -85,15 +86,11 @@ export function PocketTerminalExperience({ && activeMouseState.mouseReporting !== "none"; const handleOpenGithub = useCallback(() => { - window.open( - "https://github.com/diffplug/dormouse", - "_blank", - "noopener,noreferrer", - ); + window.location.assign(GITHUB_URL); }, []); - const handleOpenPocket = useCallback(() => { - window.open(POCKET_PLAYGROUND_PATH, "_blank", "noopener,noreferrer"); + const handleNotifyPocket = useCallback(() => { + window.location.assign(POCKET_NOTIFY_URL); }, []); const markPocketItemComplete = useCallback((id: ItemId) => { @@ -188,7 +185,7 @@ export function PocketTerminalExperience({ profile: POCKET_TUTORIAL_PROFILE, onExit, onOpenGithub: handleOpenGithub, - onOpenPocket: handleOpenPocket, + onNotifyPocket: handleNotifyPocket, getPocketTouchMode, subscribeToPocketTouchMode, }); @@ -237,7 +234,7 @@ export function PocketTerminalExperience({ touchModeListenersRef.current.clear(); adapterRef.current = null; }; - }, [getPocketTouchMode, handleOpenGithub, handleOpenPocket, subscribeToPocketTouchMode, tryAutoStart]); + }, [getPocketTouchMode, handleNotifyPocket, handleOpenGithub, subscribeToPocketTouchMode, tryAutoStart]); useEffect(() => { const reporting = activeMouseState?.mouseReporting ?? "none"; diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 6ff2915..5726ab9 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -15,6 +15,8 @@ function mountRunner( completedIds: ItemId[] = [], options: { onOpenGithub?: () => void; + onOpenPocket?: () => void; + onNotifyPocket?: () => void; pocketTouchMode?: "gestures" | "selection" | "cursor"; profile?: TutorialProfile; } = {}, @@ -43,6 +45,8 @@ function mountRunner( }, onTogglePlaceToPaste: profile?.id === "pocket" ? undefined : () => {}, onOpenGithub: options.onOpenGithub, + onOpenPocket: options.onOpenPocket, + onNotifyPocket: options.onNotifyPocket, getPocketTouchMode: () => pocketTouchMode, subscribeToPocketTouchMode: (listener) => { pocketTouchModeListeners.add(listener); @@ -243,4 +247,53 @@ describe("TutRunner snapshots", () => { expect(lastFrame()).toContain("Dormouse Playground Tutorial"); dispose(); }); + + it("keeps the desktop Flappy game-over prompt on p", () => { + vi.useFakeTimers(); + const allItemIds = SECTIONS.flatMap((section) => section.items.map((i) => i.id)); + const onOpenPocket = vi.fn(); + const { sendKeys, lastFrame, dispose } = mountRunner(allItemIds, { onOpenPocket }); + + try { + sendKeys("\x1b[B\x1b[B\x1b[B\x1b[B\r "); + vi.advanceTimersByTime(3000); + + expect(lastFrame()).toContain("GAME OVER"); + expect(lastFrame()).toContain("Read about Dormouse Pocket [p]"); + expect(lastFrame()).not.toContain("Notify me when Pocket ships"); + + sendKeys("p"); + expect(onOpenPocket).toHaveBeenCalledTimes(1); + } finally { + dispose(); + vi.useRealTimers(); + } + }); + + it("uses the Pocket Flappy game-over prompt and opens notify on n", () => { + vi.useFakeTimers(); + const allPocketItemIds = POCKET_TUTORIAL_PROFILE.sections.flatMap((section) => ( + section.items.map((i) => i.id) + )); + const onNotifyPocket = vi.fn(); + const { sendKeys, lastFrame, dispose } = mountRunner(allPocketItemIds, { + profile: POCKET_TUTORIAL_PROFILE, + onNotifyPocket, + }); + + try { + sendKeys("\x1b\x1b[B\x1b[B\x1b[B\r "); + vi.advanceTimersByTime(3000); + + expect(lastFrame()).toContain("GAME OVER"); + expect(lastFrame()).toContain("Notify me when Pocket ships [n]"); + expect(lastFrame()).not.toContain("Read about Dormouse Pocket"); + + sendKeys("n"); + expect(onNotifyPocket).toHaveBeenCalledTimes(1); + } finally { + dispose(); + vi.useRealTimers(); + } + }); }); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 3a9fd4c..2e1aaf7 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -59,6 +59,8 @@ const SPINNER_INTERVAL_MS = 100; const ACTIVE_ITEM_GLYPH = "●"; const STAR_PROMPT_TITLE = "Starred on GitHub"; const FLAPPY_TITLE = "🐭 FlappyTerm 🐭"; +const FLAPPY_DESKTOP_GAME_OVER_PROMPT = "Read about Dormouse Pocket [p]"; +const FLAPPY_POCKET_GAME_OVER_PROMPT = "Notify me when Pocket ships [n]"; // --- Flappy Term game constants (ported from flappy-term.html) --- const FLAPPY_TICK_MS = 60; @@ -120,6 +122,8 @@ interface TutRunnerOptions { onOpenGithub?: () => void; /** Called when the user presses `p` on the Flappy Term game-over screen. */ onOpenPocket?: () => void; + /** Called when the user presses `n` on Pocket's Flappy Term game-over screen. */ + onNotifyPocket?: () => void; /** Current Pocket touch mode, used for live non-progress tutorial prompts. */ getPocketTouchMode?: () => PocketTutorialTouchMode; subscribeToPocketTouchMode?: (listener: () => void) => () => void; @@ -140,6 +144,7 @@ export class TutRunner implements InteractiveProgram { private onTogglePlaceToPaste?: () => void; private onOpenGithub?: () => void; private onOpenPocket?: () => void; + private onNotifyPocket?: () => void; private getPocketTouchMode?: () => PocketTutorialTouchMode; private subscribeToPocketTouchMode?: (listener: () => void) => () => void; @@ -168,6 +173,7 @@ export class TutRunner implements InteractiveProgram { this.onTogglePlaceToPaste = options.onTogglePlaceToPaste; this.onOpenGithub = options.onOpenGithub; this.onOpenPocket = options.onOpenPocket; + this.onNotifyPocket = options.onNotifyPocket; this.getPocketTouchMode = options.getPocketTouchMode; this.subscribeToPocketTouchMode = options.subscribeToPocketTouchMode; this.returnToInitialScreen(); @@ -362,12 +368,17 @@ export class TutRunner implements InteractiveProgram { if ( this.screen === "flappy" && this.flappy && - !this.flappy.alive && - (ch === "p" || ch === "P") + !this.flappy.alive ) { - this.onOpenPocket?.(); - i += 1; - continue; + if (this.profile.id === "pocket" && (ch === "n" || ch === "N")) { + this.onNotifyPocket?.(); + i += 1; + continue; + } else if (this.profile.id === "desktop" && (ch === "p" || ch === "P")) { + this.onOpenPocket?.(); + i += 1; + continue; + } } if (ch === "q" || ch === "Q") { this.handleEscape(); @@ -746,7 +757,9 @@ export class TutRunner implements InteractiveProgram { out += this.flappyCenteredAt( COLS, r + 8, - "Read about Dormouse Pocket [p]", + this.profile.id === "pocket" + ? FLAPPY_POCKET_GAME_OVER_PROMPT + : FLAPPY_DESKTOP_GAME_OVER_PROMPT, fg256(C.text), ); } else if (!g.started) { From 766fb466e2d71e85b2b17cb887989d6950494c10 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 26 May 2026 21:09:18 -0700 Subject: [PATCH 11/14] Fix Pocket select prompt color --- docs/specs/tutorial.md | 2 +- website/src/lib/tut-runner.test.ts | 3 +++ website/src/lib/tut-runner.ts | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 74cb503..a97eb8b 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -128,7 +128,7 @@ Prose: The Copy Rewrapped step uses the wrapped item lines `ChangelogRunner` produces in the `tut-boxed` pane. The runner word-wraps each item to fit the pane width, so Rewrapped joins those lines back together while Raw preserves the wrap; clipboard contents visibly differ. On desktop, the user must override mouse capture first (the `cp-override` step) before drag-selecting inside the changelog pane, since the runner enables SGR mouse-reporting. -Pocket uses the same `cp-select`, `cp-raw`, and `cp-rewrap` checklist item ids, but removes `cp-override`. It renders a non-progress live prompt before the checklist: `Tap "Select" to enable drag-to-copy` while the current touch mode is Gestures or Mouse, and `Select is active — drag-to-copy is enabled` while the current touch mode is Select. This row is not stored, not checkmarked, and not counted in section or total completion. In Select mode, Pocket sets mouse override for the active mouse-capturing pane automatically, so the tutorial does not ask the user to click the cursor icon in `changelog`. +Pocket uses the same `cp-select`, `cp-raw`, and `cp-rewrap` checklist item ids, but removes `cp-override`. It renders a non-progress live prompt before the checklist: `Tap "Select" to enable drag-to-copy` while the current touch mode is Gestures or Mouse, and `Select is active — drag-to-copy is enabled` while the current touch mode is Select. The live prompt uses the yellow active marker while Select is inactive and the green complete marker color while Select is active, but it is not stored, not checkmarked, and not counted in section or total completion. In Select mode, Pocket sets mouse override for the active mouse-capturing pane automatically, so the tutorial does not ask the user to click the cursor icon in `changelog`. While the Copy paste section is open, pressing `p` toggles the **Place To Paste** modal — a draggable scratch box with eight pointer-event resize handles (four edges + four corners), rendered by `website/src/components/PlaceToPaste.tsx` and mounted at the page level. `TutRunner` intercepts `p`/`P` (mirroring the Alert section's `s` busy-demo intercept) and calls `onTogglePlaceToPaste`; `Playground` flips a `placeToPasteOpen` flag so the modal is portal-free and overlays the wall. The runner renders a persistent `Press \`p\` to toggle the Place To Paste …` line above the section's prose paragraph so the prompt is visible regardless of which item is active. Users paste copied text into the modal's single textarea and resize it to see whether the text reflows (Rewrapped) or stays line-broken (Raw). diff --git a/website/src/lib/tut-runner.test.ts b/website/src/lib/tut-runner.test.ts index 5726ab9..bc651eb 100644 --- a/website/src/lib/tut-runner.test.ts +++ b/website/src/lib/tut-runner.test.ts @@ -140,10 +140,13 @@ describe("TutRunner snapshots", () => { expect(lastFrame()).toContain("Copy paste"); expect(lastFrame()).toContain("0/3 complete"); expect(lastFrame()).toContain('Tap "Select" to enable drag-to-copy'); + expect(lastFrame()).toContain("\x1b[33m●"); expect(lastFrame()).not.toContain("Click the cursor icon"); setPocketTouchMode("selection"); expect(lastFrame()).toContain("Select is active"); + expect(lastFrame()).toContain("\x1b[32m●"); + expect(lastFrame()).not.toContain("\x1b[36m●"); expect(lastFrame()).not.toContain("✓"); dispose(); }); diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index 2e1aaf7..ca7b28e 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -855,7 +855,7 @@ export class TutRunner implements InteractiveProgram { const mode = this.getPocketTouchMode?.() ?? "gestures"; if (mode === "selection") { return [ - ` ${fg(36)}${ACTIVE_ITEM_GLYPH}${RESET} ${BOLD}Select is active — drag-to-copy is enabled${RESET}`, + ` ${fg(32)}${ACTIVE_ITEM_GLYPH}${RESET} ${BOLD}Select is active — drag-to-copy is enabled${RESET}`, ` ${ITALIC}Tap \`Gestures\` when you want arrow, Enter, and Esc gestures.${RESET}`, ]; } From 8af1d55450d83a7d21b24fe6fe11c437ed55b1eb Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 26 May 2026 21:36:39 -0700 Subject: [PATCH 12/14] Fix stale touchMode in Pocket pointer handlers The pointer move/up capture handlers in MobileTerminalUi read `touchMode` but omitted it from their useCallback deps, while their remaining deps (commitGestureState, executeGestureAction, scheduleGestureCompletionClear) stay stable across a mode switch. After switching into Mouse (cursor) mode at runtime the handlers kept the prior mode, so pointermove/pointerup were never synthesized into mouse events: the cursor drag never moved, no mouseup fired, and the pointer capture was never released. The down handler already listed touchMode; add it to move/up to match. Add a regression test that switches Gestures->Mouse after first render and asserts the full mousedown/mousemove/mouseup sequence still dispatches. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/MobileTerminalUi.test.tsx | 51 +++++++++++++++----- lib/src/components/MobileTerminalUi.tsx | 4 +- 2 files changed, 42 insertions(+), 13 deletions(-) diff --git a/lib/src/components/MobileTerminalUi.test.tsx b/lib/src/components/MobileTerminalUi.test.tsx index 844e00a..11cf404 100644 --- a/lib/src/components/MobileTerminalUi.test.tsx +++ b/lib/src/components/MobileTerminalUi.test.tsx @@ -46,21 +46,25 @@ function renderMobileTerminal({ }: { touchMode: MobileTerminalTouchMode; onMouseEvent: (event: MouseEvent) => void; -}): { terminal: HTMLDivElement } { +}): { terminal: HTMLDivElement; setTouchMode: (mode: MobileTerminalTouchMode) => void } { const container = document.createElement('div'); document.body.appendChild(container); const root = createRoot(container); roots.push(root); - act(() => { - root.render( - } - />, - ); - }); + const renderWith = (mode: MobileTerminalTouchMode) => { + act(() => { + root.render( + } + />, + ); + }); + }; + + renderWith(touchMode); const terminal = container.querySelector('[data-testid="terminal"]'); if (!terminal) throw new Error('missing terminal test node'); @@ -68,7 +72,7 @@ function renderMobileTerminal({ terminal.addEventListener('mousemove', onMouseEvent); terminal.addEventListener('mouseup', onMouseEvent); - return { terminal }; + return { terminal, setTouchMode: renderWith }; } let roots: Root[] = []; @@ -140,6 +144,31 @@ describe('MobileTerminalUi touch modes', () => { ]); }); + it('keeps synthesizing the full mouse sequence after switching into Mouse mode at runtime', () => { + const received: string[] = []; + const { terminal, setTouchMode } = renderMobileTerminal({ + touchMode: 'gestures', + onMouseEvent: (event) => { + received.push(`${event.type}:${event.button}:${event.buttons}:${event.clientX}:${event.clientY}`); + }, + }); + mockElementFromPoint(terminal); + + // User switches Gestures -> Mouse after the handlers were first created. + setTouchMode('cursor'); + + terminal.dispatchEvent(pointerEvent('pointerdown')); + terminal.dispatchEvent(pointerEvent('pointermove', { clientX: 18, clientY: 20 })); + terminal.dispatchEvent(pointerEvent('pointerup', { clientX: 18, clientY: 20 })); + + expect(received).toEqual([ + 'mousedown:0:1:10:12', + 'mousemove:0:1:18:20', + 'mouseup:0:0:18:20', + ]); + expect(releasePointerCapture).toHaveBeenCalledWith(7); + }); + it('sends a mouse release when a Mouse mode touch is cancelled', () => { const received: string[] = []; const { terminal } = renderMobileTerminal({ diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index 653a0a2..2e1fbc4 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -694,7 +694,7 @@ export function MobileTerminalUi({ return; } commitGestureState(nextState); - }, [commitGestureState, executeGestureAction, scheduleGestureCompletionClear]); + }, [commitGestureState, executeGestureAction, scheduleGestureCompletionClear, touchMode]); const handlePanePointerUpCapture = useCallback((event: PointerEvent) => { if (touchMode === 'cursor' && cursorPointerIdRef.current === event.pointerId && isTouchLikePrimaryPointer(event)) { @@ -734,7 +734,7 @@ export function MobileTerminalUi({ commitGestureState(completionState ?? result.state); executeGestureAction(result.action); if (completionState) scheduleGestureCompletionClear(); - }, [commitGestureState, executeGestureAction, scheduleGestureCompletionClear]); + }, [commitGestureState, executeGestureAction, scheduleGestureCompletionClear, touchMode]); const handlePaneFocusStartCapture = useCallback(() => { blurPaneTextInputs(); From a16054ca939bac6b348125ff5a12990d24a1dcf6 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 26 May 2026 21:47:17 -0700 Subject: [PATCH 13/14] Apply Pocket mouse override globally across panes Touch mode is a single global UI state, but the override effect only configured the active pane, so switching panes mid-Select left the pane you left stuck in a "permanent" override. Derive each pane's override purely from the global touch mode and that pane's own mouse-reporting state, configuring every session, so override no longer depends on which pane is active. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/components/PocketTerminalExperience.tsx | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/website/src/components/PocketTerminalExperience.tsx b/website/src/components/PocketTerminalExperience.tsx index b7dfa76..475eb82 100644 --- a/website/src/components/PocketTerminalExperience.tsx +++ b/website/src/components/PocketTerminalExperience.tsx @@ -236,14 +236,18 @@ export function PocketTerminalExperience({ }; }, [getPocketTouchMode, handleNotifyPocket, handleOpenGithub, subscribeToPocketTouchMode, tryAutoStart]); + // Touch mode is a single global UI state, so each pane's mouse override is a + // pure function of (touch mode) × (that pane's own reporting) — not of which + // pane happens to be active. Configuring every pane prevents a pane the user + // switched away from being left stuck in a stale override (e.g. a + // mouse-reporting pane left "permanent" after leaving Select mode). useEffect(() => { - const reporting = activeMouseState?.mouseReporting ?? "none"; - if (touchMode === "selection" && reporting !== "none") { - setMouseOverride(activePaneId, "permanent"); - } else { - setMouseOverride(activePaneId, "off"); + for (const session of POCKET_SESSIONS) { + const reporting = mouseStates.get(session.id)?.mouseReporting ?? "none"; + const override = touchMode === "selection" && reporting !== "none" ? "permanent" : "off"; + setMouseOverride(session.id, override); } - }, [activeMouseState?.mouseReporting, activePaneId, touchMode]); + }, [mouseStates, touchMode]); return ( Date: Wed, 27 May 2026 09:18:29 -0700 Subject: [PATCH 14/14] Document Pocket select override behavior --- docs/specs/mobile-ui.md | 7 ++++++- docs/specs/tutorial.md | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 6434e64..00a862b 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -108,11 +108,16 @@ Touch modes: | Mode | Button label | Icon | Availability | Behavior | | --- | --- | --- | --- | --- | | Gestures | `Gestures` | `HandPointingIcon` | Always available | Pane-content touches, pen presses, and primary mouse/trackpad clicks open the Gesture mode radial menu. | -| Text selection | `Select` | `CursorTextIcon` | Always available | Pane-content touch, pen, and primary mouse/trackpad drags use the same terminal text selection and copy/paste behavior as desktop. If the TUI is capturing mouse events, Dormouse activates mouse override for the active pane. | +| Text selection | `Select` | `CursorTextIcon` | Always available | Pane-content touch, pen, and primary mouse/trackpad drags use the same terminal text selection and copy/paste behavior as desktop. If a mounted pane's TUI is capturing mouse events, Dormouse activates mouse override for that pane. | | Mouse | `Mouse` | `CursorClickIcon` | Only when the active TUI is capturing mouse events | Touches are passed through as terminal mouse input. | Default touch mode is **Gestures**. +Touch mode is a global mobile UI state. Select mode derives each mounted pane's +mouse override from that global touch mode and the pane's own mouse-reporting +state, so switching sessions cannot leave an inactive pane stuck in a stale +override. + If Mouse mode is active and the active pane stops capturing mouse events, the selector must fall back to Gestures. diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index a97eb8b..4e16873 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -128,7 +128,7 @@ Prose: The Copy Rewrapped step uses the wrapped item lines `ChangelogRunner` produces in the `tut-boxed` pane. The runner word-wraps each item to fit the pane width, so Rewrapped joins those lines back together while Raw preserves the wrap; clipboard contents visibly differ. On desktop, the user must override mouse capture first (the `cp-override` step) before drag-selecting inside the changelog pane, since the runner enables SGR mouse-reporting. -Pocket uses the same `cp-select`, `cp-raw`, and `cp-rewrap` checklist item ids, but removes `cp-override`. It renders a non-progress live prompt before the checklist: `Tap "Select" to enable drag-to-copy` while the current touch mode is Gestures or Mouse, and `Select is active — drag-to-copy is enabled` while the current touch mode is Select. The live prompt uses the yellow active marker while Select is inactive and the green complete marker color while Select is active, but it is not stored, not checkmarked, and not counted in section or total completion. In Select mode, Pocket sets mouse override for the active mouse-capturing pane automatically, so the tutorial does not ask the user to click the cursor icon in `changelog`. +Pocket uses the same `cp-select`, `cp-raw`, and `cp-rewrap` checklist item ids, but removes `cp-override`. It renders a non-progress live prompt before the checklist: `Tap "Select" to enable drag-to-copy` while the current touch mode is Gestures or Mouse, and `Select is active — drag-to-copy is enabled` while the current touch mode is Select. The live prompt uses the yellow active marker while Select is inactive and the green complete marker color while Select is active, but it is not stored, not checkmarked, and not counted in section or total completion. In Select mode, Pocket sets mouse override automatically for every mounted pane whose TUI is currently capturing mouse events, so the tutorial does not ask the user to click the cursor icon in `changelog`. While the Copy paste section is open, pressing `p` toggles the **Place To Paste** modal — a draggable scratch box with eight pointer-event resize handles (four edges + four corners), rendered by `website/src/components/PlaceToPaste.tsx` and mounted at the page level. `TutRunner` intercepts `p`/`P` (mirroring the Alert section's `s` busy-demo intercept) and calls `onTogglePlaceToPaste`; `Playground` flips a `placeToPasteOpen` flag so the modal is portal-free and overlays the wall. The runner renders a persistent `Press \`p\` to toggle the Place To Paste …` line above the section's prose paragraph so the prompt is visible regardless of which item is active. Users paste copied text into the modal's single textarea and resize it to see whether the text reflows (Rewrapped) or stays line-broken (Raw).