From 1113d34ca8dbfe13ff6c763b2d47313e68e390af Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Tue, 4 Aug 2026 12:31:52 +0200 Subject: [PATCH] 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) --- CLAUDE.md | 4 +- src/web/public/app.js | 20 + src/web/public/entrance-animations.js | 754 ++++++++++++++++++++++++++ src/web/public/index.html | 15 + src/web/public/settings-ui.js | 4 + src/web/public/styles.css | 668 +++++++++++++++++++++++ src/web/public/subagent-windows.js | 30 +- src/web/public/ultracode-windows.js | 4 + 8 files changed, 1495 insertions(+), 4 deletions(-) create mode 100644 src/web/public/entrance-animations.js diff --git a/CLAUDE.md b/CLAUDE.md index 5021aa8c..39bb2199 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 ``. 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) diff --git a/src/web/public/app.js b/src/web/public/app.js index 6f953be8..b59f354c 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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 + diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 405c9526..d04c047b 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -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; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 4822390c..b864b10d 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -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; diff --git a/src/web/public/subagent-windows.js b/src/web/public/subagent-windows.js index 14b9beed..cf5ffe91 100644 --- a/src/web/public/subagent-windows.js +++ b/src/web/public/subagent-windows.js @@ -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, { `; + // 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(); } diff --git a/src/web/public/ultracode-windows.js b/src/web/public/ultracode-windows.js index 0c0eb347..6adac665 100644 --- a/src/web/public/ultracode-windows.js +++ b/src/web/public/ultracode-windows.js @@ -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);