# Adapt your app for iPhone Duo with a coding agent

Canonical: https://foldhunt.com/guides/everything-your-agent-needs-for-iphone-duo/

Adapt an iOS app for iPhone Duo with Claude Code or Codex: script the Xcode 27.1 simulator to fold, rotate and screenshot it, and use measured layout rules.

Author: [whiten](https://whitenbaiye.top/)

Published: 2026-10-09

Updated: 2026-10-11

This guide is for developers adapting an existing iOS app for iPhone Duo with a coding agent such as Claude Code, Codex or the agent in Xcode. It covers what an agent can't learn from Apple's documentation alone: how to drive the Duo simulator without a person at Device Hub, what each pose reports, and the layout decisions that hold up once you look at the screens. It comes with a kit your agent can fetch and run.

The examples come from adapting [Flicit](https://flicit.top), a SwiftUI app for collecting movie tickets, in five passes with Claude Code. Its developer judged every screenshot. The fourth pass fixed what they found using the simulator by hand: a hang on one turn, posters cropped square, and a pose that adapted one page out of three. The fifth checked the app against all six of Apple's iPhone Duo Tech Talks and added what they ask for that it didn't do yet. Everything was measured in Apple's iPhone Duo simulator (Xcode 27.1 release candidate, build 27A9275, with the iOS 27.1 runtime, build 24A94232) on an Apple silicon Mac. iPhone Duo reaches stores on October 23, so nothing here has run on the hardware yet.

**In short**

- **Build with Xcode 27.1's SDK**, not only its runtime, and run Apple's own `app-resizability` skill (`xcrun agent skills export`) before anything else. Put the fold code behind a compile flag so Xcode 27.0 keeps building.
- **Give your agent the kit.** `duo.py` folds, turns, captures and films every pose. `xcode_device.py` taps and reads the screen through Xcode's device-interaction tools, which only work while the phone is shut.
- **Capture the panel that is drawing**, and check the picture isn't black. Never run `simctl io screenConfig` on a Duo.
- **Ask the system where its bars go** (`toolbarVerticalEdge`). A tab bar you drew yourself belongs in that column, below whatever the system hangs there (the Dynamic Island grows down it with Live Activities). Everything else keeps the insets.
- **Design for the poses, not for sizes**: spreads when the page is wider than tall (a portrait photograph gets a leaf of its own), one column when it stands upright, and gutters on the crease. Put them there lying flat too: the system reports the fold as an inactive region, so a bend moves nothing. In the laptop pose, split every page that can be open in it into two halves, and keep every action the other poses have.
- **Animate only the bend.** Opening, shutting and turning happen under the system's own snapshot and rotation. Bending the open phone is the one change people watch your app make: animate it there and nowhere else, and have the new layout's images drawn in its first frame.
- **Never decide a layout from the size that decision produces.** A row that measured itself to choose its gutter hung Flicit on the turn to upright until the watchdog killed it. Measure the room you're offered.

## 1. Hand this to your agent

Everything here is meant to be fetched by an agent:

- **This guide as Markdown**: [foldhunt.com/guides/everything-your-agent-needs-for-iphone-duo/index.md](https://foldhunt.com/guides/everything-your-agent-needs-for-iphone-duo/index.md). Every FoldHunt page also answers `Accept: text/markdown`.
- **The kit**, laid out as an agent skill: [`SKILL.md`](https://foldhunt.com/kits/iphone-duo/SKILL.md) holds the rules and commands, and the scripts sit beside it. The same files are on GitHub as [iphone-duo-simulator-skill](https://github.com/WhitenWhiten/iphone-duo-simulator-skill), under the MIT licence. Nothing to install beyond Xcode; the mock-up script also needs numpy and Pillow.

| File | What it does |
|---|---|
| [`SKILL.md`](https://foldhunt.com/kits/iphone-duo/SKILL.md) | The short version of this guide: setup, hard rules, the pose table, the APIs, the layout rules |
| [`scripts/duo.py`](https://foldhunt.com/kits/iphone-duo/scripts/duo.py) | Folds and turns the simulator, captures and films the panel that is drawing, launches the app |
| [`scripts/duo_fold.c`](https://foldhunt.com/kits/iphone-duo/scripts/duo_fold.c) | The HID helper `duo.py` compiles and runs inside the simulator |
| [`scripts/xcode_device.py`](https://foldhunt.com/kits/iphone-duo/scripts/xcode_device.py) | Taps, swipes, types and reads the UI hierarchy through Xcode's MCP tools |
| [`scripts/duo_mockup.py`](https://foldhunt.com/kits/iphone-duo/scripts/duo_mockup.py) and [`duo3d.py`](https://foldhunt.com/kits/iphone-duo/scripts/duo3d.py) | Set captures in Apple's bezels, folding them for the book and laptop poses |

To install it as a skill for Claude Code (other agents, Codex among them, read the same folder from their own skills directory):

```sh
git clone --depth 1 https://github.com/WhitenWhiten/iphone-duo-simulator-skill .claude/skills/iphone-duo-simulator
```

Without git, fetch the same files from this site:

```sh
d=.claude/skills/iphone-duo-simulator; mkdir -p "$d/scripts"
for f in $(curl -fsSL https://foldhunt.com/kits/iphone-duo/files.txt); do
  curl -fsSL -o "$d/$f" "https://foldhunt.com/kits/iphone-duo/$f"
done
```

A good first instruction: *Read the iphone-duo-simulator skill and Apple's app-resizability skill. Run app-resizability's prerequisite checks, build with Xcode 27.1, then walk every pose and report what looks wrong before changing any code.* More skills, MCP servers and mock-up tools are in the [iPhone Duo developer tools](https://foldhunt.com/iphone-duo/tools/) directory.

## 2. The toolchain

### Build with the 27.1 SDK, not just its runtime

The 27.0 SDK does not contain the fold APIs: `reservedRegions`, `ArrangementView`, `onHingeChange`, `toolbarVerticalEdge`, `toolbarVerticalBehavior` and `axisBehavior` are absent from its interfaces. The trap is that a 27.0 build still runs. The 27.1 runtime boots the Duo and the app launches, but, as Apple's *Prepare your app* talk explains, an app built with the iOS 27.0 SDK stops short of the status bar's column on the inner display, and its bars stay horizontal. Only a 27.1 build reaches the edge of the screen and stands its bars in the column. Nothing fails, so an agent can mistake the old layout for the adapted one.

Find Xcode 27.1 with `ls /Applications/Xcode*.app`, point `DEVELOPER_DIR` at it, and check the SDK before trusting a build. The SwiftUI fold APIs live in SwiftUICore, so this prints a file only for an SDK that has them:

```sh
export DEVELOPER_DIR=/Applications/Xcode-27.1.app/Contents/Developer
grep -rl --include='*.swiftinterface' reservedRegions \
  "$(xcrun --show-sdk-path -sdk iphonesimulator)/System/Library/Frameworks/SwiftUICore.framework"
xcodebuild -scheme App -destination 'platform=iOS Simulator,id=<DUO-UDID>' build
```

Give the agent these facts directly instead of leaving it to reconstruct them from the `.swiftinterface`:

- `@Environment(\.toolbarVerticalEdge)` (a `HorizontalEdge?`) says which edge the system stands its bars on when it stands them in a column, `.trailing` or `.leading`. [Apple's documentation](https://developer.apple.com/documentation/swiftui/environmentvalues/toolbarverticaledge) is precise about it: it is the system's preferred edge in the current context "regardless of whether a vertical bar is currently visible", chosen from the locale and the device, and nil only where the system never uses a vertical bar (hardware without one, or a size class or orientation with horizontal bars, such as the open Duo stood upright). So a non-nil edge says where a bar would go, not that one is showing: check the safe-area inset on that edge as well. Any view can read it, not only a toolbar item. UIKit has `traitCollection.verticalBarEdge`, which is `.unspecified` where SwiftUI says nil.
- `GeometryProxy.reservedRegions(kind:options:)` returns `.division` regions for the crease and `.occlusion` regions for the clock cluster and the cameras, each with a `frame`, `margins` and an `isActive` flag. The frames are in the coordinate space of the view you ask, and in a right-to-left layout they are mirrored unless you pass `layoutDirectionBehavior: .fixed`. Apple's documentation says the method returns the regions that intersect your view, but in this build a 60-point square in the corner of the page got every region on the panel, the clock column and the crease included, so test each frame against your own bounds. Read them where you lay out, in a `GeometryReader` body or an `onGeometryChange` transform, never once at appear: they arrive after the first layout pass, so the first evaluation sees none. By default only active regions come back: Apple's layout talk says so and the simulator agrees, although the [method's documentation](https://developer.apple.com/documentation/swiftui/geometryproxy/reservedregions(kind:options:layoutdirectionbehavior:)) says it returns regions "regardless of whether they are currently active". Check `isActive` yourself either way. Pass `options: .includeInactive` and the fold is reported while the phone lies flat too, inactive, where it will bend, and so is the inner display's front camera while nothing uses it. UIKit has `UIView.reservedRegions(kind:options:)`.
- `onHingeChange` calls its closure with the previous and the current `DeviceHingeContext`. A context's `hinge` is nil on a phone without one, and otherwise carries a `status` (`.closed`, `.partiallyOpen`, `.fullyOpen`; a struct with static members, so a `switch` needs a `default`) and an `angle`. The system derives the status from the angle and the device's orientation. UIKit has `UIHingeInteraction`, whose [`UIHinge.angle`](https://developer.apple.com/documentation/uikit/uihinge/angle) is in radians; its documentation adds that the rate and precision of angle updates are system policy, and to prefer the status when that is all you need. Apple keeps hinge data for interactions and effects; layout comes from the regions.
- The bar APIs `axisBehavior(.verticalPreferred)` and `.horizontalOnly` on a toolbar item, and `toolbarVerticalBehavior(.disabled)` and `toolbarVerticalCompressionBehavior` on a view, are new in the 27.1 SDK. The last (the bar talk calls it `toolbarCompressionBehavior`) decides whether the tab bar or the toolbar gives up room first when the column runs short. `visibilityPriority` and `ToolbarOverflowMenu` were already in 27.0. Custom views stay in a horizontal bar unless they opt in.

### Keep Xcode 27.0 building

Build fold code with Xcode 27.0 and the compiler stops at every 27.1 symbol, not always in words that name the cause:

```
error: value of type 'GeometryProxy' has no member 'reservedRegions'
error: value of type 'GeometryReader<Content>' has no member 'onHingeChange'
error: cannot find 'ArrangementView' in scope
error: generic parameter 'Value' could not be inferred
error: cannot infer key path type from context; consider explicitly specifying a root type
```

All of them mean the SDK predates the fold APIs. The last two come from `@Environment(\.toolbarVerticalEdge)`, and the type in the `onHingeChange` error is whatever view you call it on. With Xcode 27.1 and a deployment target below iOS 27.1, the message is the familiar `'toolbarVerticalEdge' is only available in iOS 27.1 or newer`, which `if #available(iOS 27.1, *)` answers.

`#available(iOS 27.1, *)` doesn't help at compile time: against the 27.0 SDK, `reservedRegions` is simply not a symbol. Nor does `#if compiler(…)`: Xcode 27.0 and the 27.1 release candidate ship the same Swift 6.4 compiler. If anyone on the team, or your CI, still builds with Xcode 27.0, use a compilation condition that is on by default and cleared only for the 27.0 SDK, so every later iOS SDK gets the fold code without another edit:

```
// Debug; in Release the condition is FOLD_SDK alone, cleared to "" for 27.0
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG FOLD_SDK";
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphonesimulator27.0*]" = DEBUG;
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=iphoneos27.0*]" = DEBUG;
"SWIFT_ACTIVE_COMPILATION_CONDITIONS[sdk=macosx*]" = DEBUG;   // only with a Mac Catalyst destination
```

The last line matters only if the app has a Mac Catalyst destination. Catalyst builds against the macOS SDK, and Xcode 27.1 RC still ships macOS 27.0, whose Catalyst interfaces have none of the fold APIs, so without that line a Catalyst build fails in both Xcodes. Remove it once a macOS SDK has them. A native visionOS target is in the same position (the RC's visionOS SDK is 27.0), and so is any SDK older than 27.0 you still build with.

Keep everything that touches a 27.1 symbol in one place, wrapped in `#if FOLD_SDK` around `if #available(iOS 27.1, *)`, with the untouched view as the fallback. Flicit has one tracking modifier that publishes a single value, the "fold stage" (hinge, crease, occlusions, bar edge), and the rest of the app reads only that. The 27.0 build then doubles as proof that nothing leaked, and unit tests for the fold logic run on any stock simulator. One catch: `if #available` inside a `.toolbar { }` builder needs `buildLimitedAvailability`, which requires iOS 17.5. With an older deployment target, branch around the whole `.toolbar` call instead.

### Run Apple's skill first

Xcode 27.1 ships agent skills of its own. `xcrun agent skills export --output-dir xcode-skills` writes ten of them, and `xcrun agent plugin path --plugin-format claude` (or `codex`, `gemini`) builds a plugin with three more skills and Xcode's MCP server. The one for the Duo is **`app-resizability`**. It treats "prepare my app for iPhone Duo" as a request to run all of its tasks, because a screen that changes size exposes all of them at once: replacing `UIScreen.main`, `interfaceOrientation` checks and layout decisions based on `userInterfaceIdiom`, moving to the scene lifecycle, and fixing code that assumes the safe area is the same on both sides. It also checks three `Info.plist` prerequisites: a launch screen, all four iPad orientations, and no `UIRequiresFullScreen` (which it reports but never removes, since removing it makes the app resizable at once). Xcode 27.0 exports its predecessor, `uikit-app-modernization`, which covers UIKit only and knows nothing of the Duo.

It covers what the system resizes, not what the fold makes possible: it doesn't mention `reservedRegions`, the hinge or `ArrangementView`. Run it first, then design the poses. On Flicit it flagged three places. One was a real bug: a Metal view sized its drawable from `UIScreen.main.scale`, and now uses the view's own `traitCollection.displayScale`. For an app that still supports iOS 17, also register for `UITraitDisplayScale` changes; from iOS 18, UIKit re-runs `layoutSubviews` by itself when a trait it read changes. Skip the layout while the scale is still 0, which it is before the view reaches a window. The other two were `userInterfaceIdiom == .phone` checks that decide features, not layout (whether to offer Live Activities, which an iPad doesn't have, and whether reminders start switched on), so they stayed.

### Then watch Apple's six Tech Talks

They hold rules the skill doesn't, and this guide refers to them by short names:

| Talk | Short name | What the skill doesn't tell you |
|---|---|---|
| [Prepare your app for iPhone Duo](https://developer.apple.com/videos/play/tech-talks/111461/) | prepare talk | What the 27.0 and 27.1 SDKs each get; the inner display doesn't honor your supported orientations; `ConcentricRectangle` for the screen's corners |
| [Raise the bar with iPhone Duo](https://developer.apple.com/videos/play/tech-talks/111462/) | bar talk | Which toolbar items move into the column, `axisBehavior`, overflow priorities, sheets with one button |
| [Strike a pose with adaptive layouts](https://developer.apple.com/videos/play/tech-talks/111463/) | layout talk | Reserved regions, displacement, `ArrangementView` |
| [Leverage multiple displays and scenes](https://developer.apple.com/videos/play/tech-talks/111464/) | multitasking talk | The hinge API, Split View, new windows, scene accessories |
| [Build a great camera experience](https://developer.apple.com/videos/play/tech-talks/111465/) | camera talk | The Virtual Front Camera, the direction coordinator |
| [Design for iPhone Duo](https://developer.apple.com/videos/play/tech-talks/111466/) | design talk | What shares the side column, Picture in Picture pinned to the top, sheets in each pose, the laptop layout |

The multitasking and camera talks are the easiest to skip, and both matter. A second view on the cover is only for camera apps: `CameraCaptureAccessory` is available while the app is full screen on the inner display with an active camera session, and Apple's Group Labs panel added that it needs an entitlement too. New windows can't be opened from the cover. The camera talk introduces the Virtual Front Camera, which switches between the inner and outer front cameras by itself, and [`AVCaptureDeviceDirectionCoordinator`](https://developer.apple.com/documentation/avkit/avcapturedevicedirectioncoordinator) for apps that pick a camera themselves; it lives in AVKit, not AVFoundation. Flicit's ticket scanner uses the rear camera with `AVCaptureDevice.RotationCoordinator`, which the camera talk says updates when the app moves between displays, so it needed nothing.

## 3. Fold, rotate and screenshot the iPhone Duo simulator from the command line

Device Hub has pose buttons, a hidden hinge slider (hold Option over the pose buttons) and an orientation picker; the [simulator checklist](https://foldhunt.com/guides/iphone-duo-simulator-checklist/) covers using them by hand. An agent can use none of them. `simctl` has no hinge or rotate command. Xcode 27's `devicectl` lists simulators and has `device orientation set`, but on the Duo, shut or open, it reports the new orientation without turning anything, and `device orientation get` reads `portrait` whatever the pose. On a headless simulator `XCUIDevice.shared.orientation` changes nothing either.

The slider and the picker each post one vendor-defined HID event, usage page `0xFF61`, usage `0x5B`, whose payload is a small binary plist. Posting the same event from inside the simulator, through `simctl spawn`, folds or turns the device. Artem Novichkov's [`hinge`](https://github.com/artemnovichkov/hinge) wraps the hinge event (not the orientation) in a Homebrew CLI, and Meta's idb posts both events from the Mac in its HID code.

| Control | `source` | `type` | `value` |
|---|---|---|---|
| Hinge slider | `hinge-slider-control` | `range` | degrees, 0 shut to 180 flat |
| Orientation picker | `orientation-picker-control` | `enum` | `portrait`, `pud`, `landscape-left`, `landscape-right` |

Both payloads also carry a `provider`: the kit and `hinge` send `com.apple.Virtualization`, and idb sends `com.apple.Virtualization.VirtualMachines`. The kit's `duo_fold.c` posts the events, and `duo.py` compiles it on first use and wraps it:

```sh
python3 duo.py fold close               # 0°, the cover
python3 duo.py fold open                # 180°, the inner display
python3 duo.py fold book                # 128°, the simulator's book pose
python3 duo.py orient upright           # stand the phone upright
python3 duo.py fold laptop              # 110°; upright, that is the laptop pose
python3 duo.py path 1.0 180 140 180     # one timed motion through several angles
python3 duo.py scene --fold laptop --orient upright --bundle com.example.app --out shots/
```

`scene` folds, waits for the hinge to settle, turns the phone, relaunches the app and captures it. The orientation names are the device's own, which is confusing on an open Duo. Its native pose is sideways, so `portrait` gives the landscape interface with the crease running down, and `landscape-left` stands it upright with the crease running across; `duo.py` accepts `sideways` and `upright` for them. On the iOS 27.0 runtime an iPad mini ignores the event, so this doesn't rotate other simulators.

Set the orientation for every pose, after the hinge has stopped. An orientation posted while the hinge is still moving is sometimes dropped, so `duo.py orient` waits and posts it twice. And the device keeps its last orientation through a fold: in Flicit's walks, shutting the phone straight after the laptop pose brought the cover up sideways, 678×466 with the system's column down the leading edge, and the book pose asked for next came out upright. Both are real poses worth a look, but not the ones the script meant to capture.

Bend the phone from flat, not from shut: the simulator's hinge has hysteresis. Opened from shut, the app stays on the cover and the hinge reports `closed` up to about 147° (150° set); the inner display takes over only past 160°. Closed from flat, the inner display stays lit and the hinge `partiallyOpen` down to about 24° (20° set), and only 0° brings the cover back. So `duo.py fold` opens the phone flat first when the cover is drawing, and bends an open phone directly, without passing through flat. A `path` or `sweep` that starts shut has the same trap.

`path` exists for gestures timed in seconds. Each separate call spends most of a second starting a process, which can stretch a quick bend past whatever time window the app allows.

**There is no angle readback.** `hinge get` runs `xcrun devicectl device motion hinge-angle`, and the `devicectl` in both Xcode 27.0 (27A266a) and the 27.1 RC (27A9275), version 651.13.5 in each, rejects it: "Error: 2 unexpected arguments: 'motion', 'hinge-angle'". `hinge`'s README says it was verified with Xcode 27.1; whichever build that was, this one has no such command. The system's CoreDevice framework already contains messages for hinge-angle monitoring, so a later `devicectl` may add it: run the command once to find out. No HID listener inside the simulator receives the change, and no plist stores it. What does read back is the pose: `xcrun devicectl device info displays --device <udid>` lists both panels, `LCD` (the cover, 1398×2034) and `LCD-1` (the inner display, 2007×2853), each with `"active"` true or false, and `--stream` prints every change, so it tells shut from open without a screenshot. Its `currentDeviceOrientation` follows the open phone, named from the inner display's upright frame (sideways reads `landscapeRight`, upright `portrait`), but stayed `portrait` on the shut phone however it was turned. Remember the angle you set (`duo.py status` shows the last one). The app's own reading differs from it, most near flat: 110° read 108.9° in the app, 128° read 125.9° and 175° read 170.3°, and only exactly 180° read `fullyOpen`.

### Screenshot the inner display without getting a black image

In every pose, `simctl io <udid> enumerate` lists both panels among its ports: the cover at 1398×2034 pixels and the inner display at 2007×2853 (466×678 and 669×951 points at 3×). Shut, the app draws on the cover and the inner port captures as pure black; open, it is the other way round. `simctl io screenshot` without `--display` keeps capturing the cover, so it returns black once the phone is open.

Resolve the ports by pixel size every time, never by name: the UUIDs change with every boot, `--display=internal` happens to be the inner display, and `--display=external` times out. Then test the picture before trusting it. An off panel is an all-black PNG of about 50 KB; a lit one is several megabytes. `duo.py shot` does both: it decodes the top rows of each capture and keeps grabbing until a panel lights up, then names the panel. Relaunch the app after changing the pose, and allow for slow launches: Flicit's first launch after a reinstall took ten seconds longer than later ones, long enough for a capture to land on the splash screen.

Xcode 27's `devicectl` captures by display too, with a `--display-unique-id` from `device info displays`. Its `device capture screenshot` saves either panel already turned to the interface orientation (2853×2007 for the open phone sideways, where `simctl` gives the raw 2007×2853 framebuffer), and the off panel comes out black, as with `simctl`. `device capture screen-record` films the raw framebuffer, one recording per device: a second fails at once with "Host recording is already in progress". The unique IDs change with every boot as well. With the `active` flag beside them, `device info displays` is the quickest way to find the panel that is drawing.

### Record a video of a fold

A screenshot shows where a layout lands; a film shows how it gets there. `simctl io <udid> recordVideo --display=<port UUID>` films one panel, and on a Duo two things about it are not obvious. A device films one panel at a time: a second `recordVideo` on the same device runs, but never finishes its file. And a recorder doesn't follow the picture: when its panel goes dark it goes on filming black. So film the panel that is drawing, and switch to the other once a fold has lit it. `duo.py record` takes a list of steps and does the switching:

```sh
python3 duo.py record --out films/ path:1.2:0:180 rec:inner wait:4          # open the phone, then film the inner display
python3 duo.py record --out films/ path:2:180:128 wait:2.5 path:2:128:180   # bend into a book and back
```

Frames land only when something on the panel changes, at irregular times. Extract them as they are, `ffmpeg -i film.mp4 -fps_mode passthrough frames/%04d.jpg`, not through an fps filter, which repeats stale frames and fakes stalls.

In Flicit's films the system did most of the moving. Opening and shutting, it covers the hand-over between panels with a blurred snapshot of the app; bending, the simulator blurs and darkens the leaf that tilts away. Underneath, the app lands in its new layout in one step. The only motion that read as the app's own was a reflow on the bend: the ticket list's column jumped onto the crease as the crease became active, and back when the phone lay flat again. Any two-pane row that waits for the active crease moves the same way; section 7 has the fix.

A film runs to hundreds of frames, and the faults are in a handful. Name each frame after its time in milliseconds, compare it with the one before, and read the largest differences first: a hard cut is one large step, a crossfade a run of small ones, and a blank frame stands out on both sides.

```sh
ffmpeg -i films/laptop-inner.mp4 -fps_mode passthrough -enc_time_base 1/1000 -frame_pts 1 frames/%06d.png
prev=; for f in frames/*.png; do
  [ -n "$prev" ] && echo "$f $(compare -metric RMSE "$prev" "$f" null: 2>&1)"; prev=$f
done | sort -t'(' -k2 -g -r | head
```

Film before changing anything and again after: the films of the laptop pose in section 7 found a placeholder that the cut had hidden. In the simulator the crease turns active about a second after the hinge stops, so a swap the bend causes shows after the bend, not during it. After a turn, the region can take longer still: give a film enough tail before calling the last frame a bug.

### When a turn hangs the app

A layout that never settles doesn't crash where you can see it. The screen keeps the old layout and stops responding. A crash report appears only once the app is killed, by the watchdog or by hand, and at first glance it reads like a crash in a dispatch queue. Flicit did this on one turn only, from sideways to upright; the other way round worked. The trail that found the cause:

1. **The crash report.** The simulator's reports land on the Mac in `~/Library/Logs/DiagnosticReports/<App>-<date>.ips`. Flicit's said `0x8BADF00D`, the watchdog: "scene-update watchdog transgression … exhausted real (wall clock) time allowance of 5.00 seconds", or "Failed to terminate gracefully" after a force quit. The main thread was inside UIKit's rotation, in `-[UIViewController window:willAnimateRotationToInterfaceOrientation:duration:newSize:]` → `-[UINavigationController _layoutViewController:]` → `_UIHostingView.layoutSubviews()`, rendering SwiftUI. That is a layout pass that never returns, not a deadlock.
2. **A sample of the live hang.** Reproduce it with `duo.py orient upright`, then `sample <pid> 3` on the app's simulator process. Every main-thread sample sat in one `ViewGraphRootValueUpdater.render`, with `GraphHost.flushTransactions` and growing animation arrays under it: SwiftUI applying state changes and laying out again inside a single pass.
3. **SwiftUI's own logs.** `log show --info --debug --predicate 'subsystem BEGINSWITH "com.apple.SwiftUI"'` (through `simctl spawn <udid>`) prints faults such as "… tried to update multiple times per frame", which name the binding involved. Adding `Self._logChanges()` to suspect views' bodies (iOS 17.1 and later, so wrap it in an availability check if you deploy earlier) logs which property changed each time a body runs. Flicit's two-pane row logged `__frame changed` 2,450 times in 30 seconds.
4. **The values.** A `Logger` line in the row's geometry callback showed the frame alternating between two widths for ever. Section 9 has the loop and the fix.

The app's CPU alone doesn't tell you: right after a reinstall it can sit at 100% while it rebuilds caches, so sample before blaming the layout.

### Split View and Picture in Picture sizes

Two of the Duo's windows can't be reached headless. Split View needs a drag from the home indicator on the open phone. A video pinned to the top in Picture in Picture needs one playing; it shortens the app live, and once the phone folds it takes half the screen. Xcode's device tools, which could tap, see only the cover while the phone is open (section 4). `devicectl device appResize` gets halfway. Once the phone has been opened, the Duo lists a third display, `Resizable`, and `appResize start --preferred-size <W>x<H>` moves the frontmost app onto it, where `appResize set` resizes it live, up to 1280×1280, and `device capture screenshot` captures it. It needs an unlocked device in the orientation `devicectl` calls portrait: sideways it refuses ("Rotate the device to portrait"), open and upright it dimmed the app on the inner display while the `Resizable` display captured black, and shut it worked. Flicit came out at 475×669 and 669×455 as asked, and at 380×667 when asked for 380×600. But that is a plain resizable window, without the side column, the crease or the Duo's insets (Flicit stood its tab bar along the bottom), so it tests sizes, not the Duo's windows. Render those instead: put the app's pages in a `UIHostingController` inside a `UIWindow` in a unit test, and resize the window live through the sizes they make. Estimated from the panel's numbers, those are 951×669 open sideways, 951×420 with a video pinned, 475×669 (compact) for half the screen in Split View, 669×951 upright, and 669×455 with a video over a laptop's raised half. A layout that measures itself into a loop never returns from that test. Flicit's pages all settled, and kept an open ticket and an unsaved review through every size. In the two short windows its portrait ticket is limited by the height, the case the laptop stub was made for.

### Never run `simctl io screenConfig`

It is tempting to wake the inner panel with `simctl io screenConfig`. On a Duo, `power on` and `geometry` return without effect, and the next hinge event crashes SpringBoard, InputUI and chronod in the same frame:

```
EXC_BREAKPOINT (SIGTRAP), main thread
FrontBoardServices -[FBSDisplayMode _initWithWidth:height:…validityCheck:]
FrontBoardServices -[FBSDisplayConfiguration _initWithImmutableDisplay:…]
QuartzCore CA::Display::Display::update()     ← ensure_displays()
```

The crashes repeat until launchd gives up; the app itself never crashes. Recovery is `simctl shutdown` and `simctl boot` of the same UDID. Put the ban in the agent's standing instructions (the kit's `SKILL.md` has it), not in a code comment.

### Other tools that drive the Duo simulator

The kit isn't the only way in. What each of these does on Xcode 27.1, where a dash means the tool doesn't say:

| Tool | Folds | Turns | Taps the open phone | Notes |
|---|---|---|---|---|
| [duoctl](https://github.com/skarol/duoctl) | yes | yes | yes | Checked here on October 11 (section 4); its hinge angle reads back null, like `hinge get` |
| [hinge](https://github.com/artemnovichkov/hinge) | yes | no | no | Homebrew CLI and agent skill for the hinge event; `hinge get` fails on `devicectl` 651.13.5 |
| [serve-sim](https://github.com/EvanBacon/serve-sim) | yes | – | – | Serves a simulator in the browser, with fold buttons and a hinge slider for the Duo, according to its README |
| [Appium XCUITest driver](https://github.com/appium/appium-xcuitest-driver/pull/3001) | yes | – | – | `mobile: setSimulatedHingeAngle`, merged October 8; it doesn't wait for the layout to settle |
| [idb](https://github.com/facebook/idb/tree/main/FBSimulatorControl/HID) | yes | yes | – | Posts both HID events from the Mac |
| [Maestro](https://github.com/mobile-dev-inc/maestro/issues/3617) | no | no | – | Duo poses are an open request |

## 4. Tap and read the screen: Xcode's MCP tools and duoctl

Pictures show a layout; testing a flow needs taps. Xcode exposes MCP tools through `xcrun mcpbridge`, among them `DeviceInteractionStartSession`, `DeviceInteractionSynthesize` and `DeviceInteractionEndSession`, with a `device-interaction` skill that documents them. `Synthesize` runs one command and returns a screenshot and the UI hierarchy, with a hit point for every element. The commands are taps (`t x y`), double taps, swipes (`t x1 y1 f x2 y2 secs`), drags, multi-touch, hardware buttons, typing, waits and `orientation`. Xcode must be running, and it asks once to allow the connection. The kit's `xcode_device.py` is a small client for these three tools:

```sh
export DEVELOPER_DIR=/Applications/Xcode-27.1.app/Contents/Developer
python3 xcode_device.py start --device <DUO-UDID>   # pass the exact UDID, so nothing else boots
python3 xcode_device.py look                        # screenshot, hierarchy, what can be tapped
python3 xcode_device.py tap "Settings"              # by accessibility label
python3 xcode_device.py do "t 200 560 f 200 260 0.4"
python3 xcode_device.py end                         # sessions are expensive; always close them
```

On the Duo, Xcode reads the device through its **cover**:

- **Shut, everything works**: a real 466×678 screenshot, the hierarchy with labels and hit points, taps and swipes.
- **Open, it sees the wrong panel.** The screenshot is the black cover, the hierarchy drops every element past the cover's width (on a sideways page that includes anything in the side column), and taps don't land. `orientation landscapeLeft` didn't turn the open phone either.
- **There is no hinge command**: `hinge` and `fold` come back as "Invalid command".

So the two tools split the work. `duo.py` folds, turns and captures every pose; `xcode_device.py` taps and reads the screen while the phone is shut. To test a flow, run it shut first, then open the phone with `duo.py` and capture what it left behind. One trap: a hierarchy read during an animation has stale hit points. Flicit's search bar was still sliding up when the hierarchy was read, so a tap on its close button landed under it and only dropped the keyboard focus. Look again before you retry.

For the open phone there is [duoctl](https://github.com/skarol/duoctl). It clones the lit display's touchscreen as a virtual HID service inside the simulator and sends touches through it, in the interface's points. Checked on October 11: `duoctl tap 908 488` switched Flicit to its calendar from the side column of the open phone, where Xcode's taps don't land; `duoctl rotate portrait` stood the open phone upright; and `duoctl state` named the lit screen and its size. Its `hingeAngle` came back null, because it reads the angle with the same missing `devicectl` command. It finds elements by label only with the AXe tool installed. Otherwise take coordinates from a capture: a `devicectl` screenshot is the interface's points at 3×. So run a flow shut with Xcode's tools and their hierarchy, and on the open phone with duoctl and captures.

The hierarchy is also a measuring tool. To check that opening search no longer rebuilt every page, the agent scrolled Flicit's Me page, tapped the search button in the side column and closed it again, and compared the "Viewing Count" heading's position in the three hierarchies: unchanged, so the scroll view survived.

## 5. Safe areas, the crease and reserved regions, in points

Measured inside a view that respects the safe area, with the region frames moved from that view's coordinates into the window's. A bare SwiftUI probe app measured every row again on October 11 and got the same numbers.

| Pose | Window | Insets | Active occlusions | Crease | Bar edge |
|---|---|---|---|---|---|
| Shut | 466×678 | trailing 84, bottom 34 | clock column (382, 0, 84×170); camera (399.7, 29.3, 37×37) | none | trailing |
| Open, sideways, flat | 951×669 | trailing 84, bottom 34 | clock column (867, 0, 84×120) | none | trailing |
| Open, sideways, 128° (book) | 951×669 | trailing 84, bottom 34 | clock column (867, 0, 84×120) | (455.5, 0, 40×669) | trailing |
| Open, upright, flat | 669×951 | top 82, bottom 34 | status cluster (535, 0, 134×82) | none | nil |
| Open, upright, 110° (laptop) | 669×951 | top 82, bottom 34 | status cluster (535, 0, 134×82) | (0, 455.5, 669×40) | nil |

What follows from them:

- The side column runs the full height of the panel, but the system's own chrome in it is only 120pt tall open and 170pt shut. Below that, the column is where the system puts its bars, and it stays empty if your app has none.
- The page inside the insets is **382pt** wide shut and **867pt** open sideways. In this build, below roughly 800pt a `NavigationSplitView` stops tiling its list beside the detail, so the upright open phone (669pt) and an iPad mini in portrait (744pt) need their own answer (section 7).
- Upright, the phone behaves like a small iPad in portrait: bars along the top and bottom, an 82pt status band, and a 669pt page. The prepare talk adds that the inner display doesn't honor your supported interface orientations, so don't decide a layout from the interface orientation.
- The crease is 40pt wide at exactly half the window, and active only while the phone is part folded. Don't key anything to the angle at which it turns on.
- The cover turns too. Shut while the device is turned (after an upright pose, say), it lies 678×466 and the system moves its column to the leading edge, with the clock in its top 82 points. A layout that assumes the cover is always 382pt wide and taller than wide meets a page that is neither.

With `.includeInactive`, three more readings, in window coordinates like the table:

- Lying flat, the fold is reported inactive at the active crease's own frame: (455.5, 0, 40×669) sideways, (0, 455.5, 669×40) upright, and none shut. Apple describes it as zero wide, so code for both.
- The inner display's front camera is an inactive occlusion at (677.3, 21, 58×37) sideways and (21, 215.7, 37×58) upright. It turns active, and something to keep clear of, only while a camera session uses it.
- Turning or shutting, one layout pass carries the previous pose's regions at the new size: an upright page with the sideways fold, the cover with the open phone's. Check a region against the page before using it.

One trap in measuring code: a `GeometryReader` that ignores the safe area reports **zero** insets, because it has none left to report.

## 6. The vertical bar's column and the safe-area insets

Shut, and open sideways, the system stands its bars in a column down one side (the [vertical bar guide](https://foldhunt.com/guides/iphone-duo-vertical-bar/) has Apple's rules). What follows decides whether a page looks designed for that column or pushed aside by it.

### A tab bar you drew belongs in the column

Apple's bar talk says system bars move into the column and a bar you drew yourself stays where you put it. Taken literally, a custom floating tab bar stays at the foot of the page, beside an 84pt column that holds only a clock at its top. Every page then sits left of an empty strip. Flicit's first pass did exactly that, and the developer's verdict was that every page looked pushed to the left.

Standing the bar on its side over the page is no better: on the trailing edge it overlaps the clock, and on the leading edge it covers content. What works is a rail *inside* the system's column, below the clock, where the system's own tab bars go: the design talk keeps a tab bar at the bottom of the column. Launch partners do the same: on the open phone, [Slack](https://foldhunt.com/apps/slack/) stands its tabs and its search button at the foot of the column.

{{image flicit-cover-rail | Shut, before and after. The floating bar left an empty column beside the page; in the column, the tabs read as the page's navigation, and the foot of the page goes back to the content.}}

```swift
// In the root view, beside the existing floating bar. 27.1 SDK only.
@Environment(\.toolbarVerticalEdge) private var barEdge

.overlay {
    if let barEdge, columnWidth > 0 {               // columnWidth: the safe-area inset on barEdge
        SideRail(selection: $tab, search: expandSearch)
            .frame(width: columnWidth)
            .padding(.bottom, 12)
            .frame(maxWidth: .infinity, maxHeight: .infinity,
                   alignment: barEdge == .leading ? .bottomLeading : .bottomTrailing)
            .ignoresSafeArea(.container, edges: barEdge == .leading ? .leading : .trailing)
    }
}
```

- Read the edge rather than assume trailing. In Split View an app's controls move to its outer edge, which is leading for the app on the left. And Apple's bar talk says the column stays on the same side of the device in right-to-left languages, where that side is the leading edge. Upright, the edge is nil and the floating bar comes back.
- Measure the column from the safe-area inset on that edge. The edge is only where a bar would go, so with no inset on it there is no column and no rail. The rail ignores the safe area on that edge only; the page keeps its insets.
- With the rail showing, take the floating bar's height out of the pages' scroll margins. If search lives in the bar, let it rise as a search field over the page while it's open, without changing the page's margins (see the identity trap in section 9).
- Let the rail ignore the keyboard (`.ignoresSafeArea(.keyboard)`). Flicit's rode up with it into the clock.
- A screen that hides the floating bar, such as a full-screen detail or a selection footer, hides the rail too.
- Draw the rail as the floating bar turned on its side, not as a second design. Flicit's bar is an admission ticket: the tabs on one strip of paper, notched at a perforation, with the search as the stub beyond it. In the column the same ticket stands upright with the stub at its foot, so opening, shutting and turning the phone move the navigation without changing what it is.
- Check every pushed page that keeps a system navigation bar. A symbol-only `ToolbarItem` moves into the column, and may collide with the rail; custom views stay put unless they opt in (below).

### Share the column

The column isn't yours alone. Apple's design talk lists what else lives there: the status bar, and the Dynamic Island, which grows down the column as Live Activities arrive. When the system's own bars run out of room in it, they fold items into an overflow menu. A bar you drew can't join that menu, so it has to make its own room. Flicit's rail sat 12 points above the bottom inset at a fixed 274 points (five 54-point cells under a 4-point lip), whatever hung over the column. It now reads the active occlusion regions over its column (clock cluster, camera, island) and fits its five cells below the lowest: 54 points each, down to 44-point targets. Below that it gives way to the floating bar, as the system's bars give way to their menu.

```swift
// chrome: the bottom of the active occlusions over the column on the bar's edge (global y)
let available = pageBottom - spacing - chrome - spacing
let cell = ((available - lip) / 5).rounded(.down)
let railCell: CGFloat? = cell >= 44 ? min(54, cell) : nil     // nil: the floating bar comes back
```

The simulator won't show this happening: the app in front never sees its own Live Activity in the island, and starting another app's takes taps the headless tools can't make. Flicit's debug builds take `-columnChromeHeight <points>` to stand in for a grown island: 380 points shut shortens the cells to 47, and 200 on the turned cover or 400 open brings the floating bar back.

### Your bar items in the column

In Apple's bar talk, a toolbar's symbol items move into the column, text-only items stay in a horizontal bar, and custom views stay horizontal unless they opt in with `axisBehavior(.verticalPreferred)` and lay themselves out for a vertical axis. In a split view only the detail column's items move; the other columns keep horizontal bars. Flicit's bars are all custom views (a paper close disc, a paper back disc, a strip of four glyphs on cut paper), so on the cover a ticket's detail kept a bar across the top beside a column that held only the clock. The detail now opts the back disc and the strip into the column while the app's rail is put away, and stands the strip on its end there. Beside the rail they stay `.horizontalOnly`, so the system never stacks them where the rail is. Shut, the ticket starts higher by the bar's height.

```swift
ToolbarItem(placement: .topBarTrailing) { strip }      // a VStack in the column, an HStack along the top
    .axisBehavior(railIsAway ? .verticalPreferred : .horizontalOnly)
```

Sheets follow the same rules. Shut, a sheet's toolbar becomes a vertical bar; open, the sheet is centred with a horizontal bar, and in a book the system slides it off the crease onto one leaf. For a sheet whose bar holds a single close or done button, Apple suggests `toolbarVerticalBehavior(.disabled)` (`preferredVerticalBarBehavior` in UIKit), so the button doesn't cost the sheet a column. Its [documentation](https://developer.apple.com/documentation/swiftui/view/toolbarverticalbehavior(_:)) asks for a stable choice: don't toggle it with a view's state. The sheet then stops just short of the front camera, and the status bar moves out of its way. Flicit has ten such sheets. On the search sheet, the results used to stop at the column and now use the cover's full 466 points.

### What doesn't count as adaptation

Each of these looks right on screen and breaks a constraint the hardware imposes.

- **A band for the clock.** Growing the page header to the clock cluster's height keeps list cards out from under the clock, but it pushes every page down by 170 points shut and 120 open, for a clock that sits only in the column.
- **Content under the column.** `ignoresSafeArea(.trailing)` on a root view does nothing, because UIKit-hosted navigation containers re-read the window's insets. Inside a page it takes effect, and puts posters and serial numbers under the column, where they look cut off. Photographs are the exception: a backdrop can run under the column with its dimming, while the type keeps the insets.
- **Mirroring the column on the leading side.** Symmetric gutters centre the page on the glass and cost 84pt. Shut, they squeeze cards until their text wraps; open, they collapse a two-column layout into one.

### Centre narrow things on the glass

A page centred inside the insets sits half a column left of the glass's middle. Where content fills the page, nobody notices. Where it doesn't, it shows: a settings page or a report capped at a readable width, or a single card floating on the cover. Apple's design talk allows centring on the full display for highly visual screens that don't scroll, as long as the controls in the column cover nothing interactive. Flicit applies the same idea to narrow columns on scrolling pages, within limits: it moves them toward the middle of the glass only as far as the page's own free margin, so nothing runs under the column, and only on pages that span most of the window, never in a detail pane beside a list:

```swift
// page: the scroll view's global frame; column: the content's width
let glassMidX = (stage.contentOriginX + stage.contentWidth + stage.trailingInset) / 2
let margin = max(0, (page.width - column) / 2)
let shift = min(margin, max(-margin, glassMidX - page.midX))
content.offset(x: shift)        // and a principal toolbar title, so it stays over the content
```

On Flicit, that moved a year-in-review page 42pt toward the middle when open sideways, and its year title with it.

## 7. Layouts for the poses, not stretched phones

Making every screen fit is the first pass. The second asks what each screen should be on a page that lies wider than it stands, on one that stands upright at tablet width, and on one that folds.

Apple's design talk says to design for the two size classes and avoid fixed widths and breakpoints. On the open Duo that isn't enough alone: held sideways and stood upright, the inner display is regular in both directions, so the size class can't tell a spread from a column. Apple's own arrangements also read the aspect ratio and the division regions, and Flicit does the same: it asks whether the page is wider than tall, and where the crease band runs. The widths it still uses (600 points for a spread, about 800 where a split view stops tiling, an inset over 160 points counting as a column) describe content, such as two phone-width columns or a list beside its detail, not a screen. The 800 is the exception that needs watching: it is where this build's `NavigationSplitView` gave up tiling, not a number Apple publishes, so measure it again with each SDK.

### Lying wide: spreads, and cards that hug their content

{{image flicit-open-me | Open sideways, the Me page is a spread: the year's figures down one side, the seat map of the year beside them with the year-in-review link under it, then the collection and the two charts side by side.}}

Grown to 867pt, a phone column becomes a stretched phone: a chart the size of the page and the figures a scroll away. Rearrange it once the content is wider than a phone (Flicit uses 600pt): figures on one side, the picture on the other, panes below. Then look for what the wider page stretched:

- **Don't match heights you don't need.** Flicit's heatmap card was stretched to the height of the figures beside it, and the month view's three rows of seats floated in a mostly empty card. The card now hugs its map, and the link that was below the figures moved under the map to balance the two sides.
- **Cap what grows.** The heatmap's seats stop at a phone's size, so the auditorium stays phone-sized in a wider card.
- **Shrink what was tall for a phone.** Flicit's two charts had 160pt plots under a row of scope chips. They became 104pt plots with the scope picker in the section heading, and a rating curve over ten half-star points, mostly flat, became a histogram beside the average.
- **Keep state above the layout.** A choice made in one arrangement (the heatmap's year or month) has to survive the phone opening into the other.
- **Don't leave a pane empty.** A list beside an empty detail is the first thing people see when they open the phone on a list. Flicit's detail pane used to show a drawn placeholder and a prompt; it now lays the three newest tickets on it, each printed in its own theme and a tap from opening. The list stays where it was, and the right-hand page shows more of the collection instead of asking for a choice.
- **Give sections side by side one heading height.** A heading with a control beside it is taller than one without: Flicit's viewing chart has a Year, Month, Week switch with 44pt targets in its heading, the rating chart only a title. Side by side, the rating chart's title, rule and card started higher, and its card, stretched to the same bottom, made up the difference as empty floor above its bars. Every dashboard heading now has the same minimum height, 44pt, whatever sits beside it.

{{image flicit-open-library | Opened on the Library with nothing chosen, the right-hand page lays out the three newest tickets, each in its own theme and a tap from opening, instead of asking for a choice.}}

### A portrait photograph gets a leaf, not a banner

A phone page often opens with a photograph across the top. Flicit's film page did: the poster filled the width, top-aligned, 560pt tall, with the title over its foot. On a phone that shows most of the poster. Across the open Duo's 951pt it showed about the top quarter. A wider banner shows less of a portrait picture, never more.

Give the picture a leaf of its own instead. Open sideways, the Duo's left leaf is 455×669 points, almost exactly a poster's 2:3, so Flicit's poster now stands whole on it, edge to edge, with the title over its foot, and the overview, cast and ticket actions scroll on the right leaf, starting past the crease. That is what Vidit Bhargava's [Movie Buzz](https://foldhunt.com/ideas/movie-buzz-the-poster-fills-one-half-details-the-other/) does on the open Duo, and it holds in the book pose without a change. Elsewhere the leaf is a poster's width for the page's height.

```swift
// page: the view's frame inside the window's insets, in global coordinates
guard page.width >= 560, page.width > page.height * 1.15 else { return nil }   // lying wide
if let crease = creaseBand,                                    // below: the active crease, or where it will run
   crease.lowerBound > page.minX + 320, crease.upperBound < page.maxX - 320 {   // a phone's width each side
    return (leaf: crease.lowerBound - page.minX, gutter: crease.upperBound - crease.lowerBound)
}
return (leaf: min(glassHeight * 2 / 3, page.width * 0.48), gutter: 0)
```

The same rule takes in the sideways cover and an iPhone held in landscape. Upright at a tablet's width (the open Duo, an iPad mini in portrait), the page stays one column, and the banner grows toward the poster's own shape, up to three quarters of the screen's height, so the overview still shows under it.

Opened from a ticket, the same page sits in a split view's detail column, beside the list. There the column's leading inset is the list's whole width, 455pt, and a banner told to run under the insets ran under the opaque list, which hid most of the poster. The photograph now runs under the window's edges and the side column, but never under an inset wider than any bar (Flicit draws the line at 160pt), and the type starts past the crease that runs down the column's leading edge.

### Standing upright: one column, not two narrow ones

Upright, the open phone is 669pt wide and 951pt tall. It's tempting to treat it like the wide page and split it into panes. For Flicit's calendar, that gave a month a phone's width beside the day's schedule, both narrow, and since most days hold one or two tickets, the bottom half of the screen held nothing. Upright is one column, with the day's tickets centred under the month. The split stays for pages that lie wider than they stand.

The first version of that column spanned the page and eased its poster cells from 2:3 toward square, so the month and the day's first ticket shared the screen. It met its goal and failed the look: every poster was cropped to a square, and the developer called the result ugly. A poster is 2:3 wherever it appears. The cells stay 2:3, and the month narrows instead, to the width at which six rows of posters fit above the tab bar with the day's heading under them: about 455 of the 669 points, centred, and never narrower than a phone's month. On an iPad mini in portrait the full width already fits, so nothing narrows. When a layout has to give, give up width before you distort the content.

A `NavigationSplitView` makes the same mistake on its own. Below about 800pt it can no longer tile the list beside a readable detail, so it floats the list over a dimmed detail column. Turning Flicit's open phone upright dismissed the list altogether and left only the empty detail; launched upright, the list floated over a grey desk. Below that width, give the split view a compact size class, and it collapses into a stack, as on a phone:

```swift
@Environment(\.horizontalSizeClass) private var windowSizeClass
@State private var width: CGFloat = 0

private var sizeClass: UserInterfaceSizeClass? {   // 800: where iOS 27.1's split view stopped tiling
    windowSizeClass == .regular && width > 0 && width < 800 ? .compact : windowSizeClass
}

NavigationSplitView { List(…) } detail: { Detail(…) }
    .environment(\.horizontalSizeClass, sizeClass)
    .onGeometryChange(for: CGFloat.self, of: { $0.size.width }) { width = $0 }
```

Everything inside then follows one answer: the list is the page, a selection pushes the detail with a back button, and code written for a compact detail (Flicit hides its tab bar there) applies. Turning back sideways tiles the two columns again with the same selection. A list that becomes the whole page needs a layout for that width too; Flicit's waterfall of tickets falls into two columns once two phone-width stubs fit, and centres a single column at 520pt when they don't.

Then check the turn with something open. Reading a ticket sideways and turning the phone upright put Flicit back on its list, the ticket gone, although `preferredCompactColumn` still said `.detail`: the collapsed split view pushes the detail when that binding *changes* to it, not when it already was, which is why a cold launch upright on a ticket worked and the turn didn't. And when it did show the ticket, the tab bar stayed over it. Collapsing builds a new view for the detail before the old one disappears, and both claimed "hide the bar" under one owner ID kept in the ticket's store, so the old view letting go released the new one's claim. Two small fixes:

```swift
// In the split view: collapsed with a detail open, ask for it again.
.onChange(of: sizeClass) { _, sizeClass in
    guard sizeClass == .compact, selection != nil, compactColumn == .detail else { return }
    compactColumn = .sidebar
    Task { @MainActor in compactColumn = .detail }
}

// In the detail: a claim per view, not per item.
@State private var barOwner = UUID()
```

Apple's design talk asks that an app stay predictable and consistent as people open and close it, and both failures looked like a reasonable layout in a screenshot. Turn the phone with a detail open, both ways, and look at the result.

{{image flicit-upright-calendar | Upright, the calendar is one column: a month of whole posters, narrowed and centred so it fits the screen, and the day's tickets under it.}}

### Part folded like a book: gutters on the crease

Partly folded sideways, the crease is a 40pt band down the middle (the [fold guide](https://foldhunt.com/guides/iphone-duo-fold/) has the API). Scrolling content may cross it; the question for a two-pane page is where its gutter falls. Put it on the crease: size the leading pane to it and use the crease's width as the spacing, as long as each pane keeps its minimum width. Each pane then lies on its own leaf like a page of a book.

Put it there lying flat, too. By default the system reports the crease only while the phone is part folded, but ask for inactive regions and it reports the fold lying flat as well, where it will bend. A row that waits for the active region reflows the moment the phone bends, under the system's own fold animation, which is what Flicit's films caught. A row laid out on the line from the start doesn't move:

```swift
// In a GeometryReader body; offset the frames by the reader's global origin.
let divisions = proxy.reservedRegions(kind: .division, options: .includeInactive)
let active = divisions.first { $0.isActive }?.frame
let resting = active == nil ? divisions.first { !$0.isActive }?.frame : nil

// The crease in global x: the active one, or where it will run.
var creaseBand: ClosedRange<CGFloat>? {
    if let seam = active { return seam.minX...seam.maxX }                  // part folded
    guard let fold = resting, fold.height > fold.width,                     // runs down the page
          abs(fold.height - fullPageHeight) <= 1,                           // not left over from the last pose
          pageWidth > pageHeight else { return nil }                        // not upright
    let half = (fold.width > 0 ? fold.width : creaseWidth) / 2              // zero wide on hardware, says Apple
    return (fold.midX - half)...(fold.midX + half)
}

// A row of two panes. Measure the width the row is offered, never the panes':
// a frame with both a minimum and a maximum takes its offer, whatever its content asks.
HStack(spacing: gutter?.width ?? 16) { primary.frame(width: gutter?.primaryWidth); secondary }
    .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading)
    .onGeometryChange(for: ClosedRange<CGFloat>.self) { proxy in
        let frame = proxy.frame(in: .global)
        return frame.minX...frame.maxX                  // x only: scrolling doesn't re-run the row
    } action: { span = $0 }

guard let crease = creaseBand, let span,
      crease.lowerBound > span.lowerBound, crease.upperBound < span.upperBound else { return nil }
let primaryWidth = crease.lowerBound - span.lowerBound  // left leaf
let gutter = crease.upperBound - crease.lowerBound      // the crease itself
// secondary: whatever is left right of the crease, if it keeps its minimum
```

Flicit first worked the band out from the hinge, an angle past 90° and the middle of the glass, and each threshold was a way to get it wrong: waiting for 150° let the band lapse mid-bend and threw the columns off the line and back. Apple's multitasking talk keeps hinge data for interactions and effects and leaves layout to the regions, and the resting region holds at any angle until the crease turns active. Two checks remain, for the pass in which UIKit lays out a new pose with the old pose's region: the page must lie wider than tall (a band across an upright page started the loop in section 9), and the region must span the page's full height (shutting the turned phone, the cover's first pass still carried the open phone's fold).

For two panes that aren't navigation, Apple's own answer is `ArrangementView` (`UIArrangementViewController` in UIKit). It takes a primary and a secondary view and splits them, or lays one over the other, from the size classes, the aspect ratio and the division regions. The layout talk sets two limits: don't put one inside a `List` or `ScrollView`, and don't put a `NavigationSplitView` inside one. Flicit's two-pane rows sit inside scroll views, where an `ArrangementView` doesn't belong, so they are placed by hand, as above.

A `NavigationSplitView` gets the same treatment, with three details that only showed up once Flicit logged its column frames in every pose:

- **Part folded, the system places the divider itself**, where the crease begins (at 455.67pt, the crease's 455.5 rounded to a whole pixel), whatever ideal width you ask for. Apple's layout talk describes the same: the system keeps both columns visible in an even 50/50 split. Ask for that edge, not the middle of the crease, so lying flat matches.
- **Lying flat, it takes a changed ideal width at once, but keeps the detail at least about 448pt wide.** On the open Duo's 867pt page that stopped Flicit's list at 419pt, 36pt short of the crease, and the first bend moved it. On a folding phone, lower the detail column's minimum.
- **The crease then runs down the detail's leading edge** in both poses. Give the detail a leading inset of the crease's width, so the ticket starts on the right-hand page.

```swift
// toFold = crease.lowerBound - frame.minX, used only while it lies within 360...520; else nil
NavigationSplitView {
    List(…)
        .navigationSplitViewColumnWidth(min: 360, ideal: toFold ?? 410, max: max(440, toFold ?? 410))
} detail: {
    Detail(…)
        .safeAreaPadding(.leading, crease.upperBound - detailFrame.minX)  // 40pt
        .navigationSplitViewColumnWidth(min: 320, ideal: 412)              // folding phones only
}
```

Measure `frame` (the split view's) and `detailFrame` in global coordinates. With no crease, keep the list's usual ideal width and an inset of 0. A wider list without the lower minimum doesn't fit: the split view then floats the list over the detail, and Flicit's code that restores the columns fought it in a loop. Don't force the width by setting `min` and `max` to the same value either; see section 9.

With all three, Flicit's Library looks the same lying flat, in a book, flat again, opened from shut and turned from upright.

{{image flicit-book-ticket | Partly folded, Flicit's list ends at the fold and the ticket lies on the right-hand leaf.}}

### Grids: the fold between items, never through one

A row of posters or photos laid across the open phone will sooner or later put one item on the crease, and part folded, that item bends. The layout talk suggests preferring an even number of columns whenever a division region is present, active or not, and Wesley Matlock's [VinylCrate](https://foldhunt.com/ideas/vinylcrate-even-columns-so-the-fold-never-splits-a-cover/) does exactly that with its album grids. Flicit's collection is a contact sheet, rows of film strips with a poster in each frame, laid across the whole page. Each strip now takes the same number of frames on each leaf and leaves the crease (or where it will run, lying flat) as the gap between them; the film itself, its edges and sprockets, runs on across, so it still reads as one strip. Work it out from the strip's global extent and the crease band, as for two panes: frames per leaf is what the narrower leaf holds, and if that leaves the frames too narrow, the sheet keeps its ordinary rows.

### Centred things: the focus off the fold

Apple's layout talk asks you to audit centred layouts, with a photograph spread across a book's spine as the warning: near the crease it stops reading as one picture, and a control there is hard to hit. Scrolling content may cross; the thing the eye and the thumb go to shouldn't. Flicit's Discover page opens on a fan of trending posters, and open sideways its front poster sat exactly on the crease. Where the crease band crosses the carousel, flat or folded, the fan now moves onto the left leaf with one poster either side, and the front film's caption (title, year, runtime, rating, overview) takes the right leaf and follows the fan as it turns; a tap on either opens the film. Apple calls this displacement: move an element to the region that suits its purpose, not far from where it was, and move related things together. The system's sheets, alerts and menus already do it for themselves.

{{image flicit-open-discover | Open flat, the fan of trending posters sits on the left leaf with one poster either side of the front film, and the film's caption takes the right leaf. Nothing the eye goes to rests on the fold.}}

### The laptop pose: a screen and a surface

Stand the open phone upright and fold it to about 110°: the top half rises like a screen and the bottom half lies flat. Give each half a job, on every page that can be open in the pose, not just the one you designed it for. Flicit's first version did it on one page: the ticket shrank onto the raised half and the cinema took the flat half, and a swipe to the ticket's notes or to its film brought back full pages running across the crease.

- **Match the content's shape to the half's.** The raised half is 669 points wide and about 373 tall. A portrait ticket there is bound by the height: Flicit's came out at 192×341 points, against 408×720 lying flat. The ticket now stands there in its landscape form, the stub the wallet lists it as, printed in its own theme and scaled to fill the half, about three times the area. If your content has no landscape form, give the half something that does, rather than a shrunken portrait.
- **What you look at goes up, what you touch goes down.** The ticket stays on the raised half while its pages turn on the flat one: the hall with the ticket's seat inked in, the rating and notes, the film. Flicit's film page puts the poster and the overview up, and the rating, the cast and the ticket actions down, as [Micropod](https://foldhunt.com/ideas/micropod-cover-art-up-playback-below-when-half-folded/) puts a show's artwork above the hinge and playback below.
- **Each half scrolls on its own.** A page that scrolls across the crease puts half of what you're reading on the flat half, at an angle you can't read. Split it into two scroll views at the crease, as [Finma](https://foldhunt.com/ideas/finma-half-fold-it-and-the-chart-and-list-scroll-apart/) does with its chart and transaction list.
- **Leave out what doesn't fit.** Flicit's calendar needs a month of posters taller than either half, so it keeps its one column in the laptop pose rather than shrinking the month to stamps.
- **Keep every action.** Apple's design talk: a layout for one pose keeps the controls and hierarchy of the others. Flicit's laptop page swapped the ticket page for the stub, and with it the tear that marks a planned ticket watched, the detail's only way to do that. The ticket's menu now has Mark Watched in every pose.

The split itself is the crease's top and bottom against the page's frame, measured where the page lays out:

```swift
// seam: the active division region, in global coordinates; page: the page's global frame
guard seam.width > seam.height,                        // a crease running across: upright, part folded
      seam.minY > page.minY, seam.maxY < page.maxY else { return nil }
let raised = seam.minY - page.minY                     // above the crease
let flat = page.maxY - seam.maxY                       // below it
guard raised >= 220, flat >= 180 else { return nil }   // each half tall enough for its part
```

Compute it in the body of a `GeometryReader` that fills the page, or from a frame that doesn't depend on the split, never from the size of what you split.

{{image flicit-laptop-ticket | The laptop pose: the ticket in its landscape form on the raised half; on the flat half its pages turn under it, the first the hall seen from the screen with the ticket's seat inked in.}}

### The cover

The cover's page is 382pt wide, slightly narrower than a 390pt phone's. A grid sized to the phone loses a column there; keep the column count while each item stays at least 86 percent of its reference width. A detail that floats a single card centres it on the glass (section 6), drawn a little smaller so both edges stay on the page.

Apple's prepare talk points apps at `ConcentricRectangle` and `UICornerConfiguration`, which follow the Duo's screen corners. Flicit's chrome is cut paper on purpose, so it isn't concentric. What has to hold is that nothing gets clipped: the rail's bottom corner sits about 46 points above the cover's bottom edge and 15 in from its side, inside the curve of the glass.

Shut while turned, the cover lies 678×466, wider than tall, with the system's column on the leading edge, and it takes the rules of a wide page rather than a phone's. Flicit's calendar used to crop its poster cells square there as well. Now posters stay 2:3 everywhere, and the film page gives the poster a leaf.

{{image flicit-cover-ticket | Shut: a ticket's detail puts the rail away and stands its own bar in the column, and the ticket centres on the glass.}}

### StandBy on iPhone Duo, and showing it in the simulator

Stand the half-folded phone on a table, as a tent or an open book, and iPhone Duo goes into StandBy, the full-screen clock and widgets, on either display and without a charger. It looks like one more pose to lay out. Flicit went looking for a way in, and found it only through its widgets and Live Activities.

**No API reports the pose.** The public hinge API (`onHingeChange`, `UIHingeInteraction`) gives a `status` and an `angle`, nothing more. The system does name the poses: SwiftUI's private `_HingeState.MagicPose` lists `book` and `laptop` with their upside-down forms, `tent`, `openFlatFaceDown`, two landscape `magicMount` poses and `ambiguous`, and SpringBoard decides from them when StandBy appears and on which display. None of that is public. WidgetKit's iOS 27 additions are `isDynamicIslandLimitedInWidth` and the `.systemExtraLargePortrait` family, nothing for StandBy on a folding phone. Apple's Group Labs panel drew the same line: an ordinary app stood as a tent shows its normal landscape interface on the outer display, and the glow the inner display gives off at alarm time comes through AlarmKit, to alarm apps only. The prepare talk names the one thing an app can do for the pose: support landscape on the cover, "as people may want to set the phone down like a tent."

**Folding and turning the simulator doesn't reach it.** SpringBoard logs every decision, so you can watch it refuse:

```sh
xcrun simctl spawn <DUO-UDID> log stream --style compact \
  --predicate 'process == "SpringBoard" AND eventMessage CONTAINS[c] "magic"'
```

```
Ambient magic pose event detected [ tabletopPose : Amb ; hingeState : PO ; deviceOrientation : Lr ; … ]
Updating ambient trigger state : idle [ … isChargingRequirementFulfilled : YES ; ignoreCharging : YES ;
  … isMagicPoseSupported : NO ; magicPose : Amb ; magicPoseState : unsupported ]
```

Flicit walked the Duo through ten poses the hinge slider and the orientation picker can make, from shut through 70°, 110° and 128° to flat, in all four orientations, and the log said the same each time: the tabletop pose stays ambiguous (`Amb`), the magic pose is unsupported, and StandBy is denied with "no trigger asserted". Neither control says which edge rests on the table, so the simulator never learns that the phone is standing. Charging is not what's missing (`ignoreCharging : YES`), and Device Hub's pose buttons only combine the same two controls. Which panel draws at 70° depends on how the hinge got there (section 3). Bent down from flat, the inner display keeps drawing and the cover stays black; opened up from shut, the cover keeps drawing and the hinge reports closed, the nearest the simulator comes to an app stood as a tent.

**`devicectl` can show it.** `xcrun devicectl device settings standby --show --duration 10 --device <udid>` presents StandBy on the simulator, as long as the lock screen is showing and the interface is landscape. Until the lock screen was up it refused: "StandBy needs the lock screen to be showing, and the device to be in a pose that supports StandBy." `simctl` has no lock command, but the side button is a keyboard event (consumer page `0x0C`, usage `0x30`) that can be posted from inside the simulator like the hinge event: one press locks, a second shows the lock screen. From there StandBy came up on the inner display open flat sideways, at 128° sideways and at 70° held sideways either way up, and on the cover shut and turned to one landscape side (`landscape-right`). Upright, and shut in portrait, `device info standby` read `unavailable`. While it shows, SpringBoard logs a tabletop pose and an asserted trigger, which folding and turning never reach. Each time it opened on Apple's "Welcome to StandBy" screen. Xcode's tools can't tap the open phone, but duoctl can (section 4): past the Continue button, StandBy on the inner display showed the system's battery and contact widgets without their backgrounds, and a swipe moved between its pages. How it shows an app's own widgets, which have to be added by editing StandBy, wasn't tried. Apple's Group Labs panel also listed a tent pose and rotation in three dimensions among what Device Hub can vary, beyond the two HID controls scripted here.

So prepare what StandBy can show, as for any iPhone: small widgets that still work when the system removes their background (`showsWidgetContainerBackground` says when it has), a Lock Screen Live Activity that holds up scaled to twice its size, and, if they suit your content, extra-large widgets, which the panel called particularly useful in the Duo's StandBy. Seeing them in StandBy waits for the hardware. Flicit hasn't done this part yet: its widgets paint their own paper background, and none comes in an extra-large size.

### Between the poses: animate only the bend

A later pass filmed every change of pose, before and after, frame by frame; the [animation guide](https://foldhunt.com/guides/iphone-duo-animation/) has the reasoning in full. Opening and shutting happen under the system's blurred snapshot, and turning under its rotation: the app is resized and lands in its new layout unseen. A bend is the one change nothing covers. With its gutters on the resting crease, Flicit's sideways bend moved nothing. The laptop pose did: the ticket and film pages cut from one layout to the other in a single frame, both ways, and the layout that came in showed its artwork's placeholder (black on the stub, grey on the film's poster) for 60 to 100 milliseconds before fading the picture in.

- **Animate a bend, and decide it where the pose is published.** A bend is the crease turning active or resting while the window, the tracked view plus its insets, keeps its size. Opening, shutting and turning resize the window, and the first reading starts from nothing. Flicit's fold stage publishes a bend inside `withAnimation` (a spring, and no animation under Reduce Motion) and every other reading without one. A page that switches layout with an `if`/`else` on the pose then crossfades through SwiftUI's default transition: about 120 milliseconds on film. An `.animation(_:value:)` keyed to the pose would also fire during a turn, on top of the rotation.
- **Draw the images in the new layout's first frame.** An image view that loads asynchronously starts empty, even when the picture is already decoded. Keep the bitmaps last drawn in a cache the body can read synchronously: the exact request (file, pixel size and scale) first, else the largest decode of the same file if it is at least half the size, replaced without a fade when the view's own load lands. If something samples the pixels, check that it survives the stand-in. Flicit's poster label takes its colour from a 24×24 sample of the poster, and on film it didn't change.
- **Cut where a crossfade blinks.** Flicit's film page, whose two layouts are built on scroll views, didn't fade out: the old layout dropped in one frame, and flattening the phone, the new one faded in over bare paper for about 40 milliseconds. It opts out with `.transaction(value: isLaptopLayout) { $0.animation = nil }` and cuts, its images already drawn. Film every page that changes layout for a pose: one crossfade doesn't promise the next.

```swift
// In the stage: the crease changed, the window didn't.
func isBend(to next: FoldStage) -> Bool {
    (seam == nil) != (next.seam == nil) && windowSize.width > 0
        && abs(windowSize.width - next.windowSize.width) <= 0.5
        && abs(windowSize.height - next.windowSize.height) <= 0.5
}

// In the one reader at the root of the window.
func publish(_ next: FoldStage) {
    guard next != stage else { return }
    guard stage.isBend(to: next), !reduceMotion else { stage = next; return }
    withAnimation(.interpolatingSpring(mass: 0.9, stiffness: 280, damping: 24)) { stage = next }
}
```

## 8. The hinge as a control: gestures with onHingeChange

Apple's multitasking talk draws the line: hinge data arrives live and is for interactions and effects, and layout belongs to the region and arrangement APIs. Use the hinge only where folding is the natural motion for the action. Flicit lets you tear a planned ticket's stub off to mark it watched; on the Duo, bending the open phone lifts the stub along its perforation and opening it flat again tears it, because that is how you tear a real stub.

{{image flicit-tear | Bending the open phone lifts the stub along its perforation. Opening it flat again tears it off and asks for the rating.}}

A hinge gesture has to survive everything else people do with a hinge (the [hinge guide](https://foldhunt.com/guides/iphone-duo-hinge/) collects how hinge games detect theirs):

- **Arm it only from a known start**, here the phone lying flat (at least 166°). Read the hinge as the screen appears, not only when it changes: opened with the phone already flat, a screen can otherwise miss the first readings of a quick bend, which the system may coalesce (Apple documents the rate and precision of angle updates as system policy). Flicit keeps the latest `onHingeChange` reading in its fold stage, and the ticket reads it with `onChange(of:initial: true)`.
- **Require a deliberate motion**: a bend to 148° or less, 32° short of flat, and a return to flat within 2.5 seconds of leaving it.
- **Shutting the phone is not the gesture**: below 70°, reset. Resting it half open to read is not the gesture either: held bent past the window, disarm.
- **Confirm, and keep a second way in.** The tear opens the same sheet as the drag, nothing is saved by the hinge alone, and the drag still works on every phone.

Keep the rules in a small pure type fed with angles and timestamps; it unit-tests on any simulator, including ones without a hinge. In the Duo simulator, test the gesture as one motion: `duo.py path 1.0 180 140 180`. How it feels in the hand waits for the hardware.

## 9. Traps an agent will walk into

- **Deciding a layout from the size that decision produces.** Flicit's two-pane row read its own global frame to decide whether its gutter could sit on the crease. Forced onto the crease, the panes ran wider than the row offered (633pt); without the gutter, the row measured 538pt, too narrow for it. SwiftUI applies a state change from a geometry callback and lays out again inside the same pass, so the row switched its gutter off and on for ever: the main thread never left UIKit's rotation, and the watchdog killed the app. Measure the room you're offered, with a frame that has both a minimum and a maximum (`.frame(minWidth: 0, maxWidth: .infinity)` takes its offer whatever its content asks), and only the extent you use. Section 3 shows how it was found. The film page did it again when opened from a ticket on the phone held open sideways. Laid out once at the window's width before it was placed in the 411pt detail column, it chose the poster's leaf, whose fixed 446pt made the page wider than the column. The column centred the wider page, which moved its insets, and the page measured itself afresh on every frame at full CPU, stuck mid-push. Measured at the size offered (heights too, for a page), it settles in one pass.
- **Trusting one reading of the pose mid-turn.** The loop above started only on the turn to upright, because for one layout pass UIKit reported the upright size with the sideways bar edge and side inset still in place, and the row saw a crease band across a page standing upright. Check that what the pose says agrees with the page's own shape (a band down the page only while it lies wider than tall), and turn every pose into every other, both ways.
- **Swapping view identity when chrome appears.** A modifier written as `if height > 0 { content.contentMargins(…) } else { content }` replaces the whole scroll view when the height changes from 0. Flicit's side rail made that height change whenever search opened, so every page under it was rebuilt and replayed its entrance animation, and again on cancel. Apply the modifier unconditionally with the value 0.
- **Insets from the wrong view.** A `GeometryReader` that ignores the safe area reports zero insets, and an inset read once goes stale on the next pose. Read insets where you lay out.
- **Taking every inset for an edge.** A split view's detail column runs under its list, and its leading inset is the list's whole width (455pt on the open Duo). A photograph that runs under the insets runs under the list too. Treat an inset wider than any bar as a column beside the page (section 7).
- **Regions that arrive late.** `reservedRegions` is empty on the first layout pass. Code that measures the crease once at appear never sees it.
- **Gestures armed on change only.** Keep the latest hinge reading where every screen can read it, and read it with `onChange(of:initial: true)`, or a screen that appears mid-pose never arms.
- **Shared-state APIs.** `UIScreen.main`, `interfaceOrientation` and idiom checks for layout are what `app-resizability` replaces; run it rather than hunting by hand.
- **Pinning a split view's column.** `navigationSplitViewColumnWidth(min: w, ideal: w, max: w)` with a width that follows the fold spun Flicit's split view at full CPU on the Duo, and the app never left its launch screen. Give the crease's edge as the ideal width only, and lower the detail's minimum instead (section 7).
- **A split view expanded from a stack.** Opening the phone, or turning it from upright to sideways, expands a collapsed `NavigationSplitView`, and its list gets a fresh navigation bar: a sidebar toggle in an otherwise empty bar, a bar's height above the list, although the list asks for `.toolbar(.hidden, for: .navigationBar)`. SwiftUI doesn't hide the new bar while the requested visibility stays the same, and rebuilding the list with `.id` didn't help. Remove the toggle with `.toolbar(removing: .sidebarToggle)` and ask once more as the size class turns regular: set the bar `.visible`, then `.hidden` again on the next main-actor turn. Filmed on the Duo, the reveal falls inside the system's hand-over.
- **A split view left to float.** Below about 800pt a `NavigationSplitView` floats its list over a dimmed detail, and turning the open phone upright can dismiss the list altogether. Collapse it with a compact size class there (section 7).
- **A split view collapsing with a detail open.** Collapsed while `preferredCompactColumn` already says `.detail`, it comes up on the list: it pushes the detail when the binding changes to it. Set the binding to the list and back on the next main-actor turn. And a claim one view makes on shared chrome (hide the tab bar while I'm showing) belongs to that view, not to the item it shows: the collapse builds the new view before the old one disappears, and a shared owner ID let the old one release the new one's claim (section 7).
- **Laying out from the hinge angle.** An angle threshold for a layout is one more way to be wrong mid-bend: Flicit's crease band lapsed between 150° and the crease turning active. Ask for `.includeInactive` regions instead (section 7).
- **A drawn bar that assumes the column is empty.** The island grows down the column with Live Activities. Measure the active occlusions over the column each time, and give way when the bar no longer fits (section 6).
- **A pose layout that drops an action.** Trading a page for a better form in one pose can take a control with it. Keep every action reachable in every pose (section 7).
- **Animating the pose.** A spring keyed to the pose, or `withAnimation` around every reading, also plays when the phone opens, shuts or turns, over the system's own transitions, and on the first reading at launch. Animate a bend only (section 7).
- **A new layout's empty images.** A view built for a pose's layout loads its images afresh and shows a placeholder first, even for a picture that was on screen a moment before. A cut hides it; a crossfade shows it (section 7).
- **Captures you didn't look at.** A black panel, a splash screen and an empty data set all produce a valid PNG. Seed real data in the language you want, and look at every picture.

## 10. Mock-ups in Apple's bezels

Apple Design Resources include iPhone Duo product bezels for the closed phone and the open phone held sideways or upright, but nothing part folded. The kit's `duo_mockup.py` sets a capture in the flat art, and for the book and laptop poses folds the composite with `duo3d.py`, a small software renderer: the inner display is a developable plane-arc-plane surface with a 36pt bend, extruded to the phone's thickness, its outline traced from the art, seen through a straight-on pinhole camera, and lit only on the side walls the flat art doesn't cover. The book, laptop and tear pictures in this guide are its renders.

```sh
python3 duo_mockup.py cover.png  closed.png --pose closed
python3 duo_mockup.py open.png   book.png   --pose book --deg 128
python3 duo_mockup.py upright.png laptop.png --pose laptop --deg 110
```

Bring your own bezels. The kit contains no Apple artwork: download the iPhone Duo bezels from [Apple Design Resources](https://developer.apple.com/design/resources/), accept the licence, and put them in `~/Library/Application Support/apple-duo-bezels/` (or pass `--bezels`). The licence covers mock-ups of your app, not republishing the PNGs, which is why there is no browser version here: a web editor would have to serve Apple's art. For mock-ups in a browser, the [developer tools](https://foldhunt.com/iphone-duo/tools/) directory lists Duo Studio, Duo Mock and Fold Preview, which draw their own 3D phone, and Frames, which sets flat captures in Apple's bezels from the command line.

## 11. The brief

The briefing to hand an agent before it reads a line of the app:

1. Build with `DEVELOPER_DIR` pointing at Xcode 27.1, and check the SDK has `reservedRegions` before trusting a build.
2. Run Apple's `app-resizability` skill, prerequisites included, before designing anything.
3. Fold and turn the simulator with `duo.py`; tap and read it with `xcode_device.py` while it's shut, and with duoctl while it's open. Never call `simctl io screenConfig` on a Duo.
4. Capture by port, resolved by pixel size on every boot, and check the PNG is not black. Shut, the cover draws; open, the inner display does.
5. Seed the app with real data first, in the language of the screenshots you want.
6. Walk every pose after every change: shut, open sideways, book, open upright and laptop, setting the orientation for each pose once the hinge has stopped. Turn every pose into every other, both ways, with a detail open: the app has to keep its place and keep responding. Look at the pictures, and film the folds between them (`duo.py record`): anything that moves after the system's blur is yours. When two poses disagree, log the views' frames before guessing why.
7. Keep the system's insets. Only photographs go under the column, never under a split view's list. Don't reserve a band for the clock.
8. If the tab bar is your own, read `toolbarVerticalEdge` and put the tabs in that column, below the clock and whatever else the system hangs there; give way to a horizontal bar when they don't fit.
9. Centre narrow content on the glass, moving only into its own margin, and only on pages that span the window.
10. Wider than tall: spreads and panes, cards that hug their content, capped cells, compact charts, headings of one height side by side, a leaf for a portrait photograph, and no empty detail pane beside a list. Standing upright: one column, content at its own shape (narrow the column rather than crop a poster), and split views collapsed into a stack below about 800pt. The phone layout scaled up is wrong; the iPad layout is usually right.
11. Don't draw the crease. Keep controls off it, let text cross it, and put two-pane gutters and the gaps of grids on it, lying flat as well as part folded, from the system's inactive fold region rather than the hinge angle. Move what the eye and the thumb go to off it, and its caption with it.
12. Treat the poses as layouts: the book pose gives two leaves, the laptop pose a raised half to look at and a flat one to touch, each scrolling on its own, on every page that can be open in it. On the raised half, which is wider than tall, show the content's landscape form. Every pose keeps every action.
13. A hinge gesture must not fire on the way to shut or while resting half open, and needs a second way in.
14. Guard the 27.1 APIs behind a compile flag so the 27.0 toolchain still builds, and run the tests there. Clear it for Mac Catalyst too, whose SDK lacks them even in Xcode 27.1.
15. Never decide a layout from a size that the decision changes. Measure the room you're offered, and check the pose's readings against the page's own shape.
16. Don't hunt for a StandBy pose: no API reports it, and folding and turning the simulator never enters it (`devicectl device settings standby --show` does, from the lock screen). Get the widgets and the Live Activity ready for StandBy as on any iPhone.
17. Opt your own bar items into the column (`axisBehavior(.verticalPreferred)`, laid out vertically) only where nothing else of yours stands there, and disable the vertical bar on sheets with a single button.
18. Render the windows the simulator can't make, half in Split View and shortened by a pinned video, resizing live: every page has to settle and keep its state.
19. Animate only a bend: the crease turning active or resting while the window keeps its size. Opening, shutting and turning are the system's. A layout that comes in draws the images already decoded in its first frame, and a page whose crossfade blinks cuts instead. Film it before and after.

## Quick reference

| Goal | What worked |
|---|---|
| Find the Duo simulator | `duo.py devices`; never create or clone one |
| Fold or turn it | `duo.py fold close\|open\|book\|laptop\|0..180` (from shut it opens flat before a part fold), then `duo.py orient sideways\|upright` |
| Reach the laptop pose | `orient upright`, then `fold laptop` (about 110°) |
| Screenshot the open display | `duo.py shot`, `simctl io <udid> screenshot --display=<UUID of the 2007-wide port>`, or `devicectl device capture screenshot --display-unique-id <id of LCD-1>`; check it isn't black |
| Film a fold | `duo.py record --out films/ path:1.2:0:180 rec:inner wait:4`; one panel at a time |
| Tap and read the UI | `xcode_device.py start`, `look`, `tap "<label>"`, `end`, with the phone shut; open, `duoctl tap <x> <y>` |
| Read the hinge back | no angle in Xcode 27.1 RC (`devicectl` has no `device motion`); `devicectl device info displays` says which panel is active, so shut or open |
| Recover a crash-looping simulator | `simctl shutdown <udid>`, then `simctl boot <udid>` |
| Know where the system's bars go | `@Environment(\.toolbarVerticalEdge)`: the preferred edge, `.trailing` or `.leading`, even with no bar showing; nil where bars are horizontal. Check the inset on that edge too |
| Read the crease | `proxy.reservedRegions(kind: .division).first` (only active regions by default) |
| Find the fold lying flat | `proxy.reservedRegions(kind: .division, options: .includeInactive)`: the inactive region is where it will bend |
| Read the clock cluster and camera | `proxy.reservedRegions(kind: .occlusion)` |
| Put a gutter on the crease | size the leading pane to the crease's `minX`, use its width as the spacing; lying flat, use the inactive region |
| Keep a drawn bar clear of the island | the bottom of the active occlusions over its column; shrink to 44pt targets, then fall back to a horizontal bar |
| Put a custom bar item in the column | `.axisBehavior(.verticalPreferred)` and a vertical layout; `.horizontalOnly` keeps it out |
| A sheet with one button | `.toolbarVerticalBehavior(.disabled)` on its content |
| Check Split View and a pinned video | resize a hosting window through 475×669 and 951×420 and back; every page must settle (`devicectl device appResize` resizes the real app, but without the Duo's column or crease) |
| Stop a split view floating its list | a compact `horizontalSizeClass` on it below about 800pt |
| Line a split view up with the crease | the crease's near edge as the list's ideal width, never `min == max`; on a folding phone, detail minimum 320pt and a leading inset of the crease |
| Re-hide a list's bar after the split expands | `.toolbar(removing: .sidebarToggle)`, then flip the bar's visibility once when the size class turns regular |
| Keep a detail open through a collapse | set `preferredCompactColumn` to the list, then back to `.detail` on the next main-actor turn; claims on shared chrome per view, not per item |
| Stop a layout feedback loop | measure the offered width with `.frame(minWidth: 0, maxWidth: .infinity)` (heights too, for a page), and only the extent you use |
| Find a hang | the `.ips` report in `~/Library/Logs/DiagnosticReports`, `sample <pid>`, `_logChanges()` and SwiftUI's fault log |
| Show a portrait poster on the open phone | a leaf of its own: the left leaf sideways, a poster-shaped banner in a column upright; beside a split view's list, stop at the list |
| Animate a change of pose | only a bend (the crease active ↔ resting, the window's size unchanged), with `withAnimation` where the stage is published; none under Reduce Motion |
| No placeholder after a layout swap | draw cached bitmaps in the first frame: the exact request, else a decode of the same picture at least half the size |
| Find the jumps in a film | frames with `-fps_mode passthrough`, then `compare -metric RMSE` frame to frame, largest first |
| Split a page in the laptop pose | the crease's `minY` and `maxY` against the page's frame; a scroll view per half |
| Keep the fold off grid items | the same number of items on each leaf, the crease as the gap between them |
| See StandBy on a Duo | `devicectl device settings standby --show --duration 10` from the lock screen, with the interface landscape; folding and turning alone never trigger it, and it opens on a welcome screen that duoctl can tap past. Widgets and Live Activities are an app's only way in |
| Build on both Xcodes | compile flag on by default, cleared for `[sdk=iphonesimulator27.0*]` and `[sdk=iphoneos27.0*]`, and for `[sdk=macosx*]` with a Mac Catalyst destination |
| Get Apple's Duo skill | `xcrun agent skills export` with Xcode 27.1: `app-resizability` |
| Mock-ups in Apple's bezels | `duo_mockup.py <capture> <out> --pose closed\|open\|book\|upright\|laptop`, your own bezel files |

## Questions developers ask

### Can Claude Code or Codex adapt an app for iPhone Duo?

Yes, given two things Apple's documentation doesn't supply: a way to drive the simulator, and the numbers each pose reports. Give the agent this guide's kit as a skill, along with Apple's `app-resizability` skill from Xcode 27.1. Flicit was adapted that way in five passes with Claude Code, with a person judging every screenshot ([section 1](#1-hand-this-to-your-agent)).

### How do I fold and rotate the iPhone Duo simulator from the command line?

`simctl` has no command for either, and `devicectl device orientation set` reports success without turning anything. Post Device Hub's own HID events from inside the simulator instead: `duo.py fold book` and `duo.py orient upright` with the kit, or duoctl or hinge ([section 3](#3-fold-rotate-and-screenshot-the-iphone-duo-simulator-from-the-command-line)).

### Why is my iPhone Duo simulator screenshot black?

The simulator has two displays and only one draws: the cover while the phone is shut, the inner display while it's open. `simctl io screenshot` without `--display` captures the cover, which is black once the phone opens. Pick the display by its pixel size (2007×2853 is the inner one), or use `devicectl device capture screenshot` with the active display from `devicectl device info displays` ([capturing](#screenshot-the-inner-display-without-getting-a-black-image)).

### Can I read the hinge angle in the simulator?

Not from outside the app in Xcode 27.1 RC: `devicectl device motion hinge-angle`, which `hinge get` and duoctl call, doesn't exist in `devicectl` 651.13.5. `devicectl device info displays` tells shut from open, and inside the app `onHingeChange` reports the angle, a few degrees under the value set ([section 3](#3-fold-rotate-and-screenshot-the-iphone-duo-simulator-from-the-command-line)).

### Why does Xcode say "value of type 'GeometryProxy' has no member 'reservedRegions'"?

The build uses the Xcode 27.0 SDK, which predates the fold APIs. Build with Xcode 27.1, and keep 27.0 building with a compilation condition cleared for its SDK ([section 2](#keep-xcode-270-building)).

### What are iPhone Duo's safe areas and crease in points?

Shut, the window is 466×678 with insets of 84 points on the trailing edge and 34 at the bottom. Open sideways it is 951×669 with the same insets, and upright 669×951 with 82 at the top. The crease is 40 points wide at the middle of the window and active only while the phone is part folded ([section 5](#5-safe-areas-the-crease-and-reserved-regions-in-points)). The [screen sizes guide](https://foldhunt.com/guides/iphone-duo-screen-sizes/) has the pixel figures.

### Can the simulator show StandBy?

Folding and turning it never does. `devicectl device settings standby --show --duration 10` does, once the lock screen is showing and the interface is landscape ([StandBy](#standby-on-iphone-duo-and-showing-it-in-the-simulator)).

### Can I test Split View in the simulator?

Not headless, because it needs a drag from the home indicator. Render the app at Split View's sizes in a resized window instead, or resize the real app with `devicectl device appResize` while the phone is shut ([Split View sizes](#split-view-and-picture-in-picture-sizes)).

## Limits of this guide

The numbers come from one simulator build, iOS 27.1 (24A94232): the hinge angles, the occlusion rectangles, the split-view threshold, the detail column's floor, the bar edges, the hinge's hysteresis and the mid-turn layout pass are observations, not documentation, and may change with the next build. The landscape stub in the laptop pose and the narrowed upright calendar are Flicit's design choices, not rules. What Xcode's device-interaction tools can see on a Duo may change with a later Xcode. The hinge gesture's thresholds were tuned with a slider; how a real hinge reads, and how a real bend feels, is unknown until October 23. The laptop renders fold Apple's flat art symmetrically, a simplification of a phone resting on its lower half. The films show the simulator's fold animation, not the hardware's, and in the simulator the crease turns active about a second after the hinge stops, so the laptop pose's crossfade was filmed after the bend rather than during it. Split View and a video pinned in Picture in Picture were rendered in a resized window at estimated sizes, not run, so the column moving to the leading edge for the app on the left is still unseen. The island growing with a Live Activity was stood in for with a debug occlusion, and the zero-wide resting fold Apple describes is handled but never seen: the simulator reports it 40 points wide. StandBy was reached only through `devicectl`, and showed only the system's own widgets, so how it shows an app's widgets on each display is unknown, and nothing has touched hardware. The `devicectl` findings are from version 651.13.5 and duoctl's from 0.2.0, and Apple's documentation is quoted as published on October 11.

## Sources

- [Design for iPhone Duo — Apple Developer Tech Talk](https://developer.apple.com/videos/play/tech-talks/111466/)
- [Prepare your app for iPhone Duo — Apple Developer Tech Talk](https://developer.apple.com/videos/play/tech-talks/111461/)
- [Raise the bar with iPhone Duo — Apple Developer Tech Talk](https://developer.apple.com/videos/play/tech-talks/111462/)
- [Strike a pose with adaptive layouts on iPhone Duo — Apple Developer Tech Talk](https://developer.apple.com/videos/play/tech-talks/111463/)
- [Leverage multiple displays and scenes on iPhone Duo — Apple Developer Tech Talk](https://developer.apple.com/videos/play/tech-talks/111464/)
- [Build a great camera experience for iPhone Duo — Apple Developer Tech Talk](https://developer.apple.com/videos/play/tech-talks/111465/)
- [hinge, a CLI for the simulator's hinge — Artem Novichkov](https://github.com/artemnovichkov/hinge)
- [iPhone Duo by Examples — Artem Novichkov](https://github.com/artemnovichkov/iPhone-Duo-by-Examples)
- [idb's simulator HID controls (hinge slider and orientation picker) — Meta](https://github.com/facebook/idb/tree/main/FBSimulatorControl/HID)
- [iPhone Duo Group Labs, consolidated Q&A — Frank Schlegel](https://gist.github.com/frankschlegel/6356a059426b2393528691822edfdae6)
- [iPhone Duo SwiftUI Q&A from the developer forums — Anton Gubarenko](https://antongubarenko.substack.com/p/iphone-duo-swiftui-q-and-a-forums)
- [Designing for iPhone Duo — Apple Human Interface Guidelines](https://developer.apple.com/design/human-interface-guidelines/designing-for-iphone-duo)
- [Apple Design Resources (iPhone Duo product bezels)](https://developer.apple.com/design/resources/)
- [Flicit, the app in the examples](https://flicit.top/)
