recipe-reader
Creates a complete SwiftUI reading app for iPhone Duo, Apple's foldable: a library shelf, then one page on the closed outer display, two facing pages split at the fold when the phone is open and held
SKILL.mdplugins/duology-recipes/skills/recipe-reader/SKILL.md
GitHubrecipe-reader
This recipe makes a new Xcode project: a reading app for iPhone Duo. The library shows a shelf of books and the one you were reading. A book opens on one page when the phone is closed and on two facing pages when it opens, split at the fold like a paper book. Set the phone on a table half folded and the page sits above the fold, with big page turns below it. Close it, open it, fold it, turn it or change the text size: the words you were reading stay on screen.
The books are sample content. The value is the frame around them: a place that no layout change can lose, pages that always break where the page ends, rules you can test on the Mac in seconds, and UI tests that pass on the Duo simulator.
Labels: measured means we ran it on the iPhone Duo simulator (iOS 27.1, 20 October 2026). Unverified means we could not check it. Everything else links to Apple. "Book" and "tabletop" are Duology's words for Apple's "partially folded, like a book" and "seated on a table like a laptop"; no API reports them. Full source list: reference/sources.md.
Use a different skill when:
- the app already exists: use
duo-reading(the same pagination and reader, as code to add) andduo-continuity; - the app plays video or audio: use
recipe-media-player.
1. Create the project
/duo-new recipe-reader runs steps 1 to 5. Run every step yourself when there is no command.
Get three values from the user, or propose them:
AppName: letters and digits, starting with a letter (it becomes a Swift type,<AppName>App). NotTools, and not an Apple module name such asSwiftUIorUIKit;- a bundle id such as
com.example.shelfie; - a new, empty folder.
Run the script from this skill's folder (in the plugin:
${CLAUDE_PLUGIN_ROOT}/skills/recipe-reader/scripts/new-project.sh):"${CLAUDE_PLUGIN_ROOT}/skills/recipe-reader/scripts/new-project.sh" \ Shelfie com.example.shelfie ~/Developer/ShelfieIt copies
template/, replaces__APP_NAME__and__BUNDLE_ID__in file contents and paths, checks none are left, and prints the next steps. It refuses a folder that exists and is not empty (exit 73), and bad or clashing names (exit 65), before it creates anything.Test the rules on the Mac. Expect
0 failed:cd ~/Developer/Shelfie && Tools/run-model-tests.shBuild for the Duo simulator (find its UDID with
xcrun simctl list devices available | grep "iPhone Duo"):DEVELOPER_DIR=/Applications/Xcode-27.1.app/Contents/Developer \ xcodebuild -project Shelfie.xcodeproj -scheme Shelfie \ -destination "platform=iOS Simulator,id=<UDID>" \ -derivedDataPath build/DerivedData buildInstall, launch straight into a book, and screenshot the display in use. Use the UDID, not
booted.DEV=/Applications/Xcode-27.1.app/Contents/Developer DEVELOPER_DIR=$DEV xcrun simctl install <UDID> build/DerivedData/Build/Products/Debug-iphonesimulator/Shelfie.app DEVELOPER_DIR=$DEV xcrun simctl launch --terminate-running-process <UDID> com.example.shelfie -open keeper -chapter 0 -offset 2400 DEVELOPER_DIR=$DEV xcrun simctl io <UDID> screenshot --display=primary build/outer.png # closed DEVELOPER_DIR=$DEV xcrun simctl io <UDID> screenshot --display=primary-1 build/inner.png # openOnly the display in use has content. Look at the PNG before you report.
Run the UI tests:
DESTINATION="id=<UDID>" Tools/run-ui-tests.sh. A UI test brings the app to the front; tell the user first if they share the simulator.Tell the user what passed, and that poses change only in Device Hub (the checklist below).
Set DEVELOPER_DIR for every xcrun and xcodebuild call when the Mac has more than one Xcode. Never run xcode-select.
2. What you get
| Path | What it holds |
|---|---|
<App>/Model/ |
Library, chapter text, reading places, page breaks and turns, the reader's layout, the shelf's columns, fold geometry. Foundation and CoreGraphics only, every type nonisolated and Sendable. |
<App>/Text/Paginator.swift |
TextKit 1 page breaks. UIKit in the app, AppKit in the Mac tests. |
<App>/Reader/ |
ReaderScreen (layout, pagination, turns, menus, tabletop controls), PageView with a TextKit 1 UITextView, TextStyle (serif body at the reader's Dynamic Type size). |
<App>/Library/ |
The shelf (ShelfLayout), book tiles, the Continue Reading card, drawn covers. |
<App>/App/ |
App entry, RootView (the navigation stack), LibraryStore (places, saved as JSON), launch switches. |
<App>/Shared/ |
onFoldChange and the test marker. |
<App>/Books/ |
Library.json and eight chapters in three books, written for this template (about 6,400 words). |
<App>UITests/ |
Nine UI tests, pose-agnostic. |
Tools/ |
run-model-tests.sh, ModelTests/main.swift, run-ui-tests.sh. |
Project settings: iOS 26.0 deployment, Swift 6 language mode, SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor, approachable concurrency, file-system synchronized groups (new files need no project edits), iPhone and iPad, a privacy manifest that declares UserDefaults (reason CA92.1) for the saved places. The scheme turns off parallel testing so xcodebuild doesn't clone the simulator.
Architecture, data flow and test details: references/architecture.md. Your own books, typography, Dynamic Type and what we measured in each pose: references/books-and-text.md.
3. How it behaves in each pose
| Pose | Reader (measured, inside the navigation stack) | Pages | Page turns | Shelf |
|---|---|---|---|---|
| Closed (outer display) | 382 × 562 pt | one | vertical bar on the side | two columns |
| Open, flat, held wide | 867 × 553 pt, fold flat | two, either side of the fold line | vertical bar | two columns each side of the fold line |
| Book | 867 × 553 pt, fold active | the same two, so no new pagination | vertical bar | the same columns |
| Tabletop | 669 × 835 pt, fold active across | one, above the fold | below the fold, with Contents and Text Size | four in a row; the shelf scrolls across the fold |
Measured with Device Hub's pose buttons pressed by duo-pose, one app process through closed, open and book and another through tabletop and closed: at character 2,400 of the first chapter, the page on screen held it in every pose (closed page 4 of 7, open and book pages 3 and 4 of 8, tabletop page 4 of 7). Details: references/books-and-text.md.
4. Where each Duo technique lives
| Technique | Where | Why |
|---|---|---|
| The place survives every fold | ReadingLog in LibraryStore, @State in RootView |
State above a layout switch survives it (duo-continuity). The place is two numbers, saved on every change. |
| The place is an offset, never a page | ReadingPosition, PageBreaks.page(containing:) |
Page breaks change with every fold, rotation and text size; the first character on screen doesn't (duo-reading). |
| Never skip unread text | PageBreaks.firstPage(showing:perSpread:) |
A spread starts on an even page, so the page holding the offset is always on screen, leading or trailing. |
| Pages split at the fold, not the middle | ReaderLayout, fed by onFoldChange |
reservedRegions(kind:options:layoutDirectionBehavior:) with .division and .includeInactive, on the reader's own frame. Measured: the fold's frame started at x 455.5 in an 867 pt reader, 22 pt right of its middle. |
| Folding doesn't paginate again | PaginationKey |
Flat and book give the same page size, so the key doesn't change when the fold turns active. |
| Tabletop: read above, turn below | ReaderLayout mode .tabletop, tabletopControls |
Apple's optional per-pose layout: content in the top half, controls in the stable bottom half, the same controls as every other pose (talk 111466). |
| Page turns stay off the fold | System bars; tabletop controls in their own region | Toolbars move off the fold on their own (talk 111466). |
| Dynamic Type reflows the pages | TextStyle.attributed(_:size:), the type size in PaginationKey |
The text is built at the reader's size and paginated again; the offset keeps the place. The in-app Text Size menu moves the system size by steps. |
| Pages never overflow | Paginator and PageTextView |
Both TextKit 1 (NSLayoutManager, NSTextContainer) with the same padding; UITextView(usingTextLayoutManager: false). |
| No size feedback | .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) on the reader |
The pages' sizes come from the measured size; a frame that grows to fit them would never shrink (duo-reading). |
| An even shelf | ShelfColumns, ShelfLayout |
When a fold crosses a grid, flat or folded, use an even column count with the fold in a gutter (talk 111463). |
| VoiceOver page turns | accessibilityScrollAction on the reader |
A three-finger swipe turns the page (accessibilityScrollAction(_:)). |
5. Customise
Change the books and the rules first, then the look.
- Your books: add them to
Books/Library.json, one.txtfile per chapter; blank lines separate paragraphs. File names must be unique across books. RunTools/run-model-tests.sh: it checks every file is there and has text. More, including long books and EPUB: references/books-and-text.md. - Typography:
TextStyle(font, line height, paragraph spacing, hyphenation) andPageInsets(margins, running head, page number, longest line). Paginator and pages share them, so change them in one place. - Looks:
BookCover,BookTile,ContinueCard,PageView. - Add a model rule: write it in
Model/, keep itnonisolatedandSendable, add a check inTools/ModelTests/main.swiftand a line to its off-main-actor check.
Keep these, whatever the app:
- The place is a chapter and a character offset, in a store above the navigation stack.
- Paginate again for a new page size, text size or chapter; show the page that holds the offset.
- One
ReaderScreenfor every pose; the first page is the same view in every mode. Never anifon the pose around the pages. - Page regions from the fold's frame, never from half the width.
- The same controls in every pose. Tabletop moves them below the fold; it never adds or removes one.
- Paginator and page view on the same text engine, with the same padding and insets.
6. Test it
On the Mac: Tools/run-model-tests.sh builds Model/*.swift and Text/*.swift with the test program using swiftc -swift-version 6 -default-isolation MainActor, like the app target. It reads the real Library.json and every chapter, paginates the first chapter through AppKit and checks every character lands on exactly one page and the place stays on screen at three page sizes. The layout checks use the reader sizes and fold frames measured on the Duo simulator. Measured: 147 checks, under 10 seconds with the build.
On the simulator: Tools/run-ui-tests.sh. The tests start from launch switches (-open, -chapter, -offset, -textStep, -resetState), find elements by identifier, tap through element.coordinate(withNormalizedOffset:), and read the reader through the reader.state marker (book=keeper|chapter=0|offset=2400|first=2|pages=8|mode=spread|shown=1261-2546|step=0|ready=1|). Every test that moves the place checks the saved offset is inside shown. They never depend on the pose.
Measured: all nine passed on the Duo simulator open and held wide, about 115 s of tests, and testReaderLayoutInThisPose passed closed, in book pose and in tabletop. Set the pose in Device Hub (or with duo-pose), then run that test once per pose:
Tools/run-ui-tests.sh -only-testing:ShelfieUITests/ShelfieUITests/testReaderLayoutInThisPose
Then check each pose by hand in Device Hub, or with duo-pose-matrix (needs the user's Accessibility permission):
duo-pose-matrix com.example.shelfie --case "reader=-resetState -open keeper -chapter 0 -offset 2400"
To see the place survive the poses, pass --no-relaunch. For the shelf, launch once with -open bread -chapter 1 -offset 900 so Continue Reading has a book, then run the matrix with --case "library=" (no switches: the saved place stays).
Checklist
- Project created with
scripts/new-project.sh; no__APP_NAME__or__BUNDLE_ID__left. -
Tools/run-model-tests.shprints0 failed. -
xcodebuild … buildsucceeds for the iPhone Duo simulator with no warnings from your code. - The app launched on the Duo simulator; you looked at the screenshot of the display in use.
-
Tools/run-ui-tests.shpasses. - Closed: one page. Open and held wide, flat or like a book: two pages, no text inside the fold's frame.
- Tabletop: the page ends above the fold; Previous, Next, Contents and Text Size sit below it.
- Read to the middle of a chapter, then close, open, fold, rotate and close again: the same paragraph is on screen each time.
- Text size from the smallest to AX5 (the reader's menu, or Settings): the pages reflow and the place holds.
- The shelf: an even number of columns on the open phone, no cover on the fold, Continue Reading on the leading side.
- VoiceOver reads the page and a three-finger swipe turns it, in every pose.