The Apple client catches up with the console-UI overhaul: grid, sort, Collections, the v2 motion contract #293

Merged
enricobuehler merged 12 commits from worktree-apple-console-parity-sweep into main 2026-08-18 11:31:25 +00:00
Owner

The console-UI overhaul (#269#273#275#278) left the Apple client with none of Part C. This is the parity sweep's port, in the plan's slices (punktfunk-planning/design/apple-console-ui-parity.md), plus two rounds of on-glass fixes from the iPad and the iPhone.

What lands

  • PR-0 · shared collate vectorsclients/shared/library-collate-vectors.json, generated from collate.rs's rules, read by a Rust test (vectors_match_the_shared_file) and by the Apple port's tests. Desktop stays source of truth; three copies of the spec now share a machine contract.
  • PR-1 · model + collationGameEntry.platform (the host sent it all along); LibraryCollation / LibraryOrder (PunktfunkKit), the desktop's eight tests by name; the three device keys (librarySort, libraryView, libraryCollections) and rows on both settings surfaces; stale wording in the coverflow; error-state hints in gamepad mode; coverflow position restore; PUNKTFUNK_FAKE_LIBRARY. Also fixes a pre-existing bug: running-first put a running game ahead of the launcher prefix.
  • PR-2 · grid + barLibraryConsoleView (the chrome both arrangements share), LibraryGridView with the desktop's grid_step cursor rules (LibraryGridCursor, nine tests by name), LibraryBarView; posters decode at drawn size.
  • PR-3 · CollectionsLibraryPlaceStack / CollectionsHandover (pure, tested), LibraryCollectionsView (deck-of-covers tiles), filtered shelf, host · profile · collection titles, "start in collections".
  • PR-4 · contractConsoleMotion + GpSettingsTab in PunktfunkShared, ConsoleVectorsTests pins tabs and motion_spring; the shell's push/pop is the console's spring, Back is accepted mid-push.
  • PR-5 · touch grid — sort & group menu over the same collation.

From the glass (iPad, iPhone, iPhone simulator)

  • Stick hysteresis in GamepadMenuInput — one flick read as UP then RIGHT; the 2-D grid exposed it.
  • The grid owns its scroll offset (one sprung scalar, exact geometry, own culling) — ScrollView + lazy scrollTo gave two impulses per move on UIKit; rows no longer draw over the header/tray.
  • The sort/view bar is a tray summoned by ▲ (a fixed band ate a third of a landscape phone); columns fill the safe-area width; compact-height pass rendered in the iPhone simulator (heading bands, tile deck, legend trims, subtitle dropped on the shelf).
  • X opens a title's Options menu (the desktop's Copy link · Cancel) instead of copying outright.
  • Keyboard tray: the edited row flies to a seat above the keys; a hardware keyboard types straight into the field; Esc closes the tray, not the screen.

Gated

pf-console-ui fmt + clippy + 178 tests in the container; 371 PunktfunkKit tests; macOS build; iOS + tvOS typechecks; every library scene rendered on the Mac (violet + a pale palette) and in the iPhone 16 Pro Max simulator; on-glass rounds on the iPad and the iPhone.

Not in this PR (named)

