The console UI overhaul: motion, look, and a library you can actually navigate #269

Merged
enricobuehler merged 9 commits from worktree-console-ui-overhaul into main 2026-08-16 15:06:21 +00:00
9 Commits
Author SHA1 Message Date
enricobuehler 451f95079e fix(console): the focus recede ran backwards on all six pale palettes
ci / web (pull_request) Successful in 1m11s
ci / bun-nix (pull_request) Successful in 1m30s
ci / docs-site (pull_request) Successful in 5m30s
ci / rust-arm64 (pull_request) Successful in 7m26s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 7m18s
android / android (pull_request) Successful in 9m58s
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m5s
ci / rust (pull_request) Failing after 13m54s
Caught on the screenshot pass, which is the only place it could have been
caught: every unit test and every dark-palette render agreed the recede was
fine.

`recede_matrix` ported Apple's `.brightness(-0.24·d)` literally, and that is
dark-mode arithmetic — on a dark field, "down" is "away". This crate ships
thirteen palettes and six of them are PALE, where darkening a card increases its
contrast against a light ground. On `holo` the effect was not subtle: the
UNFOCUSED host tile rendered as the heaviest object on screen, a grey slab
beside the focused card it was supposed to be receding behind.

The recede now moves a card toward its GROUND rather than always toward black,
taking the direction from the scrim — which already encodes which way the field
leans, because it tends to black on a dark palette and white on a pale one. The
saturation drain is unchanged in both directions; colour has no handedness.

The new test asserts the two poles disagree in SIGN — a receded card sinks on
`violet` and lifts on `mint` — because that is the property, and any test
written against one palette would have passed before this fix.
2026-08-16 16:21:10 +02:00
enricobuehler d927fcb2b0 feat(console): Collections — group by platform, walk them, open one
The user's ask, verbatim: group by console, navigate the platforms, pick PS3,
see its games. Y on a library opens a carousel of collection tiles; A on one
pushes the same `LibraryScreen` with a FILTER set, titled "Host · PS3"; B walks
back out. The whole flow is asserted end to end rather than in pieces, because
it is the flow that was asked for.

It borrows the home carousel's tile language on purpose. A collection is a place
you go, exactly as a host is, and the console should have one idea of what that
looks like — same sprung cursor, same entrance, same recede, same halo. Each
tile fans up to three real covers, taken from the posters the library screen has
ALREADY decoded rather than fetched again; a group with no art yet shows its
monogram, which is also the permanent look of a platform full of art-less ROM
entries.

Filtering is index-level. The drill-in sets a `GroupKey` and the shelf collates
against it, so the shared model, the art pump and the fetch flow never learn
that collections exist — the filtered shelf is the same screen, not a copy of
the library with things removed.

The gate is a real one, and it is a pair. `worth_browsing` is false for a
library with a single group, and BOTH the Y binding and the Y hint read it, so
the legend can never advertise a press that only thuds. Launchers alone never
count toward it: every library has them, and a collections screen offering only
"Launchers" is precisely the one-tile screen the rule exists to prevent. Y is
also refused from inside a collection — a drill-in from a drill-in would collate
a set that is already one group.

Sort lives here, on the shoulders, as four pills. The shelf re-reads
`library_sort` every frame, so stepping it re-orders the collection tiles AND
the shelf waiting behind this screen at once — which is the argument for putting
it on this screen rather than in a dialog.

`LibraryScreen` stopped unpacking its host into five scalars and keeps the
`HostRow` whole, because Collections has to hand one back to `LibraryScreen::new`
and two partial copies of the same host is the state that goes stale first.

