feat(ui): add a Blur entrance animation on all four surfaces

An iOS-style focus pull: the thing arrives out of focus and the blur fades
off it as the opacity comes up. Opacity leads the blur (full opacity around
45%, blur still lifting), which is what separates it from a cross-fade.
Ships on tabs (440ms), agent windows (560ms), the terminal pane (520ms) and
connection lines (380ms), plus a `Soft focus` theme that sets all four.
Default stays `legacy`, so an untouched install is unchanged.

The terminal pane is the one surface that cannot blur itself the documented
way, and `blur` takes a deliberate exception to the "never a filter on
.terminal-container" rule. Every alternative was measured against a live
xterm and does not work: a backdrop-filter veil on ::before blurs perfectly
while STATIC, and Chrome silently drops the backdrop the moment ANY
animation runs on that pseudo-element (the veil computes blur(15.3px) while
the text behind it stays razor sharp); driving the radius from rAF buys the
same full-screen blur per frame plus main-thread work. The cost the rule
exists to avoid is inherent to blurring a terminal, so the style buys it
knowingly: opt-in, off by default, one ~520ms run per session open, class
straight back off, will-change still unset. Worst-case price, headless
SwiftShader with no GPU: frame deltas 16.7ms -> 33.3ms for the run, against
16.7ms flat for `fade`. cols x rows measured unchanged at 178x38 before,
during and after, so FitAddon never sees it.

