From 5206a044bb46e6f3b2ec37b0fc648a05f38263aa Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 9 Oct 2026 18:22:14 +0200 Subject: [PATCH] feat(settings): an Animations section in App Settings Every animation setting now has its own App Settings section, right after Appearance (owner: easier to find). It holds the Entrance Theme (the former Entrance Animations row), Tile Animations, and an Open lab button that closes settings and opens the per-surface lab (?animlab=1). Appearance keeps the skin, identity and tab settings. New animation settings go in this section; test/app-settings-structure.test.ts pins it. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 4 +- docs/tile-grid-plan.md | 2 +- docs/wiki/Settings-Reference.md | 12 ++- src/web/public/entrance-animations.js | 22 ++++-- src/web/public/index.html | 103 +++++++++++++++++--------- src/web/public/styles.css | 4 +- test/app-settings-structure.test.ts | 25 +++++++ 8 files changed, 126 insertions(+), 48 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 74cf8bf3..03898844 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -329,7 +329,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea. -**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on ``; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. ⚠️ Tiles are OFF by default (App Settings → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations) +**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows, connection lines and tile-grid tiles, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on ``; the default `legacy` theme short-circuits every hook (tiles keep the grid's own `settle`). ⚠️ Tabs and lines are destroyed mid-animation on re-render, so re-apply to the fresh element by id with a negative `animation-delay` (resume, never restart). ⚠️ Terminal-pane styles may animate only transform / opacity / clip-path (anything else resizes the PTY via FitAddon); `blur` is the ONE sanctioned `filter` exception, do not generalise it. ⚠️ Line glow lives in `--line-glow` so blur keyframes interpolate. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations) **Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1f901b4f..5e269f95 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -877,7 +877,7 @@ Tests: `test/terminal-touch-tap.test.ts`. ### Entrance animations -**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the five things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in App Settings → Appearance, full per-surface lab at `?animlab=1`. +**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the five things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` / `data-tile-anim` on ``. Defaults are the `legacy` theme, so an untouched install behaves exactly as before and every hook short-circuits on its first line. Persisted to its own `codeman:*Anim` localStorage keys (per-device, deliberately NOT in the `.strict()` `SettingsUpdateSchema`); picker in its own App Settings → **Animations** section (`#settings-animations`: the Entrance Theme, Tile Animations, and an Open lab button), full per-surface lab at `?animlab=1`. New animation settings go in that section. ⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart. @@ -889,7 +889,7 @@ Tests: `test/terminal-touch-tap.test.ts`. ⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target. -**Tile grid entrances (the fifth surface, `data-tile-anim`).** A tile plays in two beats: its FRAME enters as it mounts in a `TILE_ANIM_STYLES` style (`settle`, `fly`, `deal`, `crt`, `beam`, `cascade`, `pop`, `soft`, `off`), and its SCREEN (`.tile-body`) plays the terminal pane's style when the load queue reports its first capture, through the same `html[data-term-anim]` rules (each names `.terminal-container.term-enter` AND `.tile-body.term-enter`). Content that lands while the frame is still entering waits for it (`--tile-screen-delay`), and the screen's `::before` wash fills `forwards` only, so a held wash never shows as a bright static block. Tiles are OFF by default: the style is its own setting, App Settings → Appearance → **Tile Animations** (`#appSettingsTileAnim`, `codeman:tileAnim`), whose default `settle` is the grid's own fade and settle (`.tile--entering`, pinned by `test/tile-grid-motion.test.ts`) with no screen beat and the plain exit, exactly as before. No saved key means `settle`, so a theme saved before tiles existed gives them nothing new. Picking a theme PRESETS the tile style (every theme carries a `tile` key, `legacy` → `settle`), but the theme readout (`currentAnimTheme`) is matched on the four classic surfaces only, so changing the tile row afterwards never turns the theme into "Custom". The screen beat plays only for a tile style other than `settle`. +**Tile grid entrances (the fifth surface, `data-tile-anim`).** A tile plays in two beats: its FRAME enters as it mounts in a `TILE_ANIM_STYLES` style (`settle`, `fly`, `deal`, `crt`, `beam`, `cascade`, `pop`, `soft`, `off`), and its SCREEN (`.tile-body`) plays the terminal pane's style when the load queue reports its first capture, through the same `html[data-term-anim]` rules (each names `.terminal-container.term-enter` AND `.tile-body.term-enter`). Content that lands while the frame is still entering waits for it (`--tile-screen-delay`), and the screen's `::before` wash fills `forwards` only, so a held wash never shows as a bright static block. Tiles are OFF by default: the style is its own setting, App Settings → Animations → **Tile Animations** (`#appSettingsTileAnim`, `codeman:tileAnim`), whose default `settle` is the grid's own fade and settle (`.tile--entering`, pinned by `test/tile-grid-motion.test.ts`) with no screen beat and the plain exit, exactly as before. No saved key means `settle`, so a theme saved before tiles existed gives them nothing new. Picking a theme PRESETS the tile style (every theme carries a `tile` key, `legacy` → `settle`), but the theme readout (`currentAnimTheme`) is matched on the four classic surfaces only, so changing the tile row afterwards never turns the theme into "Custom". The screen beat plays only for a tile style other than `settle`. ⚠️ Tile FRAME keyframes animate **transform and opacity only**, colour on a `::before` wash: six tiles animate at once, so no `filter` (a tile's blur is its screen beat, one tile at a time as the queue serves captures). The keyframes are named `tile-enter*` / `tile-leave*` because that is what the mount's `animationend` and the still copy's removal listen for, ignoring any event with a `pseudoElement`. `test/entrance-animations.test.ts` pins both. diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index 61dfc804..4a2573ee 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -97,7 +97,7 @@ or settled a question the spec left open. The invariants as built are in `test/header-icon-hover.test.ts`. - **The grid opens and closes with a short animation, on by default** (owner request: "when clicking on the tile button first make this animation nicer"). It is the grid's - own `settle` style, the default of App Settings → Appearance → Tile Animations, which + own `settle` style, the default of App Settings → Animations → Tile Animations, which switches on other styles (`fly` out of the tabs, `deal` from the Tiles button, `crt`, `beam`, ...; docs/architecture-invariants.md#entrance-animations). Opening, each tile diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 71429628..e487c038 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -90,8 +90,6 @@ every session or only the active tab. | Setting | Notes | | ---------------------- | ----------------------------------------------------------------------------------------- | | Skin | Theme palettes, light ones included. Applied before first paint, so no flash of the wrong theme. | -| Entrance Animations | Per-surface animation styles for tabs, terminals, windows, and lineage lines. All default to the legacy no-animation behaviour. | -| Tile Animations | How tiles arrive when the tile grid opens and leave when it closes: fly out of their tabs, dealt from the Tiles button, CRT, beam down, cascade, pop or soft; each screen then plays the theme's terminal animation. Off by default (the grid's quick fade); picking an Entrance Animations theme presets it. | | Display Name | Your name in the UI. Cosmetic only; it never renames the package, CLI, API, or storage. | | Interface Language | English or Simplified Chinese. Per device. | | Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). | @@ -105,6 +103,16 @@ every session or only the active tab. | Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). | | Overview Home Screen | The phone home screen. On by default. | +### Animations + +All per device, all off by default, applied as you pick them. + +| Setting | Notes | +| ---------------------- | ----------------------------------------------------------------------------------------- | +| Entrance Theme | One look for how new tabs, terminal panes, agent windows and their lines arrive (Terminal, Beam down, Launch, Soft focus, Quiet, Playful). Off by default. | +| Tile Animations | How tiles arrive when the tile grid opens and leave when it closes: fly out of their tabs, dealt from the Tiles button, CRT, beam down, cascade, pop or soft; each screen then plays the theme's terminal animation. Off by default (the grid's quick fade); picking a theme presets it. | +| Animation Lab | Opens the per-surface lab (the same as `?animlab=1`): every style side by side, with replay, stagger and speed. Closes settings first. | + ### Models Claude model cards, the 1M context window switch, the thinking effort segment and the diff --git a/src/web/public/entrance-animations.js b/src/web/public/entrance-animations.js index b84517a5..a7068e0a 100644 --- a/src/web/public/entrance-animations.js +++ b/src/web/public/entrance-animations.js @@ -7,7 +7,7 @@ * * Everything is OFF by default (the `legacy` theme), so an untouched install * behaves exactly as it did before this module existed. Opt in via App Settings - * → Appearance → Entrance Animations. + * → Animations. * * Four constraints shape the design: * @@ -36,7 +36,7 @@ * `_onSessionCreated`. * * Tiles are off by default (`settle`, the grid's own quick fade, exactly as - * before) and switched on in App Settings → Appearance → Tile Animations, or + * before) and switched on in App Settings → Animations → Tile Animations, or * preset by a theme. A styled tile plays in two beats that combine two * surfaces. The FRAME enters as it mounts, in its own style * (TILE_ANIM_STYLES); the SCREEN plays the terminal pane's style when its @@ -171,7 +171,7 @@ const ANIM_THEMES = [ /** * The surfaces a theme is recognised by. A theme also PRESETS the tile style * when it is picked, but the tile style is its own setting (App Settings → - * Appearance → Tile Animations, off by default), so changing it afterwards + * Animations → Tile Animations, off by default), so changing it afterwards * does not turn the theme into "Custom". */ const ANIM_SURFACES = ['tab', 'win', 'line', 'term']; @@ -180,8 +180,8 @@ const ANIM_SURFACES = ['tab', 'win', 'line', 'term']; * Defaults are the `legacy` theme: every entrance OFF, and agent windows on the * `fly` behaviour Codeman already had before this module existed. So a user who * never opens the picker sees exactly the pre-existing UI, and each mark/apply - * hook short-circuits on its first line. Opt in via App Settings → Appearance → - * Entrance Animations, which persists to the localStorage keys below. + * hook short-circuits on its first line. Opt in via App Settings → Animations, + * which persists to the localStorage keys below. */ const TAB_ANIM_DEFAULT = 'off'; const WIN_ANIM_DEFAULT = 'fly'; @@ -338,7 +338,7 @@ Object.assign(CodemanApp.prototype, { return match ? match.key : 'custom'; }, - // ── App Settings picker ─────────────────────────────────────────────────── + // ── App Settings → Animations ───────────────────────────────────────────── // // Wired straight to setAnimTheme() rather than through saveAppSettings(): the // styles live in their own localStorage keys, so they stay per-device and never @@ -357,6 +357,16 @@ Object.assign(CodemanApp.prototype, { }); } } + // App Settings → Animations → Animation Lab. Settings has no unsaved-edit + // tracking, so this closes it as Cancel does (the row says so). + const labBtn = document.getElementById('appSettingsOpenAnimLab'); + if (labBtn && !labBtn.dataset.bound) { + labBtn.dataset.bound = '1'; + labBtn.addEventListener('click', () => { + this.closeAppSettings?.(); + this.openAnimLab(); + }); + } // Tile Animations: its own row, off (`settle`) by default. A theme picked // above presets it; picked here, it applies to tiles alone. const tileSel = document.getElementById('appSettingsTileAnim'); diff --git a/src/web/public/index.html b/src/web/public/index.html index d74f8838..0c3ea331 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1663,6 +1663,10 @@ Appearance + + + + + +
diff --git a/src/web/public/styles.css b/src/web/public/styles.css index b98b8f1f..7b1261e3 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -20081,8 +20081,8 @@ body.tile-grid-resizing--row * { } /* ── Tile grid entrance styles (entrance-animations.js TILE_ANIM_STYLES) ─── - Picked by the Entrance Animations theme (App Settings → Appearance) or per - surface in the lab (?animlab=1); `settle` is the rule above, the default, + Picked in App Settings → Animations (Tile Animations, or preset by the + Entrance Theme) or per surface in the lab (?animlab=1); `settle` is the rule above, the default, and what a reload restores with. Any other style is held one frame (.tile--enter-hold: invisible, not animating) while the entrance module orders the cascade and measures the tile's source on the final layout, then diff --git a/test/app-settings-structure.test.ts b/test/app-settings-structure.test.ts index 4dc7dc05..4045d812 100644 --- a/test/app-settings-structure.test.ts +++ b/test/app-settings-structure.test.ts @@ -80,6 +80,31 @@ describe('App Settings modal structure', () => { expect(system).toContain('id="appSettingsTunnelEnabled"'); }); + /** + * Owner decision (2026-10-09): every animation setting has its own + * Animations section, right after Appearance, so it is easy to find. The + * selects are wired by id in entrance-animations.js, not by the load/save + * path above, so they get their own check here. + */ + it('keeps every animation setting in its own Animations section, after Appearance', () => { + const modal = settingsModal(); + const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]); + const order = [...modal.matchAll(/
m[1]); + for (const list of [rail, order]) { + expect(list[list.indexOf('settings-appearance') + 1]).toBe('settings-animations'); + } + const animations = modal.match(/id="settings-animations"([\s\S]*?)<\/section>/)?.[1] ?? ''; + for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) { + expect(animations, `${id} belongs in the Animations section`).toContain(`id="${id}"`); + } + const appearance = modal.match(/id="settings-appearance"([\s\S]*?)<\/section>/)?.[1] ?? ''; + expect(appearance).not.toMatch(/id="appSettings[A-Za-z]*Anim"/); + const anim = readFileSync(resolve(publicDir, 'entrance-animations.js'), 'utf8'); + for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) { + expect(anim).toContain(`document.getElementById('${id}')`); + } + }); + it('keeps Local Echo the first row of the second section', () => { const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/); const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;