One thing worth recording: the flow test failed on its first run, asserting the
filtered shelf held 0 titles instead of 2 — a screen adopts the shared model on
its first sync, and the test asked before the app would have rendered. The
sequencing was the bug, not the filter, and the test now draws a frame the way
the console does. It is the kind of thing that would otherwise have been "fixed"
by weakening the assertion.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 121 console-ui + 213 client-core tests green.
2026-08-16 16:04:14 +02:00
enricobuehler c354d7cd09 feat(console): a grid arrangement for the library, plus art eviction and a skeleton shelf
The coverflow is a browsing surface: one cover at a time, big, artwork doing the
talking. It is the wrong shape for "I know what I want, show me everything",
which on a 200-title shelf means holding Right and watching. The grid is that
other surface — about eighteen covers at once — and it is a VIEW rather than a
second screen: same cursor, same collated order, same art cache, same detail
band underneath. Only the arrangement differs.

`grid_step` is the pure half, and it carries the one real subtlety: the two
boundary rules are different on purpose. A row's ENDS refuse, exactly as the
shelf's do, because wrapping would make a held Right scan the whole library and
the shoulders already do that. But Down into a SHORT last row CLAMPS to the last
title rather than refusing — a ragged tail is a layout accident, not a boundary
the user chose to hit. Both, plus page jumps and degenerate grids, are pinned by
tests.

Where the toggle lives is a deviation from the plan, and deliberate. The plan
put View on X and Collections on Y, but X is already Copy link in the library
and the grid needs Up/Down for rows, which leaves exactly one free face button
for two features. Rather than evict a shipped binding, `library_view` becomes a
settings row in Interface beside the palette and reduce-motion — which is what
that tab is for, and this is a preference you set once, not something you toggle
mid-browse. Y stays free for Collections.

Art eviction is not cosmetic. `LibraryScreen::art` had no eviction at all, so
every cover ever scrolled past stayed decoded for the life of the screen — the
shelf touched a dozen and hid it; the grid touches all of them. Posters are now
frame-stamped as they are DRAWN and the coldest trimmed past a budget of 160.
Stamping after the draw rather than on arrival is the load-bearing detail, and
the test says so: an LRU keyed on arrival would cheerfully evict the six covers
the cursor is sitting among. Only decodes are trimmed — the encoded bytes are
not kept here either way, so the fetch pipeline is untouched and a re-entry
costs one frame of grey.

Loading becomes a skeleton in the arrangement the real cards will arrive in,
with one travelling sheen — the wait is for a LIST whose shape is known before
its contents are, so showing that shape is more honest than a blank field that
abruptly becomes a coverflow. It costs one extra paint and freezes under reduced
motion.

Two things fell out of the refactor and are worth naming: the art-less card face
is now one function both arrangements call (a launcher without a poster still
must not read as a game whose cover failed to load), and the detail band is
shared rather than copied — it also gained the platform, which is the whole
point of plumbing it, because "Shadow of the Colossus" means something rather
different with "PS2" under it.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 118 console-ui + 213 client-core tests green. One run aborted
inside rav1d's CDEF filter; that is environmental (emulated amd64), confirmed by
A/B — the immediately preceding run carried the identical pf-client-core sources
and passed 213/213, and the re-run passes.
2026-08-16 15:55:32 +02:00
enricobuehler c804a0dc9f feat(console): plumb platform to the shelf and give it a collation module
`GameEntry.platform` ("PC", "PS2", …) has crossed the wire since the model
landed and was thrown away at the console's boundary: both `LibraryGame` mapping
sites in the session binary built the struct without it. It is what the
rom-manager plugin populates, and without it "group by console" has nothing to
group by. Two lines, and the fake-library loader inherits it for free because it
maps the same entry type.

The interesting half is `collate` — a pure module, no Skia, because grouping
rules are exactly the kind of thing that reads obviously correct and is quietly
wrong, and because it is the half that has to be identical on every client. This
file is the portable spec the Apple and Android ports implement.

Two rules earn their tests:

- A platform-less game does NOT go to "Unknown". A Steam library has no
  platforms at all, so an "Unknown" bucket would swallow it whole and be a worse
  view than no grouping; a store-front game buckets under its STORE ("Steam"),
  which is both true and useful, and only an entry with neither lands in
  "Other".