The line entrance animates `filter` too, where each line already carried
its glow. Both kinds now hold it in --line-glow and both keyframes say
`blur(N) var(--line-glow)`, so the function lists match and interpolate
instead of the glow vanishing for the run and popping back (a lineage
line's glow is a different colour, set per element). Its 100% frame omits
`opacity` on purpose so the endpoint comes from the element's own resting
value: 0.9 subagent, 0.72 lineage, 0.95 working.

test/entrance-animations.test.ts is a new static guard over the whole
feature, not just this style: the rule -> keyframes -> theme-option chain a
style silently does nothing without, the terminal's paint-only property
allowlist (the FitAddon rule), the --line-glow contract, and reduced-motion
coverage. Mutation-checked both ways.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-10 02:57:40 +02:00
parent d4fe3afc9d
commit 57899f879e
5 changed files with 301 additions and 7 deletions
+1 -1
View File
@@ -292,7 +292,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) → `sanitize-html.js`(5.6) → `app.js`(6) → `tab-rail-resize.js`(6.5) → `terminal-ui.js`(7) → `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) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `mobile-overview.js`(12.55) → `home-sessions.js`(12.56) → `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). `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).
**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. ⚠️ 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. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY. ⚠️ 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. 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 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. ⚠️ 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. ⚠️ The terminal-pane styles may animate **transform / opacity / clip-path only**, xterm's FitAddon derives rows+cols from `getComputedStyle(parent).width/height`, so animating width/height/padding there would resize the PTY; `test/entrance-animations.test.ts` pins that property allowlist, plus the rule→keyframes→theme-option chain a style silently does nothing without. ⚠️ **`blur` is the ONE style that puts a `filter` on the terminal container**, against the standing rule, because every alternative was measured against a live xterm and does not work: a `backdrop-filter` veil on `::before` blurs perfectly while STATIC and Chrome silently drops the backdrop the moment ANY animation runs on that pseudo-element (the veil computes `blur(15.3px)` and the text behind it stays razor sharp), and driving the radius from rAF buys the same full-screen blur per frame plus main-thread work. The cost the rule exists to avoid is inherent to blurring a terminal, so the style buys it knowingly: opt-in, OFF by default, one ~520ms run per session open, class straight back off, `will-change` still unset. Worst-case price, headless SwiftShader with no GPU: frame deltas 16.7ms → 33.3ms for the run, against 16.7ms flat for `fade`. Do not generalise it — a second filtered terminal style needs its own measurement. ⚠️ The `blur` connection line animates `filter` too, so both kinds of line hold their glow in **`--line-glow`** and both of its keyframes say `blur(N) var(--line-glow)`: the function lists then match and interpolate, instead of the glow vanishing for the run and popping back (a lineage line's glow is a different colour entirely, set per element). Its 100% frame deliberately omits `opacity` so the endpoint comes from the element's own resting value — 0.9 subagent, 0.72 lineage, 0.95 working — which is what `line-enter-fade`'s hardcoded 0.9 gets wrong. ⚠️ 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. 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`.
**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. ⚠️ **The ACTIVE tab is the only one with action icons, and on a phone they can eat it**: `.session-tab.active .tab-name` reserves `min-width: 44px` in the ≤430px block, because a short session name rendered a 13px label against a 50px gear+close cluster, putting the tab's geometric CENTRE on the gear, so a thumb aiming at the tab opened Session Options instead of switching (measured at 360/393/430px; only long names cleared it). ⚠️ **The floor is set by the 10th tab onward, not by the tabs you can see**: `.tab-number` renders only for `_tabIdx < 9`, so tab 10 loses 16px + a gap off its left and its centre sits 10px further right. The centre clears the icons when `reserved > icons + rightEdge - leftRunUp - gap` (= 50 + 9 - 17 - 4 = **38px**), hit-testing snaps to whole pixels so 39px still lands on the gear, and the practical floor is 40px — a NUMBERED tab clears it at 20px, which is exactly why reasoning from the tabs on screen would put the centre back on the gear. `test/mobile-tab-tap-zones.test.ts` recomputes that inequality from the stylesheet, so widening the gear or the padding fails there rather than on a phone. The guarantee is centre-off-the-ICONS, not centre-inside-the-label (on a numberless tab it lands in the gap between them, which still switches). Non-active tabs keep their icons hidden and stay tappable end to end. ⚠️ Mobile no longer hoists the active session to the front of the strip: that reordering ran on full renders only, so tab order flipped depending on which render path fired, and it renumbered the Alt+N badges. Scroll-into-view replaces it; do not reintroduce it.
+9 -1
View File
@@ -25,7 +25,10 @@
* 3. The terminal pane is ONE shared element, so its entrance is marked at
* session creation but played at selection: a session created in the
* background must not animate the pane the user is currently looking at. Its
* styles are also restricted to transform/opacity/clip-path (see below).
* styles are also restricted to transform/opacity/clip-path (see below), with
* `blur` the one documented exception - a filter is the only thing that
* actually blurs a live xterm; styles.css carries the measurement and the
* three alternatives that do not work.
* 4. Nothing may animate on page load or reconnect replay. Only ids that pass
* through `markSessionTabEntering()` animate, and `_tabEnterSeen` makes that
* once-per-id even though the POST response and the SSE event both call
@@ -50,6 +53,7 @@ const TAB_ANIM_STYLES = [
{ key: 'unroll', label: 'Unroll', blurb: 'The strip makes room and the tab widens in.', duration: 480 },
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 720 },
{ key: 'flip', label: 'Flip', blurb: 'Drops in as a card hinged on its top edge.', duration: 520 },
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur fades off it.', duration: 440 },
{ key: 'off', label: 'Off', blurb: 'Tabs just appear.', duration: 0 },
];
@@ -65,6 +69,7 @@ const WIN_ANIM_STYLES = [
{ key: 'unfold', label: 'Unfold', blurb: 'Hinges down from its top edge in 3D.', duration: 560 },
{ key: 'beam', label: 'Beam down', blurb: 'Waits for its line to reach it, then materializes.', duration: 620 },
{ key: 'pop', label: 'Pop', blurb: 'Springs open from its centre.', duration: 460 },
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur fades off it.', duration: 560 },
{ key: 'off', label: 'Off', blurb: 'Windows just appear.', duration: 0 },
];
@@ -73,6 +78,7 @@ const LINE_ANIM_STYLES = [
{ key: 'draw', label: 'Draw', blurb: 'Draws itself from the tab down to the window.', duration: 420 },
{ key: 'packet', label: 'Packet', blurb: 'Line fades in, then a bright packet runs down it.', duration: 700 },
{ key: 'fade', label: 'Fade', blurb: 'Simply fades in.', duration: 300 },
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur fades off it.', duration: 380 },
{ key: 'off', label: 'Off', blurb: 'Lines just appear.', duration: 0 },
];
@@ -92,6 +98,7 @@ const TERM_ANIM_STYLES = [
{ 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 },
{ key: 'blur', label: 'Blur', blurb: 'Focus-pulls in as the blur lifts off the pane.', duration: 520 },
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
];
@@ -102,6 +109,7 @@ const BEAM_HOLD_MS = 360;
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' },
+1
View File
@@ -1889,6 +1889,7 @@
<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>
+116 -5
View File
@@ -905,6 +905,28 @@ html[data-tab-anim="flip"] .session-tab.tab-enter {
100% { opacity: 1; transform: perspective(700px) rotateX(0deg); }
}
/* Blur, iOS-style focus pull: the tab arrives out of focus and the blur fades
OFF it as the opacity comes up, so it reads as resolving rather than moving.
Opacity leads the blur (full opacity around 45%, blur still lifting) - that
offset is what separates it from a plain cross-fade.
`filter` here, not on ::before: the tab's own box-shadow and border have to
blur with it or the shape stays sharp inside a blurred fill, and unlike
background/box-shadow (which .session-tab.active sets !important) nothing
overrides filter. */
html[data-tab-anim="blur"] .session-tab.tab-enter {
animation-name: tab-enter-blur;
animation-duration: calc(440ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
will-change: transform, opacity, filter;
}
@keyframes tab-enter-blur {
0% { opacity: 0; filter: blur(10px); transform: scale(0.94); }
45% { opacity: 1; }
100% { opacity: 1; filter: blur(0px); transform: none; }
}
/* ── Entrance lab (?animlab=1) ───────────────────────────────────────────── */
.anim-lab {
@@ -1186,6 +1208,25 @@ html[data-win-anim="pop"] .ultracode-window.win-enter {
100% { opacity: 1; transform: scale(1); }
}
/* Blur, iOS-style focus pull. `materialize` is the noisy cousin: it glitches the
opacity and rides a brightness boost. This one only defocuses, so it stays
readable next to a terminal. The scale is deliberately small (0.96): the
connection line is aimed at getBoundingClientRect(), which reports the
TRANSFORMED box, so a big scale would swing the line's target while it draws.
applyWindowEntrance() redraws the lines once the animation ends. */
html[data-win-anim="blur"] .subagent-window.win-enter,
html[data-win-anim="blur"] .ultracode-window.win-enter {
animation-name: win-enter-blur;
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes win-enter-blur {
0% { opacity: 0; filter: blur(18px); transform: scale(0.96); }
50% { opacity: 1; }
100% { opacity: 1; filter: blur(0px); transform: none; }
}
/* ── Main terminal pane entrance animations ────────────────────────────────
⚠ transform / opacity / clip-path ONLY. xterm's FitAddon derives rows+cols
from getComputedStyle(parent).width/height, the untransformed layout box -
@@ -1327,6 +1368,47 @@ html[data-term-anim="fade"] .terminal-container.term-enter {
100% { opacity: 1; transform: none; }
}
/* Blur, iOS-style focus pull.
⚠ THE ONE PLACE A `filter` GOES ON THE TERMINAL CONTAINER, and it is a
deliberate exception to the rule above, not an oversight. Every other way of
blurring this pane was tried against a real xterm and does not work:
- `backdrop-filter` on ::before blurs perfectly while it is STATIC, and
Chrome silently drops the backdrop the moment ANY animation runs on that
pseudo-element (measured: the veil computes `blur(15.3px)` and the text
behind it stays razor sharp). Animating the container instead keeps the
backdrop, so the veil would have to hold one fixed radius, which is a
frosted pane that snaps off rather than a focus pull.
- Driving the radius from rAF avoids the compositor promotion, at the cost
of the same full-screen blur per frame plus main-thread work.
So the cost the rule exists to avoid is inherent to blurring a terminal at
all, and this style buys it knowingly: it is opt-in, OFF by default, bounded
to one ~520ms run when a session is opened (or switched to, with `Also on
every tab switch`), and the class comes straight back off. `will-change` is
still deliberately unset, per the base rule. Measured price on a headless
SwiftShader rasterizer with no GPU at all, i.e. the worst case: frame deltas
go 16.7ms -> 33.3ms for the length of the run, against 16.7ms flat for `fade`.
The blur must not change layout, or FitAddon would feed wrong dimensions into
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 {
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);
}
/* Opacity leads the blur - full opacity around 45%, blur still lifting - which
is what separates the effect from a plain cross-fade. */
@keyframes term-enter-blur {
0% { opacity: 0; filter: blur(14px); transform: scale(1.008); }
45% { opacity: 1; }
100% { opacity: 1; filter: blur(0px); transform: none; }
}
/* ── Connection-line entrance animations ───────────────────────────────────
`--line-len` is the measured path length, stamped inline by
_applyLineEntrances(); `--line-enter-delay` is negative when an entrance is
@@ -1393,6 +1475,26 @@ html[data-line-anim="packet"] .connection-line.line-enter {
100% { stroke-dashoffset: calc(-1 * var(--line-len)); opacity: 0; }
}
/* Blur, the line focuses in alongside a blurred tab and window. `filter` on an
SVG path takes CSS filter functions, so the blur simply rides in front of the
line's own glow (see --line-glow on .connection-line).
⚠ The 100% frame deliberately omits `opacity`, which makes the browser take
the endpoint from the element's own computed value: a subagent line rests at
0.9, a lineage line at 0.72, and a WORKING lineage line at 0.95. Pinning 0.9
here - as `line-enter-fade` above still does - lands every lineage line on the
wrong opacity and snaps it when the class comes off. */
html[data-line-anim="blur"] .connection-line.line-enter {
animation-name: line-enter-blur;
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
animation-timing-function: cubic-bezier(0.32, 0.72, 0, 1);
}
@keyframes line-enter-blur {
0% { opacity: 0; filter: blur(5px) var(--line-glow); }
100% { filter: blur(0px) var(--line-glow); }
}
.anim-lab-check {
display: flex;
align-items: center;
@@ -9833,10 +9935,17 @@ kbd {
stroke-dasharray: 5 3;
fill: none;
opacity: 0.9;
/* Dark outline for contrast, vibrant blue glow */
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
drop-shadow(0 0 4px rgba(59, 130, 246, 0.8))
drop-shadow(0 0 8px rgba(59, 130, 246, 0.5));
/* Dark outline for contrast, vibrant blue glow. Held in a variable because the
`blur` line entrance animates `filter`: a keyframe listing only the blur would
drop the glow for the length of the run and pop it back at the end, and the
lineage lines below - whose glow is a different colour entirely, set per
element - make that obvious. Both of its keyframes say
`blur(N) var(--line-glow)`, so the function lists match and interpolate while
each kind of line keeps its own glow. */
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.8))
drop-shadow(0 0 4px rgba(59, 130, 246, 0.8))
drop-shadow(0 0 8px rgba(59, 130, 246, 0.5));
filter: var(--line-glow);
transition: opacity 0.2s, stroke-width 0.2s, filter 0.2s;
}
@@ -9930,8 +10039,10 @@ kbd {
stroke-dasharray: 5 5;
stroke-linecap: round;
opacity: 0.72;
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7)) drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
--line-glow: drop-shadow(0 0 2px rgba(0, 0, 0, 0.7))
drop-shadow(0 0 5px var(--lineage-color, var(--session-blue, #2b8fd9)))
drop-shadow(0 0 11px var(--lineage-color, var(--session-blue, #2b8fd9)));
filter: var(--line-glow);
}
/* ⚠ OUTSIDE the reduced-motion block below on purpose. A working child is the case
+174
View File
@@ -0,0 +1,174 @@
/**
* @fileoverview Static guards for the entrance-animation styles (App Settings →
* Appearance → Entrance Animations, plus the `?animlab=1` picker).
*
* A style is FOUR things that have to line up, and any one of them missing fails
* silently rather than loudly: the entry in the style array in
* entrance-animations.js (which is what the lab lists and what `_styleDuration`
* reads), the `html[data-*-anim="<key>"]` rule in styles.css, the @keyframes
* block that rule names, and — for a style that belongs to a theme — the theme's
* `<option>` in index.html. A style with no CSS behind it renders as "the
* animation silently does nothing"; a rule naming a keyframe block that does not
* exist behaves the same way.
*
* The terminal pane carries an extra rule of its own, and it is the one with
* teeth: xterm's FitAddon derives rows+cols from getComputedStyle(parent)
* .width/height, so a terminal keyframe that animates a box-model property would
* resize the PTY mid-animation. Only paint-level properties are allowed there.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
const animSource = readFileSync(resolve('src/web/public/entrance-animations.js'), 'utf8');
const stylesSource = readFileSync(resolve('src/web/public/styles.css'), 'utf8');
const indexSource = readFileSync(resolve('src/web/public/index.html'), 'utf8');
/** Surfaces, keyed by the `data-*-anim` attribute their styles are selected by. */
const SURFACES = [
{ attr: 'tab', array: 'TAB_ANIM_STYLES', selector: '.session-tab.tab-enter' },
{ 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' },
] as const;
/**
* Styles with no CSS of their own, by design: `off` means "do nothing" and `fly`
* is the pre-existing JS transition in subagent-windows.js, which deliberately
* skips the `win-enter` class entirely.
*/
const CSS_LESS_STYLES = new Set(['off', 'fly']);
function styleKeys(arrayName: string): string[] {
const start = animSource.indexOf(`const ${arrayName} = [`);
expect(start, `${arrayName} not found`).toBeGreaterThan(-1);
const body = animSource.slice(start, animSource.indexOf('];', start));
return [...body.matchAll(/\{ key: '([^']+)'/g)].map((m) => m[1]);
}
function themes(): { key: string; tab: string; win: string; line: string; term: string }[] {
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] }));
}
/**
* Every `animation-name:` a `html[data-<attr>-anim="<key>"]` block asks for,
* tagged with whether it runs on the element itself or on its ::before overlay.
* The distinction matters for the terminal: the FitAddon rule below binds to the
* container, while ::before is a throwaway wash that may animate anything.
*/
function animationNamesFor(attr: string, key: string): { name: string; onPseudo: boolean }[] {
const rules = [...stylesSource.matchAll(new RegExp(`html\\[data-${attr}-anim="${key}"\\]([^{]*)\\{([^}]*)\\}`, 'g'))];
return rules.flatMap((rule) =>
[...rule[2].matchAll(/animation-name:\s*([\w-]+);/g)].map((m) => ({
name: m[1],
onPseudo: rule[1].includes('::before'),
}))
);
}
function keyframeBody(name: string): string | null {
const start = stylesSource.indexOf(`@keyframes ${name} {`);
if (start === -1) return null;
return stylesSource.slice(start, stylesSource.indexOf('\n}', start));
}
describe('entrance animation styles', () => {
for (const surface of SURFACES) {
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)) {
expect(stylesSource).not.toContain(`html[data-${surface.attr}-anim="${key}"]`);
continue;
}
const names = animationNamesFor(surface.attr, key);
expect(names.length, `no animation-name for ${surface.attr}/${key}`).toBeGreaterThan(0);
for (const { name } of names) {
expect(keyframeBody(name), `@keyframes ${name} missing`).not.toBeNull();
}
// The style has to reach the element the surface actually animates,
// not just any selector carrying the attribute.
expect(stylesSource).toContain(`html[data-${surface.attr}-anim="${key}"] ${surface.selector}`);
}
});
});
}
it('ships the blur style on all four surfaces', () => {
for (const surface of SURFACES) expect(styleKeys(surface.array)).toContain('blur');
});
it('gives every theme an <option> and only styles that exist', () => {
for (const theme of themes()) {
expect(indexSource, `no <option value="${theme.key}">`).toContain(`<option value="${theme.key}">`);
for (const surface of SURFACES) {
expect(styleKeys(surface.array), `theme ${theme.key} names an unknown ${surface.attr} style`).toContain(
theme[surface.attr]
);
}
}
// 'custom' is a readout of a lab mix, never a theme you can select into.
expect(indexSource).toContain('<option value="custom">');
expect(themes().map((t) => t.key)).not.toContain('custom');
});
it('keeps every entrance under the reduced-motion kill switch', () => {
const start = stylesSource.indexOf('@media (prefers-reduced-motion: reduce) {\n .session-tab.tab-enter,');
expect(start, 'the entrance reduced-motion block moved or was renamed').toBeGreaterThan(-1);
const block = stylesSource.slice(
start,
stylesSource.indexOf('\n}', stylesSource.indexOf('animation: none', start))
);
for (const surface of SURFACES) expect(block).toContain(surface.selector);
});
/**
* ⚠ The FitAddon rule. It reads getComputedStyle(parent).width/height, i.e. the
* untransformed LAYOUT box, so paint-level properties are invisible to it and a
* box-model property here would resize the PTY mid-animation.
*/
it('animates only paint-level properties on the terminal pane', () => {
const allowed = new Set(['opacity', 'transform', 'clip-path', 'filter']);
for (const key of styleKeys('TERM_ANIM_STYLES')) {
if (CSS_LESS_STYLES.has(key)) continue;
for (const { name, onPseudo } of animationNamesFor('term', key)) {
if (onPseudo) continue; // a wash over the pane, it has no layout of its own
const body = keyframeBody(name);
expect(body).not.toBeNull();
for (const [, prop] of (body as string).matchAll(/(?:\{|;)\s*([a-z-]+):/g)) {
expect(allowed.has(prop), `@keyframes ${name} animates ${prop} on the terminal pane`).toBe(true);
}
}
}
});
/**
* The `blur` line entrance animates `filter`, and a keyframe listing only the
* blur would drop each line's own glow for the length of the run and pop it
* back at the end. Both frames say `blur(N) var(--line-glow)` so the function
* lists match and interpolate, which only works while both kinds of line
* actually define that variable.
*/
it('routes both kinds of connection line through --line-glow', () => {
for (const selector of ['.connection-line {', '.connection-line.lineage-line {']) {
const start = stylesSource.indexOf(selector);
expect(start, `${selector} not found`).toBeGreaterThan(-1);
const block = stylesSource.slice(start, stylesSource.indexOf('\n}', start));
expect(block, `${selector} must define --line-glow`).toContain('--line-glow:');
expect(block, `${selector} must apply it`).toContain('filter: var(--line-glow);');
}
const blur = keyframeBody('line-enter-blur') as string;
expect(blur).not.toBeNull();
expect(blur.match(/var\(--line-glow\)/g)?.length).toBe(2);
// The 100% frame deliberately omits opacity so the endpoint comes from the
// element's own resting value: 0.9 on a subagent line, 0.72 on a lineage
// line, 0.95 on a working one. Pinning a number here snaps three of them.
expect(blur).toMatch(/100%\s*\{\s*filter:[^}]*\}/);
expect(blur).not.toMatch(/100%\s*\{[^}]*opacity/);
});
});