This is the technical half of Duet. That page covers what Duet is, which wizard card 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?
“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 — 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, 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; the Play Store listing is coming shortly.
Where this starts
Duet holds three commitments, covered in full on the Duet overview: 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 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: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.
How do you know four is enough? — the closure check
How do you know four is enough? — the closure check
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: 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 vsModalBottomSheet, 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.
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.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:HostedViewController + detents + a 28pt corner radius on one side; ModalBottomSheet on the other. The Android host additionally installs BackHandlers 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:modalDismissed(kind), whose kind-equality guard makes channel races inert.
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’sparity/manifest.yaml:
Manner waivers (L3)
Manner waivers (L3)
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.
Opaque islands (L4)
Opaque islands (L4)
Filed for regions whose internal presentation state lives outside the tree. The kinds and boundaries are identical across platforms; only the opaque internals differ.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.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
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:Zero logic files touched
Not one feature module changed.
duet verify held at 134/134 by construction, and the fixture corpus was never re-recorded.Divergence stayed cheap
Every platform-specific decision below cost at most a line in a log — no contract change, no waiver, no re-record.
- 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
Dialoginstead 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.
Adding a presentable surface: the whole recipe
1
The feature ships the kind
A new case on the owning node’s route/sheet enum, with a pure-value payload. No rendering exists yet.
2
Record the fixture first
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.
3
Name the verbs
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.
4
Each platform's composition root binds a renderer
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.5
Ledger the divergence, if any
Different manner class between platforms → a waiver entry. Different styling → nothing at all.
Common questions
Doesn't this mean writing the UI twice?
Doesn't this mean writing the UI twice?
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.
What if one platform simply can't do what the other does?
What if one platform simply can't do what the other does?
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.
What happens when the two do drift?
What happens when the two do drift?
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.
Summary
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.Duet overview
What Duet is, the two wizard cards that scaffold it, what lands on disk, and how to add Android to an iPhone-only project.
Migrating a CombineRIBs app
The per-feature route from an existing Production app project to a shared Kotlin core and an Android app.