Skip to content
← All work

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.

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.

Said into the phone an example, not a recording
“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

  1. title
    Email Dana about the lease
    due
    Friday
    project
    Apartment
    effort
    none
  2. title
    Finish the Lantern slides
    due
    last Monday
    project
    lantern
    scary
    scary
  3. title
    Book a passport appointment
    scary
    none
  4. 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 catch can 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.

9 PM
10 PM
11 PM
12 AM
1 AM
2 AM
3 AM
4 AM
5 AM
6 AM
7 AM
Sleep10:00 PM – 6:00 AM
Press the blockSwipe across it, tap it, or hold it still and drag.
What broke
A LongPressGesture sequenced before a DragGesture kept 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 UILongPressGestureRecognizer at 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.

Before · scale 0.92
108405
After · opacity 0.6
96419
frame 8 of 8 at 60 fps · edges in 3x pixels
  • Bodies re-run during a scroll0 bodies across six flicks
  • The grid lays out per frame0 layout passes
  • Offscreen renderingno filters, shadows or masks
  • A 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.

Today ›
Focus···
9 AM
10 AM
11 AM
12 PM
1 PM
2 PM
Deep work
Move the frame’s topThe hours ride down 28 pt with the bar.
Today ›
Focus···
9 AM
10 AM
11 AM
12 PM
1 PM
2 PM
Deep work
Keep the frame, overlap itThe now line never moves.
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_TaskItem
  • CD_SubtaskCD_notesCD_dueDateCD_priorityRawValueCD_reminderAt
  • CD_Project
  • CD_UntwineSession
  • CD_ReflectionEntry
  • CD_PossibilityNote
  • CD_ReturnPlan
  • CD_SupportActionUse
  • CD_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-schema on both environments, diffed against the models. The local test had passed throughout.
The fix
SchemaPriming writes 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.

  1. 1A static let holding a @Modelis a global of a non-Sendable type. Made computed.
  2. 2A stored closureis a global too. Now a static func.
  3. 3The routeris @MainActor, which makes it Sendable for an App Intent.
  4. 4The debug hookswent @MainActor whole: the one behaviour change.
  5. 5A widget’s completionlost @Sendable in a conformance.
  6. 6An NSView’s deinitis nonisolated, so its event monitor is nonisolated(unsafe).
  7. 7The reminder servicestays nonisolated on purpose: scheduling is plain data.

09What I take from it

Make the promise something a test can break.

  1. Put the rule where a test can reach it.Code behind #if os(macOS) never enters the test binary.
  2. Measure the frame, not the feeling.Video and a logged offset found what “rough” meant.
  3. Ask the system, never the note.A note said four columns were missing. The container was empty.
Untwine for Mac: Focus, a day of time blocks on one timeline beside the sidebar.
The Mac’s Focus: the same rules, moved by a pointer.

Untwine your day.

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