Four passes, and the shape they landed on. The type face is Plus Jakarta Sans (variable, wght 200-800), which brings a fix with it: it carries the rupee glyph and Switzer does not, so prices stop being set in Geist Mono to work around a missing character. Mono stays where it is earned - references and phone numbers, read digit by digit. Surfaces lift rather than outline. Cards carry two very soft shadow layers instead of a hairline, because eight outlined boxes down a screen read as a wireframe. The tab bar floats as a pill again for the same reason it was right to: it is now the same kind of object as everything above it. Screens: * Home is the greeting, the address, the sphere and one card. The card lost its progress bar - a filling line says "wait", and a parcel two days into a journey is not something anyone is waiting through - and gained the size that buys. * Orders cards are four bands: identity, destination, route, and whatever is happening right now. Plus a search field, because the list is the archive. * Tracking leads with the state at display size, then TRIP MILESTONES with a step counter, then the courier. * Review is a route thread over two particular cards. * Account opens on the person: avatar, name, and two counted figures. Three real bugs the redesign surfaced: * Quick dispatch handed `loadCities()` straight to a FutureBuilder, so the catalogue was refetched on every rebuild and Home never settled. * Order cards showed the whole visit's weight on one destination's row - somebody else's parcel. Per group now, and only once actually weighed. * The pickup window was printed beside "In transit", where it reads as a delivery time nobody promised. Nothing invented. The reference shows EXPRESS PRIORITY, CARBON OFFSET, CONCIERGE ELITE and hub-to-hub routing; this backend sends none of them, so they are absent rather than mocked up. flutter analyze: clean. flutter test: 88 passing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EqVJPB9B4QuieZnBAAKgYQ
41 KiB
Doormile Customer App — Design
How this app is designed, why, and where each decision lives in the code.
Companion to README.md, which covers running and shipping it.
Rewritten 2026-09-22, then revised the same day after the effort pass (§10). The version before it described
#960019, Inter and "lift, don't outline" — a system two rebuilds out of date. Every value below was read out oflib/ui/tokens.dartas it stands; nothing here is aspirational. The old document is in git history.
0. Three generations, and what each one fixed
Reading the code makes more sense with the history, because several comments in it argue with a decision you can no longer see.
| What it was | What was wrong with it | |
|---|---|---|
| v1 | Inter, #960019, soft shadows everywhere, cards for everything |
Every surface floated, so nothing could be emphasised. A screen that drops shadows everywhere has no way left to say "this one matters". |
| v2 (2026-09-15) | Manrope + Geist Mono, #8F0F06, hairline edges, 8–12pt corners |
Correct, legible, and inert. Edges made hierarchy possible but made everything rectangular — including the thing the app exists for. |
| v3 (2026-09-22) | The BOOK sphere, an opened-up radius scale, entrance motion | This pass. v2's failure mode was that it looked drawn rather than designed: shapes a program emitted, all terminating in a hard 1px line. |
v3 did not throw out v2. The colour, the type and the "edges, not elevation" rule all survive. What changed is that the app now has one lit object in it, the corners stopped reading as a form, and screens arrive instead of appearing.
1. Principles
Five rules settle most arguments here.
- One decision per screen. A screen asks one thing and offers one dominant action. Everything else is secondary or absent.
- Edges, not elevation. Surfaces separate through a 1px warm hairline and a corner, not a shadow. Shadow is reserved for three things: the committing action, a control that genuinely floats over a map, and a sheet.
- Optional must look optional. Anything skippable is folded away and, when skipped, described calmly — never with warning styling.
- Customer language only. No consignment, waybill, AWB, MPS or piece. The customer books a pickup and sends packages.
- Complexity appears only when asked for. Multi-destination, multi-package and every advanced field stay folded until someone reaches for them.
- Every sentence earns its place. If removing a line would not make the customer choose worse, the line goes. Hierarchy, spacing, position, state and motion carry the meaning first; text carries what is left.
2. Foundations
All of this is lib/ui/tokens.dart. No screen hardcodes a value. The one
deliberate exception is the BOOK sphere, which mixes its own gradient stops —
see §4.
2.1 Colour
One accent at three intensities. Warm neutrals, never pure black. Semantic colour used sparingly and only with meaning.
| Token | Hex | Used for |
|---|---|---|
brand |
#8F0F06 |
The header field, the primary action, the sphere |
brandPress |
#730C05 |
Pressed state, and crimson text on a brandSoft wash |
brandSoft |
#FBF3F2 |
Selected options, wash cards, brand chips |
brandLine |
#F0DEDC |
That wash's own edge, and quiet brand outlines |
ink |
#191716 |
Primary text |
ink2 |
#5C5654 |
Supporting lines, lede paragraphs |
ink3 |
#6E6864 |
The quietest line — timestamps, counts, captions |
ink4 |
#B4AEAA |
Placeholders, disabled labels, chevrons |
canvas |
#F7F6F4 |
The page |
surface |
#FFFFFF |
Cards, sheets, the tab bar |
surfaceAlt |
#F1EFEC |
Filled inputs, quiet chips, tiles, skeletons |
hairline |
#F1EFEC |
The rule between two rows of one card |
border |
#E8E5E2 |
A card's own edge — the workhorse of this design |
onBrand2 / onBrand3 |
#FFD9D4 / #FFD1CB |
Text on a crimson field |
onBrandSunken |
14% black |
A recess cut into the header |
onBrandRaise |
18% white |
A control resting on the header |
ok / okSoft |
#0A6B56 / #E2F4EF |
Delivered, and only delivered |
danger |
#B3231F |
Destructive labels |
locationDot |
#1B6EF3 |
"You are here" on a map |
Two rules worth stating out loud.
Text on crimson is a tinted rose, not translucent white. rgba(255,255,255,.8)
over #8F0F06 goes grey and dirty; #FFD1CB keeps the warmth. Both onBrand
tokens are opaque colours for that reason.
The location dot is deliberately not the brand. On a map crimson already means the pickup pin, and blue is the convention every customer reads as their own position without being told.
2.2 Typography
Switzer for voice, Geist Mono for data. Switzer (Indian Type Foundry, via Fontshare, ITF Free Font License — commercial use permitted) is the app's voice as of 2026-09-23.
It is a neue-grotesque: flat-sided bowls, near-vertical terminals, a
straight-tailed y. It was chosen against a reference whose own face is almost
certainly PP Neue Montreal or Neue Haas Grotesk Display — both commercial, and
neither identifiable with certainty from a skewed mockup. Switzer is the
closest thing that can actually be bundled.
One variable file again, 141 KB. Poppins held this slot for a day and has
no variable release: four static cuts at ~155 KB each, 625 KB of sans, and for
that release the sans styles carried fontWeight alone because
fontVariations on a static font is ignored in silence. The axis is back, so
every style below sets both — the axis does the work, the enum keeps the
semantics and the system-font fallback correct.
Tighter than anything before it. A grotesque at display size wants pulling in hard, and the reference sets its headlines almost touching: the header runs at −1.4. Body sizes stay near zero. Tracking belongs to a typeface, not to a design, and this is the third time these numbers have been rewritten for that reason.
Letter spacing is authored in em by the design and converted per size, because −0.035em is a different number of pixels on a 34pt header than on a 15pt row title, and splitting the difference softens exactly the heads meant to feel tight.
| Style | Size / Height / Weight | Tracking | Used for |
|---|---|---|---|
headerTitle |
34 / 1.0 / 800 | −1.4 | The tab root's own title |
display |
31 / 1.1 / 800 | −1.1 | The one dominant line on a screen |
title |
25 / 1.16 / 800 | −0.8 | Screen headings on canvas |
sheetTitle |
21 / 1.2 / 800 | −0.65 | Bottom-sheet titles |
heading |
18 / 1.25 / 700 | −0.45 | The head above a group of cards |
cardTitle |
15 / 1.4 / 600 | −0.2 | A row's own line — the workhorse |
body |
15 / 1.5 / 500 | −0.12 | Paragraph text |
lede |
14.5 / 1.55 / 500 | — | Paragraph under a screen heading |
small |
13.5 / 1.45 / 500 | — | The supporting line under a row title |
tiny |
13 / 1.4 / 500 | — | The quietest line |
label |
12.5 / 1.3 / 700 | — | Field labels, tags, inline actions |
eyebrow |
10.5 / 1.3 / 700 | +1.16 | Uppercase micro-label above a group |
headerEyebrow |
11.5 / 1.3 / 800 | +1.84 | That eyebrow on crimson |
mono |
13 / 1.4 / 500 | −0.26 | References, plates, phone numbers |
monoLg |
19 / 1.2 / 500 | −0.38 | A reference on its own line, or a fare |
What goes in mono. Anything a customer might read back aloud or check digit-by-digit: a booking reference, a phone number, a fare, a plate, a countdown. Mono makes the glyphs line up, so a transposed character is visible. Nothing else goes in mono.
2.3 Shape
The whole scale was opened up on 2026-09-22. This is the single change that did most for the "it looks boxy" complaint, and it is four lines of token.
| Token | Was | Now | Used for |
|---|---|---|---|
xs |
8 | 10 | Tiles, chips, the small square holding a glyph |
sm |
9 | 12 | Buttons, fields, icon buttons |
md |
10 | 14 | Cards and every panel that holds rows |
lg |
12 | 16 | The segmented control, a grabber, small panels |
xl |
16 | 20 | Bottom sheets, the auth sheet |
header |
18 | 26 | The brand header's bottom sweep |
book |
= sm |
16 | The primary action bar |
At 8–12 the app measured correct and looked like a form. Four points across the scale is the difference between a panel and a card.
xl moved to 20 for a second reason: at 16 it had collided with lg, and a
sheet that shares a corner with the cards inside it has stopped being a separate
surface.
book was an alias of sm and is now its own value, because the committing
action is the one element on a screen allowed to look like an object rather than
part of the form.
2.4 Elevation
Four shadows exist. If you are reaching for a fifth, the answer is an edge.
| Token | What it is for |
|---|---|
lift |
The one committing action on a screen, and a selected slot |
liftSm |
The same, for chips and half-width controls |
float |
A control genuinely hovering over a map |
sheet |
A bottom sheet's lift off the page |
lift and liftSm are crimson shadows (#8F0F06 at 50%/65%), not black.
A black shadow under a crimson button reads as dirt; a crimson one reads as the
button glowing.
2.5 Spacing
pad 24 on canvas content, headerPad 22 inside the crimson field — the
header reads tighter than it measures, so it takes the smaller inset. tap 48
minimum target. button and field are both 54, so a form and its action share
a rhythm.
2.6 Motion
| Token | Value | For |
|---|---|---|
fast |
140ms | A press giving way |
base |
250ms | Most state changes |
slow |
460ms | A screen-level reveal |
ease |
easeOutCubic |
Everything, unless there is a reason |
glide |
Cubic(0.34, 1.06, 0.36, 1) |
The bottom nav's travelling pill |
glide carries a touch of overshoot, which reads as the indicator catching up
rather than teleporting.
Page transitions (_DmPageTransitions in main.dart) are a shared axis.
The incoming page travels 14% from the right, scales 0.97 → 1 and fades over
the first third of the distance; the page it covers slides 9% to the left and
dims to 60%. Both surfaces move, which is the whole difference between a push
that reads as movement and one that reads as a slide projector. DmMotion.enter
is an emphasised decelerate — easeOutCubic is too even over a distance this
short. Timing lives on DmPageRoute: 420ms forward, 340ms back, because a
customer going back already knows what is behind them.
Haptics are two calls and no more. selectionClick() for anything that
changes a selection — a tab, a chip, a stepper, a radio. mediumImpact() for
committing: booking, cancelling, submitting an OTP, and pressing the sphere.
3. Entrance animation
Added 2026-09-22 with flutter_animate 4.5.2, the app's only animation
dependency (lucide_icons_flutter is the other design one — every glyph in
the UI comes from it, at one 1.5px stroke weight, so an icon sits at the same
visual weight as the type beside it instead of shouting over it). It exists so content arrives instead of being there:
a screen that paints fully formed in one frame reads as a document, not an app.
Home's three elements are choreographed, not animated independently:
| Element | Delay | Motion |
|---|---|---|
| The sphere | 0 | fadeIn 420ms + scale 0.82→1 over 620ms, easeOutBack |
| "Happening now" + card | 160ms | fadeIn 380ms + slideY 0.12, 70ms interval between the two |
| "How it works" | 320ms | fadeIn 520ms + slideY 0.06 |
The sphere's easeOutBack is the only overshoot on the screen. It is there
because the sphere is the subject: overshoot on a supporting element reads as
jitter, on the subject it reads as arrival.
The gotcha, so nobody rediscovers it
flutter_animate starts its controller from a zero-delay Future.delayed.
Under the test binding's fake clock, that timer only fires on the pump after
the widget was built — so the controller begins life part-way through the first
elapse. The snapshot harness settled in two pumps and photographed a completely
blank screen, which looked exactly like a layout bug.
_settle() in test/design_snapshot_test.dart now runs four extra 400ms
frames. Everything on screen finishes inside a second, so the extra frames cost
nothing and the pictures are of settled screens.
4. The BOOK sphere
lib/ui/widgets/book_orb.dart. Home's only action, and the app's one lit
object. It is documented at length here because it is the piece most likely to
be copied, and the details that matter are not obvious from the code.
4.1 Why a circle, and why one word
Home used to be a filled rectangular card holding a heading, a sentence and a white button. Correct, and completely inert — three stacked rectangles on a screen of stacked rectangles, where the thing the app exists for looked like the things it does not.
A disc is the one shape nothing else on the screen uses, so it needs no border, no label and no arrow to be found. It is also the shape the gesture wants: a thumb lands on a circle, and the press scales from the centre without corners doing anything awkward.
The word alone is not enough. An earlier 68pt BOOK bar was removed for a reason recorded at the time — "a word with no object, which on first run told a new customer nothing about what the app does" — and that is still true. The caption under the sphere does the explaining. The boldness is in the shape; the sentence is what stops it being a shout.
4.2 The three things that separate designed from drawn
The first version of this was a flat crimson circle with two 1px rings around it, and it read as exactly what it was: shapes a program emitted. Everything below exists to fix that, and the same three rules apply to any object you add.
1. Light has a direction. Four layers, all obeying one source up and to the left:
| Layer | Value |
|---|---|
| Fill | RadialGradient, centre (-0.38, -0.52), radius 1.08, #C0230F → #8F0F06 → #5C0802 at stops 0 / 0.5 / 1 |
| Specular | RadialGradient, centre (-0.42, -0.58), radius 0.62, white 25% → transparent |
| Rim | LinearGradient stroke, top→bottom: white 35% → white 5% → #4A0602 20% |
| Inner hairline | White 8%, inset 15pt |
The rim is the layer people skip, and it is the one that sells it: bright where the light grazes the top edge, dark where the body turns away at the bottom. A uniform 1px border flattens the whole thing back into a circle.
The inner hairline is barely visible and is the reason the face reads as a made object rather than a filled shape.
2. Nothing terminates hard. Every ring is drawn through
MaskFilter.blur, so it has a soft shoulder instead of a 1px edge. A crisp
hairline ring is a drawn shape; a ring with a shoulder is light.
The glow is two shadows at different radii — a tight one for contact with the
page (#8F0F06 28%, blur 26, spread −16, offset y 14) and a wide one for the
light it throws (#8F0F06 10%, blur 52, spread −18, offset y 20). Both are
pulled in hard with negative spread. At full strength they bloom into a pink
cloud that reads as a smudge under the object rather than as its shadow — that
was the first draft, and it looked cheap.
3. Something is always moving, slowly. Two clocks:
- Pulses — three rings on a 6s repeat at phases 0 / 0.34 / 0.67, born at
the sphere's edge so they look emitted rather than drawn around. Travel is
eased
1 − (1−p)^2.4(quick to leave, slow to die, which is how a real pulse decays; linear travel reads mechanical). Opacity falls as(1−p)² × 0.42and the stroke thins from 2.2 to 1.0 as it expands, like a wavefront losing energy. - Comet — a short arc of light orbiting 16pt outside the sphere on that
same clock, with a brighter head at its leading edge. A
SweepGradientwhose tail dies over a third of the circle. - Breath — the sphere itself, 1.0 → 1.012 over 4s, mirrored, on its own controller because it reverses and has no business being locked to the radar's six.
The pulses are not decoration. This app sends somebody to your door, and a radar sweep is what that looks like — the same reason a ride app pulses over a pickup pin. They are slow and low-contrast on purpose: a fast pulse reads as an alarm, and this sits under a thumb for as long as somebody is deciding.
4.3 The press
AnimatedScale to 0.94 over 140ms easeOutCubic — just past the press, so the
finger feels the give rather than watching it happen — plus
HapticFeedback.mediumImpact().
The haptic matters more here than on a normal button: a sphere gives no edge feedback the way a bordered control does, and this press commits you to a flow.
The pulses and comet are wrapped in IgnorePointer. They leave the sphere's
edge, so a tap near them is a tap that was meant for it.
4.4 Geometry
204pt disc in a 320pt field. The 116pt of padding is where the pulses live.
5. Component library
lib/ui/widgets/. A screen composes these and does not draw.
| File | What it holds |
|---|---|
book_orb.dart |
The BOOK sphere (§4) |
buttons.dart |
Primary bar, quiet button, icon button, text action |
cards.dart |
DmCard + DmCardCell + DmCardRow, DmMicroHead, DmTile |
chrome.dart |
DmTopBar, DmStepBar, DmBrandHeader, DmHeaderTrack, DmArcField, the segmented filter |
inputs.dart |
Text fields, DmSearchField, settings rows, the OTP boxes |
option_tile.dart |
A selectable option row |
pieces.dart |
Steppers, chips, the pickup-window pieces |
milestones.dart, route_rail.dart |
The journey ladder and the pickup→drop rail |
map_panel.dart, route_map.dart, map_tiles.dart |
Real maps over OSM/CARTO |
states.dart, feedback.dart |
Empty, error, loading, snackbars |
misc.dart |
DmPill, DmLiveDot, DmAvatar, the logomark |
5.1 DmCard
A bordered panel that stacks rows and rules them apart. Two decisions in it are load-bearing:
The rule belongs to the card, not the row. A row does not know whether it is
first, so the same DmCardCell works alone or in a stack of five.
The edge is a foreground decoration. As a background border it was painted first and then covered: a cell with its own tint — the washed status strip at the head of an order card, the crimson rows on the booked screen — filled the whole box, border included, and the card lost its outline exactly where it needed one most.
Four tones: plain (white, hairline edge — almost everything), fill (crimson,
one per screen at most; it is the screen's answer to "what is happening
right now"), wash (a crimson tint for supporting brand information) and
sunken (the canvas itself, edged, for deliberately inert content).
5.2 DmBrandHeader
The crimson field at the top of each tab root: eyebrow, title, an optional trailing action, and a control belonging to that destination.
It carries a LinearGradient top-left to bottom-right (#A0150A → #8F0F06 →
#7A0C04) so the field has a direction like every other brand object. The
spread is deliberately narrow — a header that reads as a gradient rather than
as a colour has stopped being the brand.
DmArcField strikes thin white arcs from the mark's geometry at 15%. Authored
on a 340pt square anchored top-right and then scaled, so the same geometry
reads identically on a 210pt Orders header and a 260pt Account one. Decorative
only, and never announced to a screen reader.
6. Screens
6.1 Auth
auth_scaffold.dart gives login, sign-up and OTP one shell.
Login was rebuilt to the shape top logistics apps use: one question, one field, one button. No label above the field, no info banner, no badge, a compact banner, and a title that names exactly what is wanted — "Enter your mobile number". "Use email instead" sits below as a text action, not a second button. Every piece of explanation removed from this screen was explanation about a field the customer was already looking at.
6.2 Shell
Home · Orders · Account, with a travelling crimson pill on DmMotion.glide and
a selectionClick() per switch.
6.3 Home
The screen this redesign was called in for.
DmBrandHeader WELCOME, <first name>
Send a parcel / Send your first parcel
▸ pickup address, changeable in place
canvas ◉ the BOOK sphere
caption: what will happen
HAPPENING NOW (only when something is live)
the active booking, tappable through to tracking
HOW IT WORKS (first run only)
three steps on a rail
The pickup address lives in the header as a live value, not as the first question of the flow. It is the one fact every booking starts from, so booking begins at the destination — two taps from here to a reserved slot.
"How it works" used to be a bordered card holding three numbered rows, which put a box around the quietest content on the screen and made it compete with the sphere above it. It is a timeline now: glyph, hairline, text, nothing enclosed. It recedes, which is the whole job of an explainer a returning customer will never see again.
6.4 Booking
send_screen.dart is one screen, not four: destination, address, window and
package count in a single pass with a sticky estimate and Book pickup.
Optional fields — landmark, recipient name, recipient phone — are folded behind one disclosure that reports its own state: "Add landmark or recipient details" when empty, "2 of 3 extra details added" when not. A count is the only summary worth showing for a group of optional fields; listing them defeats the fold.
6.5 Tracking
Driven entirely by JourneyStage. One crimson fill card answers "what is
happening right now", the milestone ladder answers "where in the journey", and
the map answers "where physically".
6.6 Orders, receipts, sheets
Orders is a segmented filter recessed into the header — Active / Completed / Cancelled, each with its count, the selected tab lifting out as a white pill. One control, three states, no separate count row.
Sheets (destination, place_search, window, cancel) are 20pt-cornered,
DmShadow.sheet, and each is a single decision.
7. Accessibility
- No text-scale clamp. Components adapt instead: rows grow, important text
wraps, and ellipsis is reserved for secondary information. Three tests in
booking_flow_test.dartrender key screens at large accessibility text. - The sphere is one
Semanticsnode reading "BOOK. A Miler collects from your door, in a window you choose." — label and caption merged, because two nodes would make a screen reader announce a button and then an orphan sentence. - Decorative paint (
DmArcField, the pulses) is never announced. - Minimum target 48pt (
DmSpace.tap).
8. How to see the design
flutter test test/design_snapshot_test.dart --run-skipped --update-goldens
Renders every screen at 390×844 @3x with the real fonts and writes them to
test/snapshots/. This is a design check, not a regression check — a
deliberate redesign fails all of them, which is worthless as a test signal, so
the snapshot tag is skipped in a normal run (dart_test.yaml).
It exists because the only honest way to review a visual rebuild is to look at it, and this produces the same pictures on any machine without a device attached.
design/screens/ is the browsable copy — the same 17 files under readable
names, with an index. It is a copy, and a stale copy is worse than none, so
tool/screens.sh refreshes it in one command after the goldens are rendered.
The harness loads Switzer, Geist Mono and the Lucide icon font first — without that, Flutter's test harness draws Ahem's black boxes and hollow squares and the pictures are worthless.
9. Where design lives in code
| Decision | File |
|---|---|
| Every colour, radius, type style, shadow, duration | lib/ui/tokens.dart |
| Theme, page transitions, system bars | lib/main.dart |
| The one lit object | lib/ui/widgets/book_orb.dart |
| Entrance choreography | lib/ui/screens/home_screen.dart |
| Reusable surfaces | lib/ui/widgets/ |
| Greeting, relative time, date labels | lib/ui/format.dart |
| The pictures | test/design_snapshot_test.dart → test/snapshots/ |
10. The effort pass (2026-09-22)
v3 made the app look designed. It was still text-heavy, form-like and explanatory, so a second pass ran every screen through one test: if I remove this sentence, does the customer make a worse decision?
10.1 The flow changed shape
BOOK used to open the whole form, with the destination as a horizontal strip of cards halfway down it and a disabled button reading "Pick a city to see the price". The destination is the only choice that moves the price, so it is asked first, in a sheet that rises from the sphere:
Home ▸ BOOK ▸ Where is it going? (sheet: search · recent · all cities)
▸ Send a parcel (address · packages · window · price)
▸ Pickup booked
Nothing was added. The send screen lost a block and gained a price on arrival.
10.2 What each screen stopped saying
| Screen | Removed |
|---|---|
| Home | The sphere's caption for returning customers; the live booking's four-cell card (pill, reference, route rail, courier sentence, window, Track button) → one row; the explainer's three subtitles |
| Send | The city strip and its head; the pickup wash card with icon tile and three lines → one quiet line; "Street address" above "Flat or house number"; the window/package card; three sentences about weighing and charging → one line |
| Window sheet | "Collecting from …", which the screen behind it was already showing |
| Confirmed | "We've reserved your slot. A nearby Miler is being assigned…"; the "Keep the parcel ready" banner; the duplicated pickup line |
| Tracking | The supporting sentence under the headline; "Step 2 of 7"; the milestone label beside it; "Tracking your parcel live" and its Refresh action (now pull-to-refresh); "All 7 stages" → "Full timeline" |
| Orders | The crimson reference strip → a quiet identifier on the meta line; "Collected on DM-471200" |
| Account | The four washed crimson icon tiles; the crimson "Preferences" head |
10.3 The second round
The customer chooses their own window. Picking a city used to silently take the first available slot — the customer arrived at a booking carrying a time they had never agreed to. A pickup window is a promise about somebody's afternoon. The window sheet is now a step in the flow, between the destination and the form, and the button stays disabled until it has been answered.
The primary button is lit. It was a flat crimson rectangle at the field
radius — DmRadius.book existed for exactly this and had never been wired up.
It now takes the sphere's gradient and rim highlight, the crimson glow it
already had, a 0.985 press scale (it has no border to shimmer) and a medium
haptic, where everything else gets a selection click.
Floating labels. An empty field under its own caption is two rows to ask one
question. DmTextField(floating: true) puts the label inside the field and
floats it out once there is something to caption — so the field is never blank
and the caption only appears when its job is reminding you what you typed.
FROM / TO instead of two blocks. The send screen's pickup and destination were seven strings and the word "Change" twice. They are one tappable thread now: two nodes, two eyebrows, two chevrons.
10.4 The bars
The top bar knows what is under it. It was a flat slab of canvas, the same
colour as the page, with no line: content scrolled beneath it and simply
vanished. It now reads the ScrollNotificationObserver that Scaffold
installs — the same mechanism Material's own AppBar uses — and turns from
canvas to surface with a hairline the moment a list is under it, back to
invisible at the top of a page. test/top_bar_test.dart holds that behaviour,
because the observer is a framework detail this app does not own.
Back lost its box. A 40pt white square with a hairline and a 12pt corner, on a canvas of white bordered cards, read as a card containing an arrow. It is the glyph, a 48pt target, and a circular ripple in the brand wash.
The bottom bar's pill stopped glowing. It carried DmShadow.liftSm — the
65% crimson glow built for a committing button — which at the bottom edge read
as a smear rather than as lift. A quarter of that now, closer to the shape.
The glyph also pops to 1.14 and settles on the pill's own overshoot curve, so
the two read as one gesture, and a pressed tab scales to 0.9: without it, the
only feedback for a tap was the pill starting to move, which on the tab you are
already on is no feedback at all.
10.5 State, then district
The destination was one flat list of every serviceable district, on the argument recorded in the old code that "nobody picks Tamil Nadu on the way to picking Chennai". That is true of twelve districts and false of sixty: the list this app will have in a year is a scroll, not a glance.
Browsing is two steps now, in the same sheet — states, then that state's districts, with a back arrow that returns to the states rather than dismissing the question. But search cuts straight through: typing at the first step searches districts across every state and returns them flat, because somebody who knows they are sending to Chennai should not have to know which state it is in. Hierarchy for browsing, flat for search — the old insight kept where it was actually right.
The states are derived from the district list, not fetched. loadCities
already returns every serviceable district with its state attached, so grouping
it is one request instead of two and is the only version that cannot show a
state with nothing open inside it. The city count per state comes free.
Marks go on states, not districts. An earlier pass marked every district — Chennai the capital, Coimbatore a factory, Tiruppur knitwear. It scanned well at twelve and does not survive sixty, and more to the point it put the app in the business of having an opinion about every district in India: a table nobody can keep honest and a mistake somebody eventually finds offensive. There are twenty-eight states; that is a bounded problem. District rows carry the name and the delivery promise, and nothing competing with them.
lib/ui/state_marks.dart holds the table. One thing about the place a person
living in India would recognise, and never a religious building — a temple
state drawn as a church is worse than no icon.
The fallback is the contract. stateMark() answers for every code,
including ones that do not exist yet: a state opened by the backend appears
here immediately, wearing the pin and looking deliberate, until somebody adds a
line. Adding a state is one entry; forgetting to is not a bug.
10.6 The stripping pass
The window sheet is times. Each row carried up to four things: a "Fastest pickup" tag, "4 Milers nearby", the window, and a caption like "Relaxed evening handover". Three of those were the backend selling a slot to somebody who had already decided to book. An unavailable slot keeps its note, because "Fully booked" is the reason the row cannot be tapped.
The send screen stopped asking for a street address. "Flat or house number"
and "Street and area" were the last two questions, asked on the screen where
the customer has already decided. They are optional on the contract — every
test that books without them has always passed. DeliveryDetails.building and
.street still exist and still go out; they are left unset. If they come back
they belong on a screen after the booking, not in front of it.
Several places in one pickup, but only when the server says so. The
destination sheet multi-selects: districts tick, the selection survives moving
between states, and the state rows carry a count. It falls back to single-select
whenever BookingLimits.allowsMultipleDestinations is false, which is what the
live backend advertises today — multi-destination is undeliverable until the
Miler build keys on consignmentid, and a sheet that let somebody pick three
places against a server capped at one would sell them a booking the network
cannot complete. AppState.setDestinations clamps again on the way in, because
the cap can change between the sheet opening and the draft being written.
Recent went. It was three district rows above the states, which put the thing the sheet is not about at the top of it.
10.7 The payoff, and the rail
The tick left the red card. Confirmation was a filled crimson card holding a translucent disc, "Pickup booked" and the window. Crimson is this app's action colour — the sphere, the button, the live card — so the one screen whose job is "this is finished" was wearing the colour that means "do something", and a tick on a wash of its own background reads as a watermark. It is a white tick on a green disc now, centred, with no box: green already means exactly one thing in this system, and this is the only place besides a delivered order where the app gets to say it.
The rail became the progress bar. Every connector used to be the same grey hairline, so working out how far along a parcel was meant finding the filled dots and counting them. The segment above a node is crimson once that node is reached and grey when it is not, so the line fills from the top as the parcel moves. Three node shapes, readable without colour: a ticked disc behind you, a ring with a lit centre where you are, a hollow outline ahead.
It also runs chronologically now. It ran newest-first, on the argument that "where is my parcel right now" should not be at the bottom — right when it was a list of rows, wrong now that it is a rail, because a filling line read backwards is nonsense. The answer is no longer down there either: the crimson card at the top of tracking states it outright, which frees the rail to answer how far along. Identity left, time right, on one baseline — the same split the Miler app's route rail uses, so the two apps read as one product.
The card around it went too. It is on the canvas now.
10.8 Rules that came out of it
A disabled primary action is a design failure. Ask for what unlocks it first. Both the send screen and the window sheet now open in a state their button accepts.
One tap, not two, for a single-select list. A "Continue" button under a list of rows asks the customer to say the same thing twice. Choosing a row in the destination sheet closes it.
Recognition over recall. The destination sheet reads the customer's own order history for its Recent group — no request, no new endpoint.
A card holding one control is a border drawn for its own sake. The package stepper and the pickup line are rows on the canvas now.
Commitments belong above the button that agrees to them. The send screen's footer carries the window and the price, and the window half is tappable.
Continuity is cheap. While the destination sheet is up, Home scales to 0.96 and fades to 0.45, and the sphere stays compressed at 0.94. The sheet reads as having been pushed up by the thing that was tapped.
11. The reference pass (2026-09-23)
A set of logistics-app references — white cards on a soft canvas, one dark accent, big reference numbers, labelled columns — and one instruction: less red, less boxy, more modern. What follows is what that actually meant in this codebase.
11.1 The redness had one source
DmBrandHeader was 260pt of solid brand on Home, Orders and Account — three of
five screens opening on a wall of it. Every onBrand* token existed only to
survive that field, and the accent that is supposed to mean do this was the
background of half the product.
It is the canvas now, with ink type. What is left of the brand on those screens is the sphere, the primary button and a live dot, which is what an accent is for. The arcs survive at 3.5% crimson instead of 15% white — they were struck from the mark's geometry and were never the colour doing the work.
Knock-ons: the header's address track became a bordered field and then a bare line (below); the avatar and the Account/Orders actions dropped their on-brand variants; the Miler card lost its crimson wash, which on a header-less screen was the loudest surface left.
11.2 The canvas was doing no work
At #F7F6F4 a white card was a 3% step off the background, so the 1px
border was separating everything and every screen read as a list of outlined
boxes. Canvas → #F1F0EC, card radius 14 → 18. The edge is still there; it
stopped being the only thing holding the layout together.
That change immediately exposed two screens whose content was floating on bare canvas — the send screen's route and package rows, invisible as a problem at 3% contrast and unfinished-looking at the new one. Both are on cards now.
The gutter came in too, DmSpace.pad 24 → 16. Twenty-four was right
when the header was a crimson field and the canvas held loose rows; on a page
of white cards it left a broad margin down both sides and the cards read as
narrower than the screen.
11.3 Orders
The filter left the header. It was a segmented control recessed into the crimson field, which made it look like part of the title bar rather than something to press. It is three chips on the canvas, ink when selected — one of three is always on, and crimson there would be a permanent red block on a screen whose crimson is meant to be the status pills, the thing that changes.
The card follows the reference: reference number and status pill, then From / To as two columns, then Placed / Contents, then a horizontal journey rail.
Two columns rather than a vertical thread, because the rail cost the full width of the card to say two things; side by side, from and to are one glance and the same route fits in half the height — which is what lets the journey rail exist without the card becoming a page.
The rail labels one node, not five. The reference labels all three of its nodes; that works at three and does not at five — "Booking created / Order created / In transit / Out for delivery / Delivered" across 320pt is five ellipses. The line carries how far, the word carries where.
"SEPTEMBER 2026" went from the header: it sat above a list that is not grouped by month, does not scroll by month, and says "Today" and "Yesterday" on its own rows.
11.4 Tracking, rebuilt
The map went. It was 178pt of basemap, two labels and a pin, on a screen whose question is "where is my parcel". The pin was the pickup address — a place the customer chose and already knows — not the Miler, who only has a position for the twenty minutes between assignment and the door. A picture that cannot show the thing it implies invites somebody to stare at a stationary dot and conclude nothing is happening. The radar that preceded it went for the same reason: animation standing in for an answer.
The crimson head card went. "Arun Kumar is assigned" at 27pt on a filled brand field, with a supporting line, a progress rail and the window under it. Crimson is the action colour and tracking is a screen you read. It is a white card now: the reference at 26pt, a status pill, and the route as a thread.
No progress bar inside it. The journey rail below is the same five milestones with the same fill, and two of them on one screen is the app disagreeing with itself about which is the answer.
The Miler card moved to the foot. It sat directly under the reference, which put a person between the customer and the answer they opened the screen for. It is who; the screen's question is where. The journey has the middle of the page now.
Also gone: a duplicate route card at the foot, the duplicate reference in the top bar, and the white bed under the footer buttons.
11.5 Small things that were wrong
The destination node is a map pin. A rotated diamond distinguished the ends of the thread — its whole argument — and did that by being a shape with no meaning attached. A pin is the mark every customer already reads as a place, so the thread needs no legend: hollow circle where you are, pin where it is going.
The status pill was floating mid-row. Flexible gives a child the space it
needs and then leaves it at the start of what it was allotted. It takes an
Align(centerRight) to put a status where a status belongs.
Home's address is a line, not a field. It was a bordered white box with a chevron — a text input that takes no text, which is the shape ride apps stopped using years ago. A dot, the place, and a quiet "Change".
BOOK / NOW is stacked, which bought back size: both words get the sphere's full width, so it is 30pt over two lines rather than the 24pt a single line had to shrink to.
Settings rows stopped truncating. Two rounds of flex arithmetic produced
"Payment metho…" and "38 Mettupalaya…" in turn. The fix was not a third ratio:
the label never truncates, and a value is a word — anything longer belongs on
the screen the row opens.
12. If you are adding something
- Take the value from
tokens.dart. If it is not there, add it there. - Ask what the screen's one decision is. If your addition is not it, it is secondary — make it look secondary.
- Give it an edge, not a shadow, unless it is the committing action, floats over a map, or is a sheet.
- If it is lit, light it from the upper left, and do not let it terminate in a hard line.
- Re-render the snapshots and look at them.