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

13 KiB
Raw Blame History

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.