# 08 · Native mapping

How the web tokens and components map to the iPhone app (SwiftUI, iOS 17+, with Liquid Glass APIs on iOS 26) and the Android app (Jetpack Compose). The web dashboard uses `tokens.css` directly. **Behaviour must match on all three. Only the material rendering differs.**

The native code is not touched until phase 4 (after the 30 Oct pilot). This page is the plan.

## Tokens → code

Generate both platforms from `tokens.json`. Don't hand-copy values.

| Token group | SwiftUI | Compose |
|---|---|---|
| Colours (light/dark) | An asset catalog colour set per token, or `Color(light:dark:)` built on `UIColor { traits in … }`. Namespace `Ink.*`, `Accent.*`, `Status.*`, `Stage.*`, `Channel.*` (extends the existing `Palette.swift`) | `RemlyColors` data class with `lightRemlyColors()` / `darkRemlyColors()`, provided through `CompositionLocal` and wired into `MaterialTheme.colorScheme` where roles overlap |
| Type | `RemlyType` (extends `Typography.swift`): `.system(size:weight:design:)` with `relativeTo:` the mapped text style so Dynamic Type scales; tracking via `.tracking()`; tabular via `.monospacedDigit()` | `RemlyTypography` of `TextStyle`s in `sp` (scales with font scale); `fontFeatureSettings = "tnum"` for tabular |
| Spacing, radii | `enum Space`, `enum Radius` (CGFloat) | `object Space`, `object Radius` (`Dp`) |
| Elevation | `.shadow(color:radius:y:)`. Note: CSS blur ≈ 2× SwiftUI radius | `Modifier.shadow(elevation, shape, ambientColor, spotColor)` or `dropShadow` |
| Motion | `Motion.signature` etc. (already springs in `Motion.swift`). Add `lens`, `sheet` | `spring(dampingRatio, stiffness)` from `tokens.json → motion.*.compose` |
| Haptics | `Haptics.selection/light/success/warning` (exists) | `HapticFeedbackConstants` per `tokens.json → haptics` |
| Wallpaper | A `RemlyWallpaper(kind: .vivid/.calm)` view: `ZStack` of `RadialGradient`s over a `LinearGradient` | A `Box` with `drawBehind { drawRect(Brush.radialGradient(...)) }` layers |

## Materials

| Material | iOS 26+ | iOS 17–25 fallback | Android 12+ (API 31+) | Android ≤ 11 / low-RAM / reduce transparency |
|---|---|---|---|---|
| **Glass** (chrome) | `.glassEffect(.regular, in: .capsule)` or `in: .rect(cornerRadius:)`. Group neighbours in `GlassEffectContainer(spacing:)` so they blend and morph | `.background(.ultraThinMaterial, in: shape)` + 1pt gradient `strokeBorder` (edge token) + inner top highlight + `glass-shadow` | A blur behind the shape (the **Haze** library: `hazeSource` on the content, `hazeEffect` on the chrome, `HazeStyle(tint, blurRadius = 22.dp, noiseFactor = 0)`), plus the tint fill, rim and shadow | Opaque `reducedTransparencyFill` + hairline |
| **Sheet glass** | System sheets get Liquid Glass automatically with `.presentationDetents`. Don't add a second glass layer | `.presentationBackground(.regularMaterial)` | `ModalBottomSheet` with a Haze background, radius 38, inset 8 | Opaque sheet colour |
| **Frosted** | `.background(.regularMaterial, in: RoundedRectangle(30))` + 0.5pt border. Use glass only if it sits directly on the wallpaper | Same | Haze with a stronger tint (72%) | Opaque |
| **Solid** | `Color("surface")` (94% / opaque). No blur needed | Same | Same | Same |

**Accessibility switches:**
- iOS: `@Environment(\.accessibilityReduceTransparency)` → opaque fills; `\.colorSchemeContrast == .increased` → contrast tokens; `\.accessibilityReduceMotion` → `RemlyMotion` returns nil (exists).
- Android:
  - Blur off when `Build.VERSION.SDK_INT < 31`, when `ActivityManager.isLowRamDevice`, or when the app's own "Reduce transparency" setting is on (Android has no system toggle; add one under More › This phone).
  - Reduce motion: when the animator duration scale is 0.
  - High-contrast text: `AccessibilityManager.isHighTextContrastEnabled`.
- Web: the media queries in `tokens.css`.

## Components → native

