Files
doormile_customer_app/lib/ui/tokens.dart
Thiru-tenext e2fe1b1771 A white page, and cards that still read on it
The canvas was #F1F0EC — a warm grey that made every screen look slightly
dull. It is white.

── You cannot have both ──

That canvas was not an accident: it was deepened from #F7F6F4 on 2026-09-23
precisely so a white card would sit *on* something and its 1px edge could go
quiet. A white card on a white page has nothing to sit on.

Measured rather than judged by eye: after the canvas change, a pixel scan
across a card boundary on the Orders golden read #FFFFFF from end to end. The
shadow contributed nothing findable. The cards had not become subtle, they had
disappeared.

So the edge comes back. `DmCard` drew its border only for tones that are not
white — the warm canvas was doing that job for the white ones — and now draws
it for every tone, with the lift still underneath. Border plus a soft shadow is
the iOS and Revolut shape rather than the borderless Airbnb one, and that is
the price of a white page. Paid deliberately.

── What moved with it ──

`surfaceAlt` lifts to #F4F2EF. With a white page it is the only token that
makes a surface read as recessed — filled inputs, tiles, skeletons, the sunken
card — so it can no longer sit within a point of the canvas.

`DmCardTone.sunken` filled itself with the canvas colour, which on a white page
is not sunken but invisible. It takes `surfaceAlt`.
2026-09-30 11:35:30 +05:30

