diff --git a/CHANGELOG.md b/CHANGELOG.md index abb8856141..fcb2761485 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The empty-state whale now surfaces once when it is actually on screen + (~640 ms ease-out-quint land from ~87% presence, one caustic pass). The + wordmark writes **Codewhale** left to right in the current cyan, then + settles. Full-canvas launch and onboarding occlude that clock so the shine + cannot finish behind a menu. `low_motion`, `fancy_animations = false`, and + `NO_ANIMATIONS` keep the settled mark. Not a splash; input is not delayed. + Picker/menu pop uses the same 180 ms snappy settle; 70 ms receipt stagger + and 800 ms fish flee stay one-shot. - Added `/import-claude` (#5557): reads `~/.claude.json` and `~/.claude/settings.json` read-only and renders an explicit, reviewable migration plan plus a written report. MCP servers route through the diff --git a/MOTION_ETHOS_HANDOFF.md b/MOTION_ETHOS_HANDOFF.md new file mode 100644 index 0000000000..32c27f05b6 --- /dev/null +++ b/MOTION_ETHOS_HANDOFF.md @@ -0,0 +1,81 @@ +# Motion ethos handoff — v0.9.12 + +Branch: `grok/v0912-omarchy-ethos-motion-20260827` +Base: `origin/main` (`a96ea6cb09cc464ea2e88f251c538c239d1fe9ad`) +Lane: isolated TUI motion polish. Does **not** duplicate PR #5643. + +## What 5643 already does + +PR #5643 (`codex/v0912-tui-polish-20260827`) converts `ocean_started_at` to +`Option` and starts that clock when the empty ocean is shown, plus +MCP-login copy and composer vocabulary. This lane does **not** fork that +patch. `ocean_started_at` stays an `Instant` process-start clock. + +## What this lane does + +Omarchy (DHH / Hyprland) timing is infused, not copied. Codewhale keeps Blue +Stage water, the whale, gold current, and ombre depth. No workspace-slide +theater, no bouncing windows, no `rounding=0`. + +### Welcome vs launch (the reported animation regression) + +On `origin/main` the idle shine used process-start time, so the first visible +empty-ocean frame could already be parked in the 4 s caustic off-screen +window. Launch is full-canvas and returns before `ChatWidget`, so the whale +never paints there. + +This lane: + +- Adds `App::welcome_visible_since: Option` — a dedicated + occlusion-aware welcome clock, distinct from 5643's `ocean_started_at` + Option change. +- Starts that clock only when decorative motion is on, the mark can draw, + and launch/onboarding are **not** covering the ocean. +- Authored 640 ms whale surface + Codewhale letter-write land with + **ease-out-quint from ~87% presence**, never from a vanishing point. + Shine is one pass on that same clock, so it starts when the whale is + actually on screen. +- Reduced / Still / `NO_ANIMATIONS` skip the clock and show the settled + mark immediately. + +### Ethos helpers (`crates/tui/src/tui/motion/ethos.rs`) + +| Constant / curve | Value | Use | +| --- | --- | --- | +| `ease_out_quint` | `1-(1-t)^5` | arrivals, whale surface, wordmark | +| `ease_out_exit` | quadratic | exits, closer to linear | +| `FADE_MS` | 160 | almost-linear fade | +| `SURFACE_POP_MS` | 180 | picker/menu snappy settle | +| `SURFACE_EXIT_MS` | 120 | faster than entry | +| `SURFACE_POP_FROM` | 0.87 | never grow from 0 | +| `WELCOME_SURFACE_MS` | 640 | keep this duration | +| `RECEIPT_STAGGER_MS` | 70 | do not lengthen | +| `FISH_FLEE_MS` | 800 | do not lengthen | + +`SettingsPickerController` records `opened_at` and exposes `settle_pop` / +`is_settling`. Hosts that already redraw can DIM until the pop lands. +Reduced/Still return 1.0 immediately. This lane does **not** add a +`ViewAction::Redraw` driver (event-loop change; follow-up). + +## Tests + +- `motion::ethos::tests::*` +- `underwater::startup_surface_tests::*` (land-from-87%, wordmark write, + one-pass shine, launch/onboarding occlusion, Reduced/Still skip) +- `widgets::tests::idle_welcome_waits_behind_launch_then_starts_on_the_empty_ocean` +- `widgets::tests::idle_welcome_waits_behind_onboarding` +- `settings_picker::tests::picker_settle_pops_from_nearly_full_and_skips_when_still` +- existing 70 ms receipt + 800 ms fish-flee tests still pin those one-shots + +## Remaining + +- Live picker DIM needs a one-shot redraw while `is_settling` (no + `ViewAction::Redraw` yet). Spatial memory is already correct: pickers do + not slide. +- If 5643 merges first, keep `welcome_visible_since` for the 640 ms surface + and let `ocean_started_at` stay whatever 5643 made it. Do not collapse + the two clocks; occlusion and ambient start are different facts. +- Do not add workspace-slide, bounce, or Hyprland rounding. Do not lengthen + receipt stagger or fish flee toward cinematic. +- `crates/tui/src/tui/motion/ethos.rs` is the chef's-choice default. New + decorative timing should ask it rather than inventing another curve. diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index 4457e95c5c..94cd9de6f9 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -9,6 +9,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- The empty-state whale now surfaces once when it is actually on screen + (~640 ms ease-out-quint land from ~87% presence, one caustic pass). The + wordmark writes **Codewhale** left to right in the current cyan, then + settles. Full-canvas launch and onboarding occlude that clock so the shine + cannot finish behind a menu. `low_motion`, `fancy_animations = false`, and + `NO_ANIMATIONS` keep the settled mark. Not a splash; input is not delayed. + Picker/menu pop uses the same 180 ms snappy settle; 70 ms receipt stagger + and 800 ms fish flee stay one-shot. - Added `/import-claude` (#5557): reads `~/.claude.json` and `~/.claude/settings.json` read-only and renders an explicit, reviewable migration plan plus a written report. MCP servers route through the diff --git a/crates/tui/src/tui/ambient_life.rs b/crates/tui/src/tui/ambient_life.rs index d4662fe1b1..960882c50f 100644 --- a/crates/tui/src/tui/ambient_life.rs +++ b/crates/tui/src/tui/ambient_life.rs @@ -1132,7 +1132,8 @@ fn sine_bob(elapsed_ms: u128, period_ms: u128, amplitude: u16) -> u16 { /// One-shot flee arc keyed to Working transition / pointer motion. #[must_use] pub fn fish_flee_offset(elapsed_ms: u128) -> u16 { - let progress = elapsed_ms.min(800) as f32 / 800.0; + let window = crate::tui::motion::ethos::FISH_FLEE_MS; + let progress = elapsed_ms.min(window) as f32 / window as f32; let excursion = (progress * std::f32::consts::PI).sin() * 9.0; excursion.round().clamp(0.0, 9.0) as u16 } diff --git a/crates/tui/src/tui/app.rs b/crates/tui/src/tui/app.rs index 28def2a755..4b22007e5d 100644 --- a/crates/tui/src/tui/app.rs +++ b/crates/tui/src/tui/app.rs @@ -1467,7 +1467,19 @@ pub struct App { pub calm_mode: bool, pub low_motion: bool, pub constrained_frame_rate: bool, + /// Process-start ambient epoch. The 640 ms whale surface uses + /// [`Self::welcome_visible_since`] so launch/onboarding cannot consume it. + /// Kept as `Instant` (not `Option`) so this lane does not fork the polish + /// branch that reuses this field as an ocean-visible clock. + #[allow(dead_code)] // process-start epoch; welcome surface owns the shine clock pub ocean_started_at: Instant, + /// Start of the one-shot idle-whale surface / wordmark / shine. + /// `None` until that mark is actually on screen so the full-canvas launch + /// menu and onboarding cannot consume the 640 ms window off-canvas. + /// Distinct from [`Self::ocean_started_at`], which remains the + /// process-start ambient clock. The two clocks stay separate: occlusion + /// and ambient start are different facts. + pub welcome_visible_since: Option, /// The ambient animation clock, in clamped milliseconds. Creature and /// water positions are pure functions of this value; advancing it by at /// most [`App::AMBIENT_MAX_STEP_MS`] per sampled frame keeps motion diff --git a/crates/tui/src/tui/app/init.rs b/crates/tui/src/tui/app/init.rs index 23ca624a67..ee9ed0398c 100644 --- a/crates/tui/src/tui/app/init.rs +++ b/crates/tui/src/tui/app/init.rs @@ -822,6 +822,7 @@ impl App { low_motion, constrained_frame_rate, ocean_started_at: Instant::now(), + welcome_visible_since: None, ambient_clock_ms: 0, ambient_clock_sampled_at: None, ambient_idle_since: None, diff --git a/crates/tui/src/tui/motion/ethos.rs b/crates/tui/src/tui/motion/ethos.rs new file mode 100644 index 0000000000..a727483cc3 --- /dev/null +++ b/crates/tui/src/tui/motion/ethos.rs @@ -0,0 +1,184 @@ +//! Chef's-choice motion ethos for the underwater TUI. +//! +//! Infuses Omarchy (DHH / Hyprland) timing — arrivals land, exits get out of +//! the way, surfaces pop from ~87% rather than growing from a vanishing point +//! — without copying Hyprland's look. Codewhale keeps Blue Stage water, the +//! whale, gold current, and ombre depth. Workspace/page slide theater stays +//! off: spatial memory beats animated travel. +//! +//! Reduced/Still skip decorative treatments via [`super::MotionPolicy`]. + +use super::MotionPolicy; + +/// Arrivals. Things land; they do not ease-in from a crawl. +#[must_use] +pub fn ease_out_quint(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + 1.0 - (1.0 - t).powi(5) +} + +/// Exits are faster than entries and closer to linear — get out of the way. +#[must_use] +#[allow(dead_code)] // chef's-choice exit curve; picker dismiss waits on a redraw driver +pub fn ease_out_exit(t: f32) -> f32 { + let t = t.clamp(0.0, 1.0); + 1.0 - (1.0 - t) * (1.0 - t) +} + +/// Almost-linear fade window. No 400 ms theatrical dissolves. +#[allow(dead_code)] // chef's-choice fade window; hosts should ask here rather than invent +pub const FADE_MS: u128 = 160; + +/// Picker/menu surface pop-in. Snappy settle, not cinematic. +pub const SURFACE_POP_MS: u128 = 180; + +/// Exits complete sooner than entries. +#[allow(dead_code)] // chef's-choice exit window; picker dismiss waits on a redraw driver +pub const SURFACE_EXIT_MS: u128 = 120; + +/// Surfaces pop from this scale, never from 0. +pub const SURFACE_POP_FROM: f32 = 0.87; + +/// Authored empty-state whale surface window. Keep this duration; change the +/// curve, not the length, so arrival stays a beat rather than a splash. +pub const WELCOME_SURFACE_MS: u128 = 640; + +/// Receipt stagger (one-shot). Do not lengthen toward cinematic. +pub const RECEIPT_STAGGER_MS: u128 = 70; + +/// Fish flee-and-return arc. Do not lengthen toward cinematic. +pub const FISH_FLEE_MS: u128 = 800; + +/// Mix of linear and a whisper of ease-out so short fades do not look robotic. +#[must_use] +#[allow(dead_code)] // chef's-choice fade; hosts should ask here rather than invent +pub fn fade_opacity(elapsed_ms: u128, policy: MotionPolicy) -> f32 { + if !policy.allows_decorative() { + return 1.0; + } + almost_linear(elapsed_ms, FADE_MS) +} + +#[must_use] +#[allow(dead_code)] // used by fade_opacity; kept as the shared almost-linear mix +fn almost_linear(elapsed_ms: u128, duration_ms: u128) -> f32 { + if duration_ms == 0 { + return 1.0; + } + let t = (elapsed_ms as f32 / duration_ms as f32).clamp(0.0, 1.0); + 0.85 * t + 0.15 * ease_out_exit(t) +} + +/// 0.87 → 1.0 over [`SURFACE_POP_MS`] with ease-out-quint. Reduced/Still land +/// at 1.0 immediately so the picker never grows from a vanishing point. +#[must_use] +#[allow(dead_code)] // picker DIM host API; live redraw driver is a follow-up +pub fn surface_pop(elapsed_ms: u128, policy: MotionPolicy) -> f32 { + if !policy.allows_decorative() || elapsed_ms >= SURFACE_POP_MS { + return 1.0; + } + let t = ease_out_quint(elapsed_ms as f32 / SURFACE_POP_MS as f32); + SURFACE_POP_FROM + (1.0 - SURFACE_POP_FROM) * t +} + +/// 1.0 → 0.0 over [`SURFACE_EXIT_MS`]. Faster than the matching pop-in. +#[must_use] +#[allow(dead_code)] // picker DIM host API; live redraw driver is a follow-up +pub fn surface_exit(elapsed_ms: u128, policy: MotionPolicy) -> f32 { + if !policy.allows_decorative() || elapsed_ms >= SURFACE_EXIT_MS { + return 0.0; + } + 1.0 - ease_out_exit(elapsed_ms as f32 / SURFACE_EXIT_MS as f32) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::tui::motion::MotionPolicy; + + fn full() -> MotionPolicy { + MotionPolicy::from_settings(false, true, false) + } + + fn reduced() -> MotionPolicy { + MotionPolicy::from_settings(true, true, false) + } + + fn still() -> MotionPolicy { + MotionPolicy::from_settings(false, false, false) + } + + #[test] + fn arrivals_land_instead_of_crawling_in() { + let early = ease_out_quint(0.2); + let raised_cosine_early = 0.5 * (1.0 - (std::f32::consts::PI * 0.2).cos()); + assert!( + early > raised_cosine_early + 0.4, + "ease-out-quint must be well ahead of a raised-cosine crawl at t=0.2: {early} vs {raised_cosine_early}" + ); + assert!( + (ease_out_quint(0.5) - 0.96875).abs() < 0.001, + "mid-arrival should already have landed" + ); + assert_eq!(ease_out_quint(0.0), 0.0); + assert_eq!(ease_out_quint(1.0), 1.0); + } + + #[test] + fn exits_are_faster_than_entries_and_closer_to_linear() { + const { assert!(SURFACE_EXIT_MS < SURFACE_POP_MS) }; + let t = 0.4; + let entry = ease_out_quint(t); + let exit = ease_out_exit(t); + assert!( + (exit - t).abs() < (entry - t).abs(), + "exit curve should hug linear more than quint: exit={exit} entry={entry} t={t}" + ); + let entry_remaining = SURFACE_POP_MS - 80; + let exit_remaining = SURFACE_EXIT_MS.saturating_sub(80); + assert!( + exit_remaining < entry_remaining, + "an exit started at the same instant finishes first" + ); + } + + #[test] + fn surfaces_pop_from_nearly_full_scale_never_from_zero() { + assert!((SURFACE_POP_FROM - 0.87).abs() < f32::EPSILON); + let start = surface_pop(0, full()); + assert!( + (start - SURFACE_POP_FROM).abs() < 0.001, + "first frame must already occupy most of the final surface: {start}" + ); + assert!(start > 0.8); + assert_eq!(surface_pop(SURFACE_POP_MS, full()), 1.0); + assert_eq!(surface_pop(SURFACE_POP_MS + 40, full()), 1.0); + } + + #[test] + fn fade_is_short_and_almost_linear() { + assert!((150..=180).contains(&FADE_MS)); + let mid = fade_opacity(FADE_MS / 2, full()); + assert!( + (mid - 0.5).abs() < 0.08, + "a 160ms fade should be close to linear at the midpoint: {mid}" + ); + assert_eq!(fade_opacity(FADE_MS, full()), 1.0); + } + + #[test] + fn reduced_and_still_skip_decorative_pop_and_fade() { + for policy in [reduced(), still()] { + assert_eq!(surface_pop(0, policy), 1.0); + assert_eq!(surface_exit(0, policy), 0.0); + assert_eq!(fade_opacity(0, policy), 1.0); + } + } + + #[test] + fn authored_one_shots_stay_snappy() { + assert_eq!(WELCOME_SURFACE_MS, 640); + assert_eq!(RECEIPT_STAGGER_MS, 70); + assert_eq!(FISH_FLEE_MS, 800); + } +} diff --git a/crates/tui/src/tui/motion/mod.rs b/crates/tui/src/tui/motion/mod.rs index 4b2f5f0361..3cc20aca38 100644 --- a/crates/tui/src/tui/motion/mod.rs +++ b/crates/tui/src/tui/motion/mod.rs @@ -15,6 +15,7 @@ //! Provider SSE deltas are **input**, never animation timing. The display //! clock ([`crate::tui::streaming::StreamDisplayClock`]) coalesces them. +pub mod ethos; pub mod frame_requester; pub mod mode; diff --git a/crates/tui/src/tui/settings_picker/controller.rs b/crates/tui/src/tui/settings_picker/controller.rs index d0b46af580..735916655c 100644 --- a/crates/tui/src/tui/settings_picker/controller.rs +++ b/crates/tui/src/tui/settings_picker/controller.rs @@ -4,7 +4,10 @@ //! mapping from visible row → source index. Callers never re-filter ad hoc //! during render; they read [`SettingsPickerController::visible`] instead. +use std::time::Instant; + use super::option::{SettingAvailability, SettingOption}; +use crate::tui::motion::{MotionPolicy, ethos}; /// Outcome of a navigation or commit attempt. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -34,6 +37,9 @@ pub struct SettingsPickerController { selected_visible: usize, /// Snapshot of the selection id when the picker opened (for rollback). original_id: String, + /// One-shot pop-in clock. Spatial memory stays put: no slide, no bounce. + #[allow(dead_code)] // picker DIM host API; live redraw driver is a follow-up + opened_at: Instant, } impl SettingsPickerController { @@ -54,6 +60,7 @@ impl SettingsPickerController { filtered: Vec::new(), selected_visible: 0, original_id: original_id.into(), + opened_at: Instant::now(), }; controller.recompute_filter(None); // Prefer landing on the original id when it exists. @@ -303,6 +310,26 @@ impl SettingsPickerController { } } + /// Snappy pop from ~87% → 1.0. Reduced/Still land immediately. + #[must_use] + #[allow(dead_code)] // picker DIM host API; live redraw driver is a follow-up + pub fn settle_pop(&self, now: Instant, policy: MotionPolicy) -> f32 { + ethos::surface_pop( + now.saturating_duration_since(self.opened_at).as_millis(), + policy, + ) + } + + /// True while the one-shot pop is still landing. Hosts that already + /// redraw on a timer can DIM until this returns false; Reduced/Still + /// are never settling. + #[must_use] + #[allow(dead_code)] // picker DIM host API; live redraw driver is a follow-up + pub fn is_settling(&self, now: Instant, policy: MotionPolicy) -> bool { + policy.allows_decorative() + && now.saturating_duration_since(self.opened_at).as_millis() < ethos::SURFACE_POP_MS + } + fn preview_if_available(&self) -> PickerNavResult { match self.selected_option() { Some(option) if option.availability.is_available() => PickerNavResult::Preview, diff --git a/crates/tui/src/tui/settings_picker/mod.rs b/crates/tui/src/tui/settings_picker/mod.rs index f53b15abee..47ffa8b907 100644 --- a/crates/tui/src/tui/settings_picker/mod.rs +++ b/crates/tui/src/tui/settings_picker/mod.rs @@ -357,4 +357,36 @@ mod tests { Some("swatch") ); } + + #[test] + fn picker_settle_pops_from_nearly_full_and_skips_when_still() { + use std::time::{Duration, Instant}; + + use crate::tui::motion::MotionPolicy; + use crate::tui::motion::ethos::{SURFACE_POP_FROM, SURFACE_POP_MS}; + + let controller = SettingsPickerController::new(sample_options(), "system"); + let opened = Instant::now(); + let full = MotionPolicy::from_settings(false, true, false); + let pop = controller.settle_pop(opened, full); + assert!( + (pop - SURFACE_POP_FROM).abs() < 0.02, + "picker must pop from ~87%, not grow from 0: {pop}" + ); + assert!(controller.is_settling(opened, full)); + assert_eq!( + controller.settle_pop(opened + Duration::from_millis(SURFACE_POP_MS as u64), full), + 1.0 + ); + assert!( + !controller.is_settling(opened + Duration::from_millis(SURFACE_POP_MS as u64), full) + ); + + let still = MotionPolicy::from_settings(false, false, false); + assert_eq!(controller.settle_pop(opened, still), 1.0); + assert!(!controller.is_settling(opened, still)); + let reduced = MotionPolicy::from_settings(true, true, false); + assert_eq!(controller.settle_pop(opened, reduced), 1.0); + assert!(!controller.is_settling(opened, reduced)); + } } diff --git a/crates/tui/src/tui/ui/frame.rs b/crates/tui/src/tui/ui/frame.rs index 4c6307dd18..21ff990839 100644 --- a/crates/tui/src/tui/ui/frame.rs +++ b/crates/tui/src/tui/ui/frame.rs @@ -727,6 +727,8 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<( // Launch is a distinct full-canvas choice state, not a reading column. // Keep it edge-to-edge so opening Codewhale never recreates black side // banks before the responsive session ocean takes over. + // This surface occludes the idle whale: the 640 ms welcome + shine + // wait until launch dismisses (no workspace-slide theater). crate::tui::underwater::render_launch_screen(size, f.buffer_mut(), app); crate::tui::underwater::record_launch_row_areas(size, &mut app.launch); if !app.view_stack.is_empty() { diff --git a/crates/tui/src/tui/underwater.rs b/crates/tui/src/tui/underwater.rs index 8fc233c5b7..c6eabb4e76 100644 --- a/crates/tui/src/tui/underwater.rs +++ b/crates/tui/src/tui/underwater.rs @@ -7,6 +7,7 @@ //! sidebar + dashboard + footer composition with four owners for one fact. use std::borrow::Cow; +use std::time::Instant; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use ratatui::{ @@ -347,6 +348,17 @@ const IDLE_SHIMMER_CYCLE_MS: u128 = 4_000; const IDLE_SHIMMER_SWEEP_FRACTION: f32 = 0.32; const IDLE_SHIMMER_BAND_HALF_WIDTH: f32 = 0.38; const IDLE_SHIMMER_STRENGTH: f32 = 0.33; +/// One-shot empty-state surface: the idle whale lands out of the water. +/// Ease-out-quint over this window; after it the ordinary idle caustic owns +/// the mark. Not a splash; input is not delayed. +const STARTUP_SURFACE_MS: u128 = crate::tui::motion::ethos::WELCOME_SURFACE_MS; +/// The name is the second beat. It writes left to right in the same cyan +/// current as the whale's belly, then settles to body ink. Unrevealed +/// letters keep the word's width so the block does not walk as it writes. +const STARTUP_WORDMARK: &str = "Codewhale"; +const STARTUP_WORDMARK_START_MS: u128 = 120; +const STARTUP_LETTER_STAGGER_MS: u128 = 48; +const STARTUP_LETTER_SETTLE_MS: u128 = crate::tui::motion::ethos::SURFACE_POP_MS; /// The build-version string the header renders. An unstamped local build uses /// the build script's development marker while CI/release carries its source @@ -842,9 +854,11 @@ fn launch_workspace_name(app: &App) -> String { ) } -/// Render the distinct pre-session choice state. This screen contains no -/// transcript, composer, dashboard, or post-launch whale: each row dispatches -/// to real session/worktree machinery before the idle ocean is entered. +/// Render the distinct pre-session choice state. This screen is full-canvas +/// and occludes the idle whale: the 640 ms surface + wordmark wait until +/// launch dismisses so they cannot finish behind this menu. Each row +/// dispatches to real session/worktree machinery before the idle ocean is +/// entered. No workspace-slide theater — the session replaces this surface. pub fn render_launch_screen(area: Rect, buf: &mut Buffer, app: &App) { if area.width == 0 || area.height == 0 { return; @@ -1509,6 +1523,37 @@ fn idle_mark_animation_enabled(app: &App) -> bool { decorative_shell_motion_enabled(app) && matches!(ShellPhase::from_app(app), ShellPhase::Idle) } +/// Full-canvas overlays that cover the idle whale. The 640 ms surface must +/// not run here — launch is a choice list, not a travel animation. +#[must_use] +pub(crate) fn welcome_surface_occluded(app: &App) -> bool { + app.launch.visible || app.onboarding != OnboardingState::None +} + +/// Start the idle-whale surface the first time that mark is actually on +/// screen. Launch and onboarding sit in front of the empty ocean; starting +/// at `App` construction lets the 640 ms rise and shine finish behind those +/// surfaces. +pub(crate) fn ensure_welcome_surface_started(app: &mut App, area: Rect) { + if welcome_surface_occluded(app) + || !idle_mark_animation_enabled(app) + || !empty_state_mark_visible(area) + || app.welcome_visible_since.is_some() + { + return; + } + app.welcome_visible_since = Some(Instant::now()); +} + +/// Elapsed ms for the one-shot whale surface / wordmark / shine. Zero while +/// occluded or before the first visible empty-ocean frame. +#[must_use] +pub(crate) fn welcome_elapsed_ms(app: &App) -> u128 { + app.welcome_visible_since + .map(|started| started.elapsed().as_millis()) + .unwrap_or(0) +} + /// Raised-cosine caustic band for the idle whale. The 4s cycle spends roughly /// 1.3s crossing the mark and parks off-screen for the remainder, so the brand /// has a clear moment of life without becoming looping chrome. @@ -1527,6 +1572,92 @@ fn idle_mark_shine_opacity(diagonal: f32, elapsed_ms: u128) -> f32 { IDLE_SHIMMER_STRENGTH * raised_cosine } +/// Ease-out-quint land from ~87% presence → settled over [`STARTUP_SURFACE_MS`]. +/// `None` after the window so callers fall back to the idle mark. Surfaces +/// never grow from a vanishing point. +#[must_use] +fn startup_surface_opacity(elapsed_ms: u128) -> Option { + if elapsed_ms >= STARTUP_SURFACE_MS { + return None; + } + let t = elapsed_ms as f32 / STARTUP_SURFACE_MS as f32; + let landed = crate::tui::motion::ethos::ease_out_quint(t); + Some( + crate::tui::motion::ethos::SURFACE_POP_FROM + + (1.0 - crate::tui::motion::ethos::SURFACE_POP_FROM) * landed, + ) +} + +/// None until the letter's beat. `Some(0)` is current cyan; `Some(1)` is +/// settled body ink. The character stays in the line the whole time so +/// tests and centering see "Codewhale"; unrevealed letters just share the +/// surface color. +#[must_use] +fn wordmark_letter_settle(elapsed_ms: u128, index: usize) -> Option { + let start = STARTUP_WORDMARK_START_MS + index as u128 * STARTUP_LETTER_STAGGER_MS; + if elapsed_ms < start { + return None; + } + let t = (elapsed_ms - start) as f32 / STARTUP_LETTER_SETTLE_MS as f32; + Some(crate::tui::motion::ethos::ease_out_quint(t.min(1.0))) +} + +fn startup_wordmark_spans( + elapsed_ms: u128, + animated: bool, + body: Color, + current: Color, + surface: Color, +) -> Vec> { + STARTUP_WORDMARK + .chars() + .enumerate() + .map(|(index, ch)| { + let color = if !animated { + body + } else { + match wordmark_letter_settle(elapsed_ms, index) { + None => surface, + Some(settle) => idle_mark_color(current, body, settle), + } + }; + Span::styled( + String::from(ch), + Style::default().fg(color).add_modifier(Modifier::BOLD), + ) + }) + .collect() +} + +/// One caustic pass timed to the surface window. After the rise, shine is 0 +/// so the slower idle cycle can take over without a double-flash. +#[must_use] +fn startup_shine_opacity(diagonal: f32, elapsed_ms: u128) -> f32 { + if elapsed_ms >= STARTUP_SURFACE_MS { + return 0.0; + } + // Almost-linear pass: a caustic is light travel, not a surface landing. + // Ease-out-quint would park the band off the mark before the window ends. + let sweep_progress = (elapsed_ms as f32 / STARTUP_SURFACE_MS as f32).min(1.0); + let band_position = + -IDLE_SHIMMER_BAND_HALF_WIDTH + sweep_progress * (1.0 + 2.0 * IDLE_SHIMMER_BAND_HALF_WIDTH); + let distance = (diagonal - band_position).abs(); + if distance >= IDLE_SHIMMER_BAND_HALF_WIDTH { + return 0.0; + } + let raised_cosine = + 0.5 * (1.0 + (std::f32::consts::PI * distance / IDLE_SHIMMER_BAND_HALF_WIDTH).cos()); + IDLE_SHIMMER_STRENGTH * raised_cosine +} + +#[must_use] +fn emerge_from_surface(color: Color, surface: Color, emerge: Option) -> Color { + match emerge { + Some(opacity) if opacity < 1.0 => idle_mark_color(surface, color, opacity), + _ => color, + } +} + #[must_use] fn idle_mark_color(base: Color, highlight: Color, opacity: f32) -> Color { if opacity <= 0.0 { @@ -1576,6 +1707,8 @@ fn idle_whale_row_spans( base: Color, highlight: Color, eye: Color, + surface: Color, + emerge: Option, ) -> Vec> { let rows = IDLE_WHALE_ROWS.len() as f32; let cols = IDLE_WHALE_ROWS @@ -1586,21 +1719,23 @@ fn idle_whale_row_spans( let mut spans = Vec::new(); let mut run = String::new(); let mut run_color = None; + let rising = emerge.is_some(); for (column, ch) in text.chars().enumerate() { let diagonal = (column as f32 + (rows - 1.0 - row as f32)) / (cols + rows); let color = if matches!(ch, '·' | '░' | '✦' | '△') { // Soft uwu blush/sparkle and the quiet crown-fluke center use the // eye/sakura channel; classic otherwise only has the eye dot. - eye + emerge_from_surface(eye, surface, emerge) } else if animated { - idle_mark_color( - base, - highlight, - idle_mark_shine_opacity(diagonal, elapsed_ms), - ) + let shine = if rising { + startup_shine_opacity(diagonal, elapsed_ms) + } else { + idle_mark_shine_opacity(diagonal, elapsed_ms.saturating_sub(STARTUP_SURFACE_MS)) + }; + idle_mark_color(emerge_from_surface(base, surface, emerge), highlight, shine) } else { - base + emerge_from_surface(base, surface, emerge) }; if run_color != Some(color) { if let Some(previous) = run_color { @@ -1705,11 +1840,18 @@ pub fn empty_state_lines(app: &App, area: Rect) -> Vec> { let mut lines = vec![Line::from(""); usize::from(area.height / 4)]; if empty_state_mark_visible(area) { let animated = idle_mark_animation_enabled(app); - let elapsed_ms = app.ocean_started_at.elapsed().as_millis(); + let elapsed_ms = welcome_elapsed_ms(app); + let emerge = animated + .then(|| startup_surface_opacity(elapsed_ms)) + .flatten(); let spout = idle_whale_spout_row(app); let rows = idle_whale_rows(app); let current = idle_whale_current_color(app); - let mut mark = vec![vec![Span::styled(spout, Style::default().fg(current))]]; + let surface = app.ui_theme.surface_bg; + let mut mark = vec![vec![Span::styled( + spout, + Style::default().fg(emerge_from_surface(current, surface, emerge)), + )]]; // Soft uwu: sakura blush/sparkle glyphs; classic keeps body peach + text eye. let highlight = if idle_whale_is_uwu(app) { app.ui_theme.accent_primary @@ -1732,6 +1874,8 @@ pub fn empty_state_lines(app: &App, area: Rect) -> Vec> { }, app.ui_theme.text_body, highlight, + surface, + emerge, ) })); // The spout, head, belly, peduncle, and flukes are one drawing. Give @@ -1768,14 +1912,22 @@ pub fn empty_state_lines(app: &App, area: Rect) -> Vec> { app.mcp_configured_count, width, ); - let brand = "Codewhale"; - let brand_inset = " ".repeat(width.saturating_sub(brand.width()) / 2); - lines.push(Line::from(Span::styled( - format!("{brand_inset}{brand}"), - Style::default() - .fg(app.ui_theme.text_body) - .add_modifier(Modifier::BOLD), - ))); + let elapsed_ms = welcome_elapsed_ms(app); + let animated = idle_mark_animation_enabled(app); + let surface = app.ui_theme.surface_bg; + let current = idle_whale_current_color(app); + let brand_inset = " ".repeat(width.saturating_sub(STARTUP_WORDMARK.width()) / 2); + let mut brand = vec![Span::raw(brand_inset)]; + brand.extend(startup_wordmark_spans( + elapsed_ms, + animated, + app.ui_theme.text_body, + current, + surface, + )); + lines.push(Line::from(brand)); + // Caption and prompt are facts, not brand. They stay readable for the + // whole arrival instead of fading in from the water. let context = truncate_to_width(&context, width); let inset = " ".repeat(width.saturating_sub(context.width()) / 2); lines.push(Line::from(Span::styled( @@ -1795,6 +1947,136 @@ pub fn empty_state_lines(app: &App, area: Rect) -> Vec> { lines } +#[cfg(test)] +mod startup_surface_tests { + use super::{ + STARTUP_LETTER_SETTLE_MS, STARTUP_LETTER_STAGGER_MS, STARTUP_SURFACE_MS, STARTUP_WORDMARK, + STARTUP_WORDMARK_START_MS, empty_state_mark_visible, ensure_welcome_surface_started, + startup_shine_opacity, startup_surface_opacity, welcome_elapsed_ms, + welcome_surface_occluded, wordmark_letter_settle, + }; + use crate::tui::app::OnboardingState; + use crate::tui::motion::ethos; + use ratatui::layout::Rect; + + fn idle_app() -> crate::tui::app::App { + let mut app = crate::test_support::test_app_with_options( + crate::test_support::test_tui_options(std::env::temp_dir()), + ); + app.low_motion = false; + app.fancy_animations = true; + app.onboarding = OnboardingState::None; + app.launch.visible = false; + app + } + + #[test] + fn surface_lands_from_nearly_full_presence_then_hands_off() { + let start = startup_surface_opacity(0).expect("starts inside the window"); + assert!( + (start - ethos::SURFACE_POP_FROM).abs() < 0.02, + "first frame must already occupy most of the mark, not rise from 0: {start}" + ); + let mid = startup_surface_opacity(STARTUP_SURFACE_MS / 2).expect("mid-rise"); + assert!( + mid > 0.98, + "ease-out-quint should have landed by halfway: {mid}" + ); + assert!( + startup_surface_opacity(STARTUP_SURFACE_MS).is_none(), + "the idle mark owns the scene the moment the rise ends" + ); + assert!(startup_surface_opacity(STARTUP_SURFACE_MS + 80).is_none()); + } + + #[test] + fn wordmark_writes_codewhale_one_letter_at_a_time() { + assert_eq!(STARTUP_WORDMARK, "Codewhale"); + assert!(wordmark_letter_settle(0, 0).is_none()); + assert!(wordmark_letter_settle(STARTUP_WORDMARK_START_MS.saturating_sub(1), 0).is_none()); + let first = wordmark_letter_settle(STARTUP_WORDMARK_START_MS, 0).expect("C lands"); + assert!(first < 0.05, "{first}"); + assert!(wordmark_letter_settle(STARTUP_WORDMARK_START_MS, 1).is_none()); + let last = STARTUP_WORDMARK.chars().count() - 1; + let last_start = STARTUP_WORDMARK_START_MS + last as u128 * STARTUP_LETTER_STAGGER_MS; + assert!(wordmark_letter_settle(last_start.saturating_sub(1), last).is_none()); + assert_eq!( + wordmark_letter_settle(last_start + STARTUP_LETTER_SETTLE_MS, last), + Some(1.0) + ); + } + + #[test] + fn shine_is_one_pass_not_a_loop() { + assert_eq!(startup_shine_opacity(0.5, STARTUP_SURFACE_MS), 0.0); + let mid = startup_shine_opacity(0.5, STARTUP_SURFACE_MS / 2); + assert!( + mid > 0.0, + "the caustic should cross the body during the rise" + ); + assert!(mid <= 0.33); + } + + #[test] + fn welcome_clock_stays_stopped_until_the_mark_can_draw() { + let mut app = idle_app(); + assert!(app.welcome_visible_since.is_none()); + assert_eq!(welcome_elapsed_ms(&app), 0); + assert!(!welcome_surface_occluded(&app)); + + let too_small = Rect::new(0, 0, 40, 12); + assert!(!empty_state_mark_visible(too_small)); + ensure_welcome_surface_started(&mut app, too_small); + assert!(app.welcome_visible_since.is_none()); + + app.launch.visible = true; + let roomy = Rect::new(0, 0, 80, 24); + assert!(empty_state_mark_visible(roomy)); + assert!(welcome_surface_occluded(&app)); + ensure_welcome_surface_started(&mut app, roomy); + assert!( + app.welcome_visible_since.is_none(), + "launch is full-canvas; the 640ms surface must not run behind it" + ); + + app.launch.visible = false; + app.onboarding = OnboardingState::Welcome; + assert!(welcome_surface_occluded(&app)); + ensure_welcome_surface_started(&mut app, roomy); + assert!( + app.welcome_visible_since.is_none(), + "onboarding occludes the idle whale" + ); + + app.onboarding = OnboardingState::None; + ensure_welcome_surface_started(&mut app, roomy); + assert!(app.welcome_visible_since.is_some()); + assert!(welcome_elapsed_ms(&app) < STARTUP_SURFACE_MS); + } + + #[test] + fn reduced_and_still_never_start_the_welcome_clock() { + let roomy = Rect::new(0, 0, 80, 24); + let mut reduced = idle_app(); + reduced.low_motion = true; + ensure_welcome_surface_started(&mut reduced, roomy); + assert!(reduced.welcome_visible_since.is_none()); + + let mut still = idle_app(); + still.fancy_animations = false; + ensure_welcome_surface_started(&mut still, roomy); + assert!(still.welcome_visible_since.is_none()); + } + + #[test] + fn authored_one_shots_stay_on_the_ethos_clock() { + assert_eq!(STARTUP_SURFACE_MS, ethos::WELCOME_SURFACE_MS); + assert_eq!(STARTUP_LETTER_SETTLE_MS, ethos::SURFACE_POP_MS); + assert_eq!(ethos::RECEIPT_STAGGER_MS, 70); + assert_eq!(ethos::FISH_FLEE_MS, 800); + } +} + #[cfg(test)] mod empty_state_caption_tests { use super::{empty_state_caption, shorten_workspace}; diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 837fcbe8b9..831dd98b32 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -203,7 +203,7 @@ impl ChatWidget { .then_some(()) .and(app.turn_started_at) .map(|started| started.elapsed().as_millis()) - .filter(|elapsed| *elapsed < 800) + .filter(|elapsed| *elapsed < crate::tui::motion::ethos::FISH_FLEE_MS) .filter(|_| matches!(phase, ShellPhase::Working | ShellPhase::Verifying)); let scroll_track = app.ui_theme.border; let scroll_thumb = app.ui_theme.status_working; @@ -214,6 +214,7 @@ impl ChatWidget { render_options.reasoning_preview_viewport_lines = Some(visible_lines); if render_empty_state { + crate::tui::underwater::ensure_welcome_surface_started(app, content_area); let lines = build_empty_state_lines(app, content_area); app.viewport.last_transcript_area = Some(content_area); app.viewport.last_transcript_top = 0; @@ -697,7 +698,8 @@ fn apply_receipt_settle_cascade( #[must_use] fn receipt_is_settling(receipt_order: usize, elapsed_ms: u128) -> bool { - let delay = u128::try_from(receipt_order.min(6)).unwrap_or(6) * 70; + let delay = u128::try_from(receipt_order.min(6)).unwrap_or(6) + * crate::tui::motion::ethos::RECEIPT_STAGGER_MS; elapsed_ms < delay + 140 } @@ -4320,8 +4322,8 @@ mod tests { use crate::palette; use crate::tui::active_cell::ActiveCell; use crate::tui::app::{ - App, AppMode, ComposerDensity, TaskPanelEntry, TaskPanelEntryKind, ToolCollapseMode, - TranscriptSpacing, TuiOptions, + App, AppMode, ComposerDensity, OnboardingState, TaskPanelEntry, TaskPanelEntryKind, + ToolCollapseMode, TranscriptSpacing, TuiOptions, }; use crate::tui::history::{ ExecCell, ExecSource, GenericToolCell, HistoryCell, ToolCell, ToolRun, ToolStatus, @@ -4334,7 +4336,10 @@ mod tests { style::{Color, Modifier, Style}, text::{Line, Span}, }; - use std::{path::PathBuf, time::Instant}; + use std::{ + path::PathBuf, + time::{Duration, Instant}, + }; use unicode_width::UnicodeWidthStr; fn create_test_app() -> App { @@ -6500,6 +6505,59 @@ mod tests { ); } + #[test] + fn idle_welcome_waits_behind_launch_then_starts_on_the_empty_ocean() { + let mut app = create_test_app(); + app.low_motion = false; + app.fancy_animations = true; + app.onboarding = OnboardingState::None; + app.launch.visible = true; + let area = Rect::new(0, 0, 80, 24); + + let _ = ChatWidget::new(&mut app, area); + assert!( + app.welcome_visible_since.is_none(), + "launch sits in front of the idle whale, so the 640ms surface must wait" + ); + + app.launch.visible = false; + let _ = ChatWidget::new(&mut app, area); + let started = app + .welcome_visible_since + .expect("the welcome shine starts once the empty ocean is on screen"); + + std::thread::sleep(Duration::from_millis(20)); + let _ = ChatWidget::new(&mut app, area); + assert_eq!( + app.welcome_visible_since, + Some(started), + "later idle frames keep the same welcome clock" + ); + } + + #[test] + fn idle_welcome_waits_behind_onboarding() { + let mut app = create_test_app(); + app.low_motion = false; + app.fancy_animations = true; + app.onboarding = OnboardingState::Welcome; + app.launch.visible = false; + let area = Rect::new(0, 0, 80, 24); + + let _ = ChatWidget::new(&mut app, area); + assert!( + app.welcome_visible_since.is_none(), + "onboarding sits in front of the idle whale, so the shine must wait" + ); + + app.onboarding = OnboardingState::None; + let _ = ChatWidget::new(&mut app, area); + assert!( + app.welcome_visible_since.is_some(), + "the welcome shine starts once onboarding hands off the ocean" + ); + } + #[test] fn empty_state_shows_startup_context() { let mut app = create_test_app(); @@ -8232,9 +8290,18 @@ diff --git a/src/b.rs b/src/b.rs\n\ #[test] fn fish_flee_is_one_shot_and_returns_to_ambient_origin() { + assert_eq!(crate::tui::motion::ethos::FISH_FLEE_MS, 800); assert_eq!(fish_flee_offset(0), 0); assert!(fish_flee_offset(400) >= 8); assert_eq!(fish_flee_offset(800), 0); assert_eq!(fish_flee_offset(8_000), 0); } + + #[test] + fn receipt_stagger_stays_seventy_milliseconds() { + assert_eq!(crate::tui::motion::ethos::RECEIPT_STAGGER_MS, 70); + assert!(receipt_is_settling(0, 0)); + assert!(!receipt_is_settling(0, 140)); + assert!(receipt_is_settling(1, 70)); + } } diff --git a/docs/ACCESSIBILITY.md b/docs/ACCESSIBILITY.md index d3b54444bc..9b0e2d7bd2 100644 --- a/docs/ACCESSIBILITY.md +++ b/docs/ACCESSIBILITY.md @@ -12,7 +12,7 @@ visual motion and density for screen-reader and low-motion users. | `NO_ANIMATIONS=1` env var | unset | At startup, forces `low_motion = true` and `fancy_animations = false`. Overrides whatever's saved in `settings.toml`. | | `CODEWHALE_ASCII_SAFE=1` env var | unset | Replaces decorative Unicode and box-drawing marks with narrow ASCII at the terminal backend. Labels, focus, state, and controls remain available. | | `low_motion` setting | `false` | Freezes decorative and state animation without changing model text delivery. The footer water strip is controlled separately by `fancy_animations`. | -| `fancy_animations` setting | `true` | Enables expressive live-state chrome. Set to `false` to keep live-turn chrome still. | +| `fancy_animations` setting | `true` | Enables expressive live-state chrome. Set to `false` to keep live-turn chrome still. The empty-state whale's 640 ms launch surface and the Codewhale letter-write are decorative and stay still when this is off or `low_motion` is on. | | `ocean_treatment` setting | `ombre` | Chooses the background appearance: `ombre` paints the state-reactive water column; `flat` uses the plain theme surface. Both keep the same state marks and idle ambient life; appearance is independent of motion settings. | | `status_indicator` setting | `cw` | Static typographic header mark. Set to `dots` for the legacy animation, or `off` to hide it; `whale` is retired and normalizes to `cw`. | | `calm_mode` setting | `true` | Collapses tool-output details by default and trims status messages. Useful for screen readers that announce every redraw. | diff --git a/docs/MOTION_CONTRACT.md b/docs/MOTION_CONTRACT.md index 464d5f5d91..a377a9ed78 100644 --- a/docs/MOTION_CONTRACT.md +++ b/docs/MOTION_CONTRACT.md @@ -40,6 +40,23 @@ coalesces decorative frame wakes. The main `ui` poll loop remains the only - These treatments never add/remove transcript rows, change hitboxes, or use provider delta timing as an animation clock. +## Ethos (Omarchy timing, Codewhale look) + +Timing lives in `crates/tui/src/tui/motion/ethos.rs`. Infuse, do not clone +Hyprland: Codewhale keeps Blue Stage water, the whale, gold current, and +ombre depth. `rounding=0` is Omarchy's look, not ours. + +- Arrivals use ease-out-quint. Things land; they do not ease-in from a crawl. +- Exits are faster than entries and closer to linear. +- Surfaces pop from ~87% scale, never from 0. No workspace/page slide. +- Fade is almost-linear and short (150–180 ms). No 400 ms theatrical dissolves. +- One-shot state motion, not looping decoration on every chrome. +- Chef's choice: one good default. Reduced/Still skip decorative treatments + (`MotionPolicy::allows_decorative`). +- The 640 ms idle-whale surface and wordmark wait until the mark is actually + on screen. Full-canvas launch/onboarding occlude that clock so the shine + cannot finish behind a menu. + ## Honesty note: catch-up is staged, not wired `note_delta_with_backlog` and the catch-up thresholds exist and are tested,