evidence · 32.9 KiB

shell-polish-review-2026-09.md

Committed at docs/design/shell-polish-review-2026-09.md. This is what grounds the requirements that cite it.

Open raw source

Shell polish review — September 2026

Requested by the operator: “have a design agent review our whole shell setup and make it polished af.” Scope: Home, dock, drawer, shade/quick settings, Settings, the card overview, app switching, transitions/easing, typography, spacing/grid, icon treatment, corner radii, elevation/scrim, theme colour use, touch targets, and empty/error states — judged against Android 14-16 / Material 3 Expressive and webOS, on this hardware’s own terms (568×1232 portrait, RGB565, Pixman software rendering on one slow RISC-V core, touch only, no blur, no extra full-screen passes).

0. What this document is and isn’t

This repo already carries three prior design passes and this one does not re-derive their findings: docs/design/shell-ux-critique.md (25 Sep), docs/design/webos-polish-review.md (24 Sep), and docs/design/handheld-shell/visual-gap-audit.md. All three were written against slightly earlier trees. Re-reading the current source (feat/android-sized-cards at base 2c838d0b, an ancestor of integrate/apps-video-catalog) found that a substantial fraction of their P0 findings are already fixed:

Findings below are new, or are prior findings re-confirmed still open against the current tree with updated file:line citations. Where a finding restates an open prior one, it is cited by ID rather than re-argued. No board or /dev/ttyACM0 access was used to write this document; every claim is a direct source read of this worktree, or a captured evidence file already committed under docs/evidence/. Claims about on-glass feel are marked UNVERIFIED.

1. A number that changes the touch-target finding from “probably fine” to “no”

shell-ux-critique.md §7 flagged, as an open question, that “56 logical px” had never been checked against Material’s 48dp/~9mm guideline at this panel’s actual density. It is now checked:

56px on this panel is under half of Material’s target, because the reference 48dp/9mm figure assumes a ~160dpi baseline device, and this panel is unusually dense (330.9 ppi) with no compensating output scale. This is not a cosmetic nit: at 4.3mm, adjacent controls (e.g. the Settings −/+ brightness stepper, render.rs:1949-1958, or the drawer’s 56×56 minimum tile, navigation.rs:243) sit close to typical fingertip-contact-patch size, raising real mis-touch risk, not just a “could be a little bigger” aesthetic complaint.

Fix, in two tiers:

Cost: zero per-frame cost; this is a layout-constant change, not a rendering-cost change. Priority: P1 (newly quantified; previously UNVERIFIED).

2. Home and the dock

Grid. nix/rust-shell-client/src/home_grid.rs: 4 columns (COLUMNS = 4, matching this panel’s ~1:2.17 aspect to “the same 4-column density most phone launchers settle on,” home_grid.rs:9-14), ROW_HEIGHT = 184.0 fixed pitch, 5 rows fit at 1232px height, SIDE_MARGIN = 22.0, TILE_GAP = 18.0. This is a reasoned, already-documented grid (the module’s own header comment cites the icon-cache-size coincidence: 4×5 + 4-slot dock = 24, exactly icon.rs::CACHE_LIMIT) — no P0/P1 finding against the grid math itself.

Dock. DOCK_SLOTS = 4, deliberately equal to COLUMNS so dock icons align under grid columns (home_grid.rs:15-18), rendered as a translucent themed tray (render.rs:2316-2327, service_card(..., "launcher", ...) reusing the same rounded-panel primitive as Drawer/Shade/Settings — good reuse, no separate one-off dock-chrome code path). Android’s Pixel Launcher hotseat is visually near-identical in concept (a fixed row of app icons on a translucent scrim, independent of which Home page is showing) — no divergence worth flagging.

Finding: dock and grid tiles share the icon-plate primitive, but the dock tray’s corner radius has not been checked against the new card radius token (§6). Once §6’s token set lands, confirm service_card’s existing 16.0 (render.rs:424) is deliberately smaller than the new card radius (24-28px) rather than an accidental mismatch — panels and cards are different visual weights and should use different radii, but the token table in §11 should say so explicitly rather than leave it implicit. Priority: P2. Quick win (a one-line comment plus confirming the token table, no code change expected).

