DDS — The dani design system
One monochrome foundation. One red accent. A single rectangular, monospace-data design language that scales across every dani app on every platform.
DDS (dani design system) is the visual language shared by every dani product — from dani.subs to dani.zone, dani.bot to dani.pet. It is deliberately narrow: one typographic scale, one spacing grid, one control ladder, one monochrome base. Every product uses the same Dot Red accent for UI. Product-specific colors exist only for app icons and brand assets — never in-app.
This document is the complete specification. If a number is missing here, it has not been decided, and it should not ship. Every value below is expressed in logical pixels — Flutter dp, CSS px, SwiftUI pt — which are equivalent for layout purposes across the platforms dani targets.
01 · Philosophy
DDS draws from four schools — Dieter Rams' functional discipline, the Swiss International Typographic style, Nothing OS's industrial monochrome, and Apple's platform polish. From each we took one thing:
- 01Less, but better.Every element earns its place. No decoration, no gradients, no shadows. Only functional surface, text, and border.
- 02Typography is the interface.Clear hierarchy beats ornament. A grid, a monospace, weight, and whitespace do the work of a hundred icons.
- 03Rectangular, not round.Sharp 4px radii and hairline 1px borders. Circles and pills are out; rectangles are honest about what they are.
- 04Monochrome speaks, accent shouts.Black, white, and one saturated red. Accent is reserved for action — never decoration.
- 05One accent, every app.All dani products share the same Dot Red UI accent. Product identity comes from the app icon and brand assets, not from in-app colors.
- 06Status is shape, not hue.There is no green-for-success, amber-for-warning palette. Meaning is carried by label, weight, border width, and position — which survives colorblindness, monochrome printing, and glare.
02 · Color
The DDS color system has two layers. The monochrome base is shared across every product, dark mode and light mode. The accent is Dot Red — the same across all products — and appears only on action surfaces.
Monochrome base
Nine tokens per mode. Memorize them. Never deviate.
| Token | Dark | Light | Role |
|---|---|---|---|
| bg | #000000 | #F7F7F7 | app background, the deepest layer |
| surface | #0B0B0B | #FFFFFF | cards, list rows, inputs, sheets |
| surface_2 | #141414 | #F0F0F0 | avatars, nested surfaces, hover step |
| border | #1F1F1F | #E0E0E0 | card boundaries, dividers, control outlines |
| border_soft | #141414 | #EBEBEB | low-contrast dividers inside a card |
| text | #FFFFFF | #111111 | primary text, active icons |
| text_2 | #777777 | #666666 | secondary text, labels, inactive icons |
| text_3 | #444444 | #AAAAAA | tertiary text, disabled content |
| on_accent | #FFFFFF | #FFFFFF | text and icons on an accent fill |
Dark mode is matte black (#000000), not charcoal. This gives DDS its electronic/industrial character — closer to a piece of consumer hardware than a typical software UI. Light mode uses warm off-white (#F7F7F7) with pure-white surfaces stacked on top.
UI accent — Dot Red
Every dani product uses the same accent for UI: Dot Red. It ships as a pair — #FF2D2D tuned for #000 surfaces (dark mode) and #E80000 tuned for #F7F7F7 (light mode). Never cross-use.
| Token | Dark | Light | Used for |
|---|---|---|---|
| accent | #FF2D2D | #E80000 | rest state of every accent surface |
| accent_hover | #FF4747 | #CC0000 | pointer hover only — never on touch |
| accent_pressed | #E52626 | #B80000 | active/pressed, 100ms |
| accent_subtle | 12% accent | 8% accent | selected-row wash, chart fills |
| on_accent | #FFFFFF | #FFFFFF | label on an accent fill |
| focus_ring | #FF2D2D | #E80000 | 2px outline, 2px offset |
accent_subtle is the only place DDS mixes a color with a surface. It is a wash, never a gradient.
Dot Red is pre-verified for ≥4.5:1 contrast against both mode surfaces (WCAG AA). White text on a Dot Red button hits the same floor.
Brand colors (icons & assets only)
Each dani product has a unique hue for its app icon and marketing assets. These are never used as the in-app accent — all UI uses Dot Red above.
| Product | Name | Dark | Light |
|---|---|---|---|
| dani.subs | Alert Red | #FF2D2D | #E80000 |
| dani.news | Scarlet | #FF4518 | #D11500 |
| dani.penny | Copper | #FF6B1A | #C4470A |
| dani.park | Signal Orange | #FF8A00 | #B26500 |
| dani.do | Signal Orange | #FF8A00 | #B26500 |
| dani.tos | Amber | #FFB300 | #8F5C00 |
| dani.money | Gold | #FFD400 | #8C7000 |
| dani.snap | Flash Yellow | #E8FF1A | #5C7A00 |
| dani.go | Alert Red | #FF2D2D | #E80000 |
| dani.plant | Green | #3AFF7D | #0A8F3D |
| dani.budget | Mint | #00FFB0 | #008F5E |
| dani.clean | Teal | #00E0C4 | #00806F |
| dani.zone | Electric Cyan | #00F0FF | #008A94 |
| dani.track | Sky | #00B8FF | #0070B2 |
| dani.web | Blue | #3D7AFF | #0047D6 |
| dani.korea | Flag Blue | #3D7BD9 | #0047A0 |
| dani.bot | Violet | #8F5CFF | #4B1FBF |
| dani.photo | Purple | #C14DFF | #7A14B5 |
| dani.design | Magenta | #FF4DD4 | #B51495 |
| dani.pet | Pink | #FF4D8F | #C4144F |
#000080, not a dark/light pair. It scores 16.0:1 on white and 1.31:1 on black, which is below the 3:1 floor for even non-text UI. The trade is deliberate: in dark mode the dani.ko accent can never be the only thing carrying a meaning. Anything that must stay legible there needs a second cue — a border, a light fill behind it, or a weight change. This is also why the dani.ko logo tile works where bare navy text would not: the white mark sits on the fill rather than beside it.03 · Logo & wordmark
Every dani product shares one logo. It is a two-part lockup: a filled tile carrying the d. mark, and a wordmark set beside it. Only two things ever change between products — the string and the tile color. Nothing else: not the proportions, not the mark, not the spacing.
dani.go is the reference artwork. Every ratio below was measured off it, and dani.ko is drawn from those same numbers.
Construction
The lockup is driven by a single input: H, the nominal height a caller asks for. Every other dimension is a ratio of H or of the tile edge, so the lockup keeps its proportions at any size. Nothing in the logo is an absolute pixel value.
H is the nominal height every caller passes. Offsets are fractions of the tile edge, measured from the tile centre.
| Part | Ratio | Of | H = 14 | H = 24 | H = 40 |
|---|---|---|---|---|---|
| tile edge | 1.96 | H | 27.44 | 47.04 | 78.40 |
| tile radius | 0.22 | tile | 6.04 | 10.35 | 17.25 |
| letter “d” | 0.66 | tile | 18.11 | 31.05 | 51.74 |
| letter offset x | −0.125 | tile, from centre | −3.43 | −5.88 | −9.80 |
| letter offset y | +0.025 | tile, from centre | +0.69 | +1.18 | +1.96 |
| dot diameter | 0.115 | tile | 3.15 | 5.41 | 9.02 |
| dot offset x | +0.275 | tile, from centre | +7.55 | +12.94 | +21.56 |
| dot offset y | +0.175 | tile, from centre | +4.80 | +8.23 | +13.72 |
| gap tile → wordmark | 0.28 | tile | 7.68 | 13.17 | 21.95 |
| wordmark size | 2.02 | H (= 1.031 tile) | 28.28 | 48.48 | 80.80 |
| wordmark tracking | −0.03 | wordmark size | −0.76 | −1.31 | −2.18 |
Offsets are measured from the tile centre, as fractions of the tile edge. In Flutter these are Alignment(−0.25, 0.05) for the letter and Alignment(0.55, 0.35) for the dot — Alignment units are twice the fraction.
1.82 × H — 0.928 × the tile edge — until August 2026, and no shipping lockup actually drew it. Measured off the rendered dani.go artwork the wordmark is 1.031 × its tile. dani.ko, drawing the token value faithfully, read small and timid beside its own tile where dani.go reads level with its own. That 10% was the whole of the difference. The token file was corrected to the artwork rather than the other way round, and dot.css, dani.go's widget and dani.ko now all carry 2.02 / 1.031.Color
| Part | Token | Dark | Light | Inverts? |
|---|---|---|---|---|
| tile fill | the product accent | #FF2D2D (go) | #E80000 (go) | yes — except dani.ko |
| letter “d” | on_accent | #FFFFFF | #FFFFFF | no |
| dot | on_accent | #FFFFFF | #FFFFFF | no |
| wordmark | text | #FFFFFF | #111111 | yes, always |
The tile is the accent and the wordmark is text. Never the reverse: a navy or red wordmark on the page background fails contrast, which is the whole reason the mark lives on a filled tile.
Typeface
| Element | Family | Weight | Line height | Tracking |
|---|---|---|---|---|
| letter “d” | Space Grotesk | 700 | 1.0 | 0 |
| wordmark | Space Grotesk | 700 | 1.0 | −0.03em |
Line height 1.0 on both — the lockup is centred geometrically, so any leading would push the parts out of alignment.
Platform notes
The same drawing needs two different vertical corrections, because Flutter and CSS/SVG do not put a baseline in the same place.
| Platform | Baseline at | Correction |
|---|---|---|
| Flutter (height: 1.0) | 0.771 × fontSize below the line-box top | translate the wordmark down by 0.075 × wordmark size |
| CSS / SVG (half-leading) | 0.846 × fontSize below the line-box top | none — this is the reference |
Centre-aligned in a Row against the same tile, Flutter otherwise hangs the wordmark about 8% of a tile-edge too high. Use Transform.translate, not Padding — padding adds to the laid-out height and a surrounding FittedBox would scale the whole lockup down to pay for it.
The lockup's intrinsic width is driven by H, so a caller asking for a large height is really asking for a very wide row — at H = 34 that is roughly 550px. Always wrap it in a scale-down box (FittedBox(fit: BoxFit.scaleDown) in Flutter, max-width plus height: auto on the web) so it shrinks to the space it is handed instead of overflowing.
Sizes and clear space
| H | Tile | Use |
|---|---|---|
| 14 | 27 | in-app header, nav bar, footer — the default |
| 20 | 39 | auth screens, dialog headers |
| 34 | 67 | marketing hero, onboarding |
| — | any | tile alone: app icon, favicon, avatar, splash |
Clear space on all four sides is 0.28 × tile — the same value as the gap. Below H = 14 the wordmark stops being legible: drop it and ship the tile alone.
- · Draw the mark; never typeset the name by hand
- · Keep the tile letter as “d” for every product
- · Change only the string and the tile color
- · Let the wordmark follow the text token, so it inverts
- · Wrap it in a scale-down box
- · Below H = 14, ship the tile alone
- · Re-letter the tile with the product initial
- · Color the wordmark with the accent
- · Invert the dani.ko tile between modes
- · Stretch, rotate, outline, or shadow the lockup
- · Substitute a picture of the word for the mark
- · Re-space the parts by eye instead of by ratio
04 · Typography
Two families. Space Grotesk is the voice of the UI. Space Mono is the voice of data — every number, date, code, label, and timestamp uses mono. This split is the system's signature and it is not a stylistic preference: a monospace column of figures aligns without tabular-figure hacks, and it makes numbers instantly distinguishable from prose.
The full scale
Fourteen steps. Sizes below are the phone baseline — multiply by the viewport factor in the next table for tablet and desktop.
| Token | Size | Line height | Weight | Tracking | Family | Use |
|---|---|---|---|---|---|---|
| display | 28 | 32 · 1.14 | 600 | -0.01em | sans | detail-screen hero title |
| title | 20 | 26 · 1.30 | 600 | -0.01em | sans | screen titles |
| heading | 18 | 24 · 1.33 | 600 | -0.01em | sans | card titles |
| subhead | 15 | 20 · 1.33 | 600 | 0 | sans | row-group headers, dialog titles |
| body | 13 | 18 · 1.38 | 500 | 0 | sans | list item names, primary copy |
| body_sm | 12 | 16 · 1.33 | 500 | 0 | sans | secondary copy |
| caption | 11 | 14 · 1.27 | 500 | 0 | sans | helper text, inline errors |
| amount_xl | 44 | 44 · 1.00 | 700 | -0.02em | mono | hero totals, one per screen |
| amount | 36 | 36 · 1.00 | 700 | -0.02em | mono | card totals |
| amount_sm | 20 | 24 · 1.20 | 700 | -0.01em | mono | inline totals, row amounts |
| data | 12 | 16 · 1.33 | 700 | 0 | mono | dates, counts, inline numbers |
| data_sm | 10 | 14 · 1.40 | 700 | 0 | mono | dense table cells |
| label | 9 | 12 · 1.33 | 700 | 0.10em | mono | section labels, tab bar — UPPERCASE |
| micro | 8 | 10 · 1.25 | 700 | 0.08em | mono | badges, row meta — UPPERCASE |
Line height is absolute, not a ratio. Ratios drift between Flutter, CSS and SwiftUI; absolute leading does not.
Responsive scaling
The scale above is tuned for a phone. Wider viewports multiply every content size by a single factor, so the whole system grows together instead of drifting apart.
| Viewport | Width | Factor | body | title | amount | display |
|---|---|---|---|---|---|---|
| phone | ≤ 599 | 1.00× | 13 | 20 | 36 | 28 |
| tablet | 600 – 1023 | 1.15× | 15 | 23 | 41 | 32 |
| desktop | ≥ 1024 | 1.30× | 17 | 26 | 47 | 36 |
Rounded to the nearest whole pixel. Round once, at the token layer — never per component.
label, micro and data_sm are system chrome, not content. They stay at their phone size on every viewport, with a hard floor of 8px and a ceiling of 12px. A tab-bar label that grows with the window looks broken; a body paragraph that does not looks cramped.Numerals
| Rule | Value |
|---|---|
| Figure style | Tabular, lining — Space Mono is monospaced so this is automatic |
| Currency symbol | text_2, one step smaller than the amount, top-aligned |
| Thousands separator | Locale-aware; never hand-inserted commas |
| Negative values | Leading minus in the accent, never parentheses, never a second color |
| Alignment in tables | Right-aligned; decimal points line up because the face is monospaced |
| Dates | DD.MMM.YYYY uppercase in mono — 04.APR.2026 |
| Times | 24h in mono — 18:30. 12h only where the locale demands it |
Text color roles
| Role | Token | Applies to |
|---|---|---|
| primary | text | titles, list item names, amounts, active nav |
| secondary | text_2 | meta rows, labels, helper text, inactive nav |
| tertiary | text_3 | disabled text, empty-state hints, placeholders |
| accent | accent | key values, active state, destructive labels |
| on-accent | on_accent | any text sitting on an accent fill |
Wrapping and truncation
- List item names truncate with an ellipsis at one line. Never wrap a row title.
- Amounts never truncate. If space is tight, the title yields, not the number.
- Body copy wraps freely; reading columns cap at 720px.
- Labels never wrap. If a label does not fit, the label is too long — shorten the words, not the type.
- Two-line clamp is the maximum anywhere inside a card.
05 · Spacing & layout
DDS is built on a 4px grid with a 2px half-step. Every padding, margin, gap and dimension is a multiple of 2, and the overwhelming majority are multiples of 4. The half-step exists for optical corrections only — compensating a hairline border, centering a glyph — the same role Carbon gives its 2px mini-unit and Atlassian gives space.025.
Semantic spacing
Primitives are for building components. Screens use the semantic tokens below, so a change to card rhythm is one edit rather than four hundred.
| Token | Phone | Tablet | Desktop | Applies to |
|---|---|---|---|---|
| screen_padding_x | 16 | 24 | 32 | left/right gutter of every screen |
| screen_padding_top | 16 | 24 | 24 | below the app bar |
| card_padding | 18 | 18 | 20 | inside a bordered card |
| card_padding_compact | 12 | 12 | 12 | dense cards, table cells |
| card_gap | 14 | 14 | 16 | between sibling cards |
| section_gap | 24 | 32 | 40 | between logical groupings |
| row_gap | 6 | 6 | 6 | between list rows inside one card |
| inline_gap | 8 | 8 | 8 | icon → label, chip → chip |
| stack_tight | 4 | 4 | 4 | label → value |
| stack_normal | 8 | 8 | 8 | field → field |
| stack_loose | 12 | 12 | 12 | group → group inside a card |
card_padding 18 = 16 + 2. The half-step compensates the 1px border on each side so text inside a card sits on the same optical rhythm as text on the bare background.
Breakpoints and the layout grid
| Name | Min width | Columns | Gutter | Margin | Navigation |
|---|---|---|---|---|---|
| phone | 0 | 4 | 12 | 16 | bottom tab bar, 56 + safe area |
| tablet | 600 | 8 | 16 | 24 | left rail, 72 wide |
| desktop | 1024 | 12 | 24 | 32 | sidebar, 240 wide |
| wide | 1440 | 12 | 24 | centered at 1200 | sidebar, 240 wide |
Content never stretches past 1200px. Reading-heavy views cap earlier, at 720px. Beyond 1440px the shell centers and the background extends — the layout does not keep growing.
Density
Two densities. Default everywhere touch is possible; compact on pointer-only desktop surfaces and inside data tables. Compact drops every control one step on the ladder and swaps card_padding for card_padding_compact. It never changes type sizes.
06 · Radii
DDS is rectangular. Three radii exist. There are no pills and no circles — with one sanctioned exception: the small decorative dot indicators, which are the system's single break from its own rule.
| Token | Value | Applies to | Never |
|---|---|---|---|
| radius_xs | 2 | checkboxes, swatches, badges, inner shapes | cards, buttons |
| radius_sm | 4 | every control, card, button, avatar, chip, input | — |
| radius_md | 8 | bottom sheets (top corners), device frames | anything inline |
| radius_full | — | dot indicators only, max 6px diameter | buttons, chips, avatars, FABs |
On iOS, use plain corner radii — not continuous/squircle corners. The squircle is Apple's shape language, not ours.
07 · Borders & depth
DDS uses borders instead of shadows. Two widths exist: 1px hairline for standard dividers and card boundaries, and 2px strong for emphasis and selected states. No drop shadows. Ever. Depth comes from surface stacking and borders, never from blur.
| Layer | Dark | Light | Border | Example |
|---|---|---|---|---|
| L0 · background | #000000 | #F7F7F7 | — | the screen |
| L1 · surface | #0B0B0B | #FFFFFF | 1px border | cards, list rows, inputs |
| L2 · surface_2 | #141414 | #F0F0F0 | 1px border | avatars, nested blocks |
| L3 · overlay | #0B0B0B | #FFFFFF | 1px border + scrim | sheets, dialogs, menus |
| scrim | 60% #000 | 40% #000 | — | behind any L3 overlay |
Three layers is the ceiling. A fourth nested surface means the hierarchy is wrong, not that a new token is needed.
1px border + 12px padding, focus is 2px border + 11px padding.08 · Sizing & controls
Every interactive element in DDS is height-driven, not padding-driven. You set a height from the ladder and center the content; you never derive a height by adding vertical padding to a font size. This is the single change that keeps a button, an input and a select on the same baseline when their contents differ.
| Size | Height | Padding X | Gap | Type | Icon | Used for |
|---|---|---|---|---|---|---|
| xs | 24 | 6 | 4 | micro 8 | 12 | table row actions, dismiss, inline tags |
| sm | 32 | 10 | 6 | label 9 | 16 | filter chips, toolbar actions, segmented controls |
| md | 40 | 14 | 8 | body_sm 12 | 16 | default — inputs, selects, secondary buttons |
| lg | 48 | 16 | 8 | body 13 | 20 | primary CTA, full-width mobile buttons |
| xl | 56 | 20 | 10 | body 13 | 24 | onboarding and empty-state CTAs |
Padding X is the inline padding at rest, with a 1px border. Add the border compensation rule when a 2px border applies.
Component anatomy
The ladder above sets heights. This table sets everything else. Values are phone defaults; desktop drops one ladder step under compact density.
| Component | Height | Padding | Radius | Border | Type |
|---|---|---|---|---|---|
| Button · primary | 48 | 0 / 16 | 4 | 1px accent | body 13 / 600 |
| Button · secondary | 48 | 0 / 16 | 4 | 1px border | body 13 / 600 |
| Button · ghost | 48 | 0 / 16 | 4 | 1px border | body 13 / 600 |
| Button · destructive | 48 | 0 / 16 | 4 | 2px accent | body 13 / 600 |
| Icon button | 40 × 40 | — | 4 | 1px border | icon 20 |
| Text input | 40 | 0 / 12 | 4 | 1px → 2px focus | body 13 / 500 |
| Select | 40 | 0 / 12 | 4 | 1px → 2px focus | body 13 / 500 |
| Textarea | min 96 | 12 | 4 | 1px → 2px focus | body 13 / 500 |
| Search field | 40 | 0 / 12 | 4 | 1px border | body 13 / 500 |
| Chip · filter | 28 | 0 / 8 | 4 | 1px border | label 9 |
| Chip · tag | 24 | 0 / 8 | 4 | 1px accent | micro 8 |
| Badge · count | 16 | 0 / 6 | 2 | none, accent fill | micro 8 |
| List row · one line | 48 | 10 | 4 | 1px border_soft | body 13 |
| List row · two line | 56 | 10 | 4 | 1px border_soft | body 13 + micro 8 |
| List row · with amount | 56 | 10 | 4 | 1px border_soft | body 13 + data 12 |
| Avatar | 24 / 30 / 40 / 56 | — | 4 | 1px border | mono 700 |
| Checkbox | 18 × 18 | — | 2 | 2px | — |
| Radio | 18 × 18 | — | 2 | 2px | 8px inner square |
| Switch | 44 × 24 | 2 | 4 | 1px | 18px knob, radius 2 |
| Slider | track 2 | — | 0 | none | thumb 12 × 20, radius 2 |
| Progress bar | 4 | — | 2 | none | accent fill on border track |
| Segmented control | 32 | 0 / 10 | 4 | 1px, 2px on selected | label 9 |
| Tab bar · bottom | 56 | 0 / 8 | 0 | 1px top border | icon 20 + label 9 |
| App bar | 56 / 64 desktop | 0 / 16 | 0 | 1px bottom border | title 20 |
| Nav rail | w 72 | 8 | 0 | 1px right border | icon 20 + micro 8 |
| Sidebar | w 240 | 12 | 0 | 1px right border | body 13 |
| Bottom sheet | auto | 18 | 8 top | 1px border | grabber 32 × 3 |
| Dialog | max-w 400 | 24 | 4 | 1px border | subhead 15 |
| Toast | 48 | 0 / 16 | 4 | 1px border | body_sm 12 |
| Table row | 40 / 32 compact | 0 / 12 | 0 | 1px bottom border | data 12 |
| Table header | 32 | 0 / 12 | 0 | 1px bottom border | label 9 |
| Divider | 1 | — | 0 | border token | — |
09 · Icons
Icons are outline, uniform stroke, square-cornered, and drawn on the same 4px grid as everything else. There is no filled variant and no duotone — an active icon changes color, not weight.
| Size | Stroke | Optical box | Used in |
|---|---|---|---|
| 13 | 1.5 | 16 | inline with micro/label text, table affordances |
| 17 | 1.5 | 20 | default — inside buttons, chips, list rows |
| 20 | 2 | 24 | controls at lg and above, tab bar, nav rail |
| 24 | 2 | 28 | app bar actions, empty states |
| 32 | 2 | 40 | empty-state illustration anchor only |
Optical box is the layout footprint. Align the box, not the glyph — otherwise a wide icon and a narrow icon in the same row will not line up.
10 · Motion
Motion is functional, not expressive. If a user cannot explain what an animation is for, it should not be there. There are no physics-based animations, no parallax, no particle effects, and no easter-egg flourishes.
| Token | Duration | Easing | Applies to |
|---|---|---|---|
| instant | 100ms | ease-out | press feedback, tab switch, checkbox toggle |
| normal | 200ms | ease-out | reveal, dismiss, expand, route change |
| slow | 300ms | ease-out | bottom sheet and dialog entry only |
| none | 0ms | — | everything, when reduced-motion is on |
| Transition | Property | Duration |
|---|---|---|
| Button press | background → accent_pressed | 100ms |
| Hover (pointer only) | background → accent_hover / surface_2 | 100ms |
| Focus ring | outline opacity 0 → 1 | 100ms |
| Route push | opacity 0 → 1, translateY 8 → 0 | 200ms |
| Sheet entry | translateY 100% → 0 | 300ms |
| Scrim | opacity 0 → 1 | 200ms |
| List item insert | opacity 0 → 1 | 200ms, no height animation |
| Skeleton loading | opacity 0.4 ⇄ 0.8 | 1200ms, linear, infinite |
No transition animates layout size. Animating height causes reflow jank on every platform we ship to.
Haptics are light on tap and selection on tab or segment change. Nothing else vibrates.
11 · States & interaction
Eight states, defined once, applied identically to every control. States are expressed as explicit token swaps — never as a blanket opacity multiplier, which stacks unpredictably when two states coincide.
| State | Surface | Border | Text | Notes |
|---|---|---|---|---|
| rest | per component | 1px border | text | the baseline |
| hover | surface → surface_2 | unchanged | unchanged | pointer devices only |
| pressed | accent → accent_pressed | unchanged | unchanged | 100ms, no scale transform |
| focus-visible | unchanged | unchanged | unchanged | 2px accent outline, 2px offset |
| selected | unchanged | 1px → 2px accent | text | border carries selection, not fill |
| disabled | unchanged | border → border_soft | text → text_3 | no opacity multiplier, not focusable |
| loading | unchanged | unchanged | replaced | 3-dot mono ellipsis in accent, size unchanged |
| error | unchanged | 1px → 2px accent | text | leading ! marker + caption below |
✓ in mono at text. Warning and error are a ! marker plus a 2px accent border. This is a deliberate constraint: it survives colorblindness, greyscale printing and direct sunlight, and it keeps the accent meaning exactly one thing — attention here.Selection controls
12 · Components
Every DDS component is built from the same primitives: a height from the ladder, a 4px radius, a 1px border, and type from the scale. The system is combinatorial — there are no special-case components.
Buttons
| Variant | Fill | Border | Label | Used for |
|---|---|---|---|---|
| primary | accent | 1px accent | on_accent | the one action a screen exists for |
| secondary | surface | 1px border | text | supporting actions |
| ghost | transparent | 1px border | text_2 | tertiary, dismissive actions |
| destructive | transparent | 2px accent | accent | delete, cancel subscription, sign out |
| disabled | surface | 1px border_soft | text_3 | blocked actions — never hidden |
One primary button per screen. Two primaries means the screen has two purposes and should be two screens.
List items
Data card / totals
Chips & tags
Gauge / progress
Inputs
| Part | Type | Spacing | Notes |
|---|---|---|---|
| Field label | label 9 uppercase | 6 below | always above the field, never a floating placeholder |
| Field | body 13 / mono for numbers | height 40 | 1px border, 2px accent on focus |
| Prefix / suffix | mono 700, text_2 | 8 inline gap | currency symbols, units |
| Helper text | caption 11, text_2 | 6 above | one line, always reserved so layout does not jump |
| Error text | caption 11, accent | 6 above | replaces helper text, prefixed with ! |
13 · Usage rules
These are the enforcement rules. Every design and code review applies them.
- · Primary button fill
- · Key numeric values (amounts, counts)
- · Active tab / selected state
- · Alert badges and status pills
- · Tag indicators (small dots)
- · Section bullets
- · Focus rings
- · Body text
- · Borders or dividers at rest
- · Page or card backgrounds
- · Disabled / muted states
- · Standard icons
- · Section headers
- · Decorative fills or gradients
- · Pick heights from the control ladder
- · Round once, at the token layer
- · Reserve space for helper and error text
- · Expand hit areas rather than growing controls
- · Give every state an explicit token swap
- · Derive a control height from font size + padding
- · Use opacity to express a disabled state
- · Animate layout height
- · Introduce a second accent, ever
- · Add a shadow, a gradient, or a pill
14 · Accessibility
| Requirement | Floor | Source |
|---|---|---|
| Text contrast | 4.5:1 | WCAG 2.2 AA · 1.4.3 |
| Large text (≥ 24px) | 3:1 | WCAG 2.2 AA · 1.4.3 |
| UI component contrast | 3:1 | WCAG 2.2 AA · 1.4.11 |
| Target size · web | 24 × 24 px | WCAG 2.2 AA · 2.5.8 |
| Target size · iOS | 44 × 44 pt | Apple HIG |
| Target size · Android | 48 × 48 dp | Material 3 |
| Type scaling | up to 130% | DDS cap on Dynamic Type / fontScale |
| Focus indicator | 2px, 2px offset | WCAG 2.2 AA · 2.4.11 |
- Every interactive element has a semantic label — icon-only controls always carry one.
- Focus order follows visual order. Overlays trap focus and return it on dismiss.
- Reduced-motion users get motion disabled, not shortened — zero animation.
- Nothing is communicated by color alone. Every accent signal has a label, marker, or position that repeats it.
- Text scales to 130% without clipping. Rows grow; type does not shrink to fit.
- Layout mirrors fully in RTL. Icons that encode direction mirror; logos and numerals do not.
- Both modes are first-class. Neither is a filter applied to the other.
15 · Platforms
The tokens are identical everywhere. What differs is what each platform's conventions force on us — hit-target floors, safe areas, input model, and the native behaviours we deliberately switch off so DDS still looks like DDS.
| Concern | iOS / iPadOS | Android | Web / desktop |
|---|---|---|---|
| Min hit target | 44 × 44 pt | 48 × 48 dp | 24 × 24 px (44 on touch) |
| Default button size | lg · 48 | lg · 48 | md · 40 at ≥ 1024 |
| Type scaling | Dynamic Type, capped 130% | fontScale, capped 130% | browser zoom, uncapped |
| Density | default | default | compact at ≥ 1024 |
| Bottom nav | 56 + safe area inset | 56 + gesture inset | sidebar 240 / rail 72 |
| Header | 56, custom — no large titles | 56, no elevation on scroll | 64 |
| Press feedback | 100ms token swap + light haptic | 100ms token swap, ripple OFF | hover + :focus-visible |
| Overscroll | bounce, no custom glow | clamp, stretch disabled | native |
| Corner style | plain radii, not continuous | plain radii | plain radii |
| System chrome | status bar follows mode | edge-to-edge, transparent bars | theme-color meta follows mode |
| Back | edge swipe stays enabled | predictive back supported | browser history, Esc closes overlays |
iOS / iPadOS
- Never hardcode the home-indicator inset. Tab bar height is
56 + safeAreaInsets.bottom. - Large titles are off. DDS uses its own 56pt bar with a
title 20heading. - Amount fields use the decimal pad and the mono face, so the keyboard and the value agree.
- Haptics:
.lightimpact on tap,.selectionon tab and segment change. - Continuous corners are disabled — DDS radii are plain, on every platform.
Android
- The Material ripple is replaced by a 100ms surface-step press state. Splash factory is set to none.
- Minimum hit target rises to 48 × 48 dp — the visual control size does not change.
- Edge-to-edge with transparent system bars; icon brightness follows the mode.
- Stretch overscroll is disabled in favour of a clamped scroll physics.
- Elevation is zero on every surface. Depth is borders, as everywhere else.
Web & desktop
- Hover states apply only under
@media (hover: hover) and (pointer: fine). - Focus rings use
:focus-visible. Outlines are never removed, only restyled. - Full keyboard model: Tab order follows layout, Esc closes overlays, Enter submits, arrows move within tabs and segmented controls.
- Scrollbars are 8px, thumb
text_3, no arrows, no track. - Selection background is the accent with
on_accenttext. - Print forces light mode, keeps hairlines, and drops navigation chrome.
- Desktop windows have a custom 44px title bar and a 720 × 560 minimum size.
Token implementations
DDS tokens live in shared/design/dot-tokens.yaml as the single source of truth. Each platform mirrors them into native code. There is no auto-generation — the yaml is the contract, each project owns its translation.
class DdsBase {
// Dark
static const darkBg = Color(0xFF000000);
static const darkSurface = Color(0xFF0B0B0B);
static const darkSurface2 = Color(0xFF141414);
static const darkBorder = Color(0xFF1F1F1F);
static const darkText = Color(0xFFFFFFFF);
static const darkText2 = Color(0xFF777777);
// Light
static const lightBg = Color(0xFFF7F7F7);
static const lightSurface = Color(0xFFFFFFFF);
static const lightBorder = Color(0xFFE0E0E0);
static const lightText = Color(0xFF111111);
static const lightText2 = Color(0xFF666666);
}
class DdsUiAccent {
// Dot Red — same for all products
static const dark = Color(0xFFFF2D2D);
static const light = Color(0xFFE80000);
static const pressed = Color(0xFFE52626);
}
class DdsSize {
static const xs = 24.0, sm = 32.0, md = 40.0, lg = 48.0, xl = 56.0;
}
class DdsRadii {
static const xs = 2.0; // checkboxes, badges
static const sm = 4.0; // all controls
static const md = 8.0; // sheets, frames
}
// Ripple off, elevation zero — depth is borders.
final ddsTheme = ThemeData(
splashFactory: NoSplash.splashFactory,
highlightColor: Colors.transparent,
);:root {
/* Light mode (default) */
--bg: #F7F7F7;
--surface: #FFFFFF;
--border: #E0E0E0;
--text: #111111;
--text-2: #666666;
--accent: #E80000;
--accent-pressed: #B80000;
/* Control ladder */
--control-xs: 24px;
--control-sm: 32px;
--control-md: 40px;
--control-lg: 48px;
--control-xl: 56px;
}
[data-theme="dark"] {
--bg: #000000;
--surface: #0B0B0B;
--border: #1F1F1F;
--text: #FFFFFF;
--text-2: #777777;
--accent: #FF2D2D;
--accent-pressed: #E52626;
}
.btn-primary {
height: var(--control-lg);
padding-inline: 16px;
background: var(--accent);
color: #FFFFFF;
border: 1px solid var(--accent);
border-radius: 4px;
font: 600 13px 'Space Grotesk';
}
@media (hover: hover) and (pointer: fine) {
.btn-primary:hover { background: color-mix(in srgb, var(--accent) 88%, #FFF); }
}
.btn-primary:active { background: var(--accent-pressed); }
.btn-primary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }enum DdsBase {
static let darkBg = Color(hex: 0x000000)
static let darkSurface = Color(hex: 0x0B0B0B)
static let darkBorder = Color(hex: 0x1F1F1F)
static let darkText = Color(hex: 0xFFFFFF)
static let lightBg = Color(hex: 0xF7F7F7)
static let lightSurface = Color(hex: 0xFFFFFF)
static let lightBorder = Color(hex: 0xE0E0E0)
static let lightText = Color(hex: 0x111111)
}
enum DdsUiAccent {
// Dot Red — same for all products
static let dark = Color(hex: 0xFF2D2D)
static let light = Color(hex: 0xE80000)
}
enum DdsSize {
static let xs: CGFloat = 24, sm = 32, md = 40, lg = 48, xl = 56
static let minTarget: CGFloat = 44
}
// Plain corners, not continuous — and a 44pt target around a smaller control.
extension View {
func ddsControl(_ height: CGFloat) -> some View {
self.frame(height: height)
.clipShape(RoundedRectangle(cornerRadius: 4, style: .circular))
.contentShape(Rectangle())
.frame(minWidth: DdsSize.minTarget, minHeight: DdsSize.minTarget)
}
}16 · What we measured against
DDS is opinionated, but it is not invented from nothing. Before fixing the control ladder and the spacing half-step we read what the large public systems actually publish, and where a number already had a good answer we took it rather than inventing a worse one.
| System | Base grid | Control heights | Min target | Radius | Color model |
|---|---|---|---|---|---|
| Material 3 ↗ | 4dp | XS 32 · S 40 · M 56 · L 96 · XL 136 dp | 48 × 48 dp | Shape scale, fully rounded by default | Tonal palettes generated from a seed color |
| Apple HIG ↗ | Points, no published grid | Platform-native, set by the control | 44 × 44 pt | Continuous (squircle) corners | System tint + semantic system colors |
| IBM Carbon ↗ | 2px mini-unit, 8px macro | sm 32 · md 40 · lg 48 px | Not specified separately | 0px — fully square | Fixed IBM palette behind semantic token layers |
| GitHub Primer ↗ | 8px base | XS 24 · S 28 · M 32 · L 40 · XL 48 px | Not specified separately | Small, non-zero | Functional token layers over a fixed scale |
| Atlassian ↗ | 8px base with 2 / 4 / 6 half-steps | Not published as a single ladder | Not specified separately | Small, non-zero | Fixed palette behind semantic tokens |
| WCAG 2.2 ↗ | — | — | 24 × 24 CSS px (AA) · 44 × 44 (AAA) | — | 4.5:1 text contrast (AA) |
| DDS | 4px base with a 2px half-step | xs 24 · sm 32 · md 40 · lg 48 · xl 56 px | 44 pt iOS · 48 dp Android · 24 px web | 4px, everywhere, always | Monochrome base + exactly one accent |
- · Carbon's 2px mini-unit, as our half-step
- · Primer's five-step control ladder and its condensed / normal / spacious padding triple
- · Atlassian's semantic-over-primitive token naming
- · Material 3's 48dp Android target floor
- · Apple's 44pt iOS target floor and Dynamic Type respect
- · WCAG 2.2's 24px web target floor
- · Material 3 tonal palettes — DDS has exactly one accent
- · Material elevation levels and the ripple
- · Apple's continuous (squircle) corners
- · Carbon's 0px radius — 4px keeps a control reading as a control
- · Semantic status palettes — status is shape and label, not hue
- · Pills, circles, and fully-rounded shapes
The pattern is consistent: we take accessibility floors from the platform owners, because those are constraints rather than opinions, and we reject everything that would add a second visual language on top of ours.
17 · Governance & versioning
DDS is versioned semantically. The current version is 1.3.0.
- Patch (1.x.y) — accent contrast tweaks, doc corrections.
- Minor (1.y.0) — new component, new token, new size step. Backwards compatible.
- Major (x.0.0) — breaking: removed tokens, changed monochrome base, new type family.
Any change to the yaml must be mirrored in every consuming project's local token file before that project's next release. The yaml is the contract.
Changelog
| Version | Date | Change |
|---|---|---|
| 1.3.0 | 2026-08-04 | Logo & wordmark construction spec; wordmark ratio corrected 1.82 → 2.02 to match the artwork; dani.ko (Dot Navy) and dani.do added |
| 1.2.0 | 2026-08-04 | Control ladder, component anatomy, state matrix, icon scale, per-platform rules, comparative survey |
| 1.1.0 | 2026-04-08 | One shared Dot Red UI accent; brand hues demoted to icons and assets |
| 1.0.0 | 2026-04-05 | Initial specification — color, type, spacing, radii, motion, components |
DDS is not a design system you customize. It's a design system you live inside. The constraints are the feature.
shared/design/dot-tokens.yaml