Implements punktfunk-planning/design/console-ui-overhaul.md in full — all 14 work packages, sliced as the plan's eight PRs plus one fix. ~4,800 lines across 25 files.
Part A — motion
A named spring vocabulary. Feel was a dozen hand-picked time constants, so "how a focused row arrives" was a τ someone chose in a hurry. anim.rs grows a SpringSpec table with a const kc() performing SwiftUI's response/damping → k/c conversion; a test pins it against TRAY_K/TRAY_C, which were hand-derived from exactly that arithmetic.
Sprung micro-motion. Row focus splits into two channels — scale sprung (the overshoot IS the pop), tint eased (an overshooting colour lands outside the palette). The tab pill carries velocity, so a fast section skim is one accelerating travel. A stepped value slips 14 dp and springs back, crossfading the old value; the list detects the change itself by comparing what it drew against what the screen hands back, so a refused adjust correctly produces no motion.
Toasts gained a kind. "Paired with X" and "Couldn't connect" were the same grey pill. Error is the one tint that is fixed rather than palette-derived — moss's accent is a green.
A staggered card entrance (anim::Entrance), a pure function of the clock with no per-card state. The coverflow turns cards on their real Y-rotation — Apple had to fake theirs with a cos-squeeze because SwiftUI can't snapshot a rotated layer to glass. The library waits for artwork before playing, or 400 ms, so it never fans open a rank of grey placeholders.
The transition became an interruptible spring. Back mid-push retargets the same spring to 0: velocity carries, so the screen turns around and goes back. A tween can't do that. Holding B to back out of a deep stack no longer stutters at every level. console-vectors.json gains a motion_spring block at version 2 pinning parameters, not samples (springs are integrator-dependent); the v1 block stays, marked $deprecated, because Android still reads it.
A Reduce motion setting — freezes the backdrop, flattens push/pop, drops recoil travel, keeps every haptic.
Part B — look
Recede by draining colour, not by painting black over it. Saturation −42 %, brightness −24 %, as one colour matrix; the veil drops to half. Alpha is deliberately untouched, with a test saying so — coverflow side cards overlap.
A focused-tile halo, palette-tinted, and a lit top edge on glass.
OS marks at last.HostRow.os had been plumbed since the model landed, waiting on a glyph set that already existed: 13 licensed masters in assets/os-icons, resolved by the shared os_icon_tokens. The table is generated (scripts/gen_os_mark_table.py), so a new master reaches the console for free.
A battery pip on the controller chip, and a Rescan tile sending the ConsoleCmd::Probe that had existed unsent by any screen.
Part C — the library
platform is plumbed (it was dropped at the console's boundary) and a pure collate module carries the policy. Two rules earn their tests: a platform-less game buckets under its store, never "Unknown" (a Steam library would collapse into one heap), and A–Z folds the leading article ("The Witcher 3" belongs under W).
A grid arrangement with its own cursor math — row ends refuse like the shelf, but a short tail row clamps, because a ragged tail is a layout accident, not a boundary you chose to hit.
Art eviction. There was none; every cover ever scrolled past stayed decoded. Posters are frame-stamped as drawn — an LRU keyed on arrival would evict the six the cursor is sitting among.
Collections — the ask, verbatim: group by platform, walk them, pick PS3, see its games. Filtering is index-level, so the art pump and fetch flow never learn collections exist.
Verification
Every commit gated green in the pf-gtkflow container — fmt, clippy --all-targets -D warnings, a plain build, and tests. This matters more than usual: cargo check -p pf-console-ui on macOS compiles nothing. Tests went 83 → 122 in the crate; pf-client-core stays 213.
The screenshot pass caught a defect nothing else could.recede_matrix ported Apple's .brightness(-0.24·d), which is dark-mode arithmetic. On the six pale palettes, darkening a card raises its contrast against a light ground — the unfocused tile became the heaviest thing on screen. It now recedes toward the ground, taking direction from the scrim. 122 green tests and every dark-palette render had agreed it was fine.
Where the plan was wrong about the code
Resolved in favour of what is actually there, each explained in its commit:
The OS chain is slash-separated and resolves most-specific-first with brand aliases — not a ;-chain, first-known-wins. Using the shared resolver makes the console right about 13 distros instead of the 4 scoped.
The hint bar was already a glass pill; what it lacked was the shared glass recipe, so it was the one floating surface ignoring the palette.
There is no existing pad-refresh cadence, and pad_info is deliberately open-free while SDL reports power only for an open device. Battery reads from the one pad the service already holds open, on a 15 s poll.
Apple's ConsoleVectorsTests covers the palette table only — it never tested motion, so only Rust and Android need the v1→v2 migration.
The plan put View on X, but X is already Copy link and the grid needs Up/Down for rows — one free button, two features. Rather than evict a shipped binding, library_view became a settings row beside the palette; Y stays for Collections.
Progress is deleted: the transition was its last caller, and this crate lints dead code as an error.
Not done
On-glass verification is owed and needs hardware: --browse on a Linux box with clients/session/fixtures/mixed-platform-library.json, and a Deck frame-graph check of the recede/halo — the plan's named O(visible cards) cost. Apple/Android ports of grid/collections/sort remain out of scope as planned; the collate module is written to be their portable spec.
No wire or ABI changes: platform was already on the wire, battery is local SDL, and all persistence is trust::Settings fields with lenient serde defaults.
Implements `punktfunk-planning/design/console-ui-overhaul.md` in full — all 14 work packages, sliced as the plan's eight PRs plus one fix. ~4,800 lines across 25 files.
## Part A — motion
- **A named spring vocabulary.** Feel was a dozen hand-picked time constants, so "how a focused row arrives" was a τ someone chose in a hurry. `anim.rs` grows a `SpringSpec` table with a `const kc()` performing SwiftUI's response/damping → k/c conversion; a test pins it against `TRAY_K`/`TRAY_C`, which were hand-derived from exactly that arithmetic.
- **Sprung micro-motion.** Row focus splits into two channels — scale sprung (the overshoot IS the pop), tint eased (an overshooting colour lands outside the palette). The tab pill carries velocity, so a fast section skim is one accelerating travel. A stepped value slips 14 dp and springs back, crossfading the old value; the list detects the change *itself* by comparing what it drew against what the screen hands back, so a refused adjust correctly produces no motion.
- **Toasts gained a kind.** "Paired with X" and "Couldn't connect" were the same grey pill. Error is the one tint that is fixed rather than palette-derived — `moss`'s accent is a green.
- **A staggered card entrance** (`anim::Entrance`), a pure function of the clock with no per-card state. The coverflow turns cards on their *real* Y-rotation — Apple had to fake theirs with a cos-squeeze because SwiftUI can't snapshot a rotated layer to glass. The library waits for artwork before playing, or 400 ms, so it never fans open a rank of grey placeholders.
- **The transition became an interruptible spring.** Back mid-push retargets *the same spring* to 0: velocity carries, so the screen turns around and goes back. A tween can't do that. Holding B to back out of a deep stack no longer stutters at every level. `console-vectors.json` gains a `motion_spring` block at version 2 pinning **parameters, not samples** (springs are integrator-dependent); the v1 block stays, marked `$deprecated`, because Android still reads it.
- **A Reduce motion setting** — freezes the backdrop, flattens push/pop, drops recoil travel, keeps every haptic.
## Part B — look
- **Recede by draining colour, not by painting black over it.** Saturation −42 %, brightness −24 %, as one colour matrix; the veil drops to half. Alpha is deliberately untouched, with a test saying so — coverflow side cards overlap.
- **A focused-tile halo**, palette-tinted, and a lit top edge on glass.
- **OS marks at last.** `HostRow.os` had been plumbed since the model landed, waiting on a glyph set that already existed: 13 licensed masters in `assets/os-icons`, resolved by the shared `os_icon_tokens`. The table is *generated* (`scripts/gen_os_mark_table.py`), so a new master reaches the console for free.
- **A battery pip** on the controller chip, and a **Rescan tile** sending the `ConsoleCmd::Probe` that had existed unsent by any screen.
## Part C — the library
- **`platform` is plumbed** (it was dropped at the console's boundary) and a pure `collate` module carries the policy. Two rules earn their tests: a platform-less game buckets under its *store*, never "Unknown" (a Steam library would collapse into one heap), and A–Z folds the leading article ("The Witcher 3" belongs under W).
- **A grid arrangement** with its own cursor math — row ends refuse like the shelf, but a short tail row *clamps*, because a ragged tail is a layout accident, not a boundary you chose to hit.
- **Art eviction.** There was none; every cover ever scrolled past stayed decoded. Posters are frame-stamped as *drawn* — an LRU keyed on arrival would evict the six the cursor is sitting among.
- **Collections** — the ask, verbatim: group by platform, walk them, pick PS3, see its games. Filtering is index-level, so the art pump and fetch flow never learn collections exist.
## Verification
Every commit gated green in the `pf-gtkflow` container — fmt, `clippy --all-targets -D warnings`, a plain build, and tests. This matters more than usual: `cargo check -p pf-console-ui` on macOS compiles *nothing*. Tests went **83 → 122** in the crate; `pf-client-core` stays 213.
**The screenshot pass caught a defect nothing else could.** `recede_matrix` ported Apple's `.brightness(-0.24·d)`, which is dark-mode arithmetic. On the six pale palettes, darkening a card *raises* its contrast against a light ground — the unfocused tile became the heaviest thing on screen. It now recedes toward the ground, taking direction from the scrim. 122 green tests and every dark-palette render had agreed it was fine.
## Where the plan was wrong about the code
Resolved in favour of what is actually there, each explained in its commit:
1. The OS chain is **slash**-separated and resolves most-specific-**first** with brand aliases — not a `;`-chain, first-known-wins. Using the shared resolver makes the console right about 13 distros instead of the 4 scoped.
2. The hint bar was **already** a glass pill; what it lacked was the shared glass recipe, so it was the one floating surface ignoring the palette.
3. There is **no existing pad-refresh cadence**, and `pad_info` is deliberately open-free while SDL reports power only for an *open* device. Battery reads from the one pad the service already holds open, on a 15 s poll.
4. Apple's `ConsoleVectorsTests` covers the palette table only — it never tested motion, so only Rust and Android need the v1→v2 migration.
5. The plan put View on **X**, but X is already Copy link and the grid needs Up/Down for rows — one free button, two features. Rather than evict a shipped binding, `library_view` became a settings row beside the palette; **Y** stays for Collections.
`Progress` is deleted: the transition was its last caller, and this crate lints dead code as an error.
## Not done
**On-glass verification is owed** and needs hardware: `--browse` on a Linux box with `clients/session/fixtures/mixed-platform-library.json`, and a Deck frame-graph check of the recede/halo — the plan's named O(visible cards) cost. Apple/Android ports of grid/collections/sort remain out of scope as planned; the `collate` module is written to be their portable spec.
No wire or ABI changes: `platform` was already on the wire, battery is local SDL, and all persistence is `trust::Settings` fields with lenient serde defaults.
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.
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.
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.
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.
`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.
`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.
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.
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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Implements
punktfunk-planning/design/console-ui-overhaul.mdin full — all 14 work packages, sliced as the plan's eight PRs plus one fix. ~4,800 lines across 25 files.Part A — motion
anim.rsgrows aSpringSpectable with aconst kc()performing SwiftUI's response/damping → k/c conversion; a test pins it againstTRAY_K/TRAY_C, which were hand-derived from exactly that arithmetic.moss's accent is a green.anim::Entrance), a pure function of the clock with no per-card state. The coverflow turns cards on their real Y-rotation — Apple had to fake theirs with a cos-squeeze because SwiftUI can't snapshot a rotated layer to glass. The library waits for artwork before playing, or 400 ms, so it never fans open a rank of grey placeholders.console-vectors.jsongains amotion_springblock at version 2 pinning parameters, not samples (springs are integrator-dependent); the v1 block stays, marked$deprecated, because Android still reads it.Part B — look
HostRow.oshad been plumbed since the model landed, waiting on a glyph set that already existed: 13 licensed masters inassets/os-icons, resolved by the sharedos_icon_tokens. The table is generated (scripts/gen_os_mark_table.py), so a new master reaches the console for free.ConsoleCmd::Probethat had existed unsent by any screen.Part C — the library
platformis plumbed (it was dropped at the console's boundary) and a purecollatemodule carries the policy. Two rules earn their tests: a platform-less game buckets under its store, never "Unknown" (a Steam library would collapse into one heap), and A–Z folds the leading article ("The Witcher 3" belongs under W).Verification
Every commit gated green in the
pf-gtkflowcontainer — fmt,clippy --all-targets -D warnings, a plain build, and tests. This matters more than usual:cargo check -p pf-console-uion macOS compiles nothing. Tests went 83 → 122 in the crate;pf-client-corestays 213.The screenshot pass caught a defect nothing else could.
recede_matrixported Apple's.brightness(-0.24·d), which is dark-mode arithmetic. On the six pale palettes, darkening a card raises its contrast against a light ground — the unfocused tile became the heaviest thing on screen. It now recedes toward the ground, taking direction from the scrim. 122 green tests and every dark-palette render had agreed it was fine.Where the plan was wrong about the code
Resolved in favour of what is actually there, each explained in its commit:
;-chain, first-known-wins. Using the shared resolver makes the console right about 13 distros instead of the 4 scoped.pad_infois deliberately open-free while SDL reports power only for an open device. Battery reads from the one pad the service already holds open, on a 15 s poll.ConsoleVectorsTestscovers the palette table only — it never tested motion, so only Rust and Android need the v1→v2 migration.library_viewbecame a settings row beside the palette; Y stays for Collections.Progressis deleted: the transition was its last caller, and this crate lints dead code as an error.Not done
On-glass verification is owed and needs hardware:
--browseon a Linux box withclients/session/fixtures/mixed-platform-library.json, and a Deck frame-graph check of the recede/halo — the plan's named O(visible cards) cost. Apple/Android ports of grid/collections/sort remain out of scope as planned; thecollatemodule is written to be their portable spec.No wire or ABI changes:
platformwas already on the wire, battery is local SDL, and all persistence istrust::Settingsfields with lenient serde defaults.platformto the shelf and give it a collation module c804a0dc9f`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.