Sixteen defects reported after the first on-glass test of the reworked console UI on a Steam Deck, plus four more found while testing this branch on the same Deck. Every fix is gate-verified in the Linux container (fmt, clippy -D warnings, a plain non-test build, 171 tests) and the visual ones are confirmed in rendered 1280×800 screenshots — Deck-native, so what the shots show is what the panel shows.
Three of the defects were API defaults rather than mistakes, and those are the ones worth reading:
Nothing was anti-aliased.SkPaint defaults fAntiAlias to false, so Paint::new(colour, None) — the terse, natural way to write a Skia draw call — silently produces hard-stepped geometry. 70 paint sites, 15 anti-aliased, in an exact pattern: paints that got mutated for another reason collected a set_anti_alias(true) in passing; every inline argument did not. The console shipped smooth 1 px rings on top of jagged fills. Text was never affected (glyph AA is SkFont::Edging), which is why labels looked right and only shapes looked chewed.
Receded cards clamped to literal #000000. The brightness term was additive in sRGB and skia-safe's colour matrix clamps, so any channel below the offset clipped to zero. Verified by pixel-sampling the shipped render: a hard step from the aurora straight to black, no gradient.
The grid ran at 1–2 fps when full. Posters were lazy Image::from_encoded handles at full source resolution with mip chains — ~2.75 MiB of GPU each, 35–42 on screen, against a 64 MiB budget. Skia purged and re-decoded most posters from JPEG every frame, on the render thread. A cliff, not a slope — exactly as reported, smooth until the screen filled.
What's in it
#
Issue
Root cause
1
Library skeletons → spinner
Skeleton and entrance flash were one wait split across two phases
2
Cards rendered before animating in
"Not yet armed" and "already finished" were both None, both read as settled
3
Unfocused cards far too dark
Additive brightness in a clamping colour matrix
4
Every card glowed
BlurStyle::Normal kept the source shape, flooding the card and its neighbours
5, 12
Headings and tab bars centered
Both lived in one place each; now leading at a shared 24 dp inset
6
Bespoke "Copy link" shortcut
Generalised the host menu into the console's one context menu
7
"Show game library" toggle
Removed the field, not just the row — see below
8
Weird line in the toast
Redundant: the mark beside it already carried the kind, in the same tint
9
Option fields all flickered
One list-wide slip spring, crossfade alpha ungated by row identity
10
Select animation broken
Both values placed from one shared width, unclipped — plus 3 more defects in the same block
11
Stagger not visible
Raised with cap scaled to match, since cap/staggeris the number of visible steps
13
View types buried in settings
Live switchable from a focusable bar in the library
14a
Grid focus unreliable
Renderer and navigator each had their own idea of the grid's shape
14b
Grid ~1–2 fps when full
GPU texture-cache thrash → per-frame JPEG re-decode
15
Collection cards looked bad
Deck of covers with depth, hard contact plates, no blurs
16a
Sorting invisible
Both arrangements obeyed it; neither showed it
16b
Collections behind a second click
A setting; the shelf hands over once there's more than one group
Plus four found while testing this branch: collection covers never loading, the Launchers collection fanning blank cards, the grid's top row clipped inside a collection, and "Edit…" flashing the host list before the editor arrived.
Two things a reviewer should look at closely
The library toggle removal is deliberately wider than the issue. The console never read library_enabled — its row changed nothing there. What the flag actually gated was the GTK and WinUI clients, and its stored default is false. Deleting only the console row would have left every desktop user's library hidden while removing the last in-Gaming-Mode way to switch it on. So the field goes from pf-client-core entirely. No migration code is needed and that is by construction, not luck: Settings is #[serde(default)] with a #[serde(flatten)] extra map, so a stored "library_enabled": false parses into extra, round-trips untouched, and is ignored. A test pins that contract. At the GTK and WinUI menu sites the flag is replaced by the pairing predicate rather than dropped — unconditional would have promoted a documented latent bug (a fetch that cannot authenticate) into the default experience.
Apple and Android keep their own toggles for now, deliberately: both already default true, so nobody loses the library by our not touching them, and their removal is a follow-up rather than a rushed edit across two more UI frameworks here.
Verification, and its limits
Gate-green in the container, including a separate leg for punktfunk-client-linux that the standard console gate does not cover — that leg was necessary, since removing the setting orphaned a struct field that --all-targets hides and a shipping build raises. All 29 console screens re-rendered and eyeballed at both palette poles.
Three honest gaps:
The WinUI edits have never been compiled.punktfunk-client-windows is cfg(windows); it builds on neither the Mac nor the Linux container. Read-verified only, unproven until a Windows runner sees them.
#14b's frame rate cannot be measured here. The arithmetic says the working set falls from ~100 MB to ~30 MB against a now-160 MB budget, which removes the eviction cliff. Whether the slideshow is gone needs the Deck.
The launcher-card and collection-grid fixes have no screenshot witness — the dump's collection scenes are ROM entries across four platforms, so neither path is exercised by a render. Both are covered by property tests instead.
Cover-art sampling changed from nearest-neighbour to linear with mipmaps; it is verified arithmetically but wants real posters on a real panel to confirm it did not trade one artefact for softness.
Sixteen defects reported after the first on-glass test of the reworked console UI on a Steam Deck, plus four more found while testing this branch on the same Deck. Every fix is gate-verified in the Linux container (fmt, clippy `-D warnings`, a plain non-test build, 171 tests) and the visual ones are confirmed in rendered 1280×800 screenshots — Deck-native, so what the shots show is what the panel shows.
Three of the defects were API defaults rather than mistakes, and those are the ones worth reading:
- **Nothing was anti-aliased.** `SkPaint` defaults `fAntiAlias` to false, so `Paint::new(colour, None)` — the terse, natural way to write a Skia draw call — silently produces hard-stepped geometry. 70 paint sites, 15 anti-aliased, in an exact pattern: paints that got *mutated* for another reason collected a `set_anti_alias(true)` in passing; every inline argument did not. The console shipped smooth 1 px rings on top of jagged fills. Text was never affected (glyph AA is `SkFont::Edging`), which is why labels looked right and only shapes looked chewed.
- **Receded cards clamped to literal `#000000`.** The brightness term was additive in sRGB and skia-safe's colour matrix clamps, so any channel below the offset clipped to zero. Verified by pixel-sampling the shipped render: a hard step from the aurora straight to black, no gradient.
- **The grid ran at 1–2 fps when full.** Posters were lazy `Image::from_encoded` handles at full source resolution with mip chains — ~2.75 MiB of GPU each, 35–42 on screen, against a 64 MiB budget. Skia purged and **re-decoded most posters from JPEG every frame, on the render thread**. A cliff, not a slope — exactly as reported, smooth until the screen filled.
## What's in it
| # | Issue | Root cause |
|---|---|---|
| 1 | Library skeletons → spinner | Skeleton and entrance flash were one wait split across two phases |
| 2 | Cards rendered before animating in | "Not yet armed" and "already finished" were both `None`, both read as *settled* |
| 3 | Unfocused cards far too dark | Additive brightness in a clamping colour matrix |
| 4 | Every card glowed | `BlurStyle::Normal` kept the source shape, flooding the card and its neighbours |
| 5, 12 | Headings and tab bars centered | Both lived in one place each; now leading at a shared 24 dp inset |
| 6 | Bespoke "Copy link" shortcut | Generalised the host menu into the console's one context menu |
| 7 | "Show game library" toggle | Removed the *field*, not just the row — see below |
| 8 | Weird line in the toast | Redundant: the mark beside it already carried the kind, in the same tint |
| 9 | Option fields all flickered | One list-wide slip spring, crossfade alpha ungated by row identity |
| 10 | Select animation broken | Both values placed from one shared width, unclipped — plus 3 more defects in the same block |
| 11 | Stagger not visible | Raised with `cap` scaled to match, since `cap/stagger` *is* the number of visible steps |
| 13 | View types buried in settings | Live switchable from a focusable bar in the library |
| 14a | Grid focus unreliable | Renderer and navigator each had their own idea of the grid's shape |
| 14b | Grid ~1–2 fps when full | GPU texture-cache thrash → per-frame JPEG re-decode |
| 15 | Collection cards looked bad | Deck of covers with depth, hard contact plates, no blurs |
| 16a | Sorting invisible | Both arrangements obeyed it; neither showed it |
| 16b | Collections behind a second click | A setting; the shelf hands over once there's more than one group |
Plus four found while testing this branch: collection covers never loading, the Launchers collection fanning blank cards, the grid's top row clipped inside a collection, and "Edit…" flashing the host list before the editor arrived.
## Two things a reviewer should look at closely
**The library toggle removal is deliberately wider than the issue.** The console never read `library_enabled` — its row changed nothing there. What the flag actually gated was the GTK and WinUI clients, and its stored default is `false`. Deleting only the console row would have left every desktop user's library hidden while removing the last in-Gaming-Mode way to switch it on. So the field goes from `pf-client-core` entirely. No migration code is needed and that is by construction, not luck: `Settings` is `#[serde(default)]` with a `#[serde(flatten)] extra` map, so a stored `"library_enabled": false` parses into `extra`, round-trips untouched, and is ignored. A test pins that contract. At the GTK and WinUI menu sites the flag is replaced by the *pairing* predicate rather than dropped — unconditional would have promoted a documented latent bug (a fetch that cannot authenticate) into the default experience.
**Apple and Android keep their own toggles for now**, deliberately: both already default true, so nobody loses the library by our not touching them, and their removal is a follow-up rather than a rushed edit across two more UI frameworks here.
## Verification, and its limits
Gate-green in the container, including a separate leg for `punktfunk-client-linux` that the standard console gate does not cover — that leg was necessary, since removing the setting orphaned a struct field that `--all-targets` hides and a shipping build raises. All 29 console screens re-rendered and eyeballed at both palette poles.
Three honest gaps:
- **The WinUI edits have never been compiled.** `punktfunk-client-windows` is `cfg(windows)`; it builds on neither the Mac nor the Linux container. Read-verified only, unproven until a Windows runner sees them.
- **#14b's frame rate cannot be measured here.** The arithmetic says the working set falls from ~100 MB to ~30 MB against a now-160 MB budget, which removes the eviction cliff. Whether the slideshow is gone needs the Deck.
- **The launcher-card and collection-grid fixes have no screenshot witness** — the dump's collection scenes are ROM entries across four platforms, so neither path is exercised by a render. Both are covered by property tests instead.
Cover-art sampling changed from nearest-neighbour to linear with mipmaps; it is verified arithmetically but wants real posters on a real panel to confirm it did not trade one artefact for softness.
Reported from a Steam Deck, where a 1280x800 panel gives sub-pixel error nowhere
to hide. Two independent causes, both API defaults rather than anything the
overhaul got wrong on purpose.
GEOMETRY. `SkPaint`'s default constructor sets `fAntiAlias = false`, and
skia-safe's `Paint::new(colour, None)` is that constructor with a colour on it.
So the terse, natural spelling of a draw call — `&Paint::new(c, None)` passed
inline as an argument — silently produces hard-stepped edges. The crate had 70
paint sites and 15 of them were anti-aliased, in an exact pattern: paints that
got MUTATED for some other reason (a stroke style, a width) picked up a
`set_anti_alias(true)` on the way past, and every inline argument did not. The
console therefore drew smooth 1 px rings on top of jagged fills, which looks
worse than no ring at all — the smooth edge gives the eye a reference for how
wrong the fill is. The padlock on a host tile had a stair-stepped body and a
clean shackle; the online pip was a blocky octagon inside a soft blurred glow.
Text was never affected — glyph anti-aliasing is `SkFont::Edging`, which
defaults to on — which is why the labels always looked right and only the shapes
looked chewed.
COVER ART. `Canvas::draw_image_rect` samples with `SamplingOptions::default()`,
which is `FilterMode::Nearest` and `MipmapMode::None` — no filtering at all.
Every poster in the library is minified hard, a 600x900 cover into a ~180x270
Deck cell, and nearest-neighbour minification simply discards rows and columns:
box-art lettering breaks up and edges crawl as the shelf scrolls. That reads as
"low resolution" whatever the panel is.
The fix is to take the choice away from the call site. `theme::fill`, `stroke`,
`shaded`, `shaded_stroke` and `layer` are now the only sanctioned constructors,
all 70 sites go through them, and `theme::art_sampling` (linear + linear
mipmaps) covers the three image draws.
`shaded` earns its place the hard way. Skia modulates a shader's output by the
PAINT'S alpha, so the obvious way to build a gradient paint — start transparent,
let the shader supply everything — draws nothing whatever. Not dimmer: absent.
`Paint::default()` happened to be opaque black, which is the only reason the
console's gradients and the SkSL aurora never met the rule; building them from a
transparent placeholder erased the backdrop, the badge, the vignette and the
skeleton sheen in one go, and all 124 tests still passed, because a test that
only renders a frame cannot tell a missing layer from a dark one. It was caught
by looking at the pixels. `shaded` is opaque by construction so the trap cannot
be set again.
Three tests, each pinning a different half:
- `geometry_is_anti_aliased` draws one circle on a half-pixel centre and counts
partially-covered boundary pixels. Asserted on a shape rather than a screen
because a full render is a poor witness — one jagged corner is a few dozen
pixels in 1.02 M. Backed out, it reports exactly 0.
- `a_shaded_paint_is_opaque_enough_to_draw` covers the alpha rule above.
- `paints_are_built_by_the_theme_constructors` scans the crate's own source,
because the aliased spelling is the NATURAL one and will be written again by
whoever adds the next draw call. Its needles are built with `concat!` so the
scan does not match itself, which it did on the first run.
All three were verified to fail with the fix backed out, not merely to pass.
Reported from a Steam Deck: unfocused cards look "horrible — seemingly darkened,
and maybe even some sort of filter, so dark they have way too much contrast",
and "seemingly every card glows".
THE RECEDE. `recede_matrix` put its brightness term in the colour matrix's OFFSET
column, i.e. additive in sRGB-encoded space, and skia-safe's `matrix_row_major`
clamps. At RECEDE_BRIGHTNESS 0.24 that is -61/255 levels, so any channel below
0.24 clipped to zero. The coverflow's card face is a hardcoded #1E1E25, whose
post-desaturation channels are ~0.12 — every one of them lands negative. Sampling
the shipped screenshot confirms it: the interior of every side card reads exactly
(0,0,0), with a hard step from the aurora straight to black. Not a dim, a clamp.
Worse, `prox` saturates at 1.0, so cards two through five all receive the
identical treatment — the side stack was one black slab with no depth in it,
which is why it read as a filter rather than as distance.
The brightness term is now a multiplicative lerp toward the ground, so it cannot
clip at either pole and a receded poster keeps its full tonal range instead of
crushing everything below 61/255 flat. The palette-aware DIRECTION is unchanged —
receding still means "toward the field", which is down on a dark palette and up on
a pale one, and that was already right.
The veils were the other half. Both were hardcoded black while the matrix had
learned to respect the palette, so on the six pale palettes the veil spent its
alpha cancelling the lift the matrix had just applied — a grey cast over an
already-desaturated card, which is mud. They now wash toward `theme::shade`, which
is black on a dark field and white on a pale one, so all three mechanisms finally
point the same way. Their magnitudes come down as well: three stacked effects were
each tuned as though it were the only one.
THE HALO. It was never drawn for more than one card — all four call sites were
already correctly gated, and adding more gating would have been a no-op. The
"every card glows" reading came from its SHAPE: `BlurStyle::Normal` keeps the
source shape, so a 60 dp accent corona filled the focused card's own interior,
bled through the translucent panel glass, and spilled across the 58 px gap onto
both neighbours — which is precisely what "the neighbours are glowing" looks like.
`Outer` subtracts the source shape, so the halo is now only the light spilling
past the card's edge, and its reach fits inside the gap.
On a pale palette the accent halo and the black drop shadow were both simply the
wrong colour against a bright ground; the halo now mixes toward the scrim and the
shadow lightens, inside the helpers, so neither call site has to know which
palette is up.
Verified by re-deriving every predicted value: a fully receded dark card lands at
#151518 against a focused #1E1E25 — one clear step back, zero clipping — where it
used to land at #000000.
Two reports against the settings screen, both landing in the same fifty lines of
`MenuList::render`: "the option fields all flicker when changing one option", and
"the select animation is broken in many ways, the biggest issue being text
flashing to the right of the field, completely outside — mainly with longer text".
THE FLICKER. A list has ONE slip spring and one `slip_prev` saying which row it
belongs to. The horizontal displacement correctly consults that identity; the
crossfade alpha derived from the same spring did not. So `gone` was computed from
the global spring once per row and applied to EVERY row's incoming value, blanking
the whole value column for a frame and fading it back over ~0.2 s. On a held
repeat the accumulate clamps past the crossfade's normaliser, so `gone` saturates
at 1.0 and every value sits at alpha 0 for several consecutive frames — the hard
flicker as reported. Labels were never touched, which is why it reads as the
values blinking while the labels stay put.
The existing test could not see this: it drives a ONE-row list, where the slipping
row is the only row, so an ungated alpha is indistinguishable from a gated one.
The new test uses a column of six.
THE ESCAPE. The value was placed from a single measured width shared by both the
outgoing and incoming strings, so a step between values of different lengths
teleported the outgoing text by the difference — and nothing clipped it to the
row, so a long value flew out past the right edge into open field. The field is
now fixed and each string is right-aligned by its OWN width, truncated before it
is placed rather than after, and the whole run is clipped to the row.
Three more defects in the same block, found while reading it and fixed here
because they are the same code:
- The slip armed on rows that cannot be adjusted at all. A/Confirm and a pointer
press arm unconditionally, so pressing a non-steppable row fabricated a slide on
a row that has no chevrons and nothing to step.
- Row identity was the index alone, which is not an identity across the per-frame
rebuild the settings screen does — a list whose rows change shape could carry a
slip onto whichever row inherited the index.
- A step that exactly cancels one in flight left the arming offset at zero, and
the crossfade divides by it. That is a NaN alpha, and a NaN alpha draws nothing.
The console's Interface tab still offered an enable/disable switch for the game
library, left over from when the feature was experimental. Removing it turned out
to be the opposite of a one-line deletion.
The console never read the setting. Its row rendered a value and flipped a bool,
and every library affordance in the console — the home Y hint, the Y press, the
`--browse <host>` deep link — is gated on PAIRING and nothing else. So the row
showed "Game library · Off" while the same shell handed you the library on Y. The
docs already said as much.
What the flag actually gated was the other two desktop shells, GTK and WinUI,
which hide "Browse library…" unless it is on — and its stored default is `false`.
So deleting only the console row would have left every user who never found this
switch with a hidden library in the desktop apps, while removing the last place in
Gaming Mode where it could be turned on. The letter of the request, and the exact
opposite of its intent.
So the field goes, not just the row: `trust::Settings::library_enabled` and all
four reads of it. No migration code and no serde alias is needed, and that is by
construction rather than by luck — `Settings` is `#[serde(default)]` with a
`#[serde(flatten)] extra` map, so a stored `"library_enabled": false` parses into
`extra`, round-trips untouched, and is ignored. Everyone who had it off now has
the library, and a downgraded binary still finds its old value under the same key.
A test pins that contract, since it is the whole reason this is safe.
At the two GTK and two WinUI menu sites the flag is replaced by the PAIRING
predicate rather than dropped for an unconditional item. The library fetch
authenticates with the paired identity, and GTK's saved cards include trusted-but-
unpaired hosts, so an unconditional menu entry would promote a documented latent
bug — a fetch that cannot authenticate — into the default experience on every
install.
Apple and Android keep their own independent toggles for now, deliberately: both
already default TRUE, so nobody there loses anything by our not touching them, and
their removal is a follow-up rather than a rushed edit across two more UI
frameworks in this change.
VERIFICATION. The console and GTK legs are gate-green in the Linux container
(clippy `-D warnings`, a plain non-test build, and the test suites), and the GTK
leg needed that plain build: deleting the read orphaned the struct field that held
the settings store, which `--all-targets` hides and a shipping build raises.
The WinUI edits are READ-VERIFIED ONLY — `punktfunk-client-windows` is
`cfg(windows)` and compiles on neither the Mac nor the Linux container, so they
are unproven until a Windows runner sees them.
Two unrelated reports, kept together only because they are two small files.
THE TOAST LINE. "It seems to have a weird line inside left to the text." The
toast's leading run was hairline, air, mark, air — and the hairline was pure
redundancy: the mark immediately to its right already carries the toast's kind, in
the same tint, from the same `ToastKind::look()`, and for the Info kind that mark
is already a circle. Two elements saying one thing, close together and at
different weights, read as a rendering seam rather than as meaning, which is
exactly what was reported.
So the line goes and the mark stays; nothing replaces it, because nothing was
lost. The leading pad is 13 dp rather than the pill's 16 because the mark's box is
wider than anything drawn inside it — a Dot inks 6.8 dp of a 13 dp cell — so a
symmetric pad would put the mark's optical left edge 17-21 dp in against a 16 dp
trailing pad, and the pill would sit visibly left-heavy. At 13 the optical leading
pad averages 16.4 against 16.0 trailing.
THE STAGGER. "Increase it, it's not visible enough." The nominal 0.07 s flattered
itself twice over. An item is fully opaque a third of the way through its window
and its ease is 89 % done by the same point, so its VISIBLE action is ~0.20 s of a
nominal 0.6 — and every surface culls hard, so the number of staggered steps a
user can actually see is 2 on the home carousel and 5 in the coverflow. The whole
staggered event finished in 0.14 s of visible spread on Home, with 1.8 frames
between neighbours. Below the threshold where a sequence reads as a sequence at
all; it reads as one arrival with soft edges.
`stagger` and `cap` move together, because `cap / stagger` IS the number of
staggered steps: raising the stagger alone would fan fewer cards further apart and
land the rest in a block, which is the complaint, not the cure. `window` is
untouched — the individual card's feel was never the problem, the reduced-motion
crossfade path is defined in terms of it and stays byte-identical, and it is the
one entrance number the Apple client also spells out.
Nothing waits on this: the focused item has delay zero and still lands at
`window`, and no input path consults the entrance at all.
CROSS-CLIENT. These values are NOT in `console-vectors.json`, so no Rust, Android
or Apple test pins them — but Apple hand-copies the same two numbers in
`GamepadCarousel.swift`, in precisely the situation that shared file exists to
prevent. They are mirrored here so the desktop and Apple consoles do not silently
disagree about the same named gesture. Android has no card entrance at all.
Known and queued, not overlooked: the grid fans on the LINEAR cell index, so with
the new ratio its fan exhausts partway through the first row of a seven-wide grid
and everything after starts together. The fix belongs with the grid's own layout
work and is being made there.
Three reported defects, landing as one commit because they rewrite the same file
and each one's fix is visible in the others' code. In order of how much they hurt:
================================================================================
=== THE SLIDESHOW ===
The library grid was re-decoding most of its posters every frame
Reported from a Steam Deck: the grid "horribly lags, almost going down to 1-2fps
by feeling — looks like a slideshow". The decisive detail was in the same
sentence: "the lag only starts when the whole screen is filled with cards, at the
top or the bottom it seems to run mostly smooth". A cliff, not a slope — which
rules out per-card drawing cost and points at a budget being crossed.
It was the GPU resource cache. `Image::from_encoded` builds a DEFERRED image:
nothing is decoded at fetch time, the only persistent state is the encoded bytes
plus a generator, and Skia's own documentation says a purge means the next draw
must RE-DECODE. Nothing downscaled the fetched art, so each poster was its source
600x900 (SteamGridDB portraits reach 1000x1500) and `art_sampling` asks for a mip
chain on top — about 2.75 MB of GPU memory each. A filled grid at Deck scale draws
35-42 covers, so the working set was 96-115 MB against a 64 MB budget. Every
submit evicted a third of the screen, and the next frame decoded it again from
JPEG on the render thread. A half-filled screen fits, and is smooth.
The field comment claiming the posters were decoded once and merely uploaded
lazily was simply wrong, and is corrected.
Three changes, in order of how much they matter:
- Decode ONCE, at the size actually sampled, with mipmaps baked in. Arriving bytes
are decoded in `sync` and scaled into a box twice the grid cell's width at the
live scale — so a purge now costs an upload rather than a decode, and the
resident cost falls from ~2.75 MB to ~0.72 MB a poster. Twice the cell, not one:
the coverflow shares this cache and draws far larger cards, and a texture sized
for a grid cell would be visibly soft there. It never enlarges, and it keeps the
original if Skia refuses the scale.
- Bound the work per frame. Without a cap this fix would only move the stall: the
library arrives in one burst, and decoding a whole shelf on one frame is the
same hitch in a different place.
- Raise the cache ceiling to 160 MB. A ceiling, not an allocation — Skia grows
into it under demand, and the demand is now ~30 MB for a full grid. What this
buys is headroom, so the eviction cliff cannot be reached again by a larger
panel or a bigger library.
The one arrangement that can still crowd the budget is a 4K panel fed 1000x1500
portraits, where full resolution genuinely is what gets drawn — and that is a
desktop GPU by the time it happens.
NOT VERIFIED ON GLASS. The container gate proves this compiles, lints and passes
144 tests; it cannot measure a frame rate. The claim that the slideshow is gone
needs the Deck.
=== THE FOCUS ENGINE ===
The grid renderer and its navigator disagreed about the grid's own shape
Reported from a Steam Deck: "the focus engine does not work reliable, especially
when jumping from the launcher grid down to the games grid."
That "especially" is the whole diagnosis. The grid is laid out in two sections —
launchers first, then games restarting on a fresh row — and `draw_grid` knew that,
while `grid_step` was plain uniform-grid arithmetic on a flat index (`cursor %
cols` for the column, `± cols` for a row). Two independent descriptions of one
layout, which agree only when the launcher count happens to be an exact multiple
of the column count. Every other library is a grid where Down lands somewhere the
user did not point at, and the crossing between the two sections is where the
divergence is guaranteed rather than merely likely.
So the fix is not to special-case the crossing. Both sides now build the same
`GridShape` — columns, length, split — and ask IT where a cell is. They cannot
disagree, because there is no second description left to drift.
On top of that one rule: horizontal moves walk their own row and refuse at that
row's true ends (a short last row no longer wraps into open space), vertical moves
change row only, and the column is remembered rather than recomputed — a step
sideways chooses a column, a step down merely borrows it, so walking down a ragged
grid no longer drifts leftward one row at a time. A stale out-of-range cursor
reads as the nearest real cell instead of compounding.
`grid_cols_last` was seeded at 4 while a Deck draws 7, so the first press after
entering the grid moved by the wrong stride. It is an Option now, written only by
a frame that actually drew, and navigation declines rather than guessing before
then.
Also here, because it is the same layout code: the entrance fans on the LINEAR
cell index, so with the wider stagger the fan was exhausting partway through the
first row of a seven-wide grid and every cell after it started simultaneously. The
grid now fans on |row - anchor row| + |col - anchor col|, i.e. diagonally out from
the focused cell, which is what the stagger looks like everywhere else.
=== THE FLASH AND THE SKELETON ===
The shelf showed itself finished before animating in, and the skeleton went with it
Two reports against the library, and they turned out to be one wait split across
two phases.
"The libraries have skeletons — I don't like that, remove them and replace them
with a simple loading spinner." And: "the cards render before animating in, so
they instantly appear, get hidden again, and then get animated in."
THE FLASH. The draw code asked one question — is there an entrance? — of a state
that has three answers. `None` meant both "not armed yet" and "already finished",
and both fell through to SETTLED, i.e. fully arrived. Meanwhile arming is
deliberately deferred until a poster decodes or 400 ms elapse, so that the console
does not fan open a rank of grey placeholders. Put together: the finished shelf
was drawn at full opacity for up to 400 ms, then the entrance armed at the current
clock, every card blinked to zero, and the arrival played. Not one bad frame —
most of half a second of finished shelf, then a blink.
It reproduces whenever the game list arrives before its art, which is the normal
case against a real host. The collections drill-in hit it every single time,
because it starts with no decoded art at all. And the fake-library dev hook MASKS
it, by pushing art before the list — so the one tool most likely to be used to
check this shows a shelf that looks perfect.
The three states are now distinguished in one place, and nothing is drawn until
the entrance's first real frame.
THE SKELETON. Which is the same wait. The skeleton existed for the phase where the
list has not arrived; the flash existed because the phase where the ART has not
arrived had nothing to draw at all. One spinner now covers both, with no seam
between them — the shared arc the connect and wake takeovers already use, at their
size and their spacing, rather than a second spinner with its own opinions. The
hit-test geometry is cleared on those frames so a press cannot land on a card that
was never drawn.
One stated regression: the skeleton froze its sheen under reduced motion, and the
spinner does not freeze, because a frozen spinner reads as a hung application. The
crate's three other spinners behave the same way.
CONTRAST, found while in here and fixed because it is the same drawing. The
art-less poster placeholder used two HARDCODED faces under palette-derived ink. On
the six pale palettes the foreground is a near-black tinted toward the ground, so
a game's monogram over a fixed #1E1E25 face measured 1.03:1 — not dim, absent.
There is now one `theme::card_face`, the accent mixed into an opaque base the
field's own lean chooses, which keeps the launcher-versus-game distinction in the
tint. Measured across all thirteen palettes: no worse than 7.6:1 for a game's
monogram, 6.0:1 for a launcher's label. The face has to stay OPAQUE — coverflow
side cards overlap, and a translucent one would show its neighbour through it —
which is why the collections badge's recipe could not simply be reused.
"The collection cards look bad — improve them, maybe giving the 3 preview covers
some depth, aligning them in a fancy way."
The three covers used to sit in a flat row at 42 % overlap, on a tile with its own
metrics, with the title placed after whatever the covers happened to occupy — so a
tile whose art had not decoded put its title somewhere else than the tile beside
it, and "PlayStation 3" clipped to "Play…" on the ones that had.
The tile is now home's tile to the pixel. The module comment claims a collection is
"a place you go, exactly like a host is", and that claim is only checkable if the
two are the same tile. Title and count take the bottom-left rail at full inner
width, so they start in the same column on every tile whatever decoded; the kind
caption takes the top-right, where a host tile keeps its status cluster.
The covers become a DECK: each card further back is smaller, higher, further right
and turned a few more degrees, drawn back to front so the sort-first game lands on
top. Four cues rather than one, because any single one is ambiguous at couch
distance — a smaller card could be a smaller cover, a lifted one a taller cover —
but smaller AND lifted AND turned is unmistakably "behind". Under each card is a
hard contact plate, grown on every side and dropped down-right rather than merely
offset, because the deck fans UP: a plain offset plate would shadow the down-right
seams and leave the top seam, where each card actually lies on its neighbour's
face, with nothing.
No blurs. A blurred shadow per cover would be three mask filters a tile and
fifteen across a live strip, where the whole crate has three blur call sites — and
a sibling change in this same series exists because per-card GPU work took the
grid to a slideshow. At 1280x800 from a sofa a 3 px hard band and a 3 px soft one
are the same mark.
It degrades: covers compact into the front slots, empty slots become ghosts with
no plate (an absent card casts no shadow), and a group with nothing decoded shows
a monogram badge. A one-title group never pretends to be a stack of three.
The badge's face was a hardcoded near-black under palette-derived ink, which on
the pale palettes put its own initials at 1.03:1 — invisible. It is accent-derived
now, measured at 4.9:1 or better across all thirteen.
Two things ride along because they are the same drawing:
- The per-tile layer had NO bounds and was raised unconditionally, so each tile
allocated a full-SURFACE offscreen and composited it back — including the
focused tile, whose alpha is 1 and whose recede is 0, i.e. a layer that does
nothing at all. It is bounded now, and skipped when it has nothing to carry. The
same change is made to the home carousel, which had the same defect. The bounds
must clear the halo and the shadow, which are drawn INSIDE the layer; bounding
to the bare tile would clip both away.
- The drill-in handed its new shelf no art, while sitting on the decoded posters it
had just fanned on the very tile that was pressed. Since the shared art queue is
drained by whoever reads it first, that shelf then waited out the full art
deadline and showed monogram cards permanently. The snapshot is handed down now,
the way it is already handed up.
Screenshot scenes were added for this: there were none for collections at all, so
none of the above had a witness. The ordering in them is load-bearing — art must
be pushed AND decoded over several frames BEFORE the drill-in is opened, because
the art snapshot is taken at that instant, and a deck of covers photographed too
early looks exactly like a deck that was never built.
"The headings: they should be left aligned like on the other platforms, not
centered." And: "the tab bars, also align them left."
Both turned out to live in exactly one place each, which is why this is a
constant and two call sites rather than the eight-screen sweep it sounds like:
every screen's title is drawn by ONE call in the shell's layer paint, from the one
`Screen::title` match, and every strip is one `TabStrip::render` with the centring
on a single line. Nothing else in the crate draws a heading.
`theme::EDGE_INSET` is 24 design units, and it is not a new number. Apple pairs
`.padding(.horizontal, 24)` with the comment "Leading, like a console section
heading — centred read as a floating label"; Android names the same value
`ConsoleEdgeInset = 24.dp`. This console ALREADY used 24 on its right edge for the
controller chip, so the top band becomes symmetric instead of gaining a second
number — the chip's literal is now the same constant, so the symmetry is visible
in the source rather than a coincidence.
A PRE-EXISTING BUG falls out of doing this properly. The centred title was drawn
into a box spanning 15 %..85 % of the width — 192..1088 at 1280 — while the
controller chip begins at x≈1002. They already overlapped; only short strings hid
it. Left-aligning without a width budget would have run a long library title
straight under the chip, so the title now takes a budget computed from the chip's
measured width, and the chip's arithmetic is hoisted into one function that both
callers read. They cannot drift apart again.
A real behaviour change rides along: the heading is one line and ellipsizes.
It used to WRAP, growing a second line downward into the content. Both other
clients cap it at one line with an ellipsis, and a heading that reflows the screen
under it is worse than a truncated one.
The explainer lines directly under a heading (add-host, pair, host-options) go
leading with it, and their width is capped against the row column rather than the
window — at 1280 the old cap was 922 dp, which left-aligned would have run under
the chip as well. Leaving those three centred beneath a left-aligned heading was
the one outcome that would have looked worse than changing nothing.
NOT a motion change: no `anim.rs`, no spring, and nothing in
`clients/shared/console-vectors.json`. The strip's indicator is seeded at rest on
its new position, so no pill animates in from where it used to be.
KNOWN AND DELIBERATE, pending a call: on settings, add-host, pair, host-options
and pin-hosts the heading and strip now sit at 24 while the ROW column is still
centred (620 wide, landing at 330 on a 1280 panel). That is precisely Apple's
shipped layout, and it is why it is what landed. Android instead puts the rows at
the same 24. Matching Android is one line in `MenuList::render`, but it moves five
screens' entire content column 306 px left at 1280 — and needs those screens'
bottom detail bands to go leading in the same pass, or they end up left-aligned
everywhere except their footers. That is a larger change than these two issues
asked for, so it is a separate decision rather than a silent one.
The last four of the reported issues, landing together because three of them
compete for the same six pad buttons and the fourth is what those buttons lead to.
THE CONTEXT MENU. "The gamepad UI has a shortcut just for Copy link — that's not
really thought through. Let's share the pattern of a context menu, which contains
the copy link action, and possible other coming actions down the road."
The console already had most of one: a saved host's ▲ menu. It is now the
console's ONE menu, generalised over a subject, and the library's X raises it for
the focused title instead of copying a link outright. Adding the next action is a
variant, a row in a list and a label — no new screen, no new binding, and the verb
is worded once for the whole console rather than once per surface.
X and not ▲ in the library, which is the one real design choice here: the grid
arrangement spends Up on row navigation, so ▲ would open a menu in the shelf and
move the cursor in the grid — one gesture meaning two things inside one screen.
The count of bindings does not grow; only the meaning of the one X already had.
A companion defect fell out of it: the hint bar's ▲ entry was not in the pointer
click map, so the Options hint on home was inert for mouse and touch and a host's
link could not be copied without a pad.
THE VIEW AND SORT BAR. "The library view types are buried deep in the settings —
they should be live switchable directly in the library", and "the sorting should
be visible in the actual game views, both the coverflow and the grid, not only in
the collection view".
Both are surfaced in the field rather than on buttons, because after the menu
takes X there are no buttons left: A, B, X, Y, L1 and R1 are all spoken for, and
`MenuEvent` carries exactly those six. A focusable bar shows the current view and
the current sort as VALUES — the sort was already being obeyed by both
arrangements and shown by neither, which is the actual complaint — and the view
switch persists to the same setting the Interface tab writes, so the two agree.
COLLECTIONS AS AN ENTRY. "Instead of hiding the collections view behind a second
click, make it an option in the settings — so if collections are enabled the user
directly gets the collections screen."
A setting, and the shelf hands over the moment it knows there is more than one
collection. The care went into the back stack: opened as the host's library there
is nothing underneath it, so it feeds itself from the poster queue the shelf would
have drained and offers the way to the whole library, where a drill-in from a
shelf has that shelf to fall back to. A library with one group never hands over,
because a collections screen listing a single tile is a press that buys nothing.
FOUR DEFECTS FROM THE DECK, reported against the build cut before this one:
- A collection's covers never loaded. `sync` cleared the decoded art on any list
it considered new, and a drill-in's first list is new by definition — so the
snapshot the collections screen hands down was wiped on the frame after it
arrived, and the shelf then had nothing to re-fetch because the queue those
bytes came from was drained long before. It now tells "the screen just mounted"
apart from "the library moved underneath it".
- The Launchers collection fanned blank cards. The fan carried ids and looked them
up as posters; a launcher has no poster and never will — its cover is a brand
mark drawn from its icon — so every card found nothing and drew the ghost that
means "not arrived yet". The fan carries the icon now, draws the launcher face
with the same recipe the shelf's own placeholder uses, and never queues a fetch
for art that does not exist.
- The grid's top row was cut off inside a collection. The top inset was spelled as
the LAUNCHERS heading band, so one term was doing two jobs; a collection filtered
to a single platform has no launcher prefix, the term went to zero, and row 0 sat
flush against the viewport clip with its focus scale, entrance lift and halo
sliced off. The inset is unconditional now and the heading band is not. The
unfiltered grid's arithmetic is unchanged.
- Choosing "Edit…" in a host's menu flashed the host LIST before the editor
arrived. A push paints the screen BENEATH the incoming one as its receding
layer, and a replace had already popped and dropped the screen being swapped
out — so "beneath" was that screen's parent, one level too far. The replaced
screen is carried through the transition now, and a reversed replace puts it
back, because that is the screen the user watched recede and return.
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.
Sixteen defects reported after the first on-glass test of the reworked console UI on a Steam Deck, plus four more found while testing this branch on the same Deck. Every fix is gate-verified in the Linux container (fmt, clippy
-D warnings, a plain non-test build, 171 tests) and the visual ones are confirmed in rendered 1280×800 screenshots — Deck-native, so what the shots show is what the panel shows.Three of the defects were API defaults rather than mistakes, and those are the ones worth reading:
SkPaintdefaultsfAntiAliasto false, soPaint::new(colour, None)— the terse, natural way to write a Skia draw call — silently produces hard-stepped geometry. 70 paint sites, 15 anti-aliased, in an exact pattern: paints that got mutated for another reason collected aset_anti_alias(true)in passing; every inline argument did not. The console shipped smooth 1 px rings on top of jagged fills. Text was never affected (glyph AA isSkFont::Edging), which is why labels looked right and only shapes looked chewed.#000000. The brightness term was additive in sRGB and skia-safe's colour matrix clamps, so any channel below the offset clipped to zero. Verified by pixel-sampling the shipped render: a hard step from the aurora straight to black, no gradient.Image::from_encodedhandles at full source resolution with mip chains — ~2.75 MiB of GPU each, 35–42 on screen, against a 64 MiB budget. Skia purged and re-decoded most posters from JPEG every frame, on the render thread. A cliff, not a slope — exactly as reported, smooth until the screen filled.What's in it
None, both read as settledBlurStyle::Normalkept the source shape, flooding the card and its neighbourscapscaled to match, sincecap/staggeris the number of visible stepsPlus four found while testing this branch: collection covers never loading, the Launchers collection fanning blank cards, the grid's top row clipped inside a collection, and "Edit…" flashing the host list before the editor arrived.
Two things a reviewer should look at closely
The library toggle removal is deliberately wider than the issue. The console never read
library_enabled— its row changed nothing there. What the flag actually gated was the GTK and WinUI clients, and its stored default isfalse. Deleting only the console row would have left every desktop user's library hidden while removing the last in-Gaming-Mode way to switch it on. So the field goes frompf-client-coreentirely. No migration code is needed and that is by construction, not luck:Settingsis#[serde(default)]with a#[serde(flatten)] extramap, so a stored"library_enabled": falseparses intoextra, round-trips untouched, and is ignored. A test pins that contract. At the GTK and WinUI menu sites the flag is replaced by the pairing predicate rather than dropped — unconditional would have promoted a documented latent bug (a fetch that cannot authenticate) into the default experience.Apple and Android keep their own toggles for now, deliberately: both already default true, so nobody loses the library by our not touching them, and their removal is a follow-up rather than a rushed edit across two more UI frameworks here.
Verification, and its limits
Gate-green in the container, including a separate leg for
punktfunk-client-linuxthat the standard console gate does not cover — that leg was necessary, since removing the setting orphaned a struct field that--all-targetshides and a shipping build raises. All 29 console screens re-rendered and eyeballed at both palette poles.Three honest gaps:
punktfunk-client-windowsiscfg(windows); it builds on neither the Mac nor the Linux container. Read-verified only, unproven until a Windows runner sees them.Cover-art sampling changed from nearest-neighbour to linear with mipmaps; it is verified arithmetically but wants real posters on a real panel to confirm it did not trade one artefact for softness.