# Miler — Surface & Elevation System Why the app looked flat, what replaced it, and the rules that keep it fixed. **Scope.** `lib/` only. `lib/xpress` is the ported Xpress-rider lane and is not part of this system. --- ## 1. The diagnosis, with numbers The app did not look flat because it lacked decoration. It looked flat because **the ground did nothing**, and every screen compensated privately. Measured against the page ground: | Token | Hex | Contrast vs canvas | Verdict | |---|---|---|---| | `neutralLight` | `#F2F4F8` | **1.021 : 1** | indistinguishable | | `cardSurface` | `#EDEFF4` | **1.023 : 1** | indistinguishable | | `surface` | `#FCF9F8` | **1.073 : 1** | invisible, and *warm* unlike everything else | | `pureSurface` | `#FFFFFF` | **1.124 : 1** | the one that mattered — and too close | A tonal difference does not begin to read as a *different surface* until roughly **1.25 : 1**, and on the budget LCD this app ships to it needs the top of that band. So a white card on a white page was not a card; it was a rectangle you had to be told about. Every screen that wanted its content to separate therefore grew its own border or its own shadow — and once one screen does that, they all do, each in its own way. That is the mechanism that turns one product into a collection of independently-built Flutter screens. Five near-identical light tokens were also five names for about two decisions. ### The second half of the diagnosis `MilerSheet` — the shared body under Home, Deliveries and Activity — painted **white**. The `daylightSurface` canvas token existed but only two pages used it. So the app's three main screens were a white page carrying white surfaces: a 1.0 : 1 step, which is not a step at all. --- ## 2. The fix: four layers ``` 0 canvas the page itself. Never white. 1 working a region the rider reads or works in. White, no border. 2 raised something that acts. Same fill, plus lift. 3 floating over everything, with a scrim: sheets, dialogs. ``` Declared once in `lib/views/helpers/constants/miler_surface.dart` as `MilerSurface`. Read that ladder when choosing a ground — not the raw colour constants, which is what let five of them drift into meaning the same thing. ### Canvas — `#EEF2F7` → `#DEE3EA` White now separates at **1.290 : 1** instead of 1.124. Surfaces separate on their own, so the borders and shadows screens grew to compensate can come off rather than being piled higher. Nothing was traded for it: body text still clears **13.8 : 1**, and every operational ink clears AA body on both grounds (see §4). ### Which screen stands on which rung The rung is chosen by **what the screen actually draws**, not by preference: | Screen | Ground | Because | |---|---|---| | Home | working (white) | rows on a spine — **no cards by design** | | Activity | working (white) | same — a ruled record, not a stack of objects | | Deliveries | **canvas** | its content genuinely *is* raised objects: the NOW card is a layer-2 command surface and needs a ground to sit on | | Record / details | canvas | one white sheet plus folded groups | | All modal sheets | floating | scrim + glass | > **The trap, recorded because it was walked into.** Painting Home with the > canvas produced a uniformly grey screen — it has nothing white on it to > separate *from*, so the same flatness came back in a different colour. A > darker ground only helps where something white actually stands on it. --- ## 3. Elevation | Layer | Fill | Lift | |---|---|---| | working | `pureSurface` | none — the canvas step does the work | | raised | `pureSurface` | `MilerSurface.raisedShadow` (`shadowSm`) | | floating | `glassCard` | `MilerSurface.floatingShadow` + **scrim** | `MilerSurface.scrim` (`0x66000000`) replaced Flutter's default `black54` on every sheet. **The scrim is the separation; the shadow is not.** A sheet that needs a heavier shadow to be legible is a sheet whose scrim is too weak — the scrim is the knob. ### The one nesting the system allows `MilerSurface.inset` — a quiet tonal block *inside* layer 1, for a fact grid or a folded group. Only when the block is a different **kind** of thing from what surrounds it. **Never for grouping**: grouping is whitespace, type and the timeline, which is why a route reads as a route without a box around each stop. --- ## 4. Colour corrections that fell out of the canvas move Two state inks landed in "AA large only" on the deeper ground and were darkened by ~2% — the hue is indistinguishable at this distance, the arithmetic is not: | Token | Was | Now | On canvas | On white | |---|---|---|---|---| | `acceptGreen` | `#047857` | `#047354` | 4.25 → **4.54** | 5.86 | | `warning` | `#AE5008` | `#A44B08` | 4.12 → **4.54** | 5.85 | Every operational ink now clears **AA body (4.5:1)** on both grounds. Pinned in `test/surface_system_test.dart` — a contrast rule that lives only in a comment is a rule the next hex nudge undoes. ### What the colours mean | Colour | Means | |---|---| | Brand red `#960019` | identity, navigation, primary action — **not** error | | Green | confirmed success, completion | | Amber | genuine attention/exception only — **never** normal pending | | Error red | failure, and semantically distinct from brand red | | Neutral secondary | pending, waiting, metadata | > Pending was amber once. On a fresh morning every kitchen is pending, so the > screen opened in warning and left nothing to say with when a stop actually > went wrong. --- ## 5. Rules 1. **Choose a ground from the ladder**, not from the colour constants. 2. **A container is justified when it is a layer** — a modal sheet, a floating command bar, a grouped operational workspace. Not because the content inside it is a group. 3. **No card inside a card**, and no white card on a white surface. 4. **If a surface needs a border to be seen, the ground is wrong** — fix the ground, not the surface. 5. **Never introduce a one-off colour, radius, shadow or spacing number in a page.** Five near-duplicate tokens is how this started. 6. **Measure.** Any new surface pair needs ≥1.25 : 1 to read as two surfaces, and any ink needs ≥4.5 : 1 on the darkest ground it can land on. --- ## 6. The second pass: the ladder made visible The foundation pass fixed the ground; a device screenshot showed the result still *read* flat — the silhouette of every page was unchanged. The second pass gave the system its visible vocabulary, applied identically on Home, Deliveries, Activity, Account and the sheets: - **Hero heads.** A screen's key figure is a numeral (28sp w800, tabular), captioned small — never a sentence in body type. Home: `15 · stops left` with a 6pt determinate progress bar (canvas track, brand fill, green when done) and `9 of 24 done · ≈4h · 38.9 km` under it. Activity: `4 · delivered today`. Figures clamp at 1.3× text scale; captions ellipsise, numerals never. - **Wells.** Anything that opens — Home's kitchens, Activity's trips — opens into a canvas-toned inset (`radiusLg`, small padding). The well is the ladder's one permitted nesting, and it is the *canvas* tone because that is the only grey that stands on white (1.290:1). - **Tags.** A label a rider matches against the physical world (`Bag 3`) is a small filled tag: white on a well or tinted panel, canvas on white, brand 10% when selected, error 8% for `Missing`. Same shape everywhere — Home rows, the pickup sheet, the confirm sheet, the Deliveries queue. - **Placeholders recede.** An unassigned trip tab drops to `disabledFill` and w600 and says `None yet`; empty slots never compete with real work. - **Account joined the ladder**: canvas ground, the identity + day-figures as one panel, each settings group a panel, labels on the canvas between them — scoped to the Account tab so the eight other `settings_ui` screens are untouched. - **`neutralLight` is not a fill on white.** It measures 1.02:1 there; every such use found on these pages now takes the canvas tone. The token keeps its other jobs. Pinned by: `home_gutter_test` (hero and route share an edge), `stop_row_alignment_test` (the well's indent is bounded), `home_structure_test` (one well, tags under 120pt, no borders or shadows in a group), `activity_list_test` (a sheet is `working` or `canvas`, never the warm off-white). ## 7. Known gaps This was a foundation pass. It is not a completed product-wide redesign. - **`GlassCard` still carries three separation mechanisms** — fill, border and shadow. Its rim is documented as a fix for shadows crushing on a budget LCD, reported twice from a device, so it was left alone rather than changed on a guess. It should be revisited *on a device* now that the ground works. - **No typography or spacing consolidation.** Arbitrary `.sp` values still exist across pages; `MilerType` was not rationalised into a smaller scale. - **Icon families are still mixed.** Lucide was adopted for Activity and the status vocabulary; Material rounded remains widespread elsewhere. - **Account/Profile was not audited or migrated.** - **No device or text-scale QA** of the new ground, and **no GPU profiling** of `milerGlassSheet`'s blur — so no blur change was made.