The Android console grows up — the desktop's real backdrop, Apple's drum, and the cross-client tables stop drifting in silence #163
Merged
enricobuehler
merged 8 commits from 2026-08-11 18:32:03 +00:00
worktree-android-console-visual-refresh into main
8
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
332a81b077 |
Merge remote-tracking branch 'origin/main' into worktree-android-console-visual-refresh
windows / build (aarch64-pc-windows-msvc) (pull_request) Successful in 1m28s
apple / swift (pull_request) Successful in 1m41s
apple / screenshots (pull_request) Skipped
windows / build (x86_64-pc-windows-msvc) (pull_request) Successful in 2m30s
ci / rust-arm64 (pull_request) Successful in 6m15s
android / android (pull_request) Successful in 6m58s
ci / web (pull_request) Successful in 1m16s
ci / bun-nix (pull_request) Successful in 21s
ci / docs-site (pull_request) Successful in 1m35s
ci / rust (pull_request) Successful in 15m56s
nix / flake (pull_request) Successful in 14m8s
|
||
|
|
f692a1877c |
fix(android): the console answers its first field test — the glass, the drum, and the room to breathe
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.
|
||
|
|
3afc17a06d |
build: record pf-console-ui's serde_json dev-dependency in the lockfile
The shared-vectors tests added in
|
||
|
|
f90a4e5f97 |
refactor(android): ConnectScreen splits, and the console can finally open Controllers and Licenses
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. |
||
|
|
430c9831ef |
refactor(android): the prompts that say the same thing in both interfaces are now one prompt
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. |
||
|
|
4b48748b8a |
feat(android): the console can finally decide a host's clipboard and profile — and says what it is doing
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.
|
||
|
|
0b550dad84 |
feat(android): the console tables stop drifting in silence, and the stats overlay gets a pad route
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. |
||
|
|
cac500acc7 |
feat(android): the console UI stops moving under the cursor, and gets the desktop's real backdrop
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. |