From decd263b178c57989c8b17bd00a90ecefdafbe12 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Fri, 9 Oct 2026 16:45:27 +0200 Subject: [PATCH 1/2] feat(tiles): Tile Animations setting, entrance styles for the tile grid Tiles become the fifth entrance surface (data-tile-anim), switched on in App Settings > Appearance > Tile Animations and OFF by default: the default is the grid's own quick fade (`settle`), exactly as before. A styled tile plays in two beats: its frame enters as it mounts (fly out of its session tab, dealt from the Tiles button, CRT power-on, beam down from its tab, cascade, pop, soft), and its screen then plays the terminal pane style of the entrance theme when its first capture lands, through the same html[data-term-anim] rules on .tile-body. Each style has its own exit when the Tiles button closes the grid (back into the tabs, a CRT switch-off, ...). Picking an Entrance Animations theme presets the tile style (new Launch theme: tiles fly from the tabs); the theme readout ignores the tile row, so changing it never shows "Custom". Frames move transform and opacity only (one fit and one PTY resize per tile), a reload's restore always settles, and nothing moves under reduced motion. The lab (?animlab=1) gets a Tile grid group, a cascade order picker and in-place replay / close + reopen. Also removes the terminal pane's `boot` entrance style (owner decision); a saved `boot` falls back to off. Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 10 +- docs/tile-grid-plan.md | 5 +- docs/wiki/Settings-Reference.md | 1 + src/web/public/entrance-animations.js | 517 ++++++++++++++++++-- src/web/public/index.html | 18 + src/web/public/styles.css | 639 +++++++++++++++++++++++-- src/web/public/tile-grid.js | 113 ++++- test/entrance-animations.test.ts | 130 ++++- test/tile-grid-entrance-styles.test.ts | 243 ++++++++++ 10 files changed, 1556 insertions(+), 122 deletions(-) create mode 100644 test/tile-grid-entrance-styles.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index d122f877..74cf8bf3 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 and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on ``; the default `legacy` theme short-circuits every hook. ⚠️ 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. 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. ⚠️ 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) **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 fa4fb87c..1f901b4f 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 four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-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 App Settings → Appearance, full per-surface lab at `?animlab=1`. ⚠️ 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,6 +889,14 @@ 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 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. + +⚠️ A themed tile is held one frame (`.tile--enter-hold`: invisible, `animation: none`), then `_runTileEntrances` orders the cascade from the final cells (`reading` / `wave` / `ripple`, a lab override in `codeman:tileAnimOrder`) and, for `fly`/`deal`/`beam`, measures its source (its session tab, else the Tiles button) on the final layout: `openTileGrid` packs tiles and a stored grid then moves them back, so measuring at mount would aim at the wrong cell. The offset rides `--tile-from-*` custom properties set BEFORE the hold comes off. The mount arms a backstop (4 s at once, the precise one from the module), so a frame that never comes cannot strand a tile invisible. + +⚠️ **A reload's restore always settles** (`_tileEnterQuiet` around `_restoreTileGrid`) and owes no screen beat: nothing animates on page load. A re-form (count change) keeps the plain fade on its still copy (`--now`), because that copy covers tiles that stay; only the Tiles toggle's close plays the style's own exit (`_stageTileExit`: back into the tabs, a CRT switch-off), and the copy's fallback timer waits as long as that exit says. + ### Mobile tab strip scrolling **Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders. diff --git a/docs/tile-grid-plan.md b/docs/tile-grid-plan.md index a9ae64a2..61dfc804 100644 --- a/docs/tile-grid-plan.md +++ b/docs/tile-grid-plan.md @@ -97,7 +97,10 @@ 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, not an `entrance-animations.js` theme (those are off by default). Opening, each tile + own `settle` style, the default of App Settings → Appearance → 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 fades and settles in (opacity, translateY 6px and scale .97), 180 ms, 24 ms apart in reading order (`--tile-enter-index`): the last of six is done at 300 ms; a tile added later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index ff31d1d3..71429628 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -91,6 +91,7 @@ every session or only the active tab. | ---------------------- | ----------------------------------------------------------------------------------------- | | 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). | diff --git a/src/web/public/entrance-animations.js b/src/web/public/entrance-animations.js index 2fa94275..b84517a5 100644 --- a/src/web/public/entrance-animations.js +++ b/src/web/public/entrance-animations.js @@ -1,8 +1,9 @@ /** - * @fileoverview Entrance animations for the four things that appear when work + * @fileoverview Entrance animations for the things that appear when work * starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating - * agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab. - * One picker per surface, plus themes that set all four to a matching look. + * agent WINDOWS, the CONNECTION LINES tying a window back to its parent tab, and + * the TILES of the tile grid (tile-grid.js). One picker per surface, plus themes + * that set all five to a matching look. * * 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 @@ -34,13 +35,28 @@ * once-per-id even though the POST response and the SSE event both call * `_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 + * 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 + * first capture lands (`.tile-body.term-enter`, the same keyframes as the main + * pane). The load queue serves one capture at a time, so + * the screens light up one after another, the focused tile first. The frame + * styles move transform and opacity only (six tiles animate at once; a blur + * belongs to the serialized screen beat), and each has its own way out on the + * closing grid's still copy. A reload restores with `settle` whatever the + * setting. + * * Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` / - * `data-line-anim` on ; the keyframes live in styles.css. `?animlab=1` - * opens a floating picker that fakes tabs, a pane replay, a window and a line, - * so styles can be compared without spawning real sessions or agents. + * `data-line-anim` / `data-tile-anim` on ; the keyframes live in + * styles.css. `?animlab=1` opens a floating picker that fakes tabs, a pane + * replay, a window and a line, and replays the tile grid in place, so styles + * can be compared without spawning real sessions or agents. * * @mixin Extends CodemanApp.prototype via Object.assign - * @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks) + * @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks), + * tile-grid.js (tile mount, reveal and still-copy hooks) * @dependency constants.js (escapeHtml) * @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js */ @@ -94,7 +110,6 @@ const LINE_ANIM_STYLES = [ */ const TERM_ANIM_STYLES = [ { key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 }, - { key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 }, { key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 }, { key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 }, { key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 }, @@ -102,19 +117,65 @@ const TERM_ANIM_STYLES = [ { key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 }, ]; +/** + * Tile grid entrance styles, for each tile's FRAME (its screen plays the + * TERM_ANIM_STYLES style once content lands). Transform and opacity only, plus + * a wash on ::before: FitAddon reads the untransformed layout box, so a tile + * still fits once at its final size (#464). `stagger` is the default gap + * between tiles and `order` the default cascade: `reading` (row by row), + * `wave` (diagonals from the top left) or `ripple` (outward from the focused + * tile). `from`: the tile is measured against a source after layout (its tab, + * the Tiles button), so it is held one frame and timed by _runTileEntrances. + */ +// prettier-ignore +const TILE_ANIM_STYLES = [ + { key: 'settle', label: 'Off (default)', blurb: "The grid's own quick fade and settle.", duration: 180, stagger: 24, order: 'reading' }, + { key: 'fly', label: 'Fly from tab', blurb: 'Each tile flies out of its session tab, and back into it on close.', duration: 560, stagger: 55, order: 'reading', from: 'tab' }, + { key: 'deal', label: 'Deal', blurb: 'Dealt out of the Tiles button like cards, gathered back on close.', duration: 600, stagger: 75, order: 'reading', from: 'button' }, + { key: 'crt', label: 'CRT', blurb: 'Powers on as a hot line; switches off to a dot on close.', duration: 560, stagger: 70, order: 'wave' }, + { key: 'beam', label: 'Beam down', blurb: 'A beam draws down from its tab, then the tile materializes.', duration: 620, stagger: 90, order: 'reading', from: 'tab' }, + { key: 'cascade', label: 'Cascade', blurb: 'Swings down from its top edge in a diagonal wave.', duration: 600, stagger: 80, order: 'wave' }, + { key: 'pop', label: 'Pop', blurb: 'Springs open, rippling out from the focused tile.', duration: 480, stagger: 70, order: 'ripple' }, + { key: 'soft', label: 'Soft', blurb: 'Drifts in slowly, rippling out from the focused tile.', duration: 620, stagger: 60, order: 'ripple' }, + { key: 'off', label: 'None', blurb: 'Tiles just appear.', duration: 0, stagger: 0, order: 'reading' }, +]; + +/** Cascade orders for the tile grid; `auto` is each style's own. */ +const TILE_ANIM_ORDERS = [ + { key: 'auto', label: 'Style default' }, + { key: 'reading', label: 'Reading order' }, + { key: 'wave', label: 'Diagonal wave' }, + { key: 'ripple', label: 'Ripple from focus' }, +]; + /** How long a `beam` window waits before materializing. Just under the line draw. */ const BEAM_HOLD_MS = 360; +/** Gap between tiles leaving, in reading order, so the last copy ends last. */ +const TILE_EXIT_STAGGER_MS = 35; + +/** Tile exit durations per style (styles.css `tile-leave-*`); the rest use the default fade. */ +const TILE_EXIT_MS = { fly: 460, deal: 520, crt: 520, beam: 480, cascade: 480, pop: 380, soft: 520 }; + /** One-click combinations that read as a single look. */ const ANIM_THEMES = [ - { key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' }, - { key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' }, - { key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' }, - { key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' }, - { key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' }, - { key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' }, + { key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt', tile: 'crt' }, + { key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe', tile: 'beam' }, + { key: 'launch', label: 'Launch', tab: 'pop', win: 'fly', line: 'packet', term: 'fade', tile: 'fly' }, + { key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur', tile: 'soft' }, + { key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade', tile: 'settle' }, + { key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide', tile: 'deal' }, + { key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off', tile: 'settle' }, ]; +/** + * 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 + * does not turn the theme into "Custom". + */ +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 @@ -126,6 +187,9 @@ const TAB_ANIM_DEFAULT = 'off'; const WIN_ANIM_DEFAULT = 'fly'; const LINE_ANIM_DEFAULT = 'off'; const TERM_ANIM_DEFAULT = 'off'; +/** The grid's own fade and settle, unchanged for anyone who never picks a theme. */ +const TILE_ANIM_DEFAULT = 'settle'; +const TILE_ANIM_ORDER_DEFAULT = 'auto'; const TAB_ANIM_STAGGER_DEFAULT = 90; /** A new id joins the current cascade if it arrives within this of the last one. */ const TAB_ANIM_BATCH_WINDOW_MS = 600; @@ -135,6 +199,8 @@ const ANIM_KEYS = { win: 'codeman:winAnim', line: 'codeman:lineAnim', term: 'codeman:termAnim', + tile: 'codeman:tileAnim', + tileOrder: 'codeman:tileAnimOrder', termSwitch: 'codeman:termAnimOnSwitch', stagger: 'codeman:tabAnimStagger', speed: 'codeman:tabAnimSpeed', @@ -164,6 +230,10 @@ Object.assign(CodemanApp.prototype, { this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false }); this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false }); this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false }); + // Off (the grid's own `settle`) until chosen: a theme saved before tiles + // were a surface gives them nothing new. + this.setTileAnimStyle(pick('tileanim', TILE_ANIM_STYLES, ANIM_KEYS.tile, TILE_ANIM_DEFAULT), { persist: false }); + this.setTileAnimOrder(this._animRead(ANIM_KEYS.tileOrder, TILE_ANIM_ORDER_DEFAULT), { persist: false }); this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false }); this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false }); @@ -219,6 +289,18 @@ Object.assign(CodemanApp.prototype, { this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist); }, + setTileAnimStyle(key, { persist = true } = {}) { + // prettier-ignore + this._setAnimStyle('_tileAnimStyle', key, TILE_ANIM_STYLES, TILE_ANIM_DEFAULT, 'data-tile-anim', ANIM_KEYS.tile, persist); + }, + + /** The tile cascade: `auto` (the style's own) or a TILE_ANIM_ORDERS key. */ + setTileAnimOrder(key, { persist = true } = {}) { + this._tileAnimOrder = TILE_ANIM_ORDERS.some((o) => o.key === key) ? key : TILE_ANIM_ORDER_DEFAULT; + if (persist) this._animWrite(ANIM_KEYS.tileOrder, this._tileAnimOrder); + this._syncAnimLab?.(); + }, + /** Replay the terminal entrance on every tab switch, not just on a new session. */ setTermAnimOnSwitch(on, { persist = true } = {}) { this._termAnimOnSwitch = !!on; @@ -234,18 +316,25 @@ Object.assign(CodemanApp.prototype, { this.setWinAnimStyle(theme.win); this.setLineAnimStyle(theme.line); this.setTermAnimStyle(theme.term); + this.setTileAnimStyle(theme.tile); this._syncEntranceAnimSetting?.(); }, - /** The theme matching the four current styles, or 'custom' for a lab mix. */ + /** The current style of each surface, keyed as ANIM_SURFACES. */ + _currentAnimStyles() { + return { + tab: this._tabAnimStyle, + win: this._winAnimStyle, + line: this._lineAnimStyle, + term: this._termAnimStyle, + tile: this._tileAnimStyle, + }; + }, + + /** The theme matching the current tab, window, line and pane styles, or 'custom' for a lab mix. */ currentAnimTheme() { - const match = ANIM_THEMES.find( - (t) => - t.tab === this._tabAnimStyle && - t.win === this._winAnimStyle && - t.line === this._lineAnimStyle && - t.term === this._termAnimStyle - ); + const current = this._currentAnimStyles(); + const match = ANIM_THEMES.find((t) => ANIM_SURFACES.every((k) => t[k] === current[k])); return match ? match.key : 'custom'; }, @@ -257,15 +346,29 @@ Object.assign(CodemanApp.prototype, { _syncEntranceAnimSetting() { const sel = document.getElementById('appSettingsEntranceAnim'); - if (!sel) return; - sel.value = this.currentAnimTheme(); - if (!sel.dataset.bound) { - sel.dataset.bound = '1'; - sel.addEventListener('change', () => { - // 'custom' is a readout of a lab mix, not something you can select into. - if (sel.value === 'custom') sel.value = this.currentAnimTheme(); - else this.setAnimTheme(sel.value); - }); + if (sel) { + sel.value = this.currentAnimTheme(); + if (!sel.dataset.bound) { + sel.dataset.bound = '1'; + sel.addEventListener('change', () => { + // 'custom' is a readout of a lab mix, not something you can select into. + if (sel.value === 'custom') sel.value = this.currentAnimTheme(); + else this.setAnimTheme(sel.value); + }); + } + } + // 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'); + if (tileSel) { + tileSel.value = this._tileAnimStyle || TILE_ANIM_DEFAULT; + if (!tileSel.dataset.bound) { + tileSel.dataset.bound = '1'; + tileSel.addEventListener('change', () => { + this.setTileAnimStyle(tileSel.value); + this._syncEntranceAnimSetting(); + }); + } } }, @@ -303,6 +406,10 @@ Object.assign(CodemanApp.prototype, { return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle); }, + _tileAnimDuration() { + return this._styleDuration(TILE_ANIM_STYLES, this._tileAnimStyle); + }, + // ── Tabs ────────────────────────────────────────────────────────────────── /** Queue a session id to animate on its next render. Idempotent per id. */ @@ -501,6 +608,317 @@ Object.assign(CodemanApp.prototype, { } }, + // ── Tile grid ───────────────────────────────────────────────────────────── + + /** The frame style a tile mounted now enters with (tile-grid.js _mountTile). */ + tileEntranceStyle() { + return this._tileAnimStyle || TILE_ANIM_DEFAULT; + }, + + /** + * Holds a just-mounted tile (`.tile--enter-hold`: invisible, not animating) + * until the next frame, when its cell is final and _runTileEntrances can + * order it and measure it against its source. `setBackstop(ms)` arms the + * mount's own timer that ends the entrance should animationend never come + * (a hidden browser tab, a zoomed grid hiding the tile); armed long at once, + * so a frame that never comes cannot strand a tile invisible. + */ + _stageTileEntrance(el, sessionId, setBackstop) { + el.classList.add('tile--enter-themed', 'tile--enter-hold'); + (this._tileEnterQueue ||= []).push({ el, sessionId, setBackstop }); + setBackstop(4000); + if (!this._tileEnterRaf) this._tileEnterRaf = requestAnimationFrame(() => this._runTileEntrances()); + }, + + /** + * One frame after the tiles mounted, every cell is final (openTileGrid packs, + * then a stored grid moves its tiles back). Each held tile gets its delay + * from the cascade order and, for `fly`/`deal`, the offset that starts it on + * its tab or the Tiles button (FLIP: transform only, so the fit it already + * did at its real size stands). `beam` draws its lines instead. + */ + _runTileEntrances() { + this._tileEnterRaf = 0; + const queue = (this._tileEnterQueue || []).filter((q) => q.el.isConnected); + this._tileEnterQueue = []; + if (queue.length === 0) return; + const def = TILE_ANIM_STYLES.find((s) => s.key === this._tileAnimStyle) || TILE_ANIM_STYLES[0]; + const speed = this._animSpeed || 1; + const stagger = def.stagger / speed; + const duration = this._tileAnimDuration(); + const hold = def.key === 'beam' ? BEAM_HOLD_MS / speed : 0; + const order = this._tileAnimOrder && this._tileAnimOrder !== 'auto' ? this._tileAnimOrder : def.order; + const ranks = this._tileEnterRanks( + queue.map((q) => q.sessionId), + order + ); + const beams = []; + queue.forEach((item, k) => { + const { el, sessionId } = item; + const delay = ranks[k] * stagger; + el.style.setProperty('--tile-enter-delay', `${Math.round(delay + hold)}ms`); + if (def.from) { + const to = el.getBoundingClientRect(); + const from = this._tileSourceRect(def.from, sessionId); + if (from && to.width > 0 && to.height > 0) { + if (def.key === 'beam') beams.push({ from, to, delay }); + else this._setTileFlight(el, from, to, def.key, ranks[k], '--tile-from'); + if (def.from === 'tab') this._flashTileSourceTab(sessionId, delay); + } + } + el.classList.remove('tile--enter-hold'); + // When the frame lands, for a screen whose content arrives earlier. + el._tileEnterEndsAt = performance.now() + delay + hold + duration; + item.setBackstop(delay + hold + duration + 900); + }); + if (beams.length > 0) this._drawTileBeams(beams); + }, + + /** + * Cascade steps for `ids` (tiles in this batch) by their cells: `reading` + * row by row, `wave` by diagonal (row + column), `ripple` by distance from + * the focused tile. Equal keys share a step, so a diagonal lands together. + */ + _tileEnterRanks(ids, order) { + const grid = this._tileGrid; + const cols = Math.max(1, grid?.cols || 1); + const cellOf = (id) => (Array.isArray(grid?.cells) ? grid.cells.indexOf(id) : -1); + const pos = ids.map((id, k) => { + const c = cellOf(id); + return c < 0 ? { cell: k, row: 0, col: k } : { cell: c, row: Math.floor(c / cols), col: c % cols }; + }); + let keys; + if (order === 'wave') { + keys = pos.map((p) => p.row + p.col); + } else if (order === 'ripple') { + const f = cellOf(grid?.focusedId); + const fr = f < 0 ? 0 : Math.floor(f / cols); + const fc = f < 0 ? 0 : f % cols; + keys = pos.map((p) => Math.abs(p.row - fr) + Math.abs(p.col - fc)); + } else { + keys = pos.map((p) => p.cell); + } + const steps = [...new Set(keys)].sort((a, b) => a - b); + return keys.map((v) => steps.indexOf(v)); + }, + + /** On-screen rect of a tile's source: its session tab (`tab`), else the Tiles button. */ + _tileSourceRect(kind, sessionId) { + const visible = (node) => { + const r = node?.getBoundingClientRect?.(); + if (!r || !(r.width > 0 && r.height > 0)) return null; + return r.bottom > 0 && r.right > 0 && r.top < window.innerHeight && r.left < window.innerWidth ? r : null; + }; + if (kind === 'tab') { + const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`); + const r = visible(tab); + if (r) return r; + } + return visible(document.querySelector('.btn-tile-grid')); + }, + + /** + * The transform that puts a tile laid out at `to` onto `from`, as custom + * properties `-x/-y/-sx/-sy/-rot` for the keyframes: the tab's own + * size for `fly` (it grows out of it), a small card turned a little for + * `deal`. + */ + _setTileFlight(el, from, to, kind, rank, prefix) { + const dx = from.left + from.width / 2 - (to.left + to.width / 2); + const dy = from.top + from.height / 2 - (to.top + to.height / 2); + const clamp = (v, lo, hi) => Math.max(lo, Math.min(hi, v)); + let sx; + let sy; + let rot = 0; + if (kind === 'deal') { + sx = sy = clamp((from.width * 1.6) / to.width, 0.04, 0.3); + rot = [-14, 10, -7, 13, -11, 8][rank % 6]; + } else { + sx = clamp(from.width / to.width, 0.02, 1); + sy = clamp(from.height / to.height, 0.02, 1); + } + el.style.setProperty(`${prefix}-x`, `${Math.round(dx)}px`); + el.style.setProperty(`${prefix}-y`, `${Math.round(dy)}px`); + el.style.setProperty(`${prefix}-sx`, sx.toFixed(4)); + el.style.setProperty(`${prefix}-sy`, sy.toFixed(4)); + el.style.setProperty(`${prefix}-rot`, `${rot}deg`); + }, + + /** The tab a tile leaves from glows as it goes (`fly`, `beam`). */ + _flashTileSourceTab(sessionId, delay) { + const tab = document.querySelector(`.session-tab[data-id="${CSS.escape(sessionId)}"]`); + if (!tab) return; + tab.style.setProperty('--tab-launch-delay', `${Math.round(delay)}ms`); + tab.classList.remove('tab-launch'); + void tab.offsetWidth; + tab.classList.add('tab-launch'); + const onEnd = (e) => { + if (e.target === tab && /^tab-launch/.test(e.animationName || '')) done(); + }; + const done = () => { + clearTimeout(timer); + tab.removeEventListener('animationend', onEnd); + tab.classList.remove('tab-launch'); + tab.style.removeProperty('--tab-launch-delay'); + }; + const timer = setTimeout(done, delay + 900); + tab.addEventListener('animationend', onEnd); + }, + + /** + * `beam`: a line draws from each tile's tab (or the Tiles button) down into + * the middle of its tile, in the connection-line look, with a packet riding it + * when the line style is `packet`; the tile materializes as it lands. Its + * own overlay: the agent lines' one is rebuilt from scratch on every redraw. + * The overlay goes once every line has faded. + */ + _drawTileBeams(beams) { + const ns = 'http://www.w3.org/2000/svg'; + let svg = document.getElementById('tileBeamLines'); + if (!svg) { + svg = document.createElementNS(ns, 'svg'); + svg.id = 'tileBeamLines'; + svg.setAttribute('class', 'connection-lines-svg tile-beam-lines'); + svg.setAttribute('aria-hidden', 'true'); + document.body.appendChild(svg); + } + const packet = this._lineAnimStyle === 'packet'; + let last = 0; + for (const { from, to, delay } of beams) { + const x1 = from.left + from.width / 2; + const y1 = from.bottom; + // Into the tile's middle: its top edge sits right under the tab strip, + // so a beam aimed there ran sideways along the strip instead of down. + const x2 = to.left + to.width / 2; + const y2 = to.top + to.height / 2; + const midY = (y1 + y2) / 2; + const path = document.createElementNS(ns, 'path'); + path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`); + path.setAttribute('class', 'connection-line tile-beam-line'); + svg.appendChild(path); + const len = Math.max(1, Math.round(path.getTotalLength())); + path.style.setProperty('--line-len', `${len}px`); + path.style.setProperty('--line-enter-delay', `${Math.round(delay)}ms`); + if (packet) { + const dot = path.cloneNode(false); + dot.setAttribute('class', 'connection-line-packet'); + svg.appendChild(dot); + } + last = Math.max(last, delay); + } + clearTimeout(this._tileBeamTimer); + this._tileBeamTimer = setTimeout(() => svg.remove(), last + 1500 / (this._animSpeed || 1)); + }, + + /** + * A tile's screen lights up when its first capture lands (tile-grid.js load + * queue), in the terminal pane's style: the same keyframes as the main pane, + * on `.tile-body`. Transform, opacity and clip-path (and `blur`'s filter, one + * tile at a time, since the queue serves one capture at a time), so the + * xterm inside keeps its size and its fit. + */ + playTileScreenEntrance(body) { + if (!body || (this._termAnimStyle || TERM_ANIM_DEFAULT) === 'off') return; + if (window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches) return; + body._codemanScreenDone?.(); + // Content that lands while the frame is still flying in waits (held at + // its first keyframe, so hidden) until the frame has nearly landed: two + // beats, frame then screen, rather than both at once. + const frame = body.closest?.('.tile'); + const endsAt = frame?.classList.contains('tile--entering') ? frame._tileEnterEndsAt || 0 : 0; + const wait = Math.max(0, Math.round(endsAt - performance.now() - 120 / (this._animSpeed || 1))); + body.style.setProperty('--tile-screen-delay', `${wait}ms`); + body.classList.remove('term-enter'); + void body.offsetWidth; + body.classList.add('term-enter'); + let timer = null; + const done = (e) => { + if (e && (e.target !== body || e.pseudoElement)) return; + clearTimeout(timer); + body.removeEventListener('animationend', done); + body.removeEventListener('animationcancel', done); + body.classList.remove('term-enter'); + body.style.removeProperty('--tile-screen-delay'); + if (body._codemanScreenDone === done) body._codemanScreenDone = null; + }; + body._codemanScreenDone = done; + body.addEventListener('animationend', done); + body.addEventListener('animationcancel', done); + // Backstop, as the main pane's: a backgrounded tab never fires animationend. + timer = setTimeout(() => done(), wait + this._termAnimDuration() + 900); + }, + + /** + * The closing grid's still copy (tile-grid.js _ghostTileGrid) leaves the + * frame style's own way: `fly` back into each tab, `deal` gathered into the + * Tiles button, `crt` switched off to a dot, and so on. Copies leave in + * reading order, so the last one ends last (the layer goes on its + * animationend). Re-forming the grid (`now`) keeps the plain fade: that copy + * covers tiles that stay. Returns how long the slowest copy takes, for the + * layer's fallback timer, or 0 for the default fade. + */ + _stageTileExit(copies, { now = false } = {}) { + const style = this._tileAnimStyle || TILE_ANIM_DEFAULT; + const ms = TILE_EXIT_MS[style]; + if (now || !ms || copies.length === 0) return 0; + const speed = this._animSpeed || 1; + const def = TILE_ANIM_STYLES.find((s) => s.key === style); + copies.forEach(({ ghost, el, sessionId }, k) => { + ghost.style.setProperty('--tile-exit-delay', `${Math.round((k * TILE_EXIT_STAGGER_MS) / speed)}ms`); + if (style !== 'fly' && style !== 'deal') return; + const at = el.getBoundingClientRect(); + const target = this._tileSourceRect(def.from, sessionId); + if (target && at.width > 0 && at.height > 0) this._setTileFlight(ghost, target, at, style, k, '--tile-to'); + }); + return (ms + copies.length * TILE_EXIT_STAGGER_MS) / speed + 250; + }, + + /** + * Lab: replay the open grid's entrance IN PLACE (frames re-enter, screens + * light up again in queue order): no remount, reconnect or resize. With the + * grid closed it opens it, the real path. + */ + _demoTiles() { + const grid = this._tileGrid; + if (!grid?.open) { + if (this.canOpenTileGrid?.()) this.toggleTileGrid?.(); + else this.showToast?.('The tile grid needs a window at least 1180 px wide', 'info'); + return; + } + if (!this._tileMotionAllowed?.()) return; + const ids = grid.ids.slice(); + ids.forEach((id, k) => { + const entry = grid.tiles.get(id); + if (entry) this._replayTileEntrance?.(entry.el, id, k); + }); + // The screens, as the load queue would land them: focused first, then reading order. + const speed = this._animSpeed || 1; + const lead = Math.min(this._tileAnimDuration() * 0.55, 420); + const order = [grid.focusedId, ...ids.filter((id) => id !== grid.focusedId)].filter(Boolean); + clearTimeout(this._tileDemoTimer); + const timers = order.map((id, k) => + setTimeout(() => this.playTileScreenEntrance(grid.tiles.get(id)?.body), lead + (k * 140) / speed) + ); + this._tileDemoTimers?.forEach(clearTimeout); + this._tileDemoTimers = timers; + }, + + /** Lab: close the grid with its exit, then open it again with its entrance (the real paths). */ + _demoTilesRoundTrip() { + if (!this._tileGrid?.open) { + this._demoTiles(); + return; + } + this.toggleTileGrid?.(); + clearTimeout(this._tileDemoTimer); + this._tileDemoTimer = setTimeout( + () => { + if (!this._tileGrid?.open) this.toggleTileGrid?.(); + }, + 1400 / (this._animSpeed || 1) + ); + }, + // ── Lab (compare styles without spawning sessions or agents) ─────────────── /** Floating picker: switch styles per surface and replay fake entrances. */ @@ -538,6 +956,12 @@ Object.assign(CodemanApp.prototype, { ${group('Agent windows', WIN_ANIM_STYLES, 'win')} ${group('Connection lines', LINE_ANIM_STYLES, 'line')} + ${group('Tile grid (frames; screens use the pane style)', TILE_ANIM_STYLES, 'tile')} +