mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
feat(ui): opt-in entrance animations for tabs, terminal pane, agent windows and connection lines
All OFF by default (the `legacy` theme), so an untouched install behaves exactly as before and every mark/apply hook short-circuits on its first line. Opt in via App Settings > Appearance > Entrance Animations; per-surface control and a live preview lab at ?animlab=1. Surfaces and styles: - Tabs: slide, pop, crt, unroll, boot, flip. A batch launched together cascades by a configurable stagger. - Terminal pane: crt, boot, wipe, slide, fade. - Agent windows: fly (the pre-existing tab-to-window flight, still the default), crt, materialize, unfold, beam, pop. - Connection lines: draw, packet, fade. Three constraints drove the design: 1. Tabs and connection lines are DESTROYED mid-animation on every re-render: _fullRenderSessionTabs() replaces the strip's innerHTML and _updateConnectionLinesImmediate() does `svg.innerHTML = ''`, both of which run constantly while sessions and agents spawn. Each is tracked by id and re-applied to the fresh element with a NEGATIVE animation-delay so it resumes at the same offset instead of restarting or snapping. Verified on the real path: a forced rebuild mid-draw resumed at -0.243s. 2. Terminal-pane styles animate transform/opacity/clip-path ONLY. xterm's FitAddon derives rows+cols from getComputedStyle(parent).width/height, the untransformed layout box, so transforms are invisible to it; animating width/height/padding would have resized the PTY. Verified by forcing fitAddon.fit() eight times mid-animation: dimensions held at 178x38. 3. A window entrance that transforms also moves the rect its connection line aims at (crt drifts it 109px, pop 81px). `beam` animates opacity/filter only (0px drift) so its line can draw toward a stable target; the others refresh the lines on animationend. Also fixes: an agent window spawning hidden (its agent belongs to a background tab) is display:none, so its animation never runs and animationend never fires, which left the entrance class and its inline custom property stuck on the window permanently. Hidden windows now skip the entrance entirely. Styles persist to their own codeman:*Anim localStorage keys, keeping them per-device without touching the .strict() SettingsUpdateSchema. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -229,7 +229,9 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
|
||||
|
||||
### Frontend
|
||||
|
||||
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) → `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) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `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).
|
||||
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) → `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) → `ultracode-panel.js`(11.5) → `admin-ui.js`(11.7) → `session-ui.js`(12) → `webview-tabs.js`(12.5) → `entrance-animations.js`(12.6) → `ralph-wizard.js`(13) → `api-client.js`(14) → `subagent-windows.js`(15) → `ultracode-windows.js`(15.5) → `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`.
|
||||
|
||||
**Command palette + shortcut registry**: `Ctrl/Cmd/Alt+K` opens the session palette; shortcuts live in a rebindable registry (`DEFAULT_SHORTCUTS`/`getShortcutRegistry()`/`matchesShortcutEvent()` in app.js, overrides in `settings.shortcutOverrides`). ⚠️ Palette-chord keys must ALSO be swallowed in `attachCustomKeyEventHandler` (terminal-ui.js) or xterm writes the control byte (0x0B) into the PTY. ⚠️ `saveAppSettings()` rebuilds settings from the DOM, so keys edited elsewhere (`shortcutOverrides`, `showTokenCount`, `showCost`) need explicit `_prev` carry-over. → [architecture-invariants#command-palette-and-shortcut-registry](docs/architecture-invariants.md#command-palette-and-shortcut-registry)
|
||||
|
||||
|
||||
@@ -800,6 +800,10 @@ class CodemanApp {
|
||||
this.applyLocalization();
|
||||
this.applyTabWrapSettings();
|
||||
this.applyMonitorVisibility();
|
||||
// Must run before the first session:created can arrive: markSessionTabEntering()
|
||||
// ignores ids until this sets up its state, which is what keeps the tabs
|
||||
// restored on page load from animating.
|
||||
this.initEntranceAnimations?.();
|
||||
// Remove mobile-init class now that JS has applied visibility settings.
|
||||
// The inline <script> in <head> added this to prevent flash-of-content on mobile.
|
||||
document.documentElement.classList.remove('mobile-init');
|
||||
@@ -1563,6 +1567,12 @@ class CodemanApp {
|
||||
this.sessionOrder.push(data.id);
|
||||
this.saveSessionOrder();
|
||||
}
|
||||
// Idempotent per id: the POST response and the session:created event both
|
||||
// land here, and a batch launched together cascades in creation order.
|
||||
this.markSessionTabEntering?.(data.id);
|
||||
// The pane is one shared element, so it is only marked here and played when
|
||||
// this session is actually selected (see selectSession).
|
||||
this.markTerminalEntering?.(data.id);
|
||||
this.renderSessionTabs();
|
||||
this.updateCost();
|
||||
// Start stats polling when first session appears
|
||||
@@ -3443,6 +3453,9 @@ class CodemanApp {
|
||||
}
|
||||
|
||||
this.updateTabOverflowMode();
|
||||
// After the wrap measurement: the `unroll` style starts tabs at max-width 0,
|
||||
// so measuring mid-animation would decide the wrap on collapsed widths.
|
||||
this._applyTabEntrances?.();
|
||||
}
|
||||
|
||||
// Auto-wrap desktop session tabs to a second row when they overflow one row,
|
||||
@@ -3569,6 +3582,9 @@ class CodemanApp {
|
||||
// toggle (applyTabWrapSettings calls this) which would otherwise leave a stale
|
||||
// tabs-auto-wrap class until the next content render.
|
||||
this.updateTabOverflowMode();
|
||||
// Newly created tabs animate in; a re-render mid-cascade resumes them rather
|
||||
// than restarting, since this rebuild just destroyed the animating elements.
|
||||
this._applyTabEntrances?.();
|
||||
}
|
||||
|
||||
// Set up arrow key navigation for session tabs (accessibility)
|
||||
@@ -4100,6 +4116,10 @@ class CodemanApp {
|
||||
// selectSession or reconnect catches up.
|
||||
this._updateSseSubscription(sessionId);
|
||||
this.hideWelcome();
|
||||
// Terminal-pane entrance: plays for a freshly created session, and on every
|
||||
// switch when that option is on. Transform/opacity/clip-path only, xterm's
|
||||
// FitAddon reads the untransformed layout box, so this cannot reach the PTY.
|
||||
this.playTerminalEntrance?.(sessionId);
|
||||
// Clear idle hooks on view, but keep action hooks until user interacts
|
||||
this.clearPendingHooks(sessionId, 'idle_prompt');
|
||||
// Instant active-class toggle (no 100ms debounce), then schedule full render for badges/status
|
||||
|
||||
@@ -0,0 +1,754 @@
|
||||
/**
|
||||
* @fileoverview Entrance animations for the four things that appear when work
|
||||
* starts: session TABS, the main TERMINAL pane a session's CLI runs in, floating
|
||||
* agent WINDOWS, and the CONNECTION LINES tying a window back to its parent tab.
|
||||
* One picker per surface, plus themes that set all four to a matching look.
|
||||
*
|
||||
* Everything is OFF by default (the `legacy` theme), so an untouched install
|
||||
* behaves exactly as it did before this module existed. Opt in via App Settings
|
||||
* → Appearance → Entrance Animations.
|
||||
*
|
||||
* Four constraints shape the design:
|
||||
*
|
||||
* 1. `_fullRenderSessionTabs()` replaces the tab strip's entire innerHTML, and
|
||||
* `_updateConnectionLinesImmediate()` does `svg.innerHTML = ''` and rebuilds
|
||||
* every path. Both run constantly while sessions and agents are spawning, so
|
||||
* an animating tab or line element is DESTROYED mid-flight. Those two are
|
||||
* therefore tracked by id in `_tabEnterActive` / `_lineEnterActive` and
|
||||
* re-applied to the fresh element with a NEGATIVE animation-delay, resuming at
|
||||
* the same offset instead of restarting or snapping to the end. Windows are
|
||||
* stable DOM and need none of this.
|
||||
* 2. Connection-line geometry comes from `getBoundingClientRect()` on the window.
|
||||
* A window entrance that starts with a transform would move that rect, so the
|
||||
* `beam` style (which must hold still while its line draws toward it) animates
|
||||
* opacity and filter only. Every other style refreshes the lines when it ends.
|
||||
* 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).
|
||||
* 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
|
||||
* `_onSessionCreated`.
|
||||
*
|
||||
* Styles are selected by `data-tab-anim` / `data-term-anim` / `data-win-anim` /
|
||||
* `data-line-anim` on <html>; the keyframes live in styles.css. `?animlab=1`
|
||||
* opens a floating picker that fakes tabs, a pane replay, a window and a line,
|
||||
* so styles can be compared without spawning real sessions or agents.
|
||||
*
|
||||
* @mixin Extends CodemanApp.prototype via Object.assign
|
||||
* @dependency app.js (tab render pipeline), subagent-windows.js (window + line hooks)
|
||||
* @dependency constants.js (escapeHtml)
|
||||
* @loadorder 12.6 of 16, after webview-tabs.js, before ralph-wizard.js
|
||||
*/
|
||||
|
||||
/** Tab entrance styles. `key` doubles as the `data-tab-anim` value. */
|
||||
const TAB_ANIM_STYLES = [
|
||||
{ key: 'slide', label: 'Slide', blurb: 'Drifts in from the right.', duration: 380 },
|
||||
{ key: 'pop', label: 'Pop', blurb: 'Springs past full size, then settles.', duration: 460 },
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Snaps open as a hot line, then unfolds.', duration: 520 },
|
||||
{ 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: 'off', label: 'Off', blurb: 'Tabs just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Window entrance styles. `fly` is the pre-existing behaviour (the window flies
|
||||
* out of its parent tab via a JS transition in subagent-windows.js); every other
|
||||
* style positions the window at its resting spot and runs a CSS animation there.
|
||||
*/
|
||||
const WIN_ANIM_STYLES = [
|
||||
{ key: 'fly', label: 'Fly from tab', blurb: 'Current behaviour: flies out of the tab, scaling up.', duration: 400 },
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Bursts open as a hot line, then unfolds vertically.', duration: 560 },
|
||||
{ key: 'materialize', label: 'Materialize', blurb: 'Resolves out of a blur with a short glitch.', duration: 620 },
|
||||
{ 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: 'off', label: 'Off', blurb: 'Windows just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
/** Connection-line entrance styles. `key` doubles as the `data-line-anim` value. */
|
||||
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: 'off', label: 'Off', blurb: 'Lines just appear.', duration: 0 },
|
||||
];
|
||||
|
||||
/**
|
||||
* Main-terminal entrance styles: the pane a session's CLI actually runs in.
|
||||
*
|
||||
* ⚠ These may only animate transform, opacity and clip-path. xterm's FitAddon
|
||||
* derives rows/cols from `getComputedStyle(parent).width/height`, which reports
|
||||
* the untransformed layout box, so transforms are safe, but animating width,
|
||||
* height or padding would feed wrong dimensions into `resize()` and through to
|
||||
* the PTY. Colour washes go on `.terminal-container::before`, never a `filter`
|
||||
* on the container: that would blur a full-screen WebGL canvas every frame.
|
||||
*/
|
||||
const TERM_ANIM_STYLES = [
|
||||
{ key: 'crt', label: 'CRT', blurb: 'Power-on: a hot line that expands to full height.', duration: 560 },
|
||||
{ key: 'boot', label: 'Boot', blurb: 'Flickers on under a green scan sweep.', duration: 760 },
|
||||
{ key: 'wipe', label: 'Wipe', blurb: 'Reveals top-to-bottom behind a bright edge.', duration: 520 },
|
||||
{ key: 'slide', label: 'Slide up', blurb: 'Rises into place from below.', duration: 420 },
|
||||
{ key: 'fade', label: 'Fade', blurb: 'Quiet fade with a touch of scale.', duration: 340 },
|
||||
{ key: 'off', label: 'Off', blurb: 'Current behaviour: the pane just appears.', duration: 0 },
|
||||
];
|
||||
|
||||
/** How long a `beam` window waits before materializing. Just under the line draw. */
|
||||
const BEAM_HOLD_MS = 360;
|
||||
|
||||
/** One-click combinations that read as a single look. */
|
||||
const ANIM_THEMES = [
|
||||
{ key: 'terminal', label: 'Terminal', tab: 'crt', win: 'crt', line: 'draw', term: 'crt' },
|
||||
{ key: 'beamdown', label: 'Beam down', tab: 'crt', win: 'beam', line: 'draw', term: 'wipe' },
|
||||
{ key: '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' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Defaults are the `legacy` theme: every entrance OFF, and agent windows on the
|
||||
* `fly` behaviour Codeman already had before this module existed. So a user who
|
||||
* never opens the picker sees exactly the pre-existing UI, and each mark/apply
|
||||
* hook short-circuits on its first line. Opt in via App Settings → Appearance →
|
||||
* Entrance Animations, which persists to the localStorage keys below.
|
||||
*/
|
||||
const TAB_ANIM_DEFAULT = 'off';
|
||||
const WIN_ANIM_DEFAULT = 'fly';
|
||||
const LINE_ANIM_DEFAULT = 'off';
|
||||
const TERM_ANIM_DEFAULT = 'off';
|
||||
const TAB_ANIM_STAGGER_DEFAULT = 90;
|
||||
/** A new id joins the current cascade if it arrives within this of the last one. */
|
||||
const TAB_ANIM_BATCH_WINDOW_MS = 600;
|
||||
|
||||
const ANIM_KEYS = {
|
||||
tab: 'codeman:tabAnim',
|
||||
win: 'codeman:winAnim',
|
||||
line: 'codeman:lineAnim',
|
||||
term: 'codeman:termAnim',
|
||||
termSwitch: 'codeman:termAnimOnSwitch',
|
||||
stagger: 'codeman:tabAnimStagger',
|
||||
speed: 'codeman:tabAnimSpeed',
|
||||
};
|
||||
|
||||
Object.assign(CodemanApp.prototype, {
|
||||
// ── Setup ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve every style/timing and stamp them on <html>. Called once at startup. */
|
||||
initEntranceAnimations() {
|
||||
this._tabEnterSeen = new Set();
|
||||
this._tabEnterActive = new Map();
|
||||
this._lineEnterActive = new Map();
|
||||
this._tabEnterBatchIndex = 0;
|
||||
this._tabEnterLastMarkTs = 0;
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
// ?tabanim= / ?winanim= / ?lineanim= win for a single load, so a style can be
|
||||
// tried without persisting over whatever is saved.
|
||||
const pick = (param, styles, storeKey, fallback) => {
|
||||
const fromUrl = params.get(param);
|
||||
if (styles.some((s) => s.key === fromUrl)) return fromUrl;
|
||||
return this._animRead(storeKey, fallback);
|
||||
};
|
||||
|
||||
this.setTabAnimStyle(pick('tabanim', TAB_ANIM_STYLES, ANIM_KEYS.tab, TAB_ANIM_DEFAULT), { persist: false });
|
||||
this.setWinAnimStyle(pick('winanim', WIN_ANIM_STYLES, ANIM_KEYS.win, WIN_ANIM_DEFAULT), { persist: false });
|
||||
this.setLineAnimStyle(pick('lineanim', LINE_ANIM_STYLES, ANIM_KEYS.line, LINE_ANIM_DEFAULT), { persist: false });
|
||||
this.setTermAnimStyle(pick('termanim', TERM_ANIM_STYLES, ANIM_KEYS.term, TERM_ANIM_DEFAULT), { persist: false });
|
||||
this.setTermAnimOnSwitch(this._animRead(ANIM_KEYS.termSwitch, '0') === '1', { persist: false });
|
||||
|
||||
this.setTabAnimStagger(Number(this._animRead(ANIM_KEYS.stagger, TAB_ANIM_STAGGER_DEFAULT)), { persist: false });
|
||||
this.setAnimSpeed(Number(this._animRead(ANIM_KEYS.speed, 1)), { persist: false });
|
||||
|
||||
if (params.get('animlab') === '1' || params.get('tabanimlab') === '1') this.openAnimLab();
|
||||
},
|
||||
|
||||
_animRead(key, fallback) {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
return raw === null || raw === '' ? fallback : raw;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
},
|
||||
|
||||
_animWrite(key, value) {
|
||||
try {
|
||||
localStorage.setItem(key, String(value));
|
||||
} catch {
|
||||
/* private mode / quota, the in-memory value still applies for this load */
|
||||
}
|
||||
},
|
||||
|
||||
_setAnimStyle(prop, key, styles, fallback, attr, storeKey, persist) {
|
||||
const style = styles.some((s) => s.key === key) ? key : fallback;
|
||||
this[prop] = style;
|
||||
document.documentElement.setAttribute(attr, style);
|
||||
if (persist) this._animWrite(storeKey, style);
|
||||
// Keep the lab's highlight honest when a style is set from anywhere other
|
||||
// than the lab's own buttons (a theme, a URL param, the console).
|
||||
this._syncAnimLab?.();
|
||||
},
|
||||
|
||||
setTabAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_tabAnimStyle', key, TAB_ANIM_STYLES, TAB_ANIM_DEFAULT, 'data-tab-anim', ANIM_KEYS.tab, persist);
|
||||
},
|
||||
|
||||
setWinAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_winAnimStyle', key, WIN_ANIM_STYLES, WIN_ANIM_DEFAULT, 'data-win-anim', ANIM_KEYS.win, persist);
|
||||
},
|
||||
|
||||
setLineAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_lineAnimStyle', key, LINE_ANIM_STYLES, LINE_ANIM_DEFAULT, 'data-line-anim', ANIM_KEYS.line, persist);
|
||||
},
|
||||
|
||||
setTermAnimStyle(key, { persist = true } = {}) {
|
||||
// prettier-ignore
|
||||
this._setAnimStyle('_termAnimStyle', key, TERM_ANIM_STYLES, TERM_ANIM_DEFAULT, 'data-term-anim', ANIM_KEYS.term, persist);
|
||||
},
|
||||
|
||||
/** Replay the terminal entrance on every tab switch, not just on a new session. */
|
||||
setTermAnimOnSwitch(on, { persist = true } = {}) {
|
||||
this._termAnimOnSwitch = !!on;
|
||||
if (persist) this._animWrite(ANIM_KEYS.termSwitch, on ? '1' : '0');
|
||||
this._syncAnimLab?.();
|
||||
},
|
||||
|
||||
/** Apply a theme: one look across tabs, windows, lines and the terminal. */
|
||||
setAnimTheme(themeKey) {
|
||||
const theme = ANIM_THEMES.find((t) => t.key === themeKey);
|
||||
if (!theme) return;
|
||||
this.setTabAnimStyle(theme.tab);
|
||||
this.setWinAnimStyle(theme.win);
|
||||
this.setLineAnimStyle(theme.line);
|
||||
this.setTermAnimStyle(theme.term);
|
||||
this._syncEntranceAnimSetting?.();
|
||||
},
|
||||
|
||||
/** The theme matching the four current styles, or 'custom' for a lab mix. */
|
||||
currentAnimTheme() {
|
||||
const match = ANIM_THEMES.find(
|
||||
(t) =>
|
||||
t.tab === this._tabAnimStyle &&
|
||||
t.win === this._winAnimStyle &&
|
||||
t.line === this._lineAnimStyle &&
|
||||
t.term === this._termAnimStyle
|
||||
);
|
||||
return match ? match.key : 'custom';
|
||||
},
|
||||
|
||||
// ── App Settings picker ───────────────────────────────────────────────────
|
||||
//
|
||||
// Wired straight to setAnimTheme() rather than through saveAppSettings(): the
|
||||
// styles live in their own localStorage keys, so they stay per-device and never
|
||||
// reach `PUT /api/settings`, whose schema is .strict() and would reject them.
|
||||
|
||||
_syncEntranceAnimSetting() {
|
||||
const sel = document.getElementById('appSettingsEntranceAnim');
|
||||
if (!sel) return;
|
||||
sel.value = this.currentAnimTheme();
|
||||
if (!sel.dataset.bound) {
|
||||
sel.dataset.bound = '1';
|
||||
sel.addEventListener('change', () => {
|
||||
// 'custom' is a readout of a lab mix, not something you can select into.
|
||||
if (sel.value === 'custom') sel.value = this.currentAnimTheme();
|
||||
else this.setAnimTheme(sel.value);
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
setTabAnimStagger(ms, { persist = true } = {}) {
|
||||
const value = Number.isFinite(ms) ? Math.max(0, Math.min(400, Math.round(ms))) : TAB_ANIM_STAGGER_DEFAULT;
|
||||
this._tabAnimStagger = value;
|
||||
if (persist) this._animWrite(ANIM_KEYS.stagger, value);
|
||||
},
|
||||
|
||||
setAnimSpeed(multiplier, { persist = true } = {}) {
|
||||
const value = Number.isFinite(multiplier) ? Math.max(0.25, Math.min(3, multiplier)) : 1;
|
||||
this._animSpeed = value;
|
||||
// Every keyframe block reads this, so one variable retimes all of them.
|
||||
document.documentElement.style.setProperty('--anim-enter-scale', String(1 / value));
|
||||
if (persist) this._animWrite(ANIM_KEYS.speed, value);
|
||||
},
|
||||
|
||||
_styleDuration(styles, key) {
|
||||
return (styles.find((s) => s.key === key)?.duration || 0) / (this._animSpeed || 1);
|
||||
},
|
||||
|
||||
_tabAnimDuration() {
|
||||
return this._styleDuration(TAB_ANIM_STYLES, this._tabAnimStyle);
|
||||
},
|
||||
|
||||
_winAnimDuration() {
|
||||
return this._styleDuration(WIN_ANIM_STYLES, this._winAnimStyle);
|
||||
},
|
||||
|
||||
_lineAnimDuration() {
|
||||
return this._styleDuration(LINE_ANIM_STYLES, this._lineAnimStyle);
|
||||
},
|
||||
|
||||
_termAnimDuration() {
|
||||
return this._styleDuration(TERM_ANIM_STYLES, this._termAnimStyle);
|
||||
},
|
||||
|
||||
// ── Tabs ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Queue a session id to animate on its next render. Idempotent per id. */
|
||||
markSessionTabEntering(id) {
|
||||
if (!id || this._tabAnimStyle === 'off') return;
|
||||
if (!this._tabEnterSeen) return; // initEntranceAnimations() has not run yet
|
||||
if (this._tabEnterSeen.has(id)) return;
|
||||
this._tabEnterSeen.add(id);
|
||||
|
||||
const now = performance.now();
|
||||
// A launch landing well after the previous one starts its own cascade rather
|
||||
// than inheriting a large stale offset.
|
||||
if (now - this._tabEnterLastMarkTs > TAB_ANIM_BATCH_WINDOW_MS) this._tabEnterBatchIndex = 0;
|
||||
this._tabEnterLastMarkTs = now;
|
||||
|
||||
this._tabEnterActive.set(id, {
|
||||
startTs: now,
|
||||
staggerMs: this._tabEnterBatchIndex * this._tabAnimStagger,
|
||||
});
|
||||
this._tabEnterBatchIndex += 1;
|
||||
},
|
||||
|
||||
/** Attach (or resume) the entrance animation on freshly rendered tabs. */
|
||||
_applyTabEntrances() {
|
||||
const active = this._tabEnterActive;
|
||||
if (!active || active.size === 0) return;
|
||||
if (this._tabAnimStyle === 'off') {
|
||||
active.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return;
|
||||
|
||||
const now = performance.now();
|
||||
const duration = this._tabAnimDuration();
|
||||
|
||||
for (const [id, state] of active) {
|
||||
const elapsed = now - state.startTs;
|
||||
// Ran to completion while the element was detached, nothing left to show.
|
||||
if (elapsed > state.staggerMs + duration + 50) {
|
||||
active.delete(id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const tab = container.querySelector(`.session-tab[data-id="${CSS.escape(id)}"]`);
|
||||
if (!tab) continue; // not rendered yet; a later render picks it up
|
||||
// Already running on this element. Re-stamping the delay would jump it, and
|
||||
// the incremental render path can reach here for the same element.
|
||||
if (tab.classList.contains('tab-enter')) continue;
|
||||
|
||||
// Negative delay resumes mid-animation, so an unrelated re-render mid-cascade
|
||||
// does not restart the tab or make it snap.
|
||||
tab.style.setProperty('--tab-enter-delay', `${state.staggerMs - elapsed}ms`);
|
||||
tab.classList.add('tab-enter');
|
||||
const done = () => {
|
||||
tab.classList.remove('tab-enter');
|
||||
tab.style.removeProperty('--tab-enter-delay');
|
||||
this._tabEnterActive?.delete(id);
|
||||
};
|
||||
tab.addEventListener('animationend', done, { once: true });
|
||||
tab.addEventListener('animationcancel', done, { once: true });
|
||||
}
|
||||
},
|
||||
|
||||
// ── Main terminal ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Queue the terminal pane to animate the first time this session is shown.
|
||||
* Marked at creation but PLAYED at selection, because the pane is one shared
|
||||
* element: a session created in the background must not animate the pane the
|
||||
* user is currently looking at.
|
||||
*/
|
||||
markTerminalEntering(id) {
|
||||
if (!id || this._termAnimStyle === 'off') return;
|
||||
if (!this._termEnterPending) this._termEnterPending = new Set();
|
||||
this._termEnterPending.add(id);
|
||||
},
|
||||
|
||||
/** Play the terminal entrance for `sessionId`, if it is owed one. */
|
||||
playTerminalEntrance(sessionId) {
|
||||
const style = this._termAnimStyle || TERM_ANIM_DEFAULT;
|
||||
if (style === 'off') return;
|
||||
|
||||
const owed = sessionId && this._termEnterPending?.delete(sessionId);
|
||||
if (!owed && !this._termAnimOnSwitch) return;
|
||||
|
||||
const el = this.$('terminalContainer');
|
||||
if (!el) return;
|
||||
|
||||
// Restart cleanly when switching tabs faster than the animation runs.
|
||||
el.classList.remove('term-enter');
|
||||
void el.offsetWidth;
|
||||
el.classList.add('term-enter');
|
||||
|
||||
clearTimeout(this._termEnterTimer);
|
||||
const done = () => {
|
||||
el.classList.remove('term-enter');
|
||||
clearTimeout(this._termEnterTimer);
|
||||
};
|
||||
el.addEventListener('animationend', done, { once: true });
|
||||
el.addEventListener('animationcancel', done, { once: true });
|
||||
// Backstop: a backgrounded tab never fires animationend, which would leave
|
||||
// the pane stuck at its 0% keyframe (invisible) when you come back to it.
|
||||
this._termEnterTimer = setTimeout(done, this._termAnimDuration() + 900);
|
||||
},
|
||||
|
||||
// ── Windows ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* True when the window should be parked on its parent tab and flown to its
|
||||
* resting spot by subagent-windows.js. False for every CSS-animated style,
|
||||
* which needs the window to start at its final position.
|
||||
*/
|
||||
windowEntranceFliesFromTab() {
|
||||
return (this._winAnimStyle || WIN_ANIM_DEFAULT) === 'fly';
|
||||
},
|
||||
|
||||
/**
|
||||
* Run the window entrance on an already-positioned window.
|
||||
* @param {HTMLElement} win a .subagent-window or .ultracode-window
|
||||
*/
|
||||
applyWindowEntrance(win) {
|
||||
if (!win) return;
|
||||
const style = this._winAnimStyle || WIN_ANIM_DEFAULT;
|
||||
if (style === 'off' || style === 'fly') return;
|
||||
|
||||
// `beam` holds the window still (opacity/filter only) while its line draws
|
||||
// toward it, then materializes. Everything else starts immediately.
|
||||
if (style === 'beam') win.style.setProperty('--win-enter-delay', `${BEAM_HOLD_MS / (this._animSpeed || 1)}ms`);
|
||||
|
||||
win.classList.add('win-enter');
|
||||
const done = () => {
|
||||
win.classList.remove('win-enter');
|
||||
win.style.removeProperty('--win-enter-delay');
|
||||
// A transformed window reports a transformed rect, so lines drawn while it
|
||||
// was animating are slightly off. Redraw once it has settled.
|
||||
this.updateConnectionLines?.();
|
||||
};
|
||||
win.addEventListener('animationend', done, { once: true });
|
||||
win.addEventListener('animationcancel', done, { once: true });
|
||||
},
|
||||
|
||||
// ── Connection lines ──────────────────────────────────────────────────────
|
||||
|
||||
/** Queue an agent's connection line to draw itself on the next line rebuild. */
|
||||
markConnectionLineEntering(agentId) {
|
||||
if (!agentId || this._lineAnimStyle === 'off') return;
|
||||
if (!this._lineEnterActive) return;
|
||||
if (this._lineEnterActive.has(agentId)) return;
|
||||
this._lineEnterActive.set(agentId, { startTs: performance.now() });
|
||||
},
|
||||
|
||||
/**
|
||||
* Attach (or resume) the draw-in animation on freshly rebuilt paths. Called at
|
||||
* the end of `_updateConnectionLinesImmediate()`, which has just thrown away
|
||||
* and recreated every path element.
|
||||
*/
|
||||
_applyLineEntrances(svg) {
|
||||
const active = this._lineEnterActive;
|
||||
if (!active || active.size === 0 || !svg) return;
|
||||
const style = this._lineAnimStyle || LINE_ANIM_DEFAULT;
|
||||
if (style === 'off') {
|
||||
active.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const now = performance.now();
|
||||
const duration = this._lineAnimDuration();
|
||||
|
||||
for (const [agentId, state] of active) {
|
||||
const elapsed = now - state.startTs;
|
||||
if (elapsed > duration + 50) {
|
||||
active.delete(agentId);
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = svg.querySelector(`path[data-agent-id="${CSS.escape(agentId)}"]`);
|
||||
if (!path) continue;
|
||||
|
||||
const len = Math.max(1, Math.round(path.getTotalLength()));
|
||||
path.style.setProperty('--line-len', `${len}px`);
|
||||
path.style.setProperty('--line-enter-delay', `${-elapsed}ms`);
|
||||
path.classList.add('line-enter');
|
||||
|
||||
// `packet` rides a bright dash ON TOP of the normal dashed line, so the base
|
||||
// line keeps its look instead of being taken over by the animation.
|
||||
if (style === 'packet') {
|
||||
const packet = path.cloneNode(false);
|
||||
packet.removeAttribute('data-agent-id');
|
||||
packet.setAttribute('class', 'connection-line-packet');
|
||||
packet.style.setProperty('--line-len', `${len}px`);
|
||||
packet.style.setProperty('--line-enter-delay', `${-elapsed}ms`);
|
||||
svg.appendChild(packet);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// ── Lab (compare styles without spawning sessions or agents) ───────────────
|
||||
|
||||
/** Floating picker: switch styles per surface and replay fake entrances. */
|
||||
openAnimLab() {
|
||||
if (document.getElementById('animLab')) return;
|
||||
|
||||
const group = (title, styles, attr) => `
|
||||
<div class="anim-lab-group">
|
||||
<div class="anim-lab-group-title">${title}</div>
|
||||
${styles
|
||||
.map(
|
||||
(s) => `<button type="button" class="anim-lab-style" data-attr="${attr}" data-style="${s.key}">
|
||||
<strong>${escapeHtml(s.label)}</strong><em>${escapeHtml(s.blurb)}</em>
|
||||
</button>`
|
||||
)
|
||||
.join('')}
|
||||
</div>`;
|
||||
|
||||
const panel = document.createElement('div');
|
||||
panel.id = 'animLab';
|
||||
panel.className = 'anim-lab';
|
||||
panel.innerHTML = `
|
||||
<div class="anim-lab-head">
|
||||
<span>Entrance lab</span>
|
||||
<button type="button" class="anim-lab-close" aria-label="Close">×</button>
|
||||
</div>
|
||||
<div class="anim-lab-themes">
|
||||
${ANIM_THEMES.map((t) => `<button type="button" data-theme="${t.key}">${escapeHtml(t.label)}</button>`).join('')}
|
||||
</div>
|
||||
<div class="anim-lab-scroll">
|
||||
${group('Tabs', TAB_ANIM_STYLES, 'tab')}
|
||||
${group('Terminal pane', TERM_ANIM_STYLES, 'term')}
|
||||
<label class="anim-lab-check">
|
||||
<input type="checkbox" data-check="termSwitch"> Also on every tab switch
|
||||
</label>
|
||||
${group('Agent windows', WIN_ANIM_STYLES, 'win')}
|
||||
${group('Connection lines', LINE_ANIM_STYLES, 'line')}
|
||||
</div>
|
||||
<label class="anim-lab-range">Tab stagger <output data-out="stagger"></output>
|
||||
<input type="range" data-range="stagger" min="0" max="260" step="10">
|
||||
</label>
|
||||
<label class="anim-lab-range">Speed <output data-out="speed"></output>
|
||||
<input type="range" data-range="speed" min="0.5" max="2" step="0.1">
|
||||
</label>
|
||||
<div class="anim-lab-demo">
|
||||
<span>Replay</span>
|
||||
<button type="button" data-demo="tabs">Tabs</button>
|
||||
<button type="button" data-demo="term">Pane</button>
|
||||
<button type="button" data-demo="window">Window</button>
|
||||
<button type="button" data-demo="all">All</button>
|
||||
</div>
|
||||
<p class="anim-lab-hint">Fake tabs, window and line, removed after the run. Real launches use the same timing.</p>
|
||||
`;
|
||||
document.body.appendChild(panel);
|
||||
|
||||
panel.querySelector('.anim-lab-close').addEventListener('click', () => this.closeAnimLab());
|
||||
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
this.setAnimTheme(btn.dataset.theme);
|
||||
this._syncAnimLab();
|
||||
this.demoEntrance('all');
|
||||
});
|
||||
});
|
||||
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
|
||||
btn.addEventListener('click', () => {
|
||||
const { attr, style } = btn.dataset;
|
||||
if (attr === 'tab') this.setTabAnimStyle(style);
|
||||
else if (attr === 'win') this.setWinAnimStyle(style);
|
||||
else if (attr === 'term') this.setTermAnimStyle(style);
|
||||
else this.setLineAnimStyle(style);
|
||||
this._syncAnimLab();
|
||||
this.demoEntrance({ tab: 'tabs', term: 'term' }[attr] || 'all');
|
||||
});
|
||||
});
|
||||
panel.querySelector('input[data-check="termSwitch"]').addEventListener('change', (e) => {
|
||||
this.setTermAnimOnSwitch(e.target.checked);
|
||||
});
|
||||
panel.querySelectorAll('input[data-range]').forEach((input) => {
|
||||
input.addEventListener('input', () => {
|
||||
if (input.dataset.range === 'stagger') this.setTabAnimStagger(Number(input.value));
|
||||
else this.setAnimSpeed(Number(input.value));
|
||||
this._syncAnimLab();
|
||||
});
|
||||
input.addEventListener('change', () => this.demoEntrance('all'));
|
||||
});
|
||||
panel.querySelectorAll('button[data-demo]').forEach((btn) => {
|
||||
btn.addEventListener('click', () => this.demoEntrance(btn.dataset.demo));
|
||||
});
|
||||
|
||||
this._syncAnimLab();
|
||||
},
|
||||
|
||||
closeAnimLab() {
|
||||
document.getElementById('animLab')?.remove();
|
||||
this._clearEntranceDemo();
|
||||
},
|
||||
|
||||
_syncAnimLab() {
|
||||
const panel = document.getElementById('animLab');
|
||||
if (!panel) return;
|
||||
const current = {
|
||||
tab: this._tabAnimStyle,
|
||||
win: this._winAnimStyle,
|
||||
line: this._lineAnimStyle,
|
||||
term: this._termAnimStyle,
|
||||
};
|
||||
panel.querySelectorAll('.anim-lab-style').forEach((btn) => {
|
||||
btn.classList.toggle('selected', current[btn.dataset.attr] === btn.dataset.style);
|
||||
});
|
||||
panel.querySelectorAll('button[data-theme]').forEach((btn) => {
|
||||
const t = ANIM_THEMES.find((x) => x.key === btn.dataset.theme);
|
||||
btn.classList.toggle('selected', !!t && ['tab', 'win', 'line', 'term'].every((k) => t[k] === current[k]));
|
||||
});
|
||||
const check = panel.querySelector('input[data-check="termSwitch"]');
|
||||
if (check) check.checked = !!this._termAnimOnSwitch;
|
||||
panel.querySelector('input[data-range="stagger"]').value = String(this._tabAnimStagger);
|
||||
panel.querySelector('output[data-out="stagger"]').textContent = `${this._tabAnimStagger}ms`;
|
||||
panel.querySelector('input[data-range="speed"]').value = String(this._animSpeed);
|
||||
panel.querySelector('output[data-out="speed"]').textContent = `${this._animSpeed.toFixed(1)}x`;
|
||||
},
|
||||
|
||||
_clearEntranceDemo() {
|
||||
clearTimeout(this._animDemoTimer);
|
||||
clearTimeout(this._animDemoWindowTimer);
|
||||
document.querySelectorAll('.session-tab[data-demo]').forEach((el) => el.remove());
|
||||
document.querySelectorAll('.subagent-window[data-demo]').forEach((el) => el.remove());
|
||||
document.getElementById('animLabLines')?.remove();
|
||||
},
|
||||
|
||||
/**
|
||||
* The demo line gets its OWN svg overlay rather than sharing #connectionLines.
|
||||
* That overlay is rebuilt from real windows via `svg.innerHTML = ''`, so a fake
|
||||
* path dropped into it is erased the moment anything triggers a redraw -
|
||||
* including the demo window's own entrance finishing.
|
||||
*/
|
||||
_demoLineSvg() {
|
||||
let svg = document.getElementById('animLabLines');
|
||||
if (!svg) {
|
||||
svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||
svg.id = 'animLabLines';
|
||||
svg.setAttribute('class', 'connection-lines-svg');
|
||||
document.body.appendChild(svg);
|
||||
}
|
||||
return svg;
|
||||
},
|
||||
|
||||
/** @param {'tabs'|'term'|'window'|'all'} what */
|
||||
demoEntrance(what = 'all') {
|
||||
this._clearEntranceDemo();
|
||||
|
||||
// The pane is a real, shared element rather than a throwaway, so replay it
|
||||
// through the same entry point a real launch uses (bypassing the owed-id
|
||||
// check, which only exists to keep background sessions from hijacking it).
|
||||
if (what === 'term' || what === 'all') {
|
||||
const wasOnSwitch = this._termAnimOnSwitch;
|
||||
this._termAnimOnSwitch = true;
|
||||
this.playTerminalEntrance(null);
|
||||
this._termAnimOnSwitch = wasOnSwitch;
|
||||
}
|
||||
if (what === 'term') return;
|
||||
|
||||
const tabCount = what === 'window' ? 1 : 4;
|
||||
const tabs = this._demoTabs(tabCount);
|
||||
let total = Math.max(this._tabAnimDuration() + tabCount * this._tabAnimStagger, this._termAnimDuration());
|
||||
|
||||
if (what !== 'tabs') {
|
||||
// Give the tab cascade a beat, so the window reads as coming out of it.
|
||||
const lead = what === 'all' ? Math.min(320, this._tabAnimStagger * 2) : 0;
|
||||
this._animDemoWindowTimer = setTimeout(() => this._demoWindow(tabs[0]), lead);
|
||||
total = Math.max(total, lead + this._winAnimDuration() + this._lineAnimDuration() + BEAM_HOLD_MS);
|
||||
}
|
||||
|
||||
this._animDemoTimer = setTimeout(() => this._clearEntranceDemo(), total + 1800);
|
||||
},
|
||||
|
||||
/** Append `count` throwaway tabs and run the tab entrance on them. */
|
||||
_demoTabs(count) {
|
||||
const container = this.$('sessionTabs');
|
||||
if (!container) return [];
|
||||
const made = [];
|
||||
const base = this.sessions?.size || 0;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const tab = document.createElement('div');
|
||||
tab.className = 'session-tab';
|
||||
tab.dataset.demo = '1';
|
||||
tab.dataset.color = ['green', 'blue', 'purple', 'orange', 'pink', 'yellow', 'red'][i % 7];
|
||||
tab.innerHTML = `
|
||||
<span class="tab-number">${base + i + 1}</span>
|
||||
<span class="tab-status idle" aria-hidden="true"></span>
|
||||
<span class="tab-info"><span class="tab-name-row">
|
||||
<span class="tab-name">w${base + i + 1}-demo</span>
|
||||
</span></span>`;
|
||||
container.appendChild(tab);
|
||||
made.push(tab);
|
||||
if (this._tabAnimStyle !== 'off') {
|
||||
tab.style.setProperty('--tab-enter-delay', `${i * this._tabAnimStagger}ms`);
|
||||
void tab.offsetWidth; // force layout so the class add starts a fresh run
|
||||
tab.classList.add('tab-enter');
|
||||
}
|
||||
}
|
||||
return made;
|
||||
},
|
||||
|
||||
/** Append a throwaway agent window under `originTab`, plus its connection line. */
|
||||
_demoWindow(originTab) {
|
||||
const tabRect = originTab?.getBoundingClientRect();
|
||||
const win = document.createElement('div');
|
||||
win.className = 'subagent-window';
|
||||
win.dataset.demo = '1';
|
||||
win.style.width = '360px';
|
||||
win.style.height = '220px';
|
||||
win.style.left = `${Math.max(24, (tabRect?.left ?? 120) - 40)}px`;
|
||||
win.style.top = `${(tabRect?.bottom ?? 60) + 160}px`;
|
||||
win.style.zIndex = '1001';
|
||||
win.innerHTML = `
|
||||
<div class="subagent-window-header">
|
||||
<div class="subagent-window-title"><span class="icon">🤖</span><span class="id">demo-agent</span>
|
||||
<span class="status running">running</span></div>
|
||||
</div>
|
||||
<div class="subagent-window-body"><div class="subagent-empty">Preview window</div></div>`;
|
||||
document.body.appendChild(win);
|
||||
this.applyWindowEntrance(win);
|
||||
this._demoLine(tabRect, win);
|
||||
},
|
||||
|
||||
/** Draw a fake tab→window line into the lab's own overlay and animate it. */
|
||||
_demoLine(tabRect, win) {
|
||||
if (!tabRect) return;
|
||||
const svg = this._demoLineSvg();
|
||||
const winRect = win.getBoundingClientRect();
|
||||
const x1 = tabRect.left + tabRect.width / 2;
|
||||
const y1 = tabRect.bottom;
|
||||
const x2 = winRect.left + winRect.width / 2;
|
||||
const y2 = winRect.top;
|
||||
const midY = (y1 + y2) / 2;
|
||||
|
||||
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
||||
path.setAttribute('d', `M ${x1} ${y1} C ${x1} ${midY}, ${x2} ${midY}, ${x2} ${y2}`);
|
||||
path.setAttribute('class', 'connection-line');
|
||||
path.dataset.demo = '1';
|
||||
svg.appendChild(path);
|
||||
|
||||
if ((this._lineAnimStyle || LINE_ANIM_DEFAULT) === 'off') return;
|
||||
const len = Math.max(1, Math.round(path.getTotalLength()));
|
||||
path.style.setProperty('--line-len', `${len}px`);
|
||||
path.style.setProperty('--line-enter-delay', '0ms');
|
||||
path.classList.add('line-enter');
|
||||
|
||||
if (this._lineAnimStyle === 'packet') {
|
||||
const packet = path.cloneNode(false);
|
||||
packet.setAttribute('class', 'connection-line-packet');
|
||||
packet.dataset.demo = '1';
|
||||
packet.style.setProperty('--line-len', `${len}px`);
|
||||
packet.style.setProperty('--line-enter-delay', '0ms');
|
||||
svg.appendChild(packet);
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -1276,6 +1276,20 @@
|
||||
</optgroup>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-item settings-item-multiline" title="How new session tabs, the terminal pane, agent windows and their connection lines appear. This device only. For per-surface control and a live preview, add ?animlab=1 to the URL.">
|
||||
<div class="settings-item-text">
|
||||
<span class="settings-item-label">Entrance Animations</span>
|
||||
<span class="settings-item-desc">How new tabs, panes and agent windows arrive</span>
|
||||
</div>
|
||||
<select id="appSettingsEntranceAnim" class="form-select settings-inline-select">
|
||||
<option value="legacy">Off (default)</option>
|
||||
<option value="terminal">Terminal (CRT)</option>
|
||||
<option value="beamdown">Beam down</option>
|
||||
<option value="quiet">Quiet</option>
|
||||
<option value="playful">Playful</option>
|
||||
<option value="custom">Custom (set in the lab)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="settings-item" id="appSettingsWebglRendererItem" title="Use the GPU-accelerated WebGL terminal renderer (desktop only). Turn off to force the DOM renderer if you hit GPU glitches. Codeman also auto-falls-back to the DOM renderer after repeated GPU stalls.">
|
||||
<span class="settings-item-label">WebGL Renderer</span>
|
||||
<label class="switch switch-sm">
|
||||
@@ -2626,6 +2640,7 @@
|
||||
<script defer src="admin-ui.js"></script>
|
||||
<script defer src="session-ui.js"></script>
|
||||
<script defer src="webview-tabs.js"></script>
|
||||
<script defer src="entrance-animations.js"></script>
|
||||
<script defer src="ralph-wizard.js"></script>
|
||||
<script defer src="api-client.js"></script>
|
||||
<script defer src="subagent-windows.js"></script>
|
||||
|
||||
@@ -312,6 +312,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.getElementById('appSettingsShowFileViewerButton').checked = settings.showFileViewerButton ?? defaults.showFileViewerButton ?? true;
|
||||
document.getElementById('appSettingsShowAttachmentsButton').checked = settings.showAttachmentsButton ?? defaults.showAttachmentsButton ?? false;
|
||||
document.getElementById('appSettingsSkin').value = settings.skin ?? defaults.skin ?? 'daylight-blue';
|
||||
// Entrance animations. Deliberately NOT part of the settings payload: the
|
||||
// styles persist to their own localStorage keys via setAnimTheme(), which
|
||||
// keeps them per-device without touching the .strict() SettingsUpdateSchema.
|
||||
this._syncEntranceAnimSetting?.();
|
||||
// WebGL renderer (desktop only — mobile always uses the DOM renderer, so hide
|
||||
// the toggle there so it can't promise something that won't apply).
|
||||
document.getElementById('appSettingsWebglRenderer').checked = settings.webglRendererEnabled ?? defaults.webglRendererEnabled ?? true;
|
||||
|
||||
@@ -638,6 +638,674 @@ body {
|
||||
100% { box-shadow: 0 0 0px 0px rgba(34, 197, 94, 0); background: transparent; }
|
||||
}
|
||||
|
||||
/* ── Tab entrance animations (tab-entrance.js) ─────────────────────────────
|
||||
Style is picked by html[data-tab-anim]; the stagger arrives as an inline
|
||||
--tab-enter-delay per tab (negative when an entrance is resuming after a
|
||||
re-render). Duration is scaled by --anim-enter-scale so one slider retimes
|
||||
every style.
|
||||
|
||||
Colour/glow effects live on ::before, never on the tab itself: .session-tab.active
|
||||
sets background/border/box-shadow with !important (and a newly created session is
|
||||
normally the active one), and !important beats an animation. Transform and opacity
|
||||
are unaffected, so the tab keeps those. */
|
||||
|
||||
.session-tab.tab-enter {
|
||||
animation-delay: var(--tab-enter-delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.session-tab.tab-enter::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: -1px;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
animation-delay: var(--tab-enter-delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* Slide, drifts in from the right and settles. */
|
||||
html[data-tab-anim="slide"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-slide;
|
||||
animation-duration: calc(380ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes tab-enter-slide {
|
||||
from { opacity: 0; transform: translate3d(24px, 3px, 0) scale(0.96); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* Pop, springs past full size, then settles back. */
|
||||
html[data-tab-anim="pop"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-pop;
|
||||
animation-duration: calc(460ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes tab-enter-pop {
|
||||
0% { opacity: 0; transform: scale(0.4); }
|
||||
55% { opacity: 1; transform: scale(1.13); }
|
||||
76% { opacity: 1; transform: scale(0.965); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* CRT, snaps open as a hot line, then unfolds vertically. */
|
||||
html[data-tab-anim="crt"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-crt;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
html[data-tab-anim="crt"] .session-tab.tab-enter::before {
|
||||
animation-name: tab-enter-crt-flash;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes tab-enter-crt {
|
||||
0% { opacity: 0; transform: scale3d(0.04, 0.09, 1); }
|
||||
20% { opacity: 1; transform: scale3d(1, 0.09, 1); }
|
||||
56% { opacity: 1; transform: scale3d(1, 1.14, 1); }
|
||||
78% { opacity: 1; transform: scale3d(1, 0.95, 1); }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes tab-enter-crt-flash {
|
||||
0% { opacity: 1; background: rgba(205, 255, 225, 0.95); box-shadow: 0 0 26px 8px rgba(0, 255, 102, 0.6); }
|
||||
20% { opacity: 0.9; background: rgba(150, 255, 195, 0.7); box-shadow: 0 0 20px 6px rgba(0, 255, 102, 0.45); }
|
||||
60% { opacity: 0.32; background: rgba(0, 255, 102, 0.14); box-shadow: 0 0 12px 2px rgba(0, 255, 102, 0.22); }
|
||||
100% { opacity: 0; background: transparent; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* Unroll, the strip makes room and the tab widens into it. */
|
||||
html[data-tab-anim="unroll"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-unroll;
|
||||
animation-duration: calc(480ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes tab-enter-unroll {
|
||||
0% { max-width: 0; opacity: 0; padding-left: 0; padding-right: 0; transform: translateY(3px); }
|
||||
30% { opacity: 1; }
|
||||
100% { max-width: 900px; opacity: 1; padding-left: 0.6rem; padding-right: 0.6rem; transform: none; }
|
||||
}
|
||||
|
||||
/* Boot, flickers on under a green scan sweep, like a display warming up. */
|
||||
html[data-tab-anim="boot"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-boot;
|
||||
animation-duration: calc(720ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
|
||||
html[data-tab-anim="boot"] .session-tab.tab-enter::before {
|
||||
background: linear-gradient(
|
||||
100deg,
|
||||
transparent 0%,
|
||||
rgba(0, 255, 102, 0.06) 34%,
|
||||
rgba(200, 255, 220, 0.5) 50%,
|
||||
rgba(0, 255, 102, 0.06) 66%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 280% 100%;
|
||||
animation-name: tab-enter-boot-scan;
|
||||
animation-duration: calc(720ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes tab-enter-boot {
|
||||
0% { opacity: 0; transform: translate3d(0, 5px, 0); }
|
||||
8% { opacity: 0.85; }
|
||||
15% { opacity: 0.12; }
|
||||
23% { opacity: 1; }
|
||||
31% { opacity: 0.35; }
|
||||
42% { opacity: 1; transform: none; }
|
||||
55% { opacity: 0.72; }
|
||||
66% { opacity: 1; }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes tab-enter-boot-scan {
|
||||
0% { opacity: 0; background-position: 150% 0; }
|
||||
12% { opacity: 1; }
|
||||
86% { opacity: 1; }
|
||||
100% { opacity: 0; background-position: -50% 0; }
|
||||
}
|
||||
|
||||
/* Flip, drops in as a card hinged on its top edge. */
|
||||
html[data-tab-anim="flip"] .session-tab.tab-enter {
|
||||
animation-name: tab-enter-flip;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
transform-origin: 50% 0%;
|
||||
}
|
||||
|
||||
@keyframes tab-enter-flip {
|
||||
0% { opacity: 0; transform: perspective(700px) rotateX(-92deg); }
|
||||
55% { opacity: 1; transform: perspective(700px) rotateX(13deg); }
|
||||
78% { opacity: 1; transform: perspective(700px) rotateX(-5deg); }
|
||||
100% { opacity: 1; transform: perspective(700px) rotateX(0deg); }
|
||||
}
|
||||
|
||||
/* ── Entrance lab (?animlab=1) ───────────────────────────────────────────── */
|
||||
|
||||
.anim-lab {
|
||||
position: fixed;
|
||||
right: 14px;
|
||||
bottom: 14px;
|
||||
z-index: 3100;
|
||||
width: 300px;
|
||||
max-height: 88vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px 12px 12px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 10px;
|
||||
background: rgba(12, 16, 14, 0.96);
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.55);
|
||||
color: var(--text);
|
||||
font-size: 0.72rem;
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.anim-lab-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.anim-lab-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anim-lab-close:hover { color: var(--text); }
|
||||
|
||||
.anim-lab-themes {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.anim-lab-themes button {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.66rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anim-lab-themes button:hover { background: rgba(34, 197, 94, 0.16); color: var(--text); }
|
||||
|
||||
.anim-lab-themes button.selected {
|
||||
border-color: rgba(0, 255, 102, 0.7);
|
||||
background: rgba(34, 197, 94, 0.2);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.anim-lab-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.anim-lab-scroll::-webkit-scrollbar { width: 5px; }
|
||||
.anim-lab-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
||||
|
||||
.anim-lab-group { margin-bottom: 10px; }
|
||||
|
||||
.anim-lab-group-title {
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.anim-lab-style {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
width: 100%;
|
||||
margin-bottom: 3px;
|
||||
padding: 5px 8px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: var(--text-dim);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anim-lab-style:hover { background: rgba(34, 197, 94, 0.09); color: var(--text); }
|
||||
|
||||
.anim-lab-style.selected {
|
||||
border-color: rgba(0, 255, 102, 0.6);
|
||||
background: rgba(34, 197, 94, 0.14);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.anim-lab-style strong { font-size: 0.73rem; font-weight: 600; }
|
||||
.anim-lab-style em { font-style: normal; font-size: 0.64rem; opacity: 0.7; }
|
||||
|
||||
.anim-lab-range {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 4px 8px;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.anim-lab-range output { justify-self: end; color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.anim-lab-range input { grid-column: 1 / -1; width: 100%; accent-color: #00ff66; }
|
||||
|
||||
.anim-lab-demo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 8px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.anim-lab-demo button {
|
||||
flex: 1;
|
||||
padding: 4px 0;
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anim-lab-demo button:hover { background: rgba(34, 197, 94, 0.16); border-color: rgba(0, 255, 102, 0.5); }
|
||||
|
||||
.anim-lab-hint { margin: 8px 0 0; font-size: 0.64rem; opacity: 0.55; }
|
||||
|
||||
/* ── Window entrance animations ────────────────────────────────────────────
|
||||
Applied to a window already sitting at its resting position. The `fly` style
|
||||
is NOT here: it is the pre-existing JS transition in subagent-windows.js that
|
||||
flies the window out of its parent tab, and it skips this class entirely. */
|
||||
|
||||
.subagent-window.win-enter,
|
||||
.ultracode-window.win-enter {
|
||||
animation-delay: var(--win-enter-delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
.subagent-window.win-enter::before,
|
||||
.ultracode-window.win-enter::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
border-radius: inherit;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
animation-delay: var(--win-enter-delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* CRT, bursts open as a hot line, then unfolds vertically. */
|
||||
html[data-win-anim="crt"] .subagent-window.win-enter,
|
||||
html[data-win-anim="crt"] .ultracode-window.win-enter {
|
||||
animation-name: win-enter-crt;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
html[data-win-anim="crt"] .subagent-window.win-enter::before,
|
||||
html[data-win-anim="crt"] .ultracode-window.win-enter::before {
|
||||
animation-name: win-enter-crt-flash;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes win-enter-crt {
|
||||
0% { opacity: 0; transform: scale3d(0.55, 0.008, 1); }
|
||||
22% { opacity: 1; transform: scale3d(1, 0.01, 1); }
|
||||
58% { opacity: 1; transform: scale3d(1, 1.05, 1); }
|
||||
80% { opacity: 1; transform: scale3d(1, 0.98, 1); }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes win-enter-crt-flash {
|
||||
0% { opacity: 1; background: rgba(205, 255, 225, 0.9); box-shadow: 0 0 40px 14px rgba(0, 255, 102, 0.55); }
|
||||
22% { opacity: 0.8; background: rgba(120, 255, 180, 0.5); box-shadow: 0 0 30px 10px rgba(0, 255, 102, 0.4); }
|
||||
60% { opacity: 0.25; background: rgba(0, 255, 102, 0.1); box-shadow: 0 0 14px 3px rgba(0, 255, 102, 0.18); }
|
||||
100% { opacity: 0; background: transparent; box-shadow: none; }
|
||||
}
|
||||
|
||||
/* Materialize, resolves out of a blur with a short glitch. */
|
||||
html[data-win-anim="materialize"] .subagent-window.win-enter,
|
||||
html[data-win-anim="materialize"] .ultracode-window.win-enter {
|
||||
animation-name: win-enter-materialize;
|
||||
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes win-enter-materialize {
|
||||
0% { opacity: 0; transform: scale(0.94); filter: blur(16px) brightness(1.7); }
|
||||
35% { opacity: 0.85; filter: blur(6px) brightness(1.3); }
|
||||
45% { opacity: 0.4; }
|
||||
55% { opacity: 0.95; filter: blur(3px) brightness(1.15); }
|
||||
70% { opacity: 0.75; }
|
||||
100% { opacity: 1; transform: none; filter: none; }
|
||||
}
|
||||
|
||||
/* Unfold, hinges down from the top edge. */
|
||||
html[data-win-anim="unfold"] .subagent-window.win-enter,
|
||||
html[data-win-anim="unfold"] .ultracode-window.win-enter {
|
||||
animation-name: win-enter-unfold;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
transform-origin: 50% 0%;
|
||||
}
|
||||
|
||||
@keyframes win-enter-unfold {
|
||||
0% { opacity: 0; transform: perspective(1600px) rotateX(-88deg); }
|
||||
55% { opacity: 1; transform: perspective(1600px) rotateX(9deg); }
|
||||
80% { opacity: 1; transform: perspective(1600px) rotateX(-3deg); }
|
||||
100% { opacity: 1; transform: perspective(1600px) rotateX(0deg); }
|
||||
}
|
||||
|
||||
/* Beam down, holds still (so its connection line can draw toward a stable rect),
|
||||
then materializes out of a green wash. NO transform: a transformed window
|
||||
reports a transformed rect and the line would aim at the wrong place. */
|
||||
html[data-win-anim="beam"] .subagent-window.win-enter,
|
||||
html[data-win-anim="beam"] .ultracode-window.win-enter {
|
||||
animation-name: win-enter-beam;
|
||||
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
html[data-win-anim="beam"] .subagent-window.win-enter::before,
|
||||
html[data-win-anim="beam"] .ultracode-window.win-enter::before {
|
||||
animation-name: win-enter-beam-wash;
|
||||
animation-duration: calc(620ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes win-enter-beam {
|
||||
0% { opacity: 0; filter: brightness(2.6) saturate(0.4); }
|
||||
20% { opacity: 0.55; }
|
||||
32% { opacity: 0.2; }
|
||||
48% { opacity: 0.9; filter: brightness(1.5) saturate(0.8); }
|
||||
100% { opacity: 1; filter: none; }
|
||||
}
|
||||
|
||||
@keyframes win-enter-beam-wash {
|
||||
0% { opacity: 0.9; background: linear-gradient(180deg, rgba(190, 255, 215, 0.85), rgba(0, 255, 102, 0)); }
|
||||
45% { opacity: 0.5; background: linear-gradient(180deg, rgba(0, 255, 102, 0.35), rgba(0, 255, 102, 0)); }
|
||||
100% { opacity: 0; background: transparent; }
|
||||
}
|
||||
|
||||
/* Pop, springs open from the centre. */
|
||||
html[data-win-anim="pop"] .subagent-window.win-enter,
|
||||
html[data-win-anim="pop"] .ultracode-window.win-enter {
|
||||
animation-name: win-enter-pop;
|
||||
animation-duration: calc(460ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes win-enter-pop {
|
||||
0% { opacity: 0; transform: scale(0.55); }
|
||||
58% { opacity: 1; transform: scale(1.045); }
|
||||
80% { opacity: 1; transform: scale(0.99); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
/* ── 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 -
|
||||
so these are invisible to it, but animating width/height/padding here would
|
||||
resize the PTY mid-animation. Colour washes go on ::before, never as a
|
||||
`filter` on the container: that would blur a full-screen WebGL canvas every
|
||||
frame. `will-change` is deliberately not set, the base rule needs its
|
||||
`will-change: contents` for terminal compositing. */
|
||||
|
||||
.terminal-container.term-enter {
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
.terminal-container.term-enter::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 4;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* CRT, power-on: a hot line that expands to full height. */
|
||||
html[data-term-anim="crt"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-crt;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.3, 0.9, 0.3, 1);
|
||||
}
|
||||
|
||||
html[data-term-anim="crt"] .terminal-container.term-enter::before {
|
||||
animation-name: term-enter-crt-flash;
|
||||
animation-duration: calc(560ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes term-enter-crt {
|
||||
0% { opacity: 0; transform: scale3d(0.6, 0.004, 1); }
|
||||
20% { opacity: 1; transform: scale3d(1, 0.006, 1); }
|
||||
58% { opacity: 1; transform: scale3d(1, 1.03, 1); }
|
||||
80% { opacity: 1; transform: scale3d(1, 0.99, 1); }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes term-enter-crt-flash {
|
||||
0% { opacity: 1; background: rgba(210, 255, 230, 0.85); }
|
||||
20% { opacity: 0.7; background: rgba(120, 255, 180, 0.4); }
|
||||
60% { opacity: 0.2; background: rgba(0, 255, 102, 0.08); }
|
||||
100% { opacity: 0; background: transparent; }
|
||||
}
|
||||
|
||||
/* Boot, flickers on under a green scan sweep. */
|
||||
html[data-term-anim="boot"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-boot;
|
||||
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: linear;
|
||||
}
|
||||
|
||||
html[data-term-anim="boot"] .terminal-container.term-enter::before {
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
transparent 0%,
|
||||
rgba(0, 255, 102, 0.05) 40%,
|
||||
rgba(200, 255, 220, 0.28) 50%,
|
||||
rgba(0, 255, 102, 0.05) 60%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 100% 300%;
|
||||
animation-name: term-enter-boot-scan;
|
||||
animation-duration: calc(760ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes term-enter-boot {
|
||||
0% { opacity: 0; transform: scale(0.995); }
|
||||
8% { opacity: 0.85; }
|
||||
15% { opacity: 0.1; }
|
||||
24% { opacity: 1; }
|
||||
33% { opacity: 0.35; }
|
||||
45% { opacity: 1; transform: none; }
|
||||
58% { opacity: 0.7; }
|
||||
70% { opacity: 1; }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
@keyframes term-enter-boot-scan {
|
||||
0% { opacity: 0; background-position: 0 -150%; }
|
||||
12% { opacity: 1; }
|
||||
86% { opacity: 1; }
|
||||
100% { opacity: 0; background-position: 0 150%; }
|
||||
}
|
||||
|
||||
/* Wipe, reveals top-to-bottom behind a bright edge. */
|
||||
html[data-term-anim="wipe"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-wipe;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
|
||||
}
|
||||
|
||||
html[data-term-anim="wipe"] .terminal-container.term-enter::before {
|
||||
background: linear-gradient(180deg, rgba(0, 255, 102, 0.16) 0%, rgba(190, 255, 215, 0.5) 82%, transparent 100%);
|
||||
animation-name: term-enter-wipe-edge;
|
||||
animation-duration: calc(520ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.35, 0.85, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes term-enter-wipe {
|
||||
0% { opacity: 1; clip-path: inset(0 0 100% 0); }
|
||||
100% { opacity: 1; clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
@keyframes term-enter-wipe-edge {
|
||||
0% { opacity: 0.9; clip-path: inset(0 0 100% 0); }
|
||||
80% { opacity: 0.45; }
|
||||
100% { opacity: 0; clip-path: inset(0 0 0 0); }
|
||||
}
|
||||
|
||||
/* Slide up, rises into place from below. */
|
||||
html[data-term-anim="slide"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-slide;
|
||||
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
@keyframes term-enter-slide {
|
||||
0% { opacity: 0; transform: translate3d(0, 34px, 0); }
|
||||
100% { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
/* Fade, quiet, with a touch of scale. */
|
||||
html[data-term-anim="fade"] .terminal-container.term-enter {
|
||||
animation-name: term-enter-fade;
|
||||
animation-duration: calc(340ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes term-enter-fade {
|
||||
0% { opacity: 0; transform: scale(0.985); }
|
||||
100% { opacity: 1; 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
|
||||
resuming after the SVG was rebuilt underneath it. */
|
||||
|
||||
.connection-line.line-enter {
|
||||
animation-delay: var(--line-enter-delay, 0ms);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
/* Draw, the line paints itself from the tab down to the window. Overrides the
|
||||
resting 5 3 dash to a single full-length dash for the duration. */
|
||||
html[data-line-anim="draw"] .connection-line.line-enter {
|
||||
stroke-dasharray: var(--line-len);
|
||||
animation-name: line-enter-draw;
|
||||
animation-duration: calc(420ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: cubic-bezier(0.32, 0.8, 0.3, 1);
|
||||
}
|
||||
|
||||
@keyframes line-enter-draw {
|
||||
from { stroke-dashoffset: var(--line-len); opacity: 1; }
|
||||
to { stroke-dashoffset: 0; opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* Fade, plain opacity ramp, dashes intact. */
|
||||
html[data-line-anim="fade"] .connection-line.line-enter {
|
||||
animation-name: line-enter-fade;
|
||||
animation-duration: calc(300ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
@keyframes line-enter-fade {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 0.9; }
|
||||
}
|
||||
|
||||
/* Packet, the base line fades in and a separate bright dash rides down it. The
|
||||
packet is its own cloned path so the base line keeps its dashed look. */
|
||||
html[data-line-anim="packet"] .connection-line.line-enter {
|
||||
animation-name: line-enter-fade;
|
||||
animation-duration: calc(300ms * var(--anim-enter-scale, 1));
|
||||
animation-timing-function: ease-out;
|
||||
}
|
||||
|
||||
.connection-line-packet {
|
||||
fill: none;
|
||||
stroke: #cdfbe3;
|
||||
stroke-width: 4;
|
||||
stroke-linecap: round;
|
||||
pointer-events: none;
|
||||
stroke-dasharray: 20px 100000px;
|
||||
filter: drop-shadow(0 0 4px rgba(0, 255, 102, 0.95)) drop-shadow(0 0 10px rgba(59, 130, 246, 0.7));
|
||||
animation-name: line-enter-packet;
|
||||
animation-duration: calc(700ms * var(--anim-enter-scale, 1));
|
||||
animation-delay: var(--line-enter-delay, 0ms);
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.3, 1);
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes line-enter-packet {
|
||||
0% { stroke-dashoffset: 20px; opacity: 0; }
|
||||
12% { opacity: 1; }
|
||||
85% { opacity: 1; }
|
||||
100% { stroke-dashoffset: calc(-1 * var(--line-len)); opacity: 0; }
|
||||
}
|
||||
|
||||
.anim-lab-check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin: -4px 0 12px;
|
||||
padding: 0 2px;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.66rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.anim-lab-check input { accent-color: #00ff66; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.session-tab.tab-enter,
|
||||
.session-tab.tab-enter::before,
|
||||
.subagent-window.win-enter,
|
||||
.subagent-window.win-enter::before,
|
||||
.ultracode-window.win-enter,
|
||||
.ultracode-window.win-enter::before,
|
||||
.terminal-container.term-enter,
|
||||
.terminal-container.term-enter::before,
|
||||
.connection-line.line-enter,
|
||||
.connection-line-packet {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
.session-tab .tab-status {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
|
||||
@@ -465,6 +465,10 @@ Object.assign(CodemanApp.prototype, {
|
||||
if (typeof this._appendUltracodeAgentConnectionLines === 'function') {
|
||||
this._appendUltracodeAgentConnectionLines(svg, rects);
|
||||
}
|
||||
|
||||
// Every path above was just created from scratch, so any line entrance in
|
||||
// flight has to be re-attached here (resumed via a negative animation-delay).
|
||||
this._applyLineEntrances?.(svg);
|
||||
},
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
@@ -729,12 +733,17 @@ Object.assign(CodemanApp.prototype, {
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Only the `fly` entrance style parks the window on its parent tab; every
|
||||
// CSS-animated style (entrance-animations.js) needs it to start at its
|
||||
// resting position, or the animation would play at the wrong place.
|
||||
const flyFromTab = !!parentTab && !isMobile && this.windowEntranceFliesFromTab?.() !== false;
|
||||
|
||||
// If we have a parent tab, start window at tab position for spawn animation
|
||||
if (isMobile) {
|
||||
// Mobile: position using top (keyboard-aware positioning calculated above)
|
||||
win.style.top = `${finalY}px`;
|
||||
win.style.bottom = 'auto';
|
||||
} else if (parentTab) {
|
||||
} else if (flyFromTab) {
|
||||
const tabRect = parentTab.getBoundingClientRect();
|
||||
win.style.left = `${tabRect.left}px`;
|
||||
win.style.top = `${tabRect.bottom}px`;
|
||||
@@ -812,7 +821,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
this.subagentWindows.get(agentId).resizeObserver = resizeObserver;
|
||||
|
||||
// Animate to final position if spawning from tab (desktop only)
|
||||
if (parentTab && !isMobile) {
|
||||
if (flyFromTab) {
|
||||
requestAnimationFrame(() => {
|
||||
win.style.transition = 'all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1)';
|
||||
win.style.left = `${finalX}px`;
|
||||
@@ -824,11 +833,26 @@ Object.assign(CodemanApp.prototype, {
|
||||
setTimeout(() => {
|
||||
win.style.transition = '';
|
||||
win.classList.remove('spawning');
|
||||
// The line only becomes meaningful once the window has landed, so its
|
||||
// draw-in starts here rather than at spawn.
|
||||
this.markConnectionLineEntering?.(agentId);
|
||||
this.updateConnectionLines();
|
||||
}, 400);
|
||||
});
|
||||
} else {
|
||||
// No animation (mobile uses CSS positioning), just update connection lines
|
||||
// CSS-animated entrance styles (and mobile) run in place. The window is
|
||||
// already at its resting position, so the connection line can be drawn
|
||||
// against a correct rect right away and animate alongside it.
|
||||
//
|
||||
// Skipped when the window spawns hidden (its agent belongs to a background
|
||||
// tab): a display:none element never runs its animation, so `animationend`
|
||||
// would never fire and the entrance class plus its inline custom property
|
||||
// would stick to the window forever. Nothing is visible to animate anyway,
|
||||
// and revealing it later is a tab switch, not a spawn.
|
||||
if (!shouldHide) {
|
||||
this.applyWindowEntrance?.(win);
|
||||
this.markConnectionLineEntering?.(agentId);
|
||||
}
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
|
||||
|
||||
@@ -210,6 +210,9 @@ Object.assign(CodemanApp.prototype, {
|
||||
document.body.appendChild(win);
|
||||
// Drop the spawn class on the next frame so the transition runs.
|
||||
requestAnimationFrame(() => win.classList.remove('spawning'));
|
||||
// Then hand over to the chosen entrance style (no-op for `fly`/`off`, which
|
||||
// leave the small scale-in transition above as the whole animation).
|
||||
this.applyWindowEntrance?.(win);
|
||||
|
||||
const header = win.querySelector('.ultracode-window-header');
|
||||
const dragListeners = this.makeWindowDraggable(win, header);
|
||||
@@ -586,6 +589,7 @@ Object.assign(CodemanApp.prototype, {
|
||||
|
||||
document.body.appendChild(win);
|
||||
requestAnimationFrame(() => win.classList.remove('spawning'));
|
||||
this.applyWindowEntrance?.(win);
|
||||
|
||||
const header = win.querySelector('.ultracode-window-header');
|
||||
const dragListeners = this.makeWindowDraggable(win, header);
|
||||
|
||||
Reference in New Issue
Block a user