← Back to Blog
Design··11 min read

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.

Where it lives
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.

SystemUsed byShapeFaces
DDS-1 · dot_flutterdani.go, dani.scoutrectangular, 4pxSpace Grotesk / Space Mono
DDS-2 · dot.cssdani.subs, admin-consolerectangular, 4pxSpace Grotesk / Space Mono
DDS-3 · dds3/nothing yetrectangular, 4pxManrope / DM Mono
Taegeukdani.ko onlyrounded, 20px, pillsManrope / 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.

The precedent it is avoiding
dani.ko shadowed 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.

LaneFromWhat that means
FONTSTaegeukManrope and DM Mono, the 700 weight cap, −0.3px tracking, 1.16 leading, and mono = data
UI CONTROLSDDSEvery radius 4 (frame 8), no pills, no circles, no shadows, hairline borders with padding compensation
COLORper appTaegeuk'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.

css
--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.

TokenSizeWeightFamilyNote
display34700Manrope−0.3px / 1.16 — the capped ramp
title25700Manrope−0.3px / 1.2
heading18700ManropeCard titles
body14400 / 600ManropeList rows, labels in sentence case
body_sm12.5400ManropeSecondary lines
label10.5700ManropeUppercase, .15em — Manrope, not mono
amount28700ManropeThe total
data12500DM MonoPrices, dates, counts, addresses
data_lg15500DM MonoA single compared figure
Hangul at display size
Two rules that are not stylistic. Tracking goes to 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:

SelectorTaegeukDDS-3
.card204
.btn164
.field164
.mason .t164
.docrow .ic144
.tag994
.cpy994
.back994
.cb994
.bar994
.frame408

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.

Ted, 2026-09-01
“we use all the monotonic colors as taeguk.”

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.

css
.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; }
태극기 blue and red are dani.ko's accent, not a shared role
“blue/red is for dani.korea only.” — Ted, 2026-09-01. dani.ko is listed in the token file for completeness; it ships Taegeuk itself, not DDS-3.

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.”

StateMarkerColourExtra
success✓text—
warning!accent—
error!accent2px accent border
unknown–text_31px 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.

Ted, 2026-09-01
“why bright green for dark mode? it should use the same green.”

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.

Tokensubs darksubs lightgo darkgo 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.

PairContrastVerdict
#FFFFFF on #0075045.9:1The fill. Clears AA over either background — that is the point
#007504 on #F7F8FA5.6:1Green as text on light. No lift needed
#007504 on #0D11173.2:1Green as text on dark. Under the 4.5:1 floor — this is why the lift exists
#00D40A on #0D11179.4:1The lift. accent-text, dark mode, and nothing else
#E80000 on #0D11174.0:1Red 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.

MeasurementManropeSpace GroteskDelta
x-height @100px54.049.4+9.3%
cap-height @100px72.070.0+2.9%
“Add subscription” @12px/60097.19px99.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.

Size stays 12px

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.

Tracking stays +0.02em

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.

600 survives

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.

The one case where the number is not 12
If a screen ever needs apparent parity with a DDS-2 control sitting right beside it — a shared admin surface, say — the number is 11px, not 12: 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.

dart
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.

What is genuinely lost
Fast pre-attentive grouping while scrolling. A monogram is a weaker signal than a hue, and mono alignment helps you read five similar addresses rather than tell them apart without reading. This is a real cost of the rule, not a wash. If it turns out to matter in use, the honest fix is a position or ordering change — not a second palette.

07 · What DDS-3 does not have

Reach for any of these and you have left the system.

× Not in 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
× Not yours to change
  • · 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.

html
<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.

dart
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/.

The one Flutter trap
Manrope ships as a single variable file, and Flutter does not drive a variable font's 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.