Skip to main content
In this tutorial you move the app’s cards off literal colors and onto a design-token vocabulary that lives in one file, 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.
The manual setup below is what Modaal automates. Every tree this tutorial opens and every file it asks you to write is produced by the Duet templates in the Modaal new-project wizard, and a coding agent working in Modaal writes the feature, records it and runs the checks for you. This series walks the same ground by hand so you know what the scaffold emits and why: start a Duet project in the wizard when you would rather skip the setup.

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.yaml with seven color tokens in three groups and two type tokens, and six generated files under Generated/ on the two platforms.
  • The theme engines linked: the DuetTheming product of duet-services in a new Theming target of the Swift package, and the theming artifact in a new :theming Gradle 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?

Open tutorial7-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:
The Kotlin lane reports one failure, 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

1

Declare the vocabulary

Everything the cards need is seven colors and two type styles. Write them once, in 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.
parity/design-tokens.yaml
Then the colors, in groups. A token is named by what it means, never by what it looks like, and the name becomes an enum case in both languages. A color token states a 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.
parity/design-tokens.yaml
A type token states the family, the CSS-scale weight, the size, the line height, and, because a swift: target is declared, the Dynamic Type style the cut scales against on iOS:
parity/design-tokens.yaml
Generate:
2

Read what was generated

Three files per language: the two vocabularies and the value table. The vocabulary is an enum with one case per token, the doc comments carried over, the groups as section headings. The two enums are emitted from one input in one declaration order, which is what makes them one vocabulary rather than two lists kept aligned by hand.
The value table is written in each engine’s own terms. On iOS it is an extension of 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:
Each 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:
Edit a value in a generated file and the same command names the file as stale; drop a token from the config without regenerating and it names the leftover file as orphaned.
4

Write the accessors and publish the theme once

A view never touches the engine’s lookup methods. Each platform gets two accessors that name a token and return what the view layer wants: a 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:
src-ios/Libraries/FoyerKit/Sources/Theming/ThemeAccessors.swift
On Android they read two composition locals, the theme in effect and the resolved appearance, and turn a FontToken into a TextStyle in the platform’s face for its family:
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/FoyerTheme.kt
The theme is published once, at the root of each tree, and read below through the environment, so no view takes a theme parameter. Which theme is published is decided by the system: iOS exposes Increase Contrast to SwiftUI as 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:
src-ios/Libraries/FoyerKit/Sources/Theming/FoyerThemeScope.swift
src-ios/Libraries/FoyerKit/Sources/Theming/FoyerThemeScope.swift
The Compose twin provides the two locals and keeps Material’s default scheme for the components this app does not draw itself:
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/FoyerTheme.kt
src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/FoyerTheme.kt
Each root wraps its tree in the scope. The scene delegate publishes to the hosting controller’s view, and AppRoot replaces its MaterialTheme call:
The app target gains the Theming product in xcodegen.yml, beside RootShell.
5

Bind the cards

The Insights card is the one surface with a state of its own: locked or unlocked. It reads two surface tokens and two label tokens, and the locked state is a different token, not a different color. Every literal the card carried, the grey fill, .secondary, .headline, is gone:
The plan cards bind the same three tokens and the title style:
The promo binds its two labels the same way. On iOS the two view files gain 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.
6

Ship the second theme

The second theme has no values. It is a mapping over the vocabulary onto the main palette’s stronger entries: every surface collapses onto the page, every label and the card border onto the primary label. A card in the high-contrast theme is the page’s color with a solid line of ink around it, and nothing on it is grey. On iOS the mapping is a second 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:
That is the whole second theme: this file and this object, plus the one line in each scope from step 4 that selects it. Run both apps, open the upgrade flow, and raise the contrast setting while they run. On the simulator that is Settings, Accessibility, Display & Text Size, Increase Contrast, or one command. On an Android device it is Settings, Accessibility, Color and motion, Color contrast; the emulator image’s Settings app lists no such entry, and the setting’s key does the same:
Both apps re-render in place: the scope publishes the other provider on iOS, and the contrast listener recomposes on Android.
Four frames: the plan cards on the iPhone and on the Pixel in the main theme, grey cards with a hairline border and grey secondary text, above the same cards in the high-contrast theme, white with a solid black border and black text.

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.

Compare the tree against 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.
7

Put the two new checks in the workflow

Tutorial 6’s workflow gains two steps in its checks job, the token drift gate after the mocks check and the theme tests after the backend’s:
.github/workflows/parity.yml
.github/workflows/parity.yml
The tutorials repository’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 that tools/duet design-tokens --check holds to it.
  • The two theme engines linked, a MainTheme on 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:
src-kmp/theming/src/jvmTest/kotlin/dev/modaal/foyer/theming/HighContrastThemeTest.kt
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

Two enums written by hand are two lists kept aligned by review. One config emitted into both languages in one declaration order cannot disagree with itself, and 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.
Because the config is the one place a value is written. A theme with its own hex values in Swift and Kotlin would be two more tables to keep aligned, outside the generator’s reach. A mapping over the vocabulary keeps every value in 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.
A picker is feature state: a value to store, restore and reach from a screen. The contrast setting already exists on both platforms, the user owns it, and it is what a high-contrast theme is for. An in-app picker is a small addition when you want one: the engine’s 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.
The theme engine is UIKit-backed and compiles for iOS only, and the Swift package also builds for macOS so the shells lane can run as plain 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.
A recording captures state, actions and effects, and none of them carries a color. The reducer says the entitlement is 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.
They do not, on this tree: the buttons, the navigation bar and the sheet keep Material’s default scheme, and only the surfaces this app draws itself read tokens. The 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

Tutorial 6: The Checks in CI

The tree this page opens, and the workflow the two new checks join.

Tutorial 8: Localizing the App

Every string moves into string catalogs on iOS and resources on Android, German joins as a second language, and no recording changes.

Tutorial 9: Adding Analytics

The same tree, seven events emitted by the reducers as effect data, a console sink worker on each platform, and the recordings checking every event.

The Duet tutorial series

The nine tutorials, the app they build, the prerequisites and the versions they are verified against.
Last modified on September 8, 2026