From 169fb417be0e861d87b180f5952f47565b066213 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 12:44:53 -0700 Subject: [PATCH 01/12] Show Playground nav link on mobile Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/components/SiteHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/src/components/SiteHeader.tsx b/website/src/components/SiteHeader.tsx index 80945a73..d4aaea5e 100644 --- a/website/src/components/SiteHeader.tsx +++ b/website/src/components/SiteHeader.tsx @@ -6,7 +6,7 @@ export const STATIC_PAGE_HEADER_STYLE: React.CSSProperties = { }; const NAV_LINKS: readonly { href: string; label: string; external?: boolean; hideOnMobile?: boolean }[] = [ - { href: "/playground", label: "Playground", hideOnMobile: true }, + { href: "/playground", label: "Playground" }, { href: "/#download", label: "Download", hideOnMobile: true }, { href: "https://github.com/diffplug/dormouse", label: "GitHub", external: true }, ]; From ee1293be222cb3f2365eb341e942c3a41c7f92ea Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 13:22:52 -0700 Subject: [PATCH 02/12] Place selected-group gesture options on the circle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The options phase laid out option chips with squareDirectionVector, whose diagonal magnitude is √2, flinging diagonal sub-options to square corners while cardinals sat on the circle. Use the normalized direction vectors so every option lands on the circle at the given radius. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/MobileGestureRadialMenu.tsx | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index d7c8dce7..413fe509 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -34,11 +34,6 @@ const SELECT_TICK_INSET = 5; const SELECT_TICK_OUTSET = 6; const ROOT_DIAGONAL_CORNER_RADIUS = RADIUS_SELECT + SELECT_TICK_OUTSET + GAP_CARDINAL_RING * Math.SQRT1_2; -function squareDirectionVector(direction: MobileGestureDirection): MobileGesturePoint { - const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; - return { x: Math.sign(vector.x), y: Math.sign(vector.y) }; -} - const ROOT_CARDINAL_ANCHORS: Partial> = { n: { x: ROOT_LABEL_CENTER_X, y: -ROOT_CARDINAL_Y }, e: { x: ROOT_CARDINAL_X, y: ROOT_LABEL_SIDE_CENTER_Y }, @@ -188,7 +183,7 @@ function directionPoint( center: { x: number; y: number }, radius: number, ): { x: number; y: number } { - const vector = squareDirectionVector(direction); + const vector = MOBILE_GESTURE_DIRECTION_VECTORS[direction]; return { x: center.x + vector.x * radius, y: center.y + vector.y * radius, From e3633e907e350118d7bf40aabd3e3b8dbcf92bfc Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 13:43:50 -0700 Subject: [PATCH 03/12] Track gesture submenu origin through overshoot When a drag crossed the select radius into a submenu, the reference origin stayed pinned at RADIUS_SELECT from the start. Continuing to drag in the opening direction (the natural overshoot) meant the user had to drag all the way back through it before a move in any other direction could register. Add advanceOptionOrigin, a ratchet that slides the option origin outward to meet the finger while the drag continues in the selection direction, so a reversal immediately counts toward the intended option. Applied to both the options and quit submenu phases. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/mobile-gesture-menu.test.ts | 20 ++++++++++ lib/src/lib/mobile-gesture-menu.ts | 50 +++++++++++++++++++++++-- 2 files changed, 66 insertions(+), 4 deletions(-) diff --git a/lib/src/lib/mobile-gesture-menu.test.ts b/lib/src/lib/mobile-gesture-menu.test.ts index 564bc400..122f29b5 100644 --- a/lib/src/lib/mobile-gesture-menu.test.ts +++ b/lib/src/lib/mobile-gesture-menu.test.ts @@ -181,6 +181,26 @@ describe('mobile gesture menu state machine', () => { expect(updateMobileGesture(complete, optionSelectionPoint('se', 2))).toBe(complete); }); + it('tracks the reference origin while the drag overshoots the opening direction', () => { + let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('se')); + state = updateMobileGesture(state, pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3)); + expect(state.phase).toBe('options'); + if (state.phase !== 'options') return; + // The reference origin slid out to meet the overshooting finger. + expect(state.optionOrigin).toEqual(pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3)); + }); + + it('selects the back option after an overshoot without undoing the whole overshoot', () => { + // Drive past the southeast selection, keep dragging southeast, then reverse just + // far enough to break out toward the back ("Enter") option. + const overshoot = pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3); + const backOption = pointInDirection(overshoot, 'nw', RADIUS_SELECT + 1); + expect(runGesture([rootSelectionPoint('se'), overshoot, backOption])).toEqual({ + kind: 'input', + input: 'enter', + }); + }); + it('clears the option highlight when the drag moves back inside the highlight radius', () => { let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('se')); state = updateMobileGesture(state, pointInDirection(optionOrigin('se'), 'n', RADIUS_HIGHLIGHT + 1)); diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index 4c5da900..448772d5 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -289,6 +289,32 @@ export function translatedPoint( }; } +// As the user keeps dragging past a selection in the direction that opened the +// submenu, slide the reference origin out to track that overshoot. Otherwise the +// user would have to drag all the way back through the overshoot before a move in +// any other direction could register. Only advances outward (a ratchet), so as soon +// as the drag reverses the pulled-back distance counts toward the intended option. +function advanceOptionOrigin( + selectionDirection: MobileGestureDirection, + optionOrigin: MobileGesturePoint, + displayOptionOrigin: MobileGesturePoint, + point: MobileGesturePoint, +): { optionOrigin: MobileGesturePoint; displayOptionOrigin: MobileGesturePoint } { + const direction = MOBILE_GESTURE_DIRECTION_VECTORS[selectionDirection]; + const overshoot = (point.x - optionOrigin.x) * direction.x + (point.y - optionOrigin.y) * direction.y; + if (overshoot <= 0) return { optionOrigin, displayOptionOrigin }; + return { + optionOrigin: { + x: optionOrigin.x + direction.x * overshoot, + y: optionOrigin.y + direction.y * overshoot, + }, + displayOptionOrigin: { + x: displayOptionOrigin.x + direction.x * overshoot, + y: displayOptionOrigin.y + direction.y * overshoot, + }, + }; +} + function optionIndexForDirection( groupDirection: MobileGestureDirection, direction: MobileGestureDirection | null, @@ -413,15 +439,21 @@ export function updateMobileGesture( if (state.phase === 'options') { const group = MOBILE_GESTURE_GROUPS[state.selectedDirection]; if (group.options.length !== 3) return state; + const { optionOrigin, displayOptionOrigin } = advanceOptionOrigin( + state.selectedDirection, + state.optionOrigin, + state.displayOptionOrigin, + point, + ); const optionState = candidateForOptions( 'options', state.selectedDirection, group.options, - state.optionOrigin, + optionOrigin, point, ); if (optionState.candidate?.option.action.kind === 'quitMenu') { - const quitOrigin = pointOnRadius(state.optionOrigin, point, RADIUS_SELECT); + const quitOrigin = pointOnRadius(optionOrigin, point, RADIUS_SELECT); return { phase: 'quit', pointerId: state.pointerId, @@ -431,27 +463,37 @@ export function updateMobileGesture( parentDirection: state.selectedDirection, baseDirection: optionState.candidate.direction, optionOrigin: quitOrigin, - displayOptionOrigin: translatedPoint(state.displayOptionOrigin, state.optionOrigin, quitOrigin), + displayOptionOrigin: translatedPoint(displayOptionOrigin, optionOrigin, quitOrigin), }; } return { ...state, currentPoint: point, + optionOrigin, + displayOptionOrigin, highlightedOptionIndex: optionState.highlightedOptionIndex, candidate: optionState.candidate, }; } + const { optionOrigin, displayOptionOrigin } = advanceOptionOrigin( + state.baseDirection, + state.optionOrigin, + state.displayOptionOrigin, + point, + ); const optionState = candidateForOptions( 'quit', state.baseDirection, MOBILE_GESTURE_QUIT_GROUP.options, - state.optionOrigin, + optionOrigin, point, ); return { ...state, currentPoint: point, + optionOrigin, + displayOptionOrigin, highlightedOptionIndex: optionState.highlightedOptionIndex, candidate: optionState.candidate, }; From 4351769464cd0691a09dd5c6d9ba84ee154346f7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 14:40:39 -0700 Subject: [PATCH 04/12] Animate compass circle as a latched state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compass circle now tracks the gesture: it spawns expanded, collapses to the selection point when the first item is chosen, stays collapsed while the drag keeps overshooting in the opening direction, then latches back to full size as soon as the drag stops or turns toward an option — staying expanded until the final selection collapses it to the chosen item. Add an `expanded` latch to the options/quit states (set where overshoot is already detected) and drive the circle's scale/opacity/origin from phase in the component. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/MobileGestureRadialMenu.tsx | 43 ++++++++++++++++++- lib/src/lib/mobile-gesture-menu.test.ts | 20 +++++++++ lib/src/lib/mobile-gesture-menu.ts | 17 ++++++-- lib/src/stories/MobileTerminalUi.stories.tsx | 4 ++ 4 files changed, 78 insertions(+), 6 deletions(-) diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index 413fe509..d0f7bb9d 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -33,6 +33,8 @@ const COMPLETE_SCALE = 2.4; const SELECT_TICK_INSET = 5; const SELECT_TICK_OUTSET = 6; const ROOT_DIAGONAL_CORNER_RADIUS = RADIUS_SELECT + SELECT_TICK_OUTSET + GAP_CARDINAL_RING * Math.SQRT1_2; +const MIN_CIRCLE_SCALE = 0.3; +const CIRCLE_TWEEN = 'cubic-bezier(0.22, 1, 0.36, 1)'; const ROOT_CARDINAL_ANCHORS: Partial> = { n: { x: ROOT_LABEL_CENTER_X, y: -ROOT_CARDINAL_Y }, @@ -285,6 +287,33 @@ function rootOptionLayout( }; } +function circleTransform( + state: ActiveGestureState, + phaseDisplayOrigin: MobileGesturePoint, +): { scale: number; opacity: number; origin: MobileGesturePoint; durationMs: number } { + if (state.phase === 'complete') { + // Collapse toward the chosen item. + return { + scale: 0, + opacity: 0, + origin: directionPoint(state.candidate.direction, phaseDisplayOrigin, RADIUS_SELECT), + durationMs: 200, + }; + } + if (state.phase === 'options' || state.phase === 'quit') { + // Collapsed while the drag keeps pushing in the opening direction (overshoot); once + // it stops or turns toward an option, `expanded` latches and the compass stays full + // size until the final selection. + return { + scale: state.expanded ? 1 : MIN_CIRCLE_SCALE, + opacity: 1, + origin: phaseDisplayOrigin, + durationMs: 150, + }; + } + return { scale: 1, opacity: 1, origin: phaseDisplayOrigin, durationMs: 150 }; +} + export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackingState }) { if (state.phase === 'idle') return null; @@ -292,6 +321,7 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin 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 circle = circleTransform(state, phaseDisplayOrigin); const rootDirection = activeRootDirection(state); const tickDirection = activeTickDirection(state); const selectTicks = SELECT_TICK_DIRECTIONS.map((direction) => { @@ -419,13 +449,22 @@ export function MobileGestureRadialMenu({ state }: { state: MobileGestureTrackin x2={currentDisplayPoint.x} y2={currentDisplayPoint.y} stroke="var(--color-focus-ring)" - strokeOpacity="1" + strokeOpacity={state.phase === 'complete' ? 0 : 1} strokeWidth="2" strokeLinecap="round" + style={{ transition: 'stroke-opacity 200ms ease-out' }} /> { expect(state.optionOrigin).toEqual(pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3)); }); + it('keeps the compass collapsed through overshoot, then latches expanded for good', () => { + let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('se')); + expect(state.phase === 'options' && state.expanded).toBe(false); + + // Still dragging in the opening direction (overshoot) — stays collapsed. + state = updateMobileGesture(state, pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 2)); + expect(state.phase === 'options' && state.expanded).toBe(false); + state = updateMobileGesture(state, pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3)); + expect(state.phase === 'options' && state.expanded).toBe(false); + + // Turning back toward an option latches expanded. + const overshoot = pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3); + state = updateMobileGesture(state, pointInDirection(overshoot, 'nw', RADIUS_HIGHLIGHT)); + expect(state.phase === 'options' && state.expanded).toBe(true); + + // Pushing back out in the wrong direction does not collapse it again. + state = updateMobileGesture(state, pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 5)); + expect(state.phase === 'options' && state.expanded).toBe(true); + }); + it('selects the back option after an overshoot without undoing the whole overshoot', () => { // Drive past the southeast selection, keep dragging southeast, then reverse just // far enough to break out toward the back ("Enter") option. diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index 448772d5..0661e54b 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -88,6 +88,9 @@ export type MobileGestureTrackingState = selectedDirection: MobileGestureDirection; optionOrigin: MobileGesturePoint; displayOptionOrigin: MobileGesturePoint; + // Latches true once the drag stops pushing further in the opening direction, so + // the compass can expand and stay expanded until the final selection. + expanded: boolean; highlightedOptionIndex?: MobileGestureOptionIndex; candidate?: MobileGestureCandidate; } @@ -101,6 +104,7 @@ export type MobileGestureTrackingState = baseDirection: MobileGestureDirection; optionOrigin: MobileGesturePoint; displayOptionOrigin: MobileGesturePoint; + expanded: boolean; highlightedOptionIndex?: MobileGestureOptionIndex; candidate?: MobileGestureCandidate; } @@ -299,10 +303,10 @@ function advanceOptionOrigin( optionOrigin: MobileGesturePoint, displayOptionOrigin: MobileGesturePoint, point: MobileGesturePoint, -): { optionOrigin: MobileGesturePoint; displayOptionOrigin: MobileGesturePoint } { +): { optionOrigin: MobileGesturePoint; displayOptionOrigin: MobileGesturePoint; advancing: boolean } { const direction = MOBILE_GESTURE_DIRECTION_VECTORS[selectionDirection]; const overshoot = (point.x - optionOrigin.x) * direction.x + (point.y - optionOrigin.y) * direction.y; - if (overshoot <= 0) return { optionOrigin, displayOptionOrigin }; + if (overshoot <= 0) return { optionOrigin, displayOptionOrigin, advancing: false }; return { optionOrigin: { x: optionOrigin.x + direction.x * overshoot, @@ -312,6 +316,7 @@ function advanceOptionOrigin( x: displayOptionOrigin.x + direction.x * overshoot, y: displayOptionOrigin.y + direction.y * overshoot, }, + advancing: true, }; } @@ -426,6 +431,7 @@ export function updateMobileGesture( selectedDirection: closestDirection, optionOrigin, displayOptionOrigin: translatedPoint(state.displayOrigin, state.origin, optionOrigin), + expanded: false, }; } return { @@ -439,7 +445,7 @@ export function updateMobileGesture( if (state.phase === 'options') { const group = MOBILE_GESTURE_GROUPS[state.selectedDirection]; if (group.options.length !== 3) return state; - const { optionOrigin, displayOptionOrigin } = advanceOptionOrigin( + const { optionOrigin, displayOptionOrigin, advancing } = advanceOptionOrigin( state.selectedDirection, state.optionOrigin, state.displayOptionOrigin, @@ -464,6 +470,7 @@ export function updateMobileGesture( baseDirection: optionState.candidate.direction, optionOrigin: quitOrigin, displayOptionOrigin: translatedPoint(displayOptionOrigin, optionOrigin, quitOrigin), + expanded: false, }; } return { @@ -471,12 +478,13 @@ export function updateMobileGesture( currentPoint: point, optionOrigin, displayOptionOrigin, + expanded: state.expanded || !advancing, highlightedOptionIndex: optionState.highlightedOptionIndex, candidate: optionState.candidate, }; } - const { optionOrigin, displayOptionOrigin } = advanceOptionOrigin( + const { optionOrigin, displayOptionOrigin, advancing } = advanceOptionOrigin( state.baseDirection, state.optionOrigin, state.displayOptionOrigin, @@ -494,6 +502,7 @@ export function updateMobileGesture( currentPoint: point, optionOrigin, displayOptionOrigin, + expanded: state.expanded || !advancing, highlightedOptionIndex: optionState.highlightedOptionIndex, candidate: optionState.candidate, }; diff --git a/lib/src/stories/MobileTerminalUi.stories.tsx b/lib/src/stories/MobileTerminalUi.stories.tsx index 7f7e3f4d..255d8f79 100644 --- a/lib/src/stories/MobileTerminalUi.stories.tsx +++ b/lib/src/stories/MobileTerminalUi.stories.tsx @@ -386,6 +386,10 @@ export const GestureSoutheastTurnLeftY: Story = { render: () => , }; +export const GestureSoutheastOvershoot: Story = { + render: () => , +}; + export const GestureCtrlCConfirmation: Story = { render: () => ( Date: Wed, 27 May 2026 14:58:23 -0700 Subject: [PATCH 05/12] Expand compass as soon as the overshoot drag settles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expand latch only released when the drag fully stopped pushing outward or reversed, so settling jitter kept the compass collapsed until a deliberate move back — adding a second pause before it expanded. Treat an outward push smaller than OPTION_EXPAND_RELEASE as a settle (still ratcheting the origin), so the compass expands the moment the placards settle. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/mobile-gesture-menu.test.ts | 12 ++++++++++++ lib/src/lib/mobile-gesture-menu.ts | 9 ++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/lib/src/lib/mobile-gesture-menu.test.ts b/lib/src/lib/mobile-gesture-menu.test.ts index f19107bd..fba84c01 100644 --- a/lib/src/lib/mobile-gesture-menu.test.ts +++ b/lib/src/lib/mobile-gesture-menu.test.ts @@ -7,6 +7,7 @@ import { MOBILE_GESTURE_COMPLETE_MS, MOBILE_GESTURE_DIRECTION_VECTORS, MOBILE_GESTURE_OPTION_DIRECTIONS, + OPTION_EXPAND_RELEASE, RADIUS_HIGHLIGHT, RADIUS_LAYOUT, RADIUS_SELECT, @@ -210,6 +211,17 @@ describe('mobile gesture menu state machine', () => { expect(state.phase === 'options' && state.expanded).toBe(true); }); + it('expands once the overshoot drag settles, without a deliberate move back', () => { + let state = updateMobileGesture(beginMobileGesture(1, ORIGIN), rootSelectionPoint('se')); + // Brisk overshoot keeps it collapsed. + state = updateMobileGesture(state, pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3)); + expect(state.phase === 'options' && state.expanded).toBe(false); + // A tiny continued nudge in the same direction (a settle, not a hard push) expands it. + const overshoot = pointInDirection(ORIGIN, 'se', RADIUS_SELECT * 3); + state = updateMobileGesture(state, pointInDirection(overshoot, 'se', OPTION_EXPAND_RELEASE - 1)); + expect(state.phase === 'options' && state.expanded).toBe(true); + }); + it('selects the back option after an overshoot without undoing the whole overshoot', () => { // Drive past the southeast selection, keep dragging southeast, then reverse just // far enough to break out toward the back ("Enter") option. diff --git a/lib/src/lib/mobile-gesture-menu.ts b/lib/src/lib/mobile-gesture-menu.ts index 0661e54b..f9cdf36c 100644 --- a/lib/src/lib/mobile-gesture-menu.ts +++ b/lib/src/lib/mobile-gesture-menu.ts @@ -140,6 +140,9 @@ export const RADIUS_HIGHLIGHT = RADIUS_SELECT * 0.5; export const MOBILE_GESTURE_COMPLETE_MS = 220; export const MOBILE_GESTURE_DISPLAY_MARGIN = 168; export const MOBILE_GESTURE_THUMB_OFFSET = 132; +// Per-move outward distance below which the overshoot drag counts as settling rather +// than still pushing out — the point where the compass is allowed to expand. +export const OPTION_EXPAND_RELEASE = 2; export const MOBILE_GESTURE_DIRECTION_VECTORS: Record = { n: { x: 0, y: -1 }, @@ -307,7 +310,12 @@ function advanceOptionOrigin( const direction = MOBILE_GESTURE_DIRECTION_VECTORS[selectionDirection]; const overshoot = (point.x - optionOrigin.x) * direction.x + (point.y - optionOrigin.y) * direction.y; if (overshoot <= 0) return { optionOrigin, displayOptionOrigin, advancing: false }; + // Still ratchet the origin out to track the finger, but only call it "advancing" + // (which keeps the compass collapsed) while the outward push is brisk. Once the drag + // slows to a settle, advancing drops so the compass can expand without waiting for a + // deliberate move back in the chosen direction. return { + advancing: overshoot > OPTION_EXPAND_RELEASE, optionOrigin: { x: optionOrigin.x + direction.x * overshoot, y: optionOrigin.y + direction.y * overshoot, @@ -316,7 +324,6 @@ function advanceOptionOrigin( x: displayOptionOrigin.x + direction.x * overshoot, y: displayOptionOrigin.y + direction.y * overshoot, }, - advancing: true, }; } From 4d8bfcde738fbda83b8cb6a4f5c2ab8a834b6ee7 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 15:35:55 -0700 Subject: [PATCH 06/12] Adapt text selection UI for the mobile (touch) terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a TouchUiContext that MobileTerminalUi provides, so the shared selection components can tell they are rendered in the touch UI: - SelectionPopup drops the Cmd+C / Cmd+Shift+C shortcut hints (no keyboard on touch) and always anchors above the selection so the thumb can't cover it. - SelectionOverlay anchors its hint above the selection and labels block selection as "Start drag with double-tap" instead of "Hold Opt". Desktop behavior is unchanged (context defaults to false). Add Storybook stories covering both desktop and mobile for each component. Note: the double-tap block-selection gesture the mobile hint describes is not yet wired up — touch drags still produce linewise selections. Implementation follows. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/components/MobileTerminalUi.tsx | 3 + lib/src/components/SelectionOverlay.tsx | 17 ++- lib/src/components/SelectionPopup.tsx | 57 ++++++--- lib/src/components/touch-ui-context.ts | 8 ++ lib/src/stories/SelectionOverlay.stories.tsx | 51 +++++++- lib/src/stories/SelectionPopup.stories.tsx | 127 +++++++++++++++++++ 6 files changed, 234 insertions(+), 29 deletions(-) create mode 100644 lib/src/components/touch-ui-context.ts create mode 100644 lib/src/stories/SelectionPopup.stories.tsx diff --git a/lib/src/components/MobileTerminalUi.tsx b/lib/src/components/MobileTerminalUi.tsx index 2e1fbc41..7a13730e 100644 --- a/lib/src/components/MobileTerminalUi.tsx +++ b/lib/src/components/MobileTerminalUi.tsx @@ -39,6 +39,7 @@ import { type MobileGestureTrackingState, } from '../lib/mobile-gesture-menu'; import { useDynamicPalette } from '../lib/themes/use-dynamic-palette'; +import { TouchUiContext } from './touch-ui-context'; import type { SessionStatus } from '../lib/terminal-registry'; export type MobileTerminalKeyboardMode = 'sessions' | 'recent' | 'type' | 'draft'; @@ -766,6 +767,7 @@ export function MobileTerminalUi({ }, [commitGestureState]); return ( +
+
); } diff --git a/lib/src/components/SelectionOverlay.tsx b/lib/src/components/SelectionOverlay.tsx index 8d71ff32..eabfe32f 100644 --- a/lib/src/components/SelectionOverlay.tsx +++ b/lib/src/components/SelectionOverlay.tsx @@ -1,4 +1,4 @@ -import { useSyncExternalStore, type CSSProperties } from 'react'; +import { useContext, useSyncExternalStore, type CSSProperties } from 'react'; import { DEFAULT_MOUSE_SELECTION_STATE, getMouseSelectionSnapshot, @@ -11,6 +11,7 @@ import { getTerminalOverlayDims } from '../lib/terminal-registry'; import { IS_MAC } from '../lib/platform'; import { useFocusRingColor } from '../lib/themes/use-focus-ring-color'; import { PopupButtonRow } from './design'; +import { TouchUiContext } from './touch-ui-context'; interface Props { terminalId: string; @@ -21,6 +22,7 @@ interface Props { * Re-measures on every render tick (scroll, resize, output). */ export function SelectionOverlay({ terminalId }: Props) { + const touchUi = useContext(TouchUiContext); const states = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); // Subscribe to render tick so we re-render whenever xterm scrolls or resizes. useSyncExternalStore(subscribeToRenderTick, getRenderTick); @@ -65,7 +67,12 @@ export function SelectionOverlay({ terminalId }: Props) { if (endViewportRow >= 0 && endViewportRow < dims.rows) { const draggedDown = selection.endRow >= selection.startRow; const left = Math.min(dims.elementWidth - 180, Math.max(4, gridLeft + selection.endCol * cellWidth)); - if (draggedDown) { + if (touchUi) { + // Mobile: always sit above the selection so the dragging thumb doesn't cover it. + const topViewportRow = Math.min(selection.startRow, selection.endRow) - dims.viewportY; + const y = Math.max(gridTop + (topViewportRow - 1) * cellHeight - 4, 28); + hint = { left, bottom: dims.elementHeight - y }; + } else if (draggedDown) { const top = Math.min( gridTop + (endViewportRow + 2) * cellHeight + 4, dims.elementHeight - 24, @@ -108,7 +115,11 @@ export function SelectionOverlay({ terminalId }: Props) { style={{ left: hint.left, top: hint.top, bottom: hint.bottom }} >
-
Hold {IS_MAC ? 'Opt' : 'Alt'} for block selection
+
+ {touchUi + ? 'Start drag with double-tap for block selection' + : `Hold ${IS_MAC ? 'Opt' : 'Alt'} for block selection`} +
{state.hintToken && (
Press e to select the full{' '} diff --git a/lib/src/components/SelectionPopup.tsx b/lib/src/components/SelectionPopup.tsx index 5dc215fb..ff1b3582 100644 --- a/lib/src/components/SelectionPopup.tsx +++ b/lib/src/components/SelectionPopup.tsx @@ -1,4 +1,4 @@ -import { useLayoutEffect, useState, useEffect, useSyncExternalStore, type CSSProperties } from 'react'; +import { useContext, useLayoutEffect, useState, useEffect, useSyncExternalStore, type CSSProperties } from 'react'; import { DEFAULT_MOUSE_SELECTION_STATE, flashCopy, @@ -13,6 +13,7 @@ import { CheckIcon } from '@phosphor-icons/react'; import { IS_MAC } from '../lib/platform'; import { getTerminalOverlayDims } from '../lib/terminal-registry'; import { PopupButtonRow, popupButton, Shortcut } from './design'; +import { TouchUiContext } from './touch-ui-context'; interface Props { terminalId: string; @@ -23,6 +24,7 @@ interface Props { * and Copy Rewrapped. Dismissed on Esc, click-outside, or a successful copy. */ export function SelectionPopup({ terminalId }: Props) { + const touchUi = useContext(TouchUiContext); const states = useSyncExternalStore(subscribeToMouseSelection, getMouseSelectionSnapshot); useSyncExternalStore(subscribeToRenderTick, getRenderTick); @@ -52,7 +54,13 @@ export function SelectionPopup({ terminalId }: Props) { // selection than the hint did on drag-up. const draggedDown = selection.endRow >= selection.startRow; const left = Math.min(dims.elementWidth - 300, Math.max(0, gridLeft + selection.endCol * cellWidth)); - if (draggedDown) { + if (touchUi) { + // Mobile: always sit above the selection so the dragging thumb (which ends + // at the selection's lower edge) never covers the copy buttons. + const topRow = Math.max(0, Math.min(dims.rows - 1, Math.min(selection.startRow, selection.endRow) - dims.viewportY)); + const y = Math.max(gridTop + (topRow - 1) * cellHeight - 4, 28); + setAnchor({ left, bottom: dims.elementHeight - y }); + } else if (draggedDown) { const top = Math.min( gridTop + (endRow + 2) * cellHeight + 4, dims.elementHeight - 24, @@ -64,7 +72,7 @@ export function SelectionPopup({ terminalId }: Props) { const y = Math.max(gridTop + (endRow - 1) * cellHeight - 4, 28); setAnchor({ left, bottom: dims.elementHeight - y }); } - }, [terminalId, shouldRender, selection]); + }, [terminalId, shouldRender, selection, touchUi]); useEffect(() => { if (!shouldRender) return; @@ -118,6 +126,31 @@ export function SelectionPopup({ terminalId }: Props) { const flashed = (kind: 'raw' | 'rewrapped') => state.copyFlash === kind; const buttonClass = (kind: 'raw' | 'rewrapped') => popupButton({ flashed: flashed(kind) }); + // The touch UI has no keyboard, so drop the shortcut hint there and keep only the + // copy-success check. On desktop the check sits over the (hidden) shortcut so the + // button width stays put while it flashes. + const leadingIndicator = (kind: 'raw' | 'rewrapped', shortcut: string) => { + if (touchUi) { + return flashed(kind) ? ( + + + + ) : null; + } + return ( + <> + + {shortcut} + {flashed(kind) && ( + + + + )} + {' '} + + ); + }; + return ( onCopy(false)} > - - {copyShortcut} - {flashed('raw') && ( - - - - )} - {' '} + {leadingIndicator('raw', copyShortcut)} Copy Raw diff --git a/lib/src/components/touch-ui-context.ts b/lib/src/components/touch-ui-context.ts new file mode 100644 index 00000000..185325ea --- /dev/null +++ b/lib/src/components/touch-ui-context.ts @@ -0,0 +1,8 @@ +import { createContext } from 'react'; + +/** + * True when the surrounding UI is the touch-first mobile terminal, where there is + * no physical keyboard — so keyboard shortcut hints (e.g. on the selection popup) + * should be omitted. Defaults to false for the desktop UI. + */ +export const TouchUiContext = createContext(false); diff --git a/lib/src/stories/SelectionOverlay.stories.tsx b/lib/src/stories/SelectionOverlay.stories.tsx index 7b45f12a..919a9059 100644 --- a/lib/src/stories/SelectionOverlay.stories.tsx +++ b/lib/src/stories/SelectionOverlay.stories.tsx @@ -18,15 +18,18 @@ import { type TokenHint, } from '../lib/mouse-selection'; import { TERMINAL_BOTTOM_RADIUS_CLASS } from '../components/design'; +import { TouchUiContext } from '../components/touch-ui-context'; function SelectionOverlayStory({ id, selection, hintToken = null, + touch = false, }: { id: string; selection: Omit; hintToken?: TokenHint | null; + touch?: boolean; }) { const terminalHostRef = useRef(null); @@ -76,13 +79,15 @@ function SelectionOverlayStory({ }, [id, selection, hintToken]); return ( -
-
- -
+ +
+
+ +
+ ); } @@ -125,6 +130,38 @@ export const BlockDrag: Story = { }, }; +// Mobile: the block-selection hint reads "double-tap" instead of "Hold Opt", and +// sits above the selection (never below) so the dragging thumb can't cover it. +export const MobileLinewiseDrag: Story = { + args: { + id: 'selection-overlay-mobile-linewise-drag', + touch: true, + selection: { + startRow: 2, + startCol: 5, + endRow: 6, + endCol: 24, + shape: 'linewise', + dragging: true, + }, + }, +}; + +export const MobileBlockDrag: Story = { + args: { + id: 'selection-overlay-mobile-block-drag', + touch: true, + selection: { + startRow: 2, + startCol: 6, + endRow: 5, + endCol: 26, + shape: 'block', + dragging: true, + }, + }, +}; + export const SmartPathHint: Story = { args: { id: 'selection-overlay-smart-path-hint', diff --git a/lib/src/stories/SelectionPopup.stories.tsx b/lib/src/stories/SelectionPopup.stories.tsx new file mode 100644 index 00000000..cebdd988 --- /dev/null +++ b/lib/src/stories/SelectionPopup.stories.tsx @@ -0,0 +1,127 @@ +import { useEffect, useRef } from 'react'; +import type { Meta, StoryObj } from '@storybook/react'; +import '@xterm/xterm/css/xterm.css'; +import { SelectionPopup } from '../components/SelectionPopup'; +import { + focusSession, + getOrCreateTerminal, + getTerminalOverlayDims, + mountElement, + refitSession, + unmountElement, +} from '../lib/terminal-registry'; +import { flattenScenario, SCENARIO_LS_OUTPUT } from '../lib/platform'; +import { setSelection, type Selection } from '../lib/mouse-selection'; +import { TERMINAL_BOTTOM_RADIUS_CLASS } from '../components/design'; +import { TouchUiContext } from '../components/touch-ui-context'; + +function SelectionPopupStory({ + id, + selection, + touch = false, +}: { + id: string; + selection: Omit; + touch?: boolean; +}) { + const terminalHostRef = useRef(null); + + useEffect(() => { + const terminalHost = terminalHostRef.current; + if (!terminalHost) return; + + getOrCreateTerminal(id); + mountElement(id, terminalHost); + + const observer = new ResizeObserver(() => refitSession(id)); + observer.observe(terminalHost); + + return () => { + observer.disconnect(); + unmountElement(id); + }; + }, [id]); + + useEffect(() => { + focusSession(id, true); + }, [id]); + + useEffect(() => { + let cancelled = false; + let timer: ReturnType; + + const applySelection = () => { + if (cancelled) return; + const dims = getTerminalOverlayDims(id); + if (!dims || dims.cellHeight === 0) { + timer = setTimeout(applySelection, 50); + return; + } + // dragging: false so the popup (shown after mouse-up) renders. + setSelection(id, { ...selection, dragging: false, startedInScrollback: false }); + }; + + timer = setTimeout(applySelection, 100); + return () => { + cancelled = true; + clearTimeout(timer); + setSelection(id, null); + }; + }, [id, selection]); + + return ( + +
+
+ +
+ + ); +} + +const meta: Meta = { + title: 'Components/SelectionPopup', + component: SelectionPopupStory, + parameters: { + fakePty: { scenario: flattenScenario(SCENARIO_LS_OUTPUT) }, + }, +}; + +export default meta; +type Story = StoryObj; + +// Desktop: copy buttons carry their keyboard shortcuts and, for a downward drag, +// sit below the selection. +export const Desktop: Story = { + args: { + id: 'selection-popup-desktop', + selection: { + startRow: 2, + startCol: 5, + endRow: 6, + endCol: 24, + shape: 'linewise', + dragging: false, + }, + }, +}; + +// Mobile: no keyboard shortcuts, and the popup sits above the selection (never +// below) so the thumb that finished the drag can't cover it. +export const Mobile: Story = { + args: { + id: 'selection-popup-mobile', + touch: true, + selection: { + startRow: 2, + startCol: 5, + endRow: 6, + endCol: 24, + shape: 'linewise', + dragging: false, + }, + }, +}; From ef887cbeb1e4ac65e4537ed1c2a1f4c98a8b5f54 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 15:49:34 -0700 Subject: [PATCH 07/12] Implement double-tap-to-block-select on touch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mobile selection hint promises block selection via double-tap, but touch events have no Alt key (the only thing that set block shape). Detect a double-tap in the terminal mouse router — a press landing soon after and near the previous touch that ended as a tap — and latch block mode for the drag it begins, carrying it through beginDrag/updateDrag. Recording the tap only on a no-drag release keeps two quick consecutive drags from being mistaken for a double-tap. Desktop Alt behavior is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- lib/src/lib/terminal-mouse-router.test.ts | 58 +++++++++++++++++++++++ lib/src/lib/terminal-mouse-router.ts | 39 +++++++++++++-- 2 files changed, 93 insertions(+), 4 deletions(-) diff --git a/lib/src/lib/terminal-mouse-router.test.ts b/lib/src/lib/terminal-mouse-router.test.ts index 0c0ce8e1..5923e2bd 100644 --- a/lib/src/lib/terminal-mouse-router.test.ts +++ b/lib/src/lib/terminal-mouse-router.test.ts @@ -236,6 +236,64 @@ describe('terminal-mouse-router: override suppression', () => { cleanup(); }); + it('starts a block selection from a double-tap-then-drag on touch', () => { + const { cleanup, element } = createHarness(windowHost); + + // First tap: a quick press and release with no drag — leaves no selection. + element.emit('pointerdown', pointerEvent({ clientX: 5, clientY: 5 })); + windowHost.emit('pointerup', pointerEvent({ clientX: 5, clientY: 5 })); + expect(getMouseSelectionState('t1').selection).toBeNull(); + + // Second tap immediately after, in the same spot, then drag → block shape. + element.emit('pointerdown', pointerEvent({ clientX: 5, clientY: 5 })); + windowHost.emit('pointermove', pointerEvent({ clientX: 25, clientY: 15 })); + + expect(getMouseSelectionState('t1').selection).toMatchObject({ + shape: 'block', + dragging: true, + }); + cleanup(); + }); + + it('keeps a single touch drag linewise when the taps are too far apart in time', () => { + let now = 1000; + const nowSpy = vi.spyOn(Date, 'now').mockImplementation(() => now); + const { cleanup, element } = createHarness(windowHost); + + element.emit('pointerdown', pointerEvent({ clientX: 5, clientY: 5 })); + windowHost.emit('pointerup', pointerEvent({ clientX: 5, clientY: 5 })); + + now += 1000; // well beyond the double-tap window + element.emit('pointerdown', pointerEvent({ clientX: 5, clientY: 5 })); + windowHost.emit('pointermove', pointerEvent({ clientX: 25, clientY: 15 })); + + expect(getMouseSelectionState('t1').selection).toMatchObject({ + shape: 'linewise', + dragging: true, + }); + nowSpy.mockRestore(); + cleanup(); + }); + + it('does not treat two quick consecutive drags as a double-tap', () => { + const { cleanup, element } = createHarness(windowHost); + + // First interaction is a DRAG (not a tap), so it must not arm block mode. + element.emit('pointerdown', pointerEvent({ clientX: 5, clientY: 5 })); + windowHost.emit('pointermove', pointerEvent({ clientX: 25, clientY: 15 })); + windowHost.emit('pointerup', pointerEvent({ clientX: 25, clientY: 15 })); + + // A second drag right after, nearby, stays linewise. + element.emit('pointerdown', pointerEvent({ clientX: 5, clientY: 5 })); + windowHost.emit('pointermove', pointerEvent({ clientX: 25, clientY: 15 })); + + expect(getMouseSelectionState('t1').selection).toMatchObject({ + shape: 'linewise', + dragging: true, + }); + cleanup(); + }); + it('suppresses compatibility mouse events after a touch selection starts', () => { const { cleanup, element } = createHarness(windowHost); diff --git a/lib/src/lib/terminal-mouse-router.ts b/lib/src/lib/terminal-mouse-router.ts index 2cc06740..1d396655 100644 --- a/lib/src/lib/terminal-mouse-router.ts +++ b/lib/src/lib/terminal-mouse-router.ts @@ -68,10 +68,16 @@ export function attachTerminalMouseRouter({ }; const DRAG_THRESHOLD_PX_SQ = 16; + // Touch has no Alt key, so a double-tap-then-drag is how a block selection is + // started on touch. A second touch within this window and distance of the + // previous one (which ended as a tap) arms block mode for the drag it begins. + const DOUBLE_TAP_MS = 300; + const DOUBLE_TAP_DIST_PX_SQ = 24 * 24; let pendingDrag: { row: number; col: number; altKey: boolean; + block: boolean; startedInScrollback: boolean; button: number; clientX: number; @@ -81,6 +87,10 @@ export function attachTerminalMouseRouter({ } | null = null; let activePointerId: number | null = null; let suppressSyntheticMouseUntil = 0; + // The most recent touch that ended as a tap (no drag), used to recognize a double-tap. + let lastTouchTap: { time: number; x: number; y: number } | null = null; + // True while the active drag is block-mode (Alt on desktop, double-tap on touch). + let dragBlock = false; const terminalOwnsEvent = (ev: MouseEvent | PointerEvent) => { const state = getMouseSelectionState(id); @@ -94,7 +104,7 @@ export function attachTerminalMouseRouter({ const beginPendingDrag = ( ev: MouseEvent | PointerEvent, - opts: { pointerId: number | null; touchLike: boolean }, + opts: { pointerId: number | null; touchLike: boolean; block?: boolean }, ) => { const { state, cell, terminalOwns } = terminalOwnsEvent(ev); if (!terminalOwns) return false; @@ -108,6 +118,7 @@ export function attachTerminalMouseRouter({ row: cell.row, col: cell.col, altKey: ev.altKey, + block: opts.block ?? false, startedInScrollback: cell.startedInScrollback, button: ev.button, clientX: ev.clientX, @@ -130,10 +141,15 @@ export function attachTerminalMouseRouter({ const dx = ev.clientX - pendingDrag.clientX; const dy = ev.clientY - pendingDrag.clientY; if (dx * dx + dy * dy < DRAG_THRESHOLD_PX_SQ) return; + // Block mode (shape) is latched for the whole drag: Alt held at press on + // desktop, or a double-tap on touch (which has no Alt to read mid-drag). A + // tap can no longer chain into the next press once a drag has begun. + dragBlock = pendingDrag.block; + lastTouchTap = null; beginDrag(id, { row: pendingDrag.row, col: pendingDrag.col, - altKey: pendingDrag.altKey, + altKey: pendingDrag.altKey || pendingDrag.block, startedInScrollback: pendingDrag.startedInScrollback, }); terminal.clearSelection(); @@ -141,7 +157,7 @@ export function attachTerminalMouseRouter({ } if (!isDragging(id)) return; const cell = computeCell(ev); - updateDrag(id, { row: cell.row, col: cell.col, altKey: ev.altKey }); + updateDrag(id, { row: cell.row, col: cell.col, altKey: ev.altKey || dragBlock }); const suppressNativeMouse = stateRequiresNativeMouseSuppression(getMouseSelectionState(id)); if (!consumed) consumePointerEvent(ev, suppressNativeMouse || isNonMousePointerEvent(ev)); @@ -162,6 +178,11 @@ export function attachTerminalMouseRouter({ if (ev.button !== pendingDrag.button) return; const suppressNativeMouse = stateRequiresNativeMouseSuppression(getMouseSelectionState(id)); if (suppressNativeMouse || pendingDrag.touchLike) consumePointerEvent(ev, true); + // A touch press that releases without ever dragging is a tap — remember it + // so the next press can be recognized as a double-tap (block selection). + if (pendingDrag.touchLike) { + lastTouchTap = { time: Date.now(), x: ev.clientX, y: ev.clientY }; + } clearTemporaryOverrideAfterMouseDispatch(id); pendingDrag = null; return; @@ -170,6 +191,7 @@ export function attachTerminalMouseRouter({ if (!isDragging(id)) return; const suppressNativeMouse = stateRequiresNativeMouseSuppression(getMouseSelectionState(id)); endDrag(id); + dragBlock = false; setHintToken(id, null); const sel = getMouseSelectionState(id).selection; setSelectionBaseline(sel ? extractSelectionText(terminal, sel) : null); @@ -188,7 +210,15 @@ export function attachTerminalMouseRouter({ const onPointerDown = (ev: PointerEvent) => { if (ev.pointerType === 'mouse') return; if (!ev.isPrimary) return; - const handled = beginPendingDrag(ev, { pointerId: ev.pointerId, touchLike: true }); + // Double-tap = this press lands soon after, and near, the previous touch that + // ended as a tap. Recording only on a tap release (not on a drag) keeps two + // quick consecutive drags from masquerading as a double-tap. + const dx = ev.clientX - (lastTouchTap?.x ?? 0); + const dy = ev.clientY - (lastTouchTap?.y ?? 0); + const doubleTap = lastTouchTap !== null + && Date.now() - lastTouchTap.time <= DOUBLE_TAP_MS + && dx * dx + dy * dy <= DOUBLE_TAP_DIST_PX_SQ; + const handled = beginPendingDrag(ev, { pointerId: ev.pointerId, touchLike: true, block: doubleTap }); if (!handled) return; activePointerId = ev.pointerId; suppressSyntheticMouseUntil = Date.now() + 800; @@ -241,6 +271,7 @@ export function attachTerminalMouseRouter({ if (activePointerId !== ev.pointerId) return; pendingDrag = null; activePointerId = null; + dragBlock = false; setSelection(id, null); setHintToken(id, null); consumePointerEvent(ev, true); From dcf7dd9138a07177b6458b325d78a3484de4bd19 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 16:03:40 -0700 Subject: [PATCH 08/12] Clarify Pocket gesture/select tutorial copy --- website/src/lib/tut-items.ts | 10 +++++----- website/src/lib/tut-runner.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index 2642e8fc..0ec4a4ef 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -72,22 +72,22 @@ const GESTURE_NAVIGATION_SECTION: Section = { { id: 'gn-touch-mode', title: 'Switch between Select and Gestures', - hint: 'Tap `Select`, then tap `Gestures` again.', + hint: 'Tap `Select`, then tap `Gestures` again. This mode determines what happens when you touch the terminal.', }, { id: 'gn-arrows', title: 'Use Gestures to send an arrow key', - hint: 'Drag directly up, down, left, or right past the circle.', + hint: 'Touch anywhere on the terminal to open the gesture compass. Then drag directly up, down, left, or right past the circle.', }, { id: 'gn-enter', title: 'Use Gestures to press Enter', - hint: 'Drag toward `Enter`, then choose `Enter`.', + hint: 'Touch anywhere on the terminal to open the gesture compass. Drag towards the diagonal that has `Enter`, and then drag back in the other direction choose which `kind` of Enter.', }, { id: 'gn-esc', title: 'Use Gestures to press Esc', - hint: 'Drag toward `Esc`, then choose `Esc`.', + hint: 'Touch anywhere on the terminal to open the gesture compass. Drag towards the diagonal that has `Esc`, and then drag back in the other direction choose which `kind` of Esc.', }, ], }; @@ -128,7 +128,7 @@ const POCKET_COPY_PASTE_SECTION: Section = { ...COPY_PASTE_SECTION, items: COPY_PASTE_SECTION.items.filter((item) => item.id !== 'cp-override'), prose: [ - '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.', + '`Select` mode helps you copy text out of a TUI, while `Gesture` mode makes it easy to enter common keystrokes. `Mouse` mode turns your taps into clicks, but it is only available when the running program is capturing mouse input.', ], }; diff --git a/website/src/lib/tut-runner.ts b/website/src/lib/tut-runner.ts index ca7b28e5..043cc1bd 100644 --- a/website/src/lib/tut-runner.ts +++ b/website/src/lib/tut-runner.ts @@ -856,7 +856,7 @@ export class TutRunner implements InteractiveProgram { if (mode === "selection") { return [ ` ${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}`, + ``, ]; } return [ From a50516ac6b001f7b1f07fe77b34a943b1c14cb3a Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 16:12:54 -0700 Subject: [PATCH 09/12] Shrink tutorial spec to point at canonical copy in code --- docs/specs/tutorial.md | 214 ++++++++++++----------------------------- 1 file changed, 60 insertions(+), 154 deletions(-) diff --git a/docs/specs/tutorial.md b/docs/specs/tutorial.md index 4e168731..31ecf6ab 100644 --- a/docs/specs/tutorial.md +++ b/docs/specs/tutorial.md @@ -3,209 +3,115 @@ The website playground has canonical device-specific routes: - `/playground` is a client-side dispatcher. It picks Pocket for coarse-pointer devices or narrow viewports and Desktop otherwise, then replaces the history entry with `/playground/desktop` or `/playground/pocket`. The exact media query lives in `website/src/lib/playground-routing.ts`. -- `/playground/desktop` hosts the desktop tiling tutorial. When the dispatcher would have picked Pocket (coarse pointer or narrow viewport) it does not mount `Wall`; it shows a message that the screen is too small for the desktop playground and links to `/playground/pocket`. -- `/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. +- `/playground/desktop` hosts the desktop tiling tutorial. When the dispatcher would have picked Pocket it shows a "screen too small" message linking to `/playground/pocket` instead of mounting `Wall`. +- `/playground/pocket` hosts the mobile Pocket playground. On desktop it shows the temporary Pocket marketing/share page (phone preview + notify signup form). +- `/pocket` temporarily redirects to `/playground/pocket`. The future real tethering surface should stay separate from the playground URL when it exists. -The `tut` TUI has device-specific profiles: +## 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. +The `tut` TUI has two device profiles, defined in `website/src/lib/tut-items.ts` (`DESKTOP_TUTORIAL_PROFILE`, `POCKET_TUTORIAL_PROFILE`): -Each item starts pending, the first incomplete item is marked as active, and completed items become green checks when Dormouse detects the corresponding action. +- **Desktop** starts at the top-level menu; sections: Keyboard navigation, Alert and TODO, Copy paste. +- **Pocket** starts directly inside Gesture navigation (`initialSectionId`); sections: Gesture navigation, Copy paste. + +All section/item titles, hints, and prose live in `tut-items.ts`; the menu, Flappy Term, and star copy live in `tut-runner.ts`. This spec does not duplicate that text. Item ids are stable — they are the localStorage key suffixes. + +Each item starts pending; the first incomplete item is marked active, and completed items become green checks when the detector observes 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): +Three browser-side pieces in `website/src/lib/`, mirroring `ascii-splash-runner.ts` (xterm alt-screen + `FakePtyAdapter` boundary, no Node `terminal-kit`): -- **`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. 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. +- **`tut-runner.ts`** (`TutRunner`) — profile-aware alt-screen TUI. Subscribes to `TutorialState`, re-renders on progress changes, routes input via `FakePtyAdapter.writePty`. +- **`tut-detector.ts`** (`TutDetector`) — wires app events to `TutorialState.markComplete(id)`. Subscribes to `DockviewApi.onDidActivePanelChange`, the `WallEvent` stream, `subscribeToActivity` (`dormouse-lib/lib/terminal-registry`), and `subscribeToMouseSelection` (`dormouse-lib/lib/mouse-selection`). The per-item detection contract — which transition credits which id, and the guards against falsely crediting restored/spawned state — lives in this file's code and comments. +- **`tutorial-state.ts`** (`TutorialState`) — in-memory progress store; see [Storage](#storage) for keys. Profile totals are computed from the active profile's section list. +- **`tut-items.ts`** — section + item definitions and the two profiles, shared by runner and detector. ## Layout -- `SiteHeader` at top with the `Theme:` dropdown control on `/playground/desktop`. Header is `themeAware` so `--vscode-*` variables drive its background, border, text, and banner colors. +- `SiteHeader` at top with the `Theme:` dropdown on `/playground/desktop`. Header is `themeAware` so `--vscode-*` variables drive its chrome. - `
` is a flex container so Wall's `flex-1 min-h-0` root gets a real height. -- `Wall` runs `FakePtyAdapter` with `initialMode="passthrough"` on `/playground/desktop`. The route uses the desktop three-pane layout only: - - **`tut-main`** (left, ~50%) — auto-launches `TutRunner` via `mainShell.runCommand("tut")`. - - **`tut-boxed`** (right-top, ~25%) — titled "changelog". Auto-launches `ChangelogRunner` via `boxedShell.runCommand("changelog")`. Doubles as the Copy Rewrapped target — its wrapped lines exercise the rewrap path. - - **`tut-splash`** (right-bottom, ~25%) — titled "ascii-splash". Auto-launches `AsciiSplashRunner` via `splashShell.runCommand("ascii-splash")`. -- Side panes are added in `onApiReady` with `position: { referencePanel, direction }` after Wall creates the initial main pane. - -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 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`; `/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 -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 -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: - -- `✓` (green) — complete -- `●` (yellow active marker) — first incomplete item, with hint text shown below. This marker is intentionally static so runner re-renders do not feed the activity monitor. -- `·` (dim) — later incomplete items - -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`. +- `/playground/desktop` runs `Wall` (`FakePtyAdapter`, `initialMode="passthrough"`) in a three-pane layout, panes added in `onApiReady` via `position: { referencePanel, direction }`: + - **`tut-main`** (left, ~50%) — auto-launches `TutRunner` (`mainShell.runCommand("tut")`). + - **`tut-boxed`** (right-top, ~25%, "changelog") — auto-launches `ChangelogRunner`. Doubles as the Copy Rewrapped target; its wrapped lines exercise the rewrap path. + - **`tut-splash`** (right-bottom, ~25%, "ascii-splash") — auto-launches `AsciiSplashRunner`. -### Desktop Section 1 — Keyboard navigation (7 items) +Every pane gets a `TutorialShell` input handler via `PlaygroundShellRegistry`; the shell dispatches by command name to a page-provided `startProgram` factory (`tut` → `TutRunner`, `ascii-splash`/`splash` → `AsciiSplashRunner`). Spawned terminals use `SCENARIO_SHELL_PROMPT` by default. -| ID | Title | Detection | -|---|---|---| -| `kb-mode` | Enter command mode | `WallEvent.modeChange` to `'command'` (the modifier dual-tap is in the hint) | -| `kb-split-h` | Add a horizontal divider with `-` (or `"`) | `WallEvent.split { source: 'keyboard', direction: 'vertical' }` | -| `kb-arrows` | Move between panes with arrow keys | `onDidActivePanelChange` ≥ 2 distinct panels while in command mode | -| `kb-split-v` | Add a vertical divider with `\|` (or `%`) | `WallEvent.split { source: 'keyboard', direction: 'horizontal' }` | -| `kb-min` | Minimize a pane | `WallEvent.minimizeChange { count > 0 }` | -| `kb-kill` | Kill a pane | `WallEvent.kill` (added to the `WallEvent` union; emitted from `acceptKill` in `Wall.tsx`) | -| `kb-move` | Move a pane with Cmd/Ctrl + arrow | `WallEvent.move` (added to the `WallEvent` union; emitted from `handle-pane-shortcuts.ts` after `swapTerminals`) | +`/playground/pocket` runs `MobileWall` with two sessions: **`pocket-tut`** ("tutorial", active, `TutRunner` with `POCKET_TUTORIAL_PROFILE`) and **`pocket-changelog`** ("changelog", `ChangelogRunner` for wrapped text + a mouse-capturing target). It attaches `TutDetector` with the shared activity/mouse stores. Pocket-specific Gesture detections are wired in `PocketTerminalExperience`: touch-mode changes complete `gn-touch-mode`, and `MobileTerminalUi.onGestureInput` completes `gn-arrows`/`gn-enter`/`gn-esc` only for radial-menu-generated inputs. -Prose under the section: "tmux shortcuts also work — `% " d x`." +## Menu and navigation behavior -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. +The desktop runner opens at a top-level menu; Pocket starts inside Gesture navigation and Esc returns to its menu. Selecting a section drills into its item list, showing `[N/M complete]` per section. Inside a section, items render `✓` (green, complete), `●` (yellow active marker — intentionally static so runner re-renders don't feed the activity monitor), or `·` (dim, later). Esc / `q` / Ctrl+C pops back one screen (section → menu → exit); re-running `tut` re-enters. `Reset progress` returns to the profile's initial screen. -### Desktop Section 2 — Alert and TODO (6 items) +Below the sections the menu lists `Starred on GitHub` (persisted separately, calls `onOpenGithub`) and `🐭 FlappyTerm 🐭`. Flappy is `[LOCKED N/M]` until all section checklist items are complete (the star and Flappy rows don't count toward `N/M`), then shows `[High score: N]` and unlocks a runner-local mini-game. The game-over screen cross-links the other surface: desktop `p` → `onOpenPocket`, Pocket `n` → `onNotifyPocket`. The page wires these callbacks (and their URLs) in `PocketTerminalExperience.tsx` and the desktop playground page. -The detector subscribes to `subscribeToActivity()` and tracks per-id `(status, watchingEnabled, todo)` transitions. +### Runner-local intercepts -| ID | Title | Detection | -|---|---|---| -| `al-enable` | Enable WATCHING on a pane (click bell or `a`) | `watchingEnabled` transitions `false → true` | -| `al-busy` | Watch the bell tilt while a task runs | status enters `BUSY`, `MIGHT_BE_BUSY`, or `OSC_NOTIF_BUSY` | -| `al-ring` | Bell rings on completion | status enters `ALERT_RINGING` | -| `al-todo-auto` | TODO appears when you dismiss the ringing alert | `todo` transitions `false → true` while previous status was `ALERT_RINGING` | -| `al-todo-clear` | Press passthrough Enter to clear the TODO | `todo` transitions `true → false` | -| `al-todo-manual` | Manually add a TODO (`t` or right-click) | `todo` transitions `false → true` while previous status was NOT `ALERT_RINGING` | +Two keys are intercepted by `TutRunner` while a specific section is open — they are **not** real Dormouse shortcuts: -The detector remembers the most recent pane whose `watchingEnabled` flag is true, even when projected `status` is currently owned by protocol or command-exit alert tracks. The Alert section view shows a runner-local instruction: "Press `s` here to start a fake busy task." `s` is **not** a real Dormouse shortcut; it is intercepted by `TutRunner` only while the Alert section is open. When pressed, the runner does two things: +- **`s`** (Alert section) — drives a fake busy task on the WATCHING-enabled pane via `FakePtyAdapter.pumpActivity` (no text output) and animates an in-place countdown. The duration must outlast `cfg.alert.userAttention` so the bell actually rings rather than being suppressed as "user is looking"; see the comment in `tut-runner.ts`. Falls back to `PANE_BOXED` if no WATCHING pane is known. +- **`p`** (Copy paste section) — toggles the **Place To Paste** scratch modal (`website/src/components/PlaceToPaste.tsx`) via `onTogglePlaceToPaste`. Only wired on desktop; Pocket omits the callback. -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. +### Pocket Copy paste specifics -### Desktop Section 3 / Shared Behaviors — Copy paste +Pocket reuses `cp-select`/`cp-raw`/`cp-rewrap` but drops `cp-override`: in Select mode it auto-overrides mouse capture for every mounted pane whose TUI is capturing the mouse, so it never asks the user to click the cursor icon. It also renders a non-counted live prompt above the checklist that reflects the current touch mode (yellow while Select is inactive, green once active); it is not stored or checkmarked. -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` | -| `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` | Click the cursor icon in `changelog` | `override` transitions `'off' → 'temporary' \| 'permanent'` | - -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. 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 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). - -The Place To Paste prompt appears only when the runner receives an `onTogglePlaceToPaste` callback. Desktop passes the callback; Pocket does not. +## Storage -### Pocket Section 1 — Gesture navigation (4 items) +`TutorialState` persists to `localStorage`. Unknown ids in a stored payload are filtered on load, so renaming an id is a one-way reset for that item. Both profiles share the completion key; profile totals count only that profile's 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 | +- `dormouse-tut-v3` — JSON array of completed item ids. +- `dormouse-tut-star-v1` — `"true"` after `Starred on GitHub`. +- `dormouse-flappy-high-v1` — high score. -## Lib changes added for this tutorial +All three are removed on `TutorialState.reset()`. Legacy `dormouse-tutorial-step-N` / `dormouse-tut-v2-*` keys are not read. -- **`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. +## Lib changes backing the tutorial -`SCENARIO_TUTORIAL_MOTD` was removed — the runner now owns the main pane's screen. +These exist in `dormouse-lib` (or `MobileTerminalUi`) specifically so the browser-side tutorial can observe and drive real behavior: -## Storage +- **`WallEvent.kill` / `WallEvent.move`** — discriminants on the `WallEvent` union (`lib/src/components/wall/wall-types.ts`); `kill` fires from `acceptKill`, `move` from `handle-pane-shortcuts.ts` after the Cmd/Ctrl-Arrow swap. +- **`FakePtyAdapter.pumpActivity(id, durationMs, intervalMs)`** — drives the alert manager for a fixed duration with no data output (used by the `s` busy demo). +- **`FakePtyAdapter.sendOutput(id, data)`** — pushes data through the data handlers as if the PTY produced it, also driving `alertManager.onData()`. +- **`MobileTerminalUi.onGestureInput(input, data)`** — optional callback fired only for radial-menu actions, so Pocket credits gesture items without mistaking native keyboard input for gestures. -- 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. +`SCENARIO_TUTORIAL_MOTD` was removed — the runner owns the main pane's screen. ## Theme Picker -Implemented in `dormouse-lib/lib/themes` and `dormouse-lib/components/ThemePicker`. - -Bundled themes are provided by `dormouse-lib/lib/themes` and include only GitHub variants. Users can install additional themes from OpenVSX through the dropdown footer action. - -The picker appears on `/playground/desktop` and `/playground/pocket`, labeled `Theme:`. On `/playground/desktop` it is inside the theme-aware `SiteHeader`; on `/playground/pocket` mobile it floats over the terminal; on the desktop Pocket playground page it uses the standalone appbar variant. `/pocket` redirects before rendering a picker. The trigger opens a dropdown of bundled and installed themes. The dropdown footer is always `Install theme from OpenVSX`, which opens the theme store dialog. Installed theme rows include an `X` delete control; deletion requires browser confirmation before removing the theme from localStorage. If the active installed theme is deleted, the picker falls back to the first bundled theme and applies it immediately. +Implemented in `dormouse-lib/lib/themes` and `dormouse-lib/components/ThemePicker`. Bundled themes are GitHub variants only; users can install more from OpenVSX via the dropdown footer (`Install theme from OpenVSX`). Installed rows have an `X` delete control (requires browser confirmation); deleting the active installed theme falls back to the first bundled theme. -Each theme is defined as a map of `--vscode-*` CSS variable overrides. `applyTheme()` applies the active theme, which: -1. Cascades into `--color-*` variables (via `var(--vscode-*, fallback)` in `theme.css`) -2. Triggers the `MutationObserver` in `lib/src/lib/terminal-theme.ts` to re-read `getTerminalTheme()` for all xterm.js terminals -3. Updates Dockview/Tailwind token colors +The picker is labeled `Theme:` and appears on `/playground/desktop` (inside the theme-aware `SiteHeader`), `/playground/pocket` mobile (floating over the terminal), and the desktop Pocket page (standalone appbar variant). `/pocket` redirects before rendering one. -The picker restores the persisted active theme on mount. The desktop playground header is `themeAware`, so the same active theme also affects that route's site header chrome. +Each theme is a map of `--vscode-*` overrides. `applyTheme()` cascades them into `--color-*` (via `theme.css` fallbacks), triggers the `MutationObserver` in `lib/src/lib/terminal-theme.ts` to re-read `getTerminalTheme()` for xterm.js terminals, and updates Dockview/Tailwind tokens. The active theme is restored on mount. ## Mouse and Clipboard Feature Coverage -The Playground is the primary dogfood surface for the features in `docs/specs/mouse-and-clipboard.md`. The tutorial layout (`tut-main` running the runner, `tut-boxed` auto-running `changelog`, `tut-splash` auto-running `ascii-splash`) covers most of the spec; one notable gap remains. - -Legend: ✅ exercisable today, ⚠️ partial, ❌ not exercisable. +The Playground is the primary dogfood surface for `docs/specs/mouse-and-clipboard.md`. The layout (`tut-main` runner, `tut-boxed` `changelog`, `tut-splash` `ascii-splash`) covers most of the spec. Legend: ✅ exercisable today, ⚠️ partial, ❌ not exercisable. | Spec § | Feature | Status | Why | |---|---|---|---| -| §1 | Mouse icon visible when program requests reporting | ✅ | Run `ascii-splash`; the runner emits `\x1b[?1000h` / `?1002h` / `?1003h` / `?1006h`. | -| §2 | Temporary/permanent override, banner, Make-permanent / Cancel | ✅ | Run `ascii-splash`, then use the header mouse icon while the animation is active. | +| §1 | Mouse icon visible when program requests reporting | ✅ | `ascii-splash` emits `\x1b[?1000h` / `?1002h` / `?1003h` / `?1006h`. | +| §2 | Temporary/permanent override, banner, Make-permanent / Cancel | ✅ | Use the header mouse icon while `ascii-splash` is active. | | §3.1–§3.3 | Drag, Alt-block shape, "Hold Alt" hint | ✅ | Works on any visible text. | | §3.3 | "Press e to select the full URL/path" hint | ❌ | No qualifying tokens in the live scenarios. | -| §3.4 | Pure-scroll follows, cancel-on-change, cancel-on-resize | ⚠️ | `ascii-splash` makes cancel-on-change and resize cancel observable; scenarios are still too short for pure-scroll coverage. | -| §3.5 | Scrollback-origin / cross-boundary drags | ⚠️ | Scrollback is too short to exercise. | -| §3.6 | Keyboard routing during drag | ✅ | `ascii-splash` reacts to keys and mouse; with override active, drag-time keyboard consumption is observable. | +| §3.4 | Pure-scroll follows, cancel-on-change, cancel-on-resize | ⚠️ | `ascii-splash` makes cancel-on-change and resize observable; scenarios too short for pure-scroll. | +| §3.5 | Scrollback-origin / cross-boundary drags | ⚠️ | Scrollback too short to exercise. | +| §3.6 | Keyboard routing during drag | ✅ | With override active on `ascii-splash`, drag-time keyboard consumption is observable. | | §3.7 | Popup on mouse-up, new-drag-replaces | ✅ | Any selection. | | §4.1.1 | Copy Raw | ✅ | Any selection. | -| §4.1.2 | Copy Rewrapped (paragraph unwrap) | ✅ | `ChangelogRunner` in `tut-boxed` renders wrapped item lines that exercise the rewrap path. | +| §4.1.2 | Copy Rewrapped (paragraph unwrap) | ✅ | `ChangelogRunner` renders wrapped item lines that exercise the rewrap path. | | §4.2 | Cmd+C / Cmd+Shift+C | ✅ | Any selection. | | §4.3 | Esc / click-outside dismiss | ✅ | Any selection popup. | | §5 | Smart-extension (URL / abs path / rel path / Windows path / error location) | ❌ | No matching tokens in the scenarios. | | §5.3 | Press `e` to extend | ❌ | Blocked on §5 coverage. | -| §8.2 | Cmd+V / Cmd+Shift+V / Ctrl+V / Ctrl+Shift+V paste | ⚠️ | The shortcut fires and writes to the fake PTY, but `TutorialShell.handleInput` echoes characters one by one and does not interpret bracketed-paste markers. | -| §8.5 | Bracketed paste wraps `\e[200~ … \e[201~` | ❌ | No scenario emits `\x1b[?2004h`, so `getMouseSelectionState(id).bracketedPaste` stays `false` and `doPaste` sends the raw text. | +| §8.2 | Cmd+V / Cmd+Shift+V / Ctrl+V / Ctrl+Shift+V paste | ⚠️ | Fires and writes to the fake PTY, but `TutorialShell.handleInput` echoes char-by-char and ignores bracketed-paste markers. | +| §8.5 | Bracketed paste wraps `\e[200~ … \e[201~` | ❌ | No scenario emits `\x1b[?2004h`, so `bracketedPaste` stays `false`. | `§3.6` auto-scroll and `§8.7` right-click paste are deferred in the implementation itself — not Playground gaps. -### Follow-up scenarios - -Two scenarios from the previous spec's remediation plan remain useful: - -1. **`SCENARIO_BRACKETED_PASTE_TUI`** — closes §8.5. Emits `\x1b[?2004h` and an idle ANSI-framed view; pastes into it would be wrapped `\x1b[200~ … \x1b[201~`. -2. **`SCENARIO_SMART_TOKENS`** — closes §3.3 extension hint and §5.1–§5.3. Prints one of each detectable shape from `lib/src/lib/smart-token.ts`'s `PATTERNS`. +Two follow-up scenarios from the previous remediation plan remain useful and can be added without changing the three sections (expanding or replacing the `tut-boxed` neighbor): -These can be added without changing the tutorial's three sections — they would expand the `tut-boxed` neighbor or replace it depending on layout decisions at the time. +1. **`SCENARIO_BRACKETED_PASTE_TUI`** — closes §8.5. Emits `\x1b[?2004h` and an idle ANSI-framed view. +2. **`SCENARIO_SMART_TOKENS`** — closes the §3.3 hint and §5.1–§5.3. Prints one of each shape from `lib/src/lib/smart-token.ts`'s `PATTERNS`. From f8ca72093404e44a1d3bb80b33dd10a41f30fb79 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 20:06:15 -0700 Subject: [PATCH 10/12] Fix Pocket tutorial copy nits Add the missing "to" in the gn-enter/gn-esc hints, and match the on-screen mode button label by writing `Gestures` mode in the Pocket Copy paste prose (`Select` and `Mouse` in the same sentence already match their button labels). Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/lib/tut-items.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/website/src/lib/tut-items.ts b/website/src/lib/tut-items.ts index 0ec4a4ef..f14c5229 100644 --- a/website/src/lib/tut-items.ts +++ b/website/src/lib/tut-items.ts @@ -82,12 +82,12 @@ const GESTURE_NAVIGATION_SECTION: Section = { { id: 'gn-enter', title: 'Use Gestures to press Enter', - hint: 'Touch anywhere on the terminal to open the gesture compass. Drag towards the diagonal that has `Enter`, and then drag back in the other direction choose which `kind` of Enter.', + hint: 'Touch anywhere on the terminal to open the gesture compass. Drag towards the diagonal that has `Enter`, and then drag back in the other direction to choose which `kind` of Enter.', }, { id: 'gn-esc', title: 'Use Gestures to press Esc', - hint: 'Touch anywhere on the terminal to open the gesture compass. Drag towards the diagonal that has `Esc`, and then drag back in the other direction choose which `kind` of Esc.', + hint: 'Touch anywhere on the terminal to open the gesture compass. Drag towards the diagonal that has `Esc`, and then drag back in the other direction to choose which `kind` of Esc.', }, ], }; @@ -128,7 +128,7 @@ const POCKET_COPY_PASTE_SECTION: Section = { ...COPY_PASTE_SECTION, items: COPY_PASTE_SECTION.items.filter((item) => item.id !== 'cp-override'), prose: [ - '`Select` mode helps you copy text out of a TUI, while `Gesture` mode makes it easy to enter common keystrokes. `Mouse` mode turns your taps into clicks, but it is only available when the running program is capturing mouse input.', + '`Select` mode helps you copy text out of a TUI, while `Gestures` mode makes it easy to enter common keystrokes. `Mouse` mode turns your taps into clicks, but it is only available when the running program is capturing mouse input.', ], }; From 6a4d78516d7a344962573d3df54e727d37f84953 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 20:21:06 -0700 Subject: [PATCH 11/12] Lock circular RADIUS_LAYOUT in spec and test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exploded-option layout switched from a square to a circle in ee1293b, but the mobile-ui spec still described `RADIUS_LAYOUT` as a square half-side. Rewrite that paragraph to match the code and add a unit test asserting `hypot(vec.x * R, vec.y * R) ≈ R` for every direction vector so the invariant can't quietly regress. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/specs/mobile-ui.md | 8 ++++---- lib/src/lib/mobile-gesture-menu.test.ts | 13 +++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/docs/specs/mobile-ui.md b/docs/specs/mobile-ui.md index 00a862b8..fb9555e3 100644 --- a/docs/specs/mobile-ui.md +++ b/docs/specs/mobile-ui.md @@ -165,7 +165,7 @@ Gesture mode uses these radii: | Variable | Value | Behavior | | --- | --- | --- | -| `RADIUS_LAYOUT` | `92px` | Base half-side for square direction anchors around the offset compass rose origin. Exploded option labels land on these anchors; root labels are packed around the same square so long labels do not overlap. | +| `RADIUS_LAYOUT` | `92px` | Base circular radius for exploded option anchors around the offset compass rose origin. Diagonal exploded labels use normalized compass vectors, so their x/y offsets are `RADIUS_LAYOUT * Math.SQRT1_2`. Root labels use separate packed square-keypad geometry so long labels do not overlap. | | `RADIUS_SELECT` | `RADIUS_LAYOUT * 0.75` | Visible circle drawn around the offset compass rose origin. When the mirrored drag reaches this distance, the closest compass direction is selected. | | `RADIUS_FADE_START` | `RADIUS_SELECT * 0.25` | No directional root-group fading happens before this drag distance. | | `RADIUS_HIGHLIGHT` | `RADIUS_SELECT * 0.5` | No circle is drawn. When the drag reaches this distance, the closest compass direction is highlighted, but not selected. | @@ -223,9 +223,9 @@ 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. +labels use circular direction anchors at `RADIUS_LAYOUT` from the reset center. +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 diff --git a/lib/src/lib/mobile-gesture-menu.test.ts b/lib/src/lib/mobile-gesture-menu.test.ts index fba84c01..26403ff2 100644 --- a/lib/src/lib/mobile-gesture-menu.test.ts +++ b/lib/src/lib/mobile-gesture-menu.test.ts @@ -96,6 +96,19 @@ describe('mobile gesture menu state machine', () => { expect(MOBILE_GESTURE_COMPLETE_MS).toBe(220); }); + it('treats the layout radius as a circular radius for direction vectors', () => { + for (const vector of Object.values(MOBILE_GESTURE_DIRECTION_VECTORS)) { + expect(Math.hypot(vector.x * RADIUS_LAYOUT, vector.y * RADIUS_LAYOUT)).toBeCloseTo( + RADIUS_LAYOUT, + 6, + ); + } + expect(MOBILE_GESTURE_DIRECTION_VECTORS.ne.x * RADIUS_LAYOUT).toBeCloseTo( + RADIUS_LAYOUT * Math.SQRT1_2, + 6, + ); + }); + it('places exploded options opposite the selected direction', () => { expect(MOBILE_GESTURE_OPTION_DIRECTIONS.n).toEqual(['s', 'sw', 'se']); expect(MOBILE_GESTURE_OPTION_DIRECTIONS.e).toEqual(['w', 'nw', 'sw']); From b30e21b374e29eec8ddbd568df48e6cd87618932 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Wed, 27 May 2026 20:23:53 -0700 Subject: [PATCH 12/12] Drop brittle Changelog snapshot test This single-snapshot test caught no real bugs and broke on every SiteHeader tweak (most recently the mobile Playground link). Delete the test and its snapshot rather than re-baselining it again. Co-Authored-By: Claude Opus 4.7 (1M context) --- website/src/pages/Changelog.test.tsx | 23 ------------------- .../__snapshots__/Changelog.test.tsx.snap | 3 --- 2 files changed, 26 deletions(-) delete mode 100644 website/src/pages/Changelog.test.tsx delete mode 100644 website/src/pages/__snapshots__/Changelog.test.tsx.snap diff --git a/website/src/pages/Changelog.test.tsx b/website/src/pages/Changelog.test.tsx deleted file mode 100644 index 883befac..00000000 --- a/website/src/pages/Changelog.test.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { renderToStaticMarkup } from "react-dom/server"; -import { createMemoryRouter, RouterProvider } from "react-router"; -import { describe, expect, it } from "vitest"; - -import Changelog from "./Changelog"; - -describe("Changelog route", () => { - it("renders the after-version filter for /changelog/after/v0.9.0", () => { - const router = createMemoryRouter( - [ - { - path: "/changelog/after/:version", - element: , - }, - ], - { - initialEntries: ["/changelog/after/v0.9.0"], - }, - ); - - expect(renderToStaticMarkup()).toMatchSnapshot(); - }); -}); diff --git a/website/src/pages/__snapshots__/Changelog.test.tsx.snap b/website/src/pages/__snapshots__/Changelog.test.tsx.snap deleted file mode 100644 index cc5f2224..00000000 --- a/website/src/pages/__snapshots__/Changelog.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html - -exports[`Changelog route > renders the after-version filter for /changelog/after/v0.9.0 1`] = `"
Dormouse

Changelog

Release notes for Dormouse.

Showing releases newer than v0.9.0. Show all releases.

v0.10.2

Download from GitHub

Changed

  • Internal refactor unifying modal primitives and renaming dialog components to modals; Storybook entries reorganized to match (#78).

v0.10.0

Download from GitHub

Added

  • OSC 8 hyperlinks emitted by terminal programs are now clickable, with a confirmation dialog before opening external URLs (#75).
  • Kitty keyboard protocol is enabled so TUIs like Claude Code can distinguish Shift+Enter from Enter (#71).
  • Per-pane shell CWD tracking, plus an inline warning popover when a terminal program attempts an illegal rename (#59).
  • iTerm2-style OSC notifications and terminal bells are now recognized and surfaced as alerts (#57).
  • New panes inherit the working directory of the source pane when splitting (#66, closes #4).
  • Panes whose shell is still untouched skip the kill-confirmation prompt (#61).

Changed

  • **BREAKING** Rebranded from MouseTerm to Dormouse — new VS Code extension (diffplug.dormouse), new standalone bundle identifier (sh.dormouse.standalone), and new home at dormouse.sh. Existing MouseTerm installs will not auto-update; install Dormouse fresh (#70).
  • Alert model reworked to unify our existing "watching" model with OSC 9/99/777 and command-exit (#67).
  • 🖥️ Auto-update banner now requires explicit approval before downloading, and "What's new" links are pinned to the target version (#48).

Fixed

  • Mouse events no longer leak through to the PTY while the mouse-override modifier is held (#55).
  • 🖥️ Ctrl+V no longer triggers the macOS WKWebView paste permission prompt on standalone (#65).

v0.9.1

Download from GitHub

Changed

  • 🖥️ Drop-to-paste from the OS file explorer is temporarily inert on standalone while we wait on upstream Tauri (tauri#14373) to allow native drag-drop without blocking HTML5 drag events (#39).

Fixed

  • The mouse-override banner now renders inline in the terminal pane body and no longer stacks with the action-button tooltip (#43).
  • Themes with translucent selection backgrounds (e.g. Selenized Dark) no longer bleed through MouseTerm's solid AppBar and tab fills (#37).
  • 🖥️ Force-closing the standalone host now reliably kills the Node sidecar tree via a Windows Job Object / Unix process group, so subsequent builds no longer hit orphan node.exe processes locking files (#41).
  • 🖥️ Standalone macOS terminals run zsh as a login shell when no args are provided, so ~/.zprofile runs and Homebrew/asdf land on PATH (#40).
  • 🖥️ Pane drag-and-drop reordering works again on standalone (#39).
"`;