Files
doormile_milderapp/DESIGN_SYSTEM.md
2026-08-28 11:13:15 +05:30

264 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.