Implements punktfunk-planning/design/android-console-ui-visual-refresh.md (WP1–WP9, all but WP8.4), plus one round of on-glass review on the Nothing Phone 3 that reshaped the visual half. Field-tested: the final build is running on the NP3.
The two named bugs (the reason the plan exists)
Settings rows no longer grow on focus. The focused row unfolded its description in place, shifting every row below the cursor on every D-pad step — while fighting the keep-focus-visible scroll whose target moved mid-animation. The description now lives in a floating band (portrait) or a side pane (landscape); rows are fixed-height. GamepadSettingsLayoutTest drives the real screen and asserts neighbouring-row bounds don't move.
The NP3's hole punch no longer covers content. All twelve console screens padded with systemBarsPadding(), which excludes WindowInsets.displayCutout — in landscape the punch is a side inset with no bar behind it. consoleSafeArea() = systemBars ∪ displayCutout, verified against the device's reported [162,0][0,72] insets. The home carousel deliberately stays full-bleed (insetting it clipped the fanned cards — worse than cards behind a camera).
The premium-feel work
The desktop's actual mesh backdrop, mesh_sksl ported to AGSL behind API 33+ (blob fallback below; compile failure falls back rather than crashing). Locale trap handled: shader constants format through Locale.ROOT or a German device emits 0,075 — a syntax error.
The desktop's push/pop transition contract replaces Crossfade(240); ConsoleMotion/ConsoleShape become the single vocabulary. The easing is the analytic1−(1−t)³ — the first cut shipped a bezier published under the same name that is 0.08 slack at the midpoint, which the shared vectors (below) now catch.
Apple's GamepadOptionBand drum as the select field: options ride a turning cylinder, spring retargeting preserves velocity across rapid steps, neighbours exist only mid-flight, per-option edge fade (never a mask — it rasterises the projection away).
Glass, the hard way. The first cut added a focus shadow and an outside-the-clip bloom; on glass the shadow showed through the translucent fill as a rectangle inside every card, and the bloom painted over list neighbours. Both deleted — Apple's glass is material + one animatable tint, and now ours is too.
Menu haptics on the driving pad's own motors, boundary refusal nudges, a gliding tab indicator, directional tab paging (one LazyListState per pane — the shared-state constraint is what produced the first cut's fake fade), landscape rows capped at Apple's 620 dp.
Functional parity (WP8)
Select + X cycles the stats tiers in-stream — byte-identical to Apple's chord, implemented as the mic chord's twin; GamepadChordTest pins eight cases (the kit had no router-chord test at all). Start-of-stream banner with per-session honest text.
Console Edit Host gains the clipboard toggle and profile binding — a couch-only user could never change either, and a TV box has no touch UI to fall back to.
Controllers and Licenses are reachable from the console, at nav depth 2 with the cursor carried across the trip by row id. Both are inked through one ColorScheme derived from the palette (28 touch-theme colour sites were invisible over the six pale palettes).
Pinned cards wear a real profile chip; unsaved tiles a dashed edge; accessibility semantics across the console screens (rows announce once, merged; decoration silenced).
Structural (WP9)
ConnectScreen.kt 1361 → ~980 + HomeTiles.kt (pure, unit-tested tile builder), ConnectGrid.kt, ConnectPrompts.kt. The connect engine deliberately stayed — it closes over ~20 locals and needs a state holder first.
Dialog unification: six prompts that existed twice (AlertDialog + console card) are one description with two renderers. Fixed three copy drifts, including the console never stating which layer a speed-test Apply writes to.
clients/shared/console-vectors.json — the palette table (including the derived 16-cell mesh), tab names and motion constants, consumed by Rust, Kotlin and Swift tests via the deeplink-vectors.json pattern. Two real drifts caught on day one: the wrong easing bezier, and the desktop's mobile-absent Input tab (modelled as desktop_only rather than either side being "wrong"). Rust side verified under Linux (77/0 — a Mac cargo test on this crate compiles nothing and passes vacuously).
Pre-existing bugs fixed en route
Every console dialog was unreadable on all six pale palettes — a hardcoded near-black card under palette-derived dark ink.
The console carousel stayed live under a finished speed test — one A dismissed the card and started a connect.
ControllersScreennulled the shared input probes unconditionally on dispose, killing the incoming screen's pad nav during transitions.
The input test's B-hold ended at the mark, so the B release fell through to the BACK remap and closed the whole screen (bit the touch screen too).
Still open
WP8.4 (console profile create/rename/delete): blocked on the on-screen keyboard being private and lowercase-only — a console-made profile could never be named "Work".
The Controllers screen's inner buttons (Grant USB access, Test rumble/haptics) have no console focus list — a denied Sony USB grant has no console recovery path on TV.
On-glass still owed: pad haptics feel, the Select+X chord in a real stream, mesh perf on a TV box, reverse-landscape cutout.
CI: android.yml gains a filtered app-module test step (vectors + tile builder + layout + sub-screen tests) so the contracts gate on push without dragging the Roborazzi scenes in. Merged main (launcher icons landed on the same files) — clean, full gate green after.
Implements `punktfunk-planning/design/android-console-ui-visual-refresh.md` (WP1–WP9, all but WP8.4), plus one round of on-glass review on the Nothing Phone 3 that reshaped the visual half. Field-tested: the final build is running on the NP3.
## The two named bugs (the reason the plan exists)
- **Settings rows no longer grow on focus.** The focused row unfolded its description in place, shifting every row below the cursor on every D-pad step — while fighting the keep-focus-visible scroll whose target moved mid-animation. The description now lives in a floating band (portrait) or a side pane (landscape); rows are fixed-height. `GamepadSettingsLayoutTest` drives the real screen and asserts neighbouring-row bounds don't move.
- **The NP3's hole punch no longer covers content.** All twelve console screens padded with `systemBarsPadding()`, which excludes `WindowInsets.displayCutout` — in landscape the punch is a side inset with no bar behind it. `consoleSafeArea()` = `systemBars ∪ displayCutout`, verified against the device's reported `[162,0][0,72]` insets. The home carousel deliberately stays full-bleed (insetting it clipped the fanned cards — worse than cards behind a camera).
## The premium-feel work
- **The desktop's actual mesh backdrop**, `mesh_sksl` ported to AGSL behind API 33+ (blob fallback below; compile failure falls back rather than crashing). Locale trap handled: shader constants format through `Locale.ROOT` or a German device emits `0,075` — a syntax error.
- **The desktop's push/pop transition contract** replaces `Crossfade(240)`; `ConsoleMotion`/`ConsoleShape` become the single vocabulary. The easing is the *analytic* `1−(1−t)³` — the first cut shipped a bezier published under the same name that is 0.08 slack at the midpoint, which the shared vectors (below) now catch.
- **Apple's `GamepadOptionBand` drum** as the select field: options ride a turning cylinder, spring retargeting preserves velocity across rapid steps, neighbours exist only mid-flight, per-option edge fade (never a mask — it rasterises the projection away).
- **Glass, the hard way.** The first cut added a focus shadow and an outside-the-clip bloom; on glass the shadow showed *through* the translucent fill as a rectangle inside every card, and the bloom painted over list neighbours. Both deleted — Apple's glass is material + one animatable tint, and now ours is too.
- Menu haptics on the driving pad's own motors, boundary refusal nudges, a gliding tab indicator, directional tab paging (one `LazyListState` per pane — the shared-state constraint is what produced the first cut's fake fade), landscape rows capped at Apple's 620 dp.
## Functional parity (WP8)
- **Select + X cycles the stats tiers in-stream** — byte-identical to Apple's chord, implemented as the mic chord's twin; `GamepadChordTest` pins eight cases (the kit had no router-chord test at all). Start-of-stream banner with per-session honest text.
- **Console Edit Host gains the clipboard toggle and profile binding** — a couch-only user could never change either, and a TV box has no touch UI to fall back to.
- **Controllers and Licenses are reachable from the console**, at nav depth 2 with the cursor carried across the trip by row id. Both are inked through one `ColorScheme` derived from the palette (28 touch-theme colour sites were invisible over the six pale palettes).
- Pinned cards wear a real profile chip; unsaved tiles a dashed edge; accessibility semantics across the console screens (rows announce once, merged; decoration silenced).
## Structural (WP9)
- **`ConnectScreen.kt` 1361 → ~980** + `HomeTiles.kt` (pure, unit-tested tile builder), `ConnectGrid.kt`, `ConnectPrompts.kt`. The connect engine deliberately stayed — it closes over ~20 locals and needs a state holder first.
- **Dialog unification**: six prompts that existed twice (AlertDialog + console card) are one description with two renderers. Fixed three copy drifts, including the console never stating which layer a speed-test Apply writes to.
- **`clients/shared/console-vectors.json`** — the palette table (including the *derived* 16-cell mesh), tab names and motion constants, consumed by Rust, Kotlin and Swift tests via the `deeplink-vectors.json` pattern. Two real drifts caught on day one: the wrong easing bezier, and the desktop's mobile-absent Input tab (modelled as `desktop_only` rather than either side being "wrong"). Rust side verified under Linux (77/0 — a Mac `cargo test` on this crate compiles nothing and passes vacuously).
## Pre-existing bugs fixed en route
1. Every console dialog was **unreadable on all six pale palettes** — a hardcoded near-black card under palette-derived dark ink.
2. The console carousel stayed **live under a finished speed test** — one A dismissed the card *and* started a connect.
3. `ControllersScreen` **nulled the shared input probes unconditionally** on dispose, killing the incoming screen's pad nav during transitions.
4. The input test's B-hold ended **at** the mark, so the B *release* fell through to the BACK remap and closed the whole screen (bit the touch screen too).
## Still open
- **WP8.4** (console profile create/rename/delete): blocked on the on-screen keyboard being private and lowercase-only — a console-made profile could never be named "Work".
- The Controllers screen's inner buttons (Grant USB access, Test rumble/haptics) have no console focus list — a denied Sony USB grant has no console recovery path on TV.
- On-glass still owed: pad haptics feel, the Select+X chord in a real stream, mesh perf on a TV box, reverse-landscape cutout.
CI: android.yml gains a filtered app-module test step (vectors + tile builder + layout + sub-screen tests) so the contracts gate on push without dragging the Roborazzi scenes in. Merged `main` (launcher icons landed on the same files) — clean, full gate green after.
Implements P1–P3 (WP1–WP7) of `punktfunk-planning/design/android-console-ui-visual-refresh.md`.
**The two named bugs.**
Settings rows GREW ON FOCUS. The focused row unfolded its description in place
(`AnimatedVisibility` + `expandVertically`), so every D-pad step shrank one row and grew another
and shifted every row below the cursor — on a list simultaneously being scrolled to keep the
focused row visible, whose target therefore moved out from under it mid-animation. Sideways, the
value's `AnimatedContent` animated its own WIDTH on every step, walking the ‹ chevron back and
forth. Rows are now fixed: the description renders in a floating `ConsoleDetailBand` above the
legend pill — the desktop console's reserved detail band (`screens/settings.rs`) achieved by FLOAT
rather than by subtraction, so an overlay can never displace the list. Apple's per-row
`reservesSpace` was rejected for density. The value slot gets a fixed minimum, end alignment, a
snapped `SizeTransform` and tabular figures, so `1920 × 1080 → 2560 × 1440` changes nothing but
the glyphs. `GamepadSettingsLayoutTest` is the regression net — it drives the real screen and
asserts the neighbouring rows' bounds and the focused label's bounds do not move.
The NP3's HOLE PUNCH sat over settings and add-host content. All twelve console call sites padded
with `systemBarsPadding()`, which EXCLUDES `WindowInsets.displayCutout`; in landscape a punch is a
left/right edge inset with no bar behind it. New `Modifier.consoleSafeArea()` is
`systemBars ∪ displayCutout` (the rule Material3's own components lay out against) and the
floating legends' landscape branch keeps ignoring the bars while still taking the cutout —
`SENSOR_LANDSCAPE` parks the punch on exactly that corner. The backdrop stays full-bleed; it is
ambience, and running under the camera is what ambience should do.
**The premium-feel levers.**
The screen transition is now the desktop console's contract instead of `Crossfade(240)`: push
slides the incoming screen up 36 dp out of a fade at 0.985→1 while the outgoing recedes to 0.96;
pop runs it backwards. Direction comes from a nav depth on `GamepadScreen`. `ConsoleMotion` is the
single motion vocabulary (`shell.rs:29`'s `TRANSITION_S`, `render.rs:120-151`'s geometry, plus the
focus/value/tab tweens that were scattered literals) and `ConsoleShape` the corner radii that were
four literals in as many files. Reduce-motion collapses push/pop to a 90 ms cross-fade.
The backdrop is the desktop's actual field: `mesh_sksl` (`library.rs:433-525`) ported to AGSL —
4×4 bicubic colour mesh, four drifting interior warp points, ±8° hue sway, vignette, scrim, `calm`
— compiled per palette and cached, gated on API 33+ (`RuntimeShader`). `GamepadPalette` already
carried `CELL_RAMP`; it grows `MESH_COLORS`, `MESH_INTERIOR` and `meshColors`, so the 16-cell
table is now literally shared with the Rust. API 28–32 keeps the four-blob field, and a shader
that fails to compile falls back to it rather than taking the console down. The `cross()` builtin
is written out by hand — AGSL's function set is a subset of SkSL's and not worth betting on.
Constants are formatted through `Locale.ROOT`: on a German-locale device `String.format` emits
`0,075`, which is a syntax error in shader source.
`Modifier.consoleGlass` gives every row, field, card and tile a vertical luminance gradient, a
1 px top-edge highlight fading into the border, and — with focus — a drop shadow plus an accent
bloom drawn outside the clip, so focus reads as a lens rather than a recolour. Modals get
`consoleCard()`, which fixes a real bug: the cards were a hardcoded near-black indigo while their
text came from the palette, so on any of the six PALE palettes a dialog rendered dark ink on a
dark card and was unreadable. `GamepadInk` grows `card`, `modalScrim`, `highlight` and `danger`.
Micro-interactions: menu haptics (tick on move, thud on a refused press, pulse on confirm) on the
driving pad's own motors, else the phone body, else silence — a TV has neither; a refused value
step nudges 4 dp toward the press and springs back; the pressed-side chevron ticks 2 dp; the
switch knob squashes at mid-travel; one indicator pill GLIDES between the section tabs instead of
six fills cross-fading; the home carousel dips 0.97 under A and its neighbours turn away on the
Apple client's projected `cos` fan (never `rotation3DEffect` — see its note on the settle flash).
Palette leaks closed: the library's group heading was hardcoded white (invisible on a pale field),
the poster ground a fixed indigo, and the launcher badge read `MaterialTheme.colorScheme.primary`
— the TOUCH theme's wallpaper-seeded colour, nothing to do with the field it sat on. The add-host
caret and the keyboard's fill went the same way. The home header now outranks the controller chip,
which was eating "Select a Host" down to "Selec…" on a 360 dp phone.
Screenshot scenes gain the console home on the mesh (API 36), the same home on the blob fallback
(API 31), a landscape home where the carousel's fan is visible, and a pale-palette pass of each.
Not in this change: WP8 (functional parity gaps) and WP9 (structural) — the plan's own P4/P5, and
the dialog cards' backdrop frost, which would need a `HazeState` plumbed through `ConnectScreen`
to be worth anything behind a 94 %-opaque surface.
WP8 and WP9 of `punktfunk-planning/design/android-console-ui-visual-refresh.md`, in part.
**WP9.3 — shared parity vectors.** The console's background palettes, its settings section names
and its screen-transition motion each existed in three hand-written copies (`pf-console-ui`, this
client, the Apple client) held together by a comment asking the next person to keep them in step.
`clients/shared/console-vectors.json` now holds them, read the way `deeplink-vectors.json` already
is: `include_str!` in Rust, a relative path in Kotlin, `#filePath` in Swift — never a copy, because
a copy is a fourth contract free to go stale. It carries the DERIVED tables too, the 16-cell mesh
and the 4 blob colours per palette, which is the half that reaches the screen and the half Android
never checked: `GamepadPaletteTest` only ever measured the `stops` they are computed from.
Two drifts it immediately caught, both now closed:
* **The easing was the wrong curve.** `ConsoleMotion.EaseOutCubic` shipped as
`cubic-bezier(0.215, 0.61, 0.355, 1)` while claiming to be the desktop's `ease_out_cubic`. It is
not: that is the Penner/Ceaser table's curve, ~0.80 at the midpoint where `1 − (1−t)³` is 0.875 —
visibly slacker over a 260 ms transition. Compose's `Easing` is a plain function, so it now
evaluates the real thing analytically rather than approximating it at all. (Apple approximates
with a different bezier only because SwiftUI's `timingCurve` cannot take a closure; the vectors
sample the curve with a tolerance so all three can meet it.)
* **The desktop has a seventh tab.** Input — touch mode, mouse, invert-scroll, shortcuts — with
nothing to set on a phone or a TV. `settings.rs` claims in prose that a setting is found under
the same word on every client; that was true modulo an omission nobody could see. The vectors
model it with `desktop_only` rather than picking a side, so neither client has to be wrong.
Rust reads it from three tests placed in the files that own the constants, so nothing had to be
made `pub` to be checkable. Verified green under Linux (the crate is `cfg(linux|windows)` throughout
— `cargo test` on a Mac compiles nothing and passes vacuously): 77 passed, 0 failed. Android's side
gates in CI as a FILTERED task; a plain `:app:testDebugUnitTest` would drag the ~20 Roborazzi
screenshot scenes into every push, and those are a release-artifact job.
**WP8.1 — a pad route to the stats overlay.** The tier could only be cycled by a three-finger tap,
which does not exist on a TV, on a gamepad-only session, or under touch passthrough — while the
settings row promised a live cycle. `Select + X` now cycles it, byte-identical to the Apple
client's `GamepadWire.back | GamepadWire.x`, implemented as the mic chord's twin in `GamepadRouter`
and edge-triggered on the button that completes the mask. The buttons still reach the game, as both
existing chords do. `GamepadChordTest` pins eight cases the kit had no cover for at all, including
that the three chords intersect only on Select and that none is reachable through another.
**WP8.5 — a start-of-stream banner.** The desktop's `skia_overlay` banner, ported with its timing
(opaque 5.4 s, then a 0.6 s fade) and its rule of naming only shortcuts that exist: pad chords when
a pad is present, the touch gesture when there is a touchscreen and the mode can use it. Nothing
`Ctrl+Alt+Shift` is advertised, because Android has none of it. It yields to the motion-unreachable
notice rather than stacking with it — that one reports something broken about *this* session.
**WP8.6 — the home card says which profile it connects with.** `HomeTile` carried a
`pinnedProfileId` the card never drew, so a pinned host+profile card was distinguishable from the
host's own only by a subtitle that had been quietly repurposed to hold the profile name. Both now
show the address like every other card and wear a tinted profile chip — the touch grid's own
convention and the Apple client's, inked from the console palette. Unsaved tiles (discovered, Add
Host) take a dashed edge, which is what the other two surfaces already use to say "not yours yet".
⚠ Not a detail panel: the Apple client REMOVED its own and moved the status onto the card, which is
where the lock and the online pip already were here.
**WP8.7 — accessibility, in part.** The console screens carried three `contentDescription`s and no
`semantics`, `Role` or `stateDescription` at all. A settings row now announces once, merged —
label, value, and the description that lives in the floating band far from it — with `Role.Switch`
and a real toggle state, because a toggle row's on/off string was drawn by nothing at all: the
switch replaces the value text, and the switch was two undescribed `Box`es. Decoration is silenced
rather than labelled (the chevrons were read aloud as punctuation on every focused row). The hint
bar's glyphs, the tab strip and the home tiles are done; `GamepadAddHostScreen` and `LibraryScreen`
are not yet.
WP8.2 and the rest of WP8.7 from the console visual-refresh plan.
**The console's Edit Host was missing two decisions entirely.** The touch edit sheet has always
offered a shared-clipboard switch and a profile binding; the console form built only name, address,
port and MAC. Nothing was ever LOST — `KnownHost.copy` preserved both — but a couch-only user could
never CHANGE either, and a TV box has no touch interface to fall back to. Both are now rows in the
console form, driven like settings rows: left/right steps them, A flips or cycles. The binding is
filtered through the live catalog, so a host bound to a since-deleted profile reads as unset rather
than as a name nothing resolves — the same guard the touch sheet applies. "Default settings" leads
the ring as the ABSENCE of a binding, not as a fake catalog entry.
**Accessibility, finished.** The library's coverflow now says which poster a press acts on — from
the art alone a centred cover and a neighbour are indistinguishable, and only the centred one
launches. The group heading is a live region: it is the sole signal that the cursor has crossed
from the launchers into the games, and a coverflow gives a reader no other way to notice, being one
strip rather than two lists. The store badge says why it is there ("Opens Steam" / "From Steam")
instead of reading out a bare vendor name after the title.
WP9.1 of the console visual-refresh plan — dialog unification.
Six prompts existed twice: a Material `AlertDialog` in `ConnectDialogs.kt` and a console glass card
in `GamepadDialogs.kt`, maintained by hand. They had drifted, and always in the same direction —
the console losing something:
* "Pair with PIN…" and "Use a PIN…" lost their ellipses, so the console said the buttons finished
something the touch UI said would open another step;
* "if no prompt appears when you tap Allow" became "after Allow";
* the speed test dropped `speedTestTargetNote` entirely, leaving a console user — often on a TV box,
which is exactly the machine whose link is worth measuring — no statement of which layer "Apply"
was about to write to. That is a write in an unknown direction.
What is shared now is the DESCRIPTION of a prompt (a title, a list of `DialogAction`s, a body) and
what stays per-interface is only how it is drawn. `PunktfunkDialog` takes that description and
renders it as an AlertDialog or as the existing console modal. Actions are ordered primary-first:
the console stacks them that way with the cursor on the first, and the touch renderer lifts the
same first action into `confirmButton`. One order, two idioms.
The two renderers cannot be one tree — an AlertDialog composes into its own platform window while
the console modal is a Box in the calling tree, which is why one needs a `BackHandler` and the
caller's `navActive` gate and the other needs neither.
Deliberately NOT unified, and they belong apart: the PIN ceremony (a keyboard field and an editable
device name against four D-pad digit slots is a different input model, not a different skin),
Add/Edit Host (a bottom sheet against a full screen with its own on-screen keyboard), and the host
action list (an anchored dropdown against a modal stack that also grows a row per profile).
Twelve composables become six. `ConnectScreen`'s dialog block loses ten `if (gamepadUi)` branches.
WP9.2 and WP8.3 of the console visual-refresh plan.
**WP9.2 — the 1361-line ConnectScreen becomes 979 plus three files.** `HomeTiles.kt` holds a pure
`buildHomeTiles` (non-composable, so it is unit-testable — `HomeTilesTest` pins six behaviours the
console carousel had no cover for at all, including that a saved host also advertising on a NEW
DHCP address is listed once, which exercises the fingerprint match rather than just "the builder
lists what it is given"). `ConnectGrid.kt` holds the touch home. `ConnectPrompts.kt` holds
everything modal. `ConnectScreen`'s signature is untouched, so `App.kt` compiles unchanged.
What stayed, deliberately: the state and the engine — discovery, the permission dance, identity,
the trust decision, the dial and its wake fallback, the deep-link router. Those close over ~20
locals that a dozen callbacks read AND write, so lifting them means inventing a state holder, which
is a second refactor and a second thing to get wrong. A comment above `ConnectScreen` now says so.
🛑 **A real bug the split surfaced, fixed here:** the console carousel was live under a FINISHED
speed test. It looked covered by `!connecting`, and was — until the measurement ended, because
`startSpeedTest` clears `connecting` before the Done card is dismissed. From that moment the card
and the carousel underneath both consumed the pad, so one A dismissed the card *and* started a
connect. `speedTest` now sits in the `navActive` gate with every other modal.
**WP8.3 — two screens the console could not reach.** On a TV box there is no touch interface to
fall back to, so the notices and the controllers view were simply unreachable. Both are now console
screens at nav depth 2 (reached FROM settings, which is what makes the trip a push and the way back
a pop), opened by a Controller-tab row and an About row. `GpSettingsPlace` carries the cursor across
the trip, keyed by row ID rather than index, so Back lands where you left rather than on the first
row of the first tab — and because a tab's length follows the hardware, an index would have been
the stale-pointer bug the tab-switch clamp already exists for.
Four blockers, all real:
* 🛑 `ControllersScreen` installed the shared input probes unconditionally and NULLED them
unconditionally on dispose — no identity check, unlike `GamepadNavEffect2D`. During the shell's
push/pop both screens are briefly composed, so its teardown would have killed the incoming
screen's pad navigation. Now it releases only the slot it still owns.
* 🛑 `LicensesScreen` had exactly ONE focusable node, and Compose only scrolls to keep a FOCUSED
child visible — so a D-pad could not read past the first screenful of a many-screen file. Both
screens now drive their scroll state directly: up/down steps 0.28 of the viewport, shoulders page
0.88 — under a screenful on purpose, so the line you were reading survives the press.
* ⚠ Both were inked from the TOUCH theme (28 `colorScheme` sites plus implicit pulls from
`OutlinedCard`, `Switch`, `OutlinedButton`, `LinearProgressIndicator`), which is always dark —
invisible over the six PALE palettes. They are now shown through one `ColorScheme` derived from
`LocalGamepadInk` rather than 27 call-site branches, because call-site edits cannot reach the
implicit pulls at all. Screenshot scenes shoot both on a dark and a pale palette; the pale pair is
the point.
* ⚠ B was already taken — the input test's exit is a 1.2 s hold. The rule is now stated on screen:
while the test runs the pad is the test's, a short B answers with the boundary thud instead of
doing nothing, and the legend collapses to one "Hold to finish" cell.
🛑 **Second bug fixed in passing:** that hold ended the test AT the 1.2 s mark, so the B *release*
then fell through to MainActivity's B→BACK remap and closed the whole screen. It bit the touch
screen too. The test now ends on the release, which is therefore consumed.
Residual TV gap, flagged not fixed: the Controllers screen's inner buttons ("Grant USB access",
"Test rumble", "Test haptics") have no console focus list, so they stay touch-only — a denied Sony
USB grant still has no console recovery path.
The shared-vectors tests added in 0b550dad read console-vectors.json with serde_json, but the
lockfile never caught up: every gate that has run since was either the Android JVM suite (no cargo
at all) or the Linux container, which builds an rsynced COPY of the tree. The first cargo run
inside the worktree itself — building the phone's native library — wrote it.
Every item from the on-glass review on the Nothing Phone 3, which found the refresh technically
correct and visually wrong.
**The glass was structurally broken, twice.** The focus bloom was drawn OUTSIDE the clip on
purpose ("a glow that stops at the edge is just a brighter border") — but unclipped drawing does
not stop at the row's neighbours either: in a list it painted over the rows above and below, and in
the carousel it escaped the card entirely. And the focus drop shadow is drawn UNDER the surface,
which is translucent — so the shadow showed straight through the fill as a dark rectangle floating
inside every card and field. Both are gone, and the comment forbids their return: the Apple glass
(`GlassStyle.swift`) is material + an animatable tint, full stop. Focus is now the fill and border
brightening (tint up to 0.28, near Apple's 0.30) — which is also why it finally animates like the
Apple client: one interpolating fill instead of four stacked effects arriving on separate curves.
**The select field is now the Apple drum.** `ConsoleOptionBand` ports `GamepadOptionBand.swift`
whole: options ride a turning cylinder segment, position driven by one spring whose retargeting
preserves velocity — rapid steps accumulate into one accelerating travel instead of five restarted
fades. Linear, not a ring; neighbours exist only mid-flight; the soft edge is per-option opacity,
never a mask (a mask rasterises the projection away — the Apple file's own field verdict). The
band's width is fixed by the row, so a step can never reflow the chevrons; portrait narrows it
(132 dp) because at 156 the LABELS truncated, and a clipped label loses meaning where a drum value
only loses its tail into the edge fade. Chevrons are icons now, not '‹' text glyphs.
**Landscape got room.** Rows cap at the Apple client's 620 dp and sit left; the focused row's
description moved into the width a wide phone was wasting — a side pane on the right, with the
row's label anchoring it — instead of a band floating over the list's tail. Portrait keeps the
band. Add-host fields take the same cap.
**The list is no longer guillotined.** The safe area now applies to the CHROME (strip, sides, top)
only; the list runs to the physical bottom edge with the bottom inset folded into its
contentPadding, so scrolled rows glide off the screen instead of being cut at an invisible line
above it.
**"Default settings" stopped shouting.** The headline that repeated itself on every tab is a quiet
"Defaults" chip at the strip's end — same honesty (this screen edits the base layer only), no
second heading repeating the tab pill's own word.
**Tab switching is finally directional.** The old cut slid a single list's contents 24 dp under an
85 % fade — the same crossfade whichever shoulder was pressed. It is now an AnimatedContent whose
incoming section slides from the side the press pointed at while the outgoing leaves the other way.
Each pane owns its own LazyListState (one state cannot attach to two lists — the constraint that
motivated the single-list hack), seeded at the section's restored cursor.
**The carousel ignores the safe area again, on purpose.** Insetting the pager clipped the fanned
neighbours at the cutout edge; cards visibly cut off is worse than cards behind a camera. Only the
centred card matters and it sits mid-screen — the title and legend keep their insets, they are
content.
**The legend stopped jumping.** The connect takeover parked its pill at bottom-CENTRE, so pressing
Connect made the one piece of chrome that should read as fixed leap halfway across the screen. It
now sits at the same bottom-start inset as every console screen.
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/android-console-ui-visual-refresh.md(WP1–WP9, all but WP8.4), plus one round of on-glass review on the Nothing Phone 3 that reshaped the visual half. Field-tested: the final build is running on the NP3.The two named bugs (the reason the plan exists)
GamepadSettingsLayoutTestdrives the real screen and asserts neighbouring-row bounds don't move.systemBarsPadding(), which excludesWindowInsets.displayCutout— in landscape the punch is a side inset with no bar behind it.consoleSafeArea()=systemBars ∪ displayCutout, verified against the device's reported[162,0][0,72]insets. The home carousel deliberately stays full-bleed (insetting it clipped the fanned cards — worse than cards behind a camera).The premium-feel work
mesh_skslported to AGSL behind API 33+ (blob fallback below; compile failure falls back rather than crashing). Locale trap handled: shader constants format throughLocale.ROOTor a German device emits0,075— a syntax error.Crossfade(240);ConsoleMotion/ConsoleShapebecome the single vocabulary. The easing is the analytic1−(1−t)³— the first cut shipped a bezier published under the same name that is 0.08 slack at the midpoint, which the shared vectors (below) now catch.GamepadOptionBanddrum as the select field: options ride a turning cylinder, spring retargeting preserves velocity across rapid steps, neighbours exist only mid-flight, per-option edge fade (never a mask — it rasterises the projection away).LazyListStateper pane — the shared-state constraint is what produced the first cut's fake fade), landscape rows capped at Apple's 620 dp.Functional parity (WP8)
GamepadChordTestpins eight cases (the kit had no router-chord test at all). Start-of-stream banner with per-session honest text.ColorSchemederived from the palette (28 touch-theme colour sites were invisible over the six pale palettes).Structural (WP9)
ConnectScreen.kt1361 → ~980 +HomeTiles.kt(pure, unit-tested tile builder),ConnectGrid.kt,ConnectPrompts.kt. The connect engine deliberately stayed — it closes over ~20 locals and needs a state holder first.clients/shared/console-vectors.json— the palette table (including the derived 16-cell mesh), tab names and motion constants, consumed by Rust, Kotlin and Swift tests via thedeeplink-vectors.jsonpattern. Two real drifts caught on day one: the wrong easing bezier, and the desktop's mobile-absent Input tab (modelled asdesktop_onlyrather than either side being "wrong"). Rust side verified under Linux (77/0 — a Maccargo teston this crate compiles nothing and passes vacuously).Pre-existing bugs fixed en route
ControllersScreennulled the shared input probes unconditionally on dispose, killing the incoming screen's pad nav during transitions.Still open
CI: android.yml gains a filtered app-module test step (vectors + tile builder + layout + sub-screen tests) so the contracts gate on push without dragging the Roborazzi scenes in. Merged
main(launcher icons landed on the same files) — clean, full gate green after.WP8.2 and the rest of WP8.7 from the console visual-refresh plan. **The console's Edit Host was missing two decisions entirely.** The touch edit sheet has always offered a shared-clipboard switch and a profile binding; the console form built only name, address, port and MAC. Nothing was ever LOST — `KnownHost.copy` preserved both — but a couch-only user could never CHANGE either, and a TV box has no touch interface to fall back to. Both are now rows in the console form, driven like settings rows: left/right steps them, A flips or cycles. The binding is filtered through the live catalog, so a host bound to a since-deleted profile reads as unset rather than as a name nothing resolves — the same guard the touch sheet applies. "Default settings" leads the ring as the ABSENCE of a binding, not as a fake catalog entry. **Accessibility, finished.** The library's coverflow now says which poster a press acts on — from the art alone a centred cover and a neighbour are indistinguishable, and only the centred one launches. The group heading is a live region: it is the sole signal that the cursor has crossed from the launchers into the games, and a coverflow gives a reader no other way to notice, being one strip rather than two lists. The store badge says why it is there ("Opens Steam" / "From Steam") instead of reading out a bare vendor name after the title.Every item from the on-glass review on the Nothing Phone 3, which found the refresh technically correct and visually wrong. **The glass was structurally broken, twice.** The focus bloom was drawn OUTSIDE the clip on purpose ("a glow that stops at the edge is just a brighter border") — but unclipped drawing does not stop at the row's neighbours either: in a list it painted over the rows above and below, and in the carousel it escaped the card entirely. And the focus drop shadow is drawn UNDER the surface, which is translucent — so the shadow showed straight through the fill as a dark rectangle floating inside every card and field. Both are gone, and the comment forbids their return: the Apple glass (`GlassStyle.swift`) is material + an animatable tint, full stop. Focus is now the fill and border brightening (tint up to 0.28, near Apple's 0.30) — which is also why it finally animates like the Apple client: one interpolating fill instead of four stacked effects arriving on separate curves. **The select field is now the Apple drum.** `ConsoleOptionBand` ports `GamepadOptionBand.swift` whole: options ride a turning cylinder segment, position driven by one spring whose retargeting preserves velocity — rapid steps accumulate into one accelerating travel instead of five restarted fades. Linear, not a ring; neighbours exist only mid-flight; the soft edge is per-option opacity, never a mask (a mask rasterises the projection away — the Apple file's own field verdict). The band's width is fixed by the row, so a step can never reflow the chevrons; portrait narrows it (132 dp) because at 156 the LABELS truncated, and a clipped label loses meaning where a drum value only loses its tail into the edge fade. Chevrons are icons now, not '‹' text glyphs. **Landscape got room.** Rows cap at the Apple client's 620 dp and sit left; the focused row's description moved into the width a wide phone was wasting — a side pane on the right, with the row's label anchoring it — instead of a band floating over the list's tail. Portrait keeps the band. Add-host fields take the same cap. **The list is no longer guillotined.** The safe area now applies to the CHROME (strip, sides, top) only; the list runs to the physical bottom edge with the bottom inset folded into its contentPadding, so scrolled rows glide off the screen instead of being cut at an invisible line above it. **"Default settings" stopped shouting.** The headline that repeated itself on every tab is a quiet "Defaults" chip at the strip's end — same honesty (this screen edits the base layer only), no second heading repeating the tab pill's own word. **Tab switching is finally directional.** The old cut slid a single list's contents 24 dp under an 85 % fade — the same crossfade whichever shoulder was pressed. It is now an AnimatedContent whose incoming section slides from the side the press pointed at while the outgoing leaves the other way. Each pane owns its own LazyListState (one state cannot attach to two lists — the constraint that motivated the single-list hack), seeded at the section's restored cursor. **The carousel ignores the safe area again, on purpose.** Insetting the pager clipped the fanned neighbours at the cutout edge; cards visibly cut off is worse than cards behind a camera. Only the centred card matters and it sits mid-screen — the title and legend keep their insets, they are content. **The legend stopped jumping.** The connect takeover parked its pill at bottom-CENTRE, so pressing Connect made the one piece of chrome that should read as fixed leap halfway across the screen. It now sits at the same bottom-start inset as every console screen.