Engineering case study · Untwine
One product model. Two native ways to use it.
SwiftUI and SwiftData, local first. Voice capture a pure function judges, and five bugs taken apart.
- Language mode
- Swift 6
- Third-party dependencies
- 0
- Full iOS suite, November 8
- 2,341 tests
- Targets
- 1 iPhone and Mac, not Catalyst
A green suite says nothing about the Mac. So the rules live where both shells, and the tests, can reach them.
01The shape of it
Share the meaning. Not the interface.
Models and rules are shared; shells are not. The store lives on the device, sync is your own iCloud, and there’s no server of mine.
ProposalEngineHabitTileLayoutHabitDragReorderTitleBarProgressHoldProgressFocusCountdownMacPanelSize
@Models. How hard a task feels is a stored field.02Voice capture · August 21
The model proposes. A pure function decides.
Talk, and it hands back tasks, ticked, on the device. CaptureShaping judges what the model returns, with one rule: only catch what was said.
“Okay, email Dana about the lease by Friday. Finish the Lantern slides, that one’s kind of scary. Um, and renew my passport.”
Projects that exist: LanternHome
- title
- Email Dana about the lease
- due
- Friday
- project
- Apartment
- effort
- none
- title
- Finish the Lantern slides
- due
- last Monday
- project
- lantern
- scary
- scary
- title
- Book a passport appointment
- scary
- none
- title
- “ ”
Typed fields, not prose. “Omit otherwise” is only a request.
The one gap: nothing checks a title against what was said. That guard is you.
03Voice capture · August 22
It shipped broken. The tests were green.
On a real phone it crashed the moment it listened. Four more bugs came out of the same feature.
- A date a day early.Parsed in UTC, west of Greenwich.
- A country’s dates, gone.Under the Buddhist calendar, 2026 read as 1483.
- A poll that spun.531,370 loops in 0.217 s.
- A mic nobody owned.A re-render left the old engine recording.
- What broke
- The mic and the analyzer run at different sample rates, and the simple
convert(to:from:)only keeps the rate. - The evidence
- An exception on the real-time audio thread, below Swift, where no
catchcan see it. - The fix
- The block form Apple’s header asks for, out the next day.
04Focus · October 25
A hold that lets the scroll win.
A long press lifts a block. Built the obvious SwiftUI way, it froze the grid. Try both.
- What broke
- A
LongPressGesturesequenced before aDragGesturekept the scroll’s pan from starting. A swipe on a block opened it instead. - The evidence
- Reproduced with idb on the simulator. A high-priority hold swallowed every tap instead.
- The fix
- A
UILongPressGestureRecognizerat 0.2 s that fails once the finger moves 10 pt, so the pan wins every swipe.
05Habits · November 8
“Scrolling feels a bit rough sometimes.”
My note from my phone. Five hypotheses went in. Four came out refuted, by counting.
Bodies re-run during a scroll0 bodies across six flicksThe grid lays out per frame0 layout passesOffscreen renderingno filters, shadows or masksA non-lazy stackeverything built once, at launch- A resting finger presses firstpressed at +285 ms, cancelled at +474 ms
- What broke
- A finger that rests before it drags presses the tile first. Under a 0.92 scale it shrank, then sprang back as the page moved.
- The evidence
- Probes in every layout pass, and 60 fps video: the edges came in 4 points.
- The fix
- Big scrolling buttons press by dimming, never by scale.
06Focus · November 8
A bar that follows the scroll, and hours that don’t.
Focus’s title bar follows your finger. Its arrival must not move a single hour.
- What broke
- As the bar arrived, the scroll view’s top moved down and the hours rode 46 pt with it.
- The evidence
- Hour lines measured frame by frame from screen recordings.
- The fix
- Keep the frame fixed and let the bar overlap it. The now line held its pixel row while the header moved 69 px.
07CloudKit · August 7, and again for Habits
A column exists only once it’s written.
A note said Production might be missing four columns. It held no record types of mine at all.
CD_TaskItemCD_SubtaskCD_notesCD_dueDateCD_priorityRawValueCD_reminderAtCD_ProjectCD_UntwineSessionCD_ReflectionEntryCD_PossibilityNoteCD_ReturnPlanCD_SupportActionUseCD_SupportFavorite
Two types of nine, and Subtask missing its four new columns.
- What broke
- SwiftData creates record types only in Development, and only once sync runs. Nobody had turned it on.
- The evidence
cktool export-schemaon both environments, diffed against the models. The local test had passed throughout.- The fix
SchemaPrimingwrites one of everything, then Deploy. The ship script now refuses to run while a type is undeployed.
08Swift 6 · since 1.1
Seven sites, none of them SwiftData.
I bet strict concurrency would fight SwiftData. #Predicate and @Model needed no change.
- 1A static let holding a @Modelis a global of a non-Sendable type. Made computed.
- 2A stored closureis a global too. Now a static func.
- 3The routeris @MainActor, which makes it Sendable for an App Intent.
- 4The debug hookswent @MainActor whole: the one behaviour change.
- 5A widget’s completionlost @Sendable in a conformance.
- 6An NSView’s deinitis nonisolated, so its event monitor is nonisolated(unsafe).
- 7The reminder servicestays nonisolated on purpose: scheduling is plain data.
09What I take from it
Make the promise something a test can break.
- Put the rule where a test can reach it.Code behind #if os(macOS) never enters the test binary.
- Measure the frame, not the feeling.Video and a logged offset found what “rough” meant.
- Ask the system, never the note.A note said four columns were missing. The container was empty.

Untwine your day.
On iPhone, iPad, Mac and Apple Watch, and on the web.