<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://www.wesleymatlock.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://www.wesleymatlock.com/" rel="alternate" type="text/html" /><updated>2026-10-13T21:48:48+00:00</updated><id>https://www.wesleymatlock.com/feed.xml</id><title type="html">Wesley Matlock</title><subtitle>Professional iOS Engineer specializing in SwiftUI, VisionOS, and modern Apple platforms. Expert in HealthKit, tvOS, watchOS and even some VisionOS development with 85+ technical articles and comprehensive portfolio.</subtitle><entry><title type="html">Moth Into Flame: SwiftUI visualEffect Parallax Without State</title><link href="https://www.wesleymatlock.com/swiftui-visualeffect-scroll-effects/" rel="alternate" type="text/html" title="Moth Into Flame: SwiftUI visualEffect Parallax Without State" /><published>2026-10-09T00:00:00+00:00</published><updated>2026-10-09T00:00:00+00:00</updated><id>https://www.wesleymatlock.com/swiftui-visualeffect-scroll-effects</id><content type="html" xml:base="https://www.wesleymatlock.com/swiftui-visualeffect-scroll-effects/"><![CDATA[<p>The parallax on VinylCrate’s home screen never touched <code class="language-plaintext highlighter-rouge">GeometryReader</code>. It used <code class="language-plaintext highlighter-rouge">onScrollGeometryChange</code>, which is the API you’re supposed to reach for now. It still re-ran the body of the biggest view on that screen on every frame of scrolling.</p>

<p>That’s the part most <code class="language-plaintext highlighter-rouge">visualEffect</code> write-ups skip. <code class="language-plaintext highlighter-rouge">GeometryReader</code> was never the real tax. The real cost is a value that changes every frame landing in state on a view that has a lot of work to do. Whatever API produced the number, once it lands in <code class="language-plaintext highlighter-rouge">@State</code> on the wrong view, you’ve signed up for a body evaluation per frame.</p>

<p>Here’s how I moved that parallax to the render layer. I’ll also cover a second effect I built the same way and deleted three weeks later, the one scroll effect in the app that still uses state on purpose, and a <code class="language-plaintext highlighter-rouge">Bool</code> that can leave a toggle disabled forever.</p>

<h2 id="harvester-of-sorrow--the-scroll-offset-that-rebuilt-the-home-screen">Harvester of Sorrow — The Scroll Offset That Rebuilt the Home Screen</h2>

<p>In March, VinylCrate’s featured-album card got parallax. The artwork slides down at 30% of scroll speed and stops at 40 points. The image is drawn 1.4 times the card’s height, so there’s extra artwork outside the visible crop for it to slide into.</p>

<p>Here’s how it was wired. <code class="language-plaintext highlighter-rouge">CollectionScrollView</code>, the view behind the entire home screen, owned the offset:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">heroScrollOffset</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">0</span>
</code></pre></div></div>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="nf">onScrollGeometryChange</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="o">.</span><span class="k">self</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">contentOffset</span><span class="o">.</span><span class="n">y</span> <span class="p">}</span> <span class="nv">action</span><span class="p">:</span> <span class="p">{</span> <span class="n">_</span><span class="p">,</span> <span class="n">offset</span> <span class="k">in</span>
    <span class="n">heroScrollOffset</span> <span class="o">=</span> <span class="n">offset</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It passed that value to <code class="language-plaintext highlighter-rouge">FeaturedAlbumView</code> through a <code class="language-plaintext highlighter-rouge">scrollOffset:</code> parameter, and the card turned it into an offset:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="nv">parallaxOffset</span> <span class="o">=</span> <span class="n">scrollOffset</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="p">?</span> <span class="nf">min</span><span class="p">(</span><span class="n">scrollOffset</span> <span class="o">*</span> <span class="mf">0.3</span><span class="p">,</span> <span class="mi">40</span><span class="p">)</span> <span class="p">:</span> <span class="mi">0</span>
</code></pre></div></div>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="nf">offset</span><span class="p">(</span><span class="nv">y</span><span class="p">:</span> <span class="n">parallaxOffset</span><span class="p">)</span>
</code></pre></div></div>

<p>None of those lines are wrong on their own. The problem is how they fit together.</p>

<p><code class="language-plaintext highlighter-rouge">onScrollGeometryChange</code> splits its work into a transform and an action, and the action only runs when the transformed value changes. That works great when the transform turns scrolling into something coarse, like a <code class="language-plaintext highlighter-rouge">Bool</code>. This transform handed back <code class="language-plaintext highlighter-rouge">contentOffset.y</code> untouched, and during a scroll that value changes every frame. So every frame wrote <code class="language-plaintext highlighter-rouge">heroScrollOffset</code>, and every write invalidated the view that owned it.</p>

<p>The view that owned it was the whole collection screen: the <code class="language-plaintext highlighter-rouge">LazyVStack</code> with every section of the grid, plus the alphabetical index overlay. All of it was re-evaluated and <a href="/what-the-diff-understanding-swiftui-s-diffing-engine/">diffed against the previous tree</a> so one image could move a few points.</p>

<p>I’ve written about <a href="/optimizing-swiftui-reducing-body-recalculation-and-minimizing-state-updates/">keeping body re-evaluations rare</a> and <a href="/why-your-swiftui-app-is-slower-than-you-think/">finding the ones you didn’t expect</a>. This one is easy to miss because the code looks modern. In the <code class="language-plaintext highlighter-rouge">GeometryReader</code> days you could at least see the cost: a greedy container, sizes fed in by hand, an <code class="language-plaintext highlighter-rouge">onChange</code> writing state. With <code class="language-plaintext highlighter-rouge">onScrollGeometryChange</code> the code is three lines and tidy, but the structure underneath hasn’t changed. A parent still takes a state write on every frame.</p>

<h2 id="chasing-light--parallax-at-the-render-layer">Chasing Light — Parallax at the Render Layer</h2>

<p>In May I moved it into <code class="language-plaintext highlighter-rouge">visualEffect</code>. The commit message sums it up:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>This avoids body rebuilds on every scroll frame; the transform runs at the render layer.
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">@State</code> came off <code class="language-plaintext highlighter-rouge">CollectionScrollView</code>. The <code class="language-plaintext highlighter-rouge">onScrollGeometryChange</code> went with it, and so did the <code class="language-plaintext highlighter-rouge">scrollOffset:</code> parameter. Here’s what replaced all three, trimmed of the matched-geometry modifier and design tokens:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">func</span> <span class="nf">parallaxBackground</span><span class="p">(</span><span class="nv">height</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kt">CachedAsyncImage</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="n">detail</span><span class="o">.</span><span class="n">bestImageURL</span><span class="p">)</span> <span class="p">{</span> <span class="n">image</span> <span class="k">in</span>
        <span class="n">image</span>
            <span class="o">.</span><span class="nf">resizable</span><span class="p">()</span>
            <span class="o">.</span><span class="nf">aspectRatio</span><span class="p">(</span><span class="nv">contentMode</span><span class="p">:</span> <span class="o">.</span><span class="n">fill</span><span class="p">)</span>
            <span class="o">.</span><span class="n">visualEffect</span> <span class="p">{</span> <span class="n">content</span><span class="p">,</span> <span class="n">geometry</span> <span class="k">in</span>
                <span class="k">let</span> <span class="nv">amount</span> <span class="o">=</span> <span class="o">-</span><span class="n">geometry</span><span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="o">.</span><span class="n">scrollView</span><span class="p">)</span><span class="o">.</span><span class="n">minY</span>
                <span class="k">let</span> <span class="nv">parallax</span> <span class="o">=</span> <span class="n">amount</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="p">?</span> <span class="nf">min</span><span class="p">(</span><span class="n">amount</span> <span class="o">*</span> <span class="mf">0.3</span><span class="p">,</span> <span class="mi">40</span><span class="p">)</span> <span class="p">:</span> <span class="mi">0</span>
                <span class="k">return</span> <span class="n">content</span><span class="o">.</span><span class="nf">offset</span><span class="p">(</span><span class="nv">y</span><span class="p">:</span> <span class="n">parallax</span><span class="p">)</span>
            <span class="p">}</span>
    <span class="p">}</span> <span class="nv">placeholder</span><span class="p">:</span> <span class="p">{</span>
        <span class="kt">Rectangle</span><span class="p">()</span>
            <span class="o">.</span><span class="nf">fill</span><span class="p">(</span><span class="kt">AppColors</span><span class="o">.</span><span class="n">separator</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mf">0.3</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">height</span><span class="p">:</span> <span class="n">height</span><span class="p">)</span>
    <span class="o">.</span><span class="nf">clipped</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Same math, same 30%, same 40-point cap. The difference is where it runs.</p>

<p>Here’s the declaration in the SDK:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">nonisolated</span> <span class="kd">public</span> <span class="kd">func</span> <span class="nf">visualEffect</span><span class="p">(</span>
    <span class="n">_</span> <span class="nv">effect</span><span class="p">:</span> <span class="kd">@escaping</span> <span class="kd">@Sendable</span> <span class="p">(</span><span class="kt">EmptyVisualEffect</span><span class="p">,</span> <span class="kt">GeometryProxy</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kd">some</span> <span class="kt">VisualEffect</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="kd">some</span> <span class="kt">View</span>
</code></pre></div></div>

<p>The first argument is a placeholder for the view you’re modifying. You don’t build views with it. You chain effects off it and return the result. The second is a <code class="language-plaintext highlighter-rouge">GeometryProxy</code> for the modified view itself: its size, and its frame in whatever coordinate space you ask for. It’s the same proxy <code class="language-plaintext highlighter-rouge">GeometryReader</code> hands you, without a container that takes over your layout.</p>

<p>Apple describes a <code class="language-plaintext highlighter-rouge">VisualEffect</code> as changing “the visual appearance of a view without changing its ancestors or descendents,” and that’s the whole contract. Everything you can call on the placeholder is a render-time transform: offset, scale, rotation, opacity, blur, color adjustments, shaders. None of it can resize the view, and nothing around it moves to make room. That’s why it’s safe to compute from geometry. The classic scroll-effect feedback loop goes geometry to state, state to body, body to layout, and layout back to geometry. An effect that can’t change layout can’t trigger another layout pass, so the loop never starts.</p>

<p>There’s no state anywhere in that picture either. <code class="language-plaintext highlighter-rouge">FeaturedAlbumView</code>’s body runs, sets up the closure, and stays out of it. SwiftUI re-runs the closure as the image’s geometry changes, and nothing is invalidated or diffed along the way. <code class="language-plaintext highlighter-rouge">CollectionScrollView</code> doesn’t know the parallax exists.</p>

<p>Two details make this particular effect work.</p>

<p><strong>There’s hidden image to slide.</strong> The artwork is <code class="language-plaintext highlighter-rouge">.fill</code>, so it overflows its frame. The 1.4× height gives it room, and <code class="language-plaintext highlighter-rouge">.clipped()</code> trims what’s outside. Offsetting the image pulls pixels that were already cropped into view. Without that slack, an offset just opens a gap at the edge. Remember that, because it decides the next effect.</p>

<p><strong>The card measures itself.</strong> The old version read the scroll view’s <code class="language-plaintext highlighter-rouge">contentOffset</code>, which only matched “how far past the hero” because the hero happened to sit at the top of the content. The new version measures the image’s own frame against the nearest scroll view. <code class="language-plaintext highlighter-rouge">amount</code> goes positive once the image’s top edge passes above the scroll view’s top edge, and only then does the offset kick in. The card no longer depends on where it sits.</p>

<h2 id="thorn-within--the-capture-list-reduce-motion-needed">Thorn Within — The Capture List Reduce Motion Needed</h2>

<p>In September I gated that parallax on Reduce Motion. It was the only motion in the app that ignored the setting, and it sat on the first screen people see. Parallax is one of the classic triggers for motion sensitivity, so that was a bad one to miss.</p>

<p>The obvious change doesn’t compile:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="n">visualEffect</span> <span class="p">{</span> <span class="n">content</span><span class="p">,</span> <span class="n">geometry</span> <span class="k">in</span>
    <span class="k">let</span> <span class="nv">amount</span> <span class="o">=</span> <span class="o">-</span><span class="n">geometry</span><span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="o">.</span><span class="n">scrollView</span><span class="p">)</span><span class="o">.</span><span class="n">minY</span>
    <span class="k">let</span> <span class="nv">parallax</span> <span class="o">=</span> <span class="n">amount</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">reduceMotion</span> <span class="p">?</span> <span class="nf">min</span><span class="p">(</span><span class="n">amount</span> <span class="o">*</span> <span class="mf">0.3</span><span class="p">,</span> <span class="mi">40</span><span class="p">)</span> <span class="p">:</span> <span class="mi">0</span>
    <span class="k">return</span> <span class="n">content</span><span class="o">.</span><span class="nf">offset</span><span class="p">(</span><span class="nv">y</span><span class="p">:</span> <span class="n">parallax</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Go back to the signature: an <code class="language-plaintext highlighter-rouge">@Sendable</code> closure on a <code class="language-plaintext highlighter-rouge">nonisolated</code> function. The closure doesn’t run on the main actor, and <code class="language-plaintext highlighter-rouge">reduceMotion</code> is an <code class="language-plaintext highlighter-rouge">@Environment</code> property on a main-actor view. VinylCrate also builds with <code class="language-plaintext highlighter-rouge">SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor</code>, so everything in the module is main-actor unless it says otherwise. The compiler won’t let a <code class="language-plaintext highlighter-rouge">Sendable</code> closure reach back into main-actor state.</p>

<p>What ships is a capture list:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="n">visualEffect</span> <span class="p">{</span> <span class="p">[</span><span class="n">reduceMotion</span><span class="p">]</span> <span class="n">content</span><span class="p">,</span> <span class="n">geometry</span> <span class="k">in</span>
    <span class="k">let</span> <span class="nv">amount</span> <span class="o">=</span> <span class="o">-</span><span class="n">geometry</span><span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="o">.</span><span class="n">scrollView</span><span class="p">)</span><span class="o">.</span><span class="n">minY</span>
    <span class="k">let</span> <span class="nv">parallax</span> <span class="o">=</span> <span class="n">amount</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="n">reduceMotion</span> <span class="p">?</span> <span class="nf">min</span><span class="p">(</span><span class="n">amount</span> <span class="o">*</span> <span class="mf">0.3</span><span class="p">,</span> <span class="mi">40</span><span class="p">)</span> <span class="p">:</span> <span class="mi">0</span>
    <span class="k">return</span> <span class="n">content</span><span class="o">.</span><span class="nf">offset</span><span class="p">(</span><span class="nv">y</span><span class="p">:</span> <span class="n">parallax</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The capture runs when <code class="language-plaintext highlighter-rouge">body</code> builds the closure, and <code class="language-plaintext highlighter-rouge">body</code> runs on the main actor, so the copy is legal. After that the closure only ever sees a <code class="language-plaintext highlighter-rouge">Bool</code>. Reading the environment value in <code class="language-plaintext highlighter-rouge">body</code> also keeps the dependency. When someone flips Reduce Motion, <code class="language-plaintext highlighter-rouge">body</code> runs again, builds a new closure, and the new closure captures the new value. You don’t need any observation code for that.</p>

<p>The same rule covers helpers. With default main-actor isolation, a plain helper type or a <code class="language-plaintext highlighter-rouge">private func</code> on the view is main-actor isolated too, and the closure can’t call it. Anything the closure reads that isn’t a local or a capture has to be explicitly <code class="language-plaintext highlighter-rouge">nonisolated</code>. The parallax math is inline, so it never hit this. The next effect did.</p>

<p>Notice the shape of the Reduce Motion check, too. <code class="language-plaintext highlighter-rouge">parallax</code> is either the real number or <code class="language-plaintext highlighter-rouge">0</code>, and <code class="language-plaintext highlighter-rouge">.offset(y:)</code> stays in the chain either way. That’s not just style. The closure returns <code class="language-plaintext highlighter-rouge">some VisualEffect</code>, an opaque type, so every path has to produce the same concrete chain. This won’t compile:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="n">visualEffect</span> <span class="p">{</span> <span class="p">[</span><span class="n">reduceMotion</span><span class="p">]</span> <span class="n">content</span><span class="p">,</span> <span class="n">geometry</span> <span class="k">in</span>
    <span class="k">if</span> <span class="n">reduceMotion</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">content</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
        <span class="k">return</span> <span class="n">content</span><span class="o">.</span><span class="nf">offset</span><span class="p">(</span><span class="nv">y</span><span class="p">:</span> <span class="o">-</span><span class="n">geometry</span><span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="o">.</span><span class="n">scrollView</span><span class="p">)</span><span class="o">.</span><span class="n">minY</span> <span class="o">*</span> <span class="mf">0.3</span><span class="p">)</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Branch on values, not modifiers. When an effect should disappear, give it its identity value: offset <code class="language-plaintext highlighter-rouge">0</code>, scale <code class="language-plaintext highlighter-rouge">1</code>, blur <code class="language-plaintext highlighter-rouge">0</code>. The shader effects take an <code class="language-plaintext highlighter-rouge">isEnabled:</code> parameter for the same reason.</p>

<h2 id="slither--drawing-the-record-out-of-the-sleeve">Slither — Drawing the Record Out of the Sleeve</h2>

<p>The same September change also touched the album detail screen. I wanted its hero to respond to scroll too, and parallax was the obvious first idea. It doesn’t work there.</p>

<p><code class="language-plaintext highlighter-rouge">FeaturedAlbumView</code>’s parallax works because the image is <code class="language-plaintext highlighter-rouge">.fill</code> inside a fixed-height, clipped frame, so there’s hidden image to slide. The detail hero is <code class="language-plaintext highlighter-rouge">.scaledToFit()</code> with no clip. The artwork fills its frame exactly. Offset it and you expose the black background behind it. The only way to create slack would have been to crop the cover, and on a vinyl app the cover art is the whole point. Cropping it wasn’t an option.</p>

<p>So I drew the record out of the sleeve instead. A vinyl disc sat behind the cover in <code class="language-plaintext highlighter-rouge">AlbumHeroHeader</code>, and as you scrolled, it slid out to the right and turned:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">nonisolated</span> <span class="kd">private</span> <span class="kd">enum</span> <span class="kt">Geometry</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">restingReveal</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">20</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">maxReveal</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">56</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">travel</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">200</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">rotationDegrees</span><span class="p">:</span> <span class="kt">Double</span> <span class="o">=</span> <span class="mi">16</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">discInset</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">10</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">VinylDiscView</span><span class="p">(</span>
    <span class="nv">artistName</span><span class="p">:</span> <span class="n">detail</span><span class="o">.</span><span class="n">primaryArtistName</span><span class="p">,</span>
    <span class="nv">trackTitle</span><span class="p">:</span> <span class="n">detail</span><span class="o">.</span><span class="n">title</span><span class="p">,</span>
    <span class="nv">labelColor</span><span class="p">:</span> <span class="kt">VinylLabelColor</span><span class="o">.</span><span class="nf">forArtist</span><span class="p">(</span><span class="n">detail</span><span class="o">.</span><span class="n">primaryArtistName</span><span class="p">)</span>
<span class="p">)</span>
    <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="kt">Geometry</span><span class="o">.</span><span class="n">discInset</span><span class="p">)</span>
    <span class="o">.</span><span class="n">visualEffect</span> <span class="p">{</span> <span class="p">[</span><span class="n">reduceMotion</span><span class="p">]</span> <span class="n">content</span><span class="p">,</span> <span class="n">geometry</span> <span class="k">in</span>
        <span class="k">let</span> <span class="nv">scrolled</span> <span class="o">=</span> <span class="nf">max</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">-</span><span class="n">geometry</span><span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="o">.</span><span class="n">scrollView</span><span class="p">)</span><span class="o">.</span><span class="n">minY</span><span class="p">)</span>
        <span class="k">let</span> <span class="nv">progress</span> <span class="o">=</span> <span class="n">reduceMotion</span> <span class="p">?</span> <span class="mi">0</span> <span class="p">:</span> <span class="nf">min</span><span class="p">(</span><span class="n">scrolled</span> <span class="o">/</span> <span class="kt">Geometry</span><span class="o">.</span><span class="n">travel</span><span class="p">,</span> <span class="mi">1</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">content</span>
            <span class="o">.</span><span class="nf">offset</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="kt">Geometry</span><span class="o">.</span><span class="n">restingReveal</span> <span class="o">+</span> <span class="n">progress</span> <span class="o">*</span> <span class="kt">Geometry</span><span class="o">.</span><span class="n">maxReveal</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">rotationEffect</span><span class="p">(</span><span class="o">.</span><span class="nf">degrees</span><span class="p">(</span><span class="n">progress</span> <span class="o">*</span> <span class="kt">Geometry</span><span class="o">.</span><span class="n">rotationDegrees</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="o">.</span><span class="nf">accessibilityHidden</span><span class="p">(</span><span class="kc">true</span><span class="p">)</span>
</code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">nonisolated</code> on the enum is the helper rule from the last section. Without it, under default main-actor isolation, <code class="language-plaintext highlighter-rouge">Geometry</code> is main-actor isolated and the closure can’t read its constants.</p>

<p>The disc was attached as a <code class="language-plaintext highlighter-rouge">.background(alignment: .center)</code> of the sleeve, not as a <code class="language-plaintext highlighter-rouge">ZStack</code> sibling, so the sleeve decided the size and the disc just lived behind it. The math maps the first 200 points of scroll onto a <code class="language-plaintext highlighter-rouge">progress</code> from 0 to 1. The disc rests 20 points out so there’s a hint of it at the top, then travels up to 56 more and rotates up to 16 degrees. Reduce Motion pins <code class="language-plaintext highlighter-rouge">progress</code> at zero, which leaves the resting reveal and stops all motion. Values again, not modifiers.</p>

<p>Rotation came from scroll offset rather than a <code class="language-plaintext highlighter-rouge">TimelineView</code>. A spinning record is the obvious version, and it would mean a timer running on a screen people sit on while they read liner notes. Driving it from scroll meant the disc moved when your thumb moved and not otherwise.</p>

<h2 id="too-far-gone--the-effect-that-never-read">Too Far Gone? — The Effect That Never Read</h2>

<p>On September 23 the disc came back out. Here’s the note I left:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>On device the disc only ever showed as a sliver at the screen edge. On iPhone
the sleeve fills the width minus a 16pt margin, so the disc's travel went
off-screen and the effect never read.
</code></pre></div></div>

<p>That’s the whole story. The sleeve is nearly full-width on a phone, so a disc sliding out to the right has nowhere to go but past the edge of the screen. Those constants were first-pass numbers I planned to tune on a real phone. Once I saw it on a real phone, there was nothing worth tuning.</p>

<p>The refactors that came with it stayed. So did the Reduce Motion gate on the home screen. Only the disc left, and leaving was cheap. No state to unwind and no parameters threaded through parents. One closure and one enum came out of one file. <code class="language-plaintext highlighter-rouge">visualEffect</code> made the effect cheap to build, and it made it just as cheap to delete.</p>

<h2 id="mama-said--when-scroll-state-is-the-right-call">Mama Said — When Scroll State Is the Right Call</h2>

<p>Not everything belongs in <code class="language-plaintext highlighter-rouge">visualEffect</code>. The album detail screen has one scroll effect that stays in state, and it should.</p>

<p>The back, favorite, and crate buttons float over the artwork in an <code class="language-plaintext highlighter-rouge">.overlay(alignment: .top)</code>. As the hero scrolls up under them, a frosted backdrop fades in behind the buttons. It’s a linear ramp over 44 points, clamped between 0 and 0.6. The ceiling is there because <code class="language-plaintext highlighter-rouge">.ultraThinMaterial</code> is already SwiftUI’s lightest material, so capping the opacity is the only way to make it lighter.</p>

<p><code class="language-plaintext highlighter-rouge">visualEffect</code> can’t do this, for a structural reason. The closure only knows the geometry of the view it modifies. The chrome sits outside the scroll content, so its own frame never moves when you scroll. Put a <code class="language-plaintext highlighter-rouge">visualEffect</code> on it and the closure sees the same rectangle every frame. The thing the fade depends on, the hero’s bottom edge reaching the bar, lives in a different view entirely. This one really does need state.</p>

<p>So the question becomes where the state lives. Not on the screen:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@MainActor</span>
<span class="kd">@Observable</span>
<span class="kd">final</span> <span class="kd">class</span> <span class="kt">AlbumDetailChromeModel</span> <span class="p">{</span>

    <span class="kd">nonisolated</span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">maxBarOpacity</span> <span class="o">=</span> <span class="mf">0.6</span>

    <span class="kd">private(set)</span> <span class="k">var</span> <span class="nv">barOpacity</span><span class="p">:</span> <span class="kt">Double</span> <span class="o">=</span> <span class="mi">0</span>

    <span class="kd">private(set)</span> <span class="k">var</span> <span class="nv">heroHeight</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">0</span>

    <span class="kd">nonisolated</span> <span class="kd">static</span> <span class="kd">func</span> <span class="nf">opacity</span><span class="p">(</span>
        <span class="nv">scrollOffset</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">,</span>
        <span class="nv">heroHeight</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">,</span>
        <span class="nv">barHeight</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">,</span>
        <span class="nv">fadeDistance</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">44</span>
    <span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Double</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="n">heroHeight</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span> <span class="n">fadeDistance</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="mi">0</span> <span class="p">}</span>
        <span class="k">let</span> <span class="nv">threshold</span> <span class="o">=</span> <span class="n">heroHeight</span> <span class="o">-</span> <span class="n">barHeight</span>
        <span class="k">let</span> <span class="nv">progress</span> <span class="o">=</span> <span class="p">(</span><span class="n">scrollOffset</span> <span class="o">-</span> <span class="n">threshold</span><span class="p">)</span> <span class="o">/</span> <span class="n">fadeDistance</span>
        <span class="k">return</span> <span class="kt">Double</span><span class="p">(</span><span class="nf">min</span><span class="p">(</span><span class="nf">max</span><span class="p">(</span><span class="n">progress</span><span class="p">,</span> <span class="mi">0</span><span class="p">),</span> <span class="mi">1</span><span class="p">))</span> <span class="o">*</span> <span class="n">maxBarOpacity</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">updateHeroHeight</span><span class="p">(</span><span class="n">_</span> <span class="nv">height</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="n">height</span> <span class="o">!=</span> <span class="n">heroHeight</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="n">heroHeight</span> <span class="o">=</span> <span class="n">height</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">update</span><span class="p">(</span><span class="nv">scrollOffset</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">,</span> <span class="nv">barHeight</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">newValue</span> <span class="o">=</span> <span class="k">Self</span><span class="o">.</span><span class="nf">opacity</span><span class="p">(</span>
            <span class="nv">scrollOffset</span><span class="p">:</span> <span class="n">scrollOffset</span><span class="p">,</span>
            <span class="nv">heroHeight</span><span class="p">:</span> <span class="n">heroHeight</span><span class="p">,</span>
            <span class="nv">barHeight</span><span class="p">:</span> <span class="n">barHeight</span>
        <span class="p">)</span>
        <span class="k">guard</span> <span class="n">newValue</span> <span class="o">!=</span> <span class="n">barOpacity</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="n">barOpacity</span> <span class="o">=</span> <span class="n">newValue</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">AlbumDetailView</code> owns it, feeds it, and hands it to the chrome. Trimmed to the lines that matter:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">chrome</span> <span class="o">=</span> <span class="kt">AlbumDetailChromeModel</span><span class="p">()</span>

<span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kt">ScrollView</span> <span class="p">{</span>
        <span class="kt">VStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">0</span><span class="p">)</span> <span class="p">{</span>
            <span class="kt">AlbumHeroHeader</span><span class="p">(</span><span class="nv">detail</span><span class="p">:</span> <span class="n">detail</span><span class="p">)</span> <span class="p">{</span> <span class="n">chrome</span><span class="o">.</span><span class="nf">updateHeroHeight</span><span class="p">(</span><span class="nv">$0</span><span class="p">)</span> <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="o">.</span><span class="nf">onScrollGeometryChange</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="o">.</span><span class="k">self</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">contentOffset</span><span class="o">.</span><span class="n">y</span> <span class="o">+</span> <span class="nv">$0</span><span class="o">.</span><span class="n">contentInsets</span><span class="o">.</span><span class="n">top</span> <span class="p">}</span> <span class="nv">action</span><span class="p">:</span> <span class="p">{</span> <span class="n">_</span><span class="p">,</span> <span class="n">offset</span> <span class="k">in</span>
        <span class="n">chrome</span><span class="o">.</span><span class="nf">update</span><span class="p">(</span><span class="nv">scrollOffset</span><span class="p">:</span> <span class="n">offset</span><span class="p">,</span> <span class="nv">barHeight</span><span class="p">:</span> <span class="kt">Spacing</span><span class="o">.</span><span class="n">navBarHeight</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="o">.</span><span class="nf">overlay</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">top</span><span class="p">)</span> <span class="p">{</span>
        <span class="kt">AlbumDetailTopChrome</span><span class="p">(</span>
            <span class="nv">chrome</span><span class="p">:</span> <span class="n">chrome</span><span class="p">,</span>
            <span class="nv">isFavorite</span><span class="p">:</span> <span class="n">isFavorite</span><span class="p">,</span>
            <span class="nv">showsDelete</span><span class="p">:</span> <span class="n">onDelete</span> <span class="o">!=</span> <span class="kc">nil</span><span class="p">,</span>
            <span class="nv">onBack</span><span class="p">:</span> <span class="p">{</span> <span class="nf">dismiss</span><span class="p">()</span> <span class="p">},</span>
            <span class="nv">onToggleFavorite</span><span class="p">:</span> <span class="p">{</span> <span class="n">container</span><span class="p">?</span><span class="o">.</span><span class="n">collection</span><span class="o">.</span><span class="nf">toggleFavorite</span><span class="p">(</span><span class="n">detail</span><span class="p">)</span> <span class="p">},</span>
            <span class="nv">onAddToCrate</span><span class="p">:</span> <span class="p">{</span> <span class="n">showingCrateOptions</span> <span class="o">=</span> <span class="kc">true</span> <span class="p">},</span>
            <span class="nv">onDelete</span><span class="p">:</span> <span class="p">{</span> <span class="n">showingDeleteConfirmation</span> <span class="o">=</span> <span class="kc">true</span> <span class="p">}</span>
        <span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Ownership does the important work here. <code class="language-plaintext highlighter-rouge">AlbumDetailView.body</code> holds the model but never reads <code class="language-plaintext highlighter-rouge">barOpacity</code>. Only <code class="language-plaintext highlighter-rouge">AlbumDetailTopChrome</code> reads it, so a change invalidates that one small view and nothing else. That matters because <code class="language-plaintext highlighter-rouge">AlbumDetailView</code> is enormous. The SwiftLint header at the top of the file talks about splitting “the 1300-line body.” Putting this value in that view’s <code class="language-plaintext highlighter-rouge">@State</code> would recreate the home screen’s problem on the screen that can least afford it.</p>

<p>The clamp does the rest. <code class="language-plaintext highlighter-rouge">opacity</code> returns 0 before the threshold and <code class="language-plaintext highlighter-rouge">maxBarOpacity</code> past the fade band, so the value only changes inside those 44 points. Outside the band, the <code class="language-plaintext highlighter-rouge">onScrollGeometryChange</code> action still runs every frame, but it computes the same number every time, and the <code class="language-plaintext highlighter-rouge">guard newValue != barOpacity</code> turns that into no write at all. The guard is extra insurance. As I found <a href="/swiftui-equatable-observation-onchange/">in the last Observation post</a>, <code class="language-plaintext highlighter-rouge">@Observable</code>’s generated setter already skips notification when an <code class="language-plaintext highlighter-rouge">Equatable</code> value comes in equal. I keep it anyway because it makes the intent obvious in the code instead of leaving it to whatever the macro generates.</p>

<p>Adding <code class="language-plaintext highlighter-rouge">contentInsets.top</code> to the offset normalizes it so zero means “at rest,” whatever the safe area is doing. The ramp is a <code class="language-plaintext highlighter-rouge">nonisolated static func</code>, which makes it pure arithmetic you can unit test without standing up a scroll view. With default main-actor isolation, that keyword has to be spelled out.</p>

<p>One more detail from the chrome is worth stealing:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="n">background</span> <span class="p">{</span>
    <span class="kt">Color</span><span class="o">.</span><span class="n">clear</span>
        <span class="o">.</span><span class="nf">background</span><span class="p">(</span><span class="o">.</span><span class="n">ultraThinMaterial</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="n">chrome</span><span class="o">.</span><span class="n">barOpacity</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">ignoresSafeArea</span><span class="p">(</span><span class="nv">edges</span><span class="p">:</span> <span class="o">.</span><span class="n">top</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">allowsHitTesting</span><span class="p">(</span><span class="kc">false</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Even at zero opacity, that strip swallowed drags in the gaps between the buttons, which made the top of the page unscrollable. Invisible isn’t the same as absent.</p>

<h2 id="sleepwalk-my-life-away--the-bool-that-never-changes">Sleepwalk My Life Away — The Bool That Never Changes</h2>

<p>The usual advice for scroll state is to quantize it: have the transform return something coarse, like a <code class="language-plaintext highlighter-rouge">Bool</code>, so the action fires twice per round trip instead of every frame. That advice is right. It also has a trap, and CRX’s onboarding is written to avoid it.</p>

<p>The disclaimer step won’t enable its accept toggle until you’ve scrolled to the bottom of the legal copy. Here’s the obvious version, the one CRX doesn’t use:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="nf">onScrollGeometryChange</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="kt">Bool</span><span class="o">.</span><span class="k">self</span><span class="p">)</span> <span class="p">{</span> <span class="n">geometry</span> <span class="k">in</span>
    <span class="k">Self</span><span class="o">.</span><span class="nf">hasReachedBottom</span><span class="p">(</span><span class="nv">geometry</span><span class="p">:</span> <span class="n">geometry</span><span class="p">)</span>
<span class="p">}</span> <span class="nv">action</span><span class="p">:</span> <span class="p">{</span> <span class="n">_</span><span class="p">,</span> <span class="n">reachedBottom</span> <span class="k">in</span>
    <span class="k">if</span> <span class="n">reachedBottom</span> <span class="p">{</span>
        <span class="n">hasScrolledToBottom</span> <span class="o">=</span> <span class="kc">true</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The action only fires when the transformed value changes. On a phone, the disclaimer is taller than its container, so the <code class="language-plaintext highlighter-rouge">Bool</code> starts <code class="language-plaintext highlighter-rouge">false</code>, flips to <code class="language-plaintext highlighter-rouge">true</code> when you reach the end, and the toggle enables.</p>

<p>Now run it where the content fits without scrolling, like macOS or a large iPad. The <code class="language-plaintext highlighter-rouge">Bool</code> is <code class="language-plaintext highlighter-rouge">true</code> on the very first layout pass. It never changes after that, so the action never fires and the toggle stays disabled forever. The user did everything right. There was nothing to scroll.</p>

<p>The fix is to stop quantizing in the transform. Observe the whole <code class="language-plaintext highlighter-rouge">ScrollGeometry</code> and evaluate the predicate in the action:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="nf">onScrollGeometryChange</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="kt">ScrollGeometry</span><span class="o">.</span><span class="k">self</span><span class="p">)</span> <span class="p">{</span> <span class="n">geometry</span> <span class="k">in</span>
    <span class="n">geometry</span>
<span class="p">}</span> <span class="nv">action</span><span class="p">:</span> <span class="p">{</span> <span class="n">_</span><span class="p">,</span> <span class="n">geometry</span> <span class="k">in</span>
    <span class="k">if</span> <span class="k">Self</span><span class="o">.</span><span class="nf">hasReachedBottom</span><span class="p">(</span><span class="nv">geometry</span><span class="p">:</span> <span class="n">geometry</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">hasScrolledToBottom</span> <span class="o">=</span> <span class="kc">true</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="kd">func</span> <span class="nf">hasReachedBottom</span><span class="p">(</span><span class="nv">geometry</span><span class="p">:</span> <span class="kt">ScrollGeometry</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="n">geometry</span><span class="o">.</span><span class="n">contentOffset</span><span class="o">.</span><span class="n">y</span> <span class="o">+</span> <span class="n">geometry</span><span class="o">.</span><span class="n">containerSize</span><span class="o">.</span><span class="n">height</span>
        <span class="o">&gt;=</span> <span class="n">geometry</span><span class="o">.</span><span class="n">contentSize</span><span class="o">.</span><span class="n">height</span> <span class="o">-</span> <span class="mi">24</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The geometry always changes at least once, from its zero initial value to the first real layout, so the action fires on first layout too. If the content already fits, the predicate passes immediately. The 24-point tolerance means nobody has to land on the last pixel.</p>

<p>So here’s the more precise version of the rule. Quantizing is still right, but think about the first layout. A <code class="language-plaintext highlighter-rouge">Bool</code> that starts <code class="language-plaintext highlighter-rouge">false</code> and only flips when the user scrolls, like a header collapse threshold, is fine. A <code class="language-plaintext highlighter-rouge">Bool</code> that can already be <code class="language-plaintext highlighter-rouge">true</code> at first layout is the trap.</p>

<h2 id="better-than-you--when-scrolltransition-is-the-right-tool">Better Than You — When scrollTransition Is the Right Tool</h2>

<p>Before reaching for geometry at all, ask whether you need it. <code class="language-plaintext highlighter-rouge">scrollTransition</code> uses the same <code class="language-plaintext highlighter-rouge">VisualEffect</code> machinery and the same <code class="language-plaintext highlighter-rouge">@Sendable</code> closure, but it gives you a phase instead of a proxy. Here’s a general illustration, not app code:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">AlbumCard</span><span class="p">(</span><span class="nv">album</span><span class="p">:</span> <span class="n">album</span><span class="p">)</span>
    <span class="o">.</span><span class="nf">scrollTransition</span><span class="p">(</span><span class="o">.</span><span class="n">interactive</span><span class="o">.</span><span class="nf">threshold</span><span class="p">(</span><span class="o">.</span><span class="nf">visible</span><span class="p">(</span><span class="mf">0.6</span><span class="p">)))</span> <span class="p">{</span> <span class="n">content</span><span class="p">,</span> <span class="n">phase</span> <span class="k">in</span>
        <span class="n">content</span>
            <span class="o">.</span><span class="nf">opacity</span><span class="p">(</span><span class="mi">1</span> <span class="o">-</span> <span class="nf">abs</span><span class="p">(</span><span class="n">phase</span><span class="o">.</span><span class="n">value</span><span class="p">)</span> <span class="o">*</span> <span class="mf">0.5</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">scaleEffect</span><span class="p">(</span><span class="n">phase</span><span class="o">.</span><span class="n">isIdentity</span> <span class="p">?</span> <span class="mi">1</span> <span class="p">:</span> <span class="mf">0.95</span><span class="p">)</span>
    <span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">phase.value</code> runs from <code class="language-plaintext highlighter-rouge">-1</code> (leaving past the top or leading edge) through <code class="language-plaintext highlighter-rouge">0</code> (identity) to <code class="language-plaintext highlighter-rouge">1</code> (leaving past the bottom or trailing edge). The threshold decides what counts as fully visible. Here, a card is at identity once 60% of it is on screen. Swap <code class="language-plaintext highlighter-rouge">.interactive</code> for <code class="language-plaintext highlighter-rouge">.animated</code> and the transition snaps between phases with an animation instead of tracking the finger.</p>

<p>When the effect is about visibility, <code class="language-plaintext highlighter-rouge">scrollTransition</code> is the better tool. Cards that fade as they leave either edge are symmetric, and SwiftUI already knows the answer. That means less code and no coordinate-space decisions.</p>

<p>Reach for <code class="language-plaintext highlighter-rouge">visualEffect</code> when the effect is defined in points. VinylCrate’s parallax is 30% of distance scrolled, capped at 40 points, and it only reacts to the top edge. The disc mapped 200 points of travel onto a progress value. Neither is a visibility question. The same goes for anything that needs the view’s own size, or measures against a named container or a specific axis. <code class="language-plaintext highlighter-rouge">visualEffect</code> also works anywhere there’s geometry, while <code class="language-plaintext highlighter-rouge">scrollTransition</code> only works inside a scrolling container.</p>

<h2 id="spit-out-the-bone--where-visualeffect-bites">Spit Out the Bone — Where visualEffect Bites</h2>

<p><strong>Only <code class="language-plaintext highlighter-rouge">VisualEffect</code> methods are allowed.</strong> On iOS that’s <code class="language-plaintext highlighter-rouge">offset</code> (a <code class="language-plaintext highlighter-rouge">CGSize</code> or <code class="language-plaintext highlighter-rouge">x:y:</code>), <code class="language-plaintext highlighter-rouge">opacity</code>, three <code class="language-plaintext highlighter-rouge">scaleEffect</code> overloads, <code class="language-plaintext highlighter-rouge">rotationEffect</code>, <code class="language-plaintext highlighter-rouge">rotation3DEffect</code>, <code class="language-plaintext highlighter-rouge">transformEffect</code> (affine or projection), <code class="language-plaintext highlighter-rouge">blur</code>, <code class="language-plaintext highlighter-rouge">brightness</code>, <code class="language-plaintext highlighter-rouge">contrast</code>, <code class="language-plaintext highlighter-rouge">saturation</code>, <code class="language-plaintext highlighter-rouge">grayscale</code>, <code class="language-plaintext highlighter-rouge">hueRotation</code>, <code class="language-plaintext highlighter-rouge">blendMode</code>, and the shader effects <code class="language-plaintext highlighter-rouge">colorEffect</code>, <code class="language-plaintext highlighter-rouge">distortionEffect</code>, and <code class="language-plaintext highlighter-rouge">layerEffect</code>. If you’ve seen <code class="language-plaintext highlighter-rouge">offset(z:)</code>, <code class="language-plaintext highlighter-rouge">transform3DEffect</code>, or <code class="language-plaintext highlighter-rouge">perspectiveRotationEffect</code> in examples, those are visionOS. Nothing that sizes or lays out content is on the list: no <code class="language-plaintext highlighter-rouge">frame</code>, <code class="language-plaintext highlighter-rouge">padding</code>, or <code class="language-plaintext highlighter-rouge">font</code>. <code class="language-plaintext highlighter-rouge">shadow</code> and <code class="language-plaintext highlighter-rouge">clipShape</code> aren’t there either. If one of those has to respond to scroll, you’re in state territory, and the chrome model above is the shape I’d use.</p>

<p><strong>Layout never finds out.</strong> That’s the point of the API, and it’s also the trap. The parallax image offset 40 points still occupies its original frame. The disc rotated 16 degrees still had the frame it started with. Neighbors don’t make room. If something should push its siblings around, <code class="language-plaintext highlighter-rouge">visualEffect</code> is the wrong tool, and no amount of offset math will fake it convincingly.</p>

<p><strong>Don’t assume the tap target followed the pixels.</strong> Hit testing is based on layout, and a visual effect is explicitly not layout. I haven’t found Apple documentation that pins down hit testing for <code class="language-plaintext highlighter-rouge">visualEffect</code> transforms, so measure it on the OS versions you ship rather than trusting me. Then design so the answer doesn’t matter. Keep big offsets on decorative layers. VinylCrate’s parallax moves artwork inside a clipped card, and the card, which is the button, stays where layout put it. The disc was <code class="language-plaintext highlighter-rouge">accessibilityHidden</code> and never interactive.</p>

<p><strong>Pick the coordinate space on purpose.</strong> <code class="language-plaintext highlighter-rouge">.global</code> is the window, and it breaks first. Sheets, resizable iPad windows, and anything above the scroll view all leak into your “distance from the top.” <code class="language-plaintext highlighter-rouge">.scrollView</code> is the innermost containing scroll view, which is what both VinylCrate effects use, and it’s usually right until you nest a horizontal shelf inside a vertical feed. <code class="language-plaintext highlighter-rouge">.scrollView(axis: .vertical)</code> resolves to the innermost scroll view on that axis. <code class="language-plaintext highlighter-rouge">GeometryProxy.bounds(of:)</code> gives you that container’s bounds in the view’s local space when you need the viewport size as well as your position. For anything else, name the container and measure against it. <a href="/mastering-anchor-preferences-in-swiftui-advanced-coordinate-space-and-positioning/">Named coordinate spaces</a> work the same way here as everywhere else.</p>

<p><strong>Keep the closure to arithmetic.</strong> It runs for every visible view as geometry changes. No formatters, no allocations, no lookups, nothing that belongs in a model. Both VinylCrate closures are a handful of multiplications and a <code class="language-plaintext highlighter-rouge">min</code>. That’s the budget.</p>

<p><strong><code class="language-plaintext highlighter-rouge">blur</code> isn’t free.</strong> It renders offscreen, per view. One blurred hero is fine. A blur on every card in a dense list is the first thing you’ll feel on older hardware. If the frame budget gets tight, opacity and scale usually carry the effect on their own.</p>

<p><strong>Measure it.</strong> Turn on the SwiftUI instrument, scroll, and count body updates for the screen’s parent view. Do it before the change and after. That comparison is the whole argument for this API, and it’s more convincing than anything I can write.</p>

<h2 id="orion--pixels-not-state">Orion — Pixels, Not State</h2>

<p>Here’s the rule I’ve landed on. If an effect only moves pixels and can see the geometry it depends on, it belongs in <code class="language-plaintext highlighter-rouge">visualEffect</code> or <code class="language-plaintext highlighter-rouge">scrollTransition</code>, and it never touches state. If it changes layout, or it lives outside the scroll content it reacts to, it gets state. Clamp or quantize that state before it’s written, check what the first layout hands you, and keep it in the smallest view that reads it.</p>

<p>The scroll API you choose doesn’t decide the cost. Where the number lands does. VinylCrate’s home screen used the newest scroll API there is and still paid for a full collection body on every frame, because the number landed on the wrong view. Now the parallax runs at the render layer, and the home screen’s body never hears about it.</p>

<p>The disc is the other half. The architecture was right: no state, no timer, nothing a parent had to know about. That told me what the effect cost. It didn’t tell me whether anyone could see it. The phone answered that one.</p>

<p>Keep shipping.</p>]]></content><author><name>Wesley Matlock</name></author><category term="swiftui" /><category term="ios" /><category term="performance" /><category term="scrollview" /><category term="visualeffect" /><category term="parallax" /><category term="swift-concurrency" /><category term="accessibility" /><category term="vinylcrate" /><summary type="html"><![CDATA[How VinylCrate moved SwiftUI scroll parallax from per-frame @State into visualEffect, where scroll state still belongs, and an onScrollGeometryChange Bool trap.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.wesleymatlock.com/images/blog/2026-10-09-swiftui-visualeffect-scroll-effects/hero.png" /><media:content medium="image" url="https://www.wesleymatlock.com/images/blog/2026-10-09-swiftui-visualeffect-scroll-effects/hero.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">No Repeat Weekend: Two Nights on the Rail for Metallica at the Sphere</title><link href="https://www.wesleymatlock.com/metallica-sphere-las-vegas/" rel="alternate" type="text/html" title="No Repeat Weekend: Two Nights on the Rail for Metallica at the Sphere" /><published>2026-10-08T00:00:00+00:00</published><updated>2026-10-08T00:00:00+00:00</updated><id>https://www.wesleymatlock.com/metallica-sphere-las-vegas</id><content type="html" xml:base="https://www.wesleymatlock.com/metallica-sphere-las-vegas/"><![CDATA[<p>I’ve been a Metallica fan since 1986. For proof, here’s me at 17: a goofy, awkward high schooler wearing a Metal Up Your Ass shirt like it was my whole personality. Honestly, it kind of was.</p>

<picture style="display: block; width: 100%; max-width: 480px; margin: 1.5rem auto;">
  <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/high-school-metal-up-your-ass.webp" type="image/webp" />
  <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/high-school-metal-up-your-ass.jpg" alt="An old high school photo of two teenagers sitting on classroom desks: Wesley on the left, laughing in a black Metallica Metal Up Your Ass shirt, and his friend Marc in a teal shirt" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
</picture>

<p><em>That’s me on the left, 17 years old, in the Metal Up Your Ass shirt, next to my good friend Marc. Another good friend, Jamie, took the picture. We’re all still good friends to this day.</em></p>

<p>A couple of years later I finally saw them live. My first Metallica show was January 15, 1989. Damaged Justice tour, Kiefer UNO Lakefront Arena in New Orleans. I was 19 and sitting in the stands. Honestly, I don’t remember much about it other than having a great time.</p>

<p>That show opened with The Ecstasy of Gold into Blackened. Hold that thought.</p>

<p>I’ve seen Metallica about 15 times since then. This fall they kicked off a residency at the Sphere in Las Vegas called Life Burns Faster, and I went out for the first weekend: Thursday, October 1 and Saturday, October 3. They billed it as a “No Repeat Weekend.” A different setlist each night, with nothing repeated except the Kirk and Rob doodle.</p>

<p>My friend Brian got us tickets through the Legacy fan club presale. We had the First Entry GA Two Day Package, which meant general admission on the floor both nights. Brian is a good Metallica friend I met through Brandon, and Brandon is a longtime friend from when we worked together at Overstock.com. Amy stayed home with the dogs.</p>

<p>I stayed at the Westin, about a three-quarter-mile walk to the Sphere. It was really nice: no casino, no resort fees, and just one block off the Strip.</p>

<picture style="display: block; margin: 1.5rem 0;">
  <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/fan-zone-entrance.webp" type="image/webp" />
  <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/fan-zone-entrance.jpg" alt="A large black arch reading METALLICA Life Burns Faster at the entrance to the fan zone outside the Sphere" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
</picture>

<hr />

<h2 id="metal-militia--the-fan-club-line-and-the-numbers-on-our-hands">Metal Militia — The Fan Club Line and the Numbers on Our Hands</h2>

<p>The line started around 10am.</p>

<p>If you’ve never done a GA line with the Metallica fan club regulars, here’s how it works. They write your place-in-line number on your hand. It’s not official, but it keeps things civil, and it lets you come and go within reason without losing your spot.</p>

<p>On Thursday, the Tokyo fan club chapter ran the line. On Saturday, Brandon ran it, and he did an amazing job.</p>

<p>I got there at 10 on Thursday and stayed until about noon. Then we went to get food and rest, and came back around 3. That only works because of the numbers. Without them, you’re standing on that patch of ground all day.</p>

<p>By Saturday you’re seeing the same people from Thursday. The ones I remember most were the Ecuador chapter and the Tokyo chapter. They came all the way from Ecuador and Tokyo to stand in that line. The Ecuador guys had picks made special for the trip, and one of them gave me one.</p>

<div style="display: flex !important; flex-direction: row !important; gap: 1rem; margin: 1.5rem 0;">
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/line-number-hand.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/line-number-hand.jpg" alt="Wesley's hand with a place-in-line number written in Sharpie, above yellow and black Jordans on gravel" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/two-day-ticket.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/two-day-ticket.jpg" alt="Ticketmaster wallet pass reading GEN ADM FLOOR, First Entry GA Two Day Package" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
</div>

<p><em>My spot in line, in Sharpie. And the ticket that got us onto the floor both nights.</em></p>

<hr />

<h2 id="where-the-wild-things-are--the-metallica-crowd-meets-the-oz-crowd">Where the Wild Things Are — The Metallica Crowd Meets the Oz Crowd</h2>

<p>The Sphere also runs The Wizard of Oz show, and there are giant inflatable Wicked Witch legs outside, striped stockings and ruby slippers included. They were right near our line.</p>

<p>The first day, we noticed a very different crowd heading in. Older folks, women, some of them dressed like Dorothy. The Oz show was still running right up until our 5pm entry time, so for a while you had a line full of Metallica fans on one side and Dorothy on the other.</p>

<p>It was a fun contrast.</p>

<picture style="display: block; margin: 1.5rem 0;">
  <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/wicked-witch-legs.webp" type="image/webp" />
  <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/wicked-witch-legs.jpg" alt="Giant inflatable Wicked Witch legs in striped stockings and ruby slippers outside the Sphere" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
</picture>

<hr />

<h2 id="motorbreath--gates-wristbands-and-the-walk-to-the-rail">Motorbreath — Gates, Wristbands, and the Walk to the Rail</h2>

<p>Gates opened at 5:00. Everyone got in order by the number on their hand. Not ideal, but orderly. No shoving, no pushing.</p>

<p>From there we walked into another waiting zone, which was a basic single-file cattle line. We waited there until 6:45 for doors. First Entry let about 500 of us in 15 minutes before everyone else. Security, then a floor wristband, then another brisk walk to the floor.</p>

<picture style="display: block; width: 100%; max-width: 360px; margin: 1.5rem auto;">
  <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/floor-wristband.webp" type="image/webp" />
  <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/floor-wristband.jpg" alt="A gold and black Metallica floor wristband on Wesley's wrist, with the faded Sharpie line number still on the back of his hand" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
</picture>

<p><em>Our floor band. You can still see what’s left of my line number on my hand.</em></p>

<p>We ended up on the rail near the tip of the stage. Both nights. It was a lot like the two nights I spent in the snake pit at Mile High in Denver last year, which I wrote about in my <a href="/a-wild-traditional-victorious-and-tired-year/">year-end post</a>.</p>

<picture style="display: block; width: 100%; max-width: 300px; margin: 1.5rem auto;">
  <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/rail-spot-circled.webp" type="image/webp" />
  <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/rail-spot-circled.jpg" alt="An overhead view of the stage edge and the packed floor crowd inside the Sphere, with a red circle around Wesley on the rail near the tip of the stage" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
</picture>

<p><em>That’s me in the red circle, on the rail near the tip of the stage.</em></p>

<p>Before the show, the screen was covered in flags and banners from fan club chapters all over the world. I found the Tokyo chapter’s flag. I never did spot the Ecuador one.</p>

<picture style="display: block; margin: 1.5rem 0;">
  <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/fan-club-flags-stage.webp" type="image/webp" />
  <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/fan-club-flags-stage.jpg" alt="Wide view of the stage inside the Sphere before the show, with the screen above covered in fan club flags" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
</picture>

<div style="display: flex !important; flex-direction: row !important; gap: 1rem; margin: 1.5rem 0;">
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/fan-club-flags-dome.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/fan-club-flags-dome.jpg" alt="Looking straight up into the Sphere, with fan club flags covering the screen like a tunnel" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/night-one-setlist.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/night-one-setlist.jpg" alt="Printed setlist taped to the stage, dated Thursday October 1 2026, starting BATTERY, CREEP, DEAD, SAD, STRAW, ROAM" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
</div>

<hr />

<h2 id="the-call-of-ktulu--night-one">The Call of Ktulu — Night One</h2>

<p>Night one opened with Battery, the first time they’d opened a show with it since 2014.</p>

<p><strong>Thursday, October 1:</strong> Battery, Creeping Death, Now That We’re Dead, Sad but True, The Shortest Straw, Wherever I May Roam, The Day That Never Comes, …And Justice for All, Kirk and Rob Doodle, Nothing Else Matters, The Call of Ktulu, Lux Æterna, Ride the Lightning, The Memory Remains, Seek &amp; Destroy, Master of Puppets.</p>

<p>The big one for me was …And Justice for All. It hadn’t been played since August 2014, and I loved seeing it. Brandon had been hoping they’d add Justice back since it had been so long, so he got his wish.</p>

<p>From the floor it was hard to take in the full Sphere visuals. For me, this weekend was mostly about seeing the band up close. I did get a couple of shots, though: Lady Justice up on the screen during …And Justice for All, and a giant Ktulu rising over the stage during The Call of Ktulu.</p>

<div style="display: flex !important; flex-direction: row !important; gap: 1rem; margin: 1.5rem 0;">
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/james-hetfield-night-one.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/james-hetfield-night-one.jpg" alt="James Hetfield mid-scream at the front of the stage, playing a white Flying V against a blue screen" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/kirk-hammett-night-one.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/kirk-hammett-night-one.jpg" alt="Kirk Hammett mid-solo on a green-lit stage" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
</div>

<div style="display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center; margin: 1.5rem 0;">
  <div style="flex: 1 1 260px; max-width: 360px;">
    <video controls="" playsinline="" preload="metadata" poster="/images/blog/2026-10-08-metallica-sphere-las-vegas/kirk-night-one-poster.jpg" style="display: block; width: 100%; height: auto; border-radius: 8px;">
      <source src="/images/blog/2026-10-08-metallica-sphere-las-vegas/kirk-night-one.mp4" type="video/mp4" />
    </video>
    <p style="margin: 0.5rem 0 0; font-size: 0.9em;"><em>Kirk ripping right in front of us on night one.</em></p>
  </div>
  <div style="flex: 1 1 260px; max-width: 360px;">
    <video controls="" playsinline="" preload="metadata" poster="/images/blog/2026-10-08-metallica-sphere-las-vegas/james-night-one-poster.jpg" style="display: block; width: 100%; height: auto; border-radius: 8px;">
      <source src="/images/blog/2026-10-08-metallica-sphere-las-vegas/james-night-one.mp4" type="video/mp4" />
    </video>
    <p style="margin: 0.5rem 0 0; font-size: 0.9em;"><em>James talking to the crowd about the first night at the Sphere.</em></p>
  </div>
</div>

<div style="display: flex !important; flex-direction: row !important; gap: 1rem; margin: 1.5rem 0;">
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/lady-justice-screen.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/lady-justice-screen.jpg" alt="A Lady Justice statue shown on the Sphere screen during ...And Justice for All" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/ktulu-screen.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/ktulu-screen.jpg" alt="A giant Ktulu creature rising on the Sphere screen above the stage during The Call of Ktulu" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
</div>

<div style="display: flex; flex-wrap: wrap; gap: 1rem; justify-content: center; margin: 1.5rem 0;">
  <div style="flex: 1 1 260px; max-width: 360px;">
    <video controls="" playsinline="" preload="metadata" poster="/images/blog/2026-10-08-metallica-sphere-las-vegas/james-ride-the-lightning-poster.jpg" style="display: block; width: 100%; height: auto; border-radius: 8px;">
      <source src="/images/blog/2026-10-08-metallica-sphere-las-vegas/james-ride-the-lightning.mp4" type="video/mp4" />
    </video>
    <p style="margin: 0.5rem 0 0; font-size: 0.9em;"><em>James playing the riff to Ride the Lightning, right above the rail.</em></p>
  </div>
  <div style="flex: 1 1 260px; max-width: 360px;">
    <video controls="" playsinline="" preload="metadata" poster="/images/blog/2026-10-08-metallica-sphere-las-vegas/rob-night-one-poster.jpg" style="display: block; width: 100%; height: auto; border-radius: 8px;">
      <source src="/images/blog/2026-10-08-metallica-sphere-las-vegas/rob-night-one.mp4" type="video/mp4" />
    </video>
    <p style="margin: 0.5rem 0 0; font-size: 0.9em;"><em>Rob doing his spin, bass and all.</em></p>
  </div>
</div>

<video controls="" playsinline="" preload="metadata" poster="/images/blog/2026-10-08-metallica-sphere-las-vegas/night-one-ending-poster.jpg" style="display: block; width: 100%; max-width: 360px; height: auto; margin: 1.5rem auto; border-radius: 8px;">
  <source src="/images/blog/2026-10-08-metallica-sphere-las-vegas/night-one-ending.mp4" type="video/mp4" />
</video>

<p><em>The end of night one. James hands off his guitar and takes it all in.</em></p>

<hr />

<h2 id="hero-of-the-day--giving-blood-and-an-off-day-in-vegas">Hero of the Day — Giving Blood and an Off Day in Vegas</h2>

<p>Friday was the off day, so I gave blood at Metallica’s blood drive. I’d signed up ahead of time and had an appointment, so the wait was only about 15 minutes.</p>

<p>You got a T-shirt and a pick, plus cookies, snacks, and water. There were older show posters on the walls, Metallica pinball machines to play, and a drum kit set up for photos. Of course I sat behind it.</p>

<p>After that came the official pop-up store. Another three-hour line.</p>

<p>This is the show poster I was able to get. A demon tearing through the Welcome to Fabulous Las Vegas sign, with both of our dates on it.</p>

<picture style="display: block; width: 100%; max-width: 420px; margin: 1.5rem auto;">
  <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/vegas-poster.webp" type="image/webp" />
  <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/vegas-poster.jpg" alt="Metallica show poster of a red horned demon smashing through the Welcome to Fabulous Las Vegas sign, labeled Life Burns Faster, Las Vegas, NV, October 1 and 3, 2026" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
</picture>

<p>We walked around for a while, then I met Brandon and Brian for dinner and we went to see Penn &amp; Teller. It was my first time seeing them. Fun show, and I’d see them again.</p>

<p>We also found KISS World at the Rio. It’s an interesting display. They even had a KISS mini putt-putt.</p>

<div style="display: flex !important; flex-direction: row !important; gap: 1rem; margin: 1.5rem 0;">
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/blood-drive-drum-kit.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/blood-drive-drum-kit.jpg" alt="Wesley sitting behind a Tama drum kit at the blood drive photo op" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/three-picks.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/three-picks.jpg" alt="Three Metallica guitar picks on a table: a white Ecuador fan club pick, the black pick Rob Trujillo handed over, and the black blood drive pick" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
</div>

<p><em>Left to right: the Ecuador chapter pick (they had them made special for the trip), the pick Rob handed me, and the blood drive pick.</em></p>

<picture style="display: block; margin: 1.5rem 0;">
  <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/kiss-world-rio.webp" type="image/webp" />
  <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/kiss-world-rio.jpg" alt="The KISS WORLD neon sign at the Rio in Las Vegas" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
</picture>

<hr />

<h2 id="blackened--night-two-37-years-later">Blackened — Night Two, 37 Years Later</h2>

<p>Saturday started with AC/DC’s “It’s a Long Way to the Top” on tape. Then The Ecstasy of Gold. Then Blackened.</p>

<p>Same opening as my first show in New Orleans. In 1989 I was 19 and in the stands. Thirty-seven years later, I was on the rail.</p>

<video controls="" playsinline="" preload="metadata" poster="/images/blog/2026-10-08-metallica-sphere-las-vegas/night-two-opener-poster.jpg" style="display: block; width: 100%; max-width: 360px; height: auto; margin: 1.5rem auto; border-radius: 8px;">
  <source src="/images/blog/2026-10-08-metallica-sphere-las-vegas/night-two-opener.mp4" type="video/mp4" />
</video>

<p><em>The band hitting the stage on night two, filmed from the rail.</em></p>

<p><strong>Saturday, October 3:</strong> Blackened, Fuel, Leper Messiah, Devil’s Dance, 72 Seasons, King Nothing, Welcome Home (Sanitarium), The Outlaw Torn, Kirk and Rob Doodle, The Unforgiven, Orion, Hardwired, Whiskey in the Jar, For Whom the Bell Tolls, One, Enter Sandman.</p>

<p>Devil’s Dance hadn’t been played since 2008, and it was really good. The Outlaw Torn hadn’t been played since 2019. Both were amazing to see.</p>

<p>Across the two nights, they played ten songs from that 1989 setlist: Blackened, For Whom the Bell Tolls, Welcome Home (Sanitarium), Leper Messiah, Master of Puppets, One, Seek &amp; Destroy, …And Justice for All, Creeping Death, and Battery.</p>

<div style="display: flex !important; flex-direction: row !important; gap: 1rem; margin: 1.5rem 0;">
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/rail-selfie-night-two.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/rail-selfie-night-two.jpg" alt="Wesley smiling on the rail on night two, with fan club flags on the screen behind him" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/rob-trujillo-night-two.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/rob-trujillo-night-two.jpg" alt="Robert Trujillo playing a teal bass in front of a yellow screen" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
</div>

<video controls="" playsinline="" preload="metadata" poster="/images/blog/2026-10-08-metallica-sphere-las-vegas/lars-night-two-poster.jpg" style="display: block; width: 100%; max-width: 360px; height: auto; margin: 1.5rem auto; border-radius: 8px;">
  <source src="/images/blog/2026-10-08-metallica-sphere-las-vegas/lars-night-two.mp4" type="video/mp4" />
</video>

<p><em>Lars behind the kit on night two.</em></p>

<hr />

<h2 id="devils-dance--life-on-the-rail">Devil’s Dance — Life on the Rail</h2>

<p>Being on the rail is addicting. You get to interact with the band and be up close and personal.</p>

<p>James walks by, makes eye contact, and nods at you while he’s playing. Lars walks by teasing like he’s about to hand you a drumstick. Kirk comes by and just dumps picks to everyone in the crowd.</p>

<p>It makes all the standing and waiting worth it. At least to this crazy fan.</p>

<p>Then there was Rob. On night two he was walking around the stage throwing picks. He went to throw me one, and it fell short. Rob said “Fuck,” walked over, and handed me the pick.</p>

<p>I thought it was great and funny at the same time.</p>

<p>That’s two for Rob now. He gave me a high five at the Arlington show a few years ago.</p>

<p>After the show we got a group photo under the lit-up Metallica logo. That’s me on the far right, then Brandon, then Brian, plus two others whose names I don’t remember.</p>

<div style="display: flex !important; flex-direction: row !important; gap: 1rem; margin: 1.5rem 0;">
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/band-bow-night-two.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/band-bow-night-two.jpg" alt="James, Lars, Kirk, and Rob bowing together at the end of night two" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
  <picture style="display: block; flex: 0 0 48%; width: 48%;">
    <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/group-photo-after-show.webp" type="image/webp" />
    <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/group-photo-after-show.jpg" alt="Five people posing under the lit lightning-bolt M Metallica logo after the show, with Wesley on the far right next to Brandon and Brian" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
  </picture>
</div>

<hr />

<h2 id="turn-the-page--a-cancelled-flight-and-one-more-night">Turn the Page — A Cancelled Flight and One More Night</h2>

<p>Sunday, my flight got cancelled. I came back from the airport and stayed an extra night, this time at the MGM.</p>

<p>I decided to look at it as one more night in Vegas. I really hadn’t had a nice dinner the whole trip, so I went to Morimoto. Then I crashed.</p>

<picture style="display: block; width: 48%; margin: 1.5rem auto;">
  <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/morimoto-dinner.webp" type="image/webp" />
  <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/morimoto-dinner.jpg" alt="A plate of sashimi at Morimoto" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
</picture>

<p>I made my flight on Monday. I was tired and ready to get home to Amy and the dogs. The flight home went right over the Grand Canyon.</p>

<picture style="display: block; margin: 1.5rem 0;">
  <source srcset="/images/blog/2026-10-08-metallica-sphere-las-vegas/grand-canyon-flight-home.webp" type="image/webp" />
  <img src="/images/blog/2026-10-08-metallica-sphere-las-vegas/grand-canyon-flight-home.jpg" alt="The Grand Canyon seen from an airplane window on the flight home" loading="lazy" decoding="async" style="display: block !important; width: 100% !important; max-width: 100% !important; height: auto; border-radius: 8px; margin: 0 !important;" />
</picture>

<p>I’m not done with the Sphere yet. I’m going back for the October 29 and October 31 shows for my birthday, and Amy is coming with me. We’ll be in the seats this time. I’m looking forward to a whole new experience of the show, and to finally seeing the visuals. I’ll write that one up too.</p>

<p>In 1989 I was in the stands. This weekend I was on the rail. At the end of the month, I’ll be in the seats with Amy.</p>]]></content><author><name>Wesley Matlock</name></author><category term="metallica" /><category term="concerts" /><category term="las-vegas" /><category term="sphere" /><category term="live-music" /><category term="travel" /><category term="personal" /><summary type="html"><![CDATA[My trip to Metallica's first weekend at the Sphere in Las Vegas: the fan club line, two nights on the rail, a blood drive, and a pick from Rob.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.wesleymatlock.com/images/blog/2026-10-08-metallica-sphere-las-vegas/hero.jpg" /><media:content medium="image" url="https://www.wesleymatlock.com/images/blog/2026-10-08-metallica-sphere-las-vegas/hero.jpg" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Invisible Kid: When Equatable Hides Data from @Observable</title><link href="https://www.wesleymatlock.com/swiftui-equatable-observation-onchange/" rel="alternate" type="text/html" title="Invisible Kid: When Equatable Hides Data from @Observable" /><published>2026-09-28T00:00:00+00:00</published><updated>2026-09-28T00:00:00+00:00</updated><id>https://www.wesleymatlock.com/swiftui-equatable-observation-onchange</id><content type="html" xml:base="https://www.wesleymatlock.com/swiftui-equatable-observation-onchange/"><![CDATA[<p>A model’s <code class="language-plaintext highlighter-rouge">==</code> isn’t just a comparison anymore. Under Observation, it’s a gate. It decides whether anyone hears about a write at all.</p>

<p>I found that out in <a href="https://workoutwanderer.com/">Workout Wanderer</a>, my HealthKit and MapKit app. It maps your workout routes on iPhone and iPad, paints them as heat maps (heart rate, pace, elevation, cadence, power, and a few more), and lets you compare workouts side by side. Workouts load in two phases. First comes the basic list. Then background enrichment fills in each workout’s route, city name, heart-rate summary, elevation stats, and the detailed series the heat maps draw from.</p>

<p>Three commits landed on September 27, and all three trace back to one struct. The first fixed an <code class="language-plaintext highlighter-rouge">==</code> that was too loose, so observers never heard about enrichment. The other two fixed observers that were too broad, so two map cameras reacted to every enrichment write. The bugs point in opposite directions, but they’re the same bug. And I only found the second one because I fixed the first.</p>

<h2 id="dirty-window--an-id-only--on-a-two-phase-model">Dirty Window — An id-only == on a Two-Phase Model</h2>

<p>Here’s <code class="language-plaintext highlighter-rouge">WorkoutData</code> before the fix, trimmed:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">WorkoutData</span><span class="p">:</span> <span class="kt">Identifiable</span><span class="p">,</span> <span class="kt">Equatable</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">id</span><span class="p">:</span> <span class="kt">UUID</span>
    <span class="k">let</span> <span class="nv">workout</span><span class="p">:</span> <span class="kt">HKWorkout</span>

    <span class="k">var</span> <span class="nv">cityName</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
    <span class="k">var</span> <span class="nv">routeCoordinates</span><span class="p">:</span> <span class="p">[</span><span class="kt">CLLocationCoordinate2D</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">var</span> <span class="nv">heartRateData</span><span class="p">:</span> <span class="p">(</span><span class="nv">low</span><span class="p">:</span> <span class="kt">Double</span><span class="p">,</span> <span class="nv">avg</span><span class="p">:</span> <span class="kt">Double</span><span class="p">,</span> <span class="nv">high</span><span class="p">:</span> <span class="kt">Double</span><span class="p">)?</span>
    <span class="k">var</span> <span class="nv">routeLocations</span><span class="p">:</span> <span class="p">[</span><span class="kt">WorkoutLocation</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">var</span> <span class="nv">detailedHeartRateData</span><span class="p">:</span> <span class="kt">WorkoutHeartRateData</span><span class="p">?</span>
    <span class="k">var</span> <span class="nv">detailedPaceData</span><span class="p">:</span> <span class="kt">WorkoutPaceData</span><span class="p">?</span>
    <span class="c1">// ...detailedElevationData, detailedCadenceData, detailedRunningPowerData,</span>
    <span class="c1">// detailedVerticalOscillationData, detailedGAPData, detailedPowerEffortData</span>
    <span class="k">var</span> <span class="nv">cachedElevationStatistics</span><span class="p">:</span> <span class="kt">ElevationStatistics</span><span class="p">?</span>

    <span class="kd">static</span> <span class="kd">func</span> <span class="o">==</span> <span class="p">(</span><span class="nv">lhs</span><span class="p">:</span> <span class="kt">WorkoutData</span><span class="p">,</span> <span class="nv">rhs</span><span class="p">:</span> <span class="kt">WorkoutData</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">id</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">id</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">Hashable</code> lives in an extension and hashes the same single field:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">extension</span> <span class="kt">WorkoutData</span><span class="p">:</span> <span class="kt">Hashable</span> <span class="p">{</span>
    <span class="kd">func</span> <span class="nf">hash</span><span class="p">(</span><span class="n">into</span> <span class="nv">hasher</span><span class="p">:</span> <span class="k">inout</span> <span class="kt">Hasher</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">hasher</span><span class="o">.</span><span class="nf">combine</span><span class="p">(</span><span class="n">id</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>An id-only <code class="language-plaintext highlighter-rouge">==</code> is easy to write and easy to defend. Two values with the same <code class="language-plaintext highlighter-rouge">id</code> are the same workout, so they’re equal. For a model that never changes after it’s created, that’s close enough. <code class="language-plaintext highlighter-rouge">WorkoutData</code> changes after it’s created. That’s the whole point of phase two. A workout with no route and a workout with a full route, a city, and eight heat-map series all compared equal as long as they shared a UUID.</p>

<p>The commit message says what that cost: “SwiftUI observers of <code class="language-plaintext highlighter-rouge">workoutsData</code> (and of an enriched <code class="language-plaintext highlighter-rouge">selectedWorkout</code>) never saw routes, city names or heat-map data arrive.”</p>

<p>That “never” deserves a footnote, and the footnote is the most interesting part of the whole fix.</p>

<h2 id="the-house-jack-built--what-observable-generates-for-a-setter">The House Jack Built — What @Observable Generates for a Setter</h2>

<p>I went and read what the <code class="language-plaintext highlighter-rouge">@Observable</code> macro actually generates in the Swift 6.4 toolchain. For a stored property like <code class="language-plaintext highlighter-rouge">selectedWorkout</code>, the setter looks like this:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">set</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="nf">shouldNotifyObservers</span><span class="p">(</span><span class="n">_selectedWorkout</span><span class="p">,</span> <span class="n">newValue</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span>
        <span class="n">_selectedWorkout</span> <span class="o">=</span> <span class="n">newValue</span>
        <span class="k">return</span>
    <span class="p">}</span>
    <span class="nf">withMutation</span><span class="p">(</span><span class="nv">keyPath</span><span class="p">:</span> <span class="p">\</span><span class="o">.</span><span class="n">selectedWorkout</span><span class="p">)</span> <span class="p">{</span>
        <span class="n">_selectedWorkout</span> <span class="o">=</span> <span class="n">newValue</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">shouldNotifyObservers</code> is overloaded. For <code class="language-plaintext highlighter-rouge">T: Equatable</code> it returns <code class="language-plaintext highlighter-rouge">lhs != rhs</code>. For <code class="language-plaintext highlighter-rouge">T: AnyObject</code> it returns <code class="language-plaintext highlighter-rouge">lhs !== rhs</code>. For <code class="language-plaintext highlighter-rouge">T: Equatable &amp; AnyObject</code> it goes back to <code class="language-plaintext highlighter-rouge">lhs != rhs</code>. The unconstrained version returns <code class="language-plaintext highlighter-rouge">true</code>.</p>

<p><code class="language-plaintext highlighter-rouge">WorkoutData?</code> is <code class="language-plaintext highlighter-rouge">Equatable</code>. So is <code class="language-plaintext highlighter-rouge">[WorkoutData]</code>, and an array’s <code class="language-plaintext highlighter-rouge">==</code> asks each element’s <code class="language-plaintext highlighter-rouge">==</code>. Assign a value that your <code class="language-plaintext highlighter-rouge">==</code> calls equal, and Observation takes the early exit. It stores the new value and notifies nobody.</p>

<p>Read that twice, because it’s not the bug you’d guess. The data isn’t lost. It’s sitting in the backing storage. Any view that re-renders later for some unrelated reason reads it and draws it. That’s the kind of bug that looks intermittent, and it’s completely deterministic.</p>

<p>The footnote gets one level deeper. The macro also generates a <code class="language-plaintext highlighter-rouge">_modify</code> accessor, and that’s what in-place mutation goes through: <code class="language-plaintext highlighter-rouge">workoutsData[index] = merged</code>. <code class="language-plaintext highlighter-rouge">_modify</code> calls the registrar’s <code class="language-plaintext highlighter-rouge">willSet</code> and <code class="language-plaintext highlighter-rouge">didSet</code> unconditionally. No equality check. Whole-value assignment goes through <code class="language-plaintext highlighter-rouge">set</code> and hits the guard: <code class="language-plaintext highlighter-rouge">workoutsData = mergingLoadedEnrichment(into: fetchedWorkouts)</code>, <code class="language-plaintext highlighter-rouge">uiModel.selectedWorkout = finalWorkout</code>.</p>

<p>So some enrichment writes notified and others didn’t, and the difference came down to the shape of the line that did the writing. Two lines that mean the same thing to a reader meant different things to Observation.</p>

<picture>
  <source srcset="/images/blog/2026-09-28-swiftui-equatable-observation-onchange/observable-write-paths.webp" type="image/webp" />
  <img src="/images/blog/2026-09-28-swiftui-equatable-observation-onchange/observable-write-paths.png" alt="Flow diagram of the two write paths @Observable generates. A whole-value assignment goes through set, where shouldNotifyObservers compares old and new: if they differ, withMutation notifies observers; if == says they are equal, the new value is stored and nobody is told. An amber note says that with an id-only ==, every enrichment write through set takes that silent path. An in-place subscript mutation goes through _modify, which calls willSet and didSet with no equality check, so observers are notified on every write" loading="lazy" decoding="async" />
</picture>

<p><code class="language-plaintext highlighter-rouge">onChange(of:)</code> was blind for a simpler reason. It compares old and new with <code class="language-plaintext highlighter-rouge">==</code> too. Any <code class="language-plaintext highlighter-rouge">.onChange(of: uiModel.selectedWorkout)</code> in the app was, in effect, <code class="language-plaintext highlighter-rouge">.onChange(of: uiModel.selectedWorkout?.id)</code> already. Nobody wrote it that way. Nobody had to.</p>

<h2 id="purify--a-hand-written--thats-sufficient-not-exhaustive">Purify — A Hand-Written == That’s Sufficient, Not Exhaustive</h2>

<p>The textbook fix is to delete the hand-written <code class="language-plaintext highlighter-rouge">==</code> and let the compiler synthesize one. That wasn’t available here. <code class="language-plaintext highlighter-rouge">CLLocationCoordinate2D</code> isn’t <code class="language-plaintext highlighter-rouge">Equatable</code>, and a tuple can’t conform to <code class="language-plaintext highlighter-rouge">Equatable</code>, so an optional tuple has no <code class="language-plaintext highlighter-rouge">==</code> to synthesize with. Even if synthesis worked, it would compare two route arrays element by element on every diff, and workout routes are long.</p>

<p>So the new <code class="language-plaintext highlighter-rouge">==</code> is still hand-written. It’s just honest now:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Route arrays compare by count only: this runs on every SwiftUI onChange diff of workoutsData.</span>
<span class="kd">static</span> <span class="kd">func</span> <span class="o">==</span> <span class="p">(</span><span class="nv">lhs</span><span class="p">:</span> <span class="kt">WorkoutData</span><span class="p">,</span> <span class="nv">rhs</span><span class="p">:</span> <span class="kt">WorkoutData</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="n">lhs</span><span class="o">.</span><span class="n">id</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">id</span> <span class="o">&amp;&amp;</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">routeLocations</span><span class="o">.</span><span class="n">count</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">routeLocations</span><span class="o">.</span><span class="n">count</span> <span class="o">&amp;&amp;</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">routeCoordinates</span><span class="o">.</span><span class="n">count</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">routeCoordinates</span><span class="o">.</span><span class="n">count</span> <span class="o">&amp;&amp;</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">cityName</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">cityName</span> <span class="o">&amp;&amp;</span>
        <span class="nf">sameHeartRateSummary</span><span class="p">(</span><span class="n">lhs</span><span class="o">.</span><span class="n">heartRateData</span><span class="p">,</span> <span class="n">rhs</span><span class="o">.</span><span class="n">heartRateData</span><span class="p">)</span> <span class="o">&amp;&amp;</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">cachedElevationStatistics</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">cachedElevationStatistics</span> <span class="o">&amp;&amp;</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">detailedHeartRateData</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">detailedHeartRateData</span> <span class="o">&amp;&amp;</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">detailedPaceData</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">detailedPaceData</span> <span class="o">&amp;&amp;</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">detailedElevationData</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">detailedElevationData</span> <span class="o">&amp;&amp;</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">detailedCadenceData</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">detailedCadenceData</span> <span class="o">&amp;&amp;</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">detailedRunningPowerData</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">detailedRunningPowerData</span> <span class="o">&amp;&amp;</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">detailedVerticalOscillationData</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">detailedVerticalOscillationData</span> <span class="o">&amp;&amp;</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">detailedGAPData</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">detailedGAPData</span> <span class="o">&amp;&amp;</span>
        <span class="n">lhs</span><span class="o">.</span><span class="n">detailedPowerEffortData</span> <span class="o">==</span> <span class="n">rhs</span><span class="o">.</span><span class="n">detailedPowerEffortData</span>
<span class="p">}</span>

<span class="kd">private</span> <span class="kd">static</span> <span class="kd">func</span> <span class="nf">sameHeartRateSummary</span><span class="p">(</span>
    <span class="n">_</span> <span class="nv">lhs</span><span class="p">:</span> <span class="p">(</span><span class="nv">low</span><span class="p">:</span> <span class="kt">Double</span><span class="p">,</span> <span class="nv">avg</span><span class="p">:</span> <span class="kt">Double</span><span class="p">,</span> <span class="nv">high</span><span class="p">:</span> <span class="kt">Double</span><span class="p">)?,</span>
    <span class="n">_</span> <span class="nv">rhs</span><span class="p">:</span> <span class="p">(</span><span class="nv">low</span><span class="p">:</span> <span class="kt">Double</span><span class="p">,</span> <span class="nv">avg</span><span class="p">:</span> <span class="kt">Double</span><span class="p">,</span> <span class="nv">high</span><span class="p">:</span> <span class="kt">Double</span><span class="p">)?</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="p">(</span><span class="n">lhs</span><span class="p">,</span> <span class="n">rhs</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">case</span> <span class="p">(</span><span class="kc">nil</span><span class="p">,</span> <span class="kc">nil</span><span class="p">):</span> <span class="kc">true</span>
    <span class="k">case</span> <span class="p">(</span><span class="k">let</span> <span class="nv">lhs</span><span class="p">?,</span> <span class="k">let</span> <span class="nv">rhs</span><span class="p">?):</span> <span class="n">lhs</span> <span class="o">==</span> <span class="n">rhs</span>
    <span class="k">default</span><span class="p">:</span> <span class="kc">false</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The goal isn’t an exhaustive <code class="language-plaintext highlighter-rouge">==</code>. It’s a sufficient one. <code class="language-plaintext highlighter-rouge">==</code> has to change whenever anything an observer cares about changes. It doesn’t have to prove two values are identical down to the last coordinate.</p>

<p>Route counts work as a change signal because of how routes arrive in this app: whole. A workout has zero points, and then it has all of them. The trade-off is real, though. Count-only is deliberate (the comment says so), and it has a blind spot: if a route were ever replaced by a different one with the same number of points, this <code class="language-plaintext highlighter-rouge">==</code> wouldn’t see it. That’s fine for how the app loads data today. If that ever changes, this is the first place I’d look.</p>

<p>The heat-map series cost almost nothing here, because each <code class="language-plaintext highlighter-rouge">detailed*</code> type already had its own cheap <code class="language-plaintext highlighter-rouge">==</code>. The heart-rate summary needs the little helper because the stdlib compares tuples of <code class="language-plaintext highlighter-rouge">Double</code> just fine, but optional tuples get no <code class="language-plaintext highlighter-rouge">==</code> at all.</p>

<h3 id="the-test-that-guarded-the-bug">The Test That Guarded the Bug</h3>

<p>A hand-written <code class="language-plaintext highlighter-rouge">==</code> over thirteen enrichment fields has an obvious failure mode: someone adds one more and forgets it. So the new test runs once per enrichment field:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Test</span><span class="p">(</span><span class="s">"Each enrichment field on its own makes same-id copies unequal"</span><span class="p">,</span> <span class="nv">arguments</span><span class="p">:</span> <span class="kt">EnrichedField</span><span class="o">.</span><span class="n">allCases</span><span class="p">)</span>
<span class="kd">func</span> <span class="nf">equalitySeesEachEnrichmentField</span><span class="p">(</span><span class="nv">field</span><span class="p">:</span> <span class="kt">EnrichedField</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">basic</span> <span class="o">=</span> <span class="kt">WorkoutFixtures</span><span class="o">.</span><span class="nf">makeWorkoutData</span><span class="p">()</span>

    <span class="cp">#expect(basic != field.applied(to: basic))</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">EnrichedField</code> is a <code class="language-plaintext highlighter-rouge">CaseIterable</code> enum with one case per enrichment field. <code class="language-plaintext highlighter-rouge">applied(to:)</code> returns a copy with only that field set. Add a field to the model and a case to the enum, and the test tells you whether <code class="language-plaintext highlighter-rouge">==</code> noticed.</p>

<p>My favorite diff in the commit is smaller. An existing test had been asserting the bug as a feature:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Should still be equal (equality based on ID only)</span>
<span class="cp">#expect(workoutData1 == workoutData2)</span>
</code></pre></div></div>

<p>It now reads:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Loaded enrichment makes same-id copies distinguishable</span>
<span class="cp">#expect(workoutData1 != workoutData2)</span>
</code></pre></div></div>

<p>The suite wasn’t failing to catch the bug. It was defending it. A green test only tells you the code does what the test says. It doesn’t tell you the test is saying the right thing.</p>

<h2 id="confusion--identity-and-equality-answer-different-questions">Confusion — Identity and Equality Answer Different Questions</h2>

<p>Making <code class="language-plaintext highlighter-rouge">==</code> honest broke every caller that had been using it to ask a different question.</p>

<p>An id-only <code class="language-plaintext highlighter-rouge">==</code> quietly carries a second contract. Callers read <code class="language-plaintext highlighter-rouge">a == b</code> as “is this the same workout?” and for a long time the answer was right. Once <code class="language-plaintext highlighter-rouge">==</code> started meaning “does this look the same?”, every one of those callers changed behavior. The commit lists them.</p>

<p>The iPhone and iPad selection observers start a load when the selection changes. With a strict <code class="language-plaintext highlighter-rouge">==</code>, every enrichment write to the selected copy would look like a new selection, so they’d re-trigger <code class="language-plaintext highlighter-rouge">loadWorkoutData</code>, and on iPad they’d also un-minimize the detail panel. The iPhone version shows the pattern:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// before</span>
<span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">uiModel</span><span class="o">.</span><span class="n">selectedWorkout</span><span class="p">)</span> <span class="p">{</span> <span class="n">_</span><span class="p">,</span> <span class="n">newWorkout</span> <span class="k">in</span>
    <span class="nf">handleWorkoutSelectionChange</span><span class="p">(</span><span class="n">newWorkout</span><span class="p">)</span>
<span class="p">}</span>

<span class="c1">// after</span>
<span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">uiModel</span><span class="o">.</span><span class="n">selectedWorkout</span><span class="p">?</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="p">{</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span> <span class="k">in</span>
    <span class="nf">handleWorkoutSelectionChange</span><span class="p">(</span><span class="n">uiModel</span><span class="o">.</span><span class="n">selectedWorkout</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The trigger is the id. The payload is read fresh from the model. Those are two separate decisions, and the old code had fused them.</p>

<p>The iPad map-centering observer and <code class="language-plaintext highlighter-rouge">WorkoutUIModel</code>’s camera <code class="language-plaintext highlighter-rouge">didSet</code> should recenter only when the selection actually changes:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">selectedWorkout</span><span class="p">:</span> <span class="kt">WorkoutData</span><span class="p">?</span> <span class="p">{</span>
    <span class="k">didSet</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">selectedWorkout</span><span class="p">?</span><span class="o">.</span><span class="n">id</span> <span class="o">!=</span> <span class="n">oldValue</span><span class="p">?</span><span class="o">.</span><span class="n">id</span> <span class="p">{</span>   <span class="c1">// was: selectedWorkout != oldValue</span>
            <span class="nf">updateCameraForWorkoutSelection</span><span class="p">()</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The subtle one was comparison. <code class="language-plaintext highlighter-rouge">ComparisonState</code> keeps a <code class="language-plaintext highlighter-rouge">Set&lt;WorkoutData&gt;</code>. Hashing is still id-only while <code class="language-plaintext highlighter-rouge">==</code> is stricter. That pairing is still legal, because values that compare equal share an id and therefore a hash. But <code class="language-plaintext highlighter-rouge">contains</code> finds the right bucket and then asks <code class="language-plaintext highlighter-rouge">==</code>, so it now needs the same <em>value</em>, not the same workout. A copy of a selected workout that picked up its city name afterward wouldn’t register as selected, and couldn’t be deselected. So membership asks about identity explicitly:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">isSelected</span><span class="p">(</span><span class="n">_</span> <span class="nv">workout</span><span class="p">:</span> <span class="kt">WorkoutData</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="n">selectedWorkouts</span><span class="o">.</span><span class="n">contains</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">id</span> <span class="o">==</span> <span class="n">workout</span><span class="o">.</span><span class="n">id</span> <span class="p">}</span>
<span class="p">}</span>

<span class="kd">private</span> <span class="kd">func</span> <span class="nf">deselect</span><span class="p">(</span><span class="n">_</span> <span class="nv">workout</span><span class="p">:</span> <span class="kt">WorkoutData</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="k">let</span> <span class="nv">selected</span> <span class="o">=</span> <span class="n">selectedWorkouts</span><span class="o">.</span><span class="nf">first</span><span class="p">(</span><span class="nv">where</span><span class="p">:</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">id</span> <span class="o">==</span> <span class="n">workout</span><span class="o">.</span><span class="n">id</span> <span class="p">})</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
    <span class="n">selectedWorkouts</span><span class="o">.</span><span class="nf">remove</span><span class="p">(</span><span class="n">selected</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">deselect</code> removes the element that’s actually stored, found by id, not the argument it was handed. The test for it is named exactly for the case: “A city-enriched copy of a selected workout is recognised as selected and toggles it off.” It selects a workout, builds the same workout with <code class="language-plaintext highlighter-rouge">cityName = "Portland"</code>, checks that <code class="language-plaintext highlighter-rouge">isSelected</code> sees it, and toggles it off.</p>

<p><code class="language-plaintext highlighter-rouge">Identifiable</code> and <code class="language-plaintext highlighter-rouge">id</code> answer “is this the same thing?” <code class="language-plaintext highlighter-rouge">==</code> answers “is this still the same?” When one type answers both with the same function, you can’t fix one answer without breaking every caller that wanted the other. When you make <code class="language-plaintext highlighter-rouge">==</code> honest, go find everyone who was asking about identity and have them ask <code class="language-plaintext highlighter-rouge">.id</code> directly.</p>

<h2 id="shadows-follow--triggers-that-fire-on-every-enrichment-write">Shadows Follow — Triggers That Fire on Every Enrichment Write</h2>

<p>With enrichment visible, two cameras started moving too often. That’s the other direction: not a key too small to notice a change, but a key too big to tell noise from intent.</p>

<p>The all-routes camera re-fits the map around every workout. Its update function did that on every call:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">updateWorkouts</span><span class="p">(</span><span class="n">_</span> <span class="nv">workouts</span><span class="p">:</span> <span class="p">[</span><span class="kt">WorkoutData</span><span class="p">])</span> <span class="p">{</span>
    <span class="n">allWorkouts</span> <span class="o">=</span> <span class="n">workouts</span>
    <span class="k">if</span> <span class="n">showAllRoutes</span> <span class="p">{</span>
        <span class="nf">updateCameraForAllRoutes</span><span class="p">()</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The caller is <code class="language-plaintext highlighter-rouge">.onChange(of: viewModel?.workoutsData)</code> in <code class="language-plaintext highlighter-rouge">MainWorkoutView</code>. Under the id-only <code class="language-plaintext highlighter-rouge">==</code>, that observer never fired on enrichment. Under the honest one, every enrichment merge fires it, so with all routes showing, a city name landing on one workout would re-frame the whole map. The fix asks whether the <em>set of workouts</em> changed, which is the only thing a fit-everything camera cares about:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">updateWorkouts</span><span class="p">(</span><span class="n">_</span> <span class="nv">workouts</span><span class="p">:</span> <span class="p">[</span><span class="kt">WorkoutData</span><span class="p">])</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">membershipChanged</span> <span class="o">=</span> <span class="n">allWorkouts</span><span class="o">.</span><span class="nf">map</span><span class="p">(\</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="o">!=</span> <span class="n">workouts</span><span class="o">.</span><span class="nf">map</span><span class="p">(\</span><span class="o">.</span><span class="n">id</span><span class="p">)</span>
    <span class="n">allWorkouts</span> <span class="o">=</span> <span class="n">workouts</span>
    <span class="k">if</span> <span class="n">showAllRoutes</span> <span class="o">&amp;&amp;</span> <span class="n">membershipChanged</span> <span class="p">{</span>
        <span class="nf">updateCameraForAllRoutes</span><span class="p">()</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The test sets <code class="language-plaintext highlighter-rouge">mapCameraPosition</code> to a sentinel, <code class="language-plaintext highlighter-rouge">.userLocation(fallback: .automatic)</code>, calls <code class="language-plaintext highlighter-rouge">updateWorkouts</code> with enriched copies of the same workouts, and expects the sentinel to still be there. A companion test checks that a real membership change does re-fit. A no-op is only worth something if you’ve also proven the op still happens.</p>

<p>The iPhone camera lives in <code class="language-plaintext highlighter-rouge">SimplifiedEnhancedMapCameraModifier</code>, a <code class="language-plaintext highlighter-rouge">ViewModifier</code>. Here’s the part that makes this whole post hang together:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">workout</span><span class="p">)</span> <span class="p">{</span> <span class="n">oldWorkout</span><span class="p">,</span> <span class="n">newWorkout</span> <span class="k">in</span>
    <span class="k">if</span> <span class="n">oldWorkout</span><span class="p">?</span><span class="o">.</span><span class="n">id</span> <span class="o">!=</span> <span class="n">newWorkout</span><span class="p">?</span><span class="o">.</span><span class="n">id</span> <span class="p">{</span>
        <span class="n">camera</span><span class="o">.</span><span class="nf">resetForNewWorkout</span><span class="p">()</span>
    <span class="p">}</span>
    <span class="nf">updateCamera</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Look at what’s already there. The closure checks <code class="language-plaintext highlighter-rouge">id</code> before resetting. The code already knew identity mattered. But <code class="language-plaintext highlighter-rouge">updateCamera()</code> runs unconditionally, on every change of <code class="language-plaintext highlighter-rouge">workout</code>. Before the <code class="language-plaintext highlighter-rouge">==</code> fix, that was harmless, and only by accident: the id-only <code class="language-plaintext highlighter-rouge">==</code> meant “every change of <code class="language-plaintext highlighter-rouge">workout</code>” was really “every change of selection.” The loose <code class="language-plaintext highlighter-rouge">==</code> had been hiding the broad trigger. The moment <code class="language-plaintext highlighter-rouge">==</code> got honest, every city name, heart-rate summary, and heat-map series became a reason to refocus the camera.</p>

<p>Fixing bug one surfaced bug two.</p>

<p>The camera cares about two things: which workout it’s showing, and whether that workout’s route has arrived. So the key names exactly those:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">focusKey</span><span class="p">)</span> <span class="p">{</span> <span class="n">oldKey</span><span class="p">,</span> <span class="n">newKey</span> <span class="k">in</span>
    <span class="k">if</span> <span class="n">oldKey</span><span class="o">.</span><span class="n">workoutID</span> <span class="o">!=</span> <span class="n">newKey</span><span class="o">.</span><span class="n">workoutID</span> <span class="p">{</span>
        <span class="n">camera</span><span class="o">.</span><span class="nf">resetForNewWorkout</span><span class="p">()</span>
    <span class="p">}</span>
    <span class="nf">updateCamera</span><span class="p">()</span>
<span class="p">}</span>

<span class="kd">private</span> <span class="kd">struct</span> <span class="kt">FocusKey</span><span class="p">:</span> <span class="kt">Equatable</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">workoutID</span><span class="p">:</span> <span class="kt">UUID</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">routePointCount</span><span class="p">:</span> <span class="kt">Int</span>
<span class="p">}</span>

<span class="kd">private</span> <span class="k">var</span> <span class="nv">focusKey</span><span class="p">:</span> <span class="kt">FocusKey</span> <span class="p">{</span>
    <span class="kt">FocusKey</span><span class="p">(</span><span class="nv">workoutID</span><span class="p">:</span> <span class="n">workout</span><span class="p">?</span><span class="o">.</span><span class="n">id</span><span class="p">,</span> <span class="nv">routePointCount</span><span class="p">:</span> <span class="n">workout</span><span class="p">?</span><span class="o">.</span><span class="n">routeCoordinates</span><span class="o">.</span><span class="n">count</span> <span class="p">??</span> <span class="mi">0</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">FocusKey</code> is the same trade <code class="language-plaintext highlighter-rouge">==</code> made, for the same reason: routes arrive whole, so a count is enough to mean “the route showed up.” But now it’s scoped to one observer instead of baked into the model. The view model already had a smaller version of this pattern, <code class="language-plaintext highlighter-rouge">.onChange(of: viewModel.selectedWorkoutRouteCount(for: uiModel.selectedWorkout?.id))</code>, which projects an <code class="language-plaintext highlighter-rouge">Int</code> instead of observing a model. <code class="language-plaintext highlighter-rouge">FocusKey</code> is that idea with a name and a second field.</p>

<picture>
  <source srcset="/images/blog/2026-09-28-swiftui-equatable-observation-onchange/camera-refocus-timeline.webp" type="image/webp" />
  <img src="/images/blog/2026-09-28-swiftui-equatable-observation-onchange/camera-refocus-timeline.png" alt="Timeline of one workout being selected and then enriched: workout selected, route arrives, then city name, heart-rate summary, elevation stats, and four heat-map series. Three rows show what the iPhone camera modifier does at each event. With the id-only ==, it refocuses on selection and never sees any later write. With an honest == and onChange of the whole workout, it refocuses on every write, the seven enrichment writes marked red as noise. With onChange of FocusKey, it refocuses only on selection and route arrival and stays put for the rest" loading="lazy" decoding="async" />
</picture>

<h2 id="all-within-my-hands--key-each-observer-on-the-effect-it-drives">All Within My Hands — Key Each Observer on the Effect It Drives</h2>

<p>Both bugs are the same bug. The observer’s key didn’t match the effect it drives. In the first, the key was too small, so real changes were ignored. In the second, it was too big, so noise looked like intent.</p>

<p>That splits cleanly into two kinds of observers.</p>

<p>Rendering and Observation need a <code class="language-plaintext highlighter-rouge">==</code> that changes whenever anything shown changes. That’s the model’s job. It doesn’t have to be exhaustive, but it has to be sufficient, and with <code class="language-plaintext highlighter-rouge">@Observable</code> there’s no second chance: a write that <code class="language-plaintext highlighter-rouge">==</code> calls equal never notifies anyone.</p>

<p>Side effects should key on the smallest value that means intent changed. That covers <code class="language-plaintext highlighter-rouge">onChange</code>, <code class="language-plaintext highlighter-rouge">task(id:)</code>, <code class="language-plaintext highlighter-rouge">animation(_:value:)</code>, camera moves, and loads. Usually it’s an id, a list of ids, or a small named key struct like <code class="language-plaintext highlighter-rouge">FocusKey</code>. Usually it’s not a model.</p>

<p>When the camera starts jumping, the tempting fix is to loosen the model’s <code class="language-plaintext highlighter-rouge">==</code> until the noise goes away. Don’t. That just recreates bug one. And with Observation it’s worse than it used to be, because the stale data won’t even look stale all the time. It’ll show up on the next unrelated re-render, then refuse to reproduce when you go looking for it. A model’s <code class="language-plaintext highlighter-rouge">==</code> has one job, and that job is being true. When one observer needs a narrower view, give that observer its own key.</p>

<h2 id="the-end-of-the-line--a-pre-ship-checklist">The End of the Line — A Pre-Ship Checklist</h2>

<p>Everything on this list is something these three commits actually touched.</p>

<p>Find every hand-written <code class="language-plaintext highlighter-rouge">==</code> in your models:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">grep</span> <span class="nt">-rn</span> <span class="s1">'static func =='</span> <span class="nt">--include</span><span class="o">=</span><span class="s1">'*.swift'</span> <span class="nb">.</span>
</code></pre></div></div>

<p>For each one, ask whether it changes when anything a view shows changes. An id-only <code class="language-plaintext highlighter-rouge">==</code> on a type that gets mutated after creation is bug one, waiting for a whole-value assignment.</p>

<p>Find every <code class="language-plaintext highlighter-rouge">.onChange(of:)</code> that observes a whole model. Ask what should actually trigger it. If the answer is “a new selection,” key on the id. If the answer is two things, name them in a key struct.</p>

<p>Find every <code class="language-plaintext highlighter-rouge">Set</code> or <code class="language-plaintext highlighter-rouge">contains</code> on a type with a custom <code class="language-plaintext highlighter-rouge">==</code>. If a caller means “the same workout,” it has to say <code class="language-plaintext highlighter-rouge">.id</code>, because once <code class="language-plaintext highlighter-rouge">==</code> is honest, <code class="language-plaintext highlighter-rouge">contains</code> won’t say it for you.</p>

<p>Read your tests for assertions that two differently loaded copies of the same thing are equal. Mine had one, with a comment explaining why that was fine.</p>

<p>The <code class="language-plaintext highlighter-rouge">==</code> I shipped is still hand-written, still compares routes by count, and still has a blind spot I can name. It tells observers the truth about the data they draw. The cameras only move when the thing they point at changes. I’ll take that trade.</p>

<p>Keep shipping.</p>]]></content><author><name>Wesley Matlock</name></author><category term="swiftui" /><category term="swift" /><category term="observation" /><category term="ios" /><category term="equatable" /><category term="mapkit" /><category term="swift-testing" /><category term="workout-wanderer" /><summary type="html"><![CDATA[How an id-only Equatable made @Observable skip SwiftUI updates in Workout Wanderer, and why onChange should key on identity and intent, not whole models.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.wesleymatlock.com/images/blog/2026-09-28-swiftui-equatable-observation-onchange/hero.png" /><media:content medium="image" url="https://www.wesleymatlock.com/images/blog/2026-09-28-swiftui-equatable-observation-onchange/hero.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Through the Never: SwiftUI Layout for the iPhone Duo</title><link href="https://www.wesleymatlock.com/iphone-duo-swiftui-width-driven-layout/" rel="alternate" type="text/html" title="Through the Never: SwiftUI Layout for the iPhone Duo" /><published>2026-09-23T00:00:00+00:00</published><updated>2026-09-23T00:00:00+00:00</updated><id>https://www.wesleymatlock.com/iphone-duo-swiftui-width-driven-layout</id><content type="html" xml:base="https://www.wesleymatlock.com/iphone-duo-swiftui-width-driven-layout/"><![CDATA[<p>My app runs on a phone that folds. I have never held one.</p>

<p>Apple announced the iPhone Duo on September 9 — pre-orders October 16, in stores October 23, $1,999 to get in the door, riding on iOS 27.1. The closest I’ve gotten is the Duo simulator that landed with Xcode 27.1 beta on September 18: a 434-point outer display, a 669-point inner one, and no way to fold between them.</p>

<p>VinylCrate 4.1, the next release, supports Apple’s first foldable anyway. The inner display gets the sidebar layout, the grids know where the hinge is, and the camera follows whichever display you’re looking at. Getting there meant ripping out the SwiftUI layout model I taught on this blog six months ago. So that’s where this starts — with me eating a little crow.</p>

<h2 id="holier-than-thou--correcting-my-march-adaptive-layout-post">Holier Than Thou — Correcting My March Adaptive Layout Post</h2>

<p>In March I wrote <a href="/building-adaptive-swiftui-apps-that-scale/">a post about adaptive SwiftUI layout</a> built around a five-case enum. Trimmed:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">enum</span> <span class="kt">LayoutEnvironment</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">iPhonePortrait</span>
    <span class="k">case</span> <span class="n">iPhoneLandscape</span>
    <span class="k">case</span> <span class="n">iPadFullscreen</span>
    <span class="k">case</span> <span class="n">iPadSplitView</span>
    <span class="k">case</span> <span class="n">iPadSlideOver</span>

    <span class="k">var</span> <span class="nv">isIPad</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="k">switch</span> <span class="k">self</span> <span class="p">{</span>
        <span class="k">case</span> <span class="o">.</span><span class="n">iPadFullscreen</span><span class="p">,</span> <span class="o">.</span><span class="n">iPadSplitView</span><span class="p">,</span> <span class="o">.</span><span class="nv">iPadSlideOver</span><span class="p">:</span> <span class="k">return</span> <span class="kc">true</span>
        <span class="k">default</span><span class="p">:</span> <span class="k">return</span> <span class="kc">false</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That post’s closing section was titled “Design for Context, Not Device.” Read the case names again. Every one of them is a device. I told you to stop asking “is this an iPad?” and then handed you an enum whose most important property was <code class="language-plaintext highlighter-rouge">isIPad</code>.</p>

<p>A reread turns up worse. <code class="language-plaintext highlighter-rouge">.iPadSlideOver</code> is declared, but the initializer in that post never returns it, so nothing ever produced that case. And the checklist at the bottom told you to test Slide Over, “which is narrower than any iPhone,” while the app the post was about was stranding people in Slide Over. More on that in a minute.</p>

<p>And the version I published was the flattering one. The enum actually in the repo skipped the size-class reasoning and went straight to the hardware:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">init</span><span class="p">(</span><span class="nv">horizontalSizeClass</span><span class="p">:</span> <span class="kt">UserInterfaceSizeClass</span><span class="p">?,</span> <span class="nv">verticalSizeClass</span><span class="p">:</span> <span class="kt">UserInterfaceSizeClass</span><span class="p">?)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">isIPad</span> <span class="o">=</span> <span class="kt">UIDevice</span><span class="o">.</span><span class="n">current</span><span class="o">.</span><span class="n">userInterfaceIdiom</span> <span class="o">==</span> <span class="o">.</span><span class="n">pad</span>

    <span class="k">switch</span> <span class="p">(</span><span class="n">isIPad</span><span class="p">,</span> <span class="n">horizontalSizeClass</span><span class="p">,</span> <span class="n">verticalSizeClass</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">case</span> <span class="p">(</span><span class="kc">true</span><span class="p">,</span> <span class="o">.</span><span class="n">regular</span><span class="p">,</span> <span class="o">.</span><span class="n">regular</span><span class="p">):</span>
        <span class="k">self</span> <span class="o">=</span> <span class="o">.</span><span class="n">iPadLandscape</span>
    <span class="k">case</span> <span class="p">(</span><span class="kc">true</span><span class="p">,</span> <span class="o">.</span><span class="n">compact</span><span class="p">,</span> <span class="o">.</span><span class="n">regular</span><span class="p">):</span>
        <span class="c1">// iPad with compact horizontal size class means split view</span>
        <span class="k">self</span> <span class="o">=</span> <span class="o">.</span><span class="n">iPadSplitView</span>
    <span class="k">case</span> <span class="p">(</span><span class="kc">true</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">):</span>
        <span class="k">self</span> <span class="o">=</span> <span class="o">.</span><span class="n">iPadPortrait</span>
    <span class="k">case</span> <span class="p">(</span><span class="kc">false</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="o">.</span><span class="n">compact</span><span class="p">):</span>
        <span class="k">self</span> <span class="o">=</span> <span class="o">.</span><span class="n">iPhoneLandscape</span>
    <span class="k">default</span><span class="p">:</span>
        <span class="k">self</span> <span class="o">=</span> <span class="o">.</span><span class="n">iPhonePortrait</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The instinct in that post was right. Size classes alone are too coarse, and one branch at the root beats forty scattered through the views. The model was wrong, in both the published version and the shipped one. Each answered “what device is this?” with extra steps, and that question stops having an answer once one device can be two sizes. PR #5 deleted the shipped one. If you built something from the published one, consider this the correction.</p>

<h2 id="the-god-that-failed--why-userinterfaceidiom-breaks-on-the-iphone-duo">The God That Failed — Why userInterfaceIdiom Breaks on the iPhone Duo</h2>

<p>Unfolded, the Duo’s inner display is 669 by 951 points and reports regular width <em>and</em> regular height, the same pair a full-screen iPad reports. <code class="language-plaintext highlighter-rouge">userInterfaceIdiom</code> is <code class="language-plaintext highlighter-rouge">.phone</code> on both displays.</p>

<p>VinylCrate’s old classifier, the <code class="language-plaintext highlighter-rouge">init</code> above, asked the idiom first, and its phone branch never looked at the horizontal size class. On the inner display it would have drawn the phone layout: a two-column grid under a bottom tab bar, on a screen as wide as a small iPad, while a fully built <code class="language-plaintext highlighter-rouge">NavigationSplitView</code> sat unused in the same file.</p>

<p>Nothing about that fails loudly — no crash, no log line, and every test keyed to device names stays green. It just draws the wrong app.</p>

<p>The idiom was never a good layout signal, even before the Duo. iPad Slide Over reports <code class="language-plaintext highlighter-rouge">.pad</code> at roughly 320 points. An iPad app on an Apple silicon Mac reports <code class="language-plaintext highlighter-rouge">.pad</code> too. The Duo is just the case you can’t talk your way around.</p>

<p>So the idiom is gone. <code class="language-plaintext highlighter-rouge">userInterfaceIdiom</code>, <code class="language-plaintext highlighter-rouge">isIPad</code>, and <code class="language-plaintext highlighter-rouge">isIPhone</code> don’t appear anywhere in the app target now, and the platform doc says to keep it that way. Every layout question the app asks comes down to three things: the width class, the width of this container, and whether there’s a hinge.</p>

<h2 id="escape--the-ipad-slide-over-dead-end">Escape — The iPad Slide Over Dead End</h2>

<p>Here’s the gut punch: the Duo forced the rewrite, and the rewrite turned up a bug I’d already shipped.</p>

<p>The old model classified iPad Slide Over as <code class="language-plaintext highlighter-rouge">.iPadSplitView</code>, and <code class="language-plaintext highlighter-rouge">isIPad</code> sent every iPad case to the sidebar layout. So a pane about 320 points wide rendered a <code class="language-plaintext highlighter-rouge">NavigationSplitView</code>, and at compact width a split view collapses to its detail column. In that branch the sidebar was the only way to switch tabs, and there was no tab bar. Anyone who slid VinylCrate over was stuck on whichever tab they had open. The only way out was to close the app.</p>

<p>The fix isn’t a Slide Over special case. Nothing in the new code even mentions Slide Over — a narrow pane is compact, compact gets tabs, done.</p>

<p>It wasn’t free, and the changelog says so. Dragging an album onto a crate used to work in Slide Over; now narrow panes get the phone layout and its tap-to-pick crate sheet. I’d make that trade again every time. Still a trade.</p>

<h2 id="fixxxer--column-counts-from-measured-width">Fixxxer — Column Counts From Measured Width</h2>

<p><code class="language-plaintext highlighter-rouge">LayoutEnvironment</code> kept its name. It’s a struct now, trimmed here to the parts that matter:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">nonisolated</span> <span class="kd">struct</span> <span class="kt">LayoutEnvironment</span><span class="p">:</span> <span class="kt">Equatable</span><span class="p">,</span> <span class="kt">Sendable</span> <span class="p">{</span>
    <span class="kd">enum</span> <span class="kt">WidthClass</span><span class="p">:</span> <span class="kt">Equatable</span><span class="p">,</span> <span class="kt">Sendable</span> <span class="p">{</span>
        <span class="k">case</span> <span class="n">compact</span>
        <span class="k">case</span> <span class="n">regular</span>
    <span class="p">}</span>

    <span class="kd">struct</span> <span class="kt">Division</span><span class="p">:</span> <span class="kt">Equatable</span><span class="p">,</span> <span class="kt">Sendable</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">thickness</span><span class="p">:</span> <span class="kt">CGFloat</span>
    <span class="p">}</span>

    <span class="k">let</span> <span class="nv">widthClass</span><span class="p">:</span> <span class="kt">WidthClass</span>
    <span class="k">let</span> <span class="nv">containerWidth</span><span class="p">:</span> <span class="kt">CGFloat</span>
    <span class="k">let</span> <span class="nv">division</span><span class="p">:</span> <span class="kt">Division</span><span class="p">?</span>

    <span class="k">var</span> <span class="nv">usesSidebarNavigation</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="n">widthClass</span> <span class="o">==</span> <span class="o">.</span><span class="n">regular</span>
    <span class="p">}</span>

    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">widthClass</span><span class="p">(</span>
        <span class="nv">horizontal</span><span class="p">:</span> <span class="kt">UserInterfaceSizeClass</span><span class="p">?,</span>
        <span class="nv">vertical</span><span class="p">:</span> <span class="kt">UserInterfaceSizeClass</span><span class="p">?</span>
    <span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">WidthClass</span> <span class="p">{</span>
        <span class="n">horizontal</span> <span class="o">==</span> <span class="o">.</span><span class="n">regular</span> <span class="o">&amp;&amp;</span> <span class="n">vertical</span> <span class="o">!=</span> <span class="o">.</span><span class="n">compact</span> <span class="p">?</span> <span class="o">.</span><span class="nv">regular</span> <span class="p">:</span> <span class="o">.</span><span class="n">compact</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">division</code> is the hinge, and it gets its own section below. <code class="language-plaintext highlighter-rouge">widthClass(horizontal:vertical:)</code> is the one place the root reads size classes, and it adds one rule: regular width with compact height gets demoted to compact. A Max iPhone in landscape is wide enough for a sidebar and too short to open one.</p>

<p>It’s easy to overcorrect here, so to be clear: the root navigation decision still comes from size classes. Size classes were never the problem. A size class is the system telling you how much room you have; <code class="language-plaintext highlighter-rouge">isIPad</code> is you guessing at the room from the hardware. Only one of those stays right when the hardware folds.</p>

<p>Grids are where the numbers come in. Each grid has a role (the collection, the art wall, the insights summary), and each role has a target tile width and a column range. The column count is a pure function of measured width:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">static</span> <span class="kd">func</span> <span class="nf">columnCount</span><span class="p">(</span>
    <span class="k">for</span> <span class="nv">width</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">,</span>
    <span class="nv">metrics</span><span class="p">:</span> <span class="kt">ResponsiveGridMetrics</span><span class="p">,</span>
    <span class="nv">spacing</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">,</span>
    <span class="nv">environment</span><span class="p">:</span> <span class="kt">LayoutEnvironment</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Int</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">range</span> <span class="o">=</span> <span class="n">metrics</span><span class="o">.</span><span class="n">columnRange</span>
    <span class="k">guard</span> <span class="n">width</span><span class="o">.</span><span class="n">isFinite</span><span class="p">,</span> <span class="n">width</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="n">range</span><span class="o">.</span><span class="n">lowerBound</span> <span class="p">}</span>

    <span class="k">let</span> <span class="nv">ideal</span> <span class="o">=</span> <span class="nf">min</span><span class="p">((</span><span class="n">width</span> <span class="o">+</span> <span class="n">spacing</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">metrics</span><span class="o">.</span><span class="n">targetTileWidth</span> <span class="o">+</span> <span class="n">spacing</span><span class="p">),</span> <span class="kt">CGFloat</span><span class="p">(</span><span class="n">range</span><span class="o">.</span><span class="n">upperBound</span><span class="p">))</span>
    <span class="k">let</span> <span class="nv">clamped</span> <span class="o">=</span> <span class="nf">max</span><span class="p">(</span><span class="kt">Int</span><span class="p">(</span><span class="n">ideal</span><span class="o">.</span><span class="nf">rounded</span><span class="p">()),</span> <span class="n">range</span><span class="o">.</span><span class="n">lowerBound</span><span class="p">)</span>
    <span class="k">guard</span> <span class="n">environment</span><span class="o">.</span><span class="n">prefersEvenColumnCount</span><span class="p">,</span> <span class="o">!</span><span class="n">clamped</span><span class="o">.</span><span class="nf">isMultiple</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="mi">2</span><span class="p">)</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="n">clamped</span> <span class="p">}</span>

    <span class="k">if</span> <span class="n">clamped</span> <span class="o">+</span> <span class="mi">1</span> <span class="o">&lt;=</span> <span class="n">range</span><span class="o">.</span><span class="n">upperBound</span> <span class="p">{</span> <span class="k">return</span> <span class="n">clamped</span> <span class="o">+</span> <span class="mi">1</span> <span class="p">}</span>
    <span class="k">if</span> <span class="n">clamped</span> <span class="o">-</span> <span class="mi">1</span> <span class="o">&gt;=</span> <span class="n">range</span><span class="o">.</span><span class="n">lowerBound</span> <span class="p">{</span> <span class="k">return</span> <span class="n">clamped</span> <span class="o">-</span> <span class="mi">1</span> <span class="p">}</span>
    <span class="k">return</span> <span class="n">clamped</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The collection grid aims for 170-point tiles, two to six columns. The art wall aims for 74, four to twelve, with no spacing at all. There’s no device name anywhere in that function. The Duo’s 434-point outer display gets two columns because two 170-point tiles fit in 434 points — nobody had to teach the code what a Duo is. And the same math quietly fixed iPhone landscape: the collection grid used to sit at three columns sideways, and now it fits four or five depending on the phone.</p>

<p>The width a grid measures has to be its own. These grids live in split-view detail panes and inside sheets, which are both much narrower than the window, so <code class="language-plaintext highlighter-rouge">ResponsiveGrid</code> measures itself:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">ResponsiveGrid</span><span class="o">&lt;</span><span class="kt">Content</span><span class="p">:</span> <span class="kt">View</span><span class="o">&gt;</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">role</span><span class="p">:</span> <span class="kt">ResponsiveGridRole</span>
    <span class="kd">@ViewBuilder</span> <span class="k">var</span> <span class="nv">content</span><span class="p">:</span> <span class="p">()</span> <span class="o">-&gt;</span> <span class="kt">Content</span>

    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">LazyVGrid</span><span class="p">(</span><span class="nv">columns</span><span class="p">:</span> <span class="n">configuration</span><span class="o">.</span><span class="n">gridItems</span><span class="p">,</span> <span class="nv">spacing</span><span class="p">:</span> <span class="n">configuration</span><span class="o">.</span><span class="n">spacing</span><span class="p">)</span> <span class="p">{</span>
            <span class="nf">content</span><span class="p">()</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">onGeometryChange</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="o">.</span><span class="k">self</span><span class="p">)</span> <span class="p">{</span> <span class="n">proxy</span> <span class="k">in</span>
            <span class="c1">// Whole points only: this width feeds ~30 views and would otherwise thrash on</span>
            <span class="c1">// every sub-pixel step of an interactive resize.</span>
            <span class="n">proxy</span><span class="o">.</span><span class="n">size</span><span class="o">.</span><span class="n">width</span><span class="o">.</span><span class="nf">rounded</span><span class="p">()</span>
        <span class="p">}</span> <span class="nv">action</span><span class="p">:</span> <span class="p">{</span> <span class="n">width</span> <span class="k">in</span>
            <span class="n">measuredWidth</span> <span class="o">=</span> <span class="n">width</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">@Environment</span><span class="p">(\</span><span class="o">.</span><span class="n">layoutEnvironment</span><span class="p">)</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">layoutEnvironment</span>
    <span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">measuredWidth</span><span class="p">:</span> <span class="kt">CGFloat</span><span class="p">?</span>

    <span class="kd">private</span> <span class="k">var</span> <span class="nv">configuration</span><span class="p">:</span> <span class="kt">ResponsiveGridConfiguration</span> <span class="p">{</span>
        <span class="kt">ResponsiveGridConfiguration</span><span class="o">.</span><span class="nf">resolve</span><span class="p">(</span>
            <span class="nv">role</span><span class="p">:</span> <span class="n">role</span><span class="p">,</span>
            <span class="nv">width</span><span class="p">:</span> <span class="n">measuredWidth</span> <span class="p">??</span> <span class="n">layoutEnvironment</span><span class="o">.</span><span class="n">containerWidth</span><span class="p">,</span>
            <span class="nv">environment</span><span class="p">:</span> <span class="n">layoutEnvironment</span>
        <span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That rounding is load-bearing. <code class="language-plaintext highlighter-rouge">onGeometryChange</code> fires whenever the value changes, and an interactive resize nudges the width by fractions of a point every frame. Round to whole points and none of that churn reaches the thirty-odd views downstream.</p>

<p>It also has a flaw. Until the first geometry callback, the grid seeds from <code class="language-plaintext highlighter-rouge">layoutEnvironment.containerWidth</code>, and that’s the root’s width, not the pane’s.</p>

<p>In the sidebar layout the root measures the whole window while the grid lives in the narrower detail pane, so the first frame can come out one column too many and then snap back. I kept it on purpose. Without the seed, the first frame sits at the column floor. Removing it also means removing the root geometry reader, which is a design change that deserves its own review. For now it’s parked as a documented follow-up. I can live with one frame of wrong.</p>

<p>The root itself is split into two views:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">AdaptiveRootView</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">container</span><span class="p">:</span> <span class="kt">ViewModelContainer</span>
    <span class="k">let</span> <span class="nv">oauthService</span><span class="p">:</span> <span class="kt">OAuthService</span>

    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">AdaptiveRootContent</span><span class="p">(</span><span class="nv">container</span><span class="p">:</span> <span class="n">container</span><span class="p">,</span> <span class="nv">oauthService</span><span class="p">:</span> <span class="n">oauthService</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">withAdaptiveLayout</span><span class="p">()</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// A view cannot read an environment value it sets in the same body — hence the split.</span>
<span class="kd">private</span> <span class="kd">struct</span> <span class="kt">AdaptiveRootContent</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kd">@Environment</span><span class="p">(\</span><span class="o">.</span><span class="n">layoutEnvironment</span><span class="p">)</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">layoutEnvironment</span>

    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="kt">Group</span> <span class="p">{</span>
            <span class="k">if</span> <span class="n">layoutEnvironment</span><span class="o">.</span><span class="n">usesSidebarNavigation</span> <span class="p">{</span>
                <span class="n">sidebarLayout</span>
            <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
                <span class="n">tabBarLayout</span>
            <span class="p">}</span>
        <span class="p">}</span>
        <span class="c1">// The system animates the fold itself; a second cross-fade underneath reads as a glitch.</span>
        <span class="o">.</span><span class="nf">transaction</span><span class="p">(</span><span class="nv">value</span><span class="p">:</span> <span class="n">layoutEnvironment</span><span class="o">.</span><span class="n">usesSidebarNavigation</span><span class="p">)</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">animation</span> <span class="o">=</span> <span class="kc">nil</span> <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The first comment is a SwiftUI rule that bites everybody exactly once: an environment value you set applies to what you wrap, not to the body doing the setting. The second is a judgment call on a transition I’ve reasoned through but never actually watched on hardware. When the phone opens, the system is already animating — cross-fade the tab bar into the sidebar underneath it and you’ve got two animations fighting over one gesture.</p>

<h2 id="broken-beat--scarred--reading-the-hinge-from-reserved-regions">Broken, Beat &amp; Scarred — Reading the Hinge From Reserved Regions</h2>

<p>The inner display has a hinge down the middle, and it doesn’t show up as a safe-area inset. In iOS 27.1, UIKit describes it as a <em>reserved region</em>: <code class="language-plaintext highlighter-rouge">reservedRegions(kind: .division, options:)</code>, called on a view. SwiftUI has no equivalent. That’s the half of the Duo API Xcode 27.1 left out of SwiftUI. The hinge is there — you just have to go through UIKit and a representable to reach it.</p>

<p>VinylCrate’s probe is a <code class="language-plaintext highlighter-rouge">UIView</code> that ignores touches, placed in the root’s <code class="language-plaintext highlighter-rouge">.background</code> by the same modifier that publishes the layout environment:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@available</span><span class="p">(</span><span class="n">iOS</span> <span class="mf">27.1</span><span class="p">,</span> <span class="o">*</span><span class="p">)</span>
<span class="kd">private</span> <span class="kd">final</span> <span class="kd">class</span> <span class="kt">DivisionProbeView</span><span class="p">:</span> <span class="kt">UIView</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">onChange</span><span class="p">:</span> <span class="p">((</span><span class="kt">LayoutEnvironment</span><span class="o">.</span><span class="kt">Division</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Void</span><span class="p">)?</span>

    <span class="c1">// Reserved regions have no change notification, so layout is the only hook.</span>
    <span class="k">override</span> <span class="kd">func</span> <span class="nf">layoutSubviews</span><span class="p">()</span> <span class="p">{</span>
        <span class="k">super</span><span class="o">.</span><span class="nf">layoutSubviews</span><span class="p">()</span>
        <span class="c1">// A windowless or empty pass queries nothing, and a hinge must never be retracted.</span>
        <span class="k">guard</span> <span class="n">window</span> <span class="o">!=</span> <span class="kc">nil</span><span class="p">,</span> <span class="o">!</span><span class="n">bounds</span><span class="o">.</span><span class="n">isEmpty</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

        <span class="k">if</span> <span class="k">let</span> <span class="nv">division</span> <span class="o">=</span> <span class="nf">currentDivision</span><span class="p">()</span> <span class="p">{</span>
            <span class="nf">publish</span><span class="p">(</span><span class="n">division</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="k">var</span> <span class="nv">published</span><span class="p">:</span> <span class="kt">LayoutEnvironment</span><span class="o">.</span><span class="kt">Division</span><span class="p">?</span>

    <span class="c1">// `.includeInactive` makes the hinge a property of the device, not the pose: columns cannot reflow mid-fold.</span>
    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">currentDivision</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="kt">LayoutEnvironment</span><span class="o">.</span><span class="kt">Division</span><span class="p">?</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="k">let</span> <span class="nv">region</span> <span class="o">=</span> <span class="nf">reservedRegions</span><span class="p">(</span><span class="nv">kind</span><span class="p">:</span> <span class="o">.</span><span class="n">division</span><span class="p">,</span> <span class="nv">options</span><span class="p">:</span> <span class="o">.</span><span class="n">includeInactive</span><span class="p">)</span><span class="o">.</span><span class="n">first</span> <span class="k">else</span> <span class="p">{</span>
            <span class="k">return</span> <span class="kc">nil</span>
        <span class="p">}</span>
        <span class="k">return</span> <span class="kt">ReservedRegionSupport</span><span class="o">.</span><span class="nf">division</span><span class="p">(</span><span class="nv">regionFrame</span><span class="p">:</span> <span class="n">region</span><span class="o">.</span><span class="n">frame</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="c1">// Deferred by a hop: this runs inside a UIKit layout pass and the handler writes SwiftUI state.</span>
    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">publish</span><span class="p">(</span><span class="n">_</span> <span class="nv">division</span><span class="p">:</span> <span class="kt">LayoutEnvironment</span><span class="o">.</span><span class="kt">Division</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="n">published</span> <span class="o">!=</span> <span class="n">division</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="n">published</span> <span class="o">=</span> <span class="n">division</span>
        <span class="k">let</span> <span class="nv">handler</span> <span class="o">=</span> <span class="n">onChange</span>
        <span class="kt">Task</span> <span class="p">{</span> <span class="kd">@MainActor</span> <span class="k">in</span> <span class="nf">handler</span><span class="p">?(</span><span class="n">division</span><span class="p">)</span> <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>There are four decisions packed into that little view.</p>

<p><strong><code class="language-plaintext highlighter-rouge">layoutSubviews</code></strong>, because there’s nothing else to hook. Reserved regions have no notification and no trait. If the region changes, the view lays out, and that’s when you ask.</p>

<p><strong><code class="language-plaintext highlighter-rouge">.includeInactive</code></strong> makes the hinge a property of the device instead of the pose. Without it, the region would come and go as the phone opens and closes, and every grid would reflow while the fold was still moving. With it, the app decides the column count once and it holds while the hardware moves.</p>

<p><strong>A hinge is never retracted.</strong> If <code class="language-plaintext highlighter-rouge">currentDivision()</code> returns nil, nothing gets published. Once the app knows there’s a hinge, it stays known — same stability argument, other direction.</p>

<p><strong>The publish is deferred</strong> through a <code class="language-plaintext highlighter-rouge">Task { @MainActor in }</code> hop. This code runs inside a UIKit layout pass, and the handler writes SwiftUI <code class="language-plaintext highlighter-rouge">@State</code>. Writing state from inside someone else’s layout pass is how you end up with re-entrant layout.</p>

<p><code class="language-plaintext highlighter-rouge">ReservedRegionSupport.division(regionFrame:)</code> turns the frame into a thickness. It only counts the minor dimension, because the strip’s orientation follows the pose. It rounds to whole points and caps the result at 24, so a malformed region can’t collapse a grid. It also rejects <code class="language-plaintext highlighter-rouge">CGRect.infinite</code>, which reports <code class="language-plaintext highlighter-rouge">greatestFiniteMagnitude</code> extents that an <code class="language-plaintext highlighter-rouge">isFinite</code> check alone won’t catch. This is the app’s only <code class="language-plaintext highlighter-rouge">#available(iOS 27.1, *)</code> gate in the layout system, and a custom SwiftLint rule, <code class="language-plaintext highlighter-rouge">single_reserved_region_gate</code>, fails the build if <code class="language-plaintext highlighter-rouge">reservedRegions(</code> shows up anywhere else.</p>

<p>What the grids do with the hinge is small on purpose. A hinged display gets an even column count, so the hinge never runs down the middle of an album cover. The gutters also get a bonus equal to the hinge’s thickness. In the Duo’s 621-point inner pane, a 12-point hinge brings the collection grid’s spacing to 32. That works out to about 3.2 columns, which rounds to three and then bumps to four.</p>

<p>The art wall opts out of the gutter bonus:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">case</span> <span class="o">.</span><span class="nv">artWall</span><span class="p">:</span>
    <span class="c1">// Zero spacing is the point: the wall reads as one continuous surface, not a grid</span>
    <span class="c1">// of cards, so the hinge gutter bonus does not apply here.</span>
</code></pre></div></div>

<p>It still gets the even count — just not the wider gutter.</p>

<h2 id="the-judas-kiss--testing-widths-instead-of-device-names">The Judas Kiss — Testing Widths Instead of Device Names</h2>

<p>The old art wall test was <code class="language-plaintext highlighter-rouge">ArtWallGridConfigurationTests</code>, and its expected values were a table of device names:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">(</span><span class="o">.</span><span class="n">iPhonePortrait</span><span class="p">,</span> <span class="mi">5</span><span class="p">),</span> <span class="p">(</span><span class="o">.</span><span class="n">iPhoneLandscape</span><span class="p">,</span> <span class="mi">8</span><span class="p">),</span> <span class="p">(</span><span class="o">.</span><span class="n">iPadPortrait</span><span class="p">,</span> <span class="mi">8</span><span class="p">),</span> <span class="p">(</span><span class="o">.</span><span class="n">iPadLandscape</span><span class="p">,</span> <span class="mi">10</span><span class="p">),</span> <span class="p">(</span><span class="o">.</span><span class="n">iPadSplitView</span><span class="p">,</span> <span class="mi">6</span><span class="p">)</span>
</code></pre></div></div>

<p>It was green, and it had nothing to say about a device it had never heard of. You can’t add the Duo to that table without first deciding which existing case the Duo “is,” and making that decision was the bug. A test keyed to device names can only confirm that the classifier agrees with itself.</p>

<p>The replacement is a Swift Testing suite, <code class="language-plaintext highlighter-rouge">ResponsiveGridConfigurationTests</code>. It keeps a few anchors, because a person should be able to read “this width gets this many columns” and agree. The device names moved into comments, where they describe the width instead of deciding the result:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="nv">anchors</span> <span class="o">=</span> <span class="p">[</span>
    <span class="kt">Anchor</span><span class="p">(</span><span class="nv">width</span><span class="p">:</span> <span class="mi">361</span><span class="p">,</span> <span class="nv">widthClass</span><span class="p">:</span> <span class="o">.</span><span class="n">compact</span><span class="p">,</span> <span class="nv">division</span><span class="p">:</span> <span class="kc">nil</span><span class="p">,</span> <span class="nv">expected</span><span class="p">:</span> <span class="mi">2</span><span class="p">),</span> <span class="c1">// iPhone 17 Pro portrait</span>
    <span class="kt">Anchor</span><span class="p">(</span><span class="nv">width</span><span class="p">:</span> <span class="mi">434</span><span class="p">,</span> <span class="nv">widthClass</span><span class="p">:</span> <span class="o">.</span><span class="n">compact</span><span class="p">,</span> <span class="nv">division</span><span class="p">:</span> <span class="kc">nil</span><span class="p">,</span> <span class="nv">expected</span><span class="p">:</span> <span class="mi">2</span><span class="p">),</span> <span class="c1">// Duo outer display</span>
    <span class="c1">// Duo inner display, detail pane — hinged, so the odd count rounds up to even</span>
    <span class="kt">Anchor</span><span class="p">(</span><span class="nv">width</span><span class="p">:</span> <span class="mi">621</span><span class="p">,</span> <span class="nv">widthClass</span><span class="p">:</span> <span class="o">.</span><span class="n">regular</span><span class="p">,</span> <span class="nv">division</span><span class="p">:</span> <span class="k">Self</span><span class="o">.</span><span class="n">hinge</span><span class="p">,</span> <span class="nv">expected</span><span class="p">:</span> <span class="mi">4</span><span class="p">),</span>
    <span class="kt">Anchor</span><span class="p">(</span><span class="nv">width</span><span class="p">:</span> <span class="mi">506</span><span class="p">,</span> <span class="nv">widthClass</span><span class="p">:</span> <span class="o">.</span><span class="n">regular</span><span class="p">,</span> <span class="nv">division</span><span class="p">:</span> <span class="kc">nil</span><span class="p">,</span> <span class="nv">expected</span><span class="p">:</span> <span class="mi">3</span><span class="p">),</span> <span class="c1">// iPad 11" portrait pane</span>
    <span class="kt">Anchor</span><span class="p">(</span><span class="nv">width</span><span class="p">:</span> <span class="mi">890</span><span class="p">,</span> <span class="nv">widthClass</span><span class="p">:</span> <span class="o">.</span><span class="n">regular</span><span class="p">,</span> <span class="nv">division</span><span class="p">:</span> <span class="kc">nil</span><span class="p">,</span> <span class="nv">expected</span><span class="p">:</span> <span class="mi">5</span><span class="p">)</span> <span class="c1">// iPad 13" landscape pane</span>
<span class="p">]</span>
</code></pre></div></div>

<p>Most of the checking happens in the sweeps. They cover every width from 280 to 1400 points, in both width classes, with and without a hinge, and check properties that have to hold everywhere:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Test</span><span class="p">(</span><span class="s">"a hinged display gets an even column count"</span><span class="p">)</span>
<span class="kd">func</span> <span class="nf">hingedDisplaysGetEvenColumns</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">environment</span> <span class="k">in</span> <span class="k">Self</span><span class="o">.</span><span class="nf">sweep</span><span class="p">(</span><span class="nv">division</span><span class="p">:</span> <span class="k">Self</span><span class="o">.</span><span class="n">hinge</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">for</span> <span class="n">role</span> <span class="k">in</span> <span class="p">[</span><span class="kt">ResponsiveGridRole</span><span class="o">.</span><span class="n">collection</span><span class="p">,</span> <span class="o">.</span><span class="n">artWall</span><span class="p">]</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">config</span> <span class="o">=</span> <span class="k">Self</span><span class="o">.</span><span class="nf">configuration</span><span class="p">(</span><span class="n">role</span><span class="p">,</span> <span class="nv">in</span><span class="p">:</span> <span class="n">environment</span><span class="p">)</span>
            <span class="cp">#expect(config.columns.isMultiple(of: 2), "\(role) at \(environment.containerWidth)pt")</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The other sweeps check that every role stays inside its column range, that the art wall is denser than the card grid at every width, that art wall tiles never drop below the 44-point tap target, and that the wall keeps zero gutters with or without a hinge. None of them names a device, which is exactly why they’ll still mean something when Apple ships the next weird rectangle.</p>

<p>Degenerate widths get their own test, and its doc comment makes the whole case:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">/// `Int(CGFloat.nan)` traps, and a zero-size first layout pass is routine.</span>
<span class="kd">@Test</span><span class="p">(</span><span class="s">"degenerate widths resolve without trapping"</span><span class="p">)</span>
<span class="kd">func</span> <span class="nf">degenerateWidthsAreSafe</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">environment</span> <span class="o">=</span> <span class="kt">LayoutEnvironment</span><span class="p">(</span><span class="nv">widthClass</span><span class="p">:</span> <span class="o">.</span><span class="n">regular</span><span class="p">,</span> <span class="nv">containerWidth</span><span class="p">:</span> <span class="mi">0</span><span class="p">)</span>

    <span class="k">for</span> <span class="n">role</span> <span class="k">in</span> <span class="k">Self</span><span class="o">.</span><span class="n">allRoles</span> <span class="p">{</span>
        <span class="k">for</span> <span class="n">width</span> <span class="k">in</span> <span class="p">[</span><span class="kt">CGFloat</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span> <span class="o">-</span><span class="mi">1</span><span class="p">,</span> <span class="o">-</span><span class="mi">640</span><span class="p">,</span> <span class="o">.</span><span class="n">nan</span><span class="p">,</span> <span class="o">.</span><span class="n">infinity</span><span class="p">,</span> <span class="o">-.</span><span class="n">infinity</span><span class="p">]</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">config</span> <span class="o">=</span> <span class="kt">ResponsiveGridConfiguration</span><span class="o">.</span><span class="nf">resolve</span><span class="p">(</span><span class="nv">role</span><span class="p">:</span> <span class="n">role</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="n">width</span><span class="p">,</span> <span class="nv">environment</span><span class="p">:</span> <span class="n">environment</span><span class="p">)</span>
            <span class="cp">#expect(config.columns == role.metrics.columnRange.lowerBound, "\(role) at width \(width)")</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That’s why <code class="language-plaintext highlighter-rouge">columnCount</code> opens with <code class="language-plaintext highlighter-rouge">guard width.isFinite, width &gt; 0</code>. A grid that measures itself sees zero before it sees anything real, and <code class="language-plaintext highlighter-rouge">Int(ideal.rounded())</code> on a NaN crashes the app. Not a weird layout — a crash.</p>

<p>Two smaller suites cover the rest. <code class="language-plaintext highlighter-rouge">LayoutEnvironmentTests</code> maps each size-class pair to a root navigation choice, and one of those pairs is <code class="language-plaintext highlighter-rouge">(nil, nil)</code>, the state where nothing has been published yet. <code class="language-plaintext highlighter-rouge">ReservedRegionSupportTests</code> feeds the hinge parser book, tabletop, and near-square poses. It also feeds it empty frames, infinite frames, a negative extent, and a region far too thick to be a hinge, and pins down what each one resolves to.</p>

<h2 id="room-of-mirrors--a-camera-that-follows-the-display">Room of Mirrors — A Camera That Follows the Display</h2>

<p>Layout wasn’t the only place where “the device” turned out to be the wrong unit. On a foldable, “back camera” changes meaning when the phone opens. The lens facing away from you on the outer display isn’t the one facing away from you on the inner display.</p>

<p>Dead Wax Scan reads the etchings in a record’s runout groove with the same on-device OCR pipeline as the <a href="/ocr-doesn-t-give-you-text-it-gives-you-a-map/">sleeve scanner</a>, and it has to keep pointing at the record through a fold. iOS 27.1 adds <code class="language-plaintext highlighter-rouge">AVCaptureDeviceDirectionCoordinator</code> for this. You give it the preview’s view, and it reports which cameras face away from the display that view is on:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="n">coordinator</span> <span class="o">=</span> <span class="kt">AVCaptureDeviceDirectionCoordinator</span><span class="p">(</span>
    <span class="nv">view</span><span class="p">:</span> <span class="n">view</span><span class="p">,</span>
    <span class="nv">deviceTypes</span><span class="p">:</span> <span class="kt">CaptureDirection</span><span class="o">.</span><span class="n">preferredDeviceTypes</span><span class="p">,</span>
    <span class="nv">changeHandler</span><span class="p">:</span> <span class="p">{</span> <span class="p">[</span><span class="k">weak</span> <span class="k">self</span><span class="p">]</span> <span class="n">map</span> <span class="k">in</span>
        <span class="k">self</span><span class="p">?</span><span class="o">.</span><span class="nf">handle</span><span class="p">(</span><span class="n">map</span><span class="o">.</span><span class="n">backwardFacingDeviceDescriptors</span><span class="o">.</span><span class="nf">map</span><span class="p">(</span><span class="kt">CaptureDeviceDescription</span><span class="o">.</span><span class="kd">init</span><span class="p">))</span>
    <span class="p">}</span>
<span class="p">)</span>
</code></pre></div></div>

<p>Two details will bite you. First, <code class="language-plaintext highlighter-rouge">deviceDirections</code> is empty until the first callback fires, so nothing can be resolved during setup. Capture starts on a fixed rear camera (dual-wide, then wide-angle) and swaps when a callback names a different one. Second, a swap that can’t complete leaves the previous input in place. <code class="language-plaintext highlighter-rouge">replaceInput</code> reports <code class="language-plaintext highlighter-rouge">.swapped</code>, <code class="language-plaintext highlighter-rouge">.restoredPrevious</code>, or <code class="language-plaintext highlighter-rouge">.lostInput</code>, because a fold is exactly when the old input might stop working too. Only <code class="language-plaintext highlighter-rouge">.lostInput</code> moves the scanner to <code class="language-plaintext highlighter-rouge">.unavailable</code>. A coordinator that isn’t available never means there’s no camera.</p>

<p>My favorite bug of the whole project lived here. The preview view connects to the camera controller through <code class="language-plaintext highlighter-rouge">CameraPreviewHost</code>, and <code class="language-plaintext highlighter-rouge">attachPreviewView(_:)</code> and <code class="language-plaintext highlighter-rouge">detachPreviewView()</code> used to have no-op defaults in a protocol extension. When My Photos got its own in-app camera, its controller inherited those defaults without anyone noticing. It compiled, it ran, and on a real Duo, folding the phone mid-capture would have left that viewfinder pointed at the user. Nobody saw it happen — there was nothing to fold. It surfaced the old-fashioned way, by reading the code.</p>

<p>The fix was deleting the defaults:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@MainActor</span>
<span class="kd">protocol</span> <span class="kt">CameraPreviewHost</span><span class="p">:</span> <span class="kt">AnyObject</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">session</span><span class="p">:</span> <span class="kt">AVCaptureSession</span> <span class="p">{</span> <span class="k">get</span> <span class="p">}</span>
    <span class="kd">func</span> <span class="nf">attachPreviewLayer</span><span class="p">(</span><span class="n">_</span> <span class="nv">layer</span><span class="p">:</span> <span class="kt">AVCaptureVideoPreviewLayer</span><span class="p">)</span>
    <span class="c1">// Deliberately undefaulted: a no-op default silently denies a conformer the fold-aware</span>
    <span class="c1">// camera swap, which is how My Photos shipped pointing at the user after a fold.</span>
    <span class="kd">func</span> <span class="nf">attachPreviewView</span><span class="p">(</span><span class="n">_</span> <span class="nv">view</span><span class="p">:</span> <span class="kt">UIView</span><span class="p">)</span>
    <span class="kd">func</span> <span class="nf">detachPreviewView</span><span class="p">()</span>
    <span class="kd">func</span> <span class="nf">previewLayoutDidChange</span><span class="p">()</span>
    <span class="kd">func</span> <span class="nf">focus</span><span class="p">(</span><span class="n">atDevicePoint</span> <span class="nv">point</span><span class="p">:</span> <span class="kt">CGPoint</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now, leaving out either hook is a build error. A default in a protocol extension is a decision made for every future conformer, and a no-op is the quietest decision there is. Both camera screens now share a <code class="language-plaintext highlighter-rouge">CaptureSessionRunner</code> that owns the session, interruption handling, and the fold-aware swap, so the next fix only has to land in one place.</p>

<p>The outer display gets a job too. While you scan on the inner display, the outer one shows the side being read, the running etching count, and the last line found. It’s built from <code class="language-plaintext highlighter-rouge">CameraCaptureAccessory</code> (27.1) inside <code class="language-plaintext highlighter-rouge">sceneAccessory</code> (27.0), and the gate is 27.1 because the accessory is the only content. A 27.0 gate would register an accessory that draws nothing. It also shows no preview frames on purpose: the scan’s guide ring maps to OCR under one preview gravity, and a second preview surface would split that mapping in two.</p>

<h2 id="dream-no-more--the-code-that-has-never-run">Dream No More — The Code That Has Never Run</h2>

<p>Now the uncomfortable part.</p>

<p>The PR #5 description says it plainly: “All iOS 27.1 paths — reserved regions, direction coordinator, outer-display accessory — have never executed, because no simulator fold control exists.”</p>

<p>The Xcode 27.1 simulator gives you a Duo, but you can’t fold it. So the hinge probe, the direction coordinator, and the outer-display accessory have compiled, passed review, and been reasoned through carefully, and none of them has ever run against a fold. The tests cover everything around them: the thickness parser, the column math, the camera preference order, the swap bookkeeping. Those are the pure parts. The places where the system hands me something are exactly the parts I can’t run.</p>

<p>There’s a reason this post borrowed <em>Through the Never</em> for its title — a chunk of this code lives past the edge of anything I’ve been able to watch run. That’s what “before the device exists” means in practice. I didn’t find an abstraction that makes hardware irrelevant. I have a set of pure functions I trust, wrapped around a few system calls I’ve read the headers for and never seen fire. The repo’s docs have a device QA checklist sitting there, waiting on real hardware.</p>

<p>I’m comfortable shipping it this way because every 27.1 path fails toward behavior that’s already tested. If no hinge is ever published, there’s no even-column rule and no gutter bonus, and you get the same grids that already work on every iPad. If the direction map stays empty, the camera stays on the lens it started with. If the accessory never becomes available, the outer display doesn’t show it. In each case, the worst outcome is the app I’d have shipped without that code.</p>

<p>That’s the rule I’d hand anyone writing code they can’t run: when the untested path fails, make it fail into the tested one.</p>

<h2 id="creeping-death--a-checklist-before-october-23">Creeping Death — A Checklist Before October 23</h2>

<p>You can do all of this today with Xcode 27.1 beta and no hardware.</p>

<p>Find every device question:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">grep</span> <span class="nt">-rnE</span> <span class="s1">'userInterfaceIdiom|isIPad|isIPhone'</span> <span class="nt">--include</span><span class="o">=</span><span class="s1">'*.swift'</span> <span class="nb">.</span>
</code></pre></div></div>

<p>Any hit that feeds a layout decision is a bug on the Duo’s inner display, which reports <code class="language-plaintext highlighter-rouge">.phone</code> and is as wide as an iPad. Replace it with a width class, a measured width, or a hinge check.</p>

<p>Put your iPad app in Slide Over and try to change tabs. If your regular-width branch keys off the idiom, a 320-point pane gets a split view that collapses to its detail column, and your only navigation disappears with the sidebar.</p>

<p>Make grids measure their own container with <code class="language-plaintext highlighter-rouge">onGeometryChange</code>, rounded to whole points. The window’s width is wrong inside a split-view detail pane, and it’s wrong inside a sheet.</p>

<p>Read the hinge from <code class="language-plaintext highlighter-rouge">reservedRegions(kind: .division, options: .includeInactive)</code> behind a single <code class="language-plaintext highlighter-rouge">#available(iOS 27.1, *)</code> gate. Do it in a <code class="language-plaintext highlighter-rouge">UIViewRepresentable</code>, from <code class="language-plaintext highlighter-rouge">layoutSubviews</code>, and publish the result on a deferred hop. Then decide what a hinge means for your grids. An even column count is a good default.</p>

<p>Replace device-named test tables with widths and properties. Keep a few labeled anchors for the humans, sweep everything else, and include NaN and zero.</p>

<p>If your app uses the camera, open a capture flow on the outer display and work out what “back” means once the phone opens. While you’re in there, look for protocol extension defaults on anything the camera depends on.</p>

<p>October 23 is when I find out how much of this I got right. Until then, the app knows more about the Duo than I do — and everything it knows, it learned by measuring.</p>

<p>Keep shipping.</p>]]></content><author><name>Wesley Matlock</name></author><category term="swiftui" /><category term="ios" /><category term="swift" /><category term="layout" /><category term="iphone-duo" /><category term="foldable" /><category term="testing" /><category term="swift-testing" /><category term="adaptive-layout" /><category term="ipad" /><category term="vinylcrate" /><summary type="html"><![CDATA[Why a device-named layout enum breaks on the iPhone Duo, and how VinylCrate moved to width-driven SwiftUI layout, hinge-aware grids, and property tests.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.wesleymatlock.com/images/blog/2026-09-23-iphone-duo-swiftui-width-driven-layout/hero.png" /><media:content medium="image" url="https://www.wesleymatlock.com/images/blog/2026-09-23-iphone-duo-swiftui-width-driven-layout/hero.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Sad but True: Localized App Store Screenshots That Verify Themselves</title><link href="https://www.wesleymatlock.com/localized-app-store-screenshots-xcuitest/" rel="alternate" type="text/html" title="Sad but True: Localized App Store Screenshots That Verify Themselves" /><published>2026-09-04T00:00:00+00:00</published><updated>2026-09-04T00:00:00+00:00</updated><id>https://www.wesleymatlock.com/localized-app-store-screenshots-xcuitest</id><content type="html" xml:base="https://www.wesleymatlock.com/localized-app-store-screenshots-xcuitest/"><![CDATA[<p>I found this one standing in line for coffee, which is not where I do my best QA.</p>

<p>Someone asked what I’d been building, so I pulled up VinylCrate on the App Store — with my region still flipped to Spain from an hour earlier, when I’d been poking at a price tier. The screenshots loaded. Framed, captioned, nicely composed, and every last one of them in English.</p>

<p>The Spanish translation was fine. It had been fine for weeks. What had broken was the screenshot pipeline: it never applied the locale at all, the app quietly fell back to its development region, and the images that came out the other end looked completely correct to anybody who wasn’t reading them.</p>

<p>That’s the failure mode I want to talk about — partly because it’s a nasty one on its own, and partly because it went on to beat me twice more before I was finished, once in a way I’m fairly sure I’d never have caught by looking.</p>

<h2 id="sad-but-true--why-a-wrong-language-screenshot-looks-right">Sad but True — Why a Wrong-Language Screenshot Looks Right</h2>

<p>Here’s the thing about a screenshot that renders in the wrong language: it doesn’t look broken. It looks like a screenshot.</p>

<p>Nothing about it trips a check. No crash, no red diff, no missing-asset placeholder, not even a <code class="language-plaintext highlighter-rouge">Localizable.strings</code> key leaking through as <code class="language-plaintext highlighter-rouge">screenshot.caption.library</code> to give the game away. A wrong-language screenshot is a <em>correct rendering of the wrong thing</em> — every pixel doing its job, nav bar aligned, type fitting the space it was given. The only signal is semantic, which means the only detector is somebody who reads that language, looking at the image at full size, at the exact moment it matters.</p>

<p>Now count them. VinylCrate’s 4.0 set is eight panels, Apple wants a 6.9-inch iPhone set and a 13-inch iPad set, and we ship six languages — <code class="language-plaintext highlighter-rouge">en es fr de ja zh-Hans</code>. That’s 8 × 2 × 6 = 96 images, and App Store Connect hands them back to you as a scrollable strip of thumbnails maybe 90 points wide. You’re not reading kanji at 90 points. You’re definitely not telling Spanish from English at 90 points, because at that size both of them are just “some Latin script in roughly the right place.”</p>

<p>The scripts that <em>are</em> obviously different are the ones you’d catch anyway. Japanese, Simplified Chinese — one glance and you know. It’s German and French and Spanish that slip past, because they share an alphabet with your development region and a decent chunk of their proper nouns. The locales most likely to break silently are the ones you’re least equipped to notice, which is a fun property for a bug to have.</p>

<p>So manual review here isn’t just slow, it’s the wrong instrument. Ninety-six images isn’t too many to look at; I’ve reviewed worse. The problem is that “look at it” can’t answer the question. The question is a <em>comparison</em>: is the German panel byte-identical to the English one? If the answer is ever yes, something upstream lied to you.</p>

<p>That’s trivial for a machine and close to impossible for a person, so I stopped reviewing and wrote the check instead. It’s 146 lines of bash, the interesting part is <code class="language-plaintext highlighter-rouge">shasum -a 256</code>, and it has since caught two real bugs.</p>

<h2 id="master-of-puppets--seeding-an-app-that-cant-drift">Master of Puppets — Seeding an App That Can’t Drift</h2>

<p>A screenshot harness is a determinism problem wearing a UI-testing costume. Everything you’re about to hash has to be a pure function of the inputs, or the hashing is worthless. Two panels that differ because a relative timestamp ticked over are two panels whose hashes tell you nothing.</p>

<p>VinylCrate’s fixture is a <code class="language-plaintext highlighter-rouge">ScreenshotSeed</code> enum that builds an in-memory SwiftData container and populates it:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="kd">func</span> <span class="nf">makeInMemoryContainer</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="kt">ModelContainer</span><span class="p">?</span> <span class="p">{</span>
    <span class="k">do</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">container</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">ModelContainer</span><span class="p">(</span>
            <span class="nv">for</span><span class="p">:</span> <span class="kt">SharedModelContainer</span><span class="o">.</span><span class="n">schema</span><span class="p">,</span>
            <span class="nv">configurations</span><span class="p">:</span> <span class="p">[</span><span class="kt">ModelConfiguration</span><span class="p">(</span>
                <span class="nv">schema</span><span class="p">:</span> <span class="kt">SharedModelContainer</span><span class="o">.</span><span class="n">schema</span><span class="p">,</span>
                <span class="nv">isStoredInMemoryOnly</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
                <span class="nv">allowsSave</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
                <span class="nv">cloudKitDatabase</span><span class="p">:</span> <span class="o">.</span><span class="k">none</span>
            <span class="p">)]</span>
        <span class="p">)</span>
        <span class="k">let</span> <span class="nv">context</span> <span class="o">=</span> <span class="kt">ModelContext</span><span class="p">(</span><span class="n">container</span><span class="p">)</span>
        <span class="k">try</span> <span class="nf">populate</span><span class="p">(</span><span class="n">context</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">container</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
        <span class="n">logger</span><span class="o">.</span><span class="nf">error</span><span class="p">(</span><span class="s">"❌ Screenshot seed container failed: </span><span class="se">\(</span><span class="n">error</span><span class="o">.</span><span class="n">localizedDescription</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
        <span class="nf">assertionFailure</span><span class="p">(</span><span class="s">"Screenshot seed container failed: </span><span class="se">\(</span><span class="n">error</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
        <span class="k">return</span> <span class="kc">nil</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">isStoredInMemoryOnly: true</code> and <code class="language-plaintext highlighter-rouge">cloudKitDatabase: .none</code> are doing the real work: no store on disk to carry state between runs, and no sync to race with. The seeded library is twelve records (five Metallica, seven Pearl Jam) with fixed prices, fixed favorites, and fixed artwork.</p>

<p>The clock is frozen by construction rather than injected:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="k">let</span> <span class="nv">referenceDate</span> <span class="o">=</span> <span class="kt">Date</span><span class="p">(</span><span class="nv">timeIntervalSince1970</span><span class="p">:</span> <span class="mi">1_772_500_000</span><span class="p">)</span>
</code></pre></div></div>

<p>Every seeded row hangs off that constant. Play events land at <code class="language-plaintext highlighter-rouge">referenceDate.addingTimeInterval(-hoursBack * 3600)</code>, copy photos at <code class="language-plaintext highlighter-rouge">-Double(3 - index) * 3600</code>, the dead wax scan three days back. Anything that renders “2 days ago” renders it the same way every run, because the data is anchored, not the formatter. Photo IDs come from <code class="language-plaintext highlighter-rouge">DeterministicUUID.make(namespace:name:)</code> for the same reason.</p>

<p>Two more pieces close the loop. The seed is <code class="language-plaintext highlighter-rouge">#if DEBUG</code>-gated so it cannot possibly activate in a release build:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="kd">func</span> <span class="nf">isActive</span><span class="p">(</span><span class="k">in</span> <span class="nv">arguments</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="o">=</span> <span class="kt">ProcessInfo</span><span class="o">.</span><span class="n">processInfo</span><span class="o">.</span><span class="n">arguments</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="cp">#if DEBUG</span>
    <span class="n">arguments</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="n">launchArgument</span><span class="p">)</span>
    <span class="cp">#else</span>
    <span class="kc">false</span>
    <span class="cp">#endif</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And one network call gets stubbed, because Discogs’ <code class="language-plaintext highlighter-rouge">/collection/value</code> needs a real OAuth token the seeded launch never obtains and would otherwise 401 into an empty Insights panel:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="k">let</span> <span class="nv">stubCollectionValueTransport</span><span class="p">:</span> <span class="kt">DiscogsClient</span><span class="o">.</span><span class="kt">Transport</span> <span class="o">=</span> <span class="p">{</span> <span class="n">request</span> <span class="k">in</span>
    <span class="k">guard</span>
        <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="n">request</span><span class="o">.</span><span class="n">url</span><span class="p">,</span>
        <span class="n">url</span><span class="o">.</span><span class="n">path</span><span class="o">.</span><span class="nf">hasSuffix</span><span class="p">(</span><span class="s">"/collection/value"</span><span class="p">),</span>
        <span class="k">let</span> <span class="nv">response</span> <span class="o">=</span> <span class="kt">HTTPURLResponse</span><span class="p">(</span><span class="nv">url</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="nv">statusCode</span><span class="p">:</span> <span class="mi">200</span><span class="p">,</span> <span class="nv">httpVersion</span><span class="p">:</span> <span class="kc">nil</span><span class="p">,</span> <span class="nv">headerFields</span><span class="p">:</span> <span class="kc">nil</span><span class="p">)</span>
    <span class="k">else</span> <span class="p">{</span>
        <span class="k">return</span> <span class="k">try</span> <span class="k">await</span> <span class="kt">URLSession</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">request</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="nf">return</span> <span class="p">(</span><span class="n">stubCollectionValueBody</span><span class="p">,</span> <span class="n">response</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The stubbed figure is deliberately a little above the seed’s <code class="language-plaintext highlighter-rouge">$310.45</code> total invested, so the panel reads as plausible appreciation instead of an arbitrary number. Marketing screenshots have to survive a skeptical reader, not just a hash.</p>

<p>Then there’s the part I did not predict. The launch is <code class="language-plaintext highlighter-rouge">--seed-screenshots --disable-tips</code>, and even with an in-memory store and <a href="/tipkit-deep-dive-shipping-app/">TipKit silenced</a>, one piece of state kept leaking across runs: <code class="language-plaintext highlighter-rouge">@AppStorage("importPromptState")</code>. It lives in user defaults, which the in-memory container knows nothing about, and it would present the import sheet on top of a capture in a reused simulator.</p>

<p>The fix is a bigger hammer than I wanted and I’ve made peace with it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>xcrun simctl shutdown <span class="s2">"</span><span class="nv">$device</span><span class="s2">"</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true
</span>xcrun simctl erase <span class="s2">"</span><span class="nv">$device</span><span class="s2">"</span>
xcrun simctl boot <span class="s2">"</span><span class="nv">$device</span><span class="s2">"</span>
xcrun simctl status_bar <span class="s2">"</span><span class="nv">$device</span><span class="s2">"</span> override <span class="se">\</span>
  <span class="nt">--time</span> <span class="s2">"9:41"</span> <span class="nt">--batteryState</span> charged <span class="nt">--batteryLevel</span> 100 <span class="se">\</span>
  <span class="nt">--cellularBars</span> 4 <span class="nt">--wifiBars</span> 3 <span class="nt">--dataNetwork</span> wifi
</code></pre></div></div>

<p>Erase, boot, pin the status bar, twelve times per full run. It costs real minutes and I’d rather spend them than debug a capture that inherited something from the run before it. The status bar override does double duty here. App Store screenshots are supposed to look that way, and a clock left running would change every hash on every run.</p>

<h2 id="wherever-i-may-roam--why--testlanguage-fails-and-simctl-doesnt">Wherever I May Roam — Why <code class="language-plaintext highlighter-rouge">-testLanguage</code> Fails and <code class="language-plaintext highlighter-rouge">simctl</code> Doesn’t</h2>

<p>Now the part that started all of this.</p>

<p><code class="language-plaintext highlighter-rouge">xcodebuild</code> has a <code class="language-plaintext highlighter-rouge">-testLanguage</code> flag. It’s right there in the man pages, it’s the obvious tool for the job, and on the Xcode 27 toolchain this project is pinned to (27A5252f), it does not reliably reach the app under test. Read the promise closely: it “overrides the setting for the test action of a scheme in a workspace.” That’s a claim about the scheme’s test action, not about the process your UI test actually launches, and the gap between those two is where six languages go missing. I confirmed it the boring way: ran the capture with <code class="language-plaintext highlighter-rouge">-testLanguage ja</code> and got identical English UI out the other end. No warning, no error, exit 0. I haven’t filed feedback on it yet, which I should.</p>

<p>Which is the whole problem in miniature: <a href="/four-ways-xcodebuild-lied-to-me/">a flag that silently doesn’t apply</a>, sitting in a pipeline whose output is images nobody reads closely.</p>

<p>What actually works is setting the simulator’s own global domain before the test launches the app:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>xcrun simctl spawn <span class="s2">"</span><span class="nv">$device</span><span class="s2">"</span> defaults write <span class="nt">-g</span> AppleLanguages <span class="nt">-array</span> <span class="s2">"</span><span class="nv">$lang</span><span class="s2">"</span>
xcrun simctl spawn <span class="s2">"</span><span class="nv">$device</span><span class="s2">"</span> defaults write <span class="nt">-g</span> AppleLocale <span class="nt">-string</span> <span class="s2">"</span><span class="si">$(</span>locale_for <span class="s2">"</span><span class="nv">$lang</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">-array "$lang"</code> matters. <code class="language-plaintext highlighter-rouge">AppleLanguages</code> is an array; write a bare string into that slot and it isn’t an error, it’s just a value the localization machinery ignores while the bundle quietly resolves to <code class="language-plaintext highlighter-rouge">CFBundleDevelopmentRegion</code>. This is the same trap as the old <code class="language-plaintext highlighter-rouge">-AppleLanguages es</code> versus <code class="language-plaintext highlighter-rouge">-AppleLanguages "(es)"</code> parenthesis gotcha, wearing different clothes.</p>

<p>The language-to-locale mapping is a plain <code class="language-plaintext highlighter-rouge">case</code>, and it’s explicit on purpose:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>locale_for<span class="o">()</span> <span class="o">{</span>
  <span class="k">case</span> <span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span> <span class="k">in
    </span>en<span class="p">)</span> <span class="nb">echo</span> <span class="s2">"en_US"</span> <span class="p">;;</span>
    es<span class="p">)</span> <span class="nb">echo</span> <span class="s2">"es_ES"</span> <span class="p">;;</span>
    fr<span class="p">)</span> <span class="nb">echo</span> <span class="s2">"fr_FR"</span> <span class="p">;;</span>
    de<span class="p">)</span> <span class="nb">echo</span> <span class="s2">"de_DE"</span> <span class="p">;;</span>
    ja<span class="p">)</span> <span class="nb">echo</span> <span class="s2">"ja_JP"</span> <span class="p">;;</span>
    zh-Hans<span class="p">)</span> <span class="nb">echo</span> <span class="s2">"zh_Hans_CN"</span> <span class="p">;;</span>
    <span class="k">*</span><span class="p">)</span> <span class="nb">echo</span> <span class="s2">"en_US"</span> <span class="p">;;</span>
  <span class="k">esac</span>
<span class="o">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">zh-Hans</code> is the structural outlier, the only one using a script subtag, which is why it’s written out by hand rather than derived. A clever <code class="language-plaintext highlighter-rouge">"${lang}_$(echo "$lang" | tr '[:lower:]' '[:upper:]')"</code> would produce <code class="language-plaintext highlighter-rouge">zh-Hans_ZH-HANS</code>, and you’d find out from a screenshot.</p>

<p>I still pass <code class="language-plaintext highlighter-rouge">-testLanguage "$lang"</code> to <code class="language-plaintext highlighter-rouge">xcodebuild</code> alongside the <code class="language-plaintext highlighter-rouge">defaults write</code>. Belt and suspenders: it costs nothing, and if a future toolchain fixes the flag, the two mechanisms agree rather than fight.</p>

<p>One more trap, and it’s pure paperwork that will waste an afternoon: this project’s build default is <strong>iPhone 17 Pro</strong>, which is 6.3 inches, which is the wrong size. App Store Connect wants 6.9-inch, 1320 × 2868, and the iPad Pro 13-inch at 2064 × 2752. Apple scales every other size from those two, so the whole matrix is two devices, and both have to be named explicitly:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">DEVICES</span><span class="o">=(</span><span class="s2">"iPhone 17 Pro Max"</span> <span class="s2">"iPad Pro 13-inch (M5)"</span><span class="o">)</span>
</code></pre></div></div>

<p>Get that wrong and every image is beautiful, localized, correct, and rejected on upload.</p>

<h2 id="seek--destroy--catching-duplicate-screenshots-with-shasum">Seek &amp; Destroy — Catching Duplicate Screenshots with <code class="language-plaintext highlighter-rouge">shasum</code></h2>

<p>Everything up to here is plumbing. This is the part that actually fixed my problem.</p>

<p>The assertion is one sentence: no two languages may produce a byte-identical panel. Write it down that way and it stops being something a human has to notice and becomes something the script enforces.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>assert_language_hashes_distinct<span class="o">()</span> <span class="o">{</span>
  <span class="nb">local </span><span class="nv">slug</span><span class="o">=</span><span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span>
  <span class="nb">local </span>panel hashfile dup dup_langs lang f
  <span class="k">for </span>panel <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="nv">PANELS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
    </span><span class="nv">hashfile</span><span class="o">=</span><span class="si">$(</span><span class="nb">mktemp</span><span class="si">)</span>
    <span class="k">for </span>lang <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="nv">LANGS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
      </span><span class="nv">f</span><span class="o">=</span><span class="s2">"</span><span class="nv">$OUT</span><span class="s2">/</span><span class="nv">$slug</span><span class="s2">/</span><span class="nv">$lang</span><span class="s2">/panel-</span><span class="nv">$panel</span><span class="s2">.png"</span>
      shasum <span class="nt">-a</span> 256 <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span> | <span class="nb">awk</span> <span class="nt">-v</span> <span class="nv">lang</span><span class="o">=</span><span class="s2">"</span><span class="nv">$lang</span><span class="s2">"</span> <span class="s1">'{print $1, lang}'</span> <span class="o">&gt;&gt;</span> <span class="s2">"</span><span class="nv">$hashfile</span><span class="s2">"</span>
    <span class="k">done
    </span><span class="nv">dup</span><span class="o">=</span><span class="si">$(</span><span class="nb">awk</span> <span class="s1">'{print $1}'</span> <span class="s2">"</span><span class="nv">$hashfile</span><span class="s2">"</span> | <span class="nb">sort</span> | <span class="nb">uniq</span> <span class="nt">-d</span> | <span class="nb">head</span> <span class="nt">-1</span><span class="si">)</span>
    <span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$dup</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
      </span><span class="nv">dup_langs</span><span class="o">=</span><span class="si">$(</span><span class="nb">awk</span> <span class="nt">-v</span> <span class="nv">h</span><span class="o">=</span><span class="s2">"</span><span class="nv">$dup</span><span class="s2">"</span> <span class="s1">'$1==h {print $2}'</span> <span class="s2">"</span><span class="nv">$hashfile</span><span class="s2">"</span> | <span class="nb">tr</span> <span class="s1">'\n'</span> <span class="s1">' '</span><span class="si">)</span>
      <span class="nb">echo</span> <span class="s2">"FAIL </span><span class="nv">$slug</span><span class="s2"> panel-</span><span class="nv">$panel</span><span class="s2">: byte-identical across languages: </span><span class="nv">$dup_langs</span><span class="s2"> (hash </span><span class="nv">$dup</span><span class="s2">)"</span> <span class="o">&gt;</span>&amp;2
      <span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$hashfile</span><span class="s2">"</span>
      <span class="nb">exit </span>1
    <span class="k">fi
    </span><span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$hashfile</span><span class="s2">"</span>
  <span class="k">done</span>
<span class="o">}</span>
</code></pre></div></div>

<p>That’s a temp file and <code class="language-plaintext highlighter-rouge">sort | uniq -d</code> rather than an associative array, because the system bash this script’s shebang resolves to is 3.2.57 and predates <code class="language-plaintext highlighter-rouge">declare -A</code> — still what <code class="language-plaintext highlighter-rouge">/bin/bash</code> is on macOS, still going to be true next year. Inelegant, but it runs everywhere without me installing anything.</p>

<p>Exact bytes is the right gate here, and the reason matters, because perceptual hashing is the reflexive answer and it’s wrong for this. These images get captured minutes apart, on the same machine, from the same build, with the status bar pinned. There’s no antialiasing drift to absorb, nothing cross-toolchain, no golden image checked in last spring that I’m hoping still matches. In that setting SHA-256 has zero false positives: if two renders are byte-identical they <em>are</em> the same image. A perceptual hash would only buy me a threshold to tune and a new way to be wrong.</p>

<p>So that guard shipped, went green, and was still lying to me.</p>

<p>It compares the same panel number across languages, which makes it structurally blind to a different failure: the wrong <em>screen</em> captured within one language. Which is exactly what was happening. <code class="language-plaintext highlighter-rouge">testPanel06MyPhotosGallery</code> was landing on the Dead Wax section instead of the My Photos gallery, producing an image byte-identical to panel-02 — on iPhone, in every language. Every language had the same wrong screen, so the cross-language guard saw six distinct-per-panel hashes and reported success.</p>

<p>The generalization is four lines of different bookkeeping:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>assert_panels_pairwise_distinct<span class="o">()</span> <span class="o">{</span>
  <span class="nb">local </span><span class="nv">dest</span><span class="o">=</span><span class="s2">"</span><span class="nv">$1</span><span class="s2">"</span>
  <span class="nb">local </span>panel hashfile dup dup_panels f
  <span class="nv">hashfile</span><span class="o">=</span><span class="si">$(</span><span class="nb">mktemp</span><span class="si">)</span>
  <span class="k">for </span>panel <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="nv">PANELS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
    </span><span class="nv">f</span><span class="o">=</span><span class="s2">"</span><span class="nv">$dest</span><span class="s2">/panel-</span><span class="nv">$panel</span><span class="s2">.png"</span>
    shasum <span class="nt">-a</span> 256 <span class="s2">"</span><span class="nv">$f</span><span class="s2">"</span> | <span class="nb">awk</span> <span class="nt">-v</span> <span class="nv">panel</span><span class="o">=</span><span class="s2">"</span><span class="nv">$panel</span><span class="s2">"</span> <span class="s1">'{print $1, panel}'</span> <span class="o">&gt;&gt;</span> <span class="s2">"</span><span class="nv">$hashfile</span><span class="s2">"</span>
  <span class="k">done
  </span><span class="nv">dup</span><span class="o">=</span><span class="si">$(</span><span class="nb">awk</span> <span class="s1">'{print $1}'</span> <span class="s2">"</span><span class="nv">$hashfile</span><span class="s2">"</span> | <span class="nb">sort</span> | <span class="nb">uniq</span> <span class="nt">-d</span> | <span class="nb">head</span> <span class="nt">-1</span><span class="si">)</span>
  <span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$dup</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nv">dup_panels</span><span class="o">=</span><span class="si">$(</span><span class="nb">awk</span> <span class="nt">-v</span> <span class="nv">h</span><span class="o">=</span><span class="s2">"</span><span class="nv">$dup</span><span class="s2">"</span> <span class="s1">'$1==h {print $2}'</span> <span class="s2">"</span><span class="nv">$hashfile</span><span class="s2">"</span> | <span class="nb">tr</span> <span class="s1">'\n'</span> <span class="s1">' '</span><span class="si">)</span>
    <span class="nb">echo</span> <span class="s2">"FAIL </span><span class="nv">$dest</span><span class="s2">: panels byte-identical: </span><span class="nv">$dup_panels</span><span class="s2"> (hash </span><span class="nv">$dup</span><span class="s2">)"</span> <span class="o">&gt;</span>&amp;2
    <span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$hashfile</span><span class="s2">"</span>
    <span class="nb">exit </span>1
  <span class="k">fi
  </span><span class="nb">rm</span> <span class="nt">-f</span> <span class="s2">"</span><span class="nv">$hashfile</span><span class="s2">"</span>
<span class="o">}</span>
</code></pre></div></div>

<p>It’s the same primitive rotated ninety degrees. One hashes a panel across languages, the other hashes a language’s panels against each other, and the failures they catch don’t overlap at all. Neither one subsumes the other, which took me an embarrassing minute to be sure of.</p>

<p>When you write a duplicate check, ask what axis it’s blind to. A guard that compares along one dimension of a matrix will sail straight past a bug that varies along the other, and it’ll print <code class="language-plaintext highlighter-rouge">ok</code> while it does.</p>

<h2 id="the-thing-that-should-not-be--guards-that-test-themselves">The Thing That Should Not Be — Guards That Test Themselves</h2>

<p>A guard that has never fired is a guard you’re trusting on faith. Mine were written to catch a bug that was already fixed, so a normal run proves nothing about them — a <code class="language-plaintext highlighter-rouge">for</code> loop with a typo in the path would also sail through without failing. It’s the same discipline as <a href="/testing-on-device-ai-swift-testing/">writing a test that fails first</a>, just applied to a shell script instead.</p>

<p>So both guards have a dispatch mode whose entire job is to fail:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="o">[</span> <span class="s2">"</span><span class="k">${</span><span class="nv">1</span><span class="k">:-}</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"--verify-hash-guard"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span>assert_language_hashes_distinct <span class="s2">"</span><span class="nv">$2</span><span class="s2">"</span>
  <span class="nb">echo</span> <span class="s2">"guard did not fire (unexpected)"</span>
  <span class="nb">exit </span>1
<span class="k">fi

if</span> <span class="o">[</span> <span class="s2">"</span><span class="k">${</span><span class="nv">1</span><span class="k">:-}</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"--verify-panel-guard"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span>assert_panels_pairwise_distinct <span class="s2">"</span><span class="nv">$2</span><span class="s2">"</span>
  <span class="nb">echo</span> <span class="s2">"guard did not fire (unexpected)"</span>
  <span class="nb">exit </span>1
<span class="k">fi</span>
</code></pre></div></div>

<p>Follow the control flow there. If the guard fires it prints <code class="language-plaintext highlighter-rouge">FAIL</code> and exits 1 from inside the function, so the <code class="language-plaintext highlighter-rouge">echo</code> never runs. If the guard <em>doesn’t</em> fire, control falls through to a line that says so and exits 1 anyway. Nothing in either branch can exit 0. You point it at deliberately corrupted input and you get a failure either way — the only question is which message comes back, and that’s the whole design.</p>

<picture>
  <source srcset="/images/blog/2026-09-04-localized-app-store-screenshots-xcuitest/verify-hash-guard.webp" type="image/webp" />
  <img src="/images/blog/2026-09-04-localized-app-store-screenshots-xcuitest/verify-hash-guard.png" alt="Terminal session: a clean capture run printing three green ok lines, then an en panel copied over its ja counterpart, then --verify-hash-guard printing a red FAIL naming languages en and ja with a truncated SHA-256, and echo $? returning 1" loading="lazy" decoding="async" />
</picture>

<p>Verifying the language guard meant copying an <code class="language-plaintext highlighter-rouge">en</code> panel over its <code class="language-plaintext highlighter-rouge">ja</code> counterpart, running <code class="language-plaintext highlighter-rouge">--verify-hash-guard</code>, checking that the FAIL line named both languages, and putting the file back. The panel guard I got to verify twice: once by duplicating panel-04 over panel-07 in a known-clean capture, and once against the real pre-fix iPhone/<code class="language-plaintext highlighter-rouge">en</code> capture that was still sitting on disk, which correctly flagged panels 02 and 06.</p>

<p>That last one was luck, and it’s worth stealing as a habit. The bad output hadn’t been cleaned up yet, so I could test the guard against the actual failure it was written for instead of a synthetic imitation of it. Next time you fix a capture bug, hang onto the broken files until the guard that’s supposed to catch them has been run against them.</p>

<h2 id="whiplash--xcuitest-exists-and-what-ishittable-fixes">Whiplash — XCUITest, <code class="language-plaintext highlighter-rouge">.exists</code>, and What <code class="language-plaintext highlighter-rouge">.isHittable</code> Fixes</h2>

<p>The XCUITest side is five methods, one per panel, and there is nothing clever in any of them. Launch seeded, tap along stable identifiers, assert the thing you came for, attach:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">func</span> <span class="nf">launchSeeded</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="kt">XCUIApplication</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">app</span> <span class="o">=</span> <span class="kt">XCUIApplication</span><span class="p">()</span>
    <span class="n">app</span><span class="o">.</span><span class="n">launchArguments</span> <span class="o">+=</span> <span class="p">[</span><span class="s">"--seed-screenshots"</span><span class="p">,</span> <span class="s">"--disable-tips"</span><span class="p">]</span>
    <span class="n">app</span><span class="o">.</span><span class="nf">launch</span><span class="p">()</span>
    <span class="k">return</span> <span class="n">app</span>
<span class="p">}</span>

<span class="kd">private</span> <span class="kd">func</span> <span class="nf">capture</span><span class="p">(</span><span class="n">_</span> <span class="nv">app</span><span class="p">:</span> <span class="kt">XCUIApplication</span><span class="p">,</span> <span class="k">as</span> <span class="nv">name</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">attachment</span> <span class="o">=</span> <span class="kt">XCTAttachment</span><span class="p">(</span><span class="nv">screenshot</span><span class="p">:</span> <span class="n">app</span><span class="o">.</span><span class="n">windows</span><span class="o">.</span><span class="n">firstMatch</span><span class="o">.</span><span class="nf">screenshot</span><span class="p">())</span>
    <span class="n">attachment</span><span class="o">.</span><span class="n">name</span> <span class="o">=</span> <span class="n">name</span>
    <span class="n">attachment</span><span class="o">.</span><span class="n">lifetime</span> <span class="o">=</span> <span class="o">.</span><span class="n">keepAlways</span>
    <span class="nf">add</span><span class="p">(</span><span class="n">attachment</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Every tap is on an accessibility identifier, never a localized string. Obvious in hindsight; not obvious the first time you write <code class="language-plaintext highlighter-rouge">app.buttons["Settings"].tap()</code> and watch it pass in exactly one of six runs.</p>

<p>Three things cost me real time.</p>

<p><strong><code class="language-plaintext highlighter-rouge">.exists</code> does not imply visible.</strong> This was half the panel-06 bug. SwiftUI keeps off-screen views resident in a plain <code class="language-plaintext highlighter-rouge">VStack</code> inside a <code class="language-plaintext highlighter-rouge">ScrollView</code>, so a section scrolled well past the top still reports <code class="language-plaintext highlighter-rouge">exists == true</code>. The assertion passed while the camera was pointed somewhere else entirely. Assert <code class="language-plaintext highlighter-rouge">.isHittable</code> when what you mean is “on screen and photographable.”</p>

<p><strong>An accessibility identifier on a container can leak, and override.</strong> <code class="language-plaintext highlighter-rouge">CopyPhotosSection</code> had <code class="language-plaintext highlighter-rouge">.accessibilityIdentifier("copyPhotos.section")</code> on its outer <code class="language-plaintext highlighter-rouge">VStack</code>. Because that stack contains interactive buttons, SwiftUI doesn’t collapse it into a single accessibility element; the identifier landed on five separate descendants instead, and (confirmed by direct device inspection) silently overrode a descendant’s own explicit identifier. <code class="language-plaintext highlighter-rouge">app.descendants(...)["copyPhotos.section"]</code> resolved unpredictably. The fix was to take the identifier off the container and put one on an unambiguous leaf, the “My Photos” <code class="language-plaintext highlighter-rouge">Text</code>.</p>

<picture>
  <source srcset="/images/blog/2026-09-04-localized-app-store-screenshots-xcuitest/accessibility-identifier-leak.webp" type="image/webp" />
  <img src="/images/blog/2026-09-04-localized-app-store-screenshots-xcuitest/accessibility-identifier-leak.png" alt="Side-by-side accessibility tree diagrams. Left, the leak: an identifier on the outer VStack lands on all five child elements and strikes through a button's own identifier, so a query returns five matches. Right, the fix: no identifier on the container, one on the My Photos Text leaf, so the query returns one match" loading="lazy" decoding="async" />
</picture>

<p><strong><code class="language-plaintext highlighter-rouge">swipeUp()</code> is a blunt instrument.</strong> It drags nearly a full screen height per call, which is fine for a target deep in the page and terrible for a short section near the top: overshoot it and further <code class="language-plaintext highlighter-rouge">swipeUp()</code>s can never recover, because they only scroll the same direction. Hence a small-increment variant:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">func</span> <span class="nf">smallScrollUntilHittable</span><span class="p">(</span><span class="n">_</span> <span class="nv">element</span><span class="p">:</span> <span class="kt">XCUIElement</span><span class="p">,</span> <span class="k">in</span> <span class="nv">scrollView</span><span class="p">:</span> <span class="kt">XCUIElement</span><span class="p">,</span> <span class="nv">maxAttempts</span><span class="p">:</span> <span class="kt">Int</span> <span class="o">=</span> <span class="mi">20</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">attempts</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="k">while</span> <span class="o">!</span><span class="n">element</span><span class="o">.</span><span class="n">isHittable</span><span class="p">,</span> <span class="n">attempts</span> <span class="o">&lt;</span> <span class="n">maxAttempts</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">start</span> <span class="o">=</span> <span class="n">scrollView</span><span class="o">.</span><span class="nf">coordinate</span><span class="p">(</span><span class="nv">withNormalizedOffset</span><span class="p">:</span> <span class="kt">CGVector</span><span class="p">(</span><span class="nv">dx</span><span class="p">:</span> <span class="mf">0.5</span><span class="p">,</span> <span class="nv">dy</span><span class="p">:</span> <span class="mf">0.7</span><span class="p">))</span>
        <span class="k">let</span> <span class="nv">end</span> <span class="o">=</span> <span class="n">scrollView</span><span class="o">.</span><span class="nf">coordinate</span><span class="p">(</span><span class="nv">withNormalizedOffset</span><span class="p">:</span> <span class="kt">CGVector</span><span class="p">(</span><span class="nv">dx</span><span class="p">:</span> <span class="mf">0.5</span><span class="p">,</span> <span class="nv">dy</span><span class="p">:</span> <span class="mf">0.55</span><span class="p">))</span>
        <span class="n">start</span><span class="o">.</span><span class="nf">press</span><span class="p">(</span><span class="nv">forDuration</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">,</span> <span class="nv">thenDragTo</span><span class="p">:</span> <span class="n">end</span><span class="p">)</span>
        <span class="n">attempts</span> <span class="o">+=</span> <span class="mi">1</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>There’s one more, and it’s the async-rendering trap that every screenshot suite eventually hits. The Insights panel’s container exists the moment its <code class="language-plaintext highlighter-rouge">ScrollView</code> renders — before the <code class="language-plaintext highlighter-rouge">.task</code> that resolves Market Value has returned. Capturing on the container alone races the fetch and grabs the pre-resolution dash. Wait for the resolved content instead of the container that holds it:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="nv">marketValueResolved</span> <span class="o">=</span> <span class="n">app</span><span class="o">.</span><span class="nf">descendants</span><span class="p">(</span><span class="nv">matching</span><span class="p">:</span> <span class="o">.</span><span class="n">any</span><span class="p">)</span>
    <span class="o">.</span><span class="nf">matching</span><span class="p">(</span><span class="kt">NSPredicate</span><span class="p">(</span><span class="nv">format</span><span class="p">:</span> <span class="s">"label BEGINSWITH 'Market Value: $'"</span><span class="p">))</span>
    <span class="o">.</span><span class="n">firstMatch</span>
<span class="kt">XCTAssertTrue</span><span class="p">(</span><span class="n">marketValueResolved</span><span class="o">.</span><span class="nf">waitForExistence</span><span class="p">(</span><span class="nv">timeout</span><span class="p">:</span> <span class="k">Self</span><span class="o">.</span><span class="n">longTimeout</span><span class="p">),</span> <span class="s">"Market Value never resolved"</span><span class="p">)</span>
</code></pre></div></div>

<p>And the interruption monitor matches English button labels only, in a suite that runs in six languages. That’s deliberate, not an oversight: no permission prompt is expected under <code class="language-plaintext highlighter-rouge">--seed-screenshots</code>, so the monitor is a backstop against a genuinely unexpected system alert. A miss means the test hangs and fails, which is what would happen without the monitor anyway.</p>

<h2 id="nothing-else-matters--why-not-fastlane-snapshot">Nothing Else Matters — Why Not <code class="language-plaintext highlighter-rouge">fastlane snapshot</code></h2>

<p>Fair question. <code class="language-plaintext highlighter-rouge">snapshot</code> has done this job for a decade, it’s well understood, and <code class="language-plaintext highlighter-rouge">frameit</code> handles the device frames. If you’re already on fastlane, keep it.</p>

<p>I didn’t reach for it here for one reason: <code class="language-plaintext highlighter-rouge">snapshot</code> captures, it doesn’t verify. Point it at a broken locale configuration and it’ll cheerfully produce ninety-six images, sixteen of them in the wrong language, and hand you a tidy HTML index of your bug. That’s not a knock on the tool — capture is the job it signed up for, and it does it well. But capture was never my problem. Two <code class="language-plaintext highlighter-rouge">xcodebuild</code> invocations and a <code class="language-plaintext highlighter-rouge">for</code> loop capture fine. My problem was that nothing anywhere in the pipeline ever asked whether the thing that happened was the thing I’d asked for.</p>

<p>The second reason is smaller and more personal. <code class="language-plaintext highlighter-rouge">snapshot</code> wants <code class="language-plaintext highlighter-rouge">SnapshotHelper.swift</code> vendored into your UI test target and drives locale through its own launch-argument convention. That’s another layer between me and <code class="language-plaintext highlighter-rouge">AppleLanguages</code> — and given that a locale mechanism silently not applying is what caused all of this, I wanted that line in my own file, where I could read it and test it.</p>

<p>The valuable part here isn’t the harness. It’s the two guards, and they’re portable: they read PNGs off disk and don’t care what produced them. Run <code class="language-plaintext highlighter-rouge">assert_language_hashes_distinct</code> and <code class="language-plaintext highlighter-rouge">assert_panels_pairwise_distinct</code> after <code class="language-plaintext highlighter-rouge">fastlane snapshot</code> and you get the assertion layer without touching anything else you’ve built.</p>

<h2 id="fade-to-black--what-it-actually-cost">Fade to Black — What It Actually Cost</h2>

<p>One day. The commit log is honest about the shape of it. The first entry is <code class="language-plaintext highlighter-rouge">[WIP] capture harness — unverified, no test run completed</code>, and the two that matter most are <code class="language-plaintext highlighter-rouge">[FIX] Fail loudly on a repeat of the silent English-panel bug</code> and <code class="language-plaintext highlighter-rouge">[FIX] Capture the real My Photos gallery in panel-06, catch cross-panel duplicates</code>.</p>

<p>Neither of those is a translation bug. Both are <em>plumbing</em> bugs — a string, or in the second case an entire screen, that never made it from the source to the pixel. That’s the real category here, and it’s the awkward kind: invisible to eyes, trivial to machines.</p>

<p>It isn’t finished. Three of the eight panels can’t be captured by a simulator at all: the <a href="/ocr-doesn-t-give-you-text-it-gives-you-a-map/">dead wax viewfinder</a> needs a live camera over a real run-out groove, the Home Screen widget isn’t reachable from XCUITest, and CarPlay needs its own simulator. Those are captured by hand and fed to the same compositor (<code class="language-plaintext highlighter-rouge">swift Tools/ShotComposer/compose.swift</code>), which doesn’t care about provenance, only that it gets a raw PNG and a caption and emits something opaque, alpha-free, and exactly 1320 × 2868 or 2064 × 2752.</p>

<p>And the captions are still English-only, sitting in a <code class="language-plaintext highlighter-rouge">captions.json</code> next to the composer rather than in the app’s String Catalog. That’s the same bug class as the one I opened with, one layer up, waiting: a caption file no translator has ever been shown, in a pipeline that would happily composite English text over a Japanese screenshot and report success. Moving them into <code class="language-plaintext highlighter-rouge">Localizable.xcstrings</code>, where extraction is a build-time guarantee and translators already work, is the next thing, and it’ll want a guard of its own. Identical output across locales is a defect until proven otherwise.</p>

<p>The general shape of it: when you build a pipeline that asks some process to do something, make it prove what it did. My original harness sent a locale into a black box and photographed whatever came out, and it would have kept doing that indefinitely. What fixed it wasn’t a better flag — it was <code class="language-plaintext highlighter-rouge">shasum -a 256</code> run twice along two different axes, plus a mode whose only job is to point those guards at known-bad input and fail if they stay quiet.</p>

<p>Ninety-six images, six languages, and not one of them requires me to read Japanese.</p>]]></content><author><name>Wesley Matlock</name></author><category term="ios" /><category term="swift" /><category term="testing" /><category term="localization" /><category term="xcuitest" /><category term="xcodebuild" /><category term="screenshots" /><category term="vinylcrate" /><category term="bash" /><summary type="html"><![CDATA[xcodebuild's -testLanguage silently fails on Xcode 27. How VinylCrate captures 96 localized App Store screenshots and fails loudly when a locale falls back.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.wesleymatlock.com/images/blog/2026-09-04-localized-app-store-screenshots-xcuitest/hero.png" /><media:content medium="image" url="https://www.wesleymatlock.com/images/blog/2026-09-04-localized-app-store-screenshots-xcuitest/hero.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">The Memory Remains: TipKit in a Shipping App</title><link href="https://www.wesleymatlock.com/tipkit-deep-dive-shipping-app/" rel="alternate" type="text/html" title="The Memory Remains: TipKit in a Shipping App" /><published>2026-08-24T00:00:00+00:00</published><updated>2026-08-24T00:00:00+00:00</updated><id>https://www.wesleymatlock.com/tipkit-deep-dive-shipping-app</id><content type="html" xml:base="https://www.wesleymatlock.com/tipkit-deep-dive-shipping-app/"><![CDATA[<h2 id="the-ecstasy-of-gold">The Ecstasy of Gold</h2>

<p>There’s exactly one door into VinylCrate’s Insights area — an unlabeled chart icon in the Home toolbar. Behind it sits the collector’s dashboard: what your collection is worth, your top genres, your most-spun records. Analytics said people weren’t walking through it. The sessions that found Insights stayed there, so the feature wasn’t weak. The door was invisible.</p>

<p>That’s the job TipKit exists for, and almost everything written about it stops at the demo popover. Add a tip, watch it appear, ship the screenshot. What nobody writes about is what happens when TipKit meets a real app: a first-run flow that fights your popovers for the screen, a 1,300-line detail view where two tips want the same scroll position, a once-per-process <code class="language-plaintext highlighter-rouge">configure</code> call that quietly dictates how your debug tooling has to work, and the question of whether any of this is testable at all.</p>

<picture>
  <source srcset="/images/blog/2026-08-24-tipkit-deep-dive-shipping-app/insights-popover.webp" type="image/webp" />
  <img src="/images/blog/2026-08-24-tipkit-deep-dive-shipping-app/insights-popover.png" alt="The TipKit popover 'Explore Your Collector's Dashboard' anchored to the unlabeled chart icon in VinylCrate's toolbar" loading="lazy" decoding="async" />
</picture>

<h2 id="nothing-else-matters">Nothing Else Matters</h2>

<p>VinylCrate ships five tips. Not fifteen. The restraint is the point, so before any mechanics, here’s the full roster:</p>

<ul>
  <li><strong>Insights</strong> — “Explore Your Collector’s Dashboard.” The unlabeled toolbar icon that started all of this: the sole entry into an entire feature area.</li>
  <li><strong>AI Crate Builder</strong> — “Build a Crate with AI.” A sparkles button that takes a mood — “late-night thrash,” say — and assembles a crate from records you actually own.</li>
  <li><strong>Copy Photos</strong> — “Photograph Your Copy.” Covers, inners, and each disc of the physical copy on your shelf, not the Discogs stock art.</li>
  <li><strong>Dead Wax</strong> — “Identify Your Exact Pressing.” Scan the runout etchings and match the pressing you own — the difference between <em>a</em> copy of Ride the Lightning and <em>your</em> copy.</li>
  <li><strong>Sleeve Scan</strong> — “No Barcode? Scan the Sleeve.” Point the camera at the cover and on-device AI identifies the record.</li>
</ul>

<p>Notice what’s <em>not</em> on the list: anything with a visible banner or a labeled control. VinylCrate has plenty of features behind buttons that say what they do, and none of them get a tip, because a tip on a labeled button is chrome explaining chrome. TipKit gives you one display slot per day by default — we’ll get to that — and every tip you attach to something self-explanatory spends that budget teaching nothing.</p>

<p>The heuristic that produced this list: tip the features users would love but will never find. Skip the ones the UI already explains. If the tip is describing the button next to it, delete the tip.</p>

<p>One anchoring note for anyone reading along in the repo: the Insights anchor is the toolbar button in <code class="language-plaintext highlighter-rouge">CollectionView</code> — the live Home tab — in <code class="language-plaintext highlighter-rouge">Views/ContentView.swift</code>.</p>

<h2 id="sad-but-true">Sad But True</h2>

<p>TipKit gives you two primitives for eligibility, and the framework’s docs treat them as interchangeable knobs. They’re not.</p>

<p><code class="language-plaintext highlighter-rouge">@Parameter</code> is for state the app can recompute at any moment. Collection size. Whether the user owns physical copies. Whether Apple Intelligence is available on this device. Whether onboarding finished. The test is simple: if you deleted the value and could rebuild it from scratch on the next launch, it’s a parameter.</p>

<p><code class="language-plaintext highlighter-rouge">Tips.Event</code> is for history — the thing state can’t honestly represent. “The user has viewed album detail three times” isn’t derivable from your SwiftData store or your services. You can’t recompute a count of past moments; you had to be counting when they happened.</p>

<p>VinylCrate’s shared parameters live on <code class="language-plaintext highlighter-rouge">InsightsTip</code>:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">nonisolated</span> <span class="kd">struct</span> <span class="kt">InsightsTip</span><span class="p">:</span> <span class="kt">Tip</span> <span class="p">{</span>

    <span class="kd">@Parameter</span> <span class="kd">static</span> <span class="k">var</span> <span class="nv">onboardingComplete</span><span class="p">:</span> <span class="kt">Bool</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="kd">@Parameter</span> <span class="kd">static</span> <span class="k">var</span> <span class="nv">collectionAlbumCount</span><span class="p">:</span> <span class="kt">Int</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="kd">@Parameter</span> <span class="kd">static</span> <span class="k">var</span> <span class="nv">ownsCopies</span><span class="p">:</span> <span class="kt">Bool</span> <span class="o">=</span> <span class="kc">false</span>
    <span class="kd">@Parameter</span> <span class="kd">static</span> <span class="k">var</span> <span class="nv">isAIAvailable</span><span class="p">:</span> <span class="kt">Bool</span> <span class="o">=</span> <span class="kc">false</span>

    <span class="k">var</span> <span class="nv">rules</span><span class="p">:</span> <span class="p">[</span><span class="kt">Rule</span><span class="p">]</span> <span class="p">{</span>
        <span class="cp">#Rule(Self.$onboardingComplete) { $0 == true }</span>
        <span class="cp">#Rule(Self.$collectionAlbumCount) { $0 &gt;= 10 }</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="nv">options</span><span class="p">:</span> <span class="p">[</span><span class="kt">Option</span><span class="p">]</span> <span class="p">{</span> <span class="p">[</span><span class="kt">Tips</span><span class="o">.</span><span class="kt">MaxDisplayCount</span><span class="p">(</span><span class="mi">3</span><span class="p">)]</span> <span class="p">}</span>

<span class="p">}</span>
</code></pre></div></div>

<p>Those type annotations look redundant, but they’re not optional: the <code class="language-plaintext highlighter-rouge">@Parameter</code> macro won’t expand without an explicit type on the property.</p>

<p>The events are the entire other half of the model, and this is the whole file:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">nonisolated</span> <span class="kd">enum</span> <span class="kt">TipEvents</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">albumDetailViewed</span> <span class="o">=</span> <span class="kt">Tips</span><span class="o">.</span><span class="kt">Event</span><span class="p">(</span><span class="nv">id</span><span class="p">:</span> <span class="s">"albumDetailViewed"</span><span class="p">)</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">scannerOpened</span> <span class="o">=</span> <span class="kt">Tips</span><span class="o">.</span><span class="kt">Event</span><span class="p">(</span><span class="nv">id</span><span class="p">:</span> <span class="s">"scannerOpened"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Rules compose both. DeadWaxTip is the clearest example — two facts about the present and one fact about the past:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">rules</span><span class="p">:</span> <span class="p">[</span><span class="kt">Rule</span><span class="p">]</span> <span class="p">{</span>
    <span class="cp">#Rule(InsightsTip.$onboardingComplete) { $0 == true }</span>
    <span class="cp">#Rule(InsightsTip.$ownsCopies) { $0 == true }</span>
    <span class="cp">#Rule(TipEvents.albumDetailViewed) { $0.donations.count &gt;= 5 }</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Those cross-type references are deliberate. <code class="language-plaintext highlighter-rouge">InsightsTip</code> owns the parameter statics; every other tip reads them via <code class="language-plaintext highlighter-rouge">#Rule(InsightsTip.$onboardingComplete)</code>. One writer, many readers — the same instinct as single-owner state in SwiftUI. The alternative is four tips each declaring their own <code class="language-plaintext highlighter-rouge">onboardingComplete</code> parameter and four call sites that have to stay in sync forever.</p>

<p>And nothing in the view layer ever pokes a parameter directly. The facts flow one way: SwiftData, <code class="language-plaintext highlighter-rouge">@AppStorage</code>, and services feed <code class="language-plaintext highlighter-rouge">TipFacts.compute</code>, which feeds the statics. <code class="language-plaintext highlighter-rouge">RootView</code> computes an <code class="language-plaintext highlighter-rouge">Equatable</code> signature of everything the tips care about, and a <code class="language-plaintext highlighter-rouge">.task(id:)</code> fires only when the signature actually changes:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">struct</span> <span class="kt">TipFactsSignature</span><span class="p">:</span> <span class="kt">Equatable</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">albumCount</span><span class="p">:</span> <span class="kt">Int</span>
    <span class="k">let</span> <span class="nv">ownsCopies</span><span class="p">:</span> <span class="kt">Bool</span>
    <span class="k">let</span> <span class="nv">aiAvailable</span><span class="p">:</span> <span class="kt">Bool</span>
    <span class="k">let</span> <span class="nv">hasPromptedForDiscogs</span><span class="p">:</span> <span class="kt">Bool</span>
    <span class="k">let</span> <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="kt">Bool</span>
    <span class="k">let</span> <span class="nv">importPromptStateRawValue</span><span class="p">:</span> <span class="kt">Int</span>
<span class="p">}</span>

<span class="kd">private</span> <span class="k">var</span> <span class="nv">tipFactsSignature</span><span class="p">:</span> <span class="kt">TipFactsSignature</span> <span class="p">{</span>
    <span class="kt">TipFactsSignature</span><span class="p">(</span>
        <span class="nv">albumCount</span><span class="p">:</span> <span class="n">container</span><span class="o">.</span><span class="n">collection</span><span class="o">.</span><span class="n">collection</span><span class="o">.</span><span class="n">count</span><span class="p">,</span>
        <span class="nv">ownsCopies</span><span class="p">:</span> <span class="n">ownsAtLeastOneCopy</span><span class="p">,</span>
        <span class="nv">aiAvailable</span><span class="p">:</span> <span class="n">container</span><span class="o">.</span><span class="n">aiService</span><span class="o">.</span><span class="n">isAvailable</span><span class="p">,</span>
        <span class="nv">hasPromptedForDiscogs</span><span class="p">:</span> <span class="n">hasPromptedForDiscogs</span><span class="p">,</span>
        <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="n">oauthService</span><span class="o">.</span><span class="n">isAuthenticated</span><span class="p">,</span>
        <span class="nv">importPromptStateRawValue</span><span class="p">:</span> <span class="n">importPromptState</span>
    <span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="nf">task</span><span class="p">(</span><span class="nv">id</span><span class="p">:</span> <span class="n">tipFactsSignature</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">signature</span> <span class="o">=</span> <span class="n">tipFactsSignature</span>
    <span class="kt">TipsCoordinator</span><span class="o">.</span><span class="nf">refreshFacts</span><span class="p">(</span>
        <span class="nv">albumCount</span><span class="p">:</span> <span class="n">signature</span><span class="o">.</span><span class="n">albumCount</span><span class="p">,</span>
        <span class="nv">ownsCopies</span><span class="p">:</span> <span class="n">signature</span><span class="o">.</span><span class="n">ownsCopies</span><span class="p">,</span>
        <span class="nv">aiAvailable</span><span class="p">:</span> <span class="n">signature</span><span class="o">.</span><span class="n">aiAvailable</span><span class="p">,</span>
        <span class="nv">hasPromptedForDiscogs</span><span class="p">:</span> <span class="n">signature</span><span class="o">.</span><span class="n">hasPromptedForDiscogs</span><span class="p">,</span>
        <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="n">signature</span><span class="o">.</span><span class="n">isAuthenticated</span><span class="p">,</span>
        <span class="nv">importPromptState</span><span class="p">:</span> <span class="kt">ImportPromptState</span><span class="p">(</span><span class="nv">rawValue</span><span class="p">:</span> <span class="n">signature</span><span class="o">.</span><span class="n">importPromptStateRawValue</span><span class="p">)</span> <span class="p">??</span> <span class="o">.</span><span class="n">neverPrompted</span>
    <span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The coordinator is the single place where facts become parameters:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="kd">func</span> <span class="nf">refreshFacts</span><span class="p">(</span>
    <span class="nv">albumCount</span><span class="p">:</span> <span class="kt">Int</span><span class="p">,</span>
    <span class="nv">ownsCopies</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">,</span>
    <span class="nv">aiAvailable</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">,</span>
    <span class="nv">hasPromptedForDiscogs</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">,</span>
    <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">,</span>
    <span class="nv">importPromptState</span><span class="p">:</span> <span class="kt">ImportPromptState</span>
<span class="p">)</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="o">!</span><span class="kt">TipsBootstrap</span><span class="o">.</span><span class="n">isHostedTestRun</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>

    <span class="k">let</span> <span class="nv">facts</span> <span class="o">=</span> <span class="kt">TipFacts</span><span class="o">.</span><span class="nf">compute</span><span class="p">(</span>
        <span class="nv">albumCount</span><span class="p">:</span> <span class="n">albumCount</span><span class="p">,</span>
        <span class="nv">ownsCopies</span><span class="p">:</span> <span class="n">ownsCopies</span><span class="p">,</span>
        <span class="nv">aiAvailable</span><span class="p">:</span> <span class="n">aiAvailable</span><span class="p">,</span>
        <span class="nv">hasPromptedForDiscogs</span><span class="p">:</span> <span class="n">hasPromptedForDiscogs</span><span class="p">,</span>
        <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="n">isAuthenticated</span><span class="p">,</span>
        <span class="nv">importPromptState</span><span class="p">:</span> <span class="n">importPromptState</span>
    <span class="p">)</span>

    <span class="kt">InsightsTip</span><span class="o">.</span><span class="n">onboardingComplete</span> <span class="o">=</span> <span class="n">facts</span><span class="o">.</span><span class="n">onboardingComplete</span>
    <span class="kt">InsightsTip</span><span class="o">.</span><span class="n">collectionAlbumCount</span> <span class="o">=</span> <span class="n">facts</span><span class="o">.</span><span class="n">albumCount</span>
    <span class="kt">InsightsTip</span><span class="o">.</span><span class="n">ownsCopies</span> <span class="o">=</span> <span class="n">facts</span><span class="o">.</span><span class="n">ownsCopies</span>
    <span class="kt">InsightsTip</span><span class="o">.</span><span class="n">isAIAvailable</span> <span class="o">=</span> <span class="n">facts</span><span class="o">.</span><span class="n">aiAvailable</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The practical upshot: no view anywhere in the app writes a tip parameter, and when a tip misbehaves there’s exactly one function to put a breakpoint in.</p>

<h2 id="the-wait">The Wait</h2>

<p>VinylCrate’s first run is already busy. A “Connect to Discogs?” prompt appears half a second after launch. Saying yes opens a login sheet. Authenticating offers a collection-import sheet. That’s three pieces of launch UI stacked on a brand-new user — and a tip popover cheerfully rendering on top of that stack is the helpful app shouting over itself.</p>

<p>The fix is the gate you’ve already seen on every rule in the last section: <code class="language-plaintext highlighter-rouge">#Rule(InsightsTip.$onboardingComplete)</code>, on <em>every</em> tip, no exceptions. The interesting part is what “onboarding complete” actually means, and I got it wrong on the first pass. My first gate was basically “import completed” — which permanently silences every tip for users who decline Discogs. They never see the import flow, so it never completes, so the gate never opens. Everyone who tapped “Not Now” would go tipless forever, and no bug report would ever tell you why.</p>

<p>Here’s the gate that shipped:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">nonisolated</span> <span class="kd">struct</span> <span class="kt">TipFacts</span> <span class="p">{</span>

    <span class="k">let</span> <span class="nv">albumCount</span><span class="p">:</span> <span class="kt">Int</span>
    <span class="k">let</span> <span class="nv">ownsCopies</span><span class="p">:</span> <span class="kt">Bool</span>
    <span class="k">let</span> <span class="nv">aiAvailable</span><span class="p">:</span> <span class="kt">Bool</span>
    <span class="k">let</span> <span class="nv">onboardingComplete</span><span class="p">:</span> <span class="kt">Bool</span>

    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">compute</span><span class="p">(</span>
        <span class="nv">albumCount</span><span class="p">:</span> <span class="kt">Int</span><span class="p">,</span>
        <span class="nv">ownsCopies</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">,</span>
        <span class="nv">aiAvailable</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">,</span>
        <span class="nv">hasPromptedForDiscogs</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">,</span>
        <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">,</span>
        <span class="nv">importPromptState</span><span class="p">:</span> <span class="kt">ImportPromptState</span>
    <span class="p">)</span>
        <span class="o">-&gt;</span> <span class="kt">TipFacts</span> <span class="p">{</span>
        <span class="kt">TipFacts</span><span class="p">(</span>
            <span class="nv">albumCount</span><span class="p">:</span> <span class="n">albumCount</span><span class="p">,</span>
            <span class="nv">ownsCopies</span><span class="p">:</span> <span class="n">ownsCopies</span><span class="p">,</span>
            <span class="nv">aiAvailable</span><span class="p">:</span> <span class="n">aiAvailable</span><span class="p">,</span>
            <span class="nv">onboardingComplete</span><span class="p">:</span> <span class="n">hasPromptedForDiscogs</span> <span class="o">&amp;&amp;</span> <span class="p">(</span><span class="o">!</span><span class="n">isAuthenticated</span> <span class="o">||</span> <span class="n">importPromptState</span> <span class="o">==</span> <span class="o">.</span><span class="n">completed</span><span class="p">)</span>
        <span class="p">)</span>
    <span class="p">}</span>

<span class="p">}</span>
</code></pre></div></div>

<p>Walk the truth table. Not prompted yet: gate closed, the Discogs alert may still be coming. Prompted and not authenticated: gate <em>open</em> — the user declined, or dismissed the login sheet, and no further launch UI can appear, so tips are safe regardless of import state. Prompted and authenticated but import not completed: gate closed, because the import sheet is either up or still possible. Prompted, authenticated, import completed: open.</p>

<p>The lesson generalizes past Discogs. The gate isn’t “did onboarding finish” — it’s “can any launch UI still appear.” Those are different questions, and the second one has to account for the people who say no.</p>

<h2 id="one">One</h2>

<p>Copy Photos and Dead Wax both live in the album-detail scroll, and for an owned release their rules overlap almost entirely. Without intervention, a user who’s browsed five albums gets both inline tips at once — two stacked cards teaching two things, which reads as clutter and teaches neither.</p>

<p>TipKit’s answer is <code class="language-plaintext highlighter-rouge">TipGroup</code>, and it costs two lines in the view:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@State</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">ownedCopyTips</span> <span class="o">=</span> <span class="kt">TipGroup</span><span class="p">(</span><span class="o">.</span><span class="n">ordered</span><span class="p">)</span> <span class="p">{</span> <span class="kt">CopyPhotosTip</span><span class="p">();</span> <span class="kt">DeadWaxTip</span><span class="p">()</span> <span class="p">}</span>
</code></pre></div></div>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if</span> <span class="n">isInCollection</span> <span class="p">{</span>
    <span class="kt">TipView</span><span class="p">(</span><span class="n">ownedCopyTips</span><span class="o">.</span><span class="n">currentTip</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">vinylTipStyle</span><span class="p">()</span>
        <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">horizontal</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">padding</span><span class="p">(</span><span class="o">.</span><span class="n">bottom</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>One slot, <code class="language-plaintext highlighter-rouge">.ordered</code>, Copy Photos first. Whichever eligible tip the group surfaces renders in the single <code class="language-plaintext highlighter-rouge">TipView</code>; the other waits its turn.</p>

<p>The part I didn’t plan is my favorite part. The two tips have different donation thresholds — Copy Photos fires at three detail views, Dead Wax at five:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#Rule(TipEvents.albumDetailViewed) { $0.donations.count &gt;= 3 }</span>
</code></pre></div></div>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">#Rule(TipEvents.albumDetailViewed) { $0.donations.count &gt;= 5 }</span>
</code></pre></div></div>

<p>Combined with the ordered group and the daily display budget, the thresholds naturally stagger the two tips across separate visits: photograph your copy this week, identify your pressing the next. Sequenced teaching, and I never built a sequencing engine. The rules composed one on their own.</p>

<p>Worth noticing in that snippet: the slot only exists when <code class="language-plaintext highlighter-rouge">isInCollection</code> is true. Rules decide <em>whether</em> a tip is eligible; view-level conditionals decide <em>where</em> it can physically appear. They stack, and you want both — no rule expresses “this release is in the collection being rendered right now,” so the view carries that half.</p>

<picture>
  <source srcset="/images/blog/2026-08-24-tipkit-deep-dive-shipping-app/copy-photos-inline.webp" type="image/webp" />
  <img src="/images/blog/2026-08-24-tipkit-deep-dive-shipping-app/copy-photos-inline.png" alt="VinylCrate album detail view showing the inline 'Photograph Your Copy' TipView styled with the app's card background, rendered above the My Photos section for an owned record" loading="lazy" decoding="async" />
</picture>

<h2 id="fuel">Fuel</h2>

<p>The default display frequency is <code class="language-plaintext highlighter-rouge">.daily</code> — one tip, per day, across the whole app — and the default is right. It’s the single best guardrail TipKit gives you against becoming the app that nags. VinylCrate keeps it.</p>

<p>Except for one tip, and the exception has to earn itself. The Sleeve Scan tip anchors inside the scanner sheet — a surface that exists for seconds at a time. Under a daily budget, the odds that the app’s one tip-of-the-day happens to win its slot during the brief window the scanner is open are close to zero. I would have shipped a tip nobody ever sees.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">var</span> <span class="nv">rules</span><span class="p">:</span> <span class="p">[</span><span class="kt">Rule</span><span class="p">]</span> <span class="p">{</span>
    <span class="cp">#Rule(InsightsTip.$onboardingComplete) { $0 == true }</span>
    <span class="cp">#Rule(InsightsTip.$isAIAvailable) { $0 == true }</span>
    <span class="cp">#Rule(TipEvents.scannerOpened) { $0.donations.count &gt;= 2 }</span>
<span class="p">}</span>

<span class="k">var</span> <span class="nv">options</span><span class="p">:</span> <span class="p">[</span><span class="kt">Option</span><span class="p">]</span> <span class="p">{</span> <span class="p">[</span><span class="kt">Tips</span><span class="o">.</span><span class="kt">IgnoresDisplayFrequency</span><span class="p">(</span><span class="kc">true</span><span class="p">),</span> <span class="kt">Tips</span><span class="o">.</span><span class="kt">MaxDisplayCount</span><span class="p">(</span><span class="mi">2</span><span class="p">)]</span> <span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">IgnoresDisplayFrequency(true)</code> — but look at what surrounds it. The rules already make the tip rare: Apple Intelligence has to be available, and this has to be at least the second scanner session. And <code class="language-plaintext highlighter-rouge">MaxDisplayCount(2)</code> backstops the bypass at two lifetime displays no matter what.</p>

<p>That’s the rubric, not a loophole: bypass the frequency budget only when the anchor surface is transient, the rules already make the tip rare, and a max display count caps the damage. All three, or don’t.</p>

<h2 id="seek--destroy">Seek &amp; Destroy</h2>

<p><code class="language-plaintext highlighter-rouge">invalidate(reason: .actionPerformed)</code> is what keeps a tip from turning into a nag. The rule I hold: invalidation belongs at every code path that <em>performs the taught action</em>, not just wherever the tip happens to render — users get to the action however they get to it.</p>

<p>The Sleeve Scan tip teaches “switch to sleeve mode and capture.” Two paths perform that, and both invalidate:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">mode</span><span class="p">)</span> <span class="p">{</span> <span class="n">_</span><span class="p">,</span> <span class="n">newMode</span> <span class="k">in</span>
    <span class="c1">// Toggling modes cancels any in-flight capture and clears stale progress/error/complete state.</span>
    <span class="nf">cancelCapture</span><span class="p">()</span>
    <span class="n">sleeveScan</span><span class="p">?</span><span class="o">.</span><span class="nf">reset</span><span class="p">()</span>
    <span class="k">if</span> <span class="n">newMode</span> <span class="o">==</span> <span class="o">.</span><span class="n">sleeve</span> <span class="p">{</span>
        <span class="n">sleeveScanTip</span><span class="o">.</span><span class="nf">invalidate</span><span class="p">(</span><span class="nv">reason</span><span class="p">:</span> <span class="o">.</span><span class="n">actionPerformed</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">func</span> <span class="nf">startCapture</span><span class="p">(</span><span class="n">_</span> <span class="nv">service</span><span class="p">:</span> <span class="kt">SleeveScanService</span><span class="p">)</span> <span class="p">{</span>
    <span class="n">sleeveScanTip</span><span class="o">.</span><span class="nf">invalidate</span><span class="p">(</span><span class="nv">reason</span><span class="p">:</span> <span class="o">.</span><span class="n">actionPerformed</span><span class="p">)</span>
    <span class="n">captureTask</span><span class="p">?</span><span class="o">.</span><span class="nf">cancel</span><span class="p">()</span>
    <span class="n">captureTask</span> <span class="o">=</span> <span class="kt">Task</span> <span class="p">{</span> <span class="k">await</span> <span class="nf">captureSleeve</span><span class="p">(</span><span class="n">service</span><span class="p">)</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The scanner also donates its event from the same view, guarded the same way everything TipKit-adjacent is guarded (more on that guard in the testing section):</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="n">task</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="o">!</span><span class="kt">TipsBootstrap</span><span class="o">.</span><span class="n">isHostedTestRun</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
    <span class="k">await</span> <span class="kt">TipEvents</span><span class="o">.</span><span class="n">scannerOpened</span><span class="o">.</span><span class="nf">donate</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Album detail is the interesting one. Copy Photos and Dead Wax are performed inside child section views that expose no tap callback to the parent — and I wasn’t going to thread closures through four layers for a tip. But every action in VinylCrate routes through the coordinator, which means the <em>navigation state</em> already knows when the user does the thing. So the parent observes the active sheet:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="o">.</span><span class="nf">task</span><span class="p">(</span><span class="nv">id</span><span class="p">:</span> <span class="n">detail</span><span class="o">.</span><span class="n">id</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">await</span> <span class="nf">checkRemoteOwnershipIfNeeded</span><span class="p">()</span>
    <span class="k">guard</span> <span class="o">!</span><span class="kt">TipsBootstrap</span><span class="o">.</span><span class="n">isHostedTestRun</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
    <span class="k">await</span> <span class="kt">TipEvents</span><span class="o">.</span><span class="n">albumDetailViewed</span><span class="o">.</span><span class="nf">donate</span><span class="p">()</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">onChange</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="n">coordinator</span><span class="p">?</span><span class="o">.</span><span class="n">activeSheet</span><span class="p">)</span> <span class="p">{</span> <span class="n">_</span><span class="p">,</span> <span class="n">newValue</span> <span class="k">in</span>
    <span class="k">switch</span> <span class="n">newValue</span> <span class="p">{</span>
    <span class="k">case</span> <span class="o">.</span><span class="nf">copyPhotoCapture</span><span class="p">(</span><span class="k">let</span> <span class="nv">releaseID</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">)?</span> <span class="k">where</span> <span class="n">releaseID</span> <span class="o">==</span> <span class="n">detail</span><span class="o">.</span><span class="nv">id</span><span class="p">:</span>
        <span class="kt">CopyPhotosTip</span><span class="p">()</span><span class="o">.</span><span class="nf">invalidate</span><span class="p">(</span><span class="nv">reason</span><span class="p">:</span> <span class="o">.</span><span class="n">actionPerformed</span><span class="p">)</span>
    <span class="k">case</span> <span class="o">.</span><span class="nf">deadWaxScan</span><span class="p">(</span><span class="k">let</span> <span class="nv">releaseID</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">,</span> <span class="n">_</span><span class="p">)?</span> <span class="k">where</span> <span class="n">releaseID</span> <span class="o">==</span> <span class="n">detail</span><span class="o">.</span><span class="nv">id</span><span class="p">:</span>
        <span class="kt">DeadWaxTip</span><span class="p">()</span><span class="o">.</span><span class="nf">invalidate</span><span class="p">(</span><span class="nv">reason</span><span class="p">:</span> <span class="o">.</span><span class="n">actionPerformed</span><span class="p">)</span>
    <span class="k">default</span><span class="p">:</span>
        <span class="k">break</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The coordinator was built for navigation, not for tips — the sheet routes doubling as “user did the thing” signals is a freebie I’ll happily take. And note the <code class="language-plaintext highlighter-rouge">releaseID == detail.id</code> guard — invalidation precision includes <em>which instance</em>. A photo capture launched from some other album’s detail view shouldn’t invalidate the tip this view is showing.</p>

<p>Even the Insights button follows the rule, in the simplest possible form: the tap that opens the dashboard invalidates the tip in the same closure.</p>

<h2 id="jump-in-the-fire">Jump in the Fire</h2>

<p><code class="language-plaintext highlighter-rouge">Tips.configure</code> looks like housekeeping. It’s actually a strict ordering problem with three links: testing and reset overrides must run before <code class="language-plaintext highlighter-rouge">configure</code>, <code class="language-plaintext highlighter-rouge">configure</code> must run before the first <code class="language-plaintext highlighter-rouge">TipView</code> or <code class="language-plaintext highlighter-rouge">.popoverTip</code> evaluates — and the Insights anchor sits on the first screen the app renders. Chain those together and “before the first body” means <code class="language-plaintext highlighter-rouge">App.init</code>. Not <code class="language-plaintext highlighter-rouge">.task</code>, not <code class="language-plaintext highlighter-rouge">onAppear</code>. The last line of VinylCrate’s <code class="language-plaintext highlighter-rouge">App.init</code> is <code class="language-plaintext highlighter-rouge">TipsBootstrap.configureAtLaunch()</code>, and the comment above it in the app file says exactly why: it cannot defer.</p>

<p>Here’s the bootstrap, essentially whole, because every line is a decision:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">nonisolated</span> <span class="kd">enum</span> <span class="kt">TipsPreferences</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">pendingResetKey</span> <span class="o">=</span> <span class="s">"vinylcrate.tips.pendingReset"</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">debugShowAllKey</span> <span class="o">=</span> <span class="s">"vinylcrate.tips.debugShowAll"</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">debugHideAllKey</span> <span class="o">=</span> <span class="s">"vinylcrate.tips.debugHideAll"</span>
<span class="p">}</span>

<span class="kd">@MainActor</span>
<span class="kd">enum</span> <span class="kt">TipsBootstrap</span> <span class="p">{</span>

    <span class="kd">static</span> <span class="k">var</span> <span class="nv">isHostedTestRun</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="kt">ProcessInfo</span><span class="o">.</span><span class="n">processInfo</span><span class="o">.</span><span class="n">environment</span><span class="p">[</span><span class="s">"XCTestConfigurationFilePath"</span><span class="p">]</span> <span class="o">!=</span> <span class="kc">nil</span>
    <span class="p">}</span>

    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">configureAtLaunch</span><span class="p">()</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">isHostedTestRun</span> <span class="p">{</span>
            <span class="k">return</span>
        <span class="p">}</span>

        <span class="k">let</span> <span class="nv">defaults</span> <span class="o">=</span> <span class="kt">UserDefaults</span><span class="o">.</span><span class="n">standard</span>

        <span class="k">if</span> <span class="n">defaults</span><span class="o">.</span><span class="nf">bool</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="kt">TipsPreferences</span><span class="o">.</span><span class="n">pendingResetKey</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">try</span><span class="p">?</span> <span class="kt">Tips</span><span class="o">.</span><span class="nf">resetDatastore</span><span class="p">()</span>
            <span class="n">defaults</span><span class="o">.</span><span class="nf">removeObject</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="kt">TipsPreferences</span><span class="o">.</span><span class="n">pendingResetKey</span><span class="p">)</span>
        <span class="p">}</span>

        <span class="cp">#if DEBUG</span>
        <span class="k">let</span> <span class="nv">arguments</span> <span class="o">=</span> <span class="kt">ProcessInfo</span><span class="o">.</span><span class="n">processInfo</span><span class="o">.</span><span class="n">arguments</span>
        <span class="k">if</span> <span class="n">arguments</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="s">"--disable-tips"</span><span class="p">)</span> <span class="p">{</span>
            <span class="kt">Tips</span><span class="o">.</span><span class="nf">hideAllTipsForTesting</span><span class="p">()</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="n">arguments</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="s">"--show-all-tips"</span><span class="p">)</span> <span class="p">{</span>
            <span class="kt">Tips</span><span class="o">.</span><span class="nf">showAllTipsForTesting</span><span class="p">()</span>
        <span class="p">}</span>
        <span class="cp">#endif</span>

        <span class="cp">#if DEBUG</span>
        <span class="k">if</span> <span class="n">defaults</span><span class="o">.</span><span class="nf">bool</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="kt">TipsPreferences</span><span class="o">.</span><span class="n">debugShowAllKey</span><span class="p">)</span> <span class="p">{</span>
            <span class="kt">Tips</span><span class="o">.</span><span class="nf">showAllTipsForTesting</span><span class="p">()</span>
            <span class="n">defaults</span><span class="o">.</span><span class="nf">removeObject</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="kt">TipsPreferences</span><span class="o">.</span><span class="n">debugShowAllKey</span><span class="p">)</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="n">defaults</span><span class="o">.</span><span class="nf">bool</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="kt">TipsPreferences</span><span class="o">.</span><span class="n">debugHideAllKey</span><span class="p">)</span> <span class="p">{</span>
            <span class="kt">Tips</span><span class="o">.</span><span class="nf">hideAllTipsForTesting</span><span class="p">()</span>
            <span class="n">defaults</span><span class="o">.</span><span class="nf">removeObject</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="kt">TipsPreferences</span><span class="o">.</span><span class="n">debugHideAllKey</span><span class="p">)</span>
        <span class="p">}</span>
        <span class="cp">#endif</span>

        <span class="k">try</span><span class="p">?</span> <span class="kt">Tips</span><span class="o">.</span><span class="nf">configure</span><span class="p">([</span><span class="o">.</span><span class="nf">displayFrequency</span><span class="p">(</span><span class="o">.</span><span class="n">daily</span><span class="p">),</span> <span class="o">.</span><span class="nf">datastoreLocation</span><span class="p">(</span><span class="o">.</span><span class="n">applicationDefault</span><span class="p">)])</span>
    <span class="p">}</span>

<span class="p">}</span>
</code></pre></div></div>

<p>Top to bottom: stand down entirely if a test suite is hosting the app. Consume a staged datastore reset. Honor DEBUG launch arguments. Consume staged debug overrides. Then — and only then — configure.</p>

<p>Don’t skim past <code class="language-plaintext highlighter-rouge">.displayFrequency(.daily)</code> as boilerplate. That’s a product decision encoded in one argument: one tip per day is the app’s entire teaching budget. Every eligibility rule in this post competes for that single daily slot, and that scarcity is what keeps five tips from feeling like fifteen.</p>

<h2 id="the-day-that-never-comes">The Day That Never Comes</h2>

<p><code class="language-plaintext highlighter-rouge">Tips.configure</code> runs once per process. Call it again and the call is ignored. There’s no reconfigure, no teardown, no “just for this screen.” Which means every piece of QA tooling that wants to change TipKit’s world has to work <em>across</em> launches, not within one.</p>

<p>That’s why the debug panel stages instead of acts. Each button writes a <code class="language-plaintext highlighter-rouge">UserDefaults</code> key; <code class="language-plaintext highlighter-rouge">TipsBootstrap</code> consumes and clears it at the top of the next launch — you saw the consumption side above. Here’s the staging side:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">Button</span> <span class="p">{</span>
    <span class="kt">UserDefaults</span><span class="o">.</span><span class="n">standard</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="kc">true</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="kt">TipsPreferences</span><span class="o">.</span><span class="n">debugShowAllKey</span><span class="p">)</span>
<span class="p">}</span> <span class="nv">label</span><span class="p">:</span> <span class="p">{</span>
    <span class="nf">debugTipsRowLabel</span><span class="p">(</span><span class="nv">icon</span><span class="p">:</span> <span class="s">"eye"</span><span class="p">,</span> <span class="nv">title</span><span class="p">:</span> <span class="s">"Show All Tips (next launch)"</span><span class="p">)</span>
<span class="p">}</span>

<span class="kt">Button</span> <span class="p">{</span>
    <span class="kt">UserDefaults</span><span class="o">.</span><span class="n">standard</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="kc">true</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="kt">TipsPreferences</span><span class="o">.</span><span class="n">debugHideAllKey</span><span class="p">)</span>
<span class="p">}</span> <span class="nv">label</span><span class="p">:</span> <span class="p">{</span>
    <span class="nf">debugTipsRowLabel</span><span class="p">(</span><span class="nv">icon</span><span class="p">:</span> <span class="s">"eye.slash"</span><span class="p">,</span> <span class="nv">title</span><span class="p">:</span> <span class="s">"Hide All Tips (next launch)"</span><span class="p">)</span>
<span class="p">}</span>

<span class="kt">Button</span> <span class="p">{</span>
    <span class="kt">UserDefaults</span><span class="o">.</span><span class="n">standard</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="kc">true</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="kt">TipsPreferences</span><span class="o">.</span><span class="n">pendingResetKey</span><span class="p">)</span>
<span class="p">}</span> <span class="nv">label</span><span class="p">:</span> <span class="p">{</span>
    <span class="nf">debugTipsRowLabel</span><span class="p">(</span><span class="nv">icon</span><span class="p">:</span> <span class="s">"arrow.triangle.2.circlepath"</span><span class="p">,</span> <span class="nv">title</span><span class="p">:</span> <span class="s">"Reset Tips Datastore (next launch)"</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>“Takes effect on next launch” reads like a limitation you’d apologize for. It isn’t — it’s the only honest contract the API allows, so the UI says it out loud instead of pretending. The panel’s footnote spells out the whole deal for whoever’s QA-ing: staged actions take effect on next launch, and while Show All is active every tip stays visible and dismissals don’t stick until you relaunch again.</p>

<p>The launch arguments cover the automated side of the same coin. <code class="language-plaintext highlighter-rouge">--show-all-tips</code> gives visual QA every tip at once without grinding out eligibility by hand; <code class="language-plaintext highlighter-rouge">--disable-tips</code> means UI tests never race a popover for a tap target.</p>

<p>One action on the panel <em>is</em> immediate, and the distinction matters:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="kd">func</span> <span class="nf">showTipsAgain</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
    <span class="k">await</span> <span class="kt">InsightsTip</span><span class="p">()</span><span class="o">.</span><span class="nf">resetEligibility</span><span class="p">()</span>
    <span class="k">await</span> <span class="kt">AICrateBuilderTip</span><span class="p">()</span><span class="o">.</span><span class="nf">resetEligibility</span><span class="p">()</span>
    <span class="k">await</span> <span class="kt">CopyPhotosTip</span><span class="p">()</span><span class="o">.</span><span class="nf">resetEligibility</span><span class="p">()</span>
    <span class="k">await</span> <span class="kt">DeadWaxTip</span><span class="p">()</span><span class="o">.</span><span class="nf">resetEligibility</span><span class="p">()</span>
    <span class="k">await</span> <span class="kt">SleeveScanTip</span><span class="p">()</span><span class="o">.</span><span class="nf">resetEligibility</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Per-tip <code class="language-plaintext highlighter-rouge">resetEligibility()</code> is safe, immediate, and non-destructive — it re-arms the tips without touching donation history. That’s the shape you’d ship to real users as a “Show Tips Again” row in Settings. The full datastore reset is the destructive one, which is exactly why it’s DEBUG-only and staged behind a relaunch.</p>

<h2 id="king-nothing">King Nothing</h2>

<p>A short one, but it’s the section the post is named for: TipKit’s datastore is keyed by things you probably think of as freely renameable. Eligibility state, display counts, and dismissals persist under your tip <em>type names</em>. Donation history persists under your event <em>string IDs</em>.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="k">let</span> <span class="nv">albumDetailViewed</span> <span class="o">=</span> <span class="kt">Tips</span><span class="o">.</span><span class="kt">Event</span><span class="p">(</span><span class="nv">id</span><span class="p">:</span> <span class="s">"albumDetailViewed"</span><span class="p">)</span>
</code></pre></div></div>

<p>That string is forever. Rename <code class="language-plaintext highlighter-rouge">DeadWaxTip</code> to <code class="language-plaintext highlighter-rouge">PressingTip</code> in a refactor, or “clean up” that event ID, and every user’s state for it is orphaned on their next update. Dismissed tips come back. Donation counts reset to zero. The user who explicitly closed a tip six months ago gets it again, and nothing in your diff looked dangerous.</p>

<p>Freeze tip type names and event IDs the way you freeze API surface and SwiftData schema names. A refactor that “just renames” one of these is a data migration you didn’t write — the memory remains, but only if you don’t rename what it’s filed under.</p>

<h2 id="frantic">Frantic</h2>

<p>“How do you even test TipKit?” is the question that made me want to write this post, because the answer is two layers, and each catches what the other can’t.</p>

<p>Layer one: the gate logic is a pure struct. <code class="language-plaintext highlighter-rouge">TipFacts</code> doesn’t import TipKit — it’s a function from app state to booleans, which means the onboarding gate gets a parameterized truth table, including the declined-Discogs edge case that motivated it. The shipping test covers all twelve combinations; here it is trimmed to the six that tell the story:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Test</span><span class="p">(</span>
    <span class="s">"onboarding-gate truth table"</span><span class="p">,</span>
    <span class="nv">arguments</span><span class="p">:</span> <span class="p">[</span>
        <span class="p">(</span><span class="nv">hasPrompted</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nv">state</span><span class="p">:</span> <span class="kt">ImportPromptState</span><span class="o">.</span><span class="n">neverPrompted</span><span class="p">,</span> <span class="nv">expected</span><span class="p">:</span> <span class="kc">false</span><span class="p">),</span>
        <span class="p">(</span><span class="nv">hasPrompted</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nv">state</span><span class="p">:</span> <span class="kt">ImportPromptState</span><span class="o">.</span><span class="n">neverPrompted</span><span class="p">,</span> <span class="nv">expected</span><span class="p">:</span> <span class="kc">true</span><span class="p">),</span>
        <span class="p">(</span><span class="nv">hasPrompted</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span> <span class="nv">state</span><span class="p">:</span> <span class="kt">ImportPromptState</span><span class="o">.</span><span class="n">completed</span><span class="p">,</span> <span class="nv">expected</span><span class="p">:</span> <span class="kc">true</span><span class="p">),</span>
        <span class="p">(</span><span class="nv">hasPrompted</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">state</span><span class="p">:</span> <span class="kt">ImportPromptState</span><span class="o">.</span><span class="n">neverPrompted</span><span class="p">,</span> <span class="nv">expected</span><span class="p">:</span> <span class="kc">false</span><span class="p">),</span>
        <span class="p">(</span><span class="nv">hasPrompted</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">state</span><span class="p">:</span> <span class="kt">ImportPromptState</span><span class="o">.</span><span class="n">skippedOnce</span><span class="p">,</span> <span class="nv">expected</span><span class="p">:</span> <span class="kc">false</span><span class="p">),</span>
        <span class="p">(</span><span class="nv">hasPrompted</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">state</span><span class="p">:</span> <span class="kt">ImportPromptState</span><span class="o">.</span><span class="n">completed</span><span class="p">,</span> <span class="nv">expected</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>
    <span class="p">]</span>
<span class="p">)</span>
<span class="kd">func</span> <span class="nf">onboardingGate</span><span class="p">(</span>
    <span class="nv">hasPrompted</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">,</span>
    <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">,</span>
    <span class="nv">state</span><span class="p">:</span> <span class="kt">ImportPromptState</span><span class="p">,</span>
    <span class="nv">expected</span><span class="p">:</span> <span class="kt">Bool</span>
<span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">facts</span> <span class="o">=</span> <span class="kt">TipFacts</span><span class="o">.</span><span class="nf">compute</span><span class="p">(</span>
        <span class="nv">albumCount</span><span class="p">:</span> <span class="mi">0</span><span class="p">,</span>
        <span class="nv">ownsCopies</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
        <span class="nv">aiAvailable</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
        <span class="nv">hasPromptedForDiscogs</span><span class="p">:</span> <span class="n">hasPrompted</span><span class="p">,</span>
        <span class="nv">isAuthenticated</span><span class="p">:</span> <span class="n">isAuthenticated</span><span class="p">,</span>
        <span class="nv">importPromptState</span><span class="p">:</span> <span class="n">state</span>
    <span class="p">)</span>

    <span class="cp">#expect(facts.onboardingComplete == expected)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Rows two and three are the ones that matter: prompted, never authenticated, and the gate opens anyway. That’s the never-connected-Discogs user, pinned so no future “simplification” of the gate expression silences their tips again.</p>

<p>Layer two tests the rules against a real TipKit engine — because a truth table can’t tell you whether <code class="language-plaintext highlighter-rouge">#Rule</code> macros, donation counting, and parameter propagation actually compose. The suite is <code class="language-plaintext highlighter-rouge">.serialized</code> and <code class="language-plaintext highlighter-rouge">@MainActor</code>, and it configures TipKit exactly once per process, into a throwaway datastore, with <code class="language-plaintext highlighter-rouge">.immediate</code> frequency so display budgets don’t interfere with eligibility assertions:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Tips.configure(_:) is once-per-process and later calls are ignored, so</span>
<span class="c1">// every TipKit-touching test in this suite must share the same temp</span>
<span class="c1">// datastore + `.immediate` display frequency, configured exactly once here.</span>
<span class="kd">private</span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">configureOnce</span><span class="p">:</span> <span class="kt">Void</span> <span class="o">=</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">datastoreURL</span> <span class="o">=</span> <span class="kt">FileManager</span><span class="o">.</span><span class="k">default</span><span class="o">.</span><span class="n">temporaryDirectory</span>
        <span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"TipRulesTests-</span><span class="se">\(</span><span class="kt">UUID</span><span class="p">()</span><span class="o">.</span><span class="n">uuidString</span><span class="se">)</span><span class="s">"</span><span class="p">,</span> <span class="nv">isDirectory</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>
    <span class="k">try</span><span class="p">?</span> <span class="kt">FileManager</span><span class="o">.</span><span class="k">default</span><span class="o">.</span><span class="nf">createDirectory</span><span class="p">(</span><span class="nv">at</span><span class="p">:</span> <span class="n">datastoreURL</span><span class="p">,</span> <span class="nv">withIntermediateDirectories</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>
    <span class="k">try</span><span class="p">?</span> <span class="kt">Tips</span><span class="o">.</span><span class="nf">configure</span><span class="p">([</span><span class="o">.</span><span class="nf">datastoreLocation</span><span class="p">(</span><span class="o">.</span><span class="nf">url</span><span class="p">(</span><span class="n">datastoreURL</span><span class="p">)),</span> <span class="o">.</span><span class="nf">displayFrequency</span><span class="p">(</span><span class="o">.</span><span class="n">immediate</span><span class="p">)])</span>
<span class="p">}()</span>
</code></pre></div></div>

<p>Then each test flips parameters, donates events, and asserts <code class="language-plaintext highlighter-rouge">shouldDisplay</code> flips at the exact threshold — two donations no, three yes:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Test</span>
<span class="kd">func</span> <span class="nf">copyPhotosTipFlipsAtThreeAlbumDetailViews</span><span class="p">()</span> <span class="k">async</span> <span class="p">{</span>
    <span class="kt">InsightsTip</span><span class="o">.</span><span class="n">onboardingComplete</span> <span class="o">=</span> <span class="kc">true</span>
    <span class="kt">InsightsTip</span><span class="o">.</span><span class="n">ownsCopies</span> <span class="o">=</span> <span class="kc">true</span>
    <span class="k">try</span><span class="p">?</span> <span class="k">await</span> <span class="kt">TipEvents</span><span class="o">.</span><span class="n">albumDetailViewed</span><span class="o">.</span><span class="nf">deleteDonations</span><span class="p">()</span>

    <span class="k">await</span> <span class="kt">TipEvents</span><span class="o">.</span><span class="n">albumDetailViewed</span><span class="o">.</span><span class="nf">donate</span><span class="p">()</span>
    <span class="k">await</span> <span class="kt">TipEvents</span><span class="o">.</span><span class="n">albumDetailViewed</span><span class="o">.</span><span class="nf">donate</span><span class="p">()</span>
    <span class="cp">#expect(await settled { !CopyPhotosTip().shouldDisplay })</span>

    <span class="k">await</span> <span class="kt">TipEvents</span><span class="o">.</span><span class="n">albumDetailViewed</span><span class="o">.</span><span class="nf">donate</span><span class="p">()</span>
    <span class="cp">#expect(await settled { CopyPhotosTip().shouldDisplay })</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That <code class="language-plaintext highlighter-rouge">settled</code> helper is the hard-won part. The first <code class="language-plaintext highlighter-rouge">shouldDisplay</code> read in a process pays a one-time TipKit engine warm-up that no fixed delay reliably outlasts — I know because I tried the fixed delays first. Poll against a deadline instead; never guess a magic sleep:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// The very first `shouldDisplay` read in the process pays a one-time</span>
<span class="c1">// TipKit engine warm-up cost that a fixed delay can't reliably outlast;</span>
<span class="c1">// every read after that settles near-instantly. Poll instead of guessing</span>
<span class="c1">// a magic sleep duration. Timeout is generous because `TipFactsTests`</span>
<span class="c1">// (a separate, non-.serialized @Suite) can run concurrently with this</span>
<span class="c1">// suite under xcodebuild's default parallel testing and starve the</span>
<span class="c1">// TipKit engine's background settle — observed empirically, matches the</span>
<span class="c1">// project's documented `-parallel-testing-enabled NO` flake mitigation.</span>
<span class="kd">private</span> <span class="kd">func</span> <span class="nf">settled</span><span class="p">(</span><span class="nv">timeout</span><span class="p">:</span> <span class="kt">Duration</span> <span class="o">=</span> <span class="o">.</span><span class="nf">seconds</span><span class="p">(</span><span class="mi">10</span><span class="p">),</span> <span class="n">_</span> <span class="nv">condition</span><span class="p">:</span> <span class="p">()</span> <span class="o">-&gt;</span> <span class="kt">Bool</span><span class="p">)</span> <span class="k">async</span> <span class="o">-&gt;</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">deadline</span> <span class="o">=</span> <span class="kt">ContinuousClock</span><span class="o">.</span><span class="n">now</span> <span class="o">+</span> <span class="n">timeout</span>
    <span class="k">while</span> <span class="kt">ContinuousClock</span><span class="o">.</span><span class="n">now</span> <span class="o">&lt;</span> <span class="n">deadline</span> <span class="p">{</span>
        <span class="k">if</span> <span class="nf">condition</span><span class="p">()</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">true</span> <span class="p">}</span>
        <span class="k">try</span><span class="p">?</span> <span class="k">await</span> <span class="kt">Task</span><span class="o">.</span><span class="nf">sleep</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="o">.</span><span class="nf">milliseconds</span><span class="p">(</span><span class="mi">50</span><span class="p">))</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="nf">condition</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The last piece is the one that makes layer two possible at all. These tests run hosted inside the real app, and the real app configures TipKit and donates events at launch. Two configurers, one datastore, nondeterministic tests. So the host stands down whenever a suite is running:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="k">var</span> <span class="nv">isHostedTestRun</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
    <span class="kt">ProcessInfo</span><span class="o">.</span><span class="n">processInfo</span><span class="o">.</span><span class="n">environment</span><span class="p">[</span><span class="s">"XCTestConfigurationFilePath"</span><span class="p">]</span> <span class="o">!=</span> <span class="kc">nil</span>
<span class="p">}</span>
</code></pre></div></div>

<p>You’ve already seen it honored three times — <code class="language-plaintext highlighter-rouge">configureAtLaunch</code> returns early, <code class="language-plaintext highlighter-rouge">refreshFacts</code> guards, and every donation site in the views guards. When the suite runs, the app process is TipKit-silent, and the tests own the engine outright.</p>

<h2 id="of-wolf-and-man">Of Wolf and Man</h2>

<p>I assumed popover tips were sealed system chrome — style the inline <code class="language-plaintext highlighter-rouge">TipView</code>s, live with the stock popover. Turns out <code class="language-plaintext highlighter-rouge">tipBackground</code> and <code class="language-plaintext highlighter-rouge">tipCornerRadius</code> compose with both, so one five-line extension carries the design system to every tip in the app:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">extension</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="kd">func</span> <span class="nf">vinylTipStyle</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
        <span class="k">self</span>
            <span class="o">.</span><span class="nf">tipBackground</span><span class="p">(</span><span class="kt">AppColors</span><span class="o">.</span><span class="n">cardBackground</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">tipCornerRadius</span><span class="p">(</span><span class="kt">CornerRadius</span><span class="o">.</span><span class="n">medium</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Design tokens — <code class="language-plaintext highlighter-rouge">AppColors.cardBackground</code>, <code class="language-plaintext highlighter-rouge">CornerRadius.medium</code> — at exactly one call site. You saw it on the inline <code class="language-plaintext highlighter-rouge">TipView</code> in album detail; here it is on the popover anchor — same modifier, and also the simplest invalidate-at-the-action example in the app:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kt">Button</span> <span class="p">{</span>
    <span class="n">insightsTip</span><span class="o">.</span><span class="nf">invalidate</span><span class="p">(</span><span class="nv">reason</span><span class="p">:</span> <span class="o">.</span><span class="n">actionPerformed</span><span class="p">)</span>
    <span class="n">coordinator</span><span class="p">?</span><span class="o">.</span><span class="nf">presentSheet</span><span class="p">(</span><span class="o">.</span><span class="n">insights</span><span class="p">)</span>
<span class="p">}</span> <span class="nv">label</span><span class="p">:</span> <span class="p">{</span>
    <span class="kt">Image</span><span class="p">(</span><span class="nv">systemName</span><span class="p">:</span> <span class="s">"chart.bar.xaxis"</span><span class="p">)</span>
<span class="p">}</span>
<span class="o">.</span><span class="nf">popoverTip</span><span class="p">(</span><span class="n">insightsTip</span><span class="p">)</span>
<span class="o">.</span><span class="nf">vinylTipStyle</span><span class="p">()</span>
</code></pre></div></div>

<p>One Swift 6 note that will save you a confusing diagnostic. VinylCrate builds with default MainActor isolation, and <code class="language-plaintext highlighter-rouge">Tip</code> is nonisolated and <code class="language-plaintext highlighter-rouge">Sendable</code> in the SDK — so a tip struct that silently infers <code class="language-plaintext highlighter-rouge">@MainActor</code> breaks the conformance. Every tip declares itself out of the inference:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// Tip is nonisolated+Sendable in the SDK; default MainActor isolation would break the conformance</span>
<span class="kd">nonisolated</span> <span class="kd">struct</span> <span class="kt">DeadWaxTip</span><span class="p">:</span> <span class="kt">Tip</span> <span class="p">{</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">TipEvents</code> and <code class="language-plaintext highlighter-rouge">TipFacts</code> carry the same keyword for the same reason — they’re touched from nonisolated rule evaluation, not from your UI.</p>

<p>And one sentence on copy, because tips are UI text that renders far from anything a translator can see: every title and message in these files is <code class="language-plaintext highlighter-rouge">Text("…", comment:)</code>, auto-extracted into the String Catalog, and that comment is the <em>only</em> context a translator gets for a string like “No Barcode? Scan the Sleeve.” Write the comment accordingly.</p>

<h2 id="fade-to-black">Fade to Black</h2>

<p>What five tips in a shipping app taught me:</p>

<ol>
  <li><strong>Tip the features users won’t find. Skip what the UI already explains.</strong> The daily display budget is all the teaching you get — don’t spend it narrating labeled buttons.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">@Parameter</code> is recomputable state. <code class="language-plaintext highlighter-rouge">Tips.Event</code> is history.</strong> If you could rebuild it from scratch at launch, it’s a parameter. If you had to have been counting, it’s an event.</li>
  <li><strong>Gate every tip behind “no launch UI can still be in flight.”</strong> Model the user who opts out, not just the one who completes onboarding — the naive gate silences tips for exactly the users who declined something.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">Tips.configure</code> ordering makes it an <code class="language-plaintext highlighter-rouge">App.init</code> concern, and once-per-process makes debug tooling staged.</strong> Overrides before configure, configure before the first anchor renders, and QA actions that honestly say “next launch.”</li>
  <li><strong>Type names and event IDs are persistence. Freeze them like API.</strong> A rename is a data migration you didn’t write.</li>
  <li>And test both layers: the gate logic as a pure truth table, the rules against a real temp-directory datastore — and poll for the engine to settle instead of guessing at sleeps.</li>
</ol>

<p>The tips have been live for a while now. Each one shows up a handful of times, gets invalidated the moment the user does the thing, and is gone — <code class="language-plaintext highlighter-rouge">MaxDisplayCount</code> guarantees it. That’s the odd success criterion for this entire feature: it works by disappearing. Nobody will ever compliment the Copy Photos tip. But the Insights door isn’t invisible anymore — people are walking through it who never would have found it — and not one of them remembers being taught.</p>]]></content><author><name>Wesley Matlock</name></author><category term="tipkit" /><category term="swiftui" /><category term="swift" /><category term="ios" /><summary type="html"><![CDATA[TipKit in a shipping app: parameters vs. events, onboarding gates, once-per-process configure, and real tests — lessons from VinylCrate on iOS 26.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.wesleymatlock.com/images/blog/2026-08-24-tipkit-deep-dive-shipping-app/hero.png" /><media:content medium="image" url="https://www.wesleymatlock.com/images/blog/2026-08-24-tipkit-deep-dive-shipping-app/hero.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">OCR Doesn’t Give You Text. It Gives You a Map.</title><link href="https://www.wesleymatlock.com/ocr-doesn-t-give-you-text-it-gives-you-a-map/" rel="alternate" type="text/html" title="OCR Doesn’t Give You Text. It Gives You a Map." /><published>2026-08-14T00:00:00+00:00</published><updated>2026-08-14T00:00:00+00:00</updated><id>https://www.wesleymatlock.com/ocr-doesn-t-give-you-text-it-gives-you-a-map</id><content type="html" xml:base="https://www.wesleymatlock.com/ocr-doesn-t-give-you-text-it-gives-you-a-map/"><![CDATA[<p>My parser grabbed the first number it saw on the back of a record sleeve. On my test photos, that number was the catalog number — top corner, right where I expected it. On a real sleeve from my own shelf, the first number Vision handed me was the barcode digits.</p>

<p>That was the first of four bugs I fixed in a single day in the sleeve scanner in <a href="/on-device-vision-foundation-models/">VinylCrate</a>, my vinyl-collection app. Four different symptoms, four different fixes, one root cause: I was treating Vision’s output as a string when it’s actually a map.</p>

<p>Vision doesn’t give you a document. It gives you a bag of observations — strings pinned to normalized bounding boxes, in whatever order the recognizer felt like emitting them. Reading order isn’t in there. Word boundaries aren’t in there. Layout isn’t in there. You have coordinates, and everything you think of as “the text” is something you compute from those coordinates. Skip that step and your parser works on your three test photos and fails on the first sleeve that came out of a different pressing plant.</p>

<p>Here’s the setup, the four bugs, and — because every one of these survived my manual testing — the fixture that now pins each one down.</p>

<h2 id="what-vision-actually-hands-you">What Vision Actually Hands You</h2>

<p>The modern Swift Vision API makes the request itself almost boring. <code class="language-plaintext highlighter-rouge">RecognizeTextRequest</code> is a value type, <code class="language-plaintext highlighter-rouge">perform</code> is async, and the whole thing is clean with strict concurrency:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">Vision</span>

<span class="kd">struct</span> <span class="kt">TextBox</span><span class="p">:</span> <span class="kt">Equatable</span><span class="p">,</span> <span class="kt">Sendable</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">string</span><span class="p">:</span> <span class="kt">String</span>
    <span class="k">var</span> <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">recognizeText</span><span class="p">(</span><span class="k">in</span> <span class="nv">imageData</span><span class="p">:</span> <span class="kt">Data</span><span class="p">)</span> <span class="k">async</span> <span class="k">throws</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">TextBox</span><span class="p">]</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">request</span> <span class="o">=</span> <span class="kt">RecognizeTextRequest</span><span class="p">()</span>
    <span class="n">request</span><span class="o">.</span><span class="n">recognitionLevel</span> <span class="o">=</span> <span class="o">.</span><span class="n">accurate</span>
    <span class="n">request</span><span class="o">.</span><span class="n">usesLanguageCorrection</span> <span class="o">=</span> <span class="kc">false</span>

    <span class="k">let</span> <span class="nv">observations</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">request</span><span class="o">.</span><span class="nf">perform</span><span class="p">(</span><span class="nv">on</span><span class="p">:</span> <span class="n">imageData</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">observations</span><span class="o">.</span><span class="n">compactMap</span> <span class="p">{</span> <span class="n">observation</span> <span class="k">in</span>
        <span class="k">guard</span> <span class="k">let</span> <span class="nv">candidate</span> <span class="o">=</span> <span class="n">observation</span><span class="o">.</span><span class="nf">topCandidates</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span><span class="o">.</span><span class="n">first</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">nil</span> <span class="p">}</span>
        <span class="k">return</span> <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="n">candidate</span><span class="o">.</span><span class="n">string</span><span class="p">,</span> <span class="nv">box</span><span class="p">:</span> <span class="n">observation</span><span class="o">.</span><span class="n">boundingBox</span><span class="o">.</span><span class="n">cgRect</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two decisions in there matter. Language correction is off because — as the comment in VinylCrate’s shipping recognizer puts it — catalog codes aren’t words. <code class="language-plaintext highlighter-rouge">60439-1</code> isn’t in anyone’s dictionary, a matrix etching is exactly the kind of string a language model “helpfully” rewrites into English, and band names and track titles are worse: half of them are stylized past anything a dictionary expects. And I immediately flatten each observation into a <code class="language-plaintext highlighter-rouge">TextBox</code> — a string and a rect, nothing else. Every helper downstream is a pure function over <code class="language-plaintext highlighter-rouge">[TextBox]</code>. That’s not an aesthetic choice. It’s what makes the fixtures possible: I can pin every geometric decision with an array literal instead of a photo shoot. (Keeping the rect earns its keep elsewhere too — the dead-wax scanner uses the same geometry to exclude the label region from live scans.)</p>

<p>What comes back from <code class="language-plaintext highlighter-rouge">perform</code> is where the trouble starts. An array of observations that looks ordered, reads ordered in the debugger for simple images, and is not ordered.</p>

<h2 id="bug-1-the-array-is-not-the-sleeve">Bug 1: The Array Is Not the Sleeve</h2>

<p><strong>The symptom.</strong> A back cover with the classic two-column tracklist — titles down the left, runtimes down the right — came out as <code class="language-plaintext highlighter-rouge">BATTERY</code>, <code class="language-plaintext highlighter-rouge">MASTER OF PUPPETS</code>, <code class="language-plaintext highlighter-rouge">5:10</code>, <code class="language-plaintext highlighter-rouge">8:38</code>. My parser, walking the array looking for the first duration “after” each title, gave both tracks the same runtime — and decided an eight-and-a-half-minute track ran 5:10.</p>

<p><strong>The wrong assumption.</strong> That the observation array is in reading order. It roughly tracks the image top-to-bottom for simple layouts, which is exactly the kind of roughly that survives every demo and dies in production. The moment a sleeve has columns, offset side-one/side-two blocks, or a title set on a diagonal, the recognizer interleaves regions freely. Nothing in the API contract says otherwise. I had just never read the contract.</p>

<p><strong>The fix.</strong> Reading order is a spatial property, so compute it spatially. Cluster observations into lines by vertical midpoint — with a tolerance, because two boxes on the same printed line never share an exact <code class="language-plaintext highlighter-rouge">midY</code> — then sort lines top-to-bottom and members left-to-right:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">clusterLines</span><span class="p">(</span><span class="n">_</span> <span class="nv">boxes</span><span class="p">:</span> <span class="p">[</span><span class="kt">TextBox</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="p">[[</span><span class="kt">TextBox</span><span class="p">]]</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="o">!</span><span class="n">boxes</span><span class="o">.</span><span class="n">isEmpty</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="p">[]</span> <span class="p">}</span>
    <span class="k">let</span> <span class="nv">sorted</span> <span class="o">=</span> <span class="n">boxes</span><span class="o">.</span><span class="n">sorted</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">midY</span> <span class="o">&lt;</span> <span class="nv">$1</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">midY</span> <span class="p">}</span>
    <span class="k">let</span> <span class="nv">medianHeight</span> <span class="o">=</span> <span class="n">sorted</span><span class="o">.</span><span class="nf">map</span><span class="p">(\</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">height</span><span class="p">)</span><span class="o">.</span><span class="nf">sorted</span><span class="p">()[</span><span class="n">sorted</span><span class="o">.</span><span class="n">count</span> <span class="o">/</span> <span class="mi">2</span><span class="p">]</span>
    <span class="k">let</span> <span class="nv">tolerance</span> <span class="o">=</span> <span class="n">medianHeight</span> <span class="o">*</span> <span class="mf">0.6</span>

    <span class="k">var</span> <span class="nv">lines</span><span class="p">:</span> <span class="p">[[</span><span class="kt">TextBox</span><span class="p">]]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="k">var</span> <span class="nv">current</span> <span class="o">=</span> <span class="p">[</span><span class="n">sorted</span><span class="p">[</span><span class="mi">0</span><span class="p">]]</span>

    <span class="k">for</span> <span class="n">box</span> <span class="k">in</span> <span class="n">sorted</span><span class="o">.</span><span class="nf">dropFirst</span><span class="p">()</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">lineMidY</span> <span class="o">=</span> <span class="n">current</span><span class="o">.</span><span class="nf">map</span><span class="p">(\</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">midY</span><span class="p">)</span><span class="o">.</span><span class="nf">reduce</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="o">+</span><span class="p">)</span> <span class="o">/</span> <span class="kt">CGFloat</span><span class="p">(</span><span class="n">current</span><span class="o">.</span><span class="n">count</span><span class="p">)</span>
        <span class="k">if</span> <span class="nf">abs</span><span class="p">(</span><span class="n">box</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">midY</span> <span class="o">-</span> <span class="n">lineMidY</span><span class="p">)</span> <span class="o">&lt;=</span> <span class="n">tolerance</span> <span class="p">{</span>
            <span class="n">current</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="n">box</span><span class="p">)</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            <span class="n">lines</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="n">current</span><span class="p">)</span>
            <span class="n">current</span> <span class="o">=</span> <span class="p">[</span><span class="n">box</span><span class="p">]</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="n">lines</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="n">current</span><span class="p">)</span>

    <span class="k">return</span> <span class="n">lines</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">sorted</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">minX</span> <span class="o">&lt;</span> <span class="nv">$1</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">minX</span> <span class="p">}</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The tolerance is relative to the median box height, not a magic constant in normalized units, so it holds up whether the sleeve fills the frame or sits in a corner of the photo.</p>

<p><strong>The fixture.</strong> Four boxes, deliberately fed in the interleaved order Vision actually produced. No image required — the geometry is the fixture:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Test</span> <span class="kd">func</span> <span class="nf">twoColumnTracklistReadsRowByRow</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">boxes</span> <span class="o">=</span> <span class="p">[</span>
        <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"BATTERY"</span><span class="p">,</span>           <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.10</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.20</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.04</span><span class="p">)),</span>
        <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"MASTER OF PUPPETS"</span><span class="p">,</span> <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.18</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.40</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.04</span><span class="p">)),</span>
        <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"5:10"</span><span class="p">,</span>              <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.55</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.10</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.08</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.04</span><span class="p">)),</span>
        <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"8:38"</span><span class="p">,</span>              <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.55</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.18</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.08</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.04</span><span class="p">)),</span>
    <span class="p">]</span>
    <span class="k">let</span> <span class="nv">lines</span> <span class="o">=</span> <span class="nf">clusterLines</span><span class="p">(</span><span class="n">boxes</span><span class="p">)</span><span class="o">.</span><span class="nf">map</span><span class="p">(</span><span class="n">joinLine</span><span class="p">)</span>
    <span class="cp">#expect(lines == ["BATTERY 5:10", "MASTER OF PUPPETS 8:38"])</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If anyone — including me, six months from now — “simplifies” the clustering back into an array walk, this test fails with a message that explains the whole bug.</p>

<h2 id="bug-2-y-grows-up">Bug 2: Y Grows Up</h2>

<p><strong>The symptom.</strong> The first version of <code class="language-plaintext highlighter-rouge">clusterLines</code> shipped with a different sort, and the output read like the sleeve had been fed through the scanner bottom-up. Barcode and ℗ line first, album title last. Perfectly ordered. Perfectly backwards.</p>

<p><strong>The wrong assumption.</strong> That <code class="language-plaintext highlighter-rouge">origin.y</code> means what UIKit has trained me to think it means. Vision’s normalized coordinate space puts the origin at the <em>bottom-left</em>, y increasing upward — image-processing convention, not screen convention. Sorting by <code class="language-plaintext highlighter-rouge">minY</code> ascending walks the sleeve from the bottom. Worse, any box math you do in the wrong space is quietly broken: “is this box above that one” flips meaning, and vertical-overlap checks compare the wrong edges.</p>

<p><strong>The fix.</strong> Convert once, at the boundary, and let everything downstream live in top-left space. If you’re projecting into image pixels anyway, the API will do it for you — <code class="language-plaintext highlighter-rouge">boundingBox.toImageCoordinates(imageSize, origin: .upperLeft)</code>. For normalized-space work, the flip is one line:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">topLeftRect</span><span class="p">(</span><span class="n">_</span> <span class="nv">visionRect</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">CGRect</span> <span class="p">{</span>
    <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="n">visionRect</span><span class="o">.</span><span class="n">minX</span><span class="p">,</span>
           <span class="nv">y</span><span class="p">:</span> <span class="mi">1</span> <span class="o">-</span> <span class="n">visionRect</span><span class="o">.</span><span class="n">maxY</span><span class="p">,</span>
           <span class="nv">width</span><span class="p">:</span> <span class="n">visionRect</span><span class="o">.</span><span class="n">width</span><span class="p">,</span>
           <span class="nv">height</span><span class="p">:</span> <span class="n">visionRect</span><span class="o">.</span><span class="n">height</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">recognizeText</code> now applies this before anything else sees a rect. One conversion, one place. The alternative — sprinkling <code class="language-plaintext highlighter-rouge">1 - y</code> through the codebase wherever things look upside down — is how you end up with code that flips twice and looks correct in exactly one screen orientation.</p>

<p><strong>The fixture.</strong> Two boxes in raw Vision space: the album title near the top of the sleeve (high y, in Vision’s terms) and the ℗ line near the bottom. The test pins the relationship, not the arithmetic:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Test</span> <span class="kd">func</span> <span class="nf">visionSpaceFlipsToReadingOrder</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">albumTitle</span>    <span class="o">=</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.85</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.40</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">)</span>
    <span class="k">let</span> <span class="nv">copyrightLine</span> <span class="o">=</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.30</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.04</span><span class="p">)</span>
    <span class="cp">#expect(topLeftRect(albumTitle).midY &lt; topLeftRect(copyrightLine).midY)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It looks almost too small to bother with. It has already caught one refactor where a well-meaning cleanup removed the “redundant” flip.</p>

<h2 id="bug-3-word-boundaries-are-load-bearing">Bug 3: Word Boundaries Are Load-Bearing</h2>

<p><strong>The symptom.</strong> <code class="language-plaintext highlighter-rouge">MASTEROFPUPPETS</code>. Also, on a different sleeve, <code class="language-plaintext highlighter-rouge">METALLI CA</code> — the same code, failing in the opposite direction.</p>

<p><strong>The wrong assumption.</strong> That joining strings is a string problem. My first pass used <code class="language-plaintext highlighter-rouge">joined()</code> — words fused. The obvious “fix” was <code class="language-plaintext highlighter-rouge">joined(separator: " ")</code> — and now words that Vision had split <em>mid-word</em> across two observations grew a space in the middle. There is no string-level answer here, because whether two observations deserve a space between them isn’t a property of the strings. It’s a property of the <em>gap between their boxes</em>.</p>

<p><strong>The fix.</strong> Measure the gap and compare it to the local character width. If the horizontal distance between two boxes is a meaningful fraction of an average character in those boxes, it’s a word boundary. If it’s a sliver, it’s the same word:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">joinLine</span><span class="p">(</span><span class="n">_</span> <span class="nv">line</span><span class="p">:</span> <span class="p">[</span><span class="kt">TextBox</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="k">var</span> <span class="nv">result</span> <span class="o">=</span> <span class="n">line</span><span class="o">.</span><span class="n">first</span><span class="p">?</span><span class="o">.</span><span class="n">string</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="s">""</span> <span class="p">}</span>
    <span class="k">for</span> <span class="p">(</span><span class="n">previous</span><span class="p">,</span> <span class="n">next</span><span class="p">)</span> <span class="k">in</span> <span class="nf">zip</span><span class="p">(</span><span class="n">line</span><span class="p">,</span> <span class="n">line</span><span class="o">.</span><span class="nf">dropFirst</span><span class="p">())</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">gap</span> <span class="o">=</span> <span class="n">next</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">minX</span> <span class="o">-</span> <span class="n">previous</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">maxX</span>
        <span class="k">let</span> <span class="nv">averageCharWidth</span> <span class="o">=</span> <span class="p">(</span><span class="n">previous</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">width</span> <span class="o">+</span> <span class="n">next</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">width</span><span class="p">)</span>
            <span class="o">/</span> <span class="kt">CGFloat</span><span class="p">(</span><span class="n">previous</span><span class="o">.</span><span class="n">string</span><span class="o">.</span><span class="n">count</span> <span class="o">+</span> <span class="n">next</span><span class="o">.</span><span class="n">string</span><span class="o">.</span><span class="n">count</span><span class="p">)</span>
        <span class="n">result</span> <span class="o">+=</span> <span class="n">gap</span> <span class="o">&gt;</span> <span class="n">averageCharWidth</span> <span class="o">*</span> <span class="mf">0.35</span> <span class="p">?</span> <span class="s">" "</span> <span class="o">+</span> <span class="n">next</span><span class="o">.</span><span class="nv">string</span> <span class="p">:</span> <span class="n">next</span><span class="o">.</span><span class="n">string</span>
    <span class="p">}</span>
    <span class="k">return</span> <span class="n">result</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The 0.35 came from measuring real sleeves, and I’ll be honest: it’s a heuristic, and heuristics over photographs of forty-year-old ring-worn cardboard have losing days. But it’s a heuristic about geometry, which means it degrades gracefully — where <code class="language-plaintext highlighter-rouge">joined(separator: " ")</code> was a coin flip dressed up as a decision.</p>

<p><strong>The fixture.</strong> Both directions, in one test, because this bug fails both ways and a fix for one direction can silently reintroduce the other:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Test</span> <span class="kd">func</span> <span class="nf">wordBoundariesComeFromGeometryNotLuck</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">title</span> <span class="o">=</span> <span class="p">[</span>
        <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"RIDE"</span><span class="p">,</span>      <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.40</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.10</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.04</span><span class="p">)),</span>
        <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"THE"</span><span class="p">,</span>       <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.17</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.40</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.08</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.04</span><span class="p">)),</span>
        <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"LIGHTNING"</span><span class="p">,</span> <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.27</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.40</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.22</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.04</span><span class="p">)),</span>
    <span class="p">]</span>
    <span class="cp">#expect(joinLine(title) == "RIDE THE LIGHTNING")</span>

    <span class="k">let</span> <span class="nv">splitWord</span> <span class="o">=</span> <span class="p">[</span>
        <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"METALLI"</span><span class="p">,</span> <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.050</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.10</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.21</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">)),</span>
        <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"CA"</span><span class="p">,</span>      <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.266</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.10</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.06</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">)),</span>
    <span class="p">]</span>
    <span class="cp">#expect(joinLine(splitWord) == "METALLICA")</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The rects in <code class="language-plaintext highlighter-rouge">splitWord</code> are lifted straight from the failing sleeve’s debug output. That’s the move worth stealing: when a real image breaks you, don’t just fix the code — harvest the coordinates into a fixture before you lose the photo.</p>

<h2 id="bug-4-proximity-beats-order">Bug 4: Proximity Beats Order</h2>

<p><strong>The symptom.</strong> One track’s runtime came back as <code class="language-plaintext highlighter-rouge">60439-1</code> — the catalog number. The parser found the track title, then took “the next observation” as its duration. In array order, the next observation was whatever the recognizer emitted next — which, per bug 1, could be anything on the sleeve, including the catalog block.</p>

<p><strong>The wrong assumption.</strong> The subtlest one, because it <em>felt</em> fixed. I had line clustering. I had geometric joining. And then, for title–duration association, I reached for array order one more time — <code class="language-plaintext highlighter-rouge">firstIndex(of:)</code> plus one. The title and its runtime sit on the same printed line, but on a two-column tracklist they can arrive dozens of positions apart in the array, with half the sleeve between them.</p>

<p><strong>The fix.</strong> The value for a label is the nearest observation to its right <em>on the same baseline</em> — a geometric query, not an index:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">verticalOverlap</span><span class="p">(</span><span class="n">_</span> <span class="nv">a</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">,</span> <span class="n">_</span> <span class="nv">b</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">CGFloat</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">overlap</span> <span class="o">=</span> <span class="nf">min</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">maxY</span><span class="p">,</span> <span class="n">b</span><span class="o">.</span><span class="n">maxY</span><span class="p">)</span> <span class="o">-</span> <span class="nf">max</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">minY</span><span class="p">,</span> <span class="n">b</span><span class="o">.</span><span class="n">minY</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">overlap</span> <span class="o">/</span> <span class="nf">min</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">height</span><span class="p">,</span> <span class="n">b</span><span class="o">.</span><span class="n">height</span><span class="p">)</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">value</span><span class="p">(</span><span class="k">for</span> <span class="nv">label</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="k">in</span> <span class="nv">boxes</span><span class="p">:</span> <span class="p">[</span><span class="kt">TextBox</span><span class="p">])</span> <span class="o">-&gt;</span> <span class="kt">TextBox</span><span class="p">?</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="k">let</span> <span class="nv">anchor</span> <span class="o">=</span> <span class="n">boxes</span><span class="o">.</span><span class="nf">first</span><span class="p">(</span><span class="nv">where</span><span class="p">:</span> <span class="p">{</span>
        <span class="nv">$0</span><span class="o">.</span><span class="n">string</span><span class="o">.</span><span class="nf">localizedCaseInsensitiveContains</span><span class="p">(</span><span class="n">label</span><span class="p">)</span>
    <span class="p">})</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="kc">nil</span> <span class="p">}</span>

    <span class="k">return</span> <span class="n">boxes</span>
        <span class="o">.</span><span class="n">filter</span> <span class="p">{</span>
            <span class="nv">$0</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">minX</span> <span class="o">&gt;</span> <span class="n">anchor</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">maxX</span>
                <span class="o">&amp;&amp;</span> <span class="nf">verticalOverlap</span><span class="p">(</span><span class="nv">$0</span><span class="o">.</span><span class="n">box</span><span class="p">,</span> <span class="n">anchor</span><span class="o">.</span><span class="n">box</span><span class="p">)</span> <span class="o">&gt;</span> <span class="mf">0.5</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="n">min</span> <span class="p">{</span>
            <span class="p">(</span><span class="nv">$0</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">minX</span> <span class="o">-</span> <span class="n">anchor</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">maxX</span><span class="p">)</span> <span class="o">&lt;</span> <span class="p">(</span><span class="nv">$1</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">minX</span> <span class="o">-</span> <span class="n">anchor</span><span class="o">.</span><span class="n">box</span><span class="o">.</span><span class="n">maxX</span><span class="p">)</span>
        <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Requiring 50% vertical overlap — rather than a <code class="language-plaintext highlighter-rouge">midY</code> distance — handles boxes of different heights sharing a baseline, which happens constantly when a track title set in one weight sits next to a runtime set in another. And <code class="language-plaintext highlighter-rouge">min</code> by horizontal distance means a barcode two columns over never wins against the duration printed on the same line.</p>

<p><strong>The fixture.</strong> The array order is the trap, so the fixture bakes the trap in — the catalog number sits between the title and its runtime, exactly where the old code would grab it:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Test</span> <span class="kd">func</span> <span class="nf">nearestOnBaselineBeatsNextInArray</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">boxes</span> <span class="o">=</span> <span class="p">[</span>
        <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"ORION"</span><span class="p">,</span>   <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.60</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.10</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.04</span><span class="p">)),</span>
        <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"60439-1"</span><span class="p">,</span> <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.30</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.72</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.14</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.04</span><span class="p">)),</span>
        <span class="kt">TextBox</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="s">"8:12"</span><span class="p">,</span>    <span class="nv">box</span><span class="p">:</span> <span class="kt">CGRect</span><span class="p">(</span><span class="nv">x</span><span class="p">:</span> <span class="mf">0.55</span><span class="p">,</span> <span class="nv">y</span><span class="p">:</span> <span class="mf">0.60</span><span class="p">,</span> <span class="nv">width</span><span class="p">:</span> <span class="mf">0.08</span><span class="p">,</span> <span class="nv">height</span><span class="p">:</span> <span class="mf">0.04</span><span class="p">)),</span>
    <span class="p">]</span>
    <span class="cp">#expect(value(for: "ORION", in: boxes)?.string == "8:12")</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The catalog number even passes the “is it to the right of the anchor” check. It dies on the vertical-overlap filter — it’s on a different line, and no amount of array adjacency changes that.</p>

<h2 id="one-fixture-that-is-a-photo">One Fixture That Is a Photo</h2>

<p>Pure-geometry fixtures carry most of the weight, but they all share one blind spot: they trust my mental model of what Vision emits. So the suite ends with a fixture that <em>is</em> an image — a sleeve pulled off my own shelf, back cover photographed flat, checked into the test bundle, and run through the real request:</p>

<picture>
  <source srcset="/images/blog/2026-08-14-ocr-doesn-t-give-you-text-it-gives-you-a-map/sleeve-back-two-column.webp" type="image/webp" />
  <img src="/images/blog/2026-08-14-ocr-doesn-t-give-you-text-it-gives-you-a-map/sleeve-back-two-column.jpg" alt="The back cover of Metallica's Master of Puppets (Elektra 60439-1): Side I and Side II tracklists in two columns across the top with durations in small type beside each title, band photos in the center, and the barcode in the upper right — the exact two-column layout that defeats array-order parsing" loading="lazy" decoding="async" />
</picture>

<p>I wrote the obvious assertions first: the joined lines should contain <code class="language-plaintext highlighter-rouge">MASTER OF PUPPETS 8:38</code>, and <code class="language-plaintext highlighter-rouge">value(for: "ORION")</code> should hand back <code class="language-plaintext highlighter-rouge">8:12</code>, because that’s what’s printed on the sleeve.</p>

<p>The photo failed both immediately — and the failure is the most instructive thing in this post. The runtimes on this pressing are set in type small enough that Vision doesn’t emit them as observations at all. It fuses a garbled fragment into the title’s own box: <code class="language-plaintext highlighter-rouge">ORION •1</code>, one observation, one rect. There is no duration box anywhere in the output. <code class="language-plaintext highlighter-rouge">value(for: "ORION")</code> isn’t wrong — it’s <code class="language-plaintext highlighter-rouge">nil</code>, because the thing I was querying for doesn’t exist in the map. Tiny type claimed other casualties too: <code class="language-plaintext highlighter-rouge">CUFF BURTON</code>.</p>

<p>So the test pins what the pipeline actually guarantees on this photograph — rows pair across the column gutter, reading order runs top-down, the nearest-on-baseline query finds the true row partner, and the missing duration stays honestly missing:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Test</span> <span class="kd">func</span> <span class="nf">realSleevePhotoSurvivesTheWholePipeline</span><span class="p">()</span> <span class="k">async</span> <span class="k">throws</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="k">try</span> <span class="err">#</span><span class="nf">require</span><span class="p">(</span>
        <span class="kt">Bundle</span><span class="o">.</span><span class="n">module</span><span class="o">.</span><span class="nf">url</span><span class="p">(</span><span class="nv">forResource</span><span class="p">:</span> <span class="s">"sleeve-back-two-column"</span><span class="p">,</span> <span class="nv">withExtension</span><span class="p">:</span> <span class="s">"heic"</span><span class="p">)</span>
    <span class="p">)</span>
    <span class="k">let</span> <span class="nv">boxes</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="nf">recognizeText</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="kt">Data</span><span class="p">(</span><span class="nv">contentsOf</span><span class="p">:</span> <span class="n">url</span><span class="p">))</span>
    <span class="k">let</span> <span class="nv">lines</span> <span class="o">=</span> <span class="nf">clusterLines</span><span class="p">(</span><span class="n">boxes</span><span class="p">)</span><span class="o">.</span><span class="nf">map</span><span class="p">(</span><span class="n">joinLine</span><span class="p">)</span>

    <span class="k">let</span> <span class="nv">puppetsRow</span> <span class="o">=</span> <span class="k">try</span> <span class="err">#</span><span class="nf">require</span><span class="p">(</span><span class="n">lines</span><span class="o">.</span><span class="n">first</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="s">"MASTER OF PUPPETS"</span><span class="p">)</span> <span class="p">})</span>
    <span class="cp">#expect(puppetsRow.contains("LEPER MESSIAH"))</span>

    <span class="k">let</span> <span class="nv">batteryIndex</span> <span class="o">=</span> <span class="k">try</span> <span class="err">#</span><span class="nf">require</span><span class="p">(</span><span class="n">lines</span><span class="o">.</span><span class="n">firstIndex</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="s">"BATTERY"</span><span class="p">)</span> <span class="p">})</span>
    <span class="k">let</span> <span class="nv">creditsIndex</span> <span class="o">=</span> <span class="k">try</span> <span class="err">#</span><span class="nf">require</span><span class="p">(</span><span class="n">lines</span><span class="o">.</span><span class="n">firstIndex</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="s">"PRODUCED BY"</span><span class="p">)</span> <span class="p">})</span>
    <span class="cp">#expect(batteryIndex &lt; creditsIndex)</span>

    <span class="k">let</span> <span class="nv">rowPartner</span> <span class="o">=</span> <span class="nf">value</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="s">"THE THING THAT SHOULD NOT BE"</span><span class="p">,</span> <span class="nv">in</span><span class="p">:</span> <span class="n">boxes</span><span class="p">)</span>
    <span class="cp">#expect(rowPartner?.string.hasPrefix("ORION") == true)</span>

    <span class="cp">#expect(value(for: "ORION", in: boxes) == nil)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That last <code class="language-plaintext highlighter-rouge">#expect</code> is the one I’d defend in review. It documents a real property of real input — on this sleeve, at this type size, durations are not recoverable as separate fields — and it will fail loudly the day a Vision update starts splitting them, which is exactly when I’d want to revisit the parser.</p>

<p>This test is slower than the others and slightly at the mercy of the recognizer’s mood across OS releases. It’s also the only one that would have caught all four bugs at once, because it exercises the actual contract: observations in whatever order, in Vision’s coordinate space, split — or fused — wherever the recognizer chose. The array-literal fixtures explain <em>which</em> assumption broke. The photo proves the assumptions cover reality, and when the two disagree, the photo wins the argument.</p>

<h2 id="the-map-is-the-territory-you-get">The Map Is the Territory You Get</h2>

<p>Four bugs, and every one of them was the same bug wearing a different jacket: I kept treating a spatial data structure as a sequential one. The array order lied. The coordinate origin lied. The whitespace never existed. The “next” element was a stranger from another column.</p>

<p>OCR output is a map, not a string. Reading order is something you compute from y. Word boundaries are something you compute from gaps. Field association is something you compute from proximity — because on a printed page, proximity beats order every single time.</p>

<p>And every geometric assumption you make deserves a fixture, because these bugs have a signature failure mode: they work on your three test photos. The sleeve that breaks you will come from a pressing plant you’ve never seen, with a layout you didn’t imagine, and the only question is whether the coordinates it exposes end up in your test suite or in your support inbox.</p>

<p>Mine go in the test suite now. Harvest the rects, pin the behavior, keep the photo.</p>]]></content><author><name>Wesley Matlock</name></author><category term="ios" /><category term="swift" /><category term="vision" /><category term="vinylcrate" /><category term="vision" /><category term="ocr" /><category term="swift-testing" /><summary type="html"><![CDATA[Four Vision OCR bugs in VinylCrate's sleeve scanner — reading order, coordinate origin, word boundaries, proximity — each pinned with a Swift Testing fixture.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.wesleymatlock.com/images/blog/2026-08-14-ocr-doesn-t-give-you-text-it-gives-you-a-map/hero.png" /><media:content medium="image" url="https://www.wesleymatlock.com/images/blog/2026-08-14-ocr-doesn-t-give-you-text-it-gives-you-a-map/hero.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Four Ways xcodebuild Lied to Me</title><link href="https://www.wesleymatlock.com/four-ways-xcodebuild-lied-to-me/" rel="alternate" type="text/html" title="Four Ways xcodebuild Lied to Me" /><published>2026-08-02T00:00:00+00:00</published><updated>2026-08-02T00:00:00+00:00</updated><id>https://www.wesleymatlock.com/four-ways-xcodebuild-lied-to-me</id><content type="html" xml:base="https://www.wesleymatlock.com/four-ways-xcodebuild-lied-to-me/"><![CDATA[<p><code class="language-plaintext highlighter-rouge">xcodebuild</code> doesn’t fail loudly when it matters. It succeeds quietly when it shouldn’t.</p>

<p>That’s the dangerous part. A tool that crashes gets fixed. A tool that prints <code class="language-plaintext highlighter-rouge">** BUILD SUCCEEDED **</code> while doing something subtly different from what you asked gets trusted — right up until a release build ships without your fix in it, or your CI pipeline spends six months green-lighting tests against binaries that were never rebuilt.</p>

<p>I’ve hit every one of these in real pipelines. Some on personal projects like <a href="/on-device-vision-foundation-models/">VinylCrate</a>, some on airline-scale CI where a lie in the build log costs a lot more than an afternoon. This is the field guide I wish someone had handed me. Four traps: the lying command, what’s actually happening under the hood, and the invocation that tells the truth.</p>

<h2 id="trap-1-build-succeeded-that-never-built-your-app">Trap 1: “BUILD SUCCEEDED” That Never Built Your App</h2>

<p>Here’s the command that lied to me first, years ago, and still lies to people every day:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cd </span>VinylCrate
xcodebuild build
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">** BUILD SUCCEEDED **</code>. Exit code 0. CI goes green. And your app target may never have compiled.</p>

<p>When you give <code class="language-plaintext highlighter-rouge">xcodebuild</code> no <code class="language-plaintext highlighter-rouge">-scheme</code> and no <code class="language-plaintext highlighter-rouge">-target</code>, it doesn’t guess your intent. It builds the <em>first target listed in the project file</em>, with the default configuration. Target order is a property of the <code class="language-plaintext highlighter-rouge">.xcodeproj</code> itself (same for every user, and largely an accident of history). If someone once dragged a shared framework or a helper tool to the top of the target list, that’s what you just built. Your app, your tests, your script phases — untouched. <code class="language-plaintext highlighter-rouge">BUILD SUCCEEDED</code> is technically true. It’s just not about the thing you care about.</p>

<p>The workspace variant of this lie is nastier because it’s louder in the wrong direction: <code class="language-plaintext highlighter-rouge">xcodebuild -workspace VinylCrate.xcworkspace build</code> fails and demands a scheme — which trains you to think <code class="language-plaintext highlighter-rouge">xcodebuild</code> will always complain when it’s confused. It won’t. Point it at a bare project and it happily builds <em>something</em>.</p>

<p>The fix is to never let <code class="language-plaintext highlighter-rouge">xcodebuild</code> choose. First, ask what it can see:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>xcodebuild <span class="nt">-list</span> <span class="nt">-workspace</span> VinylCrate.xcworkspace
</code></pre></div></div>

<p>Then say exactly what you mean, every time:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>xcodebuild build <span class="se">\</span>
  <span class="nt">-workspace</span> VinylCrate.xcworkspace <span class="se">\</span>
  <span class="nt">-scheme</span> VinylCrate <span class="se">\</span>
  <span class="nt">-configuration</span> Release <span class="se">\</span>
  <span class="nt">-destination</span> <span class="s1">'generic/platform=iOS'</span>
</code></pre></div></div>

<p>And when you’re debugging a “why didn’t my change take effect” mystery, run <code class="language-plaintext highlighter-rouge">-showBuildSettings</code> with the same flags and check which target and configuration actually resolved.</p>

<h2 id="trap-2-green-tests-against-a-stale-build">Trap 2: Green Tests Against a Stale Build</h2>

<p>This one lived in a pipeline I inherited, and it’s the most expensive lie on this list because it compounds silently.</p>

<p>The split invocation looks like best practice — build once, test in parallel:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>xcodebuild build-for-testing <span class="se">\</span>
  <span class="nt">-workspace</span> VinylCrate.xcworkspace <span class="se">\</span>
  <span class="nt">-scheme</span> VinylCrate

xcodebuild test-without-building <span class="se">\</span>
  <span class="nt">-workspace</span> VinylCrate.xcworkspace <span class="se">\</span>
  <span class="nt">-scheme</span> VinylCrate <span class="se">\</span>
  <span class="nt">-destination</span> <span class="s1">'platform=iOS Simulator,name=iPhone 16,OS=18.5'</span>
</code></pre></div></div>

<p>Looks fine. Here’s the problem: <code class="language-plaintext highlighter-rouge">test-without-building</code> does exactly what it says: it runs whatever test bundles it finds in derived data. It does not check that they’re fresh. If those two commands run on different agents, or the default <code class="language-plaintext highlighter-rouge">~/Library/Developer/Xcode/DerivedData</code> location has products from a previous run, or someone reorders the pipeline steps, you are testing <em>last build’s</em> binaries. Your fix isn’t in them. The tests pass because the bug you fixed isn’t the bug being exercised — or worse, the tests for the fix pass because the <em>old</em> code happened to survive them.</p>

<p>I found this the way everyone finds it: a test that should have failed, didn’t, and the log timestamp on the <code class="language-plaintext highlighter-rouge">.app</code> bundle was from the previous night.</p>

<p>The honest version pins both halves to the same explicit derived data path, and hands the test step the <code class="language-plaintext highlighter-rouge">.xctestrun</code> file the build step produced — an explicit contract instead of an implicit “whatever’s lying around”:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">DERIVED</span><span class="o">=</span>./build/DerivedData

xcodebuild build-for-testing <span class="se">\</span>
  <span class="nt">-workspace</span> VinylCrate.xcworkspace <span class="se">\</span>
  <span class="nt">-scheme</span> VinylCrate <span class="se">\</span>
  <span class="nt">-destination</span> <span class="s1">'platform=iOS Simulator,name=iPhone 16,OS=18.5'</span> <span class="se">\</span>
  <span class="nt">-derivedDataPath</span> <span class="s2">"</span><span class="nv">$DERIVED</span><span class="s2">"</span>

xcodebuild test-without-building <span class="se">\</span>
  <span class="nt">-xctestrun</span> <span class="s2">"</span><span class="si">$(</span><span class="nb">ls</span> <span class="s2">"</span><span class="nv">$DERIVED</span><span class="s2">"</span>/Build/Products/<span class="k">*</span>.xctestrun | <span class="nb">head</span> <span class="nt">-1</span><span class="si">)</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-destination</span> <span class="s1">'platform=iOS Simulator,name=iPhone 16,OS=18.5'</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">.xctestrun</code> file names the exact product paths that were just built. If the build step didn’t run, the file isn’t there, and the test step fails with a missing file instead of running whatever was left over. That’s the trade you want.</p>

<p>If you’re shipping the products between CI agents, ship <code class="language-plaintext highlighter-rouge">$DERIVED/Build/Products</code> as the artifact — the <code class="language-plaintext highlighter-rouge">.xctestrun</code> travels with it, and there’s no ambient derived data on the test agent to fall back to. And if you’ve sliced your suite into <a href="/mastering-custom-test-suites-in-xcode-run-only-what-matters/">focused test plans that run only what matters</a>, nothing here changes — the <code class="language-plaintext highlighter-rouge">.xctestrun</code> carries the plan’s configuration with it.</p>

<h2 id="trap-3-the-destination-that-isnt-the-one-you-asked-for">Trap 3: The Destination That Isn’t the One You Asked For</h2>

<p>Every iOS team has this line in a CI script somewhere:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>xcodebuild <span class="nb">test</span> <span class="se">\</span>
  <span class="nt">-workspace</span> VinylCrate.xcworkspace <span class="se">\</span>
  <span class="nt">-scheme</span> VinylCrate <span class="se">\</span>
  <span class="nt">-destination</span> <span class="s1">'platform=iOS Simulator,name=iPhone 16'</span>
</code></pre></div></div>

<p>Two ways this lies to you. The first: with multiple simulator runtimes installed (normal on any CI image that’s survived a couple of Xcode updates), that specifier is ambiguous. <code class="language-plaintext highlighter-rouge">xcodebuild</code> prints a warning (“Using the first of multiple matching destinations”) and picks one. The warning scrolls past in a few thousand lines of build output, and now your “iOS 18.5” test suite has been running on the 18.2 runtime for a month. Whether that matters depends on which OS-version-gated behavior you’re testing. The month I hit it, it mattered.</p>

<p>The second way is the inverse: the CI image updates, <code class="language-plaintext highlighter-rouge">iPhone 16</code> becomes <code class="language-plaintext highlighter-rouge">iPhone 17</code>, and you get <code class="language-plaintext highlighter-rouge">Unable to find a destination matching the provided destination specifier</code> — an error that reads like a build system failure and sends people off to nuke derived data, when the actual problem is that the device name no longer exists.</p>

<p>Either way the fix starts the same: stop guessing what destinations exist and ask.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>xcodebuild <span class="nt">-showdestinations</span> <span class="se">\</span>
  <span class="nt">-workspace</span> VinylCrate.xcworkspace <span class="se">\</span>
  <span class="nt">-scheme</span> VinylCrate
</code></pre></div></div>

<p>That prints every eligible destination with platform, name, OS, and — the part you want — the device <code class="language-plaintext highlighter-rouge">id</code>. Then fully specify. For humans, name plus OS removes the ambiguity:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>xcodebuild <span class="nb">test</span> <span class="se">\</span>
  <span class="nt">-workspace</span> VinylCrate.xcworkspace <span class="se">\</span>
  <span class="nt">-scheme</span> VinylCrate <span class="se">\</span>
  <span class="nt">-destination</span> <span class="s1">'platform=iOS Simulator,name=iPhone 16,OS=18.5'</span>
</code></pre></div></div>

<p>For CI, go one step further and pin by UDID, resolved at runtime so image updates can’t drift under you:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">UDID</span><span class="o">=</span><span class="si">$(</span>xcrun simctl list devices available <span class="nt">--json</span> <span class="se">\</span>
  | jq <span class="nt">-r</span> <span class="s1">'.devices["com.apple.CoreSimulator.SimRuntime.iOS-18-5"][0].udid'</span><span class="si">)</span>

xcodebuild <span class="nb">test</span> <span class="se">\</span>
  <span class="nt">-workspace</span> VinylCrate.xcworkspace <span class="se">\</span>
  <span class="nt">-scheme</span> VinylCrate <span class="se">\</span>
  <span class="nt">-destination</span> <span class="s2">"id=</span><span class="nv">$UDID</span><span class="s2">"</span>
</code></pre></div></div>

<p>An <code class="language-plaintext highlighter-rouge">id=</code> destination matches exactly one device or fails. No first-of-multiple. No silent substitution.</p>

<h2 id="trap-4-the-clean-build-that-was-never-cold">Trap 4: The “Clean Build” That Was Never Cold</h2>

<p>This is the lie I’ve watched derail the most engineering conversations, because it corrupts <em>measurements</em>, and then people make architecture decisions based on the corrupted numbers.</p>

<p>Someone wants to know the cold build time. So they run:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">time </span>xcodebuild clean build <span class="se">\</span>
  <span class="nt">-workspace</span> VinylCrate.xcworkspace <span class="se">\</span>
  <span class="nt">-scheme</span> VinylCrate <span class="se">\</span>
  <span class="nt">-destination</span> <span class="s1">'generic/platform=iOS'</span>
</code></pre></div></div>

<p>Four minutes twenty. Great, that’s our clean build time. Except it isn’t, because <code class="language-plaintext highlighter-rouge">clean</code> is not “cold.” <code class="language-plaintext highlighter-rouge">xcodebuild clean</code> removes build <em>products</em>. It does not touch the Clang module cache (<code class="language-plaintext highlighter-rouge">ModuleCache.noindex</code>, shared across everything in default derived data), the resolved Swift package checkouts in <code class="language-plaintext highlighter-rouge">SourcePackages</code>, precompiled headers, or the build system’s other warm state. Your “clean” build reuses hundreds of prebuilt module maps from every project you’ve compiled this week. The number is fiction — and it’s fiction in the flattering direction, so the modularization effort that would actually fix your CI times looks less necessary than it is.</p>

<p>The corrected approach: own the derived data location, and delete it yourself. Don’t ask <code class="language-plaintext highlighter-rouge">xcodebuild</code> to clean; make cleanliness a property of the filesystem.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">DERIVED</span><span class="o">=</span>./build/DerivedData
<span class="nv">PACKAGES</span><span class="o">=</span>./build/SourcePackages

<span class="nb">rm</span> <span class="nt">-rf</span> <span class="s2">"</span><span class="nv">$DERIVED</span><span class="s2">"</span>

<span class="nb">time </span>xcodebuild build <span class="se">\</span>
  <span class="nt">-workspace</span> VinylCrate.xcworkspace <span class="se">\</span>
  <span class="nt">-scheme</span> VinylCrate <span class="se">\</span>
  <span class="nt">-destination</span> <span class="s1">'generic/platform=iOS'</span> <span class="se">\</span>
  <span class="nt">-derivedDataPath</span> <span class="s2">"</span><span class="nv">$DERIVED</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-clonedSourcePackagesDirPath</span> <span class="s2">"</span><span class="nv">$PACKAGES</span><span class="s2">"</span>
</code></pre></div></div>

<p>Two decisions worth making deliberately here. Scoping <code class="language-plaintext highlighter-rouge">-derivedDataPath</code> to the repo means the module cache lives inside it: <code class="language-plaintext highlighter-rouge">rm -rf</code> kills products, indexes, <em>and</em> module cache in one move, so the measurement is honest. And splitting <code class="language-plaintext highlighter-rouge">-clonedSourcePackagesDirPath</code> out separately lets you choose whether package checkouts are part of the test: keep them and you’re measuring compilation; delete them too and you’re measuring compilation plus dependency resolution plus the network. Those are different numbers that answer different questions. Know which one you’re publishing before someone builds a roadmap on it.</p>

<h2 id="trust-but-verify">Trust, But Verify</h2>

<p>None of these are bugs. Every one is <code class="language-plaintext highlighter-rouge">xcodebuild</code> doing exactly what it was told — the lie lives in the gap between what you told it and what you thought you told it. The tool’s defaults are optimized for a developer at a Mac with one project open, not for a pipeline that has to mean what it says.</p>

<p>The field checklist:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># 1. Never let xcodebuild pick the target</span>
xcodebuild <span class="nt">-list</span> <span class="nt">-workspace</span> VinylCrate.xcworkspace
xcodebuild build <span class="nt">-workspace</span> VinylCrate.xcworkspace <span class="nt">-scheme</span> VinylCrate

<span class="c"># 2. Pin build and test to the same products via .xctestrun</span>
xcodebuild build-for-testing ... <span class="nt">-derivedDataPath</span> ./build/DerivedData
xcodebuild test-without-building <span class="nt">-xctestrun</span> ./build/DerivedData/Build/Products/<span class="k">*</span>.xctestrun ...

<span class="c"># 3. Fully specify destinations — or pin by id</span>
xcodebuild <span class="nt">-showdestinations</span> <span class="nt">-workspace</span> VinylCrate.xcworkspace <span class="nt">-scheme</span> VinylCrate
<span class="nt">-destination</span> <span class="s1">'platform=iOS Simulator,name=iPhone 16,OS=18.5'</span>   <span class="c"># or id=$UDID</span>

<span class="c"># 4. "Clean" means you deleted it yourself</span>
<span class="nb">rm</span> <span class="nt">-rf</span> ./build/DerivedData <span class="o">&amp;&amp;</span> xcodebuild build ... <span class="nt">-derivedDataPath</span> ./build/DerivedData
</code></pre></div></div>

<p>When a build log says everything succeeded, believe it built <em>something</em>. Verifying it built <em>your thing</em> — right target, fresh products, the runtime and cache state you actually asked for — is still your job.</p>

<p>The log isn’t lying to you, exactly. It’s just answering a different question than the one you asked.</p>]]></content><author><name>Wesley Matlock</name></author><category term="ios" /><category term="xcodebuild" /><category term="ci" /><category term="tooling" /><category term="xcodebuild" /><category term="ci" /><category term="vinylcrate" /><summary type="html"><![CDATA[xcodebuild lies by succeeding. Four CI traps — wrong target, stale tests, drifting destinations, fake clean builds — and the invocations that tell the truth.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.wesleymatlock.com/images/blog/2026-08-02-four-ways-xcodebuild-lied-to-me/hero.png" /><media:content medium="image" url="https://www.wesleymatlock.com/images/blog/2026-08-02-four-ways-xcodebuild-lied-to-me/hero.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Eye of the Beholder: Foundation Models Gets On-Device Vision</title><link href="https://www.wesleymatlock.com/on-device-vision-foundation-models/" rel="alternate" type="text/html" title="Eye of the Beholder: Foundation Models Gets On-Device Vision" /><published>2026-07-20T00:00:00+00:00</published><updated>2026-07-20T00:00:00+00:00</updated><id>https://www.wesleymatlock.com/on-device-vision-foundation-models</id><content type="html" xml:base="https://www.wesleymatlock.com/on-device-vision-foundation-models/"><![CDATA[<h2 id="hit-the-lights">Hit the Lights</h2>

<p>There’s a specific kind of feature you’ve always had to send to the cloud. Anything where the app needs to <em>look</em> at something — a receipt, a label, a photo — and reason about it. You reach for a vision endpoint, you eat the round trip, you pay per image, and you quietly hope the user has signal.</p>

<p>That whole calculation just changed. The <a href="/apple-foundation-models-in-practice/">on-device Foundation Models framework</a> now takes images alongside text in a single prompt. You hand the model a photo, you ask a question grounded in what’s in it, and the answer comes back from the Neural Engine. No network. No per-token bill. No “this feature requires an internet connection” empty state.</p>

<p>That last part is the real unlock. Not that the model can see — plenty of models can see. It’s that it can see <em>here</em>, on the device, inside the trust boundary of the app. The photo never leaves. For anything remotely personal — and a photo of the thing sitting on your desk is personal — that’s the difference between a feature you can ship and a privacy review you’d rather not have.</p>

<p>I wired this into VinylCrate over a weekend, and it’s the first time a “point the camera at a thing” feature felt like it belonged in a small indie app instead of a startup with a cloud budget. Here’s how the pieces actually fit.</p>

<h2 id="eye-of-the-beholder">Eye of the Beholder</h2>

<p>Foundation Models is multimodal now — the prompt can carry an image segment alongside the instruction text. Everything else is unchanged from the text-only API you already know: you have a <code class="language-plaintext highlighter-rouge">LanguageModelSession</code>, you call <code class="language-plaintext highlighter-rouge">respond</code>, you get content back. The image is the only new part of the shape.</p>

<p>A quick honesty note before the code: the exact spelling of the image-attachment type is the part of this API I’d double-check against the SDK you’re building with, because it’s the newest surface and the most likely to have been renamed between betas. The <em>shape</em> below is what a working iOS dev will recognize — a <code class="language-plaintext highlighter-rouge">Prompt</code> built from an instruction plus an image segment — and that’s what matters for wiring your own version. Treat the image type name as the one line to verify, not the structure.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">FoundationModels</span>
<span class="kd">import</span> <span class="kt">UIKit</span>

<span class="kd">func</span> <span class="nf">describeSleeve</span><span class="p">(</span><span class="n">_</span> <span class="nv">image</span><span class="p">:</span> <span class="kt">UIImage</span><span class="p">)</span> <span class="k">async</span> <span class="k">throws</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">session</span> <span class="o">=</span> <span class="kt">LanguageModelSession</span><span class="p">(</span>
        <span class="nv">instructions</span><span class="p">:</span> <span class="s">"You are a vinyl cataloging assistant. Be precise and terse."</span>
    <span class="p">)</span>

    <span class="k">let</span> <span class="nv">prompt</span> <span class="o">=</span> <span class="kt">Prompt</span> <span class="p">{</span>
        <span class="s">"Describe the album sleeve in this photo — artist, title, and any label branding you can see."</span>
        <span class="kt">PromptImage</span><span class="p">(</span><span class="n">image</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="k">return</span> <span class="k">try</span> <span class="k">await</span> <span class="n">session</span><span class="o">.</span><span class="nf">respond</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="n">prompt</span><span class="p">)</span><span class="o">.</span><span class="n">content</span>
<span class="p">}</span>
</code></pre></div></div>

<p>That’s a description. Useful, but a <code class="language-plaintext highlighter-rouge">String</code> is a dead end when the next thing you do is populate a form. This is where guided generation carries its weight. Instead of parsing prose, you tell the model the shape you want and let it fill the struct.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Generable</span><span class="p">(</span><span class="nv">description</span><span class="p">:</span> <span class="s">"Structured details read from a vinyl record sleeve"</span><span class="p">)</span>
<span class="kd">struct</span> <span class="kt">SleeveReading</span><span class="p">:</span> <span class="kt">Sendable</span> <span class="p">{</span>
    <span class="kd">@Guide</span><span class="p">(</span><span class="nv">description</span><span class="p">:</span> <span class="s">"Primary recording artist or band"</span><span class="p">)</span>
    <span class="k">var</span> <span class="nv">artist</span><span class="p">:</span> <span class="kt">String</span>

    <span class="kd">@Guide</span><span class="p">(</span><span class="nv">description</span><span class="p">:</span> <span class="s">"Album or release title"</span><span class="p">)</span>
    <span class="k">var</span> <span class="nv">title</span><span class="p">:</span> <span class="kt">String</span>

    <span class="kd">@Guide</span><span class="p">(</span><span class="nv">description</span><span class="p">:</span> <span class="s">"Record label, if visible"</span><span class="p">)</span>
    <span class="k">var</span> <span class="nv">label</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>

    <span class="kd">@Guide</span><span class="p">(</span><span class="nv">description</span><span class="p">:</span> <span class="s">"Four-digit release year, if printed"</span><span class="p">,</span> <span class="o">.</span><span class="nf">range</span><span class="p">(</span><span class="mi">1900</span><span class="o">...</span><span class="mi">2100</span><span class="p">))</span>
    <span class="k">var</span> <span class="nv">year</span><span class="p">:</span> <span class="kt">Int</span><span class="p">?</span>
<span class="p">}</span>
</code></pre></div></div>

<p>And the call site barely changes — you’re just asking the session to generate that type from the same image prompt:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="nv">reading</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">session</span><span class="o">.</span><span class="nf">respond</span><span class="p">(</span>
    <span class="nv">to</span><span class="p">:</span> <span class="n">prompt</span><span class="p">,</span>
    <span class="nv">generating</span><span class="p">:</span> <span class="kt">SleeveReading</span><span class="o">.</span><span class="k">self</span>
<span class="p">)</span><span class="o">.</span><span class="n">content</span>
</code></pre></div></div>

<p>Now you’ve got a typed value out of a photograph, entirely on-device. For a lot of use cases you could stop right here.</p>

<p>But vinyl has a wrinkle, and the wrinkle is the whole reason this post exists.</p>

<h2 id="sad-but-true">Sad but True</h2>

<p>The Foundation Models vision stack is genuinely good at <em>understanding</em> a scene. Ask it what’s on the sleeve and it’ll tell you it’s a jazz record with a blue-and-white cover and a saxophone. That’s comprehension.</p>

<p>What it is not built to do is transcribe a nine-character catalog number etched in tiny type across the bottom of the jacket, or read the matrix runout code stamped into the dead wax. Those strings are the entire point of cataloging a record — <code class="language-plaintext highlighter-rouge">BLP 1577</code>, <code class="language-plaintext highlighter-rouge">SR-90521</code>, the pressing variant hiding in a runout — and they’re exactly the kind of dense, low-context, high-precision text a language model will happily hallucinate one character off. “Close” is worthless here. A catalog number that’s one digit wrong points at a different pressing worth a different amount of money.</p>

<p>So don’t ask the LLM to be an OCR engine. Give it one.</p>

<p>This is the pattern I keep coming back to with on-device models: let the LLM do the reasoning it’s good at, and hand it a tool for the deterministic work it’s bad at. The model understands the sleeve. Vision framework reads the exact text. The model gets ground truth handed to it mid-generation instead of guessing.</p>

<h2 id="seek--destroy">Seek &amp; Destroy</h2>

<p><a href="/foundation-models-tools-vinylcrate/">Foundation Models’ tool calling</a> is the join. You define a <code class="language-plaintext highlighter-rouge">Tool</code>, the session decides when to call it, and the recognized text flows back into the model’s context so its structured answer is grounded in real characters rather than its own reading of blurry type.</p>

<p>The tool wraps Vision’s modern async OCR. On iOS 18+ you get <code class="language-plaintext highlighter-rouge">RecognizeTextRequest</code>, which drops the completion-handler ceremony of the old <code class="language-plaintext highlighter-rouge">VNRecognizeTextRequest</code> for a clean <code class="language-plaintext highlighter-rouge">await</code>. (The completion-handler API still works if you’re supporting older OS versions — you’d bridge it with a continuation — but on an iOS 26 floor there’s no reason to.)</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">Vision</span>

<span class="kd">struct</span> <span class="kt">SleeveOCRTool</span><span class="p">:</span> <span class="kt">Tool</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">name</span> <span class="o">=</span> <span class="s">"readSleeveText"</span>
    <span class="k">let</span> <span class="nv">description</span> <span class="o">=</span> <span class="s">"Extract exact printed text — catalog numbers, track listings, </span><span class="err">\</span><span class="s">
label copy — from the record sleeve image."</span>

    <span class="c1">// The image lives on the tool, not in the arguments — the model can't</span>
    <span class="c1">// pass a CGImage through a generated struct, and it shouldn't need to.</span>
    <span class="k">let</span> <span class="nv">image</span><span class="p">:</span> <span class="kt">CGImage</span>

    <span class="kd">@Generable</span>
    <span class="kd">struct</span> <span class="kt">Arguments</span><span class="p">:</span> <span class="kt">Sendable</span> <span class="p">{</span>
        <span class="kd">@Guide</span><span class="p">(</span><span class="nv">description</span><span class="p">:</span> <span class="s">"What kind of text to prioritize, e.g. 'catalog number' or 'track listing'"</span><span class="p">)</span>
        <span class="k">var</span> <span class="nv">focus</span><span class="p">:</span> <span class="kt">String</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">call</span><span class="p">(</span><span class="nv">arguments</span><span class="p">:</span> <span class="kt">Arguments</span><span class="p">)</span> <span class="k">async</span> <span class="k">throws</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
        <span class="k">var</span> <span class="nv">request</span> <span class="o">=</span> <span class="kt">RecognizeTextRequest</span><span class="p">()</span>
        <span class="n">request</span><span class="o">.</span><span class="n">recognitionLevel</span> <span class="o">=</span> <span class="o">.</span><span class="n">accurate</span>
        <span class="n">request</span><span class="o">.</span><span class="n">usesLanguageCorrection</span> <span class="o">=</span> <span class="kc">false</span>  <span class="c1">// catalog codes aren't words</span>

        <span class="k">let</span> <span class="nv">observations</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">request</span><span class="o">.</span><span class="nf">perform</span><span class="p">(</span><span class="nv">on</span><span class="p">:</span> <span class="n">image</span><span class="p">)</span>
        <span class="k">let</span> <span class="nv">lines</span> <span class="o">=</span> <span class="n">observations</span><span class="o">.</span><span class="n">compactMap</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="nf">topCandidates</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span><span class="o">.</span><span class="n">first</span><span class="p">?</span><span class="o">.</span><span class="n">string</span> <span class="p">}</span>

        <span class="k">guard</span> <span class="o">!</span><span class="n">lines</span><span class="o">.</span><span class="n">isEmpty</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="s">"No text found on the sleeve."</span> <span class="p">}</span>
        <span class="k">return</span> <span class="n">lines</span><span class="o">.</span><span class="nf">joined</span><span class="p">(</span><span class="nv">separator</span><span class="p">:</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two decisions in there earn their comments. The image rides on the tool rather than the <code class="language-plaintext highlighter-rouge">Arguments</code>, because the model has no business — and no ability — to marshal a <code class="language-plaintext highlighter-rouge">CGImage</code> through generated arguments; it only needs to decide <em>that</em> it wants text and <em>what</em> to focus on. And <code class="language-plaintext highlighter-rouge">usesLanguageCorrection</code> is off on purpose. Language correction is a feature when you’re reading sentences and a liability when you’re reading <code class="language-plaintext highlighter-rouge">SR-90521</code>, which spell-check would love to turn into something that looks more like English.</p>

<p>Then you attach the tool at session construction, and the model calls it on its own when it decides it needs precise text:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">let</span> <span class="nv">session</span> <span class="o">=</span> <span class="kt">LanguageModelSession</span><span class="p">(</span>
    <span class="nv">tools</span><span class="p">:</span> <span class="p">[</span><span class="kt">SleeveOCRTool</span><span class="p">(</span><span class="nv">image</span><span class="p">:</span> <span class="n">cgImage</span><span class="p">)],</span>
    <span class="nv">instructions</span><span class="p">:</span> <span class="s">"""
        You are a vinyl cataloging assistant. Identify the release from the sleeve image.
        When you need exact printed text — catalog numbers, track listings, matrix codes — </span><span class="err">\</span><span class="s">
        call readSleeveText rather than reading the characters yourself.
        """</span>
<span class="p">)</span>
</code></pre></div></div>

<p>That instruction is doing real work. Without the nudge, the model tends to trust its own eyes and skip the tool. Tell it explicitly that exact strings are the tool’s job and it leans on the tool for precisely the text it’s worst at.</p>

<h2 id="master-of-puppets">Master of Puppets</h2>

<p>Here’s how the whole thing lands as a feature. In VinylCrate, you point the camera at an album jacket, tap scan, and the review sheet comes back pre-filled — artist, title, label, catalog number, year — with the OCR-verified strings already slotted in. You confirm or fix, then search Discogs to match the pressing. The scan turns a two-minute typing chore into a two-second glance.</p>

<div class="post-image-pair">
  <picture>
    <source srcset="/images/blog/2026-07-20-on-device-vision-foundation-models/scan-camera.webp" type="image/webp" />
    <img src="/images/blog/2026-07-20-on-device-vision-foundation-models/scan-camera.png" alt="VinylCrate's Sleeve Scan camera screen, the Barcode/Sleeve toggle set to Sleeve, framing the center label of a Metallica 'Ride the Lightning' record — catalog number MRI 769, Megaforce Records, Side A tracklist visible — under the prompt 'Fill the frame with the album cover, then capture'" loading="lazy" decoding="async" />
  </picture>
  <picture>
    <source srcset="/images/blog/2026-07-20-on-device-vision-foundation-models/scan-result-form.webp" type="image/webp" />
    <img src="/images/blog/2026-07-20-on-device-vision-foundation-models/scan-result-form.png" alt="VinylCrate's 'Sleeve Scan — Review the details' sheet with fields filled on-device: Artist METALLICA, Title RIDE THE LIGHTNING, Label 1984 Precious Metal Music/ASCAP, Catalog Number MRI 769, Year 1984, above a Search Discogs button and the note 'Edit anything Apple Intelligence misread, then search Discogs'" loading="lazy" decoding="async" />
  </picture>
</div>

<p>The model is pulling the strings here, and that’s the point. It orchestrates: it reads the scene, decides it needs exact characters, calls the OCR tool, folds the result back into its own reasoning, and hands you a finished struct. You wired the tool; the model decides when to reach for it.</p>

<p>The generable output for that is a little richer than the earlier reading, because now it can carry the tool-verified fields:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@Generable</span><span class="p">(</span><span class="nv">description</span><span class="p">:</span> <span class="s">"A record release identified from its sleeve, ready to catalog"</span><span class="p">)</span>
<span class="kd">struct</span> <span class="kt">ScannedRelease</span><span class="p">:</span> <span class="kt">Sendable</span> <span class="p">{</span>
    <span class="kd">@Guide</span><span class="p">(</span><span class="nv">description</span><span class="p">:</span> <span class="s">"Primary recording artist or band"</span><span class="p">)</span>
    <span class="k">var</span> <span class="nv">artist</span><span class="p">:</span> <span class="kt">String</span>

    <span class="kd">@Guide</span><span class="p">(</span><span class="nv">description</span><span class="p">:</span> <span class="s">"Album or release title"</span><span class="p">)</span>
    <span class="k">var</span> <span class="nv">title</span><span class="p">:</span> <span class="kt">String</span>

    <span class="kd">@Guide</span><span class="p">(</span><span class="nv">description</span><span class="p">:</span> <span class="s">"Record label"</span><span class="p">)</span>
    <span class="k">var</span> <span class="nv">label</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>

    <span class="kd">@Guide</span><span class="p">(</span><span class="nv">description</span><span class="p">:</span> <span class="s">"Catalog number exactly as printed — copy from OCR, do not guess"</span><span class="p">)</span>
    <span class="k">var</span> <span class="nv">catalogNumber</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>

    <span class="kd">@Guide</span><span class="p">(</span><span class="nv">description</span><span class="p">:</span> <span class="s">"Release year"</span><span class="p">,</span> <span class="o">.</span><span class="nf">range</span><span class="p">(</span><span class="mi">1900</span><span class="o">...</span><span class="mi">2100</span><span class="p">))</span>
    <span class="k">var</span> <span class="nv">year</span><span class="p">:</span> <span class="kt">Int</span><span class="p">?</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">@MainActor</code> view model that drives the scan screen owns the session and stays on the main actor because it’s feeding SwiftUI directly. The <code class="language-plaintext highlighter-rouge">ScannedRelease</code> is a <code class="language-plaintext highlighter-rouge">Sendable</code> value type, so it crosses back from generation to the UI without a fight from the compiler.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@MainActor</span>
<span class="kd">@Observable</span>
<span class="kd">final</span> <span class="kd">class</span> <span class="kt">ScanViewModel</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">release</span><span class="p">:</span> <span class="kt">ScannedRelease</span><span class="p">?</span>
    <span class="k">var</span> <span class="nv">state</span><span class="p">:</span> <span class="kt">State</span> <span class="o">=</span> <span class="o">.</span><span class="n">idle</span>

    <span class="kd">enum</span> <span class="kt">State</span><span class="p">:</span> <span class="kt">Sendable</span> <span class="p">{</span> <span class="k">case</span> <span class="n">idle</span><span class="p">,</span> <span class="n">scanning</span><span class="p">,</span> <span class="nf">failed</span><span class="p">(</span><span class="kt">String</span><span class="p">)</span> <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">scan</span><span class="p">(</span><span class="n">_</span> <span class="nv">image</span><span class="p">:</span> <span class="kt">CGImage</span><span class="p">)</span> <span class="k">async</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="k">case</span> <span class="o">.</span><span class="n">available</span> <span class="o">=</span> <span class="kt">SystemLanguageModel</span><span class="o">.</span><span class="k">default</span><span class="o">.</span><span class="n">availability</span> <span class="k">else</span> <span class="p">{</span>
            <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="nf">failed</span><span class="p">(</span><span class="s">"On-device scanning isn't available on this device."</span><span class="p">)</span>
            <span class="k">return</span>
        <span class="p">}</span>

        <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="n">scanning</span>
        <span class="k">let</span> <span class="nv">session</span> <span class="o">=</span> <span class="kt">LanguageModelSession</span><span class="p">(</span>
            <span class="nv">tools</span><span class="p">:</span> <span class="p">[</span><span class="kt">SleeveOCRTool</span><span class="p">(</span><span class="nv">image</span><span class="p">:</span> <span class="n">image</span><span class="p">)],</span>
            <span class="nv">instructions</span><span class="p">:</span> <span class="s">"Identify the release. Use readSleeveText for exact catalog numbers and track listings."</span>
        <span class="p">)</span>

        <span class="k">let</span> <span class="nv">prompt</span> <span class="o">=</span> <span class="kt">Prompt</span> <span class="p">{</span>
            <span class="s">"Identify this vinyl record from its sleeve and fill in the catalog details."</span>
            <span class="kt">PromptImage</span><span class="p">(</span><span class="n">image</span><span class="p">)</span>
        <span class="p">}</span>

        <span class="k">do</span> <span class="p">{</span>
            <span class="n">release</span> <span class="o">=</span> <span class="k">try</span> <span class="k">await</span> <span class="n">session</span><span class="o">.</span><span class="nf">respond</span><span class="p">(</span>
                <span class="nv">to</span><span class="p">:</span> <span class="n">prompt</span><span class="p">,</span>
                <span class="nv">generating</span><span class="p">:</span> <span class="kt">ScannedRelease</span><span class="o">.</span><span class="k">self</span>
            <span class="p">)</span><span class="o">.</span><span class="n">content</span>
            <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="n">idle</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="k">let</span> <span class="nv">error</span> <span class="k">as</span> <span class="kt">LanguageModelSession</span><span class="o">.</span><span class="kt">GenerationError</span> <span class="p">{</span>
            <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="nf">failed</span><span class="p">(</span><span class="nf">userMessage</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">error</span><span class="p">))</span>
        <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
            <span class="n">state</span> <span class="o">=</span> <span class="o">.</span><span class="nf">failed</span><span class="p">(</span><span class="s">"Couldn't read the sleeve. Try a clearer photo."</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The camera capture produces a <code class="language-plaintext highlighter-rouge">CGImage</code>, the view model runs the session, and the resulting <code class="language-plaintext highlighter-rouge">ScannedRelease</code> maps straight onto the review sheet’s fields. The sheet is still a form — the user is still in control — the scan just fills the tedious parts and gets the catalog number right.</p>

<h2 id="nothing-else-matters">Nothing Else Matters</h2>

<p>This is the part I’d have wanted someone to tell me before I shipped it.</p>

<p><strong>Check availability, and mean it.</strong> <code class="language-plaintext highlighter-rouge">SystemLanguageModel.default.availability</code> isn’t a formality. On a device that isn’t Apple Intelligence-eligible, or with the model still downloading, the whole feature is unavailable — not degraded, gone. The scan button needs a real fallback: manual entry, which is what the app did before this feature existed anyway. On-device AI is an accelerant, not a dependency. Build it so the app is whole without it.</p>

<p><strong>Blurry input is your most common failure, not model refusal.</strong> People photograph records in bad light at bad angles. The OCR tool returns “No text found,” the model does its best from the visual alone, and the catalog number comes back nil. That’s the honest outcome — a partial fill with the precise field left empty is better than a confident wrong number. Design the form to celebrate a partial scan instead of treating it as an error.</p>

<p><strong>Handle refusal separately from failure.</strong> A guardrail violation or a <code class="language-plaintext highlighter-rouge">.refusal</code> is a different branch than a decoding failure, and the user message should reflect that. Most of the time you’ll collapse them into “try a clearer photo,” but <a href="/testing-on-device-ai-swift-testing/">keep the branches distinct in code</a> so you can log — and test — what’s actually happening.</p>

<p><strong>Latency is real but local.</strong> A vision prompt with a tool round trip is not instant — you’re looking at a couple of seconds on current hardware, more on the first call before the session warms. Prewarm the session when the user opens the camera, show honest progress, and don’t block the whole UI on it.</p>

<p><strong>The concurrency shakes out clean if you let it.</strong> <code class="language-plaintext highlighter-rouge">@Generable</code> output types are <code class="language-plaintext highlighter-rouge">Sendable</code> value types. The <code class="language-plaintext highlighter-rouge">Tool</code> conformer is <code class="language-plaintext highlighter-rouge">Sendable</code> because it holds an immutable <code class="language-plaintext highlighter-rouge">CGImage</code> and nothing mutable. The session lives on a <code class="language-plaintext highlighter-rouge">@MainActor</code> view model because it drives the UI. Get those three right and Swift 6 has nothing to complain about — no <code class="language-plaintext highlighter-rouge">@unchecked</code>, no unsafe escape hatches, just isolation that matches how the data actually moves.</p>

<p>The thing I keep sitting with is how quietly this shifts what’s buildable. A year ago, “point your camera at a record and it fills in the catalog number” was a cloud feature with a cost model and a privacy story. Now it’s a weekend in a hobby app, and the photo of your record collection never leaves your phone.</p>

<p>Is the model’s own reading perfect? No. That’s why the OCR tool exists. Is the combination better than either piece alone? Every time.</p>]]></content><author><name>Wesley Matlock</name></author><category term="swift" /><category term="foundation-models" /><category term="vision" /><category term="on-device-ai" /><category term="ios" /><category term="foundation-models" /><category term="vision" /><category term="on-device-ai" /><category term="vinylcrate" /><summary type="html"><![CDATA[Foundation Models now sees images — on-device, in VinylCrate. But OCR goes to a real tool, not the model's eyes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.wesleymatlock.com/images/blog/2026-07-20-on-device-vision-foundation-models/hero.png" /><media:content medium="image" url="https://www.wesleymatlock.com/images/blog/2026-07-20-on-device-vision-foundation-models/hero.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">One TaskGroup, Two Live Streams: a Swift 6 LLM Benchmark</title><link href="https://www.wesleymatlock.com/merging-two-live-streams-taskgroup-llm-benchmark/" rel="alternate" type="text/html" title="One TaskGroup, Two Live Streams: a Swift 6 LLM Benchmark" /><published>2026-07-10T00:00:00+00:00</published><updated>2026-07-10T00:00:00+00:00</updated><id>https://www.wesleymatlock.com/merging-two-live-streams-taskgroup-llm-benchmark</id><content type="html" xml:base="https://www.wesleymatlock.com/merging-two-live-streams-taskgroup-llm-benchmark/"><![CDATA[<p>Local model throughput is not a number you read off the side of a box. The parameter count tells you almost nothing about how a model behaves on <em>your</em> Mac. Quantization changes the arithmetic. KV-cache pressure climbs as the context fills. And the moment the thermal headroom runs out, the silicon throttles and your tokens-per-second falls off a cliff — quietly, without an error, without a log line.</p>

<p>You can’t feel any of that from a CLI that prints a token count at the end. A final number is the average of a story you didn’t get to watch. The interesting part is the shape of the curve while it runs. So I built a harness to watch it: <a href="https://github.com/wesmatlock/ModelBench"><strong>ModelBench</strong></a>, a macOS 26 app that runs eval suites against local backends, captures live telemetry — tokens/sec, time-to-first-token, resident memory, thermal state — and charts it in real time while persisting every run for comparison.</p>

<picture>
  <source srcset="/images/blog/2026-07-10-merging-two-live-streams-taskgroup-llm-benchmark/modelbench-dashboard.webp" type="image/webp" />
  <img src="/images/blog/2026-07-10-merging-two-live-streams-taskgroup-llm-benchmark/modelbench-dashboard.png" alt="ModelBench mid-run: the tokens/sec curve dips where the silicon throttles, next to live memory, TTFT, and thermal readouts, with per-case results below." loading="lazy" decoding="async" />
</picture>

<p>But the app is the easy part. The part worth writing about is the concurrency design at its center: merging two independent, live async streams — inference and telemetry — into one ordered event stream, under a single structured task group, with zero data races and zero <code class="language-plaintext highlighter-rouge">Task.detached</code>. Swift 6 strict concurrency from the first line. That’s the post.</p>

<h2 id="two-streams-that-need-to-become-one">Two streams that need to become one</h2>

<p>A benchmark run is two things happening at once.</p>

<p>The first is inference: prompts going to a backend, tokens streaming back. Sequential by nature — you ask, you wait, you read the chunks. The second is telemetry: the system sampling itself on a fixed cadence, completely indifferent to whatever the model is doing. One is event-driven and bursty. The other is a metronome ticking every 500ms.</p>

<p>The UI wants one thing: an ordered stream of events it can fold into SwiftUI state. A token rate update here, a completed eval case there, a final result at the end. The runner’s whole job is to take those two unsynchronized sources and braid them into a single <code class="language-plaintext highlighter-rouge">AsyncThrowingStream&lt;RunEvent, Error&gt;</code> — one timeline out of two clocks that never agreed to keep time together.</p>

<p>The naive instinct is to spin up a detached task for telemetry, let it write into some shared dictionary of “current stats,” and have the inference loop read from it. That’s a data race wearing a lab coat. Two isolation domains, shared mutable state, no synchronization — the compiler will let you do it only if you lie to it with <a href="/mastering-sendable-in-swift-6/"><code class="language-plaintext highlighter-rouge">@unchecked Sendable</code></a>, and then you own every bug that follows.</p>

<p>The honest version puts all the run state <a href="/mastering-actors-and-concurrency-best-practices/">on one actor</a> and lets structured concurrency do the proof.</p>

<h2 id="the-runner-is-an-actor-and-thats-the-whole-trick">The runner is an actor, and that’s the whole trick</h2>

<p><code class="language-plaintext highlighter-rouge">BenchmarkRunner</code> is an <code class="language-plaintext highlighter-rouge">actor</code>. Every piece of mutable run state — the token counter, the last-sample bookkeeping, the time-to-first-token latch, the collected results — lives on it and nowhere else.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">actor</span> <span class="kt">BenchmarkRunner</span> <span class="p">{</span>
    <span class="kd">enum</span> <span class="kt">RunEvent</span><span class="p">:</span> <span class="kt">Sendable</span> <span class="p">{</span>
        <span class="k">case</span> <span class="nf">telemetry</span><span class="p">(</span><span class="kt">TelemetrySample</span><span class="p">)</span>
        <span class="k">case</span> <span class="nf">caseCompleted</span><span class="p">(</span><span class="kt">EvalResult</span><span class="p">)</span>
        <span class="k">case</span> <span class="nf">finished</span><span class="p">(</span><span class="kt">BenchmarkRun</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="c1">// Run-scoped state (reset at the start of each run).</span>
    <span class="kd">private</span> <span class="k">var</span> <span class="nv">generatedTokens</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="kd">private</span> <span class="k">var</span> <span class="nv">lastSampleTokens</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="kd">private</span> <span class="k">var</span> <span class="nv">lastSampleInstant</span><span class="p">:</span> <span class="kt">ContinuousClock</span><span class="o">.</span><span class="kt">Instant</span><span class="p">?</span>
    <span class="kd">private</span> <span class="k">var</span> <span class="nv">firstTokenLatency</span><span class="p">:</span> <span class="kt">Duration</span><span class="p">?</span>
    <span class="kd">private</span> <span class="k">var</span> <span class="nv">collectedSamples</span><span class="p">:</span> <span class="p">[</span><span class="kt">TelemetrySample</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="kd">private</span> <span class="k">var</span> <span class="nv">collectedResults</span><span class="p">:</span> <span class="p">[</span><span class="kt">EvalResult</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
    <span class="c1">// …</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The public entry point hands back a stream and kicks off the work. Note the <code class="language-plaintext highlighter-rouge">onTermination</code> hook — if the consumer tears down (the user hits cancel, the view disappears), the run task gets cancelled. No orphaned work:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">run</span><span class="p">(</span>
    <span class="nv">suite</span><span class="p">:</span> <span class="n">any</span> <span class="kt">EvalSuite</span><span class="p">,</span>
    <span class="nv">backend</span><span class="p">:</span> <span class="n">any</span> <span class="kt">ModelBackend</span><span class="p">,</span>
    <span class="nv">model</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span>
    <span class="nv">options</span><span class="p">:</span> <span class="kt">GenerationOptions</span> <span class="o">=</span> <span class="o">.</span><span class="n">benchmark</span>
<span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">AsyncThrowingStream</span><span class="o">&lt;</span><span class="kt">RunEvent</span><span class="p">,</span> <span class="kt">Error</span><span class="o">&gt;</span> <span class="p">{</span>
    <span class="kt">AsyncThrowingStream</span> <span class="p">{</span> <span class="n">continuation</span> <span class="k">in</span>
        <span class="k">let</span> <span class="nv">task</span> <span class="o">=</span> <span class="kt">Task</span> <span class="p">{</span>
            <span class="k">await</span> <span class="k">self</span><span class="o">.</span><span class="nf">execute</span><span class="p">(</span><span class="cm">/* … */</span><span class="p">,</span> <span class="nv">continuation</span><span class="p">:</span> <span class="n">continuation</span><span class="p">)</span>
        <span class="p">}</span>
        <span class="n">continuation</span><span class="o">.</span><span class="n">onTermination</span> <span class="o">=</span> <span class="p">{</span> <span class="n">_</span> <span class="k">in</span> <span class="n">task</span><span class="o">.</span><span class="nf">cancel</span><span class="p">()</span> <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now the heart of it. Inside <code class="language-plaintext highlighter-rouge">execute</code>, a single <code class="language-plaintext highlighter-rouge">withThrowingTaskGroup</code> runs the telemetry consumer concurrently with the sequential inference loop:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">try</span> <span class="k">await</span> <span class="nf">withThrowingTaskGroup</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="kt">Void</span><span class="o">.</span><span class="k">self</span><span class="p">)</span> <span class="p">{</span> <span class="n">group</span> <span class="k">in</span>
    <span class="k">let</span> <span class="nv">systemStream</span> <span class="o">=</span> <span class="k">await</span> <span class="n">monitor</span><span class="o">.</span><span class="nf">start</span><span class="p">(</span><span class="nv">interval</span><span class="p">:</span> <span class="o">.</span><span class="nf">milliseconds</span><span class="p">(</span><span class="mi">500</span><span class="p">))</span>

    <span class="c1">// Child: enrich each system sample with current tok/s and emit it.</span>
    <span class="n">group</span><span class="o">.</span><span class="n">addTask</span> <span class="p">{</span>
        <span class="k">for</span> <span class="k">await</span> <span class="n">sys</span> <span class="k">in</span> <span class="n">systemStream</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">sample</span> <span class="o">=</span> <span class="k">await</span> <span class="k">self</span><span class="o">.</span><span class="nf">makeTelemetrySample</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="n">sys</span><span class="p">,</span> <span class="nv">now</span><span class="p">:</span> <span class="n">clock</span><span class="o">.</span><span class="n">now</span><span class="p">)</span>
            <span class="n">continuation</span><span class="o">.</span><span class="nf">yield</span><span class="p">(</span><span class="o">.</span><span class="nf">telemetry</span><span class="p">(</span><span class="n">sample</span><span class="p">))</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="c1">// Parent: run eval cases sequentially.</span>
    <span class="k">for</span> <span class="n">evalCase</span> <span class="k">in</span> <span class="n">suite</span><span class="o">.</span><span class="n">cases</span> <span class="p">{</span>
        <span class="k">try</span> <span class="kt">Task</span><span class="o">.</span><span class="nf">checkCancellation</span><span class="p">()</span>
        <span class="k">let</span> <span class="nv">caseStart</span> <span class="o">=</span> <span class="n">clock</span><span class="o">.</span><span class="n">now</span>
        <span class="k">var</span> <span class="nv">output</span> <span class="o">=</span> <span class="s">""</span>

        <span class="k">for</span> <span class="k">try</span> <span class="k">await</span> <span class="n">chunk</span> <span class="k">in</span> <span class="n">backend</span><span class="o">.</span><span class="nf">stream</span><span class="p">(</span><span class="nv">prompt</span><span class="p">:</span> <span class="n">evalCase</span><span class="o">.</span><span class="n">prompt</span><span class="p">,</span> <span class="nv">model</span><span class="p">:</span> <span class="n">model</span><span class="p">,</span> <span class="nv">options</span><span class="p">:</span> <span class="n">options</span><span class="p">)</span> <span class="p">{</span>
            <span class="k">try</span> <span class="kt">Task</span><span class="o">.</span><span class="nf">checkCancellation</span><span class="p">()</span>
            <span class="k">if</span> <span class="n">chunk</span><span class="o">.</span><span class="n">hasGeneratedText</span> <span class="p">{</span>
                <span class="nf">registerGeneratedToken</span><span class="p">(</span><span class="nv">elapsed</span><span class="p">:</span> <span class="p">{</span> <span class="n">clock</span><span class="o">.</span><span class="n">now</span> <span class="o">-</span> <span class="n">caseStart</span> <span class="p">})</span>
            <span class="p">}</span>
            <span class="n">output</span> <span class="o">+=</span> <span class="n">chunk</span><span class="o">.</span><span class="n">contentDelta</span>
        <span class="p">}</span>

        <span class="k">let</span> <span class="nv">result</span> <span class="o">=</span> <span class="k">await</span> <span class="n">scorer</span><span class="o">.</span><span class="nf">score</span><span class="p">(</span><span class="n">evalCase</span><span class="p">,</span> <span class="nv">output</span><span class="p">:</span> <span class="n">output</span><span class="p">)</span>
        <span class="n">collectedResults</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="n">result</span><span class="p">)</span>
        <span class="n">continuation</span><span class="o">.</span><span class="nf">yield</span><span class="p">(</span><span class="o">.</span><span class="nf">caseCompleted</span><span class="p">(</span><span class="n">result</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="k">await</span> <span class="n">monitor</span><span class="o">.</span><span class="nf">stop</span><span class="p">()</span>      <span class="c1">// ends systemStream, letting the child return</span>
    <span class="n">group</span><span class="o">.</span><span class="nf">cancelAll</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Look at what makes this safe, because it’s subtle and it’s the entire reason the design holds.</p>

<p>The inference loop is the parent. It suspends on <em>every</em> awaited chunk — <code class="language-plaintext highlighter-rouge">for try await chunk in backend.stream(...)</code>. Each suspension is a yield point. While the parent is parked waiting on the network, the telemetry child is free to run: it pulls a <code class="language-plaintext highlighter-rouge">SystemSample</code>, calls back into the actor via <code class="language-plaintext highlighter-rouge">makeTelemetrySample</code>, mutates the counters, and yields a <code class="language-plaintext highlighter-rouge">RunEvent</code>.</p>

<p>Both the parent and the child touch the same mutable state — <code class="language-plaintext highlighter-rouge">generatedTokens</code>, <code class="language-plaintext highlighter-rouge">lastSampleInstant</code>, the rest. But every one of those touches is an actor-isolated method call. The actor serializes them. The parent increments <code class="language-plaintext highlighter-rouge">generatedTokens</code> while reading a chunk; the child reads <code class="language-plaintext highlighter-rouge">generatedTokens</code> to compute a rate. They interleave constantly, and they <em>cannot</em> race, because the actor guarantees one at a time. One actor, pulling every string — nothing touches that state without going through it. The compiler proves it. I didn’t write a single lock.</p>

<p>This is what Swift 6 buys you when you stop fighting it. Pick the right isolation and the data-race freedom is a theorem, not a hope.</p>

<h2 id="the-runner-owns-the-math-the-monitor-owns-nothing">The runner owns the math; the monitor owns nothing</h2>

<p>There’s a design boundary here I care about more than the code.</p>

<p><code class="language-plaintext highlighter-rouge">TelemetryMonitor</code> knows <em>nothing</em> about inference. It doesn’t know what a token is. It emits raw <code class="language-plaintext highlighter-rouge">SystemSample</code> values — a timestamp, a memory reading, a thermal state — on a timer, and that’s the whole of its job. It’s reusable, independently testable, and completely decoupled from whatever is generating load.</p>

<p>The runner is where token throughput gets computed. When a system sample arrives, <code class="language-plaintext highlighter-rouge">makeTelemetrySample</code> is the only place that knows the current token count, so it’s the only place that can compute an <em>instantaneous</em> rate — the delta since the previous sample, not a run-wide average:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="kd">func</span> <span class="nf">makeTelemetrySample</span><span class="p">(</span><span class="n">from</span> <span class="nv">sys</span><span class="p">:</span> <span class="kt">SystemSample</span><span class="p">,</span> <span class="nv">now</span><span class="p">:</span> <span class="kt">ContinuousClock</span><span class="o">.</span><span class="kt">Instant</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">TelemetrySample</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">tokensPerSecond</span><span class="p">:</span> <span class="kt">Double</span>
    <span class="k">if</span> <span class="k">let</span> <span class="nv">last</span> <span class="o">=</span> <span class="n">lastSampleInstant</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">elapsed</span> <span class="o">=</span> <span class="p">(</span><span class="n">now</span> <span class="o">-</span> <span class="n">last</span><span class="p">)</span><span class="o">.</span><span class="n">seconds</span>
        <span class="k">let</span> <span class="nv">delta</span> <span class="o">=</span> <span class="n">generatedTokens</span> <span class="o">-</span> <span class="n">lastSampleTokens</span>
        <span class="n">tokensPerSecond</span> <span class="o">=</span> <span class="n">elapsed</span> <span class="o">&gt;</span> <span class="mi">0</span> <span class="p">?</span> <span class="nf">max</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="kt">Double</span><span class="p">(</span><span class="n">delta</span><span class="p">)</span> <span class="o">/</span> <span class="n">elapsed</span><span class="p">)</span> <span class="p">:</span> <span class="mi">0</span>
    <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
        <span class="n">tokensPerSecond</span> <span class="o">=</span> <span class="mi">0</span>
    <span class="p">}</span>
    <span class="n">lastSampleInstant</span> <span class="o">=</span> <span class="n">now</span>
    <span class="n">lastSampleTokens</span> <span class="o">=</span> <span class="n">generatedTokens</span>

    <span class="k">let</span> <span class="nv">sample</span> <span class="o">=</span> <span class="kt">TelemetrySample</span><span class="p">(</span>
        <span class="nv">timestamp</span><span class="p">:</span> <span class="n">sys</span><span class="o">.</span><span class="n">timestamp</span><span class="p">,</span>
        <span class="nv">tokensPerSecond</span><span class="p">:</span> <span class="n">tokensPerSecond</span><span class="p">,</span>
        <span class="nv">timeToFirstToken</span><span class="p">:</span> <span class="n">firstTokenLatency</span><span class="p">,</span>
        <span class="nv">memoryMB</span><span class="p">:</span> <span class="n">sys</span><span class="o">.</span><span class="n">memoryMB</span><span class="p">,</span>
        <span class="nv">thermalState</span><span class="p">:</span> <span class="n">sys</span><span class="o">.</span><span class="n">thermalState</span>
    <span class="p">)</span>
    <span class="n">collectedSamples</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="n">sample</span><span class="p">)</span>
    <span class="k">return</span> <span class="n">sample</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Instantaneous rate is what makes thermal throttling visible. A run-wide average smears the cliff into a gentle slope and hides exactly the thing you opened the app to see. Sampling the delta every 500ms means when the silicon throttles, you watch the throughput fade to black on the chart while it’s happening — not in a summary you read after the damage is done.</p>

<h2 id="real-telemetry-public-apis-only">Real telemetry, public APIs only</h2>

<p>The memory number is the one Activity Monitor calls “Memory” — the physical footprint, <code class="language-plaintext highlighter-rouge">phys_footprint</code>, read through the mach <code class="language-plaintext highlighter-rouge">task_info</code> call. No estimates, no <code class="language-plaintext highlighter-rouge">resident_size</code> approximation that drifts from what the OS actually reports:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">static</span> <span class="kd">func</span> <span class="nf">residentMemoryMB</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="kt">Double</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">info</span> <span class="o">=</span> <span class="nf">task_vm_info_data_t</span><span class="p">()</span>
    <span class="k">var</span> <span class="nv">count</span> <span class="o">=</span> <span class="nf">mach_msg_type_number_t</span><span class="p">(</span>
        <span class="kt">MemoryLayout</span><span class="o">&lt;</span><span class="n">task_vm_info_data_t</span><span class="o">&gt;.</span><span class="n">size</span> <span class="o">/</span> <span class="kt">MemoryLayout</span><span class="o">&lt;</span><span class="n">natural_t</span><span class="o">&gt;.</span><span class="n">size</span>
    <span class="p">)</span>
    <span class="k">let</span> <span class="nv">kr</span> <span class="o">=</span> <span class="nf">withUnsafeMutablePointer</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="o">&amp;</span><span class="n">info</span><span class="p">)</span> <span class="p">{</span> <span class="n">pointer</span> <span class="k">in</span>
        <span class="n">pointer</span><span class="o">.</span><span class="nf">withMemoryRebound</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="n">integer_t</span><span class="o">.</span><span class="k">self</span><span class="p">,</span> <span class="nv">capacity</span><span class="p">:</span> <span class="kt">Int</span><span class="p">(</span><span class="n">count</span><span class="p">))</span> <span class="p">{</span> <span class="n">rebound</span> <span class="k">in</span>
            <span class="nf">task_info</span><span class="p">(</span><span class="n">mach_task_self_</span><span class="p">,</span> <span class="nf">task_flavor_t</span><span class="p">(</span><span class="kt">TASK_VM_INFO</span><span class="p">),</span> <span class="n">rebound</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">count</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="k">guard</span> <span class="n">kr</span> <span class="o">==</span> <span class="kt">KERN_SUCCESS</span> <span class="k">else</span> <span class="p">{</span> <span class="k">return</span> <span class="mi">0</span> <span class="p">}</span>
    <span class="k">return</span> <span class="kt">Double</span><span class="p">(</span><span class="n">info</span><span class="o">.</span><span class="n">phys_footprint</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="mi">1024</span> <span class="o">*</span> <span class="mi">1024</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Thermal state is <code class="language-plaintext highlighter-rouge">ProcessInfo.thermalState</code>, mapped onto a domain enum. I went back and forth on this one. There are richer thermal signals on Apple Silicon if you’re willing to read the SMC directly — but those are private, brittle across hardware, and a fast way to ship something that breaks on the next Mac. The public four-state ladder — nominal, fair, serious, critical — is coarse, but it’s stable, and it tells you the one thing that matters for a benchmark: <em>is the machine throttling right now.</em> I’ll take honest and coarse over precise and fragile every time.</p>

<p>Both reads are <code class="language-plaintext highlighter-rouge">static</code> and pure, which keeps the sampling loop trivial and the functions testable without an actor in sight.</p>

<h2 id="two-backends-one-protocol">Two backends, one protocol</h2>

<p>ModelBench drives two local servers, and they don’t speak the same language. The protocol papers over the difference with a single value-typed surface:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">protocol</span> <span class="kt">ModelBackend</span><span class="p">:</span> <span class="kt">Sendable</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">kind</span><span class="p">:</span> <span class="kt">BackendKind</span> <span class="p">{</span> <span class="k">get</span> <span class="p">}</span>
    <span class="k">var</span> <span class="nv">baseURL</span><span class="p">:</span> <span class="kt">URL</span> <span class="p">{</span> <span class="k">get</span> <span class="p">}</span>
    <span class="kd">func</span> <span class="nf">availableModels</span><span class="p">()</span> <span class="k">async</span> <span class="k">throws</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span>
    <span class="kd">func</span> <span class="nf">stream</span><span class="p">(</span>
        <span class="nv">prompt</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span>
        <span class="nv">model</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span>
        <span class="nv">options</span><span class="p">:</span> <span class="kt">GenerationOptions</span>
    <span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">AsyncThrowingStream</span><span class="o">&lt;</span><span class="kt">InferenceChunk</span><span class="p">,</span> <span class="kt">Error</span><span class="o">&gt;</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">OllamaBackend</code> talks the native <code class="language-plaintext highlighter-rouge">/api/chat</code> protocol rather than Ollama’s OpenAI-compatible shim — deliberately, because the native protocol hands back authoritative counts: <code class="language-plaintext highlighter-rouge">eval_count</code> and <code class="language-plaintext highlighter-rouge">eval_duration</code> on the terminal chunk. It parses NDJSON, one JSON object per line:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">for</span> <span class="k">try</span> <span class="k">await</span> <span class="n">line</span> <span class="k">in</span> <span class="n">bytes</span><span class="o">.</span><span class="n">lines</span> <span class="p">{</span>
    <span class="k">try</span> <span class="kt">Task</span><span class="o">.</span><span class="nf">checkCancellation</span><span class="p">()</span>
    <span class="k">guard</span> <span class="o">!</span><span class="n">line</span><span class="o">.</span><span class="n">isEmpty</span><span class="p">,</span>
          <span class="k">let</span> <span class="nv">data</span> <span class="o">=</span> <span class="n">line</span><span class="o">.</span><span class="nf">data</span><span class="p">(</span><span class="nv">using</span><span class="p">:</span> <span class="o">.</span><span class="n">utf8</span><span class="p">),</span>
          <span class="k">let</span> <span class="nv">chunk</span> <span class="o">=</span> <span class="k">try</span><span class="p">?</span> <span class="kt">JSONDecoder</span><span class="p">()</span><span class="o">.</span><span class="nf">decode</span><span class="p">(</span><span class="kt">ChatChunk</span><span class="o">.</span><span class="k">self</span><span class="p">,</span> <span class="nv">from</span><span class="p">:</span> <span class="n">data</span><span class="p">)</span>
    <span class="k">else</span> <span class="p">{</span> <span class="k">continue</span> <span class="p">}</span>

    <span class="k">let</span> <span class="nv">isDone</span> <span class="o">=</span> <span class="n">chunk</span><span class="o">.</span><span class="n">done</span> <span class="p">??</span> <span class="kc">false</span>
    <span class="n">continuation</span><span class="o">.</span><span class="nf">yield</span><span class="p">(</span>
        <span class="kt">InferenceChunk</span><span class="p">(</span>
            <span class="nv">contentDelta</span><span class="p">:</span> <span class="n">chunk</span><span class="o">.</span><span class="n">message</span><span class="p">?</span><span class="o">.</span><span class="n">content</span> <span class="p">??</span> <span class="s">""</span><span class="p">,</span>
            <span class="nv">reasoningDelta</span><span class="p">:</span> <span class="n">chunk</span><span class="o">.</span><span class="n">message</span><span class="p">?</span><span class="o">.</span><span class="n">thinking</span> <span class="p">??</span> <span class="s">""</span><span class="p">,</span>
            <span class="nv">finalCompletionTokens</span><span class="p">:</span> <span class="n">isDone</span> <span class="p">?</span> <span class="n">chunk</span><span class="o">.</span><span class="nv">evalCount</span> <span class="p">:</span> <span class="kc">nil</span><span class="p">,</span>
            <span class="nv">isFinal</span><span class="p">:</span> <span class="n">isDone</span>
        <span class="p">)</span>
    <span class="p">)</span>
    <span class="k">if</span> <span class="n">isDone</span> <span class="p">{</span> <span class="k">break</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">MLXBackend</code> speaks OpenAI-compatible SSE — the protocol <code class="language-plaintext highlighter-rouge">mlx_lm.server</code> exposes — parsing <code class="language-plaintext highlighter-rouge">data:</code> lines and watching for the <code class="language-plaintext highlighter-rouge">[DONE]</code> sentinel. Different wire format, identical <code class="language-plaintext highlighter-rouge">InferenceChunk</code> out the other side.</p>

<p>Every backend stream is a backpressure-safe <a href="/asyncsequence-for-real-time-apis-from-legacy-polling-to-swift-6-elegance/"><code class="language-plaintext highlighter-rouge">AsyncThrowingStream</code></a> with the same <code class="language-plaintext highlighter-rouge">onTermination</code> discipline: cancel the consumer and the underlying URLSession task gets torn down with it. The runner doesn’t know or care which backend it’s driving. It sees chunks.</p>

<h2 id="grading-swiftui-with-a-second-model">Grading SwiftUI with a second model</h2>

<p>Not everything a model produces can be scored with a regex. Instruction-following cases — count the bullets, check for a forbidden word, validate the JSON — score deterministically. But “write me a SwiftUI master-detail view” has no exact-match answer.</p>

<p>So ModelBench grades those with a second model. <code class="language-plaintext highlighter-rouge">JudgeClient</code> sends the candidate output and a rubric to a judge model and asks for a strict JSON verdict:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">struct</span> <span class="kt">Verdict</span><span class="p">:</span> <span class="kt">Sendable</span><span class="p">,</span> <span class="kt">Equatable</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">pass</span><span class="p">:</span> <span class="kt">Bool</span>
    <span class="k">let</span> <span class="nv">score</span><span class="p">:</span> <span class="kt">Double</span>
    <span class="k">let</span> <span class="nv">reason</span><span class="p">:</span> <span class="kt">String</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The rubrics are opinionated about <em>modern</em> SwiftUI — reward <code class="language-plaintext highlighter-rouge">@Observable</code>, <code class="language-plaintext highlighter-rouge">NavigationStack</code>, <code class="language-plaintext highlighter-rouge">.foregroundStyle</code>, <code class="language-plaintext highlighter-rouge">.task</code>; penalize <code class="language-plaintext highlighter-rouge">NavigationView</code>, <code class="language-plaintext highlighter-rouge">ObservableObject</code>, <code class="language-plaintext highlighter-rouge">.foregroundColor</code>, <code class="language-plaintext highlighter-rouge">Task.detached</code> inside <code class="language-plaintext highlighter-rouge">onAppear</code>. Which raises the only question that matters once a model is grading a model: how much do you trust the judge, and how do you keep it honest when nobody’s checking its work? <a href="/testing-on-device-ai-swift-testing/">Testing output that won’t give the same answer twice</a> is already its own discipline — a model grading a model is that with the floor removed — justice for all, handed down by a juror you can’t cross-examine. That’s a whole post on its own, and it’s the next one.</p>

<h2 id="persistence-is-its-own-isolation-domain">Persistence is its own isolation domain</h2>

<p>Runs need to survive the app. They go into SwiftData — but SwiftData on the main actor while you’re charting a live run is how you drop frames. So the store is a <code class="language-plaintext highlighter-rouge">@ModelActor</code>:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">@ModelActor</span>
<span class="kd">actor</span> <span class="kt">BenchmarkStore</span> <span class="p">{</span>
    <span class="kd">func</span> <span class="nf">save</span><span class="p">(</span><span class="n">_</span> <span class="nv">run</span><span class="p">:</span> <span class="kt">BenchmarkRun</span><span class="p">)</span> <span class="k">throws</span> <span class="p">{</span>
        <span class="n">modelContext</span><span class="o">.</span><span class="nf">insert</span><span class="p">(</span><span class="kt">RunRecord</span><span class="p">(</span><span class="n">run</span><span class="p">))</span>
        <span class="k">try</span> <span class="n">modelContext</span><span class="o">.</span><span class="nf">save</span><span class="p">()</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">allRuns</span><span class="p">()</span> <span class="k">throws</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">BenchmarkRun</span><span class="p">]</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">descriptor</span> <span class="o">=</span> <span class="kt">FetchDescriptor</span><span class="o">&lt;</span><span class="kt">RunRecord</span><span class="o">&gt;</span><span class="p">(</span>
            <span class="nv">sortBy</span><span class="p">:</span> <span class="p">[</span><span class="kt">SortDescriptor</span><span class="p">(\</span><span class="o">.</span><span class="n">startedAt</span><span class="p">,</span> <span class="nv">order</span><span class="p">:</span> <span class="o">.</span><span class="n">reverse</span><span class="p">)]</span>
        <span class="p">)</span>
        <span class="k">return</span> <span class="k">try</span> <span class="n">modelContext</span><span class="o">.</span><span class="nf">fetch</span><span class="p">(</span><span class="n">descriptor</span><span class="p">)</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="nv">$0</span><span class="o">.</span><span class="nf">toDomain</span><span class="p">()</span> <span class="p">}</span>
    <span class="p">}</span>
    <span class="c1">// …</span>
<span class="p">}</span>
</code></pre></div></div>

<p>All SwiftData access serializes on its own actor-bound <code class="language-plaintext highlighter-rouge">ModelContext</code>, off the main actor entirely. And it never leaks <code class="language-plaintext highlighter-rouge">@Model</code> types across the boundary — it converts to and from plain <code class="language-plaintext highlighter-rouge">Sendable</code> domain values, so the rest of the app talks in <code class="language-plaintext highlighter-rouge">BenchmarkRun</code>, not in persistence records. The view model is <code class="language-plaintext highlighter-rouge">@MainActor @Observable</code>, consumes the <code class="language-plaintext highlighter-rouge">RunEvent</code> stream, folds telemetry into a sliding 120-sample window for the chart, and hands the finished run to the store. Three isolation domains — main actor for UI, the runner for orchestration, the store for persistence — and the compiler keeps them from stepping on each other.</p>

<p>Once runs are persisted, comparing them is just a fetch — two runs side by side, throughput against pass rate against peak memory:</p>

<picture>
  <source srcset="/images/blog/2026-07-10-merging-two-live-streams-taskgroup-llm-benchmark/modelbench-history.webp" type="image/webp" />
  <img src="/images/blog/2026-07-10-merging-two-live-streams-taskgroup-llm-benchmark/modelbench-history.png" alt="ModelBench history view comparing two on-device LLM benchmark runs side by side: average and peak tokens/sec, pass rate, and peak memory." loading="lazy" decoding="async" />
</picture>

<h2 id="the-sandbox-wall">The sandbox wall</h2>

<p>One eval suite I had to leave half-built, and I want to be honest about why.</p>

<p>HumanEval scores Python by <em>running</em> it — you generate code, you execute the test cases, you count passes. ModelBench has the suite and the prompts. What it can’t do, in the shipped build, is run the code: the macOS app sandbox blocks spawning <code class="language-plaintext highlighter-rouge">python3</code>, and a benchmark harness that silently fakes a score is worse than useless. So those cases report as execution-disabled, explicitly, rather than lying with a green checkmark.</p>

<p>You <em>can</em> enable it — drop <code class="language-plaintext highlighter-rouge">com.apple.security.app-sandbox</code> from the entitlements and regenerate. But understand what that means: you’re running untrusted model output on your machine with no fence around it. That’s a real security tradeoff, and it should be a deliberate choice, not a default I made for you. Surfacing the wall and labeling it beats pretending it isn’t there.</p>

<h2 id="whats-next">What’s next</h2>

<p>The concurrency core is done and it holds. Two things come after it, and they’re the ones I actually want to write: a deep dive on the LLM-as-judge — how much you can trust a model grading another model’s code, and what it takes to keep that grader honest — and the first real numbers, MLX versus Ollama throughput on the same prompts, same hardware, charted live with the thermal cliff in frame.</p>

<p>ModelBench targets macOS 26+ and is written against Swift 6 strict concurrency throughout — no <code class="language-plaintext highlighter-rouge">@unchecked Sendable</code>, no <code class="language-plaintext highlighter-rouge">Task.detached</code>, no locks. The full source is <a href="https://github.com/wesmatlock/ModelBench">up on GitHub</a> — read the runner, file an issue, or tear the concurrency apart and tell me where it breaks.</p>

<p>Two streams, one task group, zero races. That’s the part worth keeping.</p>]]></content><author><name>Wesley Matlock</name></author><category term="swift" /><category term="concurrency" /><category term="macos" /><category term="ai" /><category term="swift-6" /><category term="structured-concurrency" /><category term="taskgroup" /><category term="asyncstream" /><category term="actors" /><category term="swiftdata" /><category term="on-device-llm" /><category term="mlx" /><category term="ollama" /><summary type="html"><![CDATA[How ModelBench merges live inference and telemetry streams into one ordered AsyncThrowingStream using a single Swift 6 actor and withThrowingTaskGroup.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://www.wesleymatlock.com/images/blog/2026-07-10-merging-two-live-streams-taskgroup-llm-benchmark/hero.png" /><media:content medium="image" url="https://www.wesleymatlock.com/images/blog/2026-07-10-merging-two-live-streams-taskgroup-llm-benchmark/hero.png" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>