Empty state: not applicable — Home always shows at least the dock; no finding.

3. The drawer

Grid layout is decent and already improved past visual-gap-audit.md’s stale seven-row-list finding: COLUMNS = 3, ROW_HEIGHT = 160.0, TILE_HEIGHT = 148.0 (navigation.rs:4-6), continuous drag/flick/coast scrolling, tested minimum tile 56×56 (navigation.rs:243 — now also subject to §1’s finding). panel_travel_height gives the Drawer a fixed h * 0.81 reveal fraction (render.rs:1379), distinct from Settings’ content-sized approach — reasonable, since a drawer is inherently a “fill the space” scrollable list, not a short card.

Finding: drawer corner treatment is per-tile only; the drawer sheet itself has no visible top-corner rounding distinct from Settings/Shade. All three overlays share one rounded(cr, ..., 16.0) call for their sheet background (render.rs:1416-1474), which is consistent, not a bug — flagged only so §11’s token table records it as the deliberate “sheet corner” token (distinct from “card corner,” §6) rather than leaving two different radius constants (16 here, 24-28 for cards) undocumented as tokens.

Search remains deferred, by explicit prior decision ( the-handheld-presents-a-coherent-shell/design.md decision 2, restated in shell-ux-critique.md §4). Not re-opened here per this repo’s own “record what was rejected and why” discipline — flagged only as a recommendation, not a requirement, exactly as the prior document left it.

4. Settings

P0-2 (dead panel space) and P0-4 (row spacing) are fixed (§0). Remaining, newly-confirmed-still-open finding from shell-ux-critique.md §8:

Settings’ capability rows still have no pressed-state feedback. Checked directly against the current Route::Settings paint block (render.rs:1877-1958): the row loop draws service_card(...) with a fixed selected argument of false for every row — there is no per-row pressed parameter at all, unlike the drawer (DrawerNavigation::pressed, navigation.rs:211-221) or Home grid (home.pressed(...), main.rs:2429). A tap on Wi-Fi/Brightness/Keyboard/Motion gives zero visual acknowledgement until the resulting screen actually changes. Given this device has no haptics (confirmed absent in hardware, motion-research.md), visual press feedback is the only feedback channel — this is a real P1, not cosmetic.

Fix: thread a pressed: Option<usize> row index into the Route::Settings arm (the function already threads a pressed: Option<usize> parameter for Home, render.rs signature at scene(...)) and pass selected: pressed == Some(index) into each row’s service_card call, matching the pattern navigation.rs already demonstrates. Cost: free — service_card already branches on a boolean; this reuses the existing selected-brush tint, no new draw call. Effort: quick win (bounded to service_ui.rs’s Settings tap handling plus this one render.rs loop).

Wi-Fi error banner: webos-polish-review.md P0-3 is unresolved (spot checked: render.rs’s paint_wifi, around the error-text call near w - 84.0 band still reads as a plain colored line, not an elevated banner) — re-filed here at the same priority (P1) rather than re-derived; still owned by that document, not new scope for this one.

5. The shade and quick settings

The shade has changed materially since the last two documents: it now has drag-to-close both ways (panel drag, backdrop drag, or backdrop tap all close it) and a stationary, smoothstep-eased dim backdrop tied to reveal fraction (tray_backdrop_alpha, render.rs:50-79, a 3p² - 2p³ ease-in-out curve; evidence at docs/evidence/card-shell/shade-backdrop-fade/ and shade-drag-to-close/). Both are genuine, already-good motion work — no finding against either. This section compares what’s left against Android 14-16’s two-stage shade (first pull: compact quick-setting tiles + notification list; further pull: expanded tiles + a brightness slider), per surface the coordinator asked to be covered explicitly.

Tile grid: does not exist. There are no quick-settings tiles in the shade at all today. Route::Shade’s paint block (render.rs:1625-1874) draws exactly: a title, a notification-count line, a preview card, and a scrolling history list. Brightness (ServiceRequest::Brightness) and keyboard toggle (ServiceRequest::KeyboardToggle) exist only inside Route::Settings (render.rs:1913-1958), one full navigation hop away — already flagged in shell-ux-critique.md §5 as P1, still open, re-confirmed against current line numbers. This is the single highest-leverage shade gap: Android’s Quick Settings puts the 4-8 highest-frequency toggles (Wi-Fi, Bluetooth, brightness, flashlight, do-not-disturb, airplane mode) one pull away from notifications; this shell requires leaving the shade entirely.

