parity/design-tokens.yaml, and is generated into both platforms by the duet command-line tool. You declare seven color tokens and two type styles, generate the vocabulary enums and the value tables for Swift and Kotlin, link the family’s theme engines, bind the Insights card, the promo and the plan cards to the tokens, and then ship a second theme, high contrast, that the system’s own accessibility setting selects. The second theme is one mapping per platform beside the generated tables; no feature module, no view and no recording changes for it. This is the seventh page of the nine-tutorial series, and it starts from Tutorial 6’s finished tree, as Tutorials 8 and 9 do.
What will you build?
A theming layer with one source of truth.parity/design-tokens.yaml names every color and type style the cards use, with a value per appearance; tools/duet design-tokens writes the Swift and Kotlin vocabularies and value tables from it, and tools/duet design-tokens --check joins the checks so a hand-edited or stale generated file is red. The views name tokens, cardSurface, labelLocked, cardTitle, and never a color. The main theme reads the generated table entry for entry; the high-contrast theme maps every token onto a stronger entry of the same table, so it carries no value of its own and cannot drift from the config. Expect about two hours, most of it in the two view files and the two theme files.
You will have at the end:
parity/design-tokens.yamlwith seven color tokens in three groups and two type tokens, and six generated files underGenerated/on the two platforms.- The theme engines linked: the
DuetThemingproduct of duet-services in a newThemingtarget of the Swift package, and thethemingartifact in a new:themingGradle module. - The Insights card, the promo and the plan cards bound to tokens on both platforms, with the recordings unchanged.
- A second theme, high contrast, selected by Increase Contrast on iOS and by the contrast setting on Android 14 and later, and a test that pins every label at WCAG level AAA under it.
Where do you start?
Opentutorial7-start from the duet-tutorials repository. It is Tutorial 6’s finished tree plus one failing test, the closing exercise, and it resolves Duet 0.7.0, duet-tools 0.24.0, duet-services 0.11.1 and the KSP mock processor 0.2.1 with Tutorial 6’s toolchain. Tutorials 7, 8 and 9 all open this same tree. Run the checks once before you edit anything:
Tutorial7ExerciseSecondThemeTest in the home module; that is the exercise, and everything else is green. TUTORIAL_SKIP_STUBS=1 tools/duet verify leaves the stub out and ends with duet verify: PASS. The stub sits in the home module because the module the finished test lives in, :theming, does not exist yet; the exercise moves it.
The steps
Declare the vocabulary
parity/design-tokens.yaml, whose grammar is the toolchain’s contracts/design-tokens.md. The file starts with the two targets: where each language’s generated files go, which engine they import, and the type the value table extends.light: and a dark: value, or one value: for both; alpha is a fraction on the appearance it belongs to. The doc: becomes the case’s documentation comment; the note: becomes a comment on the value.swift: target is declared, the Dynamic Type style the cut scales against on iOS:Read what was generated
MainTheme, the theme class you write in the next step, returning a ColorSet per token; the two-appearance form becomes .auto(light:dark:), which resolves to a dynamic UIColor that follows a light/dark switch on its own. On Android it is an object of ColorToken entries in 0xAARRGGBB, so the translucent border’s 0.12 alpha is the 1F byte:switch and each when is exhaustive over its vocabulary, so a token added to the config gets a value in the same generation. The type table has the same shape, a FontToken record per style on each side. The check regenerates every file in memory and compares whole files:Link the engines and declare the main theme
FoyerKit gains a Theming target over the DuetTheming product, and the package gains its second family dependency, pinned exactly like the first:swift test, and there the target compiles to nothing. On Android, a :theming module with the JVM target alone, because the Android app consumes its JVM variant and the iOS app reads the same config through its own engine:settings.gradle.kts and add implementation(project(":theming")) to the app module. The value types come from the artifact the version catalog already names, duet-services-theming.Now the theme itself. The generator wrote the value table as an extension of a class it does not declare; you declare it. On iOS MainTheme is a Themed, Assetable class with the two asset families the app does not use set to EmptyAsset, and one method generation cannot write: how a type token becomes a face, which reads font resources the app owns. This app uses the system face at the token’s weight and design, scaled by the token’s Dynamic Type style:Themes.swift registers .mainTheme and, ahead of step 6, .highContrast:MainPalette entry for entry:tools/duet verify never reaches this test; tools/duet lanes lists :theming under “outside the manifest”, and step 7 gives it a step of its own in the workflow.Write the accessors and publish the theme once
Color and a resolved type style. On iOS they are an extension of the engine’s ThemeProviding, and the View.font(_:) overload applies the token’s face, leading and tracking together:FontToken into a TextStyle in the platform’s face for its family:colorSchemeContrast, and Android 14 exposes its contrast setting through UiModeManager. FoyerThemeScope wraps the engine’s ThemeScope with that choice, over two providers built once. Nothing is persisted, because the setting is the user’s and the system keeps it:AppRoot replaces its MaterialTheme call:Theming product in xcodegen.yml, beside RootShell.Bind the cards
.secondary, .headline, is gone:import Theming and a whole-file #if os(iOS) guard, and so do MainView.swift and RootView.swift, which render them; the shells lane builds the package for macOS and tests the view shells, which carry no theming, so nothing under test disappears. The two shell targets gain Theming and DuetTheming as dependencies in Package.swift.Two checks tell you the binding is a view change and nothing else. The mock generator fingerprints every source file in a shell’s directory, so the edited views make tools/duet mocks --check red until you run tools/duet mocks, which rewrites the fingerprints and leaves the generated bodies as they were. And tools/duet record --feature home --check still reports the fixtures up to date, because nothing about a color ever entered a reducer: the state says Free, and the view picks the token.Ship the second theme
Themed, Assetable class that delegates to MainTheme; on Android it is a second object over MainPalette. Both are exhaustive, so a token added to the config is a compile error in the second theme until it says where the token maps:
The upgrade flow's plans step in the main theme (top) and the high-contrast theme (bottom), iPhone 17 simulator on the left and Pixel 8 emulator, API 36, on the right; the same token names on every card, the bottom row through the second theme's mapping; tutorial7-complete at Duet 0.7.0, duet-tools 0.24.0, duet-services 0.11.1.
tutorial6-complete to see what the theme cost: no file under src-kmp/subtrees, no recording, and no view changed for it. The views changed in step 5, once, to name tokens; every theme after that is a mapping.Put the two new checks in the workflow
checks job, the token drift gate after the mocks check and the theme tests after the backend’s:scripts/run-tree.sh runs the same two on any tree that carries parity/design-tokens.yaml and src-kmp/theming.What you now have
- One file,
parity/design-tokens.yaml, that names every color and type style the cards use, and six generated files thattools/duet design-tokens --checkholds to it. - The two theme engines linked, a
MainThemeon each platform over the generated table, and accessors that let a view name a token and nothing else. - The Insights card, the promo and the plan cards on tokens on both platforms, with the recordings byte for byte as Tutorial 6 left them.
- A high-contrast theme that the system’s setting selects, written as one mapping per platform, and a main-theme test at WCAG level AA.
Exercise: pin what the second theme promises
tutorial7-start carries Tutorial7ExerciseSecondThemeTest, a failing placeholder in the home module. The high-contrast theme exists so that every label reads at WCAG level AAA, 7:1, on every surface, in both appearances; nothing checks that yet. Delete the stub and write the test in the :theming module, over the helper from step 3:
tutorial7-complete carries the test. Change labelSecondary’s light value in the config to a lighter grey, regenerate, and the main-theme test from step 3 goes red at 4.5:1 while the high-contrast test stays green, because the second theme reads labelPrimary for that token and never sees the change.
Common questions
Why generate the vocabulary instead of writing two enums?
Why generate the vocabulary instead of writing two enums?
tools/duet design-tokens --check holds the files to it on every push. The check is a whole-file comparison against an in-memory regeneration, so a hand-edit anywhere in a generated file is red, with the file named.Why does the second theme carry no values of its own?
Why does the second theme carry no values of its own?
parity/design-tokens.yaml, and the exhaustive switch and when make a new token a compile error in the second theme until it is placed. A theme that needs values the main palette lacks adds them to the config as tokens and maps onto those.Why does the theme follow the system setting rather than an in-app picker?
Why does the theme follow the system setting rather than an in-app picker?
ThemeProvider takes a ThemePersistentStorage and a setTheme(with:) call on iOS, and a MutableStateFlow over the theme choice replaces the contrast listener on Android. On iOS, re-publish the provider after the change; the provider is not observable, so an already-rendered tree does not re-render on setTheme alone.Why are four view files now iOS-only?
Why are four view files now iOS-only?
swift test with no simulator. A view that reads the theme cannot compile in the macOS slice, so the two themed views and the two views that render them carry a whole-file guard. The shells lane tests the view shells, which carry no theming; the iOS app build, which the checks also run, compiles the views.Why did the recordings not change?
Why did the recordings not change?
Free; the view chooses lockedSurface for that. Tutorial 8 rests on the same rule for strings: the state keeps semantic values, and the presentation is the shell’s. That is also why tools/duet mocks --check was the check that noticed the edit, not record --check: the mock generator fingerprints the shell’s source directory, the recording check reads the reducer.How do Material's own components follow the theme?
How do Material's own components follow the theme?
theming artifact carries the bridge for the rest, DuetThemeSpec, an interface that binds every slot of Material’s ColorScheme and Typography to a token, and a ResolvedPalette a Compose layer turns into a scheme. The duet-services README shows the binding; it is the hand-authored half the contract names, because Material’s slots and Apple’s roles are different sets.Sources and further reading
- The duet-tools repository:
contracts/design-tokens.md— the config grammar, what is generated and what stays hand-authored, and the check. - The duet-services repository —
DuetThemingand thethemingartifact:Themed,Assetable,ThemeScope,ColorToken,FontTokenand the Material binding. - Apple:
colorSchemeContrast— the environment value Increase Contrast sets;UIFontMetrics— scaling a face by a Dynamic Type style. - Android:
UiModeManager.getContrast()— the contrast setting and its change listener; Locally scoped data with CompositionLocal — the locals the accessors read. - WCAG 2.2: contrast (minimum) and contrast (enhanced) — the 4.5:1 and 7:1 thresholds the two tests assert, and the relative luminance formula the helper implements.
- The Duet glossary — shell, the checks and golden recording.