Toasts (the plan's optional PR-6); Android's port of the same (it should read the same vectors file); the Mac trackpad wheel no longer scrolls the grid (pad, keys and click do); tvOS on glass.

The console-UI overhaul (#269 → #273 → #275 → #278) left the Apple client with none of Part C. This is the parity sweep's port, in the plan's slices (`punktfunk-planning/design/apple-console-ui-parity.md`), plus two rounds of on-glass fixes from the iPad and the iPhone. ## What lands - **PR-0 · shared collate vectors** — `clients/shared/library-collate-vectors.json`, generated from `collate.rs`'s rules, read by a Rust test (`vectors_match_the_shared_file`) and by the Apple port's tests. Desktop stays source of truth; three copies of the spec now share a machine contract. - **PR-1 · model + collation** — `GameEntry.platform` (the host sent it all along); `LibraryCollation` / `LibraryOrder` (PunktfunkKit), the desktop's eight tests by name; the three device keys (`librarySort`, `libraryView`, `libraryCollections`) and rows on both settings surfaces; stale wording in the coverflow; error-state hints in gamepad mode; coverflow position restore; `PUNKTFUNK_FAKE_LIBRARY`. Also fixes a pre-existing bug: running-first put a running *game* ahead of the launcher prefix. - **PR-2 · grid + bar** — `LibraryConsoleView` (the chrome both arrangements share), `LibraryGridView` with the desktop's `grid_step` cursor rules (`LibraryGridCursor`, nine tests by name), `LibraryBarView`; posters decode at drawn size. - **PR-3 · Collections** — `LibraryPlaceStack` / `CollectionsHandover` (pure, tested), `LibraryCollectionsView` (deck-of-covers tiles), filtered shelf, `host · profile · collection` titles, "start in collections". - **PR-4 · contract** — `ConsoleMotion` + `GpSettingsTab` in PunktfunkShared, `ConsoleVectorsTests` pins tabs and `motion_spring`; the shell's push/pop is the console's spring, Back is accepted mid-push. - **PR-5 · touch grid** — sort & group menu over the same collation. ## From the glass (iPad, iPhone, iPhone simulator) - Stick hysteresis in `GamepadMenuInput` — one flick read as UP then RIGHT; the 2-D grid exposed it. - The grid owns its scroll offset (one sprung scalar, exact geometry, own culling) — `ScrollView` + lazy `scrollTo` gave two impulses per move on UIKit; rows no longer draw over the header/tray. - The sort/view bar is a tray summoned by ▲ (a fixed band ate a third of a landscape phone); columns fill the safe-area width; compact-height pass rendered in the iPhone simulator (heading bands, tile deck, legend trims, subtitle dropped on the shelf). - X opens a title's Options menu (the desktop's `Copy link · Cancel`) instead of copying outright. - Keyboard tray: the edited row flies to a seat above the keys; a hardware keyboard types straight into the field; Esc closes the tray, not the screen. ## Gated pf-console-ui fmt + clippy + 178 tests in the container; 371 PunktfunkKit tests; macOS build; iOS + tvOS typechecks; every library scene rendered on the Mac (violet + a pale palette) and in the iPhone 16 Pro Max simulator; on-glass rounds on the iPad and the iPhone. ## Not in this PR (named) Toasts (the plan's optional PR-6); Android's port of the same (it should read the same vectors file); the Mac trackpad wheel no longer scrolls the grid (pad, keys and click do); tvOS on glass.
enricobuehler added 12 commits 2026-08-18 11:22:19 +00:00
`clients/shared/library-collate-vectors.json` pins, from `collate.rs`'s rules, the exact groups
every (sort, group_by) must produce over one mixed library, the flat lists a group filter
yields, which libraries are worth browsing, the title fold, the store labels and the persisted
sort ids. `vectors_match_the_shared_file` reads it here; the Apple port's LibraryCollationTests
reads the same file, so the three copies of the spec have a machine contract instead of prose.
Desktop stays the source of truth — a rule change regenerates the file in the same commit.

The fixture deliberately walks every rule the design doc names: a platform-less Steam title
buckets under Steam, a generic-store title under Other, launchers lead in host order under
every sort, equal A–Z keys keep the host's order, group labels sort bytewise (GOG before Game),
a filter naming a missing group yields nothing, and `filtered(None)` flattens the launcher
group too.
The first slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-1):

- `GameEntry.platform` — the host has sent it all along (`GameMeta`, flattened); `Codable`
  dropped it. It is the field the library's collections group by, and the detail band now
  reads `STORE · PLATFORM` when the host named one.
- `LibraryCollation` (PunktfunkKit) — the Swift port of `pf-console-ui`'s `collate.rs`:
  `LibrarySortKey` (host/title/platform/store, the desktop's stored ids), `LibraryGroupKey`,
  `collate`/`filtered`/`worthBrowsing`, the article-and-diacritic title fold, the
  platform-less-buckets-under-its-store rule, bytewise label order. The desktop's eight tests
  are ported by name, and `testVectorsMatchTheSharedFile` reads the same
  `clients/shared/library-collate-vectors.json` the Rust test reads — the two copies of the
  spec now share a machine contract.
- `LibraryOrder.display` replaces `LibraryView.ordered`, which put EVERY running entry first
  over `launchersFirst` — so a running game jumped ahead of the launcher prefix and the
  coverflow's heading read GAMES · LAUNCHERS · GAMES along the strip, while its comment
  claimed the opposite. The desktop's `order()`: launchers lead, running leads within a band.
- Three device keys with the desktop's ids (`librarySort`, `libraryView`,
  `libraryCollections`), and their rows on both settings surfaces (gamepad Interface tab,
  touch Library section). Presentation only, never in a profile.
- The coverflow now shows the catalog's staleness ("Last known library — waking the host…" /
  "— the host didn't answer") on its detail band — the touch grid had it, the coverflow
  never did — and opens on the title last launched from the shelf (`LibraryScrollMemory`),
  the way the grid scrolls back to it. `GamepadCarousel` gains `initialItemID` for that.
