From df44f43bc1ce06f37c6bacc18eb95f56326cc7a1 Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 21:46:38 -0700 Subject: [PATCH 1/3] Audit input and theme contracts against canonical code --- docs/specs/mobile-terminal-ui.md | 76 ++++++------------- docs/specs/mouse-and-clipboard.md | 60 ++++++--------- docs/specs/mouse-and-clipboard.rationale.md | 8 +- docs/specs/shortcuts.md | 2 +- docs/specs/theme.md | 34 ++------- lib/scripts/bundle-themes.mjs | 43 +---------- .../components/MobileGestureRadialMenu.tsx | 8 ++ .../components/wall/MouseOverrideBanner.tsx | 21 ++++- lib/src/components/wall/mouse-chrome.test.tsx | 41 ++++++++++ lib/src/lib/copy-text.ts | 16 +++- lib/src/lib/mobile-terminal-scroll.ts | 10 ++- lib/src/lib/themes/bundle-generator.test.ts | 71 +++++++++++++++++ lib/src/lib/themes/consumed-keys.test.ts | 30 -------- lib/src/lib/themes/dynamic-palette.ts | 7 ++ scripts/spec-word-budgets.json | 6 +- scripts/xterm-bump.test.mjs | 18 +++-- 16 files changed, 244 insertions(+), 207 deletions(-) create mode 100644 lib/src/lib/themes/bundle-generator.test.ts diff --git a/docs/specs/mobile-terminal-ui.md b/docs/specs/mobile-terminal-ui.md index 1697be8f6..822c0eda7 100644 --- a/docs/specs/mobile-terminal-ui.md +++ b/docs/specs/mobile-terminal-ui.md @@ -15,22 +15,13 @@ Three consumers compose them: the website Pocket playground ## Core layout -```text -┌─────────────────────────┐ -│ Mobile session header │ MobileWall, fixed/small -├─────────────────────────┤ -│ Pane content │ MobileWall, flexible terminal area -├─────────────────────────┤ -│ Touch mode selector │ always visible -├─────────────────────────┤ -│ Input mode selector │ always visible -├─────────────────────────┤ -│ Reserve area │ stable height -│ │ -│ Shows app keyboard UI │ when OS keyboard hidden -│ Occupied by OS keyboard │ when OS keyboard visible -└─────────────────────────┘ -``` +| Region, top to bottom | Owner / sizing | +| --- | --- | +| Session header | `MobileWall`, fixed height | +| Pane content | `MobileWall`, flexible | +| Touch selector | Always visible | +| Input selector | Always visible | +| Reserve | Fixed CSS height; app UI when the OS keyboard is hidden, occupied by the OS keyboard otherwise | Chrome rules: @@ -78,11 +69,9 @@ short mode label (rationale). Default **Gestures**. **Mouse mode falls back to Gestures when the active pane stops capturing mouse events.** -Touch mode is global, so **each mounted pane's mouse override is a pure function -of that mode and the pane's *own* mouse-reporting state** (`selection` + -reporting ≠ `none` → `permanent`, else `off`), recomputed for **every** pane, -not just the active one — a pane switched away from must not keep a stale -override. `lib` exports the function; the consumer owns the loop. +**Must recompute `paneMouseOverride` for every mounted pane from the global +touch mode and that pane's own reporting state**; a switched-away pane must +not retain a stale override. The consumer owns the loop. Select mode **must route touch and pen drags through the shared terminal mouse-selection router**, never a mobile-only one, so every selection and copy @@ -125,13 +114,10 @@ mode**, locking the drag's owner and Session at pointerdown until release or cancel. **Must accumulate vertical movement at 18 CSS pixels per line**; content follows the finger, with no radial-menu input or native-keyboard focus. -**Must coast after a touch or pen flick**, retaining fractional-line travel and -estimating release velocity from the last 100 ms of movement in its current -direction. **Must include release-time pauses in that estimate and suppress -momentum after an 80 ms hold, a stationary press, a mouse drag, or pointer -cancellation.** **Must decay velocity by 0.998 per millisecond**, integrate -elapsed frame time, cap launch speed at 3 CSS pixels/ms, and stop below -0.05 CSS pixels/ms (rationale). +**Must coast after a touch or pen flick**, retaining fractional-line travel; +a held or stationary press, mouse drag, or pointer cancellation launches no +momentum. The recent-direction velocity estimate, pause cutoff, launch cap, +and elapsed-time decay live at `EdgeScrollMotion` (rationale). **Must send wheel events through xterm when the Session captures the mouse**, clamping the reported coordinates inside its terminal screen. **Must otherwise @@ -163,7 +149,7 @@ The radii order `RADIUS_FADE_START` < `RADIUS_HIGHLIGHT` < `RADIUS_SELECT` < | Variable | Behavior | | --- | --- | -| `RADIUS_LAYOUT` | Circular radius for exploded option anchors around the offset rose origin; diagonal ones use normalized compass vectors, so their x/y offsets are `RADIUS_LAYOUT * Math.SQRT1_2`. Root labels use packed square-keypad geometry instead ([Root layout](#root-layout)); the quit submenu uses its own tighter `QUIT_RADIUS`. | +| `RADIUS_LAYOUT` | Circular radius for exploded option anchors around the offset rose origin, using normalized compass vectors. Root labels use packed square-keypad geometry instead ([Root layout](#root-layout)); the quit submenu uses its own tighter `QUIT_RADIUS`. | | `RADIUS_SELECT` | Visible circle around the offset rose origin; the mirrored drag reaching it selects the closest compass direction. | | `RADIUS_FADE_START` | No directional root-group fading before this drag distance. | | `RADIUS_HIGHLIGHT` | No circle drawn; the drag reaching it highlights the closest compass direction without selecting it. | @@ -191,30 +177,14 @@ in `lib/src/components/MobileGestureRadialMenu.tsx`. ### Root layout -Root labels are laid out as a square keypad, not on a circle (rationale). The -four cardinal arrow chips share one `GAP_CARDINAL_RING` from the select circle -edge. **Each diagonal group renders as three separate labels at `GAP_CLUSTER`, -never one combined pill**: its first option is the cluster center, that option's -inward corner aligned with the diagonal tick at the same ring gap, scaled to -read as the same horizontal/vertical gap rather than a longer diagonal one. -Diagonal center corners — SE aligns Enter's top-left, NE Backspace's -bottom-left, SW Tab's top-right, NW Esc's bottom-right. NE and SE place their -two secondaries right of the center option, one above and one below; NW and SW -place theirs left. - -| Group | Center | Secondary (above) | Secondary (below) | -| --- | --- | --- | --- | -| NW | Esc | ⌃C\* | Quit\*\* | -| N | ▲ | — | — | -| NE | Backspace | Paste\* | n | -| W | ◀ | — | — | -| E | ▶ | — | — | -| SW | Tab | ⬆︎Tab | Space | -| S | ▼ | — | — | -| SE | Enter | ⬆︎Enter | y | - -\* `⌃C` and `Paste` require an in-pane confirmation modal before they run. -\*\* `Quit` opens a second exploded-option menu (`q` | `⌃X` | `:q↵`) instead of +**Must pack root labels as a square keypad** (rationale). Cardinal chips share +one gap from the select circle; **each diagonal group renders three separate +labels, never one combined pill**, with its primary nearest the circle and its +secondaries stacked outward. Corner anchors and spacing live at +`rootOptionLayout`; the key inventory lives at `MOBILE_GESTURE_GROUPS`. + +**Must confirm `⌃C` and `Paste` in an in-pane modal before running them.** +**Must open a second exploded-option menu for `Quit`** instead of sending input, under the same reset-center, highlight, select, and expand-and-fade completion rules as normal option selection. diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 4891d1f37..97796e665 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -48,7 +48,7 @@ It ends on the **next mouse-up inside the terminal content area** paired with a - **Counts:** a plain primary click (down/up that never crossed the drag threshold) or a completed drag. - **Does not count:** a non-primary click, whose context menu the override swallows anyway; clicks on the No-Mouse icon or the banner buttons; and an orphan mouse-up from a drag that started outside the terminal. Pinned by `lib/src/lib/terminal-mouse-router.test.ts`. -- **On end** — or on **Cancel**, after that button's 260 ms confirmation flash — reporting is restored, banner dismissed, Mouse icon back. **No timeout:** absent any mouse action the override stays indefinitely. +- **On end** — or on **Cancel**, after that button's 260 ms confirmation flash — reporting is restored, banner dismissed, Mouse icon back. **Must cancel a pending banner action when its temporary override ends**, never reactivating it later. **Must cancel a pending banner action when its temporary override ends**, never reactivating it later. **No timeout:** absent any mouse action the override stays indefinitely. **Sticky override.** **Make sticky** converts it after the same flash (the store calls this state `permanent`): banner dismissed, No-Mouse icon kept with its "click to restore" hover text, mouse and wheel still going to the terminal. It persists until the user clicks the No-Mouse icon. @@ -56,6 +56,8 @@ It ends on the **next mouse-up inside the terminal content area** paired with a **No keyboard path is designed** for the icons or banner buttons, and focus-based activation is not actively prevented. +Source of truth: `setOverride` / `setMouseReporting` in `lib/src/lib/mouse-selection.ts`; `MouseOverrideBanner` in `lib/src/components/wall/MouseOverrideBanner.tsx`, pinned by `lib/src/components/wall/mouse-chrome.test.tsx`. + --- ## 3. Selection Behavior @@ -88,7 +90,7 @@ A small hint sits adjacent to an in-progress selection — below when dragging d A selection is anchored to the characters under it, not to screen coordinates: stored in absolute buffer rows (scrollback + viewport). - **Pure scroll** — vertical translation with no character changes — carries the selection along; coordinate math only, no matching. -- **Content change:** any change to a cell the finalized selection overlaps cancels it immediately; repaints elsewhere on screen are irrelevant. A text snapshot, retaken whenever the selection is finalized or moved (§4.3), is compared on each xterm render; **never add a partial-match or content-tracking heuristic** — cancel-on-change is the rule (§9.1). +- **Must cancel a finalized selection on the next xterm render when its extracted selected text changes**; repaints elsewhere are irrelevant. Retake the text snapshot when the selection is finalized or moved (§4.3). **Never add a partial-match or content-tracking heuristic** (§9.1). - **Terminal resize** carries a finalized linewise selection Dormouse owns, in the normal buffer, through xterm's reflow (rationale). Its editor stays open in the same format and same-labeled scope, else As selected; per-break edits drop unless the width held. - **Must cancel if an edge's line was trimmed or its cells no longer read as the selected text** (rationale). - **Must cancel any other.** @@ -144,17 +146,11 @@ Source of truth: `render` in `lib/src/lib/copy-text.ts`, pinned by `lib/src/lib/ #### 4.1.1 Auto -Each break between consecutive rows is, in this order: - -1. **Deleted** if the next row is a true soft wrap (xterm's `isWrapped`). -2. **Kept** if either line is blank, the next starts a list item, this one ends in `;` `{` `}` or the next starts with `)` `}` `]`, or the next line's indent is not this line's hanging indent. -3. **Kept** if the paragraph's longest line is under 40 columns (60% of a narrower terminal), or the next line's first word would have fit on this line within that longest line (rationale). -4. **Deleted** if this line fills that width and its last word is token-shaped (a URL or path character, or 16+ token characters) — a token split at the margin. -5. Otherwise **one space**. +**Must delete true soft wraps before judging hard breaks against their surrounding logical lines.** The ordered heuristic, including its bounded local width estimate, lives at `autoBreak` (rationale). **Must trim leading/trailing blank lines, collapse blank runs, and remove shared indent**, keeping relative indent. **Must use full row indentation for mid-line starts.** -Source of truth: `autoBreak` in `lib/src/lib/copy-text.ts`. +Source of truth: `autoBreak` / `wrapWidth` in `lib/src/lib/copy-text.ts`; pinned by `lib/src/lib/copy-text.test.ts`. ### 4.2 Scopes @@ -164,7 +160,7 @@ Source of truth: `autoBreak` in `lib/src/lib/copy-text.ts`. |---|---| | **As selected** | The drag (opens here). | | **Whole words** | Each edge grown over its token, across any row break Auto deletes; an edge on a blank grows nothing. Named **Full URL** or **Full path** when the §5.1 detector classifies a grown edge token so. | -| **Paragraph** | The lines between blank lines, frame-only lines, and box sides (`│ ┃ ║`); an edge on a boundary never crosses it. | +| **Paragraph** | The lines between blank lines, frame-only lines, and box sides (`│ ┃ ║`) at column zero; an edge on a boundary never crosses it. | The selection overlay draws a wider scope dashed around the outline; the preview marks every cell outside the drag. Source of truth: `computeScopes` in `lib/src/lib/copy-text.ts`. @@ -186,25 +182,16 @@ Source of truth: `handleMouseSelectionKeys` in `lib/src/components/wall/keyboard ### 4.4 Preview and Marks - One gutter-numbered row per clipboard line; leading whitespace shows as `·`. -- Every break is a mark — `⏎` kept, `␣` one space, `⌁` deleted. **A click cycles it keep → space → none**, and the format then reads `Auto*`. A scope or format change, or a nudge, discards those edits. +- Every break is a mark — `⏎` kept, `␣` one space, `⌁` deleted. **A click cycles it keep → space → none**, and the active format gains `*`. A scope or format change, or a nudge, discards those edits. - A format whose text an earlier one already gives is dimmed. ### 4.5 Placement and Dismissal - **Must render into `document.body` at `COPY_EDITOR_Z_INDEX`**: above the selection ring, below every `MODAL_LAYERS` value (rationale). -- It takes the first spot holding it whole in the window, less `OVERLAY_VIEWPORT_MARGIN_PX`. All but the last stay clear of the **band**, the rows of the selection and its scope: - - | Rank | Desktop | Touch | - |---|---|---| - | 1 | Below the band | Above, clear of the thumb that ended the drag | - | 2 | Above the band | Below | - | 3 | Beside the pane, the roomier side | The same | - | 4 | Squished into whichever of below, above, and the two sides gives the most area, if 120px tall (rationale) | The same | - | 5 | Over the band at the pane's bottom, at most 60% of the window | The same | - -- **Must be at least as wide as the pane and the chrome** (header and footer, never wrapped, measured at their widest in any format so `f` never resizes the editor), widening with the space to the longest line in any format of the scope, clamped to the window or the side's room (rationale). Above and below may cover neighbors. +- **Must prefer a whole fit within the viewport less `OVERLAY_VIEWPORT_MARGIN_PX`, clear of the selection and scope band**, below then above on desktop, above then below on touch; the roomier side, squishing, and an over-band fallback follow. The rank, area comparison, bounds, and hysteresis live at `placeCopyEditor` (rationale). +- **Must be at least as wide as the pane less its two `GAP_PX` margins and the chrome** (header and footer, never wrapped, measured at their widest in any format so `f` never resizes the editor), widening with the space to the longest line in any format of the scope, clamped to the window or the side's room (rationale). Above and below may cover neighbors. - **Must clip the key hints and legend before the segments, the count, or Copy; a side needs room only for those three** (rationale). -- **Must re-place on every selection or scope change; never keep a spot that covers the selection while another fits.** A held spot, natural or squished, yields only to one better by `HYSTERESIS_PX` (rationale). +- **Must re-place on every selection or scope change; never keep a spot that covers the selection while another fits.** (rationale) - **Must ease every move, restarting from the displayed rect; opening snaps**, as does any move under `motionIsInstant()` (rationale). **Must follow its pane at most once a frame** (`docs/specs/layout.md` → "Position tracking"). - **Never show while its Wall travels**, its pane hidden, or another pane zoomed over it. - **Never take focus**; only an actual scrollbar press keeps its default (rationale). Presses inside it count as inside its pane (`anchoredTarget`); its `mousedown` and `contextmenu` never reach the pane. @@ -213,7 +200,7 @@ Source of truth: `handleMouseSelectionKeys` in `lib/src/components/wall/keyboard - **Must flash only after a successful clipboard write, and only for the selection copied**: Copy reads ✓ Copied, never moving, and the copied selection fills, pulsing unless `motionIsInstant()`; after `COPY_FLASH_MS` (700 ms), `TOUCH_COPY_FLASH_MS` (1200 ms) on touch, the selection clears, however it moved meanwhile (rationale). Canceling clears the flash immediately. - **Must leave empty copies idle without writing.** **Must say a failed write failed**: Copy reads Couldn't copy for `COPY_FAILED_MS` (1500 ms) and the selection stays for a retry. Without the Clipboard API, or refused by it, the write first falls back to `execCommand('copy')` (rationale). -Source of truth: `placeCopyEditor` in `lib/src/lib/copy-editor-placement.ts`, pinned by `lib/src/lib/copy-editor-placement.test.ts`; `createRectMotion` in `lib/src/components/rect-motion.ts`; `anchoredTarget` in `lib/src/lib/dom.ts`; `CopyEditor` in `lib/src/components/CopyEditor.tsx`, pinned by `moves off a wider scope whose band reaches it`, `lays its chrome probe out the same in every format`, `eases a move from the displayed rect, after opening snapped`, `hides while its Wall travels, and shows again when the travel ends`, and `never takes focus from the pane, but leaves its scrollbar its drag` in `lib/src/components/CopyEditor.test.tsx` and the plays in `lib/src/stories/CopyEditorPlacement.stories.tsx`; `copySelection` in `lib/src/lib/copy-selection.ts`, pinned by `lib/src/lib/copy-editor.test.ts`; `writeTextToClipboard` in `lib/src/lib/clipboard.ts`, pinned by `lib/src/lib/clipboard-write.test.ts`; the fill in `SelectionOverlay` in `lib/src/components/SelectionOverlay.tsx`, pinned by `lib/src/components/SelectionOverlay.test.tsx`; `TOUCH_SLOP_PX` in `lib/src/components/CopyEditor.tsx`, pinned by `CopyEditor: touch slop` in `lib/src/components/CopyEditor.test.tsx`. +Source of truth: `placeCopyEditor` in `lib/src/lib/copy-editor-placement.ts`, pinned by `lib/src/lib/copy-editor-placement.test.ts`; `createRectMotion` in `lib/src/components/rect-motion.ts`; `anchoredTarget` in `lib/src/lib/dom.ts`; `CopyEditor` in `lib/src/components/CopyEditor.tsx`, pinned by `lib/src/components/CopyEditor.test.tsx` and the plays in `lib/src/stories/CopyEditorPlacement.stories.tsx`; `copySelection` in `lib/src/lib/copy-selection.ts`, pinned by `lib/src/lib/copy-editor.test.ts`; `writeTextToClipboard` in `lib/src/lib/clipboard.ts`, pinned by `lib/src/lib/clipboard-write.test.ts`; the fill in `SelectionOverlay` in `lib/src/components/SelectionOverlay.tsx`, pinned by `lib/src/components/SelectionOverlay.test.tsx`; `TOUCH_SLOP_PX` in `lib/src/components/CopyEditor.tsx`, pinned by `CopyEditor: touch slop` in `lib/src/components/CopyEditor.test.tsx`. ### 4.6 The Program's Own Copy (OSC 52) @@ -301,17 +288,21 @@ Source of truth: `lib/src/lib/selection-text.ts` (extraction and normalization), ### 8.2 Paste Keybindings -**`Cmd/Ctrl (+Shift) + V` — all four combinations, on every platform — are intercepted and paste** (`hasPasteModifier`); copy keeps the macOS separation instead (§4.3). The price: the raw control byte `0x16` (readline `quoted-insert`, vim literal-next) never reaches the program by this key — §8.3 is the escape hatch. (rationale) +**`Cmd/Ctrl (+Shift) + V` — all four combinations, on every platform — are intercepted and paste** (`hasPasteModifier`); copy keeps the macOS separation instead (§4.3). The price: the raw control byte `0x16` (readline `quoted-insert`, vim literal-next) never reaches the program by this key, even after a program's literal-next prefix (§8.3). (rationale) Source of truth: `lib/src/components/wall/keyboard/chords.ts`. -### 8.3 Sending `0x16` (Ctrl+Q) +### 8.3 Program Literal-Next Input -Because Ctrl+V is intercepted everywhere, a literal control character goes in through **Ctrl+Q, then the desired key** — readline's own `quoted-insert` (bash/zsh/fish), which the terminal does nothing to enable. No equivalent exists for programs without it (vim insert mode) — §9.2. +**Must intercept paste chords even after a program's literal-next prefix**; Dormouse does not track that program state. `Ctrl+Q` is forwarded normally, but a following `Ctrl+V` still pastes rather than sending `0x16` (rationale). A terminal-level literal-next shortcut remains unbuilt (§9.2). + +Source of truth: `handleMouseSelectionKeys` in `lib/src/components/wall/keyboard/handle-mouse-selection-keys.ts`; `hasPasteModifier` in `lib/src/components/wall/keyboard/chords.ts`. ### 8.4 Platform Detection -**`IS_MAC` (`lib/src/lib/platform/index.ts`) is computed once at startup** from `navigator.userAgentData.platform`, else `navigator.platform`, matched against `/Mac|iPhone|iPad/i`. It gates the copy chord (§4.3), every platform-dependent label, and the app's own macOS chrome (the AppBar's traffic-light inset, the VS Code workbench chord map) — **the paste chord alone is platform-independent** (§8.2). +**Must use `IS_MAC` for the copy chord, platform labels, and macOS chrome**; the paste chord is platform-independent (§8.2). + +Source of truth: `IS_MAC` / `PLATFORM_STRING` in `lib/src/lib/platform/index.ts`. ### 8.5 Bracketed Paste @@ -345,17 +336,15 @@ One shared Node module, `standalone/sidecar/clipboard-ops.js`, serves both hosts **The standalone/Tauri build on Windows reads the Win32 clipboard directly in Rust**, dropping the subprocess: `CF_HDROP` for file paths, `CF_UNICODETEXT` for text, `CF_DIB` for an image saved as a `.bmp` temp file — the extension differs from the sidecar path's `.png`, and the same ~5-minute cleanup applies. Non-Windows Tauri stays on the sidecar path. -**Path escaping (tiers 1 and 3, and §8.7). Quote a pasted path for the Session's launch shell** — never for the host platform, never for the app-global shell selected for future terminals (rationale). Each terminal registry entry captures its `shellKind` at spawn and keeps it across a live reconnect (the `pty:list` row carries the launch-shell path) and a cold restore. **Only a missing registry entry falls back** — to the app-global selected shell, then the platform (`cmd` on Windows, posix elsewhere). Classification uses the same `shellCommandKind` `dor` uses to quote commands (`docs/specs/dor-cli.md`). Three rules: - -- **posix** — backslash-escape each metacharacter, matching macOS Terminal's drag-and-drop format (rationale). Newline/CR paths are single-quote-wrapped instead, since bash swallows `\` as a line continuation. -- **cmd** — double-quote-wrap, doubling embedded `"`. cmd's own `%NAME%` (and `!NAME!` under delayed expansion) remains a parser limitation of this legacy path. -- **powershell** — bare when every character is inert in argument mode, else single-quote-wrapped with embedded `'` doubled, reusing `dor`'s `quotePowerShellArg`. **Never reuse the cmd rule here** (rationale). The bare set excludes `,` (array operator in argument mode) and `@` (splatting, or another expression form at a token's start). +**Path escaping (tiers 1 and 3, and §8.7). Quote a pasted path for the Session's launch shell** — never for the host platform, never for the app-global shell selected for future terminals (rationale). Each terminal registry entry captures its `shellKind` at spawn and keeps it across a live reconnect (the `pty:list` row carries the launch-shell path) and a cold restore. **Only a missing registry entry falls back** — to the app-global selected shell, then the platform (`cmd` on Windows, posix elsewhere). Classification uses the same `shellCommandKind` `dor` uses to quote commands (`docs/specs/dor-cli.md`). **Must share `quotePowerShellArg` with `dor` for literal PowerShell arguments; never use cmd quoting for PowerShell** (rationale). The posix/cmd escaping rules and their parser limitations live at `shellEscapePath`. Source of truth: `lib/src/lib/clipboard.ts` (Session-kind selection), `lib/src/lib/shell-escape.ts` (dispatch + posix/cmd rules, pinned by `lib/src/lib/shell-escape.test.ts`) over `POSIX_ESCAPABLE` in `lib/src/lib/posix-escape.ts` (the escapable set, shared with the command tokenizer), `lib/src/lib/terminal-lifecycle.ts` (captured `shellKind`), `dor/src/commands/shell-quote.ts` (`shellCommandKind`, `quotePowerShellArg`), `standalone/src-tauri/src/clipboard_win.rs` (Win32 read), and the live-PTY list contract in `docs/specs/transport.md`. ### 8.7 Drag-to-Paste -Dropping files on a terminal pane types their escaped paths at the current prompt, exactly as tier 1 does (§8.6). Tauri takes the drop natively via `WindowEvent::DragDrop` and routes the paths to the selected pane (dropped if the selection is a Door or has left the layout) — but **the wiring is inert today**: `tauri.conf.json` sets `dragDropEnabled: false` (tauri-apps/tauri#14373, dormouse#38), so the native handler never fires. **Nothing in the layout stack needs the flag off any more** — Lath's pane drag is pointer-based — so flipping it is a live option, and a deliberate, separate change (rationale). +**Must route native file drops only to the selected Pane of the active Workspace**, ignoring a Door selection or a Session outside that Workspace's layout. Paste paths as tier 1 (§8.6). **Tauri's native handler is inert while `dragDropEnabled: false`** (rationale). + +Source of truth: `useSessionPersistence` in `lib/src/components/wall/use-session-persistence.ts`; `app.windows` in `standalone/src-tauri/tauri.conf.json`; pinned by `lib/src/components/WorkspaceWindow.test.tsx`. **Drag-to-paste is not supported in the VSCode build**: the workbench excludes `WebviewView` (sidebar/panel) from external-file drop routing, so the iframe never receives `dragover`/`drop` for OS files (§9.2). VSCode users paste instead (§8.1/§8.5). @@ -403,6 +392,7 @@ Not implemented today; they may be added in response to user feedback. ### 9.2 Paste +- Enable Tauri's native file drops by setting `dragDropEnabled: true`; pointer-based Lath dragging no longer needs the flag disabled (rationale at §8.7). - Right-click context-menu Paste and OS Edit → Paste menu wiring. - A settings toggle to disable Ctrl+V interception on Windows and Linux. - A paste popup for previewing or transforming content before it is committed. diff --git a/docs/specs/mouse-and-clipboard.rationale.md b/docs/specs/mouse-and-clipboard.rationale.md index 0bfefa58b..923a58d8a 100644 --- a/docs/specs/mouse-and-clipboard.rationale.md +++ b/docs/specs/mouse-and-clipboard.rationale.md @@ -56,7 +56,7 @@ Before the editor, `OSC 52` was consumed and ignored, because a program that can ## 4.1.1 Auto -The fit test reads a greedy wrapper correctly by construction: a wrapper at width W only breaks where the next word would push past W, and the paragraph's longest row is at most W, so every wrapped break also fails the test against that row. A break the test calls intentional therefore never comes from a greedy wrap. The 40-column floor exists for paragraphs of short rows, whose longest row says nothing about a wrap width: without it `Hello` / `World`, or a list of short names, read as one wrapped line. It is absolute rather than relative to the terminal because text is often wrapped far narrower than the pane: a first cut that floored at half the terminal kept every break of `fold -w 50` output in a 200-column pane (found driving the real app, 2026-09). +The fit test reads a greedy wrapper correctly by construction: a wrapper at width W only breaks where the next word would push past W, and the longest logical line sampled near the break is at most W, so every wrapped break also fails the test against that row. A break the test calls intentional therefore never comes from a greedy wrap. The 40-column floor exists for paragraphs of short rows, whose longest row says nothing about a wrap width: without it `Hello` / `World`, or a list of short names, read as one wrapped line. It is absolute rather than relative to the terminal because text is often wrapped far narrower than the pane: a first cut that floored at half the terminal kept every break of `fold -w 50` output in a 200-column pane (found driving the real app, 2026-09). ## 5.1 Detection @@ -68,7 +68,11 @@ The mouse store mutates each pane's state object in place, but replaces its map ## 8.2 Paste Keybindings -**Why paste breaks the clean macOS separation that copy keeps.** On macOS `⌘C` is copy and `Ctrl+C` is SIGINT, and honoring that split costs nothing — a Mac user reaching for copy reaches for `⌘`. Paste is not symmetric: `Ctrl+V` is the universal expectation on every platform, so a macOS build that ignored it would read as broken rather than principled. Intercepting all four combinations everywhere buys that, at the known cost of `0x16` — §8.3's `Ctrl+Q` covers the shells, and nothing covers a program implementing neither. +**Why paste breaks the clean macOS separation that copy keeps.** On macOS `⌘C` is copy and `Ctrl+C` is SIGINT, and honoring that split costs nothing — a Mac user reaching for copy reaches for `⌘`. Paste is not symmetric: `Ctrl+V` is the universal expectation on every platform, so a macOS build that ignored it would read as broken rather than principled. Intercepting all four combinations everywhere buys that, at the known cost of `0x16`; a program's literal-next prefix cannot bypass the webview's paste-chord interception. + +## 8.3 Program Literal-Next Input + +The [Bash reference manual](https://www.gnu.org/software/bash/manual/html_node/Commands-For-Text.html) binds Readline's quoted-insert to Ctrl+Q or Ctrl+V. [Vim's insert-mode manual](https://github.com/vim/vim/blob/master/runtime/doc/insert.txt#L201-L204) also aliases Ctrl+Q to Ctrl+V, with terminal-flow-control caveats (checked 2026-10-01). These are program bindings, not a Dormouse literal-next state: the wall intercepts a subsequent Ctrl+V before the program sees it. The earlier claim that Ctrl+Q supplied a route to byte 0x16, and that Vim lacked it, conflated the two layers. ## 8.6 Paste Content diff --git a/docs/specs/shortcuts.md b/docs/specs/shortcuts.md index 56f0feeb9..f9d52504a 100644 --- a/docs/specs/shortcuts.md +++ b/docs/specs/shortcuts.md @@ -62,7 +62,7 @@ Both modes, ahead of the passthrough gate, and only on a terminal **selected** S | `e` / `⇧E`, `f` / `⇧F`, `←` `→` / `⇧←` `⇧→`, `↵` | Copy editor | Passthrough only: scope, format, edges, copy (`docs/specs/mouse-and-clipboard.md` §4.3). | | `⌘V` / `⌘⇧V` / `Ctrl+V` / `Ctrl+Shift+V` | Paste | Paste into the terminal; the `Ctrl` variants are intercepted on every platform, macOS included. | -On macOS `Ctrl+C` still reaches the running program; a literal `0x16` needs the shell's `quoted-insert` (`Ctrl+Q`) (`docs/specs/mouse-and-clipboard.md` §8.3). +On macOS `Ctrl+C` still reaches the running program. Literal-next input: `docs/specs/mouse-and-clipboard.md` §8.3. ## Browser surfaces (passthrough) diff --git a/docs/specs/theme.md b/docs/specs/theme.md index 14481aa1d..527acdda6 100644 --- a/docs/specs/theme.md +++ b/docs/specs/theme.md @@ -39,14 +39,7 @@ derive a hairline from the pair foreground at low alpha or an inset shadow ### Dynamic picks -`lib/src/theme-colors.css` binds most tokens to a fixed VSCode key. Seven are picked at -runtime instead: - -| Token | Pick | -|---|---| -| `--color-door-bg` / `--color-door-fg` | whichever pair — inactive-header or terminal bg/fg — sits further from `--color-app-bg` in OKLab | -| `--color-focus-ring` | a chromatic `focusBorder`, else a chromatic active-header background, else the candidate furthest from `--color-app-bg`; "chromatic" is OKLab chroma ≥ `FOCUS_RING_SATURATION_FLOOR` | -| `--color-alarm-vs-{header-active,header-inactive,door,terminal}` | plain white or black, by the OKLab lightness of the background the alert treatment sits on (rationale) | +`lib/src/theme-colors.css` binds most tokens to fixed VSCode keys. **Must derive dynamic tokens through the shared palette functions:** Door bg/fg through `pickDoorPair`, focus ring through `pickFocusRing`, and `--color-alarm-vs-{header-active,header-inactive,door,terminal}` through `pickAlarmColor` (rationale). Their choice algorithms belong beside those functions. The terminal alarm tint drives the whole-Pane alarm overlay. **Must derive the Door alarm tint from the newly chosen background in the same @@ -93,11 +86,7 @@ cancel queued resolution on disposal.** Pinned by Dormouse uses them as solid header and Workspace-tab fills, so `applyTheme()` composites them over `sideBar.background` first (rationale). -**A same-*object* `applyTheme()` call is a no-op only while the expected inline -`--vscode-*` variables, `color-scheme`, and the `vscode-light` / `vscode-dark` class are still on -`document.body`** — a fresh object for the same id re-applies, and the id -comparison gates only the listener notification — and **ThemePicker re-restores in a layout effect after mount** -— React Router document hydration can reconcile those writes away (rationale). +**Must repair theme variables, theme class, and `color-scheme` lost during document hydration**, including when applying the same theme object; only an id change notifies listeners. **ThemePicker must re-restore in a layout effect after mount** (rationale). Each layer declares its theme-dependent tokens twice: at document level (`@theme` so Tailwind generates utility classes, or `:root`) and on `body`, the @@ -172,13 +161,7 @@ imported VSCode theme JSON to `CONSUMED_VSCODE_KEYS`), and **may omit any key VSCode itself would omit** — `completeThemeVars()` fills those from registry defaults and the inheritance rules above. -`lib/scripts/bundle-themes.mjs` bakes the bundled themes at build time (VSIX from -OpenVSX → unzip → resolve `%nls%` labels → filter colors → `bundled.json` + -`bundled-extensions.json`, **both checked in so builds need no network**). -`lib/src/lib/themes/openvsx.ts` does the same in-browser for user-installed -themes, **dynamically importing `fflate` and `jsonc-parser`** to keep them out of -the initial bundle. The build script cannot import TS, so it restates the -consumed-key list; `lib/src/lib/themes/consumed-keys.test.ts` pins the two. +**Must check in both generated bundles so builds need no network.** **Must use `convertVscodeThemeColors` and `uiThemeToType` in both the build and browser importers.** The build tool imports their TypeScript module through the pinned Node runtime. **Must dynamically import `fflate` and `jsonc-parser` for installed themes**, keeping them out of the initial bundle. **Never** ship a theme in `bundled.json` without its `bundled-extensions.json` record, or keep a record no theme uses — that file is the provenance the @@ -196,7 +179,7 @@ id, not object identity** (rationale). It serves the website tutorial's theme st ([tutorial.md](./tutorial.md)); **never** reach for `onTerminalThemeChange()` instead (rationale). -Source of truth: `getInstalledThemes()` / `getStoredActiveThemeId()` / +Source of truth: `DormouseTheme` in `lib/src/lib/themes/types.ts`; build importer in `lib/scripts/bundle-themes.mjs`; `fetchExtensionThemes` in `lib/src/lib/themes/openvsx.ts`; `convertVscodeThemeColors` / `uiThemeToType` in `lib/src/lib/themes/convert.ts`; `getInstalledThemes()` / `getStoredActiveThemeId()` / `setActiveThemeId()` in `lib/src/lib/themes/store.ts`; `subscribeToActiveTheme()` in `lib/src/lib/themes/apply.ts`; `onTerminalThemeChange()` in `lib/src/lib/terminal-theme.ts`. @@ -303,14 +286,7 @@ Source of truth: `SNAPSHOT_EDITOR_FONT_FAMILY` in `lib/.storybook/themes.ts`; ## Theme debugger The Theme Debugger serves VSCode, standalone, and the website -playground. **Never mutate theme storage or terminal colors** — snapshot -DOM-visible state: theme metadata, consumed color `--vscode-*` tagged -host-provided vs Dormouse-materialized with its declaration site and resolver -trace, static `--color-*` tokens with their bound key, the terminal palette xterm.js -reads, and Door/focus-ring picks with candidate metrics and a prose reason -(`ThemeDiagnosticSnapshot` owns the shape). The copied report dumps -the same snapshot. **A real VSCode webview shows only the *inferred* theme -kind**, since VSCode exposes CSS variables and not raw built-in theme JSON. +playground. **Must capture DOM-visible state through `ThemeDiagnosticSnapshot` without mutating theme storage or terminal colors.** Terminal colors are the visible CSS variables, including missing values, rather than an initialized xterm instance's palette. The copied report uses the same snapshot. **A real VSCode webview shows only the inferred theme kind**, since the host supplies CSS variables. Every host reaches it as `Debug current theme` in the `ThemePicker` menu, so on `/playground/pocket` it rides the `compact` variant (two mounts, defaulting to diff --git a/lib/scripts/bundle-themes.mjs b/lib/scripts/bundle-themes.mjs index c759d2c37..49c7dea2e 100644 --- a/lib/scripts/bundle-themes.mjs +++ b/lib/scripts/bundle-themes.mjs @@ -13,6 +13,9 @@ import { resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { unzipSync } from 'fflate'; import { parse as parseJsonc } from 'jsonc-parser'; +// The workspace pins Node 24: native type stripping lets this build tool use +// the same filter and UI-kind mapping as the browser importer. +import { convertVscodeThemeColors, uiThemeToType } from '../src/lib/themes/convert.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); const OUTPUT = resolve(__dirname, '../src/lib/themes/bundled.json'); @@ -40,44 +43,6 @@ const EXCLUDED_THEMES = new Set([ 'vscode.theme-defaults.light-high-contrast', ]); -/** - * VSCode theme color keys consumed by Dormouse. - * Keep in sync with lib/src/lib/themes/convert.ts. - */ -const CONSUMED_KEYS = new Set([ - 'foreground', - 'editor.background', 'sideBar.background', 'sideBar.foreground', - 'editorWidget.background', 'editor.selectionBackground', 'editor.foreground', 'descriptionForeground', - 'focusBorder', 'panel.border', - 'list.activeSelectionBackground', 'list.activeSelectionForeground', - 'list.inactiveSelectionBackground', 'list.inactiveSelectionForeground', - 'terminal.background', 'terminal.foreground', - 'errorForeground', - 'input.background', 'input.border', - 'button.background', 'button.foreground', - 'textLink.foreground', - 'terminalCursor.foreground', 'terminal.selectionBackground', - 'terminal.ansiBlack', 'terminal.ansiRed', 'terminal.ansiGreen', 'terminal.ansiYellow', - 'terminal.ansiBlue', 'terminal.ansiMagenta', 'terminal.ansiCyan', 'terminal.ansiWhite', - 'terminal.ansiBrightBlack', 'terminal.ansiBrightRed', 'terminal.ansiBrightGreen', - 'terminal.ansiBrightYellow', 'terminal.ansiBrightBlue', 'terminal.ansiBrightMagenta', - 'terminal.ansiBrightCyan', 'terminal.ansiBrightWhite', -]); - -function convertColors(colors) { - const vars = {}; - for (const [key, value] of Object.entries(colors)) { - if (CONSUMED_KEYS.has(key)) { - vars[`--vscode-${key.replace(/\./g, '-')}`] = value; - } - } - return vars; -} - -function uiThemeToType(uiTheme) { - return uiTheme === 'vs' || uiTheme === 'hc-light' ? 'light' : 'dark'; -} - function slugify(label) { return label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, ''); } @@ -134,7 +99,7 @@ async function fetchExtensionThemes(namespace, name) { const themeJson = parseJsonc(readEntry(entries, themePath)); const colors = themeJson.colors ?? {}; - const vars = convertColors(colors); + const vars = convertVscodeThemeColors(colors); const type = uiThemeToType(contrib.uiTheme ?? themeJson.type ?? 'vs-dark'); themes.push({ diff --git a/lib/src/components/MobileGestureRadialMenu.tsx b/lib/src/components/MobileGestureRadialMenu.tsx index d0f7bb9da..a5ecbcc48 100644 --- a/lib/src/components/MobileGestureRadialMenu.tsx +++ b/lib/src/components/MobileGestureRadialMenu.tsx @@ -243,6 +243,14 @@ function OptionChip({ ); } +/** Pack root chips as a square keypad. Cardinal centers sit one + * `GAP_CARDINAL_RING` beyond the select circle. Each diagonal's first option + * anchors its inward corner at that same horizontal/vertical gap: SE top-left, + * NE bottom-left, SW top-right, NW bottom-right. Its secondaries are stacked + * above/below at `GAP_CLUSTER` to the right for NE/SE and left for NW/SW. + * `ROOT_DIAGONAL_CORNER_RADIUS` scales the diagonal gap, rather than treating + * the root chips as exploded circular option anchors. + */ function rootOptionLayout( direction: MobileGestureDirection, index: number, diff --git a/lib/src/components/wall/MouseOverrideBanner.tsx b/lib/src/components/wall/MouseOverrideBanner.tsx index 396b1fec8..e4c0dccc0 100644 --- a/lib/src/components/wall/MouseOverrideBanner.tsx +++ b/lib/src/components/wall/MouseOverrideBanner.tsx @@ -14,12 +14,27 @@ export function MouseOverrideBanner({ terminalId }: { terminalId: string }) { useEffect(() => { if (!flashed) return; + if (!visible) { + setFlashed(null); + return; + } const id = window.setTimeout(() => { - setMouseOverride(terminalId, flashed === 'sticky' ? 'permanent' : 'off'); + if (getMouseSelectionState(terminalId).override === 'temporary') { + setMouseOverride(terminalId, flashed === 'sticky' ? 'permanent' : 'off'); + } setFlashed(null); }, 260); - return () => window.clearTimeout(id); - }, [flashed, terminalId]); + // Observe each store transition: React can batch an override ending and + // restarting into one visible render. The old flash belongs to neither + // a later temporary override nor a new mouse-reporting program. + const unsubscribe = subscribeToMouseSelection(() => { + if (getMouseSelectionState(terminalId).override !== 'temporary') { + window.clearTimeout(id); + setFlashed(null); + } + }); + return () => { window.clearTimeout(id); unsubscribe(); }; + }, [flashed, terminalId, visible]); if (!visible) return null; diff --git a/lib/src/components/wall/mouse-chrome.test.tsx b/lib/src/components/wall/mouse-chrome.test.tsx index 6db8b6827..c60a9b074 100644 --- a/lib/src/components/wall/mouse-chrome.test.tsx +++ b/lib/src/components/wall/mouse-chrome.test.tsx @@ -11,6 +11,7 @@ import { setPlatform } from '../../lib/platform'; import { __resetMouseSelectionForTests, beginDrag, + getMouseSelectionState, setHintToken, setMouseReporting, setOverride, @@ -116,3 +117,43 @@ it('updates only the owning pane for reporting and override transitions', () => expect(banner()).toBeNull(); expect(commits.mock.calls.some(([id]) => id.endsWith('-two'))).toBe(false); }); + +it.each(['restore', 'reporting off'] as const)('does not revive an override after %s during Make sticky confirmation', (ended) => { + vi.useFakeTimers(); + setMouseReporting('one', 'vt200'); + setOverride('one', 'temporary'); + renderChrome(); + const pane = container.querySelector('[data-test-pane="one"]')!; + const sticky = pane.querySelector('[role="status"] button')!; + act(() => sticky.click()); + act(() => { + if (ended === 'restore') setOverride('one', 'off'); + else setMouseReporting('one', 'none'); + }); + // A new reporting program cannot inherit the old button's queued action. + if (ended === 'reporting off') act(() => setMouseReporting('one', 'vt200')); + act(() => vi.advanceTimersByTime(260)); + expect(getMouseSelectionState('one').override).toBe('off'); + expect(pane.querySelector('[role="status"]')).toBeNull(); +}); + +it.each(['sticky', 'cancel'] as const)('does not apply a stale %s action to an override restarted in one update', (action) => { + vi.useFakeTimers(); + setMouseReporting('one', 'vt200'); + setOverride('one', 'temporary'); + renderChrome(); + const pane = container.querySelector('[data-test-pane="one"]')!; + const buttons = pane.querySelectorAll('[role="status"] button'); + act(() => buttons[action === 'sticky' ? 0 : 1]!.click()); + act(() => { + setOverride('one', 'off'); + setOverride('one', 'temporary'); + }); + act(() => vi.advanceTimersByTime(260)); + expect(getMouseSelectionState('one').override).toBe('temporary'); + expect(pane.querySelector('[role="status"]')).not.toBeNull(); + // The new override must accept its own action after the old flash ended. + act(() => pane.querySelector('[role="status"] button')!.click()); + act(() => vi.advanceTimersByTime(260)); + expect(getMouseSelectionState('one').override).toBe('permanent'); +}); diff --git a/lib/src/lib/copy-text.ts b/lib/src/lib/copy-text.ts index de55693ea..f5da646ee 100644 --- a/lib/src/lib/copy-text.ts +++ b/lib/src/lib/copy-text.ts @@ -199,8 +199,8 @@ function paragraphLines(buf: CopyBuffer, from: LineFacts, dir: 1 | -1): LineFact * read the whole of it: with the line's index and the paragraph's size. */ const paragraphWidth = new WeakMap(); -/** The width a program wrapped the paragraph around the break after row `r` - * at, as best the text says: its longest line (rationale). */ +/** Estimate the wrap width from the longest logical line near this break, + * walking at most `WIDTH_REACH` lines each way within its paragraph. */ function wrapWidth(buf: CopyBuffer, r: number): number { const above = line(buf, r); const below = line(buf, r + 1); @@ -223,7 +223,17 @@ const minWrapWidth = (buf: CopyBuffer) => Math.min(40, Math.floor(buf.cols * 0.6 export type BreakKind = 'keep' | 'space' | 'none'; -/** Auto's judgement of the line break between rows `r` and `r + 1`. */ +/** Auto judges the break between rows `r` and `r + 1` in order: + * 1. Delete a true soft wrap before inspecting the joined logical lines. + * 2. Keep blanks, a new list item, code punctuation (`; { }` / `) } ]`), or + * indentation that differs from the current line's hanging indent. + * 3. Keep a break when the local longest line is under `minWrapWidth`, or + * when the next first word would have fit within that width. + * 4. Delete a margin split whose last word is token-shaped; otherwise space. + * `wrapWidth` reads at most `WIDTH_REACH` logical lines on each side within + * the paragraph, not necessarily the whole paragraph. Its cache is reusable + * only when that bounded walk would reach both paragraph boundaries. + */ export function autoBreak(buf: CopyBuffer, r: number): BreakKind { if (r + 1 >= buf.length) return 'keep'; if (buf.row(r + 1).wrapped) return 'none'; diff --git a/lib/src/lib/mobile-terminal-scroll.ts b/lib/src/lib/mobile-terminal-scroll.ts index 8ec06f4be..af90d34fe 100644 --- a/lib/src/lib/mobile-terminal-scroll.ts +++ b/lib/src/lib/mobile-terminal-scroll.ts @@ -13,7 +13,15 @@ const RELEASE_PAUSE_MS = 80; const MIN_VELOCITY = 0.05; // CSS px/ms (also the stopping speed) const MAX_VELOCITY = 3; -/** Pixel motion shared by the drag and its coast; xterm consumes whole lines. */ +/** Pixel motion shared by drag and coast; xterm consumes whole lines while + * fractional-line travel survives release. Estimate velocity over the last + * `VELOCITY_WINDOW_MS` (100 ms) of the current direction, resetting history + * on reversal or a held finger. Include release-time pauses in the estimate; + * `RELEASE_PAUSE_MS` (80 ms) suppresses momentum. Clamp launch speed to + * `MAX_VELOCITY` (3 CSS px/ms), decay by 0.998 per millisecond, integrate over + * elapsed frame time, and stop below `MIN_VELOCITY` (0.05 CSS px/ms). + * The caller alone decides whether the pointer type/release may coast. + */ export class EdgeScrollMotion { private samples: Array<{ y: number; time: number }>; private remainder = 0; diff --git a/lib/src/lib/themes/bundle-generator.test.ts b/lib/src/lib/themes/bundle-generator.test.ts new file mode 100644 index 000000000..767e88bae --- /dev/null +++ b/lib/src/lib/themes/bundle-generator.test.ts @@ -0,0 +1,71 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { basename, dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { spawnSync } from 'node:child_process'; +import { expect, it } from 'vitest'; + +it('runs the actual bundle generator with the shared color conversion', () => { + const script = resolve(dirname(fileURLToPath(import.meta.url)), '../../../scripts/bundle-themes.mjs'); + const fixture = mkdtempSync(join(tmpdir(), 'dormouse-theme-generator-')); + try { + const preload = join(fixture, 'openvsx-fixture.mjs'); + writeFileSync(preload, + "import fs from 'node:fs';\n" + + "import { basename, dirname, join, resolve } from 'node:path';\n" + + "import { createRequire, syncBuiltinESMExports } from 'node:module';\n" + + "import { pathToFileURL } from 'node:url';\n" + + "const script = " + JSON.stringify(script) + ";\n" + + "const fixture = " + JSON.stringify(fixture) + ";\n" + + "const { zipSync, strToU8 } = createRequire(pathToFileURL(script))('fflate');\n" + + "const vsix = zipSync({\n" + + " 'extension/package.json': strToU8(JSON.stringify({ contributes: { themes: [\n" + + " { path: './light.json', label: '%light%', uiTheme: 'hc-light' },\n" + + " { path: './dark.json', label: 'Test Dark', uiTheme: 'vs-dark' },\n" + + " ] } })),\n" + + " 'extension/package.nls.json': strToU8(JSON.stringify({ light: { message: 'Test Light' } })),\n" + + " 'extension/light.json': strToU8(JSON.stringify({ colors: { 'editor.background': '#123456', 'list.activeSelectionForeground': '#abcdef', 'terminal.ansiMagenta': '#fedcba', 'unconsumed.key': '#654321' } })),\n" + + " 'extension/dark.json': strToU8(JSON.stringify({ colors: {} })),\n" + + "});\n" + + "globalThis.fetch = async (input) => {\n" + + " const url = new URL(input);\n" + + " if (url.origin === 'https://open-vsx.org' && url.pathname.endsWith('/latest')) return Response.json({ version: '1.2.3', files: { download: 'https://theme-fixture.invalid/test.vsix' } });\n" + + " if (url.href === 'https://theme-fixture.invalid/test.vsix') return new Response(vsix);\n" + + " throw new Error('unexpected network request: ' + url.href);\n" + + "};\n" + + "const allowed = new Set(['bundled.json', 'bundled-extensions.json'].map(name => resolve(dirname(script), '../src/lib/themes', name)));\n" + + "const originalWrite = fs.writeFileSync;\n" + + "fs.writeFileSync = (destination, ...args) => {\n" + + " const target = resolve(String(destination));\n" + + " if (!allowed.has(target)) throw new Error('unexpected generator output: ' + target);\n" + + " originalWrite(join(fixture, basename(target)), ...args);\n" + + "};\n" + + "syncBuiltinESMExports();\n", + ); + const result = spawnSync(process.execPath, ['--import', pathToFileURL(preload).href, script], { + encoding: 'utf8', timeout: 15_000, windowsHide: true, + }); + expect(result.error).toBeUndefined(); + expect(result.status, result.stderr).toBe(0); + const themes = JSON.parse(readFileSync(join(fixture, 'bundled.json'), 'utf8')); + const extensions = JSON.parse(readFileSync(join(fixture, 'bundled-extensions.json'), 'utf8')); + expect(extensions.length).toBeGreaterThan(0); + expect(themes).toHaveLength(extensions.length * 2); + for (const extension of extensions) { + const owned = themes.filter((theme: { id: string }) => theme.id.startsWith(extension.extensionId + '.')); + expect(owned).toHaveLength(2); + expect(owned[0]).toMatchObject({ label: 'Test Light', type: 'light', vars: { + '--vscode-editor-background': '#123456', + '--vscode-list-activeSelectionForeground': '#abcdef', + '--vscode-terminal-ansiMagenta': '#fedcba', + } }); + expect(Object.keys(owned[0].vars)).toHaveLength(3); + expect(owned[1]).toMatchObject({ label: 'Test Dark', type: 'dark', vars: {} }); + expect(extension.version).toBe('1.2.3'); + } + } finally { + // mkdtemp owns only this new fixture directory, never checked-in bundles. + if (dirname(fixture) !== resolve(tmpdir()) || !basename(fixture).startsWith('dormouse-theme-generator-')) throw new Error('unexpected fixture path'); + rmSync(fixture, { recursive: true, force: true }); + } +}); diff --git a/lib/src/lib/themes/consumed-keys.test.ts b/lib/src/lib/themes/consumed-keys.test.ts index be2a21065..f807f6155 100644 --- a/lib/src/lib/themes/consumed-keys.test.ts +++ b/lib/src/lib/themes/consumed-keys.test.ts @@ -2,36 +2,6 @@ import { readFileSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, resolve } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { CONSUMED_VSCODE_KEYS } from './convert'; - -// The bundle script is a .mjs build-time tool that can't import TS, so it -// duplicates CONSUMED_VSCODE_KEYS as a local set. This test pins the two -// lists together — adding a key to one without the other will fail loudly -// instead of producing a bundled.json that's missing colors used by the app -// at runtime. -describe('CONSUMED_VSCODE_KEYS / bundle-themes.mjs parity', () => { - const here = dirname(fileURLToPath(import.meta.url)); - const scriptPath = resolve(here, '../../../scripts/bundle-themes.mjs'); - const scriptSource = readFileSync(scriptPath, 'utf8'); - - const consumedBlock = scriptSource.match(/const CONSUMED_KEYS = new Set\(\[([\s\S]*?)\]\);/); - if (!consumedBlock) throw new Error('Could not locate CONSUMED_KEYS in bundle-themes.mjs'); - const scriptKeys = new Set( - Array.from(consumedBlock[1].matchAll(/'([^']+)'/g), (match: RegExpExecArray) => match[1]), - ); - - it('every key in convert.ts CONSUMED_VSCODE_KEYS is in bundle-themes.mjs CONSUMED_KEYS', () => { - const missing = CONSUMED_VSCODE_KEYS.filter((k) => !scriptKeys.has(k)); - expect(missing).toEqual([]); - }); - - it('every key in bundle-themes.mjs CONSUMED_KEYS is in convert.ts CONSUMED_VSCODE_KEYS', () => { - const consumedSet = new Set(CONSUMED_VSCODE_KEYS); - const extra = [...scriptKeys].filter((k) => !consumedSet.has(k)); - expect(extra).toEqual([]); - }); -}); - // Every var()-bound token declared at document level (@theme or :root) must be // mirrored onto body with the same value, or it resolves to nothing outside // VS Code — rationale in docs/specs/theme.md. Values are compared, not just diff --git a/lib/src/lib/themes/dynamic-palette.ts b/lib/src/lib/themes/dynamic-palette.ts index 61ef73c84..2d5fe7e0b 100644 --- a/lib/src/lib/themes/dynamic-palette.ts +++ b/lib/src/lib/themes/dynamic-palette.ts @@ -18,6 +18,12 @@ export interface FocusRingCandidate { export const FOCUS_RING_SATURATION_FLOOR = 0.05; +/** + * Prefer a chromatic preferred candidate; otherwise choose the strongest + * chromatic nonpreferred candidate, then the candidate furthest from appLab. + * Chromatic means chroma >= FOCUS_RING_SATURATION_FLOOR; ties retain input order. + * Runtime and diagnostics share this rule (dynamic-palette.picks.test.ts). + */ export function pickFocusRing(candidates: readonly FocusRingCandidate[], appLab: Lab): FocusRingCandidate | null { if (candidates.length === 0) return null; @@ -53,6 +59,7 @@ export interface DoorChoice { fg: '--color-header-inactive-fg' | '--color-terminal-fg'; } +/** Choose the bg/fg pair furthest from the app in OKLab; ties take chrome. */ export function pickDoorPair(panelLab: Lab, terminalLab: Lab, appLab: Lab): DoorChoice { const panelDist = deltaEOklab(panelLab, appLab); const termDist = deltaEOklab(terminalLab, appLab); diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 698474a5c..8bebab38f 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -14,8 +14,8 @@ "docs/specs/glossary.md": 3000, "docs/specs/hosted.md": 4800, "docs/specs/layout.md": 11750, - "docs/specs/mobile-terminal-ui.md": 2300, - "docs/specs/mouse-and-clipboard.md": 5250, + "docs/specs/mobile-terminal-ui.md": 2100, + "docs/specs/mouse-and-clipboard.md": 5000, "docs/specs/one-time.md": 3850, "docs/specs/pocket-app.md": 5200, "docs/specs/relay.md": 11100, @@ -34,7 +34,7 @@ "docs/specs/terminal-context.md": 1100, "docs/specs/terminal-escapes.md": 4050, "docs/specs/terminal-state.md": 2400, - "docs/specs/theme.md": 2400, + "docs/specs/theme.md": 2300, "docs/specs/tiling-engine.md": 4450, "docs/specs/transport.md": 4750, "docs/specs/tutorial.md": 2050, diff --git a/scripts/xterm-bump.test.mjs b/scripts/xterm-bump.test.mjs index cc5f149ec..57c1b5d01 100644 --- a/scripts/xterm-bump.test.mjs +++ b/scripts/xterm-bump.test.mjs @@ -4,6 +4,7 @@ import { copyFileSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSy import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { test } from 'node:test'; +import { pathToFileURL } from 'node:url'; import { gzipSync } from 'node:zlib'; const CORE = '@xterm/xterm'; @@ -55,7 +56,7 @@ function run(args, { manifest = {}, missingRelease = false, mismatchPeer = false throw new Error('unexpected network request: ' + url); }; `); - const result = spawnSync(process.execPath, ['--import', join(root, 'fetch.mjs'), join(root, 'scripts/xterm-bump.mjs'), ...args], { encoding: 'utf8' }); + const result = spawnSync(process.execPath, ['--import', pathToFileURL(join(root, 'fetch.mjs')).href, join(root, 'scripts/xterm-bump.mjs'), ...args], { encoding: 'utf8' }); const packages = Object.fromEntries(['lib', 'standalone', 'canopy'].map((dir) => [dir, JSON.parse(readFileSync(join(root, dir, 'package.json'))).dependencies])); return { ...result, packages }; } finally { @@ -77,16 +78,17 @@ test('canopy dry-run verifies the release without rewriting pins', () => { assert.equal(result.packages.canopy[FORK], 'old-url'); assert.match(result.stdout, /canopy → fork .*@xterm\/xterm 6\.1\.0-beta\.12/); }); -for (const [label, options] of [ - ['missing release', { missingRelease: true }], - ['wrong archive identity', { manifest: { name: '@xterm/addon-webgl' } }], - ['wrong archive version', { manifest: { version: '0.20.0-sdf12.1' } }], - ['missing core peer', { manifest: { peerDependencies: {} } }], - ['peer counter mismatch', { manifest: { peerDependencies: { [CORE]: '^6.1.0-beta.13' } } }], - ['upstream addon peer mismatch', { mismatchPeer: true }], +for (const [label, options, reason] of [ + ['missing release', { missingRelease: true }, /release 404/], + ['wrong archive identity', { manifest: { name: '@xterm/addon-webgl' } }, new RegExp('release manifest identity/core peer disagrees')], + ['wrong archive version', { manifest: { version: '0.20.0-sdf12.1' } }, new RegExp('release manifest identity/core peer disagrees')], + ['missing core peer', { manifest: { peerDependencies: {} } }, new RegExp('release manifest identity/core peer disagrees')], + ['peer counter mismatch', { manifest: { peerDependencies: { [CORE]: '^6.1.0-beta.13' } } }, new RegExp('release manifest identity/core peer disagrees')], + ['upstream addon peer mismatch', { mismatchPeer: true }, new RegExp('no @xterm/addon-webgl published')], ]) test(`canopy refuses ${label} before rewriting pins`, () => { const result = run(canopyArgs, options); assert.notEqual(result.status, 0); + assert.match(result.stderr, reason); assert.equal(result.packages.canopy[FORK], 'old-url'); }); test('default bump repairs standalone when lib already has the newest coherent set', () => { From 3af86e315c450d7110a3b56b162c6dc76f8873c6 Mon Sep 17 00:00:00 2001 From: Ned Date: Thu, 1 Oct 2026 22:04:03 -0700 Subject: [PATCH 2/3] State the pending mouse-banner action rule once --- docs/specs/mouse-and-clipboard.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specs/mouse-and-clipboard.md b/docs/specs/mouse-and-clipboard.md index 97796e665..0c32b9c9f 100644 --- a/docs/specs/mouse-and-clipboard.md +++ b/docs/specs/mouse-and-clipboard.md @@ -48,7 +48,7 @@ It ends on the **next mouse-up inside the terminal content area** paired with a - **Counts:** a plain primary click (down/up that never crossed the drag threshold) or a completed drag. - **Does not count:** a non-primary click, whose context menu the override swallows anyway; clicks on the No-Mouse icon or the banner buttons; and an orphan mouse-up from a drag that started outside the terminal. Pinned by `lib/src/lib/terminal-mouse-router.test.ts`. -- **On end** — or on **Cancel**, after that button's 260 ms confirmation flash — reporting is restored, banner dismissed, Mouse icon back. **Must cancel a pending banner action when its temporary override ends**, never reactivating it later. **Must cancel a pending banner action when its temporary override ends**, never reactivating it later. **No timeout:** absent any mouse action the override stays indefinitely. +- **On end** — or on **Cancel**, after that button's 260 ms confirmation flash — reporting is restored, banner dismissed, Mouse icon back. **Must cancel a pending banner action when its temporary override ends**, never reactivating it later. **No timeout:** absent any mouse action the override stays indefinitely. **Sticky override.** **Make sticky** converts it after the same flash (the store calls this state `permanent`): banner dismissed, No-Mouse icon kept with its "click to restore" hover text, mouse and wheel still going to the terminal. It persists until the user clicks the No-Mouse icon. From 2d820cb76b3fd28654bcdda4540c650122713903 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Thu, 1 Oct 2026 22:28:30 -0700 Subject: [PATCH 3/3] Drop the theme bundle-generator subprocess test; correct radial placement The original replaced a 30-line source-text parity test with a 71-line subprocess test that ran the real generator under a string-built preload patching fetch and fs.writeFileSync. If the generator ever wrote through fs/promises, the patch would miss and the test would overwrite the checked-in bundled.json. The dedup it guarded already fails loudly: bundle-themes imports convert.ts directly, so a broken import stops `pnpm bundle-themes`. The original also said the diagonal secondaries are "stacked outward". rootOptionLayout puts them above and below the primary, on its far side. Co-Authored-By: Claude Opus 5.5 --- docs/specs/mobile-terminal-ui.md | 2 +- lib/src/lib/themes/bundle-generator.test.ts | 71 --------------------- 2 files changed, 1 insertion(+), 72 deletions(-) delete mode 100644 lib/src/lib/themes/bundle-generator.test.ts diff --git a/docs/specs/mobile-terminal-ui.md b/docs/specs/mobile-terminal-ui.md index 822c0eda7..f516564a7 100644 --- a/docs/specs/mobile-terminal-ui.md +++ b/docs/specs/mobile-terminal-ui.md @@ -180,7 +180,7 @@ in `lib/src/components/MobileGestureRadialMenu.tsx`. **Must pack root labels as a square keypad** (rationale). Cardinal chips share one gap from the select circle; **each diagonal group renders three separate labels, never one combined pill**, with its primary nearest the circle and its -secondaries stacked outward. Corner anchors and spacing live at +secondaries above and below its far side. Corner anchors and spacing live at `rootOptionLayout`; the key inventory lives at `MOBILE_GESTURE_GROUPS`. **Must confirm `⌃C` and `Paste` in an in-pane modal before running them.** diff --git a/lib/src/lib/themes/bundle-generator.test.ts b/lib/src/lib/themes/bundle-generator.test.ts deleted file mode 100644 index 767e88bae..000000000 --- a/lib/src/lib/themes/bundle-generator.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { basename, dirname, join, resolve } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; -import { spawnSync } from 'node:child_process'; -import { expect, it } from 'vitest'; - -it('runs the actual bundle generator with the shared color conversion', () => { - const script = resolve(dirname(fileURLToPath(import.meta.url)), '../../../scripts/bundle-themes.mjs'); - const fixture = mkdtempSync(join(tmpdir(), 'dormouse-theme-generator-')); - try { - const preload = join(fixture, 'openvsx-fixture.mjs'); - writeFileSync(preload, - "import fs from 'node:fs';\n" + - "import { basename, dirname, join, resolve } from 'node:path';\n" + - "import { createRequire, syncBuiltinESMExports } from 'node:module';\n" + - "import { pathToFileURL } from 'node:url';\n" + - "const script = " + JSON.stringify(script) + ";\n" + - "const fixture = " + JSON.stringify(fixture) + ";\n" + - "const { zipSync, strToU8 } = createRequire(pathToFileURL(script))('fflate');\n" + - "const vsix = zipSync({\n" + - " 'extension/package.json': strToU8(JSON.stringify({ contributes: { themes: [\n" + - " { path: './light.json', label: '%light%', uiTheme: 'hc-light' },\n" + - " { path: './dark.json', label: 'Test Dark', uiTheme: 'vs-dark' },\n" + - " ] } })),\n" + - " 'extension/package.nls.json': strToU8(JSON.stringify({ light: { message: 'Test Light' } })),\n" + - " 'extension/light.json': strToU8(JSON.stringify({ colors: { 'editor.background': '#123456', 'list.activeSelectionForeground': '#abcdef', 'terminal.ansiMagenta': '#fedcba', 'unconsumed.key': '#654321' } })),\n" + - " 'extension/dark.json': strToU8(JSON.stringify({ colors: {} })),\n" + - "});\n" + - "globalThis.fetch = async (input) => {\n" + - " const url = new URL(input);\n" + - " if (url.origin === 'https://open-vsx.org' && url.pathname.endsWith('/latest')) return Response.json({ version: '1.2.3', files: { download: 'https://theme-fixture.invalid/test.vsix' } });\n" + - " if (url.href === 'https://theme-fixture.invalid/test.vsix') return new Response(vsix);\n" + - " throw new Error('unexpected network request: ' + url.href);\n" + - "};\n" + - "const allowed = new Set(['bundled.json', 'bundled-extensions.json'].map(name => resolve(dirname(script), '../src/lib/themes', name)));\n" + - "const originalWrite = fs.writeFileSync;\n" + - "fs.writeFileSync = (destination, ...args) => {\n" + - " const target = resolve(String(destination));\n" + - " if (!allowed.has(target)) throw new Error('unexpected generator output: ' + target);\n" + - " originalWrite(join(fixture, basename(target)), ...args);\n" + - "};\n" + - "syncBuiltinESMExports();\n", - ); - const result = spawnSync(process.execPath, ['--import', pathToFileURL(preload).href, script], { - encoding: 'utf8', timeout: 15_000, windowsHide: true, - }); - expect(result.error).toBeUndefined(); - expect(result.status, result.stderr).toBe(0); - const themes = JSON.parse(readFileSync(join(fixture, 'bundled.json'), 'utf8')); - const extensions = JSON.parse(readFileSync(join(fixture, 'bundled-extensions.json'), 'utf8')); - expect(extensions.length).toBeGreaterThan(0); - expect(themes).toHaveLength(extensions.length * 2); - for (const extension of extensions) { - const owned = themes.filter((theme: { id: string }) => theme.id.startsWith(extension.extensionId + '.')); - expect(owned).toHaveLength(2); - expect(owned[0]).toMatchObject({ label: 'Test Light', type: 'light', vars: { - '--vscode-editor-background': '#123456', - '--vscode-list-activeSelectionForeground': '#abcdef', - '--vscode-terminal-ansiMagenta': '#fedcba', - } }); - expect(Object.keys(owned[0].vars)).toHaveLength(3); - expect(owned[1]).toMatchObject({ label: 'Test Dark', type: 'dark', vars: {} }); - expect(extension.version).toBe('1.2.3'); - } - } finally { - // mkdtemp owns only this new fixture directory, never checked-in bundles. - if (dirname(fixture) !== resolve(tmpdir()) || !basename(fixture).startsWith('dormouse-theme-generator-')) throw new Error('unexpected fixture path'); - rmSync(fixture, { recursive: true, force: true }); - } -});