594 lines
24 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
/// Design tokens. Every colour, radius, shadow and text style in the app comes
/// from here — screens never hardcode a value.
///
/// Direction (2026-09-15 rebuild, from the 14-screen design):
///
/// **Edges, not elevation.** Surfaces are separated by a 1px warm hairline and
/// a 10pt radius, not by a shadow. The only shadows left in the system are the
/// crimson glow under the one committing action on a screen, the float under a
/// control that genuinely hovers over a map, and the sheet lift. A screen that
/// drops shadows everywhere has no way left to say "this one matters".
///
/// **One accent, three intensities.** Crimson fills the header and the primary
/// action, washes (`brandSoft` over `brandLine`) carry supporting information,
/// and everything else is warm neutral. There is no second hue competing for
/// meaning — `ok` survives only for the delivered state, `danger` only for
/// destructive text.
///
/// **Poppins for voice, Geist Mono for data.** Headings run at w800 with tight
/// negative tracking; anything a customer might read back aloud or check
/// digit-by-digit — a booking reference, a phone number, a fare, a plate, a
/// countdown — is set in mono so the glyphs line up and a transposed character
/// is visible.
class DmColors {
DmColors._();
// ── Brand ────────────────────────────────────────────────────────────────
/// The mark's single colour.
///
/// Briefly brightened to a vermilion on 2026-09-23 to match a reference
/// design and put straight back. The palette is not up for redesign — the
/// reference's *layout* is what the app is being drawn against, and the
/// oxblood carries it perfectly well.
static const brand = Color(0xFF8F0F06);
/// Pressed state, and brand text sitting on a [brandSoft] wash where the
/// full brand would vibrate against the tint.
static const brandPress = Color(0xFF730C05);
static const brandDeep = brandPress;
/// The lit edge of the sphere and of the primary button's gradient. Named
/// rather than repeated as a literal in three files.
static const brandLit = Color(0xFFA6180B);
/// What the launch fills the screen with, before anything else exists.
///
/// ── Why this is not [brand] ──
///
/// It is the launcher icon's own crimson, and it matches on purpose: the
/// customer taps a red tile and the screen fills with that same red, which
/// reads as one continuous gesture rather than as an app starting up. [brand]
/// is the UI's red and is four shades deeper; using it here would make the
/// tap and the screen after it two different colours for no reason a customer
/// could name.
///
/// The two never appear together — the splash has inverted to white before a
/// single piece of UI is drawn.
///
/// Mirrored in `android/app/src/main/res/values/colors.xml` as `dm_splash_bg`
/// and in `ios/Runner/Base.lproj/LaunchScreen.storyboard`, because the window
/// is painted this colour from process start — long before Flutter can.
static const splash = Color(0xFFBD0921);
/// Tint behind selected options, wash cards and brand chips.
static const brandSoft = Color(0xFFFBF3F2);
/// The wash's own edge, and the outline on quiet brand buttons.
static const brandLine = Color(0xFFF0DEDC);
// ── Ink — warm neutrals, never pure black ────────────────────────────────
static const ink = Color(0xFF191716);
static const ink2 = Color(0xFF5C5654);
static const ink3 = Color(0xFF6E6864);
static const ink4 = Color(0xFFB4AEAA);
// ── Surfaces ─────────────────────────────────────────────────────────────
/// The page canvas. White.
///
/// ── This has been both, and the trade is real ──
///
/// It was `#F7F6F4`, then deepened to `#F1F0EC` on 2026-09-23 so a white
/// card would sit *on* something and the 1px edge could go quiet. That
/// worked, and it also made every screen read as slightly dull — which is
/// the note that brought it here.
///
/// You cannot have a white page and borderless cards: one of them has to do
/// the separating. On white, [DmCard] is held by its border and
/// [DmShadow.card] rather than by contrast, which is the iOS and Revolut
/// shape rather than the Airbnb one. That is the choice, made deliberately.
///
/// Everything that needs to read as *recessed* — filled inputs, the
/// segmented groove, a sunken card — moved to [surfaceAlt] and [groove],
/// which are the only tokens still darker than the page.
static const canvas = Color(0xFFFFFFFF);
static const surface = Color(0xFFFFFFFF);
/// Filled inputs, quiet chips, tiles, skeleton bases, the "sunken" card and
/// the sunken treatment for a disabled primary action.
///
/// With a white canvas this is the *only* thing that makes a surface read as
/// recessed, so it carries more weight than it used to. It was within a
/// point of the old canvas, which is why the groove below had to exist at
/// all; on white it does its own job again.
static const surfaceAlt = Color(0xFFF4F2EF);
/// The bed a segmented control's chosen segment is raised out of.
///
/// A step below [surfaceAlt], which is within a point of the canvas and so
/// drew no groove at all on a sheet. A segment only reads as *raised* if
/// there is something visible for it to be raised out of.
static const groove = Color(0xFFE7E4E0);
/// Barely-there divider between rows inside one card.
static const hairline = Color(0xFFF1EFEC);
/// A card's own edge — the workhorse of this design.
static const border = Color(0xFFE8E5E2);
// ── On-brand text ────────────────────────────────────────────────────────
/// Secondary text on a crimson field. Not white-at-opacity: a tinted rose
/// keeps its warmth where translucent white goes grey and dirty.
static const onBrand2 = Color(0xFFFFD9D4);
/// Tertiary text on a crimson field.
static const onBrand3 = Color(0xFFFFD1CB);
/// Recessed track inside the header (the segmented control's bed).
static const onBrandSunken = Color(0x24000000);
/// A control resting on the crimson field.
static const onBrandRaise = Color(0x2EFFFFFF);
// ── Semantic ─────────────────────────────────────────────────────────────
static const ok = Color(0xFF0A6B56);
static const okSoft = Color(0xFFE2F4EF);
static const danger = Color(0xFFB3231F);
/// "You are here" on a map. Deliberately not the brand crimson — on a map
/// crimson means the pickup pin, and blue is the convention every customer
/// already reads as their own position.
static const locationDot = Color(0xFF1B6EF3);
static const locationHalo = Color(0x241B6EF3);
static const scrim = Color(0x80191716);
// Backwards-compatible aliases used across the widget layer.
static const bg = canvas;
static const surface2 = surfaceAlt;
static const line = hairline;
static const lineStrong = border;
}
class DmRadius {
DmRadius._();
// Every corner was opened up on 2026-09-22. At 8–12 the app measured
// correct and looked like a form: a screen of tight rectangles, which is
// exactly the "boxy" the redesign was called in to fix. Four points across
// the scale is the difference between a panel and a card, and it costs
// nothing but the token.
/// Tiles, chips, the small square that holds a glyph.
static const xs = 10.0;
/// Buttons, fields, icon buttons. The design's most-used corner.
static const sm = 12.0;
/// Cards and every panel that holds rows.
static const md = 18.0;
/// A card that stands alone on the canvas rather than in a stack of rows.
/// Wider than [md] because a shadowed card with no edge needs the corner to
/// carry the shape the border used to.
static const card = 20.0;
/// A chip: a status pill, a tier badge, a stat tile's glyph well.
static const chip = 8.0;
/// Sheets' grabber area, the segmented control, the header's bottom sweep.
static const lg = 16.0;
/// Bottom sheets and the auth sheet that overlaps the hero. Moved with the
/// scale — 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.
static const xl = 20.0;
/// The brand header's bottom corners.
static const header = 26.0;
static const pill = 999.0;
/// A primary bar's corner. Softer than a field's, because the committing
/// action is the one element on a screen allowed to look like an object
/// rather than part of the form.
static const book = 16.0;
static BorderRadius all(double r) => BorderRadius.circular(r);
}
class DmSpace {
DmSpace._();
/// Horizontal page padding.
///
/// 24 once, 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 — the reference designs this
/// was drawn against run their cards to within about a finger of the edge.
static const pad = 16.0;
static const headerPad = 16.0;
/// Minimum touch target.
static const tap = 48.0;
/// Primary button height.
static const button = 54.0;
/// Field height — matched to [button] so a form and its action share a rhythm.
static const field = 54.0;
}
class DmShadow {
DmShadow._();
/// Under the one committing action on a screen, and under a selected slot.
static const lift = [
BoxShadow(
color: Color(0x808F0F06),
blurRadius: 24,
spreadRadius: -12,
offset: Offset(0, 10),
),
];
/// A smaller version of [lift] for chips and half-width controls.
static const liftSm = [
BoxShadow(
color: Color(0xA68F0F06),
blurRadius: 16,
spreadRadius: -8,
offset: Offset(0, 7),
),
];
/// A card resting on the canvas.
///
/// ── Why the cards stopped having edges ──
///
/// Every surface in this app was separated by a 1px hairline, which is
/// honest and cheap and, repeated eight times down a screen, reads as a
/// wireframe: the outline is the loudest thing about an object whose whole
/// job is to be quiet and hold something else.
///
/// Two very soft layers do it instead — a wide, faint ambient wash and a
/// short contact shadow just under the shape. Neither is visible as a
/// shadow; together they lift the white off the warm canvas so the card is
/// read as *nearer* rather than as *outlined*. That is the difference
/// between a premium consumer surface and a bordered panel, and it costs
/// nothing but two `BoxShadow`s.
static const card = [
BoxShadow(
color: Color(0x0D1F1A17),
blurRadius: 22,
spreadRadius: -10,
offset: Offset(0, 8),
),
BoxShadow(
color: Color(0x081F1A17),
blurRadius: 3,
spreadRadius: -1,
offset: Offset(0, 1),
),
];
/// A card the customer has to act on, or the one live thing in a list.
/// The same two layers, a third again as deep.
static const cardRaised = [
BoxShadow(
color: Color(0x141F1A17),
blurRadius: 30,
spreadRadius: -12,
offset: Offset(0, 10),
),
BoxShadow(
color: Color(0x0B1F1A17),
blurRadius: 4,
spreadRadius: -1,
offset: Offset(0, 2),
),
];
/// Controls that genuinely float over content — map buttons, map chips.
static const float = [
BoxShadow(
color: Color(0x0D191716),
blurRadius: 2,
offset: Offset(0, 1),
),
];
static const sheet = [
BoxShadow(
color: Color(0x2E191716),
blurRadius: 40,
spreadRadius: -20,
offset: Offset(0, -8),
),
];
// Aliases kept for existing call sites.
static const two = float;
static const three = sheet;
}
/// Clearance for the floating tab bar.
///
/// ── It was being counted twice ──
///
/// Every root padded its foot by `MediaQuery.paddingOf(context).bottom + 74`,
/// on the reasoning that the inset is the device's home indicator and 74 is
/// the bar floating over it. That is not what the body is handed. `Scaffold`
/// with `extendBody: true` **replaces** the body's bottom padding with the
/// laid-out height of the `bottomNavigationBar` — the bar's own SafeArea, its
/// 10 of padding and its 62 of pill, all of it. Measured: a 34pt device inset
/// and this bar gives the body `padding.bottom = 106`, which is the bar
/// exactly.
///
/// So the roots were clearing the bar, and then clearing it again. It cost 74
/// points of empty canvas under the last card on Home, Orders and Account —
/// enough that Home's caption had nowhere to go while a hole sat under the
/// form.
///
/// The inset is the clearance. [gap] is the air between the last card and the
/// top of the pill, which is a design decision rather than a measurement.
double dmTabBarClear(BuildContext context, {double gap = 12}) =>
MediaQuery.paddingOf(context).bottom + gap;
class DmMotion {
DmMotion._();
static const fast = Duration(milliseconds: 140);
static const base = Duration(milliseconds: 250);
static const slow = Duration(milliseconds: 460);
static const ease = Curves.easeOutCubic;
/// The bottom nav's travelling pill — a touch of overshoot, which reads as
/// the indicator catching up rather than teleporting.
static const glide = Cubic(0.34, 1.06, 0.36, 1);
/// Screen transitions. An emphasised decelerate: it leaves quickly and
/// settles slowly, so a push feels driven at the start and arrived-at by the
/// end. `easeOutCubic` is too even across a distance this short — it reads
/// as a constant-speed slide.
static const enter = Cubic(0.05, 0.7, 0.1, 1);
}
/// Poppins for voice, Geist Mono for data.
///
/// ── Why the sans styles look different from the mono ones ──
///
/// Poppins has no variable release. Google Fonts ships it as static cuts only,
/// so the sans styles below set `fontWeight` and nothing else: there is no
/// `wght` axis to move, and `fontVariations` on a static font is ignored in
/// silence, which is worse than useless — it reads as if it were doing the
/// work. `pubspec.yaml` declares one file per weight and Flutter matches on the
/// enum. Geist Mono is still variable and its three styles still carry the axis.
///
/// Only the four weights the design uses are bundled — 500, 600, 700, 800 —
/// because each one is a separate ~155 KB file. Asking for w900 anywhere would
/// not fail; Flutter would quietly synthesise it from Bold, and it would look
/// like it.
///
/// ── Every weight is one step lighter than it was ──
///
/// Poppins is a geometric face built on near-circles, and it carries far more
/// ink at the same nominal weight than the humanist faces before it. Styles
/// that were w900 are w800, w800 are w700, w700 are w600. Set at the old
/// numbers the headings looked inflated rather than strong.
///
/// ── And the tracking is deeper again ──
///
/// The opposite correction from the last face. Poppins is wide, round and has
/// generous default sidebearings, so display sizes need pulling in harder than
/// anything this app has used — the header runs at −1.2. Body sizes stay near
/// zero, because the same roundness is what makes it legible small and tracking
/// it in takes that back. Tracking belongs to a typeface, not to a design.
class DmText {
DmText._();
/// ── Switzer → Plus Jakarta Sans (2026-09-24) ──
///
/// Both are variable neo-grotesques and the swap is a one-line change, but
/// two things come with it. Plus Jakarta's humanist curves and taller
/// x-height give the place names the editorial weight the reference design
/// has, and — the practical part — **it carries the rupee glyph**, which
/// Switzer does not. Every amount in this app was set in Geist Mono for that
/// reason alone; the ones that are prices rather than data can now be type.
///
/// Its axis is wght 200–800, so every weight the design uses is real rather
/// than synthesised.
static const _f = 'PlusJakartaSans';
static const _mono = 'GeistMono';
// The weight axis. Switzer and Geist Mono both ship as one variable file, so
// a style has to move `wght` to change weight — `fontWeight` alone picks the
// nearest named instance and, on a single-file VF, that is always the
// default. Both are set on every style: the axis does the work, the enum
// keeps the semantics (and the system-font fallback) correct.
//
// This came back when Poppins left. Poppins has no variable release, so for
// one release the sans styles carried `fontWeight` alone and this block was
// the mono's private business.
static const _w500 = [FontVariation('wght', 500)];
static const _w600 = [FontVariation('wght', 600)];
static const _w700 = [FontVariation('wght', 700)];
static const _w800 = [FontVariation('wght', 800)];
// Letter spacing is quoted in em by the design and converted here at each
// size, because -0.035em is a different number of pixels on a 31pt display
// head than on a 15pt row title, and splitting the difference would soften
// exactly the heads that are supposed to feel tight.
// ── Display ──────────────────────────────────────────────────────────────
/// Screen-dominating statement — "Pickup booked", "Arun is 12 minutes away".
static const display = TextStyle(
fontFamily: _f, fontSize: 31, height: 1.1,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -1.1, color: DmColors.ink,
);
/// The largest line on a screen: the name on Home, the title of a tab root.
///
/// It was white, because it was written for a crimson header. That header is
/// a canvas now and every caller was overriding the colour back to ink, so
/// the white was a trap waiting for the first caller who did not.
static const headerTitle = TextStyle(
fontFamily: _f, fontSize: 34, height: 1.0,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -1.4, color: DmColors.ink,
);
/// Screen headings on a canvas.
static const title = TextStyle(
fontFamily: _f, fontSize: 25, height: 1.16,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.8, color: DmColors.ink,
);
/// Tab-root page titles where no brand header is used.
static const pageTitle = title;
/// The heading above a group of cards, and a sheet's own title.
static const heading = TextStyle(
fontFamily: _f, fontSize: 18, height: 1.25,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.45, color: DmColors.ink,
);
static const sheetTitle = TextStyle(
fontFamily: _f, fontSize: 21, height: 1.2,
fontWeight: FontWeight.w800, fontVariations: _w800,
letterSpacing: -0.65, color: DmColors.ink,
);
// ── Rows ─────────────────────────────────────────────────────────────────
/// A row's own line — `t1` in the design, and the workhorse of the app.
static const cardTitle = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.4,
fontWeight: FontWeight.w600, fontVariations: _w600,
letterSpacing: -0.2, color: DmColors.ink,
);
/// Alias: the same weight of statement, used mid-form.
static const bodyStrong = cardTitle;
static const body = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.5,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.12, color: DmColors.ink,
);
/// Supporting line under a row title — `t2`.
static const small = TextStyle(
fontFamily: _f, fontSize: 13.5, height: 1.45,
fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink2,
);
/// The quietest line — `t3`. Timestamps, counts, "collected on".
static const tiny = TextStyle(
fontFamily: _f, fontSize: 13, height: 1.4,
fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink3,
);
/// Paragraph under a screen heading.
static const lede = TextStyle(
fontFamily: _f, fontSize: 14.5, height: 1.55,
fontWeight: FontWeight.w500, fontVariations: _w500,
color: DmColors.ink2,
);
/// The right-hand value in a settings row.
static const value = TextStyle(
fontFamily: _f, fontSize: 13.5, height: 1.4,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2,
);
/// Field labels, tags, chips, inline actions.
static const label = TextStyle(
fontFamily: _f, fontSize: 12.5, height: 1.3,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2,
);
static const button = TextStyle(
fontFamily: _f, fontSize: 16, height: 1.2,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.25,
);
/// The text-only action under a primary button ("Back to home").
static const textButton = TextStyle(
fontFamily: _f, fontSize: 14.5, height: 1.2,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink2,
);
/// Uppercase micro-label above a group — the design's `micro`. Crimson when
/// it heads brand content, [DmColors.ink3] when it heads a neutral group.
static const eyebrow = TextStyle(
fontFamily: _f, fontSize: 10.5, height: 1.3,
fontWeight: FontWeight.w600, fontVariations: _w600,
letterSpacing: 1.16, color: DmColors.ink3,
);
/// The eyebrow as it appears on the crimson header.
static const headerEyebrow = TextStyle(
fontFamily: _f, fontSize: 11.5, height: 1.3,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: 1.84, color: DmColors.onBrand3,
);
/// Sentence-case heading above a group of rows.
static const sectionHead = TextStyle(
fontFamily: _f, fontSize: 15, height: 1.3,
fontWeight: FontWeight.w700, fontVariations: _w700,
letterSpacing: -0.3, color: DmColors.ink,
);
/// Alias kept for existing call sites.
static const groupHead = sectionHead;
/// Field input text.
static const field = TextStyle(
fontFamily: _f, fontSize: 16, height: 1.3,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink,
);
// ── Mono: anything checkable digit by digit ──────────────────────────────
/// References, plates and phone numbers sitting inline in a row.
static const mono = TextStyle(
fontFamily: _mono, fontSize: 13, height: 1.4,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.26, color: DmColors.ink3,
);
/// Step counters, timers, the smallest data.
static const monoSm = TextStyle(
fontFamily: _mono, fontSize: 11.5, height: 1.3,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.23, color: DmColors.ink3,
);
/// A booking reference given its own line, or a fare.
static const monoLg = TextStyle(
fontFamily: _mono, fontSize: 19, height: 1.2,
fontWeight: FontWeight.w500, fontVariations: _w500,
letterSpacing: -0.38, color: DmColors.ink,
);
/// The OTP digits.
static const otp = TextStyle(
fontFamily: _f, fontSize: 24, height: 1.1,
fontWeight: FontWeight.w600, fontVariations: _w600,
color: DmColors.ink,
);
}