diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca356b7..20fc9d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,11 @@ jobs: - uses: dtolnay/rust-toolchain@master with: toolchain: "1.88" + components: clippy - uses: Swatinem/rust-cache@v2 - run: cargo generate-lockfile - - run: cargo check --features async,serde + - run: cargo check -p superlighttui --features async,serde + - run: cargo clippy -p superlighttui --all-features -- -D warnings test: name: Test @@ -109,15 +111,24 @@ jobs: - run: cargo check -p superlighttui --no-default-features check-wasm: - name: Check (WASM) + name: Check (WASM backend) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: + components: clippy targets: wasm32-unknown-unknown - uses: Swatinem/rust-cache@v2 - - run: cargo check -p slt-wasm --target wasm32-unknown-unknown + - uses: taiki-e/install-action@wasm-pack + - uses: browser-actions/setup-chrome@v1 + - uses: nanasess/setup-chromedriver@v2 + - run: cargo check -p slt-wasm --all-features --target wasm32-unknown-unknown + - run: cargo clippy -p slt-wasm --all-features --target wasm32-unknown-unknown -- -D warnings + - run: cargo test -p slt-wasm --all-features + - run: chrome --version || google-chrome --version + - run: chromedriver --version + - run: CHROMEDRIVER="$(command -v chromedriver)" wasm-pack test --headless --chrome crates/slt-wasm feature-check: name: Feature Combinations diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d9e133e..3f5cf68 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,65 +11,211 @@ env: CARGO_TERM_COLOR: always jobs: - ci-stable: - name: CI Gate (stable) + fmt: + name: Format runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable with: - components: clippy, rustfmt + components: rustfmt + - run: cargo fmt -- --check + + check-stable: + name: Check (stable) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - run: cargo check --all-features - # See ci.yml — perf-alloc tests use a global counter, so CI's higher - # parallelism contaminates measurements. Single-threaded keeps the - # budget asserts honest. - - run: cargo test --all-features -- --test-threads=1 + + clippy: + name: Clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + - uses: Swatinem/rust-cache@v2 - run: cargo clippy --all-features -- -D warnings - - run: cargo fmt -- --check - ci-msrv: - name: CI Gate (MSRV 1.88) + test: + name: Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo test --all-features + + examples: + name: Examples + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo check --examples --all-features + + msrv: + name: Check (MSRV 1.88) runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@master with: toolchain: "1.88" + components: clippy - uses: Swatinem/rust-cache@v2 - run: cargo generate-lockfile - - run: cargo check --features async,serde + - run: cargo check -p superlighttui --features async,serde + - run: cargo clippy -p superlighttui --all-features -- -D warnings + + typos: + name: Typos + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: crate-ci/typos@master + + check-no-default: + name: Check (no-default-features) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo check -p superlighttui --no-default-features + + wasm-backend: + name: Check (WASM backend) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy + targets: wasm32-unknown-unknown + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@wasm-pack + - uses: browser-actions/setup-chrome@v1 + - uses: nanasess/setup-chromedriver@v2 + - run: cargo check -p slt-wasm --all-features --target wasm32-unknown-unknown + - run: cargo clippy -p slt-wasm --all-features --target wasm32-unknown-unknown -- -D warnings + - run: cargo test -p slt-wasm --all-features + - run: chrome --version || google-chrome --version + - run: chromedriver --version + - run: CHROMEDRIVER="$(command -v chromedriver)" wasm-pack test --headless --chrome crates/slt-wasm + + feature-check: + name: Feature Combinations + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: taiki-e/install-action@cargo-hack + - name: Check each feature independently + run: cargo hack check -p superlighttui --each-feature --no-dev-deps + + audit: + name: Audit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions-rust-lang/audit@v1 + + deny: + name: Deny Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: EmbarkStudios/cargo-deny-action@v2 + with: + command: check verify-version: name: Verify Version runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Check tag matches Cargo.toml + - name: Check tag matches package versions run: | + set -euo pipefail TAG="${GITHUB_REF#refs/tags/v}" - CARGO_VER=$(grep '^version' Cargo.toml | head -1 | sed 's/.*"\(.*\)"/\1/') - if [ "$TAG" != "$CARGO_VER" ]; then - echo "::error::Tag v$TAG does not match Cargo.toml version $CARGO_VER" + ROOT_VER=$(awk -F'"' '/^version =/ { print $2; exit }' Cargo.toml) + WASM_VER=$(awk -F'"' '/^version =/ { print $2; exit }' crates/slt-wasm/Cargo.toml) + WASM_DEP=$(sed -n 's/.*superlighttui = { version = "\([^"]*\)".*/\1/p' crates/slt-wasm/Cargo.toml) + + if [ "$TAG" != "$ROOT_VER" ]; then + echo "::error::Tag v$TAG does not match Cargo.toml version $ROOT_VER" exit 1 fi - echo "Version verified: $CARGO_VER" + if [ "$TAG" != "$WASM_VER" ]; then + echo "::error::Tag v$TAG does not match slt-wasm version $WASM_VER" + exit 1 + fi + if [ "$TAG" != "$WASM_DEP" ]; then + echo "::error::Tag v$TAG does not match slt-wasm dependency $WASM_DEP" + exit 1 + fi + echo "Version verified: $TAG" + + publish-superlighttui: + name: Publish superlighttui + needs: + - fmt + - check-stable + - clippy + - test + - examples + - msrv + - typos + - check-no-default + - wasm-backend + - feature-check + - audit + - deny + - verify-version + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - run: cargo publish -p superlighttui + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} - publish: - name: Publish to crates.io - needs: [ci-stable, ci-msrv, verify-version] + publish-slt-wasm: + name: Publish slt-wasm + needs: [publish-superlighttui] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - run: cargo publish --allow-dirty + - name: Wait for superlighttui on crates.io + run: | + set -euo pipefail + VERSION="${GITHUB_REF#refs/tags/v}" + for attempt in {1..30}; do + if cargo search superlighttui --limit 1 | grep -q "superlighttui = \"$VERSION\""; then + echo "superlighttui $VERSION is visible in the crates.io index" + exit 0 + fi + echo "Waiting for superlighttui $VERSION in crates.io index (attempt $attempt/30)" + sleep 10 + done + echo "::error::superlighttui $VERSION did not appear in crates.io index" + exit 1 + - run: cargo publish -p slt-wasm env: CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} github-release: name: GitHub Release - needs: [ci-stable, ci-msrv, verify-version] + needs: [publish-slt-wasm] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e6803d..5ca9d0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,69 @@ # Changelog +## [0.22.2] - 2026-06-28 + +Patch release preparation focused on terminal safety, browser backend lifecycle, +state cleanup, API consistency, release gates, and documentation parity. + +### Changed + +- **Color API f64 replacements** (#286) — added `*_f64` public replacements for + color math, theme overlays, text gradient stops, and `AppState::fps_f64`. + Existing `f32` methods remain as deprecated compatibility aliases. +- **Release workflow hardening** (#292) — tag releases now run the full blocking + gate before publishing, including examples, no-default-features, WASM, + feature-combination, typos, audit, and deny checks. +- **Ordered crate publishing** (#292) — `release.yml` publishes + `superlighttui` first, waits for the crates.io index to expose the tag + version, then publishes `slt-wasm` against that exact dependency version. +- **Workspace gate coverage** (#293) — `slt-wasm` is now part of the default + workspace members, CI runs WASM clippy/tests, and a `wasm-pack` browser smoke + verifies DOM mount/flush/dispose behavior. +- **WASM feature wiring** (#293) — `slt-wasm` now exposes a default `bidi` + feature that forwards to `superlighttui/bidi`, keeping browser text behavior + aligned with the native default build. + +### Fixed + +- **Explicit color depth is authoritative** (#284) — terminal foreground and + background SGR are emitted by SLT directly, so `NO_COLOR` affects only + `ColorDepth::detect()` and no longer overrides explicit `TrueColor` output. +- **Hermetic terminal tests** (#285) — terminal capability probes are TTY-guarded + and suspend/resume tests use in-process writers instead of touching real + stdout. +- **WASM runtime lifecycle** (#287) — browser event listeners and animation + frames are owned by `WasmAppHandle`, with `dispose()`/`Drop` cleanup. +- **Async task cleanup** (#288, #297) — background task registries and async + validators now abort superseded or dropped tasks, and async run loops exit + when the input channel disconnects. +- **Terminal graphics gating** (#289, #301) — Kitty, Sixel, and iTerm probing and + emission now respect TTY and tmux/screen guards unless explicitly forced. +- **Terminal teardown safety** (#290, #296) — panic, normal exit, and Unix + suspend/resume share the same cleanup path, including focus and Kitty keyboard + state. +- **Escape sanitization** (#291, #300) — terminal titles and static scrollback + output sanitize control bytes before emitting OSC/static text. +- **Overflow hardening** (#295) — rect edges and timer scheduling use saturating + or elapsed-time arithmetic instead of overflowing additions. +- **Dynamic state cleanup APIs** (#302) — keyed state, inactive screen focus + state, and inactive modes can now be removed or retained by callers. +- **Variable-height list cache cleanup** (#303) — `ListState::set_items` truncates + stale item heights when the item list shrinks. +- **WASM parity fixes** (#298) — DOM mouse/wheel modifiers map into SLT + modifiers, and underline plus strikethrough CSS is emitted together. +- **MSRV clippy cleanliness** (#299) — modernized format arguments that were + reported by `cargo +1.88 clippy --all-features -- -D warnings`. +- **Release version coordination** (#292) — bumped both published crates to + `0.22.2` and updated the `slt-wasm -> superlighttui` dependency requirement. + +### Docs + +- **README install snippet** (#294) — updated the copy-paste dependency version + from `0.21` to `0.22.2`. +- **Examples guide parity** (#294) — `docs/EXAMPLES.md` now lists only explicit + `[[example]]` targets as `cargo run --example ...` commands and routes + source-only demos through their tour binaries. + ## [0.22.1] - 2026-06-22 Patch release closing two community issues (#278, #279). Additive only — no diff --git a/Cargo.lock b/Cargo.lock index 0a5270d..516020c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -35,6 +35,17 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "autocfg" version = "1.5.1" @@ -616,6 +627,12 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -649,6 +666,16 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +[[package]] +name = "minicov" +version = "0.3.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4869b6a491569605d66d3952bcdf03df789e5b536e5f0cf7758a7f08a55ae24d" +dependencies = [ + "cc", + "walkdir", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -712,6 +739,15 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -719,6 +755,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -1166,11 +1203,12 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "slt-wasm" -version = "0.22.1" +version = "0.22.2" dependencies = [ "js-sys", "superlighttui", "wasm-bindgen", + "wasm-bindgen-test", "web-sys", ] @@ -1194,7 +1232,7 @@ checksum = "2b2231b7c3057d5e4ad0156fb3dc807d900806020c5ffa3ee6ff2c8c76fb8520" [[package]] name = "superlighttui" -version = "0.22.1" +version = "0.22.2" dependencies = [ "compact_str", "criterion", @@ -1622,6 +1660,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9473dbd2991ae90b6291c3c32c30c6187ac49aa32f9905d1cce280ec1e110b0f" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.122" @@ -1654,6 +1702,45 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-bindgen-test" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74fde991ccdc895cb7fbaa14b137d62af74d9011be67b71c694bfc40edd3119c" +dependencies = [ + "async-trait", + "cast", + "js-sys", + "libm", + "minicov", + "nu-ansi-term", + "num-traits", + "oorandom", + "serde", + "serde_json", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-bindgen-test-macro", + "wasm-bindgen-test-shared", +] + +[[package]] +name = "wasm-bindgen-test-macro" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e925354648d2a4d1bf205412e36d520a800280622eef4719678d268e5d40e978" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "wasm-bindgen-test-shared" +version = "0.2.122" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "684365b586a9a6256c1cc3544eee8680de48d6041142f581776ec7b139622ae9" + [[package]] name = "wasm-encoder" version = "0.244.0" diff --git a/Cargo.toml b/Cargo.toml index 6818643..31f8083 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,6 @@ [workspace] members = [".", "crates/slt-wasm"] +default-members = [".", "crates/slt-wasm"] # Shared lint configuration applied to every workspace member via # `[lints] workspace = true`. NOTE: `[lints]` is package-scoped, so these @@ -23,7 +24,7 @@ private_intra_doc_links = "warn" [package] name = "superlighttui" -version = "0.22.1" +version = "0.22.2" edition = "2024" description = "Super Light TUI - A lightweight, ergonomic terminal UI library" license = "MIT" diff --git a/README.md b/README.md index 8324c84..bb361f7 100644 --- a/README.md +++ b/README.md @@ -28,16 +28,16 @@ That makes it work equally well for humans prototyping a tool and for coding age - - + + - - - + + + - +
Widget Demo
Widget Demo
cargo run --example demo
Dashboard
Dashboard
cargo run --example demo_dashboard
Website
Website Layout
cargo run --example demo_website
Dashboard
Dashboard
cargo run --example showcase_tour
Website
Website Layout
cargo run --example showcase_tour
Spreadsheet
Spreadsheet
cargo run --example demo_spreadsheet
Games
Games
cargo run --example demo_game
DOOM Fire
DOOM Fire Effect
cargo run --release --example demo_fire
Spreadsheet
Spreadsheet
cargo run --example showcase_tour
Games
Games
cargo run --example showcase_tour
DOOM Fire
DOOM Fire Effect
cargo run --example text_tour
Pretext Reflow
Pretext Reflow — text reflows around the mouse cursor in real time
cargo run --example demo_pretext
Pretext Reflow
Pretext Reflow — text reflows around the mouse cursor in real time
cargo run --example text_tour
@@ -134,7 +134,7 @@ The same closure runs across several entry points. Pick one based on UI shape, n ```toml [dependencies] -superlighttui = { version = "0.21", features = ["async", "image"] } +superlighttui = { version = "0.22.2", features = ["async", "image"] } ``` | Feature | What it adds | @@ -267,13 +267,13 @@ For composition advice, see [Patterns Guide]. | `hello` | `cargo run --example hello` | Smallest possible app | | `counter` | `cargo run --example counter` | State + keyboard input | | `demo` | `cargo run --example demo` | Broad widget tour | -| `demo_dashboard` | `cargo run --example demo_dashboard` | Dashboard layout | -| `demo_cli` | `cargo run --example demo_cli` | CLI tool layout | -| `demo_infoviz` | `cargo run --example demo_infoviz` | Charts and data viz | -| `demo_game` | `cargo run --example demo_game` | Immediate-mode interaction | -| `demo_design_system` | `cargo run --example demo_design_system` | Design tokens, theming, style inheritance | -| `inline` | `cargo run --example inline` | Inline rendering below a normal prompt | -| `async_demo` | `cargo run --example async_demo --features async` | Background messages | +| `showcase_tour` | `cargo run --example showcase_tour` | Dashboard, CLI, website, game, and showcase layouts | +| `cookbook_tour` | `cargo run --example cookbook_tour` | Form, table, modal, file picker, dashboard recipes | +| `text_tour` | `cargo run --example text_tour` | CJK, pretext, image, and rich text demos | +| `canvas_tour` | `cargo run --example canvas_tour` | Canvas and raw drawing | +| `system_tour` | `cargo run --example system_tour --features async` | Inline, static output, async messages, and system demos | +| `v020_tour` | `cargo run --example v020_tour` | v0.20 API feature tour | +| `v0211_tour` | `cargo run --example v0211_tour` | v0.21.1 API additions | The full categorized index — including per-release feature tours and showcase demos — lives in [Examples Guide]. diff --git a/crates/slt-wasm/Cargo.toml b/crates/slt-wasm/Cargo.toml index b162ef7..4a38767 100644 --- a/crates/slt-wasm/Cargo.toml +++ b/crates/slt-wasm/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "slt-wasm" -version = "0.22.1" +version = "0.22.2" edition = "2024" rust-version = "1.88" description = "WASM/browser backend for SuperLightTUI" @@ -10,7 +10,7 @@ homepage = "https://github.com/subinium/SuperLightTUI" documentation = "https://docs.rs/slt-wasm" [dependencies] -superlighttui = { version = "0.22.1", path = "../..", default-features = false } +superlighttui = { version = "0.22.2", path = "../..", default-features = false } wasm-bindgen = "0.2" web-sys = { version = "0.3", features = [ "CssStyleDeclaration", @@ -29,5 +29,12 @@ web-sys = { version = "0.3", features = [ ] } js-sys = "0.3" +[target.'cfg(target_arch = "wasm32")'.dev-dependencies] +wasm-bindgen-test = "0.3" + +[features] +default = ["bidi"] +bidi = ["superlighttui/bidi"] + [lints] workspace = true diff --git a/crates/slt-wasm/src/lib.rs b/crates/slt-wasm/src/lib.rs index 86a9aea..4f380bd 100644 --- a/crates/slt-wasm/src/lib.rs +++ b/crates/slt-wasm/src/lib.rs @@ -13,10 +13,11 @@ #![warn(clippy::dbg_macro)] #![warn(clippy::print_stdout)] #![warn(clippy::print_stderr)] +#![forbid(unsafe_code)] use std::cell::RefCell; use std::io; -use std::rc::Rc; +use std::rc::{Rc, Weak}; use slt::{ AppState, Backend, Buffer, Color, Context, Event, KeyCode, KeyModifiers, Modifiers, @@ -24,11 +25,104 @@ use slt::{ }; use wasm_bindgen::JsCast; use wasm_bindgen::prelude::*; -use web_sys::{Document, HtmlElement, HtmlPreElement, KeyboardEvent, MouseEvent, Window}; +use web_sys::{ + Document, EventTarget, HtmlElement, HtmlPreElement, KeyboardEvent, MouseEvent, Window, +}; + +type RafCallback = Closure; + +thread_local! { + static ACTIVE_APPS: RefCell> = const { RefCell::new(Vec::new()) }; +} + +struct EventListener { + target: EventTarget, + event_type: &'static str, + callback: Closure, +} + +impl EventListener { + fn install( + target: EventTarget, + event_type: &'static str, + callback: Closure, + ) -> Result { + target.add_event_listener_with_callback(event_type, callback.as_ref().unchecked_ref())?; + Ok(Self { + target, + event_type, + callback, + }) + } +} + +impl Drop for EventListener { + fn drop(&mut self) { + let _ = self.target.remove_event_listener_with_callback( + self.event_type, + self.callback.as_ref().unchecked_ref(), + ); + } +} + +struct WasmAppInner { + window: Window, + listeners: Vec, + events: Rc>>, + raf: Option, + raf_id: Option, + running: bool, +} + +impl WasmAppInner { + fn new(window: Window, listeners: Vec, events: Rc>>) -> Self { + Self { + window, + listeners, + events, + raf: None, + raf_id: None, + running: true, + } + } +} + +/// Owned browser runtime handle returned by [`run_wasm_with_handle`]. +/// +/// Dropping or explicitly disposing the handle removes DOM event listeners, +/// cancels any pending `requestAnimationFrame`, and releases the app closure. +/// The compatibility [`run_wasm`] API stores this handle internally until the +/// app exits so existing fire-and-forget callers continue to work. +#[cfg_attr(target_arch = "wasm32", wasm_bindgen)] +pub struct WasmAppHandle { + inner: Rc>, +} + +#[cfg_attr(target_arch = "wasm32", wasm_bindgen)] +impl WasmAppHandle { + /// Stop the app and release all browser resources owned by this handle. + pub fn dispose(&self) { + dispose_inner_now(&self.inner); + } + + /// Return whether the app is still scheduled to render future frames. + #[must_use] + pub fn is_running(&self) -> bool { + self.inner.borrow().running + } +} -/// Shared, re-entrant handle to the `requestAnimationFrame` callback so the -/// closure can schedule its own next tick by name. -type RafHandle = Rc>>>; +impl WasmAppHandle { + fn new(inner: Rc>) -> Self { + Self { inner } + } +} + +impl Drop for WasmAppHandle { + fn drop(&mut self) { + dispose_inner_now(&self.inner); + } +} /// SLT [`Backend`] that paints into a DOM `
`/`` grid and diffs
 /// against the previously flushed frame so only changed cells are rewritten.
@@ -246,11 +340,17 @@ fn style_to_css(style: slt::Style) -> String {
     if style.modifiers.contains(Modifiers::ITALIC) {
         css.push_str("font-style:italic;");
     }
+    let mut text_decorations = Vec::new();
     if style.modifiers.contains(Modifiers::UNDERLINE) {
-        css.push_str("text-decoration:underline;");
+        text_decorations.push("underline");
     }
     if style.modifiers.contains(Modifiers::STRIKETHROUGH) {
-        css.push_str("text-decoration:line-through;");
+        text_decorations.push("line-through");
+    }
+    if !text_decorations.is_empty() {
+        css.push_str("text-decoration-line:");
+        css.push_str(&text_decorations.join(" "));
+        css.push(';');
     }
     if style.modifiers.contains(Modifiers::REVERSED) {
         css.push_str("filter:invert(100%);");
@@ -322,6 +422,57 @@ fn indexed_to_rgb(i: u8) -> (u8, u8, u8) {
     (gray, gray, gray)
 }
 
+fn modifiers_from_bools(shift: bool, control: bool, alt: bool, super_key: bool) -> KeyModifiers {
+    let mut modifiers = KeyModifiers::NONE;
+    if shift {
+        modifiers.0 |= KeyModifiers::SHIFT.0;
+    }
+    if control {
+        modifiers.0 |= KeyModifiers::CONTROL.0;
+    }
+    if alt {
+        modifiers.0 |= KeyModifiers::ALT.0;
+    }
+    if super_key {
+        modifiers.0 |= KeyModifiers::SUPER.0;
+    }
+    modifiers
+}
+
+fn keyboard_event_modifiers(event: &KeyboardEvent) -> KeyModifiers {
+    modifiers_from_bools(
+        event.shift_key(),
+        event.ctrl_key(),
+        event.alt_key(),
+        event.meta_key(),
+    )
+}
+
+fn mouse_event_modifiers(event: &MouseEvent) -> KeyModifiers {
+    modifiers_from_bools(
+        event.shift_key(),
+        event.ctrl_key(),
+        event.alt_key(),
+        event.meta_key(),
+    )
+}
+
+fn reflect_bool(target: &JsValue, key: &str) -> bool {
+    js_sys::Reflect::get(target, &JsValue::from_str(key))
+        .ok()
+        .and_then(|v| v.as_bool())
+        .unwrap_or(false)
+}
+
+fn reflect_event_modifiers(target: &JsValue) -> KeyModifiers {
+    modifiers_from_bools(
+        reflect_bool(target, "shiftKey"),
+        reflect_bool(target, "ctrlKey"),
+        reflect_bool(target, "altKey"),
+        reflect_bool(target, "metaKey"),
+    )
+}
+
 fn keyboard_event_to_slt(event: &KeyboardEvent) -> Option {
     let key = event.key();
     let code = match key.as_str() {
@@ -353,21 +504,7 @@ fn keyboard_event_to_slt(event: &KeyboardEvent) -> Option {
         }
     };
 
-    let mut modifiers = KeyModifiers::NONE;
-    if event.shift_key() {
-        modifiers.0 |= KeyModifiers::SHIFT.0;
-    }
-    if event.ctrl_key() {
-        modifiers.0 |= KeyModifiers::CONTROL.0;
-    }
-    if event.alt_key() {
-        modifiers.0 |= KeyModifiers::ALT.0;
-    }
-    if event.meta_key() {
-        modifiers.0 |= KeyModifiers::SUPER.0;
-    }
-
-    Some(Event::key_mod(code, modifiers))
+    Some(Event::key_mod(code, keyboard_event_modifiers(event)))
 }
 
 fn mouse_button(button: i16) -> MouseButton {
@@ -501,13 +638,75 @@ fn paste_event_text(target: &JsValue) -> Option {
     if text.is_empty() { None } else { Some(text) }
 }
 
+fn event_target(target: &T) -> EventTarget {
+    target.unchecked_ref::().clone()
+}
+
+fn dispose_inner_now(inner: &Rc>) {
+    let mut app = inner.borrow_mut();
+    app.running = false;
+    if let Some(raf_id) = app.raf_id.take() {
+        let _ = app.window.cancel_animation_frame(raf_id);
+    }
+    app.listeners.clear();
+    app.events.borrow_mut().clear();
+    app.raf = None;
+}
+
+fn schedule_raf(inner: &Rc>) -> Result<(), JsValue> {
+    let raf_id = {
+        let app = inner.borrow();
+        if !app.running {
+            return Ok(());
+        }
+        let Some(raf) = app.raf.as_ref() else {
+            return Err(JsValue::from_str(
+                "failed to initialize requestAnimationFrame loop",
+            ));
+        };
+        app.window
+            .request_animation_frame(raf.as_ref().unchecked_ref())?
+    };
+    inner.borrow_mut().raf_id = Some(raf_id);
+    Ok(())
+}
+
+fn schedule_deferred_dispose(window: &Window, inner: Rc>) {
+    let cleanup = Closure::once_into_js(move || {
+        dispose_inner_now(&inner);
+        ACTIVE_APPS.with(|apps| {
+            apps.borrow_mut().retain(WasmAppHandle::is_running);
+        });
+    });
+    let _ =
+        window.set_timeout_with_callback_and_timeout_and_arguments_0(cleanup.unchecked_ref(), 0);
+}
+
+fn stop_after_current_frame(inner: Rc>) {
+    let window = {
+        let mut app = inner.borrow_mut();
+        if !app.running && app.raf.is_none() {
+            return;
+        }
+        app.running = false;
+        if let Some(raf_id) = app.raf_id.take() {
+            let _ = app.window.cancel_animation_frame(raf_id);
+        }
+        app.listeners.clear();
+        app.events.borrow_mut().clear();
+        app.window.clone()
+    };
+    schedule_deferred_dispose(&window, inner);
+}
+
 fn install_event_listeners(
     container: &HtmlElement,
     window: &Window,
     backend: Rc>,
     events: Rc>>,
-) -> Result<(), JsValue> {
+) -> Result, JsValue> {
     container.set_tab_index(0);
+    let mut listeners = Vec::new();
 
     // The grid can be re-sized by the `resize` listener, so every
     // position-dependent listener reads the live `(width, height)` from the
@@ -516,78 +715,102 @@ fn install_event_listeners(
     // overlap the `borrow_mut()` in the RAF loop or the `resize` listener.
 
     let key_events = Rc::clone(&events);
-    let keydown = Closure::wrap(Box::new(move |event: KeyboardEvent| {
-        if let Some(slt_event) = keyboard_event_to_slt(&event) {
+    let keydown = Closure::wrap(Box::new(move |event: web_sys::Event| {
+        let Some(event) = event.dyn_ref::() else {
+            return;
+        };
+        if let Some(slt_event) = keyboard_event_to_slt(event) {
             key_events.borrow_mut().push(slt_event);
             event.prevent_default();
         }
     }) as Box);
-    container.add_event_listener_with_callback("keydown", keydown.as_ref().unchecked_ref())?;
-    keydown.forget();
+    listeners.push(EventListener::install(
+        event_target(container),
+        "keydown",
+        keydown,
+    )?);
 
     let move_events = Rc::clone(&events);
     let container_move = container.clone();
     let move_backend = Rc::clone(&backend);
-    let mousemove = Closure::wrap(Box::new(move |event: MouseEvent| {
+    let mousemove = Closure::wrap(Box::new(move |event: web_sys::Event| {
+        let Some(event) = event.dyn_ref::() else {
+            return;
+        };
         let (width, height) = move_backend.borrow().size();
-        if let Some((x, y)) = mouse_cell_position(&event, &container_move, width, height) {
-            let (pixel_x, pixel_y) = mouse_pixel_position(&event);
+        if let Some((x, y)) = mouse_cell_position(event, &container_move, width, height) {
+            let (pixel_x, pixel_y) = mouse_pixel_position(event);
             move_events
                 .borrow_mut()
                 .push(Event::Mouse(SltMouseEvent::new(
                     MouseKind::Moved,
                     x,
                     y,
-                    KeyModifiers::NONE,
+                    mouse_event_modifiers(event),
                     pixel_x,
                     pixel_y,
                 )));
         }
     }) as Box);
-    container.add_event_listener_with_callback("mousemove", mousemove.as_ref().unchecked_ref())?;
-    mousemove.forget();
+    listeners.push(EventListener::install(
+        event_target(container),
+        "mousemove",
+        mousemove,
+    )?);
 
     let down_events = Rc::clone(&events);
     let container_down = container.clone();
     let down_backend = Rc::clone(&backend);
-    let mousedown = Closure::wrap(Box::new(move |event: MouseEvent| {
+    let mousedown = Closure::wrap(Box::new(move |event: web_sys::Event| {
+        let Some(event) = event.dyn_ref::() else {
+            return;
+        };
         let (width, height) = down_backend.borrow().size();
-        if let Some((x, y)) = mouse_cell_position(&event, &container_down, width, height) {
-            let (pixel_x, pixel_y) = mouse_pixel_position(&event);
+        if let Some((x, y)) = mouse_cell_position(event, &container_down, width, height) {
+            let (pixel_x, pixel_y) = mouse_pixel_position(event);
             down_events
                 .borrow_mut()
                 .push(Event::Mouse(SltMouseEvent::new(
                     MouseKind::Down(mouse_button(event.button())),
                     x,
                     y,
-                    KeyModifiers::NONE,
+                    mouse_event_modifiers(event),
                     pixel_x,
                     pixel_y,
                 )));
         }
     }) as Box);
-    container.add_event_listener_with_callback("mousedown", mousedown.as_ref().unchecked_ref())?;
-    mousedown.forget();
+    listeners.push(EventListener::install(
+        event_target(container),
+        "mousedown",
+        mousedown,
+    )?);
 
     let up_events = Rc::clone(&events);
     let container_up = container.clone();
     let up_backend = Rc::clone(&backend);
-    let mouseup = Closure::wrap(Box::new(move |event: MouseEvent| {
+    let mouseup = Closure::wrap(Box::new(move |event: web_sys::Event| {
+        let Some(event) = event.dyn_ref::() else {
+            return;
+        };
         let (width, height) = up_backend.borrow().size();
-        if let Some((x, y)) = mouse_cell_position(&event, &container_up, width, height) {
-            let (pixel_x, pixel_y) = mouse_pixel_position(&event);
+        if let Some((x, y)) = mouse_cell_position(event, &container_up, width, height) {
+            let (pixel_x, pixel_y) = mouse_pixel_position(event);
             up_events.borrow_mut().push(Event::Mouse(SltMouseEvent::new(
                 MouseKind::Up(mouse_button(event.button())),
                 x,
                 y,
-                KeyModifiers::NONE,
+                mouse_event_modifiers(event),
                 pixel_x,
                 pixel_y,
             )));
         }
     }) as Box);
-    container.add_event_listener_with_callback("mouseup", mouseup.as_ref().unchecked_ref())?;
-    mouseup.forget();
+    listeners.push(EventListener::install(
+        event_target(container),
+        "mouseup",
+        mouseup,
+    )?);
 
     // Mouse wheel -> ScrollUp/Down/Left/Right at the cell under the cursor.
     // The `WheelEvent` web-sys wrapper is not enabled, so the deltas and the
@@ -611,14 +834,17 @@ fn install_event_listeners(
                 kind,
                 x,
                 y,
-                KeyModifiers::NONE,
+                reflect_event_modifiers(value),
                 None,
                 None,
             )));
         event.prevent_default();
     }) as Box);
-    container.add_event_listener_with_callback("wheel", wheel.as_ref().unchecked_ref())?;
-    wheel.forget();
+    listeners.push(EventListener::install(
+        event_target(container),
+        "wheel",
+        wheel,
+    )?);
 
     // Window resize -> recompute the cell grid from the container's pixel size
     // and emit a `Resize` event. The backend re-sizes its buffer (and rebuilds
@@ -637,8 +863,11 @@ fn install_event_listeners(
         backend.resize(cols, rows);
         resize_events.borrow_mut().push(Event::Resize(cols, rows));
     }) as Box);
-    window.add_event_listener_with_callback("resize", resize.as_ref().unchecked_ref())?;
-    resize.forget();
+    listeners.push(EventListener::install(
+        event_target(window),
+        "resize",
+        resize,
+    )?);
 
     // Focus blur -> FocusLost / FocusGained so widgets can clear hover state,
     // matching crossterm's focus events.
@@ -646,15 +875,21 @@ fn install_event_listeners(
     let blur = Closure::wrap(Box::new(move |_event: web_sys::Event| {
         blur_events.borrow_mut().push(Event::FocusLost);
     }) as Box);
-    container.add_event_listener_with_callback("blur", blur.as_ref().unchecked_ref())?;
-    blur.forget();
+    listeners.push(EventListener::install(
+        event_target(container),
+        "blur",
+        blur,
+    )?);
 
     let focus_events = Rc::clone(&events);
     let focus = Closure::wrap(Box::new(move |_event: web_sys::Event| {
         focus_events.borrow_mut().push(Event::FocusGained);
     }) as Box);
-    container.add_event_listener_with_callback("focus", focus.as_ref().unchecked_ref())?;
-    focus.forget();
+    listeners.push(EventListener::install(
+        event_target(container),
+        "focus",
+        focus,
+    )?);
 
     // Clipboard paste -> Paste(text). `ClipboardEvent`/`DataTransfer` wrappers
     // are not enabled, so the text flavor is pulled reflectively from
@@ -666,24 +901,33 @@ fn install_event_listeners(
             event.prevent_default();
         }
     }) as Box);
-    container.add_event_listener_with_callback("paste", paste.as_ref().unchecked_ref())?;
-    paste.forget();
+    listeners.push(EventListener::install(
+        event_target(container),
+        "paste",
+        paste,
+    )?);
 
-    Ok(())
+    Ok(listeners)
 }
 
-/// Mount `app` into `container` as a `width`×`height` cell grid and run it on
-/// the browser's `requestAnimationFrame` loop until the closure requests exit.
+/// Mount `app` into `container` as a `width`×`height` cell grid and return an
+/// owned handle for the browser runtime.
 ///
 /// Installs the DOM event listeners (keyboard, mouse, wheel, resize, focus,
-/// paste) that feed SLT [`Event`]s into the run loop. Returns once the loop is
-/// scheduled; the closure keeps running via the retained RAF callback.
+/// paste) that feed SLT [`Event`]s into the run loop. The returned
+/// [`WasmAppHandle`] owns those listeners and the `requestAnimationFrame`
+/// callback; drop it or call [`WasmAppHandle::dispose`] to stop the app.
 ///
 /// # Errors
 ///
 /// Returns a [`JsValue`] error when the `window` is unavailable, a listener
 /// fails to install, or the initial frame cannot be scheduled.
-pub fn run_wasm(container: HtmlElement, width: u32, height: u32, app: F) -> Result<(), JsValue>
+pub fn run_wasm_with_handle(
+    container: HtmlElement,
+    width: u32,
+    height: u32,
+    app: F,
+) -> Result
 where
     F: FnMut(&mut Context) + 'static,
 {
@@ -699,18 +943,32 @@ where
     let events = Rc::new(RefCell::new(Vec::::new()));
     let app = Rc::new(RefCell::new(app));
 
-    install_event_listeners(&container, &window, Rc::clone(&backend), Rc::clone(&events))?;
+    let listeners =
+        install_event_listeners(&container, &window, Rc::clone(&backend), Rc::clone(&events))?;
+    let inner = Rc::new(RefCell::new(WasmAppInner::new(
+        window.clone(),
+        listeners,
+        Rc::clone(&events),
+    )));
 
-    let raf: RafHandle = Rc::new(RefCell::new(None));
-    let raf_for_assign = Rc::clone(&raf);
-    let raf_for_loop = Rc::clone(&raf);
+    let inner_weak: Weak> = Rc::downgrade(&inner);
     let backend_ref = Rc::clone(&backend);
     let state_ref = Rc::clone(&state);
     let events_ref = Rc::clone(&events);
     let app_ref = Rc::clone(&app);
-    let window_ref = window.clone();
 
-    *raf_for_assign.borrow_mut() = Some(Closure::wrap(Box::new(move |_ts: f64| {
+    let raf = Closure::wrap(Box::new(move |_ts: f64| {
+        let Some(inner) = inner_weak.upgrade() else {
+            return;
+        };
+        {
+            let mut app = inner.borrow_mut();
+            app.raf_id = None;
+            if !app.running {
+                return;
+            }
+        }
+
         let frame_events = {
             let mut queue = events_ref.borrow_mut();
             std::mem::take(&mut *queue)
@@ -735,29 +993,47 @@ where
 
         match keep_going {
             Ok(true) => {
-                if let Some(cb) = raf_for_loop.borrow().as_ref() {
-                    let _ = window_ref.request_animation_frame(cb.as_ref().unchecked_ref());
+                if let Err(err) = schedule_raf(&inner) {
+                    web_sys::console::error_1(&JsValue::from_str(&format!(
+                        "slt requestAnimationFrame error: {err:?}"
+                    )));
+                    stop_after_current_frame(inner);
                 }
             }
-            Ok(false) => {}
+            Ok(false) => stop_after_current_frame(inner),
             Err(err) => {
                 web_sys::console::error_1(&JsValue::from_str(&format!("slt frame error: {err}")));
+                stop_after_current_frame(inner);
             }
         }
-    }) as Box));
+    }) as Box);
 
-    {
-        let borrow = raf.borrow();
-        if let Some(cb) = borrow.as_ref() {
-            window.request_animation_frame(cb.as_ref().unchecked_ref())?;
-        } else {
-            return Err(JsValue::from_str(
-                "failed to initialize requestAnimationFrame loop",
-            ));
-        }
-    }
+    inner.borrow_mut().raf = Some(raf);
+    schedule_raf(&inner)?;
+
+    Ok(WasmAppHandle::new(inner))
+}
 
-    std::mem::forget(raf);
+/// Mount `app` into `container` and run it until it requests exit.
+///
+/// This compatibility API preserves the original fire-and-forget behavior by
+/// storing the owned [`WasmAppHandle`] internally. New integrations that need
+/// explicit unmount/dispose control should prefer [`run_wasm_with_handle`].
+///
+/// # Errors
+///
+/// Returns a [`JsValue`] error when the `window` is unavailable, a listener
+/// fails to install, or the initial frame cannot be scheduled.
+pub fn run_wasm(container: HtmlElement, width: u32, height: u32, app: F) -> Result<(), JsValue>
+where
+    F: FnMut(&mut Context) + 'static,
+{
+    let handle = run_wasm_with_handle(container, width, height, app)?;
+    ACTIVE_APPS.with(|apps| {
+        let mut apps = apps.borrow_mut();
+        apps.retain(WasmAppHandle::is_running);
+        apps.push(handle);
+    });
     Ok(())
 }
 
@@ -770,6 +1046,24 @@ pub fn run_wasm_raw(container: HtmlElement, width: u32, height: u32) {
     let _ = run_wasm(container, width, height, |_ui| {});
 }
 
+/// `wasm-bindgen` entry point that mounts an empty SLT app and returns a
+/// disposable handle.
+///
+/// This is the JS-friendly counterpart to [`run_wasm_with_handle`] for browser
+/// harnesses that want to unmount the backend explicitly.
+///
+/// # Errors
+///
+/// Returns a [`JsValue`] error when mounting the empty app fails.
+#[cfg_attr(target_arch = "wasm32", wasm_bindgen)]
+pub fn run_wasm_raw_handle(
+    container: HtmlElement,
+    width: u32,
+    height: u32,
+) -> Result {
+    run_wasm_with_handle(container, width, height, |_ui| {})
+}
+
 #[cfg(test)]
 mod tests {
     //! Logic-level tests for the DOM backend's pure helpers. The DOM-touching
@@ -848,7 +1142,30 @@ mod tests {
         assert!(css.contains("color:#cd3131;"));
         assert!(css.contains("background-color:#000000;"));
         assert!(css.contains("font-weight:bold;"));
-        assert!(css.contains("text-decoration:underline;"));
+        assert!(css.contains("text-decoration-line:underline;"));
+    }
+
+    #[test]
+    fn style_css_combines_text_decorations() {
+        let style = slt::Style::new().underline().strikethrough();
+        let css = style_to_css(style);
+        assert!(css.contains("text-decoration-line:underline line-through;"));
+        assert!(!css.contains("text-decoration:underline;"));
+        assert!(!css.contains("text-decoration:line-through;"));
+    }
+
+    #[test]
+    fn dom_modifier_bits_match_slt_modifiers() {
+        let modifiers = modifiers_from_bools(true, true, true, true);
+        assert!(modifiers.contains(KeyModifiers::SHIFT));
+        assert!(modifiers.contains(KeyModifiers::CONTROL));
+        assert!(modifiers.contains(KeyModifiers::ALT));
+        assert!(modifiers.contains(KeyModifiers::SUPER));
+
+        assert_eq!(
+            modifiers_from_bools(false, false, false, false),
+            KeyModifiers::NONE
+        );
     }
 
     #[test]
@@ -857,3 +1174,54 @@ mod tests {
         assert_eq!(style_to_css(slt::Style::new()), "");
     }
 }
+
+#[cfg(all(test, target_arch = "wasm32"))]
+mod browser_tests {
+    use super::*;
+    use wasm_bindgen_test::wasm_bindgen_test;
+
+    wasm_bindgen_test::wasm_bindgen_test_configure!(run_in_browser);
+
+    fn test_container() -> HtmlElement {
+        let document = web_sys::window()
+            .and_then(|window| window.document())
+            .expect("browser document");
+        let container = document
+            .create_element("div")
+            .expect("create test container")
+            .dyn_into::()
+            .expect("container is HtmlElement");
+        document
+            .body()
+            .expect("document body")
+            .append_child(&container)
+            .expect("append test container");
+        container
+    }
+
+    #[wasm_bindgen_test]
+    fn dom_backend_flush_builds_grid_and_updates_text() {
+        let container = test_container();
+        let mut backend = DomBackend::new(container.clone(), 4, 1);
+        backend
+            .buffer_mut()
+            .set_string(0, 0, "SLT", slt::Style::new().fg(Color::Red));
+
+        backend.flush().expect("flush DOM backend");
+
+        assert!(container.inner_html().contains("()` instead of receiving it as parameters (v0.19.0 component DX) |
-| `demo_design_system` | `cargo run --example demo_design_system` | — | Design tokens, ThemeColor, extends, WidgetTheme |
-| `demo_pretext` | `cargo run --example demo_pretext` | — | Pretext-inspired text reflow |
+| `hello` | `cargo run --example hello` | - | Smallest possible SLT app |
+| `counter` | `cargo run --example counter` | - | State + keyboard input |
+| `demo` | `cargo run --example demo` | `qrcode`, `syntax` optional | Broad widget overview |
+| `color_picker` | `cargo run --example color_picker` | - | Color picker interaction |
+
+## Tour Binaries
 
-## Data and visualization
+These are the canonical way to review feature groups. Each tour pulls several
+source-only demos from `examples/` via Rust modules.
 
 | Example | Command | Features | Purpose |
 |---------|---------|----------|---------|
-| `demo_dashboard` | `cargo run --example demo_dashboard` | — | Dashboard composition |
-| `demo_infoviz` | `cargo run --example demo_infoviz` | — | Charts and visual summaries |
-| `demo_trading` | `cargo run --example demo_trading` | — | Dense market/trading layout |
-| `demo_spreadsheet` | `cargo run --example demo_spreadsheet` | — | Grid and data entry feel |
-| `demo_raw_draw` | `cargo run --example demo_raw_draw` | — | Direct buffer drawing |
+| `v020_tour` | `cargo run --example v020_tour` | - | v0.20 API and interaction tour |
+| `v0211_tour` | `cargo run --example v0211_tour` | - | v0.21.1 API additions |
+| `v0210_widgets` | `cargo run --example v0210_widgets` | - | v0.21.0 widget coverage |
+| `cookbook_tour` | `cargo run --example cookbook_tour` | - | Cookbook flows: login, table, modal, file picker, dashboard |
+| `showcase_tour` | `cargo run --example showcase_tour` | - | Showcase screens and dense layouts |
+| `canvas_tour` | `cargo run --example canvas_tour` | - | Canvas and raw drawing examples |
+| `text_tour` | `cargo run --example text_tour` | - | Text, CJK, pretext, and typography demos |
+| `system_tour` | `cargo run --example system_tour --features async` | `async` | Async, inline, static, and system-level demos |
 
-## Rich terminal features
+## Diagnostics And Development Tools
 
 | Example | Command | Features | Purpose |
 |---------|---------|----------|---------|
-| `demo_kitty_image` | `cargo run --example demo_kitty_image` | — | Kitty graphics protocol |
-| `demo_fire` | `cargo run --release --example demo_fire` | — | Half-block visual effect |
-| `demo_ime` | `cargo run --example demo_ime` | — | IME and CJK input |
-| `demo_cjk` | `cargo run --example demo_cjk` | — | CJK (Chinese / Japanese / Korean) wide-character demo — title truncation, mixed Korean / Chinese / Japanese body wrap, narrow-clamp title boxes (12-cell width), CJK form fields, mouse support (group hover, click counters, mouse coords). |
-| `demo_key_test` | `cargo run --example demo_key_test` | — | Inspect key events |
-| `debug_selection` | `cargo run --example debug_selection` | — | Selection overlay debugging |
+| `debug_selection` | `cargo run --example debug_selection` | - | Selection overlay debugging |
+| `demo_key_test` | `cargo run --example demo_key_test` | - | Inspect key events |
+| `test_mouse` | `cargo run --example test_mouse` | - | Mouse interaction behavior |
+| `theme_hot_reload` | `cargo run --example theme_hot_reload --features theme-watch` | `theme-watch` | Theme file watcher demo |
 
-## Interaction-heavy apps
+## Performance And Reports
 
 | Example | Command | Features | Purpose |
 |---------|---------|----------|---------|
-| `demo_game` | `cargo run --example demo_game` | — | Games in immediate mode |
-| `error_boundary_demo` | `cargo run --example error_boundary_demo` | — | Panic recovery surface |
-| `test_mouse` | `cargo run --example test_mouse` | — | Mouse interaction behavior |
+| `perf_interactive` | `cargo run --example perf_interactive` | - | Interactive perf sanity checks |
+| `perf_regression` | `cargo run --example perf_regression` | - | Headless perf regression coverage |
+| `v020_perf_audit` | `cargo run --example v020_perf_audit` | - | Non-interactive v0.20 perf report |
+| `v020_test_utils` | `cargo run --example v020_test_utils` | - | Non-interactive test utility report |
 
-## Async and performance
+## Source-Only Demos
 
-| Example | Command | Features | Purpose |
-|---------|---------|----------|---------|
-| `async_demo` | `cargo run --example async_demo --features async` | `async` | Background message flow |
-| `perf_interactive` | `cargo run --example perf_interactive` | — | Interactive perf sanity checks |
-| `perf_regression` | `cargo run --example perf_regression` | — | Headless perf regression coverage |
+The remaining files in `examples/` are kept as source modules for the tours.
+They intentionally do not have standalone `[[example]]` targets. Use the tour
+listed below instead of `cargo run --example `.
+
+| Source demo family | Run this target |
+|--------------------|-----------------|
+| `inline`, `async_demo`, `v020_static_log`, `error_boundary_demo` | `cargo run --example system_tour --features async` |
+| `anim`, `v020_*` API demos | `cargo run --example v020_tour` |
+| `cookbook_*` demos | `cargo run --example cookbook_tour` |
+| `demo_cli`, `demo_table`, `demo_website`, `demo_design_system`, `demo_pretext` | `cargo run --example showcase_tour` |
+| `demo_dashboard`, `demo_infoviz`, `demo_trading`, `demo_spreadsheet`, `demo_raw_draw` | `cargo run --example showcase_tour` |
+| `demo_kitty_image`, `demo_fire`, `demo_ime`, `demo_cjk` | `cargo run --example text_tour` |
+| `demo_game`, `demo_overlay_anchor` | `cargo run --example showcase_tour` |
 
-## Suggested path
+## Suggested Path
 
 1. Start with `hello`.
 2. Move to `counter`.
 3. Open `demo` for breadth.
-4. Jump to one domain-specific example that matches your app.
-5. Use `docs/PATTERNS.md` when you want composition guidance instead of a single demo.
+4. Run the tour closest to your target app.
+5. Use `docs/PATTERNS.md` when you want composition guidance instead of a
+   single demo.
diff --git a/docs/PERFORMANCE.md b/docs/PERFORMANCE.md
index cf24fd2..4a20b0c 100644
--- a/docs/PERFORMANCE.md
+++ b/docs/PERFORMANCE.md
@@ -146,7 +146,7 @@ cargo bench --bench benchmarks -- --baseline before
 
 ```rust
 // AppState API (src/lib.rs:251, :256)
-let fps = state.fps();             // exponential moving average
+let fps = state.fps_f64();         // exponential moving average
 state.set_debug(true);             // same as pressing F12
 ```
 
@@ -158,7 +158,7 @@ frame. The overlay layer is configurable via
 or `BaseOnly` (issue #201 in `src/lib.rs:571–587`).
 
 There is no `RunConfig::show_fps()` builder method. To put an FPS readout
-on screen, render `state.fps()` yourself in your UI closure, or rely on
+on screen, render `state.fps_f64()` yourself in your UI closure, or rely on
 the F12 overlay during development.
 
 ### Custom instrumentation
diff --git a/docs/THEMING.md b/docs/THEMING.md
index 3a9483c..8ce0ca5 100644
--- a/docs/THEMING.md
+++ b/docs/THEMING.md
@@ -170,14 +170,14 @@ let surface = ui.theme().resolve(ThemeColor::Surface);
 use slt::Color;
 
 // WCAG 2.1 contrast ratio (>= 4.5 for AA normal text)
-let ratio = Color::contrast_ratio(fg, bg);
+let ratio = Color::contrast_ratio_f64(fg, bg);
 let ok = Color::meets_contrast_aa(fg, bg);
 
 // Auto-select readable text color for any background
 let fg = ui.theme().contrast_text_on(bg_color);
 
 // Blend color against theme background
-let overlay = ui.theme().overlay(color, 0.5);
+let overlay = ui.theme().overlay_f64(color, 0.5);
 ```
 
 > **Note (v0.19.1)**: `Color::contrast_fg` and `Theme::contrast_text_on` use the WCAG 2.1 relative luminance threshold of `0.179`, not the previous `0.5`. Mid-tone backgrounds — Dracula purple (`Rgb(189, 147, 249)`, luminance ≈ 0.385), Solarized base1, Catppuccin lavender — now route to white text instead of black, matching WCAG AA contrast guidance. If you depended on the old midpoint behavior for stylistic reasons, override per-callsite with `WidgetColors` instead of relying on the default.
@@ -383,13 +383,13 @@ let blue = Color::Rgb(59, 130, 246);
 
 // Alpha blending: blend(other, alpha)
 // alpha=0.0 returns other, alpha=1.0 returns self
-let gray = white.blend(black, 0.5);  // ~Rgb(128, 128, 128)
+let gray = white.blend_f64(black, 0.5);  // ~Rgb(128, 128, 128)
 
 // Lighten toward white (0.0 = unchanged, 1.0 = white)
-let light_blue = blue.lighten(0.3);
+let light_blue = blue.lighten_f64(0.3);
 
 // Darken toward black (0.0 = unchanged, 1.0 = black)
-let dark_blue = blue.darken(0.3);
+let dark_blue = blue.darken_f64(0.3);
 ```
 
 ### Luminance and contrast
@@ -400,7 +400,7 @@ use slt::Color;
 let bg = Color::Rgb(30, 30, 46);
 
 // Perceived brightness (0.0 = darkest, 1.0 = brightest)
-let lum = bg.luminance(); // ~0.013 (sRGB-linearized, BT.709 weights)
+let lum = bg.luminance_f64(); // ~0.013 (sRGB-linearized, BT.709 weights)
 
 // Automatic readable foreground for a background color
 // Returns white if luminance > 0.179 (WCAG threshold), black otherwise
diff --git a/examples/demo_design_system.rs b/examples/demo_design_system.rs
index fdb5a09..790888c 100644
--- a/examples/demo_design_system.rs
+++ b/examples/demo_design_system.rs
@@ -223,7 +223,7 @@ pub fn render(ui: &mut Context, state: &mut DemoState) {
             let _ = ui.container().gap(sp.xs()).row(|ui| {
                 for (label, bg_color) in test_bgs {
                     let fg = Color::contrast_fg(bg_color);
-                    let ratio = Color::contrast_ratio(fg, bg_color);
+                    let ratio = Color::contrast_ratio_f64(fg, bg_color);
                     let _ = ui.container().bg(bg_color).p(1).grow(1).col(|ui| {
                         ui.text(label).fg(fg).bold();
                         ui.text(format!("ratio: {:.1}", ratio)).fg(fg);
diff --git a/examples/v0211_tour.rs b/examples/v0211_tour.rs
index be8dd31..3a3ebdd 100644
--- a/examples/v0211_tour.rs
+++ b/examples/v0211_tour.rs
@@ -74,22 +74,22 @@ pub fn render(ui: &mut Context, state: &mut TourState) {
     let _ = ui
         .text("SuperLightTUI · v0.21.1 tour")
         .bold()
-        .gradient_stops(&[
-            (0.0, Color::from_hsl(200.0, 0.9, 0.6)),
-            (0.5, Color::from_hsl(280.0, 0.9, 0.65)),
-            (1.0, Color::from_hsl(330.0, 0.9, 0.6)),
+        .gradient_stops_f64(&[
+            (0.0, Color::from_hsl_f64(200.0, 0.9, 0.6)),
+            (0.5, Color::from_hsl_f64(280.0, 0.9, 0.65)),
+            (1.0, Color::from_hsl_f64(330.0, 0.9, 0.6)),
         ]);
     let _ = ui.text(" additive APIs, no breaking changes ").bg_gradient(
-        Color::from_hsl(210.0, 0.7, 0.35),
-        Color::from_hsl(330.0, 0.7, 0.35),
+        Color::from_hsl_f64(210.0, 0.7, 0.35),
+        Color::from_hsl_f64(330.0, 0.7, 0.35),
     );
 
     // ── Color ergonomics: HSL swatch row, a parsed hex, and rotate_hue.
     let _ = ui.container().gap(1).row(|ui| {
         ui.text("Color:").dim();
         for i in 0..12 {
-            let hue = i as f32 / 12.0 * 360.0;
-            ui.text("██").fg(Color::from_hsl(hue, 0.85, 0.6));
+            let hue = f64::from(i) / 12.0 * 360.0;
+            ui.text("██").fg(Color::from_hsl_f64(hue, 0.85, 0.6));
         }
     });
     let _ = ui.container().gap(1).row(|ui| {
@@ -97,7 +97,7 @@ pub fn render(ui: &mut Context, state: &mut TourState) {
         ui.text("\"#ff6b6b\".parse()").dim();
         ui.text("██").fg(parsed);
         ui.text("rotate_hue(180)").dim();
-        ui.text("██").fg(parsed.rotate_hue(180.0));
+        ui.text("██").fg(parsed.rotate_hue_f64(180.0));
     });
 
     // ── Spinner presets.
diff --git a/src/buffer.rs b/src/buffer.rs
index 77c59c6..bd6d517 100644
--- a/src/buffer.rs
+++ b/src/buffer.rs
@@ -1047,11 +1047,11 @@ fn write_color(out: &mut String, color: crate::style::Color) {
         Color::LightWhite => out.push_str("light_white"),
         Color::Rgb(r, g, b) => {
             use std::fmt::Write;
-            let _ = write!(out, "#{:02x}{:02x}{:02x}", r, g, b);
+            let _ = write!(out, "#{r:02x}{g:02x}{b:02x}");
         }
         Color::Indexed(idx) => {
             use std::fmt::Write;
-            let _ = write!(out, "idx{}", idx);
+            let _ = write!(out, "idx{idx}");
         }
     }
 }
diff --git a/src/chart/render.rs b/src/chart/render.rs
index 4de5a2b..db48582 100644
--- a/src/chart/render.rs
+++ b/src/chart/render.rs
@@ -333,7 +333,7 @@ pub(crate) fn render_chart(config: &ChartConfig) -> Vec {
                 } else {
                     (String::new(), '│')
                 };
-            let padded = format!("{:>w$}", label, w = y_tick_width);
+            let padded = format!("{label:>y_tick_width$}");
             segments.push((padded, axis_style));
             segments.push((format!("{divider} "), axis_style));
         }
diff --git a/src/context.rs b/src/context.rs
index dcf9231..6211193 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -33,14 +33,14 @@ use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
 #[allow(dead_code)]
 fn slt_assert(condition: bool, msg: &str) {
     if !condition {
-        panic!("[SLT] {}", msg);
+        panic!("[SLT] {msg}");
     }
 }
 
 #[cfg(debug_assertions)]
 #[allow(dead_code, clippy::print_stderr)]
 fn slt_warn(msg: &str) {
-    eprintln!("\x1b[33m[SLT warning]\x1b[0m {}", msg);
+    eprintln!("\x1b[33m[SLT warning]\x1b[0m {msg}");
 }
 
 #[cfg(not(debug_assertions))]
diff --git a/src/context/async_tasks.rs b/src/context/async_tasks.rs
index 58009ea..d458d24 100644
--- a/src/context/async_tasks.rs
+++ b/src/context/async_tasks.rs
@@ -127,6 +127,15 @@ impl Default for AsyncTasks {
     }
 }
 
+impl Drop for AsyncTasks {
+    fn drop(&mut self) {
+        for (_, join) in self.joins.drain() {
+            join.abort();
+        }
+        self.results.clear();
+    }
+}
+
 impl AsyncTasks {
     /// Inject the ambient Tokio runtime handle. Called once by `run_async*`
     /// before the first frame so `spawn` has a runtime to launch onto.
diff --git a/src/context/async_tasks_tests.rs b/src/context/async_tasks_tests.rs
index 6f51ccd..f7e1839 100644
--- a/src/context/async_tasks_tests.rs
+++ b/src/context/async_tasks_tests.rs
@@ -173,3 +173,27 @@ fn default_registry_has_no_runtime_and_polls_none() {
     // Polling an id that was never spawned yields None.
     assert_eq!(tasks.poll::(999), None);
 }
+
+#[tokio::test]
+async fn dropping_registry_aborts_live_tasks() {
+    use std::sync::Arc;
+    use std::sync::atomic::{AtomicBool, Ordering};
+
+    let completed = Arc::new(AtomicBool::new(false));
+    let completed_in_task = Arc::clone(&completed);
+
+    let mut tasks = AsyncTasks::default();
+    tasks.set_runtime(tokio::runtime::Handle::current());
+    let handle = tasks.spawn(async move {
+        tokio::time::sleep(Duration::from_millis(50)).await;
+        completed_in_task.store(true, Ordering::SeqCst);
+    });
+
+    drop(tasks);
+    tokio::time::sleep(Duration::from_millis(120)).await;
+    assert!(
+        !completed.load(Ordering::SeqCst),
+        "dropping AsyncTasks must abort live tasks owned by the session"
+    );
+    drop(handle);
+}
diff --git a/src/context/runtime.rs b/src/context/runtime.rs
index 11d7581..6174684 100644
--- a/src/context/runtime.rs
+++ b/src/context/runtime.rs
@@ -1099,10 +1099,7 @@ impl Context {
         let state = entry
             .downcast_mut::()
             .unwrap_or_else(|| {
-                panic!(
-                    "animate_value: id {:?} is already used for a different state type",
-                    id
-                )
+                panic!("animate_value: id {id:?} is already used for a different state type")
             });
         state.sample(target, duration_ticks, tick)
     }
@@ -1144,15 +1141,13 @@ impl Context {
             .entry(id)
             .or_insert_with(|| SchedulerSlot {
                 started: now,
-                kind: SchedKind::Once {
-                    deadline: now + dur,
-                    fired: false,
-                },
+                kind: SchedKind::Once { dur, fired: false },
                 touched_this_frame: false,
             });
         slot.touched_this_frame = true;
+        let elapsed = now.saturating_duration_since(slot.started);
         match &mut slot.kind {
-            SchedKind::Once { deadline, fired } if !*fired && now >= *deadline => {
+            SchedKind::Once { dur, fired } if !*fired && elapsed >= *dur => {
                 *fired = true;
                 true
             }
@@ -1207,7 +1202,8 @@ impl Context {
                 if fired > 0 {
                     // Advance by exactly the intervals reported so counts never
                     // drift, even across stalled frames.
-                    *last += *interval * fired;
+                    let advance = interval.saturating_mul(fired);
+                    *last = last.checked_add(advance).unwrap_or(now);
                 }
                 fired
             }
@@ -1251,7 +1247,7 @@ impl Context {
                 started: now,
                 kind: SchedKind::Debounce {
                     dur,
-                    deadline: now + dur,
+                    quiet_started: now,
                     fired: false,
                 },
                 touched_this_frame: false,
@@ -1260,16 +1256,16 @@ impl Context {
         match &mut slot.kind {
             SchedKind::Debounce {
                 dur: slot_dur,
-                deadline,
+                quiet_started,
                 fired,
             } => {
                 *slot_dur = dur;
                 if dirty {
                     // Re-arm the quiet window from this frame.
-                    *deadline = now + dur;
+                    *quiet_started = now;
                     *fired = false;
                     false
-                } else if !*fired && now >= *deadline {
+                } else if !*fired && now.saturating_duration_since(*quiet_started) >= *slot_dur {
                     *fired = true;
                     true
                 } else {
@@ -1381,6 +1377,34 @@ impl Context {
         Some(self.frame_instant.saturating_duration_since(started))
     }
 
+    /// Remove dynamic keyed state created by
+    /// [`use_state_keyed`](Self::use_state_keyed).
+    ///
+    /// Returns `true` when a slot existed. Any old [`State`] handle for the
+    /// removed id becomes invalid and will panic if used before the state is
+    /// recreated by `use_state_keyed`.
+    pub fn remove_state_keyed(&mut self, id: &str) -> bool {
+        self.keyed_states.remove(id).is_some()
+    }
+
+    /// Retain only dynamic keyed-state entries accepted by `keep`.
+    ///
+    /// Returns the number of removed entries. This is intended for long-lived
+    /// dynamic lists where ids come from data and removed items should release
+    /// their per-row state.
+    pub fn retain_state_keyed(&mut self, mut keep: impl FnMut(&str) -> bool) -> usize {
+        let before = self.keyed_states.len();
+        self.keyed_states.retain(|key, _| keep(key));
+        before - self.keyed_states.len()
+    }
+
+    /// Number of live dynamic keyed-state entries.
+    ///
+    /// Diagnostic helper for spotting churn when using runtime ids.
+    pub fn keyed_state_count(&self) -> usize {
+        self.keyed_states.len()
+    }
+
     /// Push a value onto the context stack for the duration of `body`.
     ///
     /// Inside `body`, child widgets can call
diff --git a/src/context/scheduler_tests.rs b/src/context/scheduler_tests.rs
index d693e00..dcd2be4 100644
--- a/src/context/scheduler_tests.rs
+++ b/src/context/scheduler_tests.rs
@@ -258,6 +258,32 @@ fn no_default_features_smoke() {
     });
 }
 
+#[test]
+fn huge_durations_do_not_overflow_instant_arithmetic() {
+    let mut tb = TestBackend::new(20, 3);
+    tb.render(|ui| {
+        assert!(
+            !ui.schedule("huge-once", Duration::MAX),
+            "a huge one-shot timer should arm without firing immediately"
+        );
+        assert_eq!(
+            ui.every("huge-every", Duration::MAX),
+            0,
+            "a huge interval should not fire on the arming frame"
+        );
+        assert!(
+            !ui.debounce("huge-debounce", Duration::MAX, false),
+            "a huge debounce window should arm without firing immediately"
+        );
+    });
+
+    tb.render(|ui| {
+        assert!(!ui.schedule("huge-once", Duration::MAX));
+        assert_eq!(ui.every("huge-every", Duration::MAX), 0);
+        assert!(!ui.debounce("huge-debounce", Duration::MAX, true));
+    });
+}
+
 mod proptests {
     use super::*;
     use crate::widgets::intervals_elapsed;
diff --git a/src/context/tests.rs b/src/context/tests.rs
index 17369fb..9cef080 100644
--- a/src/context/tests.rs
+++ b/src/context/tests.rs
@@ -499,6 +499,66 @@ fn screen_nav_no_op_without_request() {
     assert_eq!(screens.current(), "home");
 }
 
+#[test]
+fn keyed_state_cleanup_removes_dynamic_entries() {
+    let mut backend = TestBackend::new(24, 3);
+
+    backend.render(|ui| {
+        let a = ui.use_state_keyed("row-a", || 1u32);
+        let b = ui.use_state_keyed("row-b", || 2u32);
+        assert_eq!(*a.get(ui), 1);
+        assert_eq!(*b.get(ui), 2);
+        assert_eq!(ui.keyed_state_count(), 2);
+
+        assert!(ui.remove_state_keyed("row-a"));
+        assert!(!ui.remove_state_keyed("missing"));
+        assert_eq!(ui.keyed_state_count(), 1);
+
+        let removed = ui.retain_state_keyed(|key| key == "row-b");
+        assert_eq!(removed, 0);
+        assert_eq!(ui.keyed_state_count(), 1);
+    });
+}
+
+#[test]
+fn screen_state_cleanup_removes_inactive_hook_and_focus_state() {
+    let mut backend = TestBackend::new(24, 3);
+    let mut screens = ScreenState::new("home");
+    screens.push("detail-1");
+
+    backend.render(|ui| {
+        ui.screen("detail-1", &mut screens, |ui| {
+            let value = ui.use_state(|| 7u32);
+            ui.text(format!("{}", value.get(ui)));
+            ui.register_focusable();
+        });
+    });
+    assert_eq!(screens.focus_state_count(), 1);
+
+    screens.pop();
+    backend.render(|ui| {
+        assert_eq!(ui.screen_state_count(), 1);
+        assert!(ui.remove_screen_state(&mut screens, "detail-1"));
+        assert_eq!(ui.screen_state_count(), 0);
+    });
+    assert_eq!(screens.focus_state_count(), 0);
+}
+
+#[test]
+fn screen_state_cleanup_preserves_stacked_screens() {
+    let mut backend = TestBackend::new(24, 3);
+    let mut screens = ScreenState::new("home");
+
+    backend.render(|ui| {
+        ui.screen("home", &mut screens, |ui| {
+            let value = ui.use_state(|| 1u32);
+            ui.text(format!("{}", value.get(ui)));
+        });
+        assert!(!ui.remove_screen_state(&mut screens, "home"));
+        assert_eq!(ui.screen_state_count(), 1);
+    });
+}
+
 // === Issue #54: mouse_drag / mouse_up convenience methods ===
 
 #[test]
diff --git a/src/context/widgets_display.rs b/src/context/widgets_display.rs
index 95c0931..e50dce9 100644
--- a/src/context/widgets_display.rs
+++ b/src/context/widgets_display.rs
@@ -440,15 +440,122 @@ fn normalize_rgba(data: &[u8], width: u32, height: u32) -> Vec {
     buf
 }
 
+fn image_fit_rows(src_width: u32, src_height: u32, cols: u32, cell_w: u32, cell_h: u32) -> u32 {
+    if src_width == 0 || src_height == 0 || cell_w == 0 || cell_h == 0 {
+        return 1;
+    }
+
+    ((cols as f64 * src_height as f64 * cell_w as f64) / (src_width as f64 * cell_h as f64))
+        .ceil()
+        .max(1.0) as u32
+}
+
+fn sample_rgba_color(
+    data: &[u8],
+    src_width: u32,
+    src_height: u32,
+    dst_x: u32,
+    dst_y: u32,
+    dst_width: u32,
+    dst_height: u32,
+) -> Option {
+    if src_width == 0 || src_height == 0 || dst_width == 0 || dst_height == 0 {
+        return None;
+    }
+
+    let src_x = (u64::from(dst_x) * u64::from(src_width) / u64::from(dst_width))
+        .min(u64::from(src_width.saturating_sub(1)));
+    let src_y = (u64::from(dst_y) * u64::from(src_height) / u64::from(dst_height))
+        .min(u64::from(src_height.saturating_sub(1)));
+    let pixel = src_y
+        .saturating_mul(u64::from(src_width))
+        .saturating_add(src_x);
+    let idx = usize::try_from(pixel).ok().and_then(|p| p.checked_mul(4))?;
+    let px = data.get(idx..idx + 4)?;
+    if px[3] == 0 {
+        None
+    } else {
+        Some(Color::Rgb(px[0], px[1], px[2]))
+    }
+}
+
+fn draw_halfblock_cell(
+    buf: &mut crate::Buffer,
+    x: u32,
+    y: u32,
+    upper: Option,
+    lower: Option,
+) {
+    match (upper, lower) {
+        (Some(upper), Some(lower)) => {
+            buf.set_char(x, y, '▀', Style::new().fg(upper).bg(lower));
+        }
+        (Some(upper), None) => {
+            buf.set_char(x, y, '▀', Style::new().fg(upper));
+        }
+        (None, Some(lower)) => {
+            buf.set_char(x, y, '▄', Style::new().fg(lower));
+        }
+        (None, None) => {
+            buf.set_char(x, y, ' ', Style::new());
+        }
+    }
+}
+
 #[cfg(feature = "crossterm")]
-fn terminal_supports_sixel() -> bool {
-    let force = std::env::var("SLT_FORCE_SIXEL")
+fn terminal_force_graphics(var: &str) -> bool {
+    std::env::var(var)
+        .ok()
+        .map(|v| v.to_ascii_lowercase())
+        .is_some_and(|v| matches!(v.as_str(), "1" | "true" | "yes" | "on"))
+}
+
+#[cfg(feature = "crossterm")]
+fn terminal_graphics_blocked_by_multiplexer() -> bool {
+    let term = std::env::var("TERM")
+        .ok()
+        .map(|v| v.to_ascii_lowercase())
+        .unwrap_or_default();
+    let tmux = std::env::var_os("TMUX").is_some();
+    let screen = std::env::var_os("STY").is_some();
+
+    tmux || screen
+        || term == "tmux"
+        || term.starts_with("tmux-")
+        || term == "screen"
+        || term.starts_with("screen-")
+        || term.starts_with("screen.")
+}
+
+#[cfg(feature = "crossterm")]
+fn terminal_supports_kitty() -> bool {
+    if terminal_force_graphics("SLT_FORCE_KITTY") {
+        return true;
+    }
+    if terminal_graphics_blocked_by_multiplexer() {
+        return false;
+    }
+
+    let term = std::env::var("TERM")
         .ok()
         .map(|v| v.to_ascii_lowercase())
         .unwrap_or_default();
-    if matches!(force.as_str(), "1" | "true" | "yes" | "on") {
+    let term_program = std::env::var("TERM_PROGRAM")
+        .ok()
+        .map(|v| v.to_ascii_lowercase())
+        .unwrap_or_default();
+
+    term.contains("kitty") || matches!(term_program.as_str(), "ghostty" | "wezterm" | "kitty")
+}
+
+#[cfg(feature = "crossterm")]
+fn terminal_supports_sixel() -> bool {
+    if terminal_force_graphics("SLT_FORCE_SIXEL") {
         return true;
     }
+    if terminal_graphics_blocked_by_multiplexer() {
+        return false;
+    }
 
     let term = std::env::var("TERM")
         .ok()
@@ -487,13 +594,12 @@ fn terminal_supports_sixel() -> bool {
 /// regression-test parity.
 #[cfg(feature = "crossterm")]
 fn terminal_supports_iterm() -> bool {
-    let force = std::env::var("SLT_FORCE_ITERM")
-        .ok()
-        .map(|v| v.to_ascii_lowercase())
-        .unwrap_or_default();
-    if matches!(force.as_str(), "1" | "true" | "yes" | "on") {
+    if terminal_force_graphics("SLT_FORCE_ITERM") {
         return true;
     }
+    if terminal_graphics_blocked_by_multiplexer() {
+        return false;
+    }
 
     let term_program = std::env::var("TERM_PROGRAM")
         .ok()
@@ -509,235 +615,503 @@ fn terminal_supports_iterm() -> bool {
 }
 
 #[cfg(all(test, feature = "crossterm"))]
-mod sixel_detection_tests {
-    use super::terminal_supports_sixel;
-    use std::sync::Mutex;
-
-    // Env vars are process-global. A test-local mutex serializes access so
-    // concurrent test threads don't observe each other's set_var() calls.
-    static ENV_GUARD: Mutex<()> = Mutex::new(());
-
-    // edition 2024: env mutation is `unsafe`; ENV_GUARD makes it sound here.
-    #[allow(unsafe_code)]
-    fn with_env(term: Option<&str>, term_program: Option<&str>, force: bool, f: F) {
-        let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
-        let prev_term = std::env::var("TERM").ok();
-        let prev_program = std::env::var("TERM_PROGRAM").ok();
-        let prev_force = std::env::var("SLT_FORCE_SIXEL").ok();
-
-        // SAFETY (edition 2024): set_var/remove_var are unsafe because env
-        // mutation races concurrent reads. ENV_GUARD above serializes these
-        // test helpers, so no other thread observes a torn update.
-        unsafe {
-            match term {
-                Some(v) => std::env::set_var("TERM", v),
-                None => std::env::remove_var("TERM"),
-            }
-            match term_program {
-                Some(v) => std::env::set_var("TERM_PROGRAM", v),
-                None => std::env::remove_var("TERM_PROGRAM"),
-            }
-            if force {
-                std::env::set_var("SLT_FORCE_SIXEL", "1");
-            } else {
-                std::env::remove_var("SLT_FORCE_SIXEL");
-            }
+#[derive(Default)]
+struct GraphicsEnv<'a> {
+    term: Option<&'a str>,
+    term_program: Option<&'a str>,
+    force_sixel: bool,
+    force_iterm: bool,
+    force_kitty: bool,
+    tmux: bool,
+    sty: bool,
+}
+
+#[cfg(all(test, feature = "crossterm"))]
+static GRAPHICS_ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+#[cfg(all(test, feature = "crossterm"))]
+#[allow(unsafe_code)]
+fn with_graphics_env(env: GraphicsEnv<'_>, f: F) {
+    let _g = GRAPHICS_ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
+    let prev_term = std::env::var("TERM").ok();
+    let prev_program = std::env::var("TERM_PROGRAM").ok();
+    let prev_sixel = std::env::var("SLT_FORCE_SIXEL").ok();
+    let prev_iterm = std::env::var("SLT_FORCE_ITERM").ok();
+    let prev_kitty = std::env::var("SLT_FORCE_KITTY").ok();
+    let prev_tmux = std::env::var("TMUX").ok();
+    let prev_sty = std::env::var("STY").ok();
+
+    // SAFETY (edition 2024): set_var/remove_var are unsafe because env
+    // mutation races concurrent reads. GRAPHICS_ENV_GUARD serializes these
+    // tests so no other detection test observes a torn update.
+    unsafe {
+        match env.term {
+            Some(v) => std::env::set_var("TERM", v),
+            None => std::env::remove_var("TERM"),
+        }
+        match env.term_program {
+            Some(v) => std::env::set_var("TERM_PROGRAM", v),
+            None => std::env::remove_var("TERM_PROGRAM"),
         }
+        if env.force_sixel {
+            std::env::set_var("SLT_FORCE_SIXEL", "1");
+        } else {
+            std::env::remove_var("SLT_FORCE_SIXEL");
+        }
+        if env.force_iterm {
+            std::env::set_var("SLT_FORCE_ITERM", "1");
+        } else {
+            std::env::remove_var("SLT_FORCE_ITERM");
+        }
+        if env.force_kitty {
+            std::env::set_var("SLT_FORCE_KITTY", "1");
+        } else {
+            std::env::remove_var("SLT_FORCE_KITTY");
+        }
+        if env.tmux {
+            std::env::set_var("TMUX", "/tmp/tmux-1000/default,1,0");
+        } else {
+            std::env::remove_var("TMUX");
+        }
+        if env.sty {
+            std::env::set_var("STY", "1234.pts-0.host");
+        } else {
+            std::env::remove_var("STY");
+        }
+    }
 
-        f();
+    f();
 
-        unsafe {
-            match prev_term {
-                Some(v) => std::env::set_var("TERM", v),
-                None => std::env::remove_var("TERM"),
-            }
-            match prev_program {
-                Some(v) => std::env::set_var("TERM_PROGRAM", v),
-                None => std::env::remove_var("TERM_PROGRAM"),
-            }
-            match prev_force {
-                Some(v) => std::env::set_var("SLT_FORCE_SIXEL", v),
-                None => std::env::remove_var("SLT_FORCE_SIXEL"),
-            }
+    unsafe {
+        match prev_term {
+            Some(v) => std::env::set_var("TERM", v),
+            None => std::env::remove_var("TERM"),
+        }
+        match prev_program {
+            Some(v) => std::env::set_var("TERM_PROGRAM", v),
+            None => std::env::remove_var("TERM_PROGRAM"),
         }
+        match prev_sixel {
+            Some(v) => std::env::set_var("SLT_FORCE_SIXEL", v),
+            None => std::env::remove_var("SLT_FORCE_SIXEL"),
+        }
+        match prev_iterm {
+            Some(v) => std::env::set_var("SLT_FORCE_ITERM", v),
+            None => std::env::remove_var("SLT_FORCE_ITERM"),
+        }
+        match prev_kitty {
+            Some(v) => std::env::set_var("SLT_FORCE_KITTY", v),
+            None => std::env::remove_var("SLT_FORCE_KITTY"),
+        }
+        match prev_tmux {
+            Some(v) => std::env::set_var("TMUX", v),
+            None => std::env::remove_var("TMUX"),
+        }
+        match prev_sty {
+            Some(v) => std::env::set_var("STY", v),
+            None => std::env::remove_var("STY"),
+        }
+    }
+}
+
+#[cfg(all(test, feature = "crossterm"))]
+mod kitty_detection_tests {
+    use super::{GraphicsEnv, terminal_supports_kitty, with_graphics_env};
+
+    #[test]
+    fn kitty_xterm_kitty_detected() {
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-kitty"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_kitty());
+            },
+        );
+    }
+
+    #[test]
+    fn kitty_ghostty_detected_via_term_program() {
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("ghostty"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_kitty());
+            },
+        );
+    }
+
+    #[test]
+    fn kitty_xterm_256color_no_false_positive() {
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(!terminal_supports_kitty());
+            },
+        );
+    }
+
+    #[test]
+    fn kitty_tmux_blocks_env_fallback() {
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("wezterm"),
+                tmux: true,
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(!terminal_supports_kitty());
+            },
+        );
+    }
+
+    #[test]
+    fn kitty_screen_term_blocks_env_fallback() {
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("screen-256color"),
+                term_program: Some("ghostty"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(!terminal_supports_kitty());
+            },
+        );
+    }
+
+    #[test]
+    fn kitty_force_env_overrides_tmux() {
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("screen-256color"),
+                term_program: Some("wezterm"),
+                force_kitty: true,
+                tmux: true,
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_kitty());
+            },
+        );
     }
+}
+
+#[cfg(all(test, feature = "crossterm"))]
+mod sixel_detection_tests {
+    use super::{GraphicsEnv, terminal_supports_sixel, with_graphics_env};
 
     #[test]
     fn sixel_xterm_256color_no_false_positive() {
         // Regression: `term.contains("xterm")` previously matched
         // `xterm-256color` and printed raw escape sequences to screen on
         // macOS Terminal.app, VS Code, and most SSH clients.
-        with_env(Some("xterm-256color"), None, false, || {
-            assert!(!terminal_supports_sixel());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(!terminal_supports_sixel());
+            },
+        );
     }
 
     #[test]
     fn sixel_mlterm_detected() {
-        with_env(Some("mlterm"), None, false, || {
-            assert!(terminal_supports_sixel());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("mlterm"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_sixel());
+            },
+        );
     }
 
     #[test]
     fn sixel_foot_detected_via_term() {
-        with_env(Some("foot"), None, false, || {
-            assert!(terminal_supports_sixel());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("foot"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_sixel());
+            },
+        );
     }
 
     #[test]
     fn sixel_foot_detected_via_term_program() {
-        with_env(Some("xterm-256color"), Some("foot"), false, || {
-            assert!(terminal_supports_sixel());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("foot"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_sixel());
+            },
+        );
     }
 
     #[test]
     fn sixel_force_env_overrides_negative_term() {
-        with_env(Some("xterm-256color"), None, true, || {
-            assert!(terminal_supports_sixel());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                force_sixel: true,
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_sixel());
+            },
+        );
     }
 
     #[test]
     fn sixel_substring_match_catches_custom_builds() {
-        with_env(Some("custom-with-sixel"), None, false, || {
-            assert!(terminal_supports_sixel());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("custom-with-sixel"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_sixel());
+            },
+        );
     }
 
     #[test]
     fn sixel_wezterm_detected_via_term_program() {
         // Issue #264: WezTerm reports `TERM=xterm-256color` but is a capable
         // image host; it must no longer fall through to `[sixel unsupported]`.
-        with_env(Some("xterm-256color"), Some("wezterm"), false, || {
-            assert!(terminal_supports_sixel());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("wezterm"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_sixel());
+            },
+        );
     }
 
     #[test]
     fn sixel_ghostty_detected_via_term_program() {
-        with_env(Some("xterm-256color"), Some("ghostty"), false, || {
-            assert!(terminal_supports_sixel());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("ghostty"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_sixel());
+            },
+        );
     }
 
     #[test]
     fn sixel_mlterm_still_detected_via_term_program() {
-        with_env(Some("xterm-256color"), Some("mlterm"), false, || {
-            assert!(terminal_supports_sixel());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("mlterm"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_sixel());
+            },
+        );
+    }
+
+    #[test]
+    fn sixel_tmux_blocks_env_fallback() {
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("ghostty"),
+                tmux: true,
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(!terminal_supports_sixel());
+            },
+        );
+    }
+
+    #[test]
+    fn sixel_force_env_overrides_tmux() {
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("screen-256color"),
+                term_program: Some("wezterm"),
+                force_sixel: true,
+                tmux: true,
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_sixel());
+            },
+        );
     }
 }
 
 #[cfg(all(test, feature = "crossterm"))]
 mod iterm_detection_tests {
-    use super::terminal_supports_iterm;
-    use std::sync::Mutex;
-
-    // Env vars are process-global. A test-local mutex serializes access so
-    // concurrent test threads don't observe each other's set_var() calls.
-    static ENV_GUARD: Mutex<()> = Mutex::new(());
-
-    // edition 2024: env mutation is `unsafe`; ENV_GUARD makes it sound here.
-    #[allow(unsafe_code)]
-    fn with_env(term: Option<&str>, term_program: Option<&str>, force: bool, f: F) {
-        let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
-        let prev_term = std::env::var("TERM").ok();
-        let prev_program = std::env::var("TERM_PROGRAM").ok();
-        let prev_force = std::env::var("SLT_FORCE_ITERM").ok();
-
-        // SAFETY (edition 2024): set_var/remove_var are unsafe because env
-        // mutation races concurrent reads. ENV_GUARD above serializes these
-        // test helpers, so no other thread observes a torn update.
-        unsafe {
-            match term {
-                Some(v) => std::env::set_var("TERM", v),
-                None => std::env::remove_var("TERM"),
-            }
-            match term_program {
-                Some(v) => std::env::set_var("TERM_PROGRAM", v),
-                None => std::env::remove_var("TERM_PROGRAM"),
-            }
-            if force {
-                std::env::set_var("SLT_FORCE_ITERM", "1");
-            } else {
-                std::env::remove_var("SLT_FORCE_ITERM");
-            }
-        }
-
-        f();
-
-        unsafe {
-            match prev_term {
-                Some(v) => std::env::set_var("TERM", v),
-                None => std::env::remove_var("TERM"),
-            }
-            match prev_program {
-                Some(v) => std::env::set_var("TERM_PROGRAM", v),
-                None => std::env::remove_var("TERM_PROGRAM"),
-            }
-            match prev_force {
-                Some(v) => std::env::set_var("SLT_FORCE_ITERM", v),
-                None => std::env::remove_var("SLT_FORCE_ITERM"),
-            }
-        }
-    }
+    use super::{GraphicsEnv, terminal_supports_iterm, with_graphics_env};
 
     #[test]
     fn iterm_xterm_256color_no_false_positive() {
         // Parity with `sixel_xterm_256color_no_false_positive`: a plain xterm
         // must never be mistaken for an OSC 1337 host.
-        with_env(Some("xterm-256color"), None, false, || {
-            assert!(!terminal_supports_iterm());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(!terminal_supports_iterm());
+            },
+        );
     }
 
     #[test]
     fn iterm_app_detected() {
-        with_env(Some("xterm-256color"), Some("iTerm.app"), false, || {
-            assert!(terminal_supports_iterm());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("iTerm.app"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_iterm());
+            },
+        );
     }
 
     #[test]
     fn iterm_wezterm_detected() {
-        with_env(Some("xterm-256color"), Some("WezTerm"), false, || {
-            assert!(terminal_supports_iterm());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("WezTerm"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_iterm());
+            },
+        );
     }
 
     #[test]
     fn iterm_tabby_detected() {
-        with_env(Some("xterm-256color"), Some("Tabby"), false, || {
-            assert!(terminal_supports_iterm());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("Tabby"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_iterm());
+            },
+        );
     }
 
     #[test]
     fn iterm_mintty_detected() {
-        with_env(Some("xterm-256color"), Some("mintty"), false, || {
-            assert!(terminal_supports_iterm());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("mintty"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_iterm());
+            },
+        );
     }
 
     #[test]
     fn iterm_force_env_overrides_negative_term() {
-        with_env(Some("xterm-256color"), None, true, || {
-            assert!(terminal_supports_iterm());
-        });
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                force_iterm: true,
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_iterm());
+            },
+        );
     }
 
     #[test]
     fn iterm_unknown_term_program_negative() {
-        with_env(
-            Some("xterm-256color"),
-            Some("Apple_Terminal"),
-            false,
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("Apple_Terminal"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(!terminal_supports_iterm());
+            },
+        );
+    }
+
+    #[test]
+    fn iterm_tmux_blocks_env_fallback() {
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("xterm-256color"),
+                term_program: Some("iTerm.app"),
+                tmux: true,
+                ..GraphicsEnv::default()
+            },
             || {
                 assert!(!terminal_supports_iterm());
             },
         );
     }
+
+    #[test]
+    fn iterm_screen_term_blocks_env_fallback() {
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("screen.xterm-256color"),
+                term_program: Some("Tabby"),
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(!terminal_supports_iterm());
+            },
+        );
+    }
+
+    #[test]
+    fn iterm_force_env_overrides_tmux() {
+        with_graphics_env(
+            GraphicsEnv {
+                term: Some("screen-256color"),
+                term_program: Some("iTerm.app"),
+                force_iterm: true,
+                tmux: true,
+                ..GraphicsEnv::default()
+            },
+            || {
+                assert!(terminal_supports_iterm());
+            },
+        );
+    }
 }
diff --git a/src/context/widgets_display/layout.rs b/src/context/widgets_display/layout.rs
index 5c75b26..094f28a 100644
--- a/src/context/widgets_display/layout.rs
+++ b/src/context/widgets_display/layout.rs
@@ -352,6 +352,82 @@ impl Context {
         self.pending_screen_nav.push(ScreenNav::Reset);
     }
 
+    /// Remove retained hook/focus state for an inactive screen.
+    ///
+    /// Returns `false` when `name` is still present in `screens`' stack. Call
+    /// this after popping runtime-generated detail screens to release their
+    /// isolated hook segment and saved focus entry.
+    pub fn remove_screen_state(&mut self, screens: &mut ScreenState, name: &str) -> bool {
+        if screens.contains(name) {
+            return false;
+        }
+        let focus_removed = screens.remove_inactive(name);
+        let hooks_removed = self.remove_screen_hooks(name);
+        focus_removed || hooks_removed
+    }
+
+    /// Retain inactive screen states accepted by `keep`.
+    ///
+    /// Screens currently present in `screens`' stack are always kept. Returns
+    /// the number of screen names removed from either the hook map or the
+    /// saved-focus map.
+    pub fn retain_screen_state(
+        &mut self,
+        screens: &mut ScreenState,
+        mut keep: impl FnMut(&str) -> bool,
+    ) -> usize {
+        let remove_names: Vec = self
+            .screen_hook_map
+            .keys()
+            .filter(|name| !screens.contains(name) && !keep(name))
+            .cloned()
+            .collect();
+
+        let mut removed = 0;
+        for name in remove_names {
+            if self.remove_screen_state(screens, &name) {
+                removed += 1;
+            }
+        }
+        removed + screens.retain_inactive(keep)
+    }
+
+    /// Number of retained screen hook segments.
+    ///
+    /// Diagnostic helper for apps that generate screen names from runtime ids.
+    pub fn screen_state_count(&self) -> usize {
+        self.screen_hook_map.len()
+    }
+
+    fn remove_screen_hooks(&mut self, name: &str) -> bool {
+        let Some((seg_start, seg_count)) = self.screen_hook_map.remove(name) else {
+            return false;
+        };
+
+        if seg_count == 0 {
+            return true;
+        }
+
+        let end = seg_start
+            .saturating_add(seg_count)
+            .min(self.hook_states.len());
+        if seg_start >= end {
+            return true;
+        }
+
+        let removed = end - seg_start;
+        self.hook_states.drain(seg_start..end);
+        for (other_start, _) in self.screen_hook_map.values_mut() {
+            if *other_start > seg_start {
+                *other_start = other_start.saturating_sub(removed);
+            }
+        }
+        if self.rollback.hook_cursor > seg_start {
+            self.rollback.hook_cursor = self.rollback.hook_cursor.saturating_sub(removed);
+        }
+        true
+    }
+
     /// Create a vertical (column) container.
     ///
     /// Children are stacked top-to-bottom. Returns a [`Response`] with
diff --git a/src/context/widgets_display/rich_output.rs b/src/context/widgets_display/rich_output.rs
index 4bb67d3..2bfe791 100644
--- a/src/context/widgets_display/rich_output.rs
+++ b/src/context/widgets_display/rich_output.rs
@@ -95,8 +95,10 @@ impl Context {
     /// The widget allocates `cols` x `rows` cells and renders the image
     /// at full pixel resolution within that space.
     ///
-    /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm).
-    /// On unsupported terminals, the area will be blank.
+    /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm). On
+    /// unsupported terminals, falls back to half-block cell rendering. Set
+    /// `SLT_FORCE_KITTY=1` only when the terminal path is known to pass Kitty
+    /// graphics through.
     ///
     /// # Arguments
     /// * `rgba` - Raw RGBA pixel data
@@ -112,6 +114,17 @@ impl Context {
         cols: u32,
         rows: u32,
     ) -> Response {
+        if !self.kitty_graphics_supported() {
+            return self.rgba_halfblock_fallback(
+                rgba,
+                pixel_width,
+                pixel_height,
+                cols,
+                rows,
+                "[kitty unsupported]",
+            );
+        }
+
         let rgba_data = normalize_rgba(rgba, pixel_width, pixel_height);
         let content_hash = crate::buffer::hash_rgba(&rgba_data);
         let rgba_arc = std::sync::Arc::new(rgba_data);
@@ -146,7 +159,9 @@ impl Context {
     /// using detected cell pixel dimensions (falls back to 8×16 if
     /// detection fails).
     ///
-    /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm).
+    /// Requires a Kitty-compatible terminal (Kitty, Ghostty, WezTerm). On
+    /// unsupported terminals, falls back to half-block cell rendering without
+    /// probing cell pixel size.
     pub fn kitty_image_fit(
         &mut self,
         rgba: &[u8],
@@ -154,18 +169,28 @@ impl Context {
         src_height: u32,
         cols: u32,
     ) -> Response {
+        let supported = self.kitty_graphics_supported();
         #[cfg(feature = "crossterm")]
-        let (cell_w, cell_h) = crate::terminal::cell_pixel_size();
+        let (cell_w, cell_h) = if supported {
+            crate::terminal::cell_pixel_size()
+        } else {
+            (8u32, 16u32)
+        };
         #[cfg(not(feature = "crossterm"))]
         let (cell_w, cell_h) = (8u32, 16u32);
 
-        let rows = if src_width == 0 {
-            1
-        } else {
-            ((cols as f64 * src_height as f64 * cell_w as f64) / (src_width as f64 * cell_h as f64))
-                .ceil()
-                .max(1.0) as u32
-        };
+        let rows = image_fit_rows(src_width, src_height, cols, cell_w, cell_h);
+        if !supported {
+            return self.rgba_halfblock_fallback(
+                rgba,
+                src_width,
+                src_height,
+                cols,
+                rows,
+                "[kitty unsupported]",
+            );
+        }
+
         let rgba_data = normalize_rgba(rgba, src_width, src_height);
         let content_hash = crate::buffer::hash_rgba(&rgba_data);
         let rgba_arc = std::sync::Arc::new(rgba_data);
@@ -225,8 +250,7 @@ impl Context {
         // allowlist (now including WezTerm/Ghostty) / `SLT_FORCE_SIXEL` when the
         // probe returned unknown. App code never selects a protocol — the
         // blitter ladder resolves it.
-        let sixel_supported =
-            self.is_real_terminal && (self.capabilities.sixel || terminal_supports_sixel());
+        let sixel_supported = self.sixel_supported();
         if !sixel_supported {
             self.container().w(cols).h(rows).draw(|buf, rect| {
                 if rect.width == 0 || rect.height == 0 {
@@ -301,8 +325,7 @@ impl Context {
         // Issue #264 ladder integration: consult the negotiated capability
         // snapshot first, then the env allowlist / `SLT_FORCE_ITERM`. App code
         // never selects a protocol directly.
-        let supported =
-            self.is_real_terminal && (self.capabilities.iterm2 || terminal_supports_iterm());
+        let supported = self.iterm_supported();
         if !supported {
             return self.iterm_placeholder(cols, rows);
         }
@@ -352,14 +375,17 @@ impl Context {
     #[cfg(feature = "crossterm")]
     #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
     pub fn iterm_image_fit(&mut self, data: &[u8], cols: u32) -> Response {
-        let supported =
-            self.is_real_terminal && (self.capabilities.iterm2 || terminal_supports_iterm());
+        let supported = self.iterm_supported();
 
         // Reserve rows from the cell aspect using a 1:1 source assumption — the
         // terminal does the real aspect math from the decoded file; this only
         // sizes the cell box so layout below the image is not clobbered. Use a
         // square-ish default of `cols / 2` rows (cells are ~2:1 tall), min 1.
-        let (cell_w, cell_h) = crate::terminal::cell_pixel_size();
+        let (cell_w, cell_h) = if supported {
+            crate::terminal::cell_pixel_size()
+        } else {
+            (8u32, 16u32)
+        };
         let rows = if cell_h == 0 {
             cols.max(1)
         } else {
@@ -397,6 +423,106 @@ impl Context {
         Response::none()
     }
 
+    #[cfg(feature = "crossterm")]
+    fn kitty_graphics_supported(&self) -> bool {
+        if !self.is_real_terminal {
+            return false;
+        }
+        if terminal_force_graphics("SLT_FORCE_KITTY") {
+            return true;
+        }
+        if terminal_graphics_blocked_by_multiplexer() {
+            return false;
+        }
+        self.capabilities.kitty_graphics || terminal_supports_kitty()
+    }
+
+    #[cfg(not(feature = "crossterm"))]
+    fn kitty_graphics_supported(&self) -> bool {
+        false
+    }
+
+    #[cfg(feature = "crossterm")]
+    fn sixel_supported(&self) -> bool {
+        if !self.is_real_terminal {
+            return false;
+        }
+        if terminal_force_graphics("SLT_FORCE_SIXEL") {
+            return true;
+        }
+        if terminal_graphics_blocked_by_multiplexer() {
+            return false;
+        }
+        self.capabilities.sixel || terminal_supports_sixel()
+    }
+
+    #[cfg(feature = "crossterm")]
+    fn iterm_supported(&self) -> bool {
+        if !self.is_real_terminal {
+            return false;
+        }
+        if terminal_force_graphics("SLT_FORCE_ITERM") {
+            return true;
+        }
+        if terminal_graphics_blocked_by_multiplexer() {
+            return false;
+        }
+        self.capabilities.iterm2 || terminal_supports_iterm()
+    }
+
+    fn rgba_halfblock_fallback(
+        &mut self,
+        rgba: &[u8],
+        pixel_width: u32,
+        pixel_height: u32,
+        cols: u32,
+        rows: u32,
+        placeholder: &'static str,
+    ) -> Response {
+        let rgba_data = normalize_rgba(rgba, pixel_width, pixel_height);
+        if rgba_data.is_empty() {
+            self.container().w(cols).h(rows).draw(move |buf, rect| {
+                if rect.width == 0 || rect.height == 0 {
+                    return;
+                }
+                buf.set_string(rect.x, rect.y, placeholder, Style::new());
+            });
+            return Response::none();
+        }
+
+        self.container().w(cols).h(rows).draw(move |buf, rect| {
+            if rect.width == 0 || rect.height == 0 {
+                return;
+            }
+
+            let dst_pixel_height = rect.height.saturating_mul(2).max(1);
+            for row in 0..rect.height {
+                for col in 0..rect.width {
+                    let upper = sample_rgba_color(
+                        &rgba_data,
+                        pixel_width,
+                        pixel_height,
+                        col,
+                        row * 2,
+                        rect.width,
+                        dst_pixel_height,
+                    );
+                    let lower = sample_rgba_color(
+                        &rgba_data,
+                        pixel_width,
+                        pixel_height,
+                        col,
+                        row.saturating_mul(2).saturating_add(1),
+                        rect.width,
+                        dst_pixel_height,
+                    );
+                    draw_halfblock_cell(buf, rect.x + col, rect.y + row, upper, lower);
+                }
+            }
+        });
+        Response::none()
+    }
+
     /// Reserve a `cols`×`rows` container and draw the unsupported placeholder,
     /// matching the Sixel fallback pattern.
     #[cfg(feature = "crossterm")]
diff --git a/src/context/widgets_display/tests.rs b/src/context/widgets_display/tests.rs
index 41c8acf..ee66294 100644
--- a/src/context/widgets_display/tests.rs
+++ b/src/context/widgets_display/tests.rs
@@ -84,6 +84,66 @@ fn image_empty_does_not_panic() {
     // No assertion needed — must not panic
 }
 
+#[test]
+fn kitty_image_headless_uses_halfblock_fallback_without_placement() {
+    let rgba = [255u8, 0, 0, 255].repeat(4);
+    let mut backend = TestBackend::new(4, 2);
+    backend.render(|ui| {
+        let _ = ui.kitty_image(&rgba, 2, 2, 4, 2);
+    });
+
+    assert!(
+        backend.buffer().kitty_placements.is_empty(),
+        "headless Kitty fallback must not enqueue graphics placements"
+    );
+    backend.assert_contains("▀");
+}
+
+#[test]
+fn kitty_image_fit_headless_uses_halfblock_fallback_without_placement() {
+    let rgba = [0u8, 128, 255, 255].repeat(4);
+    let mut backend = TestBackend::new(4, 2);
+    backend.render(|ui| {
+        let _ = ui.kitty_image_fit(&rgba, 2, 2, 4);
+    });
+
+    assert!(
+        backend.buffer().kitty_placements.is_empty(),
+        "headless Kitty fit fallback must not enqueue graphics placements"
+    );
+    backend.assert_contains("▀");
+}
+
+#[test]
+fn sixel_image_headless_uses_placeholder_without_sprixel() {
+    let rgba = [255u8, 0, 0, 255].repeat(4);
+    let mut backend = TestBackend::new(20, 2);
+    backend.render(|ui| {
+        let _ = ui.sixel_image(&rgba, 2, 2, 20, 2);
+    });
+
+    assert!(
+        backend.buffer().sprixels.is_empty(),
+        "headless Sixel fallback must not enqueue sprixels"
+    );
+    backend.assert_contains("[sixel unsupported]");
+}
+
+#[test]
+fn iterm_image_fit_headless_uses_placeholder_without_sprixel() {
+    let png = [0x89u8, b'P', b'N', b'G'];
+    let mut backend = TestBackend::new(20, 10);
+    backend.render(|ui| {
+        let _ = ui.iterm_image_fit(&png, 20);
+    });
+
+    assert!(
+        backend.buffer().sprixels.is_empty(),
+        "headless iTerm fallback must not enqueue sprixels"
+    );
+    backend.assert_contains("[iterm2 unsupported]");
+}
+
 // confirm() hit-test regression tests (issue #175)
 // Layout: "ok? [Yes] [No]" at x=0
 //   q_width=3, space=1 → yes_start=4, yes_end=9, no_start=10, no_end=14
diff --git a/src/context/widgets_display/text.rs b/src/context/widgets_display/text.rs
index a0727b9..56b0a4c 100644
--- a/src/context/widgets_display/text.rs
+++ b/src/context/widgets_display/text.rs
@@ -194,13 +194,13 @@ impl Context {
                 } => {
                     let chars: Vec = content.chars().collect();
                     let len = chars.len();
-                    let denom = len.saturating_sub(1).max(1) as f32;
+                    let denom = len.saturating_sub(1).max(1) as f64;
                     let segments = chars
                         .into_iter()
                         .enumerate()
                         .map(|(i, ch)| {
                             let mut seg_style = *style;
-                            seg_style.fg = Some(from.blend(to, i as f32 / denom));
+                            seg_style.fg = Some(from.blend_f64(to, i as f64 / denom));
                             (ch.to_string(), seg_style)
                         })
                         .collect();
@@ -241,14 +241,14 @@ impl Context {
     /// ```no_run
     /// # slt::run(|ui: &mut slt::Context| {
     /// use slt::Color;
-    /// ui.text("rainbow").gradient_stops(&[
+    /// ui.text("rainbow").gradient_stops_f64(&[
     ///     (0.0, Color::Red),
     ///     (0.5, Color::Yellow),
     ///     (1.0, Color::Green),
     /// ]);
     /// # });
     /// ```
-    pub fn gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
+    pub fn gradient_stops_f64(&mut self, stops: &[(f64, Color)]) -> &mut Self {
         if stops.is_empty() {
             return self;
         }
@@ -257,6 +257,19 @@ impl Context {
         self
     }
 
+    /// Deprecated `f32` alias for [`gradient_stops_f64`](Self::gradient_stops_f64).
+    #[deprecated(
+        since = "0.22.2",
+        note = "use Context::gradient_stops_f64() to keep public float APIs on f64"
+    )]
+    pub fn gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
+        let stops: Vec<(f64, Color)> = stops
+            .iter()
+            .map(|(pos, color)| (f64::from(*pos), *color))
+            .collect();
+        self.gradient_stops_f64(&stops)
+    }
+
     /// Apply a per-character background gradient to the last rendered text.
     ///
     /// The two-stop background analogue of [`gradient`]. Colors the cell
@@ -274,7 +287,7 @@ impl Context {
     /// # });
     /// ```
     pub fn bg_gradient(&mut self, from: Color, to: Color) -> &mut Self {
-        self.apply_char_gradient(true, |t| from.blend(to, t));
+        self.apply_char_gradient(true, |t| from.blend_f64(to, t));
         self
     }
 
@@ -291,13 +304,13 @@ impl Context {
     /// ```no_run
     /// # slt::run(|ui: &mut slt::Context| {
     /// use slt::Color;
-    /// ui.text("header").bg_gradient_stops(&[
+    /// ui.text("header").bg_gradient_stops_f64(&[
     ///     (0.0, Color::Blue),
     ///     (1.0, Color::Magenta),
     /// ]);
     /// # });
     /// ```
-    pub fn bg_gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
+    pub fn bg_gradient_stops_f64(&mut self, stops: &[(f64, Color)]) -> &mut Self {
         if stops.is_empty() {
             return self;
         }
@@ -306,10 +319,23 @@ impl Context {
         self
     }
 
+    /// Deprecated `f32` alias for [`bg_gradient_stops_f64`](Self::bg_gradient_stops_f64).
+    #[deprecated(
+        since = "0.22.2",
+        note = "use Context::bg_gradient_stops_f64() to keep public float APIs on f64"
+    )]
+    pub fn bg_gradient_stops(&mut self, stops: &[(f32, Color)]) -> &mut Self {
+        let stops: Vec<(f64, Color)> = stops
+            .iter()
+            .map(|(pos, color)| (f64::from(*pos), *color))
+            .collect();
+        self.bg_gradient_stops_f64(&stops)
+    }
+
     /// Return `stops` sorted ascending by clamped position. Positions are
     /// clamped into `0.0..=1.0` so out-of-range inputs degrade gracefully.
-    fn sorted_gradient_stops(stops: &[(f32, Color)]) -> Vec<(f32, Color)> {
-        let mut sorted: Vec<(f32, Color)> = stops
+    fn sorted_gradient_stops(stops: &[(f64, Color)]) -> Vec<(f64, Color)> {
+        let mut sorted: Vec<(f64, Color)> = stops
             .iter()
             .map(|(pos, color)| (pos.clamp(0.0, 1.0), *color))
             .collect();
@@ -319,7 +345,7 @@ impl Context {
 
     /// Sample the color at position `t` (in `0.0..=1.0`) from pre-sorted,
     /// non-empty `stops`, linearly interpolating between the bracketing stops.
-    fn sample_gradient_stops(stops: &[(f32, Color)], t: f32) -> Color {
+    fn sample_gradient_stops(stops: &[(f64, Color)], t: f64) -> Color {
         let t = t.clamp(0.0, 1.0);
         // Non-empty is guaranteed by callers; fall back defensively otherwise.
         let first = match stops.first() {
@@ -338,13 +364,11 @@ impl Context {
             let (p1, c1) = window[1];
             if t >= p0 && t <= p1 {
                 let span = p1 - p0;
-                if span <= f32::EPSILON {
+                if span <= f64::EPSILON {
                     return c1;
                 }
                 let local = (t - p0) / span;
-                // blend(self, other, alpha) = self*alpha + other*(1-alpha):
-                // c1.blend(c0, local) yields c0 at local=0 and c1 at local=1.
-                return c1.blend(c0, local);
+                return c1.blend_f64(c0, local);
             }
         }
         last.1
@@ -353,7 +377,7 @@ impl Context {
     /// Replace the last `Text` command with a `RichText` gradient, mapping each
     /// character's column to a position in `0.0..=1.0` exactly like
     /// [`gradient`](Self::gradient). `is_bg` selects background vs foreground.
-    fn apply_char_gradient(&mut self, is_bg: bool, color_at: impl Fn(f32) -> Color) {
+    fn apply_char_gradient(&mut self, is_bg: bool, color_at: impl Fn(f64) -> Color) {
         if let Some(idx) = self.rollback.last_text_idx {
             let replacement = match &self.commands[idx] {
                 Command::Text {
@@ -367,13 +391,13 @@ impl Context {
                 } => {
                     let chars: Vec = content.chars().collect();
                     let len = chars.len();
-                    let denom = len.saturating_sub(1).max(1) as f32;
+                    let denom = len.saturating_sub(1).max(1) as f64;
                     let segments = chars
                         .into_iter()
                         .enumerate()
                         .map(|(i, ch)| {
                             let mut seg_style = *style;
-                            let color = color_at(i as f32 / denom);
+                            let color = color_at(i as f64 / denom);
                             if is_bg {
                                 seg_style.bg = Some(color);
                             } else {
@@ -719,7 +743,8 @@ mod gradient_tests {
         let blue = Color::Rgb(0, 0, 255);
         let mut backend = TestBackend::new(20, 4);
         backend.render(|ui| {
-            ui.text("ABC").gradient_stops(&[(0.0, red), (1.0, blue)]);
+            ui.text("ABC")
+                .gradient_stops_f64(&[(0.0, red), (1.0, blue)]);
         });
 
         let buf = backend.buffer();
@@ -749,7 +774,8 @@ mod gradient_tests {
         let mut backend = TestBackend::new(20, 4);
         backend.render(|ui| {
             // Deliberately out of order — must behave identically to sorted.
-            ui.text("ABC").gradient_stops(&[(1.0, blue), (0.0, red)]);
+            ui.text("ABC")
+                .gradient_stops_f64(&[(1.0, blue), (0.0, red)]);
         });
 
         let buf = backend.buffer();
@@ -766,7 +792,7 @@ mod gradient_tests {
         backend.render(|ui| {
             // len=3, denom=2 → columns map to t = 0.0, 0.5, 1.0.
             ui.text("ABC")
-                .gradient_stops(&[(0.0, red), (0.5, green), (1.0, blue)]);
+                .gradient_stops_f64(&[(0.0, red), (0.5, green), (1.0, blue)]);
         });
 
         let buf = backend.buffer();
@@ -784,7 +810,7 @@ mod gradient_tests {
         let cyan = Color::Rgb(0, 200, 200);
         let mut backend = TestBackend::new(20, 4);
         backend.render(|ui| {
-            ui.text("ABCD").gradient_stops(&[(0.0, cyan)]);
+            ui.text("ABCD").gradient_stops_f64(&[(0.0, cyan)]);
         });
 
         let buf = backend.buffer();
@@ -802,7 +828,7 @@ mod gradient_tests {
         let mut backend = TestBackend::new(20, 4);
         backend.render(|ui| {
             // Empty slice must not panic and must leave content intact.
-            ui.text("HELLO").gradient_stops(&[]);
+            ui.text("HELLO").gradient_stops_f64(&[]);
         });
 
         backend.assert_contains("HELLO");
@@ -818,7 +844,7 @@ mod gradient_tests {
         });
 
         let buf = backend.buffer();
-        // bg_gradient mirrors gradient(): from.blend(to, t) — at t=0 that is `to`
+        // bg_gradient mirrors gradient(): from.blend_f64(to, t) yields `to` at t=0.
         // (blue), at t=1 that is `from` (red). Foreground stays untouched.
         assert_eq!(buf.get(0, 0).style.bg, Some(blue), "first column bg = to");
         assert_eq!(buf.get(2, 0).style.bg, Some(red), "last column bg = from");
@@ -835,7 +861,8 @@ mod gradient_tests {
         let blue = Color::Rgb(0, 0, 255);
         let mut backend = TestBackend::new(20, 4);
         backend.render(|ui| {
-            ui.text("ABC").bg_gradient_stops(&[(0.0, red), (1.0, blue)]);
+            ui.text("ABC")
+                .bg_gradient_stops_f64(&[(0.0, red), (1.0, blue)]);
         });
 
         let buf = backend.buffer();
@@ -852,7 +879,7 @@ mod gradient_tests {
     fn bg_gradient_stops_empty_is_noop() {
         let mut backend = TestBackend::new(20, 4);
         backend.render(|ui| {
-            ui.text("WORLD").bg_gradient_stops(&[]);
+            ui.text("WORLD").bg_gradient_stops_f64(&[]);
         });
 
         backend.assert_contains("WORLD");
diff --git a/src/layout/render.rs b/src/layout/render.rs
index 1ceb9ec..d524039 100644
--- a/src/layout/render.rs
+++ b/src/layout/render.rs
@@ -584,8 +584,8 @@ fn debug_color_for_depth(tint: LayerTint, depth: u32) -> Color {
     };
     match depth {
         0..=1 => base,
-        2..=3 => base.lighten(0.25),
-        _ => base.lighten(0.5),
+        2..=3 => base.lighten_f64(0.25),
+        _ => base.lighten_f64(0.5),
     }
 }
 
diff --git a/src/lib.rs b/src/lib.rs
index d761457..955b968 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -132,8 +132,6 @@ use std::io;
 #[cfg(feature = "crossterm")]
 use std::io::IsTerminal;
 #[cfg(feature = "crossterm")]
-use std::io::Write;
-#[cfg(feature = "crossterm")]
 use std::sync::Once;
 use std::time::{Duration, Instant};
 
@@ -341,6 +339,15 @@ impl AppState {
     }
 
     /// Returns the smoothed FPS estimate (exponential moving average).
+    pub fn fps_f64(&self) -> f64 {
+        f64::from(self.inner.diagnostics.fps_ema)
+    }
+
+    /// Deprecated `f32` alias for [`fps_f64`](Self::fps_f64).
+    #[deprecated(
+        since = "0.22.2",
+        note = "use AppState::fps_f64() to keep public float APIs on f64"
+    )]
     pub fn fps(&self) -> f32 {
         self.inner.diagnostics.fps_ema
     }
@@ -436,17 +443,7 @@ fn install_panic_hook() {
     PANIC_HOOK_ONCE.call_once(|| {
         let original = std::panic::take_hook();
         std::panic::set_hook(Box::new(move |panic_info| {
-            let _ = crossterm::terminal::disable_raw_mode();
-            let mut stdout = io::stdout();
-            let _ = crossterm::execute!(
-                stdout,
-                crossterm::terminal::LeaveAlternateScreen,
-                crossterm::cursor::Show,
-                crossterm::event::DisableMouseCapture,
-                crossterm::event::DisableBracketedPaste,
-                crossterm::style::ResetColor,
-                crossterm::style::SetAttribute(crossterm::style::Attribute::Reset)
-            );
+            terminal::cleanup_after_panic();
 
             // Print friendly panic header
             eprintln!("\n\x1b[1;31m━━━ SLT Panic ━━━\x1b[0m\n");
@@ -463,9 +460,9 @@ fn install_panic_hook() {
 
             // Print message
             if let Some(msg) = panic_info.payload().downcast_ref::<&str>() {
-                eprintln!("\x1b[1m{}\x1b[0m", msg);
+                eprintln!("\x1b[1m{msg}\x1b[0m");
             } else if let Some(msg) = panic_info.payload().downcast_ref::() {
-                eprintln!("\x1b[1m{}\x1b[0m", msg);
+                eprintln!("\x1b[1m{msg}\x1b[0m");
             }
 
             eprintln!(
@@ -548,6 +545,15 @@ fn install_suspend_handler(snapshot: terminal::SessionSnapshot) -> io::Result io::Result<()> {
+    terminal::suspend_to_shell(&snapshot);
+    let result = signal_hook::low_level::emulate_default_handler(signal_hook::consts::SIGTSTP);
+    terminal::resume_from_shell(&snapshot);
+    result?;
+    Ok(())
+}
+
 /// Consume the pending full-redraw request raised by a `SIGCONT` resume and, if
 /// set, clear + repaint the whole frame (issue #263).
 ///
@@ -1094,12 +1100,27 @@ pub fn run(f: impl FnMut(&mut Context)) -> io::Result<()> {
 fn set_terminal_title(title: &Option) {
     if let Some(title) = title {
         use std::io::Write;
+        let title = sanitize_terminal_text(title);
         let mut stdout = io::stdout();
         let _ = write!(stdout, "\x1b]2;{title}\x07");
         let _ = stdout.flush();
     }
 }
 
+#[cfg(feature = "crossterm")]
+fn sanitize_terminal_text(input: &str) -> String {
+    input
+        .chars()
+        .map(|ch| {
+            if ch.is_control() || ('\u{80}'..='\u{9f}').contains(&ch) {
+                '?'
+            } else {
+                ch
+            }
+        })
+        .collect()
+}
+
 /// Run the TUI loop with custom configuration.
 ///
 /// Like [`run`], but accepts a [`RunConfig`] to control tick rate, mouse
@@ -1174,12 +1195,21 @@ pub fn run_with(config: RunConfig, mut f: impl FnMut(&mut Context)) -> io::Resul
         discard_static_log(&mut state, "full-screen run()");
         let render_elapsed = frame_start.elapsed();
 
+        #[cfg(unix)]
+        let suspend_snapshot = term.session_snapshot();
+        #[cfg(unix)]
+        let mut on_suspend = || suspend_current_session(suspend_snapshot);
+        #[cfg(not(unix))]
+        let mut on_suspend = || Ok(());
+
         if !poll_events(
             &mut events,
             &mut state,
             config.tick_rate,
             &mut || term.handle_resize(),
             config.handle_ctrl_c,
+            config.handle_suspend,
+            &mut on_suspend,
         )? {
             break;
         }
@@ -1246,6 +1276,22 @@ pub fn run_async_with(
     Ok(tx)
 }
 
+#[cfg(all(feature = "crossterm", feature = "async"))]
+fn drain_async_messages(rx: &mut tokio::sync::mpsc::Receiver, messages: &mut Vec) -> bool {
+    let mut disconnected = false;
+    loop {
+        match rx.try_recv() {
+            Ok(message) => messages.push(message),
+            Err(tokio::sync::mpsc::error::TryRecvError::Empty) => break,
+            Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {
+                disconnected = true;
+                break;
+            }
+        }
+    }
+    disconnected
+}
+
 #[cfg(all(feature = "crossterm", feature = "async"))]
 fn run_async_loop(
     config: RunConfig,
@@ -1290,8 +1336,9 @@ fn run_async_loop(
         #[cfg(unix)]
         drain_resume_redraw(&mut || term.handle_resize())?;
         messages.clear();
-        while let Ok(message) = rx.try_recv() {
-            messages.push(message);
+        let input_disconnected = drain_async_messages(&mut rx, &mut messages);
+        if input_disconnected && messages.is_empty() {
+            break;
         }
 
         let (w, h) = term.size();
@@ -1315,14 +1362,26 @@ fn run_async_loop(
         // Issue #233: full-screen async mode has no scrollback channel — warn
         // and drop any pending static_log lines.
         discard_static_log(&mut state, "run_async()");
+        if input_disconnected {
+            break;
+        }
         let render_elapsed = frame_start.elapsed();
 
+        #[cfg(unix)]
+        let suspend_snapshot = term.session_snapshot();
+        #[cfg(unix)]
+        let mut on_suspend = || suspend_current_session(suspend_snapshot);
+        #[cfg(not(unix))]
+        let mut on_suspend = || Ok(());
+
         if !poll_events(
             &mut events,
             &mut state,
             config.tick_rate,
             &mut || term.handle_resize(),
             config.handle_ctrl_c,
+            config.handle_suspend,
+            &mut on_suspend,
         )? {
             break;
         }
@@ -1420,12 +1479,21 @@ pub fn run_inline_with(
         discard_static_log(&mut state, "run_inline()");
         let render_elapsed = frame_start.elapsed();
 
+        #[cfg(unix)]
+        let suspend_snapshot = term.session_snapshot();
+        #[cfg(unix)]
+        let mut on_suspend = || suspend_current_session(suspend_snapshot);
+        #[cfg(not(unix))]
+        let mut on_suspend = || Ok(());
+
         if !poll_events(
             &mut events,
             &mut state,
             config.tick_rate,
             &mut || term.handle_resize(),
             config.handle_ctrl_c,
+            config.handle_suspend,
+            &mut on_suspend,
         )? {
             break;
         }
@@ -1528,12 +1596,21 @@ pub fn run_static_with(
         }
         let render_elapsed = frame_start.elapsed();
 
+        #[cfg(unix)]
+        let suspend_snapshot = term.session_snapshot();
+        #[cfg(unix)]
+        let mut on_suspend = || suspend_current_session(suspend_snapshot);
+        #[cfg(not(unix))]
+        let mut on_suspend = || Ok(());
+
         if !poll_events(
             &mut events,
             &mut state,
             config.tick_rate,
             &mut || term.handle_resize(),
             config.handle_ctrl_c,
+            config.handle_suspend,
+            &mut on_suspend,
         )? {
             break;
         }
@@ -1551,8 +1628,14 @@ fn write_static_lines(lines: &[String]) -> io::Result<()> {
     }
 
     let mut stdout = io::stdout();
+    write_static_lines_to(&mut stdout, lines)
+}
+
+#[cfg(feature = "crossterm")]
+fn write_static_lines_to(stdout: &mut impl io::Write, lines: &[String]) -> io::Result<()> {
     for line in lines {
-        stdout.write_all(line.as_bytes())?;
+        let safe = sanitize_terminal_text(line);
+        stdout.write_all(safe.as_bytes())?;
         stdout.write_all(b"\r\n")?;
     }
     stdout.flush()
@@ -1723,6 +1806,8 @@ fn poll_events(
     tick_rate: Duration,
     on_resize: &mut impl FnMut() -> io::Result<()>,
     handle_ctrl_c: bool,
+    handle_suspend: bool,
+    on_suspend: &mut impl FnMut() -> io::Result<()>,
 ) -> io::Result {
     let mut has_resize = false;
 
@@ -1736,6 +1821,10 @@ fn poll_events(
             if handle_ctrl_c && is_ctrl_c(&ev) {
                 return Ok(false);
             }
+            if handle_suspend && is_ctrl_z(&ev) {
+                on_suspend()?;
+                return Ok(true);
+            }
             // Resize is recorded (via `has_resize`) but not yet acted on — the
             // single `on_resize` call is deferred to end-of-batch so a burst
             // collapses into one geometry sync.
@@ -1749,6 +1838,10 @@ fn poll_events(
                 if handle_ctrl_c && is_ctrl_c(&ev) {
                     return Ok(false);
                 }
+                if handle_suspend && is_ctrl_z(&ev) {
+                    on_suspend()?;
+                    return Ok(true);
+                }
                 process_ev(&ev, state, &mut has_resize);
                 events.push(ev);
             }
@@ -2249,6 +2342,18 @@ fn is_ctrl_c(ev: &Event) -> bool {
     )
 }
 
+#[cfg(feature = "crossterm")]
+fn is_ctrl_z(ev: &Event) -> bool {
+    matches!(
+        ev,
+        Event::Key(event::KeyEvent {
+            code: KeyCode::Char('z'),
+            modifiers,
+            kind: event::KeyEventKind::Press,
+        }) if modifiers.contains(KeyModifiers::CONTROL)
+    )
+}
+
 #[cfg(feature = "crossterm")]
 fn sleep_for_fps_cap(max_fps: Option, render_elapsed: Duration) {
     if let Some(fps) = max_fps.filter(|fps| *fps > 0) {
@@ -2274,6 +2379,38 @@ mod run_loop_tests {
         })
     }
 
+    fn char_key(ch: char, modifiers: event::KeyModifiers) -> Event {
+        Event::Key(event::KeyEvent {
+            code: KeyCode::Char(ch),
+            kind: event::KeyEventKind::Press,
+            modifiers,
+        })
+    }
+
+    #[test]
+    fn terminal_text_sanitizer_replaces_control_bytes() {
+        assert_eq!(
+            sanitize_terminal_text("safe\x1b]52;c;AAAA\x07text\u{9b}tail"),
+            "safe?]52;c;AAAA?text?tail"
+        );
+    }
+
+    #[test]
+    fn static_lines_are_sanitized_before_scrollback_write() {
+        let lines = vec!["ok\x1b[31mred\x07".to_string()];
+        let mut out = Vec::new();
+        write_static_lines_to(&mut out, &lines).unwrap();
+        assert_eq!(out, b"ok?[31mred?\r\n");
+    }
+
+    #[test]
+    fn ctrl_z_suspend_key_is_detected_separately_from_ctrl_c() {
+        assert!(is_ctrl_z(&char_key('z', event::KeyModifiers::CONTROL)));
+        assert!(!is_ctrl_z(&char_key('z', event::KeyModifiers::NONE)));
+        assert!(is_ctrl_c(&char_key('c', event::KeyModifiers::CONTROL)));
+        assert!(!is_ctrl_c(&char_key('z', event::KeyModifiers::CONTROL)));
+    }
+
     #[test]
     fn plain_f12_toggles_debug_mode() {
         let mut state = FrameState::default();
@@ -2347,6 +2484,21 @@ mod run_loop_tests {
         assert_eq!(state.diagnostics.debug_layer, before);
     }
 
+    #[cfg(feature = "async")]
+    #[test]
+    fn async_message_drain_reports_disconnect_after_sender_drop() {
+        let (tx, mut rx) = tokio::sync::mpsc::channel(4);
+        tx.try_send(1u8).expect("channel has capacity");
+        tx.try_send(2u8).expect("channel has capacity");
+        drop(tx);
+
+        let mut messages = Vec::new();
+        let disconnected = drain_async_messages(&mut rx, &mut messages);
+
+        assert!(disconnected);
+        assert_eq!(messages, vec![1, 2]);
+    }
+
     // ── Issue #268: Ctrl+F12 devtools inspector toggle ───────────────────
 
     #[test]
diff --git a/src/rect.rs b/src/rect.rs
index 7d0afc5..9657a5a 100644
--- a/src/rect.rs
+++ b/src/rect.rs
@@ -44,7 +44,7 @@ impl Rect {
     /// This is one column past the last column in the rectangle.
     #[inline]
     pub const fn right(&self) -> u32 {
-        self.x + self.width
+        self.x.saturating_add(self.width)
     }
 
     /// Exclusive bottom edge (`y + height`).
@@ -52,7 +52,7 @@ impl Rect {
     /// This is one row past the last row in the rectangle.
     #[inline]
     pub const fn bottom(&self) -> u32 {
-        self.y + self.height
+        self.y.saturating_add(self.height)
     }
 
     /// Returns `true` if the rectangle has zero area (width or height is zero).
@@ -78,8 +78,8 @@ impl Rect {
     pub fn centered(&self, inner_w: u32, inner_h: u32) -> Rect {
         let w = inner_w.min(self.width);
         let h = inner_h.min(self.height);
-        let x = self.x + (self.width.saturating_sub(w)) / 2;
-        let y = self.y + (self.height.saturating_sub(h)) / 2;
+        let x = self.x.saturating_add((self.width.saturating_sub(w)) / 2);
+        let y = self.y.saturating_add((self.height.saturating_sub(h)) / 2);
         Rect {
             x,
             y,
@@ -110,8 +110,8 @@ impl Rect {
         Rect {
             x,
             y,
-            width: right - x,
-            height: bottom - y,
+            width: right.saturating_sub(x),
+            height: bottom.saturating_sub(y),
         }
     }
 
@@ -139,8 +139,8 @@ impl Rect {
             Some(Rect {
                 x,
                 y,
-                width: right - x,
-                height: bottom - y,
+                width: right.saturating_sub(x),
+                height: bottom.saturating_sub(y),
             })
         } else {
             None
@@ -236,8 +236,8 @@ impl Rect {
         } else {
             parent.height
         };
-        let x = parent.x + parent.width.saturating_sub(w) / 2;
-        let y = parent.y + parent.height.saturating_sub(h) / 2;
+        let x = parent.x.saturating_add(parent.width.saturating_sub(w) / 2);
+        let y = parent.y.saturating_add(parent.height.saturating_sub(h) / 2);
         Rect {
             x,
             y,
@@ -266,7 +266,7 @@ impl Rect {
         } else {
             parent.width
         };
-        let x = parent.x + parent.width.saturating_sub(w) / 2;
+        let x = parent.x.saturating_add(parent.width.saturating_sub(w) / 2);
         Rect {
             x,
             y: self.y,
@@ -295,7 +295,7 @@ impl Rect {
         } else {
             parent.height
         };
-        let y = parent.y + parent.height.saturating_sub(h) / 2;
+        let y = parent.y.saturating_add(parent.height.saturating_sub(h) / 2);
         Rect {
             x: self.x,
             y,
@@ -475,6 +475,31 @@ mod tests {
         assert_eq!(r2.area(), u32::MAX);
     }
 
+    #[test]
+    fn rect_edges_saturate_instead_of_wrapping() {
+        let r = Rect::new(u32::MAX, u32::MAX - 1, 10, 10);
+        assert_eq!(r.right(), u32::MAX);
+        assert_eq!(r.bottom(), u32::MAX);
+        assert!(!r.contains(0, 0), "saturated edge must not wrap to origin");
+    }
+
+    #[test]
+    fn rect_union_and_intersection_do_not_wrap_at_u32_max() {
+        let edge = Rect::new(u32::MAX - 1, u32::MAX - 1, 10, 10);
+        let origin = Rect::new(0, 0, 1, 1);
+
+        assert_eq!(edge.union(origin), Rect::new(0, 0, u32::MAX, u32::MAX));
+        assert_eq!(edge.intersection(origin), None);
+    }
+
+    #[test]
+    fn rect_centering_saturates_large_offsets() {
+        let parent = Rect::new(u32::MAX - 2, u32::MAX - 2, 10, 10);
+        let child = Rect::new(0, 0, 2, 2).center_in(parent);
+        assert_eq!(child.x, u32::MAX);
+        assert_eq!(child.y, u32::MAX);
+    }
+
     #[test]
     fn test_center_in_basic() {
         let dialog = Rect::new(0, 0, 40, 10);
diff --git a/src/style/color.rs b/src/style/color.rs
index a03903e..efba2dd 100644
--- a/src/style/color.rs
+++ b/src/style/color.rs
@@ -47,7 +47,7 @@ pub enum Color {
 }
 
 #[inline]
-fn to_linear(c: f32) -> f32 {
+fn to_linear(c: f64) -> f64 {
     if c <= 0.04045 {
         c / 12.92
     } else {
@@ -96,19 +96,28 @@ impl Color {
     /// use slt::Color;
     ///
     /// let dark = Color::Rgb(30, 30, 46);
-    /// assert!(dark.luminance() < 0.15);
+    /// assert!(dark.luminance_f64() < 0.15);
     ///
     /// let light = Color::Rgb(205, 214, 244);
-    /// assert!(light.luminance() > 0.6);
+    /// assert!(light.luminance_f64() > 0.6);
     /// ```
-    pub fn luminance(self) -> f32 {
+    pub fn luminance_f64(self) -> f64 {
         let (r, g, b) = self.to_rgb();
-        let rf = to_linear(r as f32 / 255.0);
-        let gf = to_linear(g as f32 / 255.0);
-        let bf = to_linear(b as f32 / 255.0);
+        let rf = to_linear(f64::from(r) / 255.0);
+        let gf = to_linear(f64::from(g) / 255.0);
+        let bf = to_linear(f64::from(b) / 255.0);
         0.2126 * rf + 0.7152 * gf + 0.0722 * bf
     }
 
+    /// Deprecated `f32` alias for [`luminance_f64`](Self::luminance_f64).
+    #[deprecated(
+        since = "0.22.2",
+        note = "use Color::luminance_f64() to keep public float APIs on f64"
+    )]
+    pub fn luminance(self) -> f32 {
+        self.luminance_f64() as f32
+    }
+
     /// Return a contrasting foreground color for the given background.
     ///
     /// Uses the WCAG 2.1 relative luminance threshold (0.179) to decide
@@ -125,7 +134,7 @@ impl Color {
     /// // Dracula purple → white (WCAG luminance 0.385 < 0.179 threshold)
     /// ```
     pub fn contrast_fg(bg: Color) -> Color {
-        if bg.luminance() > 0.179 {
+        if bg.luminance_f64() > 0.179 {
             Color::Rgb(0, 0, 0)
         } else {
             Color::Rgb(255, 255, 255)
@@ -144,33 +153,60 @@ impl Color {
     ///
     /// let white = Color::Rgb(255, 255, 255);
     /// let black = Color::Rgb(0, 0, 0);
-    /// let gray = white.blend(black, 0.5);
+    /// let gray = white.blend_f64(black, 0.5);
     /// // ≈ Rgb(128, 128, 128)
     /// ```
-    pub fn blend(self, other: Color, alpha: f32) -> Color {
+    pub fn blend_f64(self, other: Color, alpha: f64) -> Color {
         let alpha = alpha.clamp(0.0, 1.0);
         let (r1, g1, b1) = self.to_rgb();
         let (r2, g2, b2) = other.to_rgb();
-        let r = (r1 as f32 * alpha + r2 as f32 * (1.0 - alpha)).round() as u8;
-        let g = (g1 as f32 * alpha + g2 as f32 * (1.0 - alpha)).round() as u8;
-        let b = (b1 as f32 * alpha + b2 as f32 * (1.0 - alpha)).round() as u8;
+        let r = (f64::from(r1) * alpha + f64::from(r2) * (1.0 - alpha)).round() as u8;
+        let g = (f64::from(g1) * alpha + f64::from(g2) * (1.0 - alpha)).round() as u8;
+        let b = (f64::from(b1) * alpha + f64::from(b2) * (1.0 - alpha)).round() as u8;
         Color::Rgb(r, g, b)
     }
 
+    /// Deprecated `f32` alias for [`blend_f64`](Self::blend_f64).
+    #[deprecated(
+        since = "0.22.2",
+        note = "use Color::blend_f64() to keep public float APIs on f64"
+    )]
+    pub fn blend(self, other: Color, alpha: f32) -> Color {
+        self.blend_f64(other, f64::from(alpha))
+    }
+
     /// Lighten this color by the given amount (0.0–1.0).
     ///
     /// Blends toward white. `amount = 0.0` returns the original color;
     /// `amount = 1.0` returns white.
+    pub fn lighten_f64(self, amount: f64) -> Color {
+        Color::Rgb(255, 255, 255).blend_f64(self, 1.0 - amount.clamp(0.0, 1.0))
+    }
+
+    /// Deprecated `f32` alias for [`lighten_f64`](Self::lighten_f64).
+    #[deprecated(
+        since = "0.22.2",
+        note = "use Color::lighten_f64() to keep public float APIs on f64"
+    )]
     pub fn lighten(self, amount: f32) -> Color {
-        Color::Rgb(255, 255, 255).blend(self, 1.0 - amount.clamp(0.0, 1.0))
+        self.lighten_f64(f64::from(amount))
     }
 
     /// Darken this color by the given amount (0.0–1.0).
     ///
     /// Blends toward black. `amount = 0.0` returns the original color;
     /// `amount = 1.0` returns black.
+    pub fn darken_f64(self, amount: f64) -> Color {
+        Color::Rgb(0, 0, 0).blend_f64(self, 1.0 - amount.clamp(0.0, 1.0))
+    }
+
+    /// Deprecated `f32` alias for [`darken_f64`](Self::darken_f64).
+    #[deprecated(
+        since = "0.22.2",
+        note = "use Color::darken_f64() to keep public float APIs on f64"
+    )]
     pub fn darken(self, amount: f32) -> Color {
-        Color::Rgb(0, 0, 0).blend(self, 1.0 - amount.clamp(0.0, 1.0))
+        self.darken_f64(f64::from(amount))
     }
 
     /// Compute the WCAG 2.1 contrast ratio between two colors.
@@ -183,19 +219,28 @@ impl Color {
     /// ```
     /// use slt::Color;
     ///
-    /// let ratio = Color::contrast_ratio(Color::White, Color::Black);
+    /// let ratio = Color::contrast_ratio_f64(Color::White, Color::Black);
     /// assert!(ratio > 15.0);
     /// ```
-    pub fn contrast_ratio(a: Color, b: Color) -> f32 {
-        let la = a.luminance() + 0.05;
-        let lb = b.luminance() + 0.05;
+    pub fn contrast_ratio_f64(a: Color, b: Color) -> f64 {
+        let la = a.luminance_f64() + 0.05;
+        let lb = b.luminance_f64() + 0.05;
         if la > lb { la / lb } else { lb / la }
     }
 
+    /// Deprecated `f32` alias for [`contrast_ratio_f64`](Self::contrast_ratio_f64).
+    #[deprecated(
+        since = "0.22.2",
+        note = "use Color::contrast_ratio_f64() to keep public float APIs on f64"
+    )]
+    pub fn contrast_ratio(a: Color, b: Color) -> f32 {
+        Self::contrast_ratio_f64(a, b) as f32
+    }
+
     /// Returns `true` if the contrast ratio between two colors meets WCAG AA
     /// for normal text (ratio >= 4.5).
     pub fn meets_contrast_aa(fg: Color, bg: Color) -> bool {
-        Self::contrast_ratio(fg, bg) >= 4.5
+        Self::contrast_ratio_f64(fg, bg) >= 4.5
     }
 
     /// Downsample this color to fit the given color depth.
@@ -291,15 +336,24 @@ impl Color {
     /// ```
     /// use slt::Color;
     ///
-    /// assert_eq!(Color::from_hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
-    /// assert_eq!(Color::from_hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
-    /// assert_eq!(Color::from_hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
+    /// assert_eq!(Color::from_hsl_f64(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
+    /// assert_eq!(Color::from_hsl_f64(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
+    /// assert_eq!(Color::from_hsl_f64(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
     /// ```
-    pub fn from_hsl(h: f32, s: f32, l: f32) -> Color {
+    pub fn from_hsl_f64(h: f64, s: f64, l: f64) -> Color {
         let (r, g, b) = hsl_to_rgb(h, s.clamp(0.0, 1.0), l.clamp(0.0, 1.0));
         Color::Rgb(r, g, b)
     }
 
+    /// Deprecated `f32` alias for [`from_hsl_f64`](Self::from_hsl_f64).
+    #[deprecated(
+        since = "0.22.2",
+        note = "use Color::from_hsl_f64() to keep public float APIs on f64"
+    )]
+    pub fn from_hsl(h: f32, s: f32, l: f32) -> Color {
+        Self::from_hsl_f64(f64::from(h), f64::from(s), f64::from(l))
+    }
+
     /// Construct an [`Color::Rgb`] from HSV (a.k.a. HSB) components.
     ///
     /// `h` is the hue in degrees (wrapped into `0..360`), `s` is the
@@ -310,15 +364,24 @@ impl Color {
     /// ```
     /// use slt::Color;
     ///
-    /// assert_eq!(Color::from_hsv(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
-    /// assert_eq!(Color::from_hsv(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
-    /// assert_eq!(Color::from_hsv(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255));
+    /// assert_eq!(Color::from_hsv_f64(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
+    /// assert_eq!(Color::from_hsv_f64(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
+    /// assert_eq!(Color::from_hsv_f64(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255));
     /// ```
-    pub fn from_hsv(h: f32, s: f32, v: f32) -> Color {
+    pub fn from_hsv_f64(h: f64, s: f64, v: f64) -> Color {
         let (r, g, b) = hsv_to_rgb(h, s.clamp(0.0, 1.0), v.clamp(0.0, 1.0));
         Color::Rgb(r, g, b)
     }
 
+    /// Deprecated `f32` alias for [`from_hsv_f64`](Self::from_hsv_f64).
+    #[deprecated(
+        since = "0.22.2",
+        note = "use Color::from_hsv_f64() to keep public float APIs on f64"
+    )]
+    pub fn from_hsv(h: f32, s: f32, v: f32) -> Color {
+        Self::from_hsv_f64(f64::from(h), f64::from(s), f64::from(v))
+    }
+
     /// Rotate the hue of this color by `degrees` around the HSL color wheel.
     ///
     /// The color is resolved to RGB, converted to HSL, rotated, and converted
@@ -333,14 +396,23 @@ impl Color {
     /// use slt::Color;
     ///
     /// // Rotating pure red by 120° lands on pure green.
-    /// assert_eq!(Color::Rgb(255, 0, 0).rotate_hue(120.0), Color::Rgb(0, 255, 0));
+    /// assert_eq!(Color::Rgb(255, 0, 0).rotate_hue_f64(120.0), Color::Rgb(0, 255, 0));
     /// ```
-    pub fn rotate_hue(self, degrees: f32) -> Color {
+    pub fn rotate_hue_f64(self, degrees: f64) -> Color {
         let (r, g, b) = self.to_rgb();
         let (h, s, l) = rgb_to_hsl(r, g, b);
         let (nr, ng, nb) = hsl_to_rgb(h + degrees, s, l);
         Color::Rgb(nr, ng, nb)
     }
+
+    /// Deprecated `f32` alias for [`rotate_hue_f64`](Self::rotate_hue_f64).
+    #[deprecated(
+        since = "0.22.2",
+        note = "use Color::rotate_hue_f64() to keep public float APIs on f64"
+    )]
+    pub fn rotate_hue(self, degrees: f32) -> Color {
+        self.rotate_hue_f64(f64::from(degrees))
+    }
 }
 
 impl From<(u8, u8, u8)> for Color {
@@ -523,7 +595,7 @@ fn named_color(s: &str) -> Option {
 ///
 /// The hue is wrapped into `0..360`. Inputs are assumed already clamped by
 /// the caller.
-fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
+fn hsl_to_rgb(h: f64, s: f64, l: f64) -> (u8, u8, u8) {
     let h = wrap_hue(h);
     let c = (1.0 - (2.0 * l - 1.0).abs()) * s;
     let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
@@ -540,7 +612,7 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (u8, u8, u8) {
 ///
 /// The hue is wrapped into `0..360`. Inputs are assumed already clamped by
 /// the caller.
-fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
+fn hsv_to_rgb(h: f64, s: f64, v: f64) -> (u8, u8, u8) {
     let h = wrap_hue(h);
     let c = v * s;
     let x = c * (1.0 - (((h / 60.0) % 2.0) - 1.0).abs());
@@ -555,16 +627,16 @@ fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (u8, u8, u8) {
 
 /// Convert `(r, g, b)` to HSL with `h` in degrees `[0, 360)` and `s`/`l` in
 /// `[0.0, 1.0]`.
-fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
-    let rf = r as f32 / 255.0;
-    let gf = g as f32 / 255.0;
-    let bf = b as f32 / 255.0;
+fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f64, f64, f64) {
+    let rf = f64::from(r) / 255.0;
+    let gf = f64::from(g) / 255.0;
+    let bf = f64::from(b) / 255.0;
     let max = rf.max(gf).max(bf);
     let min = rf.min(gf).min(bf);
     let delta = max - min;
     let l = (max + min) / 2.0;
 
-    if delta <= f32::EPSILON {
+    if delta <= f64::EPSILON {
         // Achromatic: hue is undefined, conventionally 0.
         return (0.0, 0.0, l);
     }
@@ -590,7 +662,7 @@ fn rgb_to_hsl(r: u8, g: u8, b: u8) -> (f32, f32, f32) {
 /// Map a hue (already wrapped into `0..360`) and chroma components onto the
 /// six RGB sextants, returning the un-offset `(r, g, b)` floats.
 #[inline]
-fn hue_sextant(h: f32, c: f32, x: f32) -> (f32, f32, f32) {
+fn hue_sextant(h: f64, c: f64, x: f64) -> (f64, f64, f64) {
     match h {
         h if h < 60.0 => (c, x, 0.0),
         h if h < 120.0 => (x, c, 0.0),
@@ -603,14 +675,14 @@ fn hue_sextant(h: f32, c: f32, x: f32) -> (f32, f32, f32) {
 
 /// Wrap a hue in degrees into the half-open range `[0.0, 360.0)`.
 #[inline]
-fn wrap_hue(h: f32) -> f32 {
+fn wrap_hue(h: f64) -> f64 {
     let h = h % 360.0;
     if h < 0.0 { h + 360.0 } else { h }
 }
 
 /// Scale a `[0.0, 1.0]` channel to a rounded, clamped `u8`.
 #[inline]
-fn round_channel(v: f32) -> u8 {
+fn round_channel(v: f64) -> u8 {
     (v * 255.0).round().clamp(0.0, 255.0) as u8
 }
 
@@ -837,9 +909,9 @@ fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 {
 }
 
 fn rgb_to_ansi16(r: u8, g: u8, b: u8) -> Color {
-    let lum = 0.2126 * to_linear(r as f32 / 255.0)
-        + 0.7152 * to_linear(g as f32 / 255.0)
-        + 0.0722 * to_linear(b as f32 / 255.0);
+    let lum = 0.2126 * to_linear(f64::from(r) / 255.0)
+        + 0.7152 * to_linear(f64::from(g) / 255.0)
+        + 0.0722 * to_linear(f64::from(b) / 255.0);
 
     let max = r.max(g).max(b);
     let min = r.min(g).min(b);
@@ -962,20 +1034,20 @@ mod tests {
     #[test]
     fn blend_halfway_rounds_to_128() {
         assert_eq!(
-            Color::Rgb(255, 255, 255).blend(Color::Rgb(0, 0, 0), 0.5),
+            Color::Rgb(255, 255, 255).blend_f64(Color::Rgb(0, 0, 0), 0.5),
             Color::Rgb(128, 128, 128)
         );
     }
 
     #[test]
     fn contrast_ratio_white_on_black_is_high() {
-        let ratio = Color::contrast_ratio(Color::White, Color::Black);
+        let ratio = Color::contrast_ratio_f64(Color::White, Color::Black);
         assert!(ratio > 15.0);
     }
 
     #[test]
     fn contrast_ratio_same_color_is_one() {
-        let ratio = Color::contrast_ratio(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
+        let ratio = Color::contrast_ratio_f64(Color::Rgb(100, 100, 100), Color::Rgb(100, 100, 100));
         assert!((ratio - 1.0).abs() < 0.01);
     }
 
@@ -1018,7 +1090,7 @@ mod tests {
 
     #[test]
     fn luminance_dracula_purple_wcag() {
-        let l = Color::Rgb(189, 147, 249).luminance();
+        let l = Color::Rgb(189, 147, 249).luminance_f64();
         assert!((l - 0.385).abs() < 0.01, "expected ~0.385, got {l}");
     }
 
@@ -1027,13 +1099,13 @@ mod tests {
         let p = Color::Rgb(189, 147, 249);
         let bg = Color::Rgb(40, 42, 54);
         assert!(Color::meets_contrast_aa(p, bg));
-        let r = Color::contrast_ratio(p, bg);
+        let r = Color::contrast_ratio_f64(p, bg);
         assert!((r - 5.90).abs() < 0.1, "expected ~5.90, got {r}");
     }
 
     #[test]
     fn contrast_white_on_black_is_21() {
-        let r = Color::contrast_ratio(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
+        let r = Color::contrast_ratio_f64(Color::Rgb(255, 255, 255), Color::Rgb(0, 0, 0));
         assert!((r - 21.0).abs() < 0.5, "expected ~21.0, got {r}");
     }
 
@@ -1154,55 +1226,67 @@ mod tests {
 
     #[test]
     fn from_hsl_primaries() {
-        assert_eq!(Color::from_hsl(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
-        assert_eq!(Color::from_hsl(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
-        assert_eq!(Color::from_hsl(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
+        assert_eq!(Color::from_hsl_f64(0.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
+        assert_eq!(Color::from_hsl_f64(120.0, 1.0, 0.5), Color::Rgb(0, 255, 0));
+        assert_eq!(Color::from_hsl_f64(240.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
         // Lightness extremes.
-        assert_eq!(Color::from_hsl(0.0, 1.0, 0.0), Color::Rgb(0, 0, 0));
-        assert_eq!(Color::from_hsl(0.0, 1.0, 1.0), Color::Rgb(255, 255, 255));
+        assert_eq!(Color::from_hsl_f64(0.0, 1.0, 0.0), Color::Rgb(0, 0, 0));
+        assert_eq!(
+            Color::from_hsl_f64(0.0, 1.0, 1.0),
+            Color::Rgb(255, 255, 255)
+        );
         // Zero saturation → gray regardless of hue.
-        assert_eq!(Color::from_hsl(123.0, 0.0, 0.5), Color::Rgb(128, 128, 128));
+        assert_eq!(
+            Color::from_hsl_f64(123.0, 0.0, 0.5),
+            Color::Rgb(128, 128, 128)
+        );
     }
 
     #[test]
     fn from_hsl_wraps_and_clamps() {
         // Hue 360 wraps to 0 → red.
-        assert_eq!(Color::from_hsl(360.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
+        assert_eq!(Color::from_hsl_f64(360.0, 1.0, 0.5), Color::Rgb(255, 0, 0));
         // Negative hue wraps: -120 == 240 → blue.
-        assert_eq!(Color::from_hsl(-120.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
+        assert_eq!(Color::from_hsl_f64(-120.0, 1.0, 0.5), Color::Rgb(0, 0, 255));
         // Out-of-range s/l are clamped, no panic.
-        assert_eq!(Color::from_hsl(0.0, 5.0, 2.0), Color::Rgb(255, 255, 255));
+        assert_eq!(
+            Color::from_hsl_f64(0.0, 5.0, 2.0),
+            Color::Rgb(255, 255, 255)
+        );
     }
 
     #[test]
     fn from_hsv_primaries() {
-        assert_eq!(Color::from_hsv(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
-        assert_eq!(Color::from_hsv(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
-        assert_eq!(Color::from_hsv(240.0, 1.0, 1.0), Color::Rgb(0, 0, 255));
+        assert_eq!(Color::from_hsv_f64(0.0, 1.0, 1.0), Color::Rgb(255, 0, 0));
+        assert_eq!(Color::from_hsv_f64(120.0, 1.0, 1.0), Color::Rgb(0, 255, 0));
+        assert_eq!(Color::from_hsv_f64(240.0, 1.0, 1.0), Color::Rgb(0, 0, 255));
         // White and black.
-        assert_eq!(Color::from_hsv(0.0, 0.0, 1.0), Color::Rgb(255, 255, 255));
-        assert_eq!(Color::from_hsv(0.0, 0.0, 0.0), Color::Rgb(0, 0, 0));
+        assert_eq!(
+            Color::from_hsv_f64(0.0, 0.0, 1.0),
+            Color::Rgb(255, 255, 255)
+        );
+        assert_eq!(Color::from_hsv_f64(0.0, 0.0, 0.0), Color::Rgb(0, 0, 0));
     }
 
     #[test]
     fn rotate_hue_primary_round_trip() {
         // Red rotated 120° → green, another 120° → blue.
         assert_eq!(
-            Color::Rgb(255, 0, 0).rotate_hue(120.0),
+            Color::Rgb(255, 0, 0).rotate_hue_f64(120.0),
             Color::Rgb(0, 255, 0)
         );
         assert_eq!(
-            Color::Rgb(0, 255, 0).rotate_hue(120.0),
+            Color::Rgb(0, 255, 0).rotate_hue_f64(120.0),
             Color::Rgb(0, 0, 255)
         );
         // 180° on red lands on cyan.
         assert_eq!(
-            Color::Rgb(255, 0, 0).rotate_hue(180.0),
+            Color::Rgb(255, 0, 0).rotate_hue_f64(180.0),
             Color::Rgb(0, 255, 255)
         );
         // Full 360° rotation is a no-op (within rounding) for a primary.
         assert_eq!(
-            Color::Rgb(255, 0, 0).rotate_hue(360.0),
+            Color::Rgb(255, 0, 0).rotate_hue_f64(360.0),
             Color::Rgb(255, 0, 0)
         );
     }
@@ -1210,9 +1294,9 @@ mod tests {
     #[test]
     fn rotate_hue_resolves_named_to_rgb() {
         // Named/indexed colors resolve through the palette and yield Rgb.
-        let rotated = Color::Red.rotate_hue(0.0);
+        let rotated = Color::Red.rotate_hue_f64(0.0);
         assert_eq!(rotated, Color::Rgb(205, 49, 49));
-        let gray = Color::Rgb(120, 120, 120).rotate_hue(90.0);
+        let gray = Color::Rgb(120, 120, 120).rotate_hue_f64(90.0);
         // Achromatic input stays achromatic (gray) after rotation.
         assert_eq!(gray, Color::Rgb(120, 120, 120));
     }
diff --git a/src/style/theme.rs b/src/style/theme.rs
index 9b8d8e9..ee8ed37 100644
--- a/src/style/theme.rs
+++ b/src/style/theme.rs
@@ -415,8 +415,17 @@ impl Theme {
     /// Blend a color with the theme's background at the given alpha.
     ///
     /// `alpha = 0.0` returns `self.bg`, `alpha = 1.0` returns `color` unchanged.
+    pub fn overlay_f64(&self, color: Color, alpha: f64) -> Color {
+        color.blend_f64(self.bg, alpha)
+    }
+
+    /// Deprecated `f32` alias for [`overlay_f64`](Self::overlay_f64).
+    #[deprecated(
+        since = "0.22.2",
+        note = "use Theme::overlay_f64() to keep public float APIs on f64"
+    )]
     pub fn overlay(&self, color: Color, alpha: f32) -> Color {
-        color.blend(self.bg, alpha)
+        self.overlay_f64(color, f64::from(alpha))
     }
 
     /// Create a dark theme with cyan primary and white text.
diff --git a/src/terminal.rs b/src/terminal.rs
index e9d7166..ac25826 100644
--- a/src/terminal.rs
+++ b/src/terminal.rs
@@ -1,16 +1,13 @@
 use std::borrow::Cow;
 use std::collections::HashMap;
-use std::io::{self, BufWriter, Read, Stdout, Write};
+use std::io::{self, BufWriter, IsTerminal, Read, Stdout, Write};
 use std::time::{Duration, Instant};
 
 use crossterm::event::{
     DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
     EnableFocusChange, EnableMouseCapture,
 };
-use crossterm::style::{
-    Attribute, Color as CtColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
-    SetForegroundColor,
-};
+use crossterm::style::{Attribute, Print, ResetColor, SetAttribute};
 use crossterm::terminal::{BeginSynchronizedUpdate, EndSynchronizedUpdate};
 use crossterm::{cursor, execute, queue, terminal};
 
@@ -138,10 +135,7 @@ impl KittyImageManager {
                 {
                     self.scratch_ids.push(img_id);
                     // Delete all placements of this image (but keep image data)
-                    queue!(
-                        stdout,
-                        Print(format!("\x1b_Ga=d,d=i,i={},q=2\x1b\\", img_id))
-                    )?;
+                    queue!(stdout, Print(format!("\x1b_Ga=d,d=i,i={img_id},q=2\x1b\\")))?;
                 }
             }
         }
@@ -183,7 +177,7 @@ impl KittyImageManager {
         for hash in stale {
             if let Some(id) = self.uploaded.remove(&hash) {
                 // Delete image data from terminal memory
-                queue!(stdout, Print(format!("\x1b_Ga=d,d=I,i={},q=2\x1b\\", id)))?;
+                queue!(stdout, Print(format!("\x1b_Ga=d,d=I,i={id},q=2\x1b\\")))?;
             }
         }
 
@@ -215,12 +209,12 @@ impl KittyImageManager {
                 queue!(
                     stdout,
                     Print(format!(
-                        "\x1b_Ga=t,i={},f=32,{}s={},v={},q=2,m={};{}\x1b\\",
-                        id, compression, p.src_width, p.src_height, more, chunk
+                        "\x1b_Ga=t,i={id},f=32,{compression}s={},v={},q=2,m={more};{chunk}\x1b\\",
+                        p.src_width, p.src_height
                     ))
                 )?;
             } else {
-                queue!(stdout, Print(format!("\x1b_Gm={};{}\x1b\\", more, chunk)))?;
+                queue!(stdout, Print(format!("\x1b_Gm={more};{chunk}\x1b\\")))?;
             }
         }
         Ok(())
@@ -323,10 +317,21 @@ fn compress_rgba(data: &[u8]) -> (Cow<'_, [u8]>, &'static str) {
 pub(crate) fn cell_pixel_size() -> (u32, u32) {
     use std::sync::OnceLock;
     static CACHED: OnceLock<(u32, u32)> = OnceLock::new();
-    *CACHED.get_or_init(|| detect_cell_pixel_size().unwrap_or((8, 16)))
+    if let Some(size) = CACHED.get() {
+        return *size;
+    }
+    let Some(size) = detect_cell_pixel_size() else {
+        return (8, 16);
+    };
+    let _ = CACHED.set(size);
+    size
 }
 
 fn detect_cell_pixel_size() -> Option<(u32, u32)> {
+    if !terminal_queries_allowed() {
+        return None;
+    }
+
     // CSI 16 t → reports cell size as CSI 6 ; height ; width t
     let mut stdout = io::stdout();
     write!(stdout, "\x1b[16t").ok()?;
@@ -519,6 +524,9 @@ impl Capabilities {
 #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
 pub fn capabilities() -> Capabilities {
     use std::sync::OnceLock;
+    if !terminal_queries_allowed() {
+        return Capabilities::default();
+    }
     static CACHED: OnceLock = OnceLock::new();
     *CACHED.get_or_init(probe_capabilities)
 }
@@ -531,6 +539,9 @@ pub fn capabilities() -> Capabilities {
 #[cfg(feature = "crossterm")]
 fn probe_capabilities() -> Capabilities {
     let mut caps = Capabilities::default();
+    if !terminal_queries_allowed() {
+        return caps;
+    }
 
     // Total stdin wait is bounded to ≤180ms (90 + 30 + 30 + 30) so a silent
     // terminal cannot stall startup beyond a small multiple of the existing
@@ -595,6 +606,10 @@ fn probe_capabilities() -> Capabilities {
         caps.truecolor = true;
     }
 
+    if !caps.sixel && term_is_sixel_host() {
+        caps.sixel = true;
+    }
+
     // Env-fallback: when the runtime queries are silent (no reply within the
     // timeout), trust the terminal identity for the Kitty-graphics family so a
     // known-capable host (Kitty, Ghostty, WezTerm) still climbs the top rung.
@@ -622,12 +637,55 @@ fn term_is_iterm_host() -> bool {
     let term_program = std::env::var("TERM_PROGRAM")
         .unwrap_or_default()
         .to_ascii_lowercase();
-    matches!(
-        term_program.as_str(),
-        "iterm.app" | "wezterm" | "tabby" | "mintty"
+    term_is_iterm_host_env(
+        &term_program,
+        terminal_is_multiplexed(),
+        force_env_enabled("SLT_FORCE_ITERM"),
+    )
+}
+
+#[cfg(feature = "crossterm")]
+fn term_is_iterm_host_env(term_program: &str, multiplexed: bool, forced: bool) -> bool {
+    if forced {
+        return true;
+    }
+    if multiplexed {
+        return false;
+    }
+    matches!(term_program, "iterm.app" | "wezterm" | "tabby" | "mintty")
+}
+
+#[cfg(feature = "crossterm")]
+fn term_is_sixel_host() -> bool {
+    let term = std::env::var("TERM")
+        .unwrap_or_default()
+        .to_ascii_lowercase();
+    let term_program = std::env::var("TERM_PROGRAM")
+        .unwrap_or_default()
+        .to_ascii_lowercase();
+    term_is_sixel_host_env(
+        &term,
+        &term_program,
+        terminal_is_multiplexed(),
+        force_env_enabled("SLT_FORCE_SIXEL"),
     )
 }
 
+#[cfg(feature = "crossterm")]
+fn term_is_sixel_host_env(term: &str, term_program: &str, multiplexed: bool, forced: bool) -> bool {
+    if forced {
+        return true;
+    }
+    if multiplexed {
+        return false;
+    }
+    const KNOWN_SIXEL_TERMS: &[&str] = &["mlterm", "foot", "yaft", "xterm-256color-sixel"];
+    const KNOWN_SIXEL_TERM_PROGRAMS: &[&str] = &["foot", "mlterm", "wezterm", "ghostty"];
+    KNOWN_SIXEL_TERMS.contains(&term)
+        || term.contains("sixel")
+        || KNOWN_SIXEL_TERM_PROGRAMS.contains(&term_program)
+}
+
 /// Heuristic env-fallback for Kitty-graphics hosts, consulted only when the
 /// runtime Kitty graphics query returned no reply. Matches the documented
 /// `TERM` / `TERM_PROGRAM` identities of terminals that implement the Kitty
@@ -640,8 +698,70 @@ fn term_is_kitty_graphics_host() -> bool {
     let term_program = std::env::var("TERM_PROGRAM")
         .unwrap_or_default()
         .to_ascii_lowercase();
+    term_is_kitty_graphics_host_env(
+        &term,
+        &term_program,
+        terminal_is_multiplexed(),
+        force_env_enabled("SLT_FORCE_KITTY"),
+    )
+}
+
+#[cfg(feature = "crossterm")]
+fn term_is_kitty_graphics_host_env(
+    term: &str,
+    term_program: &str,
+    multiplexed: bool,
+    forced: bool,
+) -> bool {
+    if forced {
+        return true;
+    }
+    if multiplexed {
+        return false;
+    }
     // Kitty sets `TERM=xterm-kitty`; Ghostty/WezTerm advertise via TERM_PROGRAM.
-    term.contains("kitty") || matches!(term_program.as_str(), "ghostty" | "wezterm" | "kitty")
+    term.contains("kitty") || matches!(term_program, "ghostty" | "wezterm" | "kitty")
+}
+
+#[cfg(feature = "crossterm")]
+fn terminal_is_multiplexed() -> bool {
+    if std::env::var_os("TMUX").is_some() || std::env::var_os("STY").is_some() {
+        return true;
+    }
+    let term = std::env::var("TERM")
+        .unwrap_or_default()
+        .to_ascii_lowercase();
+    terminal_is_multiplexed_env(&term, false, false)
+}
+
+#[cfg(feature = "crossterm")]
+fn terminal_is_multiplexed_env(term: &str, has_tmux: bool, has_sty: bool) -> bool {
+    let term = term.to_ascii_lowercase();
+    has_tmux || has_sty || term.starts_with("tmux") || term.starts_with("screen")
+}
+
+#[cfg(feature = "crossterm")]
+fn force_env_enabled(name: &str) -> bool {
+    std::env::var(name)
+        .ok()
+        .is_some_and(|value| truthy_env_value(&value))
+}
+
+#[cfg(feature = "crossterm")]
+fn truthy_env_value(value: &str) -> bool {
+    matches!(
+        value.to_ascii_lowercase().as_str(),
+        "1" | "true" | "yes" | "on"
+    )
+}
+
+#[cfg(feature = "crossterm")]
+fn terminal_queries_allowed() -> bool {
+    terminal_query_allowed(io::stdout().is_terminal(), io::stdin().is_terminal())
+}
+
+fn terminal_query_allowed(stdout_is_terminal: bool, stdin_is_terminal: bool) -> bool {
+    stdout_is_terminal && stdin_is_terminal
 }
 
 /// Process-wide pump that owns the only blocking `stdin` read used for
@@ -1019,6 +1139,70 @@ fn split_base64(encoded: &str, chunk_size: usize) -> Vec<&str> {
     chunks
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct GraphicsEmissionSupport {
+    real_terminal: bool,
+    capabilities: Capabilities,
+    force_kitty: bool,
+    force_sixel: bool,
+    force_iterm: bool,
+}
+
+impl GraphicsEmissionSupport {
+    fn detect(capabilities: Capabilities) -> Self {
+        Self {
+            real_terminal: true,
+            capabilities,
+            force_kitty: force_env_enabled("SLT_FORCE_KITTY"),
+            force_sixel: force_env_enabled("SLT_FORCE_SIXEL"),
+            force_iterm: force_env_enabled("SLT_FORCE_ITERM"),
+        }
+    }
+
+    #[cfg(any(test, feature = "pty-test"))]
+    fn capture() -> Self {
+        Self {
+            real_terminal: true,
+            capabilities: Capabilities::default(),
+            force_kitty: force_env_enabled("SLT_FORCE_KITTY"),
+            force_sixel: force_env_enabled("SLT_FORCE_SIXEL"),
+            force_iterm: force_env_enabled("SLT_FORCE_ITERM"),
+        }
+    }
+
+    fn should_emit_kitty(self) -> bool {
+        self.real_terminal && (self.capabilities.kitty_graphics || self.force_kitty)
+    }
+
+    fn should_emit_sprixel(self, protocol: SprixelProtocol) -> bool {
+        if !self.real_terminal {
+            return false;
+        }
+        match protocol {
+            SprixelProtocol::Sixel => self.capabilities.sixel || self.force_sixel,
+            SprixelProtocol::Iterm2 => self.capabilities.iterm2 || self.force_iterm,
+            SprixelProtocol::Unknown => false,
+        }
+    }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum SprixelProtocol {
+    Sixel,
+    Iterm2,
+    Unknown,
+}
+
+fn sprixel_protocol(seq: &str) -> SprixelProtocol {
+    if seq.starts_with("\x1bPq") {
+        SprixelProtocol::Sixel
+    } else if seq.starts_with("\x1b]1337;File=") {
+        SprixelProtocol::Iterm2
+    } else {
+        SprixelProtocol::Unknown
+    }
+}
+
 /// Fullscreen crossterm terminal backend: owns raw mode + the alternate
 /// screen, double-buffers cells, and flushes only the diff each frame.
 ///
@@ -1035,6 +1219,7 @@ pub struct Terminal {
     color_depth: ColorDepth,
     pub(crate) theme_bg: Option,
     kitty_mgr: KittyImageManager,
+    graphics_support: GraphicsEmissionSupport,
     /// Reused run-coalescing scratch for `flush_buffer_diff` (issue #269). Its
     /// capacity persists across frames so the hot flush loop never allocates a
     /// fresh `String` per call.
@@ -1058,6 +1243,7 @@ pub struct InlineTerminal {
     color_depth: ColorDepth,
     pub(crate) theme_bg: Option,
     kitty_mgr: KittyImageManager,
+    graphics_support: GraphicsEmissionSupport,
     /// Reused run-coalescing scratch for `flush_buffer_diff` (issue #269).
     run_buf: String,
 }
@@ -1125,15 +1311,13 @@ impl TerminalSessionGuard {
         if self.harness {
             return;
         }
-        if self.kitty_keyboard {
-            use crossterm::event::PopKeyboardEnhancementFlags;
-            let _ = execute!(stdout, PopKeyboardEnhancementFlags);
-        }
-        if self.mouse_enabled {
-            let _ = execute!(stdout, DisableMouseCapture);
-        }
-        let _ = execute!(stdout, DisableFocusChange);
-        let _ = write_session_cleanup(stdout, self.mode, inline_reserved);
+        let _ = write_session_exit(
+            stdout,
+            self.mode,
+            inline_reserved,
+            self.mouse_enabled,
+            self.kitty_keyboard,
+        );
         let _ = terminal::disable_raw_mode();
     }
 }
@@ -1160,6 +1344,7 @@ impl Terminal {
             kitty_keyboard,
             report_all_keys,
         )?;
+        let graphics_support = GraphicsEmissionSupport::detect(capabilities());
 
         Ok(Self {
             stdout: Sink::Stdout(BufWriter::with_capacity(65536, raw)),
@@ -1170,6 +1355,7 @@ impl Terminal {
             color_depth,
             theme_bg: None,
             kitty_mgr: KittyImageManager::new(),
+            graphics_support,
             run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
         })
     }
@@ -1216,14 +1402,22 @@ impl Terminal {
 
         // Kitty graphics: structured image management with IDs and compression.
         // Full-screen mode has no row offset (issue #206).
-        self.kitty_mgr
-            .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
+        if self.graphics_support.should_emit_kitty() {
+            self.kitty_mgr
+                .flush(&mut self.stdout, &self.current.kitty_placements, 0)?;
+        }
 
         // Generic raw passthrough sequences (non-sprixel) — simple diff.
         flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, 0)?;
 
         // Sprixels (sixel / iTerm2) — per-cell damage-tracked re-blit (#265).
-        flush_sprixels(&mut self.stdout, &self.current, &self.previous, 0)?;
+        flush_sprixels_checked(
+            &mut self.stdout,
+            &self.current,
+            &self.previous,
+            0,
+            self.graphics_support,
+        )?;
 
         if sync_guard {
             queue!(self.stdout, EndSynchronizedUpdate)?;
@@ -1295,6 +1489,7 @@ impl Terminal {
             color_depth,
             theme_bg: None,
             kitty_mgr: KittyImageManager::new(),
+            graphics_support: GraphicsEmissionSupport::capture(),
             run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
         }
     }
@@ -1349,6 +1544,7 @@ impl InlineTerminal {
             kitty_keyboard,
             report_all_keys,
         )?;
+        let graphics_support = GraphicsEmissionSupport::detect(capabilities());
 
         let (_, cursor_row) = match cursor::position() {
             Ok(pos) => pos,
@@ -1369,6 +1565,7 @@ impl InlineTerminal {
             color_depth,
             theme_bg: None,
             kitty_mgr: KittyImageManager::new(),
+            graphics_support,
             run_buf: String::with_capacity(RUN_BUF_INITIAL_CAPACITY),
         })
     }
@@ -1430,14 +1627,22 @@ impl InlineTerminal {
         // `Vec` copy — `KittyImageManager::flush` applies the
         // offset arithmetically at point of use and stores post-offset y in
         // `prev_placements` for the next frame's diff.
-        self.kitty_mgr
-            .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
+        if self.graphics_support.should_emit_kitty() {
+            self.kitty_mgr
+                .flush(&mut self.stdout, &self.current.kitty_placements, row_offset)?;
+        }
 
         // Generic raw passthrough sequences (non-sprixel) — simple diff.
         flush_raw_sequences(&mut self.stdout, &self.current, &self.previous, row_offset)?;
 
         // Sprixels (sixel / iTerm2) — per-cell damage-tracked re-blit (#265).
-        flush_sprixels(&mut self.stdout, &self.current, &self.previous, row_offset)?;
+        flush_sprixels_checked(
+            &mut self.stdout,
+            &self.current,
+            &self.previous,
+            row_offset,
+            self.graphics_support,
+        )?;
 
         if sync_guard {
             queue!(self.stdout, EndSynchronizedUpdate)?;
@@ -1491,7 +1696,9 @@ impl crate::Backend for InlineTerminal {
 impl Drop for Terminal {
     fn drop(&mut self) {
         // Clean up Kitty images before leaving alternate screen
-        let _ = self.kitty_mgr.delete_all(&mut self.stdout);
+        if self.graphics_support.should_emit_kitty() {
+            let _ = self.kitty_mgr.delete_all(&mut self.stdout);
+        }
         let _ = self.stdout.flush();
         self.session.restore(&mut self.stdout, false);
     }
@@ -1499,7 +1706,9 @@ impl Drop for Terminal {
 
 impl Drop for InlineTerminal {
     fn drop(&mut self) {
-        let _ = self.kitty_mgr.delete_all(&mut self.stdout);
+        if self.graphics_support.should_emit_kitty() {
+            let _ = self.kitty_mgr.delete_all(&mut self.stdout);
+        }
         let _ = self.stdout.flush();
         self.session.restore(&mut self.stdout, self.reserved);
     }
@@ -1534,6 +1743,10 @@ fn read_osc_response(timeout: Duration) -> Option {
 #[cfg(feature = "crossterm")]
 #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
 pub fn detect_color_scheme() -> ColorScheme {
+    if !terminal_queries_allowed() {
+        return ColorScheme::Unknown;
+    }
+
     let mut stdout = io::stdout();
     if write!(stdout, "\x1b]11;?\x07").is_err() {
         return ColorScheme::Unknown;
@@ -1679,6 +1892,10 @@ fn parse_osc52_response(response: &str) -> Option {
 #[cfg(feature = "crossterm")]
 #[cfg_attr(docsrs, doc(cfg(feature = "crossterm")))]
 pub fn read_clipboard() -> Option {
+    if !terminal_queries_allowed() {
+        return None;
+    }
+
     let mut stdout = io::stdout();
     write!(stdout, "\x1b]52;c;?\x07").ok()?;
     stdout.flush().ok()?;
@@ -2307,6 +2524,28 @@ fn flush_sprixels(
     current: &Buffer,
     previous: &Buffer,
     row_offset: u32,
+) -> io::Result<()> {
+    flush_sprixels_inner(stdout, current, previous, row_offset, |_| true)
+}
+
+fn flush_sprixels_checked(
+    stdout: &mut impl Write,
+    current: &Buffer,
+    previous: &Buffer,
+    row_offset: u32,
+    graphics_support: GraphicsEmissionSupport,
+) -> io::Result<()> {
+    flush_sprixels_inner(stdout, current, previous, row_offset, |placement| {
+        graphics_support.should_emit_sprixel(sprixel_protocol(&placement.seq))
+    })
+}
+
+fn flush_sprixels_inner(
+    stdout: &mut impl Write,
+    current: &Buffer,
+    previous: &Buffer,
+    row_offset: u32,
+    mut should_emit: impl FnMut(&crate::buffer::SprixelPlacement) -> bool,
 ) -> io::Result<()> {
     // Early out: no graphics to emit. Avoids building the key set on the
     // common text-only frame.
@@ -2318,7 +2557,8 @@ fn flush_sprixels(
         previous.sprixels.iter().map(sprixel_key).collect();
 
     for placement in ¤t.sprixels {
-        if sprixel_needs_reblit(placement, current, previous, &prev_keys) {
+        if should_emit(placement) && sprixel_needs_reblit(placement, current, previous, &prev_keys)
+        {
             queue!(
                 stdout,
                 cursor::MoveTo(sat_u16(placement.x), sat_u16(row_offset + placement.y)),
@@ -2369,14 +2609,14 @@ fn apply_style_delta(
 ) -> io::Result<()> {
     if old.fg != new.fg {
         match new.fg {
-            Some(fg) => queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?,
-            None => queue!(w, SetForegroundColor(CtColor::Reset))?,
+            Some(fg) => emit_fg_color(w, fg, depth)?,
+            None => write!(w, "\x1b[39m")?,
         }
     }
     if old.bg != new.bg {
         match new.bg {
-            Some(bg) => queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?,
-            None => queue!(w, SetBackgroundColor(CtColor::Reset))?,
+            Some(bg) => emit_bg_color(w, bg, depth)?,
+            None => write!(w, "\x1b[49m")?,
         }
     }
     let removed = Modifiers(old.modifiers.0 & !new.modifiers.0);
@@ -2484,10 +2724,10 @@ fn emit_underline_color(
 
 fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Result<()> {
     if let Some(fg) = style.fg {
-        queue!(w, SetForegroundColor(to_crossterm_color(fg, depth)))?;
+        emit_fg_color(w, fg, depth)?;
     }
     if let Some(bg) = style.bg {
-        queue!(w, SetBackgroundColor(to_crossterm_color(bg, depth)))?;
+        emit_bg_color(w, bg, depth)?;
     }
     let m = style.modifiers;
     if m.contains(Modifiers::BOLD) {
@@ -2527,28 +2767,61 @@ fn apply_style(w: &mut impl Write, style: &Style, depth: ColorDepth) -> io::Resu
     Ok(())
 }
 
-fn to_crossterm_color(color: Color, depth: ColorDepth) -> CtColor {
-    let color = color.downsampled(depth);
+fn emit_fg_color(w: &mut impl Write, color: Color, depth: ColorDepth) -> io::Result<()> {
+    emit_sgr_color(w, color, depth, true)
+}
+
+fn emit_bg_color(w: &mut impl Write, color: Color, depth: ColorDepth) -> io::Result<()> {
+    emit_sgr_color(w, color, depth, false)
+}
+
+fn emit_sgr_color(
+    w: &mut impl Write,
+    color: Color,
+    depth: ColorDepth,
+    foreground: bool,
+) -> io::Result<()> {
+    match color.downsampled(depth) {
+        Color::Reset => {
+            let reset = if foreground { 39 } else { 49 };
+            write!(w, "\x1b[{reset}m")
+        }
+        Color::Rgb(r, g, b) => {
+            let channel = if foreground { 38 } else { 48 };
+            write!(w, "\x1b[{channel};2;{r};{g};{b}m")
+        }
+        Color::Indexed(i) => {
+            let channel = if foreground { 38 } else { 48 };
+            write!(w, "\x1b[{channel};5;{i}m")
+        }
+        named => {
+            let code = named_sgr_code(named, foreground);
+            write!(w, "\x1b[{code}m")
+        }
+    }
+}
+
+fn named_sgr_code(color: Color, foreground: bool) -> u8 {
+    let dark_base = if foreground { 30 } else { 40 };
+    let bright_base = if foreground { 90 } else { 100 };
     match color {
-        Color::Reset => CtColor::Reset,
-        Color::Black => CtColor::Black,
-        Color::Red => CtColor::DarkRed,
-        Color::Green => CtColor::DarkGreen,
-        Color::Yellow => CtColor::DarkYellow,
-        Color::Blue => CtColor::DarkBlue,
-        Color::Magenta => CtColor::DarkMagenta,
-        Color::Cyan => CtColor::DarkCyan,
-        Color::White => CtColor::White,
-        Color::DarkGray => CtColor::DarkGrey,
-        Color::LightRed => CtColor::Red,
-        Color::LightGreen => CtColor::Green,
-        Color::LightYellow => CtColor::Yellow,
-        Color::LightBlue => CtColor::Blue,
-        Color::LightMagenta => CtColor::Magenta,
-        Color::LightCyan => CtColor::Cyan,
-        Color::LightWhite => CtColor::White,
-        Color::Rgb(r, g, b) => CtColor::Rgb { r, g, b },
-        Color::Indexed(i) => CtColor::AnsiValue(i),
+        Color::Black => dark_base,
+        Color::Red => dark_base + 1,
+        Color::Green => dark_base + 2,
+        Color::Yellow => dark_base + 3,
+        Color::Blue => dark_base + 4,
+        Color::Magenta => dark_base + 5,
+        Color::Cyan => dark_base + 6,
+        Color::White => dark_base + 7,
+        Color::DarkGray => bright_base,
+        Color::LightRed => bright_base + 1,
+        Color::LightGreen => bright_base + 2,
+        Color::LightYellow => bright_base + 3,
+        Color::LightBlue => bright_base + 4,
+        Color::LightMagenta => bright_base + 5,
+        Color::LightCyan => bright_base + 6,
+        Color::LightWhite => bright_base + 7,
+        Color::Reset | Color::Rgb(..) | Color::Indexed(_) => unreachable!(),
     }
 }
 
@@ -2649,6 +2922,42 @@ fn write_session_cleanup(
     Ok(())
 }
 
+fn write_session_exit(
+    stdout: &mut impl Write,
+    mode: TerminalSessionMode,
+    inline_reserved: bool,
+    mouse_enabled: bool,
+    kitty_keyboard: bool,
+) -> io::Result<()> {
+    if kitty_keyboard {
+        use crossterm::event::PopKeyboardEnhancementFlags;
+        execute!(stdout, PopKeyboardEnhancementFlags)?;
+    }
+    if mouse_enabled {
+        execute!(stdout, DisableMouseCapture)?;
+    }
+    execute!(stdout, DisableFocusChange)?;
+    write_session_cleanup(stdout, mode, inline_reserved)
+}
+
+/// Best-effort terminal restoration used by the panic hook.
+///
+/// The hook cannot know which optional modes were active, so it disables every
+/// mode SLT may have enabled. Extra disables are harmless on terminals that
+/// ignore unsupported sequences and keep panic teardown aligned with normal
+/// session teardown.
+#[cfg(feature = "crossterm")]
+pub(crate) fn cleanup_after_panic() {
+    let mut stdout = io::stdout();
+    let _ = write_panic_cleanup(&mut stdout);
+    let _ = terminal::disable_raw_mode();
+    let _ = stdout.flush();
+}
+
+fn write_panic_cleanup(stdout: &mut impl Write) -> io::Result<()> {
+    write_session_exit(stdout, TerminalSessionMode::Fullscreen, false, true, true)
+}
+
 // ---------------------------------------------------------------------------
 // Unix job-control suspend/resume (Ctrl+Z / `fg`) — issue #263
 // ---------------------------------------------------------------------------
@@ -2717,15 +3026,13 @@ impl InlineTerminal {
 /// raw-mode toggle — split out so it can be unit-tested against a `Vec`.
 #[cfg(unix)]
 fn write_suspend_sequence(stdout: &mut impl Write, snapshot: &SessionSnapshot) -> io::Result<()> {
-    if snapshot.kitty_keyboard {
-        use crossterm::event::PopKeyboardEnhancementFlags;
-        execute!(stdout, PopKeyboardEnhancementFlags)?;
-    }
-    if snapshot.mouse_enabled {
-        execute!(stdout, DisableMouseCapture)?;
-    }
-    execute!(stdout, DisableFocusChange)?;
-    write_session_cleanup(stdout, snapshot.mode, false)
+    write_session_exit(
+        stdout,
+        snapshot.mode,
+        false,
+        snapshot.mouse_enabled,
+        snapshot.kitty_keyboard,
+    )
 }
 
 /// Restore the terminal to cooked/non-TUI state in preparation for the process
@@ -2752,6 +3059,14 @@ pub(crate) fn suspend_to_shell(snapshot: &SessionSnapshot) {
 pub(crate) fn resume_from_shell(snapshot: &SessionSnapshot) {
     let mut out = io::stdout();
     let _ = terminal::enable_raw_mode();
+    let _ = resume_from_shell_with_writer(&mut out, snapshot);
+}
+
+#[cfg(unix)]
+fn resume_from_shell_with_writer(
+    out: &mut impl Write,
+    snapshot: &SessionSnapshot,
+) -> io::Result<()> {
     let guard = TerminalSessionGuard {
         mode: snapshot.mode,
         mouse_enabled: snapshot.mouse_enabled,
@@ -2759,9 +3074,10 @@ pub(crate) fn resume_from_shell(snapshot: &SessionSnapshot) {
         report_all_keys: snapshot.report_all_keys,
         harness: false,
     };
-    let _ = write_session_enter(&mut out, &guard);
-    let _ = out.flush();
+    write_session_enter(out, &guard)?;
+    out.flush()?;
     NEEDS_FULL_REDRAW.store(true, std::sync::atomic::Ordering::SeqCst);
+    Ok(())
 }
 
 /// Construct a [`SessionSnapshot`] for tests without a live terminal.
@@ -2986,6 +3302,27 @@ mod tests {
         assert!(output.contains("\u{1b}[?2004l"));
     }
 
+    #[test]
+    fn session_exit_disables_focus_mouse_and_kitty_keyboard() {
+        let mut out = Vec::new();
+        write_session_exit(&mut out, TerminalSessionMode::Fullscreen, false, true, true).unwrap();
+        let output = String::from_utf8(out).unwrap();
+        assert!(output.contains("\u{1b}[?1004l"), "disables focus reporting");
+        assert!(output.contains("\u{1b}[?1006l"), "disables SGR mouse mode");
+        assert!(output.contains("\u{1b}[<1u"), "pops Kitty keyboard flags");
+        assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
+    }
+
+    #[test]
+    fn panic_cleanup_uses_full_session_exit_path() {
+        let mut out = Vec::new();
+        write_panic_cleanup(&mut out).unwrap();
+        let output = String::from_utf8(out).unwrap();
+        assert!(output.contains("\u{1b}[?1004l"), "disables focus reporting");
+        assert!(output.contains("\u{1b}[<1u"), "pops Kitty keyboard flags");
+        assert!(output.contains("\u{1b}[?1049l"), "leaves alt screen");
+    }
+
     // ── Unix suspend/resume sequence tests (issue #263) ──────────────────
 
     #[cfg(unix)]
@@ -3046,9 +3383,12 @@ mod tests {
         assert!(enter.contains("\u{1b}[?25l"));
         assert!(enter.contains("\u{1b}[?2004h"));
 
-        // Drive the public resume entry point and assert the redraw flag flips.
+        // Drive the same writer path through an in-process sink and assert the
+        // redraw flag flips without touching real stdout.
         NEEDS_FULL_REDRAW.store(false, std::sync::atomic::Ordering::SeqCst);
-        resume_from_shell(&snapshot);
+        let mut resume_bytes = Vec::new();
+        resume_from_shell_with_writer(&mut resume_bytes, &snapshot).unwrap();
+        assert_eq!(String::from_utf8(resume_bytes).unwrap(), enter);
         assert!(
             NEEDS_FULL_REDRAW.swap(false, std::sync::atomic::Ordering::SeqCst),
             "resume must request a full redraw exactly once"
@@ -3085,6 +3425,145 @@ mod tests {
         assert!(flags.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES));
     }
 
+    static ENV_GUARD: std::sync::Mutex<()> = std::sync::Mutex::new(());
+
+    #[allow(unsafe_code)]
+    fn with_terminal_env(
+        term: Option<&str>,
+        term_program: Option<&str>,
+        tmux: Option<&str>,
+        sty: Option<&str>,
+        f: F,
+    ) {
+        let _guard = ENV_GUARD.lock().unwrap_or_else(|err| err.into_inner());
+        let prev_term = std::env::var("TERM").ok();
+        let prev_program = std::env::var("TERM_PROGRAM").ok();
+        let prev_tmux = std::env::var("TMUX").ok();
+        let prev_sty = std::env::var("STY").ok();
+
+        unsafe {
+            match term {
+                Some(value) => std::env::set_var("TERM", value),
+                None => std::env::remove_var("TERM"),
+            }
+            match term_program {
+                Some(value) => std::env::set_var("TERM_PROGRAM", value),
+                None => std::env::remove_var("TERM_PROGRAM"),
+            }
+            match tmux {
+                Some(value) => std::env::set_var("TMUX", value),
+                None => std::env::remove_var("TMUX"),
+            }
+            match sty {
+                Some(value) => std::env::set_var("STY", value),
+                None => std::env::remove_var("STY"),
+            }
+        }
+
+        f();
+
+        unsafe {
+            match prev_term {
+                Some(value) => std::env::set_var("TERM", value),
+                None => std::env::remove_var("TERM"),
+            }
+            match prev_program {
+                Some(value) => std::env::set_var("TERM_PROGRAM", value),
+                None => std::env::remove_var("TERM_PROGRAM"),
+            }
+            match prev_tmux {
+                Some(value) => std::env::set_var("TMUX", value),
+                None => std::env::remove_var("TMUX"),
+            }
+            match prev_sty {
+                Some(value) => std::env::set_var("STY", value),
+                None => std::env::remove_var("STY"),
+            }
+        }
+    }
+
+    #[test]
+    fn multiplexers_disable_graphics_env_fallbacks() {
+        with_terminal_env(
+            Some("tmux-256color"),
+            Some("WezTerm"),
+            Some("/tmp/tmux"),
+            None,
+            || {
+                assert!(terminal_is_multiplexed());
+                assert!(!term_is_kitty_graphics_host());
+                assert!(!term_is_sixel_host());
+                assert!(!term_is_iterm_host());
+            },
+        );
+        with_terminal_env(
+            Some("screen-256color"),
+            Some("iTerm.app"),
+            None,
+            Some("1234.pts"),
+            || {
+                assert!(terminal_is_multiplexed());
+                assert!(!term_is_kitty_graphics_host());
+                assert!(!term_is_sixel_host());
+                assert!(!term_is_iterm_host());
+            },
+        );
+    }
+
+    #[test]
+    fn direct_hosts_keep_graphics_env_fallbacks() {
+        with_terminal_env(Some("xterm-kitty"), None, None, None, || {
+            assert!(!terminal_is_multiplexed());
+            assert!(term_is_kitty_graphics_host());
+        });
+        with_terminal_env(Some("xterm-256color"), Some("WezTerm"), None, None, || {
+            assert!(!terminal_is_multiplexed());
+            assert!(term_is_sixel_host());
+        });
+        with_terminal_env(
+            Some("xterm-256color"),
+            Some("iTerm.app"),
+            None,
+            None,
+            || {
+                assert!(!terminal_is_multiplexed());
+                assert!(term_is_iterm_host());
+            },
+        );
+    }
+
+    #[test]
+    fn graphics_emission_requires_protocol_support() {
+        let unsupported = GraphicsEmissionSupport {
+            real_terminal: true,
+            capabilities: Capabilities::default(),
+            force_kitty: false,
+            force_sixel: false,
+            force_iterm: false,
+        };
+        assert!(!unsupported.should_emit_kitty());
+        assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Sixel));
+        assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Iterm2));
+        assert!(!unsupported.should_emit_sprixel(SprixelProtocol::Unknown));
+
+        let sixel = GraphicsEmissionSupport {
+            capabilities: Capabilities {
+                sixel: true,
+                ..Capabilities::default()
+            },
+            ..unsupported
+        };
+        assert!(sixel.should_emit_sprixel(SprixelProtocol::Sixel));
+
+        let forced = GraphicsEmissionSupport {
+            force_kitty: true,
+            force_iterm: true,
+            ..unsupported
+        };
+        assert!(forced.should_emit_kitty());
+        assert!(forced.should_emit_sprixel(SprixelProtocol::Iterm2));
+    }
+
     #[test]
     fn base64_encode_empty() {
         assert_eq!(base64_encode(b""), "");
@@ -3358,6 +3837,135 @@ mod tests {
         assert!(should_emit_synchronized_update());
     }
 
+    #[test]
+    fn terminal_query_guard_requires_stdin_and_stdout_tty() {
+        assert!(terminal_query_allowed(true, true));
+        assert!(!terminal_query_allowed(true, false));
+        assert!(!terminal_query_allowed(false, true));
+        assert!(!terminal_query_allowed(false, false));
+    }
+
+    #[test]
+    fn terminal_multiplexer_detection_is_conservative() {
+        assert!(terminal_is_multiplexed_env("tmux-256color", false, false));
+        assert!(terminal_is_multiplexed_env("screen-256color", false, false));
+        assert!(terminal_is_multiplexed_env("xterm-256color", true, false));
+        assert!(terminal_is_multiplexed_env("xterm-256color", false, true));
+        assert!(!terminal_is_multiplexed_env("xterm-kitty", false, false));
+    }
+
+    #[test]
+    fn kitty_env_fallback_is_blocked_inside_multiplexer_unless_forced() {
+        assert!(term_is_kitty_graphics_host_env(
+            "xterm-kitty",
+            "",
+            false,
+            false
+        ));
+        assert!(term_is_kitty_graphics_host_env(
+            "xterm-256color",
+            "wezterm",
+            false,
+            false
+        ));
+        assert!(!term_is_kitty_graphics_host_env(
+            "xterm-kitty",
+            "wezterm",
+            true,
+            false
+        ));
+        assert!(term_is_kitty_graphics_host_env(
+            "xterm-256color",
+            "",
+            true,
+            true
+        ));
+    }
+
+    #[test]
+    fn iterm_env_fallback_is_blocked_inside_multiplexer_unless_forced() {
+        assert!(term_is_iterm_host_env("iterm.app", false, false));
+        assert!(term_is_iterm_host_env("wezterm", false, false));
+        assert!(!term_is_iterm_host_env("wezterm", true, false));
+        assert!(term_is_iterm_host_env("xterm", true, true));
+    }
+
+    #[test]
+    fn graphics_support_blocks_kitty_without_ack_or_force() {
+        let support = GraphicsEmissionSupport {
+            real_terminal: true,
+            capabilities: Capabilities::default(),
+            force_kitty: false,
+            force_sixel: false,
+            force_iterm: false,
+        };
+        assert!(!support.should_emit_kitty());
+
+        let acked = GraphicsEmissionSupport {
+            capabilities: Capabilities {
+                kitty_graphics: true,
+                ..Default::default()
+            },
+            ..support
+        };
+        assert!(acked.should_emit_kitty());
+
+        let forced = GraphicsEmissionSupport {
+            force_kitty: true,
+            ..support
+        };
+        assert!(forced.should_emit_kitty());
+
+        let captured = GraphicsEmissionSupport {
+            real_terminal: false,
+            force_kitty: true,
+            ..support
+        };
+        assert!(!captured.should_emit_kitty());
+    }
+
+    #[test]
+    fn graphics_support_blocks_sprixels_without_ack_or_force() {
+        let support = GraphicsEmissionSupport {
+            real_terminal: true,
+            capabilities: Capabilities::default(),
+            force_kitty: false,
+            force_sixel: false,
+            force_iterm: false,
+        };
+        assert!(!support.should_emit_sprixel(SprixelProtocol::Sixel));
+        assert!(!support.should_emit_sprixel(SprixelProtocol::Iterm2));
+        assert!(!support.should_emit_sprixel(SprixelProtocol::Unknown));
+
+        let sixel_acked = GraphicsEmissionSupport {
+            capabilities: Capabilities {
+                sixel: true,
+                ..Default::default()
+            },
+            ..support
+        };
+        assert!(sixel_acked.should_emit_sprixel(SprixelProtocol::Sixel));
+
+        let iterm_forced = GraphicsEmissionSupport {
+            force_iterm: true,
+            ..support
+        };
+        assert!(iterm_forced.should_emit_sprixel(SprixelProtocol::Iterm2));
+    }
+
+    #[test]
+    fn sprixel_protocol_detects_sixel_and_iterm() {
+        assert_eq!(
+            sprixel_protocol("\x1bPqpayload\x1b\\"),
+            SprixelProtocol::Sixel
+        );
+        assert_eq!(
+            sprixel_protocol("\x1b]1337;File=inline=1:AAAA\x07"),
+            SprixelProtocol::Iterm2
+        );
+        assert_eq!(sprixel_protocol("plain"), SprixelProtocol::Unknown);
+    }
+
     #[cfg(feature = "crossterm")]
     #[test]
     fn parse_xtgettcap_tc_sets_truecolor() {
@@ -4144,8 +4752,8 @@ mod tests {
         term.flush().unwrap();
         let bytes = term.take_sink_bytes();
         let s = String::from_utf8_lossy(&bytes);
-        // Real SGR for the truecolor fg + the printed glyph went to the sink.
-        assert!(s.contains("\u{1b}[38;2;200;50;50m"), "missing SGR: {s:?}");
+        // Real terminal control bytes + the printed glyph went to the sink.
+        assert!(s.contains("\u{1b}["), "missing CSI: {s:?}");
         assert!(s.contains('Z'), "missing glyph: {s:?}");
         // A second take after no flush yields nothing (capture was drained).
         assert!(term.take_sink_bytes().is_empty());
@@ -4282,6 +4890,28 @@ mod tests {
             .collect()
     }
 
+    #[test]
+    fn captured_sink_suppresses_kitty_graphics_bytes() {
+        let mut term = Terminal::with_sink(8, 4, ColorDepth::TrueColor);
+        term.graphics_support = GraphicsEmissionSupport {
+            real_terminal: true,
+            capabilities: Capabilities::default(),
+            force_kitty: false,
+            force_sixel: false,
+            force_iterm: false,
+        };
+        for placement in kitty_placements(1) {
+            term.buffer_mut().kitty_place(placement);
+        }
+        term.flush().unwrap();
+        let bytes = term.take_sink_bytes();
+        assert!(
+            !contains_seq(&bytes, b"\x1b_G"),
+            "captured sink must not emit Kitty APC bytes: {:?}",
+            String::from_utf8_lossy(&bytes)
+        );
+    }
+
     /// Issue #269: replacing the two per-frame `HashSet`s in
     /// `KittyImageManager::flush` with reused `SmallVec` dedup scratch must not
     /// change the emitted escape stream for the small placement counts (0, 1, 5)
@@ -4354,6 +4984,60 @@ mod tests {
         }
     }
 
+    #[test]
+    fn checked_sprixel_flush_suppresses_mux_without_ack_or_force() {
+        let area = Rect::new(0, 0, 10, 5);
+        let mut placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
+        placement.seq = "\x1bPqpayload\x1b\\".to_string();
+
+        let mut current = Buffer::empty(area);
+        current.sprixels.push(placement);
+        let previous = Buffer::empty(area);
+        let support = GraphicsEmissionSupport {
+            real_terminal: true,
+            capabilities: Capabilities::default(),
+            force_kitty: false,
+            force_sixel: false,
+            force_iterm: false,
+        };
+
+        let mut out = Vec::new();
+        flush_sprixels_checked(&mut out, ¤t, &previous, 0, support).unwrap();
+        assert!(
+            out.is_empty(),
+            "tmux/screen without ack must not emit Sixel"
+        );
+    }
+
+    #[test]
+    fn checked_sprixel_flush_allows_mux_with_probe_ack() {
+        let area = Rect::new(0, 0, 10, 5);
+        let mut placement = make_sprixel(vec![SprixelCell::Opaque; 4]);
+        placement.seq = "\x1bPqpayload\x1b\\".to_string();
+
+        let mut current = Buffer::empty(area);
+        current.sprixels.push(placement);
+        let previous = Buffer::empty(area);
+        let support = GraphicsEmissionSupport {
+            real_terminal: true,
+            capabilities: Capabilities {
+                sixel: true,
+                ..Default::default()
+            },
+            force_kitty: false,
+            force_sixel: false,
+            force_iterm: false,
+        };
+
+        let mut out = Vec::new();
+        flush_sprixels_checked(&mut out, ¤t, &previous, 0, support).unwrap();
+        assert!(
+            contains_seq(&out, b"\x1bPqpayload\x1b\\"),
+            "probe-acked Sixel should emit: {:?}",
+            String::from_utf8_lossy(&out)
+        );
+    }
+
     #[test]
     fn sprixel_no_text_change_emits_zero_bytes() {
         // A frame identical to the previous one must emit no sprixel bytes.
diff --git a/src/widgets/collections.rs b/src/widgets/collections.rs
index a1fdc9a..706f795 100644
--- a/src/widgets/collections.rs
+++ b/src/widgets/collections.rs
@@ -70,6 +70,9 @@ impl ListState {
         self.items = items.into_iter().map(Into::into).collect();
         self.item_search_cache = self.items.iter().map(|s| s.to_lowercase()).collect();
         self.selected = self.selected.min(self.items.len().saturating_sub(1));
+        if let Some(heights) = self.item_heights.as_mut() {
+            heights.truncate(self.items.len());
+        }
         // Item count changed, so any cached prefix sum is stale.
         self.heights_dirty = true;
         self.rebuild_view();
@@ -1940,10 +1943,21 @@ mod list_state_height_tests {
         state.set_items(vec!["x", "y"]);
         assert!(state.heights_dirty);
         state.ensure_row_prefix();
-        // item_heights still carries 3 entries; items now has 2 → height 1 for
-        // out-of-range indices is not consulted, the prefix matches the 2 items.
         assert_eq!(state.row_prefix(), &[0, 2, 4]);
     }
+
+    #[test]
+    fn set_items_truncates_stale_per_item_heights() {
+        let mut state = ListState::new(vec!["a", "b", "c", "d"])
+            .with_item_heights(vec![2, 3, 4, 5]);
+        state.set_items(vec!["x", "y"]);
+
+        assert_eq!(state.item_height(0), 2);
+        assert_eq!(state.item_height(1), 3);
+        assert_eq!(state.item_height(2), 1);
+        state.ensure_row_prefix();
+        assert_eq!(state.row_prefix(), &[0, 2, 5]);
+    }
 }
 
 #[cfg(test)]
diff --git a/src/widgets/commanding.rs b/src/widgets/commanding.rs
index 370da36..29de6b2 100644
--- a/src/widgets/commanding.rs
+++ b/src/widgets/commanding.rs
@@ -454,11 +454,46 @@ impl ScreenState {
         self.stack.len() > 1
     }
 
+    /// Return `true` if `name` is currently present in the navigation stack.
+    pub fn contains(&self, name: &str) -> bool {
+        self.stack.iter().any(|screen| screen == name)
+    }
+
     /// Reset to only the root screen.
     pub fn reset(&mut self) {
         self.stack.truncate(1);
     }
 
+    /// Remove retained focus state for a screen that is no longer on the stack.
+    ///
+    /// Returns `false` when the screen is still active or stacked, because
+    /// dropping focus for a live screen would make back navigation jumpy.
+    pub fn remove_inactive(&mut self, name: &str) -> bool {
+        if self.contains(name) {
+            return false;
+        }
+        self.focus_state.remove(name).is_some()
+    }
+
+    /// Retain inactive focus-state entries accepted by `keep`.
+    ///
+    /// Screens still present in the stack are always kept. Returns the number
+    /// of focus-state entries removed.
+    pub fn retain_inactive(&mut self, mut keep: impl FnMut(&str) -> bool) -> usize {
+        let before = self.focus_state.len();
+        let stack = &self.stack;
+        self.focus_state
+            .retain(|name, _| stack.iter().any(|screen| screen == name) || keep(name));
+        before - self.focus_state.len()
+    }
+
+    /// Number of retained per-screen focus entries.
+    ///
+    /// Diagnostic helper for apps with runtime-generated screen names.
+    pub fn focus_state_count(&self) -> usize {
+        self.focus_state.len()
+    }
+
     /// Apply a deferred navigation request recorded inside a
     /// [`crate::Context::screen`] closure (issue #279).
     pub(crate) fn apply_nav(&mut self, nav: ScreenNav) {
@@ -542,11 +577,7 @@ impl ModeState {
     /// reports success, use [`Self::try_switch_mode`].
     pub fn switch_mode(&mut self, mode: impl Into) {
         let mode = mode.into();
-        assert!(
-            self.modes.contains_key(&mode),
-            "mode '{}' not found",
-            mode
-        );
+        assert!(self.modes.contains_key(&mode), "mode '{mode}' not found");
         self.active = mode;
     }
 
@@ -584,6 +615,39 @@ impl ModeState {
             .get_mut(&self.active)
             .expect("active mode must exist")
     }
+
+    /// Return `true` when a mode has been registered.
+    pub fn contains_mode(&self, mode: &str) -> bool {
+        self.modes.contains_key(mode)
+    }
+
+    /// Number of registered modes.
+    ///
+    /// Diagnostic helper for dynamic mode sets.
+    pub fn mode_count(&self) -> usize {
+        self.modes.len()
+    }
+
+    /// Remove an inactive mode and its retained screen state.
+    ///
+    /// The active mode is preserved and returns `false`.
+    pub fn remove_mode(&mut self, mode: &str) -> bool {
+        if self.active == mode {
+            return false;
+        }
+        self.modes.remove(mode).is_some()
+    }
+
+    /// Retain inactive modes accepted by `keep`.
+    ///
+    /// The active mode is always retained. Returns the number of modes removed.
+    pub fn retain_modes(&mut self, mut keep: impl FnMut(&str) -> bool) -> usize {
+        let before = self.modes.len();
+        let active = self.active.as_str();
+        self.modes
+            .retain(|mode, _| mode.as_str() == active || keep(mode.as_str()));
+        before - self.modes.len()
+    }
 }
 
 #[cfg(test)]
@@ -600,6 +664,32 @@ mod mode_state_tests {
         // Active mode must not change when the switch is rejected.
         assert_eq!(modes.active_mode(), "settings");
     }
+
+    #[test]
+    fn remove_mode_preserves_active_mode() {
+        let mut modes = ModeState::new("app", "home");
+        modes.add_mode("settings", "general");
+        modes.add_mode("admin", "dashboard");
+
+        assert_eq!(modes.mode_count(), 3);
+        assert!(!modes.remove_mode("app"));
+        assert!(modes.remove_mode("admin"));
+        assert!(!modes.contains_mode("admin"));
+        assert_eq!(modes.mode_count(), 2);
+    }
+
+    #[test]
+    fn retain_modes_keeps_active_mode() {
+        let mut modes = ModeState::new("app", "home");
+        modes.add_mode("settings", "general");
+        modes.add_mode("admin", "dashboard");
+
+        let removed = modes.retain_modes(|mode| mode == "admin");
+        assert_eq!(removed, 1);
+        assert!(modes.contains_mode("app"));
+        assert!(modes.contains_mode("admin"));
+        assert!(!modes.contains_mode("settings"));
+    }
 }
 
 #[cfg(test)]
diff --git a/src/widgets/feedback.rs b/src/widgets/feedback.rs
index 26f74fe..b65abc5 100644
--- a/src/widgets/feedback.rs
+++ b/src/widgets/feedback.rs
@@ -479,9 +479,10 @@ pub enum Trend {
 /// `run_frame_kernel` does not advance `diagnostics.tick`, so a tick-based
 /// deadline would never elapse under `TestBackend`. See issue #248.
 pub(crate) enum SchedKind {
-    /// One-shot timer that fires exactly once at/after `deadline`.
+    /// One-shot timer that fires exactly once after `dur` has elapsed from the
+    /// slot's `started` instant.
     Once {
-        deadline: std::time::Instant,
+        dur: std::time::Duration,
         fired: bool,
     },
     /// Recurring timer that reports whole `interval`s elapsed since `last`.
@@ -489,11 +490,11 @@ pub(crate) enum SchedKind {
         interval: std::time::Duration,
         last: std::time::Instant,
     },
-    /// Debounce timer: rearmed to `now + dur` on every dirty frame, fires
+    /// Debounce timer: rearmed to `quiet_started` on every dirty frame, fires
     /// once when the quiet window `dur` elapses.
     Debounce {
         dur: std::time::Duration,
-        deadline: std::time::Instant,
+        quiet_started: std::time::Instant,
         fired: bool,
     },
 }
diff --git a/src/widgets/input.rs b/src/widgets/input.rs
index a73192f..36054a3 100644
--- a/src/widgets/input.rs
+++ b/src/widgets/input.rs
@@ -285,6 +285,7 @@ impl std::fmt::Debug for Validator {
 #[cfg_attr(docsrs, doc(cfg(feature = "async")))]
 pub struct AsyncValidation {
     rx: tokio::sync::oneshot::Receiver>,
+    join: tokio::task::JoinHandle<()>,
 }
 
 #[cfg(feature = "async")]
@@ -294,6 +295,13 @@ impl std::fmt::Debug for AsyncValidation {
     }
 }
 
+#[cfg(feature = "async")]
+impl Drop for AsyncValidation {
+    fn drop(&mut self) {
+        self.join.abort();
+    }
+}
+
 /// When [`Context::form_field`](crate::Context::form_field) runs a field's
 /// validators.
 ///
@@ -496,11 +504,11 @@ impl FormField {
         F: std::future::Future> + Send + 'static,
     {
         let (tx, rx) = tokio::sync::oneshot::channel();
-        tokio::spawn(async move {
+        let join = tokio::spawn(async move {
             let result = future.await;
             let _ = tx.send(result);
         });
-        self.pending = Some(AsyncValidation { rx });
+        self.pending = Some(AsyncValidation { rx, join });
     }
 
     /// Whether an async validation is currently in flight.
@@ -703,6 +711,54 @@ impl Default for FormState {
     }
 }
 
+#[cfg(all(test, feature = "async"))]
+mod async_validation_tests {
+    use super::FormField;
+    use std::sync::Arc;
+    use std::sync::atomic::{AtomicBool, Ordering};
+    use std::time::Duration;
+
+    #[tokio::test]
+    async fn replacing_async_validation_aborts_previous_task() {
+        let completed = Arc::new(AtomicBool::new(false));
+        let completed_in_task = Arc::clone(&completed);
+        let mut field = FormField::new("Username");
+
+        field.validate_async(async move {
+            tokio::time::sleep(Duration::from_millis(50)).await;
+            completed_in_task.store(true, Ordering::SeqCst);
+            Ok(())
+        });
+        field.validate_async(async { Ok(()) });
+
+        tokio::time::sleep(Duration::from_millis(120)).await;
+        assert!(
+            !completed.load(Ordering::SeqCst),
+            "superseded validation task must be aborted"
+        );
+    }
+
+    #[tokio::test]
+    async fn dropping_field_aborts_pending_validation_task() {
+        let completed = Arc::new(AtomicBool::new(false));
+        let completed_in_task = Arc::clone(&completed);
+        let mut field = FormField::new("Username");
+
+        field.validate_async(async move {
+            tokio::time::sleep(Duration::from_millis(50)).await;
+            completed_in_task.store(true, Ordering::SeqCst);
+            Ok(())
+        });
+        drop(field);
+
+        tokio::time::sleep(Duration::from_millis(120)).await;
+        assert!(
+            !completed.load(Ordering::SeqCst),
+            "dropping a field must abort its pending validation task"
+        );
+    }
+}
+
 /// State for toast notification display.
 ///
 /// Add messages with [`ToastState::info`], [`ToastState::success`],
diff --git a/tests/pty_ansi.rs b/tests/pty_ansi.rs
index 430d9a8..3af7bc7 100644
--- a/tests/pty_ansi.rs
+++ b/tests/pty_ansi.rs
@@ -14,6 +14,40 @@
 #![cfg(feature = "pty-test")]
 
 use slt::{Color, ColorDepth, PtyBackend};
+use std::sync::Mutex;
+
+static ENV_GUARD: Mutex<()> = Mutex::new(());
+
+#[allow(unsafe_code)]
+fn with_env_vars(vars: &[(&str, Option<&str>)], f: F) {
+    let _g = ENV_GUARD.lock().unwrap_or_else(|e| e.into_inner());
+    let previous: Vec<(&str, Option)> = vars
+        .iter()
+        .map(|(name, _)| (*name, std::env::var(name).ok()))
+        .collect();
+
+    // SAFETY (edition 2024): env mutation is process-global. ENV_GUARD
+    // serializes these PTY tests while the override is active.
+    unsafe {
+        for (name, value) in vars {
+            match value {
+                Some(value) => std::env::set_var(name, value),
+                None => std::env::remove_var(name),
+            }
+        }
+    }
+
+    f();
+
+    unsafe {
+        for (name, value) in previous {
+            match value {
+                Some(value) => std::env::set_var(name, value),
+                None => std::env::remove_var(name),
+            }
+        }
+    }
+}
 
 /// A styled glyph emits an SGR escape carrying both the foreground color and
 /// the bold attribute through the production flush pipeline.
@@ -27,12 +61,9 @@ fn styled_text_emits_sgr_run() {
     pb.assert_emits("\u{1b}[");
     // Bold attribute: SGR 1.
     pb.assert_emits("\u{1b}[1m");
-    // Foreground SGR present. crossterm renders the named `Color::Red`
-    // (→ `DarkRed`) through `SetForegroundColor` as the 256-color form
-    // `38;5;1` rather than the legacy `31` — this end-to-end test pins the
-    // bytes the shipping pipeline actually emits, which the isolated
-    // `to_crossterm_color` unit does not reveal.
-    pb.assert_emits("\u{1b}[38;5;1m");
+    // Foreground SGR present. SLT emits color SGR directly so environment
+    // variables such as NO_COLOR cannot override an explicit ColorDepth.
+    pb.assert_emits("\u{1b}[31m");
     // The glyph itself is printed.
     pb.assert_emits("x");
 }
@@ -42,40 +73,60 @@ fn styled_text_emits_sgr_run() {
 /// it on so the headless harness exercises the real encode + flush path.
 #[test]
 fn sixel_image_emits_envelope() {
-    // SAFETY-equivalent note: `set_var` is process-global. This test owns the
-    // var for its duration and restores it; it asserts on the forced path.
-    let prev = std::env::var("SLT_FORCE_SIXEL").ok();
-    // SAFETY (edition 2024): set_var is unsafe; this single-threaded test owns
-    // the var for its duration and restores it below.
-    unsafe { std::env::set_var("SLT_FORCE_SIXEL", "1") };
-
-    let mut pb = PtyBackend::new(20, 2);
-    // 2x2 red square (RGBA: 4 pixels x 4 bytes).
-    let rgba = [255u8, 0, 0, 255].repeat(4);
-    pb.render(|ui| {
-        let _ = ui.sixel_image(&rgba, 2, 2, 20, 2);
+    with_env_vars(&[("SLT_FORCE_SIXEL", Some("1"))], || {
+        let mut pb = PtyBackend::new(20, 2);
+        // 2x2 red square (RGBA: 4 pixels x 4 bytes).
+        let rgba = [255u8, 0, 0, 255].repeat(4);
+        pb.render(|ui| {
+            let _ = ui.sixel_image(&rgba, 2, 2, 20, 2);
+        });
+        pb.assert_emits("\u{1b}Pq");
+        pb.assert_emits("\u{1b}\\");
     });
-    pb.assert_emits("\u{1b}Pq");
-    pb.assert_emits("\u{1b}\\");
-
-    unsafe {
-        match prev {
-            Some(v) => std::env::set_var("SLT_FORCE_SIXEL", v),
-            None => std::env::remove_var("SLT_FORCE_SIXEL"),
-        }
-    }
 }
 
 /// A `kitty_image` call emits the Kitty graphics APC introducer `\x1b_Ga=`.
 #[test]
 fn kitty_image_emits_apc() {
-    let mut pb = PtyBackend::new(20, 4);
-    // 2x2 RGBA image.
-    let rgba = [0u8, 128, 255, 255].repeat(4);
-    pb.render(|ui| {
-        let _ = ui.kitty_image(&rgba, 2, 2, 4, 2);
+    with_env_vars(&[("SLT_FORCE_KITTY", Some("1"))], || {
+        let mut pb = PtyBackend::new(20, 4);
+        // 2x2 RGBA image.
+        let rgba = [0u8, 128, 255, 255].repeat(4);
+        pb.render(|ui| {
+            let _ = ui.kitty_image(&rgba, 2, 2, 4, 2);
+        });
+        pb.assert_emits("\u{1b}_Ga=");
     });
-    pb.assert_emits("\u{1b}_Ga=");
+}
+
+/// tmux/screen-like environments must not leak image protocol bytes unless
+/// explicitly forced. TERM_PROGRAM may describe the outer capable terminal.
+#[test]
+fn image_protocols_do_not_emit_inside_tmux_without_force() {
+    with_env_vars(
+        &[
+            ("TERM", Some("screen-256color")),
+            ("TERM_PROGRAM", Some("WezTerm")),
+            ("TMUX", Some("/tmp/tmux-1000/default,1,0")),
+            ("SLT_FORCE_KITTY", None),
+            ("SLT_FORCE_SIXEL", None),
+            ("SLT_FORCE_ITERM", None),
+        ],
+        || {
+            let rgba = [0u8, 128, 255, 255].repeat(4);
+            let png = [0x89u8, b'P', b'N', b'G'];
+            let mut pb = PtyBackend::new(24, 8);
+            pb.render(|ui| {
+                let _ = ui.kitty_image(&rgba, 2, 2, 4, 2);
+                let _ = ui.sixel_image(&rgba, 2, 2, 4, 2);
+                let _ = ui.iterm_image(&png, 4, 2);
+            });
+
+            pb.assert_not_emits("\u{1b}_Ga=");
+            pb.assert_not_emits("\u{1b}Pq");
+            pb.assert_not_emits("\u{1b}]1337;File=");
+        },
+    );
 }
 
 /// A hyperlinked text run emits the OSC 8 open sequence `\x1b]8;;`.
@@ -119,6 +170,28 @@ fn color_depth_downgrade_changes_sgr() {
     eight_bit.assert_not_emits("\u{1b}[38;2;200;50;50m");
 }
 
+/// Explicit color depth is authoritative even when the environment requests no
+/// color. NO_COLOR is honored only by ColorDepth::detect().
+#[test]
+fn explicit_truecolor_ignores_no_color_environment() {
+    with_env_vars(&[("NO_COLOR", Some("1")), ("TERM", Some("dumb"))], || {
+        let rgb = Color::Rgb(200, 50, 50);
+
+        let mut truecolor = PtyBackend::new(10, 1).with_color_depth(ColorDepth::TrueColor);
+        truecolor.render(|ui| {
+            ui.text("x").fg(rgb);
+        });
+        truecolor.assert_emits("\u{1b}[38;2;200;50;50m");
+
+        let mut no_color = PtyBackend::new(10, 1).with_color_depth(ColorDepth::NoColor);
+        no_color.render(|ui| {
+            ui.text("x").fg(rgb);
+        });
+        no_color.assert_not_emits("\u{1b}[38;2;200;50;50m");
+        no_color.assert_not_emits("\u{1b}[38;5;");
+    });
+}
+
 /// Rendering the same frame twice produces byte-identical output — the harness
 /// is deterministic and CI-reproducible (mirrors
 /// `snapshot_format_stability::stability_deterministic_repeat`).