- Loading/error/empty in gamepad mode carry a legend (`A Retry` on an error, `B Back`); the
  zero-size back catcher answers A too. A controller-only user on an error screen had no
  visible way out.
- `PUNKTFUNK_FAKE_LIBRARY=<file.json>` — the desktop's dev hook, here: a host-shaped list (or
  the shared vectors file itself) stands in for a host, so grid/collections/sort can be
  driven on a Mac with none. `ShotMock.games` gains platforms for the store shots.
The second slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-2):

- `LibraryConsoleView` — the chrome the desktop console's library screen owns and both of its
  arrangements feed: the view/sort bar across the top, the field (coverflow or grid, one persisted
  setting apart), the detail band, the legend, the backdrop. `LibraryView` still owns the data.
  The bar's controls write the cross-client keys the Interface settings rows write, so the two
  surfaces can never disagree; both apply live and the focused TITLE survives a resort.
- `LibraryBarView` — `SORT` Default · A–Z · Platform · Store leading, `VIEW` Shelf · Grid trailing;
  focus is an accent wash over the pill row (not the band — the desktop's own trap). ▲ from the
  field hands the controller over; ◀▶ step the sort clamped, L1/R1 pick the arrangement, ▼/A/B
  hand it back, and the legend swaps with it. Pills are tappable, and focusable Buttons on tvOS.
- `LibraryGridView` — 2:3 cells 150×225 design units scaled by min(width, height)/800, columns
  2…8 as they fit, the launcher rows squarely above the game rows with the heading between, the
  focused row riding a third of the way down on a spring, ×1.06 pop + accent ring OUTSIDE the
  cover, `Resume` in the coverflow's corner, entrance fanning on |Δrow|+|Δcol|. Cells are
  focusable Buttons on tvOS (a Siri Remote is no extended gamepad).
- `LibraryGridCursor` (PunktfunkKit) — the port of `GridShape`/`grid_step`/`grid_col_hint`: one
  shape both renderer and cursor read; ◀▶ walk the row and refuse at its TRUE ends, ▲▼ change row
  carrying the remembered column, L1/R1 page three rows and land on the ends, navigation declines
  until a layout exists. The desktop's nine grid tests, by name.
- `PosterImage` decodes at the DRAWN size (`CGImageSourceCreateThumbnailAtIndex`, 2× the cell's
  edge in pixels): a grid multiplies resident posters, and the desktop's grid was a slideshow
  until it stopped decoding 600×900 capsules for 150-point cells. The coverflow shares it.
- The coverflow is now the strip and its heading only; its detail band, legend and backdrop moved
  up into the container. Screenshot scenes gain `11b-library-grid` and `11c-library-bar` (the bar
  FOCUSED — no shot ever showed the desktop's bar focused, which is how its mis-sized wash shipped).
The third slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-3): the
user's flow verbatim, "group by platform → navigate the platforms → pick PS3 → see its games".

- `LibraryPlaceStack` (PunktfunkKit) — the desktop's screen stack for the library, as a value
  INSIDE the library layer rather than deeper `GamepadShell` screens (its screen enum is derived
  with depth ≤ 1 by construction; macOS shows the library as a sheet, tvOS as a cover; and the
  desktop's own property is that drilling never touches the model). shelf → Y → collections → A →
  filtered shelf → B → collections → B → shelf → B → dismiss; a Collections ROOT offers Y "All
  titles" as a drill-in; Y is refused with a pulse on any drilled shelf and its hint hidden. The
  flows are tests, not glass.
- `CollectionsHandover.decide` — "start in collections", decided ONCE per shelf: refused when off
  or drilled, loading/empty/error decide nothing, a CACHED catalog counts as ready, and the library
  must be worth browsing (≥ 2 non-launcher groups — a platform-less Steam library is one group, by
  design). The desktop's epoch rule has no counterpart: an Apple shelf's view owns its own fetch.
- `LibraryCollectionsView` — one tile per collated group in the home carousel's tile language
  (same size, glass, recede), with label, count, kind caption, and a DECK of up to three covers
  from the sort-first titles: front 118 tall, each slot back 7 % smaller, 18 pt right, 7 pt up,
  6° turned, drawn back-to-front over HARD contact plates. A launcher fans its brand mark and is
  never fetched; a one-title group never fakes a stack. L1/R1 step the sort WRAPPING (the shelf's
  bar clamps — the desktop draws the same distinction); the sort pills ride the same bar, VIEW
  hidden. `GamepadCarousel` gains `onShoulder` for it.
- `LibraryConsoleView` hosts the places: a filtered shelf is `filtered(games, sort, group)` over
  the same catalog, art loader and running map (nothing re-fetches on drill-in or back); pushes and
  pops move with the shell's own transition; the shelf legend gains `Y Collections` where it
  applies; the title reads `host · profile · collection` on both the pinned iOS title and the
  sheet/cover navigation title. Screenshot scene `11d-collections`.
The fourth slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-4):

- `ConsoleContract.swift` (PunktfunkShared): `ConsoleMotion` — the vectors' `motion_spring`
  numbers (response 0.42, damping 0.88, slide 36, scales 0.985/0.96, reveal 0.4, interruptible)
  — and `GpSettingsTab`, moved out of the executable target so `ConsoleVectorsTests` can reach
  both. `testTabNamesMatchTheSharedVectors` (this client's trailing About is its own; the
  desktop's Input is `desktop_only`) and `testMotionMatchesTheSharedVectors` (the v2 block) join
  the palette test; the vectors' v1 `$deprecated` note now names Android as the last v1 reader.
- `GamepadShellMotion.screen` is `.spring(0.42, 0.88)` — the console's `springs::NAV` — instead
  of the 0.26 s ease-out-cubic; Reduce Motion crossfades on the desktop's `REDUCED_NAV`
  (0.22, 1.0) with no slide and no scale, instead of snapping.
- Interruptible: while a push is in flight no layer owns the controller, so a zero-size
  `MidPushBackCatcher` takes B alone and clears the trigger that raised the top screen — the same
  spring carries it back down, the way the desktop retargets its NAV spring to 0. A and the rest
  stay dropped until the spring has passed 0.85 of its travel (`inputOpensAfter`, 0.20 s), not
  until a tween has finished (0.28 s): the wall is gone, the double-tap protection stays.
- The library layer's place stack rides the same spring and the same reduce-motion crossfade.
The fifth slice of the console-UI parity port (design/apple-console-ui-parity.md, PR-5): the
plain grid draws `LibraryCollation.collate(catalog, sort, groupBy)` — launchers lead, then one
section per group under the grouping, each in the chosen sort — instead of its two fixed
Launchers/Games sections, with a toolbar menu for both. The sort is the shared `librarySort`
key (the same one the console's bar writes); the grouping (`libraryGroupBy`, none/platform/
store) is this grid's own, because on the console grouping is a PLACE (Collections), not a
mode of the shelf, and sections are the touch analogue of that place. `LibraryGridNav` already
took sections, so the keyboard cursor needed no change; ungrouped and launcher-less, the grid
renders exactly as it always did.
Four things the iPad found in the grid arrangement (design/apple-console-ui-parity.md, PR-2/3):

- "Navigation jumps at random." `GamepadMenuInput` resolved the stick per 60 Hz sample and
  fired a move whenever the resolved DIRECTION changed — a single flick to the right passes
  through samples where |y| > |x| on the way out of the dead zone, so one gesture read as UP,
  then RIGHT. Invisible on the carousels (their vertical axis is inert or a menu), a random
  jump on any 2-D field. Now an engaged direction stays engaged until ITS OWN input releases.
- The grid drew over the pinned title and the legend's tray, and rows vanished while "still in
  view": `.scrollClipDisabled()`. Rows are clipped to the viewport again; the margins and the
  heading bands are what give the focused cell's pop and ring their room, and each cell carries an
  invisible halo inside its scroll target so a minimal scroll never shaves them.
- The "focused row rides at 34 %" seat cut the row above half away after ONE step down with the
  whole field in reach — a jump, not a chase. A move now scrolls only as far as it must; the 34 %
  seat is kept for the initial restore, and the top row scrolls all the way up so the heading
  shows.
- The legend gets the bottom tray blur every other console screen has (rows scroll under it).
- "Copy link" is no longer a face-button action on a gamepad UI: X opens the title's Options
  menu (`LibraryTitleOptionsView` — the desktop's per-title menu: Copy link · Cancel, with the
  host-options idiom's title band, explainer and `A Select · B Back`); the field recedes under it
  the way the launcher recedes under a shell layer. Screenshot scene `11e-library-options`; the
  library scenes can now take `PUNKTFUNK_FAKE_LIBRARY` and `PUNKTFUNK_SHOT_INTERACTIVE=1` so the
  grid can be driven from a Mac keyboard.
On glass a single step down read as two impulses: the ScrollView settled, then moved again as
the lazy grid laid rows out under it. Every number in this field is fixed and known (cell,
gap, margin, heading band, label air, all × k), so the grid no longer asks a ScrollView + lazy
layout to guess: `GridGeometry` computes each row's place, `scrollY` is ONE sprung scalar on a
plain offset (`springs::FOCUS`), the viewport clips, and rows are culled by the same arithmetic
(one row of look-ahead across the whole travel) — the desktop console's own model. Moves reveal
the focused cell minimally (ring and pop included), row 0 goes all the way up, a restored cursor
is seated a third of the way down. A finger still drags the field on iOS; the Mac keeps keys,
pad and click (no trackpad wheel on the grid — a small, named trade against the phantom impulse).
A fixed band above the field ate a third of a landscape phone and wrecked the shelf there. ▲ was
already the way to the bar, so the bar is now only there when it is wanted: it slides down over
the field on ▲ (or the legend's `Sort & view` cell, for a pointer) on the console's INDICATOR
spring, with the top tray blur, and back up on ▼/A/B — the field keeps every point of height it
has on every screen. Collections opens the same tray (sort only) on ▲.
The shelf's detail band loses its `STORE · PLATFORM` line (the cover's chip already says it,
and the line only made the band tight); the grid keeps it except in a landscape phone's height.
Compact height, from renders in the iPhone 16 Pro Max simulator: the grid's k floor drops from
0.75 (the Deck's) to 0.5 so a ~290-pt field holds two rows instead of one, with heading bands and
captions floored at legibility; the coverflow holds a little cover height back so the title has
air; the collection tile's deck shrinks to clear its label rail; the shoulder hint hides on any
phone (a Pro Max in landscape is regular WIDTH — the rule is height-aware now) and Collections
advertises `↑ Sort` in its place; a portrait phone's legend keeps A/Y/B (X and ▲ still work) and
the sort/view tray stacks its two groups when the row won't fit.
Grid: the columns now stretch to fill the safe-area width (whatever the last cell would have left
over is shared out, cells keep 2:3) instead of stopping short of the right edge — on a phone the
slack was a fifth of the width — and the k floor goes back to the Deck's 0.75, so the covers stay
poster-sized and the field scrolls; the heading band gets a floor that clears the focused cell's
ring at every k.

Keyboard: the on-screen keyboard tray covered whatever field rows happened to be under it. While
a row is being typed into it now flies from its place in the list to a seat directly above the
keys (matched geometry, the tray's spring) and back when the keyboard closes; its slot in the
list stays empty meanwhile so nothing reflows. Same in the pair ceremony. Shot scene
`08b-gamepad-addhost-typing` (`PUNKTFUNK_SHOT_EDITING=<field>`) renders it.
fix(clients/apple): the keyboard tray — a flight that lands, hardware typing, and Esc that means Done
ci / bun-nix (pull_request) Successful in 26s
ci / docs-site (pull_request) Successful in 1m15s
ci / web (pull_request) Successful in 1m15s
ci / rust-arm64 (pull_request) Successful in 1m34s
apple / swift (pull_request) Successful in 2m3s
apple / distribute (pull_request) Skipped
apple / screenshots (pull_request) Skipped
windows-client / client (arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (pull_request) Successful in 3m1s
android / android (pull_request) Successful in 4m11s
ci / rust (pull_request) Successful in 5m36s
windows-client / client (x64, , x86_64-pc-windows-msvc, C:\t) (pull_request) Successful in 6m36s
a7eadbb680
- The seated row's flight went sideways off-screen while fading: two live matched views with the
  source flag swapped sent the INVISIBLE list row flying, and the seated row merely appeared. Now
  exactly one matched view per id at any time — the list row hands its frame to an invisible
  provider that unmounts when editing starts, so the inserted seated row animates in from it (and
  its removal flies back). Verified frame by frame on the Mac.
- A hardware keyboard types straight into the field while the tray is up (an iPad on a Magic
  Keyboard, a Mac): characters insert (the field's own `allowed` set is the rule — a capital or
  an umlaut the on-screen grid doesn't offer is fine), ⌫ deletes, arrows drive the key cursor,
  Return and Esc are Done, ⌘-anything is left to the system. No text field, so no system keyboard
  is ever raised. State writes are deferred off the key-event dispatch — written synchronously
  they re-rendered the screen mid-delivery and a fast burst lost every other keystroke.
- Esc while typing used to close the whole screen (the screen's cancel shortcut fired first);
  that shortcut is inactive while the tray is up, and Esc is the tray's Done.
enricobuehler merged commit 4ce7a3c11f into main 2026-08-18 11:31:25 +00:00
enricobuehler deleted branch worktree-apple-console-parity-sweep 2026-08-18 11:31:29 +00:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: unom/punktfunk#293