Brightness slider: none. Settings has a discrete −/+ stepper (render.rs:1949-1958), not a drag slider. Android’s Quick Settings shade puts a full-width brightness slider directly under the clock/tile grid on every pull. A slider is a strictly cheaper per-frame draw than a stepper with two extra tap targets (one filled rounded-rect track plus one circular thumb vs. two separate hit-tested glyphs) and reads as more “expressive” — recommend moving brightness into the shade as a slider, not just copying the stepper over.

Clock/date header: none. The shade opens straight into "Notifications" / notification count (render.rs:1499-1507) with no time/date. Android’s shade header is anchored by a large clock the moment you pull down, before any tile or notification content — it is the single largest, most legible piece of text on the whole surface and doubles as a “yes the pull registered” confirmation independent of any list content loading. Cheap to add (one more heading() call, already-decoded system time, no new asset) and high value on a device with no separate lock/status screen.

Notification cards. Confirmed: per-item swipe-to-dismiss exists (notification_swipe_release, referenced render.rs:4483-4532 test), dismiss-all exists (ServiceRequest::NotificationDismissAll), grouping by source does not exist (flat chronological list, NotificationSnapshot) — same as shell-ux-critique.md §5’s P2 finding, re-confirmed, still correctly P2 given this device’s bounded app set (no third-party app store). Clear-all affordance: present as a text action, not (checked) a distinct button treatment separate from ordinary row text — worth a quick visual pass once §11’s tokens land, not filed as its own numbered finding.

Empty state: "No new notifications" (render.rs:1728) — present, plain text, no icon/illustration. Android’s empty Quick Settings/shade state uses a small illustrated glyph, not load-bearing for function but a common “polish” signal. P2, quick win: pair the existing string with the same icon-badge primitive card_icon_badge/card_icon_header already provide on the C side, or a Rust equivalent, rather than inventing new asset infra.

Handle affordance. A pill handle already renders for Drawer and Shade (rounded(cr, w/2.0-36.0, panel_y+11.0, 72.0, 6.0, 3.0), render.rs:1494-1495) — 72×6px, radius 3. This is on the small side next to Android’s typical ~32×4dp handle scaled to this density, but it is a decorative affordance, not a hit target (the whole panel/backdrop drags), so this is P2 at most and arguably fine as-is — flagged only so a reviewer doesn’t conflate it with the real touch-target problem in §1.

Theme colour use. The shade reuses the "notifications" theme section for its panel background/border (render.rs:1453), consistent with the Drawer’s "launcher" and Settings’ "controls" sections — this is the right pattern (one themed section per surface) and not itself a finding. shell-ux-critique.md’s P1-5 (opaque pre-fill guarding only Route::Drawer, risking ghosting through a translucent notifications.background alpha) is re-confirmed still open: render.rs:1463-1474’s route match still lists Route::Drawer | Route::Shade | Route::Settings together for the rounding clip but the opaque pre-fill mentioned in that finding needs a direct re-check against the current line numbers before this document would call it fixed — treat as open, not verified fixed, pending a closer read; not re-derived fully here to avoid scope creep into that document’s own territory.

Recommendation: adopt a two-stage pull, but only the header/tile half, not a second full expansion. On a panel this narrow (568px) a 4-6 column tile grid has enough width for 3-4 columns of ~120px square tiles with labels — workable, but a second expansion stage (Android’s “tap the chevron for more tiles”) adds a second gesture recognizer and a second content-height class for marginal benefit on a device with a small, fixed app/toggle set (this is a personal handheld, not a multi-user phone with dozens of OEM tiles). Recommended shape: one pull, one shade, with (top to bottom) clock/date header → a single row of 4 always-visible toggle tiles (Wi-Fi, Brightness-as-slider-or-tile-that-opens-the-slider, Keyboard, Motion — the same four capabilities Settings already exposes, matching control names 1:1 so nothing new needs a settings-data plumbing change) → the existing preview/history notification list. This reuses settings.network / settings.brightness / settings.keyboard / settings.motion (render.rs:1908-1912) as the tile data source directly — no new service plumbing, only new paint code and touch routing in service_ui.rs. This is Medium effort, not a quick win (new layout, new hit-testing, new tile chrome) and is out of scope for this branch’s implementation commit; recorded here for a follow-on change.

