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);