mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39: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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
+114
-3
@@ -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))
|
||||
/* 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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user