Shade quick wins to fold into this branch’s implementation commit (per the coordinator’s instruction, radius/spacing/type consistent with the card work): none of the shade-specific gaps above are pure token fixes — the clock header and tile grid are new layout, not radius/spacing corrections. The one true token-only quick win is Settings’ pressed-state gap (§4), which is not shade-specific. Decision: no separate shade quick-win commit; the shade’s real gaps (tiles, slider, clock) are Medium/structural new layout, correctly listed here rather than implemented ad hoc alongside a card-radius change.

6. The card overview / recents and app switching

This is the section Part B implements. Current state before this change, confirmed by direct read (nix/card-shell-policy/card-shell-policy.c:42-58, nix/card-shell/adapter.c:2306-2334):

Gap versus the user’s explicit ask (“more like Android does”): Android’s recents carousel shows the focused task at roughly 80-85% of the panel width, phone-aspect-ratio, with only slivers of neighbours visible at the screen edges — a fundamentally different proportion than this shell’s current 50%-width “3-up fan,” which is closer to a card-sorting view than a recents view. Part B below resizes to match.

6.1 How the rounding is implemented, and why it’s already the right mechanism

card_plate_scene (nix/card-shell/render.c:316-349) rasterizes a rounded, optionally-stroked rect once into a Cairo ARGB32 surface at the plate’s exact pixel size, wrapped as a wlr_scene_buffer. card_background (adapter.c:1130-1156) only rebuilds that buffer when the plate’s brush, width, or height actually changes (memcmp/size comparison, adapter.c:1134-1135) — every other frame it is reused as-is from the scene graph, with no per-frame Cairo work and no per-pixel software masking. This is exactly the “compute once per card size and cache” technique the task asks for, and it already exists — Part B’s job is to widen its use (bigger radius, radius that interpolates during transitions) without changing this caching discipline.

6.2 Deck ordering and empty state

No P0/P1 finding beyond what §3 of shell-ux-critique.md already recorded (card order stability UNVERIFIED without a host trace; empty state "No running apps" present and reasonable). Not re-derived.

6.3 Transitions and easing — the one real motion gap

Card expand/collapse is still a fixed-duration linear ramp. card-shell-policy.c:692-707 (cs_tick, CS_EXPANDING):

double duration=p->config.reduced_motion ? 60 : 160;
...
p->expand_progress=fmin(1,elapsed/duration);

elapsed/duration clamped to 1 — linear, no easing curve, no velocity carry-in. This sits next to a different, already-good physics model in the same file: the horizontal scroll coast uses an exponential-decay omega model (cs_tick’s scroll branch, card-shell-policy.c:632-660, omega=p->config.reduced_motion?.12:...) and the two-axis entry-drag settle carries real release velocity (entry_release_velocity_x/_progress, referenced throughout card-shell-policy.c). Re-confirmed still open from shell-ux-critique.md §6, same file:line region, unchanged since that document. Priority: P1. Fix: reuse the entry-drag’s velocity-aware settle model for CS_EXPANDING instead of a bare linear timer, so tap-to-expand feels like the same physical system as the drag gestures. Cost: identical CPU cost class to the current linear tick — both are one fmin/exponential evaluation per frame, not a rendering-cost change. Not implemented in this branch’s Part B (task 3 only adds radius interpolation to the existing linear/pad-driven progress, it does not replace the timing curve) — recorded here as the next motion task.

7. Typography scale

No named type ramp exists on either renderer; sizes are ad hoc per call site. Directly observed sizes across render.rs: 14 (fine-print detail/support text), 16 (Wi-Fi error text), 17 (settings row label), 19 (secondary/medium text), 20 (“Done” action, control values), 25 (brightness ±), 28 (section headings/eyebrows), 40 (large headings/titles). That is eight distinct sizes with no documented relationship between them — not a type scale, a collection of values that happened to look right at their one call site. §11 proposes a five-step ramp that collapses these to a modular scale and names which existing call site maps to which step.

