M4-04: Design system with tokens, theme and fonts (ADR 0030)¶
Status: ready | Depends on: M4-01, M4-03 | Milestone: M4
Goal¶
After this ticket packages/design_system is the one place where the apps and the console get their look: the generated token classes of design/build/dart/design_tokens.g.dart (including the accent variants of M4-03) as a checked copy inside the package, a theme builder that turns the four inputs theme, density, motion and accent into a Flutter ThemeData with the token extensions, a pure resolver from the personal settings (appearance.theme, appearance.density, appearance.reduce_motion, 01 11) and the system values to those inputs, the pinned fonts Atkinson Hyperlegible Next and Mono with their license, and the golden test setup (alchemist, CI goldens with the Ahem font; platform goldens only on macOS) that every later widget ticket uses. A widget test switches light and dark, the three densities and both motion contexts, and a golden of a sample page pins the result. Why: components (M4-07), the terminal view (M4-35), the visualizations (M6) and both apps must read colors, type, spacing and motion only from the tokens (ADR 0030, design README principle 7), and the golden matrix of the exit criteria needs one shared setup.
Read first¶
docs/PLAN.md: section 3; section 5 (row "Golden"); section 10 (Exit criteria item 1, Test strategy, rows M4-04, M4-05, M4-07, M4-18, M4-35).docs/design/README.md(all),docs/design/tokens.md,docs/design/typography.md("Integration":DsType, bundled variable TTF files),docs/design/spacing-radii-elevation-motion.md,docs/design/components.md("Common rules for all components"),docs/design/palette.md.docs/spec/15-client-architecture.md: 3, 3.1, 4 (row "settings, theme, density, motion"), 20 (rows "Widget" and "Golden": alchemist, CI goldens with Ahem, platform goldens only on macOS).docs/spec/01-product.md: 8 "Accessibility" (rows "Font size", "Motion", "Contrast"), 11 "Personal settings" (appearance.*), 12 (accent).- ADRs:
0030-design-tokens-as-one-source-with-a-generator.md(Confirmation: golden tests use only the generated classes),0027,0044. tools/design-tokens/README.md(the Dart output is not formatted and was checked only against stand-ins; this ticket compiles it against the real SDK),tools/design-tokens/tokens.py(generate),tools/design-tokens/vendor/MANIFEST.json.docs/quality/licenses.md(fonts under OFL-1.1; NOTICE),quality/licenses/allowed.txt,NOTICE.- Briefs:
docs/briefs/M4-01-workspace-arch.md(member rules, generated patterns),docs/briefs/M4-03-accent-variants.md(DsAccent). docs/DECISIONS.md: D-W-100, D-W-114; section 10.
This ticket touches no API operation, no table, no action, no event and no configuration key.
Fixed requirements that apply¶
Owner's fixed build rules (apply verbatim):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
- Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them, and the ARB test of M4-06 rejects them in interface texts. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. Interface texts come frompackages/l10n, never as literals in widgets. - Limits and thresholds only through named values; the token values are the only source of sizes, colors, durations and curves (no literal in widget code except
0). This ticket adds no configuration key. - Specification first:
docs/design/typography.md"Integration" andtools/design-tokens/README.md"Outputs" describe the package copy and the fonts in the same change.
Product requirements implemented here:
- Tokens only through the generated classes (
DsColors,DsAccent,DsElevation,DsDensity,DsMotionasThemeExtensions;DsSpace,DsRadius,DsBorderWidth,DsLayout,DsSize,DsIconSize,DsFonts,DsTypeas constants) (ADR 0030, tool README "Outputs"). - Theme
lightanddark; densitycomfortable,compact,touch(mobile fixed totouch,compactonly on desktop); motionfullandreduced, where the system setting "Reduce motion" andappearance.reduce_motionselectreduced(01 8, 01 11). - Fonts: Atkinson Hyperlegible Next and Mono, variable TTF from the source repositories in a pinned version with the OFL text bundled; the fallback list of
font.family.*stays (typography.md). - Text scaling up to 200 percent through
MediaQuery.textScaler; type roles only, neverfont.size.*directly (typography.md "Roles"). - Golden tests with alchemist: CI goldens with Ahem on every machine, platform goldens only on macOS (15 20).
Scope¶
Create or fill in packages/design_system/:
lib/src/generated/design_tokens.g.dart: the copy of the generator output (Design note 1).lib/src/theme/ds_context.dart(DsContext,DsThemeMode,DsDensityMode,DsMotionMode),lib/src/theme/ds_settings_resolver.dart,lib/src/theme/ds_theme.dart,lib/src/theme/ds_theme_scope.dart(Design notes 2 to 4).fonts/AtkinsonHyperlegibleNext[wght].ttf,fonts/AtkinsonHyperlegibleNext-Italic[wght].ttf,fonts/AtkinsonHyperlegibleMono[wght].ttf,fonts/AtkinsonHyperlegibleMono-Italic[wght].ttf(only the styles the source repositories ship as variable files),fonts/<family>.OFL.txtfor each family (Design note 5),fonts/MANIFEST.json(source URL, commit, SHA-256 per file) (Design note 5).lib/design_system.dartexports;lib/testing.dartwith the golden helpers (Design note 6).test/flutter_test_config.dart,test/theme_switch_test.dart,test/settings_resolver_test.dart,test/tokens_copy_test.dart,test/fonts_test.dart,test/goldens/sample_page_test.dartwith its golden files.
Change:
tools/design-tokens/tokens.py(generate): also writes the package copy;checkcompares it (Design note 1);tools/design-tokens/tests/test_gate.pygains the case.packages/design_system/pubspec.yaml(fonts,alchemistas a dev dependency),README.md.NOTICE(font attribution),docs/design/typography.md"Integration",tools/design-tokens/README.md"Outputs",docs/quality/licenses.md(a section "Bundled fonts", Design note 5; no new row in the table of allowed licenses).docs/DECISIONS.mdsection 10: rowsD-M4-04-<n>; rootCHANGELOG.mdunder[Unreleased].
Out of scope¶
- Icons and
DsIconsrendering: M4-05 (the generatedDsIconsconstants compile here, glyph rendering comes there). - Components: M4-07. Localized texts: M4-06 (the sample page uses no text or only test strings in the test file).
- Reading
user_settingsfrom the local database andbranding.accent_colorfrom the instance: M4-18 and M4-25 feed the resolver. - Changes to the token values.
Boundaries¶
- Work in a dedicated git worktree on the branch
feat/M4-04-design-system-theme; one writer per worktree. - Conventional Commits with the package or component as scope (for example
feat(client): ...,feat(sync-engine): ...,build(gates): ...,docs(spec): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md). - Never push to
main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request ofAGENTS.md. Never publish a Dart package (publish_to: nonein everypubspec.yaml). - Do not modify files outside the scope above, except the CHANGELOG, README and
docs/DECISIONS.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1with port 0 and make no network call; fakes replace the server unless a section below names a container. Temporary directories only viamktemp -d(DartDirectory.systemTemp.createTemp, pytesttmp_path), removed at the end. Stop only processes you started, by PID. - Never read or print secrets. No network calls except package downloads for pinned tools and pub packages and what the gates of
make checkdo themselves. - The Flutter SDK at the version pinned in the root
pubspec.yamlmust be onPATH(and Go and Docker where a section names them); without them the gates fail by design (R-QA-02), and the ticket stops withVerdict: BLOCKED <tool> missinginstead of skipping. - Never lower a threshold, disable a gate, delete a test, raise
repo.max_file_kbor extendquality/licenses/allowed.txt; never exclude hand-written code from a lint, format, coverage or doc gate. - The product name appears only at the places of 02 "Code layout" ("Places that carry the product name"); the app shows
branding.product_namefrom the instance, never a literal. - No permission logic in the client (15 1 principle 1): controls follow server responses, snapshots and events, never an own derivation from grants.
- If the core contract (chapters 00, 03, 04, 05) or
docs/api/lacks something this ticket needs, stop withVerdict: BLOCKED <reason>instead of building a deviation (P-11). - This brief was written before milestone M1 closed (D-W-100, extended to M4 by D-W-114). Where the merged code of an earlier ticket names a package, type, function, file, provider or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with
Verdict: BLOCKED <reason>. - Network only for fetching the four font files once at a recorded commit (Design note 5).
Design notes¶
-
Token copy. Ruling for docs/DECISIONS.md:
tokens.py buildwrites the Dart output twice, todesign/build/dart/design_tokens.g.dart(unchanged) and topackages/design_system/lib/src/generated/design_tokens.g.dart(same bytes), andtokens.py checkcompares both; reason: a package may not import a file outside its folder (15 3.1 rule 2), and one generator writing both copies keeps them equal without a second tool. The copy is generated code (M4-01 patterns): no doc lint, no format check, no coverage; it must compile without errors underflutter analyzeagainst the real SDK. If it does not compile (the tool README says it was checked only against stand-ins), fix the generator (tokenkit/emit_dart.py) and its tests, never the copy.test/tokens_copy_test.dartasserts that the copy exists and thatDsColors,DsAccent,DsDensity,DsMotionandDsElevationeach have instances for all their contexts. -
Context and resolver.
DsContext({required DsThemeMode theme, required DsDensityMode density, required DsMotionMode motion, required String accent})(value type with==andhashCode;accenta key ofDsAccent).DsContext resolveDsContext({required String? appearanceTheme, required String? appearanceDensity, required String? appearanceReduceMotion, required Brightness platformBrightness, required bool platformReduceMotion, required bool isTouchPlatform, required String? accentKey}):appearance.themesystemor absent followsplatformBrightness;appearance.densityapplies only on desktop, a touch platform is alwaystouch, absent meanscomfortable;appearance.reduce_motionongivesreduced,offgivesfull,systemor absent followsplatformReduceMotion; an unknown value of any key falls back to the default of 01 11 (the server rejects unknown values, but a newer server may add one; 06 13); an unknown or absent accent givesspruce. Pure function, no Flutter widget import beyondBrightnessfromdart:ui(the package is a Flutter package). -
Theme.
ThemeData buildDsTheme(DsContext context):useMaterial3: true,brightness, aColorSchememapped fromDsColorsandDsAccent(primary fromcolor.accent.bg, onPrimary fromcolor.text.on-accent, surface fromcolor.surface.canvas, error fromcolor.danger.bg, outline fromcolor.border.default; record the full mapping in the package README),extensions: [DsColors.<theme>, DsAccent.of(accent, brightness), DsElevation.<theme>, DsDensity.<density>, DsMotion.<motion>],textThemefromDsTyperoles with the font families ofDsFonts,visualDensityfrom the density,pageTransitionsThemewithout motion inreduced, and the focus ring fromcolor.focus.ringwithborder-width.focus(components.md: focus always visible). Accessorscontext.dsColors,context.dsAccent,context.dsDensity,context.dsMotion,context.dsElevationas an extension onBuildContextthat throw aStateErrornaming the missing extension when the theme was not built withbuildDsTheme. -
Scope widget.
DsThemeScope({required DsContext dsContext, required Widget child})builds the theme withAnimatedThemewhose duration ismotion.duration.fade(zero inreduced), so a switch of theme or density is visible but never animated under reduced motion. No state of its own: the caller passes a newDsContext. -
Fonts. Fetch the variable TTF files and the OFL texts from
https://github.com/googlefonts/atkinson-hyperlegible-nextandhttps://github.com/googlefonts/atkinson-hyperlegible-next-monoat the commit of the default branch on the build day, at the font version thattools/design-tokens/vendor/MANIFEST.jsonnames for the WOFF2 files (5.3.0 per typography.md) or the nearest release tag of that version; record URL, commit and SHA-256 infonts/MANIFEST.json;test/fonts_test.dartrecomputes the SHA-256 of every file and fails on a mismatch, a missing or an extra file. Every file must stay belowrepo.max_file_kb; if a variable file exceeds it, stop withVerdict: BLOCKED font file over repo.max_file_kbinstead of subsetting by hand.pubspec.yamldeclares the familiesAtkinson Hyperlegible NextandAtkinson Hyperlegible Monowith the files (variable weights throughFontWeightaxis use).NOTICEnames both fonts with copyright and OFL-1.1. The font files are bundled assets, not package dependencies:make licenses(OSV-Scanner over lock files) does not see them, andquality/licenses/allowed.txtstays unchanged. Their license is settled by D-L11 and typography.md 5 (OFL-1.1, license text shipped with the files): bundle the OFL texts next to the files (fonts/<family>.OFL.txt), record"license": "OFL-1.1"per file infonts/MANIFEST.json, and lettest/fonts_test.dartalso fail when a font file has no license text or a manifest entry names another license. Add a section "Bundled fonts" todocs/quality/licenses.mdthat states this rule with D-L11 (outside the table that its test compares withallowed.txt). Ruling for docs/DECISIONS.md. -
Golden setup.
test/flutter_test_config.dartloads the bundled fonts for platform goldens and setsAlchemistConfigwithciGoldensConfigenabled everywhere andplatformGoldensConfigenabled only whenPlatform.isMacOS(15 20); golden files undertest/goldens/ci/andtest/goldens/macos/.package:design_system/testing.dartexportsdsGoldenMatrix({required String name, required Widget Function() builder, Iterable<DsThemeMode> themes, Iterable<DsDensityMode> densities, Iterable<double> textScales, Iterable<Locale> locales})that builds one alchemistGoldenTestGroupwith a scenario per combination, wrapped inDsThemeScopeand aMediaQuerywith the text scaler; later tickets call it. Ruling for docs/DECISIONS.md: test helpers in a separate librarytesting.dart, because several members need the same matrix and test folders cannot be shared between packages;alchemiststays a dev dependency of every member that importstesting.dartin its tests, andtesting.dartitself imports onlyflutter_testtypes through the caller (it takesalchemistas a dependency only iftesting.dartcannot be written without it; thenalchemistbecomes a regular dependency ofdesign_systemwith the reason in the ruling). The README states how to regenerate goldens (flutter test --update-goldens) and that platform goldens are updated only on macOS. -
Rulings for docs/DECISIONS.md (
D-M4-04-<n>): the token copy written by the generator (note 1); the resolver's fallbacks for unknown values (note 2); the color scheme mapping (note 3); the font source and version (note 5);testing.dartand the alchemist dependency (note 6).
Tests (write them first)¶
packages/design_system/test/ (run by flutter test --coverage):
settings_resolver_test.dart: one case per row:systemwith dark platform gives dark;lightoverrides a dark platform; touch platform withcompactgivestouch; desktop absent density givescomfortable;on,off,systemwith both platform values; unknown values ("neon","tiny","sometimes") give the defaults; unknown accent"purple"givesspruce.theme_switch_test.dart: a widget readscontext.dsColors.textPrimary(or the generated field name) andcontext.dsDensity.controlHeight; pumping with light then dark changes the color to the token value of each theme;comfortable,compact,touchgive the three token heights;reducedgives a zeroAnimatedThemeduration andfullthe tokenmotion.duration.fade; thelochaccent changescontext.dsAccentto thelochvariant; a widget under a plainThemeDatagets theStateError.tokens_copy_test.dart(Design note 1).fonts_test.dart(Design note 5) andfont family resolves: aTextwithDsType.bodyuses the familyAtkinson Hyperlegible Next.goldens/sample_page_test.dart: a sample page (a header intype.title, a body paragraph, a code line intype.code, a primary and a secondary button built from plain Material buttons with the theme, a raised surface withradius.md) throughdsGoldenMatrixin light and dark,comfortableandtouch, text scale 1 and 2.tools/design-tokens/tests/test_gate.py:test_check_fails_when_package_copy_differs.
Commands that must pass, from the repository root:
uv run tools/design-tokens/tokens.py check,make tokenscd packages/design_system && flutter analyze --fatal-infos && flutter test --coveragemake arch,make lint,make fmt-check,make coverage,make licensesmake check
Acceptance criteria¶
-
flutter analyzeis clean fordesign_system, and the generated token file compiles against the real Flutter SDK. - A widget test switches light and dark, the three densities, both motion contexts and an accent, and reads the token values.
- A golden of a sample page exists in light and dark,
comfortableandtouch, text scale 1 and 2, as CI golden (and as platform golden on macOS). - The fonts are bundled from a pinned source with the OFL text and SHA-256;
NOTICEnames them;make licensesis green. -
tokens.py checkfails when the package copy differs from the generator output. - Rulings recorded; typography.md and the tool README updated.
-
make checkis green.
Definition of done¶
- Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
packages/design_system/README.md(English): what the package provides, the color scheme mapping, the resolver table, how to update goldens and tokens.- Rulings of Design note 7 in
docs/DECISIONS.mdsection 10;docs/design/typography.md,tools/design-tokens/README.md,NOTICEupdated. - Root
CHANGELOG.mdentry under 'Unreleased'. - An ADR only if you take an architecture decision beyond this brief (MADR 4 as in
docs/adr/0000-template-madr-4.md, headings and text in English, next free number,make adr-index). make checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of
make check. - Review and red team verdicts and the findings fixed.
- Font source commits and SHA-256, file sizes; the alchemist and Flutter versions; the number of golden files; whether platform goldens were produced (macOS only).
- What is not proven (for example a platform test that ran only in CI, or a check done by hand).
- Follow-ups that could not be done in this scope, each with its reason, every difference to the names of this brief (D-W-100, D-W-114), and spec gaps found.
Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.