| Component | SwiftUI | Compose |
|---|---|---|
| Glass nav bar + large title | The `NavigationStack` toolbar; iOS 26 renders it as glass. Large title via `.navigationTitle` + `.toolbarTitleDisplayMode(.inlineLarge)` or a custom header that collapses on scroll. Trailing round buttons: `.buttonStyle(.glass)` (iOS 26) / `.bordered` + material (fallback) | `TopAppBar` / `LargeTopAppBar` with a Haze background that appears on scroll (`scrollBehavior`) |
| Floating tab bar + lens | iOS 26: the system `TabView` is already a floating glass bar with a moving selection. Use `Tab(...)` items. The Remy orb is a separate glass button: `tabViewBottomAccessory` or an overlay in a `GlassEffectContainer` with the bar. Fallback: the existing custom `RemlyTabBar` with material + `matchedGeometryEffect` lens | A custom `NavigationBar` replacement: a floating `Row` in a Haze pill; the lens is an `animateDpAsState` offset with the `lens` spring. The Remy orb is a separate circle |
| Sheet | `.sheet` + `.presentationDetents([.medium, .large])` + `.presentationCornerRadius(38)` (fallback) | `ModalBottomSheet` (`sheetState`, `dragHandle`) |
| List row / conversation row | A custom row view in `List` or `LazyVStack` on a solid card. `.contextMenu` = long-press menu. `.accessibilityElement(children: .combine)` + a custom label | `LazyColumn` rows; `combinedClickable(onLongClick)` → `DropdownMenu`; `Modifier.semantics(mergeDescendants = true)` |
| Unread badge, chips | `Text` in a `Capsule` | `Badge`, `AssistChip`-like custom `Surface(shape = CircleShape)` |
| Bubbles | The existing `BubbleShapes.swift` (tail on last in group). Colours from tokens | A custom `Shape` with a tail corner |
| Remy suggestion card + auto-send | Extend the existing `SuggestionBar` / `AutoSendBar` (keep the 5s countdown logic) | Same logic. `LinearProgressIndicator` for the drain |
| Voicemail card | The existing `VoicemailPlayer` (44 bars). Hide the play button when `audioURL == nil` | `MediaPlayer`/ExoPlayer; a `Canvas` waveform |
| Pipeline board | `ScrollView(.horizontal)` + `.scrollTargetBehavior(.viewAligned)`; `draggable`/`dropDestination`; "Move to…" `confirmationDialog`; `accessibilityActions` | `LazyRow` + snap fling; `detectDragGesturesAfterLongPress`; a sheet for "Move to…"; `customActions` in semantics |
| Calendar day/week | A custom `Canvas`/`GeometryReader` timeline (74pt per hour); ghost slot = `strokeBorder(style: StrokeStyle(dash:))` | A `Box` timeline with `offset`; dashed `drawBehind` path |
| Stat tile, hero, chart | Material card; Swift Charts `BarMark` for the chart | Card; a custom `Canvas` bar chart |
| Buttons | iOS 26: `.buttonStyle(.glassProminent)` tinted with `accentStrong` for primary, `.glass` for glass; fallback `.borderedProminent` | `Button` with custom `ButtonDefaults.buttonColors` |
| Segmented | `Picker(.segmented)` (iOS 26 glass) or a custom pill with a `matchedGeometryEffect` knob | `SingleChoiceSegmentedButtonRow` or a custom pill |
| Toggle | `Toggle` tinted `ok` | `Switch` with `SwitchDefaults.colors` |
| Search | `.searchable` (iOS 26 renders glass) | `SearchBar` / `DockedSearchBar` in a Haze pill |
| Skeleton | `.redacted(reason: .placeholder)` + a shimmer (off under Reduce Motion) | A placeholder brush |
| Offline pill | The existing `Connectivity` overlay | A `NetworkCallback`-driven pill |

**Deliberate platform differences** (allowed, documented here):
- Long-press menus on phones are the "…" button on the web.
- The iOS 26 system tab bar may minimise on scroll (`.tabBarMinimizeBehavior(.onScrollDown)`); others keep it visible. This is optional.
- Haptics don't exist on the web.

## Risks

1. **Glass readability drift.** System glass on iOS 26 adapts to what's behind it, so contrast can drop over bright content. *Mitigation:* dense content stays on solid surfaces, glass carries only `ink` text, and screenshots are tested over the vivid wallpaper and over white bubbles.
2. **Performance and heat.** Every blurred layer costs GPU time each frame while scrolling (worst on mid-range Android and older iPhones). *Mitigation:* blur only chrome (nav, tab bar, sheets). Never blur list rows or cards in a scrolling list. Group iOS glass in `GlassEffectContainer`. Drop to opaque on low-RAM Android. Profile on a mid-range Android before building every screen.
3. **Android has no Liquid Glass.** Haze gives a convincing blur on API 31+ only, without the refraction or light-bending. *Mitigation:* accept "frosted" on Android. The solid fallback is a first-class design, not an afterthought. Behaviour is identical.
4. **Fighting the system.** iOS 26 controls (tab bar, toolbars, sheets, search) bring their own glass. Re-styling them is fragile and breaks on OS updates. *Mitigation:* on iOS 26, use system components and tune only the tint. Custom drawing is for iOS 17–25 and Android.
5. **The iOS 17–25 fallback looks different.** Materials are flatter than Liquid Glass. *Mitigation:* the fallback is specified (material + rim + highlight). Review it on an iOS 17 device too.
6. **Web `backdrop-filter`.** It needs the `-webkit-` prefix in Safari, is slow on low-end Android Chrome, and nested blurs muddy. *Mitigation:* the same rules as native, plus the reduced-transparency path.
7. **Dynamic Type with custom sizes.** Fixed-size custom fonts don't scale. *Mitigation:* always use `relativeTo:` (iOS) and `sp` (Android). Test at AX3.
8. **Navigation change.** The new tabs (Today · Inbox · Leads · Jobs · More, Calls inside Inbox) differ from the shipping app (Home · Messages · Calls · Leads · More). *Mitigation:* Sruly decides in phase 2. Web must change in the same release so the three stay the same.
9. **Proposed data.** Suggestions, estimates, digest, drive times, suggestion mode and onboarding are not in the API. *Mitigation:* every screen works without them, and each needs a contract change before native work depends on it.
10. **Pilot freeze.** The pilot app (30 Oct) must not change. *Mitigation:* the native build starts after the pilot, on a branch, with the new look behind the existing Appearance switch (Classic / Glass) until signed off.
