mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-11 17:59:41 +02:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5206a044bb | ||
|
|
decd263b17 |
@@ -329,7 +329,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. Load order: `constants.js`(1) → `i18n.js`(1.5) → `mobile-handlers.js`(2) → `voice-input.js`(3) → `notification-manager.js`(4) → `keyboard-accessory.js`(5) → `input-cjk.js`(5.5) → `mobile-ime-preview.js`(5.52) → `terminal-keycode229-recovery.js`(5.55) → `sanitize-html.js`(5.6) → `tab-layout-browser.js`(5.9) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `terminal-tile.js`(7.4) → `terminal-split.js`(7.5) → `tile-grid.js`(7.6) → `respawn-ui.js`(8) → `ralph-panel.js`(9) → `orchestrator-panel.js`(9.5) → `cron-ui.js`(9.7) → `settings-ui.js`(10) → `panels-ui.js`(11) → `readmymind-ui.js`(11.3) → `ultracode-panel.js`(11.5) → `approvals-ui.js`(11.6) → `reboot-restore-ui.js`(11.65) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `host-wake-ui.js`(12.2) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `git-status-ui.js`(12.57) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `session-lineage.js`(15.6) → `image-input.js`(16) → `spreadsheet-preview.js`(16.5). `i18n.js` translates static + newly inserted application DOM while skipping terminal/response/file/user-name surfaces; `input-cjk.js` handles CJK IME composition via an always-visible textarea below the terminal (`window.cjkActive` blocks xterm's onData). `terminal-keycode229-recovery.js` forwards a committed `input` event that xterm's `_inputEvent` guard drops (Chrome-on-Android soft keyboards send `composed: true` after a keydown), and only when xterm emitted no canonical data for that keystroke. ⚠️ **That decision is settled at the NEXT keydown as well as on its own zero-delay timer** (#441): the drain runs from xterm's custom key handler, which fires BEFORE xterm processes that key, so a soft keyboard that commits the last character and sends Enter in one InputConnection transaction puts the character on the wire ahead of the `\r`. On the timer alone that character is not merely late, it is LOST: xterm emits the `\r` first and bumps the canonical counter past the candidate's snapshot, so the candidate stands down (measured, `hell\r` where the user typed `hello`). The edit-based diff described below is settled first at that keydown, so the drain decides with the evidence the timer had and Enter's textarea clear cannot turn the pending line into DELs. Ordering is pinned by `test/terminal-keycode229-recovery.browser.test.ts`, which the CI gate does NOT run. The same module replaces xterm's `_handleAnyTextareaChanges` (an append-only `newValue.replace(oldValue, '')` diff) with an edit-based one, so an Android autocorrect on space (delete + insert) reaches the PTY once instead of duplicating the line; ⚠️ that diff is settled at the NEXT keydown, before xterm handles that key, because xterm clears the textarea for Enter and a pending diff would then send one DEL per character ahead of the submitted line (except a composition xterm finalizes synchronously at that key, which stays xterm's). ⚠️ Every xterm that takes keyboard input wires its OWN controller from this module: the primary pane (terminal-ui.js `initTerminal()`) and each `TerminalTile` (terminal-tile.js `_createKeyCode229Recovery()`, so every grid tile and the split's Pane B, which a wide Android tablet reaches), each on its own textarea, composition helper and session; in both, `handleKeyEvent` must run ABOVE the custom key handler's keyCode-229 early return, and `notifyCanonicalData` sits in the onData lambda, never in the send path the recovered bytes also take. `mobile-ime-preview.js` (iOS WebKit only) paints the text an IME is composing: an iOS IME commit is routed into the local-echo overlay through the ordinary printable/paste branch and then `_transferMobileImeCommitToLocalEcho`, and without local echo the preview clears only on output parsed AFTER the commit (or its 2 s fallback). ⚠️ It watches keydown in the capture phase on `terminal.element`, never on the textarea, because xterm finalizes the composition and emits the commit in its own capture listener on the textarea.
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for tabs, terminal, windows and connection lines, chosen via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<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. Every animation setting lives in App Settings → **Animations** (`#settings-animations`); put new ones there. ⚠️ Tiles are OFF by default (Animations → Tile Animations, default `settle` = the grid's own fade, no screen beat); a theme only presets it. A tile's FRAME animates transform/opacity only (six at once, no filter) with `tile-enter*`/`tile-leave*` keyframe names (what the mount and the still copy listen for); its SCREEN replays the pane style on `.tile-body.term-enter`; a reload's restore always settles. Persisted per-device in `codeman:*Anim` localStorage keys, never in `SettingsUpdateSchema`; lab at `?animlab=1`. Test: `test/entrance-animations.test.ts`. → [architecture-invariants#entrance-animations](docs/architecture-invariants.md#entrance-animations)
|
||||
|
||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip scrolls horizontally, so the active tab must be kept reachable. `_updateActiveTabImmediate()` reveals it via `computeTabScrollLeft()` (constants.js, rect math on the strip's own `scrollLeft`, never `scrollIntoView()`, which scrolls the document under the fixed header); `_fullRenderSessionTabs()` must restore `scrollLeft` across rebuilds and re-reveal only when the active tab changed (`_lastRenderedActiveTabId`) or, grouped by state, the ACTIVE tab moved to another state band (`_noteActiveTabBand()`, both render paths, scrolling row only; another tab's move never scrolls). ⚠️ The phone-block `min-width` on `.session-tab.active .tab-name` keeps the tab's centre off the gear/close icons, sized for numberless tabs 10+ (floor 40px); do not shrink it. ⚠️ Never reintroduce hoisting the active session to the front of the strip. Tests: `test/mobile-tab-tap-zones.test.ts`, `test/tab-triage.test.ts`. → [architecture-invariants#mobile-tab-strip-scrolling](docs/architecture-invariants.md#mobile-tab-strip-scrolling)
|
||||
|
||||
|
||||
@@ -877,7 +877,7 @@ Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
### Entrance animations
|
||||
|
||||
**Entrance animations** (`entrance-animations.js`, all OFF by default): opt-in animations for the four things that appear when work starts, chosen per surface via `data-tab-anim` / `data-term-anim` / `data-win-anim` / `data-line-anim` on `<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 its own App Settings → **Animations** section (`#settings-animations`: the Entrance Theme, Tile Animations, and an Open lab button), full per-surface lab at `?animlab=1`. New animation settings go in that section.
|
||||
|
||||
⚠️ Tabs and connection lines are **destroyed mid-animation** on every re-render (`_fullRenderSessionTabs()` replaces the strip's innerHTML; `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''`), so both are tracked by id and re-applied to the fresh element with a **negative `animation-delay`** to resume rather than restart.
|
||||
|
||||
@@ -889,6 +889,14 @@ Tests: `test/terminal-touch-tap.test.ts`.
|
||||
|
||||
⚠️ Window styles other than `beam` transform the window, which moves the rect its connection line is aimed at; `beam` deliberately animates opacity/filter only so its line can draw toward a stable target.
|
||||
|
||||
**Tile grid entrances (the fifth surface, `data-tile-anim`).** A tile plays in two beats: its FRAME enters as it mounts in a `TILE_ANIM_STYLES` style (`settle`, `fly`, `deal`, `crt`, `beam`, `cascade`, `pop`, `soft`, `off`), and its SCREEN (`.tile-body`) plays the terminal pane's style when the load queue reports its first capture, through the same `html[data-term-anim]` rules (each names `.terminal-container.term-enter` AND `.tile-body.term-enter`). Content that lands while the frame is still entering waits for it (`--tile-screen-delay`), and the screen's `::before` wash fills `forwards` only, so a held wash never shows as a bright static block. Tiles are OFF by default: the style is its own setting, App Settings → Animations → **Tile Animations** (`#appSettingsTileAnim`, `codeman:tileAnim`), whose default `settle` is the grid's own fade and settle (`.tile--entering`, pinned by `test/tile-grid-motion.test.ts`) with no screen beat and the plain exit, exactly as before. No saved key means `settle`, so a theme saved before tiles existed gives them nothing new. Picking a theme PRESETS the tile style (every theme carries a `tile` key, `legacy` → `settle`), but the theme readout (`currentAnimTheme`) is matched on the four classic surfaces only, so changing the tile row afterwards never turns the theme into "Custom". The screen beat plays only for a tile style other than `settle`.
|
||||
|
||||
⚠️ Tile FRAME keyframes animate **transform and opacity only**, colour on a `::before` wash: six tiles animate at once, so no `filter` (a tile's blur is its screen beat, one tile at a time as the queue serves captures). The keyframes are named `tile-enter*` / `tile-leave*` because that is what the mount's `animationend` and the still copy's removal listen for, ignoring any event with a `pseudoElement`. `test/entrance-animations.test.ts` pins both.
|
||||
|
||||
⚠️ A themed tile is held one frame (`.tile--enter-hold`: invisible, `animation: none`), then `_runTileEntrances` orders the cascade from the final cells (`reading` / `wave` / `ripple`, a lab override in `codeman:tileAnimOrder`) and, for `fly`/`deal`/`beam`, measures its source (its session tab, else the Tiles button) on the final layout: `openTileGrid` packs tiles and a stored grid then moves them back, so measuring at mount would aim at the wrong cell. The offset rides `--tile-from-*` custom properties set BEFORE the hold comes off. The mount arms a backstop (4 s at once, the precise one from the module), so a frame that never comes cannot strand a tile invisible.
|
||||
|
||||
⚠️ **A reload's restore always settles** (`_tileEnterQuiet` around `_restoreTileGrid`) and owes no screen beat: nothing animates on page load. A re-form (count change) keeps the plain fade on its still copy (`--now`), because that copy covers tiles that stay; only the Tiles toggle's close plays the style's own exit (`_stageTileExit`: back into the tabs, a CRT switch-off), and the copy's fallback timer waits as long as that exit says.
|
||||
|
||||
### Mobile tab strip scrolling
|
||||
|
||||
**Mobile tab strip scrolling** (issue #257): under 768px the tab strip is a horizontal scroller (desktop wraps to a second row instead), so the active tab can sit off-screen. Three rules keep it reachable and they only work together: `_updateActiveTabImmediate()` scrolls the selected tab into view via `computeTabScrollLeft()` (pure, in constants.js) using **rect math on the strip's own `scrollLeft`**, never `scrollIntoView()`, which would also scroll the document under a fixed header; `_fullRenderSessionTabs()` **restores `scrollLeft`** across the `innerHTML` rebuild, since ambient rebuilds (a task badge appearing, a session created elsewhere) otherwise snap a mid-swipe strip back to 0; and it re-reveals the active tab **only when it changed** (`_lastRenderedActiveTabId`), so browsing the far end of the strip is not undone by background renders.
|
||||
|
||||
@@ -36,7 +36,7 @@ These are the only writes to `clis.json`. They are serialized, and a file that d
|
||||
interface CliEntry {
|
||||
id: CliId; // 'codex'
|
||||
label: string; // 'Codex' — shown in menus
|
||||
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead (no mark at all with CLI Logos on Tabs off)
|
||||
shortBadge: string; // short label ("Run CX", the Settings CLI list), e.g. 'CX'; tabs show the run-mode-dot logo instead
|
||||
accent: string; // single hex colour
|
||||
enabled: boolean;
|
||||
stock: boolean; // set by the loader; a custom entry can never claim it
|
||||
|
||||
@@ -97,7 +97,10 @@ or settled a question the spec left open. The invariants as built are in
|
||||
`test/header-icon-hover.test.ts`.
|
||||
- **The grid opens and closes with a short animation, on by default** (owner request:
|
||||
"when clicking on the tile button first make this animation nicer"). It is the grid's
|
||||
own, not an `entrance-animations.js` theme (those are off by default). Opening, each tile
|
||||
own `settle` style, the default of App Settings → Animations → Tile Animations, which
|
||||
switches on other styles (`fly` out of the tabs, `deal` from the Tiles button, `crt`,
|
||||
`beam`, ...; docs/architecture-invariants.md#entrance-animations).
|
||||
Opening, each tile
|
||||
fades and settles in (opacity, translateY 6px and scale .97), 180 ms, 24 ms apart in
|
||||
reading order (`--tile-enter-index`): the last of six is done at 300 ms; a tile added
|
||||
later enters the same way. Its terminal stays transparent (`.tile--revealing`) until the
|
||||
|
||||
@@ -90,7 +90,6 @@ every session or only the active tab.
|
||||
| Setting | Notes |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Skin | Theme palettes, light ones included. Applied before first paint, so no flash of the wrong theme. |
|
||||
| Entrance Animations | Per-surface animation styles for tabs, terminals, windows, and lineage lines. All default to the legacy no-animation behaviour. |
|
||||
| Display Name | Your name in the UI. Cosmetic only; it never renames the package, CLI, API, or storage. |
|
||||
| Interface Language | English or Simplified Chinese. Per device. |
|
||||
| Session List Layout | Header tab strip (default), a collapsible left sidebar, or the sidebar with detailed rows. See [The Dashboard](The-Dashboard#session-list-layout). |
|
||||
@@ -99,12 +98,21 @@ every session or only the active tab.
|
||||
| State Order | For *By state*: needs you on top (default) or at the bottom, right above the terminal. |
|
||||
| Vertical Rail Order | *By activity* (default) sorts the rail the way the home screens are sorted; *Manual* keeps your tab order and drag-reordering. With *By state* or *By case* it orders the rows inside each section. |
|
||||
| Tall Tabs | Taller tab strip. |
|
||||
| CLI Logos on Tabs | Each agent tab, and its row on the desktop home rail, shows the CLI's logo before the name. Off hides those logos on this device; the status dot and the shell's SH badge stay, and tiles, split headers and the Run menus keep their logos. On by default. |
|
||||
| Pop-out Button on Tabs | Adds the detach control to tabs, with a per-tab override. |
|
||||
| Spawn Lineage Lines | Lines from each tab to the sessions it spawned; the selected tab's family is drawn thicker. Desktop only, on by default. |
|
||||
| Auto-name Sessions | Titles a new tab after its first prompt, keeping the case prefix (`w3-myapp: fix the login redirect`). Synced, off by default. See [The Dashboard](The-Dashboard#automatic-session-names). |
|
||||
| Overview Home Screen | The phone home screen. On by default. |
|
||||
|
||||
### Animations
|
||||
|
||||
All per device, all off by default, applied as you pick them.
|
||||
|
||||
| Setting | Notes |
|
||||
| ---------------------- | ----------------------------------------------------------------------------------------- |
|
||||
| Entrance Theme | One look for how new tabs, terminal panes, agent windows and their lines arrive (Terminal, Beam down, Launch, Soft focus, Quiet, Playful). Off by default. |
|
||||
| Tile Animations | How tiles arrive when the tile grid opens and leave when it closes: fly out of their tabs, dealt from the Tiles button, CRT, beam down, cascade, pop or soft; each screen then plays the theme's terminal animation. Off by default (the grid's quick fade); picking a theme presets it. |
|
||||
| Animation Lab | Opens the per-surface lab (the same as `?animlab=1`): every style side by side, with replay, stagger and speed. Closes settings first. |
|
||||
|
||||
### Models
|
||||
|
||||
Claude model cards, the 1M context window switch, the thinking effort segment and the
|
||||
|
||||
@@ -73,10 +73,6 @@ precedence there.
|
||||
|
||||
One tab per session, in your order, and that order syncs across your devices.
|
||||
|
||||
An agent tab shows its CLI's logo before the name, and a shell tab an `SH` badge. **CLI Logos
|
||||
on Tabs** (App Settings → Appearance → Tabs) hides the logos on that device; the tile and split
|
||||
headers and the Run menus keep theirs.
|
||||
|
||||
**Status is carried by the dot and the tab's own styling:**
|
||||
|
||||
| Look | Meaning |
|
||||
|
||||
@@ -6709,9 +6709,7 @@ class CodemanApp {
|
||||
// every agent CLI, claude included, shows its logo through PR #532's
|
||||
// `run-mode-dot <id>` slot, the id as DATA, so the tab, the tile and split
|
||||
// headers and the Run menus draw the same mark. An id with no logo rule (a
|
||||
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot. The
|
||||
// span is always emitted: CLI Logos on Tabs (`showTabCliLogos`) hides it
|
||||
// in CSS under html[data-tab-logos='off'], so a toggle never re-renders.
|
||||
// CLI added through ~/.codeman/clis.json) gets that slot's plain dot.
|
||||
const tabModeHtml = mode === 'shell'
|
||||
? '<span class="tab-mode shell" aria-hidden="true">sh</span>'
|
||||
: `<span class="tab-harness run-mode-dot ${escapeHtml(mode)}" aria-hidden="true"></span>`;
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* @fileoverview Entrance animations for the four things that appear when work
|
||||
* @fileoverview Entrance animations for the things that appear when work
|
||||
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
|
||||
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
|
||||
* One picker per surface, plus themes that set all four to a matching look.
|
||||
* agent WINDOWS, the CONNECTION LINES tying a window back to its parent tab, and
|
||||
* the TILES of the tile grid (tile-grid.js). One picker per surface, plus themes
|
||||
* that set all five to a matching look.
|
||||
*
|
||||
* Everything is OFF by default (the `legacy` theme), so an untouched install
|
||||
* behaves exactly as it did before this module existed. Opt in via App Settings
|
||||
* → Appearance → Entrance Animations.
|
||||
* → Animations.
|
||||
*
|
||||
* Four constraints shape the design:
|
||||
*
|
||||
@@ -34,13 +35,28 @@
|
||||
* once-per-id even though the POST response and the SSE event both call
|
||||
* `_onSessionCreated`.
|
||||
*
|
||||
* Tiles are off by default (`settle`, the grid's own quick fade, exactly as
|
||||
* before) and switched on in App Settings → Animations → Tile Animations, or
|
||||
* preset by a theme. A styled tile plays in two beats that combine two
|
||||
* surfaces. The FRAME enters as it mounts, in its own style
|
||||
* (TILE_ANIM_STYLES); the SCREEN plays the terminal pane's style when its
|
||||
* first capture lands (`.tile-body.term-enter`, the same keyframes as the main
|
||||
* pane). The load queue serves one capture at a time, so
|
||||
* the screens light up one after another, the focused tile first. The frame
|
||||
* styles move transform and opacity only (six tiles animate at once; a blur
|
||||
* belongs to the serialized screen beat), and each has its own way out on the
|
||||
* closing grid's still copy. A reload restores with `settle` whatever the
|
||||
* setting.
|
||||
*
|
||||
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
|
||||
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
|
||||
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
|
||||
* so styles can be compared without spawning real sessions or agents.
|
||||
* `data-line-anim` / `data-tile-anim` on <html>; the keyframes live in
|
||||
* styles.css. `?animlab=1` opens a floating picker that fakes tabs, a pane
|
||||
* replay, a window and a line, and replays the tile grid in place, so styles
|
||||
* can be compared without spawning real sessions or agents.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
|
||||
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks),
|
||||
* tile-grid.js (tile mount, reveal and still-copy hooks)
|
||||
* @dependency constants.js (escapeHtml)
|
||||
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
|
||||
*/
|
||||
@@ -94,7 +110,6 @@ const LINE_ANIM_STYLES = [
|
||||
*/
|
||||
const TERM_ANIM_STYLES = [
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
|
||||
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
|
||||
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
|
||||
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
|
||||
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
|
||||
@@ -102,30 +117,79 @@ const TERM_ANIM_STYLES = [
|
||||
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Tile grid entrance styles, for each tile's FRAME (its screen plays the
|
||||
* TERM_ANIM_STYLES style once content lands). Transform and opacity only, plus
|
||||
* a wash on ::before: FitAddon reads the untransformed layout box, so a tile
|
||||
* still fits once at its final size (#464). `stagger` is the default gap
|
||||
* between tiles and `order` the default cascade: `reading` (row by row),
|
||||
* `wave` (diagonals from the top left) or `ripple` (outward from the focused
|
||||
* tile). `from`: the tile is measured against a source after layout (its tab,
|
||||
* the Tiles button), so it is held one frame and timed by _runTileEntrances.
|
||||
*/
|
||||
// prettier-ignore
|
||||
const TILE_ANIM_STYLES = [
|
||||
{ key: 'settle', label: 'Off (default)', blurb: "The grid's own quick fade and settle.", duration: 180, stagger: 24, order: 'reading' },
|
||||
{ key: 'fly', label: 'Fly from tab', blurb: 'Each tile flies out of its session tab, and back into it on close.', duration: 560, stagger: 55, order: 'reading', from: 'tab' },
|
||||
{ key: 'deal', label: 'Deal', blurb: 'Dealt out of the Tiles button like cards, gathered back on close.', duration: 600, stagger: 75, order: 'reading', from: 'button' },
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Powers on as a hot line; switches off to a dot on close.', duration: 560, stagger: 70, order: 'wave' },
|
||||
{ key: 'beam', label: 'Beam down', blurb: 'A beam draws down from its tab, then the tile materializes.', duration: 620, stagger: 90, order: 'reading', from: 'tab' },
|
||||
{ key: 'cascade', label: 'Cascade', blurb: 'Swings down from its top edge in a diagonal wave.', duration: 600, stagger: 80, order: 'wave' },
|
||||
{ key: 'pop', label: 'Pop', blurb: 'Springs open, rippling out from the focused tile.', duration: 480, stagger: 70, order: 'ripple' },
|
||||
{ key: 'soft', label: 'Soft', blurb: 'Drifts in slowly, rippling out from the focused tile.', duration: 620, stagger: 60, order: 'ripple' },
|
||||
{ key: 'off', label: 'None', blurb: 'Tiles just appear.', duration: 0, stagger: 0, order: 'reading' },
|
||||
];
|
||||
|
||||
/** Cascade orders for the tile grid; `auto` is each style's own. */
|
||||
const TILE_ANIM_ORDERS = [
|
||||
{ key: 'auto', label: 'Style default' },
|
||||
{ key: 'reading', label: 'Reading order' },
|
||||
{ key: 'wave', label: 'Diagonal wave' },
|
||||
{ key: 'ripple', label: 'Ripple from focus' },
|
||||
];
|
||||
|
||||
/** How long a `beam` window waits before materializing. Just under the line draw. */
|
||||
const BEAM_HOLD_MS = 360;
|
||||
|
||||
/** Gap between tiles leaving, in reading order, so the last copy ends last. */
|
||||
const TILE_EXIT_STAGGER_MS = 35;
|
||||
|
||||
/** Tile exit durations per style (styles.css `tile-leave-*`); the rest use the default fade. */
|
||||
const TILE_EXIT_MS = { fly: 460, deal: 520, crt: 520, beam: 480, cascade: 480, pop: 380, soft: 520 };
|
||||
|
||||
/** One-click combinations that read as a single look. */
|
||||
const ANIM_THEMES = [
|
||||
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
|
||||
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
|
||||
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur' },
|
||||
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade' },
|
||||
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide' },
|
||||
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off' },
|
||||
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt', tile: 'crt' },
|
||||
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe', tile: 'beam' },
|
||||
{ key: 'launch', label: 'Launch', tab: 'pop', win: 'fly', line: 'packet', term: 'fade', tile: 'fly' },
|
||||
{ key: 'softfocus', label: 'Soft focus', tab: 'blur', win: 'blur', line: 'blur', term: 'blur', tile: 'soft' },
|
||||
{ key: 'quiet', label: 'Quiet', tab: 'slide', win: 'materialize', line: 'fade', term: 'fade', tile: 'settle' },
|
||||
{ key: 'playful', label: 'Playful', tab: 'pop', win: 'pop', line: 'packet', term: 'slide', tile: 'deal' },
|
||||
{ key: 'legacy', label: 'Legacy', tab: 'off', win: 'fly', line: 'off', term: 'off', tile: 'settle' },
|
||||
];
|
||||
|
||||
/**
|
||||
* The surfaces a theme is recognised by. A theme also PRESETS the tile style
|
||||
* when it is picked, but the tile style is its own setting (App Settings →
|
||||
* Animations → Tile Animations, off by default), so changing it afterwards
|
||||
* does not turn the theme into "Custom".
|
||||
*/
|
||||
const ANIM_SURFACES = ['tab', 'win', 'line', 'term'];
|
||||
|
||||
/**
|
||||
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
|
||||
* `fly` behaviour Codeman already had before this module existed. So a user who
|
||||
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
|
||||
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
|
||||
* Entrance Animations, which persists to the localStorage keys below.
|
||||
* hook short-circuits on its first line. Opt in via App Settings → Animations,
|
||||
* which persists to the localStorage keys below.
|
||||
*/
|
||||
const TAB_ANIM_DEFAULT = 'off';
|
||||
const WIN_ANIM_DEFAULT = 'fly';
|
||||
const LINE_ANIM_DEFAULT = 'off';
|
||||
const TERM_ANIM_DEFAULT = 'off';
|
||||
/** The grid's own fade and settle, unchanged for anyone who never picks a theme. */
|
||||
const TILE_ANIM_DEFAULT = 'settle';
|
||||
const TILE_ANIM_ORDER_DEFAULT = 'auto';
|
||||
const TAB_ANIM_STAGGER_DEFAULT = 90;
|
||||
/** A new id joins the current cascade if it arrives within this of the last one. */
|
||||
const TAB_ANIM_BATCH_WINDOW_MS = 600;
|
||||
@@ -135,6 +199,8 @@ const ANIM_KEYS = {
|
||||
win: 'codeman:winAnim',
|
||||
line: 'codeman:lineAnim',
|
||||
term: 'codeman:termAnim',
|
||||
tile: 'codeman:tileAnim',
|
||||
tileOrder: 'codeman:tileAnimOrder',
|
||||
termSwitch: 'codeman:termAnimOnSwitch',
|
||||
stagger: 'codeman:tabAnimStagger',
|
||||
speed: 'codeman:tabAnimSpeed',
|
||||
@@ -164,6 +230,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
|
||||
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
|
||||
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
|
||||
// Off (the grid's own `settle`) until chosen: a theme saved before tiles
|
||||
// were a surface gives them nothing new.
|
||||
this.setTileAnimStyle(pick('tileanim', TILE_ANIM_STYLES, ANIM_KEYS.tile, TILE_ANIM_DEFAULT), { persist: false });
|
||||
this.setTileAnimOrder(this._animRead(ANIM_KEYS.tileOrder, TILE_ANIM_ORDER_DEFAULT), { persist: false });
|
||||
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
|
||||
|
||||
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
|
||||
@@ -219,6 +289,18 @@ Object.assign(CodemanApp.prototype, {
|
||||
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
|
||||
},
|
||||
|
||||
setTileAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_tileAnimStyle', key, TILE_ANIM_STYLES, TILE_ANIM_DEFAULT, 'data-tile-anim', ANIM_KEYS.tile, persist);
|
||||
},
|
||||
|
||||
/** The tile cascade: `auto` (the style's own) or a TILE_ANIM_ORDERS key. */
|
||||
setTileAnimOrder(key, { persist = true } = {}) {
|
||||
this._tileAnimOrder = TILE_ANIM_ORDERS.some((o) => o.key === key) ? key : TILE_ANIM_ORDER_DEFAULT;
|
||||
if (persist) this._animWrite(ANIM_KEYS.tileOrder, this._tileAnimOrder);
|
||||
this._syncAnimLab?.();
|
||||
},
|
||||
|
||||
/** Replay the terminal entrance on every tab switch, not just on a new session. */
|
||||
setTermAnimOnSwitch(on, { persist = true } = {}) {
|
||||
this._termAnimOnSwitch = !!on;
|
||||
@@ -234,22 +316,29 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.setWinAnimStyle(theme.win);
|
||||
this.setLineAnimStyle(theme.line);
|
||||
this.setTermAnimStyle(theme.term);
|
||||
this.setTileAnimStyle(theme.tile);
|
||||
this._syncEntranceAnimSetting?.();
|
||||
},
|
||||
|
||||
/** The theme matching the four current styles, or 'custom' for a lab mix. */
|
||||
/** The current style of each surface, keyed as ANIM_SURFACES. */
|
||||
_currentAnimStyles() {
|
||||
return {
|
||||
tab: this._tabAnimStyle,
|
||||
win: this._winAnimStyle,
|
||||
line: this._lineAnimStyle,
|
||||
term: this._termAnimStyle,
|
||||
tile: this._tileAnimStyle,
|
||||
};
|
||||
},
|
||||
|
||||
/** The theme matching the current tab, window, line and pane styles, or 'custom' for a lab mix. */
|
||||
currentAnimTheme() {
|
||||
const match = ANIM_THEMES.find(
|
||||
(t) =>
|
||||
t.tab === this._tabAnimStyle &&
|
||||
t.win === this._winAnimStyle &&
|
||||
t.line === this._lineAnimStyle &&
|
||||
t.term === this._termAnimStyle
|
||||
);
|
||||
const current = this._currentAnimStyles();
|
||||
const match = ANIM_THEMES.find((t) => ANIM_SURFACES.every((k) => t[k] === current[k]));
|
||||
return match ? match.key : 'custom';
|
||||
},
|
||||
|
||||
// ── App Settings picker ───────────────────────────────────────────────────
|
||||
// ── App Settings → Animations ─────────────────────────────────────────────
|
||||
//
|
||||
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
|
||||
// styles live in their own localStorage keys, so they stay per-device and never
|
||||
@@ -257,16 +346,40 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
_syncEntranceAnimSetting() {
|
||||
const sel = document.getElementById('appSettingsEntranceAnim');
|
||||
if (!sel) return;
|
||||
sel.value = this.currentAnimTheme();
|
||||
if (!sel.dataset.bound) {
|
||||
sel.dataset.bound = '1';
|
||||
sel.addEventListener('change', () => {
|
||||
// 'custom' is a readout of a lab mix, not something you can select into.
|
||||
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
|
||||
else this.setAnimTheme(sel.value);
|
||||
if (sel) {
|
||||
sel.value = this.currentAnimTheme();
|
||||
if (!sel.dataset.bound) {
|
||||
sel.dataset.bound = '1';
|
||||
sel.addEventListener('change', () => {
|
||||
// 'custom' is a readout of a lab mix, not something you can select into.
|
||||
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
|
||||
else this.setAnimTheme(sel.value);
|
||||
});
|
||||
}
|
||||
}
|
||||
// App Settings → Animations → Animation Lab. Settings has no unsaved-edit
|
||||
// tracking, so this closes it as Cancel does (the row says so).
|
||||
const labBtn = document.getElementById('appSettingsOpenAnimLab');
|
||||
if (labBtn && !labBtn.dataset.bound) {
|
||||
labBtn.dataset.bound = '1';
|
||||
labBtn.addEventListener('click', () => {
|
||||
this.closeAppSettings?.();
|
||||
this.openAnimLab();
|
||||
});
|
||||
}
|
||||
// Tile Animations: its own row, off (`settle`) by default. A theme picked
|
||||
// above presets it; picked here, it applies to tiles alone.
|
||||
const tileSel = document.getElementById('appSettingsTileAnim');
|
||||
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();
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
setTabAnimStagger(ms, { persist = true } = {}) {
|
||||
@@ -303,6 +416,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
|
||||
},
|
||||
|
||||
_tileAnimDuration() {
|
||||
return this._styleDuration(TILE_ANIM_STYLES, this._tileAnimStyle);
|
||||
},
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Queue a session id to animate on its next render. Idempotent per id. */
|
||||
@@ -501,6 +618,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) ───────────────
|
||||
|
||||
/** Floating picker: switch styles per surface and replay fake entrances. */
|
||||
@@ -538,6 +966,12 @@ Object.assign(CodemanApp.prototype, {
|
||||
</label>
|
||||
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
|
||||
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
|
||||
${group('Tile grid (frames; screens use the pane style)', TILE_ANIM_STYLES, 'tile')}
|
||||
<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>
|
||||
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
|
||||
<input type="range" data-range="stagger" min="0" max="260" step="10">
|
||||
@@ -552,6 +986,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
<button type="button" data-demo="window">Window</button>
|
||||
<button type="button" data-demo="all">All</button>
|
||||
</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>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
@@ -570,11 +1009,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (attr === 'tab') this.setTabAnimStyle(style);
|
||||
else if (attr === 'win') this.setWinAnimStyle(style);
|
||||
else if (attr === 'term') this.setTermAnimStyle(style);
|
||||
else if (attr === 'tile') this.setTileAnimStyle(style);
|
||||
else this.setLineAnimStyle(style);
|
||||
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) => {
|
||||
this.setTermAnimOnSwitch(e.target.checked);
|
||||
});
|
||||
@@ -601,19 +1046,16 @@ Object.assign(CodemanApp.prototype, {
|
||||
_syncAnimLab() {
|
||||
const panel = document.getElementById('animLab');
|
||||
if (!panel) return;
|
||||
const current = {
|
||||
tab: this._tabAnimStyle,
|
||||
win: this._winAnimStyle,
|
||||
line: this._lineAnimStyle,
|
||||
term: this._termAnimStyle,
|
||||
};
|
||||
const current = this._currentAnimStyles();
|
||||
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
|
||||
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
|
||||
});
|
||||
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
|
||||
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"]');
|
||||
if (check) check.checked = !!this._termAnimOnSwitch;
|
||||
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
|
||||
@@ -647,10 +1089,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
return svg;
|
||||
},
|
||||
|
||||
/** @param {'tabs'|'term'|'window'|'all'} what */
|
||||
/** @param {'tabs'|'term'|'window'|'all'|'tiles'|'tiles-roundtrip'} what */
|
||||
demoEntrance(what = 'all') {
|
||||
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
|
||||
// through the same entry point a real launch uses (bypassing the owed-id
|
||||
// check, which only exists to keep background sessions from hijacking it).
|
||||
|
||||
@@ -428,8 +428,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
line1.appendChild(badge);
|
||||
} else {
|
||||
// The agent's logo: PR #532's slot, the mode id as data (an id with no
|
||||
// logo rule gets the slot's plain dot). CLI Logos on Tabs hides it in CSS
|
||||
// (html[data-tab-logos='off']), exactly as it does on the tab strip.
|
||||
// logo rule gets the slot's plain dot).
|
||||
const logo = document.createElement('span');
|
||||
logo.className = `home-sessions-harness run-mode-dot ${row.mode}`;
|
||||
logo.setAttribute('aria-hidden', 'true');
|
||||
|
||||
@@ -369,9 +369,6 @@
|
||||
'会话列表显示为顶栏横向标签条,或左侧可折叠侧边栏(Alt+B)。完整侧边栏为每个会话显示与主界面相同的详细信息。',
|
||||
'Tall Tabs (Name + Folder)': '双行标签(名称 + 文件夹)',
|
||||
'Pop-out Button on Tabs': '标签页弹出窗口按钮',
|
||||
'CLI Logos on Tabs': '标签页上的 CLI 图标',
|
||||
"Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.":
|
||||
'在标签页和主界面的标签列表中,于会话名称前显示每个智能体的 CLI 图标。关闭后仍保留状态圆点和 Shell 的 SH 标记。平铺、分屏标题栏和运行菜单中的图标不受影响。',
|
||||
// Tab Layout and Header Stats Style (Discussion #426). The header style's
|
||||
// "Tiles" is 磁贴, never 平铺: that is the tile grid's word (the Tiles
|
||||
// button), and "Tiles (label over value)" must not read as the grid.
|
||||
|
||||
+70
-24
@@ -65,7 +65,7 @@
|
||||
app.js, NOT the handheld storage-key test `m`. Use a different predicate
|
||||
here and boot will contradict this value, animating the drawer open by
|
||||
itself on every load between 768 and 1023px. -->
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';document.documentElement.dataset.tabLogos=(A.showTabCliLogos===false)?'off':'on';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';document.documentElement.dataset.tabLogos='on';}</script>
|
||||
<script>try{var m=window.innerWidth<768||(('ontouchstart' in window||navigator.maxTouchPoints>0)&&window.innerWidth<1024);var k=m?'codeman-app-settings-mobile':'codeman-app-settings';var A=JSON.parse(localStorage.getItem(k)||'{}');var L=A.sessionListLayout;var F=Number(A.sessionSidebarFontSize);var solo=/^\/session\//.test(location.pathname);var C=localStorage.getItem('codeman-sidebar-collapsed');var S=(L==='sidebar'||L==='sidebar-rich')&&!solo;document.documentElement.dataset.sessionList=S?'sidebar':'header';document.documentElement.dataset.sidebarDetail=(S&&L==='sidebar-rich')?'rich':'simple';document.documentElement.dataset.sidebar=(C===null?window.innerWidth<1024:C==='1')?'collapsed':'expanded';var V=A.tabOrientation==='vertical'&&!S&&!solo&&window.innerWidth>=768;document.documentElement.dataset.tabOrientation=V?'vertical':'horizontal';document.documentElement.dataset.tabRailDetail=(A.tabRailDetail==='simple')?'simple':'rich';document.documentElement.dataset.tabRailSort=(A.tabRailSort==='manual')?'manual':'activity';var T=A.tabArrangement;document.documentElement.dataset.tabArrangement=(T==='state'||T==='case'||T==='ledger')?T:'classic';document.documentElement.dataset.tabStateOrder=(A.tabStateOrder==='urgent-last')?'urgent-last':'urgent-first';var H=A.headerStatsStyle;document.documentElement.dataset.headerStats=(window.innerWidth<768||solo)?'classic':(H==='classic'||H==='tiles')?H:'compact';var W=Number(A.tabRailWidth);if(V){if(Number.isInteger(W)&&W>=208&&W<=360)document.documentElement.style.setProperty('--tab-rail-width',W+'px');else if(document.documentElement.dataset.tabRailDetail!=='simple')document.documentElement.style.setProperty('--tab-rail-width','320px');}if(Number.isInteger(F)&&F>=11&&F<=18)document.documentElement.style.setProperty('--session-sidebar-name-font-size',F+'px');}catch(e){document.documentElement.dataset.sessionList='header';document.documentElement.dataset.sidebarDetail='simple';document.documentElement.dataset.sidebar='expanded';document.documentElement.dataset.tabOrientation='horizontal';document.documentElement.dataset.tabRailDetail='rich';document.documentElement.dataset.tabRailSort='activity';document.documentElement.dataset.tabArrangement='classic';document.documentElement.dataset.tabStateOrder='urgent-first';document.documentElement.dataset.headerStats='classic';}</script>
|
||||
<!-- Inline critical CSS for instant skeleton paint (before styles.css loads) -->
|
||||
<style>
|
||||
.loading-skeleton{display:flex;flex-direction:column;height:100vh;height:100dvh;background:var(--bg-dark,#11151c)}
|
||||
@@ -1663,6 +1663,10 @@
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
|
||||
<span>Appearance</span>
|
||||
</button>
|
||||
<button type="button" class="set-rail-item" data-section="settings-animations">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
|
||||
<span>Animations</span>
|
||||
</button>
|
||||
<button type="button" class="set-rail-item" data-section="settings-models">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l8 4.5v9L12 21l-8-4.5v-9L12 3z"/><path d="M12 12l8-4.5M12 12v9M12 12L4 7.5"/></svg>
|
||||
<span>Models</span>
|
||||
@@ -2053,7 +2057,7 @@
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="9"/><path d="M12 3a9 9 0 0 0 0 18 4.5 4.5 0 0 0 0-9 4.5 4.5 0 0 1 0-9z"/></svg>
|
||||
<h2>Appearance</h2>
|
||||
</div>
|
||||
<p class="set-section-blurb">Theme, motion, and what this install calls itself.</p>
|
||||
<p class="set-section-blurb">Theme, and what this install calls itself.</p>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Theme</h4><span class="set-scope">device</span></div>
|
||||
@@ -2077,21 +2081,6 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="set-row has-field" data-search="entrance animations motion tabs windows">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Entrance Animations</span>
|
||||
<span class="set-row-desc">How new tabs, panes and agent windows arrive. Add ?animlab=1 to the URL for per-surface control.</span>
|
||||
</div>
|
||||
<select id="appSettingsEntranceAnim" class="set-select">
|
||||
<option value="legacy">Off (default)</option>
|
||||
<option value="terminal">Terminal (CRT)</option>
|
||||
<option value="beamdown">Beam down</option>
|
||||
<option value="softfocus">Soft focus (blur)</option>
|
||||
<option value="quiet">Quiet</option>
|
||||
<option value="playful">Playful</option>
|
||||
<option value="custom">Custom (set in the lab)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2216,13 +2205,6 @@
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsTabTwoRows"><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="cli logos on tabs logo icon harness agent cli tab hide">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">CLI Logos on Tabs</span>
|
||||
<span class="set-row-desc">Show each agent's CLI logo before the session name on tabs and the home screen's tab list. Off leaves the status dot and the shell's SH badge. Tiles, split headers and the Run menus keep their logos.</span>
|
||||
</div>
|
||||
<label class="switch switch-sm"><input type="checkbox" id="appSettingsShowTabCliLogos" checked><span class="slider"></span></label>
|
||||
</div>
|
||||
<div class="set-row" data-search="pop out detach tab window">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Pop-out Button on Tabs</span>
|
||||
@@ -2255,6 +2237,70 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Animations ═══════════════════════════════════════════════
|
||||
Every entrance animation in one place (entrance-animations.js).
|
||||
All per-device localStorage keys, applied as they are picked,
|
||||
never part of PUT /api/settings. -->
|
||||
<section class="set-section" id="settings-animations" data-label="Animations">
|
||||
<div class="set-section-head">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 3l1.8 4.7L18.5 9.5l-4.7 1.8L12 16l-1.8-4.7L5.5 9.5l4.7-1.8z"/><path d="M19 15l.8 2.2L22 18l-2.2.8L19 21l-.8-2.2L16 18l2.2-.8z"/><path d="M3 19h6"/></svg>
|
||||
<h2>Animations</h2>
|
||||
</div>
|
||||
<p class="set-section-blurb">How tabs, terminal panes, agent windows and tiles arrive. All off by default, applied as you pick them.</p>
|
||||
|
||||
<div class="set-group">
|
||||
<div class="set-group-head"><h4>Entrances</h4><span class="set-scope">device</span></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="entrance animations theme motion tabs windows panes lines">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Entrance Theme</span>
|
||||
<span class="set-row-desc">One look for how new tabs, terminal panes, agent windows and their lines arrive.</span>
|
||||
</div>
|
||||
<select id="appSettingsEntranceAnim" class="set-select">
|
||||
<option value="legacy">Off (default)</option>
|
||||
<option value="terminal">Terminal (CRT)</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="quiet">Quiet</option>
|
||||
<option value="playful">Playful</option>
|
||||
<option value="custom">Custom (set in the lab)</option>
|
||||
</select>
|
||||
</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 class="set-group">
|
||||
<div class="set-group-head"><h4>Lab</h4></div>
|
||||
<div class="set-group-body">
|
||||
<div class="set-row has-field" data-search="animation lab compare replay styles per surface animlab">
|
||||
<div class="set-row-text">
|
||||
<span class="set-row-label">Animation Lab</span>
|
||||
<span class="set-row-desc">Closes settings and opens every style per surface side by side, with replay and speed. Same as adding ?animlab=1 to the URL.</span>
|
||||
</div>
|
||||
<button type="button" id="appSettingsOpenAnimLab" class="btn-toolbar btn-sm">Open lab</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ══ Models ═══════════════════════════════════════════════════ -->
|
||||
<section class="set-section" id="settings-models" data-label="Models">
|
||||
<div class="set-section-head">
|
||||
|
||||
@@ -487,7 +487,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsCjkInput').checked = settings.cjkInputEnabled ?? defaults.cjkInputEnabled ?? false;
|
||||
document.getElementById('appSettingsExtendedKeyboardBar').checked = settings.extendedKeyboardBar ?? false;
|
||||
document.getElementById('appSettingsTabTwoRows').checked = settings.tabTwoRows ?? defaults.tabTwoRows ?? false;
|
||||
document.getElementById('appSettingsShowTabCliLogos').checked = this.tabCliLogosEnabled(settings);
|
||||
document.getElementById('appSettingsTabOrientation').value =
|
||||
settings.tabOrientation ?? defaults.tabOrientation ?? 'horizontal';
|
||||
const tabRailWidth = window.CodemanTabRail?.resolveWidth({
|
||||
@@ -2578,7 +2577,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
webglRendererEnabled: document.getElementById('appSettingsWebglRenderer').checked,
|
||||
extendedKeyboardBar: document.getElementById('appSettingsExtendedKeyboardBar').checked,
|
||||
tabTwoRows: document.getElementById('appSettingsTabTwoRows').checked,
|
||||
showTabCliLogos: document.getElementById('appSettingsShowTabCliLogos').checked,
|
||||
tabOrientation: document.getElementById('appSettingsTabOrientation').value,
|
||||
tabRailWidth: this.readTabRailWidthSetting?.() ?? 256,
|
||||
tabRailDetail: document.getElementById('appSettingsTabRailDetail').value,
|
||||
@@ -3535,7 +3533,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
imageWatcherEnabled: false,
|
||||
ralphTrackerEnabled: false,
|
||||
tabTwoRows: false,
|
||||
showTabCliLogos: true,
|
||||
tabOrientation: 'horizontal',
|
||||
tabRailWidth: 256,
|
||||
tabRailDetail: 'rich',
|
||||
@@ -3672,16 +3669,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
return value === 'state' || value === 'case' || value === 'ledger' ? value : 'classic';
|
||||
},
|
||||
|
||||
/**
|
||||
* CLI Logos on Tabs (`showTabCliLogos`, per-device, default ON on every
|
||||
* device). Anything but an explicit false reads as on, the same test the
|
||||
* pre-paint script in index.html applies, so a reload and a Save never
|
||||
* disagree about an odd stored value.
|
||||
*/
|
||||
tabCliLogosEnabled(settings) {
|
||||
return (settings?.showTabCliLogos ?? this.getDefaultSettings().showTabCliLogos) !== false;
|
||||
},
|
||||
|
||||
/** The stored state-group order: 'urgent-last' only when chosen, else 'urgent-first'. */
|
||||
resolveTabStateOrder(settings) {
|
||||
const value = settings?.tabStateOrder ?? this.getDefaultSettings().tabStateOrder;
|
||||
@@ -3969,14 +3956,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
const previousStateOrder = root.dataset.tabStateOrder || 'urgent-first';
|
||||
const stateOrder = this.resolveTabStateOrder(settings);
|
||||
root.dataset.tabStateOrder = stateOrder;
|
||||
// CLI Logos on Tabs. Unlike the attributes above this one is pure CSS
|
||||
// (styles.css hides `.tab-harness` and `.home-sessions-harness` under
|
||||
// html[data-tab-logos='off']), so a flip re-renders nothing and stays out
|
||||
// of `changed` below: the logo spans are always in the markup. It still
|
||||
// resizes every agent tab, which the tail of this function settles.
|
||||
const previousLogos = root.dataset.tabLogos;
|
||||
const logos = this.tabCliLogosEnabled(settings) ? 'on' : 'off';
|
||||
root.dataset.tabLogos = logos;
|
||||
|
||||
const tabsEl = document.getElementById('sessionTabs');
|
||||
const rail = document.getElementById('tabRail');
|
||||
@@ -4026,14 +4005,6 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (!wrapRendered) this._fullRenderSessionTabs?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
this._refreshHomeSessionsIfVisible?.();
|
||||
} else if (previousLogos !== logos) {
|
||||
// A logo flip narrows or widens every agent tab with no render behind
|
||||
// it, so re-take what a render would have: the strip's one-row wrap
|
||||
// decision and the lines anchored to tab rects (lineage, subagent
|
||||
// connectors). A header that gains or loses a row resizes the terminal
|
||||
// container, whose ResizeObserver (terminal-ui.js) owns the PTY geometry.
|
||||
this.updateTabOverflowMode?.();
|
||||
this._updateConnectionLinesImmediate?.();
|
||||
}
|
||||
// Only detailed rows carry stamps that go stale with no event behind them.
|
||||
// _fullRenderSessionTabs() settles this too, but applyTabOrientation() runs
|
||||
@@ -4282,7 +4253,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
'showFontControls', 'showSystemStats', 'headerStatsStyle', 'showTokenCount', 'showCost',
|
||||
'showLifecycleLog', 'showResponseViewer', 'showRedrawButton',
|
||||
'showMonitor', 'showProjectInsights', 'showFileBrowser', 'showSubagents',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'showTabCliLogos', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'subagentActiveTabOnly', 'tabTwoRows', 'tabOrientation', 'tabRailWidth', 'tabRailDetail', 'tabRailSort', 'tabArrangement', 'tabStateOrder', 'sessionListLayout', 'sessionSidebarFontSize', 'localEchoEnabled', 'cjkInputEnabled', 'extendedKeyboardBar',
|
||||
'skin', 'showPlanUsageLimits', 'showAttachmentsButton', 'showFileViewerButton', 'webglRendererEnabled',
|
||||
'terminalFontFamily', 'terminalFontWeight', 'terminalFontWeightBold',
|
||||
'language',
|
||||
|
||||
+588
-64
@@ -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-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-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
|
||||
`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;
|
||||
}
|
||||
|
||||
.terminal-container.term-enter::before {
|
||||
.tile-body.term-enter .xterm {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.terminal-container.term-enter::before,
|
||||
.tile-body.term-enter::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -1470,14 +1499,29 @@ html[data-win-anim="blur"] .ultracode-window.win-enter {
|
||||
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. */
|
||||
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-duration: calc(560ms * var(--anim-enter-scale, 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-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
@@ -1498,55 +1542,16 @@ html[data-term-anim="crt"] .terminal-container.term-enter::before {
|
||||
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. */
|
||||
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-duration: calc(520ms * var(--anim-enter-scale, 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%);
|
||||
animation-name: term-enter-wipe-edge;
|
||||
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. */
|
||||
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-duration: calc(420ms * var(--anim-enter-scale, 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. */
|
||||
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-duration: calc(340ms * var(--anim-enter-scale, 1));
|
||||
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
|
||||
before, during and after a run), and the property allowlist for every one of
|
||||
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-duration: calc(520ms * var(--anim-enter-scale, 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,
|
||||
.terminal-container.term-enter,
|
||||
.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-packet {
|
||||
.connection-line-packet,
|
||||
.connection-line.tile-beam-line,
|
||||
.session-tab.tab-launch::after {
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
.tile-beam-lines {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.session-tab .tab-status {
|
||||
@@ -3010,19 +3028,6 @@ body.solo-mode .btn-lifecycle-log {
|
||||
marks as is, monochrome ones in the tab's own text colour, so no per-CLI tab
|
||||
colour lives here any more. Only the shell keeps a pill. */
|
||||
|
||||
/* CLI Logos on Tabs off (`showTabCliLogos`, per-device; settings-ui.js
|
||||
applyTabOrientation() and the pre-paint script in index.html stamp the
|
||||
attribute). The logo leaves the session tabs (header strip, vertical rail,
|
||||
sidebar, grouped rail, phone chips) and the desktop home rail; the status
|
||||
dot and the shell's SH pill stay. Every row it sits in spaces its children
|
||||
with flex `gap`, which skips a display:none item, so no empty slot is left.
|
||||
Tile and split headers (.tile-harness, .split-harness) and the Run menus
|
||||
keep their logos: only these two classes are named here. */
|
||||
html[data-tab-logos='off'] .session-tab .tab-harness,
|
||||
html[data-tab-logos='off'] .home-sessions-harness {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Timer Banner - Compact */
|
||||
.timer-banner {
|
||||
display: flex;
|
||||
@@ -20075,6 +20080,314 @@ body.tile-grid-resizing--row * {
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Tile grid entrance styles (entrance-animations.js TILE_ANIM_STYLES) ───
|
||||
Picked in App Settings → Animations (Tile Animations, or preset by the
|
||||
Entrance Theme) or per surface in the lab (?animlab=1); `settle` is the rule above, the default,
|
||||
and what a reload restores with. Any other style is held one frame
|
||||
(.tile--enter-hold: invisible, not animating) while the entrance module
|
||||
orders the cascade and measures the tile's source on the final layout, then
|
||||
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 {
|
||||
transition: opacity 160ms ease-out;
|
||||
}
|
||||
@@ -20149,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) {
|
||||
.tile-count-menu,
|
||||
.tile.tile--entering,
|
||||
.tile.tile--entering::before,
|
||||
.tile.tile--needs::after,
|
||||
.tile.tile--loading .tile-body::after,
|
||||
.tile-grid-ghosts .tile.tile--leaving,
|
||||
|
||||
+92
-21
@@ -237,8 +237,15 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (state !== 'idle') this._setTileLoadingLabel(entry.body);
|
||||
entry.el.classList.toggle('tile--loading', state !== 'idle');
|
||||
// The first capture has landed (or failed): the terminal fades in,
|
||||
// whole, instead of showing its replay scroll by.
|
||||
if (state === 'idle') entry.el.classList.remove('tile--revealing');
|
||||
// whole, instead of showing its replay scroll by, or plays the
|
||||
// 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
|
||||
// under .tile-grid--zoomed, a rule the ghost layer does not carry).
|
||||
const zoomed = section.classList.contains('tile-grid--zoomed');
|
||||
const copies = [];
|
||||
for (const id of grid.ids) {
|
||||
const el = grid.tiles.get(id)?.el;
|
||||
if (!el || (zoomed && !el.classList.contains('tile--zoomed'))) continue;
|
||||
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');
|
||||
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);
|
||||
let fallback = null;
|
||||
let holdCap = null;
|
||||
@@ -557,10 +578,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (fallback !== null || !layer.isConnected) return;
|
||||
clearTimeout(holdCap);
|
||||
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) => {
|
||||
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);
|
||||
else release();
|
||||
@@ -1323,11 +1345,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
/**
|
||||
* Builds one tile (header, body, TerminalTile); where it goes is the
|
||||
* caller's (grid.cells). It enters with a short fade and settle
|
||||
* (styles.css .tile--entering, the `enterIndex`-th of a staggered group),
|
||||
* and its terminal stays transparent until its first capture lands
|
||||
* (.tile--revealing, cleared by the load queue, with a backstop timer).
|
||||
* Opacity and transform only, never anything its fit reads.
|
||||
* caller's (grid.cells). It enters as the `enterIndex`-th of a staggered
|
||||
* group (_beginTileEntrance), and its terminal stays transparent until its
|
||||
* first capture lands (.tile--revealing, cleared by the load queue, with a
|
||||
* backstop timer). Opacity and transform only, never anything its fit reads.
|
||||
*/
|
||||
_mountTile(sessionId, { enterIndex = 0 } = {}) {
|
||||
const grid = this._tileGrid;
|
||||
@@ -1336,16 +1357,11 @@ Object.assign(CodemanApp.prototype, {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'tile tile--revealing';
|
||||
setTimeout(() => el.classList.remove('tile--revealing'), TILE_REVEAL_FALLBACK_MS);
|
||||
if (this._tileMotionAllowed()) {
|
||||
el.classList.add('tile--entering');
|
||||
el.style.setProperty('--tile-enter-index', String(enterIndex));
|
||||
const onEnd = (e) => {
|
||||
if (e.target !== el || e.animationName !== 'tile-enter') return;
|
||||
el.removeEventListener('animationend', onEnd);
|
||||
el.classList.remove('tile--entering');
|
||||
};
|
||||
el.addEventListener('animationend', onEnd);
|
||||
}
|
||||
// Its screen plays the terminal pane's entrance once its first capture
|
||||
// lands, when its frame entered in a tile style (Tile Animations): never
|
||||
// with the default `settle`, nor on a reload's restore, which settles.
|
||||
const entered = this._beginTileEntrance(el, sessionId, enterIndex);
|
||||
const screenOwed = !!entered && entered !== 'settle';
|
||||
el.dataset.sessionId = sessionId;
|
||||
// Header and body are siblings: the chrome is refreshed in place
|
||||
// (_renderTileHeader), never by rewriting the tile, which would take the
|
||||
@@ -1391,12 +1407,61 @@ Object.assign(CodemanApp.prototype, {
|
||||
renaming: false,
|
||||
// The pid this tile last saw, so a pane that starts later is noticed.
|
||||
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).
|
||||
this._renderTileHeader(sessionId);
|
||||
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
|
||||
* 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;
|
||||
const stored = this._readStoredTileGrid();
|
||||
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. */
|
||||
|
||||
@@ -1391,13 +1391,6 @@ export const SettingsUpdateSchema = z
|
||||
// CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK env var. Stripped before persisting.
|
||||
acknowledgeUnauthTunnel: z.boolean().optional(),
|
||||
tabTwoRows: z.boolean().optional(),
|
||||
/**
|
||||
* CLI Logos on Tabs. Display key (per-device), default ON: only an explicit
|
||||
* false hides the agent logo on session tabs and the desktop home rail
|
||||
* (`html[data-tab-logos='off']`, a CSS-only switch). Tile and split headers
|
||||
* and the Run menus keep their logos.
|
||||
*/
|
||||
showTabCliLogos: z.boolean().optional(),
|
||||
tabOrientation: z.enum(['horizontal', 'vertical']).optional(),
|
||||
tabRailWidth: z.number().int().min(208).max(360).optional(),
|
||||
tabRailDetail: z.enum(['simple', 'rich']).optional(),
|
||||
|
||||
@@ -80,6 +80,31 @@ describe('App Settings modal structure', () => {
|
||||
expect(system).toContain('id="appSettingsTunnelEnabled"');
|
||||
});
|
||||
|
||||
/**
|
||||
* Owner decision (2026-10-09): every animation setting has its own
|
||||
* Animations section, right after Appearance, so it is easy to find. The
|
||||
* selects are wired by id in entrance-animations.js, not by the load/save
|
||||
* path above, so they get their own check here.
|
||||
*/
|
||||
it('keeps every animation setting in its own Animations section, after Appearance', () => {
|
||||
const modal = settingsModal();
|
||||
const rail = [...modal.matchAll(/data-section="([a-z-]+)"/g)].map((m) => m[1]);
|
||||
const order = [...modal.matchAll(/<section class="set-section" id="([a-z-]+)"/g)].map((m) => m[1]);
|
||||
for (const list of [rail, order]) {
|
||||
expect(list[list.indexOf('settings-appearance') + 1]).toBe('settings-animations');
|
||||
}
|
||||
const animations = modal.match(/id="settings-animations"([\s\S]*?)<\/section>/)?.[1] ?? '';
|
||||
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
|
||||
expect(animations, `${id} belongs in the Animations section`).toContain(`id="${id}"`);
|
||||
}
|
||||
const appearance = modal.match(/id="settings-appearance"([\s\S]*?)<\/section>/)?.[1] ?? '';
|
||||
expect(appearance).not.toMatch(/id="appSettings[A-Za-z]*Anim"/);
|
||||
const anim = readFileSync(resolve(publicDir, 'entrance-animations.js'), 'utf8');
|
||||
for (const id of ['appSettingsEntranceAnim', 'appSettingsTileAnim', 'appSettingsOpenAnimLab']) {
|
||||
expect(anim).toContain(`document.getElementById('${id}')`);
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps Local Echo the first row of the second section', () => {
|
||||
const terminal = settingsModal().match(/id="settings-terminal"([\s\S]*?)<\/section>/);
|
||||
const localEcho = terminal?.[1].indexOf('appSettingsLocalEcho') ?? -1;
|
||||
|
||||
@@ -31,6 +31,7 @@ const SURFACES = [
|
||||
{ attr: 'win', array: 'WIN_ANIM_STYLES', selector: '.subagent-window.win-enter' },
|
||||
{ attr: 'line', array: 'LINE_ANIM_STYLES', selector: '.connection-line.line-enter' },
|
||||
{ attr: 'term', array: 'TERM_ANIM_STYLES', selector: '.terminal-container.term-enter' },
|
||||
{ attr: 'tile', array: 'TILE_ANIM_STYLES', selector: '.tile.tile--entering' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -40,6 +41,11 @@ const SURFACES = [
|
||||
*/
|
||||
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[] {
|
||||
const start = animSource.indexOf(`const ${arrayName} = [`);
|
||||
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]);
|
||||
}
|
||||
|
||||
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 body = animSource.slice(start, animSource.indexOf('];', start));
|
||||
return [
|
||||
...body.matchAll(/\{ key: '([^']+)'.*?tab: '([^']+)', win: '([^']+)', line: '([^']+)', term: '([^']+)' \}/g),
|
||||
].map((m) => ({ key: m[1], tab: m[2], win: m[3], line: m[4], term: m[5] }));
|
||||
const parsed = [
|
||||
...body.matchAll(
|
||||
/\{ 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`, () => {
|
||||
it('backs every style with a rule that names a keyframe block that exists', () => {
|
||||
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}"]`);
|
||||
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', () => {
|
||||
@@ -173,3 +196,96 @@ describe('entrance animation styles', () => {
|
||||
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 \\{`));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
/**
|
||||
* @fileoverview CLI Logos on Tabs (`showTabCliLogos`): the per-device switch
|
||||
* that hides the agent logo on the session tabs and the desktop home rail.
|
||||
*
|
||||
* What is pinned, and why it matters:
|
||||
* - The switch is a row in App Settings → Appearance → Tabs, right after Tall
|
||||
* Tabs, and the REAL openAppSettings() / saveAppSettings() load and save it
|
||||
* by its id. The load/save contract is getElementById by id, so a renamed
|
||||
* control would otherwise just stop loading or saving, silently.
|
||||
* - It is modelled on tabTwoRows: a display key in the server-settings merge
|
||||
* (a phone never overwrites a desktop's choice) AND an optional boolean in
|
||||
* the .strict() SettingsUpdateSchema (the save PUTs it; a key the schema did
|
||||
* not declare would 400 the whole settings save).
|
||||
* - Default ON on every device; only an explicit false turns it off.
|
||||
* - The pre-paint script in index.html stamps `data-tab-logos` from the same
|
||||
* stored blob, so a reload never flashes the logos it is about to hide, and
|
||||
* a Save stamps it live through applyTabOrientation() without re-rendering
|
||||
* the tabs (the logo spans are always in the markup; CSS hides them).
|
||||
* - The CSS names exactly the tab logo and the home-rail logo. Tile and split
|
||||
* headers, the Run menus and the welcome launchers keep theirs.
|
||||
* - The row reads in Chinese under zh-CN.
|
||||
*
|
||||
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only').
|
||||
*
|
||||
* Port: none.
|
||||
*/
|
||||
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import vm from 'node:vm';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import postcss, { type Rule } from 'postcss';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SettingsUpdateSchema } from '../src/web/schemas.js';
|
||||
|
||||
const PUBLIC = join(process.cwd(), 'src/web/public');
|
||||
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
|
||||
const INDEX = read('index.html');
|
||||
const STYLES = read('styles.css');
|
||||
const MOBILE = read('mobile.css');
|
||||
const I18N = read('i18n.js');
|
||||
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js'].map(read);
|
||||
|
||||
const DESKTOP_KEY = 'codeman-app-settings';
|
||||
const PHONE_KEY = 'codeman-app-settings-mobile';
|
||||
|
||||
type Device = 'desktop' | 'mobile';
|
||||
type Settings = Record<string, unknown>;
|
||||
interface SettingsApp {
|
||||
openAppSettings(): void;
|
||||
saveAppSettings(): Promise<void>;
|
||||
loadAppSettingsFromServer(p: Promise<Settings>): Promise<Settings>;
|
||||
loadAppSettingsFromStorage(): Settings;
|
||||
getDefaultSettings(): Settings;
|
||||
tabCliLogosEnabled(s: Settings): boolean;
|
||||
applyTabOrientation(): void;
|
||||
_cachedAppSettings?: unknown;
|
||||
_apiPut: (path: string, body: Settings) => Promise<unknown>;
|
||||
_fullRenderSessionTabs: ReturnType<typeof vi.fn>;
|
||||
updateTabOverflowMode: ReturnType<typeof vi.fn>;
|
||||
_updateConnectionLinesImmediate: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods that run for real. Every other method of the app is a no-op here:
|
||||
* openAppSettings() and saveAppSettings() fan out into the voice, webhook,
|
||||
* tunnel, model and push panels, none of which this setting touches, and the
|
||||
* load/save lines under test sit between those calls.
|
||||
*/
|
||||
const REAL = new Set([
|
||||
'openAppSettings',
|
||||
'saveAppSettings',
|
||||
'loadAppSettingsFromServer',
|
||||
'loadAppSettingsFromStorage',
|
||||
'saveAppSettingsToStorage',
|
||||
'getSettingsStorageKey',
|
||||
'getDefaultSettings',
|
||||
'tabCliLogosEnabled',
|
||||
'applyTabOrientation',
|
||||
'resolveTabArrangement',
|
||||
'resolveTabStateOrder',
|
||||
// Real so the save builds the body a browser would send (the schema check).
|
||||
'resolveHeaderStatsStyle',
|
||||
'resolveSessionSidebarFontSize',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Methods the load/save paths call that other modules add to the prototype
|
||||
* (terminal-ui.js, panels-ui.js, session-ui.js), which this harness does not
|
||||
* load. Stubbed as own properties.
|
||||
*/
|
||||
const ELSEWHERE = [
|
||||
'showToast',
|
||||
'_updateLocalEchoState',
|
||||
'renderProjectInsightsPanel',
|
||||
'updateSubagentWindowVisibility',
|
||||
'showWelcome',
|
||||
'_applyRunMode',
|
||||
];
|
||||
|
||||
const windows: { close(): void }[] = [];
|
||||
afterEach(() => {
|
||||
for (const win of windows.splice(0)) win.close();
|
||||
});
|
||||
|
||||
/** The whole index.html with constants.js, app.js and settings-ui.js loaded into it. */
|
||||
async function boot(device: Device = 'desktop', stored?: Settings) {
|
||||
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
|
||||
const win = dom.window as unknown as Window & typeof globalThis & { eval(src: string): void; __App: any };
|
||||
windows.push(win);
|
||||
// After load, so app.js's DOMContentLoaded boot (new CodemanApp(): SSE,
|
||||
// timers, the terminal) never runs; only the prototype is under test.
|
||||
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
|
||||
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
|
||||
if (stored) win.localStorage.setItem(device === 'desktop' ? DESKTOP_KEY : PHONE_KEY, JSON.stringify(stored));
|
||||
win.eval(
|
||||
[
|
||||
`var MobileDetection = {
|
||||
getDeviceType: () => ${JSON.stringify(device)},
|
||||
isHandheldDevice: () => ${JSON.stringify(device)} !== 'desktop',
|
||||
isMobile: () => false,
|
||||
isTouchDevice: () => false,
|
||||
};
|
||||
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
|
||||
var KeyboardAccessoryBar = { setMode() {} };
|
||||
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
|
||||
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
|
||||
var DEFAULT_VOICE_KEYTERMS = '';`,
|
||||
...SCRIPTS,
|
||||
'window.__App = CodemanApp;',
|
||||
].join('\n')
|
||||
);
|
||||
const target = Object.create(win.__App.prototype);
|
||||
target.sessions = new Map();
|
||||
target.sessionOrder = [];
|
||||
target._apiPut = vi.fn(async () => ({ ok: true }));
|
||||
target._fullRenderSessionTabs = vi.fn();
|
||||
target.updateTabOverflowMode = vi.fn();
|
||||
target._updateConnectionLinesImmediate = vi.fn();
|
||||
const noop = () => undefined;
|
||||
for (const name of ELSEWHERE) target[name] = noop;
|
||||
const app = new Proxy(target, {
|
||||
get(t, key, receiver) {
|
||||
const value = Reflect.get(t, key, receiver);
|
||||
const stubbed =
|
||||
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
|
||||
return stubbed ? noop : value;
|
||||
},
|
||||
}) as SettingsApp;
|
||||
const doc = win.document;
|
||||
const checkbox = () => doc.getElementById('appSettingsShowTabCliLogos') as HTMLInputElement;
|
||||
const storedBlob = () =>
|
||||
JSON.parse(win.localStorage.getItem(device === 'desktop' ? DESKTOP_KEY : PHONE_KEY) || '{}') as Settings;
|
||||
return { win, doc, app, checkbox, storedBlob };
|
||||
}
|
||||
|
||||
describe('the App Settings row', () => {
|
||||
const doc = new JSDOM(INDEX).window.document;
|
||||
const input = doc.getElementById('appSettingsShowTabCliLogos') as HTMLInputElement;
|
||||
const row = input?.closest('.set-row') as HTMLElement;
|
||||
|
||||
it('is a switch in Appearance → Tabs (device scope), right after Tall Tabs', () => {
|
||||
expect(input?.type).toBe('checkbox');
|
||||
expect(input.parentElement!.matches('label.switch.switch-sm')).toBe(true);
|
||||
const group = row.closest('.set-group')!;
|
||||
expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Tabs');
|
||||
expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('device');
|
||||
expect(row.closest('.set-section')!.id).toBe('settings-appearance');
|
||||
expect(row.previousElementSibling!.querySelector('#appSettingsTabTwoRows')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('says what it shows, what stays, and what it does not touch', () => {
|
||||
expect(row.querySelector('.set-row-label')!.textContent).toBe('CLI Logos on Tabs');
|
||||
const desc = row.querySelector('.set-row-desc')!.textContent!;
|
||||
for (const part of ['logo', 'status dot', 'SH badge', 'Tiles', 'split headers', 'Run menus']) {
|
||||
expect(desc, part).toContain(part);
|
||||
}
|
||||
});
|
||||
|
||||
it('is found by the settings search for logo, icon, harness, agent, cli, tab and hide', () => {
|
||||
const words = row.getAttribute('data-search')!.split(/\s+/);
|
||||
for (const word of ['logo', 'icon', 'harness', 'agent', 'cli', 'tab', 'hide']) expect(words, word).toContain(word);
|
||||
});
|
||||
});
|
||||
|
||||
describe('load and save through settings-ui.js', () => {
|
||||
it('loads the stored value into the switch, and an absent key as on', async () => {
|
||||
for (const [stored, expected] of [
|
||||
[{ showTabCliLogos: false }, false],
|
||||
[{ showTabCliLogos: true }, true],
|
||||
[{}, true],
|
||||
] as const) {
|
||||
const { app, checkbox } = await boot('desktop', stored);
|
||||
checkbox().checked = !expected;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked, JSON.stringify(stored)).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('saves the switch to this device and sends it in the settings PUT, which the schema accepts', async () => {
|
||||
const { app, checkbox, storedBlob } = await boot('desktop', {});
|
||||
app.openAppSettings();
|
||||
checkbox().checked = false;
|
||||
await app.saveAppSettings();
|
||||
expect(storedBlob().showTabCliLogos).toBe(false);
|
||||
expect(app._apiPut).toHaveBeenCalledTimes(1);
|
||||
const [path, body] = (app._apiPut as unknown as { mock: { calls: [string, Settings][] } }).mock.calls[0];
|
||||
expect(path).toBe('/api/settings');
|
||||
expect(body.showTabCliLogos).toBe(false);
|
||||
// The whole body, not just this key: an undeclared key 400s every save.
|
||||
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
|
||||
});
|
||||
|
||||
it('a Save stamps the attribute live', async () => {
|
||||
const { app, checkbox, doc } = await boot('desktop', {});
|
||||
app.openAppSettings();
|
||||
checkbox().checked = false;
|
||||
await app.saveAppSettings();
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('off');
|
||||
checkbox().checked = true;
|
||||
await app.saveAppSettings();
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('on');
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-device like tabTwoRows', () => {
|
||||
it('is a display key: a server value only seeds a device that has none', async () => {
|
||||
// Local choice made: the server's value (another device's) never replaces it,
|
||||
// while a synced key in the same payload does (the merge really ran).
|
||||
const kept = await boot('desktop', { showTabCliLogos: true, autoNameSessions: false });
|
||||
const merged = await kept.app.loadAppSettingsFromServer(
|
||||
Promise.resolve({ showTabCliLogos: false, autoNameSessions: true })
|
||||
);
|
||||
expect(merged.showTabCliLogos).toBe(true);
|
||||
expect(merged.autoNameSessions).toBe(true);
|
||||
|
||||
// A fresh device takes the server's value as its seed.
|
||||
const fresh = await boot('desktop');
|
||||
expect(
|
||||
(await fresh.app.loadAppSettingsFromServer(Promise.resolve({ showTabCliLogos: false }))).showTabCliLogos
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('is an optional boolean in SettingsUpdateSchema, and nothing else passes', () => {
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: true }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: false }).success).toBe(true);
|
||||
expect(SettingsUpdateSchema.safeParse({}).success).toBe(true);
|
||||
for (const bad of ['off', 'false', 0, 1, null, {}]) {
|
||||
expect(SettingsUpdateSchema.safeParse({ showTabCliLogos: bad }).success, JSON.stringify(bad)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('defaults to on, on a desktop and on a phone', async () => {
|
||||
for (const device of ['desktop', 'mobile'] as const) {
|
||||
const { app, checkbox } = await boot(device);
|
||||
expect(app.tabCliLogosEnabled(app.loadAppSettingsFromStorage()), device).toBe(true);
|
||||
checkbox().checked = false;
|
||||
app.openAppSettings();
|
||||
expect(checkbox().checked, device).toBe(true);
|
||||
}
|
||||
// The phone's defaults blob carries it explicitly, like its other tab keys.
|
||||
expect((await boot('mobile')).app.getDefaultSettings().showTabCliLogos).toBe(true);
|
||||
});
|
||||
|
||||
it('only an explicit false turns it off', async () => {
|
||||
const { app } = await boot();
|
||||
expect(app.tabCliLogosEnabled({ showTabCliLogos: false })).toBe(false);
|
||||
for (const value of [true, undefined, 'off', 0, null]) {
|
||||
expect(app.tabCliLogosEnabled({ showTabCliLogos: value }), String(value)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTabOrientation() stamps data-tab-logos without re-rendering', () => {
|
||||
async function settled(stored: Settings) {
|
||||
const booted = await boot('desktop', stored);
|
||||
booted.app.applyTabOrientation();
|
||||
for (const fn of [
|
||||
booted.app._fullRenderSessionTabs,
|
||||
booted.app.updateTabOverflowMode,
|
||||
booted.app._updateConnectionLinesImmediate,
|
||||
]) {
|
||||
fn.mockClear();
|
||||
}
|
||||
const apply = (next: Settings) => {
|
||||
booted.win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(next));
|
||||
delete booted.app._cachedAppSettings;
|
||||
booted.app.applyTabOrientation();
|
||||
};
|
||||
return { ...booted, apply };
|
||||
}
|
||||
|
||||
it('flips the attribute and leaves the tab markup alone', async () => {
|
||||
const { doc, app, apply } = await settled({ showTabCliLogos: true });
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('on');
|
||||
apply({ showTabCliLogos: false });
|
||||
expect(doc.documentElement.dataset.tabLogos).toBe('off');
|
||||
expect(app._fullRenderSessionTabs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('re-measures the strip wrap and re-anchors the lines on a flip, and only on a flip', async () => {
|
||||
// Every agent tab just got narrower (or wider) with no render behind it: the
|
||||
// one-row wrap decision and the lines drawn from tab rects are stale.
|
||||
const { app, apply } = await settled({ showTabCliLogos: true });
|
||||
apply({ showTabCliLogos: false });
|
||||
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(1);
|
||||
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(1);
|
||||
|
||||
apply({ showTabCliLogos: false });
|
||||
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(1);
|
||||
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(1);
|
||||
|
||||
apply({ showTabCliLogos: true });
|
||||
expect(app.updateTabOverflowMode).toHaveBeenCalledTimes(2);
|
||||
expect(app._updateConnectionLinesImmediate).toHaveBeenCalledTimes(2);
|
||||
expect(app._fullRenderSessionTabs).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the pre-paint script in index.html', () => {
|
||||
// The layout one: there is an earlier inline script with the same opening.
|
||||
const PRE_PAINT =
|
||||
[...INDEX.matchAll(/<script>(try\{var m=window\.innerWidth[\s\S]*?)<\/script>/g)]
|
||||
.map((m) => m[1])
|
||||
.find((src) => src.includes('dataset.tabStateOrder')) ?? '';
|
||||
|
||||
/**
|
||||
* Runs the real inline script in a fresh window as `device` (a phone is a
|
||||
* 393px viewport, the width test the script uses), with `blob` stored under
|
||||
* that device's key, or under `storeAs`'s key when given.
|
||||
*/
|
||||
function prePaint(blob: string | null, device: Device = 'desktop', storeAs: Device = device) {
|
||||
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>', {
|
||||
url: 'http://localhost/',
|
||||
runScripts: 'outside-only',
|
||||
});
|
||||
const win = dom.window as unknown as Window & { eval(src: string): void };
|
||||
Object.defineProperty(win, 'innerWidth', { value: device === 'desktop' ? 1440 : 393, configurable: true });
|
||||
if (blob !== null) win.localStorage.setItem(storeAs === 'desktop' ? DESKTOP_KEY : PHONE_KEY, blob);
|
||||
win.eval(PRE_PAINT);
|
||||
return win.document.documentElement;
|
||||
}
|
||||
|
||||
it('is found (the checks below are not vacuous)', () => {
|
||||
expect(PRE_PAINT).toContain('localStorage.getItem(k)');
|
||||
});
|
||||
|
||||
it('stamps off from the stored setting, on a desktop and on a phone', () => {
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: false })).dataset.tabLogos).toBe('off');
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: false }), 'mobile').dataset.tabLogos).toBe('off');
|
||||
});
|
||||
|
||||
it('leaves the logos on otherwise', () => {
|
||||
expect(prePaint(null).dataset.tabLogos).toBe('on');
|
||||
expect(prePaint('{}').dataset.tabLogos).toBe('on');
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: true })).dataset.tabLogos).toBe('on');
|
||||
// A phone reads its own blob: a desktop's choice does not reach it.
|
||||
expect(prePaint(JSON.stringify({ showTabCliLogos: false }), 'mobile', 'desktop').dataset.tabLogos).toBe('on');
|
||||
});
|
||||
|
||||
it('falls back to on, beside the other fallbacks, when the stored blob does not parse', () => {
|
||||
const root = prePaint('{not json');
|
||||
expect(root.dataset.tabLogos).toBe('on');
|
||||
expect(root.dataset.tabArrangement).toBe('classic');
|
||||
expect(root.dataset.headerStats).toBe('classic');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the CSS', () => {
|
||||
const rulesNaming = (css: string, pattern: RegExp) => {
|
||||
const found: Rule[] = [];
|
||||
postcss.parse(css).walkRules((rule: Rule) => {
|
||||
if (pattern.test(rule.selector)) found.push(rule);
|
||||
});
|
||||
return found;
|
||||
};
|
||||
const selectorsOf = (rule: Rule) => rule.selectors.map((s) => s.replace(/\s+/g, ' ').trim());
|
||||
|
||||
it('hides the tab logo and the home-rail logo under the attribute, in one rule, and nothing else', () => {
|
||||
const rules = rulesNaming(STYLES, /data-tab-logos/);
|
||||
expect(rules).toHaveLength(1);
|
||||
expect(selectorsOf(rules[0])).toEqual([
|
||||
"html[data-tab-logos='off'] .session-tab .tab-harness",
|
||||
"html[data-tab-logos='off'] .home-sessions-harness",
|
||||
]);
|
||||
const decls: string[] = [];
|
||||
rules[0].walkDecls((d) => {
|
||||
decls.push(`${d.prop}: ${d.value}${d.important ? ' !important' : ''}`);
|
||||
});
|
||||
expect(decls).toEqual(['display: none']);
|
||||
expect(rules[0].parent?.type).toBe('root');
|
||||
});
|
||||
|
||||
it('leaves the tile and split headers, the Run menus and the welcome launchers their logos', () => {
|
||||
const selectors = rulesNaming(STYLES, /data-tab-logos/)
|
||||
.flatMap(selectorsOf)
|
||||
.join(' ');
|
||||
for (const other of ['tile-harness', 'split-harness', 'run-mode-option', 'welcome', 'mobile-overview']) {
|
||||
expect(selectors, other).not.toContain(other);
|
||||
}
|
||||
// Not the shared slot either: the Run menus draw the same `.run-mode-dot`.
|
||||
expect(selectors).not.toMatch(/(^|\s)\.run-mode-dot/);
|
||||
});
|
||||
|
||||
it('nothing on a phone or in a skin brings the logo back over the rule', () => {
|
||||
expect(MOBILE).not.toContain('data-tab-logos');
|
||||
for (const css of [STYLES, MOBILE]) {
|
||||
for (const rule of rulesNaming(css, /tab-harness|home-sessions-harness|run-mode-dot/)) {
|
||||
rule.walkDecls('display', (d) => {
|
||||
// The only other display rule on the logo is the compact rail's rename
|
||||
// rule, which hides it too.
|
||||
expect(d.value, rule.selector).toBe('none');
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('zh-CN', () => {
|
||||
const rowText = (doc: Document) => {
|
||||
const row = doc.getElementById('appSettingsShowTabCliLogos')!.closest('.set-row')!;
|
||||
return {
|
||||
label: row.querySelector('.set-row-label')!.textContent!.trim(),
|
||||
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
|
||||
};
|
||||
};
|
||||
const english = rowText(new JSDOM(INDEX).window.document);
|
||||
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
|
||||
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
|
||||
const api = (dom.window as unknown as { CodemanI18n: { start(): void; configure(o: object): void } }).CodemanI18n;
|
||||
api.start();
|
||||
api.configure({ language: 'zh-CN' });
|
||||
const chinese = rowText(dom.window.document);
|
||||
/** What may stay Latin: the CLI and SH names. */
|
||||
const leftover = (text: string) => text.replace(/\b(CLI|SH|Shell)\b/g, '').match(/[A-Za-z]+/g) ?? [];
|
||||
|
||||
it('translates the label and the description, with no English left', () => {
|
||||
expect(chinese.label).toBe('标签页上的 CLI 图标');
|
||||
expect(chinese.description).not.toBe(english.description);
|
||||
expect(leftover(chinese.label)).toEqual([]);
|
||||
expect(leftover(chinese.description)).toEqual([]);
|
||||
});
|
||||
|
||||
it('adds each key to the dictionary once', () => {
|
||||
for (const key of [english.label, english.description]) {
|
||||
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
expect(I18N.match(new RegExp(`^\\s*(?:'${escaped}'|"${escaped}"):`, 'gm')) ?? [], key).toHaveLength(1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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