- A-Z folds the leading article. "The Witcher 3" belongs under W; left alone
  every "The …" piles up under T and the sort is useless exactly where a long
  library needs it. English articles only, with a guard so a title that IS an
  article doesn't sort as an empty string and float to the front.

Launchers lead by construction rather than by every caller remembering to put
them there, so design D4 survives grouping for free. Everything returns INDICES:
the art cache, the fetch pump and the cursor all key off the shared model's
ordering, and handing back cloned games would fork the identity of every title.

The shelf now routes its display order through `collate::filtered` even
ungrouped and unsorted — which is why this lands green on its own, and more
importantly is the right shape: shelf, sort and the coming drill-in are then one
screen with one cursor arithmetic. `SortKey::HostOrder` is the default and is
asserted byte-identical to the order the host sent, so a user who never touches
a sort pill sees no change whatever. `LibraryScreen` gains a `view: Vec<usize>`
the cursor indexes instead of `games`, and a `filter` the drill-in will set.

`library_sort` joins `trust::Settings` beside `ui_palette` and `reduce_motion` —
presentation only, never in a profile, parsed leniently so a newer client's key
reads as today's shelf. Its pills arrive with the Collections screen; the sort
is honoured now.

`clients/session/fixtures/mixed-platform-library.json` is the standing dev asset
the rest of Part C is built against: launchers, five platforms, several stores,
platform-less entries, and two deliberately awkward titles ("The Witcher 3",
"Émigré") so a broken fold shows up on glass and not only in a unit test.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 111 tests green.
2026-08-16 15:44:17 +02:00
enricobuehler be8183caab feat(console): host tiles get their OS mark, the chip gets a battery, the strip gets Rescan
`HostRow.os` has been plumbed since the model landed, with a comment saying the
drawing was a follow-up because "the Skia glyph set doesn't exist yet". It does
exist: assets/os-icons ships thirteen licensed masters, and
`pf_client_core::os::os_icon_tokens` already resolves a chain to them - walking
most-specific-first and applying the brand aliases (`macos` -> `apple`,
`steamos` -> `steam`). Every other front-end walks that same list.

So the console takes the shared resolver rather than inventing one, and gets its
table GENERATED from the masters (`scripts/gen_os_mark_table.py`, hooked into
the existing `gen-os-icons.sh`) rather than hand-transcribed. Thirteen paths of
up to 3.5 kB where one mangled character is a silently wrong logo is not work
for a human, which is precisely the reasoning the launcher-icon tables already
carry. A new master now reaches the console for free; the script's closing note
says so.

Two corrections to the plan this implements, both found in the code:
- The chain is SLASH-separated and resolves most-specific-FIRST, not "the first
  known token of a `;`-chain". A `linux/fedora/bazzite` host draws Bazzite, and
  falls back through Fedora to Tux - so the console is right about thirteen
  distros rather than the four the plan scoped.
- The hint bar was already a glass pill, not "ink on the field". What it was
  missing is that it mixed its OWN glass (a flat wash and a hand-rolled stroke),
  making it the one floating surface that ignored the palette; it now goes
  through `theme::panel` like the chip and the toast, and picks up the lit edge.

`draw_monogram` becomes `draw_badge`: the OS mark when the chain resolves, the
initial when it doesn't. A substitution, not an addition - a badge showing both
a Tux and an "L" says the same thing twice - and an older host that advertises
no `os` keeps its monogram pixel for pixel.

The controller chip gains a pad silhouette and a battery pip. `PadInfo` gets an
additive `battery: Option<PadBattery>`; nothing crosses the wire, this is local
SDL state. The plan expected to poll "on the existing pad-refresh cadence" -
there isn't one, `publish()` is entirely event-driven (hotplug, pin change). And
`pad_info` is deliberately open-free because an open GRABS the hardware, while
SDL only reports power for an OPEN device. So the level is read from the ONE pad
the service already holds open - `menu_open`, the nav pad, which is open exactly
while a console is on screen and is the only pad any UI asks about - on a 15 s
poll inside the loop that already wakes every 10 ms. Every other pad publishes
`None`, which is the honest answer.

