DDS 3 — Taegeuk's voice in DDS's geometry
A third dani design system. It takes Manrope and DM Mono and the cool neutral ramp from Taegeuk, draws them with DDS's 4px rectangular discipline, and carries exactly one chromatic token, which belongs to the product. It is additive: DDS-1 and DDS-2 are not edited and both still ship.
shared/design/dds3/ — the rules in DDS3.md, every value in dds3-tokens.yaml, the stylesheet in dds3.css, the Flutter package in dds3_flutter/. The system drawn is gallery/index.html; the screen drawn is mock/subs-subscriptions.html, dani.subs' subscriptions list before and after, dark and light. This article is the release note. Those two are the design.01 · A third system, and it replaces nothing
There are now three, and all three ship. Nothing in dot_flutter, dot.css or dot-tokens.yamlwas edited to make DDS-3 exist, because those files are shared with dani.go and every other dani product, and those products' rendering must not move.
| System | Used by | Shape | Faces |
|---|---|---|---|
| DDS-1 · dot_flutter | dani.go, dani.scout | rectangular, 4px | Space Grotesk / Space Mono |
| DDS-2 · dot.css | dani.subs, admin-console | rectangular, 4px | Space Grotesk / Space Mono |
| DDS-3 · dds3/ | nothing yet | rectangular, 4px | Manrope / DM Mono |
| Taegeuk | dani.ko only | rounded, 20px, pills | Manrope / DM Mono |
Taegeuk is in the table because DDS-3 is half of it. It is dani.ko's own base system and is not a dani-wide option.
DDS-3 declares its own names — .dds3-* in CSS, Dds3* in Dart. That is not tidiness. It is what lets a product migrate one screen at a time with both languages compiling side by side, no collision and no lint rule holding them apart.
dotColors rather than edit 253 call sites, and ended up needing a lint that forbade importing the real dot_flutter to keep the two apart. Two things with one name is a problem you pay for later, in a file that has nothing to do with design.02 · The composition is exactly three rules
Three lanes, three sources. Everything in the system is derivable from this table, and anything that is not in one of these lanes is not in DDS-3.
| Lane | From | What that means |
|---|---|---|
| FONTS | Taegeuk | Manrope and DM Mono, the 700 weight cap, −0.3px tracking, 1.16 leading, and mono = data |
| UI CONTROLS | DDS | Every radius 4 (frame 8), no pills, no circles, no shadows, hairline borders with padding compensation |
| COLOR | per app | Taegeuk's cool neutral ramp, plus exactly one chromatic token resolved per product |
Fonts = Taegeuk
Manrope replaces Space Grotesk, DM Mono replaces Space Mono, and Taegeuk's type laws come with them intact: display weight capped at 700, tracking −0.3px, leading 1.16. Taegeuk's rejected proposal ran 900 at −1.6px and 0.94, and Ted's note was “font is too bold they look bunched up”— the three values compound, and 0.94 leading under a 900 weight puts descenders into the next line's caps. None of the three moves here either.
The Korean fallback rides along on every sans rule, not only the Hangul one, because Manrope carries no Hangul at all. The stack is load-bearing rather than defensive. So is the mono one: DM Mono covers $ £ € and nothing else, so ₩ ₹ ¥ fall through to Manrope rather than to tofu.
--sans: 'Manrope', 'Apple SD Gothic Neo', 'Noto Sans KR', 'Malgun Gothic', system-ui, sans-serif;
--mono: 'DM Mono', 'Manrope', monospace;Face assignment follows Taegeuk rather than DDS-2. Taegeuk's rule is mono = data — what the user actually compares: prices, dates, renewal counts, billing periods. Chrome is Manrope. This is why label is Manrope at 10.5px here, where DDS-2 sets labels in mono at 9px.
| Token | Size | Weight | Family | Note |
|---|---|---|---|---|
| display | 34 | 700 | Manrope | −0.3px / 1.16 — the capped ramp |
| title | 25 | 700 | Manrope | −0.3px / 1.2 |
| heading | 18 | 700 | Manrope | Card titles |
| body | 14 | 400 / 600 | Manrope | List rows, labels in sentence case |
| body_sm | 12.5 | 400 | Manrope | Secondary lines |
| label | 10.5 | 700 | Manrope | Uppercase, .15em — Manrope, not mono |
| amount | 28 | 700 | Manrope | The total |
| data | 12 | 500 | DM Mono | Prices, dates, counts, addresses |
| data_lg | 15 | 500 | DM Mono | A single compared figure |
0, because the display ramp's −0.3px closes Hangul counters until the characters read as smudges at exactly the size someone is squinting at. And it is always full contrast, never accent-coloured — this is the string a taxi driver or a restaurant server reads off a stranger's phone.UI controls = DDS
No pills. No circles. dot-tokens.yaml sets pill: 0 with the note “(unused — Dot is rectangular)”and DDS-3 keeps it at 0. This is the single largest visible difference from Taegeuk and it is the whole point of the system: Taegeuk's voice, drawn square.
Three radii — 2 for hairline detail, 4 for every control, card, button, chip, avatar and input, 8 for a phone frame's outer edge. There is no second card radius and no per-widget exception. A dialog is 4. An avatar is 4, never a circle. Every Taegeuk radius collapses into that, exhaustively:
| Selector | Taegeuk | DDS-3 |
|---|---|---|
| .card | 20 | 4 |
| .btn | 16 | 4 |
| .field | 16 | 4 |
| .mason .t | 16 | 4 |
| .docrow .ic | 14 | 4 |
| .tag | 99 | 4 |
| .cpy | 99 | 4 |
| .back | 99 | 4 |
| .cb | 99 | 4 |
| .bar | 99 | 4 |
| .frame | 40 | 8 |
Eleven selectors, two destinations. This table is the entire geometry migration.
Borders are hairline 1px, 2px on selected, focus and error — with padding compensation. A control that gains a 2px border loses 1px of padding per side, so the text does not shift by a pixel when it is selected. Rest is 1px + 14/16; selected is 2px + 13/15. Same box, same baseline. Dds3Borders.pad() does it in Dart and every .on / :focus rule in dds3.css does it in CSS.
Depth is surface stacking and borders, to a maximum of three layers. Never shadows, never blur. Taegeuk's --shadowis dropped entirely; the only shadow anywhere in DDS-3 is on the mock's phone frame, which is furniture rather than UI.
Colour = per app
The neutrals are Taegeuk's, verbatim — the cool, faintly blue greys, shared by every product without exception, not DDS-2's #000000 / #0B0B0B.
Chroma is one token, resolved per product, and the hues themselves still come from dot-tokens.yaml § brand_colors — DDS-2 remains the registry. Adding a product to DDS-3 is two CSS rules and never a new token.
.dds3-subs.dds3-dark { --accent:#007504; --on-accent:#FFFFFF; --accent-text:#00D40A; --accent-soft:#0B2A10; }
.dds3-subs.dds3-light { --accent:#007504; --on-accent:#FFFFFF; --accent-text:#007504; --accent-soft:#E0F2E1; }03 · There is no semantic palette, and that is what dissolves the collision
Taegeuk carried three product-independent chromatic roles: blue for lead and action, red for deadline and warning, green for verified. Put a per-product accent underneath that and it breaks immediately — a green product collides with ok, a red one collides with warn.
The obvious fix is to reserve fixed values no product accent may occupy. DDS-3 does something else: it keeps none of the roles. It adopts DDS-2's allocation instead, which is stated in dot-tokens.yaml § status — “DDS has NO semantic color palette. Success is a ✓ in mono at text; warning and error are a ! marker plus a 2px accent border. This survives colorblindness, greyscale printing and glare, and keeps the accent meaning exactly one thing.”
| State | Marker | Colour | Extra |
|---|---|---|---|
| success | ✓ | text | — |
| warning | ! | accent | — |
| error | ! | accent | 2px accent border |
| unknown | – | text_3 | 1px dashed border |
That dissolves the collision rather than resolving it. There is no fixed ok for a green accent to collide with and no fixed warn for a red one, because status is never carried by hue at all. A green product and a red product are the same system with one value swapped — which is what the last panel of the mock shows: identical markup, one variable changed.
Colour alone is forbidden. Every accent signal repeats as a marker, a label or a position.
unknownis the one thing promoted out of Taegeuk's tags, because it was the only one of the four that was never about hue. It stays neutral and dashed, never accent. A red unknown reads as “you did something wrong” when the thing missing is ours.
04 · One accent value, both modes
accent does not change between dark and light. Nor does on-accent or accent-soft.
The product's mark has one value and the UI wears that value. An app whose logo is #007504 and whose buttons are #00D40A is two greens on one screen, and a reader sees that before they can name it.
| Token | subs dark | subs light | go dark | go light |
|---|---|---|---|---|
| accent | #007504 | #007504 | #E80000 | #E80000 |
| on-accent | #FFFFFF | #FFFFFF | #FFFFFF | #FFFFFF |
| accent-text | #00D40A | #007504 | #FF4747 | #E80000 |
| accent-soft | #0B2A10 | #E0F2E1 | #2E1216 | #FDE7E9 |
Three rows out of four are identical across modes. Only accent-text splits.
It holds for a reason that is easy to state and easy to forget: a fill is what text sits on, not what it sits against. White on #007504is 5.9:1 whether that button is on a black page or a white one. The background behind the button has no bearing on the button's own legibility, so tuning the fill per mode was solving a problem that was not there.
accent-text is the exception, and the only one — the accent as thin text or an icon sitting directly on bg, where the background is what it sits against. There the numbers force a split.
| Pair | Contrast | Verdict |
|---|---|---|
| #FFFFFF on #007504 | 5.9:1 | The fill. Clears AA over either background — that is the point |
| #007504 on #F7F8FA | 5.6:1 | Green as text on light. No lift needed |
| #007504 on #0D1117 | 3.2:1 | Green as text on dark. Under the 4.5:1 floor — this is why the lift exists |
| #00D40A on #0D1117 | 9.4:1 | The lift. accent-text, dark mode, and nothing else |
| #E80000 on #0D1117 | 4.0:1 | Red makes the same split for the same reason |
The lift applies only where the accent is thin text or an icon on the page background. Fills, borders and washes stay at the one value in both modes.
None of this is new. subs_tokens.dart already works this way, and so does Taegeuk itself: its --blue: #0047A0 is one value in both .dark and .light, with --blueText as the single dark-mode lift. DDS-3 states the rule that both were already following.
It is also why a logo widget hardcodes its light value in both modes while a UI token does not. A logo is artwork and does not adapt; a UI token is not artwork and must.
05 · The re-meter, where the premise was wrong
DDS-2's .dot-btnis 600 / 12px / +0.02em, and those numbers were metered for Space Grotesk. Swapping the face invalidates them, so the brief said to re-check rather than inherit — and it came with an assumption attached, that Manrope “sets wider and rounder” and would need the numbers loosened.
Measured in Chrome — canvas TextMetrics, Manrope-Variable at 600 against SpaceGrotesk-SemiBold, x-height and cap-height from the ascent of x and H at 100px, set width from the real button label — the assumption is refuted.
| Measurement | Manrope | Space Grotesk | Delta |
|---|---|---|---|
| x-height @100px | 54.0 | 49.4 | +9.3% |
| cap-height @100px | 72.0 | 70.0 | +2.9% |
| “Add subscription” @12px/600 | 97.19px | 99.35px | −2.2% |
Manrope is narrower at the same size, and distinctly taller in the lowercase.
So a 12px Manrope label does not read smaller than the 12px it replaces. It reads slightly larger — roughly the apparent size of 13.1px of the old face — while occupying 2.2% less width. The change that was expected to be needed had already happened in the opposite direction.
Growing it to 12.5 was the intuitive move and it is the wrong one: it compounds an increase the +9.3% x-height has already delivered. Beside the shipping box in the mock, 12.5 reads heavy for its 14/16 padding.
Rendered at 3× against a 12px/+0.01em alternative, the difference is about 0.24px per character over a 16-character label and is not perceptible at 1×. No measured reason to move it, and no visible one.
Manrope's 600 holds its colour against a hairline border at this size. 500 goes limp and 700 shouts next to the 700 display.
The result of the re-meter is that nothing moved — size, tracking, weight, padding, radius and border width all carried over from DDS-2 unchanged. That is worth separating from the case where nothing moved because nobody looked. These are the same numbers for a different reason than the one they were chosen for. Both rejected alternatives are rendered beside the shipping ones in the mock's re-meter panel, so the conclusion can be inspected rather than taken on faith.
12 × (49.4 / 54.0) = 10.98. It is not the default. DDS-3 controls are metered for DDS-3, and 11px is small for a primary label.06 · What it costs dani.subs
The brief named two collisions. Drawing the screen turned up a third, and it is the one place where DDS-3 takes away something dani.subs genuinely uses.
account_badge.dart colours each Gmail account from a ten-hue palette, hashed stably off the address. The reason is written in the file: “users can tell their accounts apart at a glance (e.g. five ‘ted’ accounts on different domains).” That is a real job and the colour was doing it.
const List<Color> _accountPalette = [
Color(0xFFE94B3C), Color(0xFFF39C12), Color(0xFFF1C40F), Color(0xFF27AE60),
Color(0xFF16A085), Color(0xFF3498DB), Color(0xFF5D6DFF), Color(0xFF8E44AD),
Color(0xFFE84393), Color(0xFF95A5A6),
];Ten hues cannot survive a rule that says one chromatic token and everything else monochrome. Under DDS-3 the account chip is neutral, and the job is carried by two things that were already on the chip: the address set in DM Mono, where five similar addresses align and scan down a column, and a 4px monogram tile in surface-2.
07 · What DDS-3 does not have
Reach for any of these and you have left the system.
- · A semantic colour palette — no success green, no warning amber, no danger red
- · A second accent, or a per-account / per-category hue
- · A radius other than 2, 4 and 8 — no pills, no circles
- · A shadow, a blur or a gradient
- · A fourth nested surface
- · Colour as the only signal — every accent signal repeats as a marker, label or position
- · Uppercasing inside a widget — uppercase is the caller's job, and a widget that uppercases its own string breaks an email address
- · Accent-coloured Hangul at display size — full contrast, always
- · dot.css, dot-tokens.yaml or dot_flutter, edited to suit DDS-3 — they belong to DDS-1 and DDS-2, which still ship
08 · Adopting it
On the web, three classes: dds3 for faces and geometry, dds3-<product> for the accent, and dds3-dark / dds3-light.
<link rel="stylesheet" href="shared/design/dds3/dds3.css">
<body class="dds3 dds3-subs dds3-dark">In Flutter, a Theme widget around one screen is the supported migration path. That is what makes a port testable on a single page without touching the rest of the app, and why every Material widget on that page picks the new language up without being rewritten.
Theme(
data: dds3Theme(Dds3Colors.subs(Brightness.dark)),
child: const SubscriptionListScreen(),
)The package declares family names only and takes no google_fontsdependency — a runtime font fetch is a network dependency on first paint, and that is not one a design system gets to add on a product's behalf. The consuming app bundles the files from shared/design/dds3/fonts/.
wght axis from fontWeight alone. Without an explicit FontVariation every weight renders at the axis default and the whole ramp flattens to one weight. Dds3TypeRamp always sets both; a hand-written TextStyle has to as well.No product has shipped on DDS-3. What exists is the specification, the tokens, the stylesheet, the Flutter package, the gallery and one screen drawn both ways. The first migration will find things this article does not know, and the mock is the thing to check against when it does — prose does not carry composition, density or rhythm, and those are what decide whether a screen reads as considered or reads as empty.