Files
doormile_customer_app/design/DESIGN.md
Thiru-tenext 86b6af48c2 Redesign on the Stitch reference, in Plus Jakarta Sans
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
2026-09-24 11:08:25 +05:30

41 KiB
Raw Blame History

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 of lib/ui/tokens.dart as 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.

  1. One decision per screen. A screen asks one thing and offers one dominant action. Everything else is secondary or absent.
  2. 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.
  3. Optional must look optional. Anything skippable is folded away and, when skipped, described calmly — never with warning styling.
  4. Customer language only. No consignment, waybill, AWB, MPS or piece. The customer books a pickup and sends packages.
  5. Complexity appears only when asked for. Multi-destination, multi-package and every advanced field stay folded until someone reaches for them.
  6. 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.42 and 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 SweepGradient whose 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.dart render key screens at large accessibility text.
  • The sphere is one Semantics node 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

  1. Take the value from tokens.dart. If it is not there, add it there.
  2. Ask what the screen's one decision is. If your addition is not it, it is secondary — make it look secondary.
  3. Give it an edge, not a shadow, unless it is the committing action, floats over a map, or is a sheet.
  4. If it is lit, light it from the upper left, and do not let it terminate in a hard line.
  5. Re-render the snapshots and look at them.