Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
81 changes: 81 additions & 0 deletions MOTION_ETHOS_HANDOFF.md
Original file line number Diff line number Diff line change
@@ -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<Instant>` 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<Instant>` — 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.
8 changes: 8 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion crates/tui/src/tui/ambient_life.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
12 changes: 12 additions & 0 deletions crates/tui/src/tui/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Instant>,
/// 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
Expand Down
1 change: 1 addition & 0 deletions crates/tui/src/tui/app/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
184 changes: 184 additions & 0 deletions crates/tui/src/tui/motion/ethos.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
1 change: 1 addition & 0 deletions crates/tui/src/tui/motion/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
27 changes: 27 additions & 0 deletions crates/tui/src/tui/settings_picker/controller.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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 {
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading