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
+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