`None` renders as no battery at all, never 0 %: a wired pad, a Steam virtual pad
and SDL's `-1` "powered, level unknown" are all the same non-answer, and 0 % is
the one reading that sends someone hunting for a charger. Charging outranks the
low-charge red, because a pad at 4 % on the cable is not the problem a pad at
4 % off it is.

Finally, Rescan: a second sentinel tile trailing Add Host, sending the
`ConsoleCmd::Probe` that has existed unsent by any screen since it was written.
A controller surface has no pull-to-refresh, so the affordance has to be a tile.
The two trailing tiles are actions rather than hosts, so `hosts.get(i)`
answering both "which host" and "which action" with one `None` became a `Slot`
enum - with a second action tile that ambiguity is a bug waiting, and the test
that matters is that an accidental A on the end of the strip can never start a
session.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 104 tests green.
2026-08-16 15:36:36 +02:00
enricobuehler a289f7b9af feat(console): recede by draining colour, not by painting black over it
An unfocused card lost scale and gained a flat black veil. A veil only darkens,
so a receded card stayed exactly as colourful as the focused one and the eye
went on reading it as a competing subject - which is why the veil had to be
heavy to work at all, and why the shelf read as "dimmed" rather than "deep".

`theme::recede_matrix` adds the two channels the Apple gamepad UI uses and this
crate did not: saturation down 42 % and brightness down 24 % at full recede, as
one 4x5 colour matrix on the layer each card composites through. The veil then
drops to HALF its old strength on both surfaces (home 0.24 -> 0.12, the
coverflow's RECEDE_DIM 0.30 -> 0.15), left doing the one job a flat darkening is
good at: separating cards that overlap.

Alpha is deliberately untouched by the matrix, and there is a test saying so.
The coverflow's side cards overlap, so a recede that reached alpha would let
them show through each other - which is the reason the veil exists in the first
place. A second test pins the focused card as EXACTLY identity, because a matrix
that tinted the focus by half a percent would be invisible in review and wrong
in every screenshot.

The focused tile also gains an accent-tinted halo under its shadow - the
palette-aware mark that says "this one" from a sofa, where a 12 % scale
difference says nothing. On the coverflow it is drawn in screen space before the
card's own transform, because it is light spilling AROUND the card and cannot
live inside the rounded rect the card clips itself to; it fades with the sprung
proximity rather than snapping on the integer cursor, so it travels with the
strip.

Glass gains a lit top edge (`theme::panel_highlight`): a 1 px inner stroke
fading out over the top 40 %, which is what makes a material read as material
rather than as a tinted rectangle. Deliberately a separate call rather than a
flag on `panel`, so the cost discipline is visible - tiles and the one focused
row get it, the dozens of resting rows a settings screen paints every frame do
not.

Cost, stated plainly: the coverflow previously had no per-card layer, so the
recede adds one per VISIBLE card there (bounded to the card by raising it after
the clip, and skipped entirely for the focused, settled one). That is the plan's
named O(visible cards) price and the thing to watch on a Deck frame graph. The
home carousel already composited each tile through a layer, so it pays nothing
extra.

The typography sweep found less than expected, which is worth recording: the
crate is already almost entirely on the 0.55 / 0.7 / 0.85 ladder. Two genuine
outliers, both in the library - a group heading a shade louder than the
identical role in `MenuList` (0.5 -> 0.45) and a detail subtitle off the ladder
(0.5 -> 0.55). The `fg(0.92)` runs are NOT outliers: they are letters on a
button badge and text on a toast pill, sitting on their own ground rather than
on the field. `ROW_MAX_W` is already Apple's 620, and the 30 dp title against 16
dp row labels is within a hair of Apple's 34/17 rhythm, so neither moved.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 99 tests green.
2026-08-16 15:25:13 +02:00
enricobuehler 50ef253e43 feat(console): the screen transition becomes an interruptible spring (vectors v2)
Pressing B while a screen was still arriving did nothing at all. The transition
was a 0.26 s ease-out-cubic and every event that landed inside it was thrown
away, so holding B to back out of a deep stack stuttered at every level: press,
wait, press again. That wall is the one place the console felt less alive than
the Apple shell it is modelled on.

`Motion` becomes one sprung scalar. The paint recipes read `spring.pos` exactly
where they read `ease_out_cubic(raw)` - same 36 dp slide, same 0.985/0.96
scales, same 0.4 reveal alpha, so the choreography is untouched and only the
time-course changed. Those four numbers stop being literals buried in the paint
sites and become named constants, because the vectors file claims to pin them
for all three clients and a literal is a thing no test can reach.

What the spring buys is the retarget. Back mid-push flips THAT SAME spring from
1.0 to 0.0: velocity carries, so the entering screen decelerates, turns, and
goes back down as one continuous motion, and `finish_nav` takes it off the stack
when the spring lands. A tween cannot do that - its progress is a function of
elapsed time, so reversing means either a snap or a second animation played
behind the first. The test asserts the continuity directly (no sample jumps by
more than a frame of the travel already underway), because "cancel and play a
pop" would pass a weaker test and still look wrong.

Back is now always heard; everything else stays queued-dropped until the spring
passes 0.85, so the double-tap protection survives. The threshold is on
POSITION, not elapsed time - with a spring those are different questions, and
the one that matters for input is whether the screen under the cursor is the one
being aimed at. Mid-pop, A is still refused (activating a half-dismissed screen
is a mis-tap, not intent) while Back starts the next pop immediately, which is
the stutter this removes. At the root the transition declines the press, because
there is no parent to fall back to and B there means quit.

Parity: `console-vectors.json` gains a `motion_spring` block and goes to version
2. Springs are integrator-dependent, so v2 pins PARAMETERS where v1 pinned
sampled positions - two runtimes that both honour response/damping agree to the
eye and disagree in the third decimal, and sampling would pin the disagreement
instead of the feel. The v1 `motion` block stays in place, marked `$deprecated`
and still correct, because the Android client's ConsoleVectorsTest reads it;
this crate's test switches to the new block and asserts the note is still there,
so deleting v1 here would fail loudly rather than redden Android silently.
(Note for the follow-ups: the Apple client mirrors the v1 constants in
GamepadShell but does NOT test them - its ConsoleVectorsTests covers the palette
table only. The plan assumed otherwise.)

`Progress` is deleted. The transition was its last caller, and this crate lints
dead code as an error - keeping a timer nothing times behind an allow would be
the wrong kind of tidy.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 97 tests green.
2026-08-16 15:19:25 +02:00
enricobuehler 2314637c0d feat(console): screens arrive instead of appearing - the staggered card entrance
Until now a console screen popped into existence fully formed; the only thing
that moved was the shell's own push slide. This ports the Apple gamepad UI's
`CardEntrance` semantics as `anim::Entrance`: a pure function of the shell clock
and an item's distance from an anchor, so a screen holds ONE of them and asks it
per item. No per-card state to keep in step with a host list that churns under
discovery.

Per item: a delay of 0.07 s per step from the anchor (capped at 0.42 s, which is
what stops a 400-title shelf still arriving a minute later), then a 0.6 s window
of ease-out-BACK travel - it crosses 1.0 and settles back, which is the
difference between a card thrown into place and one slid there - with the fade
finished in the first third, so what you read is the motion and not a dissolve.
The anchor is the CURSOR, so a restored selection assembles around the eye.

Three surfaces, each applying the envelope to its own geometry:

- The home carousel: tiles arrive 26 % small, 34 dp low, fading, folded into the
  transform and layer alpha each tile already applies. Hit boxes follow the
  drawn geometry, because a press during that second must land on the card the
  eye sees.
- The library coverflow: the same, plus a real Y-turn on the card's OWN rotation
  matrix. Apple had to fake its turn with a cos-squeeze because SwiftUI cannot
  snapshot a rotated layer to glass; Skia has no such constraint, so the strip
  genuinely fans open like a book. Cards turn away in the direction they sit
  from the anchor.
- Menu lists: 12 dp rise, 0.03 s stagger, capped at eight rows. Same language,
  deliberately quieter - a settings list that fans open like a shelf of box art
  is a settings list showing off. NOT replayed on a tab switch: re-fanning the
  rows on every L1/R1 would turn a skim through the sections into a flicker.

The library waits for content before it plays. Art streams in per title AFTER
the list lands, so without a gate the entrance would reliably show off a rank of
grey placeholder faces; it arms once the cards around the cursor have posters,
or 400 ms have gone by and they clearly are not coming - so an art-less library
of custom entries still gets its entrance, just later.

On cost: the coverflow's resting frame is byte-for-byte as cheap as it was. It
deliberately has no per-card layer - side cards overlap, so it dims them with an
opaque veil rather than whole-card alpha - and the entrance raises one only
while a card is still translucent, after the clip so `None` bounds mean the CARD
and not the screen. A full-screen layer per arriving card is the one way this
could have cost real time on a Deck.

Under reduce motion the whole thing degrades to a staggerless crossfade, decided
once when the entrance is armed so one entrance plays one way.

Verified in the pf-gtkflow container: fmt, clippy --all-targets -D warnings,
plain build, 94 tests green.
2026-08-16 15:11:55 +02:00
enricobuehler 46d648cf0f feat(console): name the motion, spring the micro-motion, and let it all be turned off
The console's feel was scattered across a dozen hand-picked time constants, so
"how a focused row arrives" was a tau someone chose in a hurry rather than a
decision anyone could find. `anim.rs` grows a `SpringSpec` table (FOCUS,
INDICATOR, PRESS) with a const `kc()` that performs SwiftUI's
response/damping -> k/c conversion; a test pins it against TRAY_K/TRAY_C, which
were hand-derived from exactly that arithmetic, and a second states the damping
choices as behaviour (FOCUS must overshoot, INDICATOR must not).

Three widgets stop easing and start springing, each for a reason:

- Row focus splits into two channels. Scale is sprung, because the whisker of
  overshoot is the pop that makes a row read as picked up; tint and alpha keep
  `approach`, because an overshooting colour lands on a tint that is not in the
  palette.
- The tab pill's x and width are sprung, so a fast skim through the sections
  accumulates into one accelerating travel instead of a series of eases that
  each restart at zero speed.
- A stepped value slips 14 dp and springs back, crossfading the old value out.
  The list detects the change ITSELF by comparing what it drew against what the
  screen hands back, so no screen has to report whether its edit landed - and a
  refused adjust correctly produces no motion at all.

Toasts gain a kind. "Paired with X", "Session ended" and "Couldn't connect" were
the same grey pill, so the only way to tell them apart was to read them; each
now carries a mark and a hairline. Error is the one tint that is fixed rather
than palette-derived: moss's accent is a green, and reporting a failure in the
colour the UI uses for "this is fine" is the bug.

And a Reduce motion setting, under Interface beside the palette. It freezes the
backdrop shader's clock (gated in `draw_aurora`, so the connect takeover
inherits it), flattens push/pop to a 0.2 s crossfade with no slide or scale, and
drops the recoil and slip travel - while keeping every haptic, because the pulse
is what replaces the motion, not another thing to take away. A console setting
rather than an OS mirror: there is no system reduce-motion SDL can read
portably. It doubles as the OLED-friendly mode.

Verified in the pf-gtkflow container (this crate is invisible to a Mac checkout -
`cargo check -p pf-console-ui` there compiles nothing at all): fmt, clippy
--all-targets -D warnings, a plain build, and 90 tests green.
2026-08-16 15:03:00 +02:00