# How to Design an App with AI: Screenshots to Design System
Source: https://docs.modaal.dev/ai-design
Turn reference screenshots into a design direction, north-star screens, and a frozen design system the agent applies to every screen it builds.
You provide references that point at what you like — the shadows, background colors, fonts, spacing, corner radii — plus anything else that matters to you. From those, the agent derives the actual values and shows them for your approval. It usually takes a few iterations to get right.
The workflow produces three files that keep every future screen consistent:
| File | What it holds |
| - | - |
| `design-system.md` | Design intent, rules, component specs |
| `DesignTokens.swift` | The only place visual values live |
| `AGENTS.md` | The rules and flow you want any coding agent to follow |
Once these exist, every new screen comes out in the style you want — you'll still make some edits, just far fewer and smaller than starting from scratch each time.
The example throughout: a co-parenting app with 4 screens — Home, Calendar, To-Do, Journal. Substitute your own screens.
## Stage 0 — Prepare (what to have ready for each stage)
Write the rules for the agent — the flow you want it to follow, whether the flow described here or your own enhanced version. Include a guardrail so it never builds from an unfinished system: tell the agent to treat any file whose STATUS says `TEMPLATE` as empty — never reading, copying, or building from it — and to keep every unfilled value as bright magenta so a not-yet-filled system can't be mistaken for a design. (The `design-system.md` and `DesignTokens.swift` files themselves are created and filled at Stage 4 — keep them out of the project until then.)
Screenshots of the app whose *visual language* you want, plus a line on what you like about it — for example the shadows, the colors, the typography, the corner radius, the spacing and whitespace, the depth or gradients, the icon style, and the overall feel or mood. Can't verbalize it? Write "not sure what I like — help me name it" and the agent proposes captions for you to confirm.
You only need these when you're not happy with how the information is arranged — data in cards vs. rows, a calendar laid out a certain way, a summary section at the top. Either describe the arrangement you want in words, or grab a reference that shows it and point the agent at the specific *element* — "layout only: lay my tasks out as cards like this, not a list."
Plain words describing what each screen shows. Example for Home: "greeting, next event with countdown, this-week custody strip, tomorrow's events." Or if you have a screenshot of your current app, that works perfectly.
1–2 things it must never look like: "no pastel baby-app vibes."
## Stage 1 — Direction (\~15 minutes)
Send your references with a message like:
```text theme={null}
Here are my style references. [images]
Ref 1: I like the monochrome grayscale, gradient depth, accent colors used only for meaning.
Anti: nothing neon, no default AI look.
My app: co-parenting app, 4 screens (content below). Feel: calm, trustworthy, grown-up.
First describe each reference back — what you see and what you'll take from it. Don't generate anything yet.
```
The agent describes each image back — correct anything misread *before* it contaminates the direction. Then it returns the direction: exact palette (light + dark), font proposal, radii, depth policy, and 2–3 signature elements.
**Fonts.** Either you know one ("we use Nunito" — it's locked), or you say: "Propose 2–3 fonts that match the reference's feel, must be free to bundle in an iOS app, and show me the same screen title in each." You pick by looking, not by name.
Check whether it's what you wanted — if not, keep iterating; if yes, move to the next stage.
## Stage 2 — First north-star screen (2–4 rounds)
Start with the screen that carries your app's core meaning — in the example, Calendar (it carries the custody information). This first screen is your *north-star*: the exemplar every later screen follows, so it absorbs all the taste decisions.
Give the agent a screenshot of this screen from your current app (if you have one) plus its content, and ask it to build it in the direction you chose. Then check: did it apply that direction, and does it work for you?
When something's off, you have three moves: describe the fix in words (the dial vocabulary below), point the agent at a reference that shows what you want, or ask it to research how similar tools handle the screen and show you a few options to pick from. Keep style and layout separate — if the look is right but the calendar's arrangement isn't, that's a layout reference, not a style change.
**Common pitfall — the agent styles from an empty template.** If the `design-system.md` and `DesignTokens.swift` templates sit in your project before they're filled, the agent may read their placeholder values and build from them — so your screens come out looking like the blank template, not your design. This is exactly what the Stage 0 guardrail prevents (mark unfilled files `TEMPLATE`, keep values magenta) — and why you keep them out of the project until Stage 4.
```text theme={null}
Build my Calendar screen in this direction as an iOS HTML mockup, light and dark.
Content: month grid; each day belongs to me or co-parent (that's the most important info); today marked; selected day shows its events below; add-event; bottom tabs: Home, Calendar, To-Do, Journal.
Commit to the direction — go bolder than safe, I'll pull back.
```
Each round, give **reactions, not specs**. Pick from the dial vocabulary:
* **color:** warmer / cooler / more muted / "bg must be white"
* **weight:** lighter / heavier, thinner / thicker
* **space:** denser / airier
* **shape:** rounder / sharper, "like the reference" (the agent re-checks the image)
* **depth:** flatter / more depth, gradient vs shadow
* **type:** quieter / louder, bigger / smaller
* **and mark what's RIGHT:** "tiles perfect, don't touch"
Exit when you'd ship it: "approved, north-star 1."
## Stage 3 — Second north-star, with a layout reference (2–3 rounds)
Do the second screen the same way as the first — same build-check-fix loop — but pick one that's structurally different (here, Home: hero card, lists, text) so you prove the style holds beyond a single layout. This time, lead with a layout reference:
```text theme={null}
Now my Home screen. Layout reference attached — take ONLY the structure: [image]
"layout only: the chronological timeline with the inline week strip; I also want an up-next hero like my current app (screenshot attached)."
First describe the layout back as zones/hierarchy — no colors, no fonts. Don't generate yet.
```
Iterate on the structure — what's visible without scrolling, denser/airier, card vs plain list, section order. You can tweak the style here too, but don't fix it on this one screen: tell the agent to change it in the system so every screen inherits the change. And reuse components across screens — if the same element shows up in more than one place (say the week-day strip on Home and the day tiles on Calendar), it should be one shared component, not two look-alikes, so spacing, font sizes, and behavior stay identical everywhere.
Exit: "approved, north-star 2. Lock it."
## Stage 4 — Freeze (one command)
When you're happy with the style and ready to lock it, ask the agent to populate the design files with every decision from Stages 1–3:
* `design-system.md` — the intent, rules, and component specs
* `DesignTokens.swift` — the values, your single source of truth
Two ways to set up the token file: take Modaal's Swift token files and edit them with your values, or ask the agent to create them from scratch. Either way, `DesignTokens.swift` becomes the one place your visual values live. The agent also saves the north-star screenshots.
Then review: read `design-system.md` §1–2 (about 10 minutes) — check that the words describe *your* app's feel and the signature elements are the ones you approved, then commit the files.
## Stage 5 — Into the app (redesign in Modaal)
When the design side is done, you hand your work to **Modaal** — step by step. Two passes: **swap the tokens first, then rebuild screen by screen.**
Open **Modaal in plan mode** (the [Plan toggle](/articles/modes-and-prompts) on), ask it to redesign, and have it **propose the most efficient approach first** — before it touches anything. Bring three inputs: the **design-system files**, the **HTML mockups**, and the **north-star PNGs**.
Ask Modaal to replace the app's existing tokens with your new ones, **one group per pass** rather than all at once:
1. **color** — primary · secondary · accent
2. **radius**
3. **spacing**
4. **typography** — families · sizes · weights
With the tokens in, iterate **one screen at a time**:
* Claude writes the spec + HTML + PNG
* → Modaal builds the screen
* → stuck? Claude re-describes that piece
* → layout, grids & page structure back to Modaal
Layout goes over as words, never as code.
You don't phrase the handoff yourself — **have Claude write the Modaal prompt for you**, then iterate until you're happy. Every future visual change comes back as **screenshots for review, never code.**
Expect one small adjustment pass on the first native render — fonts and curves come out slightly differently than in the HTML, usually better.
## Stage 6 — New screens, straight in Modaal
Once your screens are in [Modaal](https://modaal.dev), you can create new ones directly there — they're built in your style automatically, and you can edit them right in Modaal.
## Keeping it consistent over time
The system gives you consistency, but agents still drift — a hardcoded color here, a one-off component there. Every so often (say, every \~10 screens), ask the agent to audit: scan for hardcoded values that should be tokens, check text contrast in both light and dark mode, and confirm repeated elements still use the same shared component. Fix any drift by updating the token or the component, not the individual screens. The audit prompt and the smaller checks are on [Design prompts that work](/design-prompts), and [prompting agents for native mobile UI](https://modaal.dev/blog/prompting-agents-for-native-mobile-ui) covers which wording moves an agent's output.
## Summary — what you give at each stage
| Stage | You must give | You never give |
| - | - | - |
| 0 | agent rules, refs + captions, content of your screens, anti-refs | exact hexes, spacing/radius numbers |
| 1 | corrections to describe-back, palette gut-check | exact values |
| 2 | screen content, dial reactions | pixel values (unless you want to) |
| 3 | layout ref + element caption, structure tweaks | style feedback (system handles it) |
| 4 | 10-min read of the doc, token-file choice | anything else |
| 5 | inputs (design-system files, HTML, north-star PNGs), screenshot reactions | code review |
| 6 | content of each new screen | style words, code |
# Android API Levels: Versions, Codenames, and Device Coverage
Source: https://docs.modaal.dev/articles/android-api-levels
Every Android version with its API level, VERSION_CODES constant, codename, year, and live device coverage — plus how to choose the right minSdk.
Every Android release carries an **API level** — the integer that build files name in `minSdk`, `targetSdk`, and `compileSdk`. The current release is Android 17 (API level 37, 2026), and the table below lists every version back to Android 1.0 with its API level, `VERSION_CODES` constant, codename, release year, and the share of active devices an app reaches if it requires that version. Coverage comes from [Statcounter GlobalStats](https://gs.statcounter.com/android-version-market-share/mobile/worldwide); the one-row-per-release format follows [apilevels.com](https://apilevels.com/).
## Which API level is which Android version?
**Coverage measured August 2026** (Statcounter GlobalStats, mobile devices, worldwide). "Runs on" is cumulative: the share of devices on that version *or newer* — the reach of an app whose `minSdk` is that API level.
| Android version | API level | `VERSION_CODES` | Codename | Runs on | Year |
| - | - | - | - | - | - |
| Android 17 | 37 | `CINNAMON_BUN` | Cinnamon Bun | 1.0% | 2026 |
| Android 16 | 36 | `BAKLAVA` | Baklava | 27.0% | 2025 |
| Android 15 | 35 | `VANILLA_ICE_CREAM` | Vanilla Ice Cream | 44.2% | 2024 |
| Android 14 | 34 | `UPSIDE_DOWN_CAKE` | Upside Down Cake | 57.1% | 2023 |
| Android 13 | 33 | `TIRAMISU` | Tiramisu | 72.0% | 2022 |
| Android 12L | 32 | `S_V2` | Snow Cone | 72.0% | 2022 |
| Android 12 | 31 | `S` | Snow Cone | 82.1% | 2021 |
| Android 11 | 30 | `R` | Red Velvet Cake | 90.3% | 2020 |
| Android 10 | 29 | `Q` | Quince Tart | 94.6% | 2019 |
| Android 9 | 28 | `P` | Pie | 96.6% | 2018 |
| Android 8.1 | 27 | `O_MR1` | Oreo | 97.6% | 2017 |
| **Android 8.0** | **26** | **`O`** | **Oreo** | **98.3%** | **2017** |
| Android 7.1 | 25 | `N_MR1` | Nougat | 98.5% | 2016 |
| Android 7.0 | 24 | `N` | Nougat | 98.6% | 2016 |
| Android 6.0 | 23 | `M` | Marshmallow | 99.3% | 2015 |
| Android 5.1 | 22 | `LOLLIPOP_MR1` | Lollipop | 99.4% | 2015 |
| Android 5.0 | 21 | `LOLLIPOP` | Lollipop | 99.9% | 2014 |
| Android 4.4W | 20 | `KITKAT_WATCH` | KitKat | — | 2014 |
| Android 4.4 | 19 | `KITKAT` | KitKat | 99.9% | 2013 |
| Android 4.3 | 18 | `JELLY_BEAN_MR2` | Jelly Bean | — | 2013 |
| Android 4.2 | 17 | `JELLY_BEAN_MR1` | Jelly Bean | — | 2012 |
| Android 4.1 | 16 | `JELLY_BEAN` | Jelly Bean | 99.9% | 2012 |
| Android 4.0.3–4.0.4 | 15 | `ICE_CREAM_SANDWICH_MR1` | Ice Cream Sandwich | — | 2011 |
| Android 4.0 | 14 | `ICE_CREAM_SANDWICH` | Ice Cream Sandwich | — | 2011 |
| Android 3.2 | 13 | `HONEYCOMB_MR2` | Honeycomb | — | 2011 |
| Android 3.1 | 12 | `HONEYCOMB_MR1` | Honeycomb | — | 2011 |
| Android 3.0 | 11 | `HONEYCOMB` | Honeycomb | — | 2011 |
| Android 2.3.3–2.3.7 | 10 | `GINGERBREAD_MR1` | Gingerbread | — | 2011 |
| Android 2.3 | 9 | `GINGERBREAD` | Gingerbread | — | 2010 |
| Android 2.2 | 8 | `FROYO` | Froyo | — | 2010 |
| Android 2.1 | 7 | `ECLAIR_MR1` | Eclair | — | 2010 |
| Android 2.0.1 | 6 | `ECLAIR_0_1` | Eclair | — | 2009 |
| Android 2.0 | 5 | `ECLAIR` | Eclair | — | 2009 |
| Android 1.6 | 4 | `DONUT` | Donut | — | 2009 |
| Android 1.5 | 3 | `CUPCAKE` | Cupcake | — | 2009 |
| Android 1.1 | 2 | `BASE_1_1` | Petit Four | — | 2009 |
| Android 1.0 | 1 | `BASE` | — | — | 2008 |
The bolded row is the floor [Duet](/duet) ships. Duet — Modaal's cross-platform parity framework for native iOS and Android apps — sets `minSdk = 26` in every scaffolded Android target's Gradle files, with `compileSdk = 36` and `targetSdk = 36`, so a new Duet app installs on ≈98.3% of active Android devices.
## How do you read this table?
* **"Runs on" prices a `minSdk` choice.** It is the sum of Statcounter's monthly shares for that version and everything newer; requiring API 26 excludes the ≈1.7% of devices below it.
* **The source measures usage, not installs.** Statcounter derives shares from web traffic across its network, so a phone used daily counts for more than one in a drawer. It is an independent proxy for the active install base, not Google's own distribution data.
* **Rows marked "—"** sit below Statcounter's measurement floor for the month — each under 0.05% of traffic.
* **Two rows can share a number.** Android 13 and 12L both show 72.0% because 12L's own share is 0.02%, which disappears in rounding.
* **Codenames live on in the constants.** Releases are branded by number, and each still carries a dessert codename that surfaces in the [`Build.VERSION_CODES`](https://developer.android.com/reference/android/os/Build.VERSION_CODES) constants — `TIRAMISU` is API 33, `BAKLAVA` is API 36.
* **Minor SDK releases share their version's row.** Android 16 and 17 also ship mid-cycle SDK levels (36.1, 37.1), documented alongside the [version-code constants](https://developer.android.com/reference/android/os/Build.VERSION_CODES); `minSdk` stays the integer API level.
## What minSdk should you use?
Pick the lowest API level whose excluded devices you can accept, and let the table price the choice: as of August 2026, `minSdk 26` reaches ≈98.3% of active devices, `minSdk 29` reaches ≈94.6%, and `minSdk 31` reaches ≈82.1%.
The three numbers in a build file do different jobs, defined by the [`uses-sdk` element](https://developer.android.com/guide/topics/manifest/uses-sdk-element): `minSdk` is the oldest version the app installs on, `targetSdk` declares which version's behavior changes the app has been tested against, and `compileSdk` is the API surface the code compiles against. Only `minSdk` costs you devices. `targetSdk` and `compileSdk` stay current regardless — Google Play [requires a recent `targetSdk`](https://developer.android.com/google/play/requirements/target-sdk) for new submissions and updates.
Raising `minSdk` buys the ability to call newer platform APIs unconditionally — no runtime version checks, no compatibility fallbacks — and shrinks the device matrix you test. The cost is the coverage delta between the two rows, and the table states it exactly.
## Common questions
For most new apps, API 26–29. Below 26 the added reach is under 2% of devices; above 31 you exclude nearly a fifth. Duet-scaffolded Android targets ship `minSdk 26` — the ≈98.3% row — and raise it per project only when a needed API demands it.
`minSdk` gates installation — devices below it never see the app. `targetSdk` opts the app into the behavior rules of that version; the system applies compatibility behavior for newer devices. `compileSdk` only affects compilation. An app commonly compiles against and targets the newest API while keeping a much lower `minSdk`.
Google Play filters the app out of search and its store page shows it as incompatible, per the [`uses-sdk` filtering rules](https://developer.android.com/guide/topics/manifest/uses-sdk-element). The system also refuses to install the APK directly, so sideloading doesn't bypass the floor.
The measurement date is printed above the table — the numbers are from that month's Statcounter GlobalStats data, and the page is refreshed at least quarterly and whenever the Android SDK versions Modaal pins change. The chart regenerates from the same numbers as the table.
Yes, as codenames: public branding uses the number, and each release's dessert name lives in its `VERSION_CODES` constant — Android 17 is Cinnamon Bun, Android 16 is Baklava. The last release marketed under its dessert name was Android 9 Pie in 2018.
[Modaal](https://modaal.dev) scaffolds an app on the floor this page prices. [Set up the Android toolchain](/guides/setup-android) covers the SDK install; the components it pins live in the API 36 row above.
## Sources and further reading
* [Statcounter GlobalStats — Android version market share](https://gs.statcounter.com/android-version-market-share/mobile/worldwide) — the coverage data (mobile devices, worldwide)
* [apilevels.com](https://apilevels.com/) — the reference whose one-row-per-release format this table follows
* [Build.VERSION\_CODES](https://developer.android.com/reference/android/os/Build.VERSION_CODES) — every API level and its constant, from the platform itself
* [SDK Platform release notes](https://developer.android.com/tools/releases/platforms) — what each platform release added
* [The uses-sdk element](https://developer.android.com/guide/topics/manifest/uses-sdk-element) — `minSdk` and `targetSdk` semantics and Play filtering
* [Google Play's target API level requirement](https://developer.android.com/google/play/requirements/target-sdk) — the policy floor on `targetSdk`
* [Android version history](https://en.wikipedia.org/wiki/Android_version_history) — community-maintained release chronology (Wikipedia)
## Read next
The anatomy behind these numbers: the manifest, activities, Jetpack Compose, and the Gradle build that names minSdk.
The one-banner install of the pinned SDK components — the API 36 platform, build tools, and your first emulator.
# The Best Cross-Platform Frameworks in 2026
Source: https://docs.modaal.dev/articles/best-cross-platform-framework
Flutter, React Native, Compose Multiplatform, KMP, MAUI, Ionic, Skip and two native apps compared: who draws the screen, where logic runs, what proves parity.
The best cross-platform framework in 2026 depends on two questions most ranking pages never ask: who draws the screen — the platform's toolkit or the framework's own renderer — and where your logic runs — in a language runtime the app ships, or compiled against the platform. Answer those two and the field sorts itself; this page places every mainstream option on that grid, compares them side by side, and adds the axis none of the usual lists carry: what *proves* the two platforms behave the same.
*Last reviewed: September 1, 2026. Framework version and capability claims were checked against each framework's official documentation on this date; the Ionic and Capacitor claims were checked again on September 18, 2026.*
## How should you compare cross-platform frameworks?
The third question sits behind the grid: how do you know both apps behave the same? Two apps can behave identically **by convention** (two codebases kept in step by review and manual QA), **by construction** (one implementation, so there is nothing to compare — with the platform boundary exempt), or **by verification** (two implementations replay the same recorded fixtures, and CI compares the results byte-for-byte). Every approach in the table below is a position on the grid plus one of those three answers — [Should you share code between iOS and Android?](/articles/share-code-ios-android) develops the three-way distinction from the business side.
## How do the frameworks compare?
| | **[Flutter](/articles/duet-vs-flutter)** | **[React Native](/articles/duet-vs-react-native)** | **[Compose Multiplatform](/articles/duet-vs-compose-multiplatform)** | **[Ionic / Capacitor](/articles/duet-vs-ionic-capacitor)** | **.NET MAUI** | **KMP + MVI stacks** (Decompose, MVIKotlin, Circuit) | **Skip** | **Two native apps** | **Duet** |
| - | - | - | - | - | - | - | - | - | - |
| **Who draws the screen** | The framework — Impeller canvas on every target but web | The platform — Fabric composes native views | The framework — Skia canvas on iOS; on Android it *is* Jetpack Compose | A web view | The platform — handlers map controls to `UIButton` / `MaterialButton` | Per team — native UI per platform, or Compose | The platform — SwiftUI transpiled to Compose on Android | The platform, twice | The platform — SwiftUI and Jetpack Compose, authored per platform |
| **Where feature logic lives** | Once, in Dart, on the shipped Dart runtime | Once, in JavaScript/TypeScript, on the shipped Hermes engine | Once, in Kotlin, compiled — no bundled VM | Once, in JavaScript, in the web runtime | Once, in C#, on the shipped .NET runtime | Once, in Kotlin, compiled | Once, in Swift — transpiled, or natively compiled via the Swift Android SDK | Twice, once per platform | Once, in Kotlin, compiled into both apps |
| **What proves the two platforms agree** | One implementation; code behind platform channels sits outside it | One implementation; code in native modules sits outside it | One implementation, UI included | One implementation | One implementation; platform handlers sit outside it | Shared unit tests; no parity gate between the two apps' behavior | One source of truth; the transpiler is the guarantee, UI included | Code review and manual QA | Recorded fixtures replayed on both platforms; CI compares byte-for-byte |
| **A native, perf-heavy island** | Drawing stays in Dart (shaders, painters); a camera, video or GPU island is a `Texture` or a platform view fed by Swift/Kotlin plugin code | Animations can run on the native driver; a native island is a Fabric Native Component plus a Native Module, written per platform | Drawing stays shared (Compose `Canvas`); on iOS a native island is a `UIKitView` built in Kotlin through Objective-C interop (Swift only for Swift-only APIs) inside a Skia-drawn screen, on Android it is `AndroidView` interop | A Capacitor plugin in Swift/Kotlin, with native UI presented over the web view, placed around it, or drawn under or into it and aligned with an HTML element — never as a DOM element | A handler customization or custom control reaching the `PlatformView`, per platform in C#; customizations are global unless the control is subclassed | Native already: ordinary Swift/Kotlin, with whatever seam the team's own architecture defines | Native already on iOS; on Android, hand-written Compose through `ComposeView` or `#if SKIP` blocks beside the transpiled UI | Native already, written twice | Native already: ordinary SwiftUI/UIKit and Compose/Android code; the reducer decides, a worker runs the pipeline, and the seam is recorded |
| **Native code you write and compile for it** | Yes: Swift/Objective-C and Kotlin/Java in the host projects, compiled by Xcode and Gradle in the Flutter build; a plugin spares the writing, not the compiling | Yes: Objective-C++ and Kotlin/Java, compiled by Xcode and Gradle in the app build; an npm library spares the writing, not the compiling | Usually Kotlin only: Kotlin/Native's Objective-C interop builds UIKit views from Kotlin; Swift only for Swift-only APIs, compiled by Xcode | Yes: Swift/Objective-C and Java/Kotlin in the generated native projects, compiled in Xcode and Android Studio or by the CLI's build command | Platform-specific C# against the platform bindings, compiled by the .NET build per platform; no Swift or Kotlin | Yes: Swift in Xcode, Kotlin in Gradle, as in any native app | iOS: Swift in Xcode. Android: Kotlin is optional — `#if SKIP` blocks are Swift that transpiles to Kotlin; Gradle compiles either way | Yes, always | Yes, by design: Swift compiled by Xcode, Kotlin compiled by Gradle |
| **SwiftUI and Compose in the island** | Yes, wrapped: `UIHostingController` inside an iOS platform view, `ComposeView` inside an Android one; a full-screen Compose activity is also documented | Yes, wrapped: `UIHostingController` and `ComposeView` inside a Fabric Native Component with Objective-C++ glue; with Expo, Expo UI builds UI directly with SwiftUI and Compose from React | Compose is the Android UI; SwiftUI on iOS through `UIHostingController` passed to `UIKitViewController`, written in Swift | Only as native views a plugin places over, around or under the web view, written in Swift or Kotlin | The handler surface is UIKit and Android Views through the .NET bindings | They are the interface, by construction | They are the interface: SwiftUI on iOS, Compose on Android, transpiled or hand-written | They are the interface, by construction | They are the interface: SwiftUI on iOS, Jetpack Compose on Android, by construction |
| **Best fit** | UI-neutral apps with little per-platform variation, one team writing Dart end to end, desktop and web from the same code | Web/TypeScript teams, UI-neutral apps, the npm ecosystem, over-the-air updates | Kotlin-first teams that want one UI implementation with little per-platform variation, plus desktop | Existing web apps and teams; content-driven interfaces | .NET shops; enterprise LTS alignment | Kotlin teams assembling their own architecture | Swift-first teams that want Android from their existing SwiftUI source | Two independent platform teams; maximal per-platform investment | Rules-dense products that need native UI and a mechanical answer to "do the apps agree?" |
Approaches the table leaves as mentions: **NativeScript** (JS runtime driving native views — alive under the OpenJS Foundation, 9.0 announced November 2025, but absent from most current adoption lists) and **Lynx** (ByteDance's engine, open-sourced March 2025, powering TikTok surfaces; its ecosystem outside ByteDance is early). **Duet** is Modaal's cross-platform parity framework for native iOS and Android apps — the [overview](/articles/duet) covers the product; this page keeps it to one column among nine.
## Which approaches escape to native for a heavy screen?
The island row splits the table three ways. **Native already** — Duet, two native apps, the KMP shared-core practice, and Skip on iOS: a Metal view, a camera pipeline or a video editor is ordinary platform code with no bridge, written per platform. **Escape through the framework's bridge** — Flutter (a [`Texture`](https://api.flutter.dev/flutter/widgets/Texture-class.html) or a [platform view](https://docs.flutter.dev/platform-integration/ios/platform-views), which the docs say "come with performance trade-offs"), React Native (a [Fabric Native Component](https://reactnative.dev/docs/fabric-native-components-introduction) plus a Native Module), Compose Multiplatform on iOS (a [`UIKitView`](https://kotlinlang.org/docs/multiplatform/compose-uikit-integration.html) built in Kotlin through Objective-C interop, or in Swift for Swift-only APIs), .NET MAUI (a [handler](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/handlers/customize) reaching the `PlatformView`), Ionic/Capacitor (a [plugin](https://capacitorjs.com/docs/ios/custom-code) whose native UI is presented over the web view or drawn under it, as the [Google Maps plugin](https://capacitorjs.com/docs/apis/google-maps) does on Android): the island is written per platform anyway, plus the bridge that carries its parameters and results, and its logic leaves the shared implementation. **Drawn by the framework** — Flutter and Compose Multiplatform keep a drawing-heavy screen shared when it needs no platform API: GLSL shaders and painters in Dart, `Canvas` on Skia in Kotlin. The pairwise pages give each escape's mechanics; [Duet's side](/articles/duet-vs-flutter#what-if-one-screen-must-be-native-and-fast) is the same on all three: the reducer decides, a worker runs the pipeline, and the recorded seam is what CI compares.
Whether the island means writing and compiling native code, per framework: **Flutter, React Native and Ionic/Capacitor** — yes, in Swift or Objective-C(++) and Kotlin or Java, compiled by Xcode and Gradle in the app build, unless an existing plugin or library covers the island, which spares the writing and not the compiling. **Compose Multiplatform** — usually Kotlin only, because Kotlin/Native's [Objective-C interop](https://kotlinlang.org/docs/native-objc-interop.html) builds UIKit views from Kotlin, with Swift reserved for Swift-only APIs. **.NET MAUI** — platform-specific C# against the platform bindings, [compiled by .NET per platform](https://learn.microsoft.com/en-us/dotnet/maui/platform-integration/invoke-platform-code); no Swift or Kotlin. **Skip** — Swift on iOS as usual, and on Android either Kotlin or Swift in `#if SKIP` blocks that transpile to Kotlin. **KMP shared-core stacks, two native apps and Duet** — yes, by design: Swift compiled by Xcode, Kotlin compiled by Gradle.
Whether the island can be SwiftUI or Jetpack Compose rather than UIKit and Android Views: for **Duet, two native apps, KMP shared-core stacks and Skip** they are the interface already. **Flutter and React Native** get them wrapped — a SwiftUI view through [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller), Compose through [`ComposeView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views) — inside a platform view or a Fabric Native Component; React Native's own guides mention Swift nowhere, while Expo, the framework its documentation recommends, adds Swift and Kotlin modules and [Expo UI](https://docs.expo.dev/versions/latest/sdk/ui/), "a set of components that allow you to build UIs directly with Jetpack Compose and SwiftUI from React". **Compose Multiplatform** is Compose on Android and takes SwiftUI on iOS through `UIHostingController` passed to `UIKitViewController`, written in Swift. **Ionic/Capacitor** gets them only as native views a plugin places over, around or under the web view; the [Capacitor page](/articles/duet-vs-ionic-capacitor#what-if-part-of-the-screen-must-be-native) measures a UIKit tab bar placed around one. **.NET MAUI**'s handler surface is UIKit and Android Views through the .NET bindings.
## Is the shared-core shape new?
No. The bottom-left cell — platform draws, compiled shared core — is established practice under several names. Kotlin Multiplatform shared-logic practice is [officially supported by Google](https://developer.android.com/kotlin/multiplatform) and carries the mainstream case studies: Forbes (80%+ shared logic), McDonald's, Cash App (moved from shared JavaScript to KMP starting 2018), Google Docs on iOS, and 9GAG — which chose KMP after trying Flutter and React Native. The same shape exists with a Rust core: [Crux](https://github.com/redbadger/crux) captures effects as values executed by native shells — the same effects-as-data idea Duet uses — and Mozilla's [UniFFI](https://github.com/mozilla/uniffi-rs) and Signal's libsignal ship Rust cores under native apps at scale. One detail in JetBrains' own materials is worth reading as data: its shared-logic case-study list is long and mainstream, while its shared-UI list is short and recent — shared logic under native UI is the mainstream KMP shape.
What lacks close prior art is the verification layer: a recorded behavior corpus that both platforms replay, byte-compared in CI, as the *gate* for parity. The KMP and Rust stacks above share code and share tests; none of them ships a mechanism whose job is to fail the build when the two apps' behavior diverges. That layer — not the shared core — is Duet's contribution, and it is why the table gives Duet a different "what proves agreement" cell than the stacks it otherwise resembles.
## What does the table not score?
**App size.** Shipped runtimes have documented weight: Flutter's own FAQ measured a minimal release APK at \~4.3 MB (ARM32) / 4.8 MB (ARM64) and a minimal IPA download at \~10.9 MB — figures the FAQ dates to March 2021. Duet's compiled core measured +3 MB on the Android app of the shipped product Modaal's pages quote. React Native and Compose Multiplatform publish no equivalent official minimal-app figures, so this page quotes none. Capacitor publishes none either and ships no language engine of its own, since its code runs in the system web view; a lab build of a four-tab Capacitor 8 app measured 7,920 KiB as a simulator Release build, 4,480 KiB of it Capacitor's two frameworks, against 1,308 KiB for a SwiftUI build of the same screens, and the [Capacitor comparison](/articles/duet-vs-ionic-capacitor#what-ships-in-the-app-binary) gives the breakdown and the build times.
**OS-release-day behavior.** A native interface inherits a new OS version's controls, behaviors and accessibility features on the OS's schedule. A framework-drawn interface adopts them when the framework does — Compose Multiplatform's FAQ presents the same fact from the other side, noting a canvas-drawn UI "will stay the same" across OS updates. Both readings are true: consistency for the canvas, currency for the native toolkit. A web view adopts a new OS design when its CSS does: for iOS 26's Liquid Glass, Ionic's 8.8 announcement states it is "not planning to ship an official Liquid Glass theme right now", and a Capacitor app takes the look from a community theme or its own CSS. Accessibility follows the same line — CMP maps its semantics to native iOS Accessibility Services (VoiceOver and Full Keyboard Access work; the mapping is maintained by the framework), while native toolkits get new platform accessibility features without a bridge.
**Hiring and team shape.** The runtime rows map to talent pools: JavaScript/TypeScript (React Native, Ionic) is the largest; Dart expertise is mostly Flutter expertise; Kotlin rows draw from the Android population; Skip and Duet's iOS half draw from the Swift population. No numbers here age well enough to print — match the row to the team you can actually staff.
**The iteration loop.** Hot reload is a real productivity difference: mature in Flutter (stable on the web target since 3.35), stable and default in Compose Multiplatform since 1.10.0. Compiled-core stacks, Duet included, iterate with per-platform previews and fast logic tests instead.
## Who owns each framework in 2026?
Ownership moved enough in the last two years that most comparison pages are out of date:
* **React Native** is governed by the **React Foundation** under the Linux Foundation (February 2026), with Amazon, Meta, Microsoft, Expo, Callstack and others as founding members. "Meta's framework" is no longer the governance story.
* **Flutter** remains Google-led, with **Canonical named lead maintainer of Flutter Desktop** (May 2026) — stewardship is widening rather than narrowing.
* **Compose Multiplatform** is JetBrains', extending Google's Jetpack Compose; the two companies co-anchor the Kotlin ecosystem.
* **Ionic** has operated under **OutSystems since November 2022**. Ionic 9 (August 19, 2026) is the active release, with its maintenance end dates not yet set; Ionic 8's maintenance ends February 19, 2027. Ionic Framework and Capacitor stay MIT-licensed, while Ionic stopped selling its commercial products, the Appflow build and live-update service included, to new customers in February 2025, and Appflow shuts down on December 31, 2027.
* **.NET MAUI** is Microsoft's, on .NET's LTS cadence (MAUI 10, November 2025). The precedent worth knowing when weighing any vendor framework: **Xamarin support ended May 1, 2024**, and its apps required migration.
* **Skip** became free and open source (MPL-2.0) in January 2026 and releases actively.
* **Duet** is Modaal's, open on GitHub with versioned contracts, in open beta.
## What do the usage surveys say?
Quote surveys with their year, because the traps are real. Stack Overflow's 2024 survey — the last edition with a cross-platform category — put Flutter at 9.4% and React Native at 8.4% of all respondents, with Ionic at 2.5% and Capacitor at 1.8%; **the 2025 survey dropped the category**, so any page citing a 2025 Stack Overflow cross-platform figure is miscited. JetBrains reports Kotlin Multiplatform usage among its developer-ecosystem respondents doubling from 7% (2024) to 18% (2025). The widely re-cited Statista cross-platform chart's latest data year is 2023 (Flutter 46%, React Native 35% of cross-platform developers) — current pages re-cite it as if fresh; it is not.
## How do AI coding agents change the choice?
Two properties matter, and they pull in different directions. Fluency: an agent has seen more JavaScript, React and Flutter than anything else, and one-language codebases are easy for it to move through. Verifiability: an agent compounds its own mistakes unless the project gives it mechanical gates, and this is where the frameworks now differ visibly. Compose Multiplatform 1.12.0 ships an experimental MCP server that lets an agent drive hot reloads, screenshot the app and inspect the semantic tree — agent-operable *UI tooling*. Duet's recorded corpus is agent-checkable *behavior gating*: a screen cannot wire to logic without a passing recording, and every push replays the corpus on both platforms — the argument in full is on [the testable-apps page](/articles/testable-android-apps#why-does-this-suit-ai-coding-agents). The [iOS architecture comparison](/articles/best-ios-app-architecture) scores the same axis across architectures rather than frameworks.
## Which should you pick?
* **Interface-heavy, brand-driven product; one team end to end:** Flutter — or Compose Multiplatform if the team is Kotlin-first.
* **Web engineering team; modest native-module needs:** React Native (start with Expo, per the project's own recommendation) — or Ionic if the product is close to a web app already.
* **An existing web app to repackage:** Ionic/Capacitor.
* **A .NET organization:** MAUI, with eyes open about the Xamarin precedent and the LTS calendar.
* **A Swift team that wants Android from its existing SwiftUI source:** Skip.
* **A Kotlin team that wants full control of its stack:** KMP with Decompose or MVIKotlin — you assemble the architecture and the test strategy yourself.
* **A rules-dense product that needs native UI on both platforms and provable behavior parity:** Duet — the pairwise pages ([vs Flutter](/articles/duet-vs-flutter), [vs React Native](/articles/duet-vs-react-native), [vs Compose Multiplatform](/articles/duet-vs-compose-multiplatform), [vs Ionic Capacitor](/articles/duet-vs-ionic-capacitor)) argue each match-up in full.
* **Two independent platform teams, by organizational design:** two native codebases, with manual QA priced in as the parity mechanism.
## Common questions
This page prints no benchmark verdicts: the rendering models differ enough that microbenchmarks mislead, and the ranking pages that score "performance" cite each other rather than measurements. The structural facts that do hold: compiled rows carry no interpreter between your logic and the CPU; canvas renderers control their own frame pipeline; web views are bound by the browser engine. Measure your own workload if performance is the deciding axis.
At launch, a single implementation is cheaper than two — that is arithmetic. Total cost depends on what the product needs later: per-platform code behind channels and modules, interface rework to fit platform expectations, and the QA budget for whatever the framework's parity answer is. The share-code page walks the cost side, including the measured \~40% dual-platform change premium in Duet's model — against a 100% baseline of doing everything twice.
Xamarin's support ended May 1, 2024; .NET MAUI is its successor and holds the table column. Xamarin remains relevant only as the migration precedent quoted in the governance section.
A progressive web app is a real option for content-driven products and dodges this page's question entirely — no second codebase, no framework row. The trade is capability and distribution: store presence, platform APIs, background behavior and platform-native interface expectations. Teams that need those come back to this table; teams that do not can stop at the web.
Because the approaches do not compete on one axis. Flutter and React Native lead every adoption survey quoted above, and if popularity is your tiebreaker, that is data. This page's position is that the two grid questions and the parity question predict your costs better than a rank — and that the right column for a web team, a Kotlin team and a Swift team is simply a different column of the table.
Duet projects are scaffolded by [Modaal](https://modaal.dev) — [starting a Duet project](/articles/duet#starting-a-duet-project) walks through the two template cards.
## Sources and further reading
* [Flutter FAQ](https://docs.flutter.dev/resources/faq) and [Impeller](https://docs.flutter.dev/perf/impeller) — rendering, app-size figures, governance
* [React Native documentation](https://reactnative.dev/docs/getting-started) and the [React Foundation announcement](https://www.linuxfoundation.org/press/linux-foundation-announces-the-launch-of-the-react-foundation)
* [Compose Multiplatform and Jetpack Compose](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-and-jetpack-compose.html) and the [1.12.0 release post](https://blog.jetbrains.com/kotlin/2026/08/compose-multiplatform-1-12-0/)
* [Kotlin Multiplatform on developer.android.com](https://developer.android.com/kotlin/multiplatform) and [JetBrains' case studies](https://kotlinlang.org/case-studies/)
* [Ionic documentation](https://ionicframework.com/docs) and [Capacitor documentation](https://capacitorjs.com/docs)
* [.NET MAUI documentation](https://learn.microsoft.com/en-us/dotnet/maui/) and the [Xamarin support policy](https://learn.microsoft.com/en-us/lifecycle/products/xamarin)
* [Skip documentation](https://skip.tools/docs/) — the transpiled and natively compiled modes
* [Crux](https://github.com/redbadger/crux) and [UniFFI](https://github.com/mozilla/uniffi-rs) — the Rust-core prior art
* [Stack Overflow Developer Survey 2024](https://survey.stackoverflow.co/2024/technology) — the last edition with the cross-platform category
* Escape-to-native routes, per framework: Flutter [platform views](https://docs.flutter.dev/platform-integration/ios/platform-views) and [`Texture`](https://api.flutter.dev/flutter/widgets/Texture-class.html); React Native [Fabric Native Components](https://reactnative.dev/docs/fabric-native-components-introduction); JetBrains' [UIKit integration](https://kotlinlang.org/docs/multiplatform/compose-uikit-integration.html); .NET MAUI [handler customization](https://learn.microsoft.com/en-us/dotnet/maui/user-interface/handlers/customize); Capacitor [custom native code](https://capacitorjs.com/docs/ios/custom-code); Skip [cross-platform topics](https://skip.dev/docs/platformcustomization/)
* [Kotlin/Native interoperability with Swift/Objective-C](https://kotlinlang.org/docs/native-objc-interop.html), [.NET MAUI invoking platform code](https://learn.microsoft.com/en-us/dotnet/maui/platform-integration/invoke-platform-code) and the [Capacitor workflow](https://capacitorjs.com/docs/basics/workflow) — which language the island is written in and what compiles it
* Apple's [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller), Android's [`ComposeView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views), [Expo Modules API](https://docs.expo.dev/modules/overview/) and [Expo UI](https://docs.expo.dev/versions/latest/sdk/ui/) — how SwiftUI and Compose enter a framework-drawn or React-driven screen
* [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the recording toolchain and the versioned contracts
## Read next
The canvas-and-runtime model against the native-UI shared-core model, in depth.
Both draw native views — the comparison is where logic runs and what checks the boundary.
Same language decision, opposite UI decision — the nearest relative in the field.
A web view against two native toolkits, with a four-tab lab build measuring what a native look costs in Capacitor.
The same comparison discipline one level down: TCA, RIBs, VIPER, MVVM, MVC and Clean, scored.
The same comparison one level up: the app builders, not the frameworks they emit, on modaal.dev.
# Best iOS App Architecture: TCA, RIBs, VIPER, MVVM, Clean
Source: https://docs.modaal.dev/articles/best-ios-app-architecture
TCA, RIBs, VIPER, MVVM, MVC and Clean Architecture scored on testability, team size, onboarding, cross-platform reach and AI-agent fit — with cited sources.
There is no single best iOS architecture — there is a best fit per app size, team size and risk profile, and the honest way to choose is an axis-by-axis comparison. This page scores the six mainstream choices — MVC, MVVM, Clean Architecture, VIPER, RIBs and TCA (the Composable Architecture) — plus [**Duet**](/duet), Modaal's cross-platform parity framework for native iOS and Android apps, on the axes the practitioner literature actually argues about, and adds the two axes that literature does not score: whether the shape survives a second platform, and how well an AI coding agent can work inside it.
*Last reviewed: September 1, 2026. Library versions and repository states were checked against each project's official repository on this date.*
## How do the architectures compare?
| | MVC | MVVM | Clean Architecture | VIPER | RIBs | TCA | Duet |
| - | - | - | - | - | - | - | - |
| **Complexity & boilerplate** | Minimal | Low | High | High | High | Medium–high | Medium |
| **Development speed** | Fastest first screen | Fast | Slower | Slow | Slow | Medium | Medium |
| **Testability** | Low — logic accretes in controllers | Medium — ViewModels test well, flow unenforced | High — use-cases isolate logic | High — per-module protocols | High — best-in-class seams | High — exhaustive TestStore | High — recorded scenarios replay in CI |
| **Separation of concerns** | Weak in practice | Per-screen | Strong on paper, held by review | Strong, with per-screen ceremony | Best-in-class | Strong | Strong — the seams are scaffolded |
| **Pays off at** | 1–2 devs | 2–6 devs | 6+ devs | 8+ devs | 10+ devs | 4+ devs | Any size with an agent; 2+ devs by hand |
| **SwiftUI fit** | UIKit-era; SwiftUI dissolves the "C" | Natural — `@Observable` is the pattern | Neutral | Poor — UIKit vintage | Neutral — UIKit-first history | Excellent — per-field observation | Native SwiftUI shells |
| **Onboarding** | Immediate | Fast | Slow — layer map first | Slow | Slow | Slow — real concept load | Medium |
| **Over-engineering risk (small apps)** | None | Low | High | High | High | Medium | Medium |
| **Cross-platform reach** | None | Convention only — nothing verifies a port | The layer map ports; every layer is re-implemented | None built in | Proven at Uber as two parallel implementations | Swift-only | Built in — one compiled core, both platforms, gated |
| **AI-agent fit** | Weak — no mechanical gates | Medium — testable units, seams unenforced | Medium — rules live in review, not the build | Medium — many files per screen | Medium — strong seams, heavy ceremony | Good — exhaustive tests gate changes | Built for it — recordings gate each step |
Two disclosures about this table. The gradations are this page's own synthesis: the criteria come from the practitioner sources cited throughout — a [Medium survey of the patterns](https://medium.com/@sharmapraveen91/modern-ios-architecture-patterns-and-best-practices-5320e2d9d1aa) (per-app-type fit guidance and per-pattern characterizations), the [tailec/ios-architecture sample collection](https://github.com/tailec/ios-architecture), and the [r/iOSProgramming thread on scalable architecture](https://www.reddit.com/r/iOSProgramming/comments/194o9w5/) — all commentary, none of which publishes a scored matrix like this one. And the Duet column is written by Duet's vendor; the criteria definitions and the concessions below are how this page keeps that column honest.
The criteria rows encode what the r/iOSProgramming thread's practitioners argue about. Its most-quoted definition, from user danielt1263: "A good architecture does two things. 1. Separates logic from effects (user interactions, network calls and DB reads/writes) so that the logic is easily testable. 2. Separates features from each other (as much as possible) so that it's easy to add/remove/update a feature without breaking other features." The table's testability and separation rows score exactly those two properties.
## TCA: the closest relative to Duet
The [Composable Architecture](https://github.com/pointfreeco/swift-composable-architecture) (Point-Free; 1.26.2 as of August 2026, \~15k stars) is the strongest of the six at making state, actions and effects explicit — and it is the architecture Duet learned from. The mapping, concept by concept:
| TCA | Duet |
| - | - |
| State struct with `@ObservableState` | Serializable feature state — same role |
| `Action` enum | Sealed action set — kept as-is |
| Reducer body — pure state transitions | A pure reducer per feature — kept, with attribution |
| `Effect` — an opaque async closure | Effect *values* — equatable data a worker performs |
| `@Dependency` injection | Environment protocols behind the effect seam |
| Exhaustive `TestStore` | A recorded scenario, replayed on both platforms in CI |
| `scope` and the composition operators | Composition in shells and builders, not operators |
| Cancel-in-flight effect identity | Kept — cancellation expressed as data |
The two departures have one reason: a recording must be able to assert effects. An opaque `Effect` closure can be run but not compared, so Duet's reducers return effect descriptions — data with equality — and the fixture asserts exactly what was requested, in what order. The same requirement moves dependencies from `@Dependency`'s resolver into named environment protocols: what the logic needs is part of its recorded contract, and each platform supplies its implementation as a [worker](/articles/duet-glossary#worker).
**What TCA does better.** Its composition operators are a genuinely powerful way to build big features from small ones, and Duet has no equivalent — composition happens structurally, in shells. Its dependency system (`swift-dependencies`) is more ergonomic than hand-named environment protocols. Its `TestStore` is mature, exhaustive by default, with a documented non-exhaustive mode for integration tests. And its observation is finer-grained: since TCA 1.7, views observe the individual state fields they read, so a change re-renders only the views touching those fields — Duet's shells re-render per whole-state emission and lean on the platform's diffing below that.
**What TCA adds to the build.** In Modaal's build-lane measurement on a toy feature package, adding TCA brought 14 dependency packages into the build and moved a cold `swift test` from 7 seconds to 55 seconds. That is a build-and-CI cost, not a runtime-performance claim, and a toy-scale number — but the dependency surface is also what one thread commenter meant by disliking that TCA "relies heavily on a third party library": the architecture arrives as a substantial external dependency, where MVC and MVVM arrive as conventions and Duet arrives as scaffolded project structure plus a small runtime of its own.
**Choose TCA over Duet** for a Swift-only product that wants maximal in-language power — composition operators, the dependency system, per-field observation — and does not need a second platform verified against the same behavior. TCA is Swift; Duet's reducer compiles to both platforms and its recordings gate both apps.
## RIBs and VIPER: the module-boundary lineage
[VIPER](https://www.objc.io/issues/13-architecture/viper/) (Mutual Mobile, published on objc.io in 2014) applied Clean Architecture's separation to iOS screens: View, Interactor, Presenter, Entity, Router, each behind a protocol. [RIBs](https://github.com/uber/RIBs) is Uber's descendant — Router, Interactor, Builder, with optional Presenter and View — built for apps where the business tree, not the screen stack, drives structure, and proven at Uber scale as two parallel implementations, one per platform ([the rider-app engineering post](https://www.uber.com/blog/new-rider-app-architecture/) tells that story). The iOS library now lives in its own actively maintained repository, [uber/RIBs-iOS](https://github.com/uber/RIBs-iOS) (1.1.0, July 2026), still built on an RxSwift backbone; the original repo continues as Android-only.
The concession leads here, because it is the lineage's whole argument: **these architectures' protocol seams are best-in-class.** Every boundary is a protocol; everything is mockable; a team of thirty can work one app without treading on each other. RIBs adds a real innovation Duet inherits in spirit — the business-logic tree owns navigation, and screens are leaves, not owners.
The cost is per-screen ceremony. In Modaal's conversion work, a literal RIBs leaf shell — the files a screen needs before any feature logic exists — measured about 101 lines across 6 declarations, against a 28-line hand-rolled equivalent for the same screen; that overhead recurs on every screen the app adds. Duet's position is that most of what the seams buy — mockability, isolation, navigation owned by logic — is bought instead by the pure-reducer-plus-effects shape and the recorded contract, without the per-screen protocol suite; VIPER's presenter layer, specifically, is replaced by state projection. No internal measurements exist for VIPER itself — the cited sources above carry its scoring.
**Choose RIBs** when a large team needs enforced module ownership on a business-tree-shaped app and accepts the ceremony; its seams remain the reference point. Teams searching for a "RIBs successor" for a two-platform product are the readers Duet's row in the table addresses: the same tree-owns-navigation instinct, with the parallel-implementation problem replaced by one verified core.
## MVC and MVVM: the starting points
**MVC** is Apple's original template shape: fastest first screen, no structural contract. Its documented failure mode — the Medium survey calls it out as the pattern's major drawback — is the massive view controller: logic accretes in the controller because nothing says where else it goes. For a prototype or a 1–2 person app that will stay small, that trade is fine, and the practitioner thread's top-voted advice points the same way.
**MVVM** is the reactive upgrade SwiftUI made near-default: views bind to an observable ViewModel, and — as the survey puts it — the ViewModel tests well because it holds no UI. With `@Observable`, SwiftUI is MVVM-shaped out of the box. Two limits keep it mid-table. Nothing enforces unidirectionality or where effects live: each ViewModel author decides, so testability degrades screen by screen as shortcuts accrete (one thread commenter: moving logic into the view "makes it completely untestable"). And nothing verifies a port — an Android twin of an MVVM app shares an acronym, not behavior. The thread's most-voted comment is still the right small-app default: "MVVM is a pretty good default to use until you have a compelling reason to want something fancier." This page agrees, and the table's rows are the compelling reasons enumerated. For the Android-side view of the same judgment, see [how Duet compares to a hand-rolled ViewModel](/articles/testable-android-apps#what-does-a-blank-project-leave-open-that-duet-fixes).
## Clean Architecture: layers held by convention
[Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) (Robert C. Martin, 2012) gives the strongest separation on paper: entities, use-cases, interface adapters, frameworks, with dependencies pointing inward. On iOS it produces highly testable use-cases and the highest ceremony of the six, and its enforcement mechanism is the one to be clear-eyed about: layer boundaries live in convention and code review — no build gate fails when a use-case imports a framework type. Teams that keep Clean clean do it with discipline and lint rules they write themselves.
Duet's mapping: the use-case layer corresponds to the reducer plus its environment; the Dependency Rule corresponds to the scaffolded rule that reducers take no platform imports. What Duet adds is an executable form of the contract — [recordings](/articles/duet-glossary#fixture) that replay in CI — and what Clean keeps that Duet gives up is freedom: any per-layer idiom, any binding style, any test strategy. That freedom is why Clean fits large single-platform teams with strong review culture, and why the thread's enterprise-scale defenders argue for it in exactly those terms.
## Is RxSwift or Combine an architecture?
No — reactive programming is a binding substrate that several of the architectures sit on, and treating it as a competing architecture (the survey covers "Reactive Programming" as a seventh pattern) conflates two independent decisions. The evidence is the [tailec/ios-architecture](https://github.com/tailec/ios-architecture) collection — a 2019–2022 sample archive, UIKit/RxSwift vintage, and still the clearest demonstration that the *same* MVVM ships with four different binding styles (closures, subjects, observables, functions). Pick the architecture first; the binding style is a separate, smaller decision.
The substrate landscape today: [RxSwift](https://github.com/ReactiveX/RxSwift) remains actively released (6.10.2, March 2026, with 2–3 releases a year tracking new Xcode and Swift versions) and still underpins RIBs-iOS; Combine ships with no deprecation flags in Apple's documentation, while community commentary places Apple's investment in Swift Concurrency and Observation. Duet's stance on the substrate: streams live in adapters at the edges; feature logic consumes plain values and returns effect data. A subscription bag inside logic is the one shape that blocks conversion to a shared core — which is why the Combine-to-coroutines operator mapping matters at the edges and nowhere else.
## Which architecture should you choose?
* **A prototype or an app that stays small:** MVC or plain SwiftUI-MVVM. The ceremony rows of the table are costs you would pay for nothing.
* **A growing single-platform app with a few engineers:** MVVM, upgraded with discipline where it hurts — or TCA when deterministic state, exhaustive tests and in-language composition are worth its concept load and dependency surface.
* **A large single-platform team needing enforced ownership:** RIBs (business-tree apps, maximal seams) or Clean (strong review culture, maximal freedom inside layers). VIPER today is mostly the maintained-codebase case; new adoption has largely moved to its descendants.
* **A product where Android exists or is coming:** this is the axis that reorders the table. MVC, MVVM, Clean and VIPER offer a second platform nothing but conventions; RIBs offers a proven pattern implemented twice; TCA is Swift. Duet was built for this row — one Kotlin core compiled into both apps, with recorded fixtures as the [cross-platform behavior gate](/articles/duet#what-you-get) — and [whether to share at all](/articles/share-code-ios-android) is its own decision, upstream of this page.
* **A codebase an AI agent will build with you:** weight the AI-agent row. An agent is exactly the collaborator that benefits from mechanical gates between steps — the argument, with the scaffold's step order, is made in [why this suits AI coding agents](/articles/testable-android-apps#why-does-this-suit-ai-coding-agents).
## Common questions
The thread's onboarding reports are real — one commenter: "I am STRUGGLING to understand TCA at my new job. All the concepts and functions are so foreign to me." The concept load pays for exhaustive testability and composition; on a small app with simple state, that is paying for capacity you will not use, which is what the table's over-engineering row scores.
On iOS, MVP's niche was absorbed: its presenter idea lives on inside VIPER and RIBs, and MVVM took the plain-screen case once bindings became first-class. The sample archives still carry MVP implementations, but current iOS literature and hiring treat it as historical rather than a live option.
SwiftUI answers the binding question — views observe state natively — which is why MVVM's fit score is high and VIPER's is poor. It does not answer where effects live, who owns navigation, what a test can prove, or what happens on a second platform. Those four questions are the table's remaining rows, and they are architecture questions regardless of toolkit.
The unidirectional reducer shape exists across the Kotlin world as MVI (and in libraries like MVIKotlin and Decompose), but TCA itself is a Swift library. Duet is one answer to the search behind this question: the TCA-derived reducer shape written once in Kotlin, compiled into both apps, and verified with recordings rather than ported by hand.
Yes, and large apps usually do — a RIBs tree with MVVM leaves, or a Clean core behind MVC screens. The cost is per-boundary translation and a harder onboarding story. The one mix that reliably fails is architecture-per-author in the same layer, which is the MVVM drift mode the table's separation row already prices in.
Modaal scaffolds Duet projects with this page's trade-offs already decided — [modaal.dev](https://modaal.dev).
## Sources and further reading
**Authorities — each architecture's own source:**
* [swift-composable-architecture](https://github.com/pointfreeco/swift-composable-architecture) — Point-Free's TCA repository and documentation
* [uber/RIBs](https://github.com/uber/RIBs) and [uber/RIBs-iOS](https://github.com/uber/RIBs-iOS) — the two RIBs repositories; [Engineering the architecture behind Uber's new rider app](https://www.uber.com/blog/new-rider-app-architecture/) — the introducing post
* [Architecting iOS apps with VIPER](https://www.objc.io/issues/13-architecture/viper/) — the origin article, objc.io issue 13
* [The Clean Architecture](https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html) — Robert C. Martin's post; the book expands it
* [Apple's Observation documentation](https://developer.apple.com/documentation/observation) — the substrate under current MVVM and TCA observation
* [RxSwift](https://github.com/ReactiveX/RxSwift) — the reactive substrate under RIBs-iOS and pre-Combine MVVM
**Commentary — shaped this page's axes, labeled as such:**
* [Modern iOS architecture patterns and best practices](https://medium.com/@sharmapraveen91/modern-ios-architecture-patterns-and-best-practices-5320e2d9d1aa) — per-app-type fit guidance across six patterns
* [What is the best architecture to build scalable and robust apps?](https://www.reddit.com/r/iOSProgramming/comments/194o9w5/) — the practitioner criteria this page's table rows encode
* [tailec/ios-architecture](https://github.com/tailec/ios-architecture) — working samples of MVC, MVP, four MVVM binding variants and VIPER (a 2019–2022 archive)
## Read next
The same reducer-and-recordings argument on the Android side, with code.
One level up: the framework survey this page's cross-platform row points into.
The product form of the table's last row — what the scaffold fixes and what it asks.
For teams on a RIBs-family codebase today: the per-feature route to a shared core.
# Migrating a CombineRIBs App to Duet
Source: https://docs.modaal.dev/articles/combineribs-to-duet
How an existing CombineRIBs project gains a shared Kotlin core and an Android app, one feature at a time, with no flag-day rewrite and no feature freeze.
A Modaal project created from the `ios-ribs` template is a CombineRIBs app: Routers, Interactors, Builders and Components, iOS only. This page is the route from there to the shape a **Production app** for **Android + iOS** scaffolds — a shared Kotlin core, a native Android app, and iOS shells consuming the same compiled logic.
The migration is **per feature, revertible, and never a flag day**. Duet-shaped features live beside CombineRIBs ones inside one app for the whole migration, the iOS app keeps building and shipping throughout, and you can stop between any two features and keep everything banked so far.
**Prerequisite.** The tools that graft the Kotlin core and the Android app, and building both platforms, are part of [Pro](/credits-and-plans); the plans are listed on [modaal.dev/pricing](https://modaal.dev/pricing).
## Does this apply to your project?
| Your project came from | This page |
| - | - |
| **Production app**, the CombineRIBs template (`ios-ribs`) | Yes — this is the route. |
| **Watch + iPhone** (`watchos-ios-ribs`), the iPhone half | Yes. The watch target is MV and is not touched. |
| **2D game** (`ios-ribs-spritekit`) | Ask first. No Duet flavor scaffolds a SpriteKit scene host, so the render surface needs its own answer before step 0. |
| **Quick prototype** (`ios-mvvm`) | No. MVVM has no migration path — see [Starting a new project](/articles/new-project#choosing-a-template). |
| Either **Production app** combination | Not needed. A Duet Swift-flavor project reaches Android through [its own convergence](/articles/duet#adding-android-to-an-iphone-only-project), which skips most of what is below. |
**How to tell what you have:** a CombineRIBs tree has `import CombineRIBs` in its sources and a `RIBs/` directory per feature, and it has **no** `parity/manifest.yaml` at the repository root. A Duet tree has that file and no CombineRIBs import.
## What you end up with
The finished tree is shape-equivalent to one a fresh **Production app for Android + iOS** scaffold emits: `src-kmp/` holding the Gradle plane, the core modules, one module per feature and the Android app; `src-ios/` shells consuming the assembled core; `parity/` holding the manifest, the feature specs and the fixtures; and `tools/duet`. No `import CombineRIBs` anywhere, and no migration package left.
The [Duet overview](/articles/duet) describes that destination in full. This page is how an app already in flight gets there.
## Three properties that shape everything else
Converted features and CombineRIBs features live side by side inside one app for the entire migration. There is no branch, no freeze, and no big-bang cutover.
A feature converts end to end before the next one starts, and each conversion is revertible on its own: deleting the new module and restoring the old interactor body is a clean revert.
Halt after any feature and keep what you have banked — the tests, the determinism, one DI idiom, and a running Android app. The end state is not all-or-nothing.
## Before you start
Two questions worth answering out loud, because they change how much work follows each converted feature rather than which steps you take:
A shared core you can build on, a rough build to try on a device, and a store-shipped Android product all walk the same steps. What differs is how much Compose shell and platform work follows each crossed feature.
A running Android app on an emulator arrives early — at step 2, before any feature is converted. A *shipped* Android product arrives feature by feature, and its calendar time scales with your feature count. Set that against the date before the audit rather than after it.
## What are the stages of the migration?
Before anything is scanned or generated, the repository's own agent documents are edited: `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md` and any always-applied mirrors of them (`.cursor/rules/*.mdc`, `.github/copilot-instructions.md`). The edit records the migration decision and one rule: **from today, every new feature is authored in the target shape — a reducer plus a thin shell — never as a new Router.**
Those files instruct every future session in the repository. One left unedited keeps telling the next session to build a Router, which is why this edit comes before the audit rather than after it.
A static scan of the app produces three artifacts inside your project:
* **The readiness report** — a census of the distance to the target shape: routers, listener protocols, legacy workers, timing hits, vendor-SDK quarantine breaches, DI and view-binding idioms.
* **The migration ledger** (`.modaal/duet-migration-ledger.json`) — one row per surface, each with a status: `legacy`, `in-progress`, `converted`, `retired`, `blocked` or `wont-convert`. Re-running the audit merges into the same file, so the progress figure is always current.
* **Spec drafts** (`.modaal/duet-migration/spec-drafts/.md`) — a statically extracted head start on each feature spec.
Treat the counts as pointers rather than verdicts: it is a text scan, and every extracted row is a candidate to verify against the source.
One call puts the whole Duet substrate into the existing tree: the Kotlin core plane under `src-kmp/` (Gradle wrapper, the shared-service, theming and umbrella modules, the per-feature module plane), the Android app shell, and the parity workspace (`parity/manifest.yaml`, `parity/fixtures/`, `parity/feature-specs/`, the pinned `tools/duet` wrapper). It also emits one synthetic `counter` feature with the scenario test its fixtures are recorded from.
What it does **not** touch: your legacy app's sources and tests. It declares the two migration packages in the iOS app's `xcodegen.yml` without linking them, and appends a section to the agent documents and a block to `.gitignore`.
**Exit gate:** the Android app builds and launches on an emulator with the synthetic feature passing from a recorded fixture, and the legacy iOS app builds and runs unchanged.
Never hand-build this. An Android target, a Gradle plane or a Kotlin source tree added by hand is missing the migration marker in `.modaal/project.json` that routes every later call, along with the manifest rows and module wiring.
Immediately after the graft's gate, a second call links the assembled Kotlin core into the iOS app's build: the consumer package, the assemble build pre-actions, the boundary test lane, and the assemble script.
Nothing about the running app changes — it still runs its CombineRIBs code. What this buys is that every later feature flip is feature work with no build wiring left to add.
**Exit gate:** the core assembles, the legacy app builds and runs unchanged with the framework linked, and the boundary replay passes.
Every feature gets a row in `parity/manifest.yaml` and an order in the ledger. Three rules set that order:
* **Riskiest logic first** — the flows whose untested behavior worries you most: auth, save paths, navigation decisions. Conversion is how a feature *gains* exhaustive tests, so spend it where tests are most needed. It is also the same cost to convert either way.
* **Leaves before the spine** — self-contained screens before the navigation tree that hosts them.
* **Navigation staged last.**
This is the bulk of the migration and it interleaves freely with normal product work. [The next section](#what-crossing-one-feature-looks-like) walks through one crossing.
Once a feature's logic is in the Kotlin core, its remaining CombineRIBs shell is re-homed onto Duet types, and the migration package's dependency edge is deleted. [The finish line](#the-finish-line) lists what has to hold.
Steps 3 and 4 interleave with ordinary feature work. You are not asked to freeze the product for the duration — that is the point of converting one feature at a time.
## What crossing one feature looks like
A feature's logic is authored **twice, in one order, inside one window**: Swift first, then Kotlin, and the Swift copy is deleted at the end of the crossing. The two halves have different oracles, which is why they are two changes rather than one.
### First half: the Swift twin, inside the legacy app
One page, platform-agnostic: state fields, action cases, transitions, effect payloads and their seams, delegate events, fixtures. The extracted draft from step 1 is the starting point, verified row by row against the source. The legacy interactor is the behavior source; its existing Quick/Nimble spec is the acceptance oracle.
Deliberate convergences get written down here rather than ported silently — behavior that varied by build config (`#if DEBUG` aliases, scheme shortcuts) does not survive into a reducer, and incidental platform-parser tolerances are converged to one chosen, fixture-pinned rule.
`State`, `Action`, the reducer, `EffectPayload`, and an `Environment` protocol. The reducer is pure: no platform imports, no vendor SDKs, no `Date()`, no `UUID()`. Time and identity arrive as action payloads or seeded state.
The legacy spec is ported **1:1** onto the deterministic test store — every `it` becomes a scenario step sequence. Behavior differences found here are findings about the old code, not test bugs. Then the fixtures are recorded and committed.
**The mutation drill runs here and is not optional:** break one reducer guard on purpose, confirm the fixture replay goes red, revert. A drill that stays green means the harness is not wired to anything.
The Interactor keeps its class name and its RIBs lifecycle and loses its logic. A finished shell does exactly three things: binds presenter reports to store actions, projects state onto the existing view state, and forwards delegate events to the old listener. Builder, Router and View stay untouched, which is what keeps the conversion revertible.
The `Environment` protocol is the feature's only door to the platform, implemented live by adapters over your existing services and for tests by one hand-rolled fake. A feature whose only environment duty is the delegate sink has no port, no worker and no fake — that is a valid shape, not an omission.
At the end of this half the feature still ships on iOS, exercised by your app's own test suite, and the extraction is proven to have preserved behavior.
### Second half: the Kotlin twin, and Android
The feature's manifest row now declares both a `swift:` and a `kotlin:` source. That dual state is **the window**, and it is where the parity lints run.
The fixtures recorded from the Swift twin replay byte-identically from the Kotlin one. That is what "the port preserved behavior" means, and it is a different oracle from the one the first half used.
A thin Compose shell and the feature's platform workers, app-side. The feature is not crossed until the Android app runs it.
The manifest row flips to `kotlin:`-only and the Swift copy is deleted in the same change. The iOS app runs the feature from the assembled core — the build wiring for that landed back at step 2b.
**The acceptance test is the same throughout: the fixtures do not change.** A behavior-preserving move shows metadata-only churn — scenario source locations, step labels — and zero state or effect bytes. Behavioral churn during a port is a finding to investigate, never a fixture to edit.
**On this route, each feature's window closes at its flip**, because the iPhone app moves onto the shared core and the Swift twin is deleted. Keeping both copies permanently is the [Duet Twins](/articles/duet-twins) architecture, in development, where every behavior change edits both reducers.
## What happens to each piece of your code
Every CombineRIBs shape has exactly one fate. The Dependency / Component / Builder triple is the destination, not residue — Duet composition uses the same triple at every level.
| Legacy shape | Fate |
| - | - |
| `Dependency` protocol | **Port unchanged.** It is already the Duet shape. |
| `Component` | **Port.** The feature's environment factory becomes a Component member. |
| `Builder` / `Buildable` | **Port, minus resolution.** The mount job stays; a Duet Builder resolves nothing. |
| `Router` / `Routing` | **Delete.** Its job became state-driven child mounting in the shell. |
| `Interactor` + `Listener` | **Delete.** The logic left for the reducer; the three shell duties move to the view shell; delegate events are enum cases, so the listener protocol dies with the interactor. |
| CombineRIBs `Worker` | **Already gone** by then — converted to Duet workers during the feature's crossing. |
| Views and view controllers | **Untouched.** |
The measured shape of one finished shell swap: **four files changed in the app module, none in the feature package** — the interactor file renamed to a view shell (34 lines), the builder (28 lines), the renamed spec (18 lines), and one deleted `import CombineRIBs`. By the time a feature reaches the swap, every hard part is behind it.
## What it costs
Measured on a production app of 15 RIBs and roughly 17k lines of RIB-layer code, converted end to end — every feature, router retirement included — in **six calendar days** of agent-driven sessions with human review:
| Phase | Measured |
| - | - |
| Foundation — gates, tooling, device pass | ≈2 days |
| First feature lane — 3 features, one of them 3.8k LOC | ≈1.5 days |
| Remaining tab features — 3 | ≈1.5 days |
| Root and spine logic — 5 slices | 1 day |
| Router retirement and state persistence | ≈1.5 days |
Leaf features measured **under a day each**, and a 3.8k-LOC feature authored directly from its finished spec landed in one session — writing the spec first is the fast order, not the ceremonial one.
**What this table does not price.** It was measured on a migration that converted every feature to Swift and reached the Kotlin core afterwards, so it prices the architecture work — the extraction, the tests and fixtures, the router retirement. It does **not** price the second half of each crossing: the Kotlin twin, its Compose shell, and its platform workers. It also does not price the graft, which is one call plus its exit gate.
Scale it by your feature count and interactor sizes, then re-price from your own repository once two or three features have crossed.
## What you can check yourself
The migration installs its own gates at step 2, and they are ordinary commands in your repository:
| Command | What it answers |
| - | - |
| `tools/duet lint` | Is the manifest well-formed? The fast red, run on every manifest edit. |
| `tools/duet record --check` | Did the fixtures change? The port oracle and the CI drift gate. |
| `tools/duet verify` | Do both platform lanes pass? The same gate CI runs. |
| `tools/duet doctor` | Did anything take a concurrency shortcut the swap tempts? |
Progress is readable without running anything: `.modaal/duet-migration-ledger.json` carries a status per surface, and re-running the audit refreshes it.
Three safety rails hold throughout:
* **The leak detector keeps its authority** while a feature still has its Router, and stays green through every conversion.
* **Visuals get snapshotted during shell swaps.** The fixtures pin behavior; snapshots pin pixels.
* **Skipped and flaky legacy tests go in a visible registry with an owner** — never deleted quietly, and never made to pass by widening a timing bound.
## How do you know the migration is done?
The migration is done when all of these hold:
* Zero `import CombineRIBs` anywhere in the tree.
* The migration package's dependency edge is deleted from every `Package.swift`.
* Every feature in `parity/manifest.yaml` is `kotlin:`-only — no `swift:` line outlives its window. `tools/duet verify` is green and `record --check` shows zero churn.
* The tree is shape-equivalent to a fresh **Production app for Android + iOS** scaffold.
* The legacy interactor specs are deleted with their interactors — their scenarios were ported 1:1 during conversion, so a surviving spec tests a deleted type.
* The repository's own documents are swept: any README or agent-instructions file still describing the RIBs shape is rewritten in the same change. A document describing a shape the repository no longer has is not history — it is instructions to the next reader.
* The app's own device pass covers the swapped flows.
## Four things that are never the answer
The unit is the feature, not the app. Inside a feature's window the Swift twin lands first with your app's own tests as its oracle, then the Kotlin twin with the fixtures as its oracle. Two changes, two gates. Collapsing them leaves you with no way to tell which half broke.
The graft emits them as one atomic step along with the parity workspace and the migration marker that routes every later call. A hand-built plane is missing that marker, the manifest rows and the module wiring.
Fixtures are build products. Re-record and read the diff instead: behavioral churn during a port is a finding about the port, and editing the recording deletes the evidence.
One dependency edge in at the graft, one dependency edge out at the finish line. It exists so the frozen CombineRIBs fork never enters the Duet framework's own dependency graph, and it has no job once nothing imports CombineRIBs.
## Common questions
No. The iOS app builds and runs unchanged from the graft onward, converted and unconverted features coexist, and steps 3 and 4 interleave with ordinary product work. The one thing that changes on day one is that new features are authored in the target shape rather than as new Routers.
At step 2, before any of your features are converted — the graft's exit gate is the Android app launching on an emulator with a synthetic feature passing from a recorded fixture. Your features then appear in it one at a time as they cross.
Yes, and it is a supported state rather than a broken one. You keep the recorded fixtures and the tests they came from, the determinism work, one DI idiom, and a running Android app carrying the features that crossed. The ledger records exactly where you stopped.
It gets a `blocked` or `wont-convert` status in the ledger with a note saying why, and the migration continues around it. Common blockers are found by the audit before they cost anything: a vendor SDK imported directly in an interactor, or timing done with raw `Timer` and `DispatchQueue.asyncAfter` in feature logic. Both are fixed by adding the seam first.
Because the two halves have different oracles. The Swift twin lives inside your app and is checked against your existing test suite, which proves the extraction preserved behavior while the feature still ships. The Kotlin twin is checked against the fixtures that extraction produced, which proves the port preserved it. One step, one oracle.
Tell the agent your project is going to Android, or that you want to migrate it to Duet. It reads the migration guide, makes the step 0 edits, runs the readiness audit, and comes back with the report and a proposed order before anything is generated. You approve the plan; it does not graft anything first.
## Read next
The destination: what Duet is, what the finished tree holds, and how features are authored once you are on it.
Where the line between shared logic and native UI falls, and what enforces it — the part your Compose shells are written against.
Every template Modaal offers, where CombineRIBs sits among them, and which combination a new project should start from.
Building iOS and Android together is part of Pro.
The target shape built from an empty tree: what a crossed feature, its shells and its recordings look like with nothing legacy beside them.
# Handling Platform-Specific UI on iOS and Android
Source: https://docs.modaal.dev/articles/cross-platform-ui-parity
Duet's five-layer presentation contract, the kind-to-renderer registry, and the ledger that records iOS/Android divergence, with SwiftUI and Compose examples.
This is the technical half of **[Duet](/duet)**. [The overview](/articles/duet) covers what Duet is, which combination of the new-project sentence scaffolds it, and how a project gets built. This one answers the question that follows: **where exactly does the line between shared and platform-specific fall, and what enforces it?**
One question comes up in almost every conversation about building the same product on iOS and Android with Modaal:
> **"How does Modaal handle platform-specific UI components between the two?"**
The short answer is that it doesn't try to unify them. There is **no cross-platform UI layer**: iOS renders SwiftUI, Android renders Compose, and neither knows the other exists. What the two platforms share is *what is on screen*, expressed as a value in feature state; what stays platform-specific is *how that value is rendered*. The line between the two is a written contract, and it's mechanically enforced — the shared half is gated byte-for-byte in CI, while the platform half is deliberately free but written down.
**About this article.** [**Duet**](https://github.com/modaal-agent/duet) — the framework behind Modaal's iOS ↔ Android parity — is **generic**: nothing below is specific to one product, one design language, or one category of app. The contract, the registry, and the ledger apply to any dual-platform project.
The **worked examples**, though, all come from one real app: **[Memory Lane](https://memorylaneapp.lovable.app/)**, a shared-memories app whose production iOS app was joined by a full Android twin built on Duet. Every number, file name, and deliberate cut quoted here comes from that codebase.
Memory Lane is live on the **[App Store](https://apps.apple.com/us/app/memory-lane-private-journal/id6760589051)**; the Play Store listing is coming shortly.
## Where this starts
Duet holds three commitments, covered in full on the [Duet overview](/articles/duet): logic is shared and pure (features are reducers over typed state, with effects as data), UI is native and thin (SwiftUI and Compose, with navigation held as *state* that shells render), and parity is measured (both platforms replay the same recorded fixtures, and the gate is byte equality).
The third is what makes the first two safe. If you are going to write the UI twice on purpose, you need proof that the *behavior* underneath did not quietly fork. The rest of this article is the machinery that provides it.
## The seam: a five-layer presentation contract
Duet ships a normative document — `contracts/presentation-contract.md` — that splits "presentation" into five layers and assigns each one an owner and an enforcement mechanism.
| Layer | What it is | Open / closed | Shared or per-platform |
| - | - | - | - |
| **L0 — structure** | The navigation algebra: `path`, modal `slot`, `tabs`, `stage` | **Closed** — additions are versioned contract events | Shared; **byte-gated by fixtures** |
| **L1 — kinds** | What a surface *is* — app enum cases (`memoryDetail`, `sharePicker`, `capture`, …) with pure-value payloads | Open (app-defined) | Shared; lint-checked |
| **L2 — verbs** | Actions whose reduction mutates L0. **No silent transitions** | Open (app-defined) | Shared; fixture-gated |
| **L3 — manner** | Detents vs `ModalBottomSheet`, covers, animation curves, corner radii, chrome | Open (per-platform) | **Per-platform, free** — ledgered when the manner *class* diverges |
| **L4 — islands** | Declared regions whose internals live outside the tree (media viewers, camera sessions) | Open (ledgered escape hatch) | **Per-platform internals**, identical declared boundary |
**The rule of thumb:** if a *fixture* can see it, it's shared and gated; if only a *user* can see it, each platform is free to render it in its own way.
### Layer 0 — the structural algebra
Four primitives express every navigational shape an app has. They live as ordinary fields in feature state, are serialized canonically, and are the part of state that fixtures pin:
| Primitive | Shape (Swift / Kotlin) | Semantics |
| - | - | - |
| `path` | `var path: [Route]` / `val path: List` | A back-poppable push stack |
| `slot` | `var sheet: Sheet?` / `val sheet: Sheet?` | **One** modal slot per node; stacking is composition, not a second slot |
| `tabs` | `var activeTab: Tab` / `val activeTab: Tab` | User-switchable; siblings stay alive |
| `stage` | `var phase: Phase` / `val phase: Phase` | Non-optional enum; exactly one full-screen child owns the screen; transitions are reducer edges; children destroyed on exit |
The set is **closed**: adding a primitive is a versioned contract event with a written review bar, and the default answer is no. A fifth row — `overlay` — is kept in the contract precisely *because* it was measured and **declined** as structure: toasts, hints, error banners and flash clocks are plain reducer fields, several of them legitimately co-exist, and none of them need host lifecycle. Recording the rejection keeps the question from being reopened later.
Duet answers this with a measurement rather than an argument. At a defined milestone you sweep **every** reducer's state for navigation-shaped fields and check that the algebra covers them.
Run across Memory Lane's full 16-feature conversion, the census came back: `path` ×2, modal `slot` ×5, `tabs` ×1, `stage` ×1 — nothing needed a fifth primitive. One growth had been found earlier in the process (`stage`, for a launch funnel that was neither a stack nor a tab set) and was landed as a numbered amendment with its rejected alternatives written out.
The sweep also produced the general boundary rule: **a field is structural only when a generic shell attaches child lifecycle to its transitions.** A plain enum that drives copy and layout inside one view — an onboarding pager's page index, say — is feature data, not L0. No host derives mounts from it, so it isn't structure.
### Layers 1 and 2 — kinds and verbs
A **kind** is a case of an app-defined route / sheet / tab enum. The contract doesn't enumerate kinds — that's your product — it only constrains their shape: payloads are **seed values** (ids, configs, flags), never live objects. Adding a presentable surface is one new enum case plus one renderer arm per platform. Nothing else in the contract moves.
A **verb** is any action whose reduction mutates an L0 field, and one rule governs all of them:
**No silent transitions.** Every L0 mutation happens in a reducer arm in response to a named action. Hosts never write structure directly; they report what the user did.
* An interactive sheet dismissal re-enters as an action (`captureFinished(didSave: false)`), guarded against duplicate reports.
* A `NavigationStack` pop gesture re-derives as a semantic `.backPressed` — and a cancelled mid-swipe must not send one.
* Deep links and push taps enter as `deepLink`-class actions and fold through reducers.
The audit is a grep: search the platform diffs for writes to L0 fields outside `store.send`. Any hit is either a violation or a missing verb.
This is what makes two hand-written native UIs safe. The iOS host and the Android host can disagree about *everything* visual, because neither one is allowed to make a navigation decision — they can only report that the user did something, and the shared reducer decides what it meant.
### Layer 3 — manner, where platform-specific UI actually lives
*Manner* is everything the reducer must not know: detents vs `ModalBottomSheet`, push animation curves, corner radii, drag indicators, chrome. It is per-platform and unconstrained. A `detent` field appearing in feature state is a contract violation. [Tutorial 5](/tutorials/duet-05-navigation-as-state#mount-the-flow-in-each-platforms-idiom) shows the layer on one screen: the same upgrade flow, mounted from one state value, presented as a sheet on iOS and a modal bottom sheet on Android with the recordings unchanged.
### Layer 4 — opaque islands
Some regions can't be modeled as state and shouldn't be: a pinch-zoom media pager, a video transport, a camera session. Duet calls these **islands** and makes them first-class — but **ledgered**. An island declares the *boundary summary* (the serializable value persisted instead of its internals), owns no tree children, and restores to its summary rather than its internal gesture state.
## The mechanism: the presentation registry
Both flavors of the framework ship a per-host, **kind → renderer** table. A feature ships the kind; each platform's **composition root** binds how that kind renders. Manner lives entirely inside the renderer closure, so reducers and fixtures never see it.
```swift Swift (SwiftUI) theme={null}
@MainActor
public final class PresentationRegistry {
public func register(
_ kind: Kind.Type, renderer: @escaping (Kind) -> AnyView?
)
public func view(for kind: Kind) -> AnyView?
}
```
```kotlin Kotlin (Compose) theme={null}
class PresentationRegistry {
fun register(kind: KClass, renderer: (Kind) -> Surface?)
fun surfaceFor(kind: Any): Surface?
}
```
The two APIs aren't a mechanical transliteration of each other, and the difference is deliberate. SwiftUI forces a type-erasure box, so the Swift side pins `AnyView`. Kotlin needs none, so the Kotlin side stays generic over `Surface` — which keeps the published artifact **Compose-free**, and a Compose app simply instantiates `PresentationRegistry<@Composable () -> Unit>` from its composition roots.
### The same five kinds, two renderings
Here is Memory Lane's main tab host — the *identical* set of kinds, resolved by each platform's composition root:
```swift MainNavShell.swift (iOS) theme={null}
presentation.register(MainSheet.self) { [weak self] sheet in
switch (sheet, self?.sheetChild) {
case let (.capture, .capture(token, _)):
return AnyView(
HostedViewController(controller: token.viewController)
.ignoresSafeArea()
.presentationDetents([.large])
.presentationDragIndicator(.visible)
.presentationCornerRadius(28))
case let (.notificationPriming, .priming(child)):
return AnyView(
HostedViewController(controller: child.viewController)
.presentationDetents([.medium]))
// …
}
}
presentation.register(TimelineSheet.self) { [weak self] sheet in
AnyView(SharePickerHostView(store: child.store)
.presentationDetents([.medium, .large]))
}
```
```kotlin MainNavHost.kt (Android) theme={null}
registry.register(MainSheet::class) { sheet ->
when (sheet) {
MainSheet.Capture -> {{
val child by shell.sheetChild.collectAsState()
(child as? SheetChild.Capture)?.let { capture ->
ModalBottomSheet(onDismissRequest = {
shell.navStore.send(MainNavAction.CaptureFinished(didSave = false))
}) { CaptureProductSheet(capture) }
}
}}
// …
}
}
registry.register(TimelineSheet::class) { _ ->
{
ModalBottomSheet(onDismissRequest = {
shell.timelineStore.send(TimelineAction.ShareSheetDismissed)
}) { SharePickerSheet(child) }
}
}
```
`HostedViewController` + detents + a 28pt corner radius on one side; `ModalBottomSheet` on the other. The Android host additionally installs `BackHandler`s in a documented composition order — node before child, tab-gated, root modal last — a construct with no iOS analogue whatsoever. None of that reaches the shared half.
## Worked example: one slot, ten kinds, two very different platforms
The clearest illustration in Memory Lane is the capture surface. Its shared state carries **one** modal slot with **ten** kinds — photo source, photo library, camera, video source, video recorder, video library, voice recorder, location, friends, date. One optional enum, payload-free.
Now look at what each platform does with it:
| | iOS | Android |
| - | - | - |
| Source pickers (`photoSource`, `videoSource`) | `.confirmationDialog` | Material `AlertDialog` |
| Library / camera (`photoLibrary`, `camera`, `videoLibrary`, `videoRecorder`) | `.sheet(item:)` / `.fullScreenCover(item:)` wrapping in-app AVFoundation surfaces | System **Activity-result contracts** — Photo Picker, `TakePicture`, `CaptureVideo` (chrome owned by the system apps) |
| Voice recorder | `.fullScreenCover` over an `AVAudioRecorder` surface | `ModalBottomSheet` over `MediaRecorder` |
| Date | Sheet | `DatePickerDialog` |
One side uses three different SwiftUI presentation channels; the other uses a mix of Material dialogs and *other applications entirely*. And yet the reducer, the state shape, and the recorded fixtures are byte-identical — because on both platforms **every** interactive dismissal re-enters through the same single verb, `modalDismissed(kind)`, whose kind-equality guard makes channel races inert.
The legacy iOS code this replaced carried an `activeSheet` **and** an `activeFullScreen` view-state pair — two booleans-in-disguise that could both be set. Collapsing them into one slot is what made the Android twin expressible at all: N mutually-exclusive booleans cannot encode exclusivity, so two platforms will eventually disagree about which one wins.
## Divergence is ledgered, not forbidden
Duet doesn't prevent the two platforms from differing when they genuinely need to. It requires the difference to be a **decision on record** rather than a discovery six months later. There are two escape hatches, both declared in the project's `parity/manifest.yaml`:
Filed when one platform deliberately renders a kind in a different manner **class** than its twin — a full-screen dialog where the other uses a half-height sheet. Not for styling: different corner radii, different animation curves, different type scales need nothing.
```yaml theme={null}
waivers:
- kind: ..
feature:
platform: ios | android
manner:
reason:
since:
```
Filed for regions whose internal presentation state lives outside the tree. The **kinds and boundaries are identical** across platforms; only the opaque internals differ.
```yaml theme={null}
islands:
- id: .
feature:
boundary:
reason:
since:
```
Memory Lane's ledger carries three islands with an entry per platform: the photo carousel (UIKit pager ↔ Compose pager `Dialog`), the video player (`AVPlayer` transport ↔ `VideoView`/`MediaController`), and the capture chrome (`AVCapture`/`AVAudioRecorder` sessions ↔ system capture apps + `MediaRecorder`). Same kind, same boundary summary, wholly different internals.
A lint (`lockstep-lint`) validates **shape and cross-references** — every entry names a real feature, a valid platform, an ISO date — and deliberately does **not** judge whether the divergence is justified. That judgment is left to review: the lint guarantees the ledger is complete and parseable, and a human decides whether each entry is warranted.
Memory Lane's standing ledger, after the full Android build-out: **0 waivers, 6 island entries** (3 regions × 2 platforms).
**The third-verb heuristic.** Islands can quietly grow into structure. The rule: the first two actions an island wants are usually incidental; the **third** means it actually has structure — promote it to a kind. Failure/revert twins of an existing verb don't count (they're that verb's plumbing). The sweep re-runs at every milestone that added manner surfaces.
## What's gated versus what's free
| Layer | Cross-platform obligation | Enforcement |
| - | - | - |
| **L0** | Identical fields, identical serialization | Fixtures — `duet verify`, `duet record --check` |
| **L1** | Identical kinds and payloads per feature | Lockstep-lint declaration checks |
| **L2** | Identical verbs, identical reductions | Fixtures; per-milestone ledger audit |
| **L3** | **Free per platform**; divergent manner *classes* waived in the ledger | Lint shape check; human review |
| **L4** | Islands declared identically; internals free; boundary summaries identical | Lint shape check; restore drill; periodic sweep |
## What this buys you
When Memory Lane's sixteen Android product views were written — feeds, detail, capture, profile, the whole launch funnel — the measured results were:
Not one feature module changed. `duet verify` held at **134/134** by construction, and the fixture corpus was never re-recorded.
Every platform-specific decision below cost at most a line in a log — no contract change, no waiver, no re-record.
The deliberate cuts, in full:
* **No Lottie splash art on Android** — the phase is reducer-clocked either way; the art is manner.
* **No Apple sign-in button** — the platform has no Apple sign-in, so the environment maps that path to a failure the shared reducer already handles.
* **No reaction-flight animation overlay** — pure chrome; it writes no verbs.
* **No video transcode pass on Android** — the system camera emits MP4 already, so the iOS AVFoundation normalize step is simply platform manner, and the Android arm is a staged-file adoption.
* **Compose pager `Dialog` instead of the UIKit carousel** — declared island internals.
* **Text-only tab labels shipped first**, icons in a later polish pass — a manner detail that never blocked a behavior gate.
And on theme: the **mood** is kept in step, not the token system. Android runs a Material 3 scheme tuned to the iOS palette — warm paper, bark primary, light and dark — rather than an attempt to port design tokens across two design languages that disagree about what a token is.
## Adding a presentable surface: the whole recipe
A new case on the owning node's route/sheet enum, with a pure-value payload. No rendering exists yet.
The golden-first rule: pin the scenario byte-for-byte on both platforms **before** any UI is written. This is the step that makes the rest safe.
Reducer arms that set and clear the kind, named for intent. Interactive dismissals re-enter as the view's *report* action with a stale-dismissal guard.
`registry.register(Kind.self) { payload in … }`. Manner lives **only** here — detents, covers, animation, chrome. Renderers may wrap any native API, including tricks and workarounds; the registry is where platform-specific creativity goes without touching the contract.
Different manner *class* between platforms → a waiver entry. Different styling → nothing at all.
Two things must never happen: a reducer learning manner (a `detent` field in feature state), and a host writing an L0 field outside `store.send`. Both violate the contract, and the per-milestone audit greps for exactly these two patterns.
## Common questions
Yes — the *views*, but not the logic, the navigation decisions, the effect handling, or the test corpus. With any native-UI approach you were writing those views twice anyway; what you stop paying twice for is behavior, and what you gain is proof that the two behaviors agree.
The measured shape of that trade in Memory Lane: sixteen Android product surfaces built against stores that already had a passing fixture corpus, with zero logic changes and no re-recording.
Then it doesn't, and the ledger records why. Apple sign-in has no Android counterpart — the Android environment maps that path to the failure arm the shared reducer already models, and the button doesn't exist. No contract change is needed; the shared half never assumed the button.
Nothing in the contract forbids it — manner is free, so what a renderer closure returns is entirely your business. But Duet's toolchain gives you no help there, and its whole premise is that native look-and-feel is worth writing twice while behavior isn't. Sharing views would be working against the grain of the framework.
If the drift is in L0–L2, CI fails: both platforms replay the same recorded fixtures and the gate is byte equality, so a diverging reducer is a red build on the commit that caused it. If the difference is in L3–L4, it isn't drift — it's manner: either it needed a ledger entry (which the lint's shape check enforces) or it needed nothing at all.
***
## What does Duet share, and what does it let diverge?
Modaal shares the navigation *structure* as data and gates it in CI. The widgets are fully native on each side and are expected to differ; where they differ in kind rather than in pixels, the difference is recorded in a ledger that the lint checks.
**Status.** Duet is **pre-release**. The framework, both flavors, and the toolchain have landed, and the contracts are versioned alongside the code — but no artifacts are published yet, so treat the API surface as a preview until the first tagged release. The contracts quoted here (`presentation-contract.md`, `store-kernel-contract.md`, `serialization.md`, `replay-protocol-v1.md`) ship in the open framework repository. The product page is [modaal.dev/duet](https://modaal.dev/duet).
What Duet is, the two combinations that scaffold it, what lands on disk, and how to add Android to an iPhone-only project.
The per-feature route from an existing Production app project to a shared Kotlin core and an Android app.
The tree behind the examples on this page: feature modules, builders, the navigation hosts and the fixtures directory — in the Memory Lane codebase.
Every term this page leans on — kind, shell, reducer, fixture, the ledger — defined with one link each.
The contract applied by hand: one sheet value in two idioms in Tutorial 5, tokens in Tutorial 7, and strings kept out of state in Tutorial 8.
# The Duet Framework: One Shared Core, Two Native Apps
Source: https://docs.modaal.dev/articles/duet
The Duet framework: feature logic written once, native SwiftUI and Jetpack Compose interfaces, and a CI gate that fails when the two apps behave differently.
**Duet** is the architecture Modaal scaffolds for a **Production app** — the kind you pick in the sentence on the home screen, for **Android + iOS** or for **iOS**. Each feature's logic — its state, the actions that change it, the effects it requests — is written **once**. The iPhone app renders that state in SwiftUI; the Android app renders it in Jetpack Compose. Both apps replay the same recorded fixture files, and the build fails when the two replays differ.
There is no shared UI toolkit and no web view. Both apps are ordinary native codebases you can open in Xcode and Android Studio, ship to the App Store and Google Play, and export at any time.
**Status.** Android support is available to every Modaal account, in open beta. Modaal Free builds one platform of your choice; building iOS and Android together is part of [Pro](/credits-and-plans). The product page is [modaal.dev/duet](https://modaal.dev/duet).
## What you get
One pure reducer per feature holds the behavior. Adding a rule, fixing a bug, or changing a flow is one edit that both apps pick up, rather than the same change applied twice in two languages.
SwiftUI on iPhone and iPad, Jetpack Compose on Android. Each app follows its own platform's conventions — Apple's Human Interface Guidelines on one side, Material 3 on the other.
Behavior is recorded as fixture files. Both platforms replay the same fixtures and the gate is byte equality, so a divergence fails the build on the commit that caused it.
Start iPhone-only and add Android per feature later. The fixtures recorded from the Swift reducer are what the Kotlin one is verified against.
## How Duet compares to other cross-platform approaches
| | **Flutter, React Native** | **Compose Multiplatform** | **Two separate codebases** | **Duet** |
| - | - | - | - | - |
| **Which layer draws the screen** | One toolkit renders on both platforms; the widget set belongs to the framework | Compose renders on both platforms | Each platform's native toolkit | Each platform's native toolkit — SwiftUI and Compose |
| **Where feature logic lives** | Once, in Dart or JavaScript | Once, in Kotlin | Twice, once per platform | Once, in a pure reducer per feature |
| **What proves the two platforms agree** | Nothing separate — there is one implementation | Nothing separate — there is one implementation | Manual QA on both apps | Recorded fixtures replayed on both platforms; CI compares byte-for-byte |
| **A native, perf-heavy island** | An escape through the framework's bridge — platform view, texture or native component, with SwiftUI and Compose reachable only as wrapped views inside it — written per platform in Swift/Objective-C(++) and Kotlin, compiled by Xcode and Gradle in the app build, with its logic outside the shared code | Shared when the framework can draw it; on iOS a native island is a `UIKitView` built in Kotlin through Objective-C interop, or in Swift for Swift-only APIs and SwiftUI views, inside a framework-drawn screen | Ordinary platform code, written twice and compiled by Xcode and Gradle | Ordinary platform code, written twice and compiled by Xcode and Gradle; the reducer decides, a worker runs the pipeline, and the seam is recorded |
Each column is argued in full on its own page: [the framework survey](/articles/best-cross-platform-framework) places every mainstream option on these axes, and the pairwise pages cover [Flutter](/articles/duet-vs-flutter), [React Native](/articles/duet-vs-react-native), [Compose Multiplatform](/articles/duet-vs-compose-multiplatform) and [Ionic Capacitor](/articles/duet-vs-ionic-capacitor).
The trade Duet makes is explicit: **you write the views twice and the behavior once.** Screens, navigation chrome, animations and controls are authored per platform, which is what keeps each app native. Everything a fixture can see — state shape, the actions that change it, the effects a reducer requests, the order they come back in — is shared and gated.
The agent writes both view layers. The practical cost of "twice" is agent time on the second set of screens; the behavior underneath is already written, recorded and passing, so there is no second design pass.
## Starting a Duet project
### 1. Say you're building a production app
The Modaal home screen opens with one sentence above the composer: *I'm building a `kind` for `platform`*. **Production app** is the kind that creates a Duet project, and the platform word picks the flavor:
| Platform word | The line under it | What it creates | Flavor |
| - | - | - | - |
| **iOS** | iPhone now, Android later | One iPhone app | Duet, Swift |
| **Android + iOS** | One codebase, both stores | An iPhone app **and** an Android app | Duet, Kotlin Multiplatform |
The sentence opens on **Production app for iOS** the first time, and afterwards on the combination you last created a project with. **Android + iOS** is part of Pro: on a plan without it the row still selects and carries a **Pro** chip, and **Start new project** opens the upgrade screen.
The **+ Apple Watch** chip beside the platform word adds a watchOS companion target to either flavor. [Starting a new project](/articles/new-project#the-new-project-flow) covers every combination the sentence offers, Duet and otherwise.
### 2. Describe what you're building
Type your idea into the composer and press **Start new project**. You can attach a PRD, screenshots, a design doc, or a Figma link.
Nothing is written to disk until you press the button — no project, no folder, no git repository. There is no second step either: the app's name, its identifiers and its device families are all derived from your description.
### 3. Modaal creates the project
A folder, a project record and a git repository, all under the name read from your description.
The app targets, the shared core, the parity tooling, the theming and control libraries, a CI workflow, and a test suite that passes on the first run. Any files you attached are copied into the project.
The agent receives it with Plan mode on and continues from there.
You wait on the home screen while that runs: the composer goes read-only, the send button reads **Setting up…**, and a status line names the step. **Cancel** is there until scaffolding starts.
### 4. Check the bundle identifier before you ship
The bundle identifier is derived as `.` — the organization identifier being the reverse-DNS prefix your last project used, or one suggested from your account. It is what App Store Connect, TestFlight and Firebase register.
**Once a build reaches TestFlight or the App Store, its Bundle ID can never be changed.** Set it before your first upload, in the **Project Settings** tab of the sidebar. [How the identifiers fit together](/articles/new-project#how-the-three-fields-fit-together) explains what each one is for.
### 5. The first turn: PRD and first feature spec
The sentence has already fixed the structure, so the agent's first turn starts at product scope. It reads your description and attachments, writes a structured **`PRD.md`** at the project root, then plans a workable first iteration into `specs/001-/spec.md`.
It never asks which architecture to use — the template and the per-target architecture are recorded in `.modaal/project.json` and read on every turn.
When the plan looks right, turn **Plan** off and say *"let's build this"*. See [Modes and prompts](/articles/modes-and-prompts) for how the Plan toggle works from there.
***
## The two Duet templates
Both platform words scaffold the same architecture, the same authoring loop and the same gates. They differ in how many app targets exist on day one, and in which language holds the feature logic.
### Production app for Android + iOS: the Kotlin Multiplatform flavor
**Template id:** `duet-kmp` · **Emits:** an iPhone app and an Android app · **Feature logic:** Kotlin `commonMain`
Feature logic is one Kotlin module per feature under `src-kmp/`. The Android app consumes it directly. The iPhone app consumes the **same compiled logic** through a Kotlin/Native framework — there is no second implementation of a reducer to keep in step, and no hand-written Swift copy of any Kotlin rule.
The iPhone app links the Kotlin/Native runtime, including its garbage collector, and Swift reaches the core through SKIE's generated Swift layer; [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) explains who frees what.
```text theme={null}
src-kmp/ # the shared Kotlin core
├── / # one module per feature: State, Action, Effect, reducer
├── services/ # cross-cutting ports, log vocabulary, consent state
├── telemetry/ # the closed analytics event grammar
├── theming/ # design tokens, shared by both apps
├── apple-umbrella/ # one Kotlin/Native framework aggregating every feature
├── replay-runner/ # replays fixtures on the JVM
└── app/ # the Android app: Compose shell, workers, Activity edge
src-ios/
├── App/ # the iPhone app target; xcodegen.yml is the source
└── Libraries/ # Kit (links the framework), Main (composition root),
# Theming, ThemedControls
parity/
├── manifest.yaml # the declared features, fixtures and generators
├── authoring.md # this repo's own front door for feature work
├── feature-specs/ # one one-pager per feature
└── fixtures/ # recorded behavior — build products, never hand-edited
tools/duet # the CLI that records and verifies
.github/workflows/ # the parity gates, running on every push
```
**Pick it when** Android is in scope now, when you want both stores at launch, or when you would rather take on the Kotlin core now than migrate to it later. **What it needs:** the Android SDK and a JDK on your Mac, alongside Xcode. Modaal checks for both and shows an **Action needed: install the Android SDK** banner with a button that installs what is missing — [Set up the Android toolchain](/guides/setup-android) documents the whole flow.
### Production app for iOS: the Swift flavor
**Template id:** `duet-swift-ios` · **Emits:** one iPhone app · **Feature logic:** Swift packages
Feature logic is one pair of Swift packages per feature under `src-ios/Subtrees//` — the gated half holding the reducer, its types and its scenario tests, and the app-facing half holding the builder and the SwiftUI shell. Pure SwiftPM: no Gradle, no Kotlin, no Android SDK needed to build or ship it.
```text theme={null}
src-ios/
├── Subtrees// # one directory per feature:
│ ├── Feature/ # the reducer, its types, the worker, the scenarios
│ └── Node/ # the builder and the SwiftUI shell
├── App/ # the iPhone app target
└── Libraries/ # Main (composition root), Theming, ThemedControls
parity/
├── manifest.yaml
├── authoring.md
├── feature-specs/ # the porting source of truth
└── fixtures/ # recorded behavior — build products, never hand-edited
tools/duet
.github/workflows/
```
This flavor keeps the one practice an iOS-only project would normally skip: **every feature is recorded and specced exactly as in the two-platform flavor.** The fixture corpus is the portable spec of each feature's behavior, and `parity/feature-specs/.md` is the porting source of truth. Both are in place from the first feature, before there is an Android app to port to.
**Pick it when** Android is a "probably, later", when you want the smallest tree that still converges, or when you want the iPhone app in users' hands before taking on a second toolchain. **What it needs:** Xcode only.
### Choosing between them
| Your situation | Sentence |
| - | - |
| Android is in scope now, or you want both stores at launch | **Production app** for **Android + iOS** |
| Android is a "probably, later" and the iPhone app ships first | **Production app** for **iOS** |
| You want one toolchain on your machine for now | **Production app** for **iOS** |
| You are certain the product is iPhone-only forever | Either platform word works; **iOS** is the smaller tree |
| You are building a game or a drawn playfield | **2D game** — no Duet template scaffolds a SpriteKit scene host |
| You are validating a throwaway idea | **Quick prototype** — note that an MVVM project has no migration path to Duet |
**Pick Production app at creation time if Android is anywhere in the plan.** The architecture a template fixes holds for the life of the project. A CombineRIBs project can be carried to Duet feature by feature, but an MVVM one cannot — going to production from **Quick prototype** means a second project, written again.
***
## Adding Android to an iPhone-only project
The route available today moves the Swift flavor onto the Kotlin Multiplatform flavor. Android arrives as a **per-feature, fixture-gated migration** into the same repository, not a second tree kept in step by hand.
The Gradle plane, the shared-core modules, the Android app shell and the Kotlin side of the parity workspace land in the existing tree. The iPhone app builds, runs and ships unchanged while they sit alongside it.
Per feature: the Kotlin reducer is written against the fixtures **already recorded** from the Swift one, the Android app mounts the feature, and the iPhone app moves onto the shared core for it. The gate is that the same fixture bytes replay on the new side.
`parity/manifest.yaml` declares the ported side per feature, so a half-migrated repository is an ordinary, shippable state. You can stop between any two features and keep the work finished so far.
This is the same shape as the [CombineRIBs → Duet migration](/articles/combineribs-to-duet) an existing **Production app** project takes, with several of its steps already done:
| Migration step | From CombineRIBs | From the Duet Swift flavor |
| - | - | - |
| Readiness audit — find the untested logic and the shapes that resist conversion | Required first | Not needed; every feature is already a pure reducer |
| "Author new features in the target shape from today" | A rule to adopt, plus a sweep of the repo's agent documents | Already how the repo works |
| Per feature: write the reducer, its spec and its fixtures | The bulk of the work — and it starts by discovering what the old code actually did | Already done, and recorded |
| Per feature: write the Kotlin twin | Against fixtures recorded during the same migration | Against fixtures recorded when the feature was first built |
| Retire the old architecture — coexistence packages, shell swap | A terminal phase of its own | Nothing to retire |
What is left is adding the Kotlin core to the repository and translating one feature at a time: the Kotlin reducer has an executable specification to satisfy before its first line is written, which bounds the work.
You do not schedule this from a menu. Ask the agent for Android when you want it, and it works through the steps above with you, one feature at a time.
The route above moves the iPhone app onto the shared core. A second route, in development, keeps the iPhone app's Swift core and adds a Kotlin core for the Android app, with both checked against the same recordings: [Duet Twins](/articles/duet-twins).
***
## How a feature gets built
This is the loop the agent runs for every behavior change, in either flavor, and the one you run yourself if you work in the repo directly:
A one-pager under `parity/feature-specs/.md`: the states, the transitions, and a row per ending the feature can reach.
`Given` / `When` / `Then` — a branch per ending. This is the executable form of the spec.
`tools/duet record --feature ` compiles the scenario into fixture files. Fixtures are build products: they are never edited by hand, and where a fixture and the prose disagree, the fixture is authoritative.
Adjust the pure reducer until the scenario passes. The reducer takes no environment — no clocks, no IDs, no network — so anything nondeterministic round-trips through an effect and comes back as an action. That is what makes the recording replay identically on both platforms.
A SwiftUI screen on iOS, a Compose screen on Android. Each one reads state and sends actions; neither makes a navigation decision of its own.
`tools/duet verify` — the same gate CI runs. It replays every fixture on every platform and compares byte-for-byte.
You do not have to run these commands: the agent runs them, and the CI workflow in the scaffold runs them again on every push. They are in your repository and documented in `parity/authoring.md`, so you can run them yourself at any time.
To run this loop by hand from an empty tree to the CI gate, follow the [Duet tutorials](/tutorials/duet): nine tutorials build one app, Foyer, on both platforms, each opening a starting tree and ending with a finished tree you can compare against, all in the public [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials). [Tutorial 1](/tutorials/duet-01-first-feature) runs the spec, scenario, record, reducer and verify steps on one feature with no UI; [Tutorial 2](/tutorials/duet-02-two-apps) wires the first shells; [Tutorial 6](/tutorials/duet-06-checks-in-ci) puts verify and the checks around it into a CI workflow.
## What Duet requires of you
Stated plainly, so nothing is a surprise later:
* **Views are written twice.** Two screen implementations per feature, one per toolkit. The logic, the navigation decisions, the effect handling and the test corpus are written once.
* **The architecture is fixed at creation.** Duet projects stay Duet projects. Modaal has no command that converts a target from one architecture to another; the migrations that exist run *into* Duet, feature by feature.
* **No game template.** No Duet template scaffolds a SpriteKit scene host. A game starts from **2D game**, which is CombineRIBs.
* **Building both platforms is a Pro feature.** Free builds one platform of your choice. See [Plans & pricing](/credits-and-plans).
* **You still need a Mac.** Modaal builds and runs through Xcode, so macOS with Xcode 26 or newer is required for the iOS half. See [Set up Xcode](/guides/setup-xcode).
* **Duet is pre-release.** The framework, both flavors and the toolchain have landed and the contracts are versioned alongside the code, but no artifacts are published yet. Treat the API surface as a preview.
## Common questions
**Production app for Android + iOS** is built on Kotlin Multiplatform: the shared core is Kotlin `commonMain`, and the iPhone app consumes it through a Kotlin/Native framework. What Duet adds on top is the part KMP leaves to you — the feature shape, the recorded fixtures, and the CI gate that compares the two platforms' behavior. **Production app for iOS** is pure Swift with no Kotlin or Gradle in the tree.
No. There is no cross-platform UI layer. iOS renders SwiftUI, Android renders Jetpack Compose, and there is no shared UI code between them. What is shared is *what is on screen*, expressed as a value in feature state; what stays per platform is *how that value is rendered*. [Handling platform-specific UI](/articles/cross-platform-ui-parity) is the full treatment of where that line sits, and [Tutorial 5](/tutorials/duet-05-navigation-as-state#mount-the-flow-in-each-platforms-idiom) shows it on one screen: the same upgrade flow presented as a sheet on iOS and a bottom sheet on Android from one state value.
Each app keeps its own platform lifecycle, and no lifecycle callback makes a decision. Three rules keep lifecycle handling out of the shared logic:
* **Stores live outside the view tree.** On Android the app's single Activity keeps the root of the store tree in a retained object, `RetainedRoot`, held by Essenty's `InstanceKeeper`. A rotation recreates the Activity and the composition, and `getOrCreate` returns the same root with every store still running. Finishing the Activity is the only teardown. On iOS the scene delegate holds the root and tears it down in `sceneDidDisconnect`.
* **Views report what happened, and reducers decide.** A composable or a SwiftUI view sends actions such as `Appeared`. Compose re-runs its effects after a rotation, and SwiftUI calls `onAppear` again when a tab is shown again, so a reducer that receives `Appeared` ignores a repeat. That guard is part of the recorded behavior both platforms replay.
* **Navigation is state, so restoring it means restoring data.** Before the process can be killed, each app saves the route spine: every level's route value, encoded by one Kotlin function. Android writes it in `onSaveInstanceState` and iOS in the scene's `stateRestorationActivity`. On relaunch the tree is rebuilt with the spine as each store's initial state. Back presses, sheet dismissals and deep links also enter the shared logic as actions.
```kotlin MainActivity.kt (simplified) theme={null}
class MainActivity : ComponentActivity() {
private lateinit var retained: RetainedRoot
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Null on a fresh start and after a rotation; the saved spine after process death.
val restored = decodeRouteSpine(savedInstanceState?.getString(SPINE_KEY))
// A rotation returns the same instance, with every store still running.
retained = instanceKeeper().getOrCreate {
RetainedRoot(Dispatchers.Main.immediate, RootMount::teardown) { scope ->
RootBuilder(dependency).buildRoot(scope, restored)
}
}
setContent { AppRoot(retained.component) }
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(SPINE_KEY, encodeRouteSpine(retained.component.routeSpine()))
}
}
```
```swift SceneDelegate.swift (simplified) theme={null}
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options: UIScene.ConnectionOptions) {
let saved = session.stateRestorationActivity?.userInfo?[spineKey] as? String
let root = RootBuilder(dependency: SceneComponent()).buildRoot(restored: decodeRouteSpine(text: saved))
window.rootViewController = UIHostingController(rootView: RootView(shell: root.shell))
window.makeKeyAndVisible()
root.shell.activate() // mounts the first child once the window is visible
self.root = root
}
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
let activity = NSUserActivity(activityType: spineActivityType)
// The same Kotlin encoder the Android app calls.
activity.addUserInfoEntries(from: [spineKey: encodeRouteSpine(spine: root.shell.routeSpine())])
return activity
}
func sceneDidDisconnect(_ scene: UIScene) {
root?.shell.deactivate() // dropping the reference alone cancels nothing on the Kotlin side
root = nil
}
```
```kotlin SplashFeature.kt (commonMain, simplified) theme={null}
SplashAction.Appeared ->
if (state.isArmed) Reduced(state) // a repeated Appeared writes nothing and emits nothing
else Reduced(state.copy(isArmed = true), listOf(Effect.Run(ArmSafetyNet(SAFETY_NET_MILLIS))))
```
The recordings check the reducers, including these guards. The recordings do not check the Activity, the scene delegate or the views; host tests do. `RootFlowTest` on the JVM and `RootCompositionSpec` in Swift each build the tree, tear it down, and rebuild it from a saved spine. [Tutorial 2](/tutorials/duet-02-two-apps#build-the-compose-app) writes the retained root, [Tutorial 5](/tutorials/duet-05-navigation-as-state#gather-the-route-spine-and-restore-from-it) adds the route spine and [back handling on Android](/tutorials/duet-05-navigation-as-state#make-back-an-action-on-android), and [Inside a Duet Android app](/articles/duet-android-app-anatomy#what-is-in-the-android-app-module) walks through the Activity of a shipped app.
No. Every feature has its own store with its own state. A parent reducer knows two things about a child: which child is mounted, stored as a field in the parent's own state, and the child's delegate event type, wrapped in one of the parent's actions. It never holds a child's state and never sends a child's actions.
```kotlin RootFeature.kt and SignInFeature.kt (commonMain, simplified) theme={null}
data class RootState(
val phase: RootPhase = RootPhase.Splash, // which child is mounted
val entitlement: Entitlement = Entitlement.Free, // the root's own data
) // no SignInState, no MainState
sealed interface RootAction {
data class SignIn(val event: SignInDelegateEvent) : RootAction // the child's outward events only
data class Main(val event: MainDelegateEvent) : RootAction
data class EntitlementChanged(val entitlement: Entitlement) : RootAction
}
// In the child: the event is returned as effect data, so the child's recording contains it.
is SignInOutcome.SignedIn ->
Reduced(state.copy(isSigningIn = false), listOf(Effect.Run(NotifyHost(Completed(displayName)))))
```
The stores are connected in the app module, by one Builder per level. A `ChildSlot` builds the child the parent's state names and tears down the previous one, and composables render whichever child is mounted:
```kotlin RootBuilder.kt, MainBuilder.kt and AppRoot.kt (Android app module, simplified) theme={null}
val slot = host.adopt(ChildSlot(
build = { phase ->
when (phase) {
RootPhase.SignIn -> RootChildMount.SignIn(
SignInBuilder(component).buildSignIn(onDelegate = { rootStore.send(RootAction.SignIn(it)) }))
RootPhase.Main -> RootChildMount.Main(
MainBuilder(component).buildMain(displayName = rootStore.state.value.auth.displayNameOrGuest, entitlement = mount.entitlement))
// …
}
},
teardown = { it.teardown() },
))
host.adopt(StateTransitions(scope, rootStore.state) { _, state ->
mount.project(state.entitlement) // publish the one value the tabs read
slot.reconcile(state.phase) // build the named child, tear down the previous one
mount.publish(slot.activeHandle) // the child the composables render
})
// MainBuilder: the value reaches the tab as the tab's own action.
host.adopt(StateTransitions(scope, entitlement) { _, value ->
homeStore.send(HomeAction.EntitlementChanged(value))
})
// AppRoot: collects the mounted child and renders its screen over the child's own store.
@Composable
fun AppRoot(root: RootMount) {
when (val child = root.child.collectAsState().value) {
is RootChildMount.SignIn -> SignInScreen(child.store)
is RootChildMount.Main -> MainScreen(child.mount)
// …
}
}
```
Values move between levels in four ways:
* **Up:** a child's [delegate event](/articles/duet-glossary#delegate), received by the parent as one of its actions.
* **Down, once:** a seed value passed to the child's Builder when the child is mounted, such as the display name above.
* **Down, while mounted:** a [slice](/articles/duet-glossary#slice). The parent publishes one value from its state, and the child receives each change as an action of its own. The child reads the value and never writes it back.
* **Between siblings:** [lateral state](/articles/duet-glossary#lateral-state), a worker owned by the siblings' nearest common parent.
On Android the Activity holds only the root of this tree. Unlike a per-screen ViewModel, which is scoped to a back-stack entry, a child store exists while its parent's state names it. [Tutorial 3](/tutorials/duet-03-composing-features#compose-the-tree-on-android) builds the tree on both platforms, and [Tutorial 4](/tutorials/duet-04-workers#project-the-entitlement-down-as-a-slice) adds the slice.
Yes. **Production app for iOS** emits a complete iPhone app — an XcodeGen-generated Xcode project, build configurations, an app icon, a localization catalog and a passing test suite. It is a shipping iOS app that also keeps a portable record of its own behavior.
The shared reducer models the unavailable path as a failure, and the project's `parity/manifest.yaml` records the divergence. Apple sign-in has no Android counterpart: the Android environment maps that path to a failure the reducer already handles, and the Android UI has no Apple sign-in button. The shared half needs no contract change, because it never assumed the button was there.
Yes. The output is native codebases in one git repository — an XcodeGen-generated Xcode project on one side, a Gradle project on the other. Open them, edit them, run them, ship them. Modaal adds no proprietary format and no export step.
A CombineRIBs project has a documented migration path into the Duet shape, taken feature by feature — [Migrating a CombineRIBs app to Duet](/articles/combineribs-to-duet) is the walkthrough. A **Quick prototype** (MVVM) project has no migration path — Android there means a new project.
The reducer decides; workers do the work. Network calls, disk access, camera sessions and timers live in *workers* behind interfaces the reducer names as effects, and each platform supplies its own implementation. The shared half is the decisions and the ordering; the platform half is the I/O. [Tutorial 4](/tutorials/duet-04-workers#write-the-workers-on-android) writes the first two workers and the [on-device backend](/tutorials/duet-04-workers#write-the-on-device-backend) they observe.
## Read next
Nine hands-on tutorials build one native iOS and Android app, Foyer, from a first Kotlin feature to the CI checks, theming, localization and analytics.
The technical deep dive: the five-layer presentation contract, the kind→renderer registry, and how deliberate iOS/Android divergence is recorded.
The full walkthrough of the new-project sentence and every template Modaal offers, Duet and otherwise.
Already on a CombineRIBs project? The per-feature route to a shared Kotlin core and an Android app.
The decision upstream of Duet: write twice, share everything, or share the logic — with costs and returns.
Flutter, React Native, Compose Multiplatform and the rest on the same three axes as the table above.
The emitted Android tree file by file: feature modules, the thin app module, workers and fixtures.
Reducer, worker, scenario, fixture, the checks — every term defined with one link each.
The open framework repository — both flavors, the toolchain, and the versioned contracts.
The same shared-core-versus-shipped-runtime question argued for a product audience, on modaal.dev.
# The Structure of a Kotlin Multiplatform Android App
Source: https://docs.modaal.dev/articles/duet-android-app-anatomy
A Duet Android app is a thin Compose shell over a shared Kotlin core: one Gradle module per feature, plus recorded fixtures its iOS twin replays too.
A Duet-built Android app is structured as a thin, native Compose app over a shared Kotlin core: every feature's logic lives in its own Gradle module under `commonMain`, the Android app module holds only what is platform work by nature — the entry activity, the screens, the I/O workers — and recorded fixtures pin what the logic does. **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, scaffolds this shape as one repository holding two native apps: the Android app this page walks, and an iOS twin that consumes the identical compiled core. Each section below names the iOS counterpart of the part it describes. This page walks the tree the way [How Android apps work](/articles/how-android-apps-work) walks a standard Android project — read that page first if manifests, activities and Gradle modules are new vocabulary.
The worked example throughout is **[Memory Lane](https://memorylaneapp.lovable.app/)**, a shared-memories app whose production iOS app was joined by a full Android twin built on Duet — the same codebase [Handling platform-specific UI](/articles/cross-platform-ui-parity) quotes. Memory Lane is live on the [App Store](https://apps.apple.com/us/app/memory-lane-private-journal/id6760589051); every file name and count below comes from that repository.
## What does the tree look like?
One repository holds both apps and the contract between them. The Android-relevant parts of Memory Lane's tree:
```text theme={null}
src-kmp/ # the Gradle plane: shared core + the Android app
├── subtrees/
│ ├── timeline/logic/ # one module per feature: State, Action, Effect, reducer
│ ├── capture/logic/ # …16 feature modules in this app
│ └── priming/logic/
├── services/ # cross-cutting ports: identity, feeds, consent
├── telemetry/ # the closed analytics event grammar
├── theming/ # design tokens, shared with the iPhone app
├── apple-umbrella/ # Kotlin/Native framework for the iPhone app
├── replay-runner/ # replays recorded fixtures on the JVM
└── app/ # the Android app module
├── MainActivity.kt # the Android edge: intents, Bundle I/O, retained tree
├── AppRoot.kt # the Compose root
├── MainNavHost.kt # the kind → renderer table for the main host
├── Builder.kt # one builder per mounted feature
├── ui/ # theme and controls
├── workers/ # the I/O: repositories, push, media
└── widgets/ # home-screen widgets
src-ios/ # the iPhone app; consumes the same core
parity/
├── feature-specs/ # one one-pager per feature
└── fixtures/ # recorded behavior — build products, never hand-edited
```
Everything under `src-kmp/` is an ordinary Gradle build you can open in Android Studio. The one module with no Android role is `apple-umbrella`, which packages the same feature modules as a Kotlin/Native framework for the iPhone app.
## Where does feature logic live?
Each feature is one Gradle module under `subtrees/`, compiled to `commonMain` — Kotlin with no Android or iOS imports. A feature is three declarations and a function: a serializable **state**, the **actions** that can change it, the **effects** it may request, and a pure [reducer](/articles/duet-glossary#reducer). Memory Lane's smallest feature, the notification soft-ask, in skeleton (the full file is 103 lines):
```kotlin PrimingFeature.kt (commonMain) theme={null}
@Serializable
data class PrimingState(
val outcome: PrimingOutcome? = null, // the user's choice, latched
)
sealed interface PrimingAction {
data object EnableTapped : PrimingAction
data object NotNowTapped : PrimingAction
}
sealed interface PrimingEffectPayload {
data object RequestPushPermission : PrimingEffectPayload
data class NotifyListener(val event: PrimingDelegateEvent) : PrimingEffectPayload
}
fun primingReducer(
state: PrimingState,
action: PrimingAction,
): Reduced =
when (action) {
PrimingAction.EnableTapped ->
if (state.outcome != null) {
Reduced(state) // the first verb won; later taps are inert
} else {
Reduced(
state.copy(outcome = PrimingOutcome.enabled),
listOf(
Effect.Run(PrimingEffectPayload.RequestPushPermission),
Effect.Run(PrimingEffectPayload.NotifyListener(PrimingDelegateEvent.EnableChosen))))
}
PrimingAction.NotNowTapped ->
if (state.outcome != null) {
Reduced(state)
} else {
Reduced(
state.copy(outcome = PrimingOutcome.declined),
listOf(Effect.Run(PrimingEffectPayload.NotifyListener(PrimingDelegateEvent.Declined))))
}
}
```
The reducer takes no environment — no clock, no network, no permission API. The platform permission prompt is requested as an *effect value*; a worker in the app module performs it. That separation is what makes the feature recordable: a [scenario](/articles/duet-glossary#scenario) drives the reducer and records state and effects into `parity/fixtures/priming.*`, and both the Android app's JVM test lane and the iPhone app's Swift lane replay those exact bytes in CI. This layer has no hand-written iOS counterpart: the same module compiles into the `apple-umbrella` framework, and the iPhone app calls the same reducer through it.
The app module consumes the features as ordinary project dependencies — this is the whole wiring, from Memory Lane's `app/build.gradle.kts`:
```kotlin app/build.gradle.kts theme={null}
dependencies {
implementation(project(":subtrees:timeline:logic"))
implementation(project(":subtrees:memorydetail:logic"))
implementation(project(":subtrees:mainnav:logic"))
implementation(project(":subtrees:rootnav:logic"))
implementation(project(":subtrees:registration:logic"))
implementation(project(":subtrees:introduceyourself:logic"))
implementation(project(":subtrees:onboarding:logic"))
implementation(project(":subtrees:accept:logic"))
implementation(project(":subtrees:enterinvitecode:logic"))
implementation(project(":subtrees:profile:logic"))
implementation(project(":subtrees:shared:logic"))
implementation(project(":subtrees:capture:logic"))
implementation(project(":subtrees:invitecode:logic"))
implementation(project(":subtrees:priming:logic"))
implementation(project(":subtrees:sharepicker:contract"))
implementation(project(":subtrees:sharepicker:logic"))
// …theming, services, telemetry
}
```
## What is in the Android app module?
`app/` holds the three kinds of code that are platform work by nature:
* **The Android edge.** One activity. It owns exactly what only an activity can: the splash window, intent ingress (deep links, notification taps), saved-instance `Bundle` I/O, and the handle to a retained component tree that survives rotation. Its iOS counterpart is the scene component in the iPhone app target — the same thin glue, owning only what the platform's entry object must. Memory Lane's `MainActivity`, trimmed to its structure:
```kotlin MainActivity.kt theme={null}
class MainActivity : ComponentActivity() {
private lateinit var retained: RetainedRoot
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Rotation recreates the Activity; getOrCreate returns the same tree.
val restoredSpineJson = savedInstanceState?.getString(SPINE_KEY)
retained =
instanceKeeper().getOrCreate {
RetainedRoot(Dispatchers.Main.immediate, RootBuilder::teardown) { scope ->
RootBuilder(
restoredSpineJson = restoredSpineJson,
composition = RootComponent(ActivityRootDependency(applicationContext, scope)),
scope = scope,
)
}
}
// URL taps on a fresh launch only: recreation re-delivers the original
// intent, and a stale invite link must not re-enter the accept flow.
if (savedInstanceState == null) {
intent?.deepLinkUrl()?.let(retained.component::onUrl)
}
setContent {
MemoryLaneTheme {
AppRoot(retained.component.shell)
}
}
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
// Navigation is state, so the route spine rides the Bundle like any value.
retained.component.captureSpineJson()?.let { outState.putString(SPINE_KEY, it) }
}
}
```
* **Builders and shells.** One builder per mounted feature (`TimelineBuilder.kt`, `CaptureBuilder.kt`, …) constructs the feature's store and its Compose [shell](/articles/duet-glossary#shell) — the screen that reads state and sends actions. Navigation hosts like `MainNavHost.kt` bind each shared navigation *kind* to a Material renderer. The iOS counterparts are the SwiftUI builders and shells in the iPhone app — `MainNavHost.kt`'s twin is `MainNavShell.swift`, binding the same kinds to sheets and stacks; [Handling platform-specific UI](/articles/cross-platform-ui-parity) shows the two tables side by side.
* **Workers.** Everything that touches the world — repositories over the backend, push messaging, media capture, image downscaling — implements a port the reducers name as effects ([worker](/articles/duet-glossary#worker) in the glossary). Memory Lane's `workers/` directory is Firebase clients, `MediaRecorder` sessions and notification plumbing: ordinary Android code behind interfaces the shared core defines. The iOS twin implements the same ports in Swift — AVFoundation sessions and Apple push plumbing behind the identical interfaces.
The app module contains no feature logic. A saving rule, a permission latch, a navigation decision — each lives once, in its `commonMain` module, where the iPhone app compiles the identical code. When Memory Lane's sixteen Android screens were written against the already-recorded core, zero logic files changed.
## What does day one look like, before any of this grows?
The tree above is a shipped app's grown state. On the day the scaffold runs, the same shape is already there at minimal size: one sample feature module, an app module whose root mounts that single feature ([mount](/articles/duet-glossary#mount) in the glossary), one worker file, the theming and telemetry modules, and a CI workflow that records and verifies on the first push. Growth is additive — each new feature is a new `subtrees/` module and a new builder, and the mount harness gives way to a real navigation root when the second screen arrives. The structure Memory Lane ships with is the structure the scaffold created, sixteen features later.
## What about widgets and background work?
Home-screen widgets are ordinary Android surfaces in the app module: Memory Lane ships a feed widget and a quick-capture widget that render *projections* of shared state — plain values a sync worker writes — rather than mounting reducers of their own. The iOS twin ships its widgets the same way, as a WidgetKit extension beside the app target. Background work follows the platform's rules described in [How Android apps work](/articles/how-android-apps-work#what-runs-in-the-background): push messages arrive through a service, deferred work goes through scheduled workers, and each of them enters the shared core the same way the UI does — by sending actions.
## Common questions
Structurally yes — it is Gradle, `commonMain` modules and a Kotlin/Native framework, with nothing proprietary in the tree. What the scaffold fixes on top of raw KMP is the feature shape (state, actions, effects, one reducer per module), the recorded fixtures as the behavior contract, and the CI gate that replays them on both platforms. Those conventions are the part a from-scratch setup has to invent and enforce.
Yes. `src-kmp/` is a standard Gradle build: Android Studio opens it, indexes it, runs the app and the JVM tests. The iPhone half opens in Xcode from the same repository.
Under `src-ios/`, as an ordinary Xcode project. It consumes the identical compiled feature modules through the `apple-umbrella` Kotlin/Native framework — there is no Swift re-implementation of any reducer in the repository. [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) describes how that framework's collector and the app's Swift code share objects.
Because navigation is feature state, not activity plumbing. Which screen is showing, which sheet is up, what the back stack holds — each is a value a reducer owns, so it is recorded and replayed like any other behavior, and the activity's job shrinks to hosting the Compose tree and persisting the route spine so a system-killed process comes back on the same screen.
Measured on Memory Lane: about 3 MB for the Kotlin Multiplatform core in the Android app. The iPhone app carries the Kotlin/Native framework as its equivalent cost.
[Modaal](https://modaal.dev) scaffolds this tree for you — say you're building a **Production app** for **Android + iOS** in the sentence on the home screen.
## Sources and further reading
* [Kotlin Multiplatform](https://kotlinlang.org/docs/multiplatform.html) — the language feature the shared core builds on
* [Guide to app architecture](https://developer.android.com/topic/architecture) — Google's recommendation the feature shape aligns with: unidirectional data flow, state as data
* [Guide to Android app modularization](https://developer.android.com/topic/modularization) — the per-feature Gradle module pattern in general form
* [Save UI states](https://developer.android.com/topic/libraries/architecture/saving-states) — the saved-state contract the route spine rides
* [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the toolchain and the versioned contracts
## Read next
The same repository's iPhone side: the Kit package over the Kotlin framework, the Main package, theming and the app target.
The standard anatomy this page builds on: the manifest, activities, Compose and the Gradle build.
What Duet fixes in place that a blank Android project leaves open, and what that buys in tests.
What Duet is, the two combinations that scaffold it, and how a feature gets built.
Every term on this page — reducer, shell, worker, mount, fixture — defined with one link each.
This tree built by hand: the first Compose shell, host and Activity in Tutorial 2, the workers in Tutorial 4, the back policy and the route restore in Tutorial 5.
# Duet Glossary: Every Term in the Cross-Platform Framework
Source: https://docs.modaal.dev/articles/duet-glossary
Every Duet term defined in one place — feature, reducer, behavior recording, the checks, the coexistence window — and the name the tools use for each.
This glossary defines every term used by **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps: the parts of a feature, the recording-and-checking vocabulary, the steps of a migration, and the work items a migration is planned in. Each entry is one paragraph with a stable anchor and a link to the page that treats the subject in full; where one of the [nine Duet tutorials](/tutorials/duet) builds the thing by hand, the entry names that page too.
The entry heading is the name the documentation uses. Where the tools, the parity manifest or the framework's own pages use a different name for the same thing — *fixture* for behavior recording, *gate* for check, *ledger* for tracker, *oracle* for reference test — the entry states both names.
## The parts of a feature
### feature
A unit of behavior declared as state, actions, effect payloads and a pure reducer, carried as one row in [the parity manifest](#manifest) and checked by its own [behavior recordings](#fixture). See [how a feature gets built](/articles/duet#how-a-feature-gets-built).
### reducer
Where every decision happens: for each action the [Store](#store) feeds it, the reducer alone runs the guards, writes state and returns effects. It is pure — no platform calls, no minted time or identity — so its behavior [replays](#replay) byte-for-byte on both platforms.
### effect / effect payload
What a [reducer](#reducer) returns beside the new state: a value naming work the platform must do, such as a wait on a clock, a port call or a delegate notification, never the work itself. [The effect handler](#effect-handler) turns each payload into the actions it produces, and an effect id gives it cancel-in-flight semantics. Because an effect is data, a [behavior recording](#fixture) pins it byte for byte. [Tutorial 1](/tutorials/duet-01-first-feature#add-the-environment-and-the-runtime) writes the first two; [Tutorial 9](/tutorials/duet-09-analytics#emit-them-as-track-effects) adds an analytics event as one more.
### the effect handler
The module's only impure code: it takes each [effect payload](#effect) the [reducer](#reducer) returned and turns it into the stream of actions that payload produces, doing the work through [the environment](#environment). It receives a reducer's effects in the order the reducer returned them, and that order is part of what a [behavior recording](#fixture) pins. [Tutorial 1](/tutorials/duet-01-first-feature#add-the-environment-and-the-runtime) writes the first one, beside the store factory both shells later call.
### the environment
The interface of seams a feature's work goes through — the clock, identity, storage, the feature's [ports](#port) — held by [the effect handler](#effect-handler) and consulted nowhere else; the [reducer](#reducer) never sees it, which is what lets a recording replay without one. A feature with no seam has no environment, and that is a valid shape. Its mechanical test double is generated rather than hand-written; a hand-written fake enters only where the double needs behavior of its own, such as driving a port's streams. [Tutorial 1](/tutorials/duet-01-first-feature#add-the-environment-and-the-runtime) declares the first environment against a hand-written double; [Tutorial 3](/tutorials/duet-03-composing-features#generate-the-kotlin-test-doubles) switches to generated ones.
### Store
Built once per [mount](#mount): it holds the feature's state, takes actions, runs the [reducer](#reducer), and hands returned effects to [the effect handler](#effect-handler). Nothing else runs feature logic. [Tutorial 1](/tutorials/duet-01-first-feature#add-the-environment-and-the-runtime) builds the first one and drives it through the kernel's exhaustive [test store](#test-store).
### kernel
The runtime every [Store](#store) is built on: it runs each action through the reducer, tracks returned effects in flight, and owns the effect-leak check at teardown. One kernel shape serves both platforms; its contract ships with [the framework](https://github.com/modaal-agent/duet).
### shell
What stays on the platform side once logic has moved: three duties and no decisions — bind the view's intents to the store, project state into the view, bracket the lifecycle. See [what crossing one feature looks like](/articles/combineribs-to-duet#what-crossing-one-feature-looks-like); [Tutorial 2](/tutorials/duet-02-two-apps#write-the-swift-shell) writes the first Swift and Compose shells.
### host / host root
The level that [mounts](#mount) a feature: it builds the store, owns the slot the feature renders in, and takes its [delegate events](#delegate) as data. In the Swift flavor a *host root* is the package whose resolved dependencies must contain the Duet family and nothing else. [Tutorial 3](/tutorials/duet-03-composing-features#compose-the-tree-on-android) mounts a tree of features from state, one level hosting the next.
### delegate events / the delegate seam
How a feature reports an outcome upward: the reducer emits a plain enum case, and the [host](#host) receives it as data on the seam it handed in at build time. The event is a value, so a [scenario](#scenario) asserts it byte-for-byte. [Tutorial 3](/tutorials/duet-03-composing-features#write-the-root-level) receives each child's delegate events as one case of the parent's action type.
### Interactor
The CombineRIBs type that owns a RIB's business logic. In a migration every Interactor shrinks to a [shell](#shell) and is then deleted. See [what happens to each piece of your code](/articles/combineribs-to-duet#what-happens-to-each-piece-of-your-code).
### worker
Stateful, lifecycle-bound processing on the environment side, adopted at [mount](#mount): its results re-enter feature state only through declared seams, and cancellation is its stop. Workers process, reducers decide; a worker has no [golden recording](#golden-fixture). [Tutorial 4](/tutorials/duet-04-workers#write-the-workers-on-android) writes the first two and tests them with [the worker harness](#worker-harness).
### the shared core
The one place a feature's logic lives in the Kotlin Multiplatform flavor: Kotlin `commonMain` modules under `src-kmp/`, one subtree module per [feature](#feature). Android compiles it directly, and iOS consumes the same compiled logic as one prebuilt framework Gradle assembles, so the [reducer](#reducer) the iOS app runs is the one the Kotlin lane checked rather than a second copy of it. That framework links the Kotlin/Native garbage collector into the iOS app beside Swift's ARC; [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) covers who frees what. [Wiring the core](#wiring-the-core) is the migration step that links it into an existing iOS build, and [single-source](#single-source) is the shape it exists to produce. [Tutorial 2](/tutorials/duet-02-two-apps#declare-the-apple-framework-module) builds the first one into an Apple framework and [replays the recordings across it](/tutorials/duet-02-two-apps#replay-the-recordings-across-the-boundary).
### package
In the Swift flavor a feature's subtree holds two Swift packages: the gated feature half resolves the Duet family and nothing else, so the checks build it in isolation; the app-facing node half is where the app connects.
### port
An interface the shared logic calls and each app implements, such as the auth, purchases, items and account ports in the tutorials' app. Every operation starts work and returns, with the answer arriving through a callback that fires once, so a Swift class can implement it across the framework boundary; a stream on a port is a sticky value a [worker](#worker) observes. [Tutorial 3](/tutorials/duet-03-composing-features#declare-the-four-ports) declares the four ports behind mock services; [Tutorial 4](/tutorials/duet-04-workers#add-the-two-streams-to-the-ports) adds the streams and replaces the mocks with an on-device backend.
### slice
A value a parent publishes downward out of its own state, delivered to a child as an action of the child's own: the child reads it and never writes it, and nothing below the parent holds the parent's [Store](#store). [Tutorial 4](/tutorials/duet-04-workers#project-the-entitlement-down-as-a-slice) projects the entitlement to two tabs as a slice.
### lateral state
A value that must cross between sibling features, neither of which holds the other's store: one [worker](#worker) owned by their lowest common ancestor, with a void update on one side and a sticky observation on the other. [Tutorial 5](/tutorials/duet-05-navigation-as-state#share-readiness-laterally) builds one for the onboarding steps' readiness.
### `@MainActor`
The escalation a handler or worker takes to cross an isolation boundary. Marking a type unchecked-`Sendable` instead silences the compiler without proving anything, which is why [the doctor pass](#doctor) flags it on a worker.
### the fixture-authoring language
The four verbs a [scenario](#scenario) is written in — given, when, then, then-effects — with a Swift and a Kotlin spelling. The framework's pages call it the scenario DSL.
## Recording and checking
### the parity manifest
`parity/manifest.yaml` at the repository root: one row per [feature](#feature) naming the file that implements it, its state, action and effect-payload types, the [scenario](#scenario) its recordings compile from, and the [recordings](#fixture) it owns, plus the generator rows for the tree's generated doubles and the divergence waivers [the parity contract keeps](/articles/cross-platform-ui-parity#divergence-is-ledgered-not-forbidden). The file's presence is what marks a tree as a Duet tree, and its header comment names the flavor. Every check is derived from it and nothing is guessed at: [the lanes](#lane), [which platform lanes run](#swift-and-kotlin-lanes), and which recordings must report a [replay](#replay). [Tutorial 1](/tutorials/duet-01-first-feature#declare-the-feature-in-the-manifest-and-write-its-spec) declares the first feature in it; [Tutorial 6](/tutorials/duet-06-checks-in-ci#read-the-lanes-the-manifest-derives) reads the lanes it derives.
### the feature spec
One page per [feature](#feature) in prose, under `parity/feature-specs/`, in eight fixed sections: identity and config, state, actions, transitions, effects, delegate events, what stays app-side, and the recordings. It is where a behavior change is written first, before the [scenario](#scenario) that makes it executable. The tool checks that every recording [the manifest](#manifest) lists is named in the spec in backticks, so the prose cannot drift from the recordings without a red check; where spec and recording disagree about behavior, [the recording wins](#fixture). [Tutorial 1](/tutorials/duet-01-first-feature#declare-the-feature-in-the-manifest-and-write-its-spec) writes the first one, and [the readiness audit](#audit) drafts one per feature from a legacy app.
### scenario
One test in [the fixture-authoring language](#dsl), pinning one outcome. Mutually exclusive endings are branches over one starting state; a chain scenario pins a [delegate seam](#delegate) that crosses features. [Tutorial 1](/tutorials/duet-01-first-feature#describe-the-behavior-as-a-scenario-and-record-it) writes the first scenario and records it.
### behavior recording
What recording a [scenario](#scenario) produces: a checked-in file replaying the scenario's exact state and effect bytes — a build product, never hand-edited, and it wins any disagreement with [the feature spec](#feature-spec)'s prose. The tools, the manifest and the framework's pages call these files fixtures.
### canonical bytes
The one JSON dialect both platforms encode to, so a [recording](#fixture) can be compared as text rather than as two language-specific object graphs: keys sorted at every nesting level, no insignificant whitespace, a nil-valued key omitted rather than written as `null`, dates as ISO-8601 UTC with exactly three fractional digits, UUIDs lowercased, and a sum type written as an explicit case-and-value pair instead of either language's default encoding. Both sides of every comparison are re-encoded through the same writer before they are compared, so a committed file may be pretty-printed for a reader without changing what it pins. Floating-point values are not allowed in recorded types, because no cross-platform representation rule is pinned for them yet. The framework's pages call this canonical serialization.
### replay
What the checks do with a committed [behavior recording](#fixture): seed the recording's initial state, send each recorded action through the reducer, compare the [canonical](#canonical) state and effect bytes against the recording's expectations, and stop at the first step that differs, so a red names the recording and the step. The same files replay in three places — a feature's [platform lanes](#swift-and-kotlin-lanes), where the [golden test](#golden-fixture) reads the committed files at runtime; the Apple boundary lane, which replays them across the framework the iOS app consumes; and `duet protocol-run`, which drives them into a replay-runner binary over the framework's replay protocol. Replay checks the reducer against the recorded bytes; [re-recording](#re-recording) in check mode runs the opposite direction, checking the recorded bytes against the scenario. [Tutorial 1](/tutorials/duet-01-first-feature#replay-the-recordings) writes the golden test and [adds the replay runner](/tutorials/duet-01-first-feature#add-the-replay-runner); [Tutorial 2](/tutorials/duet-02-two-apps#replay-the-recordings-across-the-boundary) replays the same four recordings across the boundary; [Tutorial 6](/tutorials/duet-06-checks-in-ci#know-what-each-command-proves) lists which command replays what.
### golden recording
A [behavior recording](#fixture) in its [replay](#replay) role: the golden test reads committed recordings at runtime and fails on any byte that differs — a role, not a second kind of file. A [worker](#worker) has no reducer to replay, so it has no golden.
### chain recording
A [behavior recording](#fixture) that pins the seam between two features rather than one reducer: the delegate event one reducer emits, received by the next as its action, with each hop re-derived at verify time from the recorded bytes so an edited seam fails as structure drift. [Tutorial 3](/tutorials/duet-03-composing-features#pin-the-seams-with-chain-recordings) records the first four.
### re-recording
Running the recorder again after a deliberate behavior change, then reading the recording diff like code. The run deletes before it harvests, so a leftover file means a stale run.
### metadata-only
The pass condition for a re-record that must not change behavior: the diff touches scenario source locations and step labels, and zero state or effect bytes.
### the test store
The exhaustive harness for what a [recording](#fixture) does not cover: it drives the real [Store](#store), [effect handler](#effect-handler) and [environment](#environment) together on a virtual clock, so timing and effect lifetimes are testable without waiting. Every state change must be asserted and every effect expected — an unreceived action or an effect still running fails at `finish()`. [Tutorial 1](/tutorials/duet-01-first-feature#pin-the-safety-nets-timing-on-a-test-clock) pins the splash's one piece of timing on it.
### the worker harness
The only sanctioned test bracket for a [worker](#worker), and what stands in for the [golden recording](#golden-fixture) a worker cannot have: start the worker, drive the streams it observes, cancel it, and `finish()` — which fails when the worker's run has not returned after cancellation, the leak a hand-rolled start-then-cancel bracket does not catch. [Tutorial 4](/tutorials/duet-04-workers#test-the-workers-with-the-harness) tests the first two workers with it.
### mutation drill
The negative control on [the checks](#gate): break a reducer guard and watch the replay go red, then break a recording file and watch the test task go red — proof the checks are wired to what they claim to cover. Run in [a crossing's first half](/articles/combineribs-to-duet#what-crossing-one-feature-looks-like); [Tutorial 6](/tutorials/duet-06-checks-in-ci#write-the-first-mutation-rows) runs it from a mutation table in both directions.
### the checks
What must pass before work is done, run by one command locally and in CI: [the manifest lint](#duet-lint), the recording check, the cross-platform verification and [the doctor pass](#doctor). The framework's pages call each one a gate. [Tutorial 6](/tutorials/duet-06-checks-in-ci#know-what-each-command-proves) lists every check with what it proves and [puts them in one CI workflow](/tutorials/duet-06-checks-in-ci#put-the-checks-in-a-workflow).
### lane
One slice of a [checks](#gate) run, rooted at a specific package or module set, so a red names its platform and its root. Parity lanes derive from a feature's [manifest](#manifest) row; generation lanes produce the test doubles and DI components. [Tutorial 6](/tutorials/duet-06-checks-in-ci#read-the-lanes-the-manifest-derives) reads the lanes report the tool derives.
### the Swift and Kotlin lanes
A [manifest](#manifest) row's two implementation declarations; which of them a row carries tells [the checks](#gate) which platform lanes to run — one during ordinary work, both inside a [coexistence window](#coexistence-window).
### the manifest lint
[The manifest](#manifest)'s own consistency checks — the fast red, run on every manifest edit. The declaration-parity and subtree-geometry checks join only while a row declares both implementations.
### the doctor pass
The check that reads the tree rather than [the manifest](#manifest): it confirms the project's own declaration file still matches the tree it describes, and flags the shortcuts the byte checks cannot see — a [worker](#worker) stamped unchecked-`Sendable` to silence the compiler instead of [taking the escalation](#main-actor). A red names the declaration row, or the worker. [Tutorial 6](/tutorials/duet-06-checks-in-ci#know-what-each-command-proves) lists it beside the other nine commands with what each one proves.
### the headless test runner
A script every scaffolded project ships that runs the iOS test suite from the command line, wrapping Apple's xcodebuild — for verifying outside the Modaal app and reproducing CI failures locally.
### the run verb
The build-and-launch action in a Modaal session: one command that builds the selected target and launches it on a simulator, emulator or device. The action's name differs between session types, so Duet's documents say "the run verb".
## The migration, step by step
### the readiness audit
A static census of a legacy app's distance to the target shape, producing the readiness report, [the migration tracker](#migration-ledger) and a [feature spec](#feature-spec) draft per feature. The counts are pointers to verify, not verdicts. See [the stages](/articles/combineribs-to-duet#the-stages), step 1.
### the migration tracker
One row per [surface](#surface): a derived status, four sizing columns, and the notes the per-feature plan is written into. The tools and the migration article call it the ledger — distinct from the divergence waivers [the parity contract keeps](/articles/cross-platform-ui-parity#divergence-is-ledgered-not-forbidden).
### updating the tracker
Re-running [the readiness audit](#audit) in write mode: it creates or merges the tracker and refreshes the computed fields. It is the route's first step after [the stop-digging edit](#stop-digging-rule).
### the graft
The one operation that adds [the shared core](#core)'s Kotlin plane, the Android app shell and the parity workspace beside an existing tree while editing none of its sources — the checks exist before any behavior can change. See [the stages](/articles/combineribs-to-duet#the-stages), step 2.
### wiring the core
Run once the graft's checks are green: it links [the assembled shared core](#core) into the iOS app's build, so every later [flip](#the-flip) is feature work with no build wiring left to add. See [the stages](/articles/combineribs-to-duet#the-stages), step 2b.
### feature crossing
One feature's move onto [the shared core](#core), end to end: [spec](#feature-spec), Swift [twin](#twin), Kotlin twin, the Android [mount](#mount), the [flip](#the-flip). See [what crossing one feature looks like](/articles/combineribs-to-duet#what-crossing-one-feature-looks-like).
### the coexistence window
The stretch of a crossing in which a feature's [manifest](#manifest) row declares both implementations: both [twins](#twin) replay the same recordings and the parity lints run. A valid resting state for as long as the repository wants.
### the flip
The one change that closes a [coexistence window](#coexistence-window): delete the Swift twin, make the feature's [manifest](#manifest) row Kotlin-only, verify both platforms.
### twin
One of a feature's two implementations while its [coexistence window](#coexistence-window) is open — the Swift twin inside the app, the Kotlin twin in [the shared core](#core) — replaying the same [behavior recordings](#fixture). In a [Duet Twins](/articles/duet-twins) project, in development, both twins are permanent.
### single-source
The destination shape: one implementation of a feature's logic, in [the shared core](#core), consumed by both apps. The Swift flavor's *single-core* is the same one-copy rule before a second platform consumes it. See [the two Duet templates](/articles/duet#the-two-duet-templates).
## The work items and the field kit
### surface
A [tracker](#migration-ledger) row's unit of work: one RIB or one legacy worker in the app being converted, typed by the row's kind.
### reference test
The existing test a conversion is written against, ported one-to-one onto the deterministic test store or the worker test harness. A surface without one earns its reference test first; the audit's sizing table and the tracker call it the oracle.
### look-and-feel work
The part of a [surface](#surface) that does not move: tuned rendering, animation timing, gesture handling — re-authored on the other platform and sized by counting files. The audit calls it platform-manner mass; [manner](/articles/cross-platform-ui-parity#the-seam-a-five-layer-presentation-contract) is the contract layer it lives in.
### the note format
The shape every [tracker](#migration-ledger) note takes: a capitalized tag, an em dash, then clauses following the surface's kind — a RIB's note carries a crossing position; a worker's names the crossing it rides with.
### the stop-digging rule
From step 0 on, a new feature in the legacy app is authored in the target shape — a reducer plus a thin shell, never a new Router — and new background work is a Duet [worker](#worker) from day one. See [the stages](/articles/combineribs-to-duet#the-stages), step 0.
### adoption seam
Where a converted feature's SwiftUI view attaches to the legacy UIKit composition: a themed hosting controller takes the legacy child's slot, and activating the [shell](#shell) replaces attaching the child.
### coexistence kit
The migration package that stands for the migration's duration — the worker shim and the dual-import discipline — and whose dependency edge is deleted at completion. See [the finish line](/articles/combineribs-to-duet#the-finish-line).
### stage host
What the Android app's root grows into when the app needs real navigation: one full-screen child chosen from route state, transitions as reducer edges, back handling in a documented order. The first [crossing](#feature-crossing) that needs a screen change grows it.
### mount
Putting a feature into the app's composition: its [Store](#store) is built once at the mounting level, its screen renders from the mount row, and that level's own [workers](#worker) are adopted there.
### spine
The app's navigation trunk, in two uses: the route state that survives process death, and the root and navigation surfaces that cross last, after the leaves. [Tutorial 5](/tutorials/duet-05-navigation-as-state#gather-the-route-spine-and-restore-from-it) gathers a route spine and rebuilds the tree from it.
## Common questions
They are the same file. The headings here use the documentation's names; the tools, the parity manifest and the framework's pages use the framework-side names — fixture, gate, ledger, oracle — and each entry states both: [behavior recording](#fixture), [the checks](#gate), [the migration tracker](#migration-ledger), [reference test](#oracle).
As long as the repository wants — [the window](#coexistence-window) is a valid resting state, and the parity lints keep both twins honest while it is open. Holding one open costs the dual-language authoring premium the [migration article](/articles/combineribs-to-duet#what-crossing-one-feature-looks-like) quantifies; [the flip](#the-flip) closes it.
Three articles carry the depth: [the Duet overview](/articles/duet) for the feature shape and the two templates, [handling platform-specific UI](/articles/cross-platform-ui-parity) for the presentation contract, and [migrating a CombineRIBs app](/articles/combineribs-to-duet) for the route. The framework's own reference lives in [the Duet repository](https://github.com/modaal-agent/duet). The [nine Duet tutorials](/tutorials/duet) build each feature-level term by hand, and the [series index](/tutorials/duet#what-does-the-series-teach) maps every concept to the step that treats it.
In [Modaal](https://modaal.dev), say you're building a **Production app** — for **Android + iOS** or for **iOS** — in the sentence on the home screen. [Starting a Duet project](/articles/duet#starting-a-duet-project) walks through the choice.
## Sources and further reading
* [The Duet framework repository](https://github.com/modaal-agent/duet) — the framework API, the emitted contracts and the family's own documentation.
* [Kotlin Multiplatform documentation](https://kotlinlang.org/docs/multiplatform.html) — the technology under the Kotlin Multiplatform flavor's shared core.
* [Swift concurrency documentation](https://docs.swift.org/swift-book/documentation/the-swift-programming-language/concurrency/) — the isolation model behind the [`@MainActor`](#main-actor) entry.
## Read next
The product overview: what a Duet project is, the two templates, and how a feature gets built.
The five-layer presentation contract: kinds, manner, islands, and how deliberate divergence is recorded.
The route the migration cluster's entries walk: audit, graft, crossings, the finish line.
Feature, reducer, shell, worker, scenario, recording and the checks, each built by hand across nine tutorials.
# The Structure of a Kotlin Multiplatform iPhone App
Source: https://docs.modaal.dev/articles/duet-ios-app-anatomy
A Duet iPhone app is a SwiftUI app over a prebuilt Kotlin core: a Kit package that links it, a Main package that composes the app, and a thin app target.
A Duet-built iPhone app on the Kotlin Multiplatform flavor is a SwiftUI app over a prebuilt Kotlin core. The feature logic runs inside one XCFramework that Gradle assembles from the shared Kotlin modules. The Swift code around it is split into Swift packages: a **Kit** package that links the framework and adapts each Kotlin store for SwiftUI, a **Main** package that composes the app, two theming packages, and an app target that holds only what the operating system's entry objects must. **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, scaffolds this shape. This page walks the `src-ios/` tree; [Inside a Duet Android app](/articles/duet-android-app-anatomy) walks the same repository's Android side.
The worked example is **[Memory Lane](https://memorylaneapp.lovable.app/)**, live on the [App Store](https://apps.apple.com/us/app/memory-lane-private-journal/id6760589051), the same app the Android page walks. Its package names begin with the project's name; this page writes that prefix as ``, the placeholder the scaffold fills with your app's name. Counts come from Memory Lane's repository.
## What does the tree look like?
The iPhone-relevant parts of Memory Lane's tree:
```text theme={null}
src-kmp/
└── apple-umbrella/ # builds the Kotlin core as Kit.xcframework
src-ios/
├── App/
│ ├── xcodegen.yml # the Xcode project spec; the .xcodeproj is generated
│ ├── xcconfig/ # Debug, AdHoc and AppStore build settings
│ ├── / # the app target: AppDelegate, SceneDelegate, SceneComponent
│ └── Widgets/ # a WidgetKit extension
├── Libraries/
│ ├── Kit/ # the binary target over the XCFramework, and the bridge
│ ├── Main/ # the composition root, shells, views, workers
│ ├── Theming/ # the theme and its design tokens
│ ├── WidgetShared/ # data the app and the widget extension share
│ └── MemoryLaneConnector/ # a generated backend client
└── Subtrees/
└── Priming/PrimingNode/ # one feature's iOS shell as its own package
scripts/
└── assemble_kit.sh # assembles the core; every iOS build runs it first
parity/fixtures/ # the recordings the Kotlin and Swift lanes replay
```
The code under `src-ios/` is Swift. The iPhone app contains no copy of a reducer: the feature logic is compiled once from `src-kmp/` and linked as the XCFramework. Memory Lane's `src-ios/` holds 250 Swift files outside generated directories, 208 of them in the Main package.
## What is in the Kit package?
`src-ios/Libraries/Kit/` is where Swift meets the Kotlin core. Its manifest declares the framework as a binary target at the path `assemble_kit.sh` publishes to, and a bridge target that every shell depends on:
```swift src-ios/Libraries/Kit/Package.swift (as the scaffold emits it) theme={null}
.binaryTarget(
name: "Kit",
path: "../../../src-kmp/apple-umbrella/build/XCFrameworks/app/Kit.xcframework"
),
.target(
name: "Bridge",
dependencies: [
.target(name: "Kit"),
.product(name: "DuetShells", package: "duet"),
],
linkerSettings: [
.linkedLibrary("c++"),
.linkedFramework("Foundation"),
]
),
```
* **The binary target** is a build product of the project's own Kotlin sources. `build/` is not committed, so a fresh clone has no framework until the first assemble; [How do you build it?](#how-do-you-build-it) covers when that happens.
* **The bridge** holds `BridgedStore`, the one class every shell uses to hold a Kotlin store: a `@Published` mirror of the store's state, a synchronous `send`, and a `cancel()` that stops the Kotlin runtime. [Tutorial 2](/tutorials/duet-02-two-apps#write-the-consumer-package-and-the-store-mirror) writes it.
* **The shells.** The scaffold emits each feature's iOS side into this package as a `Shell` target: a Builder that makes the Kotlin store and the bridged mirror, a `ViewShell` that turns intents into actions and state into view state, the SwiftUI view, and a spec target that tests the shell. Memory Lane keeps its 14 shell directories in the Main package's `Shells/` instead; both layouts depend on the same bridge.
The Kotlin/Native collector frees a Kotlin object that Swift holds, on a collection after the last Swift reference is released. [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) covers what that framework links into the app and who frees what.
## What is in the Main package?
`src-ios/Libraries/Main/` composes the app. It depends on the Kit package, the two theming packages and the Duet shells and services packages, and it holds:
* **The composition root.** `RootBuilder` builds the root level: a `RootComponent` that owns the app-lifetime services (diagnostics, analytics, the inbound URL and push registry), adopts them as workers in a `StoreHost`, and then mounts the features. Composition is a Dependency, a Component and a Builder per level, the same three parts the Android app uses ([Tutorial 3](/tutorials/duet-03-composing-features)).
* **Mounts.** The scaffold writes one `Features/Mount.swift` per feature: a function that calls the feature's Builder, wraps the view in a `MountedFeature`, and gives the scene the mount's `activate` and `deactivate` bracket. A child's delegate events are routed in that file's `onDelegate` closure.
* **The root screen.** `RootScreen` renders the mounted features. As emitted it shows one feature full-screen, with a tab strip once there are two; the app's real navigation replaces it as the product grows.
* **Workers.** Everything that touches the world, implemented in Swift behind the ports the Kotlin reducers name as effects. Memory Lane's `Workers/` has 21 of them: repository workers over its backend, push notifications, media storage, image downscaling, video transcoding, widget sync.
The Android counterparts of these parts are the builders, `MainNavHost.kt` and `workers/` in the Android app module.
## Where do the theme and the controls live?
* **`Libraries/Theming/`** holds the app's theme. Its semantic colors, fonts and gradients are generated by `duet design-tokens` from `parity/design-tokens.yaml`, the file the Android theme is generated from too, so both apps read the same token names. A hand edit to a generated file fails `duet design-tokens --check`.
* **`Libraries/ThemedControls/`** holds the shared controls built on the theme. The scaffold emits `AppButton`, the one action-button style every screen uses, with primary, secondary and destructive roles. Memory Lane keeps its button, `MLButton`, in the Theming package.
[Tutorial 7](/tutorials/duet-07-theming) builds the tokens and the generated code.
## What is in the app target?
`src-ios/App//` holds what only the app's entry objects can own:
* **`SceneDelegate`** builds the root with `RootBuilder(dependency: SceneComponent()).build()`, puts `RootScreen` in a `UIHostingController` inside the theme's scope, calls `activate()`, and calls `teardown()` when the scene disconnects. In Memory Lane it also forwards every opened URL to the app-services registry the root returned.
* **`SceneComponent`** is the root level's Dependency: the objects only the scene can supply. As emitted it is empty.
* **`AppDelegate`** carries process-level duties; in Memory Lane, the launch services and the push-notification device token.
The Xcode project is generated: `xcodegen.yml` is the source, and the `.xcodeproj` is a build product. The spec gives the app scheme two build pre-actions, which run before Xcode plans the build:
1. **Increment `BUILD_NUMBER`**, for builds other than Debug.
2. **Assemble the Kotlin core**: `scripts/assemble_kit.sh debug` for a Debug build and `release` otherwise, so the build links the framework that contains the latest Kotlin edit.
The scaffold emits Debug and Release configurations. Memory Lane builds with three, Debug, AdHoc and AppStore, each with its own file in `xcconfig/` over a shared `Base.xcconfig`, and adds a WidgetKit extension, `Widgets`, that reads what the app writes through the `WidgetShared` package's App Group storage.
## How do you build it?
Open the generated project in Xcode, or let Modaal build and run it. Every build through the app scheme runs the assemble pre-action first. The first assemble on a machine downloads the Kotlin/Native toolchain and takes several minutes; later ones take seconds. A fresh clone needs one assemble before Xcode opens the project, because Swift package resolution reads the binary target before any pre-action runs:
```sh theme={null}
scripts/assemble_kit.sh debug
(cd src-ios/App && xcodegen generate --spec xcodegen.yml)
open src-ios/App/.xcodeproj
```
Build simulator targets with `ARCHS=arm64` on the `xcodebuild` command line: the framework ships arm64 slices only, and a generic simulator build also compiles x86\_64. A concrete simulator destination is arm64 already.
## What does day one look like?
On the day the scaffold runs, the same shape is there at minimal size:
* `Kit` with the binary target and `Bridge`;
* `Main` with `RootComposition.swift` (the root's Dependency, Component and Builder), `RootScreen.swift`, the analytics and ingress workers, and a test target;
* `Theming` and `ThemedControls`;
* the app target with its three entry files and the scheme's pre-actions.
Each feature the scaffold adds is one `Shell` target in the Kit package, with its spec target, and one `Features/Mount.swift` in the Main package. The Kotlin side of the same feature is a module under `src-kmp/subtrees/`, and its recordings are under `parity/fixtures/`.
## Common questions
A Swift package builds and tests without the app. `swift test` in the Kit package runs every shell's spec against the linked Kotlin core, and the Main package has its own test target for the composition root. The app target stays three files; the shells, the composition root and the workers are in packages with test targets.
In the Kotlin modules under `src-kmp/subtrees/`, compiled into the XCFramework. The Swift side sends actions, renders state and performs effects through workers; every decision a recording pins is made in a Kotlin reducer. [Inside a Duet Android app](/articles/duet-android-app-anatomy#where-does-feature-logic-live) shows a feature module.
Yes. The XCFramework links the Kotlin/Native runtime, including its collector, into the app executable. [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) measures its pauses and states the rules for Swift code that holds Kotlin objects.
Edit `src-ios/App/xcodegen.yml` and regenerate. The project file is generated from it, so an edit made in Xcode's project editor is lost at the next `xcodegen generate`.
On the Swift flavor the feature logic is Swift: each feature is a Swift package under `src-ios/Subtrees/`, with no binary target and no Kotlin/Native runtime. [The Duet overview](/articles/duet#the-two-duet-templates) compares the two flavors.
[Modaal](https://modaal.dev) scaffolds this tree for you — say you're building a **Production app** for **Android + iOS** in the sentence on the home screen.
## Sources and further reading
* [Kotlin Multiplatform: build final native binaries](https://kotlinlang.org/docs/multiplatform-build-native-binaries.html): `binaries.framework` and `XCFramework`, the Gradle side of the Kit package's binary target
* [Swift Package Manager: binary targets](https://developer.apple.com/documentation/xcode/distributing-binary-frameworks-as-swift-packages): how a package declares a prebuilt XCFramework
* [XcodeGen](https://github.com/yonaskolb/XcodeGen): the project spec format `xcodegen.yml` uses
* [WidgetKit](https://developer.apple.com/documentation/widgetkit): the extension type Memory Lane's widgets use
* [The Duet framework on GitHub](https://github.com/modaal-agent/duet): `DuetShells` (`ViewShell`, `StoreHost`) and the toolchain
## Read next
The same repository's Android side: feature modules in `commonMain`, a thin Compose app module, builders and workers.
What the Kit package's framework links into the app, the collector's measured pauses, and who frees what.
The Kit package built by hand: the framework, the bridge, the first shell and the iPhone app.
Shell, Builder, Component, mount, worker and the other terms on this page.
# Duet Twins: a Swift Core for iPhone, a Kotlin Core for Android
Source: https://docs.modaal.dev/articles/duet-twins
Coming to Duet: each app runs its own feature logic, Swift on iPhone and Kotlin on Android, and one set of recorded behavior checks both copies in CI.
This architecture is in development; Modaal does not scaffold it yet, and this page describes what it will ship.
A Duet Twins project keeps each feature's logic twice: as a Swift reducer the iPhone app runs, and as a Kotlin reducer the Android app runs. Both reducers replay the same recorded fixtures in CI. A feature whose two copies disagree fails the check on the side that differs, with the fixture bytes that side produced.
## How does it compare with the Kotlin Multiplatform flavor?
| | Duet Twins | Kotlin Multiplatform flavor |
| - | - | - |
| What the iPhone app runs | Swift reducers | the Kotlin core, through a Kotlin/Native framework |
| What the Android app runs | Kotlin reducers | the same Kotlin core |
| What the two apps share | the recordings | the core and the recordings |
| Kotlin/Native in the iPhone app | none | the runtime and its collector ([Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc)) |
| iPhone build | Xcode only | Xcode plus the core's assemble step |
| Reducers edited per behavior change | two, one per language | one |
| Apple targets that Kotlin/Native does not compile for | supported | not available |
## What does keeping two copies cost?
Every behavior change edits the Swift reducer and the Kotlin reducer, and both must replay the new recordings before CI passes. The Kotlin Multiplatform flavor writes the reducer once and renders it twice; [What does sharing the logic cost?](/articles/share-code-ios-android#what-does-sharing-the-logic-cost) gives Modaal's measured cost of a change shipped to both apps on that flavor. Choose Duet Twins when one of the reasons below outweighs the second edit.
## Who is it for?
* Teams where an iPhone developer and an Android developer each own their platform's language, including the feature logic.
* Apps that must not link the Kotlin/Native runtime into the iPhone app.
* Apps with an Apple target that Kotlin/Native does not compile for.
## What will ship?
* An iPhone-only Duet app on the Swift flavor can add a Kotlin core and an Android app.
* An Android-only Duet app can add a Swift core and an iPhone app.
* Per feature, the second copy is written against the recordings the first copy produced, so it has an executable specification before its first line.
* A feature may exist on one platform only; the manifest records which.
* A tutorial built on a two-core app ships with the release.
This page gives no dates.
## What can you do today?
* **Start an iPhone app on the Swift flavor.** Every feature it records becomes the specification a Kotlin copy will be checked against when the second core can be added.
* **Pick the Kotlin Multiplatform flavor** for one shared implementation of the logic with native SwiftUI and Jetpack Compose interfaces. [The two Duet templates](/articles/duet#the-two-duet-templates) compares the two flavors you can create today.
## Common questions
No. It is one repository with one manifest and one set of recordings. The iPhone app and the Android app each build from their own source tree in it, and CI checks both against the same recordings.
No. The Kotlin feature modules are plain Kotlin/JVM modules that the Android app compiles, and the iPhone app links no Kotlin.
That route is not planned for the first release.
## Read next
The two Duet templates you can create today and how a feature gets built.
The three ways to ship two apps, and what each can prove about behavior.
What the Kotlin Multiplatform flavor links into the iPhone app.
Twin, coexistence window, recording and the other terms on this page.
# Duet vs Compose Multiplatform: Share Logic or Share UI?
Source: https://docs.modaal.dev/articles/duet-vs-compose-multiplatform
Both are Kotlin Multiplatform. Compose Multiplatform also shares the UI, drawn on a canvas on iOS; Duet keeps SwiftUI native and shares the logic only.
Duet and Compose Multiplatform are the closest pair on this site's comparison pages: both are Kotlin Multiplatform, both compile shared Kotlin for iOS with no bundled runtime, and on Android both render with Jetpack Compose. They differ on exactly one decision — Compose Multiplatform shares the UI too, rendering Compose on iOS from the same code, while **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, keeps the iOS interface in SwiftUI and shares the logic only. Everything else on this page follows from that one decision.
*Last reviewed: September 1, 2026. Compose Multiplatform and Kotlin claims were checked against JetBrains' official documentation on this date.*
## How do Duet and Compose Multiplatform differ?
| | Compose Multiplatform | Duet |
| - | - | - |
| Who draws the screen on Android | Jetpack Compose — the same androidx toolkit in both stacks | Jetpack Compose |
| Who draws the screen on iOS | Compose, rendered by the framework onto a Skia canvas | SwiftUI — Apple's own toolkit, authored natively |
| Where feature logic lives | Once, in Kotlin `commonMain`, compiled per platform | Once, in Kotlin `commonMain`, compiled per platform |
| What proves the two platforms agree | One implementation — UI and logic alike | Recorded fixtures replayed on both platforms; CI compares byte-for-byte |
| Shared fraction | Logic and UI | Logic, navigation decisions, effects and the test corpus; the two view layers are per platform |
| Customizing beyond the catalog on iOS | Material 3 drawn by the framework; a control that must be UIKit's own is embedded through the interop, built in Kotlin via Objective-C interop or in Swift for Swift-only APIs | Every control is SwiftUI's own; customization is ordinary SwiftUI code |
| A native, perf-heavy island | Drawing-heavy screens stay shared (Compose `Canvas` on Skia); a camera, video or GPU island is `AndroidView` interop on Android and, on iOS, a `UIKitView` whose factory builds the UIKit view — in Kotlin through Objective-C interop, or in Swift for Swift-only APIs — hosted inside a Skia-drawn screen, with the pipeline's logic outside the shared Kotlin | Ordinary SwiftUI/UIKit and Compose/Android code, no bridge; the reducer decides, a worker runs the pipeline, and the seam is recorded |
| Native code you write and compile for it | Usually Kotlin only: on Android the island is Kotlin in the same Gradle build; on iOS Kotlin/Native's Objective-C interop builds UIKit views from Kotlin, and Swift is needed only for Swift-only APIs, which Xcode compiles in the app target | Yes, by design: Swift compiled by Xcode, Kotlin compiled by Gradle |
| SwiftUI and Compose in the island | Compose is the UI on Android; on iOS a SwiftUI view is wrapped in `UIHostingController` and passed to `UIKitViewController` — the docs say "you have to write them in Swift and pass them to a Kotlin function" | They are the interface: SwiftUI on iOS, Jetpack Compose on Android, by construction |
On Android the two stacks converge fully: JetBrains' own documentation states that on Android a Compose Multiplatform app "simply runs on Jetpack Compose", and Duet's emitted Android app pins Jetpack Compose through the androidx BOM (2025.06.01 in the current catalog). There is no Compose Multiplatform dependency in a Duet project — the `org.jetbrains.compose` artifacts exist for the non-Android targets Duet does not render on.
## What does Compose Multiplatform's iOS rendering actually do?
Compose Multiplatform has been [stable on iOS since 1.8](https://blog.jetbrains.com/kotlin/2025/05/compose-multiplatform-1-8-0-released-compose-multiplatform-for-ios-is-stable-and-production-ready/) (May 2025); current is 1.12.0 (August 2026). On iOS the framework draws components onto a canvas via Skia — the same rendering-ownership decision Flutter makes, but without a language runtime: the Kotlin is compiled by Kotlin/Native, so the quadrant cell is "framework draws, compiled logic." Two qualifiers keep this description current:
* **Text input is going hybrid.** Since 1.11.0 an experimental text field is backed by a real native `UIView`, giving platform-native gestures, selection handles and context menus inside the canvas.
* **Accessibility is bridged to the native services.** Compose semantics are [mapped to native iOS Accessibility Services](https://kotlinlang.org/docs/compose-ios-accessibility.html) — VoiceOver and Full Keyboard Access work, and `testTag` surfaces as `accessibilityIdentifier` for XCTest audits. The documented shape is re-implementation plus mapping: the framework maintains the bridge, and platform behaviors arrive through it rather than by default. A fully native interface inherits new OS accessibility features on the OS's schedule.
Interop runs both directions — `ComposeUIViewController` embeds Compose screens in SwiftUI apps, and `UIKitViewController` embeds native views inside Compose — so mixed adoptions are a documented path. JetBrains' [integration guide](https://kotlinlang.org/docs/multiplatform/compose-swiftui-integration.html) names maps, camera and web views as the cases for a native view inside Compose, and the same interop is the customization path: the catalog is Material 3 on every platform, so a control that must look and behave like UIKit's own is a native view embedded through the interop — built in Kotlin through Kotlin/Native's Objective-C interop, or in Swift when the API is Swift-only — and anything outside the canvas — a home-screen widget, a share extension — is Swift regardless. The fit is UI-neutral business applications whose screens are the same everywhere; the more the iOS interface must differ from the Android one, the more of it is written natively with the framework in between.
## If the language is the same, what is the actual choice?
Fidelity versus coverage, and what "agree" means:
* **Compose Multiplatform** maximizes shared code: interface included, one implementation, agreement by construction. The iOS interface is Compose — Material by default, themable toward Cupertino idiom, but authored in the shared dialect and rendered by the framework.
* **Duet** fixes the interface as each platform's own and narrows sharing to what a fixture can check: state, actions, effects and their ordering. The iOS app is SwiftUI a platform engineer (or Apple's own tooling) recognizes without caveats; the price is a [second view layer, written per feature](/articles/duet#what-duet-requires-of-you). Agreement is by verification — recorded fixtures replayed on both platforms, byte-compared in CI.
Stated plainly, as a position and not a measurement: Modaal's preference for native fidelity is a product judgment about what mobile interfaces should be. Compose Multiplatform's bet — that one excellent interface implementation beats two idiomatic ones — is a coherent judgment in the other direction, and JetBrains' a11y and text-input work above is that bet being taken seriously.
## What if one screen must be native and fast?
On Android nothing changes: Compose Multiplatform is Jetpack Compose there, and a camera preview, a `SurfaceView` or a vendor SDK view is the same [`AndroidView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/views-in-compose) interop a Duet Android app would use. On iOS, drawing-heavy screens stay shared — Compose's `Canvas` renders through Skia in Kotlin — while an island that must be UIKit's own (the docs' own examples: `MKMapView`, a camera, `WKWebView`) is a [`UIKitView`](https://kotlinlang.org/docs/multiplatform/compose-uikit-integration.html) whose factory returns the native view, hosted inside a Skia-drawn screen. Capture and processing pipelines — AVFoundation on iOS, CameraX and MediaCodec on Android — are platform code behind `expect`/`actual` declarations or a Swift wrapper the Kotlin calls into.
The drawbacks: the iOS island is UIKit code inside a framework-rendered screen, so that screen has two renderers and two ownership models, and the pipeline's logic sits outside the shared implementation, kept equal by review. On Android there is no drawback relative to Duet, because the toolkit is the same.
**Do you write and compile native code for it?** On Android, no new language: the island is Kotlin in the same Gradle build. On iOS, usually not Swift either. Kotlin/Native's [Objective-C interop](https://kotlinlang.org/docs/native-objc-interop.html) lets Kotlin construct UIKit objects directly — the docs' own factory builds `MKMapView()` in Kotlin — and Xcode compiles the app target around the framework Kotlin/Native produces. Swift becomes necessary for Swift-only APIs, because "pure Swift modules are not yet supported" by the interop: a SwiftUI view such as `Map`, or a Swift-only vendor SDK, is written in Swift, exposed to Objective-C, and compiled by Xcode.
**Can the island be SwiftUI or Compose?** Compose is the interface on Android already. On iOS a SwiftUI view is the documented case of the Swift exception above: the [SwiftUI integration guide](https://kotlinlang.org/docs/multiplatform/compose-swiftui-integration.html) wraps it in `UIHostingController`, hands the controller to `UIKitViewController`, and states that "you have to write them in Swift and pass them to a Kotlin function". The reverse direction, `ComposeUIViewController` inside a SwiftUI app, is documented on the same page.
In a Duet app the island is ordinary platform code — AVFoundation and Metal in the SwiftUI app, CameraX and MediaCodec in the Compose app — with nothing between it and the OS. The [reducer](/articles/duet-glossary#reducer) decides when capture starts, which filter applies and what happens with the result; a [worker](/articles/duet-glossary#worker) runs the pipeline. What is recorded and byte-compared is that seam: the requests the reducer makes and the results that come back. The pipeline's internals are outside the verified surface, and the island is written twice, once per platform: Swift compiled by Xcode, Kotlin compiled by Gradle, by design.
## What does Compose Multiplatform do better?
* **More shared code.** The interface is written once. Duet writes it twice by design.
* **The iteration loop.** [Compose Hot Reload](https://blog.jetbrains.com/kotlin/2026/01/compose-multiplatform-1-10-0/) is stable and on by default since 1.10.0 (January 2026); 1.12.0 added an experimental MCP server so AI agents can drive reloads, take screenshots and inspect the semantic tree.
* **Reach beyond the two phone platforms.** Desktop is stable and the web target is in beta from the same codebase; Duet targets iOS and Android only.
* **One design system by default.** If the product wants the same branded look everywhere, sharing the interface is the shorter path to it.
## When should you pick which?
Pick Compose Multiplatform for UI-neutral business applications — forms, lists, dashboards — when your team is Kotlin-first, a single branded interface across platforms (and possibly desktop and web) is a feature, and platform-idiomatic iOS is a nice-to-have rather than a requirement. Pick Duet when SwiftUI-native iOS is a requirement, when you want no cross-platform UI dependency in the iOS app, or when the domain needs behavior agreement to be a mechanical check — [the recorded corpus](/articles/testable-android-apps) is what turns "the apps agree" from a review judgment into a failing build.
The two stacks also compose over time in one direction: because Duet's core is ordinary Kotlin Multiplatform, its skills, libraries and tooling transfer — kotlinx, Ktor, the current stable Kotlin (2.4.10, which is what the Duet scaffold emits). Kotlin's Swift Export, in alpha since Kotlin 2.4.0, is the platform's stated direction for the Swift-facing seam both stacks consume today through the Objective-C bridge.
## Common questions
No. Duet's shared core is plain Kotlin Multiplatform — logic only, no UI framework in `commonMain`. Android renders with Jetpack Compose (androidx artifacts), iOS with SwiftUI. Compose Multiplatform is JetBrains' extension of Jetpack Compose to non-Android targets, and a Duet project has no dependency on it.
Technically the interop exists (a Compose screen is embeddable in SwiftUI via ComposeUIViewController), but a Duet project's parity contract covers logic, and its iOS interface convention is SwiftUI. Mixing rendering models per screen re-creates the consistency problem the architecture is built to remove — if shared UI is what you want, Compose Multiplatform proper is the honest choice.
Yes — a defect in shared Kotlin ships to both apps, in either stack. The difference is the safety net around change: Duet's recorded scenarios replay on both platforms in CI, so a behavior change (or regression) is visible as a fixture diff on the commit that caused it. Compose Multiplatform projects bring their own test strategy, typically shared unit tests in commonTest.
Both stacks stand on the same runtime facts: Kotlin/Native compiles to machine code and manages memory with a concurrent mark-and-sweep collector over a shared heap — a small runtime, no VM or interpreter. Whatever holds for one stack's iOS core holds for the other's; it is not a differentiator between them. In a Duet iPhone app, Swift's ARC references to Kotlin objects are roots for the collector, and Swift objects the core holds are released after a collection; [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) measures the pauses and states the rules for the Swift side.
Google officially supports Kotlin Multiplatform for sharing business logic between Android and iOS, and ships KMP artifacts for Room, DataStore, ViewModel and more — that support statement covers the shared-core layer both stacks use. Compose Multiplatform itself is developed by JetBrains, extending Google's Jetpack Compose beyond Android.
Duet projects are scaffolded by [Modaal](https://modaal.dev) — [starting a Duet project](/articles/duet#starting-a-duet-project) walks through the two template cards.
## Sources and further reading
* [Compose Multiplatform and Jetpack Compose](https://www.jetbrains.com/help/kotlin-multiplatform-dev/compose-multiplatform-and-jetpack-compose.html) — what the framework is relative to androidx Compose
* [Compose Multiplatform 1.8](https://blog.jetbrains.com/kotlin/2025/05/compose-multiplatform-1-8-0-released-compose-multiplatform-for-ios-is-stable-and-production-ready/), [1.10](https://blog.jetbrains.com/kotlin/2026/01/compose-multiplatform-1-10-0/) and [1.12](https://blog.jetbrains.com/kotlin/2026/08/compose-multiplatform-1-12-0/) release posts — iOS stable, Hot Reload, the MCP server
* [iOS accessibility in Compose Multiplatform](https://kotlinlang.org/docs/compose-ios-accessibility.html) — the semantics-to-native mapping and its documented gaps
* [Kotlin Multiplatform on developer.android.com](https://developer.android.com/kotlin/multiplatform) — Google's support statement and KMP Jetpack artifacts
* [Kotlin releases](https://kotlinlang.org/docs/releases.html) — current stable and the Swift Export status
* [Integration with the UIKit framework](https://kotlinlang.org/docs/multiplatform/compose-uikit-integration.html), [Integration with the SwiftUI framework](https://kotlinlang.org/docs/multiplatform/compose-swiftui-integration.html) and [Using Views in Compose](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/views-in-compose) — the escape-to-native routes for a heavy screen on each platform
* [Interoperability with Swift/Objective-C](https://kotlinlang.org/docs/native-objc-interop.html) — what Kotlin can call on iOS without Swift, and the Swift-only limit
* [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the recording toolchain and the versioned contracts
## Read next
The full survey — where the share-the-UI and share-the-logic cells sit on the quadrant.
How Duet keeps two native interfaces honest: the presentation contract and recorded divergence.
The decision one level up: write twice, share everything, or share the logic.
The recorded-scenario discipline that gives Duet its verification story, shown in code.
# Duet vs Flutter: Native UI vs One Codebase
Source: https://docs.modaal.dev/articles/duet-vs-flutter
Duet and Flutter both ship iOS and Android from shared code. They differ on who draws the screen, what ships in the binary, and what proves the apps agree.
Flutter ships one codebase whose framework draws every pixel on both platforms; Duet ships two native interfaces over one shared, compiled logic core. Pick Flutter to write everything — interface included — once, in Dart; pick **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, when the interface must be the platform's own and the thing you share is behavior. This page compares the two models on the same three questions the [Duet overview](/articles/duet#how-duet-compares-to-other-cross-platform-approaches) asks of every approach.
*Last reviewed: September 1, 2026. Flutter version and capability claims were checked against Flutter's official documentation on this date.*
## How do Duet and Flutter differ?
| | Flutter | Duet |
| - | - | - |
| Who draws the screen | Flutter's own renderer — the Impeller canvas — on both platforms; widgets belong to the framework | Each platform's native toolkit: SwiftUI on iOS, Jetpack Compose on Android |
| Where feature logic lives | Once, in Dart, running on the Dart runtime the app ships | Once, in Kotlin, compiled into both apps as a library |
| What proves the two platforms agree | One implementation — there is nothing to compare, except code behind platform channels | Recorded fixtures replayed on both platforms; CI compares byte-for-byte |
| Languages in the project | Dart (plus per-platform code at the boundary) | Kotlin for logic, Swift and Kotlin for the two interfaces |
| The interface toolkit | Flutter's widget catalog, Material and Cupertino | Apple's and Google's own — HIG on one side, Material 3 on the other |
| Customizing beyond the catalog | Theming covers Flutter's own widgets; a control that must be the platform's own is a platform view with a Swift/Kotlin side, and anything outside the canvas is platform code | Every control is already the platform's own; customization is ordinary SwiftUI and Compose code |
| A native, perf-heavy island | Drawing-heavy screens stay in Dart (painters, GLSL shaders); a camera, video or GPU island is a `Texture` or a platform view fed by Swift/Kotlin plugin code, with its logic outside the shared Dart | Ordinary SwiftUI/UIKit and Compose/Android code, no bridge; the reducer decides, a worker runs the pipeline, and the seam is recorded |
| Native code you write and compile for it | Yes: Swift or Objective-C in the iOS host project and Kotlin or Java in the Android one, compiled by Xcode and Gradle inside the Flutter build; a pub.dev plugin spares the writing, not the compiling | Yes, by design: Swift compiled by Xcode, Kotlin compiled by Gradle |
| SwiftUI and Compose in the island | Yes, wrapped: an iOS platform view returns a `UIView`, so SwiftUI enters through `UIHostingController`; an Android platform view returns a `View`, so Compose enters through `ComposeView`; a full-screen Jetpack Compose activity is also documented | They are the interface: SwiftUI on iOS, Jetpack Compose on Android, by construction |
## Who draws the screen?
Flutter draws every pixel itself. Since Flutter 3.44 the [Impeller renderer](https://docs.flutter.dev/perf/impeller) is the only engine on iOS, the default on Android API 29+ and, as of Flutter 3.47 (August 2026), the default on desktop; only the web target still renders with Skia. The widgets are Flutter's own — the Material catalog, plus Cupertino widgets that imitate Apple's controls. That buys one interface implementation and pixel-identical screens on both platforms; it also means the app's look is the framework's, and a new OS design language or control appears in your app when the framework implements it, not when the OS ships.
The catalog is broad, its theming is deep — one `ThemeData` sets colors and type for every Material widget in the app — and customization *inside* the canvas is cheap, because Flutter owns the pixels and a bespoke widget is Dart. Customization *toward the platform* is the expensive direction. The Cupertino widgets are re-implementations with the parameters the framework exposes; a control that must be the platform's own — a native map, a web view, a camera preview — is a [platform view](https://docs.flutter.dev/platform-integration/ios/platform-views), `UiKitView` on the Dart side and a `FlutterPlatformView` factory in Swift or Objective-C on the other, and Flutter's documentation states that platform views "come with performance trade-offs". Anything outside the canvas — a home-screen widget, a share extension — is platform code regardless. Per-platform variation therefore costs what a platform channel costs: the control is written per platform, plus the bridge.
Duet renders nothing itself. The iOS app is SwiftUI, the Android app is Jetpack Compose, and each inherits its platform's controls, typography, accessibility behavior and OS-version look natively. The cost is symmetric: those two interface layers are [written twice](/articles/duet#what-duet-requires-of-you).
## What ships in the app binary?
A Flutter release build ships the Dart runtime alongside your ahead-of-time-compiled Dart code. That runtime is well-engineered and the sizes are documented — Flutter's own FAQ measured a minimal release APK at about 4.3 MB (ARM32) and a minimal IPA download at about 10.9 MB, figures the FAQ dates to March 2021 — but it is a second language runtime in the binary, and every screen and every rule runs on it.
Duet's shared core is Kotlin compiled to machine code — Kotlin/Native for the iOS app, JVM bytecode on Android's own ART like any Android library. There is no bundled language VM or interpreter; the compiled core adds about 3 MB to the Android app, measured on the shipped app Modaal's Duet pages quote. The interface layers are ordinary platform code with no framework runtime under them.
## What proves the two platforms agree?
Flutter's answer is identity by construction: one implementation means there is nothing to diverge — until the code crosses a [platform channel](https://docs.flutter.dev/platform-integration/platform-channels). Camera, health data, background execution, a vendor SDK: that code is written per platform in Swift/Objective-C and Kotlin/Java (Pigeon generates the type-safe bridge), and behavior on the two sides of the channel is once again kept equal by review and QA rather than by construction.
Duet's answer is identity by verification: each feature's behavior is recorded as fixture files, both apps replay them, and CI compares the replay byte-for-byte — in the adoption measurement, 6 of 6 recordings were byte-identical across the Swift/Kotlin boundary. The guarantee covers exactly what the fixtures cover: state, actions, requested effects and their ordering. The two interface layers sit outside it by design, which is the same boundary honesty Flutter's platform channels need — stated up front in both cases.
## What if one screen must be native and fast?
Split the case in two. A screen Flutter can draw — a keyframe-animated illustration, a custom-rendered chart, a shader effect — stays in Dart: Flutter owns the canvas, and [custom fragment shaders](https://docs.flutter.dev/ui/design/graphics/fragment-shaders) (GLSL, supported by both Skia and Impeller) and custom painters are ordinary Flutter code. No escape, and the screen is shared.
An island that must be the platform's — a camera preview, hardware-decoded video, a Metal or ARKit view, an audio pipeline — leaves Dart on day one. Flutter's two routes are the [`Texture`](https://api.flutter.dev/flutter/widgets/Texture-class.html) widget, "a rectangle upon which a backend texture is mapped", fed by "a plugin that integrates with host platform video player, camera, or OpenGL APIs", and a platform view, which the docs say "come with performance trade-offs". Either way the island is written in Swift and Kotlin behind a plugin, and its processing logic — capture settings, frame filters, encoding — sits outside the shared Dart (or reaches C through [`dart:ffi`](https://docs.flutter.dev/platform-integration/ios/c-interop)), kept equal across platforms by review rather than by construction. The drawback is two native implementations plus the channel that carries their parameters and results, inside a screen the framework otherwise owns.
**Do you write and compile native code for it?** Yes, unless an existing plugin covers the island exactly. The platform side is Swift or Objective-C in the `ios/` host project and Kotlin or Java in `android/`, compiled by Xcode and Gradle as part of the Flutter build. A plugin from pub.dev spares you the writing, not the compiling: its native source is built into your app the same way. A C library reached through `dart:ffi` is one more thing to build or vendor per platform.
**Can the island be SwiftUI or Compose?** Yes, wrapped. A platform view is a `UIView` on iOS and an `android.view.View` on Android, so a SwiftUI view arrives through [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller), "a UIKit view controller that manages a SwiftUI view hierarchy", and Compose through [`ComposeView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views), which "is an Android `View`". Flutter's [Android platform-views page](https://docs.flutter.dev/platform-integration/android/platform-views) documents the compositing modes and their trade-offs, and a separate guide covers [launching a full-screen Jetpack Compose activity](https://docs.flutter.dev/platform-integration/android/compose-activity), where "you will only write Kotlin code". Either way the SwiftUI or Compose code is yours to write, and the host project compiles it.
In a Duet app the island is ordinary platform code — AVFoundation and Metal in the SwiftUI app, CameraX and MediaCodec in the Compose app — with nothing between it and the OS. The [reducer](/articles/duet-glossary#reducer) decides when capture starts, which filter applies and what happens with the result; a [worker](/articles/duet-glossary#worker) runs the pipeline. What is recorded and byte-compared is that seam: the requests the reducer makes and the results that come back. The pipeline's internals are outside the verified surface, and the island is written twice, once per platform: Swift compiled by Xcode, Kotlin compiled by Gradle, by design.
## What does Flutter do better?
* **One implementation of everything.** Interface included. A screen is built once, looks the same on both platforms, and there is no second view layer to write or review.
* **The iteration loop.** Stateful hot reload is mature and covers every target — including the web, where hot reload has been stable since Flutter 3.35 (August 2025). Duet's loop is compile-and-run per platform, plus a recording step when behavior changes.
* **Package ecosystem.** pub.dev lists roughly 77,000 packages (September 2026), many wrapping both platforms behind one Dart API.
* **Reach beyond the two phone platforms.** Stable desktop targets (with Canonical named lead maintainer of Flutter Desktop in May 2026) and a web target from the same codebase. Duet targets iOS and Android only.
* **Add-to-app.** Embedding a Flutter module into an existing native app is an officially supported, documented path.
## When should you pick which?
Pick Flutter for UI-neutral business applications — forms, lists, dashboards, internal tools, MVPs — whose screens look the same on both platforms and need little per-platform customization, and when one team writing Dart end-to-end outweighs native fidelity. The fit weakens as the interface must vary by platform: each control that has to be the platform's own is a platform view with a Swift and a Kotlin side, so a product that keeps asking for them pays for two native interfaces and the framework. Pick Duet when your users expect each platform's own behavior, when the interface must track the OS (new controls, accessibility features, design-language shifts) on release day, or when "do both apps do the same thing?" needs a mechanical answer because the domain is rules-dense — money, health, data.
One framing note, so this page implies no benchmark that never ran: Modaal builds native-first — both apps use their platform's own toolkit — and that constraint was fixed before any framework evaluation. Flutter was ruled out by the constraint, not by a bake-off, and this page compares the models on their documented properties.
## Common questions
Cupertino widgets imitate Apple's controls on Flutter's canvas; they are re-implementations, not the platform's controls. They track iOS closely, but new OS controls and behaviors arrive when the framework implements them, and system behaviors attached to real UIKit/SwiftUI views come from the imitation, not the OS. Whether that difference matters is a product judgment — this page's position is stated above.
Dart is a sound, garbage-collected language with excellent tooling, and treating it as a defect would be wrong. The trade is staffing and reach: Dart expertise is mostly Flutter expertise, while Duet's languages are the platforms' own — Kotlin and Swift — which iOS and Android engineers already write.
Add-to-app embeds Flutter screens into a native app, but the shared code still runs on the shipped Dart runtime, and driving two fully native interfaces from a Dart core is not the framework's design. The share-the-logic route with a compiled core is Kotlin Multiplatform or Rust territory — the survey page maps the options.
No. The interface layers use each platform's own preview tooling (SwiftUI previews, Compose previews), and logic changes re-run the recorded scenarios — a 1–3 second warm suite at four-feature scale in the field measurement. It is a different loop: slower to see pixels, faster to prove behavior.
Flutter gives an agent one language and a huge training corpus. Duet gives an agent machine-checkable gates: a recorded scenario must pass before a screen can wire to logic, and CI replays the corpus on every push, so a wrong edit fails mechanically instead of compounding. The iOS architecture comparison scores this axis across architectures.
Duet projects are scaffolded by [Modaal](https://modaal.dev) — [starting a Duet project](/articles/duet#starting-a-duet-project) walks through the two template cards.
## Sources and further reading
* [Flutter documentation](https://docs.flutter.dev) and the [Flutter FAQ](https://docs.flutter.dev/resources/faq) — rendering model, app-size figures, governance
* [Impeller rendering engine](https://docs.flutter.dev/perf/impeller) — engine status per platform
* [Platform channels](https://docs.flutter.dev/platform-integration/platform-channels) and [Add-to-app](https://docs.flutter.dev/add-to-app) — the native boundary and embedding paths
* [Hot reload](https://docs.flutter.dev/tools/hot-reload) — scope and targets
* [Fragment shaders](https://docs.flutter.dev/ui/design/graphics/fragment-shaders), the [`Texture` widget](https://api.flutter.dev/flutter/widgets/Texture-class.html), [platform views](https://docs.flutter.dev/platform-integration/ios/platform-views) and [C interop](https://docs.flutter.dev/platform-integration/ios/c-interop) — the in-canvas and escape-to-native routes for a heavy screen
* [Android platform views](https://docs.flutter.dev/platform-integration/android/platform-views) and [launching a Jetpack Compose activity](https://docs.flutter.dev/platform-integration/android/compose-activity); Apple's [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller) and Android's [`ComposeView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views) — how SwiftUI and Compose enter a platform view
* [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the recording toolchain and the versioned contracts
## Read next
The full survey: every mainstream framework on the same three questions, plus the quadrant this pair sits in.
The nearest relative on the canvas side: same language decision as Duet, opposite UI decision.
The decision one level up: write twice, share everything, or share the logic.
What Duet is, the combinations that scaffold it, and the six-step loop every feature follows.
# Duet vs Ionic Capacitor: Native UI or a Web View?
Source: https://docs.modaal.dev/articles/duet-vs-ionic-capacitor
Capacitor runs one web app in the system web view; Duet shares a compiled Kotlin core under SwiftUI and Compose. A lab app measures what a native look costs.
Capacitor packages one web app — HTML, CSS and JavaScript — as an iOS app and an Android app, and renders it in the operating system's web view. Ionic Framework is the component library usually paired with it, supplying tab bars, toolbars and lists as web components styled after each platform. **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, takes the opposite position on both questions the [framework survey](/articles/best-cross-platform-framework#how-should-you-compare-cross-platform-frameworks) asks: each platform's toolkit draws the screen, and the shared logic is Kotlin compiled into both apps. This page compares the two on those questions and on a third — what proves the two apps agree — and uses a public [lab repository](https://github.com/modaal-agent/lab-replicate-parro-crossplatform) that built one four-tab app in Ionic 9 and Capacitor 8 to show what the differences cost in lines of code, app size, build time and agent time.
*Last reviewed: September 18, 2026. Capacitor and Ionic claims were checked against their official documentation on this date. The measurements come from the lab repository's builds of September 13–14, 2026; its Capacitor app was built and measured on iOS only.*
## How do Duet and Ionic Capacitor differ?
| | Ionic Capacitor | Duet |
| - | - | - |
| Who draws the screen | The system web view — `WKWebView` on iOS, the Chromium-based Android WebView on Android — rendering Ionic's components as HTML and CSS | The platform — SwiftUI on iOS, Jetpack Compose on Android, written per platform |
| Where feature logic lives | Once, in JavaScript or TypeScript, run by the web view's JavaScript engine: WebKit's on iOS, Chromium's on Android | Once, in Kotlin, compiled into both apps as a library |
| What ships in the binary | The web bundle, Capacitor's native runtime and each plugin's native code; the JavaScript engine belongs to the operating system | The compiled Kotlin core and the two native view layers; no language VM or interpreter |
| App size in the lab (iOS simulator Release `.app`) | 7,920–11,604 KiB: 5.5–8.5× a SwiftUI build of the same screens (1,308–1,444 KiB) | Not measured in the lab; on Android the compiled core adds about 3 MB |
| Clean Release build in the lab (iOS) | 7.3–8.9 s, 3.1–4.6 s of it before Xcode, against 4.8 s for the SwiftUI build in the same run | Not measured in the lab; the Kotlin Multiplatform flavor adds a Gradle step before Xcode |
| What proves the two platforms agree | One implementation, rendered by two browser engines; plugin code in Swift and Kotlin sits outside it | Recorded fixtures replayed on both platforms; CI compares byte-for-byte |
| The interface toolkit | Ionic's components, with an `ios` and an `md` mode that restyle the same HTML toward each platform | Apple's and Google's own controls — HIG on iOS, Material 3 on Android |
| Customizing beyond the catalog | CSS on the page; a control that must be UIKit's or Android's own comes from a plugin, as a native view placed over, around or under the web view | Every control is the platform's own; customization is ordinary SwiftUI and Compose code |
| A native, perf-heavy island | A Capacitor plugin in Swift and Kotlin; its native UI is presented over the web view, or drawn under it and aligned with an HTML element, as the Google Maps plugin does on Android | Ordinary SwiftUI/UIKit and Compose/Android code, no bridge; the reducer decides, a worker runs the pipeline, and the seam is recorded |
| Native code you write and compile for it | Yes: Swift or Objective-C and Kotlin or Java in the generated `ios/` and `android/` projects, compiled by Xcode and Gradle; an npm plugin spares the writing, not the compiling | Yes, by design: Swift compiled by Xcode, Kotlin compiled by Gradle |
| SwiftUI and Compose in the island | Only as native views a plugin places over, around or under the page, written in Swift or Kotlin; CSS does not lay them out | They are the interface: SwiftUI on iOS, Jetpack Compose on Android, by construction |
| Dependency manager on iOS | Swift Package Manager for new projects since Capacitor 8; in such a project, a plugin that ships only a CocoaPods podspec is left out of the build | Swift Package Manager and Gradle — the platforms' defaults |
## Who draws the screen?
A Capacitor app is a native shell around one web view. On iOS the shell's view controller hosts a `WKWebView`, which renders with the WebKit of the installed iOS version. On Android, [Capacitor's documentation](https://capacitorjs.com/docs/android) states that Chrome provides the WebView on Android 7–9 and the Android System WebView does on Android 10 and later, and it requires a WebView with Chrome version 60 or later; that WebView has been [updatable from Google Play](https://developer.android.com/about/versions/lollipop) separately from the OS since Android 5.0. Capacitor 8 supports iOS 15 and Android 7.0 (API 24) and later. It loads the app's bundled web assets into the web view and connects JavaScript to native code through plugins.
Every screen, list and control the user sees is an HTML element: Ionic's `ion-tab-bar` and `ion-toolbar` are elements in the page, laid out by CSS, and neither is a `UITabBar` or a `UINavigationBar`. Screen readers reach the page through the web engine's accessibility support; Ionic's [accessibility guide](https://ionic.io/docs/accessibility/aria) states that its components "provide accessibility features through ARIA". The lab did not test VoiceOver.
Ionic's components choose a [mode](https://ionicframework.com/docs/theming/platform-styles) per platform: `ios` on iOS devices, and `md` (Material Design) on Android and every other platform. Ionic's documentation states that styling the components to each platform's guidelines "allows the application to be written once but look and feel native to the user". Each mode is a stylesheet that re-implements the platform's look in CSS, so a new platform design reaches an Ionic app when Ionic or a community theme writes it. iOS 26's Liquid Glass is the current example. A SwiftUI `TabView` built with the iOS 26 SDK draws the floating glass tab bar with no styling code; the lab's SwiftUI build, on the iOS 26.5 SDK, showed it from a plain `TabView`. Ionic's [8.8 announcement](https://ionic.io/blog/announcing-ionic-framework-8-8) (March 6, 2026) states: "While we are not planning to ship an official Liquid Glass theme right now, the goal is to provide the underlying architecture that enables developers to implement these types of design systems themselves". In `@ionic/core` 9.0.3, `grep -ril "liquid\|glass"` over `dist/collection` prints nothing. The lab used the community theme `@rdlabo/ionic-theme-ios26`, which draws glass with CSS `backdrop-filter` blur and saturation.
The lab found two parts of the iOS look that web content cannot draw, on the iOS 26.5 simulator:
* **Glass refraction.** An SVG displacement filter renders under `filter` and does not render under `backdrop-filter` in `WKWebView`, although `CSS.supports` reports the value as supported; [WebKit bug 245510](https://bugs.webkit.org/show_bug.cgi?id=245510), which tracks it, had status NEW when the lab read it on September 13, 2026. The web build's glass has blur and saturation and no lens distortion.
* **SF Symbols.** The page cannot draw Apple's symbols by name, so the lab substituted Ionicons: `people` for `person.3`, `open-outline` for `arrow.up.forward`.
Duet renders nothing itself. The iOS app is SwiftUI and the Android app is Jetpack Compose. In the Duet tutorial app, Foyer, the iOS tab bar is a SwiftUI [`TabView`](https://github.com/modaal-agent/duet-tutorials/blob/main/tutorial6-complete/src-ios/Libraries/FoyerKit/Sources/MainShell/MainView.swift) and the Android one is a Material 3 [`NavigationBar`](https://github.com/modaal-agent/duet-tutorials/blob/main/tutorial6-complete/src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/MainScreen.kt); both render the same `activeTab` value from the shared state, and a tap on either sends the same `TabSelected` action. The cost is that the two view layers are [written twice](/articles/duet#what-duet-requires-of-you).
## What does a native look cost in a Capacitor app?
The lab repository builds four tabs of a school-messaging app — Home, Calendar, Chat with a conversation screen, and Settings — with Ionic 9.0.3, React 19, Capacitor 8.5.2 and the same placeholder data throughout. Its `ionic-capacitor/` directory carries two styling variants built from one codebase:
* **`stock`** uses Ionic's iOS mode as the tabs starter ships it, with one accent color and no CSS written to match anything.
* **`matched`** adds the Liquid Glass theme and its own CSS, styled to match a SwiftUI build of the same screens in the same repository. That SwiftUI build is a plain SwiftUI app — no shared core, no recordings, no Android half — so this page uses it as the reference `matched` was measured against and as the native baseline for app size and build time, and quotes no Duet figures from it.
Claude Code wrote both variants at the repository owner's direction, and the repository's [spec](https://github.com/modaal-agent/lab-replicate-parro-crossplatform/blob/main/specs/001-four-tab-shells/spec.md) records every count below (§14–§16, §20):
| Measure | `stock` | added for `matched` |
| - | - | - |
| Hand-written code | About 1,810 lines in `src/` for the screens, the data and the behavior, of which 275 lines of CSS in 7 files | About 1,000 more lines: 754 in `src/styles/matched/` and `variant.ts`, and 240 lines added (80 removed) across 9 shared TSX files |
| The stylesheet | — | `index.css`, 725 lines: 174 pixel values, each read from the SwiftUI build's source, screenshots or accessibility frames; 9 `!important` declarations |
| npm packages added | — | 1, `@rdlabo/ionic-theme-ios26` 9.2.0 |
| Web bundle (`dist/`) | 3,068 KiB, 19 files | 6,252 KiB, 205 files: the theme's 20 imports from `@ionic/core` pull in 104 lazy-loading chunks, 2,108 KiB |
| Agent work | 141 tool calls, 47 minutes of active time | 202 tool calls, 65 minutes, 8 screenshot rounds |
| Differences left against the SwiftUI reference | Not compared: §14.5 lists `stock`'s differences from the original app's screenshots, most of them Ionic defaults | 5: SF Symbols, glass refraction, the Calendar title's transition, the time format, a 4 pt scroll offset |
Three findings from those passes bear on the choice of stack:
* **The look needed a native reference.** Every value in `matched`'s stylesheet was measured from the SwiftUI build. The spec does not estimate how long the same pass takes without one; the alternative sources it names are Apple's design resources and measurements of Apple's own apps.
* **The look depends on framework internals.** `matched` styles five of Ionic's shadow parts and depends on Ionic's internal class names (`tab-bar-hidden`, `footer-toolbar-padding`, `header-collapse-condense`), and the theme's peer range ends below `@ionic/core` 10. An Ionic or theme upgrade that changes any of them needs a new screenshot round; `stock` does not.
* **The look is per platform.** `matched` restyles Ionic's `ios` mode. The lab built no Android app; a Capacitor app matched to Material 3 on Android would need a second styling pass in `md` mode, which the lab did not measure.
In a Duet app the corresponding work is the second view layer: each screen is written once in SwiftUI and once in Compose, from the platform's own components, and the shared state they render is recorded and verified. The iOS screens use SwiftUI's containers and controls — Foyer's tab bar is a `TabView` and its lists are `List`s — and those take the current iOS design when the app is built with the current SDK.
## What ships in the app binary?
Capacitor ships no JavaScript engine: the app's code runs in the system web view's engine, so its version is the device's — the WebKit of the installed iOS, and the WebView Google Play has installed on Android. The binary carries the web bundle, Capacitor's frameworks and each plugin's native code. Simulator Release `.app` sizes from the lab, against the repository's SwiftUI build of the same screens (a plain SwiftUI app, not a Duet app):
| Stage (spec section) | SwiftUI | Capacitor `stock` | Capacitor `matched` |
| - | - | - | - |
| Phone shell (§15.3) | 1,308 KiB | 7,920 KiB (6.1×) | 11,152 KiB (8.5×) |
| Plus the iPad wide layout (§18.2, §19.3) | 1,444 KiB | 7,940 KiB (5.5×) | 11,176 KiB (7.7×) |
| Plus a UIKit tab bar (§20.3) | — (a `TabView` from the first stage) | — | 11,604 KiB (8.0×) |
Every Capacitor build carries 4,480 KiB of `Capacitor.framework` and `Cordova.framework`, the layer for [Cordova plugins](https://capacitorjs.com/docs/plugins/cordova) that the [Capacitor 9 roadmap](https://ionic.io/blog/the-road-to-capacitor-9) makes optional. The rest is the web bundle: 3,068 KiB in `stock`, about half of it a legacy copy of the JavaScript that the Ionic starter's `@vitejs/plugin-legacy` generates, and 6,252 KiB in `matched`, whose theme pulls in 2,108 KiB of `@ionic/core` chunks. The SwiftUI build's code is its 1.2 MB binary.
Duet's shared core is compiled Kotlin — Kotlin/Native on iOS, JVM bytecode on Android's ART — and adds about 3 MB to the Android app, measured on the shipped app Modaal's Duet pages quote. A Duet iOS app in the Kotlin Multiplatform flavor is a SwiftUI app plus the core's Kotlin/Native framework, for which this site publishes no size.
## How long does a clean build take?
A Capacitor iOS build runs the web build and `npx cap sync ios`, then Xcode, which links Capacitor's prebuilt XCFrameworks and compiles plugin sources. The SwiftUI build is one Xcode step. Clean Release builds at the phone-shell stage, one run of each on September 14, 2026 (spec §15.3):
| | SwiftUI | Capacitor `stock` | Capacitor `matched` |
| - | - | - | - |
| Web build and `npx cap sync ios` | — | 3.07 s | 4.59 s |
| Clean Release `xcodebuild` | 4.84 s | 4.19 s | 4.27 s |
| Total | 4.84 s | 7.26 s | 8.86 s |
These are single runs on one machine, at a load average of 2.86 to 7.08. In a separate before-and-after run, adding the UIKit tab bar plugin took `matched`'s clean Release `xcodebuild` from 4.81 s to 9.75 s; that build compiles the plugin's 3,170 lines of Swift from source. A Duet iOS build in the Kotlin Multiplatform flavor adds a Gradle step for the Kotlin/Native framework; the lab did not time one.
## What proves the two platforms agree?
Capacitor's answer is one implementation. Logic in the JavaScript bundle is shared by construction; two different engines render and run it — WebKit on iOS, Chromium on Android — so layout and CSS differences between the platforms are found by testing on both, as in cross-browser web work. Plugin code in Swift and Kotlin is written per platform and kept equal by review and QA. The lab's Capacitor build ran on iOS only, so it measured nothing on this question.
Duet's answer is verification: each feature's behavior is recorded as fixture files, both apps replay them, and CI compares the replay byte-for-byte — in the adoption measurement, 6 of 6 recordings were byte-identical across the Swift/Kotlin boundary. The guarantee covers what the fixtures cover: state, actions, requested effects and their ordering. The two view layers sit outside it, and [Handling platform-specific UI](/articles/cross-platform-ui-parity) describes how their deliberate differences are recorded.
## What if part of the screen must be native?
Capacitor's route to native is a plugin: Swift or Objective-C in the [iOS project](https://capacitorjs.com/docs/ios/custom-code) and Kotlin or Java in the [Android one](https://capacitorjs.com/docs/android/custom-code), called from JavaScript. The documented ways to show native UI place it outside the page's layout:
* **Over the web view.** On iOS the bridge's view controller "can be used to present native views over the app" ([iOS bridge API](https://capacitorjs.com/docs/core-apis/ios)); on Android a plugin starts a native Activity.
* **Under or into the web view, aligned with an HTML element.** The official [Google Maps plugin](https://capacitorjs.com/docs/apis/google-maps) renders the map "beneath the entire webview" on Android, which requires every layer of the page above it to be transparent, and "directly into the webview" on iOS. The plugin moves the native view to follow an HTML element's position as the page scrolls.
* **Around the web view.** A plugin can put native bars around the web view, which is what the lab did.
In each case the native view is not a DOM element: CSS does not lay it out, and the page's own components cannot contain it.
The lab measured one such case: replacing the CSS tab bar with UIKit's (§20 of the spec).
* **The first plugin tried did not build.** `stay-liquid` ships a CocoaPods podspec and no `Package.swift`. The iOS project Capacitor 8 generates uses Swift Package Manager, and `npx cap sync ios` printed "stay-liquid does not have a Package.swift" and left the plugin's Swift out of the build. A plugin in that state needs its maintainer to add a Swift package; the Capacitor 9 roadmap notes that CocoaPods trunk becomes read-only on December 2, 2026.
* **The second plugin draws the bar by moving the web view.** `@capgo/capacitor-native-navigation` 8.3.1 creates a `UITabBarController`, moves the Capacitor web view into the selected tab's content controller, and sends a `tabSelect` event to JavaScript. The app's router still owns the routes: the lab's handler clicks the hidden `ion-tab-button` of the same tab.
* **The cost.** 154 hand-written lines and no Swift: 91 lines of TypeScript connecting the routes to the bar, 41 lines of CSS, 22 for the accent color. The build compiles 3,170 lines of the plugin's Swift from `node_modules`, and the app binary grew from 154,800 to 567,808 bytes. The work took 142 agent tool calls in 33 minutes.
* **The side effects.** Inside the tab controller the web view's bottom safe-area inset changed from 34 pt to 83 pt. The theme computed positions from the 34 pt value, so the chat's "+" button sat 49 pt too high until 41 lines of CSS replaced that arithmetic. The keyboard's background turned from dark to light grey, because the plugin's container takes the web view's background color. On iPad the bar ignored the plugin's tint and needed an `AccentColor` in the app target.
* **What stays HTML.** The tab bar is UIKit's; the navigation bars, lists and buttons on each page are still Ionic's elements.
**Do you write and compile native code for it?** Yes, unless an npm plugin covers the case exactly. A plugin from npm spares the writing, not the compiling: its Swift and Kotlin are built into the app by Xcode and Gradle, as the 3,170 lines above were.
**Can the island be SwiftUI or Compose?** Only as a native view a plugin places over, around or under the page, written in Swift or Kotlin — a SwiftUI view through `UIHostingController`, a Compose view in a native Activity or view. The page's HTML cannot contain one.
In a Duet app the island is ordinary platform code — AVFoundation and Metal in the SwiftUI app, CameraX and MediaCodec in the Compose app — with nothing between it and the OS. The [reducer](/articles/duet-glossary#reducer) decides when capture starts, which filter applies and what happens with the result; a [worker](/articles/duet-glossary#worker) runs the pipeline. What is recorded and byte-compared is that seam: the requests the reducer makes and the results that come back. The pipeline's internals are outside the verified surface, and the island is written twice, once per platform: Swift compiled by Xcode, Kotlin compiled by Gradle, by design. A tab bar in a Duet app needs no plugin: it is the platform's own control, bound to shared state, as the Foyer example above shows.
## What happens when the platform changes its layout rules?
Apple [announced iPhone Duo](https://www.apple.com/newsroom/2026/09/apple-unveils-iphone-duo/) on September 9, 2026, available October 23 with iOS 27.1. On it, "app navigation and controls now appear on the side to maximize vertical space for content", and Split View puts two apps side by side. Per Apple's guidance, the system moves bars to the side only when they come from its own containers:
* [Preparing your app for iPhone Duo](https://developer.apple.com/documentation/technologyoverviews/preparing-your-app-for-iphone-duo) says to "prefer system-provided layouts and containers, such as split views, tab bars, arrangement views, and navigation stacks", and, if bars do not present vertically, to check that the app uses "the bar support that navigation containers, such as a tab view or navigation stack, provide".
* The tech talk [Raise the bar with iPhone Duo](https://developer.apple.com/videos/play/tech-talks/111462/) says content from custom bars built from `UIToolbar`, `UINavigationBar` or `UITabBar` "won't be considered", and names `UINavigationController` and `UITabBarController` as the containers to use.
* The tech talk [Prepare your app for iPhone Duo](https://developer.apple.com/videos/play/tech-talks/111461/) gives the inner display regular horizontal and vertical size classes, and warns that safe areas "are often asymmetric".
An HTML tab bar and HTML navigation bars are none of those containers. The lab's September 14 review (in its [DISCOVERY.md](https://github.com/modaal-agent/lab-replicate-parro-crossplatform/blob/main/DISCOVERY.md)) found no fold, segment or reserved-region API in the iOS 27.0 SDK's WebKit headers, so the web view receives a resized viewport and its safe-area insets and nothing about the fold. It also found a layout the web build would get wrong. The Capacitor build switches to two columns at `(min-width: 672px)`, the width at which SwiftUI's size class changed in the lab's iPad windows. The inner display in portrait is 669 points wide, taking the 2,007 × 2,853 px screenshot size App Store Connect lists for it at 3x, so the web build would show its phone layout on a display Apple gives regular width. Unfolding from the outer display (466 points wide) to the inner display in landscape (951 points) crosses the same breakpoint. In the lab's iPad windows each crossing of 672 points mounted a new router outlet, and state held in page components started again: keeping a typed message draft across the change took a separate store, `Drafts.tsx`, 46 lines.
The lab's recommendations for a Capacitor app, in the same entry: pass the size class from native code to the web view and choose the layout from it; let a `UITabBarController` own the tab bar, as the plugin above does; handle each safe-area inset per side; and keep navigation state in the URL or a store rather than in components that remount. None of it could be run on iPhone Duo: Xcode 27.1 beta, which carries the iPhone Duo simulator, is listed as "coming later this month" on Apple's [iPhone Duo page](https://developer.apple.com/iphone-duo/) as of September 18, 2026. No Duet app has been run on it either, for the same reason.
A Duet app's iOS screens are SwiftUI, so they can use the containers Apple's guidance names — Foyer's tab bar is a `TabView` — and a rebuild with the iOS 27.1 SDK is the documented step for the side placement. Feature state lives in stores outside the view tree, as the [Duet overview's lifecycle answer](/articles/duet#common-questions) describes, so a relayout on fold or unfold redraws the views over state the stores still hold.
## What does Ionic Capacitor do better?
* **One interface implementation, plus the web.** The same HTML, CSS and TypeScript run in the iOS app, the Android app and a browser, and Capacitor's documentation states "first-class support for [Progressive Web Apps](https://capacitorjs.com/docs/web/progressive-web-apps)", with many plugins offering the same API on the web. The lab's code uses the native tab bar only on iOS and keeps Ionic's HTML tab bar in a browser (`nativeTabBar.ts`). Duet targets iOS and Android only, with two view layers.
* **Web skills and the npm ecosystem.** A team that builds web apps can ship mobile apps without learning Swift or Kotlin until it needs a plugin, and an existing web app can be packaged rather than rewritten.
* **The iteration loop.** The web app runs in a desktop browser under the web tooling's dev server, and [live reload](https://capacitorjs.com/docs/guides/live-reload) (`npx cap run ios --live-reload`) loads it from that server into the web view on a device on the same Wi-Fi network, reloading on each change. The lab's clean web builds took 2.7 to 5.2 seconds, and `npx cap sync ios` under one second.
* **A usable default look.** `stock` reached an iOS-styled four-tab app with 275 lines of the app's own CSS; everything else came from Ionic's iOS mode.
* **Updates without a store release.** The interface and logic ship as web assets, which can be replaced over the air within Apple's rules on downloaded interpreted code, quoted in the over-the-air answer below. A compiled core cannot be replaced this way, and Duet ships every change through the App Store and Google Play.
## When should you pick which?
Pick Capacitor when you have a web app or a web team, when the product also ships on the web, when the interface can follow Ionic's platform modes or one brand design system shared by every platform, and when the native features you need are covered by maintained plugins. Pick Duet when users expect each platform's current controls — a Liquid Glass tab bar, bars that move to the side on iPhone Duo — without re-implementing them in CSS, or when the domain is rules-dense and "do both apps do the same thing?" needs a mechanical answer. Duet targets iOS and Android only; a product whose web version must run the same code as its mobile apps needs a web stack such as Capacitor.
The framing note this site puts on every comparison: Modaal builds native-first, with both interfaces in the platforms' own toolkits and no shipped runtime, and that constraint was fixed before any framework evaluation. Capacitor was ruled out by the constraint. The lab measured the effort to make a Capacitor app look like a SwiftUI app on iOS; it measured no frame rates or startup times, and this page quotes none.
## Common questions
No. Capacitor packages any web app — React, Vue, Angular or plain JavaScript — and Ionic Framework is one component library you can put in it. The lab used both, because Ionic supplies the tab bar, toolbars and iOS-styled lists that a native look starts from.
As native views outside the page's layout, yes: a plugin can present a native screen, draw a native view under or into the web view as the Google Maps plugin does, or, like `@capgo/capacitor-native-navigation`, put a `UITabBarController` or `UINavigationBar` around the web view. As page elements, no: every list, button and field that CSS lays out is HTML. In the lab, the native tab bar replaced one element of the shell; the navigation bars and every list stayed Ionic's.
No. The app's code runs in the system web view's engine — WebKit on iOS, the Chromium-based WebView on Android — so the binary carries no engine. The engine version is the device's: the WebKit of the installed iOS, and the WebView version Google Play has installed on Android, which Capacitor requires to be Chrome 60 or later.
Web assets can be replaced without a store release. Apple's [Developer Program License Agreement](https://developer.apple.com/support/terms/apple-developer-program-license-agreement/) allows it in section 3.3.1(B): "Interpreted code may be downloaded to an Application but only so long as such code" does not change the app's primary purpose, does not bypass the OS's signing, sandbox or other security features, and does not create a store for other apps. Ionic's own service for this, Appflow, [stopped taking new customers](https://ionic.io/blog/important-announcement-the-future-of-ionics-commercial-products) on February 11, 2025, and existing users keep access through December 31, 2027; Ionic's migration article names Capawesome Cloud and Capgo for live updates. A Duet app's compiled core cannot be replaced this way: every change ships through the App Store and Google Play.
There is no Capacitor-to-Duet converter, and this site does not claim one. A move to Duet means re-authoring each feature against recorded specs, one feature at a time, with a SwiftUI and a Compose screen per feature. If the web app must keep running in browsers, it stays a web app beside the native ones.
The lab is itself an agent measurement: Claude Code built both Capacitor variants in 343 tool calls. Matching the native look took techniques an agent uses when the system does not supply the values — temporary outlines on toolbars, accessibility frames read from both apps, hit-test probes, pixel comparisons. UI automation also sees less of a web view: `axe describe-ui` listed no web content in its tree dump and returned web elements only for queries at a point. Duet gives an agent machine-checkable gates: a screen cannot wire to logic without a passing recording, and CI replays the corpus on every push.
Duet projects are scaffolded by [Modaal](https://modaal.dev) — [starting a Duet project](/articles/duet#starting-a-duet-project) walks through the two template cards.
## Sources and further reading
* [Capacitor documentation](https://capacitorjs.com/docs), [Updating to Capacitor 8](https://capacitorjs.com/docs/updating/8-0), the [support policy](https://capacitorjs.com/docs/main/reference/support-policy) and [The road to Capacitor 9](https://ionic.io/blog/the-road-to-capacitor-9) — the runtime, minimum platforms, Swift Package Manager as the default, the Cordova layer
* Capacitor's [Android](https://capacitorjs.com/docs/android) page, [custom native iOS code](https://capacitorjs.com/docs/ios/custom-code), [custom native Android code](https://capacitorjs.com/docs/android/custom-code), the [iOS bridge API](https://capacitorjs.com/docs/core-apis/ios) and the [Google Maps plugin](https://capacitorjs.com/docs/apis/google-maps) — the web views, and where a plugin's native UI goes
* Capacitor's [live reload](https://capacitorjs.com/docs/guides/live-reload), [Progressive Web Apps](https://capacitorjs.com/docs/web/progressive-web-apps) and [Cordova plugins](https://capacitorjs.com/docs/plugins/cordova) pages
* [Ionic Framework documentation](https://ionicframework.com/docs), [platform styles](https://ionicframework.com/docs/theming/platform-styles), the [accessibility guide](https://ionic.io/docs/accessibility/aria), the [Ionic Framework 9 announcement](https://ionic.io/blog/announcing-ionic-framework-9) and the [8.8 announcement](https://ionic.io/blog/announcing-ionic-framework-8-8) — platform modes, ARIA, and the statement on a Liquid Glass theme
* [The future of Ionic's commercial products](https://ionic.io/blog/important-announcement-the-future-of-ionics-commercial-products) and Apple's [Developer Program License Agreement](https://developer.apple.com/support/terms/apple-developer-program-license-agreement/), section 3.3.1(B) — live updates
* [Android 5.0 for developers](https://developer.android.com/about/versions/lollipop) — the WebView updated through Google Play
* [`@rdlabo/ionic-theme-ios26`](https://www.npmjs.com/package/@rdlabo/ionic-theme-ios26) and [`@capgo/capacitor-native-navigation`](https://github.com/Cap-go/capacitor-native-navigation) — the community theme and the native tab bar plugin the lab used
* [WebKit bug 245510](https://bugs.webkit.org/show_bug.cgi?id=245510) — `backdrop-filter` with SVG filters
* Apple's [iPhone Duo announcement](https://www.apple.com/newsroom/2026/09/apple-unveils-iphone-duo/), [Preparing your app for iPhone Duo](https://developer.apple.com/documentation/technologyoverviews/preparing-your-app-for-iphone-duo), and the tech talks [Prepare your app for iPhone Duo](https://developer.apple.com/videos/play/tech-talks/111461/) and [Raise the bar with iPhone Duo](https://developer.apple.com/videos/play/tech-talks/111462/)
* [The lab repository](https://github.com/modaal-agent/lab-replicate-parro-crossplatform) — [spec 001](https://github.com/modaal-agent/lab-replicate-parro-crossplatform/blob/main/specs/001-four-tab-shells/spec.md) holds the measurements (§14–§16 for the two variants, §19 for the wide layout, §20 for the native tab bar), and [DISCOVERY.md](https://github.com/modaal-agent/lab-replicate-parro-crossplatform/blob/main/DISCOVERY.md) the iPhone Duo review
* [The Duet framework on GitHub](https://github.com/modaal-agent/duet) and the [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials) — the framework, and the Foyer app whose tab bars this page links
## Read next
The full survey — Ionic/Capacitor's cell on the quadrant, beside Flutter, and every other framework on the same three questions.
The other JavaScript stack: the same language, but native views drawn by Fabric in place of a web view.
How Duet keeps two native interfaces honest: the presentation contract and recorded divergence.
What Duet is, the combinations that scaffold it, and the six-step loop every feature follows.
# Duet vs React Native: Shared Logic, No JS Runtime
Source: https://docs.modaal.dev/articles/duet-vs-react-native
React Native runs shared logic in a shipped JavaScript engine and draws native views. Duet compiles a shared Kotlin core and verifies parity with recordings.
React Native and Duet agree on one thing most comparisons miss: both draw the interface with native platform views. They part ways on where the logic runs — React Native ships a JavaScript engine in the app and runs your code there, while **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, compiles a shared Kotlin core into both apps and verifies their agreement with recorded fixtures. Which trade fits depends on your team, your dependencies on native modules, and what you need proven about behavior.
*Last reviewed: September 1, 2026. React Native version and capability claims were checked against React Native's official documentation on this date.*
## How do Duet and React Native differ?
| | React Native | Duet |
| - | - | - |
| Who draws the screen | The platform — Fabric composes real UIKit views and Android Views from React components | The platform — SwiftUI on iOS, Jetpack Compose on Android, written per platform |
| Where feature logic lives | Once, in JavaScript/TypeScript, on the Hermes engine the app ships | Once, in Kotlin, compiled into both apps as a library |
| What proves the two platforms agree | One implementation — until code crosses into a native module | Recorded fixtures replayed on both platforms; CI compares byte-for-byte |
| The interface source | One React component tree, mapped to native views on each platform | Two view layers, each authored in its platform's own toolkit |
| Customizing beyond the catalog | Core components are a small set; anything else is a library or a Native Component written per platform | Every control is the platform's own; customization is ordinary SwiftUI and Compose code |
| A native, perf-heavy island | Animations can run on the native driver; a camera, video or GPU island is a Fabric Native Component plus a Native Module, written per platform, with its logic outside the JS bundle | Ordinary SwiftUI/UIKit and Compose/Android code, no bridge; the reducer decides, a worker runs the pipeline, and the seam is recorded |
| Native code you write and compile for it | Yes: Objective-C++ on iOS and Kotlin or Java on Android, compiled by Xcode (through CocoaPods) and Gradle in the app build, with Codegen deriving the native interface from the TypeScript spec; an npm library spares the writing, not the compiling | Yes, by design: Swift compiled by Xcode, Kotlin compiled by Gradle |
| SwiftUI and Compose in the island | Yes, wrapped: a Fabric Native Component hosts a `UIView` or Android `View`, so SwiftUI enters through `UIHostingController` and Compose through `ComposeView`, with Objective-C++ glue on iOS; with Expo, Expo UI builds UI directly with SwiftUI and Jetpack Compose from React, and Expo modules are written in Swift and Kotlin | They are the interface: SwiftUI on iOS, Jetpack Compose on Android, by construction |
| Dependency manager on iOS | CocoaPods (SwiftPM support is experimental as of 0.87) | Swift Package Manager and Gradle — the platforms' defaults |
## Where does the logic run?
A React Native app ships [Hermes](https://reactnative.dev/docs/hermes), a JavaScript engine tuned for the platform — since React Native 0.84 (February 2026), Hermes V1 is the default on both platforms, with measured wins the team published from Expensify's app: 3.2–9% faster bundle load and 2.5–7.6% faster time-to-interactive. The engine is good; the structural fact stands regardless: your rules execute in a bytecode VM the app carries, one abstraction away from the platform's own execution model.
Duet's shared core is Kotlin compiled against each platform — Kotlin/Native machine code inside the iOS app, ordinary JVM bytecode on Android's ART. There is no bundled language VM or interpreter in either app; the core adds about 3 MB to the Android app, measured on the shipped app Modaal's Duet pages quote.
## Has the New Architecture closed the native gap?
Substantially, on the rendering side. Comparisons that describe an asynchronous bridge describe React Native before 0.82 (October 2025): since that release the old bridge cannot be enabled, and its code has been deleted from default builds since 0.84. [Fabric](https://reactnative.dev/architecture/fabric-renderer) renders synchronously and composes genuine platform views, which is why this page places React Native beside Duet on the who-draws axis, not beside Flutter.
What the New Architecture does not change is the boundary. React Native's [own documentation](https://reactnative.dev/docs/native-platform) states it: platform capabilities not covered by React Native or a library require a Native Module or Native Component, written in Kotlin and Swift/Objective-C per platform. Logic that crosses that line is implemented twice and leaves the shared-behavior guarantee — agreement across the boundary is maintained by review and QA. That is the precise, checkable form of this comparison: not "is React Native native enough," but *how much of your product's logic will live beyond the shared implementation, and what checks it there.* Duet's answer to the same question is that the logic lives in [reducers](/articles/duet-glossary#reducer) — pure, shared, replayed on both platforms — and platform code sits behind [workers](/articles/duet-glossary#worker) whose requests and results are part of the recorded contract: the fixture asserts what was requested and what came back, so the seam itself is inside the verified surface.
The same boundary bounds customization. The [core components](https://reactnative.dev/docs/components-and-apis) are a small set — View, Text, Image, TextInput, Pressable, ScrollView — styled through a cross-platform flexbox subset; a control beyond them comes from a library or is a Native Component written in Swift and Kotlin, and anything outside the React tree — a home-screen widget, a share extension — is platform code regardless. React Native fits UI-neutral business applications whose screens are the same on both platforms; the more the interface must vary by platform, the more of it is written natively, twice, with the framework in between.
## What proves the two platforms agree?
React Native: one implementation, so agreement is by construction inside the JS bundle and by convention beyond it. Duet: both apps replay the same recorded fixtures and CI compares byte-for-byte — 6 of 6 recordings byte-identical across the Swift/Kotlin boundary in the adoption measurement. [Should you share code between iOS and Android?](/articles/share-code-ios-android) develops the by-construction/by-verification distinction; the practical difference is what happens when platforms drift: a bug report in one model, a failing build in the other.
## What if one screen must be native and fast?
React Native's escape is shorter than Flutter's, because the tree is already native views. Animation-heavy screens have a route inside the framework: the [Animated API's native driver](https://reactnative.dev/docs/animations#using-the-native-driver) sends the whole animation to native before it starts, so it runs on the UI thread even when the JavaScript thread is blocked — limited, per the docs, to non-layout properties such as `transform` and `opacity`. A custom-rendered or capture island is a [Fabric Native Component](https://reactnative.dev/docs/fabric-native-components-introduction) — a React component wrapping a host view, for when "core components are not sufficient" — written in Kotlin or Java on Android and Objective-C++ on iOS per the guide, usually paired with a Native Module for the pipeline itself: camera control, video encoding, audio processing.
The drawbacks: the island is written twice, the iOS half is Objective-C++ rather than Swift, its props and events cross the JavaScript boundary, and the pipeline's logic lives in the by-convention zone the boundary section above describes — outside the shared bundle, kept equal by review.
**Do you write and compile native code for it?** Yes, unless an npm library covers the island exactly. A Fabric Native Component is Objective-C++ on iOS and Kotlin or Java on Android, compiled by Xcode and Gradle in the app build, with Codegen deriving the native interface from the TypeScript spec. A library with native code spares you the writing, not the compiling: its native source is built into your app the same way.
**Can the island be SwiftUI or Compose?** Yes, wrapped. A Fabric Native Component hosts a `UIView` on iOS and a `View` on Android, so a SwiftUI view arrives through [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller) — "a UIKit view controller that manages a SwiftUI view hierarchy" — and Compose through [`ComposeView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views), which "is an Android `View`"; React Native's own component and module guides show the iOS glue in Objective-C++ and mention Swift nowhere. Expo, the framework React Native's documentation recommends, shortens both routes: the [Expo Modules API](https://docs.expo.dev/modules/overview/) lets you "write Swift and Kotlin to add new capabilities to your app with native modules and views" (its reference says rendering SwiftUI views directly is planned, with `UIHostingController` as the current route), and [Expo UI](https://docs.expo.dev/versions/latest/sdk/ui/) is "a set of components that allow you to build UIs directly with Jetpack Compose and SwiftUI from React".
In a Duet app the island is ordinary platform code — AVFoundation and Metal in the SwiftUI app, CameraX and MediaCodec in the Compose app — with nothing between it and the OS. The [reducer](/articles/duet-glossary#reducer) decides when capture starts, which filter applies and what happens with the result; a [worker](/articles/duet-glossary#worker) runs the pipeline. What is recorded and byte-compared is that seam: the requests the reducer makes and the results that come back. The pipeline's internals are outside the verified surface, and the island is written twice, once per platform: Swift compiled by Xcode, Kotlin compiled by Gradle, by design.
## What does React Native do better?
* **The talent pool and the ecosystem.** JavaScript/TypeScript is the largest developer population there is, and npm's breadth has no counterpart in any mobile stack. If your team is web engineers, React Native meets them where they are.
* **One interface implementation.** The React component tree is written once; Fabric maps it to native views on both platforms. Duet's two view layers are written per platform.
* **Framework maturity around the edges.** The project [recommends starting with a framework such as Expo](https://reactnative.dev/docs/environment-setup), and that layer — routing, builds, updates, deployment — is deep and battle-tested.
* **Web from the same skills.** React on the web and React Native on mobile share idioms, components patterns, and often engineers.
* **Governance breadth.** React and React Native moved to the React Foundation under the Linux Foundation in February 2026, with Amazon, Meta, Microsoft, Expo, Callstack and others as founding members — the project's stewardship no longer rests on one company's roadmap.
## When should you pick which?
Pick React Native for UI-neutral business applications — forms, lists, dashboards, content — when your engineering strength is web/TypeScript, native-module and native-component needs are modest or covered by maintained libraries, and one interface implementation matters more than per-platform idiom. Pick Duet when the logic is the risk surface — rules-dense domains where "both apps do the same thing" needs a mechanical check — when you want no second runtime in the binary, or when the interface must be each platform's own toolkit, authored natively.
The framing note this site puts on every comparison: Modaal builds native-first, with both interfaces in the platforms' own toolkits and no shipped runtime — a constraint fixed before any framework evaluation. React Native was ruled out by that constraint, not by a benchmark; this page compares documented properties, not race results.
## Common questions
The views are native — Fabric composes real UIKit views and Android Views. What is not platform-native is the layer deciding what to render (React, in JavaScript) and the styling system (flexbox everywhere). In practice React Native apps can look and feel very close to native; the remaining gap is idiom and the boundary cases, not the widget identity.
At author time, largely yes — 0.87 even makes the Strict TypeScript API the default. The difference is at the boundary and at runtime: types are erased before Hermes executes, and the JS-to-native seam is where mismatches surface. Duet's seam is compiled end to end — a contract change that breaks a caller fails the build on both platforms.
Shipping JavaScript means the bundle can be updated without a store release, within store policies — a genuine operational advantage of the runtime model, and Expo's update service productizes it. A compiled core cannot do this; Duet ships through the stores like any native app.
There is no React-Native-to-Duet converter, and this site does not claim one. The honest route is the one the share-code page describes for any rewrite decision: if the app's logic is well-tested and its native-module surface is small, staying put is usually right; a move to Duet is a re-authoring of features against recorded specs, taken one feature at a time.
React Native offers an agent the largest training corpus of any stack and one language end to end. Duet offers machine-checkable gates: a screen cannot wire to logic without a passing recording, and CI replays the corpus on every push. One optimizes for the agent's fluency, the other for catching the agent's mistakes; the iOS architecture comparison scores the axis in detail.
Duet projects are scaffolded by [Modaal](https://modaal.dev) — [starting a Duet project](/articles/duet#starting-a-duet-project) walks through the two template cards.
## Sources and further reading
* [React Native documentation](https://reactnative.dev/docs/getting-started) — the environment-setup framework recommendation, Hermes, releases
* [Native Modules and Native Components](https://reactnative.dev/docs/native-platform) — the official statement of the per-platform boundary
* [Fabric renderer](https://reactnative.dev/architecture/fabric-renderer) — how React components become platform views
* [React Native 0.82](https://reactnative.dev/blog/2025/10/08/react-native-0.82) and [0.84](https://reactnative.dev/blog/2026/02/11/react-native-0.84) release posts — New Architecture-only builds, Hermes V1, legacy code removal
* [The React Foundation announcement](https://www.linuxfoundation.org/press/linux-foundation-announces-the-launch-of-the-react-foundation) — governance since February 2026
* [Fabric Native Components](https://reactnative.dev/docs/fabric-native-components-introduction) and the [Animated native driver](https://reactnative.dev/docs/animations#using-the-native-driver) — the escape-to-native and in-framework routes for a heavy screen
* [Expo Modules API](https://docs.expo.dev/modules/overview/) and [Expo UI](https://docs.expo.dev/versions/latest/sdk/ui/); Apple's [`UIHostingController`](https://developer.apple.com/documentation/swiftui/uihostingcontroller) and Android's [`ComposeView`](https://developer.android.com/develop/ui/compose/migrate/interoperability-apis/compose-in-views) — how SwiftUI and Compose enter a native component
* [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the recording toolchain and the versioned contracts
## Read next
The full survey — including the quadrant cell React Native shares with .NET MAUI, and the one Duet shares with KMP practice.
The other one-codebase heavyweight — different answer on who draws the screen.
The decision one level up: write twice, share everything, or share the logic.
What Duet is, the combinations that scaffold it, and the six-step loop every feature follows.
What to check before committing to a builder — output, ownership, and the framework it locks you into, on modaal.dev.
# How to Add Firebase to an iOS App: Auth, Database, Analytics
Source: https://docs.modaal.dev/articles/firebase
Connect Firebase to your iOS app — authentication, cloud database, file storage, functions, analytics, and crash reporting — in a few clicks from Modaal.
## What is Firebase for?
Firebase is Google's **backend-as-a-service** for mobile apps. You don't run servers, manage databases, or write auth flows from scratch — Firebase gives you all of that through a small set of SDKs.
Typical things you'd use it for:
Email+password, Apple, Google, phone (SMS code), anonymous — with one line per provider. Users stay signed in automatically across app launches.
Save user data, share it across devices, and collaborate in real time. **Firestore** keeps a phone's local cache in sync even when offline.
Need a **relational** backend — tables, joins, transactions? **Firebase Data Connect** gives you managed Postgres with a typed Swift SDK. See the dedicated guide.
User avatars, attached photos, recorded audio — anything that's too big to go in the database.
Server-side code you don't have to host. Run logic on signup, send push notifications, call paid APIs without exposing keys in the app.
See which screens users visit, what they tap, where they drop off. Free, unlimited.
Real crash reports from real users, grouped by cause, with stack traces and breadcrumbs. Required reading before every release.
Send notifications to one user, a segment, or everyone — from the Firebase console or your own backend.
Flip feature flags, tune limits, A/B test copy — without shipping a new app version.
**Need a relational database?** Firestore is a document store — it's great for document-shaped data and real-time sync, but it's not Postgres. If your data is naturally relational (strict schemas, complex joins, multi-row transactions), you don't have to leave Firebase: use **[Firebase Data Connect](/articles/firebase-data-connect)**, Google's managed Postgres backend, which Modaal sets up and manages from the same Firebase panel.
***
## Step-by-step setup
Open the **Firebase** tab in Modaal (the flame icon in the left sidebar of your project). The panel walks you through connecting your Google account, picking (or creating) a Firebase project, and wiring the iOS app.
Click **Connect**. A browser window opens; sign in with the Google account you use (or plan to use) for Firebase. Modaal stores the access token locally and refreshes it automatically in the background — you don't sign in again every day.
If your Firebase projects are under a different Google account than your primary sign-in, sign in with that one. You can change accounts later from the Firebase panel's "⋯" menu.
Some operations (project creation, deployments) go through Google's `firebase` command-line tool. Modaal checks whether it's installed and offers to install it for you if not. Click **Install** — this runs `npm install -g firebase-tools` behind the scenes.
The **Firebase project** dropdown lists every project visible to the signed-in Google account.
* **Reuse an existing project** (e.g., one you already created in the Firebase console for another app) by picking it from the list.
* **Create a new one** by selecting **— Create Project —** at the top of the dropdown. Modaal creates it under your Google account using the `firebase projects:create` CLI command.
The panel compares your iOS bundle identifier (from `xcodegen.yml`) against the iOS apps registered in the Firebase project.
* If there's a match, you're done — Modaal downloads `GoogleService-Info.plist`, drops it into `src-ios/App//`, and wires it into the Xcode project.
* If there's no match, click **Register this app**. Modaal registers the bundle id with Firebase and downloads the plist automatically.
The **Deployable services** card lets you toggle Firestore, Cloud Storage, Cloud Functions, and Hosting. Each toggle scaffolds the local files Firebase expects (rules, TypeScript function starter, static site with AASA) and adds the matching top-level key to `firebase/firebase.json`.
You can click **Initialize Firebase project** to turn them all on in one step.
From the chat, say:
> "Add Firebase sign-in with Apple, and let users save their favorite recipes to Firestore."
The agent reads Modaal's Firebase integration knowledge base, picks the right wrappers, writes the Swift code, and runs the build. When it needs something only a human can do — enabling a provider in the Firebase console, uploading an APNs auth key for push — it tells you exactly what to do and where.
***
## How Modaal wires Firebase — and why
Modaal doesn't drop the vanilla `firebase-ios-sdk` into your app. It adds a small Swift package called [`modaal-firebase-wrappers`](https://github.com/modaal-agent/modaal-firebase-wrappers) that wraps each Firebase SDK behind a **protocol**.
Your app depends on `ModaalFirebaseAuth`, `ModaalFirebaseFirestore`, etc. — one product per service, each re-exporting the parts of the underlying SDK you need, plus a protocol you can inject and mock.
### Why not vanilla `firebase-ios-sdk`?
With the vanilla SDK, every screen that reads Firestore depends on `Firestore` directly. That means **every unit test needs a real Firestore emulator running**, or a tangled manual mock. With `ModaalFirebaseFirestore`, your screen depends on a `FirestoreServiceProtocol`. Unit tests inject a fake that returns fixed documents — no emulator, no network, milliseconds per test.
Same story for Auth, Functions, Storage, Analytics, and Remote Config. Each one ships a protocol and a ready-made mock you can drop into test targets.
When the agent writes Firebase code, it needs to know the SDK's types and method signatures. The vanilla `firebase-ios-sdk` is **over a million tokens** of symbol information across a dozen modules. The agent can't hold that in context, so it either hallucinates APIs or skips parts of the SDK.
Modaal's wrappers are tiny — a few hundred lines per product. The agent reads the **protocols**, sees exactly what's available, and writes code that compiles, with fewer retries and lower bills.
The app builds its service dependencies once, at launch, in a single composition-root file. Swapping `FirestoreService()` (real) for `FakeFirestoreService(fixtures: ...)` (preview / UI test) is a one-line change. No conditional compilation, no feature flags, no `#if DEBUG`.
This is the same pattern used by RIBs and clean-architecture iOS apps — the wrappers just make it easy for you without writing boilerplate.
Google bumps the Firebase SDK every few weeks. Most updates are silent, but occasionally a public type gets renamed or an enum shifts. When your code depends on `ModaalFirebaseAuth` instead of `FirebaseAuth`, the upgrade lands in the wrapper — your app code usually doesn't need to change.
### What Modaal puts in your project
* **`src-ios/App//GoogleService-Info.plist`** — the per-app config file downloaded from Firebase. Added as a resource in `xcodegen.yml`.
* **`Package.swift` / xcodegen dependency on `modaal-firebase-wrappers`** — the SPM package. You import only the products you need (e.g., `ModaalFirebaseAuth`, `ModaalFirebaseFirestore`).
* **`firebase/firebase.json`** — the CLI config. One top-level key per enabled service.
* **`firebase/.firebaserc`** — pins the default Firebase project id for CLI commands.
* **`firebase/firestore.rules`**, **`firebase/storage.rules`** — security rules (start as default-deny).
* **`firebase/functions/`** — TypeScript + Node.js starter for Cloud Functions, with `package.json`, `tsconfig.json`, example callable.
* **`firebase/hosting/public/`** — static-site root, including the Apple App Site Association (AASA) file pre-wired with your bundle id and team id for universal links.
* **`.modaal/firebase_deployments.json`** — Modaal-owned ledger tracking the Firebase project id, iOS app id, and content hashes of what's been deployed. Not secret; safe to commit.
* **A `[Firebase Crashlytics] Upload dSYMs` postBuildScript** — automatically added to your Xcode target so crash reports are symbolicated without extra work.
**GoogleService-Info.plist** is safe to commit to source control. It's not a secret — it identifies your app to Firebase, but access control lives in Firestore/Storage security rules and in provider restrictions, not in the plist itself.
***
## What do people build with Firebase?
### Scenario 1: Add sign-in with Apple + save favorites
You have a working app. You want users to sign in with Apple and save a list of favorites that syncs across their devices.
Click the flame icon, connect your Google account, pick (or create) a Firebase project, register the iOS app. Covered above in **Step-by-step setup**.
Firebase needs Apple as a **provider** turned on. The agent can't do this — it's a privileged action in your Firebase project.
Open [Firebase Console → Authentication → Sign-in method](https://console.firebase.google.com), enable **Apple**, and save. (The console will ask you to register a Service ID in your Apple Developer account — it walks you through it.)
In chat:
> "Add Apple sign-in using `ModaalFirebaseAuth`. After sign-in, save the user's uid to the `users/{uid}` document in Firestore. Add a Favorites screen that reads and writes `users/{uid}/favorites`."
The agent will:
* Add the `ModaalFirebaseAuth` and `ModaalFirebaseFirestore` products to `Package.swift`.
* Add a "Sign in with Apple" button and a sign-in flow.
* Add a `FavoritesService` that talks to Firestore through the protocol.
* Add a real-time listener so favorites sync across devices without a refresh button.
* Run the build and fix any errors.
The scaffolded `firestore.rules` is default-deny for safety. Ask the agent:
> "Update `firestore.rules` so users can read and write their own `users/{uid}/**` subtree but nothing else."
Click **Deploy** on the Firestore row in the Firebase panel. The rules go live in \~10 seconds.
### Scenario 2: Catch crashes in production
You're a week before launch and want to know what breaks on real devices.
From chat:
> "Add `ModaalFirebaseCrashlytics` to the app. Log non-fatal errors from the top-level `catch` branches in `FavoritesService`."
The agent adds the product, initializes it at launch, and wraps your `catch` blocks with `Crashlytics.record(error:)`. The dSYM upload script was already added when you registered the iOS app, so symbolicated reports arrive automatically.
Ask:
> "Add a hidden `crashNow()` trigger in the Settings screen that forces a crash, so I can verify Crashlytics is catching events end-to-end."
Build, run on device, crash the app, relaunch. Within a couple of minutes, the crash appears in [Firebase Console → Crashlytics](https://console.firebase.google.com).
> "Remove the `crashNow()` trigger from Settings."
### Scenario 3: Send a welcome notification
You want users to receive a push notification the first time they sign in.
In the Firebase panel's **Deployable services** card, toggle **Cloud Functions**. Modaal scaffolds `firebase/functions/` with a TypeScript starter.
Cloud Functions require the **Blaze (pay-as-you-go) plan**. Free-tier quotas are generous, but you have to enter a credit card. Upgrade from the Firebase console if prompted.
Push notifications go through Apple Push Notification service (APNs). Open your Apple Developer account → **Keys** → create a new key with **APNs** enabled → download the `.p8` file. Upload it to [Firebase Console → Project Settings → Cloud Messaging → Apple apps](https://console.firebase.google.com).
> "Add `ModaalFirebaseMessaging` and request notification permission on first launch. When a new user is created in Firestore (`onDocumentCreated` trigger on `users/{uid}`), send them a welcome notification via FCM."
The agent adds the iOS side (permission request, FCM token registration, foreground handler) **and** writes the Cloud Function in `firebase/functions/src/index.ts`.
Run `cd firebase/functions && npm install && npm run build` (the agent can do this for you). Then click **Deploy** on the Functions row in the Firebase panel. Watch the logs stream in the modal.
***
## What do I deploy, and when?
When the agent (or you) edits a file under the `firebase/` folder, those changes are **local only** until you click the matching **Deploy** button in the Firebase panel. The live app keeps running with the last-deployed version until then.
The Firebase panel's **Deployable services** card shows one row per service with a colored dot:
* 🟢 **Green** — what's live matches your local files. Nothing to do.
* 🟡 **Yellow** — you have local changes that haven't been deployed yet. Click **Deploy** on that row.
* ⚪ **Gray** — the service is set up locally but has never been deployed. Click **Deploy** to publish it for the first time.
* 🔴 **Red** — the last deploy failed. Click **Deploy** again; the log window shows what broke.
### Changed this? Deploy that.
| You edited or added… | Deploy this | What happens if you skip the deploy |
| - | - | - |
| **`firebase/firestore.rules`** — who can read/write your database | **Firestore rules** | The database keeps enforcing the old rules. Users can't reach new data, or can reach data you meant to lock down. |
| **`firebase/firestore.indexes.json`** — added a new query in the app | **Firestore indexes** | New queries silently fail in the app with a "query requires an index" error. |
| **`firebase/storage.rules`** — who can upload/download files | **Storage rules** | Same as Firestore rules — old rules stay in effect. |
| **Anything inside `firebase/functions/`** — your server-side code | **Cloud Functions** | Clients keep calling the old version of your function until you redeploy. |
| **Added an npm package** in `firebase/functions/package.json` | **Cloud Functions** (after `npm install && npm run build`) | The function crashes at cold-start because the package isn't in the deployed bundle. |
| **Anything in `firebase/hosting/public/`** — your static site | **Hosting** | The public site keeps serving the last-deployed content. |
| **The AASA file** (`apple-app-site-association`) for universal links | **Hosting** | iOS downloads the AASA from your live site, not from your computer. Until you redeploy, universal links behave however the previous AASA said. |
| **Changed `DEVELOPMENT_TEAM`** in iOS Project Settings after Hosting was set up | **Hosting** | Modaal automatically rewrites the AASA locally, but universal links won't work with the new team id until the Hosting deploy publishes the updated file. |
### You do NOT need to deploy after:
* **Swift code changes** in your iOS app — those go live when you run or ship the iOS app, not through Firebase deploy.
* **Swapping the Google account connected to Modaal** — that's just a sign-in change.
* **Turning Firebase Analytics events on/off in code** — events go live with the next iOS build.
* **Enabling Apple / Google / phone sign-in** — that's done in the [Firebase Console](https://console.firebase.google.com) → Authentication → Sign-in method, not via Deploy.
* **Uploading an APNs push key** — done in the Firebase Console, not via Deploy.
* **Editing Remote Config values** — done in the Firebase Console → Remote Config (click Publish there).
**Not sure what's pending?** Look at the dots in the Firebase panel. Any yellow, gray, or red dot is something worth deploying. The agent also tends to end its turn with a sentence like *"Now open the Firebase panel → Deploy on the Firestore rules row"* — that's your cue.
***
## Fix a broken Firebase setup
When something goes wrong with Firebase, the best first move is almost always:
Type: **"Check the Firebase setup for this project and fix anything that's broken."**
The agent walks through every place Firebase wiring lives — the project settings file, the iOS build configuration, the `GoogleService-Info.plist`, the wrappers package, the crash-reporting upload step — and fixes whatever's out of place. For anything that can only be done by a human (like enabling a provider in the Firebase console or upgrading the billing plan), it tells you the exact button to click or URL to open. Prompts like this run with the [Plan toggle](/articles/modes-and-prompts) off.
[Modaal](https://modaal.dev) also surfaces two ready-made prompts from the Firebase panel's "⋯" menu — click one and the agent does the rest:
* **Fix Firebase integration** — brings your project's local files back in sync with Firebase (re-downloads the plist, re-links it in the iOS project, re-adds the crash-reporting upload step).
* **Migrate to wrappers** — if your project still uses the vanilla Firebase SDK directly, this switches it over to the Modaal wrappers without changing how the app behaves.
If the Firebase project already backs a web app, [migrating a Lovable app to a mobile app](https://modaal.dev/blog/migrate-lovable-app-to-mobile-app) covers which parts of that backend the native app reuses unchanged.
### Fix the common Firebase errors
Google access tokens expire after about an hour. Modaal refreshes them automatically in the background when the panel opens. If the refresh fails because of a transient network issue, the banner clears on the next Refresh click. If it persists, click **Connect** again — a re-auth takes ten seconds and doesn't disturb any other Firebase state.
The Google account you're signed in with isn't a member of that Firebase project. This usually means the project was created under a **different** Google account than the one you used to sign in.
Options:
* Click **Sign in with a different Google account** in the error banner and use the account that owns the project.
* Or ask the project owner to invite you as a member in Firebase Console → Project settings → Users and permissions.
Some operations (creating a project, deploying rules or functions) use Google's `firebase` command-line tool, which has its own separate sign-in.
Modaal shows a **Sign in via Terminal** button when this is needed — click it, a terminal opens with `firebase login` pre-typed, finish the browser flow, click **Retry**. You only do this once per machine.
Firebase Storage needs a one-time console bootstrap. Open [Firebase Console → Storage](https://console.firebase.google.com) and click **Get started**. Come back to Modaal and click Deploy again.
Cloud Functions require Firebase's pay-as-you-go plan (free-tier quotas are generous, but a credit card is required). Click the URL in the error to upgrade, then retry the deploy.
Checklist:
* Did the app crash on a **real device**, not the Simulator? (Simulator crashes don't upload.)
* Did you **relaunch** the app after the crash? Crashlytics uploads on the next launch, not at crash time.
* Is the `[Firebase Crashlytics] Upload dSYMs` post-build script present in your Xcode target? (The Firebase panel's status banner shows this explicitly. If it's missing, click **Fix Firebase integration**.) This script turns raw crash addresses into readable function names — without it, reports show up but you can't tell where the crash happened.
The **AASA file** at `firebase/hosting/public/.well-known/apple-app-site-association` is what tells iOS to open your app when someone taps a link to your site. It's generated when Hosting is first enabled, using whatever Apple Team was selected at that moment. If you change the team later from the iOS Project Settings panel, Modaal automatically rewrites the AASA file to match. If you edited it by hand before changing the team, re-run **Deploy** on the Hosting row to publish the refreshed file.
Click the refresh icon (↻) at the top of the Firebase panel to re-fetch the list of projects. If the project still doesn't appear, the Google account signed in to Modaal probably isn't the one that created it — re-check which account you used in the Firebase console.
From the Firebase panel's "⋯" menu, pick **Disconnect**. This clears the stored Google token but **does not** touch files in your repository (`GoogleService-Info.plist`, `firebase/…`, Package.swift, etc.) — your app keeps working and can be re-connected to the same or a different Firebase project later.
To remove Firebase from the project entirely, ask the agent:
> "Remove all Firebase integration from this project — the wrappers package, the plist, the post-build script, and the `firebase/` directory."
**Still stuck?** Share a screenshot of the Firebase panel (the top status banner shows exactly which piece of the setup is out of place) in our [Discord](https://discord.gg/ng84Jm92tm) — it's usually enough for us to pinpoint the fix.
# Firebase Data Connect: A SQL Backend for Your iOS App
Source: https://docs.modaal.dev/articles/firebase-data-connect
Add a real SQL (PostgreSQL) backend to your iOS app — relational tables, joins, transactions, and a typed Swift SDK — managed from Modaal's Firebase panel.
Firebase **Data Connect** is Google's SQL backend for Firebase: a fully-managed **PostgreSQL** database (running on Cloud SQL) that you model with a GraphQL schema and query with a **typed Swift SDK** generated for you. It sits next to the rest of Firebase — same project, same sign-in, same billing — and coexists with Firestore in the same app.
[Modaal](https://modaal.dev) manages the whole lifecycle from the **Firebase panel**: scaffold the service, generate the typed Swift SDK, and deploy the schema — no Terminal, no hand-written GraphQL plumbing. [Your agent](/guides/connect-your-agent) writes the schema and the Swift data layer; you click the buttons that talk to Google. Where the SQL schema already exists behind a web app, [migrating a Lovable app to a mobile app](https://modaal.dev/blog/migrate-lovable-app-to-mobile-app) covers which parts of that backend the native app keeps.
This article is the companion to the main **[Firebase](/articles/firebase)** guide. Everything there — connecting your Google account, installing the CLI, the Deploy panel, Crashlytics, sign-in — still applies. This page covers only the SQL/Data Connect piece.
## Data Connect vs Firestore — which one?
Both are databases in the same Firebase project, but they solve different problems. Firestore stores **documents**; Data Connect stores **rows in relational tables**.
Your data is **relational** — separate `User`, `Friendship`, `Post`, `Order` tables joined by foreign keys. You need **joins**, **server-side aggregation**, or **transactions** that write several rows atomically. You want a **typed query surface** the compiler checks.
Your data is **document-shaped** — self-contained or tree-shaped records. **Offline-first** reads and writes are a product requirement. Per-document writes dominate, and you're fine with weakly-typed dynamic field access.
| Pick **Data Connect** when… | Pick **Firestore** when… |
| - | - |
| The schema has foreign keys, many-to-many tables, or joins are common | Documents are self-contained or naturally tree-shaped |
| Server-side aggregations or multi-row transactional writes are required | Per-document writes are the dominant pattern |
| The model is naturally normalized (`User` / `Friendship` / `Post` tables) | The model is naturally denormalized (a user doc with an embedded friend list) |
| You want a typed Swift API generated from a schema | You're OK with dynamic, untyped field access |
| Offline-first is **not** a hard requirement | Offline reads/writes are a product requirement |
**If both fit, default to Firestore** — its offline cache, security rules, and indexes are more mature. Choose Data Connect when the schema *genuinely* needs relational semantics and you can accept the requirements below. You can also use **both in the same app** — see [Using both together](#using-both-together).
## Before you start — three requirements
Data Connect asks a little more of your project than Firestore does:
The generated Swift SDK is built on Apple's **Observation framework** (`@Observable`), which is iOS 17-only. Modaal raises your app's deployment floor to iOS 17 automatically when it wires the SDK in. If your app must ship to iOS 16 or earlier, Data Connect isn't an option — use Firestore.
Data Connect provisions a **Cloud SQL Postgres instance**, which is paid infrastructure — so the Firebase project must be on the **Blaze** plan (a credit card on file). New services start on the SQL Connect no-cost trial, but billing must be enabled. If it isn't, the first deploy tells you in the log.
Every Data Connect action is gated on a **region** being set (the panel's **Region** card). The region fixes where your Cloud SQL database physically lives, so it's **permanent** — changing it later means migrating the whole database. It also carries **data-residency / GDPR** weight: if you store EU users' personal data, pick an EU region (`europe-west1`, `europe-west3`, …) so the data stays in the EU. The Data Connect buttons stay disabled with a *"Set the project region first"* hint until you've chosen one.
## The three panel actions
Data Connect lives in the **Firebase** panel (the flame icon in the left sidebar). Once a region is set, three actions drive everything — and, exactly like every other Firebase operation, each one **streams its logs into a modal** and runs against Google's servers for you. The agent points you at the button; it never shells out to the `firebase` CLI itself.
On a project with no Data Connect yet, the **Add a service** card shows a **Set up** button. Click it and Modaal scaffolds `firebase/dataconnect/` — a `dataconnect.yaml`, an (empty, comment-only) `schema/schema.gql`, and a `connector/` with `queries.gql` + `mutations.gql` — deriving sensible defaults from your project name (service id, Cloud SQL instance id, and the Swift package name, e.g. package `MyAppConnector`, service `myapp`).
An **empty schema is valid**. The scaffolded comment-only `schema.gql` compiles and deploys fine — it provisions the service and the Cloud SQL instance with no tables. Add tables when you're ready; there's no "you must define something first" step.
The **Data Connect** row has a **Generate SDK** button (it reads **Regenerate SDK** once the schema has changed). It runs Google's code generator server-side, commits the typed Swift package under `src-ios/Libraries//`, and **auto-wires** it into your app's main Swift package (raising the iOS-17 floor). You don't touch `Package.swift` or `xcodegen.yml` — the panel does it.
Schema deploys ride the **shared Deploy button** below the service rows — Data Connect is just another target there, alongside Firestore, Storage, Functions, and Hosting. When you've edited the schema, the Data Connect row shows a **Schema changed** badge and the Deploy button includes it. Deploying applies the change to the live Postgres database (see [Deploying schema changes](#deploying-schema-changes) for the additive-vs-destructive rules).
**"Regenerate SDK" and "Deploy" are two different things — and a schema edit usually needs both.** The **SDK stale** badge means your *local* typed Swift package is behind your `.gql` files → click **Regenerate SDK**. The **Schema changed** badge means the *live database* is behind your local schema → click **Deploy**. They drift independently. After editing the schema you almost always want to *regenerate the SDK* **and** *deploy the schema*.
The Data Connect row also shows a one-line status — the service id, package name, and region — plus an aggregate badge: **Not generated**, **Not wired**, **Wired**, **SDK stale**, or **Schema changed**.
## What Modaal puts in your project
* **`firebase/dataconnect/dataconnect.yaml`** — the service config: service id, region, and the Cloud SQL instance id. The service id and instance id are **frozen after the first deploy** (renaming a live service is a data migration, not an edit).
* **`firebase/dataconnect/schema/schema.gql`** — your tables, written in Data Connect's GraphQL dialect (`@table`, `@ref`, `@index`, `@auth`, …). Starts empty.
* **`firebase/dataconnect/connector/queries.gql`** and **`mutations.gql`** — your read and write operations. Split by convention; the code generator turns each one into a typed Swift call.
* **`src-ios/Libraries/Connector/`** — the **generated, committed** Swift package. It's checked into source control on purpose, so teammates can build without installing the Firebase CLI. Don't hand-edit it — it's overwritten on every regenerate.
* **A `dataconnect` block in `firebase/firebase.json`** — so `firebase deploy` picks the service up.
The generated Swift package is **committed source**, not build output. That keeps builds deterministic and means only the person editing the schema needs the Firebase CLI. When you (or the agent) change the schema, regenerate and commit the refreshed package.
## How the agent builds with it
You don't write GraphQL or wire up Swift generics by hand. Ask the agent, and it reads Modaal's Data Connect knowledge base and does the modeling for you:
> "Model a friends feature in Data Connect: a `User` table keyed by the Firebase Auth uid, a `Friendship` join table with an `isActive` flag, and a `Memory` table each user can share with several friends. Add queries for my friends list and my incoming feed."
The agent will:
* Write the tables and relationships in `schema/schema.gql`, and the read/write operations in `queries.gql` / `mutations.gql`.
* Tell you to click **Regenerate SDK** and **Deploy** in the panel (only you can push to Google).
* Write the iOS data layer behind a **per-feature repository protocol** — one clean protocol your screens depend on, with the generated SDK hidden inside a single implementation file. That keeps your views testable (they inject a fake repository) and swappable (Data Connect could be replaced without touching feature code).
* Keep server-only operations (privileged cross-user reads, account-deletion cascades, invite redemption) **out of the shipped client SDK** by putting them in a separate connector, so they never appear as callable symbols in your app binary.
The architecture the agent follows — protocol-per-feature, a domain type mirrored at the boundary, Combine and async variants side by side — mirrors the Modaal Firebase **wrappers** pattern described in the [Firebase guide](/articles/firebase#how-modaal-wires-firebase-and-why). You get the same testability and token-efficiency benefits; you just don't have to think about them.
## Keep the UI in sync with live queries
Data Connect queries can be **observed** — the UI updates when the underlying rows change — the same way Firestore listeners work. The agent bridges the generated observable query into your app's Combine or SwiftUI layer and cleans up the subscription when the view goes away. Refreshes can be **automatic** (a query keyed by primary key refreshes when a matching write executes) or **time-based** (a periodic backstop that catches changes made on another device). You don't manage any of this directly — you ask for "a friends list that stays in sync across devices" and the agent picks the right refresh strategy.
## Deploying schema changes
Deploying Data Connect is different from deploying rules or functions, because **the database has data in it**. Two kinds of migration:
* **Additive** — a new table, a new nullable column, a new index. These apply **automatically**, no prompt.
* **Destructive** — dropping a column, narrowing a type, removing a `NOT NULL` constraint. These are **blocked**: the deploy stops, the modal shows you the Postgres migration plan (the exact SQL), and an explicit **Apply destructive migration** button is the only way to proceed.
**Read the SQL diff before you click Apply destructive migration.** A destructive migration runs with `--force` and can **permanently delete data** — it cannot be undone. The deploy log shows precisely what will be dropped. Never click it blind, and never let the agent tell you to click it without showing you the diff first.
**Almost never.** When this opaque error appears right after the log's `ensuring required API … sqladmin.googleapis.com` lines, it's usually **not** a flaky backend — it's a *breaking change* (a destructive SQL migration, or a query field that changed from required to optional) that the non-interactive deploy can't prompt you to confirm. Retrying the same deploy just loops.
Modaal detects this and surfaces a **Data Connect deploy blocked** gate with two ways forward: **Deploy with `--force`** (after you've read the diff in the log), or **Open in Terminal** (you run the deploy yourself and answer the breaking-change prompt interactively). When in doubt, use **Open in Terminal** so you can review it live.
**The very first deploy provisions everything and takes several minutes.** It enables the required Google APIs and creates the Cloud SQL Postgres instance in your chosen region. The schema migration is skipped while the instance is still spinning up (that's expected, not an error) — the deploy still completes, and the *next* deploy applies your schema once the instance is live.
## Using both together
Data Connect and Firestore coexist in one app with no special wiring — Modaal's Firebase SDK resolves to a single shared Firebase core, which the generated Data Connect package uses too. A common split:
* **Firestore** — user profile documents, settings, chat messages, anything document-shaped or offline-first.
* **Data Connect** — the relational core (posts ↔ authors ↔ tags, friendships, orders ↔ items), anything that benefits from joins or transactional consistency.
Your screens depend on whichever repository fits the data; the two never reach across each other. Ask the agent to "keep profiles in Firestore but move the social graph to Data Connect" and it wires both.
## Fix greyed-out buttons, stale SDKs and blocked deploys
You haven't set a **region** yet. Every Data Connect action (Set up, Generate SDK, and the shared Deploy) is gated on it. Open the **Region** card at the top of the Firebase panel and pick one — remember it's effectively permanent and has data-residency implications, so choose an EU region if you store EU personal data.
Your generated SDK is **stale** — the schema (and the live server) moved on, but the local typed package still encodes the old operation shapes. The row shows an **SDK stale** badge. Click **Regenerate SDK** on the Data Connect row and rebuild. Always regenerate after every `.gql` edit.
The app is being built against **iOS 16 or earlier**. The generated SDK needs iOS 17 (it uses the Observation framework). Modaal raises the floor when it wires the SDK in — if you see this, check that your app target's deployment target is 17.0 and rebuild. Ask the agent to "raise the iOS deployment target to 17 everywhere Data Connect needs it."
Data Connect needs a Cloud SQL instance, which is paid infrastructure — the Firebase project must be on the **Blaze** plan. Open the [Firebase Console](https://console.firebase.google.com) → upgrade to Blaze (a credit card is required; free-tier quotas are generous), then retry the deploy.
Modaal reconciles the on-disk config against its own records through the **top-level Firebase banner** (the wiring-health surface). If it's missing metadata it can backfill, the **Fix Firebase wiring** button does it (strictly additive — it never overwrites). If the on-disk ids *disagree* with what Modaal recorded (e.g. a renamed service), it routes you to a **Have the agent fix it** prompt — treat that as a reconciliation, not a silent rewrite, because a deployed service's id and Cloud SQL instance are frozen.
See the **[Firebase guide's troubleshooting section](/articles/firebase#troubleshooting)** — account/token issues, the CLI login handoff, and the general "ask the agent to fix the setup" move all live there and apply here too.
**Still stuck?** Share a screenshot of the Firebase panel (its top status banner shows exactly which piece is out of place) in our [Discord](https://discord.gg/ng84Jm92tm) — it's usually enough for us to pinpoint the fix.
# How Android Apps Work: Anatomy of a Modern Android App
Source: https://docs.modaal.dev/articles/how-android-apps-work
An Android app is an APK or app bundle: a manifest declaring entry points, Jetpack Compose screens driven by state holders, and a Gradle build behind it.
An Android app is a signed package — an APK, or an app bundle that Google Play turns into APKs — holding a manifest that tells the system where the app starts, compiled Kotlin code whose screens are functions that turn state into UI, and the resources the app draws on. This page walks that anatomy for readers who build software but haven't built for Android: what is in the package, where code starts running, how a screen is drawn, where the logic lives, and how the finished app reaches users. Each section links the page of [Google's developer documentation](https://developer.android.com/guide/components/fundamentals) that treats it in full.
## What is inside an Android app?
The artifact users install is an **APK** — a signed archive of compiled code, resources (icons, strings, themes), and native libraries. What developers upload to Google Play is usually an **[app bundle](https://developer.android.com/guide/app-bundle)** (`.aab`), from which Play generates a smaller APK tailored to each device's screen density, CPU architecture, and language.
At the root of the package sits **[AndroidManifest.xml](https://developer.android.com/guide/topics/manifest/manifest-intro)**. It names the app, lists every entry point the system may launch, and declares the permissions the app requests — camera, location, notifications. Android reads the manifest before running any of the app's code, so nothing undeclared can be launched or granted.
## Where does an Android app start? The entry points
There is no single `main()` function to look for. The system starts an app at whichever declared entry point the situation calls for, and the manifest lists them all.
An **[Activity](https://developer.android.com/guide/components/activities/intro-activities)** is the entry point for anything the user sees. When someone taps the app's icon, Android launches the activity the manifest marks as the launcher — conventionally named `MainActivity` — and hands it a window to fill. A modern app typically has one activity hosting every screen, switching between them in code.
The **[Application class](https://developer.android.com/reference/android/app/Application)** is instantiated before any activity, once per process. Apps use it for initialization that everything else depends on — logging, dependency wiring, crash reporting.
**[Broadcast receivers](https://developer.android.com/guide/components/broadcasts)** are entry points with no UI at all: the system invokes them when an event they registered for occurs — the device finished booting, connectivity changed, an alarm fired — even if the app isn't running.
## How is a screen drawn? Jetpack Compose
Screens are written in **[Jetpack Compose](https://developer.android.com/develop/ui/compose/mental-model)**, Google's recommended toolkit for native UI: a screen is a [Kotlin](https://developer.android.com/kotlin) function annotated `@Composable` that describes what to show for the current state. When the state changes, the framework re-runs the affected functions and updates only what differs — the developer never mutates the screen by hand. A minimal but complete app:
```kotlin theme={null}
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent { CounterScreen() }
}
}
@Composable
fun CounterScreen() {
var count by remember { mutableIntStateOf(0) }
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxSize(),
) {
Text("Taps: $count", style = MaterialTheme.typography.headlineMedium)
Button(onClick = { count++ }) { Text("Tap me") }
}
}
```
(Imports omitted.) The activity's one job here is `setContent` — everything visible is the composable tree.
Search results will also surface the older layer: **XML layout files** inflated into a tree of `View` objects, edited through code. That system still runs in production apps and in most tutorials written before Compose, which is why both appear when you search — but a new project's screens are Compose functions.
## Where does the app's logic live?
Logic lives in a **state holder**: a class that owns a screen's state, exposes it as an observable value, and updates it in response to events from the UI — the pattern Google's [app architecture guide](https://developer.android.com/topic/architecture) recommends, with Jetpack's `ViewModel` as the common implementation. State flows one way, from the holder down into the composable tree; events flow back up as function calls. The payoff is that a screen's behavior can be exercised in a plain unit test — construct the state holder, send it events, assert on the state — with no device attached.
## What runs in the background?
Work that must outlive the screen — uploads, sync, media playback — runs through the platform's [background-work facilities](https://developer.android.com/develop/background-work/background-tasks): scheduled workers for deferrable jobs, and foreground services for work the user is actively aware of, which must show a notification. Android decides when deferred work actually runs, batching it around battery and network conditions, so apps request work rather than simply spawning threads that the system may kill.
## How is an Android app built? Gradle
Every Android app is built by **[Gradle](https://developer.android.com/build)**, driven by build files checked into the project — there is no IDE-owned project file that the build depends on. The `build.gradle.kts` of the app module declares the app's identity and its platform window:
```kotlin theme={null}
android {
namespace = "dev.example.counter"
compileSdk = 36
defaultConfig {
applicationId = "dev.example.counter"
minSdk = 26
targetSdk = 36
}
}
```
Three numbers define that window: `compileSdk` is the API the code compiles against, `minSdk` is the oldest Android version the app installs on, and `targetSdk` declares which version's behavior rules the app has been tested for. What each API level corresponds to, and how many devices each `minSdk` choice reaches, is the whole subject of [Android API levels: versions, codenames, and device coverage](/articles/android-api-levels).
## Where do you run it? Devices and emulators
Development runs on a real device over USB or on the **[Android Emulator](https://developer.android.com/studio/run/emulator)** — a virtual device booted from a system image of a chosen API level, defined by an AVD ("Android Virtual Device") profile such as a Pixel 8 running API 36. The emulator runs the same system the phone does, so the app, its background workers, and its notifications behave as on hardware. On a Mac, one setup pass installs the SDK, a JDK, and a first emulator — [Set up the Android toolchain](/guides/setup-android) documents it.
## How does an app reach users?
Through **Google Play**: the developer uploads the app bundle, and Play verifies, signs, and distributes per-device APKs. With [Play App Signing](https://developer.android.com/studio/publish/app-signing), Google holds the signing key and every update must come from the same developer account — devices refuse an update whose signature doesn't match the installed app. Play also enforces [a floor on `targetSdk`](https://developer.android.com/google/play/requirements/target-sdk) for new submissions and updates, so shipped apps track recent platform behavior.
## Common questions
An APK is what a device installs and runs. An app bundle (`.aab`) is what a developer uploads to Google Play: a superset package from which Play builds a smaller APK per device configuration. Outside Play — a local build, a direct download — you deal in APKs.
Not anymore, as a rule. An Activity is the entry point that owns a window; a modern app has one activity, and its screens are composable functions the app navigates between inside that window. Apps built earlier often used one activity per screen, and both designs run fine today.
Only to read existing code and older tutorials. New screens are written in Jetpack Compose — Kotlin functions, no XML. The XML `View` system remains supported, and large apps commonly contain both while newer screens accumulate in Compose.
Kotlin. Android's own APIs, documentation, and tooling are [Kotlin-first](https://developer.android.com/kotlin), and Compose is Kotlin-only. Java remains fully supported for existing code, so long-lived apps often carry both languages.
Yes — the SDK, the emulator, and the JDK all run natively on macOS, alongside Xcode. [Set up the Android toolchain](/guides/setup-android) walks through the one-banner install Modaal provides.
[Modaal](https://modaal.dev) builds the apps this page describes without you assembling the toolchain yourself; [Set up the Android toolchain](/guides/setup-android) covers what it installs.
## Sources and further reading
* [Application fundamentals](https://developer.android.com/guide/components/fundamentals) — Google's overview of apps, components, and the manifest
* [Introduction to activities](https://developer.android.com/guide/components/activities/intro-activities) — the entry-point contract in full
* [Thinking in Compose](https://developer.android.com/develop/ui/compose/mental-model) — the state-driven UI model
* [Guide to app architecture](https://developer.android.com/topic/architecture) — state holders and unidirectional data flow
* [Background work overview](https://developer.android.com/develop/background-work/background-tasks) — workers, services, and when each applies
* [Configure your build](https://developer.android.com/build) — the Gradle build system for Android
* [About Android App Bundles](https://developer.android.com/guide/app-bundle) — the publishing format
* [App signing](https://developer.android.com/studio/publish/app-signing) — how Play signs and verifies releases
## Read next
Every Android version with its API level and codename, live device-coverage numbers, and how to choose a minSdk.
iOS and Android from one shared core: feature logic written once, native SwiftUI and Jetpack Compose interfaces, and a CI gate that keeps the two apps in agreement.
This page's anatomy, applied: what a Duet-built Android app puts in each module, shown on a shipped app.
What a fixed architecture buys over a blank project: pure reducers, effects as data, and recorded behavior tests.
# Kotlin/Native GC and Swift ARC in One iPhone App
Source: https://docs.modaal.dev/articles/kotlin-native-gc-swift-arc
How a Duet iPhone app runs its Kotlin core: the Kotlin/Native collector beside Swift's ARC, SKIE's generated Swift, who frees what, and how to check a build.
The iPhone app of a Duet project on the Kotlin Multiplatform flavor runs two memory managers in one process. The Kotlin core runs under the Kotlin/Native garbage collector, which is linked into the app executable. The Swift code that calls the core runs under Swift's automatic reference counting (ARC). This page shows what the app links, what the collector costs on the main thread, who frees each object that crosses between the two, and the rules a Duet shell follows as a result.
Every figure on this page comes from a build you can reproduce: **Foyer**, the app the [Duet tutorials](/tutorials/duet) build, at `tutorial9-complete`, and **Memory Lane**, a production app on the [App Store](https://apps.apple.com/us/app/memory-lane-private-journal/id6760589051) built on the same flavor.
*Measured October 1, 2026, with Kotlin 2.4.10, SKIE 0.10.14 and Xcode 26.6 on an Apple M4 Pro.*
## What does the iPhone app link?
The iPhone app links one static XCFramework, `FoyerKit.xcframework` in Foyer. Its iPhone slice, `ios-arm64/FoyerKit.framework/FoyerKit`, is an `ar` archive with two object files:
* **`FoyerKit.framework.o`**, written by the Kotlin/Native compiler: the Kotlin core compiled to arm64 machine code, an Objective-C class for every exported Kotlin class, and the Kotlin/Native runtime (the memory allocator, the garbage collector and the safepoint machinery).
* **`FoyerKit.o`**, written by Swift's compiler from source that SKIE generated: the Swift API your app calls. It contains no Kotlin code; it calls the Objective-C classes the first object defines.
A static framework is copied into the app executable at link time, so the collector runs inside the app's own process, on threads the runtime starts.
The figures for both apps, from the Release `iosArm64` framework and the Release app executable:
| | Foyer | Memory Lane |
| - | - | - |
| Kotlin/Native object | 16.2 MB | 24.0 MB |
| Kotlin functions defined in it (`kfun:` symbols) | 7,361 | 11,963 |
| Collector, memory-manager and allocator symbols (`kotlin::gc::`, `kotlin::mm::`, `kotlin::alloc::`) | 54, 58, 96 | 54, 58, 96 |
| Concurrent-mark symbols (`gc::mark::ConcurrentMark`) | 16 | 16 |
| Objective-C classes defined in it | 559 | 852 |
| SKIE's Swift object | 1.5 MB | 2.6 MB |
| Swift symbols defined in it | 5,009 | 7,041 |
| Kotlin functions defined in it | 0 | 0 |
| Kotlin-defined Objective-C classes it references | 229 | 561 |
| Release app executable | 6.2 MB | 31.3 MB |
The object files are larger than the executable because they carry symbol tables and code the app never calls; the Release link drops both. Both executables contain the runtime's message `GC stop the world: concurrent mark took too long`, which is one way to confirm the collector shipped.
## How does Kotlin/Native manage memory?
Kotlin objects live in one heap that every thread shares. The [Kotlin/Native memory manager](https://kotlinlang.org/docs/native-memory-manager.html) reclaims them with a tracing collector:
* The collector is **concurrent mark and sweep**, with no generations. It runs on its own thread and starts a collection on memory-pressure heuristics or on a timer.
* **Marking runs concurrently with the app's threads.** Each collection stops the app's Kotlin-running threads twice, briefly; the runtime logs each stop as a "mutators pause".
* When marking takes too long, the runtime finishes it with the threads stopped and logs `GC stop the world: concurrent mark took too long`. When the heap grows past its target while a collection is running, the threads running Kotlin code wait until that collection finishes.
ARC frees a Swift object when its last strong reference goes away, at that moment and on that thread; the collector does not trace Swift objects.
### What the collector costs on the main thread
The runtime prints one block of lines per collection when the framework is built with its log option:
```kotlin src-kmp/apple-umbrella/build.gradle.kts theme={null}
target.binaries.framework {
// One log block per collection on stderr; measurement builds only.
freeCompilerArgs += "-Xruntime-logs=gc=info"
}
```
Each collection reports its two pauses as `Mutators pause time #1` and `#2`, measured from the moment the collector asks the threads to stop until they resume, plus the objects it marked and the heap before and after. The two pause times added together are how long one collection holds the main thread. `Time to pause` is the part of a pause spent waiting for every thread to reach a stopping point.
**Foyer, a scripted 100-second walk** on the iPhone 17 Pro simulator (iOS 26.5): guest sign-in, 55 taps on the buttons on screen, 14 deep links and swipes. Foyer allocates little, so its 10 collections came from the timer, one every 10.0 s, with about 970 live objects. The pauses of a collection added up to a median of 49 µs and at most 241 µs.
**A SwiftUI list scrolling at 60 frames per second on an iPhone 15 Pro (iOS 26.6.1)**, while the main thread creates Kotlin objects from Swift, far more than Foyer does, to show where the collector starts to cost frames. Each row is three 30-second runs of a Release build, timed by the app's own display link:
| Allocation on the main thread | Collections | Pauses per collection | Dropped frames |
| - | - | - | - |
| 300,000 Kotlin objects per second | 568, one every 0.16 s | median 78 µs; 99% under 3.1 ms; longest 13.7 ms | 1 of 5,399 (a 33 ms frame) |
| 1,200,000 Kotlin objects per second | 2,295, one every 0.04 s | median 19 µs; 99% under 0.07 ms; longest 7.7 ms | 1 of 5,400 (a 25 ms frame, outside any collection) |
| 300,000 Kotlin objects per second, collector disabled (`kotlin.native.binary.gc=noop`) | 0 | — | 0 of 5,400 |
| The same scroll with no Kotlin objects | — | — | 1 of 5,400 (a 25 ms frame) |
No pause lasted a full frame (16.7 ms), and the Kotlin runs dropped as many frames as the scroll with no Kotlin objects did. In the longest pause, 10.3 of the 13.7 ms was the wait for a thread to reach it. The collector-disabled build did not finish a run at 1,200,000 objects per second: it held 2.37 GiB when iOS logged a low-memory event, and it exited before its 30 seconds ended.
**Recording with Instruments changes the result.** With the Animation Hitches template recording, the same 300,000-objects-per-second runs dropped 15 frames in 5,301, and the longest frame lasted 869 ms; the runs with no Kotlin objects and with the collector disabled dropped none by the app's own timing. During the 869 ms frame the main thread was inside `+[KotlinBase allocWithZone:]`, in the allocator's page lookup, where it yielded the CPU 43,147 times, and the collector thread was blocked; the collection's two logged pauses added up to 40 µs. When a hitch trace shows a long frame inside a Kotlin allocation, run the same scenario without the recording and time the frames in the app before attributing the frame to the collector.
To see collections in Instruments next to your own intervals, also set `kotlin.native.binary.enableSafepointSignposts=true` in `gradle.properties`.
## What does SKIE add?
The Kotlin/Native compiler exports the core through an Objective-C header. [SKIE](https://skie.touchlab.co/) adds Swift source on top of that header, and Swift's compiler builds it into the framework's second object. SKIE generates wrappers only: the Kotlin runs as Kotlin/Native compiled it, and SKIE's Swift calls the Objective-C classes. Foyer's Release build generates 127 Swift files, under `src-kmp/apple-umbrella/build/skie/binaries/releaseFramework/RELEASE/iosArm64/swift/generated/`.
Its `StateFlow` wrapper shows the pattern. The Swift class holds the Kotlin flow and reads through to it:
```swift Skie/Skie.SkieSwiftStateFlow.swift (generated by SKIE 0.10.14) theme={null}
public final class SkieSwiftStateFlow : FoyerKit.SkieSwiftFlowProtocol,
Swift._ObjectiveCBridgeable {
@_spi(SKIE)
public let delegate: FoyerKit.Kotlinx_coroutines_coreStateFlow
public var value: T {
delegate.value as! T
}
// …bridging and makeAsyncIterator()
}
```
Holding a `SkieSwiftStateFlow` therefore holds the Kotlin `StateFlow`. Iterating it with `for await` creates a `SkieSwiftFlowIterator`, which starts a Kotlin collection and cancels it in its `deinit`.
What crosses as what:
| Kotlin declaration | What Swift sees |
| - | - |
| A sealed interface or class | An Objective-C protocol or class for each type in the hierarchy, plus `onEnum(of:)`, which returns a Swift enum to `switch` over exhaustively |
| `StateFlow` returned by a top-level function | `SkieSwiftStateFlow`: `.value`, and an `AsyncSequence` of `T` |
| A `StateFlow` declared on an exported interface | The raw `Kotlinx_coroutines_coreStateFlow`, with no element type ([Tutorial 4](/tutorials/duet-04-workers#add-the-two-streams-to-the-ports) re-exposes such streams as top-level functions) |
| A `suspend` function | An `async` Swift function |
| A Kotlin class | An Objective-C class with the module's prefix, such as `FoyerKitSplashState`, named `SplashState` in Swift |
## What happens when Swift holds a Kotlin object?
Swift holds a Kotlin object through its Objective-C class, so ARC counts the references. The Kotlin runtime treats every object with a nonzero count as a root: the collector does not free it while Swift holds it.
After the last Swift reference is released, the object is freed **on a later collection**, not at the release. The Kotlin page on [ARC integration](https://kotlinlang.org/docs/native-arc-integration.html#object-reclamation) states it: "An object is reclaimed only during the garbage collection." Nothing in the Kotlin object runs when Swift lets go of it.
Measured with Foyer's splash store in a test program on macOS (the harness in [the cycle section](#why-does-a-cycle-across-the-boundary-leak-and-how-do-you-avoid-it)): after the last Swift reference to the store was released, the store was freed 0.30 s later while the program allocated Kotlin objects, and 10.08 s later while it allocated nothing, when the collector's timer fired.
## What happens when Kotlin holds a Swift object?
A Duet feature hands Swift objects to its Kotlin store: the live environment (`LiveSplashEnvironment` in Foyer), each port implemented in Swift, and the closures they carry. The Kotlin object that holds one keeps it alive with an Objective-C retain.
The Swift object is released after the collector frees its Kotlin holder. The runtime's finalizer does the release, and the Kotlin page on [deinitializers](https://kotlinlang.org/docs/native-arc-integration.html#deinitializers) states where: on the main thread for objects passed to Kotlin on the main thread, and on a collector thread otherwise. The binary option `kotlin.native.binary.objcDisposeOnMain=false` moves every release to the collector thread. Because a Swift `deinit` may call back into Kotlin, it runs after the collection ends. A chain in which Kotlin and Swift objects alternate is therefore freed over several collections: the Kotlin page's example, Kotlin → Swift → Kotlin → Swift, takes two.
In the same test program, the environment's `deinit` ran on the main thread 0.30 s after its store's last Swift release while the program allocated, and 10.08 s after it while idle. Code in the `deinit` of a Swift object you hand to Kotlin runs that long after the screen that created the object closed.
## Why does a cycle across the boundary leak, and how do you avoid it?
ARC frees a Swift cycle only when you break it with a `weak` or `unowned` reference. The collector frees a cycle of Kotlin objects on its own. A cycle that contains objects of both kinds is never freed: ARC does not see the Kotlin references, and the collector treats the Swift-held Kotlin object as a root. The Kotlin page on [retain cycles](https://kotlinlang.org/docs/native-arc-integration.html#retain-cycles): "if at least one Objective-C object is present, the retain cycle of a whole graph of objects cannot be reclaimed".
The cycle a Duet shell can create: an object owns a Kotlin store, the store holds its environment, and the environment's delegate closure captures the owner strongly.
```swift theme={null}
final class Owner {
var store: AnyObject?
func handle(_ event: SplashDelegateEvent) {}
}
let owner = Owner()
// Leaks: the closure holds `owner`, which holds the store, which holds the environment and its closure.
let environment = LiveSplashEnvironment { event in owner.handle(event) }
owner.store = makeSplashStore(environment: environment, scope: mainImmediateStoreScope())
```
The fix is a weak capture in every closure that crosses into Kotlin:
```swift theme={null}
let environment = LiveSplashEnvironment { [weak owner] event in owner?.handle(event) }
```
The test program builds both owners, releases them, and allocates 4 million Kotlin objects:
| Owner | Owner `deinit` | Environment `deinit` |
| - | - | - |
| Weak capture | at the release | 0.30 s later, on the main thread |
| Strong capture | never | never |
**What the memory graph shows.** `leaks`, the command-line reader for the memory graphs Xcode exports, reports `0 leaks for 0 total leaked bytes` for the strong-capture owner, and lists the owner and its environment as live objects. The scanner cannot see the cycle: it scans the Kotlin heap as a root, so every Swift object the cycle holds is reachable from it. Find the cycle from the object that should be gone instead:
1. Close the screen, wait 10 seconds for a collection, and export the memory graph (**File ▸ Export Memory Graph** in Xcode's memory graph debugger).
2. Find an instance of the owner type that should have been freed.
3. Trace what holds it: `leaks --traceTree= .memgraph`. A path that runs through `Region Memory Tag 246` goes through the Kotlin heap. 246 is the tag the Kotlin/Native allocator gives its memory unless the `mmapTag` binary option changes it.
Duet's emitted code captures weakly in the closures it hands to Kotlin. `BridgedStore`'s collector task is `Task { [weak self] in … }`, and a parent shell routes a child's delegate events with `mounter.mountWelcome { [weak self] event in self?.store.send(…) }`. Keep both forms when you edit them.
## Why does a Duet shell stop its store explicitly?
Releasing a Kotlin store does not stop it. A store whose effects are still running stays reachable from them: a pending timer, for example, is held by the dispatcher that will resume it. The collector does not free such a store, and its effects keep running. A Duet shell therefore stops the Kotlin store as a step of its own. Foyer's splash builder gives the bridge a teardown closure:
```swift src-ios/Libraries/FoyerKit/Sources/SplashShell/SplashBuilder.swift theme={null}
let scope = mainImmediateStoreScope()
let store = makeSplashStore(
environment: LiveSplashEnvironment(onDelegate: onDelegate),
scope: scope)
let bridged = SplashKitStore(
state: splashStateFlow(store: store),
send: { store.send(action: $0) },
teardown: {
store.teardown()
cancelStoreScope(scope: scope)
})
```
`BridgedStore.cancel()` cancels the Swift collector task and calls that closure. The shell adopts the bridged store into its `StoreHost`, which calls `cancel()` when the shell deactivates, in reverse order of adoption. [Tutorial 2](/tutorials/duet-02-two-apps#write-the-swift-shell) writes this shell.
## Why does `BridgedStore` re-read the state after `send`?
A Duet shell must observe a reduce before `send` returns: its view state and the child mounts follow from the new state in the same call. The Kotlin store reduces on the calling thread, because its scope runs on `Dispatchers.Main.immediate`. The bridged `StateFlow` delivers to Swift through `for await`, and every delivery crosses a continuation hop, so it arrives in a later main-actor turn. `BridgedStore.send` therefore forwards the action and then reads `stateFlow.value` in the same call. On each delivery its collector task re-reads `value` and discards the delivered element, because the bridged sequence buffers and can deliver a state older than the one `send` already mirrored. [Tutorial 2](/tutorials/duet-02-two-apps#write-the-consumer-package-and-the-store-mirror) writes the mirror.
## What happens to a Kotlin exception?
A Kotlin exception that reaches Swift through a function without `@Throws` terminates the process. With `@Throws(SomeException::class)` on the Kotlin function, Swift sees a `throws` function and the exception arrives as a Swift `Error`. Foyer's boundary object annotates every exported function that can throw; [Tutorial 2](/tutorials/duet-02-two-apps#export-the-replay-boundary) shows it. The Kotlin page on [Swift/Objective-C interop](https://kotlinlang.org/docs/native-objc-interop.html#errors-and-exceptions) has the full rules.
## How is Android different?
On Android the same Kotlin modules compile to JVM bytecode and run on ART, under ART's garbage collector. The Compose shell, the store and the workers are objects in one heap, and the Android app calls the store's `send` directly, with no generated layer and no retain/release at a boundary. The rules on this page apply to the iPhone app only. [Inside a Duet Android app](/articles/duet-android-app-anatomy) walks that tree.
## How do you check your own build?
Run these against the Release framework of your app's iPhone slice. The paths are Foyer's; replace `FoyerKit` with your project's framework name.
```sh theme={null}
cd src-kmp/apple-umbrella/build/bin/iosArm64/releaseFramework
mkdir -p /tmp/kit && cp FoyerKit.framework/FoyerKit /tmp/kit/ && cd /tmp/kit
ar -t FoyerKit # the two objects
ar -x FoyerKit
nm -m FoyerKit.framework.o | grep -v undefined | grep -c 'kfun:' # Kotlin functions
nm FoyerKit.framework.o | c++filt | grep -c 'gc::mark::ConcurrentMark' # the collector
nm -m FoyerKit.framework.o | grep -v undefined | grep -c '_OBJC_CLASS_\$_' # Objective-C classes
nm -m FoyerKit.o | grep -v undefined | grep -c ' _\$s' # SKIE's Swift symbols
nm -m FoyerKit.o | grep -v undefined | grep -c 'kfun:' # 0: no Kotlin in SKIE's object
```
Foyer prints:
```text theme={null}
__.SYMDEF SORTED
FoyerKit.framework.o
FoyerKit.o
7361
16
559
5009
0
```
A Debug framework lists more objects: one per generated Swift file and one per cached Kotlin library. Use the Release framework for these counts.
To check for the collector in the app you ship, search the executable inside the archive:
```sh theme={null}
strings -a "Foyer.xcarchive/Products/Applications/Foyer.app/Foyer" | grep 'concurrent mark took too long'
```
## Common questions
Yes, twice per collection, and briefly in the measurements on this page. On an iPhone 15 Pro scrolling a list while the main thread created 300,000 Kotlin objects per second, the pauses of a collection added up to a median of 78 µs and at most 13.7 ms, and 1 frame in 5,399 was dropped, as many as in the same scroll with no Kotlin objects. In a scripted Foyer walk on the simulator the longest was 241 µs. [What the collector costs on the main thread](#what-the-collector-costs-on-the-main-thread) has the method, the flag to measure your own app, and how recording with Instruments changed the result.
Yes. `@State` holds a strong Swift reference, so the Kotlin object stays alive while the view does, and the collector frees it on a later collection after the view goes away. For a Kotlin value such as a state snapshot that is all you need. For a Kotlin store, use a shell and a `BridgedStore`: a store in `@State` is never stopped, and [a store with running effects is never freed](#why-does-a-duet-shell-stop-its-store-explicitly).
SKIE adds code size, measured above as 1.5 MB in Foyer's framework and 2.6 MB in Memory Lane's before the link, and one Swift wrapper object per value it wraps: an `onEnum(of:)` result, a `SkieSwiftStateFlow`, an iterator per `for await`. Each delivery of a bridged flow crosses a continuation hop. This page has no CPU measurement of SKIE's wrappers.
The Kotlin/Native compiler runs inside Gradle, which runs on the JVM. The app scheme's build pre-action runs `scripts/assemble_kit.sh`, which runs Gradle to compile the Kotlin core into the XCFramework before Xcode links it. The JDK is a build-machine requirement; the app contains no JVM.
Yes. On the Swift flavor, each feature's reducer is Swift and the iPhone app links no Kotlin/Native framework, so none of this page applies to it. [The Duet overview](/articles/duet#the-two-duet-templates) compares the two flavors. [Duet Twins](/articles/duet-twins), in development, pairs a Swift core in the iPhone app with a Kotlin core in the Android app.
## Sources and further reading
* [Kotlin/Native memory management](https://kotlinlang.org/docs/native-memory-manager.html): the collector, its triggers, the `gc` and `-Xruntime-logs` options and the safepoint signposts
* [Integration with Swift/Objective-C ARC](https://kotlinlang.org/docs/native-arc-integration.html): reclamation timing, main-thread deinitialization, `objcDisposeOnMain` and retain cycles
* [Interoperability with Swift/Objective-C](https://kotlinlang.org/docs/native-objc-interop.html): how Kotlin declarations map to Objective-C, and `@Throws`
* [SKIE: Flows](https://skie.touchlab.co/features/flows), [Sealed classes](https://skie.touchlab.co/features/sealed) and [Suspend functions](https://skie.touchlab.co/features/suspend): the Swift layer SKIE generates
* [Gathering information about memory use](https://developer.apple.com/documentation/xcode/gathering-information-about-memory-use): Xcode's memory graph debugger and `.memgraph` files
* [Understanding hitches in your app](https://developer.apple.com/documentation/xcode/understanding-hitches-in-your-app): how Apple defines a hitch and measures it with Instruments
* [Kotlin/Native GC scheduler, `MutatorAssists.hpp`](https://github.com/JetBrains/kotlin/blob/v2.4.10/kotlin-native/runtime/src/gcScheduler/common/cpp/MutatorAssists.hpp): why threads running Kotlin code wait for a running collection when the heap passes its target
* [The duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials): `tutorial9-complete`, the Foyer tree measured on this page
## Read next
Build the framework this page measures, the `BridgedStore` mirror, and the shell that stops the Kotlin store.
Both stacks run the same Kotlin/Native runtime on iOS; they differ in who draws the screen.
The same core on Android, where it shares one heap with the Compose shell.
Shell, store, host, mount and the other terms on this page.
# Working with Modaal: A Guide to Modes and Prompts
Source: https://docs.modaal.dev/articles/modes-and-prompts
When to turn the Plan toggle on for a feature spec, when to let the agent build directly, and the prompting techniques that improve the result in each mode.
## What do the two modes share, and when does the toggle apply?
[Modaal](https://modaal.dev) is an AI system for building native iOS and Android apps. You describe an idea or a task, and the AI agent generates code, builds the project, analyzes errors, and iterates with you. The quality of the result depends directly on *how* you phrase your request and whether you ask for a plan first.
There's a single control for that — the **Plan** toggle in the composer:
* **Plan off** (the default) — the agent works directly: it writes code, builds the project, debugs, fixes, and iterates with you.
* **Plan on** — the agent produces a structured implementation plan (a feature spec) **without writing code**.
Both share the same project context, constitution, knowledge base, and tooling — toggle **Plan** on or off at any point.
**On a brand-new project you don't touch this toggle at all.** Your project's structure is chosen by [the sentence on the home screen](/articles/new-project#the-new-project-flow) before the session starts, and the first turn runs in planning mode automatically — it writes a PRD and plans the first feature. Everything below applies from your second prompt onward.
***
## The Plan toggle
You control planning with the **Plan** button next to the composer. Leave it **off** for most work; turn it **on** when you want a thorough plan before any code is written.
| Plan toggle | When to Use | What the Agent Does | Writes Code? |
| - | - | - | - |
| **Off** (default) | Building, iterating, prototyping, and one-off actions | Implements features, builds, debugs, fixes, refactors — iterating with you | Yes |
| **On** | Plan before building — complex features | Researches the codebase, writes a detailed spec | No |
The two produce different deliverables. With **Plan** on, the deliverable is the plan itself — a feature spec at `specs//spec.md` — and no code is written yet. With **Plan** off, the deliverable is working code: the agent implements your request directly. If a spec already exists from a Plan-on session, the agent reads it and builds from it.
### How it fits together
**Just created a project?** Your first turn already ran in planning mode — you have a `PRD.md` and a first feature spec waiting. Leave **Plan** off and say *"let's build this"* to start implementing.
**Working on an existing project?** Two typical workflows:
**Structured: Plan on → Plan off → repeat**
```
Plan on (spec) → Plan off (implement) → Plan on (next feature) → Plan off (implement) → ...
```
Use **Plan on** to research the codebase and produce a detailed feature spec, then turn **Plan off** to implement it. Repeat for each feature.
**Exploratory: keep Plan off**
```
Plan off (build & iterate) → Plan on (when a feature needs a thorough spec) → Plan off ...
```
Keep **Plan off** for continuous building and iteration, turning **Plan on** when a feature is complex enough to warrant a thorough spec first.
The agent picks up all context automatically when you toggle — active feature, constitution, knowledge base, attached files.
***
## With Plan on — Planning
### What it does
With **Plan** on, the agent produces a thorough written plan **before** any code is written. It:
* **Does not** edit, create, or modify any source code
* Reads any file in the codebase for research
* Produces a single output: `spec.md` inside the feature directory
* Can continue or update an existing feature spec
Planning has two flavors, and the agent **auto-detects** which applies — you don't pick:
* **A freshly created project** → the **Refine** flow (below), which runs automatically on your first turn
* **An established project** → researches your codebase and writes a feature spec referencing real files and patterns to reuse
### Refine: the first turn on a new project
The first prompt on a freshly created project is the **Refine** step, and it runs in planning mode whether or not you ever touch the toggle. You come in with an idea — anything from a single sentence to a multi-page description with Figma links — and the agent turns it into a structured project.
Refine has two phases:
* **Phase A** — Understand the idea → confirm scope if it's ambiguous → write the PRD
* **Phase B** — select the first feature from the PRD → create the feature directory → write its `spec.md`
**What the agent does:**
1. Reads your description (text, images, Figma links — whatever you provide)
2. Researches the knowledge base for patterns matching your project's architecture and platform
3. Confirms scope with you if the input is ambiguous — core features vs. deferred
4. Writes a structured PRD to `PRD.md`
5. Plans the first implementable feature and writes `spec.md`
**Refine never asks about architecture, platforms, or devices.** Those were settled by the template [the sentence](/articles/new-project#the-new-project-flow) picked, and the agent is told what was scaffolded. It won't re-scaffold or second-guess the structure either.
Refine is also prototype-first by default: the first iteration aims at a working, clickable prototype with realistic mock data, deferring auth, backends, and analytics unless you ask for them. If you'd have reached for a "vibe-code" mode, this is it — you're already in it.
After Refine completes, turn **Plan** off to start implementing the first feature.
**Prompt examples (new project):**
**A brief idea:**
> A guitar tuner app. It listens to the microphone, detects the pitch, and shows whether the string is in tune.
The agent expands this into a PRD: the main tuner screen, tuning selection (standard, drop-D, open-G), visual feedback (needle + color), session history (as a provisional suggestion). It notes that microphone access and audio analysis are core, while cloud sync is deferred. Then it writes a feature spec for the first iteration.
**A detailed description:**
> I'm building a local services marketplace. Here's what I need:
>
> **User side**: Browse services by category, view provider profiles with ratings, book appointments with date/time picker, in-app chat with provider, pay via Stripe, rate after service.
>
> **Provider side**: Create/edit profile, manage availability calendar, accept/reject bookings, view earnings dashboard.
>
> Target: iOS 18+, iPhone only. Need to support Ukrainian and English.
Here the agent preserves everything as-is, adds structure (user flows, acceptance criteria, technical considerations), and doesn't invent extra features.
**Figma-based:**
> Here's my Figma design: \[link]. Build this app exactly as designed. The first tab is a feed, the second is search, the third is profile.
The agent uses Figma MCP to pull design context and screenshots, then generates a PRD based on the UI structure.
**Just an attachment:**
You can attach a PRD, design doc, or screenshot with no text at all. The agent reads the attachment and works from its content.
### Planning on an existing project
For an existing app, **Plan** on makes the agent research the codebase thoroughly, understand existing patterns, and produce a detailed feature spec — without writing any code.
**How it works:**
1. **Parse User Input** — reads your feature description and checks for existing feature context
2. **Determine Feature Context** — decides whether to continue an existing feature or create a new one:
* If the request refines/extends the current feature → updates the existing spec
* If it describes a clearly different feature → creates a new feature directory
* If ambiguous → asks you to choose
3. **Research the Codebase** — reads source files, identifies patterns, finds reusable code, checks the knowledge base
4. **Write the Spec** — creates or updates `spec.md` with: Overview, User Scenarios, Technical Approach (Architecture, Key Design Decisions, Existing Code to Reuse), Implementation Steps (phased), Files to Create/Modify, Risks & Open Questions, Testing Strategy
5. **Report Completion** — summarizes findings and flags open questions
**Prompt examples (existing project):**
**New feature for an existing app:**
> Add push notification support. Users should be able to opt in/out per category (promotions, reminders, social). Store preferences locally and sync with the backend. Show an onboarding prompt on first launch.
The agent explores the existing codebase, understands the current architecture and patterns, and produces a detailed spec referencing actual files and functions to reuse.
**Updating an existing feature plan:**
> The notification spec needs to also handle silent push for background data sync. Add a section covering the background modes capability and how it integrates with the existing sync service.
The agent reads the existing `spec.md`, incorporates the new requirement, and overwrites with the revised version — preserving sections that are still valid.
After the plan is ready, turn **Plan** off to implement.
***
## With Plan off — building directly
This is the default. The agent executes your request directly — it writes code, builds the project, and iterates with you. Use it for anything that produces code: continuous feature-building, fast prototyping, and short, specific one-off tasks.
With **Plan** off, the agent:
* Generates code, builds the project, and iterates with you
* Verifies its work (runs the project after changing Swift or Kotlin sources)
* Suggests alternatives and improvements
* Can ask you clarifying questions via structured prompts
* Commits after completing a logical unit of work (asking you first)
If a feature was already planned with **Plan** on, the agent reads that `spec.md` for context and implements from it. On its own, **Plan** off doesn't write a spec — it goes straight to the code.
Working directly isn't "sloppy." The agent still follows the project's architectural rules (Duet, RIBs or MVVM), uses semantic design tokens, and structures modules correctly. It just doesn't write a formal plan before it starts.
### How it works
**Plan** off handles two kinds of request, and the agent picks the right shape per turn:
* **Execute** — an imperative request (*"build the project"*, *"fix the crash in ProfileViewModel.swift:42"*, *"extract this into a helper"*). The agent does the work, verifies it (runs the project if Swift or Kotlin sources changed), and reports what changed with file/line citations.
* **Answer** — a question (*"why is the build failing?"*, *"what does this function do?"*, *"where is the device list populated?"*). The agent reads the relevant files and answers directly, with citations — it doesn't start editing code just to answer.
It does what was asked and stops — no unrelated cleanups, no speculative next steps. When a change adds up to a logical unit of work, it asks whether to commit. If a feature was already planned with **Plan** on, the agent reads that `spec.md` and implements from it — thorough planning followed by fast execution.
### When to use it
* You're validating an idea and want to see results fast
* You want a clickable prototype quickly
* Requirements aren't fully clear yet — you want to iterate
* You're implementing a feature that was already planned with Plan on
* You need a one-off action: a build, a debug session, a specific fix, a refactor
**Want a quick clickable prototype?** There's no separate "prototype" mode — just say so. Describe what you want with **Plan** off and iterate, or turn **Plan** on for a prototype-focused plan first (UI + realistic mock data, integrations deferred), then build it.
### Prompts for building directly
**Starting from scratch:**
> I want to build a habit tracker. The main screen shows today's habits as a checklist. Tapping a habit marks it done with a satisfying animation. There's a stats screen showing streaks.
The agent starts with the main screen, then adds navigation and the statistics screen. After each step — build, verify, next step.
**Implementing a planned feature:**
> Let's implement this. Start with the notification preferences screen.
The agent reads the spec from your Plan-on session, picks up the first implementation step, and starts building.
**Iterating on what exists:**
> The habit list looks too plain. Let's add a card-based layout — each habit is a rounded card with the habit icon on the left, name in the center, and a circular progress indicator on the right.
The agent understands the context (which view to change), updates the layout, builds, and shows the result.
**Changing direction:**
> Actually, let's pivot. Instead of a checklist, make it a ring-based UI like Apple's Activity rings. Each habit is a ring, and filling it gives visual feedback.
The agent handles pivots smoothly — generates the new UI and preserves what didn't change (models, services).
**Technical request:**
> Add CloudKit sync. When a habit is completed on one device, it should appear on other devices within a few seconds. Use CKSubscription for real-time updates.
You can give precise technical instructions. The agent doesn't simplify — it follows your level of detail.
**Build:**
> Build the project.
or
> Build only the watchOS target.
**Build debugging:**
> The build is failing with "Cannot find type 'ThemeProviding' in scope" in TunerView\.swift. Fix it.
Give the agent the full error. It analyzes imports, module dependencies, and suggests a fix.
**Runtime debugging:**
> The app crashes on launch. The crash log shows EXC\_BAD\_ACCESS in SceneDelegate.swift line 14.
The agent checks crash context, runtime logs, and suggests a fix.
**Precise edit:**
> In MainView\.swift, change the navigation title from "Home" to use the localized string .mainScreenTitle instead.
**Refactoring:**
> Extract the booking flow (BookingDateView, BookingConfirmView, BookingPaymentView) into a separate SPM module called BookingFeature under Libraries/.
***
## What each mode writes to the feature directory
Turning **Plan** on produces a written artifact — a feature spec — that **Plan** off then implements:
```text theme={null}
specs/
└── 001-feature-name/
└── spec.md ← The feature specification (written with Plan on)
```
What the spec contains depends on how you planned it:
| How it was written | spec.md Content |
| - | - |
| **Plan on** (existing app) | Overview, User Scenarios, Technical Approach (Architecture, Existing Code to Reuse), Implementation Steps, Files to Modify, Risks, Testing |
| **Refine** (new project, first turn) | Same structure, scoped to the first iteration from the PRD. Refine also writes `PRD.md` |
**Plan** off doesn't create a spec of its own — it builds directly. But when a spec from a Plan-on session exists, **Plan** off reads it and implements from it. That's the structured workflow: plan with **Plan** on, build with **Plan** off, against the same feature directory.
***
## How six sessions went, start to finish
### Case 1. "Build Me an App" — From One Sentence to a Prototype
**Situation:** You have an idea and want to see results as fast as possible. No specs, no requirements — just a sentence.
**Approach:** Refine (the first turn) → Plan off
**Step 1 — start the project** with this in the composer, with the sentence set to **I'm building a Quick prototype**:
> A meal planning app. You pick recipes for the week, and it generates a shopping list.
The agent generates a PRD: weekly planner screen (calendar grid), recipe catalog (with mock data), auto-generated shopping list grouped by category. Immediately marks as deferred: backend, auth, sharing. Then it writes a feature spec for the first iteration.
**Step 2 (Plan off):**
> Let's build this. Start with the weekly planner screen — a horizontal scroll of 7 day cards. Tapping a day opens a sheet to add recipes.
Then you iterate:
> The day cards are too small on iPhone SE. Make them fill the width and stack vertically instead — a scrollable list of 7 rows.
> Add a "Generate Shopping List" button at the bottom of the planner. It should aggregate all ingredients from selected recipes, merge duplicates (2 onions + 1 onion = 3 onions), and group by supermarket aisle.
> The shopping list looks good but needs a "check off" feature — tapping an item strikes it through.
From one sentence to a working prototype — a few hours.
### Case 2. Complex UI from Figma
**Situation:** A designer created a detailed mockup in Figma. You need to translate it to code pixel-perfect. Multiple screens, custom components, animations.
**Approach:** Plan off (with Figma MCP)
> Here's the Figma design for the onboarding flow: [https://figma.com/design/abc123/Onboarding?node-id=1-2](https://figma.com/design/abc123/Onboarding?node-id=1-2)
>
> Build all 4 onboarding screens exactly as designed. Each screen has a full-bleed illustration, title, subtitle, and a "Continue" button. The last screen has "Get Started" instead. Add a page indicator at the bottom. Swipe gesture between screens.
The agent pulls design context via Figma MCP (colors, fonts, spacing, layout), generates SwiftUI views with the closest matching semantic tokens, and builds the project.
Iteration:
> The illustration on screen 2 is clipped at the bottom on smaller devices. Make it aspect-fit instead of aspect-fill, and add 16pt bottom padding before the title.
> Add a subtle parallax effect to the illustrations — as the user swipes, the illustration on the next screen should slide in slightly faster than the text.
### Case 3. Adding a Feature to an Existing App
**Situation:** You have a working app and want to add a complex feature. You want the agent to understand the existing code before proposing changes.
**Approach:** Plan on → Plan off
**Step 1 (Plan on):**
> Add push notification support. Users should be able to opt in/out per category (promotions, reminders, social). Store preferences locally and sync with the backend. Show an onboarding prompt on first launch.
The agent researches the codebase, identifies the existing architecture and patterns, and produces a detailed `spec.md` referencing actual files, functions, and modules to reuse.
**Step 2 (Plan off):**
> Let's implement this. Start with the notification preferences screen.
The agent reads the spec it just wrote, picks up the first implementation step, and starts building.
### Case 4. Technical Feature: Camera + CoreML
**Situation:** You need to add object detection via the camera using CoreML. This is a UIKit-heavy feature (AVCaptureSession) that integrates into a SwiftUI wrapper.
**Approach:** Plan off, with technical detail
> Add a camera scanner feature to the app. Requirements:
>
> 1. Use AVCaptureSession with the back camera, preview in a full-screen layer
> 2. Run a CoreML model (YOLOv8 — I'll add the .mlmodel file later, use a placeholder for now) on every nth frame
> 3. Overlay bounding boxes on detected objects with labels and confidence scores
> 4. In RIBs: create a ScannerRIB under Main. The interactor manages the capture session lifecycle. The view is a UIViewController with AVCaptureVideoPreviewLayer, not a SwiftUI view.
> 5. Add a "Scan" button on the main screen that attaches the ScannerRIB.
Notice: the prompt tells the agent *which* architecture to use (RIBs here — in a Duet or MVVM project, name that architecture instead), *which* system frameworks (AVFoundation, CoreML, Vision), and *where* to place the code. The more technical detail you give, the more precise the result.
Iteration:
> The bounding box overlay flickers. The issue is likely that we're updating UI on a background thread. Dispatch the overlay updates to the main queue. Also, add a confidence threshold — only show boxes with confidence > 0.7.
### Case 5. "Fix My Build" — A Debug Session
**Situation:** After a refactoring, the project stopped building. Lots of errors, and you don't want to dig through them yourself.
**Approach:** Plan off (build + debug). A bug you can see on the screen rather than in the build log takes the same mode — see [Debug your app by pointing at the screen](/articles/visual-debugging).
**Step 1:**
> Build the project.
The agent runs the build, gets the error log, analyzes it.
**Step 2** (the agent responds with analysis, you provide context):
> Fix all the build errors. The refactoring was moving SettingsInteractor from the Main module to a new SettingsFeature module — some imports and dependency wiring are probably broken.
Context about *what* you refactored helps the agent understand the root cause instead of just patching symptoms.
**Step 3** (if there are still errors after the fix):
> Build again. If there are still errors, fix them too.
An iterative build-fix cycle is a normal pattern. The agent keeps context between iterations.
### Case 6. Prototype Evolution: From Quick Builds to Structure
**Situation:** You started with Plan off, built a prototype, and it grew to 12 screens. Now you want to add a complex feature (subscription with paywall, trial period, purchase restoration) and feel you need more structure.
**Approach:** Plan on → Plan off (or a detailed Plan-off prompt)
You can turn Plan on to get a thorough spec first, or give a spec-level prompt directly with Plan off:
> Add a subscription system with these requirements:
>
> 1. **Paywall screen**: Shown when user tries to access premium features. Two plans: Monthly ($4.99) and Annual ($39.99). Highlight annual as "Best Value". Show feature comparison table.
> 2. **Purchase flow**: Use StoreKit 2. Handle: successful purchase, cancelled, failed, pending (Ask to Buy). Show appropriate UI for each state.
> 3. **Trial**: 7-day free trial for annual plan. Show trial badge on paywall. When trial ends, auto-convert to paid (standard StoreKit behavior).
> 4. **Restore**: "Restore Purchases" button on paywall and in Settings. Verify receipt server-side (mock the server call for now, return success).
> 5. **Premium gate**: Create a PremiumAccessService that checks subscription status. Inject via @Environment. Premium features: unlimited habits (free = 5), custom icons, stats export.
> 6. **Entitlement check**: On launch, verify current entitlements via StoreKit 2 Transaction.currentEntitlements. Cache status locally for offline access.
This is a Plan-off prompt, but with planning-level detail. The agent implements step by step, building after each step.
***
## How to get a better result from a prompt
### Detail Level Drives the Result
| Prompt | What You Get |
| - | - |
| `"Add a settings screen"` | A basic Settings with typical iOS patterns (toggles, about, version) |
| `"Add a settings screen with: dark mode toggle, notification preferences (time picker for reminder), language selector (UA/EN), clear cache button with confirmation alert"` | Exactly what you described |
| `"Add a settings screen. The dark mode toggle should write to UserDefaults key 'preferredAppearance' and immediately apply via overrideUserInterfaceStyle on the root window."` | Exactly what you described, with the specific implementation detail |
All three are valid prompts. The difference is how much you control the result.
### Structure Complex Requests
For features with multiple components, use numbered lists. The agent treats them as checklists — implements item by item and marks them done.
### Attach Files for Context
You can attach files (images, documents, PDFs) to any prompt. The agent reads and incorporates them — useful for design references (see [designing with a design system](/ai-design), and [design prompts that work](/design-prompts) for the wording that gets a usable result), PRDs from other tools, or API documentation. You can even send *just* an attachment with no text, and the agent will interpret it.
### Give Context When Debugging
Bad:
> "The app crashes."
Good:
> "The app crashes when I tap the 'Save' button on the edit profile screen. Console shows: 'Fatal error: Unexpectedly found nil while unwrapping an Optional value' in ProfileViewModel.swift."
### Reference Specific Files
> "In `BookingView.swift`, the date picker is showing dates in the past. Add a `minimumDate` constraint set to today."
Naming the file, the element, and the change means the agent doesn't have to search for any of them.
### Pivoting Is Normal
With Plan off you can change direction at any time. The agent adapts on the spot. You don't need to start a new project — just say:
> "Scrap the tab-based navigation. Let's switch to a sidebar layout — the sidebar shows categories, and the main area shows the selected category's content."
### Toggling Plan Is Free
Plan on and Plan off share the same project context. The agent picks up all context automatically — active feature, constitution, knowledge base.
***
## Summary: Plan on or Plan off?
```text theme={null}
Have an idea, want to see results?
│
├── Brand new project
│ └── Complete the sentence, describe the app → first turn runs Refine
│ (PRD + first spec, automatically) → then a workflow below
│
├── Structured workflow (plan each feature, then build)
│ └── Plan on → Plan off → Plan on → Plan off → ...
│
├── Exploratory workflow (build and iterate freely)
│ └── Plan off (turn Plan on when a feature needs a thorough spec)
│
└── Need a specific action (build, fix, refactor)
└── Plan off
```
Turn **Plan** on to plan before building — it specs out a feature without touching code. Leave **Plan** off (the default) to build, iterate, and handle one-off actions. Toggle freely — the context follows you. New projects bootstrap themselves: [the new-project sentence](/articles/new-project#the-new-project-flow) sets up the structure, and the first turn runs **Refine** on its own.
The practice the toggle implements is spec-driven development. [Spec-driven mobile app development](https://modaal.dev/blog/spec-driven-mobile-app-development) sets out the method on its own terms, without reference to a particular tool.
***
*Modaal uses the same knowledge base, constitution, and architectural templates either way. The difference isn't in code quality — it's in how much planning happens before the code gets written.*
# Starting a New Project and Choosing the Right Template
Source: https://docs.modaal.dev/articles/new-project
How the new-project flow works, what each template scaffolds, and how to choose between an iOS + Android app, a prototype, an iPhone app and a 2D game.
Every Modaal project starts from a **template**. The template determines what is created on disk before the agent's first turn: which targets exist, which platforms they run on, and — most consequentially — which **architecture** your code is written against.
This article covers both halves of that decision: the sentence you pick the template with, and how to choose the one that fits what you're building.
***
## The new-project flow
You start a project by completing one sentence above the composer, describing the app in the composer, and pressing **Start new project**. There is no second step: nothing else is asked, and the name, the identifiers and the device families are derived from your description.
### 1. Complete the sentence
The sentence reads *I'm building a `kind` for `platform`*, with an **+ Apple Watch** chip beside it. Each word is a button that opens a menu.
**Word 1 — what you're building.** Three rows:
| Kind | The line under it |
| - | - |
| **Quick prototype** | Test an idea fast |
| **Production app** | Built to ship, feature by feature |
| **2D game** | Build something playful |
**Word 2 — where it runs.** It appears for **Production app** only — **Quick prototype** and **2D game** each have one template, so the sentence ends after the first word. Two rows:
| Platform | The line under it |
| - | - |
| **iOS** | iPhone now, Android later |
| **Android + iOS** | One codebase, both stores |
**Word 3 — the Apple Watch chip.** Also **Production app** only. Unselected it is a dashed **+ Apple Watch** chip beside word 2; clicking it fills it in and it reads **Apple Watch**, and clicking again clears it. The **Add Apple Watch companion** checkbox in the platform menu is the same setting — two places to set one value. Switching the kind away from **Production app** hides the chip and does not send it; switching back restores what it said.
**Android + iOS is part of Pro** ([plans on modaal.dev](https://modaal.dev/pricing)). On a plan without it the row still selects and the sentence reads it, with a **Pro** chip and the monthly price on the row; **Start new project** then opens the upgrade screen instead of creating the project, and your draft and your selection are kept.
The sentence opens on **Production app for iOS** the first time, on every plan. After that it opens on the combination you last created a project with.
Both menus end with **How templates work ↗**, which opens this page.
### 2. Describe the app in the composer
Type your idea into the composer — the placeholder continues the sentence: *"…that lets people — describe it in your own words"*. The paperclip attaches source materials: a PRD, a screenshot, a design doc, a Figma link. The selector beside the send button picks the agent and model that will do the work.
Your description is used three ways: it names the app, it sets the device families (name iPad in it and the project is created for iPhone and iPad), and the agent writes the PRD from it on the first turn.
Nothing is created while you type, and nothing is created if you never press the button — no project record, no folder, no git repository.
### 3. Press Start new project
The name comes from your description; when nothing can be read from it, the project is created as **My App** and you can rename it in the **Project Settings** tab. The organization identifier comes from the one you last created with, or is suggested from your account, and the bundle identifier is derived from both — see [The name and identifiers](#how-the-three-fields-fit-together).
A folder, a project record, and a git repository, all under that name.
Targets, SPM modules, `xcodegen.yml`, build configurations, an app icon, and a passing test suite. Any files you attached are copied into the project.
The agent receives it with Plan mode on, and continues from there.
You wait on the home screen while that runs. The composer holds your text and goes read-only, the sentence stops responding, the send button reads **Setting up…**, and the line under the composer names the step — **Naming your app…**, then **Scaffolding**, with the name of the template being written. A **Cancel** button sits beside it until scaffolding starts — after that the project exists, so there is nothing left to cancel.
If the create fails, the home screen shows the error and keeps both your draft and your selection.
### 4. The first turn: PRD and first feature spec
The sentence has already answered the structural questions, so the agent's first turn starts at product scope. It:
1. Reads your description (and any attachments — images, PDFs, Figma links).
2. Writes a structured **`PRD.md`** at the project root.
3. Plans a workable **first iteration** and writes it to `specs/001-/spec.md`.
The architecture and the target layout are already recorded in `.modaal/project.json`, so that turn neither asks which architecture to use nor re-scaffolds. It may ask a product question if your scope is ambiguous.
When the plan looks right, turn **Plan** off and say *"let's build this"*. See [Modes and prompts](/articles/modes-and-prompts) for how the Plan toggle works from there on.
### The name and identifiers
Three values are set at creation without asking you. Two of them are one chain — the product name and the organization identifier are the inputs, the bundle identifier is the output:
```text theme={null}
Product name: Test App
Organization identifier: com.test.modaal
Bundle identifier: com.test.modaal.TestApp
```
* **Product name** is the user-facing field: the display name under the icon, the default name of the App Store listing, and the name of the project folder Modaal creates. It is taken from your description, and editable afterwards in the **Project Settings** tab.
* **Organization identifier** is an internal naming convention and nothing more. Modaal reuses the one your last project was created with, or suggests one from your account. The custom is to take a domain you control and write it back to front: a personal site `janedoe.dev` gives `dev.janedoe`, a company site `acme.com` gives `com.acme`. Following the custom keeps your bundle identifiers unique without any registry, because nobody else owns that domain. Nothing verifies it, though: no check confirms the domain exists or that you own it, the string is never shown to users, and it is not registered with Apple or Google on its own. Its only consequence is the prefix it contributes to the bundle identifier.
* **Bundle identifier** is the field with external consequences, derived as `.`. iOS uses it to tell apps apart on a device, and App Store Connect uses it to tie every uploaded build to one app record, so it must be unique across the App Store. Wherever the app is registered — the Apple Developer portal, App Store Connect, TestFlight, Firebase — it is the bundle identifier that is registered, never the organization identifier or the product name.
**Check the bundle identifier before your first upload.** Once a build reaches TestFlight or the App Store, its Bundle ID can never be changed — you'd have to ship a new app. Set it in the **Project Settings** tab of the sidebar.
Devices and orientations are set from template defaults, plus any device family your description names. Both are editable afterwards in the **Project Settings** tab, together with the name, the display name, the bundle identifier, the version, the minimum deployment target and the app icon.
***
## The templates at a glance
The sentence has six combinations, and each one names a template:
| Kind | Platform | Apple Watch | Best for | Targets | Architecture |
| - | - | - | - | - | - |
| **Production app** | **iOS** | off | iPhone-first products that expect Android | iOS app | Duet (Swift) |
| **Production app** | **iOS** | on | the same, with a watch companion | iOS app + watchOS app | Duet (Swift) |
| **Production app** | **Android + iOS** | off | products that ship on both stores | iOS app + Android app | Duet (Kotlin Multiplatform) |
| **Production app** | **Android + iOS** | on | the same, with a watch companion | iOS app + Android app + watchOS app | Duet (Kotlin Multiplatform) |
| **Quick prototype** | — | — | trying an idea, demos, experiments | iOS app | MVVM |
| **2D game** | — | — | games, animated lessons, and screens drawn from sprites rather than views | iOS app | RIBs + SpriteKit |
Some things every combination gives you: [XcodeGen](https://github.com/yonaskolb/XcodeGen)-generated project files, `.xcconfig`-based build configurations (Debug / AdHoc / AppStore), an Icon Composer app icon, a localization catalog (`Localizable.xcstrings` + SwiftGen), a theming module with semantic design tokens, and a test suite that passes from the first build (Swift Testing in **Quick prototype**, XCTest in **2D game**, scenario tests and recorded fixtures in the two **Production app** combinations).
**Four templates sit outside the sentence** and are not creation paths: the CombineRIBs **Production app** template (`ios-ribs`), the two Apple Watch templates (`watchos`, `watchos-ios-ribs`) and the empty project (`empty`). Projects already created from them are fully supported, keep building, and keep getting features — see [CombineRIBs projects](#why-production-app-is-no-longer-offered), and [What each template scaffolds](#what-each-template-scaffolds) for the two watch templates. A workspace with no app targets is what an imported repository is; ask the agent to add targets to it in chat.
***
## What each template scaffolds
A native iPhone app and a native Android app in one repository, with each feature's logic written once as a pure Kotlin reducer that both apps consume.
```text theme={null}
src-kmp/ # the shared Kotlin core
├── / # one module per feature: State, Action, Effect, reducer
├── services/ # cross-cutting ports, log vocabulary, consent state
├── telemetry/ # the closed analytics event grammar
├── theming/ # design tokens, shared by both apps
├── apple-umbrella/ # one Kotlin/Native framework aggregating every feature
├── replay-runner/ # replays fixtures on the JVM
└── app/ # the Android app: Compose shell, workers, Activity edge
src-ios/
├── App/ # the iPhone app target; xcodegen.yml is the source
└── Libraries/ # Kit (links the framework), Main (composition root),
# Theming, ThemedControls
parity/
├── manifest.yaml # the declared features, fixtures and generators
├── authoring.md # this repo's own front door for feature work
├── feature-specs/ # one one-pager per feature
└── fixtures/ # recorded behavior — build products, never hand-edited
tools/duet # the CLI that records and verifies
```
**How you'll write features:** a feature spec, then a `Given`/`When`/`Then` scenario, then `tools/duet record` to compile it into fixtures, then the reducer until the scenario passes, then a SwiftUI screen and a Compose screen that read state and send actions. `tools/duet verify` replays every fixture on both platforms and compares byte-for-byte; the same gate runs in CI on every push.
The iPhone app consumes the *same compiled Kotlin logic* as the Android app, so there is no second implementation of a reducer to keep in step.
See [Duet](/duet) for the full picture and [Handling platform-specific UI](/articles/cross-platform-ui-parity) for where the shared/native line falls. The [Duet tutorials](/tutorials/duet) write by hand what this combination scaffolds, one feature at a time, so you can read an emitted project and know what each file is for.
One iPhone app, pure SwiftPM — no Kotlin, no Gradle, no Android SDK needed to build or ship it. Each feature is a pair of Swift packages.
```text theme={null}
src-ios/
├── Subtrees// # one directory per feature:
│ ├── Feature/ # the reducer, its types, the worker, the scenarios
│ └── Node/ # the builder and the SwiftUI shell
├── App/ # the iPhone app target
└── Libraries/ # Main (composition root), Theming, ThemedControls
parity/
├── manifest.yaml
├── authoring.md
├── feature-specs/ # the porting source of truth
└── fixtures/ # recorded behavior — build products, never hand-edited
tools/duet
```
**How you'll write features:** the same loop as the Kotlin Multiplatform flavor, with the reducer in Swift and one shell instead of two.
Every feature is recorded and specced exactly as in the two-platform flavor. That corpus is what makes Android a **per-feature, fixture-gated migration** later — the Kotlin core and Android shell are added to this same repository, and each feature's Kotlin twin is verified against fixtures recorded when the feature was first built. The iPhone app keeps building and shipping throughout.
Pure SwiftUI, with the smallest module structure that still keeps the ViewModels unit-testable.
```text theme={null}
src-ios/
├── App/
│ ├── MyApp/ # @main App entry, Info.plist, app icon, assets
│ ├── xcconfig/ # Base / Debug / AdHoc / AppStore
│ └── xcodegen.yml
├── Libraries/
│ ├── MyAppMain/
│ │ ├── App/AppRootView.swift
│ │ ├── Navigation/Route.swift # NavigationStack routes
│ │ ├── Features/Main/MainView.swift
│ │ ├── Features/Main/MainViewModel.swift
│ │ └── Localizable.xcstrings
│ └── Theming/ # semantic design tokens
└── SharedLibraries/
├── CombineExtensions/
└── SharedUtility/
```
**How you'll write features:** one folder per feature under `Features/`, each with a SwiftUI `View` and an `@Observable` `ViewModel`. Navigation is a `Route` enum driving a `NavigationStack`. Services are injected through `@Environment`. Tests target the ViewModels.
An explicit module tree, protocol boundaries between every module, and a navigation tree that is explicit in the router hierarchy. The sentence does not create this template — see [CombineRIBs projects](#why-production-app-is-no-longer-offered). This section describes the projects created from it, and the same RIBs structure is what **2D game** builds on and what the iPhone target of the `watchos-ios-ribs` template gets.
```text theme={null}
src-ios/
├── App/
│ ├── MyApp/ # AppDelegate, SceneDelegate, SceneComponent (composition root)
│ ├── xcconfig/
│ └── xcodegen.yml
├── Libraries/
│ ├── MyAppMain/
│ │ ├── RIBs/Root/ # Builder · Router · Interactor
│ │ ├── RIBs/Splash/
│ │ ├── RIBs/Main/
│ │ ├── Workers/
│ │ └── Localizable.xcstrings
│ └── Theming/
├── SharedLibraries/
│ ├── AppServices/ # app-lifecycle & shared service protocols
│ ├── CombineExtensions/
│ ├── Diagnostics/
│ ├── RIBsExtensions/
│ ├── SharedUtility/
│ └── SimpleTheming/
└── AllTests.xcworkspace # runs every module's tests together
```
**How you'll write features:** one RIB per screen or flow — `Builder` (constructs it), `Router` (attaches and detaches children), `Interactor` (business logic), `View` (SwiftUI or UIKit). Children attach to parents, so the router hierarchy is where the app's navigation state lives, and you can read the whole app off it. Mocks are generated automatically by [Sourcery](https://github.com/krzysztofzablocki/Sourcery) from `/// sourcery: CreateMock` annotations, so every module is unit-testable in isolation.
A Splash RIB and a Main RIB come pre-wired under Root, so the app runs the moment it's created.
The CombineRIBs structure with the view layer swapped for a SpriteKit scene tree. Builders, Interactors, Routers, Components and Sourcery mocks are identical; what changes is that a RIB's presenter is an `SKScene` or an `SKNode` instead of a SwiftUI view, and the whole app has exactly one view controller — the scene navigator that owns the `SKView`.
```text theme={null}
src-ios/
├── App/
│ ├── MyApp/ # AppDelegate, SceneDelegate, SceneComponent
│ ├── xcconfig/
│ └── xcodegen.yml
├── Art/Game/ # source PNGs — the artwork you edit
├── Scripts/pack-atlases.sh # packs Art/ into the texture atlas
├── Libraries/
│ ├── MyAppMain/
│ │ ├── RIBs/Root/ # the app's one view controller (SceneNavigator)
│ │ ├── RIBs/Splash/ # SplashScene — waits on the resource loader
│ │ ├── RIBs/Menu/ # MenuScene — hosts the node RIBs below
│ │ ├── RIBs/Game/ # GameNode — the sample game
│ │ ├── RIBs/Settings/ # SettingsNode — sound volume, persisted
│ │ ├── Layout/ # SceneLayout, SceneNodes, ZPlane
│ │ ├── Content/ # sprite, atlas and sound catalogs
│ │ ├── Workers/ # ResourcesLoadingWorker
│ │ └── Resources/ # packed atlas + sound effects
│ └── Theming/
└── AllTests.xcworkspace
```
**How you'll write features:** two scenes (Splash and Menu) and everything deeper as **node RIBs** — an `SKNode` subclass you `addChild` into the host scene. A node reads no ambient state (`self.scene`, `UIScreen`, the view) and lays out against the metrics its host hands it, so the same node can be attached into a different scene without edits. Animation is `SKAction` choreography rather than a per-frame game loop, and `layoutScene()` runs on presentation, on rotation and on every safe-area change.
The SpriteKit machinery — `BaseScene`, the scene navigator, the routing protocols, the sound pool, the atlas loader, the button control — is the open-source [modaal-foundation-spritekit](https://github.com/modaal-agent/modaal-foundation-spritekit) package, pinned by URL in the app module's `Package.swift`. It is a versioned dependency: you import its products, and it is upgraded by moving the pin.
The scaffolded app runs immediately: splash → menu → a tap-the-target sample game with a score and a sound effect, plus a settings screen that persists sound volume. The sample game and the four placeholder sprites in `Art/Game/` are there to be replaced — only the sample game in `RIBs/Game` uses them.
See [Building 2D games with SpriteKit](/articles/spritekit-games) for what this looks like in practice.
Turning the **+ Apple Watch** chip on adds a watchOS app target to whichever Duet combination you picked. It is a target on the project, not a project of its own: the phone app is still what the platform word says, and the watch app is its companion.
```text theme={null}
src-ios/App/
├── / # the iPhone app target
└── WatchApp/ # the watch app target
├── WatchApp.swift # @main App entry
└── WatchRootView.swift # the placeholder surface to replace
```
What you get on day one: a target in `src-ios/App/xcodegen.yml` that builds and runs on a watchOS simulator, a bundle identifier of `.watchkitapp` with `WKCompanionAppBundleIdentifier` pointing at the phone app, a `watchOS 10.0` deployment target, a row in `.modaal/project.json`, and an entry in the Run picker. `WatchRootView` is a placeholder: the first watch feature replaces it.
The Duet feature packages target iOS, so the watch app links none of them and its screens are written for the wrist rather than shared. Ask the agent for them the way you ask for any other screen.
Forgot the chip? Ask the agent to add the watch target later — it takes the same path the chip takes.
Two templates outside the sentence. **Watch only** (`watchos`) is a standalone Apple Watch app with no iPhone app in the project; **Watch + iPhone** (`watchos-ios-ribs`) pairs a CombineRIBs iPhone app with an MV watch companion. Projects on either keep building and keep getting features.
**MV (Model-View)** is a simplified MVVM: there is no ViewModel layer. Views observe `@Observable` services directly through `@Environment`. At watch-screen scope a ViewModel per screen adds a layer without adding testable logic, so MV omits it. Tests target the services.
For a new project, the watch is the **+ Apple Watch** chip on a **Production app**, which puts the watch target in a Duet tree.
A project folder and a git repository. There is no Xcode project and no scheme, so there is nothing to build or run until you add a target.
The sentence does not create this: every combination scaffolds an app. A workspace with no app targets is what an imported repository is, and the agent is told when one is empty — ask it to add a target when you're ready.
What was chosen is recorded in `.modaal/project.json` — the template at the project level, and the architecture per target. Both the agent and Modaal's tooling read it, which is why you never have to restate your architecture in a prompt.
***
## Choosing a template
Four questions, in this order. The first is the one that decides the most: platform reach comes before architecture.
### 1. Will this ship on Android, now or later?
**Yes, or probably** → **Production app**, and the platform word decides which [Duet](/duet) flavor you get. On both, each feature's logic is written once, both apps render it natively, and recorded fixtures gate the two against each other in CI. **Android + iOS** is part of Pro ([plans on modaal.dev](https://modaal.dev/pricing)).
| | **Production app for Android + iOS** | **Production app for iOS** |
| - | - | - |
| Targets on day one | iPhone app + Android app | iPhone app |
| Feature logic | Kotlin, consumed by both apps | Swift |
| Toolchain on your Mac | Xcode, plus the Android SDK and a JDK | Xcode |
| Getting the other platform | Already there | A per-feature, fixture-gated migration into the same repository |
Pick **Android + iOS** when Android is in scope now, or when you want both stores at launch. Pick **iOS** when Android is a "probably", or when you want the iPhone app in users' hands before taking on a second toolchain — its features are recorded and specced exactly as in the two-platform flavor, which is what makes the later port bounded work.
**No, and I'm certain** → question 2.
**Platform reach is a creation-time decision.** A template fixes the architecture for the life of the project. A **Quick prototype** (MVVM) project has no migration path to Duet — Android there means a second project, written again. A CombineRIBs project does have one, taken feature by feature, but it is work a **Production app** avoids entirely. When Android is even a maybe, **Production app** costs less.
### 2. Is this a prototype or a product?
**A prototype** — idea validation, an investor demo, a personal utility with a fixed scope → **Quick prototype**. Pure SwiftUI, a View and a ViewModel per screen, no module boilerplate beyond that, and a working app in hours.
**A product** — something that will live long, grow past a dozen screens, or earn money → **Production app**, even when only iPhone is planned. **Production app for iOS** is the production template for an Apple-only product.
**A Quick prototype project is a throwaway by design.** MVVM has no migration path in Modaal: going to production means starting a second project and carrying the behavior across by hand. Before picking it for something that "might eventually go to production", ask whether you are ready to write it again. If the answer is "probably not" or "I hadn't thought about that", pick **Production app**.
The extra structure a production template needs is written by the agent, not by you — generating a Duet feature and generating an MVVM screen differ by seconds of agent time. What differs later is the cost of having picked wrong, and you pay that one.
### 3. Is the screen drawn rather than laid out?
Sprite artwork placed at coordinates, animated per frame, hit-tested where it is drawn → **2D game**. It is the only template that scaffolds a SpriteKit scene host, so it is the answer for a game. [The section below](#when-to-pick-the-2d-game-card) covers where that line falls.
### 4. Does the product reach the wrist?
Turn the **+ Apple Watch** chip on, and keep the platform word set to where the phone app ships. The watch app is a companion target in the same project, on the same Duet architecture, and the chip is the only way to get one at creation — the kind stays **Production app** even when the wrist is the part you care about.
### Which template for which situation?
| Your situation | Sentence |
| - | - |
| Android planned, now or later | **Production app** for **Android + iOS** |
| Both stores at launch | **Production app** for **Android + iOS** |
| iPhone first, Android a likely follow-up | **Production app** for **iOS** |
| Production app with growth potential, Apple-only | **Production app** for **iOS** |
| Fintech, healthtech, regulated industry | **Production app** |
| Complex navigation, auth gates, multi-step flows | **Production app** |
| Utility with a fixed, bounded scope | **Quick prototype** |
| Prototype for idea validation | **Quick prototype**, knowing there is no migration path |
| Game, arcade, puzzle, animated lesson | **2D game** |
| Workouts, timers, quick capture on the wrist | **Production app** with **+ Apple Watch** on |
| You are bringing your own project structure | Import the repository instead of starting from the sentence |
### CombineRIBs projects
The **Production app** kind in the sentence creates a Duet project. The template that used to carry that name, `ios-ribs`, scaffolds **RIBs** (Router-Interactor-Builder, via CombineRIBs): explicit module boundaries, a navigation tree that lives in the router hierarchy, and protocol contracts between modules. The sentence does not create it, because the two Duet combinations cover every app it scaffolds — with a recorded behavior corpus and a route to Android on top. Three things follow:
* **Projects already on it are fully supported.** They keep building, features written today follow the same RIBs patterns, and the agent knows them.
* **A game is the exception.** No Duet template scaffolds a SpriteKit scene host, so **2D game**, which is RIBs with a SpriteKit view layer, is reachable from the sentence and is the answer for a game.
* **There is a way across.** A CombineRIBs project can be carried to the Duet shape feature by feature, in place — [Migrating a CombineRIBs app to Duet](/articles/combineribs-to-duet) walks through the stages, what each one costs, and where you can stop. Starting a new project on **Production app** means that migration is never needed.
***
## When to pick 2D game
**Pick it when the screen is drawn rather than laid out** — sprite artwork placed at coordinates, animated per frame, hit-tested where it is drawn. Games, arcade and puzzle apps, animated lessons, and kids' apps where the play is the product.
**Stay on a conventional kind for a conventional app, including an animation-heavy one.** Rich SwiftUI animation is not a reason to move to SpriteKit. Building a single playful screen on a scene tree means that screen takes on the scene lifecycle, the layout pass and the atlas pipeline, and its UI is assembled from `SKNode` subclasses rather than from your existing SwiftUI views.
**RIBs → RIBs + SpriteKit is additive.** The RIB tree, the modules and the tests keep their shape; what gets added is the SpriteKit packages and a scene host. So an existing CombineRIBs project that needs a drawn screen can ask the agent to add the SpriteKit surface at that point.
A Duet project has no equivalent move — no Duet template scaffolds a scene host. If the product is a game, pick **2D game** at creation and take CombineRIBs with it.
[Building 2D games with SpriteKit](/articles/spritekit-games) walks through what the template gives you and what the first turn looks like.
***
## Picking a template for a real app
**Context.** Max wants a simple interval training app. Three screens: workout templates, interval settings, and the timer itself with start / pause / stop. A personal project he might share with friends. No cloud — data stored locally in SwiftData.
**Prototype or product?** A personal utility. Even on the App Store it's unlikely to reach 50 screens. No Android plans, three screens, linear navigation (list → settings → timer). Nothing points at a production template.
**Decision: Quick prototype (MVVM).** A production template would add per-feature module structure — and, on a Duet template, a spec and a recorded fixture corpus per feature — that three linear screens never use. MVVM covers the whole app: a `TimerViewModel` with `@Observable` holding timer state, a `NavigationStack` with `NavigationLink` and `.sheet()`, `@Environment` for the persistence service, and tests against the ViewModel with zero navigation mocks.
A few hours after describing the idea, Max has a working MVP. If he later adds a WidgetKit widget or a watch companion, the main app's architecture doesn't constrain them — those targets run on pure SwiftUI either way.
**Takeaway.** For 3–10 screens with a clear, bounded scope, MVVM is the right call. Don't "play it safe" with a heavier architecture just in case.
**Context.** A fintech building a mobile bank: registration with KYC verification, balances, P2P and IBAN transfers, filtered transaction history, profile settings, push notifications, biometric auth. Android planned in six months.
**Prototype or product?** Production. A regulated industry, real money, and a codebase that will be maintained for years.
* **Android in six months.** **Production app for Android + iOS** is the only combination that emits an Android app target. Picking it at creation makes Android a target rather than a migration, and the six-month date stops being a second project plan.
* **Navigation.** KYC is a multi-step flow with branches (document photo → selfie → verification → waiting → result, with back-navigation). Transfers are another flow behind an auth gate. In Duet those transitions are reducer state, recorded as fixtures — so the flow is pinned byte-for-byte and both apps replay the same recording.
* **Regulatory scrutiny.** Every flow ends up with a written spec under `parity/feature-specs/` and an executable recording of what it does. That corpus is produced by ordinary feature work, not by a separate documentation effort.
* **Security.** Features are separate modules with typed boundaries. The payments module has no access to KYC's internal state, and the compiler enforces that rather than convention.
* **Native SDK work.** Biometrics, document-verification SDKs and custom PIN entry live in workers behind ports, with one implementation per platform. The reducer names the effect; the platform supplies the I/O.
**Decision: Production app for Android + iOS (Duet, Kotlin Multiplatform).** This isn't about team size — it is the right pick for a solo developer with an agent, because the agent generates each feature against a clear contract and the fixture gate catches a behavior change on the commit that caused it.
**Takeaway.** When a product must exist on both stores and is judged on whether it behaves correctly, write the behavior once and let CI prove the two apps agree.
**Context.** A solo founder building a marketplace for plumbers, electricians and cleaners: an offerings feed, provider profiles, booking with a date/time picker, in-app chat, Stripe payments, order history, user profile. Android is coming, but "not now." The business model anticipates growth.
**Prototype or product?** There's a business model with payments and growth plans — production.
* **"Android not now"** commonly becomes an urgent requirement 6–8 months after launch. On a **Production app** that is a scheduled port; on **Quick prototype** it is a second project.
* **Navigation** is a real flow: pick service → pick date → confirm → pay → result, with an auth gate (browse without an account, book with one). Chat is a separate real-time flow.
* **Integrations**: Stripe, WebSockets, push, geolocation.
**Decision: Production app for iOS (Duet, Swift flavor).** One toolchain and one set of screens to launch with, and every feature recorded and specced as it is written. When Android is scheduled, the Kotlin core and Android shell are added to the same repository and features cross one at a time, each verified against fixtures recorded months earlier.
**The line between the two platform words:** if Android has to ship within roughly six months, pick **Android + iOS** at creation instead — the Android app is then built alongside the iPhone one rather than ported after it.
**Takeaway.** When a project shows signs of growth — a business model, non-trivial navigation, possible Android — pick **Production app** from the start. Which platform word depends on when Android has to exist, not on whether it will.
**Context.** An internal app for \~500 employees: contact directory, office map, meeting-room booking, news feed. Tight budget, results needed fast. Data from a corporate REST API. No Android — everyone has an iPhone.
**Prototype or product?** Less clear-cut than the others. It's used in production, but it doesn't *grow* — the scope is fixed. It will never become a marketplace or a social network. Navigation is linear: tab bar → list → detail, with booking as a simple form sheet.
**Decision: Quick prototype (MVVM).** Fixed scope, linear navigation, no Android. A production template would add module structure the fixed scope never uses.
The distinction that matters: the key question in the decision tree isn't "will this be in production?" — it's **"will it grow?"** A corporate directory with a clear spec doesn't. If someone asks for vacation requests and expense tracking a year later, *that's* when you decide whether the next version starts as a new **Production app** project. Deciding now, just in case, is premature optimization.
**Takeaway.** Not every "real" app needs a production architecture. Bounded scope means MVVM delivers a better result in less time — corporate projects included.
**Context.** Anna built a habit tracker — three screens: habit list, add habit, stats. She picked **Quick prototype**, correctly, and the agent generated a working version in hours. She shipped it and started gaining users.
Four months later: 50,000 users, an investor interested, and new requirements — social challenges, a subscription paywall, CloudKit sync, widgets, a watch companion, five languages.
**What she's facing.** This is the classic "throwaway prototype that accidentally became the product." Two options:
* **A — Evolve the MVVM codebase.** Keep adding features, and for complex flows (paywall, challenge onboarding) add lightweight flow coordinators: `@Observable` state machines that own navigation for a single flow. Not full MVVM+C — no coordinator protocol, no coordinator tree, no parent-child lifecycle. Just one concrete object per complex flow.
* **B — Restart on a Production app.** MVVM has no migration path in Modaal, so this is a new project rather than a conversion: the agent rebuilds the app on a Duet template, and the business logic — models, services, CloudKit code — carries across as-is, because it lives in SPM modules rather than the architectural layer. The screens are written again.
**Recommendation.** If a few days can be set aside: **B**, now, while the app is three screens plus the new requirements rather than thirty. With an agent that rebuild is days of work, and it is the point at which Android stops being a separate project.
If there's genuinely no time and the product is growing daily: **A** as a stopgap. Flow coordinators for the paywall and onboarding hold for a few months. Record the restart as the next planned piece of work — the longer it waits, the more screens it has to cover.
Worth noting: **widgets and watch targets are pure SwiftUI either way**, so they carry across untouched.
**Takeaway.** Two rules apply. At the start, be honest that **Quick prototype** means throwaway — if there's even a 30% chance of growth, start on **Production app**. When it takes off, restart while the codebase is still small.
***
## Changing your mind later
Not everything the sentence sets is permanent. Here's what is:
| Decision | Changing it later |
| - | - |
| **Name**, **display name**, **version**, **devices**, **orientations**, **minimum deployment target**, **app icon** | Edit them in the **Project Settings** tab of the sidebar. |
| **Bundle identifier** | The **Project Settings** tab, until you ship; fixed once a build reaches TestFlight or the App Store. |
| **Adding targets** — watch companion, widgets, extensions | Ask in chat; the agent scaffolds and wires them. A watch target added in chat takes the path the **+ Apple Watch** chip takes. |
| **The Android app**, on a Duet project | Already there on **Production app for Android + iOS**. On **Production app for iOS**: a per-feature migration into the same repository — see [Adding Android to an iPhone-only project](/articles/duet#adding-android-to-an-iphone-only-project). |
| **Architecture** | The one real commitment. MVVM has no migration path — going to production means a second project. [CombineRIBs → Duet](/articles/combineribs-to-duet) is per-feature and in place. RIBs → RIBs + SpriteKit is additive. Duet projects stay Duet projects. |
Everything else — features, screens, navigation, integrations, pivots — is ordinary work you do in chat. See [Modes and prompts](/articles/modes-and-prompts) for how to drive it.
The first turn writes a `PRD.md` from your description. If you would rather bring your own, [the app requirements document template](https://modaal.dev/blog/app-requirements-document-template) sets out what to put in each section.
**Need help deciding?** Ask in our [community](https://discord.gg/ng84Jm92tm) — describe what you're building and someone will sanity-check the combination with you.
***
*Modaal currently supports Duet in two flavors for multiplatform projects, MVVM, RIBs and RIBs + SpriteKit for iOS, and MV for watchOS. Duet is pre-release; Android support is available to every account.*
# Should You Share Code Between iOS and Android?
Source: https://docs.modaal.dev/articles/share-code-ios-android
Three ways to ship iOS and Android: write two apps, share everything, or share the logic and keep the UI native — with costs, returns, and who should skip it.
Share the business logic when you ship both platforms and your product's value is in its behavior — rules, flows, money, data. Keep the interface native on each platform, because the interface is where the platforms genuinely differ. The rest of this page is the case for that split: the three ways to ship two apps, what each way can prove about behavior, what sharing costs in practice, and the products for which sharing is the wrong call.
## What are the three ways to ship two apps?
Every iOS-and-Android product takes one of three routes:
1. **Write both apps in full.** A Swift codebase and a Kotlin codebase, each implementing every rule. Two teams (or one team twice), full platform fidelity, and every behavior change made twice. Duet is adding a variant of this route that checks the two implementations against one set of recordings: [Duet Twins](/articles/duet-twins).
2. **Share everything, including the interface.** One implementation in one language — [Flutter](/articles/duet-vs-flutter), [React Native](/articles/duet-vs-react-native), [Compose Multiplatform](/articles/duet-vs-compose-multiplatform), or [Ionic with Capacitor](/articles/duet-vs-ionic-capacitor) — with the framework supplying the widgets on both platforms.
3. **Share the logic, keep the interface native.** One implementation of the rules and flows; two thin interface layers, each in its platform's own toolkit. Google [supports Kotlin Multiplatform for exactly this split](https://developer.android.com/kotlin/multiplatform), and it is the route Duet takes — **Duet** is Modaal's cross-platform parity framework for native iOS and Android apps.
The third route is established practice, not an experiment: Forbes, McDonald's, Cash App and Google Docs ship shared Kotlin logic under native interfaces, per [JetBrains' case studies](https://kotlinlang.org/case-studies/) and [Google's Kotlin Multiplatform page](https://developer.android.com/kotlin/multiplatform), and the same shape exists with a Rust core (Mozilla's UniFFI bindings, Signal's libsignal).
## How do you know both apps behave the same?
The three routes give three different answers, and the answer is the real decision:
* **Identity by convention** — two full codebases. The same rule is implemented twice, and agreement rests on code review and manual QA finding every divergence. Nothing mechanical fails when the two apps drift apart.
* **Identity by construction** — one shared implementation. There is nothing to compare, so agreement is automatic — up to the boundary. Where the shared code calls into per-platform code (a platform channel, a native module), the guarantee stops, and that boundary code is back to identity by convention.
* **Identity by verification** — two implementations (or one shared core consumed twice) checked against the same executable contract. In Duet, each feature's behavior is recorded as fixture files; both apps replay them and [CI compares the results byte-for-byte](/articles/duet#what-you-get), so a divergence is a failing build rather than a bug report.
[The best cross-platform frameworks in 2026](/articles/best-cross-platform-framework) places every mainstream option on this grid, one comparison column per approach.
## What does "business logic" mean concretely?
Business logic is the set of decisions that must not differ between platforms: what a tap is allowed to do, what happens when a payment fails mid-flow, in which order results land, what gets persisted and when. In a Duet feature it is literally one [pure reducer](/articles/duet-glossary#reducer) — current state and an action in, new state and requested effects out — with clocks, network and storage behind effects that platform [workers](/articles/duet-glossary#worker) perform.
Pixels are not business logic. Screen layout, navigation chrome, animation, haptics and typography are where iPhone and Android users hold different expectations — Apple's Human Interface Guidelines on one side, Material 3 on the other — and where sharing produces an app that feels foreign on at least one platform. The split holds even when a screen is data-driven: *what is on screen* is a value in shared state; *how it is rendered* stays per platform. [Handling platform-specific UI](/articles/cross-platform-ui-parity) covers where that line sits case by case.
## What does sharing the logic cost?
Two costs, stated plainly:
* **The views are written twice.** Each feature gets a SwiftUI screen and a Jetpack Compose screen. The logic, navigation decisions, effect handling and the test corpus are written once, so the second view layer is rendering work against behavior that already passes.
* **A change touching both platforms costs more than one platform.** In Modaal's own runs, a behavior change shipped to both apps measured roughly 40% more agent effort than the single-platform equivalent — not the 100% of doing everything twice, and the delta is concentrated in the second view layer.
What sharing does not cost: platform access. The shared core compiles into each app as an ordinary library; anything the platform can do, the app can still do, because the interface layer and the workers are native code. That includes the screens that must be native and fast — a Metal-drawn view, a camera pipeline, a video editor. In the share-the-logic route they are ordinary platform code, with the shared core deciding when they start and what they return; in the share-everything route each one is an escape through the framework's native bridge, written per platform anyway in Swift and Kotlin that someone writes and the app build compiles. [The survey page](/articles/best-cross-platform-framework#which-approaches-escape-to-native-for-a-heavy-screen) scores every approach on that escape.
## What do you get for it?
* **One bug, fixed once.** A logic defect lives in one implementation. The fix ships to both stores from one edit.
* **Behavior parity you can point at.** The recorded fixtures are an artifact — replayed on both platforms on every push, byte-compared in CI. "Do the apps agree?" has a mechanical answer, not an opinion.
* **Native look and feel on both platforms.** Each app uses its platform's toolkit, inherits its accessibility behavior, and adopts new OS releases on the platform's schedule — there is no cross-platform widget layer to wait on.
* **An exit that costs nothing.** Both apps are ordinary native codebases in one repository; the shared core is a library either app could in principle replace. Nothing is hostage to a framework's runtime.
## Who should not share code?
* **Single-platform products.** If Android (or iOS) is genuinely never coming, the shared core's discipline still buys fast, deterministic tests, but the parity machinery is overhead you chose without the payoff.
* **Games and heavily drawn interfaces.** A physics loop or a custom-rendered playfield is not reducer-shaped work, and the drawn surface *is* the product — a shared canvas framework or a game engine fits better. Modaal's own guidance says the same: its 2D game template is not a Duet template.
* **Teams committed to two independent native teams.** Sharing logic couples the teams' release trains at the core. If your organization deliberately staffs two autonomous teams and accepts double implementation as the price of independence, that is a coherent position — it is identity by convention, and manual QA is its verification budget.
## Common questions
A single shared implementation is the cheapest way to get two app icons on two stores, and for interface-light products it is a good trade — the survey page's best-fit row says so. The costs arrive later and are specific: the interface is the framework's on both platforms, and shared code that crosses into per-platform modules leaves the guarantee. Compare the routes on those terms, not on launch cost alone.
In shared-logic practice, the logic majority: Forbes states 80%+ shared logic for its KMP apps. In a Duet project, each feature's state, actions, reducer, effects and test corpus are shared; the two view layers and the platform workers are not. The number depends on how interface-heavy the product is.
The shared core is Kotlin compiled into two ordinary native apps — a library, not a runtime. Both codebases open in Xcode and Android Studio and ship to the stores like any native app. That is different in kind from a shared-UI framework, whose widgets and runtime the app cannot ship without.
Yes, and it is cheaper if the iOS app records its behavior from day one. Duet's iPhone-first flavor does exactly that: features are recorded and specced as they are built, so the later Kotlin core is written against an executable specification instead of reverse-engineered. The Duet overview covers the per-feature migration.
Sharing logic with a web app is the same decision one platform wider, and Kotlin and Rust cores both compile for it. Duet targets iOS and Android; if web-plus-mobile from one codebase is the requirement today, that is a real advantage of Flutter and Compose Multiplatform — see the survey page's platform-coverage section.
Duet projects are scaffolded by [Modaal](https://modaal.dev) — [starting a Duet project](/articles/duet#starting-a-duet-project) walks through the two template cards.
## Sources and further reading
* [Kotlin Multiplatform on developer.android.com](https://developer.android.com/kotlin/multiplatform) — Google's support statement for sharing business logic between Android and iOS
* [JetBrains Kotlin Multiplatform case studies](https://kotlinlang.org/case-studies/) — Forbes, McDonald's, Cash App and others on the shared-logic shape
* [Crux](https://github.com/redbadger/crux) and [UniFFI](https://github.com/mozilla/uniffi-rs) — the same shared-core shape with Rust
* [Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/) and [Material 3](https://m3.material.io) — the two design systems a native interface answers to
## Read next
Every mainstream option on one axis — who draws, where logic lives, what proves agreement — with a best-fit row.
The product behind the third route: one shared core, two native apps, and a CI gate that fails when they disagree.
Where the shared/native line sits on the rendering side, and how deliberate divergence is recorded.
The same architecture argued from the testing side: pure reducers, effects as data, recorded scenarios.
The third route in code: nine tutorials build one app in Kotlin, SwiftUI and Compose, with recordings gating both apps.
Which platform to ship first when you are not shipping both, on modaal.dev.
# Building 2D Games with SpriteKit
Source: https://docs.modaal.dev/articles/spritekit-games
The 2D game template scaffolds a running SpriteKit app — scenes, sprites, sound and a sample game — on a RIBs module structure with a SpriteKit view layer.
SwiftUI lays out a tree of views. A game screen is a tree of sprites: artwork placed at coordinates, animated per frame, hit-tested where it is drawn, and ordered by explicit z-position. [SpriteKit](https://developer.apple.com/documentation/spritekit) — Apple's 2D scene-graph framework — models that tree directly.
The **2D game** template starts you on SpriteKit, wired into the RIBs module structure that [CombineRIBs](/articles/new-project#why-production-app-is-no-longer-offered) projects use.
## What you get on the first build
Pick the card, press **Create**, and the app runs: splash → menu → a tap-the-target sample game with a score and a sound effect, plus a settings screen that persists sound volume. Five pieces you would otherwise write yourself are already wired behind that sample:
* **A resource gate.** `ResourcesLoadingWorker` registers the fonts, makes the sprite atlas resident and warms the sound pool; the splash waits on it. If a resource fails to load, the worker records it to `Diagnostics` and publishes it on `loadingIssues` for the splash to display, then finishes loading and opens the gate anyway.
* **An artwork pipeline.** Source PNGs live in `Art//`, and `Scripts/pack-atlases.sh` packs them into the committed atlas page the app reads. Run it when the artwork changes; `Scripts/pack-atlases.sh --verify` checks the committed page against the sources without re-packing.
* **Pooled audio.** A fixed-size pool of preloaded `AVAudioPlayer`s, rotated least-recently-used. Overlapping plays each get their own player up to the pool size; past that the new sound is dropped rather than cutting off a playing one.
* **Idempotent layout.** `layoutScene()` runs on presentation, on rotation and on every safe-area change. Chrome is positioned against `safeAreaFrame` and the playfield against the full frame, so the notch and home indicator are accounted for.
* **Tests that pass from the first build**, registered in the same all-modules workspace as the other templates. The sample game's tests run its rules without instantiating SpriteKit, because the score and sound logic is Interactor state.
The SpriteKit machinery — the scene base class, the scene navigator, the routing protocols, the sound pool, the atlas loader, the button control — is the open-source [modaal-foundation-spritekit](https://github.com/modaal-agent/modaal-foundation-spritekit) package, pinned by URL in your `Package.swift`. It is a versioned dependency: you import its products, and it is upgraded by moving the pin rather than by editing files in your project.
## What changes from a RIBs app
Compared with a RIBs app built from SwiftUI views, such as a project created from the CombineRIBs template, a RIB's presenter is an `SKScene` or an `SKNode` subclass instead of a SwiftUI view. The rest is the same: Builder, Interactor, Router and Component, the same protocol boundaries, the same generated mocks.
One rule follows from that: **build screens as node RIBs, not scene RIBs.** Presenting a scene runs a transition and discards the outgoing scene's node tree. Attaching a node RIB is an `addChild` into the scene already on screen, and the node remains attachable elsewhere afterwards — it reads no ambient state (`self.scene`, `UIScreen`, the view) and lays out against the metrics its host hands it, so the same node can be attached into a different scene, or into a host app's scene, without edits.
The scaffolded app is built that way: two scenes, Splash and Menu, with the game and settings screens as nodes attached into the Menu scene.
Ask the agent for a new screen and it scaffolds the RIB for you — the right variant, plus the artwork wiring when the screen draws sprites.
## Start a 2D game project
Set the sentence on the [Modaal](https://modaal.dev) home screen to **I'm building a 2D game**, describe the game in the composer, and press **Start new project**. The first turn writes a `PRD.md` and a first feature spec, as it does for every template. The architecture is already recorded in `.modaal/project.json`, so that turn goes straight to product scope.
Two prompts with [enough detail](/articles/modes-and-prompts#detail-level-drives-the-result) for the agent to plan from:
```text theme={null}
A puzzle game where you tap falling blocks to match colors.
Three lives, a combo multiplier, and a game-over screen with a high score.
```
```text theme={null}
An arithmetic practice game for kids: a character hops between numbered
blocks to answer the sum at the top. Hearts for lives, a timer, and a
star score that persists between sessions.
```
The sample game and the four placeholder sprites in `Art/Game/` are there to be replaced. Only the sample game in `RIBs/Game` uses them, and deleting them and re-running `Scripts/pack-atlases.sh` also drops the \~1 MiB atlas page they occupy.
## When not to use it
Pick a conventional kind for a conventional app, including an animation-heavy one — **Production app** for a product, built on [Duet](/duet), and **Quick prototype** for a throwaway idea. Rich SwiftUI animation is not a reason to move to SpriteKit. Building a single playful screen on a scene tree means that screen takes on the scene lifecycle, the layout pass and the atlas pipeline, and its UI is assembled from `SKNode` subclasses rather than from your existing SwiftUI views.
If the graphics requirement is uncertain, weigh the two directions before you pick. A Duet project has no scaffolded route to one: no Duet template scaffolds a SpriteKit scene host. An existing CombineRIBs project can gain one: **RIBs → RIBs + SpriteKit is additive, not a rewrite** — the RIB tree, the modules and the tests keep their shape, and what gets added is the SpriteKit packages and a scene host. Ask the agent to add the game surface at the point you need it. CombineRIBs is not one of the sentence's combinations, so if a drawn screen is likely, start on **2D game**.
***
More on picking a template: [Starting a new project](/articles/new-project). For a second opinion on your idea, ask in the [community](https://discord.gg/ng84Jm92tm).
# Add In-App Purchases and Subscriptions to an iOS App
Source: https://docs.modaal.dev/articles/storekit-testing
Use a local StoreKit configuration file to test in-app purchases and subscriptions before your products exist in App Store Connect — right from Modaal.
Testing in-app purchases against Apple's servers requires setup in App Store Connect: products, agreements, banking and tax information. Until all of it is in place, `Product.products(for:)` returns an empty array without an error, and your paywall renders with nothing in it.
The standard way to develop against this is [StoreKit Testing](https://developer.apple.com/documentation/xcode/setting-up-storekit-testing-in-xcode), Apple's local testing mechanism: your app loads test products from a `.storekit` configuration file instead of from Apple's servers, so you can build and test the entire purchase flow before anything exists in App Store Connect. [Modaal](https://modaal.dev) supports it directly — select a `.storekit` file in Run Options, run, and your paywall fills in. What to charge and which tiers to offer is a separate decision — [how to monetize your mobile app](https://modaal.dev/blog/how-to-monetize-your-mobile-app) covers it.
You don't need to write Swift to follow this guide. Everything below is a setting in Modaal or a [prompt](/articles/modes-and-prompts) you can give to your agent. If your app doesn't have a paywall yet, start with [Starting from scratch](#starting-from-scratch).
***
## The three StoreKit environments
Your app can load products from three different sources. Most paywall problems come down to being in a different environment than you think, so it's worth understanding how the three differ.
| | StoreKit Test | Sandbox | Production |
| - | - | - | - |
| Where products come from | A local `.storekit` file | Apple's servers | Apple's servers |
| Requires App Store Connect products | No | Yes | Yes |
| Requires the Paid Applications Agreement | No | Yes | Yes |
| Requires App Review approval | No | No | Yes |
| Can simulate failures and time travel | Yes | Limited | No |
| Used by TestFlight builds | No | Yes | No |
The `.storekit` file is a launch setting, not part of your app. Modaal applies it when it starts the app, which is why the change takes effect immediately in the simulator — and why the file can never end up in a TestFlight or App Store build. It isn't compiled into the binary.
Start in the StoreKit Test environment. It has no dependency on App Store Connect, so you can build and iterate on a paywall before you've created a product, signed an agreement, or filled in a tax form.
***
## Select a StoreKit configuration
The cogwheel to the right of the **Run** button opens **Run Options**, Modaal's equivalent of the scheme editor in Xcode.
Click the cogwheel. Modaal lists every `.storekit` file it finds in your project, so there's nothing to type or browse for.
Modaal saves your choice in the project's scheme, so it persists between sessions, survives rebuilds, and appears in Xcode as well.
The setting takes effect the next time you run the app; it doesn't affect an app that's already running. Run the app and the paywall fills in.
The build log records the result of every run:
Edits to the `.storekit` file also take effect on the next run. Change a price, run the app, and the new price appears — you don't need to delete the app or reset the simulator.
***
## Confirm which environment you're in
Product identifiers can't tell you which environment you're in. The identifiers in your `.storekit` file are deliberately the same as the ones in App Store Connect — that's what lets the same code run in every environment — so a populated paywall looks equally plausible in either one.
Prices and product names are the reliable signal. Here is the same app, in the same simulator, with the same product identifiers, run twice:
The annual plan costs $7.99 in one run and $6.99 in the other, and the discount badge recalculates to match. The app is behaving correctly in both cases; it's loading its products from a different source.
A paywall that looks correct isn't proof that your configuration is applied. When the configuration is cleared, the app requests products from Apple without reporting an error, and if your App Store Connect setup is complete you get real products at real prices. When in doubt, compare a price on screen with the price in your `.storekit` file.
***
## The setting is shared with Xcode
Modaal writes your selection into the project's scheme — the same place the scheme editor in Xcode stores it. Open the project in [Xcode](/guides/setup-xcode) and the configuration is already selected; run it with ⌘R and you get the same result.
| | Run from Modaal | ⌘R from Xcode |
| - | - | - |
| **Local configuration** |
|
|
| **None** |
|
|
There's nothing to keep in sync, and no reason to avoid opening the project in Xcode when you want its additional StoreKit tooling — the **Debug → StoreKit → Manage Transactions** window lets you refund, expire, and fast-forward subscriptions by hand.
***
## Testing on a real device
Modaal applies the configuration on the simulator. On a physical device it writes the setting into your scheme but can't inject it at launch; that part of the mechanism belongs to Xcode.
To use local test products on a device, open the project in Xcode and run it from there. Both the Run Options dialog and the build log note this, so you always know which case you're in.
This is a limitation of how Modaal launches apps on devices, not a limitation of StoreKit. Local test products work on a physical iPhone in both Debug and Release builds — the app just has to be launched by Xcode.
***
## Starting from scratch
If your app has no paywall yet, the fastest route is to build it against local products from the start:
> Add a Premium subscription with monthly and annual plans and a one-week free trial. Create a StoreKit configuration file so we can test it locally, then build a paywall screen that loads the plans and shows the annual savings.
Confirm that the build log says the configuration was applied, and that the prices on screen match your file.
Change prices, trial lengths, and copy in the `.storekit` file and run again. None of this touches App Store Connect, so there's nothing to wait for and nothing to undo.
When the flow is right, create the products in App Store Connect with the same product identifiers, complete your agreements, and do a sandbox pass on a device.
***
## Fix an empty paywall and other StoreKit failures
Start with the build log: was a configuration applied or not? The two cases have entirely different causes.
If a configuration was applied (the log shows `→ simulator`) and the paywall is still empty, check the following, in order of likelihood:
1. **Product identifier mismatch.** The identifiers in the `.storekit` file must match the identifiers your code requests exactly. This is the most common cause.
2. **A subscription outside its group.** In a `.storekit` file, auto-renewable subscriptions belong in a subscription group, not in the general products list. A subscription in the wrong list is never returned.
3. **You're on a physical device.** See [Testing on a real device](#testing-on-a-real-device).
A prompt for your agent:
> The paywall is empty. We're running with the StoreKit configuration applied. Compare the product IDs in the .storekit file against the IDs the app requests, and check that subscriptions are in a subscription group. Fix any mismatch.
If no configuration was applied (the log shows `cleared`, or there's no StoreKit line at all), the app is requesting products from App Store Connect — continue to the checklist below.
Each of the following produces the same symptom — an empty product list with no error. Check them in order of how often each is the cause:
1. **The Paid Applications Agreement isn't active.** This is the most common cause. In App Store Connect, under **Business**, the agreement must be signed, the tax forms completed, and a bank account attached. Until all three are done, StoreKit returns no products, and nothing else on this list matters.
2. **The product is in the Missing Metadata state.** Products in this state are never returned. Add a reference name, product identifier, price, and at least one localization — and for subscriptions, a group and a duration. A product in the Ready to Submit state is returned.
3. **The bundle identifier doesn't match.** Your app's bundle identifier must exactly match an existing App Store Connect record. The build itself doesn't need to be uploaded.
4. **The changes haven't propagated yet.** New products and metadata changes can take from a few minutes up to an hour to appear. If everything above is in order, wait and try again — there's nothing to fix in your code.
App Review approval is not required for any of this. Products in the Ready to Submit, Waiting for Review, or In Review states — even rejected products — are all returned outside production.
This is almost always the environment rather than a bug. Compare the price on screen with the `displayPrice` in your `.storekit` file:
* If they match, the app is using your local test products.
* If they differ, the app is using your real App Store Connect products, and the configuration isn't applied.
If you expected local products, open Run Options, confirm that a file is selected, and run again. The setting takes effect on the next run.
A completed purchase and a granted entitlement are two different things, and the gap between them is where hand-written purchase code usually goes wrong. Check for three causes:
1. **The transaction was never finished.** Finish every successful transaction after you grant access. StoreKit redelivers unfinished transactions indefinitely, which can leave the user stuck.
2. **Access is stored instead of derived.** A flag like `isPremium = true` saved at purchase time survives refunds, expirations, cancellations, and reinstalls. Derive access from the current entitlements at every launch instead.
3. **Nothing listens for transaction updates.** Purchases can complete outside your paywall — an approved Ask to Buy request, a renewal, a purchase made on another device, a refund. Start a transaction listener at app launch so the app hears about them.
> After a successful purchase the app still shows the free tier. Check that we finish the transaction after granting access, that premium status is derived from the current entitlements rather than a stored flag, and that we have a transaction updates listener running from launch.
To verify the fix, buy the subscription with a local configuration applied, then expire or refund it from Xcode's **Debug → StoreKit → Manage Transactions** window and confirm that the app revokes access.
Restoring doesn't replay a purchase record you keep — it re-reads the user's current entitlements from Apple. When Restore appears to do nothing, it's usually one of the following:
* The button runs a purchase flow instead of re-reading entitlements.
* Access is stored locally, so a fresh install has nothing to read and nothing to restore.
* The purchase was made in a different environment. A purchase made against local test products doesn't exist in sandbox, and vice versa — restore in the environment you bought in.
To test it properly, make a purchase, delete the app from the simulator, reinstall with Run, and tap Restore.
> Delete and reinstall the app, then check that Restore Purchases brings back the subscription without asking me to buy it again.
Simulating failure is what the StoreKit Test environment is for, and the main reason not to do all of your testing in sandbox.
With a configuration applied, open the project in Xcode and use **Debug → StoreKit → Manage Transactions** to fail transactions, approve or decline Ask to Buy requests, expire subscriptions, trigger billing retry, or issue refunds. The `.storekit` file also has settings for injecting errors and simulating slow networks.
Scenarios worth covering before you ship:
* Purchase interrupted (app killed mid-transaction)
* Purchase declined by the payment method
* Subscription expires and isn't renewed
* Subscription enters billing retry, then recovers
* A refund is issued after the fact
* Ask to Buy — a parent approves *or* denies
> Write a test that covers what happens when a purchase is interrupted and the app is relaunched before the transaction finishes. The entitlement should still be granted on relaunch.
This is expected behavior, and the most important gap to close before shipping.
TestFlight builds always use the sandbox environment. Your `.storekit` file was never part of the build, so a TestFlight build is your app's first real contact with App Store Connect — and the first moment the agreement and metadata problems from the checklist above can surface.
Sandbox differs from StoreKit Test in a few ways:
* Testers use their regular Apple Account; no separate sandbox account is needed.
* Purchases are free, and subscription renewals are accelerated.
* Auto-renewable subscriptions stop renewing after about six cycles.
* None of the StoreKit Test tools are available — no forced failures, no time travel, no clearing transactions.
Before you rely on TestFlight, do one sandbox pass on a real device: sign in under **Settings → Developer → Sandbox Apple Account** and run a build with no `.storekit` file attached. That run is what proves your receipts, your server notifications, and your backend against Apple's real servers.
Ask your agent to create one. It knows the format and where the file belongs:
> Create a StoreKit configuration file for the app with our subscription products, and wire it up so I can select it in Run Options.
The agent adds the file and configures the project so the file stays out of your shipped app — local test prices don't belong in a build that goes to the App Store.
You can also create one in Xcode with **File → New → File → StoreKit Configuration File**. Either way, the file appears in Run Options the next time you open the dialog.
A configuration that covers most apps:
* One monthly and one annual subscription in a single group, with the annual plan priced so the savings are easy to see.
* An introductory offer on both — a free trial is the most common.
Create them in your `.storekit` file first and iterate on the paywall locally. Once the design settles, create matching products in App Store Connect with the same product identifiers — that's what lets the same code work in every environment.
Your first in-app purchase must be submitted for review together with a new version of your app. After that first submission is approved, later products can be submitted on their own. Plan the first submission with this in mind — it isn't a separate, quicker path.
***
## Before you ship
Every purchase state has been exercised in StoreKit Test — including the failure cases, not just the happy path.
Restore Purchases works from a fresh install.
One sandbox pass has been done on a real device, with no `.storekit` file attached, against real App Store Connect products.
The Paid Applications Agreement is active — signed, tax forms complete, bank account attached.
Product identifiers in App Store Connect exactly match the ones in your code.
Your first in-app purchase is submitted together with the new app version.
Entitlements are checked at launch rather than remembered, so a refund or an expired subscription takes access away.
Ask your agent to audit the last one for you:
> Check that premium access is derived from the current StoreKit entitlements every launch, and that we're not persisting an "is premium" flag anywhere that would survive a refund or an expired subscription.
# How to Build Testable Android Apps: What Duet Fixes in Place
Source: https://docs.modaal.dev/articles/testable-android-apps
Make Android apps testable by keeping logic in pure reducers: nondeterminism lives behind effects, and recorded scenarios replay as the regression suite.
The reliable way to make an Android app testable is to keep every decision in code that takes no environment: a pure function from state and action to new state, with clocks, IDs, network and permissions pushed behind effect values a worker performs later. **Duet**, Modaal's cross-platform parity framework for native iOS and Android apps, fixes that architecture at project creation instead of leaving it as a per-team discipline — and then records each feature's behavior as fixture files that CI replays on every push. This page states what stays standard Android, what the scaffold fixes in place, what that costs, and the measured results from a shipped app.
## What stays standard Android?
The toolchain is unchanged: Kotlin, Jetpack Compose, Gradle, and a tree Android Studio opens and runs — nothing proprietary in it, no code generator you cannot leave. The architecture is the one Google's own [app architecture guide](https://developer.android.com/topic/architecture) recommends in general form — unidirectional data flow, state as data, a clear UI/logic boundary. What Duet changes is who enforces it: the shape is scaffolded and gated rather than described in a document, and the logic layer compiles to `commonMain` so the iPhone app in the same repository runs the identical code. [Inside a Duet Android app](/articles/duet-android-app-anatomy) walks the resulting tree file by file.
## What does a blank project leave open that Duet fixes?
A new Android project fixes almost nothing about structure: each screen's author chooses where logic sits, how state is held, and what a test can reach. Duet replaces those per-screen choices with three project-wide ones:
* **One feature shape.** Every feature is a serializable state, a sealed set of actions, and one pure [reducer](/articles/duet-glossary#reducer) — no hand-rolled ViewModel variants, no per-screen state idiom.
* **Effects as data.** A reducer never calls a repository, reads a clock, or generates an ID. It *returns* effect values; [workers](/articles/duet-glossary#worker) perform them and report back as actions. Anything nondeterministic round-trips through that seam, which is what makes behavior replayable.
* **Recordings as the test contract.** Each feature carries a [scenario](/articles/duet-glossary#scenario) — a `Given`/`When`/`Then` script that is compiled into fixture files. The fixtures are build products checked into the repository, and CI replays them byte-for-byte on every platform that implements the feature.
### The same behavior, hand-rolled and fixed
A typical hand-rolled ViewModel, and the failure modes a test has to fight:
```kotlin FavoritesViewModel.kt (a blank-project idiom) theme={null}
class FavoritesViewModel(
private val repo: FavoritesRepository,
) : ViewModel() {
private val _ui = MutableStateFlow(FavoritesUiState())
val ui: StateFlow = _ui
fun onFavoriteTapped(id: String) {
viewModelScope.launch {
try {
repo.setFavorite(id, favoritedAt = System.currentTimeMillis())
_ui.update { it.copy(favoriteIds = it.favoriteIds + id) }
} catch (e: IOException) {
_ui.update { it.copy(error = "Couldn't save") }
}
}
}
}
```
Testing this class needs a fake repository, a test dispatcher, and control over a clock the code reads inline; the coroutine races the assertion, and the error branch fires only when the fake throws on cue. The same behavior as a Duet feature:
```kotlin FavoritesFeature.kt (commonMain) theme={null}
fun favoritesReducer(
state: FavoritesState,
action: FavoritesAction,
): Reduced =
when (action) {
is FavoritesAction.FavoriteTapped ->
Reduced(
state.copy(pending = state.pending + action.id),
listOf(Effect.Run(FavoritesEffectPayload.SaveFavorite(action.id))))
is FavoritesAction.SaveSucceeded ->
Reduced(state.copy(
pending = state.pending - action.id,
favoriteIds = state.favoriteIds + action.id))
is FavoritesAction.SaveFailed ->
Reduced(state.copy(pending = state.pending - action.id, error = FavoritesError.SaveFailed))
}
```
The timestamp is gone from the logic: if the saved-at time matters, the worker supplies it when it reports `SaveSucceeded`. And the test is no longer a class with fakes — it is a script over the reducer, recorded once and replayed forever:
```kotlin FavoritesScenarioTest.kt theme={null}
scenario(feature = "favorites") {
given(FavoritesState())
branch("save succeeds") {
whenAction("favorite a memory", FavoritesAction.FavoriteTapped("m1"))
thenEffects("exactly one save request") {
it == effectsOf(Effect.Run(FavoritesEffectPayload.SaveFavorite("m1")))
}
whenAction("the worker reports success", FavoritesAction.SaveSucceeded("m1"))
then("the favorite is on") { "m1" in it.favoriteIds }
}
branch("save fails") {
whenAction("favorite a memory", FavoritesAction.FavoriteTapped("m1"))
whenAction("the worker reports failure", FavoritesAction.SaveFailed("m1"))
then("the tap can be retried") { it.pending.isEmpty() && it.error == FavoritesError.SaveFailed }
}
}
```
Recording this scenario produces a fixture file; the JVM test lane replays it on every push, and when the iPhone app implements the same feature, the identical bytes gate that side too.
## How do the two setups compare?
| | A blank Android project | A Duet project |
| - | - | - |
| Architecture | Chosen per team, drifts per screen | One shape, scaffolded: state + actions + reducer per feature |
| Where nondeterminism lives | Wherever it was called — clocks and IDs inline | Behind effects; values round-trip through workers as actions |
| The unit of test | A ViewModel plus fakes and test dispatchers | A scenario over a pure function; no fakes, no dispatchers |
| What CI checks | The tests each author remembered to write | The recorded corpus, replayed and byte-compared on every push |
| A second platform | A second codebase, kept in step by review | The same fixtures gate the iPhone app's identical core |
## What does it cost?
The fix has three costs; the [Duet overview](/articles/duet#what-duet-requires-of-you) carries the full list:
* **The architecture is fixed at creation.** A Duet project stays a Duet project; the conventions — the feature shape, the effect seam, the recording step — are not per-feature choices.
* **Views are still written per platform.** The testability applies to logic. Compose screens are ordinary Compose, tested the way the platform tests UI, and an Android-plus-iOS change was measured at roughly 40% more agent effort than the single-platform equivalent in Modaal's own runs.
* **The recording step is part of the loop.** Changing behavior means updating the scenario and re-recording, not just editing code until the app looks right. That is the point — but it is a step a blank project does not have.
## What happens when Android kills your app's process?
Android can kill an app's process while it is in the background and recreate the activity from a saved `Bundle` — an asymmetry iOS developers meet on day one of an Android port, documented in [Google's saved-state guidance](https://developer.android.com/topic/libraries/architecture/saving-states). In this architecture the answer is uniform: state is serializable data, so the navigation spine is captured as a value in `onSaveInstanceState` and restored into the rebuilt tree — the [anatomy page](/articles/duet-android-app-anatomy#what-is-in-the-android-app-module) shows the dozen lines in the activity that do it. There is no per-screen inventory of what to save, because there is no per-screen state idiom.
## Why does this suit AI coding agents?
An agent building a feature needs machine-checkable gates between its steps, or errors compound silently across a session. The scaffold's loop supplies them in a fixed order: the feature spec, then the scenario, then the recording, then the reducer that must satisfy it, then the screens — with `tools/duet verify` as the same gate CI runs. An agent (or a person) cannot wire a screen to logic that has no passing recording, and a behavior regression fails the build on the commit that caused it rather than surfacing in QA. This staged order is how Modaal's own agent builds every Duet feature — [the Duet overview](/articles/duet#how-a-feature-gets-built) walks the six steps.
## What are the measured results?
From Memory Lane, the shipped dual-platform app this site's Duet pages quote, each number with its scope:
* **Warm test suite: 1–3 seconds** for the dual-platform behavior suite at four-feature scale during the field migration — fast enough to run on every edit.
* **Mutation drill: 10 out of 10.** Ten seeded logic mutations, each caught by fixture replay at the recording layer.
* **Recordings byte-identical across platforms: 6 of 6** in the adoption measurement — the Kotlin core replays the exact fixture bytes the Swift reducers recorded.
* **Sixteen Android screens, zero logic edits.** The Android app's product surfaces were built against a core whose recordings already passed; no fixture was re-recorded to make Android work.
## When should you not use Duet?
Two cases are outside its lane today. Games and drawn playfields: no Duet template yet scaffolds a SpriteKit scene host, and a physics loop is not reducer-shaped work — Modaal's **2D game** template covers that ground, and Duet support for graphics-rich apps is planned. And teams that want to assemble a bespoke stack — their own DI framework, their own test strategy, per-screen architectural freedom — are choosing exactly the degrees of freedom Duet removes; a blank Android Studio project serves that intent better.
## Common questions
It is in the same family — unidirectional flow with state as data and actions as the only inputs. What a generic MVI setup does not fix is where nondeterminism lives or what proves behavior: Duet adds the effect seam as a hard rule and recorded fixtures as the contract, replayed on both platforms in CI.
A ViewModel test exercises code against fakes you maintain, and it lives on one platform. A scenario drives a pure reducer with no fakes at all, and recording it produces an artifact — the fixture — that both the Android and iPhone implementations must replay byte-for-byte. The test suite is also a cross-platform agreement check, not only a regression net.
No. The project is a standard Gradle build; Android Studio opens it, Compose screens are ordinary Compose, and Jetpack libraries live where they always did — in the app module's workers and screens. The constraint is only that feature logic stays in its module and reaches I/O through effects.
The state that must survive is serializable and rides the saved-instance Bundle; effects in flight are re-requested by the restored state where the feature models it that way. The scenario can pin that behavior — restoration after a system kill is a branch you record, not a bug class you meet in production.
Structurally yes — the Android app and the shared core work with no iOS target. The recording discipline still pays (fast pure tests, a mutation-resistant suite), but the cross-platform gate is Duet's distinctive return; for a product that will only ever ship one platform, weigh the conventions against a lighter setup.
[Modaal](https://modaal.dev) scaffolds Duet projects that start from this architecture instead of retrofitting it.
## Sources and further reading
* [Guide to app architecture](https://developer.android.com/topic/architecture) — Google's recommendation: UI layer, data layer, unidirectional data flow
* [Save UI states](https://developer.android.com/topic/libraries/architecture/saving-states) — what must survive when the system recreates the activity or kills the process, and how
* [Test apps on Android](https://developer.android.com/training/testing) — the platform's testing fundamentals this page's approach narrows
* [Thinking in Compose](https://developer.android.com/develop/ui/compose/mental-model) — state-driven UI on the rendering side of the seam
* [The Duet framework on GitHub](https://github.com/modaal-agent/duet) — both flavors, the recording toolchain and the versioned contracts
## Read next
The tree this page's rules produce: feature modules, the thin app module, workers and the fixtures directory.
What Duet is, the combinations that scaffold it, and the six-step loop every feature follows.
Where the shared/native line sits on the rendering side, and how deliberate divergence is recorded.
Reducer, worker, scenario, fixture, the checks — every term defined with one link each.
The rules on this page applied by hand: a pure reducer and its recordings in Tutorial 1, the worker harness in Tutorial 4, the mutation drill in Tutorial 6.
# Debug Your App by Pointing at the Screen
Source: https://docs.modaal.dev/articles/visual-debugging
Point at the screen and describe the bug: the agent reads screenshots, console logs, and the live view hierarchy to find the cause, fix it, and verify the fix.
Visual bugs — a missing image, a misaligned button, the wrong text on the wrong screen — are the hardest things to describe in code terms. You see them, but you don't know which file or what to check. Modaal closes that gap by letting the agent see and use your running app. You describe what you see, and the agent debugs it the way an engineer would.
## How to do it
With your project open and the simulator running ([Set up Xcode](/guides/setup-xcode) covers the simulator), describe the bug in plain language. Name the screen where you see it. If there's another screen where the same thing works correctly, name that too — the contrast is the most useful clue the agent can get.
A good prompt looks like this:
> The avatar is missing on the Profile tab — I just see a grey circle with my initial. On the Invite screen, the avatar shows correctly. Find and fix it.
That's it. No file names. No hypothesis.
## What the agent does
On its own, the agent will:
1. Search your code to find both screens
2. Read the console logs from the running simulator
3. Take a screenshot of the broken screen and look at it
4. Inspect the live UI hierarchy
5. Write a fix and rebuild the app
6. Tap to the affected screen in the simulator
7. Take a new screenshot to verify the fix worked
You don't open Xcode. You don't touch the simulator.
## Check the surrounding screens
After the fix, ask the agent to verify nothing else broke:
> Check that the Invite screen still works.
The agent taps the Invite button on its own, takes a screenshot, and confirms the avatar is still showing there too. When both screens look right, tell the agent to commit.
## Why this works
Modaal gives the agent a live connection to the iOS simulator — screenshots, console logs, UI hierarchy, and the ability to tap, scroll, and switch tabs. So when the agent says "the fix is verified," it's because it looked at the screen, not because the build compiled. The prompt shape that helps most is on [Modes and prompts](/articles/modes-and-prompts#give-context-when-debugging). For what Xcode's built-in Claude covers on its own and what it does not, see [Claude Code and Xcode](https://modaal.dev/claude-code-xcode).
That's the loop a senior iOS engineer runs every day. Visual debugging is how Modaal gives you the same loop without learning Swift.
# Modaal Changelog: Releases and Updates
Source: https://docs.modaal.dev/changelog
Every Modaal release in order, newest first: what each version added, changed or fixed, with the download link for the macOS build of each one.
The current build is on [modaal.dev/download](https://modaal.dev/download); each release below links its own installer, and [Plans & pricing](/credits-and-plans) lists what Free and Pro include. Longer write-ups of what the releases make possible are on [the Modaal blog](https://modaal.dev/blog).
## Release v1.8.0
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_8\_0.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_8_0.dmg)
Updates:
* **The "New Project" wizard replaces the architecture question** - add your PRD or a description of the first feature, then start from the template that fits it, instead of answering obscure architectural questions mid-agent session. See [the new-project flow](/articles/new-project#the-new-project-flow)
* **New "2D game / Interactive app" template** - not a bespoke game engine, but a better place to start a simple 2D mini-game than an empty app. See [building 2D games with SpriteKit](/articles/spritekit-games)
* Fixed the testing script and the MVVM and Apple Watch templates
## Release v1.7.1
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_7\_1.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_7_1.dmg)
Updates:
* Questions the agent asks you now arrive as the intended choices - a malformed call is rejected with an error the agent can correct, instead of rendering as raw markup with a plain text box
## Release v1.7.0
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_7\_0.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_7_0.dmg)
Updates:
* **Icon Composer icons are now the default** in newly scaffolded apps, with knowledge-base documentation for the agent and a fix for how icons are displayed
* **StoreKit local testing on the simulator** - pick a `.storekit` configuration in the run scheme options and debug in-app purchases without App Store Connect. See the [in-app purchases guide](/articles/storekit-testing)
* Agents can now respond to background tasks
* Fixed agent resolution after an update
## Release v1.6.11–1.6.13
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_6\_13.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_6_13.dmg)
Updates:
* **Icon Composer support** - Modaal recognizes `.icon` app icons, and you can open them straight in Icon Composer from Project Settings
* **Git Sync**: "Resolve with agent" button right in the merge-conflict dialog - hand the conflict to your agent instead of resolving it by hand
* **Git Import**: projects you cloned outside of Modaal are now matched and adopted instead of being re-cloned
* Fixed behind/ahead counts in Git Sync when switching between projects
* Refreshed sidebar icons
* Forward-compatibility: Modaal no longer overwrites Duet configuration fields in your project config files
## Release v1.6.7–1.6.9
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_6\_9.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_6_9.dmg)
Updates:
* **GitHub import fixes**: the imported project opens with your current default agent, importing a folder you already have registered simply adopts it, and error responses no longer crash the import flow
* **"Fix with agent"** button when Archive & Upload fails - send the error straight to the agent
* Warning dialogs no longer appear behind the chat pane
## Release v1.6.6
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_6\_6.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_6_6.dmg)
Updates:
* **Firebase CLI**: one resolver for detect / deploy / login, and the login handoff now uses the app's own CLI binary
* **"Needs re-install" pill** when an agent's binary goes missing, plus a clean, human-readable failure instead of a silent hang
* Simulators with the same name and version are disambiguated in the picker
* Fixed simulator selection snapping back to the last-run simulator
* Fixed the Run button latching disabled after a lost provisioning result
## Release v1.6.5
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_6\_5.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_6_5.dmg)
Updates:
* Knowledge base: plan-toggle awareness, plus DRY & code-hygiene rules and examples for the agent
* The new guest home page and prompt-first experience is now the default
* UX: signup / auth popovers, full-window file drop, trial notification and sticky banner, smoother upgrade flow
* Firebase auto-fix / reconciliation improvements
## Release v1.6.4
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_6\_4.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_6_4.dmg)
Updates:
* **Bundled Node installer** - no Homebrew dependency for the first-use experience (it's only needed later, for fastlane)
* Modaal Studio **app icon v2**
* Knowledge base update: agent self-awareness, and guidance on how to install Xcode and connect agents
## Release v1.6.1–1.6.3
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_6\_3.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_6_3.dmg)
Updates:
* `xcode-select` switching now asks for the admin password with a proper prompt
* **Agent Settings** UI refactor, **Claude OAuth** sign-in flow with a Terminal fallback
* Fixed Codex ACP OAuth (accepts the `chat-gpt` method id)
* Terminal login opens in your projects directory, not `$HOME`
* Firebase Data Connect: broke the destructive-deploy retry loop
* Fixed the onboarding npm `EACCES` error on non-Homebrew Node
* Initial Homebrew install now brings Terminal to the foreground
## Release v1.6.0
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_6\_0.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_6_0.dmg)
New features:
* **Progressive, non-blocking onboarding** - start building first, install the rest as you need it
* **New first-time experience**: Guest Home → Prompt → Auth → Chat
* **Modaal Preview** provider + proxy included
* Chat / Preview split now responds to window resizing
* Improved question-asking (AskUserQuestion) flow
* Better auto-preview of priority files
* Onboarding nudges for credits, Xcode and plan upgrades
* Bugfix: git state and commit history reload when the panel is shown
* Pricing update
Builds older than v1.5.2 are no longer distributed.
## Release v1.5.4–1.5.6
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_5\_6.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_5_6.dmg)
Updates:
* Onboarding: no more spurious "System update is required" during onboarding
* **Much faster prerequisites install**: the Apple-dev signing toolchain is deferred until you first sign something, XcodeBuildMCP installs via npm (-20 seconds), and xcodegen / xcbeautify are pre-bundled instead of built with mint (-60%)
* **Xcode 27 support**: picks Device Hub (Xcode 27) vs Simulator.app (Xcode 26)
* Opens Xcode for `.xcodeproj` even when the default handler is another editor
* iOS version shown in the "Running on …" toolbar state and in build logs
* Markdown previews keep their position when you resize the panel or window
* "Update available" pill throttled per provider (24h)
* Fixed stale question popovers replaying when switching projects or chats
## Release v1.5.3
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_5\_3.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_5_3.dmg)
Updates:
* **Planning ⇄ Execute mode switch** (the "Vibe" mode is retired)
* **Chat history** - switch between chats, with auto-generated chat titles
* **Home page refresh**: fixed-size left bar, friendly titles ("Welcome back "), status bar, collaborators list
* **Firebase Data Connect** scaffolding + native deployment support
* Agent dependency management, resolution & version-update tracking
* The old CLI-based Claude and Cursor providers are deprecated - there's a guided auto-migration to the ACP-based Claude Agent and Cursor
* Numerous UI tweaks and improvements
## Release v1.5.0–1.5.2
[https://modaal-studio-distr.modaal.dev/ModaalStudio\_1\_5\_2.dmg](https://modaal-studio-distr.modaal.dev/ModaalStudio_1_5_2.dmg)
Updates:
* Human-readable agent error messages instead of raw protocol errors
* iOS template update: AppServices module + RIBsExtensions helpers
* Knowledge base re-work: covers security, best practices and migrations
* Added the Codex ACP wrapper + fixed mode / permission settings
* Clearer "commit-gate" wording when the agent asks before committing
* Clicking a file link in a chat message re-fetches the file and refreshes the tree
## Release v1.4.0–1.4.2
Updates:
* **Open tabs & layout are remembered per project**
* **Markdown preview**: breadcrumbs navigation (section preview) and a table-of-contents mini-map
* Quote-insert for headers and quotes
* The agent **confirms before making a git commit**, with an option to self-review first
* Fully re-worked agent guidance / knowledge base, including guidance on self-migrating existing projects
* Updated iOS template: `run_tests` script, refreshed dependencies
* Manual refresh button in the file tree
* Fixed a macOS 15 layout bug where Modaal Studio toolbar buttons stopped working
* Fixed preview line numbers for list items, table rows and code blocks
* The chat idle watchdog no longer drops your connection while the agent is still running
## Release v1.3.1–1.3.6
Updates:
* **Firebase**: better deployment validation and auto-fixing prompts, Data Connect guidance, and an artifacts maintenance policy added automatically on the first functions deployment
* **"Thinking effort"** option for agents that support it, and ACP `mode` options surfaced in the model selector
* Updated Claude Agent integration
* Knowledge base revamp + re-index, including security / vulnerability-resistance guidance for agents
* GitHub sync now works when the remote URL is an `ssh`/`git` one
* Re-worked Firebase and RIBs integration guides
* Bugfixes: chat cancellation & resume, composer no longer blocked during streaming, no more unnecessary "System components update is required", modal z-order and Esc handling, missing build / error output in the logs panel, app bundle discovery path
\#Release v1.3.0
Changes:
* Improved Apple ID login
* Automatic new device registration (Run on Device function)
* Automatic App Store record creation for a new AppStore distribution
\#Release v1.2.5
Changes:
* Firebase integration: Fix repo URL; Improve migration guide/prompts;
* Make "Start new chat" button always enabled/visible.
* Make agentic guidance easier to access
\#Release v1.2.2
Changes:
* Firebase integration: **Bugfixes & improvements**
\#Release v1.2.1
Added:
Firebase integration
Guide:
[https://docs.modaal.dev/articles/firebase](https://docs.modaal.dev/articles/firebase)
\#Release v1.1.1-1.1.2
Changes:
* Fixed the prerequisites check - Homebrew installs now run in an interactive Terminal, so you can enter your sudo password
* Removed the Figma step from onboarding
\#Release v1.1.0
New features:
* Natively **Run on Device** - select real connected iOS devices in the target selector, hit "Run"
* Upload to **TestFlight / AppStore** - simple flow, only the build (no screenshots / texts), right from Modaal Studio!
* UI fixes and performance improvements
* Better prompts / agentic guidance / documentation
^^ For the Run on Device & Upload to TestFlight to work, you'll need to have an Apple Dev Account (Free or Paid), and be logged in in Xcode (Cmd+`,` / "Apple Accounts")
\#Release v1.0.0-1.0.3
Summary of all updates:
* **New planning mode** - plans features per your request
* **Improved internal documentation** - the agent will be able to guide you better throughout TestFlight or AppStore submissions, resolve build and config issues
* Improved **"Export to Xcode"** - less friction submitting TestFlight or AppStore
* **Various UI** and **performance** improvements
\#Release v0.9.6
Updates:
* Fixed Claude Agent (ACP) integration - and added **AskUserQuestion** tool support - now agents can ask you questions when writing plans!
* Added iOS **Project Settings** and **Capabilities/Entitlements** editing from within Modaal - both you and your agent can now add APNS entitlement, enable keychain sharing, or associated domain settings
* Improvements to the **GitHub Connect** flow and minor **UI improvements**
\#Release v0.9.5
**Improvement**: Claude CLI automatically clears old sessions, which previously caused a cryptic "No conversation found with session ID" error.
We now detect this and prompt you to start a fresh conversation instead.
\#Release v0.9.4
Updates:
* **GitHub Connect** (Publish to GitHub) / **Import from GitHub** - publish your project to GitHub and invite collaborators!
* **UI improvements**: Floating and collapsible user message in the message list allows to navigate long conversations easier
* Added long-awaited "Open in Terminal" command (split-button in the Toolbar) - run Terminal commands directly in your project's folder!
\#Release v0.9.2-0.9.3
Bugfixes:
* Fixed ACP agent validation sequence (fixes Goose validation)
* Fixed the `block-goose-cli` package dependency
* Updated agent auth hints
\#release v0.9.1
Updates:
* Fixed model selection for new agents
* Improved token stats reporting
\#release v0.9.0
Updates:
1. Support for 11 coding agents, including Open-Source agents that give access to open-weight (cheap or free) models
no more juggling between ChatGPT and Modaal - can use OpenAI Codex with your existing ChatGPT subscription!
2. With Claude Code, we're monitoring token/subscription cost usage
3. Better guidance -> less mistakes during coding sessions
\#release v0.8.0
Updates:
* Add "Direct" workflow mode
* Full MVVM template & support in the scaffolding tools.
* Improve iOS template: Feature-level customized color tokens
* UI improvements & bugfixes
\#release v0.7.2
Changes:
* new agentic scaffolding tools - can add new iOS/watchOS targets to existing projects easily
* UI improvements: auto-updating file previews, target & simulator selection during builds, tool names & parameters formatting
\#v0.7.1
* More autonomous debug tools
* Better agent guidance - the agent is able to use the tools more effectively
\#v0.7.0
Modaal is now a fully autonomous software engineer - it "sees" the app screen, can navigate / tap / type, can read debug and console logs, and can use LLDB for advanced debugging.
Connect Figma and Modaal will iterate on the UI until it's pixel-perfect - all by itself, building, comparing and tweaking. No more tedious "Move the top navigation button 3px to the right"!
\#v0.6.4
Bugfix:
* Fixed a bug where a single project's unparseable YAML config resulted in an empty project list.
\#v0.6.3
Local Figma MCP server integration.
With Figma running locally on your Mac, Modaal now automatically discovers and uses its MCP server to allow direct fetching of the design context and previews.
Just give the agent a link to the node in your Figma file, and ask it to implement the screen / view / button / style etc.
Also some UI & performance improvements.
Please feel free to ask questions if you have any issues enabling Figma MCP!
For me it's been a productivity game-changer once I started using it in my workflow.
\#v0.6.2
Big one:
Modaal shows all available devices / simulators (iPhones, iPads, watchOS simulators) in the dropdown and allows to build and run the app on any simulator from the UI
Also, you can just ask the agent "Run this app on an iPad" or "Run this app on an iPhone", or "Run this app on a WatchOS simulator"!
\#v0.6.1
Changes:
* (internal) PostHog analytics instrumentation
* Allow API KEY Cursor / Claude usage
* Set up Xcode screen shows available iOS and WatchOS runtimes and simulators
* Improved knowledge base / agent guidance
* UI performance optimizations - no more lags when scrolling!
**Bigger changes:**
* The agent now supports stand-alone WatchOS app development, as well as dual iOS + WatchOS. Just tell the agent you want a standalone WatchOS app!
* For pure WatchOS apps, the template is minimal, and the build times are lightning-fast!
* Improved agent guidance and context search functionality, to give the agent better context for each prompt.
**Smaller changes:**
* Re-organized onboarding screens: Now the agent installs Xcode first, before the pre-requisites.
* Also, during Xcode check, WatchOS runtimes/simulators are detected.
* UI improvements: display better tool names in the chat history
* Show "Building" status, and the build logs, even when the build is started by the agent! No more confusion what's happening!
Small changes:
* Onboarding flow: 1) Install Xcode as the first step; 2) Show install/update log during prerequisites check (can take several minutes)
* Improve licensing setup: add NOTICE; agents mark new files as explicitly NOT MIT-licensed.
Latest update:
Added MIT license to the app template, and clearly marked all source files. The MIT license is the most permissive, and unlocks broad range of the permitted use cases for the bundled source code.
Update the app template to the modern Swift.Combine patterns. Among others: removes legacy dependencies, and makes the initial build 2-3x times faster! Also, LLMs are much more knowledgeable with the new patterns, so should be able to write better code, faster!
For those of you who started building earlier, please use the attached prompt (copy-paste to Modaal) to migrate the existing project to the new template:
(but it might burn through quite a lot of AI credits, use with discretion).
[https://discord.com/channels/1442563142818336931/1442563142818336938/1469081014763786475](https://discord.com/channels/1442563142818336931/1442563142818336938/1469081014763786475)
Latest update brings:
* Extended Semantic Color Scheme (light + dark)
* Fixed iOS template (RxSwift dependency no longer breaks - fixed warnings during compilation)
Prompts to migrate the project for those who started building earlier:
1.
```
Please update dependencies in Package.swift across the project to switch back to the production RxSwift SPM:
`.package(url: "https://github.com/ReactiveX/RxSwift.git", from: "6.10.1"),`
The `6_10_0_swift59_fix` workaround is no longer needed.
```
2.
```
Please read new Semantic Color Scheme definitions `knowledge/ios-design-system.md`, and consistently apply semantic colors according to their intended use cases across the project. If the project uses custom color scheme, confirm before the migration.
Check this commit for reference:
https://github.com/modaal-agent/modaal-ios-template/commit/c3f7d6d769071989f910db2da880eabdd63228f5
```
Happy building!
Special Launch Offer! All users who register by February 15 get a 1-month free trial. No credit card required.
# Modaal Plans and Pricing: Free and Pro
Source: https://docs.modaal.dev/credits-and-plans
Modaal has two plans: Free, which does not expire and needs no card, and Pro at €9 per user per month billed annually. No credit system, no per-token charge.
Modaal has two plans. **Free** does not expire and does not require a card.
**Pro** unlocks unlimited projects, both platforms together, and distribution to
TestFlight and the App Store.
There is no credit system, no per-token charge, and no trial countdown. The
same plans are on [modaal.dev/pricing](https://modaal.dev/pricing).
## What does the Free plan include?
Free is a real plan, not a preview. It has no expiry date.
| | |
| - | - |
| **Active projects** | 1 |
| **Prompts** | Unlimited. No daily or monthly cap. |
| **Platforms** | One of your choice — iOS or Android |
| **AI agent** | Bring your own — Claude Agent, Codex CLI, Cursor, OpenCode, Gemini CLI, GitHub Copilot CLI, Auggie, Cline, Goose, Kilo Code, Mistral Vibe, OpenHands and Qwen Code |
| **Output** | Truly native builds: Swift for iPhone, iPad and Apple Watch, or Kotlin for Android |
| **Your code** | Yours. Export at any time. |
| **Card required** | No |
### What Free does not include
Three scope limits, stated plainly:
* **One active project.** Finish or archive one before starting another.
* **One platform at a time.** iOS or Android, not both together.
* **No TestFlight or App Store distribution.** Free will take you all the way to
a working native app running on your own device. Publishing it to other people
is Pro.
There is no usage limit. Prompts are uncapped on Free.
## What does Pro cost and unlock?
**€9 per user per month, billed annually.** €15 per user per month billed
monthly. The same figures apply in US dollars.
Everything in Free, plus:
* **Unlimited active projects**
* **iOS and Android together**, from one shared core — see [Duet](/duet)
* **TestFlight and App Store distribution**
* **Git collaboration**
* **Priority support**
You do not need a card to start on Free. Upgrade when you actually need one of
the above.
## Why there is no credit system
Most tools in this category buy inference on your behalf. They bundle credits or
resell tokens, so every message you send costs them money — which means free
accounts have to be capped, and paid accounts have to be metered.
Modaal does not buy inference. You connect an agent you already subscribe to and
authenticate with your own credentials, and Modaal orchestrates it locally
against Xcode on your own Mac. We are not paying for your tokens and we are not
running your builds.
That is why prompts are uncapped even on Free: a free user does not cost us much.
The limits on Free are about scope, not about how hard you are allowed to work.
See [Connect your agent](/guides/connect-your-agent) for the list of supported
agents and how authentication works.
## Add hands-on help by the month
Add-ons sit on top of Pro and are billed month by month.
### Human in the Loop — +€199 / month
A short call to figure out how we can best help you
* Two live 45-minute sessions with a Modaal developer
* Async support in Discord, with a response within one business day
* Access to the founding team
Sessions do not roll over. Cancel or reschedule at least 24 hours ahead.
### Team in the Loop — +€2,500 / month
A short call to figure out how we can best help you
* Up to 8 hours of hands-on building per month
* Weekly sync calls
* Dedicated Discord channel
* Optional UX, design and security reviews
* App Store submission guidance
Add-ons are non-refundable and non-cancellable once purchased, are active for one
calendar month, and do not auto-renew.
## What else do people ask about plans and billing?
**Is Free really free?**
Yes. No expiry, no card, no usage cap. One active project, one platform, and no
store distribution.
**What happened to the 14-day trial?**
We removed it. A countdown is a poor way to get to know a tool you are still
learning to build with — people would run out mid-way through forming an opinion.
Free replaces it and does not expire.
**Do I need to pay for Claude or ChatGPT separately?**
To go beyond the built-in free preview agent, yes — you use a subscription you
already have. Modaal adds no per-token or API cost on top of it.
**Can I cancel Pro and keep my apps?**
Yes. Your projects are ordinary native codebases on your own disk. There is no
proprietary format and no export step, because the code was never anywhere else.
Cancelling changes what Modaal will do for you, not what you already have. How
that compares with builders that keep the code on their own servers is in
[what code export means across AI app builders](https://modaal.dev/blog/ai-app-builder-code-export).
**Do I need a Mac?**
Yes. Modaal builds and runs your app through Xcode, so it needs macOS with
Xcode 26 or newer and around 50 GB of free space. See
[Set up Xcode](/guides/setup-xcode).
# AI Design Prompts for App UI: Spacing, Contrast, and Polish
Source: https://docs.modaal.dev/design-prompts
The vocabulary and copy-paste prompts for polishing your app's design — spacing, contrast, tap targets, consistency, motion, and the full audit.
Once your [design system](/ai-design) exists, most design work is a short prompt away in [Modaal](https://modaal.dev). This page gives you the vocabulary to describe what you see, plus copy-paste prompts for the checks non-designers most often miss. Every prompt here runs with the [Plan toggle](/articles/modes-and-prompts) off. [Prompting agents for native mobile UI](https://modaal.dev/blog/prompting-agents-for-native-mobile-ui) covers the same ground against iOS and Android interface conventions.
## Learn the vocabulary for describing what you see
* *"more breathing room / airier"* → bigger gaps
* *"tighter / denser"* → smaller gaps
* *"the padding inside the card"* → space between a card's edge and its contents
* *"the margin"* → space from the screen edge to the content
* *"the gap / the rhythm between items"* → space between repeated elements
Useful sentence: *"Increase the vertical rhythm between sections by one step on the spacing scale."*
iOS uses a fixed type scale. You rarely need the numbers, but here they are so you can point at a role:
* **34pt** — large title (the big screen title)
* **22pt** — title
* **17pt** — body / headline (default reading size; anything smaller starts to look like a website)
* **15pt** — subheadline
* **13pt / 11pt** — caption / metadata (timestamps, labels)
The words iOS uses, light to heavy: **regular → medium → semibold → bold**. *"Make the amount semibold so it stands out from the label."*
* **accent / primary** — the interactive color (buttons, active tab, selection)
* **surface / background** — the color behind content (card vs screen)
* **ink / text-primary / text-secondary** — text colors: primary for headings, secondary for supporting text
* **semantic roles** — success, warning, error, plus your app's own named roles
* **corner radius** — how rounded corners are (*"softer corners"* / *"sharper corners"*)
* **elevation / shadow / depth policy** — whether things float on shadows, sit on surfaces, or use gradients. Your system should pick *one* policy; point at it: *"we use surfaces, not shadows — remove that drop shadow."*
* **tap target** — the invisible touchable area (must be at least **44×44pt**)
* **pressed / disabled state** — how something looks when tapped or unavailable
* **hairline** — a 1px divider line
Not sure what to ask for? Use this:
```text theme={null}
I don't love this screen but can't name why. Give me 4 dials I can react to — like warmer/cooler, airier/denser, quieter/louder type, softer/sharper shapes — and show me what pushing each one would do.
```
## Reuse values and components
Extract repeated things into shared components:
```text theme={null}
Look across all my screens. Find any visual element that repeats — cards, buttons, list rows, headers, badges. For each, tell me if it's built the same way everywhere or if there are subtle differences. Then propose turning the repeated ones into a single shared component that every screen uses.
```
Before building anything new, reuse what exists:
```text theme={null}
I want to add a [new screen / new element]. Build it from the existing components and tokens so it matches the rest of the app. Only if nothing existing fits, add the new piece to the design system rather than to just this screen.
```
## Accessibility — text contrast
```text theme={null}
Run an accessibility contrast check on this screen. Check every text element against its background, in both light and dark mode, and tell me whether each one passes WCAG AA. Flag anything that fails and suggest the smallest fix.
```
For dots, icons, and other non-text elements:
```text theme={null}
Also check non-text elements that carry meaning — status dots, icons, selection outlines, dividers. Flag any that don't have enough contrast against their background, and suggest a fix.
```
## Check every tap target is at least 44×44pt
Anything tappable must be at least **44×44pt**, even if the visible icon is smaller.
```text theme={null}
Check every interactive element — buttons, chevrons, icons, toggles, list rows, close buttons. Measure each one's tap target. Flag anything smaller than 44×44pt and fix it by adding padding around the element (grow the touchable area, don't necessarily grow the visible icon). Confirm the fix doesn't push neighboring elements out of alignment.
```
Quick version for a single element: *"Is this chevron's tap target at least 44pt? If not, pad it to 44 without moving the icon."*
## Find and fix inconsistencies across screens
The catch-all "does this all feel like one app" review:
```text theme={null}
Do a consistency pass across all my screens. For each recurring element, and for spacing rhythm, corner radii, font sizes and weights for equivalent roles, button styles, how headers are built, icon style and weight, and color usage by role — check whether every screen does it the same way. Give me a table of every inconsistency, ranked by how noticeable it is, and recommend which version should become the standard.
```
Follow-up once you've picked the standards:
```text theme={null}
For the inconsistencies we agreed on, make the fixes by updating tokens or shared components — not by editing screens one by one. Show me the list of tokens/components you changed.
```
## Check for hardcoded values
"Hardcoded" means a number or color typed directly into a screen instead of pulled from the token file. It's the #1 source of drift — it looks fine today and breaks consistency the moment you restyle.
```text theme={null}
Scan all my SwiftUI screens for hardcoded values — any color, font size, spacing number, or corner radius written literally instead of read from DesignTokens.swift — and fix them. Where there's a clear matching token, just swap it in. Where a value is close to an existing token, snap it to that token. Only pause for me on the genuine judgment calls: a value that doesn't map to anything and might need a new token. When you're done, give me a short summary of what you changed and the few decisions (if any) you want me to confirm.
```
Your `DesignTokens.swift` is the source of truth. A restyle should be a **one-file edit**. Every hardcoded value is a place that edit will silently miss.
## Prompt for animation and motion
Describe motion by **feel and purpose**; the agent picks the spring, duration, and easing. The vocabulary: *spring vs ease*, *snappy vs gentle*, *duration*, and *transition* (how something enters/exits).
Describing a motion you want — no jargon needed:
```text theme={null}
When a new event is added to the calendar, I want it to feel like it gently settles into place — not a hard pop. Use a spring that's soft, not bouncy, quick enough to not feel sluggish. Show me the values you chose and let me react.
```
Iterating on a motion that's not quite right:
```text theme={null}
This animation feels off. Give me dials to react to: faster/slower, bouncier/calmer, bigger/subtler movement, and where it starts from. Show me what each extreme looks like so I can pick.
```
Respect the accessibility setting:
```text theme={null}
Make sure every animation respects Reduce Motion — when it's on, replace movement with a simple fade or no animation. Check this is wired up everywhere, not just on the main screen.
```
## Things non-designers forget
**Dark mode** — half your users:
```text theme={null}
Verify every screen has proper dark-mode tokens. Flag anything that's just inverted or looks washed out rather than deliberately designed.
```
**Empty / loading / error states** — screens aren't always full of data:
```text theme={null}
For each screen, do I handle the empty state (no data yet), the loading state, and the error state? Show me which are missing and propose designs consistent with the system.
```
**Icon consistency:**
```text theme={null}
Verify all my icons come from one family (SF Symbols), share a weight that matches adjacent text, and none are a stray web/emoji icon.
```
## The big audit — everything at once
```text theme={null}
Do a full design-system audit of my iOS app. Use design-system.md and DesignTokens.swift as the source of truth. Cover, and give me a ranked findings table for each:
1. Hardcoded values — anything not read from the token file
2. Text contrast — WCAG AA, light and dark
3. Non-text contrast — dots, icons, dividers at 3:1
4. Tap targets — everything interactive ≥ 44×44pt
5. Cross-screen consistency — recurring elements that drift
6. Dark mode — deliberate, not just inverted
7. Reused components — repeated elements that should be shared
For each finding: the screen/file, what's wrong, severity, and the smallest fix (preferring a token or shared-component change over per-screen edits). Don't fix anything yet — show me the table first, and I'll tell you what to apply.
```
# Duet Framework Documentation Map
Source: https://docs.modaal.dev/duet
Duet documentation index: the overview, the tutorials, whether to share code, the framework and architecture comparisons, Android primers and the glossary.
Duet is Modaal's cross-platform parity framework for native iOS and Android apps: feature logic is written once in Kotlin, each app keeps its platform's own interface, and a CI gate replays recorded behavior on both platforms and fails the build when the two results differ. This page lists every Duet page on this site, grouped by the question each page answers. If Duet is new to you, read the overview first.
## Where should you start?
What Duet is, what you get, what it requires of your project, and the two combinations that scaffold it.
Nine hands-on tutorials build one native iOS and Android app, Foyer, from a first Kotlin feature to the CI checks, theming, localization and analytics.
The three ways to ship two apps, what each can prove about behavior, what sharing costs, and who should skip it.
How the new-project sentence works and how to pick between the multiplatform, iPhone-only, prototype and game templates.
What the install-the-Android-SDK banner means and what its button installs.
## Learn Duet by building one app
The [Duet tutorial series](/tutorials/duet) teaches Duet by building one native iOS and Android app, Foyer, from its first Kotlin feature to its analytics: a splash, a sign-in gate, an onboarding gate, a home tab with one paid feature, an upgrade flow and a profile tree. You write each feature's logic once in Kotlin, mount it in a SwiftUI app and a Compose app, and record its behavior so the checks replay the same recordings on both platforms. Every tutorial opens a starting tree and ends with a finished tree in the public [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials); every tree is pinned to one family release and checked in that repository's CI. Tutorials 1 to 6 build on each other in order; Tutorials 7, 8 and 9 each start from Tutorial 6's finished tree, so you can take them in any order. Tutorial 1 needs a JDK only; from Tutorial 2 onward you need Xcode, XcodeGen and the Android SDK, which the [series index](/tutorials/duet#what-do-you-need-installed) lists. The index also [maps every Duet concept and engineering practice](/tutorials/duet#what-does-the-series-teach) to the tutorial step that teaches it. The nine tutorials, in order:
* [Tutorial 1: Your first feature](/tutorials/duet-01-first-feature): state, actions and effects as data, a pure reducer, and a scenario recorded and replayed, in Kotlin with no UI.
* [Tutorial 2: One behavior, two apps](/tutorials/duet-02-two-apps): the Kotlin core as an Apple framework, and a SwiftUI shell and a Compose shell over the same store.
* [Tutorial 3: Composing features](/tutorials/duet-03-composing-features): a feature tree mounted from state, delegate events as parent actions, ports, and chain recordings.
* [Tutorial 4: Workers](/tutorials/duet-04-workers): an on-device backend, streams observed by workers, and a slice projected down to two tabs.
* [Tutorial 5: Navigation as state](/tutorials/duet-05-navigation-as-state): route state, back as an action, deep links, process-death restore, and lateral state between siblings.
* [Tutorial 6: The checks in CI](/tutorials/duet-06-checks-in-ci): the lanes, the ten commands, one GitHub Actions workflow, and the mutation drill.
* [Tutorial 7: Theming with design tokens](/tutorials/duet-07-theming): a token vocabulary generated for both platforms and a second theme with no feature-code change.
* [Tutorial 8: Localizing the app](/tutorials/duet-08-localization): refusals as values, string catalogs and resources, and a second language that changes no recording.
* [Tutorial 9: Adding analytics](/tutorials/duet-09-analytics): events emitted from reducers as effect data, a console sink worker, and the taxonomy read back out of the recordings.
## How does Duet compare to the alternatives?
Flutter, React Native, Compose Multiplatform, KMP, MAUI, Ionic, Skip and two native apps on one set of axes.
Which layer draws the screen, what ships in the binary, and what verifies that the two apps behave the same.
Shared logic in a shipped JavaScript engine versus a compiled Kotlin core verified with recordings.
Both are Kotlin Multiplatform; one shares the UI on a canvas, the other keeps SwiftUI native.
One web app in the system web view versus two native interfaces over a compiled Kotlin core, measured on a four-tab app.
TCA, RIBs, VIPER, MVVM, MVC and Clean Architecture scored on testability, onboarding, cross-platform reach and AI-agent fit.
## What does a Duet app look like inside?
A thin Compose shell over a shared Kotlin core: one Gradle module per feature, plus the recorded fixtures the iOS app replays.
A SwiftUI app over the prebuilt Kotlin core: the Kit package that links it, the Main package that composes the app, and a thin app target.
What the iPhone app links, who frees what across the Kotlin/Swift boundary, and the Swift rules that follow.
Logic in pure reducers, nondeterminism behind effects, recorded scenarios as the regression suite.
The presentation contract, the kind-to-renderer registry, and the ledger that records deliberate iOS/Android divergence.
How an existing app gains a shared Kotlin core and an Android app one feature at a time, with the iOS app shipping throughout.
## What do you need to know about Android first?
An APK or app bundle: a manifest declaring entry points, Compose screens driven by state holders, and a Gradle build behind it.
Every Android version with its API level, codename, year and live device coverage, and how to choose a minSdk.
## Where is the reference material?
Every Duet term defined in one place, with the name the tools use for each.
Both flavors, the recording toolchain and the versioned contracts.
## Where does Duet live outside these docs?
* [Duet on modaal.dev](https://modaal.dev/duet) is the product page: what Modaal scaffolds and what it costs.
* [github.com/modaal-agent/duet](https://github.com/modaal-agent/duet) holds the framework itself under an open license, with its contracts and CONTRIBUTING rules.
* [github.com/modaal-agent/duet-tutorials](https://github.com/modaal-agent/duet-tutorials) holds every tutorial's starting and finished tree, pinned to one family release and gated in that repository's CI.
# Modaal FAQ: Common Questions About Building Apps with AI
Source: https://docs.modaal.dev/faq
Answers to the questions people ask before building with Modaal: platforms, the generated stack, code ownership, agents, pricing, and how to start.
## What is Modaal and how does it work?
[Modaal](https://modaal.dev) is an AI coding agent that turns plain-language product specifications into a production-ready **native** mobile app. The [welcome page](/welcome) gives the overview in one read.
Instead of generating snippets, it works through a structured **Spec → Build** workflow and keeps key artifacts aligned (PRD, flows, architecture, and code), so you end up with a real, scalable codebase you can ship.
Yes. The Free plan does not expire and does not need a card. You get one active project, unlimited prompts with no daily or monthly cap, your choice of iOS or Android, bring-your-own-AI, genuinely native builds, and code you can export at any time.
Pro is €9 per user per month billed annually, or €15 monthly, and adds unlimited projects, iOS and Android together, TestFlight and App Store distribution, Git collaboration and priority support.
See [Plans & Pricing](/credits-and-plans), and [Modaal alternatives](https://modaal.dev/alternatives) for how the plans compare with other app builders.
**Vibe coding** is fast and fun, but it often breaks down on complex features, edge cases, and larger codebases—because decisions happen “in the moment” and aren’t captured cleanly.
**Spec-driven development** keeps the speed, but adds structure:
* define requirements and constraints up front
* document system design + key decisions
* break work into implementable tasks with acceptance criteria
* keep everything traceable from intent → implementation
In practice this means fewer misinterpretations, less drift, and better results as the app grows.
It is not an alternative to them — it runs on them.
Modaal orchestrates the coding agent you already pay for. Claude Agent, Codex CLI, Cursor, OpenCode, Gemini CLI, GitHub Copilot CLI, Auggie, Cline, Goose, Kilo Code, Mistral Vibe, OpenHands and Qwen Code are all supported, and you authenticate with your existing subscription rather than buying inference through us.
What Modaal adds is everything between a terminal agent and a shipping native app. A coding agent on its own writes good Swift but cannot register a new file in an Xcode target, read a build error in a structured way, drive the simulator, or hold an architecture in mind across a context reset. Modaal scaffolds the project, runs a build-and-fix loop through Xcode after each step, and keeps the module structure on disk so it survives context clears and agent switches.
See [Connect your agent](/guides/connect-your-agent) for the full list.
## What do you need before you start?
Yes. Modaal builds and runs your app through Apple's Xcode, which is macOS-only. You will want Xcode 26 or newer and roughly 50 GB of free disk space for Xcode and the simulators. Apple silicon is recommended for build speed.
See [Set up Xcode](/guides/setup-xcode).
Start with the [Quickstart](/guides/quickstart) guide to get set up. After that, you typically go through three steps:
1. Describe what you want to build (users, key journeys, must-have features)
2. Work on screens, flows, and logic (iterate in plain language, step by step)
3. Check how it looks on simulator or real device
If you’re coming from a web prototype (Lovable, v0, etc.), the app has to be rebuilt — Modaal generates Swift or Kotlin, not React. Bring your MVP scope and core flows and build them in Modaal.
The best inputs are concrete:
* core user journeys (happy path + 2–3 key edge cases)
* your MVP feature list and what “done” means
* data objects you care about (e.g., User, Project, Session)
* any must-have constraints (offline, background work, sensors, auth, etc.)
* example apps you like (for UX reference)
Tip: start with one “end-to-end” flow you can ship fast (a vertical slice), then expand.
Yes. Point the agent at a Figma design — it pulls the design context and screenshots through the Figma integration and builds the screens to match. See the [Modes and prompts guide](/articles/modes-and-prompts) for a Figma-based example.
You can also bring or build your own design system and have every screen follow it — see [Design your app with a design system](/ai-design).
You control that with the **Plan** toggle in the composer:
* **Plan off** (the default) — the agent works directly: it writes code, builds the project, debugs, and iterates with you. Best for building, prototyping, and one-off tasks.
* **Plan on** — the agent researches your codebase and produces a structured feature spec **without writing code**. On a brand-new project, the first prompt with Plan on runs **Refine**: it turns your idea into a PRD and scaffolds the project.
Both share the same project context, so you can toggle Plan at any point — plan a feature, then turn Plan off to implement it. See the [Modes and prompts guide](/articles/modes-and-prompts) for workflows and prompt examples.
## Who owns the code Modaal writes?
Yes, and there is no export step — the project is a standard Xcode project on your own disk from the first build. Modaal drives Xcode locally; the code was never anywhere else.
Open it in Xcode with or without Modaal running. Add Swift packages, refactor, connect your own backend, point your own CI/CD at it, or hand it to another engineer who has never heard of Modaal.
Yes. It's a normal native project, so treat it like any other:
* refactor
* add libraries
* integrate your backend
* ship through your normal CI/CD
Modaal’s value is that the “spec → build” loop stays structured as you iterate.
## Which platforms and stack does Modaal target?
Both iOS and Android.
* **iOS** — native Swift and SwiftUI. Generally available.
* **Android** — native Kotlin and Jetpack Compose. Available to every account, in open beta.
Both are built from one shared core through [Duet](/duet), so the business logic stays identical across platforms while each app keeps a fully native interface — following Apple's Human Interface Guidelines and the Liquid Glass idiom on iOS, and Material 3 Expressive on Android. The [Duet tutorials](/tutorials/duet) build one such app on both platforms by hand.
Modaal Free builds one platform of your choice. Building iOS and Android together is part of Pro.
Modaal generates native UI and app structure, including:
* SwiftUI screens for iOS, Jetpack Compose screens for Android
* data models and API logic
* background tasks where relevant
## What can Modaal build?
Modaal is designed for “native-advantaged” features where web/hybrid builders hit limits, like:
* widgets and OS surfaces (e.g., Live Activities)
* offline mode and background work
* sensors / hardware access
* watch companions (where relevant)
* performance-critical experiences
In-app purchases and subscriptions are covered separately in [Add in-app purchases and subscriptions to an iOS app](/articles/storekit-testing), including how to test a paywall before the products exist in App Store Connect.
Yes. If you already have APIs, you can provide:
* endpoints + auth method
* key request/response shapes
* error states and retry behavior you want
Then Modaal can align the data models and flows around that contract.
If you would rather Modaal provision the backend, [Firebase](/articles/firebase) covers auth, cloud database, storage and analytics, and [Firebase Data Connect](/articles/firebase-data-connect) covers a managed PostgreSQL schema with a typed Swift SDK.
## How the workflow stays coherent
Modaal treats PRD, user flows, data flows, navigation, and architecture as connected artifacts.
When you change one (e.g., add a new state to a flow), it updates the related pieces so you don’t get “version drift” between docs, design, and implementation.
# Connect Claude Code, Cursor, or Codex to Modaal
Source: https://docs.modaal.dev/guides/connect-your-agent
Start with Modaal's built-in free preview, then connect the coding agent you already pay for — Claude Code, Codex, Gemini, Copilot or Cursor — in a click.
Modaal is the **orchestrator**; the **agent** is the model that actually writes your code. Every new project starts on Modaal's built-in **free preview** agent so you can try things immediately — the [quickstart](/guides/quickstart) runs on it. When you're ready, connect your own agent — it's almost always **cheaper than paying per-API-call** ([Modaal adds no per-token charge](/credits-and-plans)), and you can **switch any time**.
## Why connect your own agent?
Already subscribed to Claude, ChatGPT/Codex, Gemini, or Copilot? Sign in and Modaal runs on that plan — no separate API bill.
The free preview is for trying Modaal out. Your own agent gives you the full, latest model and higher limits.
Connect several agents and flip the default whenever you like. Your project doesn't change — only who writes the code.
***
## Open Agent settings
Click your **profile avatar** in the top-right corner to open the **User Menu**, then choose **Agent settings**.
This opens **Choose your build agent** — the list of every agent Modaal can drive. Each row shows the agent, its current status, and one action button. The panel opens on the ones most people reach for — **Claude Agent**, **Codex CLI**, **Cursor**, **OpenCode**, **Gemini CLI** and **GitHub Copilot CLI** — and **Show more** reveals the rest.
***
## The two kinds of action button
Before you start, it helps to know that agents differ along two simple axes — and those two axes are all that change the buttons you see.
### 1. Getting the agent onto your Mac — **Get** vs **Check**
| Button | What it means | Examples |
| - | - | - |
| **Get** | Modaal can **install** this agent's CLI for you (via `npm` or Homebrew). Just click it. | Claude Agent, Codex CLI, Gemini CLI, GitHub Copilot CLI, OpenCode, Auggie, Cline, Goose, Kilo Code, Qwen Code |
| **Check** | Modaal **can't install** this one — you install its CLI yourself, then Modaal detects it. Click **Check** (or **Re-check**) once it's on your system. | Cursor, Mistral Vibe, OpenHands |
### 2. Signing in — **in-app login** vs **Login via Terminal**
After the agent is installed, you sign in with your own account:
* **In-app login** — Modaal signs you in directly. For some agents (like Claude) you'll pick *how* to sign in from a small menu; for others it starts straight away. These agents can also be **re-authenticated** later from the **⋯** menu.
* **Login via Terminal** — Modaal opens a Terminal window where the agent's own CLI handles sign-in. Once you've signed in there, come back and click **Re-check** so Modaal picks it up.
You don't need to memorize which agent is which — the panel always shows the right button for the right step. The rest of this guide walks the happy path with **Claude Agent**.
**Modaal installs these for you (Get):** Claude Agent, Codex CLI, Gemini CLI,
GitHub Copilot CLI, OpenCode, Auggie, Cline, Goose, Kilo Code, Qwen Code.
**You install these yourself, Modaal detects them (Check):** Cursor,
Mistral Vibe, OpenHands.
The open-source agents — OpenCode, Cline, Goose, Kilo Code and Qwen Code — can
point at open-weight models, which is the cheapest way to run Modaal if you
don't have a subscription to any of the others.
***
## Connect an agent, step by step
Find the agent you want and click **Get**. Modaal downloads and installs its CLI for you (this can take a minute). If the agent shows **Check** instead, install its CLI yourself first, then click **Check**.
When it's installed, the status changes to **Installed · Not signed in** and the button becomes **Login**.
Click **Login**. If the agent offers more than one way to sign in, pick the one that matches your plan. For **Claude Agent** you choose between:
* **Claude Subscription** — use your Claude (Pro/Max) subscription.
* **Anthropic Console** — use Anthropic Console (API usage billing).
Pick **Claude Subscription** to use the plan you already pay for. A browser window opens for you to sign in and approve access.
Agents that use **Login via Terminal** instead open a Terminal window — sign in there, then return to Modaal and click **Re-check**.
Once you're signed in, the status shows **Signed in as \** and the button becomes **Use as default**. Click it to tell Modaal to write your code with this agent.
The button turns into a **✓ Default** marker. That's it — your next message in Modaal is handled by your own agent.
***
## Switching agents later
You can connect as many agents as you like and switch the default whenever you want — open **Agent settings** again and click **Use as default** on a different one. Useful tools live in each row's **⋯** menu:
* **Re-check** — re-detect status (use this after signing in via Terminal, or installing a **Check** agent).
* **Re-authenticate** — sign in again (in-app login agents).
* **Log out** — sign out (only some agents support this in-app; otherwise sign out from the agent's own CLI).
* **Show details** — expand the row for version and account info.
***
## Fix 401 errors and a stuck sign-in status
This almost always means your sign-in token has gone stale — a **transient** issue, not a broken account. The fix is to refresh your credentials:
1. Open **Agent settings** (User Menu → **Agent settings**).
2. On your agent's row, open the **⋯** menu and click **Re-authenticate** if it's there, then sign in again.
3. If there's no **Re-authenticate** option, click **Log out** and then **Login** again. For agents that sign in through Terminal, sign out and back in via the agent's own CLI, then click **Re-check**.
Once you've signed back in, retry your request. Other transient auth errors (403, “token expired”, “unauthorized”) clear the same way.
Click **Re-check** on the agent's row — sign-ins done in a Terminal window are only picked up on re-check. If it still won't catch, **Log out** / **Login** again from Agent settings.
Click **Show more** to reveal the full catalog. If it's a **Check**-type agent (e.g. Cursor), install its CLI yourself first, then click **Check** so Modaal can detect it.
Connecting your own agent is the single biggest upgrade over the free preview — you get the full model on a plan you already own. If you're not subscribed to any, **Claude**, **ChatGPT/Codex**, **Gemini**, and **GitHub Copilot** all have plans that work well with Modaal. What Modaal does that Claude Code alone does not is on [modaal.dev](https://modaal.dev/compare/modaal-vs-claude-code), and [bring your own AI](https://modaal.dev/blog/byoa-ai-tools) compares the agent subscriptions on price and capability.
**Need help?** Join our [community on Discord](https://discord.gg/ng84Jm92tm).
# Submit to the App Store Now, Stay iPhone Duo-Ready
Source: https://docs.modaal.dev/guides/iphone-duo-app-store-submission
Guard iOS 27.1 SDK code so one project builds the iPhone Duo layout in Xcode 27.1 beta and archives in Xcode 27.0 for App Store review, with a ready agent prompt.
Written on September 25, 2026, when Xcode 27.0 is the current release and Xcode 27.1 is in beta. The same method applies whenever an app uses APIs from an SDK that is still in beta at the time you need to submit.
iPhone Duo ships with iOS 27.1. Its layout APIs — `ArrangementView`, `GeometryProxy.reservedRegions(kind:)` and `UIView.reservedRegions(kind:options:)` — exist only in the iOS 27.1 SDK, which comes with Xcode 27.1 beta. A project that adopts them hits two restrictions at once:
* **Xcode 27.1 beta** builds the project and runs it on the iPhone Duo simulator, but App Store Connect sends a build to review only if it was made with the current public (GM) Xcode or its release candidate. A build from Xcode 27.1 beta uploads, then cannot be added for review.
* **Xcode 27.0** produces builds that App Store Connect accepts for review, but its iOS 27.0 SDK does not declare the iPhone Duo APIs, so the project does not compile.
Wrapping the new code in `if #available(iOS 27.1, *)` does not fix the Xcode 27.0 build. `#available` chooses a code path when the app runs; the compiler still has to find every symbol in the SDK it builds against.
This guide changes the project so that the same source compiles under both Xcodes. Xcode 27.1 beta compiles the iPhone Duo layout; Xcode 27.0 compiles the layout the app had before, and that build can go to App Store review today. When App Store Connect starts accepting Xcode 27.1 builds for review, you archive the same code with Xcode 27.1 and submit it again.
| | Xcode 27.0 | Xcode 27.1 beta |
| - | - | - |
| Released | September 14, 2026 | September 18, 2026 (beta) |
| iOS SDK | 27.0 | 27.1 |
| Declares the iPhone Duo APIs | No | Yes |
| Build can be submitted for App Store review | Yes | No |
| Use it for | Archive & Upload | Building and testing the iPhone Duo layout |
The screenshots follow one app, Memory Lane (the **Modaal WikiMemory** project), through every step.
***
## Why the usual pre-approval steps fail for this project
The common advice for getting an app approved ahead of a launch date is three steps in App Store Connect: upload a build, submit it for review, and select **Manually release this version** so the approved version waits until you release it.
With a build from Xcode 27.1 beta, the upload in step 1 goes through and step 2 fails: App Store Connect refuses to add the build for review.
The same project built with Xcode 27.0 stops with compile errors, shown in [step 4](#step-4). Steps 1–7 below produce a build from Xcode 27.0 that App Store Connect accepts for review, in which the iPhone Duo code is left out at compile time and kept in the source.
***
## Step 1 — Install Xcode 27.0 and Xcode 27.1 beta side by side
Download both from [Apple Developer downloads](https://developer.apple.com/download/all/?q=xcode). Sign in with your Apple Account.
Expand each `.xip` and move both apps into `/Applications` under different names. The prompt in [step 5](#step-5) uses `/Applications/Xcode_27_0.app` and `/Applications/Xcode_27_1_beta.app`. If yours are named differently, the agent finds them with `ls /Applications | grep -i xcode`, which is the first thing the prompt tells it to run.
Open each Xcode once and download its iOS platform in **Xcode → Settings… → Components**. [Set up Xcode](/guides/setup-xcode#step-2) covers the first launch and the license prompt.
Simulator runtimes are shared between Xcode installations on the same Mac, which is why Modaal lists both the iOS 27.0 and iOS 27.1 runtimes under each Xcode in the next step.
***
## Step 2 — Set Xcode 27.1 beta as active in Modaal
Click your profile avatar, choose **Set up Xcode**, and click **Set as active** on the installation at `/Applications/Xcode_27_1_beta.app`. Modaal builds, runs and archives with the active Xcode. [Have more than one Xcode installed?](/guides/setup-xcode#multiple-xcode) explains the switch and the administrator password it asks for.
***
## Step 3 — Build and test the iPhone Duo layout
Build the iPhone Duo features with the iOS 27.1 APIs: `ArrangementView` with its `.split` and `.overlay` styles, `GeometryProxy.reservedRegions(kind:)`, and `UIView.reservedRegions(kind:options:)` in UIKit code. Choose the **iPhone Duo** simulator in the menu next to the **Run** button and test the layout there.
***
## Step 4 — Set Xcode 27.0 as active and archive
When you are ready to submit, open **Set up Xcode** again and click **Set as active** on Xcode 27.0.
Open **Signing & TestFlight** and click **Archive & Upload**. The first archive fails:
Building the same project in Xcode 27.0 directly gives the same errors, so the cause is the SDK and not the way Modaal archives:
The errors are of three kinds:
* `Value of type 'GeometryProxy' has no member 'reservedRegions'`
* `Cannot find 'ArrangementView' in scope`
* `Cannot infer key path type from context; consider explicitly specifying a root type`, on the `.map(\.frame)` line after each `reservedRegions` call. It is caused by the first error, and the same fix removes it.
Every error is in code that uses an iOS 27.1 API. The failure dialog's **Fix with agent** button sends the build log to the agent. A request to fix the build errors without further instructions can be met by deleting the iPhone Duo code. Use the prompt in step 5 instead: it tells the agent to keep that code and exclude it from the Xcode 27.0 build.
***
## Step 5 — Give the agent the prompt
Paste the prompt below as-is, either into Modaal's prompt box or into any coding agent session opened at the root of the project's repository. The prompt names the missing APIs, the SDK versions that tell the two Xcodes apart, the guard to put around each use, and how to verify both builds. Its last section covers Modaal and Duet projects: building with the `run_target` tool, running `scripts/run_tests.sh`, and regenerating mocks.
````markdown Agent prompt wrap theme={null}
# Task: make an iOS project build with the iOS 27.0 SDK (Xcode 27.0) without losing its iOS 27.1-SDK code
## Why the build fails
- The code was written against the Xcode 27.1 beta (iOS 27.1 SDK). App Store submission needs Xcode 27.0 final, and its SDK doesn't declare the 27.1 APIs.
- `if #available(iOS 27.1, *)`, `guard #available` and `@available(iOS 27.1, *)` only choose a path **at run time**. The compiler still has to find every symbol in the SDK it builds against, so these checks can't hide a missing API.
- Typical errors (the last one is a knock-on error that the same fix removes):
- `value of type 'GeometryProxy' has no member 'reservedRegions'`
- `cannot find 'ArrangementView' in scope`
- `cannot infer key path type from context` on the following `.map(\.frame)` lines
- iOS 27.1-only APIs seen so far:
- **SwiftUI** (declared in SwiftUICore, re-exported by SwiftUI): `ArrangementView`, `.arrangementViewStyle(_:)`, `.split` / `.overlay` (`SplitArrangementViewStyle`, `OverlayArrangementViewStyle`), `ArrangementViewStyleConfiguration`, `GeometryProxy.reservedRegions(kind:options:layoutDirectionBehavior:)`, `ReservedRegion` (`.occlusion`, `.division`, `QueryOptions.includeInactive`)
- **UIKit**: `UIView.reservedRegions(kind:options:)`, `UIView.ReservedRegion`
## What can tell the two SDKs apart (verified)
- `#if compiler(>=…)` / `#if swift(>=…)` **cannot**. Xcode 27.0 and 27.1 beta ship the identical compiler, Apple Swift 6.4 (swiftlang-6.4.0.34.1).
- The framework module versions **do** differ. Each is the `-user-module-version` in the header of the framework's `.swiftinterface`:
| Module | iOS 27.0 SDK | iOS 27.1 beta SDK | Guard |
|---|---|---|---|
| SwiftUI / SwiftUICore (same version) | 8.0.84.1.104 | 8.0.85.27 | `#if canImport(SwiftUI, _version: 8.0.85)` |
| UIKit | 9127.0.84.1.116 | 9127.0.85.28 | `#if canImport(UIKit, _version: 9127.0.85)` |
## The pattern: compile-time guard outside, run-time guard inside
```swift
#if canImport(SwiftUI, _version: 8.0.85) // iOS 27.1 SDK or newer
if #available(iOS 27.1, *) { newPath } else { fallback }
#else
fallback // built from the 27.0 SDK
#endif
```
Keep the `#available` check. The deployment target doesn't change, and older OSes still need the fallback.
### How to apply it
1. **Function returning a value**:
```swift
private func divisionWidth(in proxy: GeometryProxy) -> CGFloat {
#if canImport(SwiftUI, _version: 8.0.85)
guard #available(iOS 27.1, *) else { return 0 }
return proxy.reservedRegions(kind: .division).map(\.frame)
.filter { $0.height >= $0.width }.map(\.width).max() ?? 0
#else
return 0
#endif
}
```
2. **A `ViewBuilder` body choosing between a new view and a fallback**: move each branch into its own property so the fallback is written once.
```swift
var body: some View {
#if canImport(SwiftUI, _version: 8.0.85)
if #available(iOS 27.1, *) { arrangement } else { fallback }
#else
fallback
#endif
}
#if canImport(SwiftUI, _version: 8.0.85)
@available(iOS 27.1, *)
private var arrangement: some View { ArrangementView { … } secondary: { … } }
// put helpers that only the new path uses here too, so the 27.0 build has no dead code
#endif
private var fallback: some View { … }
```
`#if` is resolved at compile time. Unlike `if #available`, it adds no conditional content and doesn't change view identity at run time.
3. **Types, extensions or stored properties that name new types**: wrap the whole declaration in the same `#if` and keep its `@available`.
4. Explain the guard once, in a doc comment on the main type, and point the other sites to it (e.g. `// The 27.1 SDK guard: see AdaptiveSplit`). Always use exactly the same condition text so the guards are easy to grep and remove later.
### Pitfalls
- If `canImport(M, _version:)` can't read the module's version, it **ignores the version and evaluates to true** (the compiler warns "cannot find user version number"). The 27.0 build is the test: if the guard were ignored, the same errors would come back.
- The compiler keeps only the first 4 parts of a version (8.0.84.1.104 → 8.0.84.1). Use the newer SDK's first three parts as the threshold (`8.0.85`), not the beta's full build number, which a final release could fall below.
- Check against the module the file imports (`SwiftUI`). SwiftUI and SwiftUICore always carry the same version.
- **What ships:** an App Store binary built with the 27.0 SDK always takes the fallback, even on iOS 27.1 devices. Make sure the fallback is acceptable and check it on a device. It comes back automatically once you archive with Xcode 27.1.
- The compiler stops at the first module that fails, so the log may not list every problem. Search the **whole** tree (all packages, extensions/widgets, test targets) for the symbols and for `#available(iOS 27.1`.
## Procedure
1. **Collect the errors** from the failed build/archive log (`grep -n "error:"
`). List the symbols and files, then search the whole source tree as above.
2. **Find the installed Xcodes:** `ls /Applications | grep -i xcode`; `xcode-select -p`.
3. **Find which module declares each missing symbol** in the newer SDK:
```sh
NEW=/Applications/Xcode_27_1_beta.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk
grep -lE "ArrangementView|reservedRegions" \
$NEW/System/Library/Frameworks/*/Modules/*.swiftmodule/arm64-apple-ios-simulator.swiftinterface
```
4. **Compare the compilers and module versions** of both Xcodes:
```sh
for x in Xcode_27_0 Xcode_27_1_beta; do
D=/Applications/$x.app/Contents/Developer
$D/Toolchains/XcodeDefault.xctoolchain/usr/bin/swift --version 2>&1 | head -1
F=$D/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator.sdk/System/Library/Frameworks
for m in SwiftUI SwiftUICore UIKit; do
echo "$x $m $(grep -m1 -o 'user-module-version [0-9.]*' $F/$m.framework/Modules/$m.swiftmodule/arm64-apple-ios-simulator.swiftinterface)"
done
done
```
Different compiler versions: `#if compiler(>=…)` also works. Same compiler: use `canImport(_version:)` with the newer SDK's first three version parts.
5. **Apply the guards** at every site.
6. **Verify with both SDKs:**
- Build and run the tests with **Xcode 27.0**. This must succeed.
- Build and run the tests with **Xcode 27.1 beta**, without changing `xcode-select`: set `DEVELOPER_DIR=/Applications/Xcode_27_1_beta.app/Contents/Developer` and use a **separate** derived-data directory.
- Confirm that each build compiled the path you expect: the 27.1 build's object files must reference the new API, the 27.0 build's must not.
```sh
nm -u /Build/Intermediates.noindex/.build/Debug-iphonesimulator/.build/Objects-normal/arm64/.o | grep -c ArrangementView
```
- Delete the temporary derived data afterwards.
7. **Commit.** In the message, say which build takes which path. Once the app ships from the 27.1 SDK, delete the guards (grep for the condition).
## If the repo is a Modaal / Duet project
- Build with the `run_target` MCP tool. It uses the Xcode currently selected (`xcode-select` / Modaal's Xcode settings). Never call `xcodebuild` or `xcodegen` directly.
- Run tests with `scripts/run_tests.sh`. It respects `DEVELOPER_DIR` and `DERIVED_DATA_PATH`, so this compiles the 27.1 branch without touching the selected Xcode:
`DEVELOPER_DIR=/Applications/Xcode_27_1_beta.app/Contents/Developer DERIVED_DATA_PATH=/tmp/dd-27-1 scripts/run_tests.sh`
- The mock generator fingerprints every source file it scans. After editing any Swift file, `run_tests.sh` fails with `content differs from the recorded hash`. Run `scripts/generate-mocks.sh` and commit the `Generated/` files; the diff should be fingerprints only. Never hand-edit `Generated/`.
- Files under `src-ios/Libraries/Main` are app-tree code, not covered by the parity gates (`tools/duet scope ` confirms). They need the test suite and a build, not `tools/duet verify`.
- Run the repo's commit-confirmation step before committing.
````
[What the prompt changes](#what-the-prompt-changes) explains the guard it adds.
***
## Step 6 — Archive and upload with Xcode 27.0
With Xcode 27.0 still active, run the app on an iOS 27.0 simulator. It shows the layout the app had before iPhone Duo support.
Review the diff and commit it. In a Duet project, include the files under `Generated/`: without their updated fingerprints, `scripts/run_tests.sh` and the CI `codegen` job fail. Then click **Archive & Upload** again.
In App Store Connect, add the build to a new version and submit it for review. Select **Manually release this version** if the approved version should wait until you release it.
This build contains only the fallback code path. It shows the layout from before iPhone Duo support on every device, including an iPhone Duo running iOS 27.1. Check that layout on a physical device before you release the build.
***
## Step 7 — Check that Xcode 27.1 beta still builds the iPhone Duo layout
Set Xcode 27.1 beta as active again and run the app on the iPhone Duo simulator. Without any source change, the build compiles the iOS 27.1 code path.
The prompt already has the agent check both paths without changing the active Xcode: it builds a second time with `DEVELOPER_DIR` set to Xcode 27.1 beta and a separate derived-data directory, then uses `nm -u` on the object files to confirm that only the Xcode 27.1 build references `ArrangementView`.
***
## What the prompt changes
The guard has to be decided at compile time, and the compiler version cannot decide it: Xcode 27.0 and Xcode 27.1 beta ship the same compiler, Apple Swift 6.4 (swiftlang-6.4.0.34.1), so `#if compiler(>=…)` and `#if swift(>=…)` evaluate the same in both. The SDK frameworks differ. Each framework's `.swiftinterface` file records a module version, and `#if canImport(Module, _version:)` compares against it:
| Module | iOS 27.0 SDK | iOS 27.1 beta SDK | Guard |
| - | - | - | - |
| SwiftUI and SwiftUICore | 8.0.84.1.104 | 8.0.85.27 | `#if canImport(SwiftUI, _version: 8.0.85)` |
| UIKit | 9127.0.84.1.116 | 9127.0.85.28 | `#if canImport(UIKit, _version: 9127.0.85)` |
The agent wraps every use of an iOS 27.1 API in the compile-time guard and keeps the `#available` check inside it:
```swift theme={null}
#if canImport(SwiftUI, _version: 8.0.85) // iOS 27.1 SDK or newer
if #available(iOS 27.1, *) { newPath } else { fallback }
#else
fallback // built from the 27.0 SDK
#endif
```
`#if` decides which code is compiled. `#available` decides which code runs, and is still needed in the Xcode 27.1 build because the deployment target stays the same and devices on older iOS versions take the fallback. The threshold `8.0.85` is the first three parts of the newer SDK's version rather than the beta's full build number, so the final Xcode 27.1 release also passes it.
***
## When Xcode 27.1 is released
When Apple releases Xcode 27.1 as a release candidate or final version and App Store Connect accepts builds made with it for review:
1. Install it and set it as active in **Set up Xcode**.
2. Click **Archive & Upload**. The guard evaluates to true against the iOS 27.1 SDK, so this build contains the iPhone Duo code path.
3. Submit the build for review as a new version.
None of these steps changes the source. Once you no longer need to build with Xcode 27.0, remove the guards. Every guard uses the same condition text, so one search finds them all:
```bash theme={null}
grep -rnE "canImport\((SwiftUI|UIKit), _version:" .
```
***
To install and switch Xcode versions, see [Set up Xcode](/guides/setup-xcode). For how to write prompts like the one in step 5, see [Modes and prompts](/articles/modes-and-prompts).
**Questions about your project?** Ask in our [community on Discord](https://discord.gg/ng84Jm92tm).
# Build Your First Native App with Modaal (iOS and Android)
Source: https://docs.modaal.dev/guides/quickstart
Install Modaal, connect a coding agent, set up Xcode or the Android SDK, and go from a plain-language idea to a running native iOS or Android app.
Modaal builds native iOS and Android apps from plain-language descriptions — [what Modaal is and why it works this way](/welcome) is the one-read overview. You describe what you want, and the coding agent writes the Swift or Kotlin, builds it through Xcode or Gradle, fixes the errors, and iterates with you. Here's how to get from nothing to a running app.
## 1. Download and install Modaal
Get the app from [modaal.dev/download](https://modaal.dev/download) and install it on your Mac. Modaal runs on macOS only — building iOS apps requires a Mac, and Android builds run from the same machine. Release notes are in the [changelog](/changelog).
## 2. Connect a coding agent
Every project starts on the built-in **free preview**, so you can begin right away. To use a more capable model — or a subscription you already pay for (Claude Agent, Codex CLI, Gemini CLI, GitHub Copilot CLI, Cursor and more) — connect your own agent from **User Menu → Agent settings**. See [Connect your agent](/guides/connect-your-agent).
## 3. Set up the toolchain
Xcode is what compiles and runs your app on the iOS simulator. You can start chatting and planning before it's ready — the toolchain only gates building and running. Install Xcode, open it once to download an iOS simulator runtime, then confirm it in **User Menu → Set up Xcode**. Full walkthrough: [Set up Xcode](/guides/setup-xcode).
For Android, Modaal shows a banner offering to install the SDK, a user-local JDK and a first emulator; press the button and it does the whole install. Full walkthrough: [Set up the Android toolchain](/guides/setup-android).
## 4. Start your project
The home screen opens with one sentence above the composer — *I'm building a `kind` for `platform`* — and that sentence decides your project's structure and architecture:
1. **The kind.** *Production app* is the [Duet](/duet) architecture, for an app you'll ship and grow; *Quick prototype* tests an idea fast; *2D game* starts a SpriteKit game.
2. **The platform**, on a *Production app*: *Android + iOS* builds native iOS and Android apps from one shared core, *iOS* builds the iPhone app first and takes Android per feature later.
3. **The + Apple Watch chip**, on a *Production app*: adds a watch companion target.
Type your idea in plain language and press **Start new project**. Nothing else is asked — the app's name, its identifiers and its device families are read from your description. The project is scaffolded — targets, modules, build settings, app icon, and a passing test suite — then the agent picks up your description and writes a PRD plus a plan for the first feature.
Full walkthrough, and how to choose between the templates: [Starting a new project](/articles/new-project).
## 5. Build, review, iterate
From there you describe what you want, and Modaal generates the code and runs a build-and-fix loop through Xcode or Gradle after each step — so you get a real native project that stays maintainable as it grows. Review each step and steer as you go; the [Modes and prompts guide](/articles/modes-and-prompts) shows how to get the most out of your prompts. For a bug you can see on screen, [point at it](/articles/visual-debugging).
The work around the code — scoping the first release, designing it, getting it into a store — is covered in [how to build a mobile app from scratch](https://modaal.dev/blog/how-to-build-a-mobile-app-from-scratch).
Helpful to have ready (optional):
* A clear app idea in **3–5 sentences**, a short **PRD**, or one **core user flow** to ship first.
* An **iPhone, iPad or Android device** for real-device testing — the simulator and the emulator are great, but not real life.
When you're ready to test with others or ship, you'll need an **Apple Developer account** for TestFlight and App Store distribution.
**Need help?** The [FAQ](/faq) answers the questions people ask first; for the rest, join our [community](https://discord.gg/ng84Jm92tm).
# Set Up the Android Toolchain
Source: https://docs.modaal.dev/guides/setup-android
What Modaal's install-the-Android-SDK banner means and what its button installs: the pinned SDK components, a user-local JDK, and your first emulator.
Building the Android half of a [Duet](/duet) project needs the **[Android SDK](https://developer.android.com/tools)** and a **[JDK](https://adoptium.net/)** on your Mac, alongside Xcode. Modaal checks for both and, when something is missing, shows an **Action needed: install the Android SDK** banner with a button that installs it. This guide explains what the banner means, what the button installs, and how to verify the result. [What Claude Code alone cannot do on Android](https://modaal.dev/build-android-apps-with-claude-code) is on modaal.dev.
## Before you start
The Android toolchain installs alongside your iOS setup — [Set up Xcode](/guides/setup-xcode) first if you haven't.
The SDK components, the emulator system image and the JDK are a multi-gigabyte download. The setup prints the exact total it downloaded in its final receipt.
Everything installs user-local, under your home folder. Nothing asks for elevation and nothing touches a system package manager.
If a SDK is already on the machine, the setup respects it and adds only the components that are missing.
***
## What the banner means
On a project with Android in scope, Modaal probes for the toolchain — the SDK components, the JDK, and an emulator. When any of them is missing, the workspace shows the **Action needed: install the Android SDK** banner with an **Install** button and the list of what is missing.
## What the button installs
The button opens **Terminal** and runs Modaal's setup there, in a window you own — Google's SDK licenses have to be accepted by you, and that happens in this window, not silently in the background. As of September 2026 the setup installs:
| Component | Version |
| - | - |
| Android platform | android-36 |
| Build tools | 36.0.0 |
| Platform tools (includes `adb`) | current |
| Emulator | current |
| System image | android-36, Google APIs, arm64 |
| Command-line tools | 23.0 |
| JDK | Temurin 25 |
It also creates your first emulator — a **Pixel 8 on API 36**, named `Modaal_Pixel_8_API_36` — and pre-warms the Kotlin build toolchain the project compiles with (about a minute, \~440 MB), so the first real build doesn't pay that cost.
At the end, the setup offers to add the SDK and JDK environment exports to your shell profile, so your own terminal sees the same toolchain Modaal uses. You can decline — Modaal doesn't need it.
## Where everything lives
* **The SDK** goes to `~/Library/Android/sdk` — unless the machine already points at an SDK through the standard `ANDROID_SDK_ROOT` or `ANDROID_HOME` variables, in which case that location is used and only missing components are added.
* **The JDK** goes to `~/Library/Java/JavaVirtualMachines`, the user-local directory macOS resolves JDKs from — no admin password involved.
The setup's final receipt lists every location, every component it installed or found already present, and the total it downloaded.
## Verify it worked
Modaal picks the result up without a restart: the banner clears on the next prerequisites check, and the **Run picker** offers the new emulator alongside your iOS simulators. Ask Modaal to run the app on Android and it boots the emulator and launches your app there.
## What the banner can show
| State | What it means | What to do |
| - | - | - |
| **Action needed**, with an **Install** button | Components are missing, and one setup run installs them. The banner lists which. | Press the button and follow the Terminal window. |
| **Installing** | The setup is running in Terminal. | Answer the license prompt there and let it finish. |
| No banner | The toolchain is complete. | Nothing — pick an Android device in the Run picker. |
| An error, with no button | Something the setup cannot heal — for example, a custom SDK or JDK override pointing at a broken location. The banner names what is wrong. | Fix or remove the override it names, then let Modaal re-check. |
***
## Common questions
No. The setup respects an existing SDK found through `ANDROID_SDK_ROOT` or `ANDROID_HOME`, installs only missing components into it, and never removes anything. Your Android Studio projects keep working unchanged.
Google's SDK licenses must be accepted before components can install, and declining ends the run without installing them. Run the setup again from the banner's button and answer **y** at the license prompt.
Yes. Re-running is the repair path: every step checks what is already in place before acting, so a second run finishes what the first one couldn't and changes nothing that succeeded.
No. The Swift-flavor Duet template and the iOS-only templates build with Xcode alone — see [the two Duet templates](/articles/duet#the-two-duet-templates). The banner appears only when a project has Android in scope.
From [Tutorial 2](/tutorials/duet-02-two-apps#build-the-compose-app) onward, yes: that page builds the Compose app, and every later page runs it. [Tutorial 1](/tutorials/duet-01-first-feature) needs a JDK only. The [series index](/tutorials/duet#what-do-you-need-installed) lists every prerequisite with the tutorial that first needs it.
Toolchain in place? [Start your first project](/guides/quickstart) and say you're building a **Production app** for **Android + iOS**. What shipping an Android app involves beyond the build — the Play Console, signing keys, release tracks — is covered in [how to build an Android app](https://modaal.dev/blog/how-to-build-an-android-app).
**Still stuck?** Join our [community on Discord](https://discord.gg/ng84Jm92tm) — we're happy to help you get set up.
# Set Up Xcode for Modaal: Install, Simulators, Verify
Source: https://docs.modaal.dev/guides/setup-xcode
Install Xcode, open it once to download the iOS simulators, and let Modaal confirm the setup: the three steps before Modaal can build and run your iOS app.
**Xcode** is Apple's development environment for building and running iOS apps. Modaal drives it for you behind the scenes — you never have to write Swift in Xcode or wire up a project there. You just need it installed, opened once, and with the iOS simulators downloaded. Modaal does the rest. [What Modaal adds on top of Xcode](https://modaal.dev/compare/modaal-vs-xcode) is on modaal.dev.
## Before you start
Xcode only runs on macOS. A reasonably recent Mac (Apple Silicon recommended) makes builds much faster.
Xcode plus the iOS simulators are large. Check under **→ System Settings → General → Storage** and free up space first if you're tight.
Xcode is a multi-gigabyte download, and the iOS simulators download separately on first launch. Plan for a coffee break.
You only need one to run on a **real iPhone** or to publish to TestFlight / the App Store. The simulator needs nothing.
***
## Open the Xcode setup panel in Modaal
Everything is driven from one place inside Modaal. Click your **profile avatar** in the top-right corner to open the **User Menu**, then choose **Set up Xcode**.
Keep this panel open while you work through the steps below. After each step you'll come back to it and press **Check** / **Re-check** to let Modaal re-detect your setup.
The panel reports one of three states:
| Status | What it means | What to do |
| - | - | - |
| 🔴 **Not detected** | Modaal can't find any Xcode installation. | Install Xcode — [Step 1](#step-1). |
| 🟠 **Attention needed** | Xcode is installed, but it isn't finished — usually the iOS simulators haven't been downloaded yet. | Open Xcode and download simulators — [Step 2](#step-2). |
| 🟢 **Installed** | Xcode is installed, set up, and has iOS simulators. You're ready to build. | Nothing — you're done. |
***
## Step 1 — Install Xcode
If the panel says **Not detected**, Xcode isn't installed yet.
Install Xcode from either source:
The easiest option. Search for **Xcode**, click **Get**, and it installs and updates automatically.
Download a specific Xcode version directly. Useful if you need an exact release. Sign in with a free Apple Account.
Xcode is a large download (15+ GB) and can take a while to install — let it finish completely before moving on.
For the latest features — **iOS 26**, the **Liquid Glass** design system, and the newest simulators — install **Xcode 26 or newer**.
***
## Step 2 — Open Xcode once and download the simulators
This is the step that trips most people up. **Installing Xcode is not enough** — you have to launch it once so it can finish its own setup and download the iOS simulators. Until you do, Modaal shows **Attention needed**:
Open **Xcode** from your Applications folder. The first time it runs, it asks you to agree to the **Xcode and Apple SDKs Agreement**. Read it, then click **Agree** (you'll be asked for your Mac password).
Xcode then offers to install platform components. Make sure **iOS** is checked and click **Download & Install**. You can leave watchOS, tvOS, and visionOS unchecked — Modaal only needs **iOS** to build and run iPhone apps.
If Xcode doesn't show this screen on launch, you can trigger it any time from **Xcode → Settings… → Components**, then download an **iOS** simulator runtime.
The iOS simulator runtime is several gigabytes and downloads in the background. You can watch progress in Xcode's **Downloads** window.
When the download completes, the runtime shows a checkmark and Xcode opens its welcome window. Xcode is now fully set up.
You can quit Xcode now — Modaal launches it on its own when it needs to build.
***
## Step 3 — Re-check in Modaal
Go back to Modaal's **Set up Xcode** panel and click **Re-check**. Modaal probes your installation and, when everything's in place, switches to **Installed** (green) and lists the iOS simulators it found.
That's it — you can now ask Modaal to build and run your app, and it'll launch it on the iOS simulator for you.
You can also re-validate from the User Menu via **Run system check**, which re-checks Xcode along with Modaal's other prerequisites.
***
## Have more than one Xcode installed?
If you keep several Xcode versions (for example a stable release and a beta), Modaal lists each one and marks which is **active** — the one macOS will use for builds. To switch, click **Set as active** on the version you want.
macOS asks for your administrator password to make the switch (it runs Apple's `xcode-select` under the hood). If the button ever fails, the panel shows the exact command you can run yourself in **Terminal**:
```bash theme={null}
sudo xcode-select -s /path/to/Xcode.app/Contents/Developer
```
***
## Fix “Attention needed”, missing runtimes and Set as active
You almost certainly skipped [Step 2](#step-2). Installing Xcode is not enough — open it once, accept the license, and download an **iOS** simulator runtime from **Xcode → Settings… → Components**. Then click **Re-check** in Modaal.
The simulator runtime hasn't been downloaded. In Xcode, go to **Settings… → Components**, download an **iOS** runtime, wait for it to finish, then **Re-check** in Modaal.
On recent Xcode versions the simulator viewer can't launch when **Xcode is installed on an external or ownership-disabled volume**. Move Xcode to your internal **/Applications** folder, or re-enable ownership on the volume with `sudo diskutil enableOwnership `. The build itself still ran — this only affects showing the window.
Make sure you entered the correct **administrator** password. As a fallback, copy the `sudo xcode-select -s …` command the panel shows and run it directly in **Terminal**.
Xcode plus simulators can easily use 30–50 GB. Remove old simulator runtimes you don't need from **Xcode → Settings… → Components**, and check **→ System Settings → General → Storage** for other large items.
Xcode confirmed? Next: [connect a coding agent](/guides/connect-your-agent) if you have not yet, then [start your first project](/guides/quickstart). TestFlight and App Store distribution need an Apple Developer account as well — [setting one up](https://modaal.dev/blog/apple-developer-account-setup) walks through the enrolment.
**Still stuck?** Join our [community on Discord](https://discord.gg/ng84Jm92tm) — we're happy to help you get set up.
# Kotlin Multiplatform Tutorial Series: Duet in Nine Steps
Source: https://docs.modaal.dev/tutorials/duet
Nine Duet tutorials build one native iOS and Android app, Foyer, from a first Kotlin feature to CI checks, theming, localization and analytics.
This series teaches Duet, Modaal's cross-platform parity framework for native iOS and Android apps, by building one app from its first feature to its analytics. You write each feature's logic once in Kotlin, mount it in a SwiftUI app and a Jetpack Compose app, and record its behavior so a CI gate replays the same recordings on both platforms. Nine tutorials, each with a starting tree you open and a finished tree you can compare against, all in the public [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials). The same nine pages are in that repository as Markdown under [`docs/`](https://github.com/modaal-agent/duet-tutorials/tree/main/docs), so you can read a step next to the tree it builds.
**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 [Modaal](https://modaal.dev), 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 Modaal](/articles/new-project) when you would rather skip the setup.
## What will you build?
The app is **Foyer**: a splash screen with a safety net, a sign-in gate, a three-step onboarding gate, a home with one paid feature behind an entitlement check, a three-step upgrade flow, and a profile tree three levels deep. The screens are generic; the flows are the ones most consumer apps share. Every arrow in the diagram below is a delegate event received as a parent action, every screen is mounted from state, and the paid check reads one entitlement value that only an entitlement stream writes.
```mermaid theme={null}
flowchart LR
splash["splash"] -->|Completed| signin["sign-in gate"]
signin -->|signed in| onboarding["onboarding gate"]
onboarding -->|done| main["main"]
main --> home["Home: items list, Insights card"]
main --> profile["Profile: Account, Edit name, Sign out"]
home -->|Free| upgrade["upgrade: plans, confirm, done"]
profile -->|plan row| upgrade
upgrade -->|entitlement stream flips| home
```
The backend is on the device: four ports (auth, purchases, items, account) with local implementations that persist to a JSON file. Running any tree needs no account, no store connection and no network.
## The nine tutorials
State, actions and effects as data; a pure reducer; a scenario recorded into four fixtures and replayed on the host lane; the test-clock suite. No UI.
The Kotlin core built into an Apple framework, the four recordings replayed across the Swift boundary, then a SwiftUI app and a Compose app that play the same splash from the same core and land on the same placeholder screen.
A parent level, children mounted from state, a child's delegate events received as parent actions, chain scenarios pinning the sign-in gate and a sign-out climbing four levels, and the four ports behind per-platform mock services.
An on-device backend replacing the mock services one for one; session and entitlement streams observed by workers adopted at mount and tested with the worker harness; the entitlement projected down as a slice; the home's locked card unlocking from the stream.
Route state, back as an action, process-death restore on Android, a deep link parsed into route state, and onboarding steps as siblings sharing their progress through lateral state.
Lanes and the manifest, the parity workflow in your own repository, and the mutation drill run both directions.
A token vocabulary, generated accessors on both platforms, and a second theme that changes no feature code.
String catalogs on iOS, resources on Android, semantic values in shared state, and recordings that do not change with a translation.
The app's event verbs emitted from reducers as effect data, a console sink worker, and events asserted in scenarios.
Tutorials 1 to 6 build on each other in order. Tutorials 7, 8 and 9 each start from Tutorial 6's finished tree, so you can take them in any order. Every starting tree from Tutorial 2 onward is the previous tutorial's finished tree plus one deliberately failing test, the closing exercise; Tutorial 1 starts from an empty project.
## What do you need installed?
* **macOS with Xcode 26.6.** Tutorial 1 uses none of it; Tutorial 2 onward builds the iOS app and the Kotlin/Native framework it consumes.
* **A JDK, version 25.** The Gradle wrapper in every tree fetches Gradle itself, and the Kotlin compiler arrives as a Gradle plugin; there is no separate Kotlin install.
* **Android Studio's SDK** from Tutorial 2 onward, for the Compose app. [Set up the Android toolchain](/guides/setup-android) lists the components.
* **XcodeGen** (`brew install xcodegen`) from Tutorial 2 onward. The iOS app's Xcode project is generated from `src-ios/App/xcodegen.yml`.
* **The `duet` command-line tool** is fetched by each tree's `tools/duet` wrapper at the pinned version on first use.
## Which versions are the tutorials verified against?
Every tree resolves the same family set, written once in the repository's `pins.env`: Duet 0.7.0, duet-tools 0.24.0 and duet-services 0.11.1, with Kotlin 2.4.10 and Gradle 9.7.1. The repository's CI runs every tree's checks on every push, so a tree that stops passing at those versions fails the build there rather than on your machine. When the family moves, the trees are re-pinned in one change and the repository is tagged for the set they were verified against. Two pages add pins of their own: Tutorial 2 adds SKIE 0.10.14, the Swift projection of the Kotlin/Native framework, and Tutorial 3 adds the KSP mock processor 0.2.1 and the Sourcery mocks bundle 0.6.2.
## What does the series teach?
One loop, repeated per feature. Write the feature's state, actions and effect payloads as data and its reducer as a pure function in Kotlin. Describe its behavior as a scenario in the fixture-authoring language, and record the scenario into fixture files. The Kotlin lane replays the fixtures against the reducer; from Tutorial 2, the Apple boundary lane replays the same fixtures through the framework the iOS app consumes, and CI fails when either lane disagrees with the bytes on disk. Each app keeps its own native interface and its own platform code for anything the shared logic cannot own. The [Duet overview](/articles/duet) states the architecture; the [glossary](/articles/duet-glossary) defines each term the tutorials use. The pages call a recorded file a recording; the `duet` tool, the manifest and the `parity/fixtures/` directory call the same file a fixture.
The two tables below map each concept the series teaches to the tutorials that treat it, so you can open the step that introduces a concept without reading the series in order; each link lands on that step. The first table covers Duet's own vocabulary, each term defined in the [glossary](/articles/duet-glossary).
| Duet concept | What the tutorials do with it | Tutorials |
| - | - | - |
| Feature: state, actions, effect payloads and a pure reducer | The splash is written as one state field, three actions and two effect payloads; every later feature on the tree takes the same shape | [Tutorial 1](/tutorials/duet-01-first-feature#write-the-state-the-actions-and-the-effect-payloads), [Tutorial 3](/tutorials/duet-03-composing-features#write-the-sign-in-gate) |
| Effects as data, the environment and the effect handler | The reducer asks for a wait or a notification as a value; the handler, the module's only impure code, performs it through the environment interface; an analytics event is one more effect | [Tutorial 1](/tutorials/duet-01-first-feature#add-the-environment-and-the-runtime), [Tutorial 3](/tutorials/duet-03-composing-features#write-the-sign-in-gate), [Tutorial 9](/tutorials/duet-09-analytics#emit-them-as-track-effects) |
| Store, kernel and `TestStore` | The store factory both shells call, effect ids with cancel-in-flight semantics, and the exhaustive `finish()` that fails on an unreceived action or a running effect | [Tutorial 1](/tutorials/duet-01-first-feature#add-the-environment-and-the-runtime), [Tutorial 2](/tutorials/duet-02-two-apps#write-the-consumer-package-and-the-store-mirror) |
| Scenario and behavior recording | A given, `whenAction` steps and `then` checks, with branches over one given; recorded into fixture files, replayed by a golden test, and held to the scenario by `record --check` | [Tutorial 1](/tutorials/duet-01-first-feature#describe-the-behavior-as-a-scenario-and-record-it), [Tutorial 8](/tutorials/duet-08-localization#exercise-pin-the-receipt) |
| Chain recording | The seam between two features pinned as a `hop`: the delegate one reducer emits, received by the next as its action | [Tutorial 3](/tutorials/duet-03-composing-features#pin-the-seams-with-chain-recordings), [Tutorial 5](/tutorials/duet-05-navigation-as-state#exercise-record-the-entitlement-chain), [Tutorial 9](/tutorials/duet-09-analytics#assert-them-in-the-scenarios-and-re-record) |
| Feature spec and the manifest | `parity/manifest.yaml` names each feature's types, scenario and recordings; `parity/feature-specs/` describes the feature in prose the tool cross-checks | [Tutorial 1](/tutorials/duet-01-first-feature#declare-the-feature-in-the-manifest-and-write-its-spec), [Tutorial 6](/tutorials/duet-06-checks-in-ci#read-the-lanes-the-manifest-derives) |
| Shell and its three duties | Intents into actions, state into view state, the store's lifetime bracketed; mounting children from state is the fourth duty, and a shell never resolves a string | [Tutorial 2](/tutorials/duet-02-two-apps#write-the-swift-shell), [Tutorial 3](/tutorials/duet-03-composing-features#compose-the-tree-on-android), [Tutorial 8](/tutorials/duet-08-localization#move-the-shells-strings-into-the-views) |
| The Apple boundary | The Kotlin core as an XCFramework, SKIE's Swift projection, the recordings replayed across the boundary, the `BridgedStore` mirror, and Kotlin flows read as async sequences; [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) covers object lifetime across it | [Tutorial 2](/tutorials/duet-02-two-apps#declare-the-apple-framework-module), [Tutorial 4](/tutorials/duet-04-workers#write-the-workers-on-ios), [Tutorial 9](/tutorials/duet-09-analytics#wire-the-sink-on-ios) |
| Host, mount and `ChildSlot` | A host builds a store, owns its slot and tears it down; a slot mounts at most one child from a state value, including a sheet | [Tutorial 2](/tutorials/duet-02-two-apps#build-the-ios-app), [Tutorial 3](/tutorials/duet-03-composing-features#compose-the-tree-on-android), [Tutorial 5](/tutorials/duet-05-navigation-as-state#mount-the-flow-in-each-platforms-idiom) |
| Delegate events received as parent actions | Every arrow up the tree is a child's delegate event, one case of the parent's action type | [Tutorial 3](/tutorials/duet-03-composing-features#write-the-root-level), [Tutorial 5](/tutorials/duet-05-navigation-as-state#write-the-upgrade-flow) |
| Composition: Dependency, Component and Builder | A per-level Dependency, a Component that forwards it and owns level-scoped objects, and a Builder constructed once per mount, on both platforms | [Tutorial 3](/tutorials/duet-03-composing-features#compose-the-tree-on-android), [Tutorial 4](/tutorials/duet-04-workers#delete-the-mock-services-one-for-one), [Tutorial 9](/tutorials/duet-09-analytics#wire-the-sink-on-android) |
| Ports | Callback-shaped interfaces the logic calls and each app implements, first as mock services, then as on-device repositories with streams | [Tutorial 3](/tutorials/duet-03-composing-features#declare-the-four-ports), [Tutorial 4](/tutorials/duet-04-workers#add-the-two-streams-to-the-ports) |
| Workers and `WorkerTester` | An object adopted at mount that observes a stream for the mount's lifetime and sends what it sees; tested behaviorally, never recorded | [Tutorial 4](/tutorials/duet-04-workers#write-the-workers-on-android), [Tutorial 5](/tutorials/duet-05-navigation-as-state#share-readiness-laterally), [Tutorial 9](/tutorials/duet-09-analytics#wire-the-sink-on-android) |
| Slices: state projected down | The root's entitlement reaches two tabs as an action of their own; the onboarding page reaches the progress row the same way | [Tutorial 4](/tutorials/duet-04-workers#project-the-entitlement-down-as-a-slice), [Tutorial 5](/tutorials/duet-05-navigation-as-state#share-readiness-laterally) |
| Lateral state between siblings | A worker owned by the lowest common ancestor, with a void update on one side and a sticky observation on the other | [Tutorial 5](/tutorials/duet-05-navigation-as-state#share-readiness-laterally) |
| Navigation as route state | Back as an action, a deep link parsed into a value and forwarded down the tree, a route spine saved before process death and applied as initial state | [Tutorial 5](/tutorials/duet-05-navigation-as-state#draw-the-routes-before-the-screens) |
| The checks and the lanes | `verify`, `lint`, `doctor`, `record --check`, `mocks --check`, `protocol-run` and the lanes report; what each proves and what its red names | [Tutorial 1](/tutorials/duet-01-first-feature#replay-the-recordings), [Tutorial 3](/tutorials/duet-03-composing-features#generate-the-swift-components-and-mocks), [Tutorial 6](/tutorials/duet-06-checks-in-ci#know-what-each-command-proves) |
| Generated code held by a check | Environment mocks from KSP, Swift Components and mocks from Sourcery, token vocabularies from one YAML file, each a build product with a drift check | [Tutorial 3](/tutorials/duet-03-composing-features#generate-the-kotlin-test-doubles), [Tutorial 7](/tutorials/duet-07-theming#read-what-was-generated) |
| Mutation drill | Rows that seed one defect each into a reducer; the recordings must catch every row, and a surviving row names the recording to write | [Tutorial 6](/tutorials/duet-06-checks-in-ci#write-the-first-mutation-rows) |
| Behavior versus presentation | What a recording sees and what it does not: a reveal animation, a sheet against a bottom sheet, a color token, a translated string | [Tutorial 2](/tutorials/duet-02-two-apps#write-the-swiftui-view), [Tutorial 5](/tutorials/duet-05-navigation-as-state#mount-the-flow-in-each-platforms-idiom), [Tutorial 7](/tutorials/duet-07-theming#bind-the-cards), [Tutorial 8](/tutorials/duet-08-localization#make-what-the-logic-says-a-value) |
| Design tokens and themes | A token vocabulary generated for both platforms, and a second theme as one mapping per platform | [Tutorial 7](/tutorials/duet-07-theming#declare-the-vocabulary) |
| Telemetry grammar and `Track` effects | Events emitted by the reducer that owns the transition, a console sink worker behind one fan-out per platform | [Tutorial 9](/tutorials/duet-09-analytics#declare-the-events-beside-the-reducers) |
The second table covers the engineering practice the series exercises along the way.
| Engineering practice | What the tutorials do with it | Tutorials |
| - | - | - |
| Gradle multi-module project with a version catalog | One module per feature, a replay-runner application, an umbrella framework module, and pins written once | [Tutorial 1](/tutorials/duet-01-first-feature#declare-the-feature-module), [Tutorial 2](/tutorials/duet-02-two-apps#declare-the-apple-framework-module) |
| Unit tests on virtual time | `runTest`, `advanceTimeBy` and a clock seam, so a three-second timer and a thirty-minute session expiry both run in milliseconds | [Tutorial 1](/tutorials/duet-01-first-feature#pin-the-safety-nets-timing-on-a-test-clock), [Tutorial 2](/tutorials/duet-02-two-apps#test-the-host-on-the-jvm), [Tutorial 4](/tutorials/duet-04-workers#write-the-on-device-backend) |
| Golden tests | Recorded output replayed byte for byte, on the JVM and across the Swift boundary | [Tutorial 1](/tutorials/duet-01-first-feature#replay-the-recordings), [Tutorial 2](/tutorials/duet-02-two-apps#replay-the-recordings-across-the-boundary) |
| Test doubles | A hand-written double for one interface, generated doubles once there are several | [Tutorial 1](/tutorials/duet-01-first-feature#pin-the-safety-nets-timing-on-a-test-clock), [Tutorial 3](/tutorials/duet-03-composing-features#generate-the-kotlin-test-doubles) |
| Integration tests over a composition root | One headless walk through the whole tree on the JVM and one across the boundary in Swift, including a restore after process death | [Tutorial 3](/tutorials/duet-03-composing-features#test-the-composition-roots), [Tutorial 5](/tutorials/duet-05-navigation-as-state#gather-the-route-spine-and-restore-from-it) |
| Building a Kotlin/Native XCFramework | An umbrella module with `export`, an assemble script, SwiftPM binary targets, and an XcodeGen spec with a build pre-action | [Tutorial 2](/tutorials/duet-02-two-apps#assemble-the-framework-with-one-script) |
| Dependency injection with per-level interfaces | A Dependency per level kept as narrow as the level's reads, satisfied by the parent's Component at compile time | [Tutorial 3](/tutorials/duet-03-composing-features#compose-the-tree-on-android) |
| Persistence | An on-device backend persisting one JSON document through a two-function file port, with memory files in tests | [Tutorial 4](/tutorials/duet-04-workers#write-the-on-device-backend) |
| Reactive streams | `StateFlow` on Kotlin and async sequences on Swift, sticky values for late subscribers, and a fan-out over sinks | [Tutorial 4](/tutorials/duet-04-workers#add-the-two-streams-to-the-ports), [Tutorial 5](/tutorials/duet-05-navigation-as-state#share-readiness-laterally), [Tutorial 9](/tutorials/duet-09-analytics#wire-the-sink-on-android) |
| Idempotency guards | An arming latch, an in-flight latch and a late-completion guard, each pinned by a recording; three guards drilled by mutation rows | [Tutorial 1](/tutorials/duet-01-first-feature#write-the-reducer), [Tutorial 3](/tutorials/duet-03-composing-features#write-the-sign-in-gate), [Tutorial 6](/tutorials/duet-06-checks-in-ci#run-the-table-and-read-the-row-that-survives) |
| Deep links, state restoration and back handling | URL schemes on both platforms, the instance `Bundle` and the scene restoration activity, and a back policy as pure predicates with a test | [Tutorial 5](/tutorials/duet-05-navigation-as-state#grow-the-root-the-second-gate-and-the-deep-link) |
| Continuous integration on GitHub Actions | One workflow with a macOS job and an ubuntu job, a toolchain cache, a nightly job and a job summary | [Tutorial 6](/tutorials/duet-06-checks-in-ci#put-the-checks-in-a-workflow) |
| Mutation testing | Exact-string edits to reducers, run one at a time against the full suite | [Tutorial 6](/tutorials/duet-06-checks-in-ci#write-the-first-mutation-rows) |
| Code generation with a drift check | Generated files committed as build products and compared against an in-memory regeneration on every push | [Tutorial 3](/tutorials/duet-03-composing-features#generate-the-swift-components-and-mocks), [Tutorial 7](/tutorials/duet-07-theming#read-what-was-generated) |
| Accessibility | WCAG contrast ratios asserted in tests, Dynamic Type scaling, and a theme selected by the system's contrast setting | [Tutorial 7](/tutorials/duet-07-theming#link-the-engines-and-declare-the-main-theme) |
| Localization | String catalogs with a build-tool plugin, Android resources, plurals, positional placeholders, per-app language, and a completeness test per platform | [Tutorial 8](/tutorials/duet-08-localization#write-the-catalogs-and-link-the-generator) |
| Product analytics | An event taxonomy spelled for a dashboard, parameters that describe behavior and never content, and consent gated at the sink | [Tutorial 9](/tutorials/duet-09-analytics#mint-the-apps-verb) |
## Common questions
Tutorial 1 is Kotlin only. From Tutorial 2 each page shows the Swift and Kotlin halves of every per-platform step in tabs; the shared logic stays Kotlin throughout. Reading SwiftUI and Compose is enough to follow; the shells are short.
Yes. Open that tutorial's `-start` tree from the repository; it is the previous tutorial's finished state plus the exercise stub. Tutorials 7 to 9 all start from Tutorial 6's finished tree.
So every tree runs with no account, no store and no network, and so the feature logic reaches the outside world only through the four port interfaces. A real backend implements the same ports; nothing in the feature modules changes.
The trees the series opens have the shape the [Duet templates in Modaal](/articles/new-project) emit. The series writes by hand what the scaffold and a coding agent produce for you, so you can read an emitted project and know what each file is for.
## Sources and further reading
* [The duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials) — the step trees, their checks, the pins file, and [`docs/`](https://github.com/modaal-agent/duet-tutorials/tree/main/docs), which holds these nine pages as Markdown.
* [The Duet framework repository](https://github.com/modaal-agent/duet) — the kernel, the test harness and the framework's contracts.
* [The duet-tools repository](https://github.com/modaal-agent/duet-tools) — the `duet` command-line tool the trees run their checks with, and the manifest grammar.
* [Kotlin Multiplatform documentation](https://kotlinlang.org/docs/multiplatform.html) — the technology under the shared core.
## Read next
Write the splash feature in Kotlin, record its behavior and verify it, with no UI yet.
What Duet is, what you get, and the two project cards that scaffold it.
# Duet Tutorial 1: Your First Feature
Source: https://docs.modaal.dev/tutorials/duet-01-first-feature
Write a splash feature in Kotlin as state, actions and effects with a pure reducer, then record its behavior into four fixtures and verify them. No UI yet.
In this tutorial you write the splash feature of Foyer as a Duet feature in Kotlin: its state, its actions, its effects as data, and a pure reducer that turns one into the next. You describe the feature's behavior as a scenario, record the scenario into four fixture files, and verify that the reducer replays them; a test-clock suite pins the one piece of timing. There is no UI in this tutorial: [Tutorial 2](/tutorials/duet-02-two-apps) mounts the feature in a SwiftUI app and a Compose app. This page is the first of the [nine-tutorial series](/tutorials/duet), which builds one app, Foyer, end to end.
**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 [Modaal](https://modaal.dev), 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 Modaal](/articles/new-project) when you would rather skip the setup.
## What will you build?
A splash screen has one job: play an animation, then hand control to the host. The splash you write here does that with a safety net. When the splash appears it arms a timer; when the animation ends, or when the timer fires, the feature tells its host that the splash completed and by which path. Both paths notify the host every time they fire, because the host treats a repeat as a no-op, and a second `Appeared` is inert so the timer is armed once per mount. That is the whole behavior, and it fits in one state field, three actions and two effect payloads.
You will have at the end:
* `src-kmp/subtrees/splash/logic`, a Kotlin Multiplatform module with the feature's types, its reducer, its serializers, its environment interface and its effect handler.
* Four recordings under `parity/fixtures/`: `splash.ceremony-completes`, `splash.safety-net-fires`, `splash.both-paths-notify-twice` and `splash.repeat-appear-inert`.
* A green `tools/duet verify`: the four recordings replay against the reducer, and the safety net's timing holds on a virtual clock.
* A replay runner the `duet` tool drives over a protocol, which [Tutorial 6](/tutorials/duet-06-checks-in-ci#put-the-checks-in-a-workflow) puts in CI.
## Where do you start?
Clone the [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials) and open `tutorial1-start`. It is a Kotlin Multiplatform project with no feature yet: a Gradle wrapper, a version catalog pinning Duet 0.7.0 and Kotlin 2.4.10, an empty parity manifest, and `tools/duet`, which fetches the `duet` command-line tool at version 0.24.0 on first use. Every path on this page is relative to that directory. Expect about an hour; the first Gradle run downloads the wrapper's distribution and is the slowest step. Confirm the tree is green before you edit anything:
```sh theme={null}
tools/duet verify
```
The last line reads `duet verify: PASS` with a note that no feature is declared. The finished tree is `tutorial1-complete` in the same repository; every code block on this page is an excerpt of it, named by its path.
## The steps
A Duet feature is a Gradle module under `src-kmp/subtrees//logic`. Its build file declares one JVM target, where the tests run, and three Apple targets, which compile the same sources for the iOS app in Tutorial 2. The module depends on the Duet kernel and on `kotlinx.serialization`; its tests add the kernel's test harness and the coroutines test library. Create the file:
```kotlin src-kmp/subtrees/splash/logic/build.gradle.kts theme={null}
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.kotlin.serialization)
}
// Every feature module is a Gradle project named `logic`, so each one names
// its own archive; two jars called `logic-jvm.jar` on one classpath fail the
// replay-runner's `installDist`.
base.archivesName.set("splash-logic")
// The feature's one implementation lives in commonMain. The JVM target runs
// the host test lane (the scenario, the golden replays, the test-store suite);
// the Apple targets compile the same sources for the iOS app, which arrives in
// Tutorial 2.
kotlin {
jvmToolchain(25)
jvm()
macosArm64()
iosArm64()
iosSimulatorArm64()
sourceSets {
commonMain.dependencies {
api(libs.duet.kernel)
implementation(libs.kotlinx.serialization.json)
}
jvmTest.dependencies {
implementation(libs.duet.kernel.test)
implementation(kotlin("test"))
implementation(libs.kotlinx.coroutines.test)
}
}
}
tasks.withType().configureEach {
useJUnitPlatform()
// The golden replays read parity/fixtures/*.json at runtime. Declaring the
// directory as a task input makes a fixtures-only change re-run the tests
// instead of leaving the task UP-TO-DATE.
inputs.dir(rootProject.layout.projectDirectory.dir("../parity/fixtures"))
.withPropertyName("parityFixtures")
.withPathSensitivity(PathSensitivity.RELATIVE)
}
```
The last block declares `parity/fixtures` as an input of the test task, so that a change to a recording re-runs the replays instead of leaving the task up to date. Then include the module in the settings file:
```kotlin src-kmp/settings.gradle.kts theme={null}
include(":subtrees:splash:logic")
```
Check it with `(cd src-kmp && ./gradlew projects -q)`; the project hierarchy ends with `\--- Project ':subtrees:splash:logic'`.
Everything the feature knows is data. The state is one field, the arming latch. The actions are the three things that can happen: two reports from the shell (the splash appeared; the animation finished) and one from the environment (the timer elapsed). The effect payloads describe what the feature asks the outside world to do, without doing it: wait on a clock, or tell the host something. The delegate event is what the host receives, and its `path` says which of the two ways completed the splash. Every type is `@Serializable`, because the recordings are JSON.
```kotlin src-kmp/subtrees/splash/logic/src/commonMain/kotlin/dev/modaal/foyer/splash/SplashFeature.kt theme={null}
// MARK: - Configuration
object SplashConfig {
/**
* How long the safety net waits before it completes the splash on the
* ceremony's behalf. The reducer carries the value out in the effect
* payload, so the recordings pin it.
*/
const val SAFETY_NET_MILLIS: Long = 3_000L
}
object SplashEffectIds {
/**
* The safety net's effect id. One net is in flight per store: a second
* `Run` under the same id would cancel the first, and the store's teardown
* cancels it outright.
*/
const val SAFETY_NET: EffectId = "splash.safetyNet"
}
// MARK: - State
@Serializable
data class SplashState(
/** The arming latch: the safety net is armed once per mount. */
val isArmed: Boolean = false,
)
// MARK: - Actions
@Serializable(with = SplashActionSerializer::class)
sealed interface SplashAction {
/** Shell report: the splash is on screen and the safety net should arm. */
@Serializable @SerialName("appeared") data object Appeared : SplashAction
/** Shell report: the splash animation reached its end. */
@Serializable
@SerialName("ceremonyFinished")
data object CeremonyFinished : SplashAction
/** Environment report: the armed delay elapsed. */
@Serializable
@SerialName("safetyNetElapsed")
data object SafetyNetElapsed : SplashAction
}
// MARK: - Delegate events
/** Which of the two paths completed the splash. */
@Serializable(with = SplashCompletionPathSerializer::class)
sealed interface SplashCompletionPath {
@Serializable @SerialName("ceremony") data object Ceremony : SplashCompletionPath
@Serializable @SerialName("safetyNet") data object SafetyNet : SplashCompletionPath
}
/** What the splash tells its host. The host decides what happens next. */
@Serializable(with = SplashDelegateEventSerializer::class)
sealed interface SplashDelegateEvent {
@Serializable
@SerialName("completed")
data class Completed(val path: SplashCompletionPath) : SplashDelegateEvent
}
// MARK: - Effect payloads
@Serializable(with = SplashEffectPayloadSerializer::class)
sealed interface SplashEffectPayload {
/**
* Wait `afterMillis` on the environment's clock, then report
* `SafetyNetElapsed`. Runs under `SplashEffectIds.SAFETY_NET`.
*/
@Serializable
@SerialName("armSafetyNet")
data class ArmSafetyNet(val afterMillis: Long) : SplashEffectPayload
/** Hand a delegate event to the host. */
@Serializable
@SerialName("notifyListener")
data class NotifyHost(val event: SplashDelegateEvent) : SplashEffectPayload
}
```
Two choices here matter later. The timer's duration is a module constant that the reducer copies into the payload, so the recordings pin the number and a retiming shows up as a diff in a fixture file. The effect id `splash.safetyNet` is what gives the timer cancel-in-flight semantics: the kernel's [Store](/articles/duet-glossary#store) cancels a running effect when a new one starts under the same id, and cancels every running effect at teardown.
The [reducer](/articles/duet-glossary#reducer) is a pure function from a state and an action to a new state and a list of effects. It never sleeps, never reads a clock and never calls the host; it only decides. The three arms below are the whole behavior: `Appeared` arms the net once, and each completion path asks for a notification.
```kotlin src-kmp/subtrees/splash/logic/src/commonMain/kotlin/dev/modaal/foyer/splash/SplashFeature.kt theme={null}
fun splashReducer(
state: SplashState,
action: SplashAction,
): Reduced =
when (action) {
SplashAction.Appeared ->
if (state.isArmed) {
// The arming guard: a repeat `Appeared` writes nothing and emits nothing.
Reduced(state)
} else {
Reduced(
state.copy(isArmed = true),
listOf(
Effect.Run(
SplashEffectPayload.ArmSafetyNet(SplashConfig.SAFETY_NET_MILLIS),
id = SplashEffectIds.SAFETY_NET)))
}
SplashAction.CeremonyFinished ->
Reduced(
state,
listOf(
Effect.Run(
SplashEffectPayload.NotifyHost(
SplashDelegateEvent.Completed(SplashCompletionPath.Ceremony)))))
SplashAction.SafetyNetElapsed ->
Reduced(
state,
listOf(
Effect.Run(
SplashEffectPayload.NotifyHost(
SplashDelegateEvent.Completed(SplashCompletionPath.SafetyNet)))))
}
```
Note what the reducer does not do. `CeremonyFinished` does not cancel the net and does not set a "completed" flag: if the timer fires after the animation ended, the host is notified a second time, and the host's job is to ignore it. Keeping the rule in one place, the host, is what lets the reducer stay three arms long, and the recordings in step 7 pin that rule so nobody adds a latch later by accident.
Recordings are JSON, and every sum type on the reducer's boundary needs a canonical coding: an object with a `case` and, when the case carries data, a `value`. The kernel's `CanonicalSumSerializer` takes one registry line per case; a case you forget fails at the first encode rather than producing a fixture that quietly disagrees with the Swift side later. A case whose single payload is unlabeled, such as `NotifyHost(event)`, is marked `inline` so the payload encodes bare.
```kotlin src-kmp/subtrees/splash/logic/src/commonMain/kotlin/dev/modaal/foyer/splash/SplashSerializers.kt theme={null}
object SplashActionSerializer :
CanonicalSumSerializer(
"SplashAction",
listOf(
case(SplashAction.Appeared::class, SplashAction.Appeared.serializer()),
case(SplashAction.CeremonyFinished::class, SplashAction.CeremonyFinished.serializer()),
case(SplashAction.SafetyNetElapsed::class, SplashAction.SafetyNetElapsed.serializer()),
))
```
```kotlin src-kmp/subtrees/splash/logic/src/commonMain/kotlin/dev/modaal/foyer/splash/SplashSerializers.kt theme={null}
object SplashEffectPayloadSerializer :
CanonicalSumSerializer(
"SplashEffectPayload",
listOf(
case(SplashEffectPayload.ArmSafetyNet::class, SplashEffectPayload.ArmSafetyNet.serializer()),
case(
SplashEffectPayload.NotifyHost::class,
SplashEffectPayload.NotifyHost.serializer(),
inline = true),
))
```
The finished file registers `SplashCompletionPath` and `SplashDelegateEvent` the same way.
The reducer asked for two things it cannot do itself: wait, and notify the host. The environment interface is the feature's only door to the platform, and each app implements it in Tutorial 2. It has a clock rather than a `delay` call, so a test can run the wait on virtual time.
```kotlin src-kmp/subtrees/splash/logic/src/commonMain/kotlin/dev/modaal/foyer/splash/SplashEnvironment.kt theme={null}
interface SplashEnvironment {
/**
* The clock the safety net waits on. The effect handler calls `sleep` here
* rather than `delay` directly, so a test can run the wait on virtual time.
*/
val clock: KernelClock
/** Hand a delegate event to the host. */
fun notifyHost(event: SplashDelegateEvent)
}
```
The effect handler is the module's only impure code. It turns each payload into a cold flow of the actions the effect produces: `ArmSafetyNet` sleeps and then emits `SafetyNetElapsed`; `NotifyHost` calls the environment and emits nothing. Cancellation needs no code of its own: when the store cancels the effect, the sleep throws and the flow ends without emitting. The store factory below it is what both shells call.
```kotlin src-kmp/subtrees/splash/logic/src/commonMain/kotlin/dev/modaal/foyer/splash/SplashRuntime.kt theme={null}
fun splashEffectHandler(
environment: SplashEnvironment,
): (SplashEffectPayload) -> Flow = { payload ->
flow {
when (payload) {
is SplashEffectPayload.ArmSafetyNet -> {
environment.clock.sleep(payload.afterMillis * NANOS_PER_MILLI)
emit(SplashAction.SafetyNetElapsed)
}
is SplashEffectPayload.NotifyHost -> environment.notifyHost(payload.event)
}
}
}
private const val NANOS_PER_MILLI = 1_000_000L
/** The store every shell hosts: the reducer and the handler, wired. */
fun makeSplashStore(
environment: SplashEnvironment,
scope: CoroutineScope,
): Store =
Store(
initialState = SplashState(),
reducer = ::splashReducer,
handler = splashEffectHandler(environment),
scope = scope,
)
```
Compile the module with `(cd src-kmp && ./gradlew :subtrees:splash:logic:compileKotlinJvm)`; the last line reads `BUILD SUCCESSFUL`.
`parity/manifest.yaml` is what the `duet` tool reads: which file holds the feature, which test is its scenario, and which recordings it owns. Add the feature under `features:`.
```yaml parity/manifest.yaml theme={null}
features:
splash:
kotlin: src-kmp/subtrees/splash/logic/src/commonMain/kotlin/dev/modaal/foyer/splash/SplashFeature.kt
state: SplashState
action: SplashAction
effectPayload: SplashEffectPayload
scenario: src-kmp/subtrees/splash/logic/src/jvmTest/kotlin/dev/modaal/foyer/splash/SplashScenarioTest.kt
fixtures:
- splash.ceremony-completes
- splash.safety-net-fires
- splash.both-paths-notify-twice
- splash.repeat-appear-inert
```
Beside it, `parity/feature-specs/splash.md` is the one-page description of the feature in prose: identity and config, state, actions, transitions, effects, delegate events, what stays app-side, and the recordings. The tool checks that every recording listed in the manifest is named in that file in backticks, so the prose cannot drift from the fixtures without a red check. Copy the finished tree's file, or write your own from its headings; its last section is the table below.
```markdown parity/feature-specs/splash.md theme={null}
| Recording | Pins |
| --- | --- |
| `splash.ceremony-completes` | `appeared` arms the net; `ceremonyFinished` notifies `completed(ceremony)` |
| `splash.safety-net-fires` | `safetyNetElapsed` notifies `completed(safetyNet)`. Mutually exclusive with the row above, so it is a branch over the same given rather than a scenario of its own |
| `splash.both-paths-notify-twice` | the deliberate non-latch: `ceremonyFinished` then `safetyNetElapsed`, two notifications |
| `splash.repeat-appear-inert` | the arming guard: a second `appeared` writes nothing and emits nothing |
```
Run `tools/duet lint`. It fails, and the failure names the next step:
```text theme={null}
✗ [splash] fixture listed but missing on disk: splash.ceremony-completes — just declared? run: tools/duet record --feature splash
```
A [scenario](/articles/duet-glossary#scenario) is a test written in the fixture-authoring language: a given state, `whenAction` steps, and `then` and `thenEffects` checks. Where two endings are mutually exclusive, they are branches over the same given, and each branch leaf becomes one recording. The splash has one given, the appeared splash, and four branches.
```kotlin src-kmp/subtrees/splash/logic/src/jvmTest/kotlin/dev/modaal/foyer/splash/SplashScenarioTest.kt theme={null}
given(SplashState())
whenAction("the splash appears", SplashAction.Appeared)
then("the safety net is armed") { it.isArmed }
thenEffects("exactly the keyed safety net, carrying its duration") {
it ==
effectsOf(
Effect.Run(
SplashEffectPayload.ArmSafetyNet(SplashConfig.SAFETY_NET_MILLIS),
id = SplashEffectIds.SAFETY_NET))
}
branch("ceremony completes") {
whenAction("the animation reaches its end", SplashAction.CeremonyFinished)
then("the arming latch is untouched") { it.isArmed }
thenEffects("the host is notified, by the ceremony path") {
it == notified(SplashCompletionPath.Ceremony)
}
}
branch("safety net fires") {
whenAction("the armed delay elapses instead", SplashAction.SafetyNetElapsed)
then("the arming latch is untouched") { it.isArmed }
thenEffects("the host is notified, by the safety-net path") {
it == notified(SplashCompletionPath.SafetyNet)
}
}
branch("both paths notify twice") {
whenAction("the animation reaches its end", SplashAction.CeremonyFinished)
thenEffects("the host is notified, by the ceremony path") {
it == notified(SplashCompletionPath.Ceremony)
}
whenAction("the armed delay elapses on top of it", SplashAction.SafetyNetElapsed)
thenEffects("the host is notified again, by the safety-net path") {
it == notified(SplashCompletionPath.SafetyNet)
}
}
branch("repeat appear inert") {
whenAction("the splash reports appearing a second time", SplashAction.Appeared)
then("still armed, unchanged") { it.isArmed }
thenEffects("nothing: the net is not re-armed") { it.isEmpty() }
}
```
The scenario builder is wrapped in a `scenario(feature = "splash", description = …, source = …)` call, and the test ends by handing the scenario, the three serializers and the reducer to `ScenarioRunner.verifyOrRecord`. Run as an ordinary test it verifies; run by the `duet` tool with regeneration on, it records. Record it:
```sh theme={null}
tools/duet record --feature splash
```
The first line reads `duet record: rewrote 4 fixture(s):` followed by the four paths. Open one. Each step holds the action, the expected state and the expected effects as canonical JSON, plus the label and the source line it came from:
```json parity/fixtures/splash.repeat-appear-inert.fixture.json theme={null}
"steps": [
{
"action": {
"case": "appeared"
},
"expectedEffects": [
{
"id": "splash.safetyNet",
"kind": "run",
"payload": {
"case": "armSafetyNet",
"value": {
"afterMillis": 3000
}
}
}
],
"expectedState": {
"isArmed": true
},
"label": "the splash appears",
"line": 33
},
{
"action": {
"case": "appeared"
},
"expectedEffects": [],
"expectedState": {
"isArmed": true
},
"label": "repeat appear inert › the splash reports appearing a second time",
"line": 71
}
]
```
Fixtures are build products: review the diff like code, never edit them by hand.
A [golden recording](/articles/duet-glossary#golden-fixture) is only worth something if a test replays it. The golden test has one method per branch leaf, so a divergence in one ending fails on its own line, and the `duet` tool checks that every recording listed in the manifest reported a replay.
```kotlin src-kmp/subtrees/splash/logic/src/jvmTest/kotlin/dev/modaal/foyer/splash/SplashGoldenTest.kt theme={null}
class SplashGoldenTest {
@Test fun ceremonyCompletesLeaf() = replay("splash.ceremony-completes")
@Test fun safetyNetFiresLeaf() = replay("splash.safety-net-fires")
@Test fun bothPathsNotifyTwiceLeaf() = replay("splash.both-paths-notify-twice")
@Test fun repeatAppearInertLeaf() = replay("splash.repeat-appear-inert")
private fun replay(leaf: String) {
FixtureRunner.run(
fixture = leaf,
stateSerializer = SplashState.serializer(),
actionSerializer = SplashActionSerializer,
payloadSerializer = SplashEffectPayloadSerializer,
reducer = ::splashReducer,
)
}
}
```
Run the checks:
```sh theme={null}
tools/duet verify
```
The output includes `duet verify: 4/4 fixture report(s) passed` and ends with `duet verify: PASS`. The verify command runs the manifest checks first, then the Kotlin lane, which is the module's `jvmTest` task; the manifest's Swift lane is skipped because no feature declares a `swift:` path. The Apple boundary lane, which replays the same recordings across the framework the iOS app consumes, arrives in [Tutorial 2](/tutorials/duet-02-two-apps#replay-the-recordings-across-the-boundary).
The recordings pin what the reducer emits and nothing about time: the reducer never waits. The timing lives in the effect handler, so its test drives the real store through the handler on the coroutines test scheduler's virtual clock. The test double for the environment records every delegate event and waits on `LiveClock`, which under `runTest` suspends on virtual time.
```kotlin src-kmp/subtrees/splash/logic/src/jvmTest/kotlin/dev/modaal/foyer/splash/RecordingSplashEnvironment.kt theme={null}
class RecordingSplashEnvironment(
override val clock: KernelClock = LiveClock,
) : SplashEnvironment {
val notified = mutableListOf()
override fun notifyHost(event: SplashDelegateEvent) {
notified += event
}
}
```
The suite has four tests: the net does not fire before its duration, fires after it, fires exactly once, and never fires after teardown. The first and last are below; every test starts from the same given, a store that has received `Appeared`.
```kotlin src-kmp/subtrees/splash/logic/src/jvmTest/kotlin/dev/modaal/foyer/splash/SplashTestStoreTest.kt theme={null}
@Test
fun safetyNetDoesNotFireBeforeItsDuration() = runTest {
val environment = RecordingSplashEnvironment()
val store = armedStore(environment)
advanceTimeBy(SplashConfig.SAFETY_NET_MILLIS - 1)
runCurrent()
// Teardown first: a net still sleeping at `finish()` would count as an
// effect left in flight. Then `finish()` fails on any action that arrived
// and was never received.
store.teardown()
store.finish()
assertEquals(emptyList(), environment.notified)
}
```
```kotlin src-kmp/subtrees/splash/logic/src/jvmTest/kotlin/dev/modaal/foyer/splash/SplashTestStoreTest.kt theme={null}
@Test
fun teardownCancelsTheArmedNet() = runTest {
val environment = RecordingSplashEnvironment()
val store = armedStore(environment)
store.teardown()
advanceTimeBy(SplashConfig.SAFETY_NET_MILLIS * 3)
runCurrent()
store.finish()
assertEquals(emptyList(), environment.notified)
}
```
`TestStore` is exhaustive: `finish()` fails if an action arrived that the test never received, or if an effect is still running. That is how the "exactly once" test works with no counter: a second `SafetyNetElapsed` would be an unreceived action. Run `tools/duet verify` again; it ends with `duet verify: PASS`. Then run the regeneration gate, which CI uses to catch a scenario edited without re-recording:
```sh theme={null}
tools/duet record --check
```
It prints `duet record --check: fixtures are up to date with their scenarios`.
The last module is a small JVM application that serves the app's features over the replay protocol: the `duet` tool sends each recorded step to it and compares the bytes that come back. It is how the same recordings are checked from outside the test JVM, and [Tutorial 6](/tutorials/duet-06-checks-in-ci#put-the-checks-in-a-workflow) puts it in the CI workflow. It is a registry with one entry per feature:
```kotlin src-kmp/replay-runner/src/main/kotlin/dev/modaal/foyer/replayrunner/Main.kt theme={null}
private val registry =
ReplayRegistry(
listOf(
ReplayFeature.entry(
"splash",
SplashState.serializer(),
SplashActionSerializer,
SplashEffectPayloadSerializer,
::splashReducer),
))
fun main() {
ReplayServer.serve(registry)
}
```
Its build file applies the Kotlin JVM plugin and the `application` plugin, and depends on the kernel and on the feature module:
```kotlin src-kmp/replay-runner/build.gradle.kts theme={null}
dependencies {
implementation(libs.duet.kernel)
implementation(project(":subtrees:splash:logic"))
}
application {
mainClass.set("dev.modaal.foyer.replayrunner.MainKt")
}
```
Include it in the settings file:
```kotlin src-kmp/settings.gradle.kts theme={null}
// The replay-protocol endpoint `tools/duet protocol-run` drives.
include(":replay-runner")
```
Build the runner and drive it:
```sh theme={null}
(cd src-kmp && ./gradlew :replay-runner:installDist -q)
tools/duet protocol-run --runner src-kmp/replay-runner/build/install/replay-runner/bin/replay-runner
```
The output includes `4 leaf + 0 chain fixture(s), 9 step(s) byte-gated CLI-side` and ends with `protocol-run: PASS`.
## What you now have
* One feature module, `src-kmp/subtrees/splash/logic`, with the behavior in four `commonMain` files: the types and the reducer, the serializers, the environment interface and the runtime.
* Four recordings under `parity/fixtures/`, each a JSON file the reducer replays byte for byte.
* Nine passing tests on the Kotlin lane: the scenario, four golden replays and four test-clock checks.
* A green `tools/duet verify`, a green `tools/duet record --check`, and a replay runner that passes `tools/duet protocol-run`.
* A manifest row and a feature spec that the tool cross-checks against the recordings.
Well done: that is a complete Duet feature, and [Tutorial 2](/tutorials/duet-02-two-apps#write-the-swift-shell) puts it on two screens without changing a line of it.
## Exercise: break the arming guard
The recording `splash.repeat-appear-inert` pins that a second `Appeared` does nothing. Delete the guard and watch the recording catch it. In `SplashFeature.kt`, replace the `Appeared` arm's `if`/`else` with the `else` branch's body alone, so every `Appeared` arms a net. Run `tools/duet verify`. The Kotlin lane fails on one recording, and the report names the step, the expected effects and what the reducer emitted instead:
```text theme={null}
duet verify: 3/4 fixture report(s) passed
✗ fixture 'splash.repeat-appear-inert' step 1 'repeat appear inert › nothing: the net is not re-armed' — Then failed [kotlin]
at expectedEffects[0]
expected: ∅ actual: {"id":"splash.safetyNet","kind":"run","payload":{"case":"armSafetyNet","value":{"afterMillis":3000}}}
```
Restore the guard and run `tools/duet verify` again; it ends with `duet verify: PASS`. A recording is a test you did not have to write twice: the same file fails the Apple boundary lane in [Tutorial 2](/tutorials/duet-02-two-apps#replay-the-recordings-across-the-boundary) if the framework the iOS app consumes ever disagrees.
## Common questions
So the number is in the recording. `expectedEffects` in every fixture carries `afterMillis: 3000`; a change to `SplashConfig.SAFETY_NET_MILLIS` shows up as a diff in four JSON files that a reviewer sees. A duration hidden in the handler would be invisible to the recordings and to the Apple boundary lane.
Because the host is idempotent and the reducer stays simpler for it: three arms, one state field, no ordering between the two paths to get wrong. The recording `splash.both-paths-notify-twice` pins the second notification, so a future edit that adds a cancel or a latch fails a check instead of silently changing the contract with the host.
The scenario test is the source of the recordings: run with regeneration on by `tools/duet record`, it writes the fixture files; run as a plain test, it checks them. The golden test only replays what is on disk, one method per recording, and is what a second platform runs against the same files. Both stay in the tree so that a recording can be regenerated from its scenario and checked without it.
`RecordingSplashEnvironment` is nine lines and lives in test sources, which is the rule for every test double in a Duet tree. [Tutorial 3](/tutorials/duet-03-composing-features#generate-the-kotlin-test-doubles) replaces hand-written doubles with the family's generated mocks once there are several environments to double; for one interface with two members, the class is shorter than the generator wiring.
The same `commonMain` sources compile to an Apple framework in [Tutorial 2](/tutorials/duet-02-two-apps#declare-the-apple-framework-module), and declaring the targets now means the module's shape does not change when the app arrives. Nothing on this page builds them: `tools/duet verify` runs the JVM target only.
## Sources and further reading
* [The Duet framework repository](https://github.com/modaal-agent/duet) — the kernel's `Store`, `Effect` and `Reduced` types, the `kernel-test` harness this page's tests use, and the contracts for the [store kernel](https://github.com/modaal-agent/duet/blob/main/contracts/store-kernel-contract.md) and [serialization](https://github.com/modaal-agent/duet/blob/main/contracts/serialization.md).
* [The duet-tools repository](https://github.com/modaal-agent/duet-tools) — the `duet` command-line tool and the [manifest grammar](https://github.com/modaal-agent/duet-tools/blob/main/contracts/manifest.md).
* [The duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials) — `tutorial1-start` and `tutorial1-complete`, and the checks CI runs on them.
* [kotlinx-coroutines-test](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-test/) — `runTest`, `advanceTimeBy` and the virtual clock the test-clock suite runs on.
* [Kotlin serialization](https://kotlinlang.org/docs/serialization.html) — the `@Serializable` and `@SerialName` annotations on every recorded type.
* [The Duet glossary](/articles/duet-glossary) — [feature](/articles/duet-glossary#feature), [reducer](/articles/duet-glossary#reducer), [scenario](/articles/duet-glossary#scenario), [behavior recording](/articles/duet-glossary#fixture) and [the checks](/articles/duet-glossary#gate).
## Read next
Build the Kotlin core into an Apple framework, then mount the splash in a SwiftUI app and a Compose app.
The nine tutorials, the app they build, the prerequisites and the versions they are verified against.
What Duet is, what you get, and the two project cards that scaffold it.
# Duet Tutorial 2: One Behavior, Two Apps
Source: https://docs.modaal.dev/tutorials/duet-02-two-apps
Build the Kotlin core into an Apple framework, replay the recordings across it, then mount the splash in a SwiftUI app and a Compose app from the same store.
In this tutorial you put the splash feature from [Tutorial 1](/tutorials/duet-01-first-feature) on two screens. First you build the Kotlin core into an Apple framework and replay the four recordings across the Swift boundary, so the reducer that reaches the iOS app is provably the one the Kotlin lane checked. Then you write the two shells: a Swift shell and a SwiftUI view in the iOS app, and a Compose screen in the Android app. Each shell does three things and nothing else: it turns intents into actions, projects state into what the view renders, and brackets the store's lifetime. At the end both apps play the splash from the same store and land on the same placeholder screen; [Tutorial 3](/tutorials/duet-03-composing-features) replaces that placeholder with the feature tree. This is the second page of the [nine-tutorial series](/tutorials/duet).
**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 [Modaal](https://modaal.dev), 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 Modaal](/articles/new-project) when you would rather skip the setup.
## What will you build?
The Kotlin module you wrote in Tutorial 1 becomes one static framework, `FoyerKit`, built by Gradle and linked by two Swift packages: a test-only package that replays the recordings across the boundary, and the app's consumer package that holds the bridge and the shell. The iOS app is a scene delegate, a host object and two SwiftUI views; the Android app is an Activity, the same host in Kotlin and two composables. Neither app contains a line of feature logic. The splash reveals the app's name over 1.6 seconds, sends `CeremonyFinished` when the reveal ends, and the host swaps in a placeholder that names which path completed it.
This is the most setup-heavy tutorial in the series. The framework assembly is the setup the Modaal scaffold automates; here you write it by hand, in three files and one script. Expect about two hours, most of it in the first four steps.
You will have at the end:
* `src-kmp/apple-umbrella`: the framework module and the replay boundary it exports, assembled by `scripts/assemble_kit.sh`.
* Six passing tests in `src-kmp/apple-umbrella/swift-consumer`: the four recordings replayed across the boundary, plus two on the error channel.
* `src-ios/Libraries/FoyerKit`: the `FoyerBridge` target with the store mirror, the `SplashShell` target with the shell, the builder and the view, and three passing shell tests.
* `src-ios/App`: the iOS app, built from an XcodeGen spec.
* `src-kmp/app`: the Android app, with two passing host tests on the JVM.
* A green `parity/scripts/apple-boundary-lane.sh`, and `tools/duet doctor` reporting two target declarations.
## Where do you start?
Open `tutorial2-start` from the [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials). It is Tutorial 1's finished tree plus one failing test, the closing exercise, and it resolves Duet 0.7.0, duet-tools 0.24.0 and duet-services 0.11.1. You need Xcode 26.6, a JDK 25, XcodeGen (`brew install xcodegen`) and the Android SDK with platform 36; the [series index](/tutorials/duet#what-do-you-need-installed) lists them. Run the checks once before you edit anything:
```sh theme={null}
tools/duet verify
```
The Kotlin lane reports 10 tests with one failure, `Tutorial2ExerciseMountBracketTest`; 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 steps
The Apple side of a Duet app links one Kotlin/Native framework that aggregates the kernel and every feature module. One framework rather than one per feature, because a static Kotlin/Native framework embeds the Kotlin runtime and two of them would carry it twice. The module is `src-kmp/apple-umbrella`. It applies the multiplatform plugin and SKIE, the compiler plugin that projects sealed hierarchies as Swift enums and `StateFlow` as an async sequence. Add the SKIE version and plugin to the version catalog:
```toml src-kmp/gradle/libs.versions.toml theme={null}
# SKIE: the Swift-friendly projection of the Kotlin/Native framework — sealed
# hierarchies as enums, StateFlow as an async sequence.
skie = "0.10.14"
```
```toml src-kmp/gradle/libs.versions.toml theme={null}
skie = { id = "co.touchlab.skie", version.ref = "skie" }
```
Register it once in the root build file with `apply false`, and include the module in the settings file:
```kotlin src-kmp/build.gradle.kts theme={null}
// The Apple framework's Swift projection, applied by :apple-umbrella.
alias(libs.plugins.skie) apply false
```
```kotlin src-kmp/settings.gradle.kts theme={null}
// The Apple boundary: one Kotlin/Native framework over the kernel and every
// feature module, consumed by the iOS app's shells package and by the
// boundary replay suite, both through scripts/assemble_kit.sh.
include(":apple-umbrella")
```
The module's build file declares three arm64 targets, names the framework, and exports the feature module and the kernel. `export` is what puts a dependency's declarations in the framework's headers; a module that is linked but not exported compiles, and Swift cannot name a single type from it.
```kotlin src-kmp/apple-umbrella/build.gradle.kts theme={null}
kotlin {
jvmToolchain(25)
val xcf = XCFramework("FoyerKit")
listOf(
macosArm64(),
iosArm64(),
iosSimulatorArm64(),
).forEach { target ->
target.binaries.framework {
baseName = "FoyerKit"
isStatic = true
binaryOption("bundleId", "dev.modaal.foyer.kit")
// `export` puts a dependency's declarations in the framework's headers.
// A module that is linked but not exported compiles, and Swift cannot
// name a single type from it. One line per feature module.
export(dependencies.project(":subtrees:splash:logic"))
export(libs.duet.kernel)
xcf.add(this)
}
}
sourceSets {
commonMain.dependencies {
api(project(":subtrees:splash:logic"))
api(libs.duet.kernel)
}
}
}
```
Check it with `(cd src-kmp && ./gradlew projects -q)`; the hierarchy now lists `Project ':apple-umbrella'`.
A framework over an empty `commonMain` produces no XCFramework at all, so the module needs a source file, and there is one thing that is per app rather than per feature: the replay registry the Swift side drives. It names the same four declarations the Kotlin replay runner names, one entry per feature, and exposes two calls: canonicalize any fixture JSON through the core's own writer, and open a replay session over a registered feature.
```kotlin src-kmp/apple-umbrella/src/commonMain/kotlin/dev/modaal/foyer/kit/FoyerBoundary.kt theme={null}
private val registry =
ReplayRegistry(
listOf(
ReplayFeature.entry(
"splash",
SplashState.serializer(),
SplashActionSerializer,
SplashEffectPayloadSerializer,
::splashReducer),
))
/**
* The surface the Swift replay suite drives across the framework: canonicalize
* fixture JSON through the core's own writer, and open a replay session over
* a registered feature. Swift loads the files, threads the steps and compares
* bytes; every canonical byte on both sides is produced here.
*/
object FoyerBoundary {
// `@Throws` is the error channel. A Kotlin exception that crosses
// Kotlin/Native without it terminates the process instead of surfacing as
// a Swift error, so every throwing path exported to Swift carries it.
@Throws(IllegalArgumentException::class)
fun canonicalize(rawJson: String): String = BoundaryReplay.canonicalize(rawJson)
@Throws(IllegalArgumentException::class)
fun makeSession(feature: String, initialStateJson: String): ReplaySession =
BoundaryReplay.makeSession(registry, feature, initialStateJson)
}
```
The `@Throws` annotations are a contract, not a style: a Kotlin exception that crosses Kotlin/Native without one terminates the process. Build the framework once with `(cd src-kmp && ./gradlew :apple-umbrella:assembleFoyerKitDebugXCFramework)`. The first build downloads the Kotlin/Native toolchain and compiles three targets; allow ten minutes. It ends with `BUILD SUCCESSFUL`, and `src-kmp/apple-umbrella/build/XCFrameworks/debug/FoyerKit.xcframework` exists.
SwiftPM links a prebuilt binary and has no build-graph link to Gradle. Every Swift consumer therefore points at one path that a single script writes, so `swift test` and the app build always link what was assembled last, and nothing links Gradle's per-flavor output directories directly.
```sh scripts/assemble_kit.sh theme={null}
(cd "$ROOT/src-kmp" && ./gradlew ":apple-umbrella:$TASK" --console=plain -q)
SRC="$ROOT/src-kmp/apple-umbrella/build/XCFrameworks/$FLAVOR/FoyerKit.xcframework"
DST="$ROOT/src-kmp/apple-umbrella/build/XCFrameworks/app/FoyerKit.xcframework"
mkdir -p "$(dirname "$DST")"
rsync -a --delete "$SRC/" "$DST/"
echo "assemble_kit: $FLAVOR → ${DST#"$ROOT"/}"
```
The full script also resolves `JAVA_HOME` when it is unset, because Xcode runs script phases with a minimal environment. Run `scripts/assemble_kit.sh debug`; its last line is `assemble_kit: debug → src-kmp/apple-umbrella/build/XCFrameworks/app/FoyerKit.xcframework`.
The first consumer is a test-only Swift package next to the module. Its manifest declares the binary target at the consumed path and one test target that links it. Kotlin/Native frameworks need libc++, which Swift does not autolink.
```swift src-kmp/apple-umbrella/swift-consumer/Package.swift theme={null}
.binaryTarget(
name: "FoyerKit",
path: "../build/XCFrameworks/app/FoyerKit.xcframework"
),
.testTarget(
name: "BoundaryTests",
dependencies: ["FoyerKit"],
swiftSettings: [.enableExperimentalFeature("StrictConcurrency")],
linkerSettings: [
// FoyerKit is a static Kotlin/Native framework; its runtime needs
// libc++ symbols Swift does not autolink.
.linkedLibrary("c++"),
.linkedFramework("Foundation"),
]
),
```
The harness reads a recording from `parity/fixtures/`, opens a session, and sends each step's action through it. The expected state and effects go through the core's `canonicalize` before the comparison, so both sides of every assertion come from the same writer.
```swift src-kmp/apple-umbrella/swift-consumer/Tests/BoundaryTests/BoundaryReplayHarness.swift theme={null}
var replayed = 0
for rawStep in document["steps"] as! [[String: Any]] {
let label = rawStep["label"] as? String ?? "step \(replayed)"
let result = session.step(actionJson: try compactString(rawStep["action"]!))
let expectedState = try FoyerBoundary.shared.canonicalize(
rawJson: try compactString(rawStep["expectedState"]!))
let expectedEffects = try FoyerBoundary.shared.canonicalize(
rawJson: try compactString(rawStep["expectedEffects"]!))
XCTAssertEqual(
result.stateCanonical, expectedState, "\(fixture)/\(label): state diverged",
file: file, line: line)
XCTAssertEqual(
result.effectsCanonical, expectedEffects, "\(fixture)/\(label): effects diverged",
file: file, line: line)
replayed += 1
}
return replayed
```
One test method per recording, so a recording without a row here is a recording this suite does not check:
```swift src-kmp/apple-umbrella/swift-consumer/Tests/BoundaryTests/SplashBoundaryReplayTests.swift theme={null}
private func replay(_ fixture: String) throws {
let steps = try BoundaryReplayHarness.replay(fixture: fixture) { initialState in
try FoyerBoundary.shared.makeSession(feature: "splash", initialStateJson: initialState)
}
XCTAssertGreaterThan(steps, 0, "the recording must contain steps to replay")
}
func testCeremonyCompletes() throws {
try replay("splash.ceremony-completes")
}
```
A second file, `BoundaryErrorChannelTests.swift`, pins that an unregistered feature name arrives in Swift as a catchable error carrying the Kotlin message, and that `canonicalize` orders keys. Run `(cd src-kmp/apple-umbrella/swift-consumer && swift test)`; the summary line reads `Executed 6 tests, with 0 failures`.
The app links the framework through a second package, `src-ios/Libraries/FoyerKit`. It has a target for the bridge, one shell target per feature, and one test target per shell, and it pins the Duet Swift package exactly at 0.7.0, the same release the Kotlin side resolves.
```swift src-ios/Libraries/FoyerKit/Package.swift theme={null}
.target(
name: "FoyerBridge",
dependencies: [
.target(name: "FoyerKit"),
.product(name: "DuetShells", package: "duet"),
],
swiftSettings: strictConcurrency,
linkerSettings: [
// FoyerKit is a static Kotlin/Native framework; its runtime needs
// libc++ symbols Swift does not autolink.
.linkedLibrary("c++"),
.linkedFramework("Foundation"),
]
),
.target(
name: "SplashShell",
dependencies: [
"FoyerBridge",
.target(name: "FoyerKit"),
.product(name: "DuetShells", package: "duet"),
],
swiftSettings: strictConcurrency
),
.testTarget(
name: "SplashShellTests",
dependencies: ["SplashShell"],
swiftSettings: strictConcurrency
),
```
The bridge target holds one class. The Kotlin `Store` is the runtime; `BridgedStore` is its Swift face: a `@Published` mirror of the bridged `StateFlow`, a synchronous `send`, and a `cancel()` that stops the Kotlin runtime. The class exists for one reason. A shell must observe a reduce before `send` returns, and a bridged `StateFlow` delivers asynchronously, so the mirror re-reads the Kotlin state in the same call.
```swift src-ios/Libraries/FoyerKit/Sources/FoyerBridge/BridgedStore.swift theme={null}
/// Reduces before returning: the Kotlin store reduces synchronously on
/// this thread, and the mirror re-reads `state.value` in the same call.
public func send(_ action: Action) {
sendAction(action)
let latest = stateFlow.value
if latest != state {
state = latest
}
}
/// `StoreHost` teardown: stops the collector, then the Kotlin runtime.
public func cancel() {
collector?.cancel()
collector = nil
teardownRuntime()
}
```
The class is feature-generic; every shell in the app uses it with its own state and action types.
The shell subclasses `ViewShell` from the Duet shells package. Its `bind()` runs at `activate()` and adopts two things into the shell's `StoreHost`: the store mirror, and a `StateTransitions` observation that projects each state into the view state. Adopting the mirror first means it unwinds last, after the projection, when `deactivate()` runs. The two intents are the two things the view reports.
```swift src-ios/Libraries/FoyerKit/Sources/SplashShell/SplashViewShell.swift theme={null}
public final class SplashViewShell: ViewShell {
public let viewState = SplashViewState()
public let store: SplashKitStore
public init(store: SplashKitStore) {
self.store = store
super.init()
}
override public func bind() {
// Registered first so it unwinds last: whatever this shell adopts on top
// of the store stops before the store's `cancel()` ends its effects.
// Adopting the mirror is also what makes `deactivate()` stop the Kotlin
// runtime: its `cancel()` is the store's teardown plus its scope's.
host.adopt(store)
// State to view state. `StateTransitions` replays the current value on
// subscription, so this adoption is also the initial projection.
host.adopt(
StateTransitions(state: store.$state) { [weak self] _, state in
self?.apply(state)
})
}
// MARK: - Intents (the view calls these)
public func appeared() { store.send(SplashActionAppeared.shared) }
public func ceremonyFinished() { store.send(SplashActionCeremonyFinished.shared) }
// MARK: - State to view state
private func apply(_ state: SplashState) {
viewState.isArmed = state.isArmed
}
}
```
The environment interface from Tutorial 1 arrives in Swift as an Objective-C protocol, so an `NSObject` subclass implements it: the clock is the Kotlin `LiveClock` object, and `notifyHost` forwards to a closure the host supplies. The builder wires a mount in four lines: a main-immediate scope, the Kotlin store from `makeSplashStore`, the mirror over `splashStateFlow`, and the shell.
```swift src-ios/Libraries/FoyerKit/Sources/SplashShell/SplashBuilder.swift theme={null}
final class LiveSplashEnvironment: NSObject, SplashEnvironment {
private let onDelegate: (SplashDelegateEvent) -> Void
init(onDelegate: @escaping (SplashDelegateEvent) -> Void) {
self.onDelegate = onDelegate
}
/// The wall clock, the Kotlin object. Under `runTest` the Kotlin lane
/// swaps in virtual time; the app runs on this.
var clock: KernelClock { LiveClock.shared }
func notifyHost(event: SplashDelegateEvent) {
onDelegate(event)
}
}
```
```swift src-ios/Libraries/FoyerKit/Sources/SplashShell/SplashBuilder.swift theme={null}
@MainActor
public func buildSplash(
onDelegate: @escaping (SplashDelegateEvent) -> Void
) -> SplashChild {
let scope = mainImmediateStoreScope()
let store = makeSplashStore(
environment: LiveSplashEnvironment(onDelegate: onDelegate),
scope: scope)
let bridged = SplashKitStore(
state: splashStateFlow(store: store),
send: { store.send(action: $0) },
teardown: {
store.teardown()
cancelStoreScope(scope: scope)
})
return SplashChild(shell: SplashViewShell(store: bridged))
}
```
The teardown closure is what makes `deactivate()` reach the Kotlin side: it cancels the store's effects and then the scope they ran in.
The view reads the shell's view state and calls the shell's intents. It owns the reveal animation, because the animation is presentation: the feature knows nothing about 1.6 seconds, only that a ceremony ends. `onAppear` reports `appeared()`, and the animation's completion reports `ceremonyFinished()`. The progress line appears when the state says the safety net is armed.
```swift src-ios/Libraries/FoyerKit/Sources/SplashShell/SplashView.swift theme={null}
public var body: some View {
VStack(spacing: 12) {
Text("Foyer")
.font(.system(size: 45, weight: .medium, design: .default))
Text("One core, two apps")
.font(.body)
.foregroundStyle(.secondary)
if viewState.isArmed {
ProgressView()
.progressViewStyle(.linear)
.frame(width: 120)
.padding(.top, 24)
}
}
.opacity(revealed ? 1 : 0)
.offset(y: revealed ? 0 : 16)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.onAppear {
shell.appeared()
withAnimation(.easeOut(duration: ceremonySeconds)) {
revealed = true
} completion: {
shell.ceremonyFinished()
}
}
}
```
Build the package with `(cd src-ios/Libraries/FoyerKit && swift build)`; it ends with `Build complete!`.
The shell's tests check what only the bridge can break, and nothing the recordings already pin. The first row drives the builder's wiring and asserts that `appeared()` projects back before it returns. The second builds the runtime by hand with a spy environment, so the test can see the Kotlin effect loop call back into Swift with the ceremony path. The third deactivates the shell and shows that a further action still reduces but runs no effect, because the scope is gone.
```swift src-ios/Libraries/FoyerKit/Tests/SplashShellTests/SplashViewShellSpec.swift theme={null}
func testTheBuilderComposesTheRuntime() {
let child = SplashBuilder().buildSplash(onDelegate: { _ in })
let shell = child.shell
shell.activate()
defer { shell.deactivate() }
XCTAssertFalse(shell.viewState.isArmed)
shell.appeared()
XCTAssertTrue(shell.viewState.isArmed, "the projection reads back before send returns")
}
```
```swift src-ios/Libraries/FoyerKit/Tests/SplashShellTests/SplashViewShellSpec.swift theme={null}
shell.activate()
shell.appeared()
XCTAssertTrue(kotlinState.value.isArmed)
shell.deactivate()
// Driven directly: the store object is still alive, only its scope is
// gone. A notification arriving here would mean teardown did not cross.
store.send(action: SplashActionCeremonyFinished.shared)
try? await Task.sleep(nanoseconds: 200_000_000)
XCTAssertTrue(environment.notified.isEmpty, "an effect ran after deactivate")
```
These rows run on real time, deliberately: no test dispatcher crosses the boundary, and virtual time stays with the Kotlin lane. One script, the Apple boundary lane, runs the assembly and both packages in the order SwiftPM cannot express, and fails if either package executed zero tests. Its second half, the consumer package's own tests, is what this series calls the shells lane:
```sh parity/scripts/apple-boundary-lane.sh theme={null}
echo "apple-boundary-lane: boundary replay (swift-consumer)"
run_swift_tests "boundary replay" "$ROOT/src-kmp/apple-umbrella/swift-consumer"
echo "apple-boundary-lane: Swift shells (FoyerKit)"
run_swift_tests "shells" "$ROOT/src-ios/Libraries/FoyerKit"
```
Run `parity/scripts/apple-boundary-lane.sh`; it prints `boundary replay: 6 test(s) executed`, `shells: 3 test(s) executed` and ends with `apple-boundary-lane: PASS`.
The app target has no feature code. A host object mounts the splash through the builder, receives its delegate event, tears the splash down and switches the phase; a view switches on the phase. Both notification paths call the host, and the host acts on the first only.
```swift src-ios/App/Foyer/AppHost.swift theme={null}
private func splashCompleted(_ event: SplashDelegateEvent) {
// Both completion paths notify every time; the first notification moves
// the app on and the rest arrive after the splash is gone.
guard case .splash = phase,
case .completed(let completed) = onEnum(of: event)
else { return }
splash?.shell.deactivate()
splash = nil
phase = .placeholder(completedBy: completed.path)
}
```
`onEnum(of:)` is SKIE's projection of the Kotlin sealed interface as a Swift enum. The screen renders the splash while the phase is `.splash` and the placeholder after:
```swift src-ios/App/Foyer/AppScreen.swift theme={null}
var body: some View {
switch app.phase {
case .splash:
if let splash = app.splash {
SplashView(viewState: splash.shell.viewState, shell: splash.shell)
}
case .placeholder(let completedBy):
PlaceholderView(completedBy: completedBy)
}
}
```
The scene delegate builds the host, shows the window, and activates the host after the window is visible, so the first frame renders from mounted state. `sceneDidDisconnect` calls `teardown()`, because dropping the reference alone cancels nothing on the Kotlin side. The project itself is generated from an XcodeGen spec that names the consumer package's two products and runs the assemble script as the scheme's build pre-action:
```yaml src-ios/App/xcodegen.yml theme={null}
dependencies:
- package: FoyerKit
product: FoyerBridge
- package: FoyerKit
product: SplashShell
```
```yaml src-ios/App/xcodegen.yml theme={null}
preActions:
- name: Assemble FoyerKit (Kotlin core)
script: |
KIT="$PROJECT_DIR/../../scripts/assemble_kit.sh"
[ -x "$KIT" ] || exit 0
FLAVOR=debug
if [ "$CONFIGURATION" != "Debug" ]; then FLAVOR=release; fi
"$KIT" "$FLAVOR"
settingsTarget: Foyer
```
Generate and build with `(cd src-ios/App && xcodegen generate --spec xcodegen.yml && xcodebuild build -project Foyer.xcodeproj -scheme Foyer -destination 'generic/platform=iOS Simulator' CODE_SIGNING_ALLOWED=NO ARCHS=arm64 | tail -1)`; the last line is `** BUILD SUCCEEDED **`. `ARCHS=arm64` matters: the framework has no x86\_64 slice, and a generic simulator build otherwise compiles for both.
The Android app is a Gradle module, `src-kmp/app`, on the same plane as the feature modules. It depends on the splash module directly, on the kernel, and on the Duet `shells-compose` artifact for `StoreHost` and `RetainedRoot`.
```kotlin src-kmp/app/build.gradle.kts theme={null}
implementation(project(":subtrees:splash:logic"))
implementation(libs.duet.kernel)
// StoreHost and RetainedRoot: the teardown registry and the retained
// carrier the Activity keeps the app on across rotation.
implementation(libs.duet.shells.compose)
implementation(libs.essenty.instance.keeper)
```
The builder is the Swift builder's twin, shorter because there is no boundary to cross: the live environment is a Kotlin class, and the store is the module's own `makeSplashStore`.
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/SplashBuilder.kt theme={null}
private class LiveSplashEnvironment(
private val onDelegate: (SplashDelegateEvent) -> Unit,
) : SplashEnvironment {
override val clock: KernelClock = LiveClock
override fun notifyHost(event: SplashDelegateEvent) = onDelegate(event)
}
/**
* Builds one splash mount: the live environment and the store, on the scope
* the host owns. The store's lifetime belongs to the host, which is why the
* scope is a build parameter.
*/
class SplashBuilder {
fun buildSplash(onDelegate: (SplashDelegateEvent) -> Unit, scope: CoroutineScope): SplashStore =
makeSplashStore(environment = LiveSplashEnvironment(onDelegate), scope = scope)
}
```
The host is the same object as on iOS, in Kotlin, and it is Android-free so a JVM test can drive it. It registers the store in a `StoreHost` and tears it down on the first completion:
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/AppHost.kt theme={null}
class AppHost(scope: CoroutineScope) {
/** The teardown registry: what the host builds registers here and unwinds in reverse. */
val host = StoreHost(scope)
private val mutablePhase = MutableStateFlow(AppPhase.Splash)
val phase: StateFlow = mutablePhase
/** The splash store: mounted here, torn down when the splash completes. */
var splash: SplashStore? =
host.host(SplashBuilder().buildSplash(onDelegate = ::splashCompleted, scope = scope))
private set
private fun splashCompleted(event: SplashDelegateEvent) {
// Both completion paths notify every time; the first notification moves
// the app on and the rest arrive after the splash is gone.
if (mutablePhase.value !is AppPhase.Splash) return
val completed = event as SplashDelegateEvent.Completed
splash?.teardown()
splash = null
mutablePhase.value = AppPhase.Placeholder(completedBy = completed.path)
}
/** Logical destruction only (finish, not rotation). */
fun teardown() = host.teardownAll()
}
```
On Android the composable is the shell: it collects the store's state, and its launched effect sends `Appeared`, plays the reveal, then sends `CeremonyFinished`. After a rotation the effect runs again; the reducer's arming guard makes the second `Appeared` inert, and the host ignores a second completion. The rules you recorded in [Tutorial 1](/tutorials/duet-01-first-feature#describe-the-behavior-as-a-scenario-and-record-it) are what make rotation safe here.
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/SplashScreen.kt theme={null}
@Composable
fun SplashScreen(store: SplashStore, modifier: Modifier = Modifier) {
val state by store.state.collectAsState()
val reveal = remember { Animatable(0f) }
LaunchedEffect(Unit) {
store.send(SplashAction.Appeared)
reveal.animateTo(1f, tween(CEREMONY_MILLIS, easing = FastOutSlowInEasing))
store.send(SplashAction.CeremonyFinished)
}
```
The Activity keeps the host on a retained scope, so rotation recreates the Activity and `getOrCreate` hands back the same host with its running store; finishing the Activity is the one teardown.
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/MainActivity.kt theme={null}
retained =
instanceKeeper().getOrCreate {
RetainedRoot(Dispatchers.Main.immediate, AppHost::teardown) { scope -> AppHost(scope) }
}
setContent { AppRoot(retained.component) }
```
`AppRoot` collects the phase and shows `SplashScreen` or the placeholder. Include the module in the settings file (`include(":app")`), then build with `(cd src-kmp && ./gradlew :app:assembleDebug)`; it ends with `BUILD SUCCESSFUL`.
Because the host is Android-free, its test runs on the JVM with the store on the test scope, so the safety net waits on virtual time. The first test drives the ceremony path and then checks that the torn-down store's net never fires; the second advances the clock past the duration instead.
```kotlin src-kmp/app/src/test/kotlin/dev/modaal/foyer/app/AppHostTest.kt theme={null}
@Test
fun theCeremonyPathLandsOnThePlaceholderAndTearsTheSplashDown() = runTest {
val app = AppHost(backgroundScope)
val splash = assertNotNull(app.splash)
assertEquals(AppPhase.Splash, app.phase.value)
splash.send(SplashAction.Appeared)
splash.send(SplashAction.CeremonyFinished)
runCurrent()
assertEquals(AppPhase.Placeholder(SplashCompletionPath.Ceremony), app.phase.value)
assertNull(app.splash, "the completed splash is torn down")
// The torn-down store's net never fires: past the deadline, nothing changes.
advanceTimeBy(SplashConfig.SAFETY_NET_MILLIS)
runCurrent()
assertEquals(AppPhase.Placeholder(SplashCompletionPath.Ceremony), app.phase.value)
app.teardown()
}
```
Run `(cd src-kmp && ./gradlew :app:testDebugUnitTest)`; it ends with `BUILD SUCCESSFUL` and the report under `src-kmp/app/build/reports/tests/testDebugUnitTest/` lists two tests.
`tools/duet doctor` reads the tree's declaration file and cross-checks it against what is on disk. Declare the two app targets, paired under one name:
```json .modaal/project.json theme={null}
"targets": {
"Foyer": {
"platform": "iOS",
"architecture": "duet",
"template": "duet-kmp",
"pair": "Foyer",
"deviceTypes": ["iPhone"],
"iphoneOrientations": ["UIInterfaceOrientationPortrait"]
},
"FoyerAndroid": {
"platform": "Android",
"architecture": "duet",
"template": "duet-kmp",
"pair": "Foyer",
"androidOrientations": ["Portrait"]
}
},
```
Run `tools/duet doctor`; it ends with `duet doctor: PASS — 2 target declaration(s), 10 Swift source file(s) scanned`. Then run the apps. On iOS, open `src-ios/App/Foyer.xcodeproj` and run the `Foyer` scheme on an iPhone simulator; the pre-action assembles the core first. On Android, start an emulator and run `(cd src-kmp && ./gradlew :app:installDebug)`, then open Foyer from the launcher. Both apps reveal the name over 1.6 seconds and then show the placeholder with the line `Splash completed by the ceremony.`
To see the other path, set a breakpoint in the reveal's completion on either platform and wait three seconds; the placeholder then reads `Splash completed by the safety net.`
## What you now have
* One framework, `FoyerKit`, assembled from the Kotlin core by `scripts/assemble_kit.sh` and linked by two Swift packages.
* The four recordings replayed across the Swift boundary, plus two error-channel tests, in `src-kmp/apple-umbrella/swift-consumer`.
* A Swift shell with its builder, view and three tests in `src-ios/Libraries/FoyerKit`, and a Compose screen with its builder, host and two tests in `src-kmp/app`.
* Two apps that play the splash from the same store and land on the same placeholder.
* A green `parity/scripts/apple-boundary-lane.sh`, a green `tools/duet verify`, and a doctor report with two targets.
The feature module did not change by one line between Tutorial 1 and here, and that is the point of the series.
## Exercise: finish the mount-bracket test
`tutorial2-start` carries `Tutorial2ExerciseMountBracketTest` in the splash module's `jvmTest`. Its given is written: one test store, armed, then torn down. Finish it so it pins that a second store over the same environment arms a fresh net, and that after advancing the clock past the duration the environment records exactly one `Completed(SafetyNet)`, the second store's. The first store's net was cancelled at teardown, and `first.finish()` fails on any action that arrived there. Run `tools/duet verify`; the Kotlin lane reports 10 tests with no failure and the run ends with `duet verify: PASS`. `tutorial2-complete` carries the finished test as `SplashMountBracketTest`.
## Common questions
Because a shell must observe a reduce before `send` returns, and every delivery of a bridged `StateFlow` crosses a continuation hop. `BridgedStore.send` re-reads the Kotlin state synchronously after forwarding the action, which is the ordering `StateTransitions` and the other shell helpers assume. Effect-fed actions, such as the safety net's tick, still arrive through the collector one hop later.
The Kotlin runtime does, on a later collection after the last Swift reference is released. That is why `BridgedStore.cancel()` stops the Kotlin store explicitly: a store whose effects are still running is never freed. It is also why the collector task captures `[weak self]`: a reference cycle that runs through both Swift and Kotlin objects is never collected, and `leaks` does not report it. [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc#why-does-a-cycle-across-the-boundary-leak-and-how-do-you-avoid-it) has the full account.
A static Kotlin/Native framework embeds the Kotlin runtime. Two frameworks in one app carry it twice and can disagree about shared types at the boundary. The module boundaries between features stay enforced by Gradle; the Apple side sees one binary.
`StoreHost` tears down in reverse registration order. Adopting the store first means the projection stops before the store's `cancel()` ends its effects, so no observation fires into a shell that is half torn down. On Android the same registry is `StoreHost` in `AppHost`, and `RetainedRoot` calls its `teardownAll` on logical destruction.
The reveal is presentation; each platform owns its animation and may change it without touching a recording. The safety net is behavior: it decides when the host is told, so it lives in the reducer, and its duration is in every recording. The Swift spec and the Kotlin test-clock suite both pin that the net still fires when no `CeremonyFinished` arrives.
No test dispatcher crosses the boundary, so the Swift side cannot advance the Kotlin store's clock. The rows that need to wait poll in 20 ms slices against a ceiling, and the teardown row waits 200 ms for an effect that must not arrive. Timing behavior is pinned on the Kotlin lane, where virtual time is available.
In the `AppHost` that `RetainedRoot` keeps in the Activity's `InstanceKeeper`. Rotation recreates the Activity and the composition, and `getOrCreate` hands the same host back with its store still running; the composable's effect sends `Appeared` again, which the arming guard makes inert. [Tutorial 5](/tutorials/duet-05-navigation-as-state#gather-the-route-spine-and-restore-from-it) adds process-death restore, which this mechanism does not cover.
## Sources and further reading
* [The Duet framework repository](https://github.com/modaal-agent/duet) — the `DuetShells` package this page's shell subclasses (`ViewShell`, `StoreHost`, `StateTransitions`), the Kotlin `shells-compose` artifact (`StoreHost`, `RetainedRoot`), and the kernel's `mainImmediateStoreScope` runtime seam.
* [The duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials) — `tutorial2-start` and `tutorial2-complete`, and the checks CI runs on them.
* [SKIE](https://skie.touchlab.co/) — the Swift projection of the Kotlin/Native framework: sealed hierarchies as enums, `onEnum(of:)`, and `StateFlow` as an async sequence.
* [Kotlin/Native GC and Swift ARC](/articles/kotlin-native-gc-swift-arc) — what the iPhone app links, the collector's pauses, and who frees what across the boundary this tutorial builds.
* [Kotlin Multiplatform: build final native binaries](https://kotlinlang.org/docs/multiplatform-build-native-binaries.html) — `binaries.framework`, `export`, and `XCFramework`.
* [Essenty](https://github.com/arkivanov/Essenty) — `InstanceKeeper`, the retained carrier under `RetainedRoot`.
* [XcodeGen](https://github.com/yonaskolb/XcodeGen) — the project spec format `src-ios/App/xcodegen.yml` uses.
* [The Duet glossary](/articles/duet-glossary) — [shell](/articles/duet-glossary#shell), [host](/articles/duet-glossary#host), [mount](/articles/duet-glossary#mount), [Store](/articles/duet-glossary#store) and [the Swift and Kotlin lanes](/articles/duet-glossary#swift-and-kotlin-lanes).
## Read next
The splash feature both apps on this page mount: state, actions, effects and the recordings.
The root, the sign-in gate and the profile tree: children mounted from state, delegate events received as parent actions, and chain recordings pinning each seam.
The nine tutorials, the app they build, the prerequisites and the versions they are verified against.
# Duet Tutorial 3: Composing Features
Source: https://docs.modaal.dev/tutorials/duet-03-composing-features
Write a feature tree in Kotlin, mount children from state in SwiftUI and Compose, receive delegate events as parent actions, and pin each seam with a chain.
In this tutorial you grow the splash from [Tutorial 2](/tutorials/duet-02-two-apps) into a tree of eight features. A `root` level mounts exactly one child from its phase: the splash, then a sign-in gate, then `main` with a home tab and a profile tab; the profile tab mounts an account screen, which mounts a name editor. Every arrow up the tree is a child's delegate event, received by the parent as one of its own actions; every arrow down is a mount decided by state. You write the composition triple, a Dependency, a Component and a Builder, at each level on both platforms, put four mock services behind four ports, and record the seams as chains so `tools/duet verify` fails when a shell forwards the wrong event. This is the third page of the [nine-tutorial series](/tutorials/duet).
**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 [Modaal](https://modaal.dev), 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 Modaal](/articles/new-project) when you would rather skip the setup.
## What will you build?
The tree below, in Kotlin, with a SwiftUI app and a Compose app mounting it from the same core. Each box is a feature module with its own state, actions, reducer and recordings; the solid arrows are mounts, the dashed ones are delegate events climbing back up.
```mermaid theme={null}
flowchart TB
root["root: phase, auth"] --> splash["splash"]
root --> signin["signin"]
root --> main["main: activeTab"]
main --> home["home"]
main --> profile["profile"]
profile --> account["account"]
account --> editname["editname"]
splash -. Completed .-> root
signin -. Completed .-> root
main -. SignOutRequested .-> root
profile -. SignOutRequested .-> main
account -. "NameChanged, SignOutRequested, Closed" .-> profile
editname -. "Saved, Closed" .-> account
```
The logic reaches the outside world through four ports: auth, purchases, items and account. In this tutorial each port is implemented by a mock service, a class with canned data written twice, once in Swift and once in Kotlin, as product sources of each app. [Tutorial 4](/tutorials/duet-04-workers#delete-the-mock-services-one-for-one) replaces them one for one with the on-device backend. Expect about three hours; the Swift half is the longer one.
You will have at the end:
* Eight feature modules under `src-kmp/subtrees/`, seven of them new, and a `ports` module, with 28 recordings under `parity/fixtures/`.
* Four chain recordings, `chain-root-splash`, `chain-root-signin`, `chain-main-signout` and `chain-profile-editname`, each pinning a seam between two features.
* Generated test doubles on both sides: `EnvironmentMock` classes from the KSP processor on the JVM, and `DependencyMock` classes and `Component` forwarders from `tools/duet mocks` in Swift.
* The composition triple at every level of the tree on both platforms, and a passing composition test on each: `RootFlowTest` on the JVM and `RootCompositionSpec` in the Swift shells lane.
* Both apps walking the same tree: splash, sign-in, the tabs, the account screen and the name editor.
## Where do you start?
Open `tutorial3-start` from the [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials). It is Tutorial 2'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. The toolchain is Tutorial 2's: Xcode 26.6, a JDK 25, XcodeGen and the Android SDK with platform 36. Run the checks once before you edit anything:
```sh theme={null}
tools/duet verify
```
The Kotlin lane reports 11 tests with one failure, `Tutorial3ExerciseEditNameChainTest`; 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 steps
A port is an interface the logic calls and each app implements. The four live in one common-code module, `src-kmp/ports`, together with the value types they carry. Every operation starts the work and returns; the result comes back through a callback that fires exactly once. That shape is deliberate: a Swift class can implement a Kotlin interface across the framework boundary only when its members are plain functions, and a `suspend` member cannot be implemented from Swift.
```kotlin src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Ports.kt theme={null}
interface AuthPort {
fun signIn(provider: SignInProvider, onOutcome: (SignInOutcome) -> Unit)
fun signOut(onDone: () -> Unit)
}
interface PurchasesPort {
fun plans(onPlans: (List) -> Unit)
fun purchase(plan: Plan, onOutcome: (PurchaseOutcome) -> Unit)
}
interface ItemsPort {
fun items(onItems: (List- ) -> Unit)
}
interface AccountPort {
fun saveDisplayName(name: String, onSaved: () -> Unit)
}
```
The value types above them, `SignInProvider`, `SignInOutcome`, `Plan`, `PlanOffer`, `PurchaseOutcome` and `Item`, are `@Serializable` with the canonical sum serializers from Tutorial 1, because they appear inside feature state and actions and so inside the recordings. A one-line helper turns the callback shape back into a suspending call for the effect handlers:
```kotlin src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Callbacks.kt theme={null}
suspend fun awaitCallback(start: (onResult: (T) -> Unit) -> Unit): T =
suspendCancellableCoroutine { continuation -> start { continuation.resume(it) } }
```
Include the module in the settings file and check with `(cd src-kmp && ./gradlew :ports:compileKotlinJvm -q)`.
The root is the app's spine. Its state is a phase that names exactly one child, an auth snapshot, and a latch for a splash that finishes before the session is known. Its actions are its children's delegate events, one case per child, plus the host's `AuthChanged` report.
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
/** Which child the root mounts. Tutorial 5 adds `Onboarding`. */
@Serializable(with = RootPhaseSerializer::class)
sealed interface RootPhase {
@Serializable @SerialName("splash") data object Splash : RootPhase
@Serializable @SerialName("signIn") data object SignIn : RootPhase
@Serializable @SerialName("main") data object Main : RootPhase
}
/** What the root knows about the session. Seeded by the host at mount. */
@Serializable(with = AuthSnapshotSerializer::class)
sealed interface AuthSnapshot {
@Serializable @SerialName("unknown") data object Unknown : AuthSnapshot
@Serializable @SerialName("signedOut") data object SignedOut : AuthSnapshot
@Serializable @SerialName("signedIn") data class SignedIn(val displayName: String) : AuthSnapshot
}
@Serializable
data class RootState(
val phase: RootPhase = RootPhase.Splash,
val auth: AuthSnapshot = AuthSnapshot.Unknown,
/** The splash finished before auth was known; the phase moves on `AuthChanged`. */
val awaitingAuth: Boolean = false,
)
```
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
sealed interface RootAction {
/** The splash's delegate events, received as this level's actions. */
@Serializable @SerialName("splash") data class Splash(val event: SplashDelegateEvent) : RootAction
/** The sign-in gate's delegate events. */
@Serializable @SerialName("signIn") data class SignIn(val event: SignInDelegateEvent) : RootAction
/** The main level's delegate events. */
@Serializable @SerialName("main") data class Main(val event: MainDelegateEvent) : RootAction
/** Host report: the session changed. Tutorial 4's session worker sends this. */
@Serializable @SerialName("authChanged") data class AuthChanged(val auth: AuthSnapshot) : RootAction
}
```
The reducer routes. A finished splash goes to the gate or straight to main by the snapshot, or sets the latch when the snapshot is still unknown; the gate's completion signs the session in; a `SignOutRequested` climbing from anywhere under main raises the gate again. A `Completed` that arrives after the splash phase is inert, because both splash paths notify and only the first one may move the app.
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
fun rootReducer(state: RootState, action: RootAction): Reduced =
when (action) {
is RootAction.Splash ->
when {
state.phase != RootPhase.Splash -> Reduced(state)
state.auth == AuthSnapshot.Unknown -> Reduced(state.copy(awaitingAuth = true))
else -> Reduced(state.copy(phase = phaseAfterSplash(state.auth)))
}
is RootAction.AuthChanged -> {
val next = state.copy(auth = action.auth)
if (next.awaitingAuth && action.auth != AuthSnapshot.Unknown) {
Reduced(next.copy(phase = phaseAfterSplash(action.auth), awaitingAuth = false))
} else {
Reduced(next)
}
}
is RootAction.SignIn ->
when (val event = action.event) {
is SignInDelegateEvent.Completed ->
if (state.phase != RootPhase.SignIn) {
Reduced(state)
} else {
Reduced(
state.copy(phase = RootPhase.Main, auth = AuthSnapshot.SignedIn(event.displayName)))
}
}
is RootAction.Main ->
when (action.event) {
MainDelegateEvent.SignOutRequested ->
Reduced(state.copy(phase = RootPhase.SignIn, auth = AuthSnapshot.SignedOut))
}
}
/** Where a finished splash goes: the gate when signed out, main when signed in. */
private fun phaseAfterSplash(auth: AuthSnapshot): RootPhase =
if (auth is AuthSnapshot.SignedIn) RootPhase.Main else RootPhase.SignIn
```
The root has no effects yet, so `RootEffectPayload` is a sealed interface with no cases and the handler returns an empty flow; the type exists so the root has the kernel's full shape and replays like every other feature. The module depends on the three children it mounts, `splash`, `signin` and `main`, because their delegate event types are its action payloads. Its scenario has five branches, one per row of the reducer; `tools/duet record --feature root` writes them.
The gate is one leaf written in full here; the other five, `main`, `home`, `profile`, `account` and `editname`, follow the same shape and are in the tree. The state holds an in-flight latch, the last failure and the provider in flight; the actions are the shell's `ContinueTapped` and the environment's `SignInFinished`.
```kotlin src-kmp/subtrees/signin/logic/src/commonMain/kotlin/dev/modaal/foyer/signin/SignInFeature.kt theme={null}
data class SignInState(
/** The in-flight latch: one sign-in at a time. */
val isSigningIn: Boolean = false,
/** The last failure to show, cleared by the next attempt. */
val failure: String? = null,
/** The provider in flight, so a nameless outcome still gets a display name. */
val pending: SignInProvider? = null,
)
```
```kotlin src-kmp/subtrees/signin/logic/src/commonMain/kotlin/dev/modaal/foyer/signin/SignInFeature.kt theme={null}
sealed interface SignInAction {
/** Shell report: the user chose a provider and tapped Continue. */
@Serializable
@SerialName("continueTapped")
data class ContinueTapped(val provider: SignInProvider) : SignInAction
/** Environment report: the auth port answered. */
@Serializable
@SerialName("signInFinished")
data class SignInFinished(val outcome: SignInOutcome) : SignInAction
}
```
The delegate event carries the display name the root will show, and the delegate effect's serial name is `notifyListener`. That name is a convention of the chain runner: at a hop it looks for the previous step's `notifyListener` payload and decodes the delegate out of it. The splash from [Tutorial 1](/tutorials/duet-01-first-feature#register-the-serializers) renamed its serial name to match in this tree; its Kotlin class and environment method keep their names.
```kotlin src-kmp/subtrees/signin/logic/src/commonMain/kotlin/dev/modaal/foyer/signin/SignInFeature.kt theme={null}
/** What the gate tells its host: who signed in. */
@Serializable(with = SignInDelegateEventSerializer::class)
sealed interface SignInDelegateEvent {
@Serializable
@SerialName("completed")
data class Completed(val displayName: String) : SignInDelegateEvent
}
// MARK: - Effect payloads
@Serializable(with = SignInEffectPayloadSerializer::class)
sealed interface SignInEffectPayload {
/** Ask the auth port to sign in; the answer re-enters as `SignInFinished`. */
@Serializable @SerialName("signIn") data class SignIn(val provider: SignInProvider) : SignInEffectPayload
/**
* Hand a delegate event to the host. The serial name `notifyListener` is
* the one the chain runner looks for when a hop crosses this seam.
*/
@Serializable
@SerialName("notifyListener")
data class NotifyHost(val event: SignInDelegateEvent) : SignInEffectPayload
}
```
An empty address never reaches the port, a second tap while one sign-in is in flight is inert, and a success completes the gate with a name: the account's saved one when the outcome carries it, and otherwise the email's local part or `Guest`.
```kotlin src-kmp/subtrees/signin/logic/src/commonMain/kotlin/dev/modaal/foyer/signin/SignInFeature.kt theme={null}
fun signInReducer(
state: SignInState,
action: SignInAction,
): Reduced =
when (action) {
is SignInAction.ContinueTapped ->
when {
state.isSigningIn -> Reduced(state)
action.provider.isEmptyAddress ->
Reduced(state.copy(failure = SignInMessages.EMPTY_ADDRESS))
else ->
Reduced(
state.copy(isSigningIn = true, failure = null, pending = action.provider),
listOf(Effect.Run(SignInEffectPayload.SignIn(action.provider))))
}
is SignInAction.SignInFinished ->
when (val outcome = action.outcome) {
is SignInOutcome.SignedIn ->
Reduced(
state.copy(isSigningIn = false, pending = null),
listOf(
Effect.Run(
SignInEffectPayload.NotifyHost(
SignInDelegateEvent.Completed(
outcome.displayName ?: defaultDisplayName(state.pending))))))
is SignInOutcome.Failed ->
Reduced(state.copy(isSigningIn = false, pending = null, failure = outcome.reason))
}
}
private val SignInProvider.isEmptyAddress: Boolean
get() = this is SignInProvider.Email && address.isBlank()
/** The name the app shows when the account has none saved. */
fun defaultDisplayName(provider: SignInProvider?): String =
when (provider) {
is SignInProvider.Email -> provider.address.substringBefore('@')
SignInProvider.Guest, null -> "Guest"
}
```
The environment is the feature's own seam, narrower than the auth port behind it; the effect handler waits on the port's one callback and turns it into an action.
```kotlin src-kmp/subtrees/signin/logic/src/commonMain/kotlin/dev/modaal/foyer/signin/SignInEnvironment.kt theme={null}
interface SignInEnvironment {
/** Start a sign-in; `onOutcome` fires once with the port's answer. */
fun signIn(provider: SignInProvider, onOutcome: (SignInOutcome) -> Unit)
/** Hand a delegate event to the host. */
fun notifyHost(event: SignInDelegateEvent)
}
```
```kotlin src-kmp/subtrees/signin/logic/src/commonMain/kotlin/dev/modaal/foyer/signin/SignInRuntime.kt theme={null}
fun signInEffectHandler(
environment: SignInEnvironment,
): (SignInEffectPayload) -> Flow = { payload ->
flow {
when (payload) {
is SignInEffectPayload.SignIn -> {
val outcome =
awaitCallback { onOutcome ->
environment.signIn(payload.provider, onOutcome)
}
emit(SignInAction.SignInFinished(outcome))
}
is SignInEffectPayload.NotifyHost -> environment.notifyHost(payload.event)
}
}
```
Write the serializers file as in Tutorial 1, the scenario with its four branches, and the golden test with one row per branch. The email branch pins the latch and the derived name:
```kotlin src-kmp/subtrees/signin/logic/src/jvmTest/kotlin/dev/modaal/foyer/signin/SignInScenarioTest.kt theme={null}
branch("email signs in") {
whenAction("continue with an email", SignInAction.ContinueTapped(ann))
then("signing in, the provider held") { it.isSigningIn && it.pending == ann }
thenEffects("exactly the sign-in call") {
it == effectsOf(Effect.Run(SignInEffectPayload.SignIn(ann)))
}
whenAction("a second tap while in flight", SignInAction.ContinueTapped(ann))
thenEffects("nothing: one sign-in at a time") { it.isEmpty() }
whenAction(
"the port signs the account in with no saved name",
SignInAction.SignInFinished(SignInOutcome.SignedIn(displayName = null)))
then("the latch is released") { !it.isSigningIn && it.pending == null }
thenEffects("the host hears the local part of the address") {
it == completed("ann")
}
}
```
Add the manifest row and `parity/feature-specs/signin.md`, then `tools/duet record --feature signin`. Repeat for the other five leaves; when all seven rows are in, `tools/duet lint` reports 8 features and 28 fixtures.
[Tutorial 1](/tutorials/duet-01-first-feature#pin-the-safety-nets-timing-on-a-test-clock) wrote its environment double by hand. From here the family's KSP processor generates one per environment interface at test compilation, into `build/generated/ksp`, so nothing is committed and a member added to the interface fails the next test compile. Wire it in each module that has an environment worth driving:
```kotlin src-kmp/subtrees/signin/logic/build.gradle.kts theme={null}
dependencies {
"kspJvmTest"(libs.mocks.processor)
}
ksp {
arg("kspMocksTargets", "dev.modaal.foyer.signin.SignInEnvironment")
}
```
The generated `SignInEnvironmentMock` records every call and is seeded through one handler per method. The test-store suite drives the effect handler through it: the mock's callback re-enters as `SignInFinished`, and the delegate reaches the sink.
```kotlin src-kmp/subtrees/signin/logic/src/jvmTest/kotlin/dev/modaal/foyer/signin/SignInTestStoreTest.kt theme={null}
fun thePortsAnswerReentersAsAnAction() = runTest {
val environment = SignInEnvironmentMock()
environment.signInHandler = { _, onOutcome -> onOutcome(SignInOutcome.SignedIn("Ann")) }
val store =
TestStore(
initialState = SignInState(),
reducer = ::signInReducer,
handler = signInEffectHandler(environment),
scope = this,
)
val ann = SignInProvider.Email("ann@example.com")
store.send(SignInAction.ContinueTapped(ann)) { it.copy(isSigningIn = true, pending = ann) }
store.expectEffects(listOf(Effect.Run(SignInEffectPayload.SignIn(ann))))
runCurrent()
store.receive(SignInAction.SignInFinished(SignInOutcome.SignedIn("Ann"))) {
it.copy(isSigningIn = false, pending = null)
}
store.expectEffects(
listOf(Effect.Run(SignInEffectPayload.NotifyHost(SignInDelegateEvent.Completed("Ann")))))
runCurrent()
store.finish()
assertEquals(listOf(ann), environment.signInArgs)
assertEquals(
listOf(SignInDelegateEvent.Completed("Ann")),
environment.notifyHostArgs)
}
```
Run `(cd src-kmp && ./gradlew :subtrees:signin:logic:jvmTest -q)`; the module reports 7 tests passing.
A leaf recording pins one reducer. A chain recording pins the seam between two: the delegate one feature emits, received as the next feature's action. Each `hop` states the forwarding the parent's shell performs in production; the recording marks the emitting step, and `verify` re-derives the mapping from the replayed payload, so an edited seam fails as structure drift. Node handles carry each node's starting state, and the scenario names its fixture explicitly, because the toolchain finds the test by that quoted name.
```kotlin src-kmp/subtrees/root/logic/src/jvmTest/kotlin/dev/modaal/foyer/root/RootChainsTest.kt theme={null}
private val root =
ChainNode(
"root",
RootState(auth = AuthSnapshot.SignedOut),
RootState.serializer(),
RootActionSerializer,
RootEffectPayloadSerializer,
::rootReducer)
private val splash =
ChainNode(
"splash",
SplashState(isArmed = true),
SplashState.serializer(),
SplashActionSerializer,
SplashEffectPayloadSerializer,
::splashReducer)
@Test
fun theSplashSeam() {
val chain =
chainScenario(
chain = "root-splash",
fixture = "chain-root-splash",
description =
"The splash's Completed crosses into the root as Splash(event); a " +
"signed-out root raises the gate.",
source =
"src-kmp/subtrees/root/logic/src/jvmTest/kotlin/" +
"dev/modaal/foyer/root/RootChainsTest.kt",
) {
whenAction(splash, "the ceremony ends", SplashAction.CeremonyFinished)
thenEffects(splash, "exactly the Completed delegate, by the ceremony path") {
it ==
effectsOf(
Effect.Run(
SplashEffectPayload.NotifyHost(
SplashDelegateEvent.Completed(SplashCompletionPath.Ceremony))))
}
hop(
"the splash's delegate is the root's action",
from = splash,
to = root,
delegateSerializer = SplashDelegateEventSerializer,
) { event ->
RootAction.Splash(event)
}
then(root, "the gate is up") { it.phase == RootPhase.SignIn }
}
ChainScenarioRunner.verifyOrRecord(chain)
}
```
The same file records `chain-root-signin` and `chain-main-signout`, the sign-out climbing from `account` through `profile` and `main` to the root in three hops. They live in the root module's tests because the chain's last node is the root and the module already depends on every participant. Declare the fixtures in the manifest:
```yaml parity/manifest.yaml theme={null}
chains:
- chain-root-splash
- chain-root-signin
- chain-main-signout
- chain-profile-editname
```
Record them with `tools/duet record --chain chain-root-splash` and the two others; each participating feature's spec must mention the chain by name, which `verify` checks. `TUTORIAL_SKIP_STUBS=1 tools/duet verify` now reports 31 fixtures and ends with `duet verify: PASS`.
Each port gets a mock service per platform: a class with canned data, answering inside the call, living in the app's product sources. The two `MockAuth` classes duplicate their rules on purpose. A shared mock-data module would outlive its content, and the mocks are deleted in Tutorial 4; drift between them fails no check because behavior is pinned by the recordings, not by the data.
```swift src-ios/Libraries/FoyerKit/Sources/FoyerServices/MockAuth.swift theme={null}
public final class MockAuth: NSObject, AuthPort {
public override init() {}
public func signIn(
provider: SignInProvider, onOutcome: @escaping (SignInOutcome) -> Void
) {
switch onEnum(of: provider) {
case .email(let email):
if email.address.trimmingCharacters(in: .whitespaces).isEmpty {
onOutcome(SignInOutcomeFailed(reason: "Enter an email address."))
} else {
onOutcome(SignInOutcomeSignedIn(displayName: nil))
}
case .guest:
onOutcome(SignInOutcomeSignedIn(displayName: nil))
}
}
public func signOut(onDone: @escaping () -> Void) {
onDone()
}
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/services/MockAuth.kt theme={null}
class MockAuth : AuthPort {
override fun signIn(provider: SignInProvider, onOutcome: (SignInOutcome) -> Unit) {
onOutcome(
when (provider) {
is SignInProvider.Email ->
if (provider.address.isBlank()) SignInOutcome.Failed("Enter an email address.")
else SignInOutcome.SignedIn(displayName = null)
SignInProvider.Guest -> SignInOutcome.SignedIn(displayName = null)
})
}
override fun signOut(onDone: () -> Unit) = onDone()
}
```
The Swift mocks live in a `FoyerServices` target of the consumer package and conform to the port protocols the framework exports, which is why the umbrella module now exports `:ports` alongside the feature modules. `MockItems` carries twelve rows, `MockAccount` holds the saved name in memory, and `MockPurchases` answers two plans that nothing reads until Tutorial 4.
Every level gets the same three parts in one file named for the Builder. The Dependency names exactly what the level consumes from its parent; the Component forwards it in one `by` clause, owns what is scoped to the level, and assembles the environment from its own members; the Builder constructs the Component once per mount and resolves nothing itself.
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/SignInBuilder.kt theme={null}
interface SignInDependency {
val auth: AuthPort
}
/**
* The Component: forwards its whole Dependency in one clause, owns what is
* scoped to this level (nothing yet), and assembles the level's environment
* from its own members.
*/
class SignInComponent(dependency: SignInDependency) : SignInDependency by dependency {
fun environment(onDelegate: (SignInDelegateEvent) -> Unit): SignInEnvironment =
object : SignInEnvironment {
override fun signIn(provider: SignInProvider, onOutcome: (SignInOutcome) -> Unit) =
auth.signIn(provider, onOutcome)
override fun notifyHost(event: SignInDelegateEvent) = onDelegate(event)
}
}
/**
* The Builder: constructs the Component once per mount, builds the store on
* the host's scope, and resolves nothing itself.
*/
class SignInBuilder(private val dependency: SignInDependency) {
fun buildSignIn(onDelegate: (SignInDelegateEvent) -> Unit, scope: CoroutineScope): SignInStore {
val component = SignInComponent(dependency)
return makeSignInStore(environment = component.environment(onDelegate), scope = scope)
}
}
```
A parent supplies a child's Dependency by conforming its own Component to it. `AccountComponent` conforms to `EditNameDependency`, `ProfileComponent` to `AccountDependency`, `MainComponent` to both tabs' Dependencies, and the root Component, which owns the four services, to `SignInDependency` and `MainDependency`. Delete a member from any Dependency and the parent's conformance stops compiling; that is what keeps the interfaces honest.
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/RootBuilder.kt theme={null}
class RootComponent(dependency: RootDependency) :
RootDependency by dependency, SignInDependency, MainDependency {
override val auth: AuthPort = MockAuth()
override val items: ItemsPort = MockItems()
override val account: AccountPort = MockAccount()
/** Not consumed yet: Tutorial 4's entitlement stream and Tutorial 5's upgrade flow read it. */
val purchases: PurchasesPort = MockPurchases()
}
```
Mounting from state is the fourth shell duty, after the three from [Tutorial 2](/tutorials/duet-02-two-apps#write-the-swift-shell), and `ChildSlot` from the shells package does it: at most one child, built when the key appears, torn down when it changes or clears. The root builder registers a slot keyed on the phase and observes the store with `StateTransitions`; each child's delegate events route to the root store as actions, so the composition holds no listener of its own.
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/RootBuilder.kt theme={null}
val child =
host.adopt(
ChildSlot(
build = { phase ->
when (phase) {
RootPhase.Splash ->
RootChildMount.Splash(
SplashBuilder()
.buildSplash(onDelegate = { store.send(RootAction.Splash(it)) }, scope = scope))
RootPhase.SignIn ->
RootChildMount.SignIn(
SignInBuilder(component)
.buildSignIn(onDelegate = { store.send(RootAction.SignIn(it)) }, scope = scope))
RootPhase.Main ->
RootChildMount.Main(
MainBuilder(component)
.buildMain(
displayName = store.state.value.auth.displayNameOrGuest,
onDelegate = { store.send(RootAction.Main(it)) },
scope = scope,
))
}
},
teardown = {
when (it) {
is RootChildMount.Splash -> it.store.teardown()
is RootChildMount.SignIn -> it.store.teardown()
is RootChildMount.Main -> it.mount.teardown()
}
},
))
host.adopt(
StateTransitions(scope, store.state) { _, state ->
child.reconcile(state.phase)
mount.publish(child.activeHandle)
})
```
`ProfileBuilder` and `AccountBuilder` register the same kind of slot on `state.child`; `MainBuilder` builds both tabs at once, because they live for the level's lifetime. Each level's mount class exposes its store and a `StateFlow` of the child it currently holds, and the composables render whatever is there. Run `(cd src-kmp && ./gradlew :app:testDebugUnitTest -q)` after the next step's test; for now `:app:assembleDebug` compiles.
The Swift triple is the same three parts in the shell target of each feature. The Dependency carries two annotations for the mocks pipeline of the step after this one; the Component's forwarders are generated from it, so the hand-written file holds the Dependency, the live environment, the environment factory as a Component member, and the Builder.
```swift src-ios/Libraries/FoyerKit/Sources/SignInShell/SignInBuilder.swift theme={null}
/// sourcery: DuetComponent
/// sourcery: CreateMock
public protocol SignInDependency: AnyObject {
var auth: any AuthPort { get }
}
```
```swift src-ios/Libraries/FoyerKit/Sources/SignInShell/SignInBuilder.swift theme={null}
final class LiveSignInEnvironment: NSObject, SignInEnvironment {
private let auth: any AuthPort
private let onDelegate: (SignInDelegateEvent) -> Void
init(auth: any AuthPort, onDelegate: @escaping (SignInDelegateEvent) -> Void) {
self.auth = auth
self.onDelegate = onDelegate
}
func signIn(provider: SignInProvider, onOutcome: @escaping (SignInOutcome) -> Void) {
auth.signIn(provider: provider, onOutcome: onOutcome)
}
func notifyHost(event: SignInDelegateEvent) {
onDelegate(event)
}
}
```
```swift src-ios/Libraries/FoyerKit/Sources/SignInShell/SignInBuilder.swift theme={null}
public final class SignInBuilder {
private let dependency: SignInDependency
public init(dependency: SignInDependency) {
self.dependency = dependency
}
@MainActor
public func buildSignIn(
onDelegate: @escaping (SignInDelegateEvent) -> Void
) -> SignInChild {
let component = SignInComponent(dependency: dependency)
let scope = mainImmediateStoreScope()
let store = makeSignInStore(
environment: component.environment(onDelegate: onDelegate),
scope: scope)
let bridged = SignInKitStore(
state: signInStateFlow(store: store),
send: { store.send(action: $0) },
teardown: {
store.teardown()
cancelStoreScope(scope: scope)
})
return SignInChild(shell: SignInViewShell(store: bridged))
}
}
```
The root's Component is hand-written because it owns objects, and a generated Component only forwards. Its two conformances are empty extensions: the members already line up.
```swift src-ios/Libraries/FoyerKit/Sources/RootShell/RootComposition.swift theme={null}
final class RootComponent {
private let dependency: RootDependency
init(dependency: RootDependency) {
self.dependency = dependency
}
let auth: any AuthPort = MockAuth()
let items: any ItemsPort = MockItems()
let account: any AccountPort = MockAccount()
/// Not consumed yet: Tutorial 4's entitlement stream and Tutorial 5's
/// upgrade flow read it.
let purchases: any PurchasesPort = MockPurchases()
}
extension RootComponent: SignInDependency {}
extension RootComponent: MainDependency {}
```
A parent shell mounts its child inside its own `bind()`, through a `ChildSlot` adopted between the store and the projection, so teardown unwinds the projection first, the child next and the store's effects last. The root shell reads the state being applied rather than the mirror's `state`, because a `@Published` property publishes before it assigns and the mirror still holds the previous phase during the projection.
```swift src-ios/Libraries/FoyerKit/Sources/RootShell/RootViewShell.swift theme={null}
private func build(_ key: PhaseKey) -> RootChildMount {
switch key {
case .splash:
let child = mounter.mountSplash { [weak self] event in
self?.store.send(RootActionSplash(event: event))
}
child.shell.activate()
return .splash(child)
case .signIn:
let child = mounter.mountSignIn { [weak self] event in
self?.store.send(RootActionSignIn(event: event))
}
child.shell.activate()
return .signIn(child)
case .main:
let auth = (applying ?? store.state).auth
let child = mounter.mountMain(displayName: displayName(of: auth)) {
[weak self] event in
self?.store.send(RootActionMain(event: event))
}
child.shell.activate()
return .main(child)
}
}
```
```swift src-ios/Libraries/FoyerKit/Sources/RootShell/RootViewShell.swift theme={null}
private func apply(_ state: RootState) {
applying = state
defer { applying = nil }
let key: PhaseKey =
switch onEnum(of: state.phase) {
case .splash: .splash
case .signIn: .signIn
case .main: .main
}
child?.reconcile(key: key)
viewState.child = child?.activeHandle
}
```
`AccountViewShell` and `ProfileViewShell` do the same over `state.child`, with a factory closure the Builder hands them over the level's Component; `MainViewShell` activates both tabs in its `bind()` and adopts their deactivation. Add one shell target and one test target per feature to `Package.swift`, and re-point the app's XcodeGen spec at the `RootShell` product.
`tools/duet mocks` runs the family's Sourcery templates over the shell targets from rows in the manifest: one row generates a level's Component from its `DuetComponent`-annotated Dependency into the target, another generates the Dependency's test double from `CreateMock` into the test target. The bundle tag pins the release that carries the engine, the templates and the CLI together.
```yaml parity/manifest.yaml theme={null}
mocks:
bundle: 0.6.2
generators:
signin_components:
output: src-ios/Libraries/FoyerKit/Sources/SignInShell/Generated/SignInShellComponents.swift
template: Component.swifttemplate
sources:
- src-ios/Libraries/FoyerKit/Sources/SignInShell
args:
- import=FoyerKit
signin_mocks:
output: src-ios/Libraries/FoyerKit/Tests/SignInShellTests/Generated/SignInShellMocks.swift
template: Mocks.swifttemplate
sources:
- src-ios/Libraries/FoyerKit/Sources/SignInShell
args:
- import=SignInShell
- import=FoyerKit
```
Run `tools/duet mocks` once; it downloads the bundle and writes thirteen files under `Generated/`. A generated Component is one forwarder per member, in name order:
```swift src-ios/Libraries/FoyerKit/Sources/MainShell/Generated/MainShellComponents.swift theme={null}
// MARK: - MainComponent
final class MainComponent: MainDependency {
private let dependency: MainDependency
init(dependency: MainDependency) {
self.dependency = dependency
}
var account: any AccountPort { dependency.account }
var auth: any AuthPort { dependency.auth }
var items: any ItemsPort { dependency.items }
}
```
The files are build products with a fingerprint block; `tools/duet mocks --check` re-hashes the inputs and fails on a hand edit or a stale file without running the engine. The per-tree gate runs it whenever the manifest has a `mocks:` section.
The render layer on each platform is one switch over the child the root holds. Each child's view takes its own shell; nothing here decides anything.
```swift src-ios/Libraries/FoyerKit/Sources/RootShell/RootView.swift theme={null}
public struct RootView: View {
@ObservedObject var viewState: RootViewState
let shell: RootViewShell
public init(viewState: RootViewState, shell: RootViewShell) {
self.viewState = viewState
self.shell = shell
}
public var body: some View {
switch viewState.child {
case .splash(let child):
SplashView(viewState: child.shell.viewState, shell: child.shell)
case .signIn(let child):
SignInView(viewState: child.shell.viewState, shell: child.shell)
case .main(let child):
MainView(viewState: child.shell.viewState, shell: child.shell)
case nil:
EmptyView()
}
}
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/AppRoot.kt theme={null}
@Composable
fun AppRoot(root: RootMount) {
val child by root.child.collectAsState()
MaterialTheme(
colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme()
) {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
when (val current = child) {
is RootChildMount.Splash -> SplashScreen(current.store)
is RootChildMount.SignIn -> SignInScreen(current.store)
is RootChildMount.Main -> MainScreen(current.mount)
null -> Unit
}
}
}
}
}
```
The iOS app target shrinks to three files. `SceneComponent` is the root Dependency's conformer, two lines; the scene delegate builds the root over it, hosts `RootView`, and activates the root shell once the window is visible.
```swift src-ios/App/Foyer/SceneDelegate.swift theme={null}
let root = RootBuilder(dependency: SceneComponent()).buildRoot()
window.rootViewController = UIHostingController(
rootView: RootView(viewState: root.shell.viewState, shell: root.shell))
window.makeKeyAndVisible()
// Activate after the window is visible: activation runs the shell's
// bind(), which mounts the splash and starts its Kotlin effect loop.
root.shell.activate()
```
On Android, `MainActivity` keeps the root mount on the retained scope as in Tutorial 2 and renders `AppRoot` over it. Build both apps: `xcodegen generate` and an Xcode build for the iOS app, `./gradlew :app:assembleDebug` for the Android one. Both show the splash, then the gate.
Each composition root gets one walk through the whole tree over the generated dependency mock. On the JVM the walk is headless, with every store on the test scope; in Swift it crosses the boundary on real time and settles on each phase change.
```kotlin src-kmp/app/src/test/kotlin/dev/modaal/foyer/app/RootFlowTest.kt theme={null}
fun theTreeMountsFromStateAndTheSignOutClimbsToTheGate() = runTest {
val root = RootBuilder(TestRootDependency).buildRoot(backgroundScope)
runCurrent()
assertEquals(AuthSnapshot.SignedOut, root.store.state.value.auth)
val splash = assertIs(root.child.value)
// The splash completes; the root raises the gate and the splash is gone.
splash.store.send(SplashAction.Appeared)
splash.store.send(SplashAction.CeremonyFinished)
runCurrent()
assertEquals(RootPhase.SignIn, root.store.state.value.phase)
val gate = assertIs(root.child.value)
// The gate completes through the mock auth service; main mounts with the
// derived display name, both tabs built.
gate.store.send(SignInAction.ContinueTapped(SignInProvider.Email("ann@example.com")))
runCurrent()
assertEquals(AuthSnapshot.SignedIn("ann"), root.store.state.value.auth)
val main = assertIs(root.child.value).mount
assertEquals("ann", main.profile.store.state.value.displayName)
assertNull(main.profile.account.value)
// The profile tab mounts the account screen from its state.
main.profile.store.send(ProfileAction.AccountTapped)
runCurrent()
val account = assertNotNull(main.profile.account.value)
assertEquals("ann", account.store.state.value.displayName)
// The sign-out climbs four levels; the gate is up and main is torn down.
account.store.send(AccountAction.SignOutTapped)
runCurrent()
assertEquals(RootPhase.SignIn, root.store.state.value.phase)
assertEquals(AuthSnapshot.SignedOut, root.store.state.value.auth)
assertIs(root.child.value)
root.teardown()
}
```
```swift src-ios/Libraries/FoyerKit/Tests/RootShellTests/RootCompositionSpec.swift theme={null}
func testTheTreeMountsFromThePhaseAndTheSignOutClimbsToTheGate() async {
let root = RootBuilder(dependency: RootDependencyMock()).buildRoot()
let shell = root.shell
shell.activate()
defer { shell.deactivate() }
guard case .splash(let splash) = shell.viewState.child else {
return XCTFail("the splash is mounted first")
}
splash.shell.appeared()
splash.shell.ceremonyFinished()
await settle(until: isSignIn(shell.viewState.child), "the gate came up")
guard case .signIn(let gate) = shell.viewState.child else { return }
gate.shell.continueWithEmail("ann@example.com")
await settle(until: isMain(shell.viewState.child), "main came up")
guard case .main(let main) = shell.viewState.child,
let profile = main.shell.profile
else { return }
XCTAssertEqual(profile.shell.viewState.displayName, "ann")
profile.shell.accountTapped()
guard let account = profile.shell.viewState.account else {
return XCTFail("the account screen is mounted")
}
account.shell.signOutTapped()
await settle(until: isSignIn(shell.viewState.child), "the gate came back up")
}
```
Each shell target also has a spec over its own generated mock: `AccountViewShellSpec` mounts the editor from state and watches a save climb back as `NameChanged`. Run `parity/scripts/apple-boundary-lane.sh`; it assembles the framework, replays all 28 recordings across the boundary (30 tests with the two error-channel rows), and reports `shells: 14 test(s) executed`. Then `(cd src-kmp && ./gradlew :app:testDebugUnitTest -q)` for the two JVM walks. Sign in on both apps, open the Profile tab and the Account row:
## What you now have
* Eight feature modules and the `ports` module, each with its scenario, golden test and recordings; 28 leaf fixtures and four chain fixtures under `parity/fixtures/`.
* The composition triple at every level on both platforms, with `ChildSlot` mounting the root's, the profile tab's and the account screen's children from state.
* Four mock services per platform behind the four ports, in product sources, to be replaced in Tutorial 4.
* Generated doubles on both sides, and a green `tools/duet mocks --check`.
* Two composition tests walking the whole tree, and both apps walking it too. The tree is the app; the screens are its projection.
## Exercise: record the edit-name chain
`tutorial3-start` carries `Tutorial3ExerciseEditNameChainTest`, a failing placeholder in the splash module. Delete it and write `ProfileEditNameChainTest` in the profile module's `jvmTest`, where the chain's last node lives. The chain starts at the editor with a save in flight and pins two hops: `Saved("Ann B")` crossing into the account screen as `EditName(event)`, whose reducer takes the name and emits `NameChanged`, then that delegate crossing into the profile tab as `Account(event)`, whose header shows the new name and emits nothing. Add `chain-profile-editname` to the manifest's `chains:` list and to the three participating specs, then run `tools/duet record --chain chain-profile-editname`. The finished hop reads:
```kotlin src-kmp/subtrees/profile/logic/src/jvmTest/kotlin/dev/modaal/foyer/profile/ProfileEditNameChainTest.kt theme={null}
then(account, "the account screen took the name and dismissed the editor") {
it.displayName == "Ann B" && it.child == null
}
thenEffects(account, "exactly the NameChanged delegate") {
it ==
effectsOf(
Effect.Run(AccountEffectPayload.NotifyHost(AccountDelegateEvent.NameChanged("Ann B"))))
}
hop(
"the account screen's delegate is the profile tab's action",
from = account,
to = profile,
delegateSerializer = AccountDelegateEventSerializer,
) { event ->
ProfileAction.Account(event)
}
then(profile, "the header shows the new name") { it.displayName == "Ann B" }
thenEffects(profile, "nothing climbs further") { it.isEmpty() }
```
Run `tools/duet verify`; the Kotlin lane replays 32 fixtures and the run ends with `duet verify: PASS`. `tutorial3-complete` carries the finished test.
## Common questions
A hub gives every consumer every member, so every test double implements all of them, and it gives level-scoped objects no home. A per-level Dependency stays as narrow as the level's reads, its double is a constructor-seeded bag of exactly those members, and deleting a member breaks the parent's conformance at compile time. The Component is where a level's own objects live, with the level's lifetime; the Builder constructs it once per mount so nothing owned outlives the mount.
Because the Swift mock services implement the same Kotlin interfaces across the framework boundary, and a `suspend` member cannot be implemented from Swift. A plain function with a one-shot callback can, and `awaitCallback` turns it back into a suspending call inside the effect handler. Tutorial 4's Kotlin repositories keep the shape so the ports do not change when the mocks go.
Each mock is a product source of one app, native to its platform, holding its own data. A shared mock-data module would be a module that outlives its content: the mocks are deleted in Tutorial 4 as the repositories land. Drift between the two `MockAuth` classes fails no check because behavior is pinned by the recordings; the mocks only answer.
In this tree the root routes: every action it receives is a child's delegate event or the host's auth report, and every transition is a state write. `RootEffectPayload` has no cases so that the root keeps the kernel's shape and replays through the same runner as every leaf; [Tutorial 5](/tutorials/duet-05-navigation-as-state#grow-the-root-the-second-gate-and-the-deep-link) adds its first case, the deep-link forward.
A hop decodes the delegate out of the previous step's effects, and it finds the right effect by that case name. Naming it once per feature is what makes the hop mapping re-derivable at verify time from the recorded bytes rather than from a typed mapping the test author could get wrong. The Kotlin class can be called anything; `NotifyHost` stays.
One source. The gate's reducer computes it when the outcome carries no saved name, the root keeps it in the auth snapshot, and the root shell reads it at the moment main is mounted and hands it down through the Builders to the profile tab and the account screen. A name saved in the editor climbs back as `NameChanged`. From [Tutorial 4](/tutorials/duet-04-workers#write-the-workers-on-android) the session stream carries it, so every reader updates through `AuthChanged`.
## Sources and further reading
* [The Duet framework repository](https://github.com/modaal-agent/duet) — `docs/composition.md`, the Dependency, Component and Builder rule this page applies; `ChildSlot`, `ChildStores` and `StateTransitions` in the shells packages; the chain scenario dialect in `kernel-test` and `DuetTesting`; `contracts/mock-dialect-v1.md`, the member vocabulary both generated doubles share.
* [The duet-tools repository](https://github.com/modaal-agent/duet-tools) — `contracts/manifest.md` for the `chains:` and `mocks:` sections, and the `record --chain`, `mocks` and `mocks --check` verbs.
* [kotlin-ksp-mocks](https://github.com/modaal-agent/kotlin-ksp-mocks) — the KSP processor behind `kspMocksTargets`.
* [swift-sourcery-templates](https://github.com/modaal-agent/swift-sourcery-templates) — the `Component` and `Mocks` templates `tools/duet mocks` runs.
* [The duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials) — `tutorial3-start` and `tutorial3-complete`, and the checks CI runs on them.
* [The Duet glossary](/articles/duet-glossary) — [delegate](/articles/duet-glossary#delegate), [mount](/articles/duet-glossary#mount), [host](/articles/duet-glossary#host), [scenario](/articles/duet-glossary#scenario) and [fixture](/articles/duet-glossary#fixture).
## Read next
The framework boundary and the splash shells this tree grows from.
The on-device backend replacing these mock services, its two streams observed by workers adopted at mount, and the card that unlocks from a stream.
The nine tutorials, the app they build, the prerequisites and the versions they are verified against.
# Duet Tutorial 4: Workers
Source: https://docs.modaal.dev/tutorials/duet-04-workers
Replace mock services with an on-device backend, observe its streams with workers adopted at mount, test them with the harness, and unlock a card from a stream.
In this tutorial you replace the four mock services from [Tutorial 3](/tutorials/duet-03-composing-features) with Foyer's backend, four repositories written once in Kotlin and persisted on device, and you meet the worker: an object adopted at a mount that observes a stream for the mount's lifetime and reports each value into a store as an action. Two workers observe the backend's two streams, the session and the entitlement; the root projects the entitlement down to the home and profile tabs as a slice; the home's *Insights* card is locked while the entitlement is Free and unlocks when the stream says Premium, never when the purchase's own answer comes back. Workers carry no recordings. They are tested with `WorkerTester`, the harness that fails a worker whose `run()` survives its cancellation. This is the fourth page of the [nine-tutorial series](/tutorials/duet).
**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 [Modaal](https://modaal.dev), 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 Modaal](/articles/new-project) when you would rather skip the setup.
## What will you build?
The same app, with the mocks gone. Every port now has one implementation in `src-kmp/backend-local`, a Kotlin module both apps consume: `LocalAuth`, `LocalPurchases`, `LocalItems` and `LocalAccount`, persisted through one `KeyValueFile` port as a JSON document under each app's private storage. The auth port gains a `sessions` stream and the purchases port an `entitlements` stream; both are sticky, so a late subscriber sees the current value first. A session worker turns each session value into the root's `AuthChanged`, an entitlement worker turns each entitlement value into `EntitlementChanged`, and the root keeps the entitlement as the one value the paid check reads. A guest session expires after thirty minutes and the expiry raises the gate from wherever the user is. Expect about two and a half hours.
You will have at the end:
* A `backend-local` module with the four repositories, the file port with its two platform implementations, and five unit tests running the guest expiry and the purchase delay on virtual time.
* No `Mock*` service anywhere: `grep -r 'class Mock' .` prints nothing in the finished tree.
* Two workers on each platform, adopted at the root mount, with `WorkerTester` suites on both sides and a pure transform pinned once in the root module.
* Seven new recordings: two for the root, four for the home tab, one for the profile tab, 39 in all.
* Both apps showing the *Insights* card locked, the promo behind it, the card unlocking after the purchase, and the summary behind the unlocked card.
## Where do you start?
Open `tutorial4-start` from the [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials). It is Tutorial 3'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 3's toolchain. Run the checks once before you edit anything:
```sh theme={null}
tools/duet verify
```
The Kotlin lane reports one failure, `Tutorial4ExerciseSessionExpiryTest`; 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 steps
A stream is a port member of a different shape from a call: it answers "what is true now", and it keeps answering. Two value types join the ports module, and two ports gain a `StateFlow` each.
```kotlin src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Ports.kt theme={null}
sealed interface Session {
data object SignedOut : Session
/** `displayName` is the account's saved name, or the default derived from the provider. */
data class SignedIn(val displayName: String) : Session
}
```
```kotlin src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Ports.kt theme={null}
sealed interface Entitlement {
@Serializable @SerialName("free") data object Free : Entitlement
@Serializable @SerialName("premium") data class Premium(val plan: Plan) : Entitlement
}
```
```kotlin src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Ports.kt theme={null}
interface AuthPort {
/** The session, sticky: a late subscriber sees the current value first. */
val sessions: StateFlow
fun signIn(provider: SignInProvider, onOutcome: (SignInOutcome) -> Unit)
fun signOut(onDone: () -> Unit)
}
interface PurchasesPort {
/** The entitlement, sticky; the only writer of the value the paid check reads. */
val entitlements: StateFlow
fun plans(onPlans: (List) -> Unit)
fun purchase(plan: Plan, onOutcome: (PurchaseOutcome) -> Unit)
}
```
`Entitlement` is `@Serializable` with a canonical sum serializer, because it lives inside three features' state and so inside the recordings; `Session` is not, because no feature state holds it. The default display name rule, the email's local part or "Guest", moves from the sign-in module to this one, because the backend needs it too. One more file re-exposes the two streams as functions:
```kotlin src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Streams.kt theme={null}
fun sessionsFlow(auth: AuthPort): StateFlow = auth.sessions
fun entitlementsFlow(purchases: PurchasesPort): StateFlow = purchases.entitlements
```
A stream declared on an interface crosses the Apple boundary as the raw Kotlin flow type. A top-level function's return type crosses as the Swift async sequence the workers iterate, the same reason [Tutorial 2](/tutorials/duet-02-two-apps#write-the-swift-shell)'s `splashStateFlow(store:)` exists. The Kotlin workers read the members directly.
The module `src-kmp/backend-local` holds one implementation of each port. They are product code, named `Local*`: the apps run on them, the composition roots construct them, and nothing replaces them later. The backend persists through a port of its own, two plain functions, so each app hands it a file it owns and a test hands it memory:
```kotlin src-kmp/backend-local/src/commonMain/kotlin/dev/modaal/foyer/backend/KeyValueFile.kt theme={null}
interface KeyValueFile {
/** The document's text, or null when nothing was written yet. */
fun read(): String?
fun write(text: String)
}
```
`JsonFile(path)` implements it once per platform, in the module's `jvmMain` and `appleMain` source sets, through `java.io.File` and `NSString`; `MemoryFile` implements it in common code for the test suites and the composition specs. One `@Serializable` record is the whole document, and `LocalStorage` reads it once and writes it whole on every update. The auth repository owns the session stream:
```kotlin src-kmp/backend-local/src/commonMain/kotlin/dev/modaal/foyer/backend/LocalAuth.kt theme={null}
object LocalAuthConfig {
/** How long a guest session lasts. Shorten it to watch the expiry raise the gate. */
const val GUEST_SESSION_MINUTES = 30L
}
```
```kotlin src-kmp/backend-local/src/commonMain/kotlin/dev/modaal/foyer/backend/LocalAuth.kt theme={null}
override fun signIn(provider: SignInProvider, onOutcome: (SignInOutcome) -> Unit) {
if (provider is SignInProvider.Email && provider.address.isBlank()) {
onOutcome(SignInOutcome.Failed("Enter an email address."))
return
}
val saved = storage.record.displayName
val record = SessionRecord(saved ?: defaultDisplayName(provider), provider == SignInProvider.Guest)
storage.update { it.copy(session = record) }
mutableSessions.value = Session.SignedIn(record.displayName)
if (record.guest) armExpiry()
onOutcome(SignInOutcome.SignedIn(saved))
}
```
```kotlin src-kmp/backend-local/src/commonMain/kotlin/dev/modaal/foyer/backend/LocalAuth.kt theme={null}
private fun armExpiry() {
expiry?.cancel()
expiry =
scope.launch {
clock.sleep(LocalAuthConfig.GUEST_SESSION_MINUTES * 60 * 1_000_000_000L)
endSession()
}
}
private fun endSession() {
expiry?.cancel()
expiry = null
storage.update { it.copy(session = null) }
mutableSessions.value = Session.SignedOut
}
```
The expiry waits on the kernel's clock seam, `KernelClock.sleep`, in the scope the composition root hands the backend; the window restarts at each launch, because the seam carries a sleep and no wall-clock reading. The purchases repository does the same with a one-second delay, so the promo's in-flight state is visible, and it writes the entitlement stream before it answers the call:
```kotlin src-kmp/backend-local/src/commonMain/kotlin/dev/modaal/foyer/backend/LocalPurchases.kt theme={null}
override fun purchase(plan: Plan, onOutcome: (PurchaseOutcome) -> Unit) {
scope.launch {
clock.sleep(LocalPurchasesConfig.PURCHASE_MILLIS * 1_000_000L)
storage.update { it.copy(plan = plan) }
mutableEntitlements.value = Entitlement.Premium(plan)
onOutcome(PurchaseOutcome.Purchased(plan))
}
}
```
The account repository persists the saved name and hands it to the auth repository, so a live session carries the new name and every reader of the session stream sees it. One class constructs all four over one document:
```kotlin src-kmp/backend-local/src/commonMain/kotlin/dev/modaal/foyer/backend/LocalBackend.kt theme={null}
class LocalBackend(file: KeyValueFile, scope: CoroutineScope, clock: KernelClock) {
constructor(file: KeyValueFile, scope: CoroutineScope) : this(file, scope, LiveClock)
private val storage = LocalStorage(file)
private val localAuth = LocalAuth(storage, clock, scope)
val auth: AuthPort = localAuth
val purchases: PurchasesPort = LocalPurchases(storage, clock, scope)
val items: ItemsPort = LocalItems()
val account: AccountPort = LocalAccount(storage, localAuth)
}
```
The module's `jvmTest` runs the repositories on a memory file. Under `runTest`, `LiveClock` sleeps on the scheduler's virtual time, so the thirty-minute expiry is an `advanceTimeBy`:
```kotlin src-kmp/backend-local/src/jvmTest/kotlin/dev/modaal/foyer/backend/LocalAuthTest.kt theme={null}
fun aGuestSessionExpiresOnTheClockSeam() = runTest {
val backend = LocalBackend(MemoryFile(), backgroundScope, LiveClock)
backend.auth.signIn(SignInProvider.Guest) {}
assertEquals(Session.SignedIn("Guest"), backend.auth.sessions.value)
advanceTimeBy(LocalAuthConfig.GUEST_SESSION_MINUTES * 60_000 - 1)
runCurrent()
assertEquals(Session.SignedIn("Guest"), backend.auth.sessions.value)
advanceTimeBy(1)
runCurrent()
assertEquals(Session.SignedOut, backend.auth.sessions.value)
}
```
Include the module in the settings file, export it from the umbrella module so the Swift composition root can construct it, and run `(cd src-kmp && ./gradlew :backend-local:jvmTest)`.
Each mock leaves as its repository lands, and nothing else on the tree references it. Out: `src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/services/` with its four files, and the `FoyerServices` target of the consumer package with its four files. In: the root Component on each side owns one `LocalBackend` and hands its members down as the ports it satisfied with mocks before. The root's Dependency names, for the first time, something the platform supplies: the file.
```swift src-ios/Libraries/FoyerKit/Sources/RootShell/RootComposition.swift theme={null}
public protocol RootDependency: AnyObject {
var storage: any KeyValueFile { get }
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/RootBuilder.kt theme={null}
interface RootDependency {
val storage: KeyValueFile
}
```
```swift src-ios/Libraries/FoyerKit/Sources/RootShell/RootComposition.swift theme={null}
final class RootComponent {
private let dependency: RootDependency
private let backend: LocalBackend
init(dependency: RootDependency, scope: any Kotlinx_coroutines_coreCoroutineScope) {
self.dependency = dependency
backend = LocalBackend(file: dependency.storage, scope: scope)
}
var auth: any AuthPort { backend.auth }
var items: any ItemsPort { backend.items }
var account: any AccountPort { backend.account }
var purchases: any PurchasesPort { backend.purchases }
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/RootBuilder.kt theme={null}
class RootComponent(dependency: RootDependency, scope: CoroutineScope) :
RootDependency by dependency, SignInDependency, MainDependency {
private val backend = LocalBackend(storage, scope)
override val auth: AuthPort = backend.auth
override val items: ItemsPort = backend.items
override val account: AccountPort = backend.account
override val purchases: PurchasesPort = backend.purchases
}
```
Each app's conformer supplies a `JsonFile` under its private storage: the scene's `Documents` directory on iOS, the Activity's `filesDir` on Android. The host tests supply a `MemoryFile`. The main level's Dependency gains `purchases`, because the home tab now buys through it; the home's gains it too. Regenerate the Swift Components and doubles with `tools/duet mocks`, then confirm the deletion:
```sh theme={null}
grep -r 'class Mock' src-kmp src-ios
```
The command prints nothing. Every test that used a mock now builds a `LocalBackend` over a `MemoryFile` and passes the members it needs into the generated Dependency double.
The root keeps the entitlement as the one value the paid check reads, and receives two worker reports as actions. The sign-out rule gains a twin: a session ending while main is up raises the gate, which is what a guest expiry does from wherever the user is.
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
data class RootState(
val phase: RootPhase = RootPhase.Splash,
val auth: AuthSnapshot = AuthSnapshot.Unknown,
/** The splash finished before auth was known; the phase moves on `AuthChanged`. */
val awaitingAuth: Boolean = false,
/** The paid check's one value. The entitlement worker is its only writer; `home` and `profile` read it as a slice. */
val entitlement: Entitlement = Entitlement.Free,
)
```
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
/** The session worker's report: the auth port's stream emitted. */
@Serializable @SerialName("authChanged") data class AuthChanged(val auth: AuthSnapshot) : RootAction
/** The entitlement worker's report: the purchases port's stream emitted. */
@Serializable
@SerialName("entitlementChanged")
data class EntitlementChanged(val entitlement: Entitlement) : RootAction
}
```
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
is RootAction.AuthChanged -> {
val next = state.copy(auth = action.auth)
when {
next.awaitingAuth && action.auth != AuthSnapshot.Unknown ->
Reduced(next.copy(phase = phaseAfterSplash(action.auth), awaitingAuth = false))
state.phase == RootPhase.Main && action.auth == AuthSnapshot.SignedOut ->
Reduced(next.copy(phase = RootPhase.SignIn))
else -> Reduced(next)
}
}
is RootAction.EntitlementChanged -> Reduced(state.copy(entitlement = action.entitlement))
```
The transform from a session to an auth snapshot is a pure function in the root module, so the two native workers share it and one unit test pins it:
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/SessionProjection.kt theme={null}
fun authSnapshot(session: Session): AuthSnapshot =
when (session) {
Session.SignedOut -> AuthSnapshot.SignedOut
is Session.SignedIn -> AuthSnapshot.SignedIn(session.displayName)
}
```
Add `entitlement changes` to the root scenario, list `root.entitlement-changes` in the manifest and re-record the root with `tools/duet record --feature root`; the five existing leaves re-record too, because the state gained a field. The three chains through the root are unchanged, since a chain carries no state.
A worker is one method: `run()` is its whole life. The host adopts it at mount, which starts `run()` in a coroutine on the host's scope, and cancels that coroutine at teardown; `collect` returns on the cancellation, and there is no `stop()` to forget. A worker decides nothing. It reads a stream and sends.
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/workers/SessionWorker.kt theme={null}
class SessionWorker(
private val auth: AuthPort,
private val relay: Relay,
) : Working {
override suspend fun run() {
auth.sessions.collect { session -> relay.send(RootAction.AuthChanged(authSnapshot(session))) }
}
}
```
The report enters the root store through a `Relay`, the shells package's settable event funnel: the composition root creates it, hands it to the workers it builds, and points its sink at the store once the store exists. The two adoptions sit at the end of `buildRoot`, after the child slot and the projection are in place:
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/RootBuilder.kt theme={null}
val relay = Relay()
relay.sink = store::send
host.adopt(SessionWorker(component.auth, relay))
host.adopt(EntitlementWorker(component.purchases, relay))
```
The `AuthChanged(SignedOut)` seed that `buildRoot` sent in [Tutorial 3](/tutorials/duet-03-composing-features#compose-the-tree-on-android) is gone: the session stream is sticky, so the session worker's first report is the current session, and a persisted one skips the gate after the splash.
Workers carry behavioral tests through `WorkerTester` and no recordings. Parity is never gated at a worker: a worker that exists on both platforms is two native implementations, one over a `Flow` and one over an async sequence, sharing the pure transform from the step before, and the transform is where the cross-platform test concentrates. The harness is the adopt bracket, test-side: `start` on the test scope, drive the backend, read what reached the relay, `finish`.
```kotlin src-kmp/app/src/test/kotlin/dev/modaal/foyer/app/workers/SessionWorkerTest.kt theme={null}
fun theStickyStreamSeedsTheRootAndEveryChangeFollows() = runTest {
val backend = LocalBackend(MemoryFile(), backgroundScope, LiveClock)
val relay = Relay()
val reported = mutableListOf()
relay.sink = reported::add
val tester = WorkerTester(SessionWorker(backend.auth, relay))
tester.start(backgroundScope)
runCurrent()
assertEquals(listOf(RootAction.AuthChanged(AuthSnapshot.SignedOut)), reported)
backend.auth.signIn(SignInProvider.Email("ann@example.com")) {}
runCurrent()
assertEquals(RootAction.AuthChanged(AuthSnapshot.SignedIn("ann")), reported.last())
backend.account.saveDisplayName("Ann B") {}
runCurrent()
assertEquals(RootAction.AuthChanged(AuthSnapshot.SignedIn("Ann B")), reported.last())
tester.finish()
assertTrue(tester.isFinished)
}
```
`finish()` cancels the worker's coroutine, yields until `run()` returns, and fails the test if it never does. That is the leak class a `stop()` convention cannot catch, made a harness guarantee. A second test in the same file advances virtual time past the guest window and reads `AuthChanged(SignedOut)` off the relay. Add `testImplementation(libs.duet.kernel.test)` to the app module and run `(cd src-kmp && ./gradlew :app:testDebugUnitTest)`.
The Swift worker is the same shape over `for await`. It is `@MainActor`, so its state needs no lock and the report lands on the main-confined store; `Working` needs only a nonisolated `run()`, which a main-actor method witnesses. The stream is read through the ports module's `sessionsFlow`, whose return type crossed the boundary as the async sequence.
```swift src-ios/Libraries/FoyerKit/Sources/RootShell/Workers.swift theme={null}
final class SessionWorker: Working {
private let auth: any AuthPort
private let relay: Relay
init(auth: any AuthPort, relay: Relay) {
self.auth = auth
self.relay = relay
}
func run() async {
for await session in sessionsFlow(auth: auth) {
relay.send(RootActionAuthChanged(auth: authSnapshot(session: session)))
}
}
}
```
The Builder creates the relay, binds its sink to the store mirror with a weak hold, and hands the two workers to the shell:
```swift src-ios/Libraries/FoyerKit/Sources/RootShell/RootBuilder.swift theme={null}
let relay = Relay()
relay.bindSink(bridged) { store, action in store.send(action) }
let shell = RootViewShell(
store: bridged,
mounter: RootChildMounter(component: component),
workers: RootWorkers(
session: SessionWorker(auth: component.auth, relay: relay),
entitlement: EntitlementWorker(purchases: component.purchases, relay: relay)))
```
The shell adopts them in `bind()`, after the store and the projection, so the first report of each sticky stream lands on a store whose projection is already bound; the host cancels them first at teardown, in the reverse of adoption order.
```swift src-ios/Libraries/FoyerKit/Sources/RootShell/RootViewShell.swift theme={null}
// Adopted last, so the first report of each sticky stream lands on a
// store whose projection is already bound; cancelled first at teardown.
host.adopt(workers.session)
host.adopt(workers.entitlement)
```
The Swift suite, `WorkersSpec` in the root shell's test target, drives the same three rows through `WorkerTester` from the `DuetTesting` product, which the test target now depends on. Its purchase row waits a real second, because the backend sleeps on the wall clock outside `runTest`. The composition spec asserts `shell.host.liveWorkerCount == 2` while the tree is up: the host's ledger of adopted workers whose `run()` has not returned.
Two tabs read the entitlement and neither owns it. The root publishes the value as a slice, the main level projects the slice into both tabs as an action of their own, and a tab's reducer writes what it received. State travels down as a value; nothing below the root holds the root's store. On Android the slice is a `StateFlow` on the root mount and the projection is a `StateTransitions`:
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/MainBuilder.kt theme={null}
host.adopt(
StateTransitions(scope, entitlement) { _, value ->
home.send(HomeAction.EntitlementChanged(value))
profile.store.send(ProfileAction.EntitlementChanged(value))
})
```
On iOS the bridge target gains `Projected`, a slice the parent writes and the child observes; the observation is a `HostedObservation`, so the host retains it for the mount and cancels it at teardown.
```swift src-ios/Libraries/FoyerKit/Sources/FoyerBridge/Projected.swift theme={null}
public func project(_ value: Value) {
if (subject.value as AnyObject).isEqual(value) { return }
subject.send(value)
}
/// The child's read: `sink` runs with the current value now and with every
/// change after, until the returned observation is cancelled.
public func observe(_ sink: @escaping @MainActor (Value) -> Void) -> ProjectedObservation {
ProjectedObservation(
subject.sink { value in
MainActor.assumeIsolated { sink(value) }
})
}
```
```swift src-ios/Libraries/FoyerKit/Sources/MainShell/MainViewShell.swift theme={null}
// State down: the slice reaches both tabs as an action of their own.
// The first delivery is the current value, at mount.
host.adopt(
entitlement.observe { [weak self] value in
self?.home?.shell.entitlementChanged(value)
self?.profile?.shell.entitlementChanged(value)
})
```
The root shell writes the slice at the top of `apply`, before it reconciles the child, so a main level built in the same projection reads the current value. Each tab's shell gains one intent for it:
```swift src-ios/Libraries/FoyerKit/Sources/HomeShell/HomeViewShell.swift theme={null}
/// The parent's projection: the root's entitlement slice, as this tab's action.
public func entitlementChanged(_ entitlement: Entitlement) {
store.send(HomeActionEntitlementChanged(entitlement: entitlement))
}
```
The profile tab's reducer writes `entitlement` and its header gains a plan row reading "Free" or "Premium · Monthly"; the leaf `profile.plan-row-follows-the-stream` pins the write.
The home tab gains the paid surface. Its state carries the slice, what is presented over the list, and the promo's in-flight latch; the card opens the promo while Free and the summary once Premium; the promo's one button buys the monthly plan.
```kotlin src-kmp/subtrees/home/logic/src/commonMain/kotlin/dev/modaal/foyer/home/HomeFeature.kt theme={null}
data class HomeState(
val items: List- = emptyList(),
val isLoading: Boolean = false,
/** The slice the root projects down. The reducer reads it and never writes it on its own. */
val entitlement: Entitlement = Entitlement.Free,
val presented: HomePresentation? = null,
/** The in-flight latch for the promo's one-tap purchase. */
val isPurchasing: Boolean = false,
/** The last purchase failure to show, cleared by the next attempt. */
val failure: String? = null,
)
```
```kotlin src-kmp/subtrees/home/logic/src/commonMain/kotlin/dev/modaal/foyer/home/HomeFeature.kt theme={null}
HomeAction.InsightsTapped ->
Reduced(
state.copy(
presented =
if (state.entitlement is Entitlement.Premium) HomePresentation.Insights
else HomePresentation.Promo))
HomeAction.PurchaseTapped ->
if (state.isPurchasing) {
Reduced(state)
} else {
Reduced(
state.copy(isPurchasing = true, failure = null),
listOf(Effect.Run(HomeEffectPayload.Purchase(Plan.Monthly))))
}
is HomeAction.PurchaseFinished ->
when (val outcome = action.outcome) {
is PurchaseOutcome.Purchased -> Reduced(state.copy(isPurchasing = false, presented = null))
is PurchaseOutcome.Failed ->
Reduced(state.copy(isPurchasing = false, failure = outcome.reason))
}
HomeAction.Dismissed -> Reduced(state.copy(presented = null))
```
A finished purchase closes the promo and nothing more. The entitlement has one writer, the stream, and it reaches this reducer only as `EntitlementChanged` through the slice; the scenario's branch pins that the card is unlocked before the purchase's own answer arrives and that the answer writes no entitlement:
```kotlin src-kmp/subtrees/home/logic/src/jvmTest/kotlin/dev/modaal/foyer/home/HomeScenarioTest.kt theme={null}
branch("purchase flips through the stream") {
whenAction("the Insights card, while Free", HomeAction.InsightsTapped)
whenAction("the promo's button", HomeAction.PurchaseTapped)
then("a purchase is in flight") { it.isPurchasing && it.failure == null }
thenEffects("exactly the monthly purchase") {
it == effectsOf(Effect.Run(HomeEffectPayload.Purchase(Plan.Monthly)))
}
whenAction("a second tap while in flight", HomeAction.PurchaseTapped)
thenEffects("nothing: one purchase at a time") { it.isEmpty() }
whenAction(
"the entitlement stream emits first",
HomeAction.EntitlementChanged(Entitlement.Premium(Plan.Monthly)))
then("the card is unlocked, the promo still up") {
it.entitlement == Entitlement.Premium(Plan.Monthly) &&
it.presented == HomePresentation.Promo
}
whenAction(
"the port answers",
HomeAction.PurchaseFinished(PurchaseOutcome.Purchased(Plan.Monthly)))
then("the promo closes; the entitlement was never written here") {
!it.isPurchasing && it.presented == null && it.entitlement == Entitlement.Premium(Plan.Monthly)
}
}
```
The environment gains `purchase(plan, onOutcome)`, so the generated `HomeEnvironmentMock` gains `purchaseHandler` and `purchaseArgs`, and the test-store suite gains the round trip. Record the four new leaves with `tools/duet record --feature home`. The screens are the same on both platforms: a card with a lock and "Premium" while locked, the promo and the summary each replacing the tab's content while `presented` names them.
Build and run each app, sign in as a guest, and tap the card: the promo opens, the button reads "Purchasing…" for a second, the promo closes and the card is unlocked. Tap it again for the summary. Relaunch: the session and the plan are back from the document before the splash ends. Sign out and back in as a guest, shorten `GUEST_SESSION_MINUTES` to one, and watch the gate come up from the profile tab a minute later; the reducer rule from the root step is what moves the phase, and the worker only reported the value.
The Kotlin host test `RootFlowTest` walks the same path headless, including the expiry on virtual time and the relaunch over the same memory file; the Swift `RootCompositionSpec` does the same across the boundary on real time.
## What you now have
* A `backend-local` module with one implementation per port, persisted as one JSON document behind a two-function file port, and no mock service on the tree.
* Two streams on the ports, two workers per platform observing them for the root mount's lifetime, and one relay carrying their reports into the root store.
* `WorkerTester` suites on both sides, a pure transform pinned once, and no recording for a worker.
* The entitlement written by one action from one stream, projected down to two tabs as a slice, and read by a locked card that unlocks from the stream.
* Seven new recordings, 39 in all, and every check green: `tools/duet verify`, `tools/duet mocks --check`, the backend's tests, the Android unit tests and the Apple lane.
## Exercise: record the session expiry
`tutorial4-start` carries `Tutorial4ExerciseSessionExpiryTest`, a failing placeholder in the root module. Delete it, add a branch `session expiry returns to gate` to the root scenario, list `root.session-expiry-returns-to-gate` in the manifest and in the root's feature spec, and add its row to `RootGoldenTest`. The branch starts from a signed-in session, completes the splash into main, then sends `AuthChanged(SignedOut)` as the session worker would after the guest window, and pins the gate. Without the reducer rule from the root step the recording shows the phase staying on main, which is the point of writing the branch first. The finished reducer clause reads:
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
state.phase == RootPhase.Main && action.auth == AuthSnapshot.SignedOut ->
Reduced(next.copy(phase = RootPhase.SignIn))
```
Run `tools/duet record --feature root`, then `tools/duet verify`; the Kotlin lane replays 39 fixtures and the run ends with `duet verify: PASS`. `tutorial4-complete` carries the finished branch.
## Common questions
A recording pins a reducer: the same action on the same state gives the same state and effects, on both platforms, byte for byte. A worker has no reducer. It is native per platform, it observes a platform stream, and its one duty is to send what it saw. What a recording would pin is the transform from a stream value to an action, and that transform is a pure function in the root module with its own unit test. The worker's test is behavioral: start it, drive its source, read the relay, finish.
A worker adopted at mount subscribes after the backend already knows the answer. A sticky stream, a `StateFlow` on Kotlin, replays its current value to a late subscriber, so the first report is the current session or the current entitlement and the root needs no seed of its own. An event stream would deliver only what changes after the subscription, and a relaunch would start the app signed out with a plan it owns. The workers page in the framework repository names the two shapes: a sticky flag and an event tick.
Because then the value would have two writers, and the second one is wrong in the case that matters: a restored purchase, a purchase made on another device, a refund. The stream is the backend's statement of what the user is entitled to now, and the entitlement worker is its only path into feature state. The promo's answer closes the promo; the card unlocks when the stream says so, and the scenario branch pins that order. [Tutorial 5](/tutorials/duet-05-navigation-as-state#write-the-upgrade-flow)'s upgrade flow keeps the rule.
One implementation shape on both platforms, readable by you: open the document under the app's storage and the session, the saved name and the plan are there in plain text. The port is two functions, so the choice is local; a preferences-backed `KeyValueFile` is a small class in each app and nothing in the backend changes. [Tutorial 5](/tutorials/duet-05-navigation-as-state#gather-the-route-spine-and-restore-from-it) relies on the document surviving process death for the restore demonstration.
The Kotlin/Native framework exports an interface's `StateFlow` member as the raw Kotlin flow type, which Swift cannot iterate with `for await`. A top-level function's return type is exported through the Swift-friendly projection as the async sequence. `sessionsFlow` and `entitlementsFlow` are that projection for the two streams, two lines in the ports module, and the Kotlin workers read the members directly.
The kernel's clock seam is a sleep, not a wall-clock reading, so the backend can wait thirty minutes on it, on virtual time in a test, but it cannot store a deadline and compare it later. Restarting the window at each launch keeps the expiry testable through the seam alone. A backend that needs a real deadline adds a `now` to its own port, outside the kernel's seam.
## Sources and further reading
* [The Duet framework repository](https://github.com/modaal-agent/duet) — `docs/workers.md`, the worker contract this page applies: one bracket, one life, no silent ingress, logical tests only; `Working`, `StoreHost.adopt` and `Relay` in the shells packages; `WorkerTester` in `DuetTesting` and `kernel-test`; `KernelClock`, the clock seam the backend waits on.
* [The duet-tools repository](https://github.com/modaal-agent/duet-tools) — `contracts/manifest.md` and the `record` verb, for the re-recorded features.
* [kotlinx.coroutines: StateFlow](https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/) — the sticky stream both ports carry, and its conflation.
* [SKIE: Flows](https://skie.touchlab.co/features/flows) — how a Kotlin flow becomes a Swift async sequence, and where the conversion applies.
* [The duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials) — `tutorial4-start` and `tutorial4-complete`, and the checks CI runs on them.
* [The Duet glossary](/articles/duet-glossary) — [worker](/articles/duet-glossary#worker), [mount](/articles/duet-glossary#mount), [host](/articles/duet-glossary#host) and [golden recording](/articles/duet-glossary#golden-fixture).
## Read next
The onboarding gate, the upgrade flow in a sheet slot, deep links and the route spine that survives process death.
The tree these workers report into, and the mock services this page replaces.
The nine tutorials, the app they build, the prerequisites and the versions they are verified against.
# Duet Tutorial 5: Navigation as State
Source: https://docs.modaal.dev/tutorials/duet-05-navigation-as-state
Add an onboarding gate, an upgrade flow and deep links as route state, restore the tree after process death, and share readiness between sibling steps.
In this tutorial you make every screen of Foyer reachable from state alone. [Tutorial 4](/tutorials/duet-04-workers#lock-the-insights-card) left the app with two gates and a locked card; here the root gains an onboarding gate with three steps and a progress row, the main level gains a sheet slot that mounts an upgrade flow from either tab, the app answers two deep links, and each level's route sliver is gathered into one value that both apps save before the process dies and rebuild the tree from afterwards. Back is an action on the level that owns the page. The progress row learns each step's readiness through a seam between siblings, not through the parent. This is the fifth page of the [nine-tutorial series](/tutorials/duet).
**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 [Modaal](https://modaal.dev), 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 Modaal](/articles/new-project) when you would rather skip the setup.
## What will you build?
Six new features and a new phase. The `onboarding` level owns a page and mounts one of three step leaves, `welcome`, `name` and `preferences`, beside a `progress` leaf that lives for the level's lifetime; the session carries whether the account finished the steps, and the root mounts the gate from that fact. The `upgrade` flow walks plans, confirm and done as route state, buys through the purchases port and completes through its delegate; the `main` level mounts it in a sheet slot when either tab asks, and the card still unlocks from the entitlement stream and nothing else. Two links, `foyer://upgrade` and `foyer://profile/account`, are parsed into a value and forwarded down the tree one level at a time. A `RouteSpine` gathers the phase, the tab, the profile tree's depth, the home tab's presented screen, the flow's step and the onboarding page; Android saves it in the instance `Bundle`, iOS in the scene's restoration activity, and both rebuild the tree from it. Expect about three hours.
You will have at the end:
* Six new logic modules with 20 recordings, 33 re-recorded for the five that changed, 72 fixtures on the tree, and three new chains, one of them the closing exercise.
* A readiness worker per platform, owned by the onboarding level's composition, with a `WorkerTester` suite on each side.
* The upgrade flow presented as a sheet on iOS and a modal bottom sheet on Android from the same `sheet` value.
* Both deep links opening on both platforms, from a cold start and while the app runs.
* The Android app resumed inside the name editor after process death, and an Android back policy pinned by a test.
## Where do you start?
Open `tutorial5-start` from the [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials). It is Tutorial 4'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 4's toolchain. Run the checks once before you edit anything:
```sh theme={null}
tools/duet verify
```
The Kotlin lane reports one failure, `Tutorial5ExerciseUpgradeEntitlementChainTest`; 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 steps
Every screen in Foyer is the consequence of a value: the root's `phase`, the main level's `activeTab` and `sheet`, the profile tab's `child`, the home tab's `presented`, the flow's `step`, the onboarding level's `page`. This tutorial adds the values in the shaded boxes; the arrows are delegate events received as the parent's actions, as in [Tutorial 3](/tutorials/duet-03-composing-features#write-the-root-level).
```mermaid theme={null}
flowchart TB
splash["splash"] -- "Completed" --> root{"root.phase"}
root -- "signedOut" --> signin["signIn gate"]
signin -- "Completed(name, hasOnboarded)" --> root
root -- "signedIn, not onboarded" --> onboarding["onboarding.page
welcome → name → preferences"]
onboarding -- "Completed(name, preferences)" --> root
root -- "signedIn, onboarded" --> main["main.activeTab + main.sheet"]
main --> home["home.presented"]
main --> profile["profile.child → account.child"]
main -- "sheet = upgrade" --> upgrade["upgrade.step
plans → confirm → done"]
home -- "UpgradeRequested" --> main
profile -- "UpgradeRequested" --> main
upgrade -- "Completed / Dismissed" --> main
link["foyer://…"] -- "DeepLink(link)" --> root
root -- "ForwardLink" --> main
main -- "ForwardLink" --> profile
style onboarding fill:#e8eef8,stroke:#4a6fa5
style upgrade fill:#e8eef8,stroke:#4a6fa5
style link fill:#e8eef8,stroke:#4a6fa5
```
Nothing on the diagram is a navigation call. A level changes its own route value in its reducer, and the shell mounts and unmounts children from that value with the same reconciler [Tutorial 3](/tutorials/duet-03-composing-features#compose-the-tree-on-android) introduced. Back, a deep link and a restore are three more ways a route value changes; none of them needs a second mechanism.
The root needs one fact to pick between main and the onboarding gate: whether this account finished the steps. The backend owns the fact and the session stream carries it, the same way the stream carries a saved name in [Tutorial 4](/tutorials/duet-04-workers#write-the-on-device-backend), so no feature seeds it and no second read can disagree with it.
```kotlin src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Ports.kt theme={null}
sealed interface Session {
data object SignedOut : Session
/**
* `displayName` is the account's saved name, or the default derived from the
* provider; `hasOnboarded` is whether the account finished the onboarding
* steps, the fact the root's onboarding gate reads.
*/
data class SignedIn(val displayName: String, val hasOnboarded: Boolean) : Session
}
```
The sign-in outcome and the gate's `Completed` carry the same flag, so the root learns it from the gate at the moment the gate completes, without waiting for the stream's next value. The account port gains one void call, the onboarding write; the session stream carries its result.
```kotlin src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Ports.kt theme={null}
interface AccountPort {
fun saveDisplayName(name: String, onSaved: () -> Unit)
/**
* The onboarding steps finished: persist the name and the preferences and
* mark the account onboarded. No callback: the session stream carries the
* result, as it carries a saved name.
*/
fun completeOnboarding(name: String, preferences: List)
}
```
```kotlin src-kmp/backend-local/src/commonMain/kotlin/dev/modaal/foyer/backend/LocalAccount.kt theme={null}
override fun completeOnboarding(name: String, preferences: List) {
storage.update { it.copy(displayName = name, hasOnboarded = true, preferences = preferences) }
auth.sessionRenamed(name)
auth.sessionOnboarded()
}
```
`LocalRecord` gains `hasOnboarded` and `preferences`, and `LocalAuth` builds every session value from the record, so a relaunch reports the fact with the stream's first value. `LocalAccountTest` pins the write, the stream and the next sign-in in one test. Re-record `signin`, whose `Completed` event grew a field.
The level's state is its page and the two answers the steps hand up. Each step's `Continued` arrives as the level's action and moves the page; the last one climbs `Completed`; Back moves the page back and is inert on the first page.
```kotlin src-kmp/subtrees/onboarding/logic/src/commonMain/kotlin/dev/modaal/foyer/onboarding/OnboardingFeature.kt theme={null}
data class OnboardingState(
/** Which step is mounted. The shell mounts from this value and projects it to the progress row. */
val page: OnboardingPage = OnboardingPage.Welcome,
/** What the name step handed up, once it did. */
val name: String? = null,
/** What the preferences step handed up, once it did. */
val preferences: List = emptyList(),
)
```
```kotlin src-kmp/subtrees/onboarding/logic/src/commonMain/kotlin/dev/modaal/foyer/onboarding/OnboardingFeature.kt theme={null}
OnboardingAction.Back ->
when (state.page) {
OnboardingPage.Welcome -> Reduced(state)
OnboardingPage.Name -> Reduced(state.copy(page = OnboardingPage.Welcome))
OnboardingPage.Preferences -> Reduced(state.copy(page = OnboardingPage.Name))
}
```
The page value itself lives in the ports module, because the steps and the progress row name it too:
```kotlin src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Onboarding.kt theme={null}
enum class OnboardingPage {
@SerialName("welcome") Welcome,
@SerialName("name") Name,
@SerialName("preferences") Preferences,
}
```
The three step leaves are the smallest features on the tree. The name step reuses the editor's validation through one shared function, `validateDisplayName`, added to the `editname` module, so the two screens that accept a display name never disagree:
```kotlin src-kmp/subtrees/name/logic/src/commonMain/kotlin/dev/modaal/foyer/name/NameFeature.kt theme={null}
fun nameReducer(state: NameState, action: NameAction): Reduced =
when (action) {
is NameAction.DraftChanged -> {
val ready = validateDisplayName(action.text) == null
val next = state.copy(draft = action.text, isReady = ready, validation = null)
if (ready == state.isReady) {
Reduced(next)
} else {
Reduced(
next,
listOf(Effect.Run(NameEffectPayload.PublishReadiness(OnboardingPage.Name, ready))))
}
}
NameAction.ContinueTapped ->
when (val validation = validateDisplayName(state.draft)) {
null ->
Reduced(
state,
listOf(
Effect.Run(
NameEffectPayload.NotifyHost(NameDelegateEvent.Continued(state.draft.trim())))))
else -> Reduced(state.copy(validation = validation))
}
}
```
Each edit re-validates the draft and publishes the readiness when it changed. That publish is the next step's subject. Write the `welcome` and `preferences` leaves the same way (the finished tree has both), add the five modules to the settings file, the umbrella's exports and both replay registries, write their scenarios, and record them: `tools/duet record --feature onboarding`, and the same for `welcome`, `name`, `preferences`.
The progress row shows "step n of 3" and a tick per step that is ready to continue. The page reaches it the way the entitlement reached the tabs in [Tutorial 4](/tutorials/duet-04-workers#project-the-entitlement-down-as-a-slice): projected down from the level that owns it. The readiness cannot reach it that way, because the level does not have it; each step does, and a step never holds a sibling's store. The shape for a value that must cross between siblings is one worker, owned by their lowest common ancestor, with a void update on one side and a sticky observation on the other. Neither side names the other; each sees one narrow port.
```kotlin src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Onboarding.kt theme={null}
/** The producer's side: a step publishes its readiness. Void; the stream carries the result. */
interface ReadinessUpdating {
fun updateReadiness(step: OnboardingPage, ready: Boolean)
}
/**
* The consumer's side: the progress row observes every step's readiness.
* Sticky: `onChange` fires now with each step's current value, then on every
* change, until the returned subscription is cancelled.
*/
interface ReadinessObserving {
fun observeReadiness(onChange: (StepReadiness) -> Unit): ReadinessSubscription
}
fun interface ReadinessSubscription {
fun cancel()
}
```
A step's reducer emits `PublishReadiness(step, ready)` and its handler makes the one call; nothing re-enters. The progress row's reducer starts the observation once, on its first appearance, and its handler keeps the flow open for the store's lifetime, turning every delivery into one action:
```kotlin src-kmp/subtrees/progress/logic/src/commonMain/kotlin/dev/modaal/foyer/progress/ProgressRuntime.kt theme={null}
fun progressEffectHandler(
environment: ProgressEnvironment,
): (ProgressEffectPayload) -> Flow = { payload ->
when (payload) {
ProgressEffectPayload.ObserveReadiness ->
callbackFlow {
val subscription =
environment.observeReadiness { readiness ->
trySend(ProgressAction.ReadinessChanged(readiness.step, readiness.ready))
}
awaitClose { subscription.cancel() }
}
}
}
```
The store's teardown cancels the flow, which cancels the subscription; `ProgressTestStoreTest` pins that with the generated environment double and a hand-written sticky source behind its handler. The worker itself is native per platform. On Android it is a `MutableStateFlow` behind both ports, adopted by the level's host; on iOS the worker is main-bound and holds a `CurrentValueSubject` behind the two ports:
```swift src-ios/Libraries/FoyerKit/Sources/OnboardingShell/OnboardingProgressWorker.swift theme={null}
final class ReadinessSeam: NSObject, ReadinessUpdating, ReadinessObserving {
private let readiness = CurrentValueSubject<[OnboardingPage: Bool], Never>([:])
func updateReadiness(step: OnboardingPage, ready: Bool) {
var current = readiness.value
current[step] = ready
readiness.send(current)
}
func observeReadiness(onChange: @escaping (StepReadiness) -> Void) -> any ReadinessSubscription {
var delivered: [OnboardingPage: Bool] = [:]
let cancellable = readiness.sink { current in
for (step, ready) in current where delivered[step] != ready {
delivered[step] = ready
onChange(StepReadiness(step: step, ready: ready))
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/workers/OnboardingProgressWorker.kt theme={null}
class OnboardingProgressWorker(private val scope: CoroutineScope) :
Working, ReadinessUpdating, ReadinessObserving {
private val readiness = MutableStateFlow
The level's Component owns the worker `lazy` and answers both Dependencies with it; the steps' Dependency has `readinessUpdates`, the row's has `readinessObservation`. The level's composition adopts the worker last, after the row and the step slot, as the root adopts its two workers in [Tutorial 4](/tutorials/duet-04-workers#write-the-workers-on-ios):
```swift src-ios/Libraries/FoyerKit/Sources/OnboardingShell/OnboardingBuilder.swift theme={null}
final class OnboardingComponent: StepDependency, ProgressDependency {
private let dependency: OnboardingDependency
private(set) lazy var progressWorker = OnboardingProgressWorker()
init(dependency: OnboardingDependency) {
self.dependency = dependency
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/OnboardingBuilder.kt theme={null}
class OnboardingComponent(dependency: OnboardingDependency, scope: CoroutineScope) :
OnboardingDependency by dependency, StepDependency, ProgressDependency {
val progressWorker: OnboardingProgressWorker by lazy { OnboardingProgressWorker(scope) }
override val readinessUpdates: ReadinessUpdating
get() = progressWorker
override val readinessObservation: ReadinessObserving
get() = progressWorker
fun environment(onDelegate: (OnboardingDelegateEvent) -> Unit): OnboardingEnvironment =
object : OnboardingEnvironment {
override fun notifyHost(event: OnboardingDelegateEvent) = onDelegate(event)
}
}
/** The step the level has mounted, as the render layer sees it. */
sealed interface OnboardingStepMount {
data class Welcome(val store: WelcomeStore) : OnboardingStepMount
data class Name(val store: NameStore) : OnboardingStepMount
data class Preferences(val store: PreferencesStore) : OnboardingStepMount
}
```
```swift src-ios/Libraries/FoyerKit/Sources/OnboardingShell/OnboardingViewShell.swift theme={null}
private func apply(_ state: OnboardingState) {
// State down: the page reaches the row as its own action.
progress.shell.pageChanged(state.page)
viewState.canGoBack = state.page != .welcome
step?.reconcile(key: state.page)
viewState.step = step?.activeHandle
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/OnboardingBuilder.kt theme={null}
StateTransitions(scope, store.state) { _, state ->
// State down: the page reaches the progress row as its own action.
progress.send(ProgressAction.PageChanged(state.page))
step.reconcile(state.page)
mount.publish(step.activeHandle)
})
// The seam's worker, adopted for the level's lifetime: the ancestor
// brackets it, the steps and the row only hold their ports to it.
host.adopt(component.progressWorker)
```
The worker has no recording and no scenario; its one contract is behavioral, that a late subscriber sees the current value first, and `WorkerTester` pins it on both sides:
```swift src-ios/Libraries/FoyerKit/Tests/OnboardingShellTests/OnboardingProgressWorkerSpec.swift theme={null}
func testALateSubscriberSeesTheCurrentValueThenEveryChange() async {
let worker = OnboardingProgressWorker()
let seam = worker.seam
let tester = WorkerTester(worker)
tester.start()
// Published before anyone observes.
seam.updateReadiness(step: .welcome, ready: true)
seam.updateReadiness(step: .name, ready: false)
var received: [StepReadiness] = []
let subscription = seam.observeReadiness { received.append($0) }
XCTAssertEqual(received.count, 2, "the current value per step, first")
```
```kotlin src-kmp/app/src/test/kotlin/dev/modaal/foyer/app/workers/OnboardingProgressWorkerTest.kt theme={null}
fun aLateSubscriberSeesTheCurrentValueThenEveryChange() = runTest {
val worker = OnboardingProgressWorker(backgroundScope)
val tester = WorkerTester(worker)
tester.start(backgroundScope)
// Published before anyone observes.
worker.updateReadiness(OnboardingPage.Welcome, true)
worker.updateReadiness(OnboardingPage.Name, false)
runCurrent()
val received = mutableListOf()
val subscription = worker.observeReadiness { received.add(it) }
runCurrent()
assertEquals(
listOf(StepReadiness(OnboardingPage.Welcome, true), StepReadiness(OnboardingPage.Name, false)),
received,
"the current value per step, first")
```
Run `tools/duet record --feature progress` and `tools/duet verify`. The two shapes now sit on one screen: the page comes down from the parent as a projection, the readiness comes across from siblings through the seam, and the row's reducer treats both as plain actions.
The flow's step is route state with three cases, and Back is an action the reducer reads against the step: from confirm it returns to the plans, on the plans it climbs `Dismissed`, on done it climbs `Completed`. The purchase goes through the purchases port and its answer moves the step. No entitlement appears anywhere in this module.
```kotlin src-kmp/subtrees/upgrade/logic/src/commonMain/kotlin/dev/modaal/foyer/upgrade/UpgradeFeature.kt theme={null}
sealed interface UpgradeStep {
@Serializable @SerialName("plans") data object Plans : UpgradeStep
@Serializable @SerialName("confirm") data class Confirm(val plan: Plan) : UpgradeStep
@Serializable @SerialName("done") data object Done : UpgradeStep
}
```
```kotlin src-kmp/subtrees/upgrade/logic/src/commonMain/kotlin/dev/modaal/foyer/upgrade/UpgradeFeature.kt theme={null}
is UpgradeAction.PurchaseFinished ->
when (val outcome = action.outcome) {
is PurchaseOutcome.Purchased ->
Reduced(state.copy(isPurchasing = false, step = UpgradeStep.Done))
is PurchaseOutcome.Failed ->
Reduced(state.copy(isPurchasing = false, failure = outcome.reason))
}
UpgradeAction.Back ->
when (state.step) {
is UpgradeStep.Confirm ->
if (state.isPurchasing) Reduced(state)
else Reduced(state.copy(step = UpgradeStep.Plans, failure = null))
UpgradeStep.Plans -> Reduced(state, listOf(dismissed))
UpgradeStep.Done -> Reduced(state, listOf(completed))
```
The scenario's `select confirm purchase done` branch pins the walk and the `back walks the steps` branch pins Back on two steps. The home tab's promo loses its one-tap purchase: `UpgradeTapped` closes the promo and climbs `UpgradeRequested`, and the profile tab's plan row climbs the same event from `PlanTapped`. The main level gains the slot both reach:
```kotlin src-kmp/subtrees/main/logic/src/commonMain/kotlin/dev/modaal/foyer/main/MainFeature.kt theme={null}
sealed interface MainSheet {
@Serializable @SerialName("upgrade") data object Upgrade : MainSheet
}
```
```kotlin src-kmp/subtrees/main/logic/src/commonMain/kotlin/dev/modaal/foyer/main/MainFeature.kt theme={null}
is MainAction.Home ->
when (action.event) {
HomeDelegateEvent.UpgradeRequested -> Reduced(state.copy(sheet = MainSheet.Upgrade))
}
is MainAction.Profile ->
when (action.event) {
ProfileDelegateEvent.UpgradeRequested -> Reduced(state.copy(sheet = MainSheet.Upgrade))
ProfileDelegateEvent.SignOutRequested ->
Reduced(
state,
listOf(Effect.Run(MainEffectPayload.NotifyHost(MainDelegateEvent.SignOutRequested))))
}
is MainAction.Upgrade ->
when (action.event) {
UpgradeDelegateEvent.Completed,
UpgradeDelegateEvent.Dismissed -> Reduced(state.copy(sheet = null))
}
```
The flow's `Completed` clears the sheet and climbs nothing, which is the point the closing exercise pins: the card unlocks when the entitlement stream emits, through the root's slice, as in [Tutorial 4](/tutorials/duet-04-workers#project-the-entitlement-down-as-a-slice). Record `upgrade`, re-record `home`, `profile` and `main`, and delete the two home fixtures the promo's purchase owned; `tools/duet lint` names them.
The main level's composition mounts the flow from `state.sheet` through a single-slot reconciler, exactly as the profile tab mounts the account screen. The one difference from every other screen in the series is the presentation: the slot takes each platform's idiom with no extra code, a `.sheet` on iOS and a `ModalBottomSheet` on Android. The recordings do not change; the difference is manner, not behavior, and the [presentation ledger](/articles/cross-platform-ui-parity#divergence-is-ledgered-not-forbidden) has no entry for it.
```swift src-ios/Libraries/FoyerKit/Sources/MainShell/MainView.swift theme={null}
.sheet(
isPresented: Binding(
get: { viewState.upgrade != nil },
set: { presented in
if !presented { viewState.upgrade?.shell.dismissed() }
})
) {
if let upgrade = viewState.upgrade {
UpgradeView(viewState: upgrade.shell.viewState, shell: upgrade.shell)
}
}
}
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/MainScreen.kt theme={null}
// The sheet slot, composed after the tabs so its back handler wins while
// the flow is up: the platform's modal bottom sheet over whichever tab.
upgrade?.let { UpgradeSheet(it) }
```
Each idiom's own dismissal reaches the flow as `DismissTapped`: the sheet's swipe on iOS through the presentation binding, the bottom sheet's swipe and the system back press on Android through `onDismissRequest`. The flow decides what a dismissal means on each step: `Dismissed` before a purchase, `Completed` after one. The Back button inside the flow walks the steps.
The root's phase gains `Onboarding`, and one function decides where a signed-in session goes. The gate's `Completed` and the stream's `AuthChanged` both feed it; the onboarding gate's `Completed` writes through the account port and mounts main.
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
/** Where a finished splash goes: the sign-in gate, the onboarding gate, or main. */
private fun phaseAfterSplash(auth: AuthSnapshot): RootPhase =
when (auth) {
is AuthSnapshot.SignedIn -> if (auth.hasOnboarded) RootPhase.Main else RootPhase.Onboarding
AuthSnapshot.SignedOut,
AuthSnapshot.Unknown -> RootPhase.SignIn
}
```
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
is RootAction.Onboarding ->
when (val event = action.event) {
is OnboardingDelegateEvent.Completed ->
if (state.phase != RootPhase.Onboarding) {
Reduced(state)
} else {
val entered =
enter(
state.copy(auth = AuthSnapshot.SignedIn(event.name, hasOnboarded = true)),
RootPhase.Main)
Reduced(
entered.state,
listOf(Effect.Run(RootEffectPayload.CompleteOnboarding(event.name, event.preferences))) +
entered.effects)
}
}
```
A deep link is the one input the operating system hands the app after launch. Both shells parse the URL with the same function and send the value to the root, which forwards it down as one effect when main is up and holds it otherwise; entering main releases the held link.
```kotlin src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/DeepLink.kt theme={null}
fun parseDeepLink(url: String): DeepLink? {
val prefix = "${DeepLinkConfig.SCHEME}://"
if (!url.startsWith(prefix)) return null
return when (url.removePrefix(prefix).trimEnd('/')) {
"upgrade" -> DeepLink.Upgrade
"profile/account" -> DeepLink.ProfileAccount
else -> null
}
```
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
is RootAction.DeepLink ->
if (state.phase == RootPhase.Main) {
Reduced(state, listOf(Effect.Run(RootEffectPayload.ForwardLink(action.link))))
} else {
Reduced(state.copy(pendingLink = action.link))
}
}
```
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
/** Move to a phase; entering main releases a held link as one forward. */
private fun enter(state: RootState, phase: RootPhase): Reduced {
val link = state.pendingLink
return if (phase == RootPhase.Main && link != null) {
Reduced(
state.copy(phase = phase, pendingLink = null),
listOf(Effect.Run(RootEffectPayload.ForwardLink(link))))
} else {
Reduced(state.copy(phase = phase))
}
}
```
The root gains its first environment, two void calls: `forwardLink` and `completeOnboarding`. The main level handles `OpenLink` by setting its sheet or its tab and forwarding the account link on down to the profile tab, whose `OpenLink` mounts the account screen exactly as the row does. Each shell registers the scheme and hands the URL over: Android through an intent filter and `onNewIntent` under `singleTask`, iOS through `CFBundleURLTypes` and the scene's `openURLContexts`.
```swift src-ios/App/Foyer/SceneDelegate.swift theme={null}
/// The URL as a link, if it is one the app answers; the root routes it from there.
private func openLink(_ url: URL) {
guard let link = parseDeepLink(url: url.absoluteString) else { return }
root?.shell.openLink(link)
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/MainActivity.kt theme={null}
/** The intent's URL as a link, if it is one the app answers; the root routes it from there. */
private fun openLink(intent: Intent?) {
val link = intent?.dataString?.let(::parseDeepLink) ?: return
retained.component.store.send(RootAction.DeepLink(link))
}
```
The `forwardLink` effect can run before the shell has applied the phase change that mounts main, so each root mount holds a forwarded link until its main child is published. Re-record `root`; add the `link forwards under main` and `link waits for main` branches first, and the `gate picks onboarding` and `onboarding completes into main` branches. The chain `chain-root-onboarding` in `RootChainsTest` pins the second gate's seam.
Process death loses every store. What must survive is each level's route sliver and nothing else: the items reload, the session and the entitlement arrive through the streams, and the answers already persisted. One value in the root module gathers the slivers, encoded by one function and decoded tolerantly, so a stale payload restores nothing and never fails a launch.
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RouteSpine.kt theme={null}
data class RouteSpine(
val phase: RootPhase = RootPhase.Splash,
val activeTab: @Serializable(with = MainTabSerializer::class) MainTab? = null,
val profilePath: @Serializable(with = ProfilePathSerializer::class) ProfilePath? = null,
val homePresented: @Serializable(with = HomePresentationSerializer::class) HomePresentation? = null,
val upgradeStep: @Serializable(with = UpgradeStepSerializer::class) UpgradeStep? = null,
val onboardingPage: OnboardingPage? = null,
)
fun encodeRouteSpine(spine: RouteSpine): String =
CanonicalSerializers.json.encodeToString(RouteSpine.serializer(), spine)
/** The spine, or null for text this version does not read. A saved string never fails a launch. */
fun decodeRouteSpine(text: String?): RouteSpine? =
text?.let { runCatching { CanonicalSerializers.json.decodeFromString(RouteSpine.serializer(), it) }.getOrNull() }
```
`RouteSpineTest` pins the encoding as a golden string and the tolerant decode. Each composition reads the spine from its live stores when the platform asks, and applies it as each store's initial state when it builds: every `make*Store` gained a parameter for its sliver, and the reconcilers mount from those initial values without any imperative unwind. The splash replays on a restore, and the spine's phase says whether the slivers below it apply.
```swift src-ios/App/Foyer/SceneDelegate.swift theme={null}
/// What the scene saves before the process may die: each level's route
/// sliver, encoded once by the Kotlin core.
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
guard let root else { return nil }
let activity = NSUserActivity(activityType: Self.spineActivityType)
activity.addUserInfoEntries(from: [Self.spineKey: encodeRouteSpine(spine: root.shell.routeSpine())])
return activity
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/MainActivity.kt theme={null}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putString(SPINE_KEY, encodeRouteSpine(retained.component.routeSpine()))
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/RootBuilder.kt theme={null}
// The spine applies to the first child after the splash, and only
// when that child is the one the spine names; otherwise it is dropped.
var restoreFor: RouteSpine? = null
if (phase != RootPhase.Splash) {
restoreFor = pendingRestore?.takeIf { it.phase == phase }
pendingRestore = null
}
```
The host test `RootFlowTest` walks it: sign in, onboard, open the profile tab, the account screen and the editor, encode the spine, tear the tree down, rebuild it from the decoded spine over the same memory file, and find the editor mounted after the splash; a payload from another version restores the default tree. `RootCompositionSpec` does the same across the boundary. To watch it on a device, open the editor, background the app and kill the process:
```sh theme={null}
adb shell am kill dev.modaal.foyer
```
Reopen the app from the launcher. After the splash, the editor is on screen with the name in its field.
A back press on Android reaches the last composed enabled `BackHandler`. The tree composes its handlers shallow to deep with the sheet last, and every handler is enabled by one predicate in a pure table, so the winner per state is a function of the spine and a test replays it.
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/BackPolicy.kt theme={null}
/** The handler a back press reaches for this spine, or null when the system handles it. */
fun winner(spine: RouteSpine): Level? =
when (spine.phase) {
RootPhase.Splash,
RootPhase.SignIn -> null
RootPhase.Onboarding ->
Level.ONBOARDING.takeIf { onboardingEnabled(spine.onboardingPage ?: OnboardingPage.Welcome) }
RootPhase.Main ->
COMPOSITION_ORDER.lastOrNull { level ->
when (level) {
Level.ONBOARDING -> false
Level.HOME_PRESENTED ->
spine.activeTab == MainTab.Home && homePresentedEnabled(spine.homePresented)
Level.ACCOUNT -> spine.activeTab == MainTab.Profile && accountEnabled(spine.profilePath)
Level.EDIT_NAME -> spine.activeTab == MainTab.Profile && editNameEnabled(spine.profilePath)
Level.UPGRADE -> upgradeEnabled(spine.upgradeStep?.let { MainSheet.Upgrade })
}
}
}
}
```
Each handler sends one action into one store: `OnboardingAction.Back` on the level, `HomeAction.Dismissed`, `AccountAction.CloseTapped`, `EditNameAction.CancelTapped`; the sheet's own back handling sends `UpgradeAction.DismissTapped`. The splash and the sign-in gate install no handler and the onboarding gate's first page installs none, so back on a gate leaves the app. `BackPolicyTest` pins that a hidden tab's handlers are dead and that the sheet wins over everything below it. iOS has no system back; its screens offer their own Back buttons, which send the same actions.
Build and run each app as in [Tutorial 2](/tutorials/duet-02-two-apps#declare-the-targets-and-run-both-apps). Sign in with a new address and the onboarding gate comes up: the progress row shows step 1 of 3 with the first tick already filled, because the welcome step published its readiness when it appeared. Type a name and the second tick fills while you type; clear it and the tick empties.
Finish the steps and main comes up with the name you entered. Open the promo from the locked card and buy through the flow; the card unlocks when the stream emits, and the plan row shows the plan. Then try the links:
```sh theme={null}
xcrun simctl openurl booted foyer://profile/account
adb shell am start -a android.intent.action.VIEW -d foyer://upgrade
```
Each opens the screen it names on the app that receives it, with the app running or from a cold start; on a cold start the link waits through the splash. `scripts/run-tree.sh tutorial5-complete` runs every check on the tree; the Android job's unit tests include the restore, the back policy and the worker.
## What you now have
* Six new logic modules, 72 fixtures on the tree and three new chains; the onboarding gate and the upgrade flow as route state, with Back as an action on the level that owns the page.
* A readiness worker per platform, owned once by the onboarding level's composition, with the sticky delivery pinned by `WorkerTester` on both sides and no recording.
* The flow presented in each platform's idiom from one `sheet` value, and the card unlocking from the stream alone.
* Two deep links parsed by one function, forwarded down the tree as effects and held until main mounts.
* A route spine encoded once, saved by each platform's own mechanism, and applied as initial state on rebuild; a back policy as pure predicates with a test.
* Every check green: `tools/duet verify`, `tools/duet mocks --check`, the backend's tests, the Android unit tests and the Apple lane.
## Exercise: record the entitlement chain
`tutorial5-start` carries `Tutorial5ExerciseUpgradeEntitlementChainTest`, a failing placeholder in the home module. Delete it and write the chain in the main module, whose module sees all three nodes: the flow on its done step sends `DoneTapped` and emits exactly the `Completed` delegate; the hop carries it into the main level as `Upgrade(event)`, which clears the sheet and emits nothing; the home node, still on its initial Free state, receives `EntitlementChanged(Premium(Monthly))` as the root's slice and unlocks. List `chain-upgrade-entitlement` in the manifest and in the three participants' feature specs. The finished hop reads:
```kotlin src-kmp/subtrees/main/logic/src/jvmTest/kotlin/dev/modaal/foyer/main/MainUpgradeEntitlementChainTest.kt theme={null}
hop(
"the flow's delegate is the main level's action",
from = upgrade,
to = main,
delegateSerializer = UpgradeDelegateEventSerializer,
) { event ->
MainAction.Upgrade(event)
}
then(main, "the sheet is cleared") { it.sheet == null }
thenEffects(main, "nothing climbs: the entitlement is not the flow's to report") {
it.isEmpty()
}
```
Run `tools/duet record --chain chain-upgrade-entitlement`, then `tools/duet verify`; the Kotlin lane replays 72 fixtures and the run ends with `duet verify: PASS`. `tutorial5-complete` carries the finished test.
## Common questions
A link that arrives during the splash names a screen under main, and main does not exist yet. Holding the value in state and releasing it as one effect when main is entered keeps the link on the same path every other route change takes: a reducer changes a value, the shell mounts from it. The `link waits for main` recording pins the hold and the release, on both platforms, without a shell in the loop.
Because the phase is the consequence of the session, and the session arrives through the stream after the workers start. Restoring `main` before the auth snapshot is known would mount the profile tree with no name to show. The splash replays, the stream's first value decides the phase as it always does, and the spine's slivers apply when the child they belong to mounts. The spine's `phase` field is a guard: slivers under a phase the app does not enter are dropped.
A slice carries a parent's state down to a child, and the readiness is not the parent's: each step has its own. Neither step can send the row an action or hold its store, so the value crosses through a seam the level owns, and the row reads it as any other environment stream, one action per delivery. The page, which is the level's own value, takes the slice route. The two arrive at the same reducer as two plain actions.
The main level mounts the flow from `state.sheet` through the same reconciler every other child uses; what the two shells do with the mounted child is presentation. On iOS a `.sheet` and on Android a `ModalBottomSheet` are each the platform's own surface for a screen over the tabs, and each takes the child with no extra code. The recordings are identical, the chain is identical, and the [presentation ledger](/articles/cross-platform-ui-parity#divergence-is-ledgered-not-forbidden) records behavior-visible divergence only.
The framework's checks require a worker to be isolated, and this app's workers are main-bound. The Kotlin port interfaces carry no isolation, so a main-actor class cannot satisfy them directly; the seam is the worker's one stored value, an object that conforms to both ports, and the worker's `run()` parks until the level's host cancels it. On Android the worker conforms to both ports itself, because a Kotlin interface has no isolation to reconcile.
The root does keep it, inside the auth snapshot, but it never derives it. The backend is the one writer: a relaunch, a second device or a support reset would each leave a root-owned flag wrong. The session stream already carries every account fact the app reads, the display name since Tutorial 4, so the onboarding fact joins it and the root reads one value from one source.
## Sources and further reading
* [The Duet framework repository](https://github.com/modaal-agent/duet) — `docs/composition.md`, the child slot and the state-down projection the level uses; `docs/workers.md`, the lateral-state shape, one ancestor-owned worker with a void update and a sticky observation; `ChildSlot`, `StateTransitions` and `Relay` in the shells packages.
* [The duet-tools repository](https://github.com/modaal-agent/duet-tools) — the chain dialect the closing exercise records, and `duet doctor`'s worker rule.
* [Android: Save UI states](https://developer.android.com/topic/libraries/architecture/saving-states) — the saved-instance `Bundle` the Activity writes the spine to, and when the system restores it.
* [Android: Add support for the predictive back gesture](https://developer.android.com/guide/navigation/custom-back/predictive-back-gesture) — the `BackHandler` dispatch order the back policy relies on.
* [Apple: Restoring your app's state](https://developer.apple.com/documentation/uikit/restoring-your-app-s-state) — the scene's restoration activity the spine rides on iOS.
* [Apple: Defining a custom URL scheme for your app](https://developer.apple.com/documentation/xcode/defining-a-custom-url-scheme-for-your-app) — `CFBundleURLTypes` and `openURLContexts`.
* [The duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials) — `tutorial5-start` and `tutorial5-complete`, and the checks CI runs on them.
* [The Duet glossary](/articles/duet-glossary) — [delegate](/articles/duet-glossary#delegate), [worker](/articles/duet-glossary#worker), [mount](/articles/duet-glossary#mount) and [golden recording](/articles/duet-glossary#golden-fixture).
## Read next
The lanes the manifest derives, every check as one workflow in your own repository, and the mutation drill run both directions.
The streams, the workers and the slice this page's onboarding gate and readiness seam build on.
The nine tutorials, the app they build, the prerequisites and the versions they are verified against.
# Duet Tutorial 6: The Checks in CI
Source: https://docs.modaal.dev/tutorials/duet-06-checks-in-ci
Read the lanes the manifest derives, run every check as one workflow in a repository of your own, and run the mutation drill in both directions.
In this tutorial you take the checks you have been running since [Tutorial 1](/tutorials/duet-01-first-feature) and make them the gate of a repository. You read the lanes the manifest derives and the list of what verify does not cover, learn what each command proves and what its red looks like, put every command into one workflow with three jobs and a scheduled fourth, and run the mutation drill in both directions: a row whose recording exists is caught, and a row that survives tells you which recording to write. The app does not change on this page; one recording joins it. This is the sixth page of the [nine-tutorial series](/tutorials/duet), and Tutorials 7, 8 and 9 each start from this page's finished tree.
**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 [Modaal](https://modaal.dev), 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 Modaal](/articles/new-project) when you would rather skip the setup.
## What will you build?
A repository whose main branch cannot go red silently. The checks are the ones [Tutorial 5](/tutorials/duet-05-navigation-as-state#run-both-apps) left you with, `tools/duet verify` and its neighbours; this page adds nothing to them and puts them where they run on every push. `parity/mutations.json` gains three rows, each an exact-string edit that seeds a defect into a reducer, and `tools/duet mutate` proves that the recordings catch every one. The third row survives on the tree you open, which is the exercise: the corpus does not pin the guard it deletes until you record the leaf that does. Expect about ninety minutes, most of it waiting for the first workflow run.
You will have at the end:
* The lanes report for the tree, and a table of the ten commands the checks are made of with what each proves.
* `.github/workflows/parity.yml` in a repository of your own: `checks`, `apple` and `android` green on a push, `mutate` on a nightly schedule with its receipt on the run's summary page.
* `parity/mutations.json` with three rows, and `tools/duet mutate` ending with `3/3 caught`.
* One new recording, `root.late-sign-in-inert`, 73 fixtures on the tree, and the exercise stub gone.
## Where do you start?
Open `tutorial6-start` from the [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials). It is Tutorial 5'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 5's toolchain. Run the checks once before you edit anything:
```sh theme={null}
tools/duet verify
```
The Kotlin lane reports one failure, `Tutorial6ExerciseSurvivingMutationTest` in the root 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 steps
Every check on this tree is derived from `parity/manifest.yaml`: one entry per feature naming its implementation file, its state, action and effect types, the scenario its recordings are compiled from, and the recordings that pin it. The root's entry reads:
```yaml parity/manifest.yaml theme={null}
root:
kotlin: src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt
state: RootState
action: RootAction
effectPayload: RootEffectPayload
scenario: src-kmp/subtrees/root/logic/src/jvmTest/kotlin/dev/modaal/foyer/root/RootScenarioTest.kt
fixtures:
- root.splash-before-auth-holds
- root.gate-after-splash
- root.late-splash-inert
- root.late-sign-in-inert
- root.signed-in-skips-gate
- root.gate-picks-onboarding
- root.onboarding-completes-into-main
- root.sign-out-returns-to-gate
- root.session-expiry-returns-to-gate
- root.entitlement-changes
- root.link-forwards-under-main
- root.link-waits-for-main
```
`tools/duet lanes` prints what the `duet` tool derives from the file and does not guess at anything else:
```text theme={null}
duet lanes — derived from parity/manifest.yaml
swift lane: none (no `swift:` paths)
kotlin lane: src-kmp — unscoped task(s): :subtrees:account:logic:jvmTest :subtrees:editname:logic:jvmTest … :subtrees:welcome:logic:jvmTest
a `./gradlew` line of your own reaches every declared module with: jvmTest
per-feature:
account: :subtrees:account:logic:jvmTest
…
chains (record via `duet record --chain `):
chain-root-splash: participants root, splash
…
protocol lane: duet protocol-run (runner: :replay-runner (Kotlin installDist)) — NOT run by `duet verify`
outside the manifest — verify makes no coverage claim; run their suites in your workflow:
gradle modules: :app :apple-umbrella :backend-local :ports :replay-runner
swift packages: src-ios/Libraries/FoyerKit src-kmp/apple-umbrella/swift-consumer
```
Three lines decide the shape of the workflow. There is one lane, the Kotlin lane, because every feature declares a `kotlin:` path and none declares a `swift:` one; the Swift side is reached through the Apple boundary instead, which is a script and not a lane. The protocol lane is not part of verify, so it is a step of its own. And the last block is a list of modules verify makes no claim about: the two apps, the backend, the ports, the runner and the Swift packages. Each of those needs its own step, or its tests never run anywhere.
The checks are ten commands. You have run most of them on earlier pages; this is the list with what each one proves and what its red names.
| Command | Proves | A red names |
| - | - | - |
| `tools/duet version` | the pinned CLI resolves at `parity/duet-tools.ref`, checksum verified | the pin, or the checksum |
| `tools/duet lint` | the manifest parses and every path it names exists | the line and the missing path |
| `tools/duet doctor` | `.modaal/project.json` matches the tree, and no worker is stamped `@unchecked Sendable` | the declaration row, or the worker |
| `tools/duet verify` | the meta-checks, then every lane over every recording, then that every recording the manifest names was reported | the fixture and the step, then the lane |
| `tools/duet record --feature --check` | the recordings on disk are what the scenario produces today | the fixture that drifted |
| `tools/duet mocks --check` | the generated Swift components and mocks match their inputs | the generated file |
| `tools/duet protocol-run` | the same corpus replays over the wire protocol into the runner binary | the fixture |
| `./gradlew :backend-local:jvmTest` | the on-device backend's own tests, which no lane reaches | the test |
| `parity/scripts/apple-boundary-lane.sh` | the core assembles, the recordings replay across the XCFramework, the Swift shells' suites pass | the phase, then the test |
| `./gradlew :app:testDebugUnitTest :app:assembleDebug` and `xcodebuild build` | the two apps build, and the Android unit tests pass | the compiler, or the test |
The order matters once: `verify` refuses to run a lane while a meta-check fails, so a manifest error is reported alone, and `record --check` after `verify` tells drift apart from a wrong reducer. You saw the shape of a verify red in [Tutorial 1's exercise](/tutorials/duet-01-first-feature#exercise-break-the-arming-guard), where deleting the arming guard failed one recording at one step; the drill below produces that red on purpose, many times.
Run the ten in the order the table lists them, from the tree's root. The two lines that take minutes are the boundary lane, which assembles the Kotlin core into an XCFramework before it replays anything, and the first Gradle invocation, which downloads the wrapper's distribution. Every later run is warm.
```sh theme={null}
tools/duet version && tools/duet lint && tools/duet doctor && tools/duet verify
tools/duet record --feature root --check
tools/duet mocks --check
(cd src-kmp && ./gradlew :replay-runner:installDist -q) && tools/duet protocol-run --runner src-kmp/replay-runner/build/install/replay-runner/bin/replay-runner
(cd src-kmp && ./gradlew :backend-local:jvmTest :app:testDebugUnitTest :app:assembleDebug -q)
parity/scripts/apple-boundary-lane.sh
```
Each command ends with its own `PASS` line or with nothing, which for Gradle is the same. The tutorials repository wraps exactly this sequence in `scripts/run-tree.sh`, and its CI runs that script; the workflow you write next is the same sequence with a runner around each part.
`tutorial6-complete` carries `.github/workflows/parity.yml`, a workflow for a repository shaped like this tree. GitHub reads workflows at a repository's root, so the file does nothing where it sits inside the tutorials repository; it is the file you copy. Its header states the split:
```yaml .github/workflows/parity.yml theme={null}
# The checks, as one workflow for a repository shaped like this tree. Every
# step is a command you can run at the repository root; CI adds nothing the
# local loop does not have, and a red job names the command that went red.
#
# Four jobs:
# checks the toolchain gates on macOS: lint, doctor, verify (every lane
# the manifest declares, plus its meta-checks and the per-fixture
# coverage gate), the recording drift check, the generated-mock
# fingerprints, the protocol lane and the backend's own tests
# apple the Apple boundary: the Kotlin core assembled into an
# XCFramework, the same fixtures replayed across it, the Swift
# shells' suites, and the iOS app built for a simulator
# android the Compose app's unit tests and a debug build, on ubuntu — the
# hosted ubuntu image ships the Android SDK and the macOS image
# does not
# mutate the mutation drill from parity/mutations.json, on the nightly
# schedule and on demand; each row is a full suite run, so it does
# not run per push
#
# The toolchain floors: Xcode 26.6 and a JDK 25, the versions the trees are
# verified against. The duet CLI arrives through tools/duet at the version in
# parity/duet-tools.ref; Gradle through the committed wrapper.
```
The `checks` job is the first eight commands with a comment above each. Two steps carry the shape you would otherwise get wrong. The recording check is scoped per feature and per chain, never unscoped, because an unscoped check refuses on a tree whose feature declares both lanes, and the scoped form checks the same fixtures on any tree:
```yaml .github/workflows/parity.yml theme={null}
- name: tools/duet verify
run: tools/duet verify
# The drift gate: the recordings on disk are what the scenarios produce
# today, checked per feature and per chain.
- name: tools/duet record --check
run: |
plan="$(tools/duet lint --json)"
for f in $(echo "$plan" | jq -r '.features | keys[]'); do
tools/duet record --feature "$f" --check
done
for c in $(echo "$plan" | jq -r '.chains[]?'); do
tools/duet record --chain "$c" --check
done
```
The `android` job runs on ubuntu, because the hosted ubuntu image ships the Android SDK and the macOS image does not; it is the whole reason the workflow has two runners:
```yaml .github/workflows/parity.yml theme={null}
android:
if: github.event_name != 'schedule'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: "25"
- uses: gradle/actions/setup-gradle@v6
# The route restore, the back policy and the worker tests are here.
- name: Android app: unit tests + assembleDebug
run: (cd src-kmp && ./gradlew :app:testDebugUnitTest :app:assembleDebug --console=plain -q)
```
The `apple` job is the boundary lane and the iOS build behind a cache of the Kotlin/Native toolchain, keyed on the version catalog and saved even when the lane is red so the next run is warm. The `mutate` job is the subject of the last two steps.
To run it, make the tree a repository of its own and push it:
```sh theme={null}
cp -R tutorial6-complete ~/foyer && cd ~/foyer
git init -b main && git add -A && git commit -q -m "Foyer at tutorial 6"
gh repo create foyer --private --source=. --push
```
The Actions tab shows one run with three jobs. For scale, the tutorials repository's own runs on this tree, which run the same commands as one macOS job and one ubuntu job, take about eight minutes on the macOS runner with the toolchain cache warm and under two on ubuntu; the first run downloads the Kotlin/Native toolchain and takes longer. Every step's name is the command it ran, so a red job reads the same as the same command red on your machine.
The recordings are tests; the drill tests them. `parity/mutations.json` lists edits that each seed one defect into a reducer, and `tools/duet mutate` applies them one at a time, runs verify, and requires it to go red. A row that leaves verify green has found a behavior no recording pins. The first two rows target guards you know: the splash's arming guard from [Tutorial 1](/tutorials/duet-01-first-feature#write-the-reducer), and the root's hold on the phase while the session is unknown, from [Tutorial 3](/tutorials/duet-03-composing-features#write-the-root-level):
```json parity/mutations.json theme={null}
"name": "splash-arming-guard",
"file": "src-kmp/subtrees/splash/logic/src/commonMain/kotlin/dev/modaal/foyer/splash/SplashFeature.kt",
"old": "if (state.isArmed) {",
"new": "if (false) {",
"seeds": "every Appeared arms a new safety net; a splash that reappears fires twice"
},
{
"name": "root-splash-hold",
"file": "src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt",
"old": "next.awaitingAuth && action.auth != AuthSnapshot.Unknown ->",
"new": "action.auth != AuthSnapshot.Unknown ->",
"seeds": "the session's first report moves the phase while the splash is still on screen"
```
`old` must match the named file exactly once; `new` replaces it; `seeds` is the defect in one sentence, and the drill prints it beside the verdict. The first row turns the guard into a condition that is never true, so every `Appeared` arms a new net:
```kotlin src-kmp/subtrees/splash/logic/src/commonMain/kotlin/dev/modaal/foyer/splash/SplashFeature.kt theme={null}
SplashAction.Appeared ->
if (state.isArmed) {
// The arming guard: a repeat `Appeared` writes nothing and emits nothing.
Reduced(state)
} else {
Reduced(
state.copy(isArmed = true),
listOf(
Effect.Run(
SplashEffectPayload.ArmSafetyNet(SplashConfig.SAFETY_NET_MILLIS),
id = SplashEffectIds.SAFETY_NET)))
}
```
The second drops `next.awaitingAuth` from the root's `AuthChanged` arm, so the session's first report would move the phase with the splash still on screen:
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
is RootAction.AuthChanged -> {
val next = state.copy(auth = action.auth)
when {
next.awaitingAuth && action.auth != AuthSnapshot.Unknown ->
enter(next.copy(awaitingAuth = false), phaseAfterSplash(action.auth))
state.phase.isSignedIn && action.auth == AuthSnapshot.SignedOut ->
Reduced(next.copy(phase = RootPhase.SignIn))
else -> Reduced(next)
}
}
```
Run one row by name while you write it:
```sh theme={null}
tools/duet mutate splash-arming-guard
```
```text theme={null}
duet mutate: verifying the clean tree is green first…
seeding splash-arming-guard → src-kmp/subtrees/splash/logic/src/commonMain/kotlin/dev/modaal/foyer/splash/SplashFeature.kt
(if interrupted, restore with: git checkout -- src-kmp/subtrees/splash/logic/src/commonMain/kotlin/dev/modaal/foyer/splash/SplashFeature.kt)
✓ splash-arming-guard: caught by [kotlin]
```
The drill verifies the clean tree first, because on a red tree every row would count as caught. It names the seeded file before the run, restores the exact prior bytes after it, and reports which lane went red. `splash.repeat-appear-inert` is the recording that caught this one; you deleted the same guard by hand in [Tutorial 1](/tutorials/duet-01-first-feature#exercise-break-the-arming-guard) and read the same red.
The third row targets the root's guard on a late sign-in completion: the `SignIn` arm ignores a `Completed` that arrives when the gate is not the mounted child. The row compares the wrong phase, so a completion under main would be accepted and would replace the session:
```json parity/mutations.json theme={null}
"name": "root-late-signin-guard",
"file": "src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt",
"old": "if (state.phase != RootPhase.SignIn) {",
"new": "if (state.phase == RootPhase.Splash) {",
"seeds": "a sign-in completion that arrives after the gate is gone re-enters a phase and replaces the session"
```
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
is RootAction.SignIn ->
when (val event = action.event) {
is SignInDelegateEvent.Completed ->
if (state.phase != RootPhase.SignIn) {
Reduced(state)
} else {
val auth = AuthSnapshot.SignedIn(event.displayName, event.hasOnboarded)
enter(state.copy(auth = auth), phaseAfterSplash(auth))
}
}
```
Run the whole table:
```sh theme={null}
tools/duet mutate
```
```text theme={null}
duet mutate: verifying the clean tree is green first…
✓ splash-arming-guard: caught by [kotlin]
✓ root-splash-hold: caught by [kotlin]
✗ root-late-signin-guard: SURVIVED — the corpus does not pin this behavior
| # | Mutation | Seeds | Result | Detail |
| --- | --- | --- | --- | --- |
| 1 | `splash-arming-guard` | every Appeared arms a new safety net; a splash that reappears fires twice | caught: kotlin | > Task :subtrees:splash:logic:jvmTest FAILED |
| 2 | `root-splash-hold` | the session's first report moves the phase while the splash is still on screen | caught: kotlin | > Task :subtrees:root:logic:jvmTest FAILED |
| 3 | `root-late-signin-guard` | a sign-in completion that arrives after the gate is gone re-enters a phase and replaces the session | SURVIVED | the corpus does not pin this behavior |
duet mutate: FAIL — 1 survived of 3 in 12.1s
```
The third row survives on the tree you opened: every root recording sends the gate's `Completed` while the gate is up, so a reducer that accepts it under main passes them all. The receipt table is the drill's output and the reason the `mutate` job in the workflow writes it to the run's summary page:
```yaml .github/workflows/parity.yml theme={null}
mutate:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: macos-26
timeout-minutes: 60
steps:
- uses: actions/checkout@v7
- uses: actions/setup-java@v6
with:
distribution: temurin
java-version: "25"
- uses: gradle/actions/setup-gradle@v6
# Seeds each row of parity/mutations.json into the sources, runs verify,
# and requires it to go red; restores the file before the next row. A
# surviving row fails the job, and so does a stale one.
- name: tools/duet mutate
run: tools/duet mutate | tee mutate.log
# The receipt table the drill prints, on the run's summary page.
- name: receipt
if: always()
run: grep '^|' mutate.log >> "$GITHUB_STEP_SUMMARY" || true
```
The job runs on the schedule and on demand, never on a push: each row is a full verify, and the table grows with the app. Trigger it once from the Actions tab and read the table there; the third row is red until the exercise below is done.
## What you now have
* The lanes report read, and the list of modules verify makes no claim about, each with its own step in the workflow.
* Ten commands, each ending in its own pass line, run in one order locally and in the same order by the workflow.
* A repository of your own whose `checks`, `apple` and `android` jobs are green on a push, with the Kotlin/Native toolchain cached between runs.
* A mutation table with three rows and the drill's receipt on a nightly run's summary page; two rows caught, one surviving until the recording below exists.
## Exercise: record the leaf that catches the surviving row
`tutorial6-start` carries `Tutorial6ExerciseSurvivingMutationTest`, a failing placeholder in the root module. The surviving row deletes the guard that keeps a late sign-in completion inert, and no recording sends one. Add a branch to the root scenario that brings main up and then delivers a `Completed` from a gate that is gone:
```kotlin src-kmp/subtrees/root/logic/src/jvmTest/kotlin/dev/modaal/foyer/root/RootScenarioTest.kt theme={null}
branch("late sign-in inert") {
whenAction("the host reports an onboarded session", RootAction.AuthChanged(onboardedAnn))
whenAction("the splash completes", splashCompleted)
then("main is up") { it.phase == RootPhase.Main }
whenAction(
"a sign-in completion arrives with the gate long gone",
RootAction.SignIn(SignInDelegateEvent.Completed("mallory", hasOnboarded = false)))
then("nothing changed: the phase and the session are as they were") {
it.phase == RootPhase.Main && it.auth == onboardedAnn
}
thenEffects("nothing") { it.isEmpty() }
}
```
List `root.late-sign-in-inert` in the manifest under the root's fixtures and in the root's feature spec, add its replay to the golden test, and delete the stub:
```kotlin src-kmp/subtrees/root/logic/src/jvmTest/kotlin/dev/modaal/foyer/root/RootGoldenTest.kt theme={null}
@Test fun lateSignInInertLeaf() = replay("root.late-sign-in-inert")
```
Run `tools/duet record --feature root`, then `tools/duet verify`, which replays 73 fixtures, then the drill:
```text theme={null}
duet mutate: verifying the clean tree is green first…
✓ splash-arming-guard: caught by [kotlin]
✓ root-splash-hold: caught by [kotlin]
✓ root-late-signin-guard: caught by [kotlin]
duet mutate: PASS — 3/3 caught in 12.1s
```
`tutorial6-complete` carries the recording, the table and the workflow. A mutation that survives has told you exactly which recording is missing, and the recording you add is a test the drill will keep checking on every nightly run.
## Common questions
Each row is a full verify run plus a baseline: seconds per row on a laptop with a warm Gradle daemon, as the output above shows, and under three minutes for the baseline and three rows on a hosted macOS runner that starts cold, with the table growing as the app does. The drill also edits source files while it runs, which a per-push gate must not do. The workflow gives it a schedule and a manual trigger, and the summary page carries its table, so a surviving row is read the next morning rather than blocking a push.
Then the guard the row deletes is unobservable, and the row does not belong in the table. The root has one such guard: the arm that ignores a splash `Completed` after the splash phase. Without it, a late completion re-enters the phase the session already implies, which is the phase the root is in, with no effect, so every recording passes with the guard gone and no recording can be written that fails. The guard stays as a statement of intent; the table holds only rows whose defect a recording can see.
GitHub reads workflow files from a repository's root `.github/workflows/` directory only. The tutorials repository is many trees in one repository, so the file in `tutorial6-complete/.github/workflows/` is inert there; the repository's own workflows run `scripts/run-tree.sh` over every tree, which runs the same ten commands. In a repository of your own the tree is the root, and the file runs as written.
The macOS runner carries Xcode, which the Apple boundary lane and the iOS build need, and the hosted macOS image ships no Android SDK. The hosted ubuntu image ships the SDK and no Xcode. One job per runner, each with the steps that runner can carry, is the whole arrangement; the alternative is installing an SDK on every macOS run.
A row targets source text, and source text moves. A row whose `old` no longer matches, or matches twice, would either seed nothing or seed the wrong site, and either way the verdict would be meaningless; the drill fails on it as a configuration error, so the table sweeps itself when a reducer is refactored. Recordings do not have this problem, which is why a row is a negative control for the recordings and not a replacement for them.
Because you run it on a tree with uncommitted edits, while writing the next row. The drill reads the file, applies the substitution, runs the suite, and writes the bytes it read back, so your edits to that file survive. If the drill is interrupted mid-row the file is left mutated, which is why it prints the `git checkout` line before every run.
## Sources and further reading
* [The duet-tools repository](https://github.com/modaal-agent/duet-tools) — the verbs the checks are made of, `lanes`, `verify`, `record --check`, `mocks --check`, `protocol-run` and `mutate`, and `contracts/manifest.md`, the grammar the lanes derive from.
* [The duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials) — `scripts/run-tree.sh`, the same commands as one script, and `.github/workflows/trees.yml` and `nightly.yml`, which run it over every tree.
* [GitHub Actions: Workflow syntax](https://docs.github.com/en/actions/writing-workflows/workflow-syntax-for-github-actions) — `on.schedule`, `concurrency`, `permissions` and the `if` expressions the jobs use.
* [GitHub Actions: Adding a job summary](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/workflow-commands-for-github-actions#adding-a-job-summary) — `$GITHUB_STEP_SUMMARY`, where the drill's table lands.
* [GitHub Actions: Caching dependencies](https://docs.github.com/en/actions/writing-workflows/choosing-what-your-workflow-does/caching-dependencies-to-speed-up-workflows) — the restore and save steps around the Kotlin/Native toolchain.
* [The Duet glossary](/articles/duet-glossary) — [the checks](/articles/duet-glossary#gate), [lane](/articles/duet-glossary#lane), [mutation drill](/articles/duet-glossary#mutation-drill) and [golden recording](/articles/duet-glossary#golden-fixture).
## Read next
The tree this page puts under CI: the onboarding gate, the upgrade flow, the deep links and the route spine.
The cards move onto a token vocabulary generated for both platforms, and a high-contrast theme ships as one mapping with no feature-code change.
Every string moves into string catalogs on iOS and resources on Android, German joins as a second language, and no recording changes.
Seven events emitted by the reducers as effect data, a console sink worker on each platform, and the recordings checking every event.
The nine tutorials, the app they build, the prerequisites and the versions they are verified against.
# Duet Tutorial 7: Theming with Design Tokens
Source: https://docs.modaal.dev/tutorials/duet-07-theming
Declare a token vocabulary once, generate accessors for SwiftUI and Compose, bind the cards to it, and ship a high-contrast theme that changes no feature code.
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](/tutorials/duet), and it starts from [Tutorial 6](/tutorials/duet-06-checks-in-ci)'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 [Modaal](https://modaal.dev), 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 Modaal](/articles/new-project) 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](https://github.com/modaal-agent/duet-tutorials). 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:
```sh theme={null}
tools/duet verify
```
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
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`](https://github.com/modaal-agent/duet-tools/blob/main/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.
```yaml parity/design-tokens.yaml theme={null}
version: 1
swift:
output: src-ios/Libraries/FoyerKit/Sources/Theming/Generated
engine: DuetTheming
theme: MainTheme
kotlin:
output: src-kmp/theming/src/commonMain/kotlin/dev/modaal/foyer/theming/Generated
package: dev.modaal.foyer.theming
engine: dev.modaal.duet.services.theming
palette: MainPalette
```
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.
```yaml parity/design-tokens.yaml theme={null}
colors:
- group: Surfaces
tokens:
- name: surface
doc: >-
The page. In the high-contrast theme every card sits directly on
it.
light: "#FFFFFF"
dark: "#121212"
- name: cardSurface
doc: >-
The ground of a card: the Insights card and the plan cards.
light: "#F1F1EE"
dark: "#262626"
- name: lockedSurface
doc: >-
The ground of the Insights card while the entitlement is Free.
note: >-
A warm wash rather than a grey: the locked state is a state of the
card, and a colour the eye reads before the lock glyph does.
light: "#FBF3DC"
dark: "#3B3320"
- group: Labels
tokens:
- name: labelPrimary
doc: >-
Titles on cards: the card's name, the plan's name and price.
light: "#1A1A1A"
dark: "#EDEDED"
- name: labelSecondary
doc: >-
Supporting text on cards, one step down: the teaser under the
card's name.
light: "#6A6A6A"
dark: "#9C9C9C"
- name: labelLocked
doc: >-
The lock glyph and the word under a locked card's name.
light: "#7A5C0A"
dark: "#E5C15C"
- group: Chrome
tokens:
- name: cardBorder
doc: >-
The hairline around every card.
note: >-
Translucent, so it reads as a tint of whichever surface the card sits
on; the high-contrast theme maps it onto the primary label, which
makes it a solid line of ink.
light: "#1A1A1A"
lightAlpha: 0.12
dark: "#EDEDED"
darkAlpha: 0.16
```
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:
```yaml parity/design-tokens.yaml theme={null}
fonts:
- group: The two card styles
tokens:
- name: cardTitle
doc: >-
The card's name, and the plan's name and price.
family: sans
weight: 600
size: 17
lineHeight: 22
swift:
textStyle: headline
- name: cardBody
doc: >-
The teaser under a card's name.
family: sans
weight: 400
size: 15
lineHeight: 20
swift:
textStyle: subheadline
```
Generate:
```sh theme={null}
tools/duet design-tokens
```
```text theme={null}
duet design-tokens: wrote 6 token file(s):
src-ios/Libraries/FoyerKit/Sources/Theming/Generated/SemanticColor.swift
src-ios/Libraries/FoyerKit/Sources/Theming/Generated/SemanticFont.swift
src-ios/Libraries/FoyerKit/Sources/Theming/Generated/MainThemePalette.swift
src-kmp/theming/src/commonMain/kotlin/dev/modaal/foyer/theming/Generated/SemanticColor.kt
src-kmp/theming/src/commonMain/kotlin/dev/modaal/foyer/theming/Generated/SemanticFont.kt
src-kmp/theming/src/commonMain/kotlin/dev/modaal/foyer/theming/Generated/MainPalette.kt
review and commit the diff (generated token sources are committed build products)
```
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.
```swift src-ios/Libraries/FoyerKit/Sources/Theming/Generated/SemanticColor.swift theme={null}
public enum SemanticColor: ColorAssetable {
// MARK: - Surfaces
/// The page. In the high-contrast theme every card sits directly on it.
case surface
/// The ground of a card: the Insights card and the plan cards.
case cardSurface
/// The ground of the Insights card while the entitlement is Free.
case lockedSurface
// MARK: - Labels
/// Titles on cards: the card's name, the plan's name and price.
case labelPrimary
/// Supporting text on cards, one step down: the teaser under the card's name.
case labelSecondary
/// The lock glyph and the word under a locked card's name.
case labelLocked
// MARK: - Chrome
/// The hairline around every card.
case cardBorder
}
```
```kotlin src-kmp/theming/src/commonMain/kotlin/dev/modaal/foyer/theming/Generated/SemanticColor.kt theme={null}
enum class SemanticColor {
// Surfaces
/**
* The page. In the high-contrast theme every card sits directly on it.
*/
surface,
/**
* The ground of a card: the Insights card and the plan cards.
*/
cardSurface,
/**
* The ground of the Insights card while the entitlement is Free.
*/
lockedSurface,
// Labels
/**
* Titles on cards: the card's name, the plan's name and price.
*/
labelPrimary,
/**
* Supporting text on cards, one step down: the teaser under the card's name.
*/
labelSecondary,
/**
* The lock glyph and the word under a locked card's name.
*/
labelLocked,
// Chrome
/**
* The hairline around every card.
*/
cardBorder,
}
```
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:
```swift src-ios/Libraries/FoyerKit/Sources/Theming/Generated/MainThemePalette.swift theme={null}
case .lockedSurface:
return ColorSet(.auto(light: tokenColor(0xFBF3DC), dark: tokenColor(0x3B3320)))
// MARK: Labels
case .labelPrimary:
return ColorSet(.auto(light: tokenColor(0x1A1A1A), dark: tokenColor(0xEDEDED)))
case .labelSecondary:
return ColorSet(.auto(light: tokenColor(0x6A6A6A), dark: tokenColor(0x9C9C9C)))
case .labelLocked:
return ColorSet(.auto(light: tokenColor(0x7A5C0A), dark: tokenColor(0xE5C15C)))
// MARK: Chrome
// Translucent, so it reads as a tint of whichever surface the card sits on;
// the high-contrast theme maps it onto the primary label, which makes it a
// solid line of ink.
case .cardBorder:
return ColorSet(.auto(
light: tokenColor(0x1A1A1A, alpha: 0.12),
dark: tokenColor(0xEDEDED, alpha: 0.16)))
```
```kotlin src-kmp/theming/src/commonMain/kotlin/dev/modaal/foyer/theming/Generated/MainPalette.kt theme={null}
// Labels
SemanticColor.labelPrimary -> ColorToken.auto(light = 0xFF1A1A1A, dark = 0xFFEDEDED)
SemanticColor.labelSecondary -> ColorToken.auto(light = 0xFF6A6A6A, dark = 0xFF9C9C9C)
SemanticColor.labelLocked -> ColorToken.auto(light = 0xFF7A5C0A, dark = 0xFFE5C15C)
// Chrome
// Translucent, so it reads as a tint of whichever surface the card sits
// on; the high-contrast theme maps it onto the primary label, which makes
// it a solid line of ink.
SemanticColor.cardBorder -> ColorToken.auto(light = 0x1F1A1A1A, dark = 0x29EDEDED)
```
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:
```sh theme={null}
tools/duet design-tokens --check
```
```text theme={null}
duet design-tokens --check: 6 generated token file(s) up to date
```
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.
The generated files import an engine on each side, and the engines are two products of the duet-services release the tree already pins for [Tutorial 9](/tutorials/duet-09-analytics#link-the-grammar)'s telemetry. On iOS, `FoyerKit` gains a `Theming` target over the `DuetTheming` product, and the package gains its second family dependency, pinned exactly like the first:
```swift src-ios/Libraries/FoyerKit/Package.swift theme={null}
.package(url: "https://github.com/modaal-agent/duet-services.git", exact: "0.11.1"),
```
```swift src-ios/Libraries/FoyerKit/Package.swift theme={null}
// The theming layer: the vocabularies and the value table generated from
// parity/design-tokens.yaml, the two themes over them, the call-site
// accessors and the scope that picks the theme from the system setting.
// iOS-only behind whole-file guards, as the engine is, so the target
// compiles to nothing in the macOS slice the shells lane builds.
.target(
name: "Theming",
dependencies: [
.product(name: "DuetTheming", package: "duet-services")
],
swiftSettings: strictConcurrency
),
```
The engine is iOS-only, and so is every file in this target; the package also builds for macOS, where the shells lane runs `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:
```kotlin src-kmp/theming/build.gradle.kts theme={null}
plugins {
alias(libs.plugins.kotlin.multiplatform)
}
base.archivesName.set("theming")
// The theming module: the token vocabularies and the value table generated
// from parity/design-tokens.yaml (`Generated/`, written by `tools/duet
// design-tokens` and gated by `tools/duet design-tokens --check`), and the
// two themes over them, hand-authored in Theme.kt. The JVM target is the only
// one: the Android app consumes this module's JVM variant, and the iOS app
// reads the same config through its own engine, so no Apple slice has a
// consumer.
kotlin {
jvmToolchain(25)
jvm()
sourceSets {
commonMain.dependencies {
// ColorToken and FontToken, the value types the generated table is
// written in.
api(libs.duet.services.theming)
}
jvmTest.dependencies {
implementation(kotlin("test"))
}
}
}
tasks.withType().configureEach { useJUnitPlatform() }
```
Include it in `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:
```swift src-ios/Libraries/FoyerKit/Sources/Theming/MainTheme.swift theme={null}
public class MainTheme: Themed, Assetable {
public typealias _ImageAsset = EmptyAsset
public typealias _GradientAsset = EmptyAsset
}
```
```swift src-ios/Libraries/FoyerKit/Sources/Theming/MainTheme.swift theme={null}
public func fontSet(for asset: _FontAsset) -> FontSet {
let token = fontToken(for: asset)
let scaled = UIFontMetrics(forTextStyle: token.textStyle).scaledFont(for: token.face)
return FontSet(
.static(scaled),
fontMetrics: FontMetrics(
pointSize: token.size,
lineHeight: token.lineHeight,
// The config states tracking as a fraction of the em; `.pct` is a
// percentage of the point size, the same quantity times 100.
letterSpacing: .pct(token.trackingEm * 100)
)
)
}
```
The engine finds a theme by key and resolves both registrations by cast at the first lookup, so the two conformances live in one file. `Themes.swift` registers `.mainTheme` and, ahead of step 6, `.highContrast`:
```swift src-ios/Libraries/FoyerKit/Sources/Theming/Themes.swift theme={null}
extension Theme: @retroactive Themeable {
public static let mainTheme = Theme(key: "mainTheme")
public static let highContrast = Theme(key: "highContrast")
public func themed() -> Themed {
switch self {
case .mainTheme:
return MainTheme()
case .highContrast:
return HighContrastTheme()
default:
fatalError("no theme is registered under the key \(key)")
}
}
}
```
On Android the theme is the app's own interface over the two vocabularies, and the main theme reads `MainPalette` entry for entry:
```kotlin src-kmp/theming/src/commonMain/kotlin/dev/modaal/foyer/theming/Theme.kt theme={null}
interface Theme {
fun color(token: SemanticColor): ColorToken
fun font(token: SemanticFont): FontToken
}
```
```kotlin src-kmp/theming/src/commonMain/kotlin/dev/modaal/foyer/theming/Theme.kt theme={null}
object MainTheme : Theme {
override fun color(token: SemanticColor): ColorToken = MainPalette.color(token)
override fun font(token: SemanticFont): FontToken = MainPalette.font(token)
}
```
The Kotlin module carries the theme tests, and the first one reads the table the way a screen will. Every token has a value in both appearances, and every label meets WCAG level AA, 4.5:1, on every surface it can sit on. The contrast ratio is fifteen lines in the test source set:
```kotlin src-kmp/theming/src/jvmTest/kotlin/dev/modaal/foyer/theming/Contrast.kt theme={null}
fun contrastRatio(a: Long, b: Long): Double {
val la = relativeLuminance(a)
val lb = relativeLuminance(b)
return (maxOf(la, lb) + 0.05) / (minOf(la, lb) + 0.05)
}
```
```kotlin src-kmp/theming/src/jvmTest/kotlin/dev/modaal/foyer/theming/MainThemeTest.kt theme={null}
fun everyLabelReadsAtAaOnEveryCard() {
for (appearance in ResolvedAppearance.entries) {
for (surface in surfaces) {
for (label in labels) {
val ratio = MainTheme.contrast(label, surface, appearance)
assertTrue(ratio >= 4.5, "$label on $surface at $appearance: ${"%.2f".format(ratio)}")
}
}
val locked = MainTheme.contrast(SemanticColor.labelLocked, SemanticColor.lockedSurface, appearance)
assertTrue(locked >= 4.5, "labelLocked on lockedSurface at $appearance: ${"%.2f".format(locked)}")
}
}
```
```sh theme={null}
(cd src-kmp && ./gradlew :theming:jvmTest)
```
The module is no feature module, so `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.
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:
```swift src-ios/Libraries/FoyerKit/Sources/Theming/ThemeAccessors.swift theme={null}
public extension ThemeProviding {
/// A colour token as a SwiftUI `Color`. Under the system appearance the
/// colour is dynamic, so a view holding it follows a light/dark switch on
/// its own.
func color(_ semanticColor: SemanticColor) -> Color {
Color(color(for: semanticColor, preferredAppearance: nil, on: nil))
}
/// A type token resolved for rendering: the face, the line height and the
/// tracking together. `.font(theme.font(.cardTitle))` applies all three.
func font(_ semanticFont: SemanticFont) -> ThemedFont {
let resolved = font(for: semanticFont, preferredAppearance: nil, on: nil)
return ThemedFont(face: resolved.font, metrics: resolved.metrics)
}
}
```
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:
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/FoyerTheme.kt theme={null}
/** A colour token's value under the theme in effect, at the resolved appearance. */
@Composable
@ReadOnlyComposable
fun SemanticColor.color(): Color = Color(LocalTheme.current.color(this).value(LocalAppearance.current))
/** A type token as a text style, in the platform's face for its family. */
@Composable
@ReadOnlyComposable
fun SemanticFont.textStyle(): TextStyle = LocalTheme.current.font(this).textStyle()
```
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:
```swift src-ios/Libraries/FoyerKit/Sources/Theming/FoyerThemeScope.swift theme={null}
public struct FoyerThemeScope: View {
@Environment(\.colorSchemeContrast) private var contrast
private let content: Content
public init(@ViewBuilder content: () -> Content) {
self.content = content()
}
public var body: some View {
ThemeScope(contrast == .increased ? FoyerThemes.highContrast : FoyerThemes.main) { content }
}
}
```
```swift src-ios/Libraries/FoyerKit/Sources/Theming/FoyerThemeScope.swift theme={null}
@MainActor
enum FoyerThemes {
static let main = provider(for: .mainTheme)
static let highContrast = provider(for: .highContrast)
private static func provider(for theme: Theme) -> ThemeProvider {
ThemeProvider(persistentStorage: Unpersisted(), defaultTheme: theme, defaultPreferredAppearance: .system)
}
}
```
The Compose twin provides the two locals and keeps Material's default scheme for the components this app does not draw itself:
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/FoyerTheme.kt theme={null}
fun FoyerTheme(content: @Composable () -> Unit) {
val dark = isSystemInDarkTheme()
val theme = if (rememberSystemContrast() > 0f) HighContrastTheme else MainTheme
CompositionLocalProvider(
LocalTheme provides theme,
LocalAppearance provides if (dark) ResolvedAppearance.Dark else ResolvedAppearance.Light,
) {
MaterialTheme(colorScheme = if (dark) darkColorScheme() else lightColorScheme(), content = content)
}
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/FoyerTheme.kt theme={null}
private fun rememberSystemContrast(): Float {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) return 0f
val context = LocalContext.current
val manager = remember(context) { context.getSystemService(UiModeManager::class.java) }
var contrast by remember(manager) { mutableFloatStateOf(manager.contrast) }
DisposableEffect(manager) {
val listener = UiModeManager.ContrastChangeListener { contrast = it }
manager.addContrastChangeListener(context.mainExecutor, listener)
onDispose { manager.removeContrastChangeListener(listener) }
}
return contrast
}
```
Each root wraps its tree in the scope. The scene delegate publishes to the hosting controller's view, and `AppRoot` replaces its `MaterialTheme` call:
```swift src-ios/App/Foyer/SceneDelegate.swift theme={null}
// The theme is published once, here, to the whole tree: the scope picks
// the main or the high-contrast theme from the system's contrast setting.
window.rootViewController = UIHostingController(
rootView: FoyerThemeScope { RootView(viewState: root.shell.viewState, shell: root.shell) })
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/AppRoot.kt theme={null}
FoyerTheme {
Surface(Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Box(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) {
when (val current = child) {
is RootChildMount.Splash -> SplashScreen(current.store)
is RootChildMount.SignIn -> SignInScreen(current.store)
is RootChildMount.Onboarding -> OnboardingScreen(current.mount)
is RootChildMount.Main -> MainScreen(current.mount)
null -> Unit
}
}
}
}
```
The app target gains the `Theming` product in `xcodegen.yml`, beside `RootShell`.
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:
```swift src-ios/Libraries/FoyerKit/Sources/HomeShell/HomeView.swift theme={null}
struct InsightsCard: View {
@Environment(\.theme) private var theme
let locked: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
HStack {
VStack(alignment: .leading, spacing: 4) {
Text("Insights")
.font(theme.font(.cardTitle))
.foregroundStyle(theme.color(.labelPrimary))
Text(locked ? "Premium" : "Your week at a glance")
.font(theme.font(.cardBody))
.foregroundStyle(theme.color(locked ? .labelLocked : .labelSecondary))
}
Spacer()
if locked {
Image(systemName: "lock.fill")
.foregroundStyle(theme.color(.labelLocked))
}
}
.padding(16)
.background(theme.color(locked ? .lockedSurface : .cardSurface), in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(theme.color(.cardBorder), lineWidth: 1))
}
.buttonStyle(.plain)
}
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/HomeScreen.kt theme={null}
private fun InsightsCard(entitlement: Entitlement, onClick: () -> Unit) {
val locked = entitlement is Entitlement.Free
val surface = if (locked) SemanticColor.lockedSurface else SemanticColor.cardSurface
val teaser = if (locked) SemanticColor.labelLocked else SemanticColor.labelSecondary
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp),
colors = CardDefaults.cardColors(containerColor = surface.color()),
border = BorderStroke(1.dp, SemanticColor.cardBorder.color()),
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text("Insights", style = SemanticFont.cardTitle.textStyle(), color = SemanticColor.labelPrimary.color())
Text(
if (locked) "Premium" else "Your week at a glance",
style = SemanticFont.cardBody.textStyle(),
color = teaser.color(),
)
}
if (locked) {
Spacer(Modifier.width(12.dp))
Icon(Icons.Filled.Lock, contentDescription = "Locked", tint = SemanticColor.labelLocked.color())
}
}
}
}
```
The plan cards bind the same three tokens and the title style:
```swift src-ios/Libraries/FoyerKit/Sources/UpgradeShell/UpgradeView.swift theme={null}
ForEach(viewState.cards, id: \.name) { card in
Button {
shell.selectPlan(card.plan)
} label: {
HStack {
Text(card.name)
.font(theme.font(.cardTitle))
Spacer()
Text(card.price)
.font(theme.font(.cardTitle))
}
.foregroundStyle(theme.color(.labelPrimary))
.padding(16)
.background(theme.color(.cardSurface), in: RoundedRectangle(cornerRadius: 12))
.overlay(RoundedRectangle(cornerRadius: 12).stroke(theme.color(.cardBorder), lineWidth: 1))
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/UpgradeSheet.kt theme={null}
offers.forEach { offer ->
Card(
onClick = { store.send(UpgradeAction.PlanSelected(offer.plan)) },
modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp),
colors = CardDefaults.cardColors(containerColor = SemanticColor.cardSurface.color()),
border = BorderStroke(1.dp, SemanticColor.cardBorder.color()),
) {
Row(Modifier.padding(16.dp), verticalAlignment = Alignment.CenterVertically) {
Text(
offer.plan.planName,
style = SemanticFont.cardTitle.textStyle(),
color = SemanticColor.labelPrimary.color(),
modifier = Modifier.weight(1f),
)
Text(offer.price, style = SemanticFont.cardTitle.textStyle(), color = SemanticColor.labelPrimary.color())
}
}
}
```
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.
```sh theme={null}
tools/duet mocks && tools/duet verify
```
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:
```swift src-ios/Libraries/FoyerKit/Sources/Theming/HighContrastTheme.swift theme={null}
public final class HighContrastTheme: Themed, Assetable {
public typealias _ImageAsset = EmptyAsset
public typealias _GradientAsset = EmptyAsset
private let main = MainTheme()
public func colorSet(for asset: SemanticColor) -> ColorSet {
main.colorSet(for: asset.highContrastSource)
}
public func fontSet(for asset: SemanticFont) -> FontSet {
main.fontSet(for: asset)
}
}
```
```kotlin src-kmp/theming/src/commonMain/kotlin/dev/modaal/foyer/theming/Theme.kt theme={null}
object HighContrastTheme : Theme {
override fun color(token: SemanticColor): ColorToken = MainPalette.color(token.highContrastSource)
override fun font(token: SemanticFont): FontToken = MainPalette.font(token)
}
```
```swift src-ios/Libraries/FoyerKit/Sources/Theming/HighContrastTheme.swift theme={null}
extension SemanticColor {
/// The main-palette entry a token reads in the high-contrast theme.
var highContrastSource: SemanticColor {
switch self {
case .surface, .cardSurface, .lockedSurface:
return .surface
case .labelPrimary, .labelSecondary, .labelLocked, .cardBorder:
return .labelPrimary
}
}
}
```
```kotlin src-kmp/theming/src/commonMain/kotlin/dev/modaal/foyer/theming/Theme.kt theme={null}
internal val SemanticColor.highContrastSource: SemanticColor
get() =
when (this) {
SemanticColor.surface,
SemanticColor.cardSurface,
SemanticColor.lockedSurface -> SemanticColor.surface
SemanticColor.labelPrimary,
SemanticColor.labelSecondary,
SemanticColor.labelLocked,
SemanticColor.cardBorder -> SemanticColor.labelPrimary
}
```
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:
```sh theme={null}
xcrun simctl ui booted increase_contrast enabled
adb shell settings put secure contrast_level 1
```
Both apps re-render in place: the scope publishes the other provider on iOS, and the contrast listener recomposes on Android.
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.
[Tutorial 6](/tutorials/duet-06-checks-in-ci#put-the-checks-in-a-workflow)'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:
```yaml .github/workflows/parity.yml theme={null}
- name: tools/duet design-tokens --check
run: tools/duet design-tokens --check
```
```yaml .github/workflows/parity.yml theme={null}
- name: theming tests
run: (cd src-kmp && ./gradlew :theming:jvmTest --console=plain -q)
```
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:
```kotlin src-kmp/theming/src/jvmTest/kotlin/dev/modaal/foyer/theming/HighContrastThemeTest.kt theme={null}
class HighContrastThemeTest {
private val labels = listOf(SemanticColor.labelPrimary, SemanticColor.labelSecondary, SemanticColor.labelLocked)
private val surfaces = listOf(SemanticColor.surface, SemanticColor.cardSurface, SemanticColor.lockedSurface)
@Test
fun everyLabelReadsAtAaaOnEveryCard() {
for (appearance in ResolvedAppearance.entries) {
for (surface in surfaces) {
for (label in labels) {
val ratio = HighContrastTheme.contrast(label, surface, appearance)
assertTrue(ratio >= 7.0, "$label on $surface at $appearance: ${"%.2f".format(ratio)}")
}
}
}
}
@Test
fun theCardBorderIsOpaqueInk() {
for (appearance in ResolvedAppearance.entries) {
val border = HighContrastTheme.color(SemanticColor.cardBorder).value(appearance)
assertEquals(0xFFL, (border shr 24) and 0xFF, "the border is opaque at $appearance")
assertEquals(HighContrastTheme.color(SemanticColor.labelPrimary).value(appearance), border)
}
}
@Test
fun theTypeStylesAreTheMainThemes() {
for (token in SemanticFont.entries) {
assertEquals(MainTheme.font(token).sizeSp, HighContrastTheme.font(token).sizeSp, "$token")
assertEquals(MainTheme.font(token).weight, HighContrastTheme.font(token).weight, "$token")
}
}
}
```
```sh theme={null}
(cd src-kmp && ./gradlew :theming:jvmTest) && tools/duet verify
```
`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](/tutorials/duet-08-localization#make-what-the-logic-says-a-value) 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](https://github.com/modaal-agent/duet-services) 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`](https://github.com/modaal-agent/duet-tools/blob/main/contracts/design-tokens.md) — the config grammar, what is generated and what stays hand-authored, and the check.
* [The duet-services repository](https://github.com/modaal-agent/duet-services) — `DuetTheming` and the `theming` artifact: `Themed`, `Assetable`, `ThemeScope`, `ColorToken`, `FontToken` and the Material binding.
* [Apple: `colorSchemeContrast`](https://developer.apple.com/documentation/swiftui/environmentvalues/colorschemecontrast) — the environment value Increase Contrast sets; [`UIFontMetrics`](https://developer.apple.com/documentation/uikit/uifontmetrics) — scaling a face by a Dynamic Type style.
* [Android: `UiModeManager.getContrast()`](https://developer.android.com/reference/android/app/UiModeManager#getContrast\(\)) — the contrast setting and its change listener; [Locally scoped data with CompositionLocal](https://developer.android.com/develop/ui/compose/compositionlocal) — the locals the accessors read.
* [WCAG 2.2: contrast (minimum)](https://www.w3.org/TR/WCAG22/#contrast-minimum) and [contrast (enhanced)](https://www.w3.org/TR/WCAG22/#contrast-enhanced) — the 4.5:1 and 7:1 thresholds the two tests assert, and the [relative luminance](https://www.w3.org/TR/WCAG22/#dfn-relative-luminance) formula the helper implements.
* [The Duet glossary](/articles/duet-glossary) — [shell](/articles/duet-glossary#shell), [the checks](/articles/duet-glossary#gate) and [golden recording](/articles/duet-glossary#golden-fixture).
## Read next
The tree this page opens, and the workflow the two new checks join.
Every string moves into string catalogs on iOS and resources on Android, German joins as a second language, and no recording changes.
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 nine tutorials, the app they build, the prerequisites and the versions they are verified against.
# Duet Tutorial 8: Localizing the App
Source: https://docs.modaal.dev/tutorials/duet-08-localization
Turn every message the logic writes into a value, move every string into catalogs and resources, add German, and see what a translation can no longer change.
In this tutorial you localize Foyer, and the first thing you localize is not a view. Five messages in the shared core are English sentences today: the two refusals of a display name, the two refusals of a sign-in, and a declined purchase. They sit in state, they sit in six recordings, and a translator cannot reach them. You turn each into a value the reducer writes and the view names, re-record the features that carry them, and then move every string the two apps show into each platform's own localization system: one string catalog per shell target on iOS, with accessors generated by the xcstrings-tool plugin at build time, and string resources on Android. Then you add German on both and take the receipt: after the migration, a translation changes no recording. This is the eighth page of the [nine-tutorial series](/tutorials/duet), and it starts from [Tutorial 6](/tutorials/duet-06-checks-in-ci)'s finished tree, as Tutorials 7 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 [Modaal](https://modaal.dev), 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 Modaal](/articles/new-project) when you would rather skip the setup.
## What will you build?
A localized app on both platforms, with the rule that makes it cheap: the shared logic carries values, the views carry strings, and nothing in between carries either. The reducers already say `Free`, `Monthly`, `Guest`, `digest`, and after this tutorial they also say `NameValidation.Empty`, `SignInFailure.NoAccount` and `PurchaseFailure.Declined` where they said "Enter a name.", "No account for that address." and "Payment declined." The views say every sentence, through a catalog or a resource. Two view shells that projected strings into their view state stop doing so, and the tests that compared those strings compare values instead. Expect about two and a half hours: the core change is small, the string moves are many.
You will have at the end:
* Three sealed values in the core, `NameValidation`, `SignInFailure` and `PurchaseFailure`, in place of five English sentences, with six recordings re-recorded to carry a case where they carried a string.
* Nine `Localizable.xcstrings` catalogs, one per shell target, with the xcstrings-tool plugin generating a `String.Localizable` accessor per key on every build; `values/strings.xml` on Android with sixty-five strings and one plural.
* German on both platforms, with a test on each that holds the second language to the first: every key translated, every placeholder matched.
* The two apps running in German from one command each, refusals included, and a test in the root module that fails if a recording ever carries a string the resources own.
## Where do you start?
Open `tutorial8-start` from the [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials). 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:
```sh theme={null}
tools/duet verify
```
The Kotlin lane reports one failure, `Tutorial8ExerciseRecordingsUnchangedTest` in the root 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 steps
Before a catalog exists, count what goes into it. The SwiftUI views under `src-ios/Libraries/FoyerKit/Sources` hold forty string literals and the Compose screens under `src-kmp/app` hold thirty-eight: titles, buttons, placeholders, the tab labels, three accessibility labels, the promo, the plan cards, the insights rows. Then look at what the core says. The entitlement is `Free` or `Premium(plan)`, the plan is `Monthly` or `Yearly`, the provider is `Email` or `Guest`, a preference is its key, `digest`; the price is a string the purchases port formats. None of those is a sentence. Five things the core says are sentences:
```sh theme={null}
grep -rn '"[A-Z][a-z].*\."' src-kmp/subtrees/*/logic/src/commonMain src-kmp/ports/src/commonMain src-kmp/backend-local/src/commonMain
```
The editor's validator returns "Enter a name." or "Keep it to 40 characters or fewer."; the sign-in gate writes "Enter an email address." into state and the auth port answers "No account for that address."; the purchases port answers "Payment declined." Each ends in a state field, `validation` or `failure`, that a view prints as it is, and each is in a recording: `name.empty-name-rejected`, both `editname.*-rejected` fixtures, `signin.empty-address-fails`, `signin.port-failure-lands` and `upgrade.purchase-failure-lands` carry the English sentence in their expected state. A German user gets those five in English, and a translator who fixes the wording changes a recording. That is what step 2 removes. Step 3 then moves the views' forty and thirty-eight, and step 4 the last three strings that live between the core and the views: the profile shell projects `planLabel`, the preferences shell projects a `label` per toggle, and the upgrade view compares one string to another to choose the billing line:
```swift theme={null}
Text("\(price), billed \(planName == "Monthly" ? "every month" : "once a year").")
```
In German the plan's name is "Monatlich" and that comparison is wrong on every card.
A refusal is a fact the reducer knows: the draft was empty, the address has no account, the payment was declined. The sentence that tells the user is the view's, in the user's language. So each message becomes a sealed value, coded like the ports' other sum types, with one case per reason. The name validator lives in the editor's module, where [Tutorial 5](/tutorials/duet-05-navigation-as-state#write-the-onboarding-level-and-its-steps) put it so the editor and the onboarding name step share one rule, and it moves first:
```kotlin src-kmp/subtrees/editname/logic/src/commonMain/kotlin/dev/modaal/foyer/editname/EditNameFeature.kt theme={null}
@Serializable(with = NameValidationSerializer::class)
sealed interface NameValidation {
@Serializable @SerialName("empty") data object Empty : NameValidation
@Serializable @SerialName("tooLong") data object TooLong : NameValidation
}
/**
* The one validation of a display name: the refusal for a draft the app
* rejects, or null for one it accepts. The editor and the onboarding name
* step both call it, so the two screens never disagree.
*/
fun validateDisplayName(draft: String): NameValidation? {
val name = draft.trim()
return when {
name.isEmpty() -> NameValidation.Empty
name.length > EditNameConfig.MAX_LENGTH -> NameValidation.TooLong
else -> null
}
}
```
The two refusals of a sign-in and the one refusal of a purchase go into the ports module, because the port's answer carries them: `SignInOutcome.Failed(reason: SignInFailure)` and `PurchaseOutcome.Failed(reason: PurchaseFailure)`.
```kotlin src-kmp/ports/src/commonMain/kotlin/dev/modaal/foyer/ports/Ports.kt theme={null}
@Serializable(with = SignInFailureSerializer::class)
sealed interface SignInFailure {
/** The gate refuses an empty address before it reaches the port. */
@Serializable @SerialName("emptyAddress") data object EmptyAddress : SignInFailure
/** The port knows no account for the address. */
@Serializable @SerialName("noAccount") data object NoAccount : SignInFailure
}
```
Each gets a `CanonicalSumSerializer` beside the others, and the state fields change type: `validation: NameValidation?` on the name step and the editor, `failure: SignInFailure?` on the gate, `failure: PurchaseFailure?` on the flow. The reducers do not change. The name step still writes whatever the validator returned, the gate still writes `SignInFailure.EmptyAddress` where it wrote the constant, and both still write the port's `reason` through:
```kotlin src-kmp/subtrees/name/logic/src/commonMain/kotlin/dev/modaal/foyer/name/NameFeature.kt theme={null}
NameAction.ContinueTapped ->
when (val validation = validateDisplayName(state.draft)) {
null ->
Reduced(
state,
listOf(
Effect.Run(
NameEffectPayload.NotifyHost(NameDelegateEvent.Continued(state.draft.trim())))))
else -> Reduced(state.copy(validation = validation))
```
The on-device backend answers with a case instead of a sentence, and the scenarios compare cases:
```kotlin src-kmp/subtrees/signin/logic/src/jvmTest/kotlin/dev/modaal/foyer/signin/SignInScenarioTest.kt theme={null}
then("the reason is shown, the latch released") {
it.failure == SignInFailure.NoAccount && !it.isSigningIn
```
Now re-record the four features. This is the one point in the tutorial where a recording changes, and the diff says exactly what moved:
```sh theme={null}
for f in name editname signin upgrade; do tools/duet record --feature $f; done
git diff --stat -- parity/fixtures
```
```diff theme={null}
"expectedState": {
"draft": "",
"isReady": false,
- "validation": "Enter a name."
+ "validation": {
+ "case": "empty"
+ }
},
```
Six recordings change like this, a string replaced by a case; seven more of the same features change only in the scenario line numbers their steps cite, which `tools/duet record --check` treats as metadata. Nothing else on the tree moved: no action, no effect, no other field. The feature specs under `parity/feature-specs` name the new types in their state tables, and `tools/duet verify` is green again.
Two things now hold. A translator can change every one of those five sentences without touching the core, because the core no longer has them. And the shells on both platforms receive a case and must name it, which is the next two steps.
On iOS a string catalog is a JSON file, `Localizable.xcstrings`, that Xcode edits as a table and the build compiles into the resource bundle. Each shell target gets its own, beside its views, and the [xcstrings-tool plugin](https://github.com/liamnichols/xcstrings-tool-plugin) turns every key into a Swift accessor at build time. The package manifest declares the source language, adds the plugin as its second dependency, pinned exactly like the first, and gives every shell target its catalog as a resource and the plugin as a build tool:
```swift src-ios/Libraries/FoyerKit/Package.swift theme={null}
.package(url: "https://github.com/modaal-agent/duet.git", exact: "0.7.0"),
// The string-catalog accessor generator: a build-tool plugin that writes
// `String.Localizable` from each shell's Localizable.xcstrings at build
// time. Pinned exactly, as every dependency of this tree is.
.package(url: "https://github.com/liamnichols/xcstrings-tool-plugin.git", exact: "1.2.0"),
```
```swift src-ios/Libraries/FoyerKit/Package.swift theme={null}
name: "OnboardingShell",
dependencies: [
"FoyerBridge",
.target(name: "FoyerKit"),
.product(name: "DuetShells", package: "duet"),
],
// The shell's strings, and, after the settings, the plugin that
// generates their accessors.
resources: [.process("Localizable.xcstrings")],
swiftSettings: strictConcurrency,
plugins: [.plugin(name: "XCStringsToolPlugin", package: "xcstrings-tool-plugin")]
```
The manifest also gains `defaultLocalization: "en"` at the top. A catalog entry is a key, a state, and one string unit per language; the key is lowerCamelCase because it becomes a Swift identifier. This is the onboarding shell's progress line, with the German it gets in step 5 already in place, and `%lld` where an integer goes:
```json src-ios/Libraries/FoyerKit/Sources/OnboardingShell/Localizable.xcstrings theme={null}
"stepOf" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"stringUnit" : {
"state" : "translated",
"value" : "Schritt %lld von %lld"
}
},
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Step %lld of %lld"
}
}
}
},
```
Build once and the plugin writes one file per catalog under `.build/plugins/outputs`, never into the tree. The first build on a machine asks you to trust the plugin, in Xcode's dialog or in `swift build`'s prompt; a CI runner has no one to answer, so the workflow's `xcodebuild` line passes `-skipPackagePluginValidation`, and `scripts/run-tree.sh` does the same. For that entry it generates a function, with one `Int` parameter per specifier; a key without specifiers becomes a static property:
```swift theme={null}
/// Source localization: Step %lld of %lld
internal static func stepOf(_ arg1: Int, _ arg2: Int) -> Localizable {
Localizable(
key: "stepOf",
arguments: [
.int(arg1),
.int(arg2)
],
table: "Localizable"
)
}
```
The same file gives you `Text(localizable:)` for SwiftUI, which resolves the string through the target's own resource bundle, and `String(localized: .localizable(…))` for an API that wants a `String`: a text field's prompt, a button's title, an accessibility label. The generated type is internal to its target, which is why the catalog lives beside the views that read it. The progress row and the welcome step now read:
```swift src-ios/Libraries/FoyerKit/Sources/OnboardingShell/OnboardingView.swift theme={null}
struct ProgressRowView: View {
@ObservedObject var viewState: ProgressViewState
let shell: ProgressViewShell
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(localizable: .stepOf(viewState.stepNumber, viewState.stepCount))
.font(.subheadline.weight(.semibold))
Spacer()
ForEach(1...viewState.stepCount, id: \.self) { number in
Image(systemName: viewState.readySteps.contains(number) ? "checkmark.circle.fill" : "circle")
.foregroundStyle(viewState.readySteps.contains(number) ? Color.accentColor : Color.secondary)
.accessibilityLabel(
String(localized: .localizable(viewState.readySteps.contains(number) ? .stepReady(number) : .stepNotReady(number))))
}
}
ProgressView(value: Double(viewState.stepNumber), total: Double(viewState.stepCount))
}
.onAppear { shell.appeared() }
}
}
```
```swift src-ios/Libraries/FoyerKit/Sources/OnboardingShell/OnboardingView.swift theme={null}
struct WelcomeStepView: View {
let shell: WelcomeViewShell
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(localizable: .welcomeTitle)
.font(.title)
Text(localizable: .welcomeBody)
.foregroundStyle(.secondary)
Button {
shell.continueTapped()
} label: {
Text(localizable: .continueAction)
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.padding(.top, 24)
}
.onAppear { shell.appeared() }
}
}
```
Do the same in the other eight shells. The refusals from step 2 arrive in the view state as the reducer's value; each view gains one extension that names the case, and prints that instead of a string it never had:
```swift src-ios/Libraries/FoyerKit/Sources/EditNameShell/EditNameView.swift theme={null}
/// The refusal's string, from its case: the reducer says why, the view says it.
extension NameValidation {
var message: String.Localizable {
switch onEnum(of: self) {
case .empty: .validationEmpty
case .tooLong: .validationTooLong
}
}
```
The editor's view shows it with `Text(localizable: validation.message)`; the onboarding name step has the same extension over the same type, the sign-in view one over `SignInFailure`, the upgrade view one over `PurchaseFailure`. Three shell specs that compared the message compare the case now, `XCTAssertTrue(child.shell.viewState.validation is NameValidationEmpty)`. One string on the insights screen is a count, "4 days", and a count is a plural: the catalog entry carries a `one` and an `other` variation instead of one unit, and the accessor takes the number:
```json src-ios/Libraries/FoyerKit/Sources/HomeShell/Localizable.xcstrings theme={null}
"streakDays" : {
"extractionState" : "manual",
"localizations" : {
"de" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld Tag"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld Tage"
}
}
}
}
},
"en" : {
"variations" : {
"plural" : {
"one" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld day"
}
},
"other" : {
"stringUnit" : {
"state" : "translated",
"value" : "%lld days"
}
}
}
}
}
}
},
```
```swift src-ios/Libraries/FoyerKit/Sources/HomeShell/HomeView.swift theme={null}
InsightRow(label: String(localized: .localizable(.itemsReadThisWeek)), value: "7")
InsightRow(label: String(localized: .localizable(.longestStreak)), value: String(localized: .localizable(.streakDays(4))))
InsightRow(label: String(localized: .localizable(.mostRead)), value: String(localized: .localizable(.readingList)))
```
```sh theme={null}
(cd src-ios/Libraries/FoyerKit && swift build)
```
The build prints one `Output written to` line per catalog, nine in all, and compiles without a string literal left in a view.
A view shell projects state into view state; a string in view state is a string the shell chose, in a language the shell does not know, and a string the shell's tests then compare. The three cases from step 1 each become a value. The upgrade shell's confirm step carried the plan's name; it carries a key now, and the view names the plan and the billing line from it, so nothing compares two strings:
```swift src-ios/Libraries/FoyerKit/Sources/UpgradeShell/UpgradeViewShell.swift theme={null}
/// A plan, as the view names it. The Kotlin `Plan` crosses the bridge as a
/// class; this key is what the view switches on and what the tests compare.
/// The view picks the plan's name and its billing line from it, so no string
/// is ever compared to another.
public enum PlanKey: Hashable {
case monthly
case yearly
}
/// The step, as the view switches on it.
public enum UpgradeStepKey: Equatable {
case plans
case confirm(plan: PlanKey, price: String)
case done
}
/// One plan card.
public struct PlanCard {
public let plan: Plan
public let key: PlanKey
public let price: String
}
```
```swift src-ios/Libraries/FoyerKit/Sources/UpgradeShell/UpgradeView.swift theme={null}
case .confirm(let plan, let price):
Text(localizable: .confirmPlan(String(localized: .localizable(plan.name))))
.font(.title2)
Text(localizable: plan == .monthly ? .billedMonthly(price) : .billedYearly(price))
.foregroundStyle(.secondary)
```
```swift src-ios/Libraries/FoyerKit/Sources/UpgradeShell/UpgradeView.swift theme={null}
/// The plan's name, from its key. The view names the string; the shell never
/// projects one, so nothing downstream can compare two names.
extension PlanKey {
var name: String.Localizable {
switch self {
case .monthly: .planMonthly
case .yearly: .planYearly
}
}
```
The profile shell's `planLabel` becomes `plan`, a three-case value, with the same mapping at the end of the view file; the preferences shell's row keeps the key and drops the label, and the view names the toggle from the key:
```swift src-ios/Libraries/FoyerKit/Sources/ProfileShell/ProfileViewShell.swift theme={null}
/// The plan row's value, as the view names it. The Kotlin `Entitlement`
/// crosses the bridge as a class; this key is what the view switches on and
/// what the tests compare. The shell projects no string for it.
public enum PlanLabel: Equatable {
case free
case premiumMonthly
case premiumYearly
}
```
```swift src-ios/Libraries/FoyerKit/Sources/OnboardingShell/StepShells.swift theme={null}
/// One toggle, as the view lists it: the key the reducer knows, and whether
/// it is on. The view names the toggle from the key.
public struct PreferenceRow: Equatable {
public let key: String
public let isOn: Bool
}
```
```swift src-ios/Libraries/FoyerKit/Sources/OnboardingShell/OnboardingView.swift theme={null}
/// The toggle's label, from its key; a key the catalog does not name shows as itself.
private func label(for key: String) -> Text {
switch key {
case "digest": Text(localizable: .preferenceDigest)
case "reminders": Text(localizable: .preferenceReminders)
case "tips": Text(localizable: .preferenceTips)
default: Text(key)
}
}
```
Four assertions in the shell specs compared the projected strings; they compare the values now, in `MainViewShellSpec` and `RootCompositionSpec`:
```swift src-ios/Libraries/FoyerKit/Tests/MainShellTests/MainViewShellSpec.swift theme={null}
XCTAssertEqual(shell.profile?.shell.viewState.plan, .free)
entitlement.project(EntitlementPremium(plan: PlanYearly.shared))
XCTAssertEqual(shell.home?.shell.viewState.isInsightsLocked, false)
XCTAssertEqual(shell.profile?.shell.viewState.plan, .premiumYearly)
```
That is the whole Swift side: no shell resolves a string, no test compares one, and `swift test` on the package runs its thirty-five shell specs as before. The reason the shells must not resolve strings is not only tidiness; a Common question below has the build fact behind it.
Android has had this system since its first release: `res/values/strings.xml` is the source language, the resource compiler generates `R.string` and `R.plurals` from it, and a Compose screen reads a resource with `stringResource`. The one file the app already had held the launcher label; it holds every string now, and the screens read it by name:
```xml src-kmp/app/src/main/res/values/strings.xml theme={null}
Foyer
One core, two apps
Enter an email address.
No account for that address.
Sign in to continue
Email
Continue with email
Continue as guest
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/OnboardingScreen.kt theme={null}
private fun ProgressRow(store: ProgressStore) {
val state by store.state.collectAsState()
LaunchedEffect(Unit) { store.send(ProgressAction.Appeared) }
Column {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Text(
stringResource(R.string.onboarding_step_of, state.page.stepNumber, STEP_COUNT),
style = MaterialTheme.typography.labelLarge,
modifier = Modifier.weight(1f),
)
OnboardingPage.entries.forEach { page ->
val ready = state.readiness[page] == true
Icon(
if (ready) Icons.Filled.CheckCircle else Icons.Outlined.CheckCircle,
contentDescription =
stringResource(if (ready) R.string.onboarding_step_ready else R.string.onboarding_step_not_ready, page.stepNumber),
tint =
if (ready) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 8.dp),
)
}
}
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { state.page.stepNumber.toFloat() / STEP_COUNT },
modifier = Modifier.fillMaxWidth(),
)
}
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/OnboardingScreen.kt theme={null}
private fun WelcomeStep(store: WelcomeStore) {
LaunchedEffect(Unit) { store.send(WelcomeAction.Appeared) }
Column(Modifier.fillMaxSize()) {
Text(stringResource(R.string.welcome_title), style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.welcome_body),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(32.dp))
Button(onClick = { store.send(WelcomeAction.ContinueTapped) }, modifier = Modifier.fillMaxWidth()) {
Text(stringResource(R.string.action_continue))
}
}
}
```
Placeholders are positional, `%1$d` and `%2$d`, so a translation can reorder them; the count on the insights screen is a `` resource read with `pluralStringResource`, which takes the number twice, once to pick the form and once to print:
```xml src-kmp/app/src/main/res/values/strings.xml theme={null}
- %1$d day
- %1$d days
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/HomeScreen.kt theme={null}
InsightRow(stringResource(R.string.insight_items_read), "7")
InsightRow(stringResource(R.string.insight_longest_streak), pluralStringResource(R.plurals.streak_days, 4, 4))
InsightRow(stringResource(R.string.insight_most_read), stringResource(R.string.insight_reading_list))
```
The refusals get their names the same way, a resource id per case, and the text field prints the resource:
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/EditNameScreen.kt theme={null}
/** The refusal's string, from its case: the reducer says why, the screen says it. */
val NameValidation.messageRes: Int
@StringRes
get() =
when (this) {
NameValidation.Empty -> R.string.validation_empty
NameValidation.TooLong -> R.string.validation_too_long
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/EditNameScreen.kt theme={null}
supportingText = state.validation?.let { refusal -> { Text(stringResource(refusal.messageRes)) } },
```
The Compose side has the same three helpers the Swift side had, and they change the same way: the plan's name and its billing line become resource ids chosen from the `Plan` value, and the toggle's label becomes a composable over its key:
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/UpgradeSheet.kt theme={null}
/** The plan's name, as a resource: the screen names it and never compares it. */
val Plan.nameRes: Int
@StringRes
get() =
when (this) {
Plan.Monthly -> R.string.plan_monthly
Plan.Yearly -> R.string.plan_yearly
}
/** The confirm step's billing line, with the price as its one argument. */
private val Plan.billedRes: Int
@StringRes
get() =
when (this) {
Plan.Monthly -> R.string.upgrade_billed_monthly
Plan.Yearly -> R.string.upgrade_billed_yearly
}
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/UpgradeSheet.kt theme={null}
Text(stringResource(R.string.upgrade_confirm_plan, stringResource(plan.nameRes)), style = MaterialTheme.typography.headlineSmall)
Spacer(Modifier.height(8.dp))
Text(stringResource(plan.billedRes, price), color = MaterialTheme.colorScheme.onSurfaceVariant)
```
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/OnboardingScreen.kt theme={null}
/** The toggle's label, from its key; a key the resources do not name shows as itself. */
@Composable
fun String.preferenceLabel(): String =
when (this) {
"digest" -> stringResource(R.string.preference_digest)
"reminders" -> stringResource(R.string.preference_reminders)
"tips" -> stringResource(R.string.preference_tips)
else -> this
}
```
```sh theme={null}
(cd src-kmp && ./gradlew :app:assembleDebug)
```
German is one more string unit per catalog entry on iOS, `de` beside `en`, as the entries above already show, and one more directory on Android, `values-de/`, with the same names:
```xml src-kmp/app/src/main/res/values-de/strings.xml theme={null}
Foyer
Ein Kern, zwei Apps
Gib eine E-Mail-Adresse ein.
Kein Konto für diese Adresse.
Melde dich an, um fortzufahren
E-Mail
Mit E-Mail fortfahren
Als Gast fortfahren
```
Each system also needs to be told the language exists. iOS picks an app's language from the app bundle's declared localizations, not from what the package's resource bundles happen to contain, so the app's `Info.plist` lists both; Android 13 and later show a per-app language row in Settings for the languages a `locale-config` names:
```yaml src-ios/App/xcodegen.yml theme={null}
# The languages the app offers. The strings live in the shells'
# catalogs, inside the package's resource bundle; iOS picks the app's
# language from this list, so a language missing here is never
# chosen, however complete its catalog entries are.
CFBundleLocalizations:
- en
- de
```
```xml src-kmp/app/src/main/res/xml/locales_config.xml theme={null}
```
```xml src-kmp/app/src/main/AndroidManifest.xml theme={null}
android:supportsRtl="true"
android:localeConfig="@xml/locales_config"
android:theme="@style/Theme.Foyer">
```
Neither platform fails a build over a missing translation: the source language fills in, and the gap shows up on a device in the second language. So each side gets a test that holds the second language to the first. The Swift one reads all nine catalogs through `#filePath` and checks that every key has a German unit marked translated, for every plural category of the source, with the same format specifiers; the Kotlin one parses the two resource files and checks the names and the positional placeholders:
```swift src-ios/Libraries/FoyerKit/Tests/RootShellTests/SecondLanguageSpec.swift theme={null}
final class SecondLanguageSpec: XCTestCase {
private static let sources = URL(fileURLWithPath: #filePath)
.deletingLastPathComponent() // this file
.deletingLastPathComponent() // RootShellTests
.deletingLastPathComponent() // Tests
.appendingPathComponent("Sources")
func testEveryKeyInEveryCatalogHasATranslatedGermanEntry() throws {
let catalogs = try FileManager.default
.contentsOfDirectory(at: Self.sources, includingPropertiesForKeys: nil)
.map { $0.appendingPathComponent("Localizable.xcstrings") }
.filter { FileManager.default.fileExists(atPath: $0.path) }
XCTAssertEqual(catalogs.count, 9, "one catalog per shell target")
for catalog in catalogs {
let name = catalog.deletingLastPathComponent().lastPathComponent
let document = try JSONSerialization.jsonObject(with: Data(contentsOf: catalog)) as! [String: Any]
let strings = document["strings"] as! [String: [String: Any]]
for (key, entry) in strings {
let localizations = entry["localizations"] as! [String: [String: Any]]
let source = try XCTUnwrap(localizations["en"], "\(name) \(key): no source entry")
let german = try XCTUnwrap(localizations["de"], "\(name) \(key): no German entry")
for (category, unit) in units(of: source) {
let translated = try XCTUnwrap(units(of: german)[category], "\(name) \(key): no German \(category)")
XCTAssertEqual(translated["state"] as? String, "translated", "\(name) \(key) \(category)")
XCTAssertEqual(
try specifiers(unit["value"] as! String), try specifiers(translated["value"] as! String),
"\(name) \(key) \(category): format specifiers")
}
}
}
}
/// The string units of one localization: one for a plain string, one per
/// plural category for a plural, keyed by the category.
private func units(of localization: [String: Any]) -> [String: [String: Any]] {
if let unit = localization["stringUnit"] as? [String: Any] { return ["string": unit] }
let plural = (localization["variations"] as! [String: Any])["plural"] as! [String: [String: Any]]
return plural.mapValues { $0["stringUnit"] as! [String: Any] }
}
/// The format specifiers of one value, in order: `%lld`, `%@`.
private func specifiers(_ value: String) throws -> [String] {
try value.ranges(of: Regex("%(lld|@)")).map { String(value[$0]) }
}
}
```
```kotlin src-kmp/app/src/test/kotlin/dev/modaal/foyer/app/TranslationsCompleteTest.kt theme={null}
class TranslationsCompleteTest {
private val source = entries(File("src/main/res/values/strings.xml"))
private val german = entries(File("src/main/res/values-de/strings.xml"))
@Test
fun everyNameHasAGermanEntryAndNoGermanEntryIsAnOrphan() {
assertEquals(source.keys, german.keys)
}
@Test
fun everyPairCarriesTheSamePlaceholders() {
for ((name, value) in source) {
assertEquals(placeholders(value), placeholders(german.getValue(name)), "placeholders of $name")
}
}
/** name → value for each ``; name/quantity → value for each `` item. */
private fun entries(file: File): Map {
val document = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
val out = linkedMapOf()
val strings = document.getElementsByTagName("string")
for (i in 0 until strings.length) {
val string = strings.item(i) as Element
out[string.getAttribute("name")] = string.textContent
}
val plurals = document.getElementsByTagName("plurals")
for (i in 0 until plurals.length) {
val plural = plurals.item(i) as Element
val items = plural.getElementsByTagName("item")
for (j in 0 until items.length) {
val item = items.item(j) as Element
out["${plural.getAttribute("name")}/${item.getAttribute("quantity")}"] = item.textContent
}
}
return out
}
/** The positional placeholders of one value: `%1$s`, `%2$d`. */
private fun placeholders(value: String): Set =
Regex("""%\d+\$[sd]""").findAll(value).map { it.value }.toSet()
}
```
```sh theme={null}
(cd src-ios/Libraries/FoyerKit && swift test --filter SecondLanguageSpec)
(cd src-kmp && ./gradlew :app:testDebugUnitTest --tests '*TranslationsCompleteTest')
```
Both join the checks where they are: the Swift one runs in the shells lane with the other specs, the Kotlin one in the Android job's unit tests.
Neither app has a language picker, because both systems have one. On a device it is Settings, then the app, then Language, on both platforms. From the terminal, a simulator app takes its language as launch arguments, and an emulator app takes a per-app locale from the locale manager:
```sh theme={null}
xcrun simctl launch booted dev.modaal.foyer -AppleLanguages "(de)" -AppleLocale de_DE
adb shell cmd locale set-app-locales dev.modaal.foyer --locales de-DE
```
Launch each app fresh and continue as a guest. The onboarding gate opens on the welcome step:
Every screen behind it is German too, the plan cards read "Monatlich" and "Jährlich", the confirm step's billing line follows the plan and not the name, and the insights screen says "4 Tage". Then go back to the gate and press the email button with the field empty. The refusal is the sign-in reducer's `SignInFailure.EmptyAddress`, the same case in the same recording on both platforms, and each view says it in German:
Compare the tree against `tutorial6-complete`. Under `src-kmp`, the core changed in step 2 and nowhere else: three sealed types, four state fields, one backend line, and the scenarios that compare cases. Under `parity/fixtures`, thirteen files differ, six of them by the case that replaced a sentence and seven by scenario line numbers only. Everything after step 2 is under `src-ios/Libraries/FoyerKit/Sources`, `src-ios/App` and `src-kmp/app`, plus the tests. Adding German in step 6 changed nothing under `parity`:
```sh theme={null}
tools/duet record --check
diff -rq ../tutorial6-complete/parity/fixtures parity/fixtures | wc -l
```
```text theme={null}
duet record --check: fixtures are up to date with their scenarios
13
```
One check does go red on the way: `tools/duet mocks --check`, because the mock generator fingerprints every source file in a shell's directory, and every shell's directory changed. `tools/duet mocks` rewrites the fingerprints; the generated bodies are as they were, since no shell's interface changed. Then `tools/duet verify` is green, `record --check` is green, and the tree's own workflow runs the two translation tests in the lanes they already run.
## What you now have
* A core that says why and never how: `NameValidation`, `SignInFailure` and `PurchaseFailure` where five English sentences were, and six recordings that carry the case.
* Every string the apps show in a string catalog or a resource file, read by name from the views, with the accessors generated on each platform by that platform's own tooling.
* No view shell that resolves a string and no test that compares one; the three places that did carry values now.
* German on both platforms, refusals included, held complete by a test on each side, and both apps running in it from one command.
## Exercise: pin the receipt
`tutorial8-start` carries `Tutorial8ExerciseRecordingsUnchangedTest`, a failing placeholder in the root module. Step 2 found the five sentences by reading; make the checks find the next one. After step 5 the resources hold every sentence the app shows, and the recordings hold every value the reducers write, so the two sets must be disjoint: no string in any recording's state, action or effect may equal a resource's value. Delete the stub and write the test in its place:
```kotlin src-kmp/subtrees/root/logic/src/jvmTest/kotlin/dev/modaal/foyer/root/RecordingsHoldNoDisplayTextTest.kt theme={null}
class RecordingsHoldNoDisplayTextTest {
private val fixtures = File("../../../../parity/fixtures")
private val resources = File("../../../app/src/main/res/values/strings.xml")
@Test
fun noRecordingCarriesAStringTheResourcesOwn() {
val copy = displayText(resources)
assertTrue(copy.size > 50, "the resources hold the app's copy (${copy.size} values)")
val files = fixtures.listFiles { file -> file.name.endsWith(".fixture.json") }!!.sorted()
assertTrue(files.size > 70, "the tree's recordings are on disk (${files.size} files)")
val leaks = mutableListOf()
for (file in files) {
val recorded = mutableListOf()
val document = Json.parseToJsonElement(file.readText()).jsonObject
for (field in RECORDED) document[field]?.let { collect(it, recorded) }
for (step in document.getValue("steps").jsonArray) {
for (field in RECORDED) step.jsonObject[field]?.let { collect(it, recorded) }
}
for (value in recorded) if (value in copy) leaks += "${file.name}: \"$value\""
}
assertTrue(leaks.isEmpty(), "display text in recordings:\n" + leaks.joinToString("\n"))
}
/** Every string value under one element, depth first. */
private fun collect(element: JsonElement, into: MutableList) {
when (element) {
is JsonObject -> element.values.forEach { collect(it, into) }
is JsonArray -> element.forEach { collect(it, into) }
is JsonPrimitive -> if (element.isString) into += element.content
}
}
/** The values of every `` and `` item, unescaped. */
private fun displayText(xml: File): Set =
Regex("<(?:string|item)[^>]*>([^<]*)")
.findAll(xml.readText())
.map { it.groupValues[1].replace("\\'", "'") }
.toSet()
private companion object {
/** The fields that carry the reducer's values, at the top of a fixture and on each step. */
val RECORDED = listOf("initialState", "initialStates", "action", "expectedState", "expectedEffects")
}
}
```
```sh theme={null}
tools/duet verify
```
`tutorial8-complete` carries the test. To see it work, copy `tutorial6-complete`'s `name.empty-name-rejected.fixture.json` over the tree's and run `tools/duet verify`: the test names the fixture and "Enter a name.", which is now a resource value. Restore the fixture. A reducer that writes a sentence into state after this fails the same way on its next recording, and its own scenario stays green, because a scenario cannot tell copy from a value.
## Common questions
The plugin generates one accessor type per target, and the type is internal to it, so a catalog beside its views is the shape that needs no configuration. One catalog for the whole package would be a `Strings` target with an `xcstrings-tool-config.yaml` setting `accessLevel: public`, a dependency from every shell, and one file that every feature edits. Nine files that each change with their own views is the same shape the shells already have for their mocks and their tests.
Two reasons, and the second is a build fact. A shell's tests compare its view state, so a projected string is a string the tests pin in one language. And the package's `swift test` runs on macOS through Swift Package Manager, which copies `Localizable.xcstrings` into the resource bundle as it is; only Xcode compiles a catalog into the tables the runtime reads. On that lane a shell that resolved `.planFree` would see the key, not "Free", and the spec would fail. Views are never rendered in the shells lane and the app is built by Xcode, so a string resolved in a view is always resolved against a compiled catalog.
Because a message is a language, and a language in state is a language in every recording that carries it. With "Enter a name." in state, the German user reads English on the name step, a translator who improves the wording re-records three features, and the same sentence lives in the core and in nothing a translator's tools can see. With `NameValidation.Empty` in state, the reducer says why, the recording pins why, and each platform's view says it in the user's language from the same catalog or resource file as every other sentence. The port's reason is the same case: a backend that returns error codes maps them to `SignInFailure` at the port, and a backend that returns sentences has no place to put them.
A guest session seeds `displayName` with "Guest", the same value on both platforms, and it is in four recordings. It stays: it is the account's saved name until the user sets one, a stored value the account port owns, not a sentence the view chooses, and the name step replaces it before the main screen shows. A name the app invents for a user is data with a default, and the default is the port's.
Because the core would then carry a language, and every recording of a feature that shows text would carry it too; a changed translation would be a changed fixture, and a translator's edit would need a re-record. Kept in the shells, the strings live in the two systems the platforms' own tools, editors and translators already handle, string catalogs and string resources, and the core stays the one place that never has to know what language the user reads.
By the system, on both platforms: the device language, or since iOS 13 and Android 13 a per-app language the user picks in Settings. iOS offers that row for every language in `CFBundleLocalizations`; Android offers it for every language the manifest's `localeConfig` names, and the `set-app-locales` command from step 6 sets the same preference. An app that wants its own picker sets `AppleLanguages` in its defaults on iOS and calls `AppCompatDelegate.setApplicationLocales` on Android; that is a preference the shell stores, not feature state.
Both platforms resolve plural forms from the language's rules at run time, given a resource with one string per category; step 2 and step 4 each have one. German uses the same two categories as English, so the completeness tests, which compare the source's categories to the translation's, pass without special cases; a language with more categories adds them to its own entry. Word order is why placeholders are positional: "Confirm %@" is "%@ bestätigen" in the German catalog, and "Confirm %1$s" is "%1$s bestätigen" in the German resources, with the argument list unchanged at the call site.
## Sources and further reading
* [xcstrings-tool](https://github.com/liamnichols/xcstrings-tool) and its [Swift package plugin](https://github.com/liamnichols/xcstrings-tool-plugin) — the generator, its configuration file, and the generated API.
* [Apple: Localizing and varying text with a string catalog](https://developer.apple.com/documentation/xcode/localizing-and-varying-text-with-a-string-catalog) — the catalog format, plural variations, and the editor; [`LocalizedStringResource`](https://developer.apple.com/documentation/foundation/localizedstringresource) — the type the accessors resolve through.
* [Android: Localize your app](https://developer.android.com/guide/topics/resources/localization) — resource directories per language; [Per-app language preferences](https://developer.android.com/guide/topics/resources/app-languages) — `localeConfig` and the Settings row; [Quantity strings](https://developer.android.com/guide/topics/resources/string-resource#Plurals) — the `` resource.
* [The Duet glossary](/articles/duet-glossary) — [shell](/articles/duet-glossary#shell), [golden recording](/articles/duet-glossary#golden-fixture) and [the checks](/articles/duet-glossary#gate).
## Read next
The tree this page opens, and the workflow the two translation tests run in.
The same tree, the same rule for colors: the state says locked, the view picks the token.
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 nine tutorials, the app they build, the prerequisites and the versions they are verified against.
# Duet Tutorial 9: Adding Analytics
Source: https://docs.modaal.dev/tutorials/duet-09-analytics
Mint the app's verb, emit seven events from reducers as effect data, add a console sink worker behind one fan-out per platform, and let recordings check them.
In this tutorial you add analytics to Foyer without calling an analytics SDK from anywhere in the app. Seven things happen in Foyer that a product team would want counted: the app finishes launching, a user signs in, finishes onboarding, opens the promo, buys a plan, edits their name, signs out. Each is a transition a reducer already decides, so each becomes one more effect that reducer emits, typed on a shared event grammar, and the recordings pin it beside the state change it belongs to. The sink that receives the events is a worker at the edge, one per platform, and the one this tutorial ships prints to the console. This is the ninth and last page of the [nine-tutorial series](/tutorials/duet), and it starts from [Tutorial 6](/tutorials/duet-06-checks-in-ci)'s finished tree, as Tutorials 7 and 8 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 [Modaal](https://modaal.dev), 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 Modaal](/articles/new-project) when you would rather skip the setup.
## What will you build?
An event taxonomy of seven names, spelled the way a dashboard reads them, declared once in Kotlin and reaching a Swift sink and a Kotlin sink unchanged. The grammar comes from the family's telemetry artifact: an event is a subject, a verb and a list of primitive parameters, and the artifact ships ten starter verbs. Foyer adds one verb of its own, `Signed In`, and reaches for the starter vocabulary for the other six. Each event is emitted as a `Track` effect from the reducer that owns the transition, so `tools/duet record` writes it into the recordings and `tools/duet verify` fails when it moves. A fan-out worker at each composition root holds the sink list; the console sink behind it is a worker in its own right, adopted for the mount's lifetime, and a test on each platform drives it through the worker harness. Expect about an hour and a half.
You will have at the end:
* A `src-kmp/telemetry` module with the app's one verb and a test that pins how it renders; seven `Events` objects, one beside each reducer that emits.
* A `Track` effect on seven features, asserted in their scenarios and in five chains, with 26 recordings re-recorded to carry the event envelope.
* A console sink worker on Android and on iOS behind the artifact's fan-out, each adopted at the root, each with a test through `WorkerTester`.
* Two tests that read every event envelope back out of the recordings, one with the Kotlin grammar and one with its Swift twin, and hold the set of names to the seven declared.
## Where do you start?
Open `tutorial9-start` from the [duet-tutorials repository](https://github.com/modaal-agent/duet-tutorials). 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:
```sh theme={null}
tools/duet verify
```
The Kotlin lane reports 73 recordings passing and one test failing, `Tutorial9ExerciseTrackedEventsTest` in the sign-in module. That test is the exercise at the end of this page: the sign-in event is the one you emit yourself. The six others are the steps.
## The steps
The event grammar is the family artifact `dev.modaal.duet.services:telemetry`, already in the version catalog since [Tutorial 6](/tutorials/duet-06-checks-in-ci#put-the-checks-in-a-workflow) as `duet-services-telemetry`. Its `commonMain` carries `TrackedEvent`, `TrackedVerb`, `TrackedParam`, the one encoding rule that turns an event into a vendor-facing name, and the port `AnalyticsTracking`; its JVM half adds the worker-typed sink port `AnalyticsTrackingWorking` and the fan-out `AnalyticsTrackingWorker`. The app links it through one module of its own, `src-kmp/telemetry`, which re-exports the artifact and holds what only this app can say:
```kotlin src-kmp/telemetry/build.gradle.kts theme={null}
sourceSets {
commonMain.dependencies {
api(libs.duet.services.telemetry)
}
jvmTest.dependencies {
implementation(kotlin("test"))
// The recordings are JSON; the taxonomy test reads the envelopes back.
implementation(libs.kotlinx.serialization.json)
}
}
```
Register the module, and export it from the Apple umbrella so Swift can name a `TrackedEvent`:
```kotlin src-kmp/settings.gradle.kts theme={null}
// The telemetry grammar: the app's own event verbs over the family's
// telemetry artifact. Reducers emit `Track` effects on its types, so every
// feature module that emits one depends on it.
include(":telemetry")
```
```kotlin src-kmp/apple-umbrella/build.gradle.kts theme={null}
// The event grammar the `Track` effects carry: the app's verbs, and
// the artifact whose types they are, so Swift can name a TrackedEvent.
export(dependencies.project(":telemetry"))
export(libs.duet.services.telemetry)
```
Every feature module that will emit an event depends on the grammar module, and on nothing else new. The dependency is what makes a `TrackedEvent` nameable in a reducer; the Kotlin lane admits it because the module's own dependencies are family artifacts only:
```kotlin src-kmp/subtrees/home/logic/build.gradle.kts theme={null}
// The event grammar the `Track` effects carry.
api(project(":telemetry"))
```
The Android app links the module and the artifact too, for the fan-out and the sink port; `src-kmp/app/build.gradle.kts` gains the same two lines under `implementation`.
A `TrackedVerb` is a token, not an enum case: the artifact declares `Viewed`, `Opened`, `Started`, `Completed`, `Failed`, `Created`, `Edited`, `Deleted`, `Toggled` and `Signed Out` as companion values, and an app that needs another declares it in one line. The token is the vendor-facing word: `encodedName()` splices it after the subject, so `TrackedVerb("Signed In")` on the `Session` subject renders as `Session Signed In` on every platform and in every sink. Foyer needs one verb the artifact does not ship:
```kotlin src-kmp/telemetry/src/commonMain/kotlin/dev/modaal/foyer/telemetry/AppVerbs.kt theme={null}
object AppVerbs {
/** The starter vocabulary has `Signed Out` and no `Signed In`; this is its pair. */
val SignedIn = TrackedVerb("Signed In")
}
```
The test beside it is the module's lane; `tools/duet verify` never reaches it, because the module is not a manifest feature, and step 8 puts it into the workflow:
```kotlin src-kmp/telemetry/src/jvmTest/kotlin/dev/modaal/foyer/telemetry/AppVerbsTest.kt theme={null}
fun theAppsVerbRendersAsAuthored() {
assertEquals("Session Signed In", TrackedEvent("Session", AppVerbs.SignedIn).encodedName())
}
```
Now write the taxonomy down before writing any code, because every row is a name a dashboard will key on for years:
| Event | Owner | Emitted when | Parameters |
| - | - | - | - |
| `Splash Completed` | `root` | the root acts on the splash's completion | `path`: `ceremony` or `safety_net` |
| `Session Signed In` | `signin` | the auth port signs the account in | `provider`: `email` or `guest` |
| `Onboarding Completed` | `onboarding` | the last step continues | `preference_count` |
| `Promo Viewed` | `home` | a locked card opens the offer | none |
| `Upgrade Completed` | `upgrade` | the purchases port answers `purchased` | `plan`: `monthly` or `yearly` |
| `Name Edited` | `editname` | the account port confirms the save | none |
| `Session Signed Out` | `account` | the auth port confirms the sign-out | none |
Two rows read differently from how you might first name them. The promo is `Viewed`, not `Shown`, and the name is `Edited`, not `Changed`, because both acts are in the starter vocabulary already: a second spelling of the same act is a second column on every dashboard. The one verb the app mints is the one the vocabulary lacks. The parameters follow the grammar's privacy rule: primitives that describe behavior, never content. `Name Edited` carries nothing because the name is the user's; `Session Signed In` carries which kind of provider, never the address; `Onboarding Completed` carries how many preferences were picked, not which.
Each feature that emits declares its events in an object next to its reducer, one builder per named event, from the grammar's types. The root's event takes the completion path, a sealed value in the splash module, and spells it as a string the dashboard can group on:
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootEvents.kt theme={null}
object RootEvents {
/** The app finished launching; `path` says which route ended the splash. */
fun splashCompleted(path: SplashCompletionPath): TrackedEvent =
TrackedEvent(
"Splash",
TrackedVerb.Completed,
listOf(
TrackedParam.string(
"path",
when (path) {
SplashCompletionPath.Ceremony -> "ceremony"
SplashCompletionPath.SafetyNet -> "safety_net"
})))
}
```
The editor's has no builder function because it has no parameter:
```kotlin src-kmp/subtrees/editname/logic/src/commonMain/kotlin/dev/modaal/foyer/editname/EditNameEvents.kt theme={null}
object EditNameEvents {
val nameEdited: TrackedEvent = TrackedEvent("Name", TrackedVerb.Edited)
}
```
`OnboardingEvents.completed(preferenceCount:)`, `HomeEvents.promoViewed`, `UpgradeEvents.completed(plan:)` and `AccountEvents.signedOut` follow the same two shapes; `SignInEvents` is the exercise. The objects are Kotlin and only Kotlin: the Swift side never spells an event, because the recordings and the framework carry the declaration across.
An event leaves a reducer the way every other side effect does: as a value in the effect list. Each of the seven features gains one effect payload case, one serializer case, one environment member and one handler arm; the shape is the same in every module, so the root's four are shown once:
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
@Serializable @SerialName("track") data class Track(val event: TrackedEvent) : RootEffectPayload
```
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootSerializers.kt theme={null}
case(RootEffectPayload.Track::class, RootEffectPayload.Track.serializer()),
```
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootEnvironment.kt theme={null}
/** Forward a `Track` effect's event to the app's one sink (fire-and-forget). */
fun track(event: TrackedEvent)
```
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootRuntime.kt theme={null}
is RootEffectPayload.Track -> environment.track(payload.event)
```
The handler arm is fire-and-forget, like `notifyListener`: the flow calls the environment and emits no action. The environment member is the one line the generated mock grows too, so `RootEnvironmentMock` has a `trackArgs` list on the next test compile.
Then the reducer arms. The root is the interesting one, because the splash notifies its host on every completion path and [Tutorial 1](/tutorials/duet-01-first-feature#describe-the-behavior-as-a-scenario-and-record-it) pinned that deliberately in `splash.both-paths-notify-twice`. The launch is counted where one completion is acted on, and a second completion, late or while the first waits for auth, writes nothing and tracks nothing:
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
is RootAction.Splash ->
when (val event = action.event) {
is SplashDelegateEvent.Completed ->
when {
state.phase != RootPhase.Splash -> Reduced(state)
// The other path completing while the first waits for auth: the
// launch was counted once, so nothing is written and nothing tracked.
state.awaitingAuth -> Reduced(state)
state.auth == AuthSnapshot.Unknown ->
Reduced(state.copy(awaitingAuth = true), listOf(splashCompleted(event.path)))
else -> {
val entered = enter(state, phaseAfterSplash(state.auth))
Reduced(entered.state, listOf(splashCompleted(event.path)) + entered.effects)
}
}
}
```
```kotlin src-kmp/subtrees/root/logic/src/commonMain/kotlin/dev/modaal/foyer/root/RootFeature.kt theme={null}
/** The launch event: emitted once, by whichever completion this level acted on. */
private fun splashCompleted(path: SplashCompletionPath): Effect =
Effect.Run(RootEffectPayload.Track(RootEvents.splashCompleted(path)))
```
The `awaitingAuth` guard is new: before this step a second completion while waiting re-wrote the same flag, which no recording could see. Now it would emit a second event, so the guard makes the case explicit and the scenario pins it in the next step. The home tab tracks the promo and not the summary, the flow tracks the purchase when the port answers and not when the user leaves the last step, and the account screen tracks the sign-out when the port confirms:
```kotlin src-kmp/subtrees/home/logic/src/commonMain/kotlin/dev/modaal/foyer/home/HomeFeature.kt theme={null}
HomeAction.InsightsTapped ->
if (state.entitlement is Entitlement.Premium) {
Reduced(state.copy(presented = HomePresentation.Insights))
} else {
// The one screen product counts: a locked card tapped, the offer shown.
Reduced(
state.copy(presented = HomePresentation.Promo),
listOf(Effect.Run(HomeEffectPayload.Track(HomeEvents.promoViewed))))
}
HomeAction.UpgradeTapped ->
Reduced(
state.copy(presented = null),
listOf(Effect.Run(HomeEffectPayload.NotifyHost(HomeDelegateEvent.UpgradeRequested))))
HomeAction.Dismissed -> Reduced(state.copy(presented = null))
}
```
```kotlin src-kmp/subtrees/upgrade/logic/src/commonMain/kotlin/dev/modaal/foyer/upgrade/UpgradeFeature.kt theme={null}
is PurchaseOutcome.Purchased ->
Reduced(
state.copy(isPurchasing = false, step = UpgradeStep.Done),
listOf(Effect.Run(UpgradeEffectPayload.Track(UpgradeEvents.completed(outcome.plan)))))
```
```kotlin src-kmp/subtrees/account/logic/src/commonMain/kotlin/dev/modaal/foyer/account/AccountFeature.kt theme={null}
AccountAction.SignedOut ->
Reduced(
state.copy(isSigningOut = false),
listOf(
// The event first: the delegate below climbs to the root, which
// raises the gate and tears this screen down.
Effect.Run(AccountEffectPayload.Track(AccountEvents.signedOut)),
Effect.Run(AccountEffectPayload.NotifyHost(AccountDelegateEvent.SignOutRequested))))
```
Where an event rides beside a delegate event, it comes first in the list, and the order is load-bearing. A delegate event can end the mount that emitted it: the sign-in gate's `Completed` moves the root's phase, and the root shell tears the gate down; the editor's `Saved` clears the account screen's child. Effects run in list order, and the iOS root shell applies a phase change synchronously inside the delegate call, so an effect listed after the delegate is cancelled with the store before it runs. On Android the transition observer runs on a later hop and the second effect gets through, which is the kind of platform difference a recording cannot see and a console can, and step 8 shows the line that found it. The editor's `SaveFinished`, the onboarding level's last `Continued` and the account screen's `SignedOut` take the same shape: event, then delegate. Nothing in any arm asks whether analytics is enabled. Consent gates the sink, never the emission, and that is what keeps every recording deterministic.
A `Track` effect is asserted where every effect is: in the scenario's `thenEffects`, as the exact list. The root's first branch grows a step for the guard, and the branch that already pinned a late completion now pins that it tracks nothing either:
```kotlin src-kmp/subtrees/root/logic/src/jvmTest/kotlin/dev/modaal/foyer/root/RootScenarioTest.kt theme={null}
branch("splash before auth holds") {
whenAction("the splash completes while auth is unknown", splashCompleted)
then("the latch is set, the phase stays") {
it.awaitingAuth && it.phase == RootPhase.Splash
}
thenEffects("the launch is counted, by the ceremony path") {
it == effectsOf(launched(SplashCompletionPath.Ceremony))
}
whenAction("the safety net completes it again while auth is still unknown", safetyNetCompleted)
then("nothing changed") { it.awaitingAuth && it.phase == RootPhase.Splash }
thenEffects("nothing: the launch was counted once") { it.isEmpty() }
whenAction("the host reports a signed-out session", signedOut)
then("the latch releases into the gate") {
!it.awaitingAuth && it.phase == RootPhase.SignIn && it.auth == AuthSnapshot.SignedOut
}
}
```
The flow's step that used to say "nothing: the stream carries the entitlement" now says exactly the event, with the plan the port answered with:
```kotlin src-kmp/subtrees/upgrade/logic/src/jvmTest/kotlin/dev/modaal/foyer/upgrade/UpgradeScenarioTest.kt theme={null}
then("the done step; no entitlement anywhere in this state") {
it.step == UpgradeStep.Done && !it.isPurchasing
}
thenEffects("exactly the event with the plan: the stream carries the entitlement") {
it ==
effectsOf(
Effect.Run(UpgradeEffectPayload.Track(UpgradeEvents.completed(Plan.Yearly))))
}
```
Five chains cross a step that now emits, and the chain scenario asserts the emitting node's effects the same way; the splash seam, for one, ends by naming the launch event the root emitted after the hop:
```kotlin src-kmp/subtrees/root/logic/src/jvmTest/kotlin/dev/modaal/foyer/root/RootChainsTest.kt theme={null}
thenEffects(root, "the launch event, by the ceremony path") {
it ==
effectsOf(
Effect.Run(
RootEffectPayload.Track(RootEvents.splashCompleted(SplashCompletionPath.Ceremony))))
}
```
Re-record each feature and each chain that changed, then verify:
```sh theme={null}
for f in root onboarding home upgrade editname account; do tools/duet record --feature $f; done
for c in chain-root-splash chain-main-signout chain-profile-editname chain-root-onboarding; do tools/duet record --chain $c; done
tools/duet verify
```
Thirty-nine recordings change: 26 carry a new effect, and 13 differ only in the line numbers the scenario's steps moved to. Every one is a diff you can read; this is what the flow's purchase step records now:
```json parity/fixtures/upgrade.select-confirm-purchase-done.fixture.json theme={null}
"case": "track",
"value": {
"event": {
"params": [
{
"case": "string",
"value": {
"key": "plan",
"value": "yearly"
}
}
],
"subject": "Upgrade",
"verb": "Completed"
```
That object is the event envelope: the effect's `case`, the event's subject and verb, and each parameter in the grammar's canonical form, `case` naming the primitive kind. The Kotlin encoders wrote it, and step 8 reads it back with both languages' decoders. `tools/duet verify` reports 73 of 73 and `tools/duet record --check` reports every feature and chain up to date; the mutation drill from [Tutorial 6](/tutorials/duet-06-checks-in-ci#write-the-first-mutation-rows) gains nothing here, because a deleted emission fails the recording that pins it.
Everything so far ran on the Kotlin lane with a generated mock as the environment. On a device, a `Track` effect has to reach a sink. The artifact's fan-out, `AnalyticsTrackingWorker`, holds a list of sinks and forwards every call to each; a sink implements `AnalyticsTrackingWorking`, which is the tracking port plus `Working`, so the composition root adopts it like the session and entitlement workers from [Tutorial 4](/tutorials/duet-04-workers#write-the-workers-on-android). Foyer's one sink prints:
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/workers/ConsoleAnalyticsSink.kt theme={null}
class ConsoleAnalyticsSink(
private val line: (String) -> Unit = ::println,
) : AnalyticsTrackingWorking {
@Volatile private var enabled = true
override val isEnabled: Boolean
get() = enabled
override fun setEnabled(enabled: Boolean) {
this.enabled = enabled
}
override fun track(event: TrackedEvent) {
if (!enabled) return
val bag = event.encodedProperties()
val properties =
if (bag.isEmpty()) "" else bag.entries.joinToString(", ", " {", "}") { "${it.key}=${it.value}" }
line("analytics: ${event.encodedName()}$properties")
}
override fun identify(uid: String) {
if (enabled) line("analytics: identify $uid")
}
override fun reset() {
if (enabled) line("analytics: reset")
}
/** Parks until the host cancels: a console has nothing to flush. */
override suspend fun run() = untilCancelled()
}
```
The root Component owns the sink and the fan-out, and conforms to `AnalyticsProviding`, the artifact's one-member Dependency interface, so that every level's Dependency can extend it and be satisfied by the same member:
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/RootBuilder.kt theme={null}
* The one vendor sink this app ships: the console. Picking a real vendor is
* one more class implementing [AnalyticsTrackingWorking] — the only file
* that imports the SDK — added to the list below and adopted in its own
* right.
*/
val consoleSink: AnalyticsTrackingWorking = ConsoleAnalyticsSink()
/**
* The grammar-typed sink [AnalyticsProviding]'s one member names, and a
* worker the root adopts: the artifact's fan-out over the sink list. It
* holds no flag of its own; the seed reaches every sink in the constructor,
* before any event can.
*/
override val analytics: AnalyticsTrackingWorking =
AnalyticsTrackingWorker(sinks = listOf(consoleSink), isEnabled = true)
```
The root Builder adopts both before it builds the child slot, so no child can emit into a sink that is not yet running; the fan-out does not bracket the sinks' lifetimes, so each is adopted on its own:
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/RootBuilder.kt theme={null}
host.adopt(component.consoleSink)
host.adopt(component.analytics)
```
Each feature's Dependency extends `AnalyticsProviding` and its live environment forwards the effect; the compiler walks the chain from the root down, which is the same discipline [Tutorial 3](/tutorials/duet-03-composing-features#compose-the-tree-on-android) set for the ports:
```kotlin src-kmp/app/src/main/kotlin/dev/modaal/foyer/app/HomeBuilder.kt theme={null}
interface HomeDependency : AnalyticsProviding {
val items: ItemsPort
}
class HomeComponent(dependency: HomeDependency) : HomeDependency by dependency {
fun environment(onDelegate: (HomeDelegateEvent) -> Unit): HomeEnvironment =
object : HomeEnvironment {
override fun loadItems(onItems: (List- ) -> Unit) = items.items(onItems)
override fun notifyHost(event: HomeDelegateEvent) = onDelegate(event)
override fun track(event: TrackedEvent) = analytics.track(event)
```
`Main` and `Profile` extend it too, though their reducers emit nothing: a level's Dependency is the union of its subtree's needs. The sink's test goes through `WorkerTester`, as the two stream workers' do, and one of its three cases pins the fan-out's seed, the reason a disabled sink never egresses in the gap between composition and the first consent flip:
```kotlin src-kmp/app/src/test/kotlin/dev/modaal/foyer/app/workers/ConsoleAnalyticsSinkTest.kt theme={null}
fun theFanOutSeedsTheSinkBeforeAnyEventReachesIt() = runTest {
val lines = mutableListOf()
val sink = ConsoleAnalyticsSink(lines::add)
val analytics = AnalyticsTrackingWorker(sinks = listOf(sink), isEnabled = false)
// The seed landed in the constructor: the sink reports it, and drops the event.
assertFalse(sink.isEnabled)
analytics.track(signedIn)
assertTrue(lines.isEmpty())
analytics.setEnabled(true)
analytics.track(signedIn)
assertEquals(listOf("analytics: Session Signed In {provider=email}"), lines)
}
```
`./gradlew :app:testDebugUnitTest` runs it beside the app's other suites; `RootFlowTest`, the headless walk over the whole tree, needs no change, because the root Component builds its own sink.
The Apple side types its sinks on the Swift half of the grammar, `DuetTelemetry` in the shared-services package: the artifact's fan-out is JVM-only, and a Kotlin `Track` effect carries the framework's bridged `TrackedEvent` class, not the Swift package's struct. So the tree gains the package, and one file that holds both grammars and converts between them. It lives in `FoyerBridge`, the target every shell and every spec already shares:
```swift src-ios/Libraries/FoyerKit/Package.swift theme={null}
// The shared-services package, for DuetTelemetry: the Swift twin of the
// event grammar the Kotlin core emits on, and the fan-out worker the
// console sink sits behind. Built against the same duet release; the
// Kotlin half pins the same version as `duetServices`.
.package(url: "https://github.com/modaal-agent/duet-services.git", exact: "0.11.1"),
```
`BridgedAnalyticsWorker` wears the bridged port, holds the package's `AnalyticsTrackingWorker`, and converts each crossing event before fanning it out. The conversion copies the subject, carries the verb token verbatim, and maps the four parameter kinds; SKIE projects the Kotlin sealed interface as an enum, so the switch is exhaustive:
```swift src-ios/Libraries/FoyerKit/Sources/FoyerBridge/BridgedAnalyticsWorker.swift theme={null}
public func track(event: FoyerKit.TrackedEvent) {
worker.track(event: DuetTelemetry.TrackedEvent(bridged: event))
}
```
```swift src-ios/Libraries/FoyerKit/Sources/FoyerBridge/BridgedAnalyticsWorker.swift theme={null}
public init(bridged: any FoyerKit.TrackedParam) {
switch onEnum(of: bridged) {
case .stringParam(let param):
self = .string(key: param.key, value: param.value)
case .intParam(let param):
// The Kotlin `Int` bridges as `Int32`; the Swift grammar's case holds
// `Int`, which is wider on every platform this package builds for.
self = .int(key: param.key, value: Int(param.value))
case .boolParam(let param):
self = .bool(key: param.key, value: param.value)
case .doubleParam(let param):
self = .double(key: param.key, value: param.value)
}
}
```
The console sink is the Android one's twin on the Swift port. A `Working` is `Sendable`, so its enabled flag lives behind a lock rather than in a plain property:
```swift src-ios/Libraries/FoyerKit/Sources/RootShell/ConsoleAnalyticsSink.swift theme={null}
public func track(event: TrackedEvent) {
guard isEnabled else { return }
let bag = event.encodedProperties()
let properties =
bag.isEmpty
? ""
: " {" + bag.keys.sorted().map { "\($0)=\(bag[$0]!)" }.joined(separator: ", ") + "}"
line("analytics: \(event.encodedName())\(properties)")
}
```
The root Component owns the sink and the bridged fan-out, and exposes the fan-out under the bridged port's type as `analytics`, the member every level's Dependency names; the shell adopts the sink and the fan-out before the child slot exists:
```swift src-ios/Libraries/FoyerKit/Sources/RootShell/RootComposition.swift theme={null}
let consoleSink = ConsoleAnalyticsSink()
/// The fan-out over the sink list, wearing the bridged port so a Kotlin
/// `Track` effect lands on it unchanged; a worker the root adopts. The
/// seed reaches every sink in the initializer, before any event can.
let analyticsWorker: BridgedAnalyticsWorker
init(dependency: RootDependency, scope: any Kotlinx_coroutines_coreCoroutineScope) {
self.dependency = dependency
backend = LocalBackend(file: dependency.storage, scope: scope)
analyticsWorker = BridgedAnalyticsWorker(sinks: [consoleSink], isEnabled: true)
}
var auth: any AuthPort { backend.auth }
var items: any ItemsPort { backend.items }
var account: any AccountPort { backend.account }
var purchases: any PurchasesPort { backend.purchases }
var analytics: any AnalyticsTracking { analyticsWorker }
```
```swift src-ios/Libraries/FoyerKit/Sources/RootShell/RootViewShell.swift theme={null}
// The sinks and the fan-out over them, adopted before any child exists:
// a `Track` effect is fire-and-forget, so a sink not yet running would
// hear nothing. The fan-out does not bracket the sinks' lifetimes.
for sink in workers.sinks { host.adopt(sink) }
host.adopt(workers.analytics)
```
Each feature's Dependency gains the member and its live environment forwards the effect, the mirror of the Kotlin side:
```swift src-ios/Libraries/FoyerKit/Sources/HomeShell/HomeBuilder.swift theme={null}
public protocol HomeDependency: AnyObject {
var items: any ItemsPort { get }
var analytics: any AnalyticsTracking { get }
}
```
```swift src-ios/Libraries/FoyerKit/Sources/HomeShell/HomeBuilder.swift theme={null}
func track(event: TrackedEvent) {
analytics.track(event: event)
}
```
The Dependency protocols are what `tools/duet mocks` generates the Components and the test doubles from, so regenerate; the generated `HomeDependencyMock` now takes `analytics:` in its initializer, and every shell spec passes the working default, a fan-out over no sink:
```sh theme={null}
tools/duet mocks
```
```swift src-ios/Libraries/FoyerKit/Tests/HomeShellTests/HomeViewShellSpec.swift theme={null}
HomeBuilder(dependency: HomeDependencyMock(analytics: BridgedAnalyticsWorker(sinks: []), items: backend.items))
```
The composition spec counted two live workers on the root after a sign-out; it counts four now. The crossing gets a spec of its own, because the package's twin fixtures pin the two grammars' encodings and not this app's converter:
```swift src-ios/Libraries/FoyerKit/Tests/RootShellTests/BridgedAnalyticsWorkerSpec.swift theme={null}
func testABridgedEventReachesTheSinkAsItsSwiftTwin() {
let sink = RecordingSink()
let analytics = BridgedAnalyticsWorker(sinks: [sink], isEnabled: true)
analytics.track(
event: FoyerKit.TrackedEvent(
subject: "Session",
verb: FoyerKit.TrackedVerb(rendered: "Signed In"),
params: [
TrackedParamStringParam(key: "provider", value: "email"),
TrackedParamIntParam(key: "attempt", value: 2),
TrackedParamBoolParam(key: "cached", value: true),
TrackedParamDoubleParam(key: "elapsed_s", value: 1.5),
]))
XCTAssertEqual(sink.recorded.count, 1)
// The vendor-facing name is the grammar's one encoding rule, and the verb
// token crosses verbatim — a converter that re-derived it would spell the
// event differently on the two platforms.
XCTAssertEqual(sink.recorded.first?.encodedName(), "Session Signed In")
XCTAssertEqual(
sink.recorded.first?.params,
[
.string(key: "provider", value: "email"),
.int(key: "attempt", value: 2),
.bool(key: "cached", value: true),
.double(key: "elapsed_s", value: 1.5),
])
}
```
`ConsoleAnalyticsSinkSpec` is the Android test's twin, through the Swift `WorkerTester`. The Apple lane runs both after it assembles the core: `parity/scripts/apple-boundary-lane.sh` replays 67 recordings across the framework, `Track` envelopes included, and runs the shells' 41 specs.
The receipt is the taxonomy, read back out of the recordings by both languages. On the Kotlin side a test in the grammar module walks every fixture, decodes each `track` envelope with the grammar's own serializer, and holds the set of encoded names to the seven declared; it lives in `:telemetry` and not in a feature module on purpose, because `tools/duet record` runs a feature module's whole test task, and this test cannot pass until the recording it reads exists:
```kotlin src-kmp/telemetry/src/jvmTest/kotlin/dev/modaal/foyer/telemetry/TrackedEventsInRecordingsTest.kt theme={null}
val DECLARED =
setOf(
"Splash Completed",
"Session Signed In",
"Onboarding Completed",
"Promo Viewed",
"Upgrade Completed",
"Name Edited",
"Session Signed Out",
)
```
On the Swift side a spec does the same with the Swift twin's `Codable`, and re-encodes each event to check it writes back what Kotlin wrote:
```swift src-ios/Libraries/FoyerKit/Tests/RootShellTests/TrackedEventEnvelopesSpec.swift theme={null}
let again = try JSONSerialization.jsonObject(with: JSONEncoder().encode(event))
XCTAssertEqual(
again as? NSDictionary, envelope as? NSDictionary, "\(file): the Swift twin re-encodes the envelope unchanged")
}
}
XCTAssertGreaterThan(envelopes, 0)
XCTAssertEqual(names, Self.declared, "the names in the recordings")
```
An eighth event, wherever it is emitted, fails both until its name is added to both lists: adding an event is a taxonomy decision, and this is where it is made. The grammar module's suite joins the workflow as one step, beside the backend's:
```yaml .github/workflows/parity.yml theme={null}
# The telemetry grammar module is not a manifest feature either. Its
# suite pins the app's own verb and reads every `track` envelope back
# out of the recordings; the encoding rule and the fan-out belong to the
# family artifact and are gated by its suite.
- name: telemetry grammar tests
run: (cd src-kmp && ./gradlew :telemetry:jvmTest --console=plain -q)
```
Then run the apps and watch the console. On Android the sink prints through `println`, which reaches logcat under the `System.out` tag; on iOS it prints to the process's standard output, which `xcrun simctl launch --console-pty` shows. Launch each, sign in as a guest, and the first two lines are the same on both:
```text theme={null}
analytics: Splash Completed {path=ceremony}
analytics: Session Signed In {provider=guest}
```
Each line is the sink's rendering of `encodedName()` and `encodedProperties()`, the two things a vendor SDK is handed. This is also the check that found the ordering rule in step 4: with the event listed after the delegate, Android printed both lines and the iOS console stopped after the first, because the gate was torn down before its second effect ran. Swap the console sink for a vendor's and nothing above the composition root changes.
## What you now have
* Seven events with dashboard-ready names, declared once in Kotlin beside the reducers that emit them, reaching a Swift sink and a Kotlin sink unchanged; one verb of the app's own, and a test that pins how it renders.
* Every emission pinned as an effect in a recording, with its parameters, and asserted in the scenario and in the chains that cross it; a repeated or late completion pinned as tracking nothing.
* A console sink worker on each platform behind the artifact's fan-out, adopted at the root before any child can emit, with a test through the worker harness on each side.
* The taxonomy read back out of the recordings by both languages' decoders, so an eighth event is a deliberate edit in two lists, and a grammar module suite that runs as one step of the workflow.
## Exercise: emit the sign-in event
`tutorial9-start` carries `Tutorial9ExerciseTrackedEventsTest`, a failing placeholder in the sign-in module. The taxonomy's second row is still empty on the tree: emit `Session Signed In` from `signInReducer` when the auth port answers `signedIn`, with one parameter, `provider`, whose value is `email` or `guest` from the provider held in `pending`. The steps above give you the four shape edits and the `SignInEvents` object to write; the verb is `AppVerbs.SignedIn`. Assert the event in `signin.email-signs-in` and `signin.guest-signs-in`, re-record the feature and `chain-root-signin`, which crosses the emitting step, and replace the placeholder with `SignInTrackedEventsTest`: a `TestStore` over the generated environment mock that checks the event reached `trackArgs` once with the name and the bag a vendor would see, and that a refused sign-in reached it not at all.
```kotlin src-kmp/subtrees/signin/logic/src/jvmTest/kotlin/dev/modaal/foyer/signin/SignInTrackedEventsTest.kt theme={null}
val tracked: List = environment.trackArgs
assertEquals(1, tracked.size)
assertEquals("Session Signed In", tracked.single().encodedName())
// The kind, never the address: the property bag is what a vendor sees.
assertEquals(mapOf("provider" to "email"), tracked.single().encodedProperties())
```
```sh theme={null}
tools/duet verify
```
`tutorial9-complete` carries the finished event and test. The address never enters the event: `SignInEvents.signedIn` maps the provider to its kind, and the test's last assertion is the property bag with one key.
## Common questions
The splash notifies its host on both completion paths by design, and [Tutorial 1](/tutorials/duet-01-first-feature#describe-the-behavior-as-a-scenario-and-record-it) pinned that in `splash.both-paths-notify-twice`: a completion latch in the splash would hide a bug the safety net exists to catch. A `Track` there would fire twice per launch. The root is where one of the two completions is acted on and the other is inert, so the root owns the count, and its recordings pin that a late or repeated completion tracks nothing. An event belongs to the reducer that decides the transition, which is not always the one that first sees the input.
Because `Viewed` and `Edited` are in the artifact's starter vocabulary and describe the same acts. A verb is a taxonomy decision: every event with that verb becomes a family of dashboard names, and two spellings of one act split a chart in two for as long as both exist. The rule is to reach for a starter verb first and mint one only for an act the vocabulary lacks, which is why Foyer mints exactly `Signed In`, the pair of the starter `Signed Out`.
A reducer that checked a consent flag before emitting would need the flag in its state, and then two recordings per branch. Consent gates the sink instead: `setEnabled(false)` on the fan-out reaches every sink, and each sink drops what it receives while disabled, which is where a vendor SDK's own opt-out switch lives anyway. The fan-out pushes the seed into every sink in its constructor, so nothing egresses before the app's persisted choice is applied. Foyer seeds `true` and has no Settings row; an app that ships a vendor adds the row, persists the choice, and forwards it to `setEnabled`. `identify(uid:)` and `reset()` are the same shape: composition-root calls on the session stream, never reducer effects.
A vendor SDK breaks the tree's hermeticity and needs a credential, so the tutorial ships the sink that needs neither. The shape is the vendor's: one class conforming to `AnalyticsTrackingWorking`, the only file that imports an SDK, added to the sink list at the root and adopted in its own right. Everything above the root, and every recording, is identical with a real sink in that list.
Lifecycle and system events are shell-side: a view shell calls `analytics.track` directly on the same grammar types, with no `Track` effect, because no reducer decided anything. Nothing in Foyer needs one today; the seven events are all transitions. The split rule is the one from the grammar's own documentation: reducer-driven events are effects and fixture-gated, lifecycle events are shell calls and are not.
The shared-services package commits five fixtures written by its Kotlin encoders and decoded by its Swift suite, which pins that the two grammars encode identically, verb tokens included. That is what lets the converter in `BridgedAnalyticsWorker` copy fields instead of translating. The app's envelope tests pin something the package cannot: that this app's seven events, as recorded, decode with both grammars and name exactly the taxonomy. Step 8's two tests are the app's contribution to the same contract.
A recording carries the line number of each step in its scenario source, so an assertion added above a branch shifts the lines of every branch below it. `tools/duet record` marks those rewrites as metadata-only, and the drift check treats them the same as any other change: a recording is what the scenario produces today, line numbers included.
## Sources and further reading
* [duet-services](https://github.com/modaal-agent/duet-services) — `DuetTelemetry` and its Kotlin twin `dev.modaal.duet.services:telemetry`: the grammar, the encoding rule, the sink port, the fan-out worker, and the twin fixtures under `contracts/telemetry-twin`.
* [Tutorial 4: Workers](/tutorials/duet-04-workers#test-the-workers-with-the-harness) — the worker seam and `WorkerTester`, which the two sink tests use.
* [Tutorial 6: The Checks in CI](/tutorials/duet-06-checks-in-ci#put-the-checks-in-a-workflow) — the workflow the grammar module's step joins.
* [The Duet glossary](/articles/duet-glossary) — [effect](/articles/duet-glossary#effect), [worker](/articles/duet-glossary#worker) and [golden recording](/articles/duet-glossary#golden-fixture).
## Read next
The tree this page opens, and the workflow the grammar module's step joins.
The same tree, a second theme over the cards, and no feature-code diff.
The same tree, every string into catalogs and resources, and a second language that changes no recording.
The nine tutorials, the app they build, the prerequisites and the versions they are verified against.
# AI App Builder for Native iOS and Android Apps
Source: https://docs.modaal.dev/welcome
Go from idea to production with Modaal. Describe what you want to build or change in natural language and Modaal will write the Swift and Kotlin for you.
Modaal is an AI coding agent that turns plain-language descriptions into **production-ready, native iOS and Android apps**. It’s built for developers, founders, product managers, designers and agencies who want to ship quickly and keep the resulting codebase maintainable.
## What Modaal is
Modaal is an orchestrator between you, your AI coding agent (like Cursor or Claude Code), and your native toolchain — Xcode on iOS, Android Studio and Gradle on Android. It guides the workflow end-to-end so your project stays structured and maintainable as it grows.
## Keeping a growing project maintainable
An AI agent handles a new project well, because the whole codebase fits in its context. As the app grows it stops fitting: architectural patterns diverge between features, and a change in one place breaks another. Modaal adds four steps to the AI loop to prevent that.
**🧩 What Modaal does:**
* **Turns your request into a written spec**
* **Plans architecture and key decisions** (and verifies them with you)
* **Keeps structure consistent over time** (prevents drift)
* **Builds, catches errors, and fixes continuously**
**💰 Predictable pricing:** a small Modaal platform fee ([plans on modaal.dev](https://modaal.dev/pricing)) + the agent you already pay for (Cursor / Claude Code). The Modaal fee is flat, not metered per generation.
**Built for:** developers, technical PMs/designers, founders, and agencies who want true native output.
## Native on both platforms
Modaal writes Swift on iOS and Kotlin on Android: no web view, no hybrid runtime, and no shared UI layer between the two.
SwiftUI screens, UIKit where SwiftUI does not cover the case, and an Xcode project Modaal builds and fixes as it goes.
Jetpack Compose screens and idiomatic Kotlin, in a Gradle project held to the same structure rules.
## Cross-platform without a cross-platform UI
Writing the same product twice costs roughly twice as much to build and maintain, and the two implementations tend to diverge in behavior over time. [**Duet**](/duet) shares the app’s *behavior* across both platforms and gates it mechanically, while the *UI* stays fully native on each side.
* **Logic is shared and pure** — features are reducers over typed state, identical on both platforms.
* **UI is native and thin** — SwiftUI on iOS, Compose on Android, with no shared UI code between them.
* **Parity is measured** — both platforms replay the same recorded fixtures, and the gate is byte equality, so a behavioral difference fails the build.
Where the two platforms need to differ — a Material dialog against a SwiftUI detent, a system camera against an in-app one — each difference is recorded in a ledger the project keeps. [Should you share code between iOS and Android?](/articles/share-code-ios-android) covers whether to share code at all and which route fits your app. The [Duet tutorials](/tutorials/duet) build one app this way on both platforms, from the first Kotlin feature to the CI checks.
How Duet keeps two fully native apps in sync — the five-layer presentation contract, the kind→renderer registry, and the ledger that records deliberate divergence.
## How to start
Download Modaal from [modaal.dev/download](https://modaal.dev/download) and follow the quickstart to a running app. The [FAQ](/faq) answers the questions people ask before their first project, and the [changelog](/changelog) lists what each release added. If describing software instead of writing it is new to you, [the vibe coding guide](https://modaal.dev/blog/vibe-coding-guide) covers what the term means and where native apps sit within it.
Install Modaal, connect a coding agent, set up Xcode, and go from an idea to a running app in five steps.
The new-project flow, what each template scaffolds, and how to pick between them.
When to turn the Plan toggle on, when to build directly, and the prompts that get a better result.
**Status.** iOS is the mature path. Android support is available to every account, in open beta. The Duet framework is **pre-release** — the framework, both flavors, and the toolchain have landed, but no artifacts are published yet, so treat the API surface as a preview until the first tagged release.