mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-10 17:29:41 +02:00
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
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 `<html>`; 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 `<html>`; 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)
|
**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)
|
||||||
|
|
||||||
|
|||||||
@@ -877,7 +877,7 @@ Tests: `test/terminal-touch-tap.test.ts`.
|
|||||||
|
|
||||||
### Entrance animations
|
### 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 `<html>`. 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 `<html>`. 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.
|
⚠️ 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.
|
⚠️ 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
|
||||||
|
|
||||||
**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.
|
**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.
|
||||||
|
|||||||
@@ -97,7 +97,10 @@ or settled a question the spec left open. The invariants as built are in
|
|||||||
`test/header-icon-hover.test.ts`.
|
`test/header-icon-hover.test.ts`.
|
||||||
- **The grid opens and closes with a short animation, on by default** (owner request:
|
- **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
|
"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
|
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
|
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
|
later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the
|
||||||
|
|||||||
@@ -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. |
|
| 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. |
|
| 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. |
|
| 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. |
|
| 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). |
|
| 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). |
|
||||||
|
|||||||
@@ -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
|
* 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.
|
* agent WINDOWS, the CONNECTION LINES tying a window back to its parent tab, and
|
||||||
* One picker per surface, plus themes that set all four to a matching look.
|
* 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
|
* 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
|
* 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
|
* once-per-id even though the POST response and the SSE event both call
|
||||||
* `_onSessionCreated`.
|
* `_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` /
|
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
|
||||||
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
|
* `data-line-anim` / `data-tile-anim` on <html>; the keyframes live in
|
||||||
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
|
* styles.css. `?animlab=1` opens a floating picker that fakes tabs, a pane
|
||||||
* so styles can be compared without spawning real sessions or agents.
|
* 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
|
* @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)
|
* @dependency constants.js (escapeHtml)
|
||||||
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
|
* @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 = [
|
const TERM_ANIM_STYLES = [
|
||||||
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
|
{ 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: '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: '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 },
|
{ 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 },
|
{ 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. */
|
/** How long a `beam` window waits before materializing. Just under the line draw. */
|
||||||
const BEAM_HOLD_MS = 360;
|
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. */
|
/** One-click combinations that read as a single look. */
|
||||||
const ANIM_THEMES = [
|
const ANIM_THEMES = [
|
||||||
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
|
{ 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' },
|
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe', tile: 'beam' },
|
||||||
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
|
{ key: 'launch', label: 'Launch', tab: 'pop', win: 'fly', line: 'packet', term: 'fade', tile: 'fly' },
|
||||||
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
|
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur', tile: 'soft' },
|
||||||
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
|
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade', tile: 'settle' },
|
||||||
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
|
{ 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
|
* 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
|
* `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 WIN_ANIM_DEFAULT = 'fly';
|
||||||
const LINE_ANIM_DEFAULT = 'off';
|
const LINE_ANIM_DEFAULT = 'off';
|
||||||
const TERM_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;
|
const TAB_ANIM_STAGGER_DEFAULT = 90;
|
||||||
/** A new id joins the current cascade if it arrives within this of the last one. */
|
/** A new id joins the current cascade if it arrives within this of the last one. */
|
||||||
const TAB_ANIM_BATCH_WINDOW_MS = 600;
|
const TAB_ANIM_BATCH_WINDOW_MS = 600;
|
||||||
@@ -135,6 +199,8 @@ const ANIM_KEYS = {
|
|||||||
win: 'codeman:winAnim',
|
win: 'codeman:winAnim',
|
||||||
line: 'codeman:lineAnim',
|
line: 'codeman:lineAnim',
|
||||||
term: 'codeman:termAnim',
|
term: 'codeman:termAnim',
|
||||||
|
tile: 'codeman:tileAnim',
|
||||||
|
tileOrder: 'codeman:tileAnimOrder',
|
||||||
termSwitch: 'codeman:termAnimOnSwitch',
|
termSwitch: 'codeman:termAnimOnSwitch',
|
||||||
stagger: 'codeman:tabAnimStagger',
|
stagger: 'codeman:tabAnimStagger',
|
||||||
speed: 'codeman:tabAnimSpeed',
|
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.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.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 });
|
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.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
|
||||||
|
|
||||||
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { 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);
|
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. */
|
/** Replay the terminal entrance on every tab switch, not just on a new session. */
|
||||||
setTermAnimOnSwitch(on, { persist = true } = {}) {
|
setTermAnimOnSwitch(on, { persist = true } = {}) {
|
||||||
this._termAnimOnSwitch = !!on;
|
this._termAnimOnSwitch = !!on;
|
||||||
@@ -234,18 +316,25 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
this.setWinAnimStyle(theme.win);
|
this.setWinAnimStyle(theme.win);
|
||||||
this.setLineAnimStyle(theme.line);
|
this.setLineAnimStyle(theme.line);
|
||||||
this.setTermAnimStyle(theme.term);
|
this.setTermAnimStyle(theme.term);
|
||||||
|
this.setTileAnimStyle(theme.tile);
|
||||||
this._syncEntranceAnimSetting?.();
|
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() {
|
currentAnimTheme() {
|
||||||
const match = ANIM_THEMES.find(
|
const current = this._currentAnimStyles();
|
||||||
(t) =>
|
const match = ANIM_THEMES.find((t) => ANIM_SURFACES.every((k) => t[k] === current[k]));
|
||||||
t.tab === this._tabAnimStyle &&
|
|
||||||
t.win === this._winAnimStyle &&
|
|
||||||
t.line === this._lineAnimStyle &&
|
|
||||||
t.term === this._termAnimStyle
|
|
||||||
);
|
|
||||||
return match ? match.key : 'custom';
|
return match ? match.key : 'custom';
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -257,15 +346,29 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
_syncEntranceAnimSetting() {
|
_syncEntranceAnimSetting() {
|
||||||
const sel = document.getElementById('appSettingsEntranceAnim');
|
const sel = document.getElementById('appSettingsEntranceAnim');
|
||||||
if (!sel) return;
|
if (sel) {
|
||||||
sel.value = this.currentAnimTheme();
|
sel.value = this.currentAnimTheme();
|
||||||
if (!sel.dataset.bound) {
|
if (!sel.dataset.bound) {
|
||||||
sel.dataset.bound = '1';
|
sel.dataset.bound = '1';
|
||||||
sel.addEventListener('change', () => {
|
sel.addEventListener('change', () => {
|
||||||
// 'custom' is a readout of a lab mix, not something you can select into.
|
// 'custom' is a readout of a lab mix, not something you can select into.
|
||||||
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
|
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
|
||||||
else this.setAnimTheme(sel.value);
|
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);
|
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
_tileAnimDuration() {
|
||||||
|
return this._styleDuration(TILE_ANIM_STYLES, this._tileAnimStyle);
|
||||||
|
},
|
||||||
|
|
||||||
// ── Tabs ──────────────────────────────────────────────────────────────────
|
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** Queue a session id to animate on its next render. Idempotent per id. */
|
/** 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 `<prefix>-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) ───────────────
|
// ── Lab (compare styles without spawning sessions or agents) ───────────────
|
||||||
|
|
||||||
/** Floating picker: switch styles per surface and replay fake entrances. */
|
/** Floating picker: switch styles per surface and replay fake entrances. */
|
||||||
@@ -538,6 +956,12 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
</label>
|
</label>
|
||||||
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
|
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
|
||||||
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
|
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
|
||||||
|
${group('Tile grid (frames; screens use the pane style)', TILE_ANIM_STYLES, 'tile')}
|
||||||
|
<label class="anim-lab-select">Tile order
|
||||||
|
<select data-select="tileOrder">
|
||||||
|
${TILE_ANIM_ORDERS.map((o) => `<option value="${o.key}">${escapeHtml(o.label)}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
|
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
|
||||||
<input type="range" data-range="stagger" min="0" max="260" step="10">
|
<input type="range" data-range="stagger" min="0" max="260" step="10">
|
||||||
@@ -552,6 +976,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
<button type="button" data-demo="window">Window</button>
|
<button type="button" data-demo="window">Window</button>
|
||||||
<button type="button" data-demo="all">All</button>
|
<button type="button" data-demo="all">All</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="anim-lab-demo">
|
||||||
|
<span>Tiles</span>
|
||||||
|
<button type="button" data-demo="tiles">Replay</button>
|
||||||
|
<button type="button" data-demo="tiles-roundtrip">Close + reopen</button>
|
||||||
|
</div>
|
||||||
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
|
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(panel);
|
document.body.appendChild(panel);
|
||||||
@@ -570,11 +999,17 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (attr === 'tab') this.setTabAnimStyle(style);
|
if (attr === 'tab') this.setTabAnimStyle(style);
|
||||||
else if (attr === 'win') this.setWinAnimStyle(style);
|
else if (attr === 'win') this.setWinAnimStyle(style);
|
||||||
else if (attr === 'term') this.setTermAnimStyle(style);
|
else if (attr === 'term') this.setTermAnimStyle(style);
|
||||||
|
else if (attr === 'tile') this.setTileAnimStyle(style);
|
||||||
else this.setLineAnimStyle(style);
|
else this.setLineAnimStyle(style);
|
||||||
this._syncAnimLab();
|
this._syncAnimLab();
|
||||||
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
|
this._syncEntranceAnimSetting?.();
|
||||||
|
this.demoEntrance({ tab: 'tabs', term: 'term', tile: 'tiles' }[attr] || 'all');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
panel.querySelector('select[data-select="tileOrder"]').addEventListener('change', (e) => {
|
||||||
|
this.setTileAnimOrder(e.target.value);
|
||||||
|
this.demoEntrance('tiles');
|
||||||
|
});
|
||||||
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
|
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
|
||||||
this.setTermAnimOnSwitch(e.target.checked);
|
this.setTermAnimOnSwitch(e.target.checked);
|
||||||
});
|
});
|
||||||
@@ -601,19 +1036,16 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
_syncAnimLab() {
|
_syncAnimLab() {
|
||||||
const panel = document.getElementById('animLab');
|
const panel = document.getElementById('animLab');
|
||||||
if (!panel) return;
|
if (!panel) return;
|
||||||
const current = {
|
const current = this._currentAnimStyles();
|
||||||
tab: this._tabAnimStyle,
|
|
||||||
win: this._winAnimStyle,
|
|
||||||
line: this._lineAnimStyle,
|
|
||||||
term: this._termAnimStyle,
|
|
||||||
};
|
|
||||||
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
|
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
|
||||||
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
|
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
|
||||||
});
|
});
|
||||||
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
|
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
|
||||||
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
|
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
|
||||||
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
|
btn.classList.toggle('selected', !!t && ANIM_SURFACES.every((k) => t[k] === current[k]));
|
||||||
});
|
});
|
||||||
|
const order = panel.querySelector('select[data-select="tileOrder"]');
|
||||||
|
if (order) order.value = this._tileAnimOrder || TILE_ANIM_ORDER_DEFAULT;
|
||||||
const check = panel.querySelector('input[data-check="termSwitch"]');
|
const check = panel.querySelector('input[data-check="termSwitch"]');
|
||||||
if (check) check.checked = !!this._termAnimOnSwitch;
|
if (check) check.checked = !!this._termAnimOnSwitch;
|
||||||
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
|
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
|
||||||
@@ -647,10 +1079,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
return svg;
|
return svg;
|
||||||
},
|
},
|
||||||
|
|
||||||
/** @param {'tabs'|'term'|'window'|'all'} what */
|
/** @param {'tabs'|'term'|'window'|'all'|'tiles'|'tiles-roundtrip'} what */
|
||||||
demoEntrance(what = 'all') {
|
demoEntrance(what = 'all') {
|
||||||
this._clearEntranceDemo();
|
this._clearEntranceDemo();
|
||||||
|
|
||||||
|
if (what === 'tiles') return this._demoTiles();
|
||||||
|
if (what === 'tiles-roundtrip') return this._demoTilesRoundTrip();
|
||||||
|
// With the grid open, the "pane" is every tile's screen.
|
||||||
|
if (what === 'term' && this._tileGrid?.open) return this._demoTiles();
|
||||||
|
|
||||||
// The pane is a real, shared element rather than a throwaway, so replay it
|
// The pane is a real, shared element rather than a throwaway, so replay it
|
||||||
// through the same entry point a real launch uses (bypassing the owed-id
|
// through the same entry point a real launch uses (bypassing the owed-id
|
||||||
// check, which only exists to keep background sessions from hijacking it).
|
// check, which only exists to keep background sessions from hijacking it).
|
||||||
|
|||||||
@@ -2086,12 +2086,30 @@
|
|||||||
<option value="legacy">Off (default)</option>
|
<option value="legacy">Off (default)</option>
|
||||||
<option value="terminal">Terminal (CRT)</option>
|
<option value="terminal">Terminal (CRT)</option>
|
||||||
<option value="beamdown">Beam down</option>
|
<option value="beamdown">Beam down</option>
|
||||||
|
<option value="launch">Launch (tiles fly from tabs)</option>
|
||||||
<option value="softfocus">Soft focus (blur)</option>
|
<option value="softfocus">Soft focus (blur)</option>
|
||||||
<option value="quiet">Quiet</option>
|
<option value="quiet">Quiet</option>
|
||||||
<option value="playful">Playful</option>
|
<option value="playful">Playful</option>
|
||||||
<option value="custom">Custom (set in the lab)</option>
|
<option value="custom">Custom (set in the lab)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="set-row has-field" data-search="tile animations tiles grid motion entrance fly deal crt beam">
|
||||||
|
<div class="set-row-text">
|
||||||
|
<span class="set-row-label">Tile Animations</span>
|
||||||
|
<span class="set-row-desc">How tiles arrive when the grid opens and leave when it closes. A theme above presets it.</span>
|
||||||
|
</div>
|
||||||
|
<select id="appSettingsTileAnim" class="set-select">
|
||||||
|
<option value="settle">Off (default)</option>
|
||||||
|
<option value="fly">Fly from tab</option>
|
||||||
|
<option value="deal">Deal</option>
|
||||||
|
<option value="crt">CRT</option>
|
||||||
|
<option value="beam">Beam down</option>
|
||||||
|
<option value="cascade">Cascade</option>
|
||||||
|
<option value="pop">Pop</option>
|
||||||
|
<option value="soft">Soft</option>
|
||||||
|
<option value="off">None (tiles just appear)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+588
-51
@@ -1270,6 +1270,26 @@ html[data-tab-anim="blur"] .session-tab.tab-enter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.anim-lab-range output { justify-self: end; color: var(--text); font-variant-numeric: tabular-nums; }
|
.anim-lab-range output { justify-self: end; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.anim-lab-select {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin: -2px 0 10px;
|
||||||
|
padding: 0 2px;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.anim-lab-select select {
|
||||||
|
background: var(--bg-dark, #111);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.66rem;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
.anim-lab-range input { grid-column: 1 / -1; width: 100%; accent-color: #00ff66; }
|
.anim-lab-range input { grid-column: 1 / -1; width: 100%; accent-color: #00ff66; }
|
||||||
|
|
||||||
.anim-lab-demo {
|
.anim-lab-demo {
|
||||||
@@ -1456,11 +1476,20 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
|
|||||||
frame. `will-change` is deliberately not set, the base rule needs its
|
frame. `will-change` is deliberately not set, the base rule needs its
|
||||||
`will-change: contents` for terminal compositing. */
|
`will-change: contents` for terminal compositing. */
|
||||||
|
|
||||||
.terminal-container.term-enter {
|
/* A tile's screen (.tile-body, tile-grid.js) plays the same style when its
|
||||||
|
first capture lands: the same rules, so a tile combines its frame's own
|
||||||
|
entrance with the pane style. Its xterm's opacity fade stands aside for it. */
|
||||||
|
.terminal-container.term-enter,
|
||||||
|
.tile-body.term-enter {
|
||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-container.term-enter::before {
|
.tile-body.term-enter .xterm {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-container.term-enter::before,
|
||||||
|
.tile-body.term-enter::before {
|
||||||
content: "";
|
content: "";
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -1470,14 +1499,29 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
|
|||||||
animation-fill-mode: both;
|
animation-fill-mode: both;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* A screen whose content lands while its frame is still entering waits for
|
||||||
|
it (--tile-screen-delay, entrance-animations.js): the body holds its first
|
||||||
|
keyframe meanwhile (hidden), its wash does not (a held wash is a bright
|
||||||
|
static block). */
|
||||||
|
.tile-body.term-enter {
|
||||||
|
animation-delay: var(--tile-screen-delay, 0ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile-body.term-enter::before {
|
||||||
|
animation-delay: var(--tile-screen-delay, 0ms);
|
||||||
|
animation-fill-mode: forwards;
|
||||||
|
}
|
||||||
|
|
||||||
/* CRT, power-on: a hot line that expands to full height. */
|
/* CRT, power-on: a hot line that expands to full height. */
|
||||||
html[data-term-anim="crt"] .terminal-container.term-enter {
|
html[data-term-anim="crt"] .terminal-container.term-enter,
|
||||||
|
html[data-term-anim="crt"] .tile-body.term-enter {
|
||||||
animation-name: term-enter-crt;
|
animation-name: term-enter-crt;
|
||||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-term-anim="crt"] .terminal-container.term-enter::before {
|
html[data-term-anim="crt"] .terminal-container.term-enter::before,
|
||||||
|
html[data-term-anim="crt"] .tile-body.term-enter::before {
|
||||||
animation-name: term-enter-crt-flash;
|
animation-name: term-enter-crt-flash;
|
||||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||||
animation-timing-function: ease-out;
|
animation-timing-function: ease-out;
|
||||||
@@ -1498,55 +1542,16 @@ html[data-term-anim="crt"] .terminal-container.term-enter::before {
|
|||||||
100% { opacity: 0; background: transparent; }
|
100% { opacity: 0; background: transparent; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Boot, flickers on under a green scan sweep. */
|
|
||||||
html[data-term-anim="boot"] .terminal-container.term-enter {
|
|
||||||
animation-name: term-enter-boot;
|
|
||||||
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
|
|
||||||
animation-timing-function: linear;
|
|
||||||
}
|
|
||||||
|
|
||||||
html[data-term-anim="boot"] .terminal-container.term-enter::before {
|
|
||||||
background: linear-gradient(
|
|
||||||
180deg,
|
|
||||||
transparent 0%,
|
|
||||||
rgba(0, 255, 102, 0.05) 40%,
|
|
||||||
rgba(200, 255, 220, 0.28) 50%,
|
|
||||||
rgba(0, 255, 102, 0.05) 60%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
background-size: 100% 300%;
|
|
||||||
animation-name: term-enter-boot-scan;
|
|
||||||
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
|
|
||||||
animation-timing-function: ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes term-enter-boot {
|
|
||||||
0% { opacity: 0; transform: scale(0.995); }
|
|
||||||
8% { opacity: 0.85; }
|
|
||||||
15% { opacity: 0.1; }
|
|
||||||
24% { opacity: 1; }
|
|
||||||
33% { opacity: 0.35; }
|
|
||||||
45% { opacity: 1; transform: none; }
|
|
||||||
58% { opacity: 0.7; }
|
|
||||||
70% { opacity: 1; }
|
|
||||||
100% { opacity: 1; transform: none; }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes term-enter-boot-scan {
|
|
||||||
0% { opacity: 0; background-position: 0 -150%; }
|
|
||||||
12% { opacity: 1; }
|
|
||||||
86% { opacity: 1; }
|
|
||||||
100% { opacity: 0; background-position: 0 150%; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Wipe, reveals top-to-bottom behind a bright edge. */
|
/* Wipe, reveals top-to-bottom behind a bright edge. */
|
||||||
html[data-term-anim="wipe"] .terminal-container.term-enter {
|
html[data-term-anim="wipe"] .terminal-container.term-enter,
|
||||||
|
html[data-term-anim="wipe"] .tile-body.term-enter {
|
||||||
animation-name: term-enter-wipe;
|
animation-name: term-enter-wipe;
|
||||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||||
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
|
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-term-anim="wipe"] .terminal-container.term-enter::before {
|
html[data-term-anim="wipe"] .terminal-container.term-enter::before,
|
||||||
|
html[data-term-anim="wipe"] .tile-body.term-enter::before {
|
||||||
background: linear-gradient(180deg, rgba(0, 255, 102, 0.16) 0%, rgba(190, 255, 215, 0.5) 82%, transparent 100%);
|
background: linear-gradient(180deg, rgba(0, 255, 102, 0.16) 0%, rgba(190, 255, 215, 0.5) 82%, transparent 100%);
|
||||||
animation-name: term-enter-wipe-edge;
|
animation-name: term-enter-wipe-edge;
|
||||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||||
@@ -1565,7 +1570,8 @@ html[data-term-anim="wipe"] .terminal-container.term-enter::before {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Slide up, rises into place from below. */
|
/* Slide up, rises into place from below. */
|
||||||
html[data-term-anim="slide"] .terminal-container.term-enter {
|
html[data-term-anim="slide"] .terminal-container.term-enter,
|
||||||
|
html[data-term-anim="slide"] .tile-body.term-enter {
|
||||||
animation-name: term-enter-slide;
|
animation-name: term-enter-slide;
|
||||||
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
|
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
|
||||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
@@ -1577,7 +1583,8 @@ html[data-term-anim="slide"] .terminal-container.term-enter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Fade, quiet, with a touch of scale. */
|
/* Fade, quiet, with a touch of scale. */
|
||||||
html[data-term-anim="fade"] .terminal-container.term-enter {
|
html[data-term-anim="fade"] .terminal-container.term-enter,
|
||||||
|
html[data-term-anim="fade"] .tile-body.term-enter {
|
||||||
animation-name: term-enter-fade;
|
animation-name: term-enter-fade;
|
||||||
animation-duration: calc(340ms * var(--anim-enter-scale, 1));
|
animation-duration: calc(340ms * var(--anim-enter-scale, 1));
|
||||||
animation-timing-function: ease-out;
|
animation-timing-function: ease-out;
|
||||||
@@ -1615,7 +1622,8 @@ html[data-term-anim="fade"] .terminal-container.term-enter {
|
|||||||
resize() and through to the PTY. `filter` is paint-only (measured live: 178x38
|
resize() and through to the PTY. `filter` is paint-only (measured live: 178x38
|
||||||
before, during and after a run), and the property allowlist for every one of
|
before, during and after a run), and the property allowlist for every one of
|
||||||
these keyframes is pinned by test/entrance-animations.test.ts. */
|
these keyframes is pinned by test/entrance-animations.test.ts. */
|
||||||
html[data-term-anim="blur"] .terminal-container.term-enter {
|
html[data-term-anim="blur"] .terminal-container.term-enter,
|
||||||
|
html[data-term-anim="blur"] .tile-body.term-enter {
|
||||||
animation-name: term-enter-blur;
|
animation-name: term-enter-blur;
|
||||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||||
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
|
||||||
@@ -1738,10 +1746,20 @@ html[data-line-anim="blur"] .connection-line.line-enter {
|
|||||||
.ultracode-window.win-enter::before,
|
.ultracode-window.win-enter::before,
|
||||||
.terminal-container.term-enter,
|
.terminal-container.term-enter,
|
||||||
.terminal-container.term-enter::before,
|
.terminal-container.term-enter::before,
|
||||||
|
.tile-body.term-enter,
|
||||||
|
.tile-body.term-enter::before,
|
||||||
|
.tile.tile--entering,
|
||||||
|
.tile.tile--entering::before,
|
||||||
.connection-line.line-enter,
|
.connection-line.line-enter,
|
||||||
.connection-line-packet {
|
.connection-line-packet,
|
||||||
|
.connection-line.tile-beam-line,
|
||||||
|
.session-tab.tab-launch::after {
|
||||||
animation: none !important;
|
animation: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tile-beam-lines {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-tab .tab-status {
|
.session-tab .tab-status {
|
||||||
@@ -20062,6 +20080,314 @@ 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,
|
||||||
|
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
|
||||||
|
arrives with .tile--enter-themed and an inline --tile-enter-delay (and, for
|
||||||
|
`fly` and `deal`, the --tile-from-* offset onto the tab or the Tiles button).
|
||||||
|
FRAME keyframes animate transform and opacity only: FitAddon reads the
|
||||||
|
untransformed layout box (#464), and six tiles run at once, so no filter
|
||||||
|
(a blur belongs to the screen beat, one tile at a time). Colour goes on a
|
||||||
|
::before wash. Each style also has its own way out on the closing grid's
|
||||||
|
still copy (tile-leave-*, below). */
|
||||||
|
html[data-tile-anim="settle"] .tile.tile--entering {
|
||||||
|
animation-name: tile-enter;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.tile--entering.tile--enter-themed {
|
||||||
|
animation-delay: var(--tile-enter-delay, 0ms);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
will-change: transform, opacity;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.tile--entering.tile--enter-themed::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
animation-delay: var(--tile-enter-delay, 0ms);
|
||||||
|
animation-fill-mode: both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.tile--enter-hold {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tile.tile--enter-hold,
|
||||||
|
.tile.tile--enter-hold::before {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fly from tab: grows out of its session tab (FLIP onto the tab's own box),
|
||||||
|
overshoots a hair and lands. */
|
||||||
|
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed {
|
||||||
|
animation-name: tile-enter-fly;
|
||||||
|
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||||
|
animation-timing-function: cubic-bezier(0.2, 0.9, 0.25, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tile-anim="fly"] .tile.tile--entering.tile--enter-themed::before {
|
||||||
|
animation-name: tile-wash-launch;
|
||||||
|
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||||
|
animation-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-enter-fly {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -48px))
|
||||||
|
scale(var(--tile-from-sx, 0.3), var(--tile-from-sy, 0.1));
|
||||||
|
}
|
||||||
|
16% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
74% {
|
||||||
|
transform: translate(0, 0) scale(1.01);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-wash-launch {
|
||||||
|
0% {
|
||||||
|
opacity: 1;
|
||||||
|
background: color-mix(in srgb, var(--accent, #4a9eff) 45%, transparent);
|
||||||
|
box-shadow: inset 0 0 0 2px var(--accent, #4a9eff);
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
opacity: 0.55;
|
||||||
|
background: color-mix(in srgb, var(--accent, #4a9eff) 12%, transparent);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: inset 0 0 0 1px transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Deal: dealt out of the Tiles button like cards, each turned a little, then
|
||||||
|
squared up in its cell. */
|
||||||
|
html[data-tile-anim="deal"] .tile.tile--entering.tile--enter-themed {
|
||||||
|
animation-name: tile-enter-deal;
|
||||||
|
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
|
||||||
|
animation-timing-function: cubic-bezier(0.22, 0.85, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-enter-deal {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(var(--tile-from-x, 0px), var(--tile-from-y, -240px)) scale(var(--tile-from-sx, 0.1))
|
||||||
|
rotate(var(--tile-from-rot, -10deg));
|
||||||
|
}
|
||||||
|
10% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
transform: translate(0, 0) scale(1.02) rotate(calc(var(--tile-from-rot, -10deg) * -0.1));
|
||||||
|
}
|
||||||
|
88% {
|
||||||
|
transform: scale(0.996) rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CRT: powers on as a hot line, then unfolds, under the agent windows' flash. */
|
||||||
|
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed {
|
||||||
|
animation-name: tile-enter-crt;
|
||||||
|
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||||
|
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tile-anim="crt"] .tile.tile--entering.tile--enter-themed::before {
|
||||||
|
animation-name: win-enter-crt-flash;
|
||||||
|
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||||
|
animation-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-enter-crt {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale3d(0.5, 0.006, 1);
|
||||||
|
}
|
||||||
|
22% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale3d(1, 0.01, 1);
|
||||||
|
}
|
||||||
|
58% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale3d(1, 1.04, 1);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale3d(1, 0.985, 1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Beam down: holds still while a beam draws down from its tab (the entrance
|
||||||
|
module's own overlay, .tile-beam-line), then materializes out of the agent
|
||||||
|
windows' green wash. No transform, so the beam lands where the tile is. */
|
||||||
|
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed {
|
||||||
|
animation-name: tile-enter-beam;
|
||||||
|
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
|
||||||
|
animation-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tile-anim="beam"] .tile.tile--entering.tile--enter-themed::before {
|
||||||
|
animation-name: win-enter-beam-wash;
|
||||||
|
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
|
||||||
|
animation-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-enter-beam {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
32% {
|
||||||
|
opacity: 0.2;
|
||||||
|
}
|
||||||
|
48% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cascade: swings down from its top edge, a diagonal at a time. */
|
||||||
|
html[data-tile-anim="cascade"] .tile.tile--entering.tile--enter-themed {
|
||||||
|
animation-name: tile-enter-cascade;
|
||||||
|
animation-duration: calc(600ms * var(--anim-enter-scale, 1));
|
||||||
|
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||||
|
transform-origin: 50% 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-enter-cascade {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: perspective(1400px) rotateX(-80deg);
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: perspective(1400px) rotateX(8deg);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: perspective(1400px) rotateX(-2.5deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: perspective(1400px) rotateX(0deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pop: springs open from its centre, rippling out from the focused tile. */
|
||||||
|
html[data-tile-anim="pop"] .tile.tile--entering.tile--enter-themed {
|
||||||
|
animation-name: tile-enter-pop;
|
||||||
|
animation-duration: calc(480ms * var(--anim-enter-scale, 1));
|
||||||
|
animation-timing-function: ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-enter-pop {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.6);
|
||||||
|
}
|
||||||
|
58% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
80% {
|
||||||
|
transform: scale(0.993);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Soft: a slow drift up into place; with the Soft focus theme each screen
|
||||||
|
then focus-pulls in (the pane's `blur` style on .tile-body). */
|
||||||
|
html[data-tile-anim="soft"] .tile.tile--entering.tile--enter-themed {
|
||||||
|
animation-name: tile-enter-soft;
|
||||||
|
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
|
||||||
|
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-enter-soft {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(16px) scale(0.94);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* The tab a tile flies (or beams) out of glows as it goes. */
|
||||||
|
.session-tab.tab-launch:not(.tab-loading)::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
animation: tab-launch-flash calc(520ms * var(--anim-enter-scale, 1)) ease-out var(--tab-launch-delay, 0ms) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tab-launch-flash {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
background: color-mix(in srgb, var(--accent, #4a9eff) 40%, transparent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent, #4a9eff), 0 0 14px 2px color-mix(in srgb, var(--accent, #4a9eff) 70%, transparent);
|
||||||
|
}
|
||||||
|
18% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
background: transparent;
|
||||||
|
box-shadow: 0 0 0 1px transparent, 0 0 0 0 transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* `beam`'s lines: the connection-line look, drawn from the tab down to the
|
||||||
|
tile, held while the tile materializes, then faded. Their own overlay
|
||||||
|
(#tileBeamLines), removed by the entrance module once they are done. */
|
||||||
|
.connection-line.tile-beam-line {
|
||||||
|
stroke-dasharray: var(--line-len);
|
||||||
|
animation:
|
||||||
|
line-enter-draw calc(380ms * var(--anim-enter-scale, 1)) cubic-bezier(0.32, 0.8, 0.3, 1)
|
||||||
|
var(--line-enter-delay, 0ms) both,
|
||||||
|
tile-beam-out calc(420ms * var(--anim-enter-scale, 1)) ease-in
|
||||||
|
calc(var(--line-enter-delay, 0ms) + 640ms * var(--anim-enter-scale, 1)) forwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-beam-out {
|
||||||
|
from {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.tile-body .xterm {
|
.tile-body .xterm {
|
||||||
transition: opacity 160ms ease-out;
|
transition: opacity 160ms ease-out;
|
||||||
}
|
}
|
||||||
@@ -20136,9 +20462,220 @@ body.tile-grid-resizing--row * {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The entrance style's own way out (entrance-animations.js _stageTileExit),
|
||||||
|
on the still copy only and only for the Tiles toggle's close: a re-form's
|
||||||
|
copy (--now) covers tiles that stay, so it keeps the plain fade above.
|
||||||
|
Copies leave in reading order (--tile-exit-delay), so the last one ends
|
||||||
|
last and takes the layer with it. */
|
||||||
|
.tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 6;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||||
|
animation: tile-leave-fly calc(460ms * var(--anim-enter-scale, 1)) cubic-bezier(0.55, 0, 0.75, 0.2)
|
||||||
|
var(--tile-exit-delay, 0ms) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tile-anim="fly"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
|
||||||
|
animation: tile-wash-return calc(460ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-leave-fly {
|
||||||
|
0% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -48px))
|
||||||
|
scale(var(--tile-to-sx, 0.3), var(--tile-to-sy, 0.1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-wash-return {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
background: color-mix(in srgb, var(--accent, #4a9eff) 55%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tile-anim="deal"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||||
|
animation: tile-leave-deal calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.25)
|
||||||
|
var(--tile-exit-delay, 0ms) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-leave-deal {
|
||||||
|
0% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
22% {
|
||||||
|
transform: scale(1.02) rotate(calc(var(--tile-to-rot, -10deg) * -0.15));
|
||||||
|
}
|
||||||
|
85% {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(var(--tile-to-x, 0px), var(--tile-to-y, -240px)) scale(var(--tile-to-sx, 0.1))
|
||||||
|
rotate(var(--tile-to-rot, -10deg));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* CRT: the classic switch-off, down to a line, then a dot, then dark. */
|
||||||
|
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||||
|
animation: tile-leave-crt calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.6, 1)
|
||||||
|
var(--tile-exit-delay, 0ms) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tile-anim="crt"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
|
||||||
|
animation: tile-wash-crt-off calc(520ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-leave-crt {
|
||||||
|
0% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
42% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale3d(1, 0.012, 1);
|
||||||
|
}
|
||||||
|
72% {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale3d(0.03, 0.012, 1);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale3d(0, 0, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-wash-crt-off {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
38% {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(220, 255, 235, 0.95);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
background: rgba(255, 255, 255, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Beam down: beamed back up, flickering out under a rising wash. */
|
||||||
|
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||||
|
animation: tile-leave-beam calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
html[data-tile-anim="beam"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving::before {
|
||||||
|
animation: tile-wash-beam-up calc(480ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-leave-beam {
|
||||||
|
0% {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
40% {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
55% {
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
70% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-wash-beam-up {
|
||||||
|
0% {
|
||||||
|
opacity: 0;
|
||||||
|
background: linear-gradient(0deg, rgba(0, 255, 102, 0.35), rgba(0, 255, 102, 0));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
background: linear-gradient(0deg, rgba(190, 255, 215, 0.85), rgba(0, 255, 102, 0.2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cascade: folds back up on its top edge. */
|
||||||
|
html[data-tile-anim="cascade"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||||
|
animation: tile-leave-cascade calc(480ms * var(--anim-enter-scale, 1)) cubic-bezier(0.5, 0, 0.75, 0.2)
|
||||||
|
var(--tile-exit-delay, 0ms) both;
|
||||||
|
transform-origin: 50% 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-leave-cascade {
|
||||||
|
0% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: perspective(1400px) rotateX(0deg) scale(0.985);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: perspective(1400px) rotateX(82deg) scale(0.985);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Pop: a last swell, then it pops away. */
|
||||||
|
html[data-tile-anim="pop"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||||
|
animation: tile-leave-pop calc(380ms * var(--anim-enter-scale, 1)) ease-in var(--tile-exit-delay, 0ms) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-leave-pop {
|
||||||
|
0% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
35% {
|
||||||
|
opacity: 0.9;
|
||||||
|
transform: scale(1.03);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Soft: sinks away slowly. */
|
||||||
|
html[data-tile-anim="soft"] .tile-grid-ghosts.tile-grid-ghosts--release:not(.tile-grid-ghosts--now) .tile.tile--leaving {
|
||||||
|
animation: tile-leave-soft calc(520ms * var(--anim-enter-scale, 1)) cubic-bezier(0.4, 0, 0.7, 0.4)
|
||||||
|
var(--tile-exit-delay, 0ms) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tile-leave-soft {
|
||||||
|
0% {
|
||||||
|
opacity: 0.8;
|
||||||
|
transform: scale(0.985);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(14px) scale(0.94);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.tile-count-menu,
|
.tile-count-menu,
|
||||||
.tile.tile--entering,
|
.tile.tile--entering,
|
||||||
|
.tile.tile--entering::before,
|
||||||
.tile.tile--needs::after,
|
.tile.tile--needs::after,
|
||||||
.tile.tile--loading .tile-body::after,
|
.tile.tile--loading .tile-body::after,
|
||||||
.tile-grid-ghosts .tile.tile--leaving,
|
.tile-grid-ghosts .tile.tile--leaving,
|
||||||
|
|||||||
+92
-21
@@ -237,8 +237,15 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (state !== 'idle') this._setTileLoadingLabel(entry.body);
|
if (state !== 'idle') this._setTileLoadingLabel(entry.body);
|
||||||
entry.el.classList.toggle('tile--loading', state !== 'idle');
|
entry.el.classList.toggle('tile--loading', state !== 'idle');
|
||||||
// The first capture has landed (or failed): the terminal fades in,
|
// The first capture has landed (or failed): the terminal fades in,
|
||||||
// whole, instead of showing its replay scroll by.
|
// whole, instead of showing its replay scroll by, or plays the
|
||||||
if (state === 'idle') entry.el.classList.remove('tile--revealing');
|
// terminal pane's entrance style (entrance-animations.js).
|
||||||
|
if (state === 'idle') {
|
||||||
|
entry.el.classList.remove('tile--revealing');
|
||||||
|
if (entry.screenOwed) {
|
||||||
|
entry.screenOwed = false;
|
||||||
|
this.playTileScreenEntrance?.(entry.body);
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -536,14 +543,28 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
// Zoomed, only the zoomed tile is on screen (the others are display: none
|
// Zoomed, only the zoomed tile is on screen (the others are display: none
|
||||||
// under .tile-grid--zoomed, a rule the ghost layer does not carry).
|
// under .tile-grid--zoomed, a rule the ghost layer does not carry).
|
||||||
const zoomed = section.classList.contains('tile-grid--zoomed');
|
const zoomed = section.classList.contains('tile-grid--zoomed');
|
||||||
|
const copies = [];
|
||||||
for (const id of grid.ids) {
|
for (const id of grid.ids) {
|
||||||
const el = grid.tiles.get(id)?.el;
|
const el = grid.tiles.get(id)?.el;
|
||||||
if (!el || (zoomed && !el.classList.contains('tile--zoomed'))) continue;
|
if (!el || (zoomed && !el.classList.contains('tile--zoomed'))) continue;
|
||||||
const ghost = el.cloneNode(true);
|
const ghost = el.cloneNode(true);
|
||||||
ghost.classList.remove('tile--entering', 'tile--needs', 'tile--loading', 'tile--drop-target', 'tile--dragging');
|
ghost.classList.remove(
|
||||||
|
'tile--entering',
|
||||||
|
'tile--enter-themed',
|
||||||
|
'tile--enter-hold',
|
||||||
|
'tile--needs',
|
||||||
|
'tile--loading',
|
||||||
|
'tile--drop-target',
|
||||||
|
'tile--dragging'
|
||||||
|
);
|
||||||
|
ghost.querySelector?.('.tile-body')?.classList.remove('term-enter');
|
||||||
ghost.classList.add('tile--leaving');
|
ghost.classList.add('tile--leaving');
|
||||||
layer.appendChild(ghost);
|
layer.appendChild(ghost);
|
||||||
|
copies.push({ ghost, el, sessionId: id });
|
||||||
}
|
}
|
||||||
|
// The entrance style's own way out (entrance-animations.js): back into the
|
||||||
|
// tabs, a CRT switch-off. Placed while the real tiles are still measurable.
|
||||||
|
const leaveMs = this._stageTileExit?.(copies, { now: !hold }) || 0;
|
||||||
main.appendChild(layer);
|
main.appendChild(layer);
|
||||||
let fallback = null;
|
let fallback = null;
|
||||||
let holdCap = null;
|
let holdCap = null;
|
||||||
@@ -557,10 +578,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (fallback !== null || !layer.isConnected) return;
|
if (fallback !== null || !layer.isConnected) return;
|
||||||
clearTimeout(holdCap);
|
clearTimeout(holdCap);
|
||||||
layer.classList.add('tile-grid-ghosts--release');
|
layer.classList.add('tile-grid-ghosts--release');
|
||||||
fallback = setTimeout(done, TILE_GHOST_FALLBACK_MS);
|
fallback = setTimeout(done, Math.max(TILE_GHOST_FALLBACK_MS, leaveMs));
|
||||||
};
|
};
|
||||||
layer.addEventListener('animationend', (e) => {
|
layer.addEventListener('animationend', (e) => {
|
||||||
if (/^tile-leave/.test(e.animationName) && e.target === layer.lastElementChild) done();
|
if (e.pseudoElement || e.target !== layer.lastElementChild) return;
|
||||||
|
if (/^tile-leave/.test(e.animationName)) done();
|
||||||
});
|
});
|
||||||
if (hold) holdCap = setTimeout(release, TILE_GHOST_HOLD_MAX_MS);
|
if (hold) holdCap = setTimeout(release, TILE_GHOST_HOLD_MAX_MS);
|
||||||
else release();
|
else release();
|
||||||
@@ -1323,11 +1345,10 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds one tile (header, body, TerminalTile); where it goes is the
|
* Builds one tile (header, body, TerminalTile); where it goes is the
|
||||||
* caller's (grid.cells). It enters with a short fade and settle
|
* caller's (grid.cells). It enters as the `enterIndex`-th of a staggered
|
||||||
* (styles.css .tile--entering, the `enterIndex`-th of a staggered group),
|
* group (_beginTileEntrance), and its terminal stays transparent until its
|
||||||
* and its terminal stays transparent until its first capture lands
|
* first capture lands (.tile--revealing, cleared by the load queue, with a
|
||||||
* (.tile--revealing, cleared by the load queue, with a backstop timer).
|
* backstop timer). Opacity and transform only, never anything its fit reads.
|
||||||
* Opacity and transform only, never anything its fit reads.
|
|
||||||
*/
|
*/
|
||||||
_mountTile(sessionId, { enterIndex = 0 } = {}) {
|
_mountTile(sessionId, { enterIndex = 0 } = {}) {
|
||||||
const grid = this._tileGrid;
|
const grid = this._tileGrid;
|
||||||
@@ -1336,16 +1357,11 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'tile tile--revealing';
|
el.className = 'tile tile--revealing';
|
||||||
setTimeout(() => el.classList.remove('tile--revealing'), TILE_REVEAL_FALLBACK_MS);
|
setTimeout(() => el.classList.remove('tile--revealing'), TILE_REVEAL_FALLBACK_MS);
|
||||||
if (this._tileMotionAllowed()) {
|
// Its screen plays the terminal pane's entrance once its first capture
|
||||||
el.classList.add('tile--entering');
|
// lands, when its frame entered in a tile style (Tile Animations): never
|
||||||
el.style.setProperty('--tile-enter-index', String(enterIndex));
|
// with the default `settle`, nor on a reload's restore, which settles.
|
||||||
const onEnd = (e) => {
|
const entered = this._beginTileEntrance(el, sessionId, enterIndex);
|
||||||
if (e.target !== el || e.animationName !== 'tile-enter') return;
|
const screenOwed = !!entered && entered !== 'settle';
|
||||||
el.removeEventListener('animationend', onEnd);
|
|
||||||
el.classList.remove('tile--entering');
|
|
||||||
};
|
|
||||||
el.addEventListener('animationend', onEnd);
|
|
||||||
}
|
|
||||||
el.dataset.sessionId = sessionId;
|
el.dataset.sessionId = sessionId;
|
||||||
// Header and body are siblings: the chrome is refreshed in place
|
// Header and body are siblings: the chrome is refreshed in place
|
||||||
// (_renderTileHeader), never by rewriting the tile, which would take the
|
// (_renderTileHeader), never by rewriting the tile, which would take the
|
||||||
@@ -1391,12 +1407,61 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
renaming: false,
|
renaming: false,
|
||||||
// The pid this tile last saw, so a pane that starts later is noticed.
|
// The pid this tile last saw, so a pane that starts later is noticed.
|
||||||
pid: this.sessions.get(sessionId)?.pid ?? null,
|
pid: this.sessions.get(sessionId)?.pid ?? null,
|
||||||
|
// Its screen plays the terminal entrance once its first capture lands.
|
||||||
|
screenOwed,
|
||||||
});
|
});
|
||||||
// Where it goes is the caller's (grid.cells).
|
// Where it goes is the caller's (grid.cells).
|
||||||
this._renderTileHeader(sessionId);
|
this._renderTileHeader(sessionId);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A tile's frame enters as the `enterIndex`-th of a staggered group, in the
|
||||||
|
* entrance style (entrance-animations.js, App Settings → Entrance
|
||||||
|
* Animations). `settle` is the grid's own fade and settle (styles.css
|
||||||
|
* .tile--entering), and what a reload restores with whatever the theme:
|
||||||
|
* nothing animates on page load. Any other style is timed by the entrance
|
||||||
|
* module a frame later, once the layout is final (_stageTileEntrance).
|
||||||
|
* Opacity and transform only, never anything the fit reads.
|
||||||
|
*
|
||||||
|
* @returns {string|null} the style it enters with, or null when it does not animate
|
||||||
|
*/
|
||||||
|
_beginTileEntrance(el, sessionId, enterIndex = 0) {
|
||||||
|
const style = this._tileEnterQuiet ? 'settle' : this.tileEntranceStyle?.() || 'settle';
|
||||||
|
if (!this._tileMotionAllowed() || style === 'off') return null;
|
||||||
|
el.classList.add('tile--entering');
|
||||||
|
el.style.setProperty('--tile-enter-index', String(enterIndex));
|
||||||
|
let backstop = null;
|
||||||
|
const finish = () => {
|
||||||
|
clearTimeout(backstop);
|
||||||
|
el.removeEventListener('animationend', onEnd);
|
||||||
|
el.classList.remove('tile--entering', 'tile--enter-themed', 'tile--enter-hold');
|
||||||
|
if (el._tileEnterFinish === finish) el._tileEnterFinish = null;
|
||||||
|
};
|
||||||
|
// The tile's own entrance (`tile-enter`, or a themed `tile-enter-*`),
|
||||||
|
// never a child's animation or a wash on its ::before.
|
||||||
|
const onEnd = (e) => {
|
||||||
|
if (e.target !== el || e.pseudoElement || !/^tile-enter/.test(e.animationName || '')) return;
|
||||||
|
finish();
|
||||||
|
};
|
||||||
|
el.addEventListener('animationend', onEnd);
|
||||||
|
el._tileEnterFinish = finish;
|
||||||
|
if (style !== 'settle') {
|
||||||
|
this._stageTileEntrance?.(el, sessionId, (ms) => {
|
||||||
|
clearTimeout(backstop);
|
||||||
|
backstop = setTimeout(finish, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return style;
|
||||||
|
},
|
||||||
|
|
||||||
|
/** Plays a mounted tile's entrance again (the entrance lab's replay): nothing is remounted or refitted. */
|
||||||
|
_replayTileEntrance(el, sessionId, enterIndex = 0) {
|
||||||
|
el._tileEnterFinish?.();
|
||||||
|
void el.offsetWidth;
|
||||||
|
return this._beginTileEntrance(el, sessionId, enterIndex);
|
||||||
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes `el` a drop target for a session tab dragged from the strip (the
|
* Makes `el` a drop target for a session tab dragged from the strip (the
|
||||||
* strip's own drag sets `draggedTabId`) and for a tile dragged by its header
|
* strip's own drag sets `draggedTabId`) and for a tile dragged by its header
|
||||||
@@ -2598,7 +2663,13 @@ Object.assign(CodemanApp.prototype, {
|
|||||||
if (this._tilesOwnTerminal()) return false;
|
if (this._tilesOwnTerminal()) return false;
|
||||||
const stored = this._readStoredTileGrid();
|
const stored = this._readStoredTileGrid();
|
||||||
if (!stored?.open || stored.ids.length === 0) return false;
|
if (!stored?.open || stored.ids.length === 0) return false;
|
||||||
return this._openStoredTileGrid(stored);
|
// Put back, not opened: the tiles settle in whatever the entrance theme.
|
||||||
|
this._tileEnterQuiet = true;
|
||||||
|
try {
|
||||||
|
return this._openStoredTileGrid(stored);
|
||||||
|
} finally {
|
||||||
|
this._tileEnterQuiet = false;
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */
|
/** A followed `#session=` link took the screen on load: the stored grid stays remembered, closed. */
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const SURFACES = [
|
|||||||
{ attr: 'win', array: 'WIN_ANIM_STYLES', selector: '.subagent-window.win-enter' },
|
{ attr: 'win', array: 'WIN_ANIM_STYLES', selector: '.subagent-window.win-enter' },
|
||||||
{ attr: 'line', array: 'LINE_ANIM_STYLES', selector: '.connection-line.line-enter' },
|
{ attr: 'line', array: 'LINE_ANIM_STYLES', selector: '.connection-line.line-enter' },
|
||||||
{ attr: 'term', array: 'TERM_ANIM_STYLES', selector: '.terminal-container.term-enter' },
|
{ attr: 'term', array: 'TERM_ANIM_STYLES', selector: '.terminal-container.term-enter' },
|
||||||
|
{ attr: 'tile', array: 'TILE_ANIM_STYLES', selector: '.tile.tile--entering' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -40,6 +41,11 @@ const SURFACES = [
|
|||||||
*/
|
*/
|
||||||
const CSS_LESS_STYLES = new Set(['off', 'fly']);
|
const CSS_LESS_STYLES = new Set(['off', 'fly']);
|
||||||
|
|
||||||
|
/** `fly` is CSS-less on the window surface only: a tile's `fly` is keyframes like any other. */
|
||||||
|
function isCssLess(attr: string, key: string): boolean {
|
||||||
|
return key === 'off' || (attr === 'win' && CSS_LESS_STYLES.has(key));
|
||||||
|
}
|
||||||
|
|
||||||
function styleKeys(arrayName: string): string[] {
|
function styleKeys(arrayName: string): string[] {
|
||||||
const start = animSource.indexOf(`const ${arrayName} = [`);
|
const start = animSource.indexOf(`const ${arrayName} = [`);
|
||||||
expect(start, `${arrayName} not found`).toBeGreaterThan(-1);
|
expect(start, `${arrayName} not found`).toBeGreaterThan(-1);
|
||||||
@@ -47,12 +53,21 @@ function styleKeys(arrayName: string): string[] {
|
|||||||
return [...body.matchAll(/\{ key: '([^']+)'/g)].map((m) => m[1]);
|
return [...body.matchAll(/\{ key: '([^']+)'/g)].map((m) => m[1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function themes(): { key: string; tab: string; win: string; line: string; term: string }[] {
|
type Theme = { key: string; tab: string; win: string; line: string; term: string; tile: string };
|
||||||
|
|
||||||
|
function themes(): Theme[] {
|
||||||
const start = animSource.indexOf('const ANIM_THEMES = [');
|
const start = animSource.indexOf('const ANIM_THEMES = [');
|
||||||
const body = animSource.slice(start, animSource.indexOf('];', start));
|
const body = animSource.slice(start, animSource.indexOf('];', start));
|
||||||
return [
|
const parsed = [
|
||||||
...body.matchAll(/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)' \}/g),
|
...body.matchAll(
|
||||||
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5] }));
|
/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)', tile: '([^']+)' \}/g
|
||||||
|
),
|
||||||
|
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5], tile: m[6] }));
|
||||||
|
// Every theme entry must parse: a theme missing a surface (or a regex that
|
||||||
|
// drifted from the source) would otherwise pass the checks below vacuously.
|
||||||
|
expect(parsed.length, 'a theme entry did not parse').toBe((body.match(/\{ key: '/g) || []).length);
|
||||||
|
expect(parsed.length).toBeGreaterThan(0);
|
||||||
|
return parsed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -82,7 +97,7 @@ describe('entrance animation styles', () => {
|
|||||||
describe(`${surface.attr} surface`, () => {
|
describe(`${surface.attr} surface`, () => {
|
||||||
it('backs every style with a rule that names a keyframe block that exists', () => {
|
it('backs every style with a rule that names a keyframe block that exists', () => {
|
||||||
for (const key of styleKeys(surface.array)) {
|
for (const key of styleKeys(surface.array)) {
|
||||||
if (CSS_LESS_STYLES.has(key)) {
|
if (isCssLess(surface.attr, key)) {
|
||||||
expect(stylesSource).not.toContain(`html[data-${surface.attr}-anim="${key}"]`);
|
expect(stylesSource).not.toContain(`html[data-${surface.attr}-anim="${key}"]`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -99,8 +114,16 @@ describe('entrance animation styles', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
it('ships the blur style on all four surfaces', () => {
|
/**
|
||||||
for (const surface of SURFACES) expect(styleKeys(surface.array)).toContain('blur');
|
* Tiles are the exception: six frames animate at once, so their styles stay
|
||||||
|
* off `filter`, and a tile's blur is its SCREEN beat (the pane's `blur`
|
||||||
|
* style on .tile-body, one tile at a time), which the Soft focus theme uses.
|
||||||
|
*/
|
||||||
|
it('ships the blur style on the four single-element surfaces', () => {
|
||||||
|
for (const surface of SURFACES) {
|
||||||
|
if (surface.attr === 'tile') expect(styleKeys(surface.array)).not.toContain('blur');
|
||||||
|
else expect(styleKeys(surface.array)).toContain('blur');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('gives every theme an <option> and only styles that exist', () => {
|
it('gives every theme an <option> and only styles that exist', () => {
|
||||||
@@ -173,3 +196,96 @@ describe('entrance animation styles', () => {
|
|||||||
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
|
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('tile grid entrance styles', () => {
|
||||||
|
/** Keyframes a `html[data-tile-anim=...]` rule names, on the tile or on its ::before wash. */
|
||||||
|
const tileNames = (key: string, scope: RegExp) =>
|
||||||
|
[...stylesSource.matchAll(new RegExp(`html\\[data-tile-anim="${key}"\\]([^{]*)\\{([^}]*)\\}`, 'g'))]
|
||||||
|
.filter((rule) => scope.test(rule[1]))
|
||||||
|
.flatMap((rule) =>
|
||||||
|
[...rule[2].matchAll(/animation(?:-name)?:\s*([\w-]+)/g)].map((m) => ({
|
||||||
|
name: m[1],
|
||||||
|
onPseudo: rule[1].includes('::before'),
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ⚠ The FitAddon rule, for six frames at once: transform and opacity only.
|
||||||
|
* A tile fits once at its final size (#464); a box-model property here would
|
||||||
|
* resize its PTY mid-animation, and a filter on six live terminals at once
|
||||||
|
* is the frame-time cost the pane's `blur` takes for one.
|
||||||
|
*/
|
||||||
|
it('animates only transform and opacity on a tile frame, entering and leaving', () => {
|
||||||
|
for (const key of styleKeys('TILE_ANIM_STYLES')) {
|
||||||
|
if (key === 'off') continue;
|
||||||
|
const names = [...tileNames(key, /tile--entering/), ...tileNames(key, /tile--leaving/)];
|
||||||
|
expect(names.length, `no keyframes for tile/${key}`).toBeGreaterThan(0);
|
||||||
|
for (const { name, onPseudo } of names) {
|
||||||
|
const body = keyframeBody(name);
|
||||||
|
expect(body, `@keyframes ${name} missing`).not.toBeNull();
|
||||||
|
if (onPseudo) continue; // a wash over the tile, no layout of its own
|
||||||
|
for (const [, prop] of (body as string).matchAll(/(?:\{|;)\s*([a-z-]+):/g)) {
|
||||||
|
expect(['opacity', 'transform'], `@keyframes ${name} animates ${prop} on a tile`).toContain(prop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The mount clears `.tile--entering` on the tile's own `tile-enter*`
|
||||||
|
* animationend, and the still copy goes on its last tile's `tile-leave*`:
|
||||||
|
* a keyframe named otherwise would leave the class on (or the copy up)
|
||||||
|
* until a backstop timer.
|
||||||
|
*/
|
||||||
|
it('names every frame keyframe for the events tile-grid.js listens for', () => {
|
||||||
|
for (const key of styleKeys('TILE_ANIM_STYLES')) {
|
||||||
|
for (const { name, onPseudo } of tileNames(key, /tile--entering/)) {
|
||||||
|
if (!onPseudo) expect(name, `tile/${key} entering`).toMatch(/^tile-enter/);
|
||||||
|
}
|
||||||
|
for (const { name, onPseudo } of tileNames(key, /tile--leaving/)) {
|
||||||
|
if (!onPseudo) expect(name, `tile/${key} leaving`).toMatch(/^tile-leave/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gives every exit the module times a leaving rule, and keeps `settle` on the grid default', () => {
|
||||||
|
const exits = animSource.slice(animSource.indexOf('const TILE_EXIT_MS = {'));
|
||||||
|
const timed = [...exits.slice(0, exits.indexOf('};')).matchAll(/(\w+): \d+/g)].map((m) => m[1]);
|
||||||
|
expect(timed.length).toBeGreaterThan(0);
|
||||||
|
for (const key of timed) {
|
||||||
|
expect(styleKeys('TILE_ANIM_STYLES')).toContain(key);
|
||||||
|
expect(tileNames(key, /tile--leaving/).length, `no leaving rule for tile/${key}`).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
expect(timed).not.toContain('settle');
|
||||||
|
expect(animSource).toContain("const TILE_ANIM_DEFAULT = 'settle';");
|
||||||
|
// The legacy theme (the default) leaves the grid's own motion untouched.
|
||||||
|
expect(themes().find((t) => t.key === 'legacy')?.tile).toBe('settle');
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App Settings → Appearance → Tile Animations: one option per style, wired
|
||||||
|
* by id (entrance-animations.js _syncEntranceAnimSetting), with the grid's
|
||||||
|
* own `settle` first and named as the off default.
|
||||||
|
*/
|
||||||
|
it('lists every tile style in the Tile Animations setting, off by default', () => {
|
||||||
|
const start = indexSource.indexOf('<select id="appSettingsTileAnim"');
|
||||||
|
expect(start, 'the Tile Animations select is missing').toBeGreaterThan(-1);
|
||||||
|
const select = indexSource.slice(start, indexSource.indexOf('</select>', start));
|
||||||
|
const values = [...select.matchAll(/<option value="([^"]+)"/g)].map((m) => m[1]);
|
||||||
|
expect([...values].sort()).toEqual([...styleKeys('TILE_ANIM_STYLES')].sort());
|
||||||
|
expect(values[0]).toBe('settle');
|
||||||
|
expect(select).toContain('<option value="settle">Off (default)</option>');
|
||||||
|
expect(animSource).toContain("document.getElementById('appSettingsTileAnim')");
|
||||||
|
// Nothing new for an install that never picks it: no saved key means settle.
|
||||||
|
expect(animSource).toMatch(/pick\('tileanim', TILE_ANIM_STYLES, ANIM_KEYS\.tile, TILE_ANIM_DEFAULT\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** A tile's screen plays the pane's style: every term rule also reaches .tile-body. */
|
||||||
|
it('plays every terminal pane style on a tile screen too', () => {
|
||||||
|
for (const key of styleKeys('TERM_ANIM_STYLES')) {
|
||||||
|
if (CSS_LESS_STYLES.has(key)) continue;
|
||||||
|
// The body itself, not only its ::before wash.
|
||||||
|
expect(stylesSource).toMatch(new RegExp(`html\\[data-term-anim="${key}"\\] \\.tile-body\\.term-enter \\{`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* @fileoverview tile-grid.js's side of the tile entrance styles (the entrance
|
||||||
|
* module, entrance-animations.js, picks the style and times it):
|
||||||
|
*
|
||||||
|
* - `settle` (the default, and what the harness gets with no entrance module
|
||||||
|
* loaded) is the grid's own motion, untouched: tile-grid-motion.test.ts.
|
||||||
|
* - Any other style hands each mounted tile to `_stageTileEntrance`, which
|
||||||
|
* arms the backstop that ends the entrance if animationend never comes.
|
||||||
|
* - `.tile--entering` comes off on the tile's own `tile-enter*` end only,
|
||||||
|
* never on a ::before wash ending first.
|
||||||
|
* - `off` mounts tiles with no entrance and no screen beat.
|
||||||
|
* - A reload's restore settles whatever the style, and owes no screen beat.
|
||||||
|
* - The screen beat (`playTileScreenEntrance`) plays once, when the load queue
|
||||||
|
* reports a tile's first capture done, and only for a tile style (never
|
||||||
|
* with the default `settle`).
|
||||||
|
* - The closing grid's still copy hands its copies to `_stageTileExit` and
|
||||||
|
* waits as long as it says before the fallback removes the copy.
|
||||||
|
*
|
||||||
|
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
|
||||||
|
*/
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import {
|
||||||
|
FakeEl,
|
||||||
|
localStore,
|
||||||
|
main,
|
||||||
|
makeGridApp,
|
||||||
|
resetGridHarness,
|
||||||
|
section,
|
||||||
|
windowStub,
|
||||||
|
type GridApp,
|
||||||
|
} from './mocks/tile-grid-vm.js';
|
||||||
|
|
||||||
|
const IDS = ['s-a', 's-b', 's-c', 's-d'];
|
||||||
|
const tiles = () => section.children.filter((c) => c.classList.contains('tile'));
|
||||||
|
const ghosts = () => main.children.filter((c) => c.classList.contains('tile-grid-ghosts'));
|
||||||
|
|
||||||
|
/** An app whose entrance module picks `style`, with the module's hooks recorded. */
|
||||||
|
function styledApp(style: string): GridApp {
|
||||||
|
const app = makeGridApp(IDS);
|
||||||
|
app.selectSession = vi.fn((id: string) => app._selectTiledSession(id, {}));
|
||||||
|
app.tileEntranceStyle = () => style;
|
||||||
|
app._stageTileEntrance = vi.fn();
|
||||||
|
app.playTileScreenEntrance = vi.fn();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Swaps in a load queue that hands the grid's state callback to the test. */
|
||||||
|
function captureQueue() {
|
||||||
|
const Queue = windowStub.TileLoadQueue;
|
||||||
|
const box: { report: (tile: unknown, state: string) => void; restore: () => void } = {
|
||||||
|
report: () => {},
|
||||||
|
restore: () => {
|
||||||
|
windowStub.TileLoadQueue = Queue;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
windowStub.TileLoadQueue = class {
|
||||||
|
constructor(opts: { onChange: (tile: unknown, state: string) => void }) {
|
||||||
|
box.report = opts.onChange;
|
||||||
|
}
|
||||||
|
schedule(_t: unknown, _k: string, run: () => Promise<void>) {
|
||||||
|
return run();
|
||||||
|
}
|
||||||
|
drop() {}
|
||||||
|
};
|
||||||
|
return box;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetGridHarness();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a themed entrance', () => {
|
||||||
|
it('hands every mounted tile to the entrance module, entering and in reading order', () => {
|
||||||
|
const app = styledApp('fly');
|
||||||
|
app.openTileGrid(IDS.slice(0, 3), { focusedId: 's-b' });
|
||||||
|
expect(app._stageTileEntrance).toHaveBeenCalledTimes(3);
|
||||||
|
const calls = (app._stageTileEntrance as ReturnType<typeof vi.fn>).mock.calls;
|
||||||
|
expect(calls.map((c) => c[1])).toEqual(['s-a', 's-b', 's-c']);
|
||||||
|
for (const [el] of calls) expect((el as FakeEl).classList.contains('tile--entering')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the backstop the module arms ends the entrance when animationend never comes', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const app = styledApp('crt');
|
||||||
|
app.openTileGrid(['s-a']);
|
||||||
|
const [el, , setBackstop] = (app._stageTileEntrance as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
(el as FakeEl).classList.add('tile--enter-themed', 'tile--enter-hold');
|
||||||
|
setBackstop(800);
|
||||||
|
vi.advanceTimersByTime(799);
|
||||||
|
expect((el as FakeEl).classList.contains('tile--entering')).toBe(true);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect((el as FakeEl).className.split(' ')).not.toContain('tile--entering');
|
||||||
|
expect((el as FakeEl).classList.contains('tile--enter-themed')).toBe(false);
|
||||||
|
expect((el as FakeEl).classList.contains('tile--enter-hold')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ends on its own tile-enter-* animationend, never on a ::before wash or a child', () => {
|
||||||
|
const app = styledApp('crt');
|
||||||
|
app.openTileGrid(['s-a']);
|
||||||
|
const el = tiles()[0];
|
||||||
|
el.dispatch('animationend', { target: el, animationName: 'win-enter-crt-flash', pseudoElement: '::before' });
|
||||||
|
el.dispatch('animationend', { target: el, animationName: 'tile-enter-crt', pseudoElement: '::before' });
|
||||||
|
el.dispatch('animationend', { target: new FakeEl(), animationName: 'tile-enter-crt' });
|
||||||
|
expect(el.classList.contains('tile--entering')).toBe(true);
|
||||||
|
el.dispatch('animationend', { target: el, animationName: 'tile-enter-crt', pseudoElement: '' });
|
||||||
|
expect(el.classList.contains('tile--entering')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('`off` mounts tiles with no entrance and owes them no screen beat', () => {
|
||||||
|
const queue = captureQueue();
|
||||||
|
try {
|
||||||
|
const app = styledApp('off');
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
expect(tiles().some((t) => t.classList.contains('tile--entering'))).toBe(false);
|
||||||
|
expect(app._stageTileEntrance).not.toHaveBeenCalled();
|
||||||
|
app._tileLoadQueue();
|
||||||
|
queue.report(app._tileFor('s-a'), 'idle');
|
||||||
|
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
queue.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('`settle` stays on the grid default: no hand-off to the module', () => {
|
||||||
|
const app = styledApp('settle');
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
expect(app._stageTileEntrance).not.toHaveBeenCalled();
|
||||||
|
expect(tiles().map((t) => t.style['--tile-enter-index'])).toEqual(['0', '1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('the screen beat', () => {
|
||||||
|
it('never plays with the default settle: the grid behaves exactly as before', () => {
|
||||||
|
const queue = captureQueue();
|
||||||
|
try {
|
||||||
|
const app = styledApp('settle');
|
||||||
|
app.openTileGrid(['s-a']);
|
||||||
|
app._tileLoadQueue();
|
||||||
|
queue.report(app._tileFor('s-a'), 'idle');
|
||||||
|
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
queue.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('plays once, on the first capture landing, on that tile body', () => {
|
||||||
|
const queue = captureQueue();
|
||||||
|
try {
|
||||||
|
const app = styledApp('fly');
|
||||||
|
app.openTileGrid(['s-a', 's-b']);
|
||||||
|
app._tileLoadQueue();
|
||||||
|
const tileA = app._tileFor('s-a');
|
||||||
|
queue.report(tileA, 'running');
|
||||||
|
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
|
||||||
|
queue.report(tileA, 'idle');
|
||||||
|
expect(app.playTileScreenEntrance).toHaveBeenCalledTimes(1);
|
||||||
|
expect(app.playTileScreenEntrance).toHaveBeenCalledWith(app._tileGrid.tiles.get('s-a').body);
|
||||||
|
// A later load (a reconnect refresh) plays nothing again.
|
||||||
|
queue.report(tileA, 'running');
|
||||||
|
queue.report(tileA, 'idle');
|
||||||
|
expect(app.playTileScreenEntrance).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
queue.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('a reload restoring the grid', () => {
|
||||||
|
it('settles whatever the style, and owes no screen beat (nothing animates on page load)', () => {
|
||||||
|
const queue = captureQueue();
|
||||||
|
try {
|
||||||
|
localStore.set(
|
||||||
|
'codeman:tile-grid',
|
||||||
|
JSON.stringify({
|
||||||
|
v: 1,
|
||||||
|
open: true,
|
||||||
|
ids: ['s-a', 's-b'],
|
||||||
|
focused: 's-a',
|
||||||
|
zoomed: null,
|
||||||
|
colFr: [1, 1],
|
||||||
|
rowFr: [1],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const app = styledApp('deal');
|
||||||
|
expect(app._restoreTileGrid()).toBe(true);
|
||||||
|
expect(app._stageTileEntrance).not.toHaveBeenCalled();
|
||||||
|
expect(tiles().every((t) => t.classList.contains('tile--entering'))).toBe(true);
|
||||||
|
app._tileLoadQueue();
|
||||||
|
queue.report(app._tileFor('s-a'), 'idle');
|
||||||
|
expect(app.playTileScreenEntrance).not.toHaveBeenCalled();
|
||||||
|
// The quiet flag is gone after the restore: the next open is themed.
|
||||||
|
app.closeTileGrid({ reselect: false });
|
||||||
|
app.openTileGrid(['s-c']);
|
||||||
|
expect(app._stageTileEntrance).toHaveBeenCalledTimes(1);
|
||||||
|
} finally {
|
||||||
|
queue.restore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('closing with the Tiles toggle', () => {
|
||||||
|
it('hands the copies to the exit, stripped of entrance state, and waits as long as it says', () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const app = styledApp('crt');
|
||||||
|
app._stageTileExit = vi.fn(() => 1200);
|
||||||
|
app.openTileGrid(IDS.slice(0, 2));
|
||||||
|
for (const t of tiles()) t.classList.add('tile--enter-themed', 'tile--enter-hold');
|
||||||
|
app.selectSession = vi.fn(() => new Promise<void>(() => {}));
|
||||||
|
app.toggleTileGrid();
|
||||||
|
const [copies, opts] = (app._stageTileExit as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect(opts).toEqual({ now: false });
|
||||||
|
expect((copies as Array<{ sessionId: string }>).map((c) => c.sessionId)).toEqual(['s-a', 's-b']);
|
||||||
|
const layer = ghosts()[0];
|
||||||
|
for (const g of layer.children) {
|
||||||
|
expect(g.classList.contains('tile--enter-themed')).toBe(false);
|
||||||
|
expect(g.classList.contains('tile--enter-hold')).toBe(false);
|
||||||
|
}
|
||||||
|
// Held 700 ms (the single view never settles here), then released: the
|
||||||
|
// fallback is the exit's 1200 ms, not the default fade's 450.
|
||||||
|
vi.advanceTimersByTime(700);
|
||||||
|
expect(layer.classList.contains('tile-grid-ghosts--release')).toBe(true);
|
||||||
|
vi.advanceTimersByTime(1199);
|
||||||
|
expect(ghosts()).toHaveLength(1);
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(ghosts()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('the copy goes on its last tile’s tile-leave-* end, not on a ::before wash', () => {
|
||||||
|
const app = styledApp('crt');
|
||||||
|
app._stageTileExit = vi.fn(() => 900);
|
||||||
|
app.openTileGrid(IDS.slice(0, 2));
|
||||||
|
app.toggleTileGrid();
|
||||||
|
const layer = ghosts()[0];
|
||||||
|
const last = layer.lastElementChild!;
|
||||||
|
layer.dispatch('animationend', { target: last, animationName: 'tile-leave-crt', pseudoElement: '::before' });
|
||||||
|
expect(ghosts()).toHaveLength(1);
|
||||||
|
layer.dispatch('animationend', { target: last, animationName: 'tile-leave-crt', pseudoElement: '' });
|
||||||
|
expect(ghosts()).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user