264 lines
13 KiB
Markdown
264 lines
13 KiB
Markdown
# 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).
|
||
|
||
## 6b. The third pass: the glass came out, and forms became bands
|
||
|
||
Two changes, made together because the second one is only possible once the
|
||
first is done.
|
||
|
||
### The glass is gone
|
||
|
||
Nothing in `lib/` is translucent any more. What was removed, and why each one
|
||
was not worth what it cost:
|
||
|
||
| Was | Now | Why |
|
||
|---|---|---|
|
||
| `milerGlassSurface()` — `BackdropFilter` at sigma 18 on every bar | **deleted** | a full-width blur re-samples everything behind it every frame, on the mid-range Android this ships to, over a map that repaints as it pans. It was already rationed to sigma 18, which is the tell that it was tolerated rather than enjoyed. |
|
||
| `glassCard` `#EBFFFFFF` (92% white) | `#FFFFFF` | a translucent card takes its contrast from whatever happens to be behind it — the same address is crisp over the canvas and grey over a map. |
|
||
| `glassSheet` `#E0FDFDFE` (88%) | `#FFFFFF` | every point of transparency was contrast taken off an address read one-handed at a doorstep in sun. |
|
||
| `glassCardLive` `#0D960019` laid *over* the card | `#FAF2F3`, the same tint flattened | with an opaque surface the overlay would cover the content rather than warm it. A live card is a card with a different fill. |
|
||
| `glassRim` `#8CFFFFFF` — a specular highlight | `#E5E7EB`, a hairline | the highlight only read as an edge because the surface under it was see-through. |
|
||
| `glassRed` | **deleted** | its only caller was the blur. |
|
||
|
||
`ColorConstants.tint(accent, amount, {on})` replaces the
|
||
`accent.withValues(alpha: …)` idiom wherever it was painting a *surface*.
|
||
It resolves the composite once against a named ground and returns an ordinary
|
||
opaque colour. Same appearance where the old code was already over white;
|
||
predictable everywhere else. Borders and scrims keep their alpha — those are
|
||
lines and dims, not surfaces.
|
||
|
||
### Forms are bands, not cards
|
||
|
||
`MilerBand` (`app_widgets.dart`). A card is right for a screen showing a *list
|
||
of objects* — a stop, a job, a booking — because the margin is what says these
|
||
are separate things. It is wrong for a screen that is one long thing: a form, a
|
||
review, a receipt. There the page margin buys nothing and costs twice.
|
||
|
||
- **Width.** 32–40pt off a 390pt phone, spent on two strips of empty ground
|
||
beside text that is mostly addresses. On the verification screen that is the
|
||
difference between an address on two lines and one on three.
|
||
- **A second boundary.** The gap between two cards already separates them; the
|
||
outline says it again.
|
||
|
||
So a band runs edge to edge, the page's ground showing between one band and the
|
||
next is the only separator, and the margin moves *inside* as padding — air
|
||
around words rather than air around a box. `MilerBand.pad` (20) and
|
||
`MilerBand.gap` (10) are the two numbers, declared once, so every band's left
|
||
edge lines up down the whole flow.
|
||
|
||
A band cannot carry state in an outline it does not have, so state goes in the
|
||
**fill**, and a finished band adds `rail` — a 3pt accent stripe down its
|
||
leading edge. That is the one mark that survives a phone at arm's length in
|
||
sun.
|
||
|
||
**Which screens took it.** The whole first-mile flow, which is where forms live:
|
||
`stop_verify` (collect · hand over · confirm), `shipment_capture`,
|
||
`shipment_review`, `collect_payment`, `delivery_proof_page`. Each is now a grey
|
||
page (`daylightSurface`) carrying full-bleed white bands — the inverse of the
|
||
white-page/tinted-panel arrangement §6 landed on, and taken for the width
|
||
rather than for the tone.
|
||
|
||
Home, Bookings and Deliveries are **unchanged**. They are lists of objects and
|
||
the card is still the right shape there.
|
||
|
||
## 7. Known gaps
|
||
|
||
This was a foundation pass. It is not a completed product-wide redesign.
|
||
|
||
- ~~**`GlassCard` still carries three separation mechanisms.**~~ Closed by §6b:
|
||
the fill is opaque, and the hairline stays — it is the documented fix for
|
||
shadows crushing on a budget LCD, reported twice from a device, and nothing
|
||
about going opaque changes that. Still worth a look **on a device**.
|
||
- **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. The blur question is moot:
|
||
§6b removed every `BackdropFilter` in `lib/`, so there is nothing left to
|
||
profile.
|