Design system - MilerSurface ladder (canvas → working → raised → floating) with MilerPanel as layer 1; canvas moved to #DEE3EA so white separates at 1.290:1. - Visible vocabulary applied across Home, Deliveries, Activity, Account and the sheets: hero heads (tabular numeral + small caption, clamped at 1.3x), canvas wells for anything that opens, small filled tags for shelf labels, demoted placeholders. Recorded in DESIGN_SYSTEM.md §6. - One icon family: 222 Material glyphs migrated to Lucide; none left outside lib/xpress. - Colour semantics corrected: amber only for what is genuinely owed, brand red reserved for the live stop, disabled primaries go neutral rather than pale. Data and lifecycle - lib/data/lifecycle.dart reads mutations for what they prove; route_order.dart makes admin sequence the single ordering authority; service_day.dart, and stop_area.dart rewritten against live Coimbatore addresses (digit-token stripping, city stoplist, street suffixes, stammer collapse). - countLabel states the load once, in bags. Testing - 1440 tests passing; golden shot harnesses for Home, Deliveries, Activity, sheets and verify, with test/failures/ now gitignored (diff debris). - New pins: home_gutter_test, stop_area_test, plus updated structural bounds. Note: this commit also carries pre-existing working-tree deletions that were present before this work (API_SPEC.md, README.md, demo test fixtures). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
9.1 KiB
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
- Choose a ground from the ladder, not from the colour constants.
- 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.
- No card inside a card, and no white card on a white surface.
- If a surface needs a border to be seen, the ground is wrong — fix the ground, not the surface.
- Never introduce a one-off colour, radius, shadow or spacing number in a page. Five near-duplicate tokens is how this started.
- 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 leftwith a 6pt determinate progress bar (canvas track, brand fill, green when done) and9 of 24 done · ≈4h · 38.9 kmunder 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% forMissing. Same shape everywhere — Home rows, the pickup sheet, the confirm sheet, the Deliveries queue. - Placeholders recede. An unassigned trip tab drops to
disabledFilland w600 and saysNone 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_uiscreens are untouched. neutralLightis 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.
GlassCardstill 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
.spvalues still exist across pages;MilerTypewas 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.