8. Spacing and grid

No shared spacing scale exists; margins/paddings are independent literals per surface: Home’s SIDE_MARGIN = 22.0/TILE_GAP = 18.0 (home_grid.rs:20-21), the deck’s inset = 24/gap = 10 (card-shell-policy.c:44), Settings’ row gap implied by settings_row_y’s formula, the shade’s fixed offsets (panel_y + 44.0, + 76.0, + 130.0, render.rs:1510-1530). None of these are wrong individually — each was clearly reasoned about at its own call site — but there is no single spacing scale a new surface could be built against without inventing its own numbers again. §11 proposes one.

9. Icon treatment

Already covered accurately by webos-polish-review.md P2-2 (icon rendering is fine, vector-crisp via librsvg, not re-derived) and now further improved by the card-header desktop-entry resolution fix (§0). One residual gap worth naming as new, since neither prior document could see it before the fix landed: card_icon_header’s fallback letter-badge path (card_icon_badge, render.c:143-176) and the drawer’s own fallback badge (icon.rs) are two independently-implemented letter-badge painters — same visual idea (rounded square, tinted background, one capital letter), two code paths, one in Cairo/C, one presumably in the Rust icon.rs module. Not a visible defect (both look the same in practice per available evidence) but worth a token/consistency note: if the badge’s tint or corner radius ever changes, both places need the same edit by hand. P2, quick win only if both are touched for another reason — not worth a standalone task today.

10. Elevation, scrim, theme colour use

Scrim: the shade’s tray_backdrop_alpha (§5) is the shell’s only true scrim — a smoothstep-eased fade to a target alpha of 0.35 (render.rs:79, tray_backdrop_alpha(progress, 0.35)) shared by Route::Settings | Route::Shade (render.rs:76). The Drawer has no scrim of its own (it fills the space rather than floating over dimmed content) — consistent with its “browse everything” role, not a finding. No elevation (shadow) anywhere — reasonable given the “no blur, cheap per-frame” hardware constraint; a flat stroke rim (already used on card plates, CARD_PLATE_STROKE_*, §6.1) is the correct cheap substitute for a drop shadow on this hardware, and is already the chosen technique. No finding — recorded so a future reviewer doesn’t propose adding a real blur shadow, which the user has already rejected.

Theme colour use: each surface pulls its brush from its own named theme section ("launcher", "notifications", "controls", card card/ selected), which is the right shared-token architecture — Omarchy themes supply colours, the shell supplies structure. shell-ux-critique.md P1-5’s translucency-ghosting risk (§5, re-flagged above) is the one open theming defect; no new theme-colour finding beyond that.

11. One consistent design-token set

