mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 06:59:42 +02:00
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:
@@ -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' },
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user