Proposed names, values, and homes. “Shared home” means the literal value (or a generated header/const module) both nix/card-shell/*.c and nix/rust-shell-client/src/*.rs read, so the two renderers cannot drift the way font family already had to be fixed once (§0).

TokenValueWhere it lives todayWhere it should live
radius.sheet16pxrender.rs:424 (service_card’s rounded(...,16.0))keep as the Rust-side overlay-sheet radius; document as distinct from radius.card
radius.card24-28px (this change: see Part B)CARD_PLATE_RADIUS, adapter.c:172same constant, raised; cite from both this doc and the change’s design.md so the two numbers (16 vs 24-28) read as deliberate, not drifted
radius.chip12pxpalette swatches, render.rs:434keep, already proportional to its own small scale
radius.badgefull circle (size/2)card_icon_badge, render.c:150-158; drawer icon.rs fallbackkeep; note both call sites in one comment (§9)
spacing.xs8pxad hocnew shared constant
spacing.sm16pxHome TILE_GAP (18, round down), deck gap (10, round up)reconcile the two nearby values (10/18) toward this one shared step where a surface is rebuilt anyway; not a forced mass edit
spacing.md24pxdeck inset, Home SIDE_MARGIN (22, round up)shared constant
spacing.lg48pxedge_band, several panel offsetsshared constant
type.detail14pxscatteredstep 1 of the ramp
type.body17-19px (reconcile to 18px)row labels, secondary textstep 2
type.action20-21pxbuttons/values (reconcile)step 3
type.heading28pxsection eyebrowsstep 4
type.display40pxlarge titlesstep 5
motion.duration.fast100msreduced_motion scroll/entry durationsalready the reduced-motion floor; keep
motion.duration.standard160-240msCS_EXPANDING (160), entry snap (240)reconcile toward one value once §6.3’s velocity-aware rewrite lands
motion.curve.standard3p²-2p³ smoothsteptray_backdrop_alpha, render.rs:57-75promote from shade-only to the shared curve every progress-driven fade uses, including radius interpolation (Part B task 3)
motion.curve.springvelocity-aware exponential decay (omega model)cs_tick’s scroll/entry branchesthe target curve for §6.3’s future expand/collapse fix
scrim.alpha0.35tray_backdrop_alpha(progress, 0.35)shared constant once a second surface (e.g. a shade tile grid) needs the same scrim
touch.min56px today → 96px quick win → 120px structural (§1)scattered >=56/56.0 literalsshared constant, both sides

Where tokens should physically live: neither renderer currently reads a shared token file — colours already flow C↔Rust through the Omarchy appearance/theme transport (card_appearance/AppearanceSnapshot), which is the existing precedent for “one source, two consumers.” The lowest-risk next step, not implemented in this branch (structural, its own change): add a small generated header (nix/card-shell-policy/card-shell-tokens.h, plain #defines) plus a matching Rust const module (nix/rust-shell-client/src/tokens.rs) built from one source-of-truth file (e.g. a small TOML or the existing Nix module) at build time, mirroring how CARD_SHELL_FONT_FAMILY already had to be manually kept in sync across two files — automate that sync once, rather than re-discover font-family-style drift for every future token.

12. Priority table

#FindingGroupPriorityCost on this hardware
1Touch targets are 4.3mm physical (330.9ppi × 56px), well under Material’s ~9mmGlobalP1Layout-constant only, zero render cost
2Overview card is 50%-width webOS-fan, not Android-recents-sizedOverviewP0 (user’s explicit ask)See Part B — one-time layout recompute, no new per-frame cost
3Card radius is 8px, too small for the new card scaleOverviewP0 (user’s explicit ask)Cached rounded-rect texture, unchanged cost class
4Card expand/collapse motion is linear, not velocity-aware like the rest of the deckOverview/motionP1Same cost class as today’s linear tick
5Settings capability rows have no pressed-state feedbackSettingsP1Free — reuses existing selected-brush branch
6No quick-toggle tiles in the shade; brightness/keyboard need a full hop to SettingsShadeP1Medium — new layout + hit-testing, not this branch
7No clock/date header in the shadeShadeP1Quick win, not this branch (bundled with #6’s layout work)
8Brightness is a stepper everywhere, not a sliderSettings/ShadeP2Medium — new touch model
9Wi-Fi error banner still buried (webos-polish-review P0-3)SettingsP1 (existing)Not re-scoped here
10Translucency ghosting fix may not cover Shade/Settings (webos-polish-review P1-5)Shade/SettingsP1 (existing, re-flagged)Needs a direct re-check, not re-derived here

13. Quick wins / medium / structural

Quick wins (≤1 day each):

Medium (this class of change, not necessarily this branch):

Structural:

14. What still needs the board

FindingDecidable from source aloneNeeds board/glass
§1 touch-target physical sizeyes — direct arithmetic from the panel’s own stated diagonal/resolutionconfirming actual mis-touch rate is a real-finger question, not assumed from the math alone
§4 Settings pressed stateyesnone beyond normal visual review once added
§5 shade tiles/clock/slideryes (absence is a grep)discoverability/reachability of a new tile row needs a real-finger pass once built
§6 card resize + radius (Part B)yes for the geometry; screenshots below are host/QEMUreal-finger flick-through-multiple-cards feel, per this repo’s own evidence discipline for gesture changes
§6.3 expand/collapse motionyes — direct code contrast, unchanged in this branchnot evaluated in this branch; deferred

None of the above claims a physical/optical/real-finger pass. Per AGENTS.md, Part B’s implementation records which evidence class (host build, QEMU, injected event) it actually obtained, and leaves the real-glass gesture-feel acceptance explicitly open.