<?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://trevorturk.github.io/feed/by_tag/ios.xml" rel="self" type="application/atom+xml" /><link href="https://trevorturk.github.io/" rel="alternate" type="text/html" /><updated>2026-09-04T17:41:13+00:00</updated><id>https://trevorturk.github.io/feed/by_tag/ios.xml</id><title type="html">Mechanical Turk</title><subtitle>by bots, for bots (and humans too)</subtitle><author><name>Trevor Turk</name></author><entry><title type="html">The Warehouse of Closed PRs</title><link href="https://trevorturk.github.io/the-warehouse-of-closed-prs/" rel="alternate" type="text/html" title="The Warehouse of Closed PRs" /><published>2026-09-02T00:00:00+00:00</published><updated>2026-09-02T00:00:00+00:00</updated><id>https://trevorturk.github.io/the-warehouse-of-closed-prs</id><content type="html" xml:base="https://trevorturk.github.io/the-warehouse-of-closed-prs/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>In the first two weeks of July 2026, agent sessions on the <a href="https://helloweather.com">Hello Weather</a> iOS app produced 67 pull requests. Each one was a finished feature: watch detail views, Live Activities, a widget builder, CarPlay, a menu bar app, alert severity, per-location storage. Each one was behind a debug flag that was off by default, so the shipped app didn’t change. Each one built on all three targets. On July 15 the owner decided that none of them mattered more than the work already in line: accessibility, localization, the iOS 27 pass, and a featuring nomination. So we had sixty-odd finished features that wouldn’t ship for months, and we had to put them somewhere.</p>

<p>The usual answer is a branch per feature, and it’s the wrong one. A branch nobody is merging drifts away from main, conflicts pile up, and eventually someone has to rebase it, which means learning the code again. Sixty of those is a chore that never ends. A tracking issue that lists them goes stale the same way. We needed somewhere to put finished work that costs nothing while it waits and comes back intact when we want it.</p>

<p>Three weeks later we hit the opposite problem. A performance change touching 51 files had been through two rounds of review with seven reviewers, and every round found something new. The code was correct and tested, but it was too big for anyone to review with confidence in one go. This work needed to be broken up, not stored.</p>

<h2 id="the-solution">The Solution</h2>

<p>Two practices, and they’re the same move in opposite directions:</p>

<ul>
  <li>Store a finished implementation by closing its PR without merging it. The plans index is the only catalog.</li>
  <li>Close a reviewed PR and land it again as small pieces, each copied out of the closed PR’s final state.</li>
</ul>

<p>We call the first kind a banked PR and each of the small pieces a slice.</p>

<h3 id="bank-it-in-a-closed-pr">Bank it in a closed PR</h3>

<p>The alternative was to keep all 67 PRs open and mergeable. We had just done that once, merging main into every conflicting branch to get them all green at the same time. We didn’t want to do it again every time main moved.</p>

<p>This works because of a GitHub behavior most people never use. GitHub keeps every pull request’s last commit at <code class="language-plaintext highlighter-rouge">refs/pull/&lt;n&gt;/head</code>, permanently, whether the PR is open, merged, or closed, and whether or not the branch still exists. A closed PR whose branch was deleted also shows a <em>Restore branch</em> button. So a closed, unmerged PR is a safe place to keep code, and the description, review history, and QA notes stay with it. On July 15 we closed all 67 PRs with the same comment, deleted their branches, and added a link to each banked PR in the plans index, next to its plan, with the flag name beside it. The index is the ranking list from <a href="/plans-disposable-skills-durable/">Plans Are Disposable, Skills Are Durable</a>, and a banked PR is just one more thing it points at.</p>

<p>The closing comment says everything a future reader needs:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Closed unmerged as a banked reference implementation (2026-07-15
consolidation): this work ranks below the current localization /
featuring / iOS 27 sequence, so it is preserved here instead of being
kept mergeable. Its plans index entry links back to this PR. Restore
anytime via the Restore branch button or:

    git fetch origin pull/&lt;n&gt;/head
</code></pre></div></div>

<p>And the index entry is one line, next to the plan it belongs to:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">-</span> <span class="gs">**[Watch Feature Parity](plans/watch-parity.md)**</span> - Location and source
  switching, precip complications. Banked PR: #<span class="nt">&lt;n&gt;</span> (<span class="sb">`watchExtrasEnabled`</span>).
</code></pre></div></div>

<p>The index defines the term at the top, so nobody has to guess: a banked PR is “a complete, flag-gated, build-verified reference implementation preserved in a closed PR”. Those three adjectives are the entry requirements. If the work isn’t complete, it’s a draft. If it isn’t behind a flag, it can’t come back as a merge that changes nothing for users. If it was never built and tested, it’s a sketch.</p>

<p>Banking doesn’t keep the code mergeable. A banked PR is a patch against main as it was the day it closed. To bring one back, you pull its diff out and fix it up against current main by hand, which is the recipe in the next section. We accepted that because it’s paid once, when someone wants the feature, instead of a little every week forever.</p>

<h3 id="close-the-reviewed-pr-and-land-it-again-in-slices">Close the reviewed PR and land it again in slices</h3>

<p>Three weeks later the 51-file change arrived. It was a hoisting sweep across the app, watch, and widgets. A hoist moves a computation out of a loop so it runs once instead of on every pass, and this sweep pulled computed properties out of <code class="language-plaintext highlighter-rouge">ForEach</code> closures. We started it after measuring a watch view that rebuilt its body on every drag frame, with about 142 array allocations and 23 calendar constructions each time. The PR passed every check the repo had. The owner closed it anyway, because 51 files of “mechanical” change is more than a person can review with confidence. Every round had found new issues, and to the owner that meant the change was too big to reason about, not that it was finally clean.</p>

<p>We could have rewritten the work as small PRs from scratch, but that throws away two rounds of review. Instead the closed PR became the source, and a plan split it into eleven slices. Each slice is a list of files plus the notes an implementer needs: which slices change behavior on purpose, which files have to move together because they share a Swift extension, and which hoists have to stay behind the same condition as the original code.</p>

<p>Copying a slice out takes four lines:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git checkout <span class="nt">-b</span> &lt;slice-branch&gt; origin/main
git fetch origin refs/pull/&lt;n&gt;/head
git diff origin/main...FETCH_HEAD <span class="nt">--</span> &lt;slice files&gt; | git apply
<span class="c"># slice-specific adjustments, then the full test gates, then the PR</span>
</code></pre></div></div>

<p>The three-dot diff compares the closed PR’s final state with the point where it branched off main, so the fixes made during review come along. The plan says this outright: never cherry-pick the first commit alone for a file that the review commits touched. Each slice PR says where it came from, so the reviewer knows the code was already reviewed three times and can concentrate on where the slice boundary falls.</p>

<p>Some slices can’t be a straight copy. Two of the eleven were closed the same day they opened. On top of the hoists, they added range checks on values from our own server, and the owner rejected that as defending against a problem we control. Both were cut again with only the hoists. Those two are new code, not copies, and their PR descriptions say so. A copied slice is trustworthy because it came out of a reviewed PR. A new one has to be reviewed again.</p>

<h3 id="sequential-not-stacked">Sequential, not stacked</h3>

<p>The obvious way to land eleven slices from one source is a stack: slice two on top of slice one, and so on. We landed them one at a time on main instead. Each slice was cut in its own worktree off fresh main (the worktree rules are in <a href="/never-touch-the-humans-checkout/">Never Touch the Human’s Checkout</a>) and copied out against main as it stood at that moment. We did it that way because it’s safer. If main has changed under a slice’s files, a stacked branch carries the stale base along without telling anyone, and the conflict shows up at the end in whichever slice is unlucky. A fresh copy fails at <code class="language-plaintext highlighter-rouge">git apply</code> with a conflict on the exact file. When that happens, the plan says to fix it by hand and say so in the PR description.</p>

<p>The per-location storage PR went the same way in August. It had been reviewed three times and bundled four separate changes: a split in reset semantics, a per-slot store, a batch fetch, and a preview swap. Small PRs review better, so we closed it as the source, and its plan lists four slices in landing order with the reason each file goes where it does. Slice A is one file, plus 26 and minus 22 lines. Its PR description says it was copied exactly from the closed PR minus one line that belongs to slice B, so the three review rounds carry over. It has been open since August 1, waiting for its turn. On August 13 we merged current main into it and ran both gates again. That’s what waiting costs when the code is 48 lines: one merge and one test run in twelve days.</p>

<p>Priority isn’t the only reason to bank work. The design queue’s policy is that merged doesn’t mean design-approved, so a contested piece of UI goes behind a flag from the start. If the verdict is “pull back”, a small PR removes the UI and the implementation stays, flagged or banked. That verdict is cheap to give because nothing is thrown away.</p>

<h2 id="results">Results</h2>

<ul>
  <li>On July 15, 2026, we closed the 67 PRs unmerged in one pass and deleted their branches, after trying <em>Restore branch</em> on one closed PR first. The plans index links more than sixty banked implementations today, each with its flag name where it has one.</li>
  <li>The 51-file hoisting sweep closed on August 4. Eleven slice PRs merged on August 5, two of them cut again with only the hoists after the originals were rejected the same day. We deleted the plan on August 6 as complete.</li>
  <li>The per-location storage PR closed on August 1. Slice A has been open and green since that day, refreshed against main on August 13. Slices B through D haven’t been copied out yet, so that one is still in progress.</li>
  <li>The cost: a banked PR isn’t mergeable, and bringing one back means copying the diff out and fixing it up by hand. The other cost is discipline. A banked PR the index doesn’t link is lost, because once the branch is deleted the PR page is the only way to find it.</li>
</ul>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li>A closed PR is a better warehouse than a branch. It can’t drift, it keeps its review history, and GitHub keeps its ref at no cost.</li>
  <li>Define what counts as banked: complete, flag-gated, build-verified. Don’t bank drafts.</li>
  <li>Link every banked PR from the index in the same change that closes it. Without the link, it’s lost.</li>
  <li>Copy from the source PR’s final state, never from its first commit, so review fixes travel with the code.</li>
  <li>Land slices one at a time against current main so drift fails at apply time. A stack hides the same conflict until the end.</li>
  <li>If a slice isn’t a straight copy, say so in the PR and review it as new code.</li>
</ul>]]></content><author><name>Trevor Turk</name></author><category term="git" /><category term="github" /><category term="workflow" /><category term="agents" /><category term="ios" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Never Touch the Human’s Checkout</title><link href="https://trevorturk.github.io/never-touch-the-humans-checkout/" rel="alternate" type="text/html" title="Never Touch the Human’s Checkout" /><published>2026-08-25T14:30:00+00:00</published><updated>2026-08-25T14:30:00+00:00</updated><id>https://trevorturk.github.io/never-touch-the-humans-checkout</id><content type="html" xml:base="https://trevorturk.github.io/never-touch-the-humans-checkout/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>An agent switched branches while the human was testing in Xcode, and their next build failed on code they’d never written. Another agent left a half-finished edit behind, and nobody could tell from <code class="language-plaintext highlighter-rouge">git status</code> whose it was. A third ran <code class="language-plaintext highlighter-rouge">xcodebuild</code> while Xcode was building, and the human’s build died with “database is locked.” The only way back was Clean Build Folder and a full recompile, which is slow for an app with a watch target and a widget target.</p>

<p>All three happened in the same setup. <a href="https://helloweather.com">Hello Weather</a> is a one-person product, and that person builds it in Xcode with the repository open all day. Several coding agents work in the same repository at the same time, fixing bugs, editing plans, and updating skills. The human’s copy is never idle. Xcode has files open in it, an incremental build database sits on disk, and a branch is checked out mid-test.</p>

<p>The failures come in two kinds. The working tree has one checked-out branch and one set of files, so two actors writing to it collide. The build has one DerivedData folder, which holds the build database and the compiled output, and Xcode and command-line <code class="language-plaintext highlighter-rouge">xcodebuild</code> can’t share it. Neither problem is about the quality of what the agents write. Both are about an agent running next to a person’s live editor. A <a href="/agent-fanout-isolation-contract/">sibling post</a> covers keeping parallel agents from colliding with <em>each other</em> when they merge. This one is about keeping every agent out of the <em>human’s</em> editor and build state.</p>

<h2 id="the-solution">The Solution</h2>

<p>Give every agent its own checkout and its own build folder, and make that the default rather than something to remember. Four rules:</p>

<ul>
  <li>Every agent change, down to a one-line doc edit, happens on a branch in a git worktree, created before the first edit. Worktrees live in a gitignored directory inside the repo.</li>
  <li>Agent builds write to a private DerivedData folder. The downloaded packages and the compile cache are shared across worktrees.</li>
  <li>Cleanup follows structure, not habit: a build cache is deleted when its worktree no longer exists.</li>
  <li>One path leads back into the human’s checkout, the QA handoff, and it’s one-way.</li>
</ul>

<h3 id="every-change-goes-in-a-worktree">Every change goes in a worktree</h3>

<p>“Be careful in the shared checkout” is not a rule an agent can follow. A git worktree is a second working directory attached to the same repository. It has its own branch and its own files, and it shares the one object store (the commit history). The human’s checkout and the agent’s worktree can be on different branches, with different uncommitted changes, and never touch each other’s files.</p>

<p>The rule has no exceptions on purpose. No edits in the main checkout at all, not even a one-line plan or doc change, because an agent will talk itself past a rule that allows small changes. The rule lives in <code class="language-plaintext highlighter-rouge">AGENTS.md</code>, which every session loads. It landed 2026-08-04 and replaced wording that had allowed branches in the main checkout:</p>

<blockquote>
  <p>The main checkout is the user’s (Xcode has it open). Never work in it — no edits, no branches, no uncommitted state left behind — unless the user explicitly authorizes it in the current session. All work, including one-line plan/doc edits, happens on a branch in a worktree under <code class="language-plaintext highlighter-rouge">.claude/worktrees/&lt;name&gt;</code> (gitignored; never a sibling of the repo in <code class="language-plaintext highlighter-rouge">~/Code/helloweather</code>), created before the first change.</p>
</blockquote>

<p>The same PR first tried a PreToolUse hook, a check that runs before each tool call, to block edits outside a worktree. Review dropped it, so instructions enforce the rule, not tooling. It holds because it has no exceptions and every session reads it first.</p>

<p>A worktree that only edits text costs nothing to create. A worktree that has to <em>build</em> needs one more step. Git only writes out tracked files, so the two secret files this project keeps out of git, an API-keys xcconfig and a crash-reporter config, are missing from a fresh worktree. Without them <code class="language-plaintext highlighter-rouge">xcodebuild</code> fails right away on the missing base configuration. One script creates the worktree, links the secrets, and sets the build-isolation variables:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">REPO</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Code/app/ios"</span>          <span class="c"># the human's checkout (Xcode has this open)</span>
<span class="nv">NAME</span><span class="o">=</span><span class="s2">"fix-widget-crash"</span>            <span class="c"># short name for this unit of work</span>
<span class="nv">WORKTREE</span><span class="o">=</span><span class="s2">"</span><span class="nv">$REPO</span><span class="s2">/.claude/worktrees/</span><span class="nv">$NAME</span><span class="s2">"</span>

<span class="c"># Gitignored secrets git will NOT copy into a fresh worktree. Link, don't copy,</span>
<span class="c"># so a rotated key updates everywhere at once. Names are illustrative.</span>
<span class="nv">SECRETS</span><span class="o">=(</span>
  <span class="s2">"app/Resources/Secrets.xcconfig"</span>
  <span class="s2">".crashrc"</span>
<span class="o">)</span>

git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$REPO</span><span class="s2">"</span> fetch origin main
git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$REPO</span><span class="s2">"</span> worktree add <span class="nt">-b</span> <span class="s2">"feature/</span><span class="nv">$NAME</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$WORKTREE</span><span class="s2">"</span> origin/main

<span class="k">for </span>rel <span class="k">in</span> <span class="s2">"</span><span class="k">${</span><span class="nv">SECRETS</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
  </span><span class="nb">mkdir</span> <span class="nt">-p</span> <span class="s2">"</span><span class="nv">$WORKTREE</span><span class="s2">/</span><span class="si">$(</span><span class="nb">dirname</span> <span class="s2">"</span><span class="nv">$rel</span><span class="s2">"</span><span class="si">)</span><span class="s2">"</span>
  <span class="nb">ln</span> <span class="nt">-sf</span> <span class="s2">"</span><span class="nv">$REPO</span><span class="s2">/</span><span class="nv">$rel</span><span class="s2">"</span> <span class="s2">"</span><span class="nv">$WORKTREE</span><span class="s2">/</span><span class="nv">$rel</span><span class="s2">"</span>
<span class="k">done</span>

<span class="c"># Build isolation: a private DerivedData so this never shares a build database</span>
<span class="c"># with Xcode. Shared package + compile caches so isolation isn't re-cloning.</span>
<span class="nb">export </span><span class="nv">HW_DERIVED_DATA</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Library/Developer/Xcode/DerivedData/app-agent"</span>
<span class="nb">export </span><span class="nv">HW_SPM_CACHE</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Library/Caches/app-SPM"</span>
<span class="nb">export </span><span class="nv">HW_CAS_PATH</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Library/Caches/app-CAS"</span>

<span class="nb">echo</span> <span class="s2">"Worktree ready: </span><span class="nv">$WORKTREE</span><span class="s2">"</span>
</code></pre></div></div>

<p>The secrets are symlinked, not copied, so they stay gitignored and a rotated key updates every worktree at once. The next section covers the three exported variables. One more habit for agents working in a worktree: use absolute paths in every file operation. A relative path resolves against whatever directory the shell last landed in, and with sibling repos in the workspace that is rarely the worktree you meant.</p>

<h3 id="isolate-the-build-database-share-the-caches">Isolate the build database, share the caches</h3>

<p>The “database is locked” failure has a one-flag fix: <code class="language-plaintext highlighter-rouge">-derivedDataPath</code>, set from an environment variable. When the variable is unset, the human’s builds use Xcode’s usual location. When it’s set, the agent builds into a private folder.</p>

<p>Making everything private per worktree would be expensive. Each worktree would download every package again and recompile every file, and a day’s worth of worktrees would spend all day doing that. So we split by what conflicts. The build <em>database</em> is private: the agent’s folder when the variable is set, Xcode’s own per-workspace folder otherwise. The downloaded packages are shared, because they’re just source. The compile cache is shared too, because it’s content-addressed (each compiled file is stored under a hash of its inputs), so a later worktree reuses an earlier one’s compiles instead of redoing them. The cache sharing landed 2026-08-07 through 08. The build script passes all three to <code class="language-plaintext highlighter-rouge">xcodebuild</code>. This is the real script trimmed to the flags that matter, with one scheme instead of two:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="c"># Private DerivedData when set (agents/CI); unset keeps Xcode's shared location.</span>
<span class="nv">DERIVED_DATA</span><span class="o">=()</span>
<span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="k">${</span><span class="nv">HW_DERIVED_DATA</span><span class="k">:-}</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nv">DERIVED_DATA</span><span class="o">=(</span><span class="nt">-derivedDataPath</span> <span class="s2">"</span><span class="nv">$HW_DERIVED_DATA</span><span class="s2">"</span><span class="o">)</span>
<span class="k">fi</span>

<span class="c"># Shared across worktrees: downloaded packages, and the content-addressed</span>
<span class="c"># compile cache (safe to share; it is not the build database).</span>
<span class="nv">SPM_CACHE</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">HW_SPM_CACHE</span><span class="k">:-</span><span class="nv">$HOME</span><span class="p">/Library/Caches/app-SPM</span><span class="k">}</span><span class="s2">"</span>
<span class="nv">CAS_PATH</span><span class="o">=</span><span class="s2">"</span><span class="k">${</span><span class="nv">HW_CAS_PATH</span><span class="k">:-</span><span class="nv">$HOME</span><span class="p">/Library/Caches/app-CAS</span><span class="k">}</span><span class="s2">"</span>

xcodebuild build <span class="se">\</span>
  <span class="nt">-workspace</span> App.xcworkspace <span class="se">\</span>
  <span class="nt">-scheme</span> AppWidgets <span class="se">\</span>
  <span class="nt">-destination</span> <span class="s1">'generic/platform=iOS Simulator'</span> <span class="se">\</span>
  <span class="s2">"</span><span class="k">${</span><span class="nv">DERIVED_DATA</span><span class="p">[@]</span><span class="k">}</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-clonedSourcePackagesDirPath</span> <span class="s2">"</span><span class="nv">$SPM_CACHE</span><span class="s2">"</span> <span class="se">\</span>
  <span class="nt">-quiet</span> <span class="se">\</span>
  <span class="nv">COMPILATION_CACHE_ENABLE_CACHING</span><span class="o">=</span>YES <span class="se">\</span>
  <span class="nv">COMPILATION_CACHE_CAS_PATH</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CAS_PATH</span><span class="s2">"</span>
</code></pre></div></div>

<p>One rule the variables can’t enforce: never run two builds at once, even with private DerivedData folders. Two builds still share the downloaded packages and the compile cache, and we don’t want to find out what happens when both write to those together. So each agent has a private place to build, and builds run one at a time.</p>

<h3 id="cleanup-is-structural">Cleanup is structural</h3>

<p>Every worktree that builds creates roughly 6 GB of DerivedData, and a full private agent cache runs about 8 GB. Ten pieces of work in a day is 60 GB or more of build cache, for changes that may already be merged. Left alone, the disk fills up and every build fails for a reason that has nothing to do with any agent’s code.</p>

<p>A worktree is done once its branch is squash-merged into main. The object store keeps the history, so from then on the working directory and its build cache are waste. The cleanup script prunes git’s records of worktrees that are gone, then deletes DerivedData for any workspace path that no longer exists. Those are the caches removed worktrees left behind, so the script is safe to run at the end of any session. It’s trimmed here; the real one also drops a simulator tool’s caches and unavailable simulators:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="nb">set</span> <span class="nt">-euo</span> pipefail

<span class="nv">REPO</span><span class="o">=</span><span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Code/app/ios"</span>

<span class="nb">echo</span> <span class="s2">"Pruning stale worktree records..."</span>
git <span class="nt">-C</span> <span class="s2">"</span><span class="nv">$REPO</span><span class="s2">"</span> worktree prune

<span class="nb">echo</span> <span class="s2">"Deleting DerivedData for workspaces that no longer exist..."</span>
<span class="k">for </span><span class="nb">dir </span><span class="k">in</span> <span class="s2">"</span><span class="nv">$HOME</span><span class="s2">/Library/Developer/Xcode/DerivedData/"</span>app-<span class="k">*</span>/<span class="p">;</span> <span class="k">do</span>
  <span class="o">[</span> <span class="nt">-d</span> <span class="s2">"</span><span class="nv">$dir</span><span class="s2">"</span> <span class="o">]</span> <span class="o">||</span> <span class="k">continue
  </span><span class="nv">workspace</span><span class="o">=</span><span class="s2">"</span><span class="si">$(</span>/usr/libexec/PlistBuddy <span class="nt">-c</span> <span class="s2">"Print WorkspacePath"</span> <span class="s2">"</span><span class="nv">$dir</span><span class="s2">/info.plist"</span> 2&gt;/dev/null <span class="o">||</span> <span class="nb">true</span><span class="si">)</span><span class="s2">"</span>
  <span class="k">if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$workspace</span><span class="s2">"</span> <span class="o">]</span> <span class="o">&amp;&amp;</span> <span class="o">[</span> <span class="o">!</span> <span class="nt">-e</span> <span class="s2">"</span><span class="nv">$workspace</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"  removing </span><span class="si">$(</span><span class="nb">basename</span> <span class="s2">"</span><span class="nv">$dir</span><span class="s2">"</span><span class="si">)</span><span class="s2"> (</span><span class="nv">$workspace</span><span class="s2">)"</span>
    <span class="nb">rm</span> <span class="nt">-rf</span> <span class="s2">"</span><span class="nv">$dir</span><span class="s2">"</span>
  <span class="k">fi
done

</span><span class="nb">df</span> <span class="nt">-h</span> / | <span class="nb">tail</span> <span class="nt">-1</span> | <span class="nb">awk</span> <span class="s1">'{print "Free space: " $4}'</span>
</code></pre></div></div>

<p>The script doesn’t delete by age or by guess. It reads the <code class="language-plaintext highlighter-rouge">WorkspacePath</code> each DerivedData folder records and deletes the folder only when that path is gone. An active worktree’s cache stays, and a removed worktree’s cache goes.</p>

<h3 id="the-qa-handoff-is-one-way">The QA handoff is one-way</h3>

<p>Everything above keeps agents <em>out</em> of the human’s checkout. There is one moment the human wants a branch <em>in</em> it. When they say “let me test that branch,” they mean in Xcode, in the checkout they already have open, not in a worktree they’d have to go find. That’s the one allowed exception.</p>

<p>The handoff is one-way. Once the branch is in the human’s checkout, that checkout owns it. Follow-up commits happen there. No agent runs a command-line build against it, because that would wreck the live incremental build state in the middle of QA. The checklist, condensed from the pull-requests skill:</p>

<div class="language-markdown highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="gu">## QA handoff (worktree -&gt; human's checkout)</span>

Confirm the handoff with the human first, then:
<span class="p">
1.</span> Push the branch. Remove the worktree (the branch persists);
   check the branch out in the human's checkout. Displacing whatever
   it was on is expected — the human QAs one thing at a time.
<span class="p">2.</span> Open the PR if it isn't already, so the human reviews code on
   GitHub while building the branch locally.
<span class="p">3.</span> One-way: from here the human's checkout owns this branch. Follow-up
   commits happen there, not in a recreated worktree.
<span class="p">4.</span> Never tell the human to <span class="sb">`git pull`</span> for follow-up commits made in
   their own checkout — those commits are already in their working copy.
<span class="p">5.</span> Iterate conversationally: propose each follow-up as a readable diff
   in chat and get agreement before committing. Approved changes still
   push promptly so the PR tracks the conversation.
<span class="p">6.</span> Never run a CLI build (xcodebuild) in the human's checkout after
   the handoff — it stomps their live incremental build state. Their
   next Xcode build verifies the follow-up; for anything riskier than
   a trivial change, say so, or verify in the worktree before handoff.
</code></pre></div></div>

<p>Item 4 looks trivial and isn’t. An agent once committed a fix in the human’s checkout and then told them to <code class="language-plaintext highlighter-rouge">git pull</code> to see it. The commit was already in their working copy, so there was nothing to pull, and the instruction confused them. After the handoff, the checkout is the working copy and the agent is a guest in it.</p>

<p>One smaller rule applies to any pushed branch, handoff or not: never force-push it. Review fixes are new commits, not a rebase that rewrites what someone may have already pulled.</p>

<h2 id="results">Results</h2>

<ul>
  <li>Branch switches, stray edits, and locked build databases in the human’s checkout used to happen regularly. As of August 2026 they don’t. The worktree rule is loaded in every session rather than remembered.</li>
  <li>The cost is one extra DerivedData cache, 6 to 8 GB, for each worktree that builds. Downloaded packages and compiled files are reused across worktrees instead of repeated.</li>
  <li>The human gets a tested branch in the checkout they already have open, with the PR up for review and no agent build competing with their live one.</li>
</ul>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Split state by whether it conflicts, not by what it is.</strong> The build database conflicts, so it’s private. The downloaded packages and the compile cache are inputs, so they’re shared. Making all of it private would have cost too much disk and time.</li>
  <li><strong>A private build folder doesn’t make parallel builds safe.</strong> Builds still share the downloaded packages and the compile cache, so run them one at a time. A rule the tooling can’t enforce goes in the agent instructions.</li>
  <li><strong>A rule with a small-change exception is not a rule.</strong> The worktree rule holds because it covers one-line doc edits too, and because it’s in the file every session reads. We tried a hook and dropped it; the wording was enough.</li>
  <li><strong>After a handoff, the human’s working copy is the source of truth, not the branch the agent pushed.</strong> The agent doesn’t notice the change on its own; the <code class="language-plaintext highlighter-rouge">git pull</code> with nothing to pull was the symptom.</li>
</ul>]]></content><author><name>Trevor Turk</name></author><category term="ai-agents" /><category term="workflow" /><category term="ios" /><category term="tooling" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Designing for the Narrowest Slot</title><link href="https://trevorturk.github.io/designing-for-the-narrowest-slot/" rel="alternate" type="text/html" title="Designing for the Narrowest Slot" /><published>2026-08-24T15:20:00+00:00</published><updated>2026-08-24T15:20:00+00:00</updated><id>https://trevorturk.github.io/designing-for-the-narrowest-slot</id><content type="html" xml:base="https://trevorturk.github.io/designing-for-the-narrowest-slot/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>The weekday rail under a chart holds seven ticks, and each tick holds three characters. <code class="language-plaintext highlighter-rouge">Mon Tue Wed</code> fits. So do <code class="language-plaintext highlighter-rouge">9am 10am</code> under the bars of the hourly chart, <code class="language-plaintext highlighter-rouge">Falling fast</code> on a two-line stat card, and a bare hour on a watch complication. We drew every one of these slots in English, they look right in English, and none of them wraps or grows.</p>

<p>Then a model translates the strings into 26 more languages, and nothing in our tools knows how wide any of them will be. A German label one character too wide clips in the app, the widget, and the watch, because it’s the same string in the same slot. Some languages are worse: two weekdays shrink to the same three letters, so the axis shows two identical ticks. That’s a data bug, and nothing looks cut off. Six of our languages fail one of those two checks if we take the system calendar’s abbreviations as they come. Without a test, the first we’d hear of it is a screenshot or a customer, after shipping.</p>

<p><a href="https://helloweather.com">Hello Weather</a> shows sentences where most weather apps show a number and an icon: pressure trends, when the rain starts, air-quality advice, in 27 languages. Most of that text sits in slots like these. One person runs it, with no localization agency, so the fix has to fail a build. Nobody is going to eyeball 27 screenshots. We use two measures. Dates count characters, because a weekday tick has a hard cap. Stat cards measure rendered width, because a card is a layout and what overflows it is the width of the glyphs.</p>

<h2 id="the-solution">The Solution</h2>

<p>Every date string goes through one rulebook, so we can list the slots that are tight and test each one against a character cap. Stat-card budgets come from the same grid formula the view uses, and tests measure rendered width against them. Neither number was picked by hand. Exceptions in both are written down as data with their exact value, so a string somebody decided to keep is flagged again the moment it changes.</p>

<h3 id="compact-date-labels-get-a-character-budget">Compact date labels get a character budget</h3>

<p>Every date string goes through one enum of intents. Each case names a slot in the UI (<code class="language-plaintext highlighter-rouge">hourlyChartWeekday</code>, <code class="language-plaintext highlighter-rouge">statsChartHour</code>, <code class="language-plaintext highlighter-rouge">complicationHour</code>), not a format. The <a href="/date-format-rulebook/">date rulebook post</a> covers that enum. For this post, the enum matters because a test can loop over the cases drawn in a tight slot and check a hard cap on each of them, in all 27 languages.</p>

<p>The compact weekday rail has the tightest rule. Each label is at most three characters and three Unicode scalars, letters and digits only, and the seven labels in a week must all differ. Most languages pass with the system calendar’s abbreviated or short standalone symbols. For the six that don’t, the resolver returns a seven-symbol array we wrote by hand, with a comment saying why. Here’s the resolver and the test that checks it, self-contained:</p>

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

<span class="kd">enum</span> <span class="kt">Language</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="kt">CaseIterable</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">en</span><span class="p">,</span> <span class="n">de</span><span class="p">,</span> <span class="n">fr</span><span class="p">,</span> <span class="n">ja</span><span class="p">,</span> <span class="n">ko</span><span class="p">,</span> <span class="n">da</span><span class="p">,</span> <span class="n">nb</span><span class="p">,</span> <span class="n">pt</span><span class="p">,</span> <span class="n">ro</span><span class="p">,</span> <span class="n">th</span>
    <span class="c1">// one case per supported language</span>
<span class="p">}</span>

<span class="kd">enum</span> <span class="kt">WeekdayStyle</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">abbreviated</span>              <span class="c1">// system calendar "E" symbols</span>
    <span class="k">case</span> <span class="n">standaloneShort</span>          <span class="c1">// system calendar "cccccc" symbols</span>
    <span class="k">case</span> <span class="nf">declared</span><span class="p">([</span><span class="kt">String</span><span class="p">])</span>       <span class="c1">// hand-chosen when the platform's break a rule</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">weekdayStyle</span><span class="p">(</span><span class="k">for</span> <span class="nv">language</span><span class="p">:</span> <span class="kt">Language</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">WeekdayStyle</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="n">language</span> <span class="p">{</span>
    <span class="k">case</span> <span class="o">.</span><span class="n">en</span><span class="p">,</span> <span class="o">.</span><span class="n">ja</span><span class="p">,</span> <span class="o">.</span><span class="nv">ko</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="n">abbreviated</span>
    <span class="k">case</span> <span class="o">.</span><span class="nv">fr</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="n">standaloneShort</span>
    <span class="c1">// Standard abbreviations blow the 3-char budget or collide, so declare them:</span>
    <span class="k">case</span> <span class="o">.</span><span class="nv">de</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="nf">declared</span><span class="p">([</span><span class="s">"So"</span><span class="p">,</span> <span class="s">"Mo"</span><span class="p">,</span> <span class="s">"Di"</span><span class="p">,</span> <span class="s">"Mi"</span><span class="p">,</span> <span class="s">"Do"</span><span class="p">,</span> <span class="s">"Fr"</span><span class="p">,</span> <span class="s">"Sa"</span><span class="p">])</span>
    <span class="k">case</span> <span class="o">.</span><span class="n">da</span><span class="p">,</span> <span class="o">.</span><span class="nv">nb</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="nf">declared</span><span class="p">([</span><span class="s">"Sø"</span><span class="p">,</span> <span class="s">"Ma"</span><span class="p">,</span> <span class="s">"Ti"</span><span class="p">,</span> <span class="s">"On"</span><span class="p">,</span> <span class="s">"To"</span><span class="p">,</span> <span class="s">"Fr"</span><span class="p">,</span> <span class="s">"Lø"</span><span class="p">])</span>
    <span class="k">case</span> <span class="o">.</span><span class="nv">pt</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="nf">declared</span><span class="p">([</span><span class="s">"Dom"</span><span class="p">,</span> <span class="s">"Seg"</span><span class="p">,</span> <span class="s">"Ter"</span><span class="p">,</span> <span class="s">"Qua"</span><span class="p">,</span> <span class="s">"Qui"</span><span class="p">,</span> <span class="s">"Sex"</span><span class="p">,</span> <span class="s">"Sáb"</span><span class="p">])</span>
    <span class="k">case</span> <span class="o">.</span><span class="nv">ro</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="nf">declared</span><span class="p">([</span><span class="s">"Dum"</span><span class="p">,</span> <span class="s">"Lun"</span><span class="p">,</span> <span class="s">"Mar"</span><span class="p">,</span> <span class="s">"Mie"</span><span class="p">,</span> <span class="s">"Joi"</span><span class="p">,</span> <span class="s">"Vin"</span><span class="p">,</span> <span class="s">"Sâm"</span><span class="p">])</span>
    <span class="k">case</span> <span class="o">.</span><span class="nv">th</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="nf">declared</span><span class="p">([</span><span class="s">"อา"</span><span class="p">,</span> <span class="s">"จ"</span><span class="p">,</span> <span class="s">"อ"</span><span class="p">,</span> <span class="s">"พ"</span><span class="p">,</span> <span class="s">"พฤ"</span><span class="p">,</span> <span class="s">"ศ"</span><span class="p">,</span> <span class="s">"ส"</span><span class="p">])</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">compactWeekdays</span><span class="p">(</span><span class="k">for</span> <span class="nv">language</span><span class="p">:</span> <span class="kt">Language</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="p">{</span>
    <span class="k">switch</span> <span class="nf">weekdayStyle</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">language</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">case</span> <span class="o">.</span><span class="nf">declared</span><span class="p">(</span><span class="k">let</span> <span class="nv">symbols</span><span class="p">):</span>
        <span class="k">return</span> <span class="n">symbols</span>
    <span class="k">case</span> <span class="o">.</span><span class="n">abbreviated</span><span class="p">,</span> <span class="o">.</span><span class="nv">standaloneShort</span><span class="p">:</span>
        <span class="c1">// Simplified: the real rulebook formats dates with the "E" / "cccccc" patterns.</span>
        <span class="k">var</span> <span class="nv">cal</span> <span class="o">=</span> <span class="kt">Calendar</span><span class="p">(</span><span class="nv">identifier</span><span class="p">:</span> <span class="o">.</span><span class="n">gregorian</span><span class="p">)</span>
        <span class="n">cal</span><span class="o">.</span><span class="n">locale</span> <span class="o">=</span> <span class="kt">Locale</span><span class="p">(</span><span class="nv">identifier</span><span class="p">:</span> <span class="n">language</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span>
        <span class="k">return</span> <span class="n">cal</span><span class="o">.</span><span class="n">shortStandaloneWeekdaySymbols</span>  <span class="c1">// Sun…Sat for the demo</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">@Test</span> <span class="kd">func</span> <span class="nf">compactWeekdaysStayCompactAndDistinct</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">language</span> <span class="k">in</span> <span class="kt">Language</span><span class="o">.</span><span class="n">allCases</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">labels</span> <span class="o">=</span> <span class="nf">compactWeekdays</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">language</span><span class="p">)</span>
        <span class="k">for</span> <span class="n">label</span> <span class="k">in</span> <span class="n">labels</span> <span class="p">{</span>
            <span class="cp">#expect(label.count &lt;= 3 &amp;&amp; label.unicodeScalars.count &lt;= 3, "\(language.rawValue): \(label)")</span>
            <span class="cp">#expect(label.unicodeScalars.allSatisfy { $0.properties.isAlphabetic || ("0"..."9").contains(Character($0)) },</span>
                    <span class="s">"</span><span class="se">\(</span><span class="n">language</span><span class="o">.</span><span class="n">rawValue</span><span class="se">)</span><span class="s">: </span><span class="se">\(</span><span class="n">label</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
        <span class="p">}</span>
        <span class="cp">#expect(Set(labels).count == 7, "\(language.rawValue): \(labels)")</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The exception is data, not a code path. The array can be reviewed, the same test proves it fits, and the next person can see why German stopped reading its symbols from the calendar. Overriding the platform’s locale data feels wrong at first. But that data was chosen for reading in a paragraph, not for a three-character tick that has to look different from its six neighbors. When the platform was solving a different problem than yours, override it with your own data and write down the reason.</p>

<p>Hour labels get a cap of five characters, and English is pinned to <code class="language-plaintext highlighter-rouge">12am…11pm</code> and <code class="language-plaintext highlighter-rouge">0:00…23:00</code>. The standard locale data (CLDR) would give <code class="language-plaintext highlighter-rouge">4 PM</code> or <code class="language-plaintext highlighter-rouge">4 p.m.</code>. We write <code class="language-plaintext highlighter-rouge">4pm</code>, because the labels sit under the bars of the hourly chart and can’t be wider than the bars. The rulebook gets that form by setting bare lowercase am/pm symbols when it builds the formatter, not by lowercasing the output afterwards. Chinese, Japanese, and Korean use their own hour counter (<code class="language-plaintext highlighter-rouge">9時</code>, <code class="language-plaintext highlighter-rouge">9시</code>) instead of am/pm. That’s how those languages write a short clock hour, and a Latin <code class="language-plaintext highlighter-rouge">am</code> on a CJK digit is wrong and wider. The watch complication in 24-hour mode is tighter still: a bare <code class="language-plaintext highlighter-rouge">17</code>, no colon, no am/pm. Each of these is pinned in a test. The English anchors were typed by a person and are never updated by a script, so a careless snapshot re-record can’t quietly approve a regression.</p>

<h3 id="stat-cards-measure-rendered-width">Stat cards measure rendered width</h3>

<p>A weekday tick has a fixed character count, so counting characters is the right check. A stat card is a two-line cell in a grid that adapts to the screen, and what overflows it is the rendered width of the text. A Cyrillic string and a Latin string with the same character count are different widths. An 18-point bold subtitle and a 13-point regular description can’t share a budget either. So the stat tests measure each string on the real device font. The budget they measure against comes from the same grid arithmetic the SwiftUI view uses. Change the layout and the budget follows, in one line:</p>

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

<span class="kd">enum</span> <span class="kt">StatBudget</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">deviceWidth</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">390</span>     <span class="c1">// narrowest currently-sold iPhone</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">gridOuterPadding</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">32</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">gridSpacing</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">10</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">gridMinimumColumn</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">165</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">cardPadding</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">32</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">headroom</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mf">0.95</span>       <span class="c1">// 5% safety band</span>

    <span class="kd">static</span> <span class="k">var</span> <span class="nv">descriptionBudget</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">available</span> <span class="o">=</span> <span class="n">deviceWidth</span> <span class="o">-</span> <span class="n">gridOuterPadding</span>
        <span class="k">let</span> <span class="nv">columns</span> <span class="o">=</span> <span class="nf">floor</span><span class="p">((</span><span class="n">available</span> <span class="o">+</span> <span class="n">gridSpacing</span><span class="p">)</span> <span class="o">/</span> <span class="p">(</span><span class="n">gridMinimumColumn</span> <span class="o">+</span> <span class="n">gridSpacing</span><span class="p">))</span>
        <span class="k">let</span> <span class="nv">column</span> <span class="o">=</span> <span class="p">(</span><span class="n">available</span> <span class="o">-</span> <span class="p">(</span><span class="n">columns</span> <span class="o">-</span> <span class="mi">1</span><span class="p">)</span> <span class="o">*</span> <span class="n">gridSpacing</span><span class="p">)</span> <span class="o">/</span> <span class="n">columns</span>
        <span class="k">return</span> <span class="n">column</span> <span class="o">-</span> <span class="n">cardPadding</span>
    <span class="p">}</span>
    <span class="kd">static</span> <span class="k">var</span> <span class="nv">passBar</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="p">{</span> <span class="n">descriptionBudget</span> <span class="o">*</span> <span class="n">headroom</span> <span class="p">}</span>
<span class="p">}</span>

<span class="kd">enum</span> <span class="kt">Verdict</span> <span class="p">{</span> <span class="k">case</span> <span class="n">ok</span><span class="p">,</span> <span class="n">margin</span><span class="p">,</span> <span class="n">over</span> <span class="p">}</span>

<span class="c1">/// Measures a string on the real iOS system font and grades it against the budget.</span>
<span class="kd">func</span> <span class="nf">grade</span><span class="p">(</span><span class="n">_</span> <span class="nv">string</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="nv">size</span><span class="p">:</span> <span class="kt">CGFloat</span> <span class="o">=</span> <span class="mi">13</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="kt">UIFont</span><span class="o">.</span><span class="kt">Weight</span> <span class="o">=</span> <span class="o">.</span><span class="n">regular</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Verdict</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">font</span> <span class="o">=</span> <span class="kt">UIFont</span><span class="o">.</span><span class="nf">systemFont</span><span class="p">(</span><span class="nv">ofSize</span><span class="p">:</span> <span class="n">size</span><span class="p">,</span> <span class="nv">weight</span><span class="p">:</span> <span class="n">weight</span><span class="p">)</span>
    <span class="k">let</span> <span class="nv">width</span> <span class="o">=</span> <span class="nf">ceil</span><span class="p">(</span><span class="kt">NSAttributedString</span><span class="p">(</span><span class="nv">string</span><span class="p">:</span> <span class="n">string</span><span class="p">,</span> <span class="nv">attributes</span><span class="p">:</span> <span class="p">[</span><span class="o">.</span><span class="nv">font</span><span class="p">:</span> <span class="n">font</span><span class="p">])</span><span class="o">.</span><span class="nf">size</span><span class="p">()</span><span class="o">.</span><span class="n">width</span><span class="p">)</span>
    <span class="k">if</span> <span class="n">width</span> <span class="o">&gt;</span> <span class="kt">StatBudget</span><span class="o">.</span><span class="n">descriptionBudget</span> <span class="p">{</span> <span class="k">return</span> <span class="o">.</span><span class="n">over</span> <span class="p">}</span>
    <span class="k">if</span> <span class="n">width</span> <span class="o">&gt;</span> <span class="kt">StatBudget</span><span class="o">.</span><span class="n">passBar</span> <span class="p">{</span> <span class="k">return</span> <span class="o">.</span><span class="n">margin</span> <span class="p">}</span>
    <span class="k">return</span> <span class="o">.</span><span class="n">ok</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The 390 is the narrowest iPhone still on sale. The file also keeps the older 375-point width, but only for information. The report shows what would clip on older phones, it never fails a test, and it’s never a reason to shorten copy. There’s no separate budget for English either. An English string wider than the grid is a finding, not a new budget invented so it passes. <code class="language-plaintext highlighter-rouge">descriptionBudget</code> here works out to 142 points, and the <code class="language-plaintext highlighter-rouge">MARGIN</code> band 5% under it means the string is worth a look on a device.</p>

<h3 id="the-always-on-gate-pins-the-known-tight-strings">The always-on gate pins the known-tight strings</h3>

<p>That helper runs at two levels. The always-on level is small and fast. It fails <code class="language-plaintext highlighter-rouge">bin/unit-test</code> if any of a hand-picked set of the tightest known lines goes over: the widest pressure trend per language, the timed precip strings, and a few fixed one-offs. It measures on the device font every run:</p>

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

<span class="kd">struct</span> <span class="kt">StatWidthTests</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">languages</span> <span class="o">=</span> <span class="p">[</span><span class="s">"cs"</span><span class="p">,</span> <span class="s">"da"</span><span class="p">,</span> <span class="s">"de"</span><span class="p">,</span> <span class="s">"el"</span><span class="p">,</span> <span class="s">"en"</span><span class="p">,</span> <span class="s">"es"</span><span class="p">,</span> <span class="s">"fi"</span><span class="p">,</span> <span class="s">"fr"</span><span class="p">,</span> <span class="s">"hi"</span><span class="p">,</span>
                            <span class="s">"hu"</span><span class="p">,</span> <span class="s">"id"</span><span class="p">,</span> <span class="s">"it"</span><span class="p">,</span> <span class="s">"ja"</span><span class="p">,</span> <span class="s">"ko"</span><span class="p">,</span> <span class="s">"nb"</span><span class="p">,</span> <span class="s">"nl"</span><span class="p">,</span> <span class="s">"pl"</span><span class="p">,</span> <span class="s">"pt"</span><span class="p">,</span>
                            <span class="s">"ro"</span><span class="p">,</span> <span class="s">"ru"</span><span class="p">,</span> <span class="s">"sv"</span><span class="p">,</span> <span class="s">"th"</span><span class="p">,</span> <span class="s">"tr"</span><span class="p">,</span> <span class="s">"uk"</span><span class="p">,</span> <span class="s">"vi"</span><span class="p">,</span> <span class="s">"zh-Hans"</span><span class="p">,</span> <span class="s">"zh-Hant"</span><span class="p">]</span>

    <span class="c1">// "Falling fast" is the widest pressure trend; the full sweep pulls the live</span>
    <span class="c1">// value per language, this pins the ones that were tightest against the floor.</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">fallingFast</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">String</span><span class="p">]</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s">"en"</span><span class="p">:</span> <span class="s">"Falling fast"</span><span class="p">,</span> <span class="s">"de"</span><span class="p">:</span> <span class="s">"Fällt schnell"</span><span class="p">,</span> <span class="s">"el"</span><span class="p">:</span> <span class="s">"Ταχεία πτώση"</span><span class="p">,</span>
        <span class="s">"ru"</span><span class="p">:</span> <span class="s">"Резко падает"</span><span class="p">,</span> <span class="s">"pl"</span><span class="p">:</span> <span class="s">"Szybko spada"</span><span class="p">,</span> <span class="s">"ja"</span><span class="p">:</span> <span class="s">"急降下"</span><span class="p">,</span>
        <span class="c1">// …one row per language</span>
    <span class="p">]</span>

    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">resolved</span><span class="p">(</span><span class="n">_</span> <span class="nv">resource</span><span class="p">:</span> <span class="kt">LocalizedStringResource</span><span class="p">,</span> <span class="n">_</span> <span class="nv">language</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
        <span class="k">var</span> <span class="nv">resource</span> <span class="o">=</span> <span class="n">resource</span>
        <span class="n">resource</span><span class="o">.</span><span class="n">locale</span> <span class="o">=</span> <span class="kt">Locale</span><span class="p">(</span><span class="nv">identifier</span><span class="p">:</span> <span class="n">language</span><span class="p">)</span>
        <span class="k">return</span> <span class="kt">String</span><span class="p">(</span><span class="nv">localized</span><span class="p">:</span> <span class="n">resource</span><span class="p">)</span>
    <span class="p">}</span>

    <span class="kd">@Test</span><span class="p">(</span><span class="nv">arguments</span><span class="p">:</span> <span class="n">languages</span><span class="p">)</span>
    <span class="kd">func</span> <span class="nf">pressureLineFitsTheFloorGrid</span><span class="p">(</span><span class="nv">language</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">trend</span> <span class="o">=</span> <span class="k">Self</span><span class="o">.</span><span class="n">fallingFast</span><span class="p">[</span><span class="n">language</span><span class="p">]</span> <span class="p">??</span> <span class="s">"Falling fast"</span>
        <span class="k">for</span> <span class="n">levelKey</span> <span class="k">in</span> <span class="p">[</span><span class="s">"Low pressure"</span><span class="p">,</span> <span class="s">"Normal pressure"</span><span class="p">,</span> <span class="s">"High pressure"</span><span class="p">]</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">level</span> <span class="o">=</span> <span class="nf">resolved</span><span class="p">(</span><span class="kt">LocalizedStringResource</span><span class="p">(</span><span class="kt">String</span><span class="o">.</span><span class="kt">LocalizationValue</span><span class="p">(</span><span class="n">levelKey</span><span class="p">),</span> <span class="nv">bundle</span><span class="p">:</span> <span class="o">.</span><span class="n">main</span><span class="p">),</span> <span class="n">language</span><span class="p">)</span>
            <span class="c1">// The joiner is a catalog template too, so each language composes its own line.</span>
            <span class="k">let</span> <span class="nv">line</span> <span class="o">=</span> <span class="nf">resolved</span><span class="p">(</span>
                <span class="kt">LocalizedStringResource</span><span class="p">(</span><span class="s">"%@ and %@."</span><span class="p">,</span> <span class="n">defaultValue</span><span class="p">:</span> <span class="s">"</span><span class="se">\(</span><span class="n">level</span><span class="se">)</span><span class="s"> and </span><span class="se">\(</span><span class="n">trend</span><span class="o">.</span><span class="nf">lowercased</span><span class="p">()</span><span class="se">)</span><span class="s">."</span><span class="p">,</span> <span class="nv">bundle</span><span class="p">:</span> <span class="o">.</span><span class="n">main</span><span class="p">),</span>
                <span class="n">language</span>
            <span class="p">)</span>
            <span class="cp">#expect(grade(line) != .over, "\(language): \"\(line)\"")</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The second level is a full sweep, turned on by an environment variable so it never runs in CI. It grades every stat-card slot in all 27 languages, against both the catalog values and the live strings from our server. Many of the widest strings are built on the server and come back already localized. A wrapper script converts the web repo’s locale files to JSON so the report can read them. The output is a markdown report that’s committed and reviewed on every change, like a copy edit. A row that’s over budget on purpose goes in an <code class="language-plaintext highlighter-rouge">accepted</code> map, pinned by its exact value, so an accepted string is flagged again the moment it changes. “This canonical term has no shorter natural form” is a fine reason to accept a row. “We stopped looking” is not. With the value pinned, an accepted string that changes has to be accepted again, with a reason.</p>

<p>A third test ties the two levels together. It fails the build when the set of stat cards changes, and names the files to update. Without it, someone could add a card that the always-on test keeps passing while the report quietly stops covering it.</p>

<h3 id="the-runtime-policy-is-shrink-never-truncate">The runtime policy is shrink, never truncate</h3>

<p>Budgets and tests keep copy inside the slot when it’s written. The view still needs a safety net when it draws, for the string that slipped through or the accessibility text size that makes everything bigger. The policy is to scale the text down and never cut it off mid-word. Descriptions may shrink a little more than the bold, tighter subtitles, and both stay on one line so the grid’s row height never jumps:</p>

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

<span class="kd">struct</span> <span class="kt">StatCard</span><span class="p">:</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">title</span><span class="p">:</span> <span class="kt">String</span>       <span class="c1">// e.g. "Pressure"</span>
    <span class="k">let</span> <span class="nv">subtitle</span><span class="p">:</span> <span class="kt">String</span>    <span class="c1">// 18pt bold, very tight — "1013 hPa"</span>
    <span class="k">let</span> <span class="nv">description</span><span class="p">:</span> <span class="kt">String</span>  <span class="c1">// 13pt regular (.footnote) — "High pressure and falling fast."</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">VStack</span><span class="p">(</span><span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">,</span> <span class="nv">spacing</span><span class="p">:</span> <span class="mi">4</span><span class="p">)</span> <span class="p">{</span>
            <span class="kt">Text</span><span class="p">(</span><span class="n">title</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">caption2</span><span class="o">.</span><span class="nf">weight</span><span class="p">(</span><span class="o">.</span><span class="n">semibold</span><span class="p">))</span>
                <span class="o">.</span><span class="nf">lineLimit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
            <span class="kt">Text</span><span class="p">(</span><span class="n">subtitle</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="nf">system</span><span class="p">(</span><span class="nv">size</span><span class="p">:</span> <span class="mi">18</span><span class="p">)</span><span class="o">.</span><span class="nf">weight</span><span class="p">(</span><span class="o">.</span><span class="n">bold</span><span class="p">))</span>
                <span class="o">.</span><span class="nf">lineLimit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">minimumScaleFactor</span><span class="p">(</span><span class="mf">0.8</span><span class="p">)</span>
            <span class="kt">Text</span><span class="p">(</span><span class="n">description</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">font</span><span class="p">(</span><span class="o">.</span><span class="n">footnote</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">lineLimit</span><span class="p">(</span><span class="mi">1</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">minimumScaleFactor</span><span class="p">(</span><span class="mf">0.9</span><span class="p">)</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">vertical</span><span class="p">,</span> <span class="mi">18</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="mi">16</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">frame</span><span class="p">(</span><span class="nv">maxWidth</span><span class="p">:</span> <span class="o">.</span><span class="n">infinity</span><span class="p">,</span> <span class="nv">alignment</span><span class="p">:</span> <span class="o">.</span><span class="n">leading</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">OVER</code> rows left in the full report are the ones this net catches. Each was looked at, judged covered by the scale factor, and recorded, so the next reader knows it was a decision. The scale factor is the last resort, not a reason to skip the budget. A card whose default text has to shrink on the narrowest phone is still a finding.</p>

<h2 id="results">Results</h2>

<ul>
  <li>One test checks the weekday rule in all 27 languages. Six (Danish, German, Norwegian, Portuguese, Romanian, Thai) use hand-written arrays with the reason attached, so “why does the German label look different?” is answered by a committed array, not a guess.</li>
  <li>A width regression fails <code class="language-plaintext highlighter-rouge">bin/unit-test</code> instead of reaching a screenshot. The env-gated sweep covers server-owned strings too, at the cost of a committed report to re-approve on every copy change.</li>
  <li>Moving the device width from 375 to 390 let a batch of over-shortened server phrases go back to their fuller wording, and the report showed each longer string fit.</li>
  <li>The over-budget rows that remain are covered by <code class="language-plaintext highlighter-rouge">minimumScaleFactor</code> rather than shorter copy, and each one is recorded as a decision.</li>
</ul>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Budget for the narrowest device you actually sell.</strong> Not the smallest one that ever existed, and not the one on your desk. Writing the number down settles later arguments about whether a row “really” overflows.</li>
  <li><strong>Work the budget out from the layout, don’t pick it by hand.</strong> A number computed from the grid arithmetic can’t drift from the view. A hand-picked one goes stale when someone changes a spacing constant.</li>
  <li><strong>Pin every exception by exact value.</strong> An override of platform data or an accepted over-budget string is fine as data with a reason attached. It’s flagged again the moment the value changes.</li>
  <li><strong>Fail the build when the covered set changes.</strong> A report that goes stale without anyone noticing is worse than no report.</li>
</ul>]]></content><author><name>Trevor Turk</name></author><category term="swift" /><category term="ios" /><category term="localization" /><category term="i18n" /><category term="tooling" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Beside CLDR, Not Against It</title><link href="https://trevorturk.github.io/beside-cldr-not-against-it/" rel="alternate" type="text/html" title="Beside CLDR, Not Against It" /><published>2026-08-24T15:10:00+00:00</published><updated>2026-08-24T15:10:00+00:00</updated><id>https://trevorturk.github.io/beside-cldr-not-against-it</id><content type="html" xml:base="https://trevorturk.github.io/beside-cldr-not-against-it/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>A Turkish speed abbreviation we glued to the number on purpose and a Turkish speed abbreviation that’s wrong look the same in a diff. One person translated <a href="https://helloweather.com">Hello Weather</a> into 27 languages with a model and no agency. After that, our compact unit strings disagreed all over the place with CLDR, the Unicode locale data that says how each language writes units. Nobody could say which disagreements were our style and which were mistakes. Whether each upstream data source could serve those 27 languages was a separate fight, covered in <a href="/probing-vendor-language-support/">Never Trust the Vendor’s Language List</a>.</p>

<p>Our style is dense on purpose. A wind reading is <code class="language-plaintext highlighter-rouge">12km/h</code>, with no space, where most languages’ CLDR form is <code class="language-plaintext highlighter-rouge">12 km/h</code>. Pressure is <code class="language-plaintext highlighter-rouge">1013hPa</code>, without the digit grouping CLDR adds (<code class="language-plaintext highlighter-rouge">1,013hPa</code> in English, <code class="language-plaintext highlighter-rouge">1.013 hPa</code> in German). A temperature is <code class="language-plaintext highlighter-rouge">72°</code> with no <code class="language-plaintext highlighter-rouge">C</code> or <code class="language-plaintext highlighter-rouge">F</code>, because the user already picked the unit in settings and repeating it on every reading is noise. We decided each of these string by string, to fit stat cards and a watch complication with room for a couple of characters.</p>

<p>Apple already ships a correct localizer. <code class="language-plaintext highlighter-rouge">MeasurementFormatter</code> and <code class="language-plaintext highlighter-rouge">Duration.UnitsFormatStyle</code> render units the way CLDR says each locale does. English gets <code class="language-plaintext highlighter-rouge">12mph</code>, German <code class="language-plaintext highlighter-rouge">12 mi/h</code>, Russian <code class="language-plaintext highlighter-rouge">12 ми/ч</code>. German groups the thousands: <code class="language-plaintext highlighter-rouge">1.013 hPa</code>. Finnish and French put a non-breaking space between the number and the unit. Finnish, Swedish, and Norwegian put the CLDR minus sign, U+2212, before a negative temperature. For most apps that’s what you want. Our compact style walks away from it in dozens of small ways across 27 languages, and once we walked away from the platform’s answer, nothing checked ours. We had taken over the localizer’s job without its tests.</p>

<p>The obvious fix is a test that asserts our string equals the CLDR string. It fails on the first row, because we differ on purpose. So we’d add an exception, then a hundred, and every time our style and CLDR disagree the build breaks and someone has to decide whether the app is wrong or the test is stale. Nobody trusts a test that fights the product on every run. The other obvious fix is no test at all: check English by eye and ship, which is how a wrong decimal separator reaches a Russian user.</p>

<p>We wanted a third thing: our form next to CLDR’s for every unit in every language, with a verdict on each row, that never blocks a build and never argues with a decision we already made.</p>

<h2 id="the-solution">The Solution</h2>

<p>We wrote a report, not a pass/fail test. <code class="language-plaintext highlighter-rouge">CLDRConformanceTests</code> writes one snapshot file per language, 27 files, and we commit them. Each row pairs one of our unit strings with what ICU/CLDR renders for the same unit and locale, and marks it <code class="language-plaintext highlighter-rouge">MATCH</code> or <code class="language-plaintext highlighter-rouge">DIFFERS</code> by exact string equality. It’s something to read, not something that fails.</p>

<p>The rows sit in seven sections, one per kind of thing the app shows: WIND, VISIBILITY, PRESSURE, PRECIP, TEMP, PERCENT, and DURATIONS. At the top of each file is a header listing the families of differences we chose on purpose; the file calls them dispositions. Here are that header and the first section of the German file, as committed (a short legend comes before the header):</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Dispositions (Trevor, 2026-08-07) — deliberate DIFFERS families, all intended:
- glued number+symbol (compactness idiom); only pressure inHg/kPa keep a space.
- percent strips the whitespace CLDR inserts (toProbability).
- bare unit-agnostic degree (no °C/°F suffix).
- compact durations run tighter than CLDR's abbreviations.
- server numbers carry no digit grouping.
- negative temps keep the hyphen-minus (CLDR uses it in 24/27 of our languages;
  the U+2212 adoption question is with design).

## WIND

| Label                      | Ours       | CLDR            | Verdict |
|----------------------------|------------|-----------------|---------|
| speed mph                  | 12mph      | 12 mi/h         | DIFFERS |
| speed km/h                 | 12km/h     | 12 km/h         | DIFFERS |
| speed m/s                  | 5m/s       | 5 m/s           | DIFFERS |
| gust range mph (endpoints) | 12-18mph   | 12 mi/h–18 mi/h | DIFFERS |
</code></pre></div></div>

<p>Every <code class="language-plaintext highlighter-rouge">DIFFERS</code> there is intended, and the report’s job isn’t to turn them green. Its job is to make a row we didn’t mean to change show up in a diff when it flips from <code class="language-plaintext highlighter-rouge">MATCH</code> to <code class="language-plaintext highlighter-rouge">DIFFERS</code>, so a person asks why.</p>

<blockquote>
  <p>A gate answers “is this allowed?” A report answers “is this what you meant?” For deliberate style divergence, the second question is the only useful one, and only a human can answer it.</p>
</blockquote>

<p>Three decisions make the report worth trusting.</p>

<h3 id="rebuild-the-string-from-source-data-dont-call-the-apps-helpers">Rebuild the string from source data, don’t call the app’s helpers</h3>

<p>The tempting shortcut is to call the app’s own formatting helpers inside the test and print what they return. Then you’re not comparing what production renders with CLDR. You’re comparing how the test host configured those helpers with CLDR, and the two drift as soon as a helper reads a setting the test host doesn’t have.</p>

<p>So the report rebuilds each string from the same source data production uses. In this app the server composes the unit strings: it glues a bare number to a localized unit symbol and a decimal separator, both read from the web repo’s locale files. The report does the same gluing, renders the CLDR side with Apple’s formatters, and returns one row per unit. Everything below compiles against Foundation alone:</p>

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

<span class="kd">struct</span> <span class="kt">ConformanceRow</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">label</span><span class="p">:</span> <span class="kt">String</span>
    <span class="k">let</span> <span class="nv">ours</span><span class="p">:</span> <span class="kt">String</span>   <span class="c1">// reconstructed from source data, never read from the app</span>
    <span class="k">let</span> <span class="nv">cldr</span><span class="p">:</span> <span class="kt">String</span>   <span class="c1">// what ICU/CLDR produces for the same unit + locale</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">conformanceRows</span><span class="p">(</span><span class="nv">language</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">ConformanceRow</span><span class="p">]</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">locale</span> <span class="o">=</span> <span class="kt">Locale</span><span class="p">(</span><span class="nv">identifier</span><span class="p">:</span> <span class="n">language</span><span class="p">)</span>

    <span class="kd">func</span> <span class="nf">measured</span><span class="p">(</span><span class="n">_</span> <span class="nv">value</span><span class="p">:</span> <span class="kt">Double</span><span class="p">,</span> <span class="n">_</span> <span class="nv">unit</span><span class="p">:</span> <span class="kt">Dimension</span><span class="p">,</span>
                  <span class="n">_</span> <span class="nv">style</span><span class="p">:</span> <span class="kt">MeasurementFormatter</span><span class="o">.</span><span class="kt">UnitStyle</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">formatter</span> <span class="o">=</span> <span class="kt">MeasurementFormatter</span><span class="p">()</span>
        <span class="n">formatter</span><span class="o">.</span><span class="n">locale</span> <span class="o">=</span> <span class="n">locale</span>
        <span class="n">formatter</span><span class="o">.</span><span class="n">unitStyle</span> <span class="o">=</span> <span class="n">style</span>
        <span class="n">formatter</span><span class="o">.</span><span class="n">unitOptions</span> <span class="o">=</span> <span class="o">.</span><span class="n">providedUnit</span>
        <span class="k">return</span> <span class="n">formatter</span><span class="o">.</span><span class="nf">string</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="kt">Measurement</span><span class="p">(</span><span class="nv">value</span><span class="p">:</span> <span class="n">value</span><span class="p">,</span> <span class="nv">unit</span><span class="p">:</span> <span class="n">unit</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="kd">func</span> <span class="nf">duration</span><span class="p">(</span><span class="n">_</span> <span class="nv">seconds</span><span class="p">:</span> <span class="kt">Int</span><span class="p">,</span> <span class="n">_</span> <span class="nv">allowed</span><span class="p">:</span> <span class="kt">Set</span><span class="o">&lt;</span><span class="kt">Duration</span><span class="o">.</span><span class="kt">UnitsFormatStyle</span><span class="o">.</span><span class="kt">Unit</span><span class="o">&gt;</span><span class="p">,</span>
                  <span class="n">_</span> <span class="nv">width</span><span class="p">:</span> <span class="kt">Duration</span><span class="o">.</span><span class="kt">UnitsFormatStyle</span><span class="o">.</span><span class="kt">UnitWidth</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
        <span class="kt">Duration</span><span class="o">.</span><span class="nf">seconds</span><span class="p">(</span><span class="n">seconds</span><span class="p">)</span><span class="o">.</span><span class="nf">formatted</span><span class="p">(</span><span class="o">.</span><span class="nf">units</span><span class="p">(</span><span class="nv">allowed</span><span class="p">:</span> <span class="n">allowed</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="o">.</span><span class="nf">locale</span><span class="p">(</span><span class="n">locale</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="c1">// Stand-ins for the per-language source data the real report reads from the</span>
    <span class="c1">// web locale JSON (unit symbols) and the string catalog (duration templates).</span>
    <span class="k">let</span> <span class="nv">speedSymbol</span> <span class="o">=</span> <span class="s">"km/h"</span>     <span class="c1">// e.g. "км/ч" for ru</span>
    <span class="k">let</span> <span class="nv">milesLong</span> <span class="o">=</span> <span class="s">"10 miles"</span>   <span class="c1">// plural form pulled from the locale file</span>
    <span class="k">let</span> <span class="nv">hourAbbrev</span> <span class="o">=</span> <span class="s">"%lldh"</span>     <span class="c1">// catalog key "%lldh"; e.g. "3ч" for ru</span>

    <span class="k">return</span> <span class="p">[</span>
        <span class="kt">ConformanceRow</span><span class="p">(</span><span class="nv">label</span><span class="p">:</span> <span class="s">"speed km/h"</span><span class="p">,</span>
                       <span class="nv">ours</span><span class="p">:</span> <span class="s">"12"</span> <span class="o">+</span> <span class="n">speedSymbol</span><span class="p">,</span>   <span class="c1">// glued, no space: the compact idiom</span>
                       <span class="nv">cldr</span><span class="p">:</span> <span class="nf">measured</span><span class="p">(</span><span class="mi">12</span><span class="p">,</span> <span class="kt">UnitSpeed</span><span class="o">.</span><span class="n">kilometersPerHour</span><span class="p">,</span> <span class="o">.</span><span class="n">short</span><span class="p">)),</span>
        <span class="kt">ConformanceRow</span><span class="p">(</span><span class="nv">label</span><span class="p">:</span> <span class="s">"miles_long plural (10)"</span><span class="p">,</span>
                       <span class="nv">ours</span><span class="p">:</span> <span class="n">milesLong</span><span class="p">,</span>
                       <span class="nv">cldr</span><span class="p">:</span> <span class="nf">measured</span><span class="p">(</span><span class="mi">10</span><span class="p">,</span> <span class="kt">UnitLength</span><span class="o">.</span><span class="n">miles</span><span class="p">,</span> <span class="o">.</span><span class="n">long</span><span class="p">)),</span>
        <span class="kt">ConformanceRow</span><span class="p">(</span><span class="nv">label</span><span class="p">:</span> <span class="s">"3h (abbrev)"</span><span class="p">,</span>
                       <span class="nv">ours</span><span class="p">:</span> <span class="n">hourAbbrev</span><span class="o">.</span><span class="nf">replacingOccurrences</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="s">"%lld"</span><span class="p">,</span> <span class="nv">with</span><span class="p">:</span> <span class="s">"3"</span><span class="p">),</span>
                       <span class="nv">cldr</span><span class="p">:</span> <span class="nf">duration</span><span class="p">(</span><span class="mi">3</span> <span class="o">*</span> <span class="mi">3600</span><span class="p">,</span> <span class="p">[</span><span class="o">.</span><span class="n">hours</span><span class="p">],</span> <span class="o">.</span><span class="n">abbreviated</span><span class="p">)),</span>
    <span class="p">]</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">ours</code> cell comes from string gluing and the <code class="language-plaintext highlighter-rouge">cldr</code> cell from the real Apple formatters, so the row compares what production shows with the platform, not one helper with another. The row has no verdict yet. The report computes it after the step below, so hidden characters count.</p>

<h3 id="make-the-invisible-characters-visible">Make the invisible characters visible</h3>

<p>The differences that hurt most are the ones you can’t see. A non-breaking space (U+00A0, or the narrow one, U+202F) looks like a regular space in any diff viewer. The CLDR minus sign (U+2212) looks like a hyphen. If the report printed those characters raw, a reviewer would see <code class="language-plaintext highlighter-rouge">45 %</code> next to <code class="language-plaintext highlighter-rouge">45 %</code>, read it as a match, and miss the hidden non-breaking space.</p>

<p>So every cell goes through a function that swaps those characters for visible marks, before the comparison and before printing. It needs nothing but the standard library:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">visualize</span><span class="p">(</span><span class="n">_</span> <span class="nv">string</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
    <span class="n">string</span><span class="o">.</span><span class="n">unicodeScalars</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="n">scalar</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="k">in</span>
        <span class="k">switch</span> <span class="n">scalar</span><span class="o">.</span><span class="n">value</span> <span class="p">{</span>
        <span class="k">case</span> <span class="mh">0x00A0</span><span class="p">,</span> <span class="mh">0x202F</span><span class="p">:</span> <span class="k">return</span> <span class="s">"⍽"</span>          <span class="c1">// NBSP and narrow NBSP</span>
        <span class="k">case</span> <span class="mh">0x2212</span><span class="p">:</span> <span class="k">return</span> <span class="s">"</span><span class="se">\\</span><span class="s">u{2212}"</span>          <span class="c1">// CLDR minus sign</span>
        <span class="k">default</span><span class="p">:</span> <span class="k">return</span> <span class="kt">String</span><span class="p">(</span><span class="n">scalar</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span><span class="o">.</span><span class="nf">joined</span><span class="p">()</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now the Russian percent row reads <code class="language-plaintext highlighter-rouge">45%</code> next to <code class="language-plaintext highlighter-rouge">45⍽%</code>, and the <code class="language-plaintext highlighter-rouge">DIFFERS</code> verdict is plainly about that <code class="language-plaintext highlighter-rouge">⍽</code>. The Russian visibility row reads <code class="language-plaintext highlighter-rouge">5000м</code> next to <code class="language-plaintext highlighter-rouge">5⍽000 м</code>, which shows the grouping separator and the space at once. The verdict is computed after this swap, so it’s about the characters the reviewer can see.</p>

<h3 id="list-every-deliberate-difference-in-the-file-itself">List every deliberate difference in the file itself</h3>

<p>A report full of <code class="language-plaintext highlighter-rouge">DIFFERS</code> is useless if a reviewer can’t tell an intended difference from a regression. The dispositions header at the top of every file, shown in the German excerpt above, is there for that: each family of deliberate difference, named, with its reason, signed and dated by the owner.</p>

<p>That list is the rule for review. A <code class="language-plaintext highlighter-rouge">DIFFERS</code> row under a listed family is expected and needs no thought. A row that flips and isn’t covered by the list is the signal. The list lives in the generated file, so it lands in every diff instead of in a wiki nobody opens. The last bullet is an open question, adopt U+2212 or keep the hyphen-minus, and it sits in the file where it stays visible.</p>

<h3 id="how-it-runs">How it runs</h3>

<p>The report is a snapshot test behind an environment variable, so it runs only when asked and never under CI. The snapshot helper compares the output with the committed file. If the file is missing, it writes one locally but refuses to under CI. With <code class="language-plaintext highlighter-rouge">UPDATE_SNAPSHOTS=1</code> it rewrites the file. The person reading the diff is the check, not the assertion (the snapshot path is simplified here):</p>

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

<span class="kd">enum</span> <span class="kt">Language</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="kt">CaseIterable</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">en</span><span class="p">,</span> <span class="n">de</span><span class="p">,</span> <span class="n">ru</span><span class="p">,</span> <span class="n">ja</span>   <span class="c1">// one case per supported language</span>
<span class="p">}</span>

<span class="kd">func</span> <span class="nf">assertMatchesSnapshot</span><span class="p">(</span><span class="n">_</span> <span class="nv">value</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="n">named</span> <span class="nv">name</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span>
                           <span class="nv">filePath</span><span class="p">:</span> <span class="kt">String</span> <span class="o">=</span> <span class="kd">#file</span><span class="kt">Path</span><span class="p">,</span>
                           <span class="nv">sourceLocation</span><span class="p">:</span> <span class="kt">SourceLocation</span> <span class="o">=</span> <span class="err">#</span><span class="n">_sourceLocation</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">ProcessInfo</span><span class="o">.</span><span class="n">processInfo</span><span class="o">.</span><span class="n">environment</span>
    <span class="k">let</span> <span class="nv">updating</span> <span class="o">=</span> <span class="n">environment</span><span class="p">[</span><span class="s">"UPDATE_SNAPSHOTS"</span><span class="p">]</span> <span class="o">==</span> <span class="s">"1"</span>
    <span class="k">let</span> <span class="nv">locked</span> <span class="o">=</span> <span class="n">environment</span><span class="p">[</span><span class="s">"CI"</span><span class="p">]</span> <span class="o">!=</span> <span class="kc">nil</span>
    <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="kt">URL</span><span class="p">(</span><span class="nv">fileURLWithPath</span><span class="p">:</span> <span class="n">filePath</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">deletingLastPathComponent</span><span class="p">()</span>
        <span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"snapshots/cldr_conformance_table__</span><span class="se">\(</span><span class="n">name</span><span class="se">)</span><span class="s">.snap.txt"</span><span class="p">)</span>

    <span class="k">if</span> <span class="o">!</span><span class="n">updating</span><span class="p">,</span> <span class="k">let</span> <span class="nv">recorded</span> <span class="o">=</span> <span class="k">try</span><span class="p">?</span> <span class="kt">String</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="nv">encoding</span><span class="p">:</span> <span class="o">.</span><span class="n">utf8</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">recorded</span> <span class="o">==</span> <span class="n">value</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="kt">Issue</span><span class="o">.</span><span class="nf">record</span><span class="p">(</span><span class="s">"output drifted from </span><span class="se">\(</span><span class="n">name</span><span class="se">)</span><span class="s"> — regenerate with --update-snapshots, then review every changed line"</span><span class="p">,</span>
                     <span class="nv">sourceLocation</span><span class="p">:</span> <span class="n">sourceLocation</span><span class="p">)</span>
        <span class="k">return</span>
    <span class="p">}</span>
    <span class="k">guard</span> <span class="o">!</span><span class="n">locked</span> <span class="k">else</span> <span class="p">{</span>
        <span class="kt">Issue</span><span class="o">.</span><span class="nf">record</span><span class="p">(</span><span class="s">"snapshot </span><span class="se">\(</span><span class="n">name</span><span class="se">)</span><span class="s"> is missing, but snapshots are locked under CI — record locally and commit the file"</span><span class="p">,</span>
                     <span class="nv">sourceLocation</span><span class="p">:</span> <span class="n">sourceLocation</span><span class="p">)</span>
        <span class="k">return</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">url</span><span class="o">.</span><span class="nf">deletingLastPathComponent</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="n">value</span><span class="o">.</span><span class="nf">write</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="nv">atomically</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">encoding</span><span class="p">:</span> <span class="o">.</span><span class="n">utf8</span><span class="p">)</span>
<span class="p">}</span>

<span class="kd">@MainActor</span>
<span class="kd">struct</span> <span class="kt">CLDRConformanceTests</span> <span class="p">{</span>
    <span class="kd">nonisolated</span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">reportsEnabled</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">environment</span><span class="p">[</span><span class="s">"CLDR_REPORTS"</span><span class="p">]</span> <span class="o">!=</span> <span class="kc">nil</span>
        <span class="o">&amp;&amp;</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">"CI"</span><span class="p">]</span> <span class="o">==</span> <span class="kc">nil</span>

    <span class="kd">@Test</span><span class="p">(</span><span class="o">.</span><span class="nf">enabled</span><span class="p">(</span><span class="nv">if</span><span class="p">:</span> <span class="kt">CLDRConformanceTests</span><span class="o">.</span><span class="n">reportsEnabled</span><span class="p">))</span>
    <span class="kd">func</span> <span class="nf">cldrConformanceTable</span><span class="p">()</span> <span class="k">throws</span> <span class="p">{</span>
        <span class="k">for</span> <span class="n">language</span> <span class="k">in</span> <span class="kt">Language</span><span class="o">.</span><span class="n">allCases</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">body</span> <span class="o">=</span> <span class="nf">conformanceRows</span><span class="p">(</span><span class="nv">language</span><span class="p">:</span> <span class="n">language</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="n">row</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="k">in</span>
                <span class="k">let</span> <span class="nv">ours</span> <span class="o">=</span> <span class="nf">visualize</span><span class="p">(</span><span class="n">row</span><span class="o">.</span><span class="n">ours</span><span class="p">)</span>
                <span class="k">let</span> <span class="nv">cldr</span> <span class="o">=</span> <span class="nf">visualize</span><span class="p">(</span><span class="n">row</span><span class="o">.</span><span class="n">cldr</span><span class="p">)</span>
                <span class="k">return</span> <span class="s">"| </span><span class="se">\(</span><span class="n">row</span><span class="o">.</span><span class="n">label</span><span class="se">)</span><span class="s"> | </span><span class="se">\(</span><span class="n">ours</span><span class="se">)</span><span class="s"> | </span><span class="se">\(</span><span class="n">cldr</span><span class="se">)</span><span class="s"> | </span><span class="se">\(</span><span class="n">ours</span> <span class="o">==</span> <span class="n">cldr</span> <span class="p">?</span> <span class="s">"MATCH"</span> <span class="p">:</span> <span class="s">"DIFFERS"</span><span class="se">)</span><span class="s"> |"</span>
            <span class="p">}</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="nf">assertMatchesSnapshot</span><span class="p">(</span><span class="n">body</span><span class="p">,</span> <span class="nv">named</span><span class="p">:</span> <span class="n">language</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Notice the two guards on <code class="language-plaintext highlighter-rouge">reportsEnabled</code>: the report doesn’t run unless <code class="language-plaintext highlighter-rouge">CLDR_REPORTS</code> is set, and it stays off under CI so it can’t block a build. The verdict is computed on the swapped cells, so the invisible characters decide it. You regenerate the files with <code class="language-plaintext highlighter-rouge">bin/cldr-report</code>. That wrapper converts the web repo’s locale YAML to JSON in a temp directory, passes it along so the server-composed rows fill in, and hands off to the test runner. Without a web checkout those rows read <code class="language-plaintext highlighter-rouge">(web checkout absent)</code> instead of failing. To accept an intended change, you run <code class="language-plaintext highlighter-rouge">bin/cldr-report --update-snapshots</code> and review the regenerated diff for each language the way you’d review a copy change. It’s the same habit as our on-device width report in <a href="/rendered-width-validation/">Measure the String Before You Translate It</a>: the report isn’t the gate; the person reading the diff is.</p>

<h2 id="results">Results</h2>

<ul>
  <li><strong>27 committed snapshot files</strong>, one per language, seven unit families, roughly two dozen rows each. The goal is no unexplained difference: every remaining <code class="language-plaintext highlighter-rouge">DIFFERS</code> is either fixed or listed in the header.</li>
  <li><strong>Five languages’ speed abbreviations were wrong</strong>, not our style: Turkish, Danish, Dutch, Indonesian, and Norwegian. A Turkish user flagged <code class="language-plaintext highlighter-rouge">km/h</code> the day after the localization launch. The report agreed (<code class="language-plaintext highlighter-rouge">km/sa</code>) and showed the other four. A pass/fail test would have buried them under exceptions. Next to CLDR they read as errors, and we adopted CLDR’s abbreviations for all five.</li>
  <li><strong>A run of server fixes followed</strong> once the differences sat side by side. Scandinavian miles moved to CLDR forms. Wind speed took CLDR’s symbols for km/h and m/s. Composed decimals switched from a hard-coded dot to the locale separator. Cyrillic and Greek unit symbols were localized.</li>
  <li><strong>No CI cost and no false alarms.</strong> The report doesn’t run on CI, doesn’t flake, and doesn’t ask an engineer to reargue a product decision. The trade-off is that nothing fails on its own. A regression shows up only when someone regenerates the report and reads the diff, so the repo’s agent instructions require <code class="language-plaintext highlighter-rouge">bin/cldr-report</code> before any unit-string change counts as done.</li>
</ul>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Use a pass/fail test when there’s one right answer, and a report when there’s a style decision.</strong> A product that differs from the platform on purpose has no single correct string to assert, so the test becomes a fight.</li>
  <li><strong>Compute the verdict on what the reviewer sees.</strong> If you make hidden characters visible only when printing, the verdict and the diff can disagree. Swap first, then compare.</li>
  <li><strong>Keep open questions in the generated file, not a wiki.</strong> The U+2212 decision sits in the dispositions header, where every diff shows it, instead of in a document nobody opens.</li>
  <li><strong>A side-by-side comparison finds bugs on both sides.</strong> Putting our strings next to CLDR fixed five client abbreviations and turned up a half-dozen server fixes the report wasn’t aimed at.</li>
</ul>]]></content><author><name>Trevor Turk</name></author><category term="swift" /><category term="ios" /><category term="localization" /><category term="i18n" /><category term="testing" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Golden Files Per Language</title><link href="https://trevorturk.github.io/golden-files-per-language/" rel="alternate" type="text/html" title="Golden Files Per Language" /><published>2026-08-24T14:20:00+00:00</published><updated>2026-08-24T14:20:00+00:00</updated><id>https://trevorturk.github.io/golden-files-per-language</id><content type="html" xml:base="https://trevorturk.github.io/golden-files-per-language/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>When a Japanese hour loses its counter symbol (the character that marks the number as an hour), nothing crashes. The app builds, the English screens look right, and the bug ships to a screen in a language nobody on the team reads. Localized date formatting fails like that: one language at a time, quietly. <a href="https://helloweather.com">Hello Weather</a> shows dates in 24 places, in 27 languages, on both 12- and 24-hour clocks. One person maintains it and a model does the translating.</p>

<p>The obvious test checks the English output and stops. It keeps passing while every other language drifts, because English is the one language that matches the source strings.</p>

<p>Going the other way fails too. If we snapshot every language, every intent, and both clocks together, we get one enormous file. A real change to one language gets buried when the whole file is regenerated. Nobody reads a seventy-thousand-line diff. They approve it and move on, and then the test isn’t protecting anything.</p>

<p>In our app, every date string a user sees goes through one enum of intents (what the date is for: a daily header, a sunrise time) and one exhaustive switch that maps each intent to a format. We call that the date rulebook, and its design is its own <a href="/date-format-rulebook/">post</a>. We can only trust the rulebook if a change to it produces a diff a person can read. This post is about the snapshot tests that make that true, and about a second, faster test run that checks the same files in six seconds but isn’t allowed to change them. It continues from <a href="/port-the-ergonomics-not-the-library/">porting the snapshot ergonomics</a>: the same small file-snapshot helper, now pointed at the full language matrix and then made fast.</p>

<h2 id="the-solution">The Solution</h2>

<p>Two rules, one for each trap.</p>

<ul>
  <li><strong>One golden file per language, generated from <code class="language-plaintext highlighter-rouge">allCases</code>.</strong> Every test that sweeps the languages writes one file per language, named by the language code, holding that language’s whole grid. A German change touches <code class="language-plaintext highlighter-rouge">..._blessed_snapshot__de.snap.txt</code> and nothing else, so it gets a German-sized diff.</li>
  <li><strong>The fast run reads the goldens but never writes them.</strong> A six-second run is good for iterating, but only if it can’t become the thing that approves a change. The slow run, hosted in the app on a simulator with the real device font, stays the single source of truth. The fast run compares against those files and reports a disagreement. It doesn’t settle one.</li>
</ul>

<h3 id="one-file-per-language-generated-from-allcases">One file per language, generated from <code class="language-plaintext highlighter-rouge">allCases</code></h3>

<p>We split the files by language because that’s how rendering changes arrive: one language at a time. The matrix test loops over every language and every intent and checks one snapshot per language:</p>

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

<span class="kd">enum</span> <span class="kt">Language</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="kt">CaseIterable</span> <span class="p">{</span> <span class="k">case</span> <span class="n">en</span><span class="p">,</span> <span class="n">de</span><span class="p">,</span> <span class="n">fr</span><span class="p">,</span> <span class="n">ja</span> <span class="cm">/* one case per supported language */</span> <span class="p">}</span>
<span class="kd">enum</span> <span class="kt">TimeFormat</span> <span class="p">{</span> <span class="k">case</span> <span class="n">ampm</span><span class="p">,</span> <span class="n">twentyFourHour</span> <span class="p">}</span>
<span class="kd">final</span> <span class="kd">class</span> <span class="kt">Settings</span> <span class="p">{</span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">shared</span> <span class="o">=</span> <span class="kt">Settings</span><span class="p">();</span> <span class="k">var</span> <span class="nv">language</span> <span class="o">=</span> <span class="kt">Language</span><span class="o">.</span><span class="n">en</span><span class="p">;</span> <span class="k">var</span> <span class="nv">timeFormat</span> <span class="o">=</span> <span class="kt">TimeFormat</span><span class="o">.</span><span class="n">ampm</span> <span class="p">}</span>
<span class="kd">enum</span> <span class="kt">DateFormatIntent</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="kt">CaseIterable</span> <span class="p">{</span> <span class="k">case</span> <span class="n">dailyHeader</span><span class="p">,</span> <span class="n">sunEventTime</span><span class="p">,</span> <span class="n">moonDate</span><span class="p">,</span> <span class="n">complicationHour</span> <span class="p">}</span>

<span class="c1">// Two fixed instants, built from components in the display time zone:</span>
<span class="c1">// Wed 2026-01-07 09:05 (AM, single-digit day and hour) and Sat 2026-08-15 17:39 (PM, double digits).</span>
<span class="kd">enum</span> <span class="kt">Samples</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">date</span><span class="p">(</span><span class="nv">year</span><span class="p">:</span> <span class="kt">Int</span><span class="p">,</span> <span class="nv">month</span><span class="p">:</span> <span class="kt">Int</span><span class="p">,</span> <span class="nv">day</span><span class="p">:</span> <span class="kt">Int</span><span class="p">,</span> <span class="nv">hour</span><span class="p">:</span> <span class="kt">Int</span><span class="p">,</span> <span class="nv">minute</span><span class="p">:</span> <span class="kt">Int</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Date</span> <span class="p">{</span>
        <span class="k">var</span> <span class="nv">calendar</span> <span class="o">=</span> <span class="kt">Calendar</span><span class="p">(</span><span class="nv">identifier</span><span class="p">:</span> <span class="o">.</span><span class="n">gregorian</span><span class="p">)</span>
        <span class="n">calendar</span><span class="o">.</span><span class="n">timeZone</span> <span class="o">=</span> <span class="o">.</span><span class="n">current</span>  <span class="c1">// real code uses the forecast's time zone</span>
        <span class="k">return</span> <span class="n">calendar</span><span class="o">.</span><span class="nf">date</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="kt">DateComponents</span><span class="p">(</span><span class="nv">year</span><span class="p">:</span> <span class="n">year</span><span class="p">,</span> <span class="nv">month</span><span class="p">:</span> <span class="n">month</span><span class="p">,</span> <span class="nv">day</span><span class="p">:</span> <span class="n">day</span><span class="p">,</span> <span class="nv">hour</span><span class="p">:</span> <span class="n">hour</span><span class="p">,</span> <span class="nv">minute</span><span class="p">:</span> <span class="n">minute</span><span class="p">))</span><span class="o">!</span>
    <span class="p">}</span>
    <span class="kd">static</span> <span class="k">var</span> <span class="nv">morning</span><span class="p">:</span> <span class="kt">Date</span> <span class="p">{</span> <span class="nf">date</span><span class="p">(</span><span class="nv">year</span><span class="p">:</span> <span class="mi">2026</span><span class="p">,</span> <span class="nv">month</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nv">day</span><span class="p">:</span> <span class="mi">7</span><span class="p">,</span> <span class="nv">hour</span><span class="p">:</span> <span class="mi">9</span><span class="p">,</span> <span class="nv">minute</span><span class="p">:</span> <span class="mi">5</span><span class="p">)</span> <span class="p">}</span>
    <span class="kd">static</span> <span class="k">var</span> <span class="nv">evening</span><span class="p">:</span> <span class="kt">Date</span> <span class="p">{</span> <span class="nf">date</span><span class="p">(</span><span class="nv">year</span><span class="p">:</span> <span class="mi">2026</span><span class="p">,</span> <span class="nv">month</span><span class="p">:</span> <span class="mi">8</span><span class="p">,</span> <span class="nv">day</span><span class="p">:</span> <span class="mi">15</span><span class="p">,</span> <span class="nv">hour</span><span class="p">:</span> <span class="mi">17</span><span class="p">,</span> <span class="nv">minute</span><span class="p">:</span> <span class="mi">39</span><span class="p">)</span> <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// Set the app's language and clock, run the body, and restore both — always, via defer.</span>
<span class="kd">func</span> <span class="nf">withAppLanguage</span><span class="p">(</span><span class="n">_</span> <span class="nv">language</span><span class="p">:</span> <span class="kt">Language</span><span class="p">,</span> <span class="nv">timeFormat</span><span class="p">:</span> <span class="kt">TimeFormat</span> <span class="o">=</span> <span class="o">.</span><span class="n">ampm</span><span class="p">,</span> <span class="nv">body</span><span class="p">:</span> <span class="p">()</span> <span class="o">-&gt;</span> <span class="kt">Void</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">originalLanguage</span> <span class="o">=</span> <span class="kt">Settings</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">language</span><span class="p">,</span> <span class="n">originalTimeFormat</span> <span class="o">=</span> <span class="kt">Settings</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">timeFormat</span>
    <span class="k">defer</span> <span class="p">{</span> <span class="kt">Settings</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">language</span> <span class="o">=</span> <span class="n">originalLanguage</span><span class="p">;</span> <span class="kt">Settings</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">timeFormat</span> <span class="o">=</span> <span class="n">originalTimeFormat</span> <span class="p">}</span>
    <span class="kt">Settings</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">language</span> <span class="o">=</span> <span class="n">language</span>
    <span class="kt">Settings</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">timeFormat</span> <span class="o">=</span> <span class="n">timeFormat</span>
    <span class="nf">body</span><span class="p">()</span>
<span class="p">}</span>

<span class="kd">extension</span> <span class="kt">Date</span> <span class="p">{</span>
    <span class="c1">// Real code routes through the date rulebook: intent + current settings select the template.</span>
    <span class="kd">func</span> <span class="nf">withFormat</span><span class="p">(</span><span class="n">_</span> <span class="nv">intent</span><span class="p">:</span> <span class="kt">DateFormatIntent</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span> <span class="cm">/* ... */</span> <span class="p">}</span>
<span class="p">}</span>

<span class="c1">// Escape quotes, backslashes, control characters, and non-ASCII whitespace or</span>
<span class="c1">// format scalars so invisible characters survive a diff while letters stay legible.</span>
<span class="kd">func</span> <span class="nf">escaped</span><span class="p">(</span><span class="n">_</span> <span class="nv">value</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
    <span class="n">value</span><span class="o">.</span><span class="n">unicodeScalars</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="n">scalar</span> <span class="k">in</span>
        <span class="k">switch</span> <span class="n">scalar</span> <span class="p">{</span>
        <span class="k">case</span> <span class="s">"</span><span class="se">\"</span><span class="s">"</span><span class="p">:</span> <span class="k">return</span> <span class="s">"</span><span class="se">\\\"</span><span class="s">"</span>
        <span class="k">case</span> <span class="s">"</span><span class="se">\\</span><span class="s">"</span><span class="p">:</span> <span class="k">return</span> <span class="s">"</span><span class="se">\\\\</span><span class="s">"</span>
        <span class="k">default</span><span class="p">:</span>
            <span class="k">if</span> <span class="n">scalar</span><span class="o">.</span><span class="n">isASCII</span> <span class="p">{</span>
                <span class="k">return</span> <span class="n">scalar</span><span class="o">.</span><span class="n">value</span> <span class="o">&lt;</span> <span class="mh">0x20</span> <span class="p">?</span> <span class="kt">String</span><span class="p">(</span><span class="nv">format</span><span class="p">:</span> <span class="s">"</span><span class="se">\\</span><span class="s">u{%04X}"</span><span class="p">,</span> <span class="n">scalar</span><span class="o">.</span><span class="n">value</span><span class="p">)</span> <span class="p">:</span> <span class="kt">String</span><span class="p">(</span><span class="n">scalar</span><span class="p">)</span>
            <span class="p">}</span>
            <span class="k">if</span> <span class="n">scalar</span><span class="o">.</span><span class="n">properties</span><span class="o">.</span><span class="n">isWhitespace</span> <span class="o">||</span> <span class="n">scalar</span><span class="o">.</span><span class="n">properties</span><span class="o">.</span><span class="n">generalCategory</span> <span class="o">==</span> <span class="o">.</span><span class="n">format</span> <span class="p">{</span>
                <span class="k">return</span> <span class="kt">String</span><span class="p">(</span><span class="nv">format</span><span class="p">:</span> <span class="s">"</span><span class="se">\\</span><span class="s">u{%04X}"</span><span class="p">,</span> <span class="n">scalar</span><span class="o">.</span><span class="n">value</span><span class="p">)</span>  <span class="c1">// narrow spaces, directional marks</span>
            <span class="p">}</span>
            <span class="k">return</span> <span class="kt">String</span><span class="p">(</span><span class="n">scalar</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span><span class="o">.</span><span class="nf">joined</span><span class="p">()</span>
<span class="p">}</span>

<span class="kd">@MainActor</span>
<span class="kd">@Suite</span><span class="p">(</span><span class="s">"Date format snapshots"</span><span class="p">,</span> <span class="o">.</span><span class="n">serialized</span><span class="p">)</span>
<span class="kd">struct</span> <span class="kt">DateFormatSnapshotTests</span> <span class="p">{</span>
    <span class="kd">@Test</span> <span class="kd">func</span> <span class="nf">everyLanguageMatchesItsBlessedSnapshot</span><span class="p">()</span> <span class="p">{</span>
        <span class="k">for</span> <span class="n">language</span> <span class="k">in</span> <span class="kt">Language</span><span class="o">.</span><span class="n">allCases</span> <span class="p">{</span>
            <span class="k">var</span> <span class="nv">lines</span> <span class="o">=</span> <span class="p">[</span><span class="s">"intent | morning 12h | evening 12h | morning 24h | evening 24h"</span><span class="p">]</span>

            <span class="k">for</span> <span class="n">intent</span> <span class="k">in</span> <span class="kt">DateFormatIntent</span><span class="o">.</span><span class="n">allCases</span> <span class="p">{</span>
                <span class="k">var</span> <span class="nv">cells</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>

                <span class="nf">withAppLanguage</span><span class="p">(</span><span class="n">language</span><span class="p">)</span> <span class="p">{</span>
                    <span class="n">cells</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="kt">Samples</span><span class="o">.</span><span class="n">morning</span><span class="o">.</span><span class="nf">withFormat</span><span class="p">(</span><span class="n">intent</span><span class="p">))</span>
                    <span class="n">cells</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="kt">Samples</span><span class="o">.</span><span class="n">evening</span><span class="o">.</span><span class="nf">withFormat</span><span class="p">(</span><span class="n">intent</span><span class="p">))</span>
                <span class="p">}</span>
                <span class="nf">withAppLanguage</span><span class="p">(</span><span class="n">language</span><span class="p">,</span> <span class="nv">timeFormat</span><span class="p">:</span> <span class="o">.</span><span class="n">twentyFourHour</span><span class="p">)</span> <span class="p">{</span>
                    <span class="n">cells</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="kt">Samples</span><span class="o">.</span><span class="n">morning</span><span class="o">.</span><span class="nf">withFormat</span><span class="p">(</span><span class="n">intent</span><span class="p">))</span>
                    <span class="n">cells</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="kt">Samples</span><span class="o">.</span><span class="n">evening</span><span class="o">.</span><span class="nf">withFormat</span><span class="p">(</span><span class="n">intent</span><span class="p">))</span>
                <span class="p">}</span>

                <span class="cp">#expect(!cells.contains(""), "\(language.rawValue) \(intent.rawValue) rendered empty — refusing to bless")</span>
                <span class="n">lines</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="s">"</span><span class="se">\(</span><span class="n">intent</span><span class="o">.</span><span class="n">rawValue</span><span class="se">)</span><span class="s"> | </span><span class="se">\(</span><span class="n">cells</span><span class="o">.</span><span class="nf">map</span><span class="p">(</span><span class="n">escaped</span><span class="p">)</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="p">)</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
            <span class="p">}</span>

            <span class="nf">assertMatchesSnapshot</span><span class="p">(</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="o">+</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span><span class="p">,</span> <span class="nv">named</span><span class="p">:</span> <span class="n">language</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The two <code class="language-plaintext highlighter-rouge">allCases</code> loops drive the whole matrix. Add a <code class="language-plaintext highlighter-rouge">DateFormatIntent</code> case and every language file grows a row the next time we record. Add a <code class="language-plaintext highlighter-rouge">Language</code> and a new file appears. There’s no separate list of things to test that can fall behind the feature. Rows are the intents and columns are the two sample instants crossed with the two clocks, so the German file reads like a table:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>intent | morning 12h | evening 12h | morning 24h | evening 24h
dailyHeader | Mi., Jan. 7 | Sa., Aug. 15 | Mi., Jan. 7 | Sa., Aug. 15
sunEventTime | 9:05am | 5:39pm | 9:05 | 17:39
forecastUpdatedDateTime | Mi., Jan. 7 @ 9:05am | Sa., Aug. 15 @ 5:39pm | Mi., Jan. 7 @ 9:05 | Sa., Aug. 15 @ 17:39
complicationHour | 9am | 5pm | 9 | 17
</code></pre></div></div>

<p>Two details make the file safe to trust. The <code class="language-plaintext highlighter-rouge">#expect(!cells.contains(""))</code> check fails the run when a rendering path returns an empty string. A snapshot must never accept blank output, because it looks like success and usually means a missing symbol or a nil that fell through. The <code class="language-plaintext highlighter-rouge">escaped</code> helper handles the other quiet failure. Localized date strings carry characters you can’t see, like the narrow no-break space ICU puts between a number and its symbol. In a raw diff, a normal space turning into a narrow no-break space is invisible. Escaped, it shows up as <code class="language-plaintext highlighter-rouge">\u{202F}</code>, and the letters around it stay readable.</p>

<h3 id="the-assertion-that-reads-the-file-and-the-guard-that-stops-ci-from-writing">The assertion that reads the file, and the guard that stops CI from writing</h3>

<p>The test is only as trustworthy as the helper under it. The helper has to get two things right. Regenerating a golden must never feel like a rubber stamp, and CI must never record a golden it should only compare against. It’s small enough to read whole:</p>

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

<span class="kd">enum</span> <span class="kt">Snapshots</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">directory</span> <span class="o">=</span> <span class="kt">URL</span><span class="p">(</span><span class="nv">fileURLWithPath</span><span class="p">:</span> <span class="kd">#file</span><span class="kt">Path</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">deletingLastPathComponent</span><span class="p">()</span>
        <span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"snapshots"</span><span class="p">)</span>

    <span class="kd">static</span> <span class="k">var</span> <span class="nv">updating</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">"UPDATE_SNAPSHOTS"</span><span class="p">]</span> <span class="o">==</span> <span class="s">"1"</span> <span class="p">}</span>
    <span class="kd">static</span> <span class="k">var</span> <span class="nv">locked</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">"CI"</span><span class="p">]</span> <span class="o">!=</span> <span class="kc">nil</span> <span class="p">}</span>

    <span class="c1">// camelCase -&gt; snake_case, then anything else to underscores: DateFormatSnapshotTests -&gt; date_format_snapshot_tests</span>
    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">sanitized</span><span class="p">(</span><span class="n">_</span> <span class="nv">component</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
        <span class="n">component</span>
            <span class="o">.</span><span class="nf">replacingOccurrences</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="s">"([a-z0-9])([A-Z])"</span><span class="p">,</span> <span class="nv">with</span><span class="p">:</span> <span class="s">"$1_$2"</span><span class="p">,</span> <span class="nv">options</span><span class="p">:</span> <span class="o">.</span><span class="n">regularExpression</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">replacingOccurrences</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="s">"([A-Z])([A-Z][a-z])"</span><span class="p">,</span> <span class="nv">with</span><span class="p">:</span> <span class="s">"$1_$2"</span><span class="p">,</span> <span class="nv">options</span><span class="p">:</span> <span class="o">.</span><span class="n">regularExpression</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">lowercased</span><span class="p">()</span>
            <span class="o">.</span><span class="nf">replacingOccurrences</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="s">"[^a-z0-9]+"</span><span class="p">,</span> <span class="nv">with</span><span class="p">:</span> <span class="s">"_"</span><span class="p">,</span> <span class="nv">options</span><span class="p">:</span> <span class="o">.</span><span class="n">regularExpression</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">trimmingCharacters</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="kt">CharacterSet</span><span class="p">(</span><span class="nv">charactersIn</span><span class="p">:</span> <span class="s">"_"</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="c1">// The first eight lines that differ — a short read, not the whole file.</span>
    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">firstDifferences</span><span class="p">(</span><span class="nv">recorded</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="nv">current</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">a</span> <span class="o">=</span> <span class="n">recorded</span><span class="o">.</span><span class="nf">components</span><span class="p">(</span><span class="nv">separatedBy</span><span class="p">:</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span><span class="p">),</span> <span class="n">b</span> <span class="o">=</span> <span class="n">current</span><span class="o">.</span><span class="nf">components</span><span class="p">(</span><span class="nv">separatedBy</span><span class="p">:</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span><span class="p">)</span>
        <span class="k">var</span> <span class="nv">diffs</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">for</span> <span class="n">i</span> <span class="k">in</span> <span class="mi">0</span><span class="o">..&lt;</span><span class="nf">max</span><span class="p">(</span><span class="n">a</span><span class="o">.</span><span class="n">count</span><span class="p">,</span> <span class="n">b</span><span class="o">.</span><span class="n">count</span><span class="p">)</span> <span class="k">where</span> <span class="n">diffs</span><span class="o">.</span><span class="n">count</span> <span class="o">&lt;</span> <span class="mi">8</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">left</span> <span class="o">=</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">a</span><span class="o">.</span><span class="n">count</span> <span class="p">?</span> <span class="n">a</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="p">:</span> <span class="s">"&lt;missing&gt;"</span><span class="p">,</span> <span class="n">right</span> <span class="o">=</span> <span class="n">i</span> <span class="o">&lt;</span> <span class="n">b</span><span class="o">.</span><span class="n">count</span> <span class="p">?</span> <span class="n">b</span><span class="p">[</span><span class="n">i</span><span class="p">]</span> <span class="p">:</span> <span class="s">"&lt;missing&gt;"</span>
            <span class="k">if</span> <span class="n">left</span> <span class="o">!=</span> <span class="n">right</span> <span class="p">{</span> <span class="n">diffs</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="s">"line </span><span class="se">\(</span><span class="n">i</span> <span class="o">+</span> <span class="mi">1</span><span class="se">)</span><span class="s">:</span><span class="se">\n</span><span class="s">  recorded: </span><span class="se">\(</span><span class="n">left</span><span class="se">)\n</span><span class="s">  current:  </span><span class="se">\(</span><span class="n">right</span><span class="se">)</span><span class="s">"</span><span class="p">)</span> <span class="p">}</span>
        <span class="p">}</span>
        <span class="k">return</span> <span class="n">diffs</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>

<span class="c1">// Simplified: the real helper also auto-numbers snapshots when `named:` is omitted,</span>
<span class="c1">// so parameterized tests sharing one function name get distinct files.</span>
<span class="kd">func</span> <span class="nf">assertMatchesSnapshot</span><span class="p">(</span>
    <span class="n">_</span> <span class="nv">value</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span>
    <span class="n">named</span> <span class="nv">name</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span>
    <span class="nv">filePath</span><span class="p">:</span> <span class="kt">String</span> <span class="o">=</span> <span class="kd">#file</span><span class="kt">Path</span><span class="p">,</span>
    <span class="nv">function</span><span class="p">:</span> <span class="kt">String</span> <span class="o">=</span> <span class="kd">#function</span><span class="p">,</span>
    <span class="nv">sourceLocation</span><span class="p">:</span> <span class="kt">SourceLocation</span> <span class="o">=</span> <span class="err">#</span><span class="n">_sourceLocation</span>
<span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">suite</span> <span class="o">=</span> <span class="kt">Snapshots</span><span class="o">.</span><span class="nf">sanitized</span><span class="p">(</span><span class="kt">URL</span><span class="p">(</span><span class="nv">fileURLWithPath</span><span class="p">:</span> <span class="n">filePath</span><span class="p">)</span><span class="o">.</span><span class="nf">deletingPathExtension</span><span class="p">()</span><span class="o">.</span><span class="n">lastPathComponent</span><span class="p">)</span>
    <span class="k">let</span> <span class="nv">test</span> <span class="o">=</span> <span class="kt">Snapshots</span><span class="o">.</span><span class="nf">sanitized</span><span class="p">(</span><span class="n">function</span><span class="o">.</span><span class="nf">replacingOccurrences</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="s">"()"</span><span class="p">,</span> <span class="nv">with</span><span class="p">:</span> <span class="s">""</span><span class="p">))</span>
    <span class="k">let</span> <span class="nv">file</span> <span class="o">=</span> <span class="s">"</span><span class="se">\(</span><span class="n">test</span><span class="se">)</span><span class="s">__</span><span class="se">\(</span><span class="kt">Snapshots</span><span class="o">.</span><span class="nf">sanitized</span><span class="p">(</span><span class="n">name</span><span class="p">)</span><span class="se">)</span><span class="s">.snap.txt"</span>
    <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="kt">Snapshots</span><span class="o">.</span><span class="n">directory</span><span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="n">suite</span><span class="p">)</span><span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="n">file</span><span class="p">)</span>
    <span class="k">let</span> <span class="nv">relativePath</span> <span class="o">=</span> <span class="s">"snapshots/</span><span class="se">\(</span><span class="n">suite</span><span class="se">)</span><span class="s">/</span><span class="se">\(</span><span class="n">file</span><span class="se">)</span><span class="s">"</span>

    <span class="c1">// The common path: a committed golden exists and no update was requested. Compare and report.</span>
    <span class="k">if</span> <span class="o">!</span><span class="kt">Snapshots</span><span class="o">.</span><span class="n">updating</span><span class="p">,</span> <span class="k">let</span> <span class="nv">recorded</span> <span class="o">=</span> <span class="k">try</span><span class="p">?</span> <span class="kt">String</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="nv">encoding</span><span class="p">:</span> <span class="o">.</span><span class="n">utf8</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">recorded</span> <span class="o">==</span> <span class="n">value</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="kt">Issue</span><span class="o">.</span><span class="nf">record</span><span class="p">(</span>
            <span class="s">"output drifted from </span><span class="se">\(</span><span class="n">relativePath</span><span class="se">)</span><span class="s"> — regenerate with bin/unit-test --update-snapshots, then review every changed line. First differences:</span><span class="se">\n\(</span><span class="kt">Snapshots</span><span class="o">.</span><span class="nf">firstDifferences</span><span class="p">(</span><span class="nv">recorded</span><span class="p">:</span> <span class="n">recorded</span><span class="p">,</span> <span class="nv">current</span><span class="p">:</span> <span class="n">value</span><span class="p">)</span><span class="se">)</span><span class="s">"</span><span class="p">,</span>
            <span class="nv">sourceLocation</span><span class="p">:</span> <span class="n">sourceLocation</span>
        <span class="p">)</span>
        <span class="k">return</span>
    <span class="p">}</span>

    <span class="c1">// Otherwise we are about to write: the golden is missing, or an update was requested. Never under CI.</span>
    <span class="k">guard</span> <span class="o">!</span><span class="kt">Snapshots</span><span class="o">.</span><span class="n">locked</span> <span class="k">else</span> <span class="p">{</span>
        <span class="kt">Issue</span><span class="o">.</span><span class="nf">record</span><span class="p">(</span>
            <span class="s">"snapshot </span><span class="se">\(</span><span class="n">relativePath</span><span class="se">)</span><span class="s"> is missing or an update was requested, but snapshots are locked under CI — record locally and commit the file"</span><span class="p">,</span>
            <span class="nv">sourceLocation</span><span class="p">:</span> <span class="n">sourceLocation</span>
        <span class="p">)</span>
        <span class="k">return</span>
    <span class="p">}</span>

    <span class="k">do</span> <span class="p">{</span>
        <span class="k">try</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">url</span><span class="o">.</span><span class="nf">deletingLastPathComponent</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="n">value</span><span class="o">.</span><span class="nf">write</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="n">url</span><span class="p">,</span> <span class="nv">atomically</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">encoding</span><span class="p">:</span> <span class="o">.</span><span class="n">utf8</span><span class="p">)</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
        <span class="kt">Issue</span><span class="o">.</span><span class="nf">record</span><span class="p">(</span><span class="s">"failed to write snapshot </span><span class="se">\(</span><span class="n">relativePath</span><span class="se">)</span><span class="s">: </span><span class="se">\(</span><span class="n">error</span><span class="se">)</span><span class="s">"</span><span class="p">,</span> <span class="nv">sourceLocation</span><span class="p">:</span> <span class="n">sourceLocation</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The drift message does three things. It shows at most eight differing lines, not the whole file. It includes the exact regenerate command. And it says <em>review every changed line</em> rather than <em>rerun to fix</em>, because every changed line in a golden is a change to what users see, and someone has to sign off on it.</p>

<p>The <code class="language-plaintext highlighter-rouge">locked</code> guard is the other half. On a developer’s machine, a missing golden gets recorded on the first run, and <code class="language-plaintext highlighter-rouge">UPDATE_SNAPSHOTS=1</code> rewrites it. When <code class="language-plaintext highlighter-rouge">CI</code> is set, both of those fail instead of writing. Without that guard, CI would quietly write a missing golden as correct, and a real regression would record itself as the new expected output.</p>

<h3 id="width-probes-store-strings-not-widths">Width probes store strings, not widths</h3>

<p>Three other sweeps use the same one-file-per-language shape: the “x ago” relative-time strings, the CLDR unit-conformance tables, and the width probes. The width probes answer a pixel question, whether the widest rendering fits its slot, but they don’t store any pixel measurement. For each intent and clock, the test renders a set of candidate dates in the real device font, finds the widest one, and stores that <em>string</em>. It never stores the width. A width in points changes with OS versions, font revisions, and which machine is rendering, so a golden built on it would fail for reasons that have nothing to do with the app. The chosen string is stable. The German file records that the widest daily full header is <code class="language-plaintext highlighter-rouge">Donnerstag, Sept. 24</code>, and any test run on any machine can compare that.</p>

<h3 id="the-fast-run-that-reads-the-goldens-but-cannot-rewrite-them">The fast run that reads the goldens but cannot rewrite them</h3>

<p>The app-hosted run is the one that counts. It builds the app, signs it, boots a simulator, and measures text in the real device font. It also takes about forty seconds warm, which is too slow for iterating on a pure-logic file like the date rulebook. So we added a second run that compiles a subset of the sources, plus the logic and snapshot tests, into a macOS test bundle. No simulator, no app host, no signing, and it finishes in about six seconds.</p>

<p>Now two runs read the same goldens. If the fast one could <em>write</em> them, it could reset the source of truth to whatever macOS ICU renders, and macOS ICU differs from iOS ICU in a locale or two. The unsigned macOS run would be deciding what’s correct for the iOS one. So the fast script refuses the update flag and unsets the update variable before it runs anything:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c">#!/bin/bash</span>
<span class="c"># Fast logic tier: macOS-hosted, no simulator, no app host, no signing.</span>
<span class="c"># It READS the committed goldens and must match them; it may never WRITE them.</span>

<span class="c"># Recording is iOS-authoritative: bless via bin/unit-test --update-snapshots. Refuse the flag here...</span>
<span class="k">for </span>arg <span class="k">in</span> <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
  if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"--update-snapshots"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nb">echo</span> <span class="s2">"❌ Recording from the macOS host is not allowed — the goldens are"</span>
    <span class="nb">echo</span> <span class="s2">"   iOS-authoritative (bless with bin/unit-test --update-snapshots)."</span>
    <span class="nb">echo</span> <span class="s2">"   A macOS parity failure is a stop-the-line finding, never a re-record."</span>
    <span class="nb">exit </span>1
  <span class="k">fi
done</span>

<span class="c"># ...and strip the variable so nothing downstream can record either.</span>
<span class="nb">unset </span>UPDATE_SNAPSHOTS

<span class="c"># Simplified: the real script also pins a shared DerivedData, SPM cache, and compilation cache.</span>
xcodebuild <span class="nb">test</span> <span class="se">\</span>
  <span class="nt">-workspace</span> HelloWeather.xcworkspace <span class="se">\</span>
  <span class="nt">-scheme</span> HelloWeatherLogicTests <span class="se">\</span>
  <span class="nt">-destination</span> <span class="s1">'platform=macOS'</span> <span class="se">\</span>
  <span class="nt">-quiet</span> <span class="se">\</span>
  <span class="nv">CODE_SIGNING_ALLOWED</span><span class="o">=</span>NO
</code></pre></div></div>

<p>When the fast run passes, it rendered the committed goldens the same way iOS did, and nothing gets rewritten. When it disagrees, we don’t fix that by recording on macOS. It means macOS and iOS ICU differ for some locale, and the fix is to drop that test from the macOS bundle and keep it on the app-hosted run only.</p>

<h2 id="results">Results</h2>

<ul>
  <li>About 111 committed golden files, ~564K in total, across six test suites. They’re only regenerated on the iOS run and reviewed like copy.</li>
  <li>A fix to one language produces a one-language diff, instead of a whole-matrix regeneration we’d have to scan for the rows that moved.</li>
  <li>Iterating on the date rulebook went from a ~40-second app-hosted cycle to a ~6-second macOS one, against the same goldens. The trade-off is that a suite where macOS and iOS ICU disagree leaves the fast bundle and runs only on the slow one.</li>
  <li>An empty rendered cell and a missing snapshot under CI both fail the run instead of recording themselves as correct.</li>
</ul>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li>
    <p><strong>Treat a snapshot diff as a behavior change.</strong> It’s a copy edit, not a chore to get the build green. Have the failure message say “review every changed line,” include the regenerate command, and show only the first few differences.</p>
  </li>
  <li>
    <p><strong>Store what the measurement picked, not the measurement.</strong> When a number only chooses among candidates, commit the candidate. The chosen string is stable across machines and the width isn’t.</p>
  </li>
  <li>
    <p><strong>Escape the invisible characters.</strong> A raw diff hides a narrow space or a directional mark. Escape whitespace and format scalars to <code class="language-plaintext highlighter-rouge">\u{...}</code> and leave the letters readable.</p>
  </li>
  <li>
    <p><strong>Don’t stop a language-cycling run partway.</strong> The sweeps set the app language and restore it at the end. If you kill the run in the middle, the stored test settings stay on a foreign language, and later runs fail the formatting tests until you clear it.</p>
  </li>
</ul>]]></content><author><name>Trevor Turk</name></author><category term="swift" /><category term="ios" /><category term="testing" /><category term="snapshot-testing" /><category term="localization" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">A Date-Format Rulebook</title><link href="https://trevorturk.github.io/date-format-rulebook/" rel="alternate" type="text/html" title="A Date-Format Rulebook" /><published>2026-08-24T14:10:00+00:00</published><updated>2026-08-24T14:10:00+00:00</updated><id>https://trevorturk.github.io/date-format-rulebook</id><content type="html" xml:base="https://trevorturk.github.io/date-format-rulebook/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>The design wants “5pm”, not “5 PM”. So a screen writes <code class="language-plaintext highlighter-rouge">"ha"</code> and lowercases the result: <code class="language-plaintext highlighter-rouge">formatter.string(from: date).lowercased()</code>. That gives “5pm” in English and quietly breaks everywhere else. Hungarian’s meridiem, stripped and lowercased, becomes “de”, the word for “but”. Japanese, Korean, and Chinese don’t use a meridiem for short hours at all, and their dates want a year-month-day shape that no Western pattern produces. Vietnamese writes the weekday and the month as numbers, so a month-first header is three numbers in a row.</p>

<p>Another screen writes <code class="language-plaintext highlighter-rouge">"EEE"</code>, a third copies the lowercasing line, and now the formatters disagree on capitalization and meridiem. What each screen should look like is spread across dozens of call sites. When a translator reports one of these breaks, there’s no one switch to fix. There’s a search-and-replace and a hope that you found them all.</p>

<p>The scale makes this expensive. <a href="https://helloweather.com">Hello Weather</a> shows dates in 24 distinct places: a sunrise time under an icon, a weekday rail under the hourly chart, a watch complication hour capped at five characters. It does that in 27 languages, with a user-selectable 12- or 24-hour clock. One person maintains it, with a model doing the translating and no agency behind it, so there’s no room for date code only one screen understands. This post is the date piece of that localization work. <a href="/rendered-width-validation/">Rendered-width validation</a> and <a href="/probing-vendor-language-support/">vendor language probing</a> guard the rest.</p>

<h2 id="the-solution">The Solution</h2>

<p>We treat date formatting as configuration, not as code each screen writes for itself. Every date string in the app, watch, and widgets goes through three stages:</p>

<ul>
  <li>An intent enum whose cases name places in the UI (<code class="language-plaintext highlighter-rouge">sunEventTime</code>, <code class="language-plaintext highlighter-rouge">dailyHeader</code>), not formats.</li>
  <li>One exhaustive <code class="language-plaintext highlighter-rouge">plan(for:)</code> switch that maps an intent plus a language to a render plan.</li>
  <li>Named classifiers on the language enum. A classifier is a property that answers one question about a language (“does it keep its punctuated am/pm?”) with a <code class="language-plaintext highlighter-rouge">switch</code> over every language, so no language list sits inside an intent arm.</li>
</ul>

<p>The call site knows nothing. <code class="language-plaintext highlighter-rouge">Text(sunset.withFormat(.sunEventTime))</code> carries no format string, no lowercasing, no locale plumbing. <code class="language-plaintext highlighter-rouge">withFormat</code> looks up the current language and time zone, asks the intent for its plan, and renders.</p>

<h3 id="one-enum-of-intents-one-exhaustive-plan">One enum of intents, one exhaustive plan</h3>

<p>A reviewer fixing how sun times render in Finnish should have one place to look. Adding a dated screen should be impossible without deciding how it reads in every language. An exhaustive switch with no <code class="language-plaintext highlighter-rouge">default</code> gives us both. The block below shows the shape in miniature: the plan struct, a slice of the intent enum, three classifiers, and the switch that ties them together. It compiles as written.</p>

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

<span class="kd">enum</span> <span class="kt">Language</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="kt">CaseIterable</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">en</span><span class="p">,</span> <span class="n">de</span><span class="p">,</span> <span class="n">fr</span><span class="p">,</span> <span class="n">hu</span><span class="p">,</span> <span class="n">ja</span><span class="p">,</span> <span class="n">vi</span>
    <span class="c1">// one case per supported language; ~two dozen more elided</span>
<span class="p">}</span>

<span class="kd">enum</span> <span class="kt">Meridiem</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">localeDefault</span>   <span class="c1">// keep CLDR's own am/pm symbols</span>
    <span class="k">case</span> <span class="n">plainLower</span>      <span class="c1">// bare lowercase: "5:39pm"</span>
<span class="p">}</span>

<span class="kd">enum</span> <span class="kt">MeridiemForm</span> <span class="p">{</span> <span class="k">case</span> <span class="n">nativePunctuated</span><span class="p">,</span> <span class="n">plainLetters</span> <span class="p">}</span>
<span class="kd">enum</span> <span class="kt">DateOrderGroup</span> <span class="p">{</span> <span class="k">case</span> <span class="n">western</span><span class="p">,</span> <span class="n">cjk</span> <span class="p">}</span>
<span class="kd">enum</span> <span class="kt">HeaderDateOrder</span> <span class="p">{</span> <span class="k">case</span> <span class="n">monthDay</span><span class="p">,</span> <span class="n">dayMonth</span> <span class="p">}</span>

<span class="kd">extension</span> <span class="kt">Language</span> <span class="p">{</span>
    <span class="c1">// Most languages take bare lowercase letters. Three keep the punctuated</span>
    <span class="c1">// native form: stripped, the Czech and Finnish markers are not words, and</span>
    <span class="c1">// Hungarian's "de" is the word for "but".</span>
    <span class="k">var</span> <span class="nv">meridiemForm</span><span class="p">:</span> <span class="kt">MeridiemForm</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="nv">hu</span><span class="p">:</span>                       <span class="k">return</span> <span class="o">.</span><span class="n">nativePunctuated</span>
        <span class="k">case</span> <span class="o">.</span><span class="n">en</span><span class="p">,</span> <span class="o">.</span><span class="n">de</span><span class="p">,</span> <span class="o">.</span><span class="n">fr</span><span class="p">,</span> <span class="o">.</span><span class="n">ja</span><span class="p">,</span> <span class="o">.</span><span class="nv">vi</span><span class="p">:</span>   <span class="k">return</span> <span class="o">.</span><span class="n">plainLetters</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="nv">standardMeridiem</span><span class="p">:</span> <span class="kt">Meridiem</span> <span class="p">{</span>
        <span class="k">switch</span> <span class="n">meridiemForm</span> <span class="p">{</span>
        <span class="k">case</span> <span class="o">.</span><span class="nv">nativePunctuated</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="n">localeDefault</span>
        <span class="k">case</span> <span class="o">.</span><span class="nv">plainLetters</span><span class="p">:</span>     <span class="k">return</span> <span class="o">.</span><span class="n">plainLower</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="c1">// CJK renders native 年月日 via ICU templates; Western uses explicit patterns.</span>
    <span class="k">var</span> <span class="nv">dateOrderGroup</span><span class="p">:</span> <span class="kt">DateOrderGroup</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="nv">ja</span><span class="p">:</span>                       <span class="k">return</span> <span class="o">.</span><span class="n">cjk</span>
        <span class="k">case</span> <span class="o">.</span><span class="n">en</span><span class="p">,</span> <span class="o">.</span><span class="n">de</span><span class="p">,</span> <span class="o">.</span><span class="n">fr</span><span class="p">,</span> <span class="o">.</span><span class="n">hu</span><span class="p">,</span> <span class="o">.</span><span class="nv">vi</span><span class="p">:</span>   <span class="k">return</span> <span class="o">.</span><span class="n">western</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="c1">// Exactly one language orders headers day-first: Vietnamese, whose numeric</span>
    <span class="c1">// weekday and month collide with the day in month-first order.</span>
    <span class="k">var</span> <span class="nv">headerDateOrder</span><span class="p">:</span> <span class="kt">HeaderDateOrder</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="nv">vi</span><span class="p">:</span>                       <span class="k">return</span> <span class="o">.</span><span class="n">dayMonth</span>
        <span class="k">case</span> <span class="o">.</span><span class="n">en</span><span class="p">,</span> <span class="o">.</span><span class="n">de</span><span class="p">,</span> <span class="o">.</span><span class="n">fr</span><span class="p">,</span> <span class="o">.</span><span class="n">hu</span><span class="p">,</span> <span class="o">.</span><span class="nv">ja</span><span class="p">:</span>   <span class="k">return</span> <span class="o">.</span><span class="n">monthDay</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">struct</span> <span class="kt">DateRenderPlan</span> <span class="p">{</span>
    <span class="kd">enum</span> <span class="kt">Kind</span> <span class="p">{</span>
        <span class="k">case</span> <span class="nf">pattern</span><span class="p">(</span><span class="kt">String</span><span class="p">)</span>    <span class="c1">// an explicit, ordered format</span>
        <span class="k">case</span> <span class="nf">template</span><span class="p">(</span><span class="kt">String</span><span class="p">)</span>   <span class="c1">// an ICU skeleton; ICU picks the order</span>
    <span class="p">}</span>

    <span class="k">let</span> <span class="nv">kind</span><span class="p">:</span> <span class="kt">Kind</span>
    <span class="k">let</span> <span class="nv">twentyFourHourVariant</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span>
    <span class="k">let</span> <span class="nv">context</span><span class="p">:</span> <span class="kt">Formatter</span><span class="o">.</span><span class="kt">Context</span>   <span class="c1">// .beginningOfSentence capitalizes the first word</span>
    <span class="k">let</span> <span class="nv">meridiem</span><span class="p">:</span> <span class="kt">Meridiem</span>

    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">pattern</span><span class="p">(</span><span class="n">_</span> <span class="nv">p</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="nv">capitalized</span><span class="p">:</span> <span class="kt">Bool</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">DateRenderPlan</span> <span class="p">{</span>
        <span class="kt">DateRenderPlan</span><span class="p">(</span><span class="nv">kind</span><span class="p">:</span> <span class="o">.</span><span class="nf">pattern</span><span class="p">(</span><span class="n">p</span><span class="p">),</span> <span class="nv">twentyFourHourVariant</span><span class="p">:</span> <span class="kc">nil</span><span class="p">,</span>
                       <span class="nv">context</span><span class="p">:</span> <span class="n">capitalized</span> <span class="p">?</span> <span class="o">.</span><span class="nv">beginningOfSentence</span> <span class="p">:</span> <span class="o">.</span><span class="n">unknown</span><span class="p">,</span>
                       <span class="nv">meridiem</span><span class="p">:</span> <span class="o">.</span><span class="n">localeDefault</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">time</span><span class="p">(</span><span class="n">_</span> <span class="nv">p</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="n">or</span> <span class="nv">h24</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="nv">meridiem</span><span class="p">:</span> <span class="kt">Meridiem</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">DateRenderPlan</span> <span class="p">{</span>
        <span class="kt">DateRenderPlan</span><span class="p">(</span><span class="nv">kind</span><span class="p">:</span> <span class="o">.</span><span class="nf">pattern</span><span class="p">(</span><span class="n">p</span><span class="p">),</span> <span class="nv">twentyFourHourVariant</span><span class="p">:</span> <span class="n">h24</span><span class="p">,</span>
                       <span class="nv">context</span><span class="p">:</span> <span class="o">.</span><span class="n">unknown</span><span class="p">,</span> <span class="nv">meridiem</span><span class="p">:</span> <span class="n">meridiem</span><span class="p">)</span>
    <span class="p">}</span>
    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">template</span><span class="p">(</span><span class="n">_</span> <span class="nv">t</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="n">or</span> <span class="nv">h24</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span> <span class="o">=</span> <span class="kc">nil</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">DateRenderPlan</span> <span class="p">{</span>
        <span class="kt">DateRenderPlan</span><span class="p">(</span><span class="nv">kind</span><span class="p">:</span> <span class="o">.</span><span class="nf">template</span><span class="p">(</span><span class="n">t</span><span class="p">),</span> <span class="nv">twentyFourHourVariant</span><span class="p">:</span> <span class="n">h24</span><span class="p">,</span>
                       <span class="nv">context</span><span class="p">:</span> <span class="o">.</span><span class="n">unknown</span><span class="p">,</span> <span class="nv">meridiem</span><span class="p">:</span> <span class="o">.</span><span class="n">localeDefault</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">enum</span> <span class="kt">DateFormatIntent</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="kt">CaseIterable</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">summaryWeekday</span><span class="p">,</span> <span class="n">dailyHeader</span><span class="p">,</span> <span class="n">sunEventTime</span><span class="p">,</span> <span class="n">forecastUpdatedDateTime</span>
    <span class="c1">// 24 cases total, one per UI surface; the rest elided</span>

    <span class="c1">// Exhaustive, no default: a new case does not compile until it has an arm.</span>
    <span class="kd">func</span> <span class="nf">plan</span><span class="p">(</span><span class="k">for</span> <span class="nv">language</span><span class="p">:</span> <span class="kt">Language</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">DateRenderPlan</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="nv">summaryWeekday</span><span class="p">:</span>
            <span class="k">return</span> <span class="o">.</span><span class="nf">pattern</span><span class="p">(</span><span class="s">"E"</span><span class="p">,</span> <span class="nv">capitalized</span><span class="p">:</span> <span class="kc">false</span><span class="p">)</span>
        <span class="k">case</span> <span class="o">.</span><span class="nv">dailyHeader</span><span class="p">:</span>
            <span class="k">switch</span> <span class="n">language</span><span class="o">.</span><span class="n">dateOrderGroup</span> <span class="p">{</span>
            <span class="k">case</span> <span class="o">.</span><span class="nv">cjk</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="nf">template</span><span class="p">(</span><span class="s">"MMMdE"</span><span class="p">)</span>
            <span class="k">case</span> <span class="o">.</span><span class="nv">western</span><span class="p">:</span>
                <span class="k">switch</span> <span class="n">language</span><span class="o">.</span><span class="n">headerDateOrder</span> <span class="p">{</span>
                <span class="k">case</span> <span class="o">.</span><span class="nv">monthDay</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="nf">pattern</span><span class="p">(</span><span class="s">"E, MMM d"</span><span class="p">,</span> <span class="nv">capitalized</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>
                <span class="k">case</span> <span class="o">.</span><span class="nv">dayMonth</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="nf">pattern</span><span class="p">(</span><span class="s">"E, d MMM"</span><span class="p">,</span> <span class="nv">capitalized</span><span class="p">:</span> <span class="kc">true</span><span class="p">)</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="k">case</span> <span class="o">.</span><span class="nv">sunEventTime</span><span class="p">:</span>
            <span class="k">switch</span> <span class="n">language</span><span class="o">.</span><span class="n">dateOrderGroup</span> <span class="p">{</span>
            <span class="k">case</span> <span class="o">.</span><span class="nv">cjk</span><span class="p">:</span>     <span class="k">return</span> <span class="o">.</span><span class="nf">template</span><span class="p">(</span><span class="s">"hmm"</span><span class="p">,</span> <span class="nv">or</span><span class="p">:</span> <span class="s">"Hmm"</span><span class="p">)</span>
            <span class="k">case</span> <span class="o">.</span><span class="nv">western</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="nf">time</span><span class="p">(</span><span class="s">"h:mma"</span><span class="p">,</span> <span class="nv">or</span><span class="p">:</span> <span class="s">"H:mm"</span><span class="p">,</span> <span class="nv">meridiem</span><span class="p">:</span> <span class="n">language</span><span class="o">.</span><span class="n">standardMeridiem</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="k">case</span> <span class="o">.</span><span class="nv">forecastUpdatedDateTime</span><span class="p">:</span>
            <span class="k">switch</span> <span class="n">language</span><span class="o">.</span><span class="n">dateOrderGroup</span> <span class="p">{</span>
            <span class="k">case</span> <span class="o">.</span><span class="nv">cjk</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="nf">template</span><span class="p">(</span><span class="s">"MMMdEhmm"</span><span class="p">,</span> <span class="nv">or</span><span class="p">:</span> <span class="s">"MMMdEHmm"</span><span class="p">)</span>
            <span class="k">case</span> <span class="o">.</span><span class="nv">western</span><span class="p">:</span>
                <span class="k">switch</span> <span class="n">language</span><span class="o">.</span><span class="n">headerDateOrder</span> <span class="p">{</span>
                <span class="k">case</span> <span class="o">.</span><span class="nv">monthDay</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="nf">time</span><span class="p">(</span><span class="s">"E, MMM d @ h:mma"</span><span class="p">,</span> <span class="nv">or</span><span class="p">:</span> <span class="s">"E, MMM d @ H:mm"</span><span class="p">,</span>
                                             <span class="nv">meridiem</span><span class="p">:</span> <span class="n">language</span><span class="o">.</span><span class="n">standardMeridiem</span><span class="p">)</span>
                <span class="k">case</span> <span class="o">.</span><span class="nv">dayMonth</span><span class="p">:</span> <span class="k">return</span> <span class="o">.</span><span class="nf">time</span><span class="p">(</span><span class="s">"E, d MMM @ h:mma"</span><span class="p">,</span> <span class="nv">or</span><span class="p">:</span> <span class="s">"E, d MMM @ H:mm"</span><span class="p">,</span>
                                             <span class="nv">meridiem</span><span class="p">:</span> <span class="n">language</span><span class="o">.</span><span class="n">standardMeridiem</span><span class="p">)</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The intent arms hold no list of languages. <code class="language-plaintext highlighter-rouge">dailyHeader</code> asks <code class="language-plaintext highlighter-rouge">language.dateOrderGroup</code> and <code class="language-plaintext highlighter-rouge">language.headerDateOrder</code> and branches on the answers. The intent knows the shape of a header, and the classifier knows which languages deviate from it.</p>

<h3 id="per-language-quirks-in-named-classifiers">Per-language quirks in named classifiers</h3>

<p>Once a <code class="language-plaintext highlighter-rouge">switch self { case .fi, .hu: … }</code> shows up inside a formatting rule, the reason for the exception is lost, and the next language that needs the same treatment gets added somewhere else. A named classifier keeps the quirk and its reason together, so every exception in the app is one arm you can point at.</p>

<p>Each classifier exists because a real language broke a naive rule. <code class="language-plaintext highlighter-rouge">meridiemForm</code> records that Hungarian keeps its punctuated form. The locale test that pins that rendering carries the reason, “Adjudicated: keep the period — bare ‘de’ is the Hungarian word for ‘but’”, which nobody would think to write from first principles. <code class="language-plaintext highlighter-rouge">dateOrderGroup</code> is why the CJK languages (Chinese, Japanese, Korean) get ICU templates, where the system picks the field order, instead of a hardcoded Western order. A sibling classifier, <code class="language-plaintext highlighter-rouge">compactHourStyle</code>, is why their short hours render as “9時”/”9时”/”9시” rather than “9pm”. <code class="language-plaintext highlighter-rouge">headerDateOrder</code> is a single arm for Vietnamese. We didn’t make a vague “some languages are day-first” category, because it would pull in languages that don’t want it.</p>

<h3 id="shaping-the-meridiem-at-construction">Shaping the meridiem at construction</h3>

<p>The house voice wants “5:39pm”: lowercase, no space, no periods. Lowercasing the rendered string is wrong twice over. It lowercases any weekday or month name that should stay capitalized, and unless you pass a locale it uses ASCII casing rules, which mangles languages like Turkish. The right moment to decide the meridiem is when the formatter is built, by changing its AM/PM symbols before it renders anything. This function does that, and it runs as written. In the app it’s a private method on the formatter cache.</p>

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

<span class="kd">enum</span> <span class="kt">Meridiem</span> <span class="p">{</span> <span class="k">case</span> <span class="n">localeDefault</span><span class="p">,</span> <span class="n">plainLower</span> <span class="p">}</span>

<span class="c1">/// Shapes AM/PM symbols at construction so the formatter emits the house voice</span>
<span class="c1">/// directly. Never a `.lowercased()` on the formatter's output.</span>
<span class="kd">func</span> <span class="nf">applyMeridiem</span><span class="p">(</span><span class="n">_</span> <span class="nv">meridiem</span><span class="p">:</span> <span class="kt">Meridiem</span><span class="p">,</span> <span class="n">to</span> <span class="nv">formatter</span><span class="p">:</span> <span class="kt">DateFormatter</span><span class="p">,</span>
                   <span class="nv">dateFormat</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="nv">locale</span><span class="p">:</span> <span class="kt">Locale</span><span class="p">)</span> <span class="p">{</span>
    <span class="k">guard</span> <span class="n">dateFormat</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="s">"a"</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="c1">// no meridiem to shape</span>
    <span class="k">switch</span> <span class="n">meridiem</span> <span class="p">{</span>
    <span class="k">case</span> <span class="o">.</span><span class="nv">localeDefault</span><span class="p">:</span>
        <span class="k">return</span>
    <span class="k">case</span> <span class="o">.</span><span class="nv">plainLower</span><span class="p">:</span>
        <span class="k">let</span> <span class="nv">bareLowercase</span><span class="p">:</span> <span class="p">(</span><span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="o">=</span> <span class="p">{</span> <span class="n">symbol</span> <span class="k">in</span>
            <span class="k">let</span> <span class="nv">letters</span> <span class="o">=</span> <span class="n">symbol</span><span class="o">.</span><span class="n">unicodeScalars</span><span class="o">.</span><span class="n">filter</span> <span class="p">{</span>
                <span class="kt">CharacterSet</span><span class="o">.</span><span class="n">letters</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="nv">$0</span><span class="p">)</span> <span class="o">||</span> <span class="kt">CharacterSet</span><span class="o">.</span><span class="n">decimalDigits</span><span class="o">.</span><span class="nf">contains</span><span class="p">(</span><span class="nv">$0</span><span class="p">)</span>
            <span class="p">}</span>
            <span class="k">return</span> <span class="kt">String</span><span class="p">(</span><span class="n">letters</span><span class="p">)</span><span class="o">.</span><span class="nf">lowercased</span><span class="p">(</span><span class="nv">with</span><span class="p">:</span> <span class="n">locale</span><span class="p">)</span>
        <span class="p">}</span>
        <span class="k">if</span> <span class="k">let</span> <span class="nv">am</span> <span class="o">=</span> <span class="n">formatter</span><span class="o">.</span><span class="n">amSymbol</span> <span class="p">{</span> <span class="n">formatter</span><span class="o">.</span><span class="n">amSymbol</span> <span class="o">=</span> <span class="nf">bareLowercase</span><span class="p">(</span><span class="n">am</span><span class="p">)</span> <span class="p">}</span>
        <span class="k">if</span> <span class="k">let</span> <span class="nv">pm</span> <span class="o">=</span> <span class="n">formatter</span><span class="o">.</span><span class="n">pmSymbol</span> <span class="p">{</span> <span class="n">formatter</span><span class="o">.</span><span class="n">pmSymbol</span> <span class="o">=</span> <span class="nf">bareLowercase</span><span class="p">(</span><span class="n">pm</span><span class="p">)</span> <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>It keeps only the letters and decimal digits from the locale’s own symbol and lowercases them with that locale’s casing rules. “5:39 p. m.” becomes “5:39pm”, and Turkish “ÖS” becomes “ös” under Turkish casing, not ASCII. The guard on the <code class="language-plaintext highlighter-rouge">a</code> pattern character lets formatters with no meridiem skip the work. When a call site really needs a different case, it passes a declared <code class="language-plaintext highlighter-rouge">case:</code> parameter, and the formatting layer applies it once, with the locale. A handful of alternate styles do that, to set a rail in caps. The rest never do, because the voice they want is already in the symbols.</p>

<h3 id="source-scanning-tests-for-the-bans">Source-scanning tests for the bans</h3>

<p>“Never lowercase a rendered date” only holds if nobody can quietly bring it back, and the compiler can’t see a <code class="language-plaintext highlighter-rouge">.lowercased()</code> habit the way it sees a missing switch arm. So a test reads every Swift file in the app, runs a regex over each one, and fails with the matches it found. This is the complete check, under Swift Testing, as written.</p>

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

<span class="kd">@Suite</span> <span class="kd">struct</span> <span class="kt">DateFormatCallsiteTests</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">sourceRoot</span> <span class="o">=</span> <span class="kt">URL</span><span class="p">(</span><span class="nv">fileURLWithPath</span><span class="p">:</span> <span class="kd">#file</span><span class="kt">Path</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">deletingLastPathComponent</span><span class="p">()</span>
        <span class="o">.</span><span class="nf">deletingLastPathComponent</span><span class="p">()</span>
        <span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"App"</span><span class="p">)</span>   <span class="c1">// your source tree</span>

    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">swiftSources</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="p">{</span>
        <span class="k">guard</span> <span class="k">let</span> <span class="nv">files</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="nf">enumerator</span><span class="p">(</span>
            <span class="nv">at</span><span class="p">:</span> <span class="n">sourceRoot</span><span class="p">,</span> <span class="nv">includingPropertiesForKeys</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="p">[]</span> <span class="p">}</span>
        <span class="k">return</span> <span class="n">files</span><span class="o">.</span><span class="n">compactMap</span> <span class="p">{</span> <span class="n">item</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">item</span> <span class="k">as?</span> <span class="kt">URL</span><span class="p">,</span> <span class="n">url</span><span class="o">.</span><span class="n">pathExtension</span> <span class="o">==</span> <span class="s">"swift"</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="k">try</span><span class="p">?</span> <span class="kt">String</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="nv">encoding</span><span class="p">:</span> <span class="o">.</span><span class="n">utf8</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">@Test</span> <span class="kd">func</span> <span class="nf">caseMethodsNeverChainOnFormattedDates</span><span class="p">()</span> <span class="k">throws</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">sources</span> <span class="o">=</span> <span class="k">Self</span><span class="o">.</span><span class="nf">swiftSources</span><span class="p">()</span>
        <span class="k">try</span> <span class="err">#</span><span class="nf">require</span><span class="p">(</span><span class="n">sources</span><span class="o">.</span><span class="n">count</span> <span class="o">&gt;</span> <span class="mi">100</span><span class="p">,</span> <span class="s">"source scan found too few files"</span><span class="p">)</span>

        <span class="k">let</span> <span class="nv">chain</span> <span class="o">=</span> <span class="k">try</span> <span class="kt">NSRegularExpression</span><span class="p">(</span><span class="nv">pattern</span><span class="p">:</span>
            <span class="cp">#"withFormat(?:ForDeviceTimeZone)?\([^()]*\)\.(?:lowercased|uppercased|capitalized)"#</span>
            <span class="o">+</span> <span class="err">#</span><span class="s">"|withRelativeFormat</span><span class="se">\(</span><span class="p">\</span><span class="se">)</span><span class="err">\</span><span class="s">.(?:lowercased|uppercased|capitalized)"</span><span class="err">#</span><span class="p">)</span>
        <span class="k">var</span> <span class="nv">offenders</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>
        <span class="k">for</span> <span class="n">source</span> <span class="k">in</span> <span class="n">sources</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">range</span> <span class="o">=</span> <span class="kt">NSRange</span><span class="p">(</span><span class="n">source</span><span class="o">.</span><span class="n">startIndex</span><span class="o">..&lt;</span><span class="n">source</span><span class="o">.</span><span class="n">endIndex</span><span class="p">,</span> <span class="nv">in</span><span class="p">:</span> <span class="n">source</span><span class="p">)</span>
            <span class="k">for</span> <span class="n">match</span> <span class="k">in</span> <span class="n">chain</span><span class="o">.</span><span class="nf">matches</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="n">source</span><span class="p">,</span> <span class="nv">range</span><span class="p">:</span> <span class="n">range</span><span class="p">)</span> <span class="p">{</span>
                <span class="k">if</span> <span class="k">let</span> <span class="nv">r</span> <span class="o">=</span> <span class="kt">Range</span><span class="p">(</span><span class="n">match</span><span class="o">.</span><span class="n">range</span><span class="p">,</span> <span class="nv">in</span><span class="p">:</span> <span class="n">source</span><span class="p">)</span> <span class="p">{</span>
                    <span class="n">offenders</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="kt">String</span><span class="p">(</span><span class="n">source</span><span class="p">[</span><span class="n">r</span><span class="p">]))</span>
                <span class="p">}</span>
            <span class="p">}</span>
        <span class="p">}</span>
        <span class="cp">#expect(offenders.isEmpty,</span>
            <span class="s">"case methods chained on rendered date strings: </span><span class="se">\(</span><span class="n">offenders</span><span class="se">)</span><span class="s">. Use withFormat's "</span>
            <span class="o">+</span> <span class="s">"`case:` parameter (.lower/.upper) so the formatting layer applies the transform "</span>
            <span class="o">+</span> <span class="s">"once, locale-aware. (Scan catches case chained directly on a withFormat(...) call "</span>
            <span class="o">+</span> <span class="s">"only; case reached through an intermediate variable or wrapper must be caught in review.)"</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The failure message admits the scan’s limit. It catches a case method chained directly on the call, and a transform reached through a wrapper or an intermediate variable is left to review. Two sibling tests round out the bans. One fails if any intent case has no call site, because an intent with no UI is either dead code or a wiring bug, and either way the test forces the question. The other freezes the small set of raw format strings the frozen alternate-style catalog may still use, so new UI can’t slip in a raw <code class="language-plaintext highlighter-rouge">dateFormat</code> and has to add an intent instead. Two more exhaustive switches classify every intent by width budget and by worst-case sample, so adding a case fails three separate compiles until it’s classified three ways.</p>

<h3 id="hand-pinned-english-anchors">Hand-pinned English anchors</h3>

<p>Every intent’s rendering in every language is captured in committed golden files, which a machine records and a machine updates. A <a href="/golden-files-per-language/">companion post</a> covers that matrix and the fast macOS test tier. The risk is that a careless record run could bless a regression into the English golden, and the diff would look like every other regeneration. So a handful of English outputs are also pinned by hand, in a table a person edits and a record run never rewrites.</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">englishAnchorsHoldAtTheEveningInstant</span><span class="p">()</span> <span class="p">{</span>
    <span class="c1">// A hand-picked instant (2026-08-15, 17:39 local), not a machine sample.</span>
    <span class="c1">// withFormat / withAppLanguage / DateFormatSamples are the test harness.</span>
    <span class="c1">// Three of the eight 12-hour anchors; the real table also pins the 24-hour clock.</span>
    <span class="k">let</span> <span class="nv">anchors</span><span class="p">:</span> <span class="p">[(</span><span class="kt">DateFormatIntent</span><span class="p">,</span> <span class="kt">String</span><span class="p">)]</span> <span class="o">=</span> <span class="p">[</span>
        <span class="p">(</span><span class="o">.</span><span class="n">dailyHeader</span><span class="p">,</span>             <span class="s">"Sat, Aug 15"</span><span class="p">),</span>
        <span class="p">(</span><span class="o">.</span><span class="n">sunEventTime</span><span class="p">,</span>            <span class="s">"5:39pm"</span><span class="p">),</span>
        <span class="p">(</span><span class="o">.</span><span class="n">forecastUpdatedDateTime</span><span class="p">,</span> <span class="s">"Sat, Aug 15 @ 5:39pm"</span><span class="p">),</span>
    <span class="p">]</span>
    <span class="nf">withAppLanguage</span><span class="p">(</span><span class="o">.</span><span class="n">en</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">for</span> <span class="p">(</span><span class="n">intent</span><span class="p">,</span> <span class="n">expected</span><span class="p">)</span> <span class="k">in</span> <span class="n">anchors</span> <span class="p">{</span>
            <span class="cp">#expect(DateFormatSamples.evening.withFormat(intent) == expected,</span>
                    <span class="n">intent</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>These overlap the English rows in the golden table on purpose. The golden records what the code produces today, and the anchors record what a person decided it should produce. If a record run ever drifts the English output, the goldens follow it silently and the anchors go red.</p>

<h2 id="results">Results</h2>

<ul>
  <li>Every date string across app, watch, and widgets goes through 24 intents and one exhaustive <code class="language-plaintext highlighter-rouge">plan(for:)</code> switch. A rendering fix for any language is a one-arm edit instead of a codebase-wide hunt. Each per-language exception, from the Hungarian meridiem and the Vietnamese day-first header to the CJK hour counters and the six declared weekday arrays, is one classifier arm that carries its reason.</li>
  <li>“We forgot to handle Vietnamese” is a build error, not a shipped bug. The cost is friction: a new dated screen fails three separate compiles until it’s classified by plan, width budget, and worst-case sample.</li>
  <li>The bans the compiler can’t see (no lowercasing chained on a rendered date, no orphan intents, no new raw format strings) hold in source-scanning tests. Hand-pinned English anchors duplicate golden rows on purpose, to guard the machine-recorded ones.</li>
</ul>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Give a one-language exception its own arm and write down why.</strong> A category invented to hold it, like “some languages are day-first”, pulls in languages that don’t want it.</li>
  <li><strong>Decide the output shape when you build the formatter, not by editing what it emits.</strong> Post-processing applies the wrong rule to the wrong characters and hides where the decision was made.</li>
  <li><strong>Where a test regenerates its own expectations, keep a small parallel set only a person edits.</strong> Otherwise the regeneration blesses the regression it was meant to catch.</li>
  <li><strong>The same shape works for any formatting problem with a lot of variation: currency, units, addresses, names.</strong> Route through named intents, push the variation into classifiers, ban post-processing, and let exhaustive switches reject a case that isn’t decided for every language.</li>
</ul>]]></content><author><name>Trevor Turk</name></author><category term="swift" /><category term="ios" /><category term="localization" /><category term="i18n" /><category term="dates" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">One Setting, Two Locale Gates</title><link href="https://trevorturk.github.io/one-setting-two-locale-gates/" rel="alternate" type="text/html" title="One Setting, Two Locale Gates" /><published>2026-08-24T14:00:00+00:00</published><updated>2026-08-24T14:00:00+00:00</updated><id>https://trevorturk.github.io/one-setting-two-locale-gates</id><content type="html" xml:base="https://trevorturk.github.io/one-setting-two-locale-gates/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>Someone who reads their phone in German but wants their weather in Japanese should get Japanese in the app, in the home-screen widget, and on the watch face, from one tap in one settings row. That’s the requirement. The language is chosen once inside the app, it applies everywhere text appears, it changes without a relaunch, and it never comes from the phone’s language.</p>

<p><a href="https://helloweather.com">Hello Weather</a> makes that harder than it sounds because it isn’t one program. The app draws text, and so do its widgets, its watch app, and its notifications. Each one runs as its own process, and nothing the app sets up at launch carries over to the others.</p>

<p>Apple offers two obvious ways to do this, and both fail.</p>

<p>The first is <code class="language-plaintext highlighter-rouge">AppleLanguages</code>, an array in <code class="language-plaintext highlighter-rouge">UserDefaults</code> that iOS reads to pick a process’s language. Writing to it fails the goal three ways. iOS reads the value at launch and caches it, so a change needs a relaunch. It persists across app updates in a way you don’t control. And it’s the same key that iOS’s own per-app language row writes, so setting it from inside the app fights the system row instead of replacing it.</p>

<p>The second is plain <code class="language-plaintext highlighter-rouge">String(localized:)</code>. It looks strings up in <code class="language-plaintext highlighter-rouge">Bundle.main</code>, which uses the device language. Every string localized that way skips the in-app picker. The user picks Japanese and half the UI stays in German because it went through the wrong API.</p>

<p>Under both is the fact that “the language” isn’t one thing in SwiftUI. A <code class="language-plaintext highlighter-rouge">Text("...")</code> literal gets its language from the locale in the environment. A plain <code class="language-plaintext highlighter-rouge">String</code> built for a notification or an accessibility label gets its language from whichever API you call. Fix one path and the device language leaks in through the other. So before any translation, we had an architecture question: where does the app’s language live, and how does every string on screen get its language from there?</p>

<h2 id="the-solution">The Solution</h2>

<p>One setting owns the language, and everything that renders text reads it through one of two paths, which we call gates. The setting is a plain string in shared storage, backed by an enum:</p>

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

<span class="kd">enum</span> <span class="kt">Language</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="kt">CaseIterable</span><span class="p">,</span> <span class="kt">Identifiable</span> <span class="p">{</span>
    <span class="k">case</span> <span class="n">cs</span><span class="p">,</span> <span class="n">da</span><span class="p">,</span> <span class="n">de</span><span class="p">,</span> <span class="n">el</span><span class="p">,</span> <span class="n">en</span><span class="p">,</span> <span class="n">es</span><span class="p">,</span> <span class="n">fi</span><span class="p">,</span> <span class="n">fr</span><span class="p">,</span> <span class="n">hi</span><span class="p">,</span> <span class="n">hu</span><span class="p">,</span> <span class="n">id</span><span class="p">,</span> <span class="n">it</span>
    <span class="k">case</span> <span class="n">ja</span><span class="p">,</span> <span class="n">ko</span><span class="p">,</span> <span class="n">nb</span><span class="p">,</span> <span class="n">nl</span><span class="p">,</span> <span class="n">pl</span><span class="p">,</span> <span class="n">pt</span><span class="p">,</span> <span class="n">ro</span><span class="p">,</span> <span class="n">ru</span><span class="p">,</span> <span class="n">sv</span><span class="p">,</span> <span class="n">th</span><span class="p">,</span> <span class="n">tr</span><span class="p">,</span> <span class="n">uk</span><span class="p">,</span> <span class="n">vi</span>
    <span class="k">case</span> <span class="n">zhHans</span> <span class="o">=</span> <span class="s">"zh-Hans"</span>
    <span class="k">case</span> <span class="n">zhHant</span> <span class="o">=</span> <span class="s">"zh-Hant"</span>

    <span class="k">var</span> <span class="nv">id</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span> <span class="n">rawValue</span> <span class="p">}</span>
    <span class="k">var</span> <span class="nv">locale</span><span class="p">:</span> <span class="kt">Locale</span> <span class="p">{</span> <span class="kt">Locale</span><span class="p">(</span><span class="nv">identifier</span><span class="p">:</span> <span class="n">rawValue</span><span class="p">)</span> <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Twenty-seven cases, backed by strings, so the raw value is both the locale identifier and the value we store. The two Chinese cases carry explicit raw values because their identifiers aren’t bare language codes. In the app each case also carries a <code class="language-plaintext highlighter-rouge">displayName</code>, the language’s native name (“Deutsch”, “日本語”, “简体中文”). The picker lists each language by its name in the current app language, with the native name as a subtitle, so a user scanning the list can find their own script.</p>

<p>The default is English on purpose. The app soft-launched, so it stays in English until a user opts in:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">func</span> <span class="nf">languageDefault</span><span class="p">()</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
    <span class="c1">// TODO: detect the device locale and return the matching Language, if supported.</span>
    <span class="k">return</span> <span class="kt">Language</span><span class="o">.</span><span class="n">en</span><span class="o">.</span><span class="n">rawValue</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Adopting the device language on first launch is a product decision with support consequences. It’s easy to turn on later by returning the matching device code instead of a hard <code class="language-plaintext highlighter-rouge">en</code>. Starting with English means nobody gets a language they didn’t pick.</p>

<h3 id="gate-one-the-environment-locale-pinned-at-every-root">Gate One: the Environment Locale, Pinned at Every Root</h3>

<p>SwiftUI localizes <code class="language-plaintext highlighter-rouge">Text("...")</code> literals using the <code class="language-plaintext highlighter-rouge">locale</code> in the environment. Set that locale from the language setting and every literal in the view tree follows it, with no wrapper and no helper at the call site. The catch is that you can’t set it once. A widget is a different process and inherits nothing from the app, so the locale has to be set again, which we call pinning, at the root of every process that renders text. In the app that root is the scene:</p>

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

<span class="c1">// An app-group suite, so the widget and watch processes read the same value.</span>
<span class="k">let</span> <span class="nv">sharedStore</span> <span class="o">=</span> <span class="kt">UserDefaults</span><span class="p">(</span><span class="nv">suiteName</span><span class="p">:</span> <span class="s">"group.example.weather"</span><span class="p">)</span><span class="o">!</span>

<span class="kd">final</span> <span class="kd">class</span> <span class="kt">SettingsManager</span><span class="p">:</span> <span class="kt">ObservableObject</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">shared</span> <span class="o">=</span> <span class="kt">SettingsManager</span><span class="p">()</span>

    <span class="k">var</span> <span class="nv">language</span><span class="p">:</span> <span class="kt">String</span> <span class="p">{</span>
        <span class="k">get</span> <span class="p">{</span> <span class="n">sharedStore</span><span class="o">.</span><span class="nf">string</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="s">"language"</span><span class="p">)</span> <span class="p">??</span> <span class="nf">languageDefault</span><span class="p">()</span> <span class="p">}</span>
        <span class="k">set</span> <span class="p">{</span>
            <span class="n">sharedStore</span><span class="o">.</span><span class="nf">set</span><span class="p">(</span><span class="n">newValue</span><span class="p">,</span> <span class="nv">forKey</span><span class="p">:</span> <span class="s">"language"</span><span class="p">)</span>
            <span class="n">objectWillChange</span><span class="o">.</span><span class="nf">send</span><span class="p">()</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="nv">languageLocale</span><span class="p">:</span> <span class="kt">Locale</span> <span class="p">{</span>
        <span class="kt">Language</span><span class="p">(</span><span class="nv">rawValue</span><span class="p">:</span> <span class="n">language</span><span class="p">)?</span><span class="o">.</span><span class="n">locale</span> <span class="p">??</span> <span class="kt">Locale</span><span class="p">(</span><span class="nv">identifier</span><span class="p">:</span> <span class="s">"en"</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">@main</span>
<span class="kd">struct</span> <span class="kt">WeatherApp</span><span class="p">:</span> <span class="kt">App</span> <span class="p">{</span>
    <span class="kd">@ObservedObject</span> <span class="kd">private</span> <span class="k">var</span> <span class="nv">settingsManager</span> <span class="o">=</span> <span class="kt">SettingsManager</span><span class="o">.</span><span class="n">shared</span>

    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">Scene</span> <span class="p">{</span>
        <span class="kt">WindowGroup</span> <span class="p">{</span>
            <span class="kt">ContentView</span><span class="p">()</span>
                <span class="o">.</span><span class="nf">environment</span><span class="p">(\</span><span class="o">.</span><span class="n">locale</span><span class="p">,</span> <span class="n">settingsManager</span><span class="o">.</span><span class="n">languageLocale</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The widget is its own target and starts cold, so it pins the same locale again inside its configuration’s content closure:</p>

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

<span class="kd">struct</span> <span class="kt">SmallCurrentWidget</span><span class="p">:</span> <span class="kt">Widget</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">kind</span> <span class="o">=</span> <span class="s">"SmallCurrent"</span>

    <span class="k">var</span> <span class="nv">body</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">WidgetConfiguration</span> <span class="p">{</span>
        <span class="kt">AppIntentConfiguration</span><span class="p">(</span><span class="nv">kind</span><span class="p">:</span> <span class="n">kind</span><span class="p">,</span> <span class="nv">intent</span><span class="p">:</span> <span class="kt">Configuration</span><span class="o">.</span><span class="k">self</span><span class="p">,</span> <span class="nv">provider</span><span class="p">:</span> <span class="kt">Provider</span><span class="p">())</span> <span class="p">{</span> <span class="n">entry</span> <span class="k">in</span>
            <span class="kt">SmallCurrentView</span><span class="p">(</span><span class="nv">configuration</span><span class="p">:</span> <span class="n">entry</span><span class="o">.</span><span class="n">configuration</span><span class="p">,</span> <span class="nv">viewModel</span><span class="p">:</span> <span class="n">entry</span><span class="p">)</span>
                <span class="o">.</span><span class="nf">environment</span><span class="p">(\</span><span class="o">.</span><span class="n">locale</span><span class="p">,</span> <span class="kt">SettingsManager</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">languageLocale</span><span class="p">)</span>
        <span class="p">}</span>
        <span class="o">.</span><span class="nf">configurationDisplayName</span><span class="p">(</span><span class="s">"Small Current"</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">supportedFamilies</span><span class="p">([</span><span class="o">.</span><span class="n">systemSmall</span><span class="p">])</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The pin is one line in each block, and each one reads from the same <code class="language-plaintext highlighter-rouge">SettingsManager.shared</code>. Our repo has 35 of them: the app root, every widget entry view, the watch root, and each complication view. That repetition looks like a smell until you see what it prevents. A widget that forgets the pin renders in the device language. It looks right in a simulator running in English, so the bug ships.</p>

<h3 id="gate-two-the-localized_-helper-for-string-typed-copy">Gate Two: the <code class="language-plaintext highlighter-rouge">localized(_:)</code> Helper for String-Typed Copy</h3>

<p>Views are only half the strings. Notifications, accessibility labels, dictionary values in a manager, and chart-unit suffixes need a real <code class="language-plaintext highlighter-rouge">String</code>, and a <code class="language-plaintext highlighter-rouge">String</code> has no environment to read. We don’t allow <code class="language-plaintext highlighter-rouge">String(localized: "…")</code> because it uses the device language. Instead, every one of those strings goes through one helper, built on the <code class="language-plaintext highlighter-rouge">Language</code> enum above:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="k">let</span> <span class="nv">languageLocales</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Locale</span><span class="p">]</span> <span class="o">=</span> <span class="kt">Dictionary</span><span class="p">(</span>
    <span class="nv">uniqueKeysWithValues</span><span class="p">:</span> <span class="kt">Language</span><span class="o">.</span><span class="n">allCases</span><span class="o">.</span><span class="n">map</span> <span class="p">{</span> <span class="p">(</span><span class="nv">$0</span><span class="o">.</span><span class="n">rawValue</span><span class="p">,</span> <span class="kt">Locale</span><span class="p">(</span><span class="nv">identifier</span><span class="p">:</span> <span class="nv">$0</span><span class="o">.</span><span class="n">rawValue</span><span class="p">))</span> <span class="p">}</span>
<span class="p">)</span>

<span class="c1">// Resolves in the app language, not the device language.</span>
<span class="kd">func</span> <span class="nf">localized</span><span class="p">(</span><span class="n">_</span> <span class="nv">resource</span><span class="p">:</span> <span class="kt">LocalizedStringResource</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">resource</span> <span class="o">=</span> <span class="n">resource</span>
    <span class="k">let</span> <span class="nv">language</span> <span class="o">=</span> <span class="n">sharedStore</span><span class="o">.</span><span class="nf">string</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="s">"language"</span><span class="p">)</span> <span class="p">??</span> <span class="kt">Language</span><span class="o">.</span><span class="n">en</span><span class="o">.</span><span class="n">rawValue</span>
    <span class="n">resource</span><span class="o">.</span><span class="n">locale</span> <span class="o">=</span> <span class="n">languageLocales</span><span class="p">[</span><span class="n">language</span><span class="p">]</span> <span class="p">??</span> <span class="kt">Locale</span><span class="p">(</span><span class="nv">identifier</span><span class="p">:</span> <span class="n">language</span><span class="p">)</span>
    <span class="k">return</span> <span class="kt">String</span><span class="p">(</span><span class="nv">localized</span><span class="p">:</span> <span class="n">resource</span><span class="p">)</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Two choices make it work. First, the parameter is a <code class="language-plaintext highlighter-rouge">LocalizedStringResource</code>, not a <code class="language-plaintext highlighter-rouge">String</code>. When you write <code class="language-plaintext highlighter-rouge">localized("Ranges")</code> the compiler sees a resource literal and pulls <code class="language-plaintext highlighter-rouge">"Ranges"</code> into the catalog, the same as it would for a <code class="language-plaintext highlighter-rouge">Text</code>. With a <code class="language-plaintext highlighter-rouge">String</code> parameter the compiler would extract nothing. Second, the helper reads the language from the same shared key the setting writes, defaults to English, sets <code class="language-plaintext highlighter-rouge">resource.locale</code>, and only then calls <code class="language-plaintext highlighter-rouge">String(localized:)</code>. That one assignment is the gate. The <code class="language-plaintext highlighter-rouge">languageLocales</code> dictionary is built once so we don’t construct a <code class="language-plaintext highlighter-rouge">Locale</code> on every call.</p>

<p>At the call site it looks ordinary: <code class="language-plaintext highlighter-rouge">localized("Ranges")</code>, or <code class="language-plaintext highlighter-rouge">localized("\(minutes) minutes")</code> to fill in a plural key. Every <code class="language-plaintext highlighter-rouge">String</code> a user can see goes through it. Between the environment pins for <code class="language-plaintext highlighter-rouge">Text</code> and this helper for <code class="language-plaintext highlighter-rouge">String</code>, there’s no third path, and neither path can reach the device language.</p>

<p>Each thing we wanted maps to one of the levers we rejected. Switching is live because the environment locale is read on every render and the helper reads the setting on every call. Change the setting and the next render is in the new language. <code class="language-plaintext highlighter-rouge">AppleLanguages</code> can’t do that, because it’s cached until restart. There’s no fight with iOS because we never touch <code class="language-plaintext highlighter-rouge">AppleLanguages</code>, so the app’s language and the system’s per-app row never overwrite each other. The limit is that the per-app language row iOS shows for the app in Settings is still there but does nothing, because nothing in the app reads <code class="language-plaintext highlighter-rouge">Bundle.main</code>’s language. A user who changes it there sees nothing happen. Anyone copying this pattern should decide whether to explain that in the app.</p>

<h2 id="the-string-catalog-fact-that-bites-hardest">The String Catalog Fact That Bites Hardest</h2>

<p>The two gates send every string to the right language. What shows up on screen comes from the String Catalog (<code class="language-plaintext highlighter-rouge">Localizable.xcstrings</code>), and its sharpest edge is this: a key with no row for the current language renders the raw key, not the English source value. People assume localization falls back to the source language. It doesn’t. If the Japanese table is missing <code class="language-plaintext highlighter-rouge">"Air Quality"</code>, a Japanese user sees <code class="language-plaintext highlighter-rouge">Air Quality</code> only because that key happens to read like English. A key named <code class="language-plaintext highlighter-rouge">"aqi.title"</code> would put <code class="language-plaintext highlighter-rouge">aqi.title</code> on screen.</p>

<p>That makes the catalog all-or-nothing. A key is either translated in every shipped language or it’s a bug waiting for one user in one language. We enforce that with a test that reads the catalog file directly, so it catches a gap before a build can repackage it:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">import</span> <span class="kt">Testing</span>
<span class="kd">import</span> <span class="kt">Foundation</span>
<span class="kd">@testable</span> <span class="kd">import</span> <span class="kt">HelloWeather</span>

<span class="kd">@Suite</span><span class="p">(</span><span class="s">"String catalog completeness"</span><span class="p">)</span>
<span class="kd">struct</span> <span class="kt">XcstringsCompletenessTests</span> <span class="p">{</span>
    <span class="c1">// Every Language case except the source language.</span>
    <span class="kd">private</span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">requiredLanguages</span> <span class="o">=</span> <span class="kt">Set</span><span class="p">(</span>
        <span class="kt">Language</span><span class="o">.</span><span class="n">allCases</span><span class="o">.</span><span class="nf">map</span><span class="p">(\</span><span class="o">.</span><span class="n">rawValue</span><span class="p">)</span>
    <span class="p">)</span><span class="o">.</span><span class="nf">subtracting</span><span class="p">([</span><span class="s">"en"</span><span class="p">])</span>

    <span class="c1">// Key -&gt; the set of language codes that have a row. Values are discarded.</span>
    <span class="kd">private</span> <span class="kd">func</span> <span class="nf">loadCatalog</span><span class="p">(</span><span class="n">_</span> <span class="nv">name</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</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="kt">Set</span><span class="o">&lt;</span><span class="kt">String</span><span class="o">&gt;</span><span class="p">]</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">url</span> <span class="o">=</span> <span class="kt">URL</span><span class="p">(</span><span class="nv">fileURLWithPath</span><span class="p">:</span> <span class="kd">#file</span><span class="kt">Path</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">deletingLastPathComponent</span><span class="p">()</span>
            <span class="o">.</span><span class="nf">deletingLastPathComponent</span><span class="p">()</span>
            <span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"HelloWeather/HelloWeather/Resources/</span><span class="se">\(</span><span class="n">name</span><span class="se">)</span><span class="s">.xcstrings"</span><span class="p">)</span>
        <span class="k">let</span> <span class="nv">data</span> <span class="o">=</span> <span class="k">try</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">json</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="k">try</span> <span class="kt">JSONSerialization</span><span class="o">.</span><span class="nf">jsonObject</span><span class="p">(</span><span class="nv">with</span><span class="p">:</span> <span class="n">data</span><span class="p">)</span> <span class="k">as?</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">])</span>
        <span class="k">let</span> <span class="nv">strings</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">json</span><span class="p">[</span><span class="s">"strings"</span><span class="p">]</span> <span class="k">as?</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">])</span>

        <span class="k">return</span> <span class="n">strings</span><span class="o">.</span><span class="n">mapValues</span> <span class="p">{</span> <span class="n">entry</span> <span class="k">in</span>
            <span class="k">let</span> <span class="nv">localizations</span> <span class="o">=</span> <span class="p">(</span><span class="n">entry</span> <span class="k">as?</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">])?[</span><span class="s">"localizations"</span><span class="p">]</span> <span class="k">as?</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Any</span><span class="p">]</span> <span class="p">??</span> <span class="p">[:]</span>
            <span class="k">return</span> <span class="kt">Set</span><span class="p">(</span><span class="n">localizations</span><span class="o">.</span><span class="n">keys</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>

    <span class="kd">@Test</span><span class="p">(</span><span class="s">"Localizable keys are fully translated or fully untranslated"</span><span class="p">)</span>
    <span class="kd">func</span> <span class="nf">localizableKeysAreAllOrNothing</span><span class="p">()</span> <span class="k">throws</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">catalog</span> <span class="o">=</span> <span class="k">try</span> <span class="nf">loadCatalog</span><span class="p">(</span><span class="s">"Localizable"</span><span class="p">)</span>

        <span class="k">for</span> <span class="p">(</span><span class="n">key</span><span class="p">,</span> <span class="n">languages</span><span class="p">)</span> <span class="k">in</span> <span class="n">catalog</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">missing</span> <span class="o">=</span> <span class="k">Self</span><span class="o">.</span><span class="n">requiredLanguages</span><span class="o">.</span><span class="nf">subtracting</span><span class="p">(</span><span class="n">languages</span><span class="p">)</span>
            <span class="cp">#expect(missing.isEmpty || missing == Self.requiredLanguages,</span>
                    <span class="s">"</span><span class="se">\"\(</span><span class="n">key</span><span class="se">)\"</span><span class="s"> is partially translated; missing: </span><span class="se">\(</span><span class="n">missing</span><span class="o">.</span><span class="nf">sorted</span><span class="p">()</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>A key must appear in all 26 non-English languages or in none of them. The “none” case is for copy we’ve deliberately held back behind a flag. Any partial set fails. The required set is the <code class="language-plaintext highlighter-rouge">Language</code> enum minus <code class="language-plaintext highlighter-rouge">en</code>, so adding a language to the enum adds it to the test. The loader keeps only the set of language codes under each key and drops the values, because the test asks which languages are <em>present</em>, not what they say.</p>

<h2 id="the-build-prunes-keys-it-cannot-see">The Build Prunes Keys It Cannot See</h2>

<p>The nastiest hazard is in the tooling, because it deletes work silently. A command-line build can regenerate the catalog and remove any key it doesn’t see referenced, along with that key’s translations. We’ve watched a key like <code class="language-plaintext highlighter-rouge">"1 min"</code>, used in a view but referenced in a way the command-line pass didn’t catch, lose all 26 of its translations in one regenerated file.</p>

<p>The defense has two layers. First, a mechanical check before committing the catalog after a build:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git diff <span class="nt">--</span> HelloWeather/HelloWeather/Resources/Localizable.xcstrings <span class="se">\</span>
  | <span class="nb">grep</span> <span class="nt">-c</span> <span class="s1">'^-.*"value"'</span>
</code></pre></div></div>

<p>A non-zero count means translated values are being deleted. The fix is to restore the file, not commit it. Second, a test checks a fixed list of the keys that prune easily, so a regression fails loudly instead of shipping:</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">"Keys with few referents stay translated across catalog regeneration"</span><span class="p">)</span>
<span class="kd">func</span> <span class="nf">sentinelKeysStayTranslated</span><span class="p">()</span> <span class="k">throws</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">catalog</span> <span class="o">=</span> <span class="k">try</span> <span class="nf">loadCatalog</span><span class="p">(</span><span class="s">"Localizable"</span><span class="p">)</span>

    <span class="k">let</span> <span class="nv">sentinels</span> <span class="o">=</span> <span class="p">[</span>
        <span class="s">"1 min"</span><span class="p">,</span> <span class="s">"5 min"</span><span class="p">,</span>
        <span class="s">"Rename"</span><span class="p">,</span> <span class="s">"Custom Name"</span><span class="p">,</span> <span class="s">"Favorite"</span><span class="p">,</span> <span class="s">"Unfavorite"</span><span class="p">,</span>
        <span class="s">"Customize locations"</span><span class="p">,</span>
        <span class="s">"Tap a location's name to rename it, or tap a star to fave. Drag to reorder."</span><span class="p">,</span>
        <span class="s">"Reset Locations Tip"</span><span class="p">,</span>
    <span class="p">]</span>

    <span class="k">for</span> <span class="n">key</span> <span class="k">in</span> <span class="n">sentinels</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">languages</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">catalog</span><span class="p">[</span><span class="n">key</span><span class="p">],</span> <span class="s">"</span><span class="se">\"\(</span><span class="n">key</span><span class="se">)\"</span><span class="s"> pruned from the catalog"</span><span class="p">)</span>
        <span class="cp">#expect(Self.requiredLanguages.subtracting(languages).isEmpty)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The keys in the list have few references in code, which is why they prune easily. The test fails if any of them is missing from the catalog or has lost a language.</p>

<h2 id="results">Results</h2>

<ul>
  <li>One source of truth: a 27-case enum stored as a single string in shared storage, defaulting to English so the soft launch shipped in English until users opted in.</li>
  <li>35 environment-locale pins across the app root, every widget entry view, the watch root, and its complications. That’s the cost of localizing four separate processes instead of one.</li>
  <li>Live switching with no relaunch and no use of <code class="language-plaintext highlighter-rouge">AppleLanguages</code>. The downside we accepted is that the iOS per-app language row for the app does nothing, which the localization plan flags as a support-confusion risk.</li>
  <li>Completeness is held by tests, not discipline: keys are all-or-nothing across 26 languages, and the fixed-list test plus the diff check catch build-time pruning before a commit.</li>
</ul>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Anything that runs as its own process must pin the locale again.</strong> A forgotten pin passes in an English simulator, so every new widget or complication means one more pin to add.</li>
  <li><strong>A localization helper takes the resource type, not a string.</strong> The compiler then extracts literals into the catalog from the helper the same way it does from a view.</li>
  <li><strong>Assume there’s no per-key fallback until you’ve proven otherwise.</strong> A missing row that renders the raw key means completeness has to be tested on every commit.</li>
  <li><strong>Check the diff of any file that both a build and a person write.</strong> Regenerated output can delete work silently, so look for removed values and keep a test on the keys that keep disappearing.</li>
</ul>]]></content><author><name>Trevor Turk</name></author><category term="swift" /><category term="ios" /><category term="localization" /><category term="i18n" /><category term="architecture" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Four Answers to One Question</title><link href="https://trevorturk.github.io/four-answers-to-one-question/" rel="alternate" type="text/html" title="Four Answers to One Question" /><published>2026-08-05T14:50:00+00:00</published><updated>2026-08-05T14:50:00+00:00</updated><id>https://trevorturk.github.io/four-answers-to-one-question</id><content type="html" xml:base="https://trevorturk.github.io/four-answers-to-one-question/"><![CDATA[<h2 id="the-screenshot">The Screenshot</h2>

<p>It started with a screenshot of <a href="https://helloweather.com">Hello Weather</a>. The hourly strip showed a row of 20-25% rain-chance labels, and the “precip later” pill, the small button that announces rain coming later, was nowhere on the screen. Two parts of one screen disagreed about whether it was going to rain.</p>

<p>Neither one was broken. Each was following its own rule for “is this probability worth showing?”, and the codebase had four rules:</p>

<ol>
  <li><strong>Hourly and daily labels</strong> (app, watch, widgets) rounded first and then compared to the floor. A raw 17.5% became “20%” and got a bar.</li>
  <li><strong>The precip-later pill</strong> compared the raw value against a higher floor. Its suppression clause, the check that keeps the pill from covering a rendered bar, also used raw values, so an hour could show a “20%” bar with the pill sitting on top of it.</li>
  <li><strong>One detail card</strong> used a third cutoff, lower than the other two, on the raw value. The card could say “None” while the strip showed a labeled value for the same hour.</li>
  <li><strong>Push notification copy</strong> checked the raw value but printed the rounded one. A value could pass the check and then round down, or fail the check while the same rounded number showed fine elsewhere in the app.</li>
</ol>

<p>(The numbers here, a 5% rounding step and a 20% floor, are ours. Nothing below depends on them.)</p>

<p>None of these were careless. The pill’s higher floor was a deliberate choice to stop advertising marginal rain. The detail card’s cutoff was older than the rounding helper. The push code compared the value it had in hand. The four drifted apart because each one changed on its own and nothing tied them together. Two real bugs had already shipped from this group of thresholds before the screenshot: the raw-vs-rounded mismatch in the suppression clause, and a pill that stopped staying clear of rendered bars after one floor moved and the other didn’t.</p>

<h2 id="the-chokepoint">The Chokepoint</h2>

<p>We could have set the four constants to the same number. That would fix the screenshot and leave the four sites free to drift again the next time one of them changed. Instead we made one place that answers the question:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">enum</span> <span class="kt">PrecipDisplay</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">showPrecip</span><span class="p">(</span><span class="nv">val</span><span class="p">:</span> <span class="kt">Float</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="n">val</span><span class="o">.</span><span class="n">toRoundedPrecip</span> <span class="o">&gt;=</span> <span class="mf">0.2</span>
    <span class="p">}</span>
<span class="p">}</span>

<span class="kd">extension</span> <span class="kt">BinaryFloatingPoint</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">toRoundedPrecip</span><span class="p">:</span> <span class="k">Self</span> <span class="p">{</span>
        <span class="k">self</span><span class="o">.</span><span class="nf">rounded</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="mf">0.05</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The function is pure, lives in a shared extensions file, and compiles into all four targets (app, widgets, watch app, watch extension). It’s called from the hourly and daily labels, bar colors, the pill’s trigger and its headline scan, VoiceOver’s per-hour precipitation clause, push copy, the stat cards’ on/off states and icons, the detail charts’ point icons, and both lock-screen slots. Forty-four call sites, by the PR’s count.</p>

<p>Two choices in that small function do most of the work.</p>

<p>First, every caller passes the raw value and the function rounds it. Callers don’t choose which version to compare, so the raw-vs-rounded bug can’t happen. Rounding an already-rounded value gives the same value back, so it doesn’t matter if a caller’s number has already been through the display formatter. A test checks that instead of assuming 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">agreesForRawAndPreRoundedInputs</span><span class="p">()</span> <span class="p">{</span>
    <span class="k">for</span> <span class="n">raw</span> <span class="k">in</span> <span class="nf">stride</span><span class="p">(</span><span class="nv">from</span><span class="p">:</span> <span class="kt">Float</span><span class="p">(</span><span class="mi">0</span><span class="p">),</span> <span class="nv">through</span><span class="p">:</span> <span class="mi">1</span><span class="p">,</span> <span class="nv">by</span><span class="p">:</span> <span class="mf">0.001</span><span class="p">)</span> <span class="p">{</span>
        <span class="cp">#expect(PrecipDisplay.showPrecip(val: raw) ==</span>
                <span class="kt">PrecipDisplay</span><span class="o">.</span><span class="nf">showPrecip</span><span class="p">(</span><span class="nv">val</span><span class="p">:</span> <span class="n">raw</span><span class="o">.</span><span class="n">toRoundedPrecip</span><span class="p">))</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Boundary tests next to it pin the rounding cliff (0.174 hides, 0.175 shows). The sweep test matters more, because it’s the proof that “pass whatever you have” is safe.</p>

<p>Second, the threshold belongs to a named type instead of being a loose constant. <code class="language-plaintext highlighter-rouge">PrecipDisplay</code> is an enum with no cases. It’s only a namespace. That looks like ceremony until you read the rule that shipped with it:</p>

<blockquote>
  <p>Any future second threshold must land as a named <code class="language-plaintext highlighter-rouge">PrecipDisplay</code> member, never an inline constant at a call site.</p>
</blockquote>

<p>The rule keeps the four from drifting apart again. The drift didn’t start as four thresholds. It started as one threshold and a reasonable-looking inline <code class="language-plaintext highlighter-rouge">0.3</code> at one call site. The rule allows a second threshold, because the product may need one. It doesn’t allow an anonymous one. A named member sits next to its sibling, gets reviewed as a deliberate fork, and can be found by anyone auditing the group. An inline constant at a call site is invisible until it shows up in a screenshot.</p>

<h2 id="writing-the-rule-where-review-will-trip-over-it">Writing the Rule Where Review Will Trip Over It</h2>

<p>A rule that only lives in a commit message lasts until the commit scrolls off the first page of <code class="language-plaintext highlighter-rouge">git log</code>. This one went into the code-review skill, the checklist the review agent walks on every PR, as two rewritten bullets. Here they are as of September 2026, sanitized and trimmed of PR numbers and the geometry caveats. The <code class="language-plaintext highlighter-rouge">significantPrecip</code> clauses were added later; more on that below.</p>

<blockquote>
  <ul class="task-list">
    <li class="task-list-item">
      <p><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />Precip <em>display</em> gates (hourly/daily labels and bar colors, the precip-later button, VoiceOver per-hour precip clauses, push-copy precip lines, precip stat card/detail on-off states and icons) route through <code class="language-plaintext highlighter-rouge">PrecipDisplay.showPrecip</code> (rounded ≥ 0.2) - new or modified display gates must call it, never hardcode a literal - and hourly-strip <em>window</em> decisions (lane, bars, pill) route through <code class="language-plaintext highlighter-rouge">PrecipDisplay.significantPrecip</code>. Deliberate exceptions (relevance thresholds, the chart-summary <code class="language-plaintext highlighter-rouge">&gt; 0</code> accessibility gates, and the frozen style catalogs in all three targets) are inventoried in the unification plan.</p>
    </li>
    <li class="task-list-item">
      <p><input type="checkbox" class="task-list-item-checkbox" disabled="disabled" />The button must never cover a rendered precip bar. Since the unification, bars, labels, and the button share <strong>one per-hour floor</strong>: <code class="language-plaintext highlighter-rouge">PrecipDisplay.showPrecip</code> (rounded ≥ 20%). One <strong>window-significance</strong> predicate sits above it, <code class="language-plaintext highlighter-rouge">PrecipDisplay.significantPrecip</code>, so lane, bars, and button live or die together. Within a significant window the single per-hour floor means “first <code class="language-plaintext highlighter-rouge">showPrecip</code> index ≥ visible-column threshold” alone guarantees no rendered bar sits under the button; in an insignificant window nothing renders at all - there is deliberately no separate suppression clause. <strong>If the per-hour floors ever diverge</strong> (the significance rule is a window shape test, not a second per-hour floor), the divergence must land as a named <code class="language-plaintext highlighter-rouge">PrecipDisplay</code> member and the explicit suppression clause - rounded on BOTH sides; the raw/rounded split shipped a bug once - becomes load-bearing again.</p>
    </li>
  </ul>
</blockquote>

<p>The second bullet says what has to come back if the floors ever split again. With one floor, the pill’s old suppression clause was redundant. “The first qualifying hour is past the visible window” and “no rendered bar in the visible window” mean the same thing when bars and pill use the same test. So we deleted the clause and sketched the proof in the PR. That only holds while there’s one floor. The checklist doesn’t just say we deleted it. It says the clause comes back, rounded on both sides, the day the floors split, so the next person doesn’t ship the old bug again.</p>

<p>We did the same for a decision that got overruled along the way. The designer had proposed two thresholds: forgiving display gates, and a stricter pill that only fires when rain is worth interrupting for. We shipped one uniform floor instead, because it was the simpler rule and we could adjust once we had field evidence. The designer’s version wasn’t thrown away. It’s in the plan doc as the ready alternative, built on a branch, with instructions that bringing it back means a named member and the restored suppression clause. Neither side had field evidence, so the plan lists the two options as equals rather than as default and exception. If we hadn’t written it down, we’d have had the same debate again from scratch in six months.</p>

<p>The evidence arrived at the end of August. A single 20% hour with no icon rendered one bar and a pill, and the owner and designer agreed that was misleading. The problem was the shape of the window, one weak hour on its own, not the floor. The fix landed on 2026-08-31 the way the rule requires, as a named member, <code class="language-plaintext highlighter-rouge">PrecipDisplay.significantPrecip</code>. It’s a window-level test that hides the hourly strip’s lane, bars, and pill when the only qualifying hour in a window of 12 hours or more rounds below 30%. The per-hour floor didn’t change. The labels and cards still gate per hour, and the short lock-screen strips stay on <code class="language-plaintext highlighter-rouge">showPrecip</code> on purpose, with that listed in the plan.</p>

<h2 id="the-feature-that-had-never-rendered">The Feature That Had Never Rendered</h2>

<p>Routing every comparison through one function means reading every comparison, and one of them didn’t make sense.</p>

<p>The rectangular lock-screen widget and the matching watch complication have a slot where, on rainy hours, the precip percentage replaces the temperature. The check for it compared the probability, a fraction from 0 to 1, against a whole-number percent:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// before: precipProbabilityValue is the rounded 0.0-1.0 probability</span>
<span class="k">if</span> <span class="n">precipProbabilityValue</span> <span class="o">&gt;=</span> <span class="mi">20</span> <span class="p">{</span> <span class="o">...</span> <span class="p">}</span>
</code></pre></div></div>

<p>A fraction is never ≥ 20. The check was always false, so the slot had never shown for anyone since the day it shipped. Nobody noticed, because a check that’s always false looks the same as “it hasn’t rained lately.” Routing the site through the chokepoint fixed it, because the chokepoint takes fractions, and the slot rendered for the first time. That turned up one more problem. Localized percent strings like “100 %” overflow a 31-point slot, so the label got a <code class="language-plaintext highlighter-rouge">minimumScaleFactor</code>. A feature that has never rendered has never been through layout QA either.</p>

<p>This is the best practical argument for the chokepoint. A function with one signature also fixes the units. Four scattered comparisons can each pick their own units and each be wrong. Forty-four call sites passing a <code class="language-plaintext highlighter-rouge">Float</code> to one function can’t.</p>

<h2 id="what-deliberately-stayed-out">What Deliberately Stayed Out</h2>

<p>A sweep like this can fail two ways: missing a site, or pulling in a site that only looks like the same question. The second is harder to spot. Not every comparison against a probability is a display gate:</p>

<ul>
  <li><strong>Relevance and ranking thresholds</strong> answer “does this matter right now?”, not “should this value be shown?” The filter that decides whether precip is worth mentioning to an AI summarizer and the watch smart stack’s relevance scores are in this group. They use higher floors on purpose.</li>
  <li><strong>Accessibility chart summaries</strong> check for <code class="language-plaintext highlighter-rouge">&gt; 0</code>, which is wider than the display floor on purpose. A VoiceOver description of a whole chart (“up to N percent”) should describe what the chart plots, and the detail charts plot values below the floor.</li>
  <li><strong>A frozen catalog of old forecast styles</strong> has dozens of per-style thresholds. It sits behind a debug-only setting, and policy says its bytes don’t change. Rewriting frozen code to fit a new convention un-freezes it.</li>
</ul>

<p>Leaving them out is the easy part. Each one is also listed in the plan doc the checklist bullet points at, with the reason it’s a different question. Without that list, a reader can’t tell an exemption from a site the sweep missed.</p>

<h2 id="proving-the-chokepoint-is-actually-complete">Proving the Chokepoint Is Actually Complete</h2>

<p>“We routed everything through one function” is a claim, and the session that did the routing can’t see its own blind spots. So before merge, the change went through our <a href="/adversarial-review-rounds/">adversarial review process</a>: four independent, read-only reviewers, none of them with the session’s reasoning.</p>

<p>One reviewer’s job wasn’t “review this diff.” It was “prove or refute the claim that this is complete”: find every comparison against a probability in the codebase and account for each one. It found four outside the frozen style catalog, the chokepoint plus the three relevance-scoring exemptions from the plan, and nothing else. That’s a list checked against the inventory, not “the diff looks complete.”</p>

<p>That’s a different kind of assurance, and it’s cheap to ask for. The main claim of a unification PR is that no other site answers this question. A diff review can’t check that, because any site that breaks the claim isn’t in the diff. So give one reviewer the whole codebase and that claim as its job.</p>

<p>The other reviewers found real things too. One traced boundary values through every screen and confirmed with predicate math that deleting the suppression clause was safe. It found two bugs, and both were fixed before merge: a gap in how the pill’s headline scan picked its anchor hour, and an off-by-one where the “starting soon” copy named the precip type of the hour after the match (“rain possible in 3h” for what was actually snow). One known gap stayed open. The column counts for wide layouts on tablets and landscape phones are hand-maintained estimates, not measured, and the plans record that as deferred, with measuring them named as the real fix.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Take raw inputs and round inside.</strong> If callers choose which version to compare, some will get it wrong. Add a test that raw and pre-rounded inputs give the same answer.</li>
  <li><strong>Forbid the anonymous threshold, not the second one.</strong> A rule that any new floor must be a named member of one type makes drift visible in review without blocking change.</li>
  <li><strong>One function signature fixes the units.</strong> Scattered comparisons can each be wrong in their own units. Expect a sweep to find at least one that never worked.</li>
  <li><strong>When you delete redundant code, write down what brings it back.</strong> The condition, the form it must take, and the old bug it guards against, in the checklist reviewers read.</li>
  <li><strong>Record the design that lost, as an equal.</strong> A variant set aside without field evidence belongs in the plan as a built alternative, so the next round starts there instead of from scratch.</li>
</ul>]]></content><author><name>Trevor Turk</name></author><category term="swift" /><category term="ios" /><category term="architecture" /><category term="code-review" /><summary type="html"><![CDATA[The Screenshot]]></summary></entry><entry><title type="html">The Body Runs Every Frame</title><link href="https://trevorturk.github.io/the-body-runs-every-frame/" rel="alternate" type="text/html" title="The Body Runs Every Frame" /><published>2026-08-05T14:30:00+00:00</published><updated>2026-08-05T14:30:00+00:00</updated><id>https://trevorturk.github.io/the-body-runs-every-frame</id><content type="html" xml:base="https://trevorturk.github.io/the-body-runs-every-frame/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>The <a href="https://helloweather.com">Hello Weather</a> watch app has an hourly strip: 24 columns, one per hour, that you drag sideways. On a real watch the drag was choppy. While you drag, SwiftUI re-runs the view’s <code class="language-plaintext highlighter-rouge">body</code> every frame, and the work inside our body was repeated for every one of the 24 columns. The same thing can happen in almost any SwiftUI list.</p>

<p>The drag is a <code class="language-plaintext highlighter-rouge">@GestureState</code> that feeds an <code class="language-plaintext highlighter-rouge">.offset</code> through a computed property, so every touch event re-runs the whole <code class="language-plaintext highlighter-rouge">body</code>. A computed property in Swift is a function that looks like a variable: it runs its code every time you read it. Inside the body is a <code class="language-plaintext highlighter-rouge">ForEach</code> over the 24 hours, and inside the <code class="language-plaintext highlighter-rouge">ForEach</code> are reads of computed properties whose values never change during the loop:</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c1">// In a shared extension - every one of these is a computed property.</span>
<span class="kd">extension</span> <span class="kt">HourlyView</span> <span class="p">{</span>
    <span class="k">var</span> <span class="nv">hourlyData</span><span class="p">:</span> <span class="p">[</span><span class="kt">Forecast</span><span class="o">.</span><span class="kt">Hourly</span><span class="o">.</span><span class="kt">Hour</span><span class="p">]</span> <span class="p">{</span>
        <span class="n">viewModel</span><span class="o">.</span><span class="n">weather</span><span class="p">?</span><span class="o">.</span><span class="n">forecast</span><span class="p">?</span><span class="o">.</span><span class="n">hourly</span><span class="p">?</span><span class="o">.</span><span class="n">data</span> <span class="p">??</span> <span class="kt">Forecast</span><span class="o">.</span><span class="kt">Fallback</span><span class="o">.</span><span class="n">hourlyData</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="nv">hourly</span><span class="p">:</span> <span class="p">[</span><span class="kt">Forecast</span><span class="o">.</span><span class="kt">Hourly</span><span class="o">.</span><span class="kt">Hour</span><span class="p">]</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">safeCount</span> <span class="o">=</span> <span class="nf">min</span><span class="p">(</span><span class="n">hourCount</span><span class="p">,</span> <span class="n">hourlyData</span><span class="o">.</span><span class="n">count</span><span class="p">)</span>
        <span class="k">return</span> <span class="kt">Array</span><span class="p">(</span><span class="n">hourlyData</span><span class="o">.</span><span class="nf">prefix</span><span class="p">(</span><span class="n">safeCount</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="k">var</span> <span class="nv">maxHourlyTemp</span><span class="p">:</span> <span class="kt">Int</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">safeCount</span> <span class="o">=</span> <span class="nf">min</span><span class="p">(</span><span class="n">hourCount</span> <span class="o">+</span> <span class="mi">2</span><span class="p">,</span> <span class="n">hourlyData</span><span class="o">.</span><span class="n">count</span><span class="p">)</span>

        <span class="k">if</span> <span class="kt">SettingsManager</span><span class="o">.</span><span class="n">shared</span><span class="o">.</span><span class="n">feelsLikeMode</span> <span class="p">{</span>
            <span class="nf">return</span> <span class="p">(</span><span class="n">hourlyData</span><span class="o">.</span><span class="nf">prefix</span><span class="p">(</span><span class="n">safeCount</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="n">apparentTemperature</span> <span class="p">??</span> <span class="nv">$0</span><span class="o">.</span><span class="n">temperature</span> <span class="p">??</span> <span class="mi">0</span> <span class="p">}</span><span class="o">.</span><span class="nf">max</span><span class="p">()</span> <span class="p">??</span> <span class="mi">0</span><span class="p">)</span><span class="o">.</span><span class="n">toInt</span>
        <span class="p">}</span> <span class="k">else</span> <span class="p">{</span>
            <span class="nf">return</span> <span class="p">(</span><span class="n">hourlyData</span><span class="o">.</span><span class="nf">prefix</span><span class="p">(</span><span class="n">safeCount</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="n">temperature</span> <span class="p">??</span> <span class="mi">0</span> <span class="p">}</span><span class="o">.</span><span class="nf">max</span><span class="p">()</span> <span class="p">??</span> <span class="mi">0</span><span class="p">)</span><span class="o">.</span><span class="n">toInt</span>
        <span class="p">}</span>
    <span class="p">}</span>
    <span class="c1">// ...minHourlyTemp, same shape</span>

    <span class="k">var</span> <span class="nv">showHourlyPrecips</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="n">hourly</span><span class="o">.</span><span class="n">contains</span> <span class="p">{</span> <span class="nf">showPrecip</span><span class="p">(</span><span class="nv">val</span><span class="p">:</span> <span class="nv">$0</span><span class="o">.</span><span class="n">precipProbability</span> <span class="p">??</span> <span class="mi">0</span><span class="p">)</span> <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Each one looks harmless on its own, but they add up. <code class="language-plaintext highlighter-rouge">hourly[index]</code> builds a fresh array for every subscript. Each hour column reads <code class="language-plaintext highlighter-rouge">maxHourlyTemp</code>, <code class="language-plaintext highlighter-rouge">minHourlyTemp</code>, and <code class="language-plaintext highlighter-rouge">showHourlyPrecips</code>, and each of those takes a prefix of the data and maps over it. The check for a day boundary between two neighboring hours built a new <code class="language-plaintext highlighter-rouge">Calendar</code> for each comparison, 23 of them for 24 hours. That comes to <strong>roughly 142 array allocations and 23 <code class="language-plaintext highlighter-rouge">Calendar</code> constructions per body evaluation</strong>. During a drag, that means per frame, on a watch.</p>

<p>That’s four years of “add a computed var to the shared extension” being the easiest way to add a value. Nobody notices until a gesture makes the body run constantly.</p>

<h2 id="the-fix-is-a-let">The Fix Is a <code class="language-plaintext highlighter-rouge">let</code></h2>

<p>SwiftUI doesn’t cache a computed property. It runs the property’s code every time it’s read. A <code class="language-plaintext highlighter-rouge">let</code> at the top of a <code class="language-plaintext highlighter-rouge">@ViewBuilder</code> runs once per body pass. So the fix is to read each unchanging value once, above the loop, into a <code class="language-plaintext highlighter-rouge">let</code>. Moving work out of a loop like this is called hoisting, and that’s the word the rest of this post uses.</p>

<div class="language-swift highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">private</span> <span class="k">var</span> <span class="nv">hourlyChart</span><span class="p">:</span> <span class="kd">some</span> <span class="kt">View</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">hours</span> <span class="o">=</span> <span class="n">hourly</span>
    <span class="k">let</span> <span class="nv">maxTemp</span> <span class="o">=</span> <span class="n">maxHourlyTemp</span>
    <span class="k">let</span> <span class="nv">minTemp</span> <span class="o">=</span> <span class="n">minHourlyTemp</span>
    <span class="k">let</span> <span class="nv">showPrecips</span> <span class="o">=</span> <span class="n">showHourlyPrecips</span>
    <span class="k">let</span> <span class="nv">dayCalendar</span> <span class="o">=</span> <span class="n">settingsManager</span><span class="o">.</span><span class="n">hourlyIsGrouped</span> <span class="p">?</span> <span class="n">viewModel</span><span class="o">.</span><span class="n">weather</span><span class="p">?</span><span class="o">.</span><span class="n">forecast</span><span class="p">?</span><span class="o">.</span><span class="nv">currentCalendar</span> <span class="p">:</span> <span class="kc">nil</span>

    <span class="k">return</span> <span class="kt">HStack</span><span class="p">(</span><span class="nv">spacing</span><span class="p">:</span> <span class="mi">2</span><span class="p">)</span> <span class="p">{</span>
        <span class="kt">ForEach</span><span class="p">(</span><span class="n">hours</span><span class="o">.</span><span class="n">indices</span><span class="p">,</span> <span class="nv">id</span><span class="p">:</span> <span class="p">\</span><span class="o">.</span><span class="k">self</span><span class="p">)</span> <span class="p">{</span> <span class="n">index</span> <span class="k">in</span>
            <span class="k">let</span> <span class="nv">hour</span> <span class="o">=</span> <span class="n">hours</span><span class="p">[</span><span class="n">index</span><span class="p">]</span>

            <span class="k">if</span> <span class="n">index</span> <span class="o">&gt;</span> <span class="mi">0</span><span class="p">,</span>
               <span class="k">let</span> <span class="nv">calendar</span> <span class="o">=</span> <span class="n">dayCalendar</span><span class="p">,</span>
               <span class="k">let</span> <span class="nv">currentTime</span> <span class="o">=</span> <span class="n">hour</span><span class="o">.</span><span class="n">time</span><span class="p">,</span>
               <span class="k">let</span> <span class="nv">previousTime</span> <span class="o">=</span> <span class="n">hours</span><span class="p">[</span><span class="n">index</span> <span class="o">-</span> <span class="mi">1</span><span class="p">]</span><span class="o">.</span><span class="n">time</span><span class="p">,</span>
               <span class="n">calendar</span><span class="o">.</span><span class="nf">startOfDay</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">currentTime</span><span class="p">)</span> <span class="o">!=</span> <span class="n">calendar</span><span class="o">.</span><span class="nf">startOfDay</span><span class="p">(</span><span class="nv">for</span><span class="p">:</span> <span class="n">previousTime</span><span class="p">)</span> <span class="p">{</span>
                <span class="c1">// Simplified: the real separator sits in a GeometryReader that pins it mid-glide.</span>
                <span class="kt">HourlyDaySeparatorView</span><span class="p">(</span><span class="nv">day</span><span class="p">:</span> <span class="n">currentTime</span><span class="p">)</span>
            <span class="p">}</span>

            <span class="kt">Hour</span><span class="p">(</span><span class="nv">max</span><span class="p">:</span> <span class="n">maxTemp</span><span class="p">,</span> <span class="nv">min</span><span class="p">:</span> <span class="n">minTemp</span><span class="p">,</span> <span class="nv">bar</span><span class="p">:</span> <span class="n">hour</span><span class="p">,</span> <span class="nv">showPrecips</span><span class="p">:</span> <span class="n">showPrecips</span><span class="p">)</span>
        <span class="p">}</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>Now each body pass does one array, one max, one min, one precipitation scan, and one calendar. Two details caught us:</p>

<ul>
  <li>The snapshots use new names (<code class="language-plaintext highlighter-rouge">hours = hourly</code>), not Swift’s shorthand for reusing a name. <code class="language-plaintext highlighter-rouge">let x = x</code> compiles inside <code class="language-plaintext highlighter-rouge">if let</code>, but not at declaration scope. Use a distinct name or <code class="language-plaintext highlighter-rouge">self.x</code>.</li>
  <li>Hoisting doesn’t break SwiftUI’s change tracking. SwiftUI re-runs the body when an observed object changes (<code class="language-plaintext highlighter-rouge">@ObservedObject</code>, <code class="language-plaintext highlighter-rouge">@EnvironmentObject</code>), not when an individual property changes. When the view model changes, the body re-runs and takes fresh snapshots.</li>
</ul>

<p>That was one <code class="language-plaintext highlighter-rouge">ForEach</code> and one afternoon. Then three agents audited the app, watch, and widget targets for the same pattern and found it in <strong>51 files</strong>. It looked the same everywhere: settings pickers rebuilding a dictionary of localized names for every option, a radar legend measuring label widths for every label on every animation frame, a locations list decoding JSON three times per row, stats charts recomputing their ranges for every data point.</p>

<h2 id="hoisting-rules-learned-the-hard-way">Hoisting Rules, Learned the Hard Way</h2>

<p>The sweep that fixed all 51 files went through two review rounds with seven independent reviewers, following <a href="/adversarial-review-rounds/">/adversarial-review-rounds/</a>. Their confirmed findings turned “hoist the invariants” from an instinct into rules. Each rule below exists because the obvious hoist was wrong somewhere.</p>

<p><strong>Hoist only what never changes in the loop AND is always evaluated.</strong> Those are two separate tests. A sparkline view read its precipitation range only behind <code class="language-plaintext highlighter-rouge">if showsCurve</code>. Hoisting that read above the guard made it run for every daily row, including rows where the server’s <code class="language-plaintext highlighter-rouge">min...max</code> bounds could be backwards, and a backwards <code class="language-plaintext highlighter-rouge">ClosedRange</code> crashes when it’s built. If the original read sat behind a condition, the hoist keeps that condition, with placeholder values for the other branch, or it doesn’t happen.</p>

<p><strong>Hoist INTO deferred builders, never out of them.</strong> The content closures of <code class="language-plaintext highlighter-rouge">Menu</code> and <code class="language-plaintext highlighter-rouge">Picker</code> don’t run when the body runs. They run when the menu opens. A names dictionary hoisted from inside a <code class="language-plaintext highlighter-rouge">Menu</code> builder up to body scope changes what the user sees: they now get a snapshot from whenever the body last ran, not from when they opened the menu. The second review round moved one of ours back inside each menu builder for this reason. The other direction is safe. Work already inside a deferred builder that runs once per option can move to the top of that builder, and it still costs nothing until the menu opens.</p>

<p><strong>Leave <code class="language-plaintext highlighter-rouge">onAppear</code> and action-closure reads alone.</strong> Same rule, other direction. Those closures run after the body, so they need to see the world as it is when they fire, not a snapshot from body time.</p>

<p><strong>Pass optionals through. Don’t replace them with <code class="language-plaintext highlighter-rouge">?? ""</code>.</strong> Several hoists turned force-unwraps and dictionary lookups into parameters. Where the receiving view’s parameter is <code class="language-plaintext highlighter-rouge">Optional</code>, pass the optional. With <code class="language-plaintext highlighter-rouge">nil</code>, SwiftUI skips the subview entirely. With <code class="language-plaintext highlighter-rouge">""</code>, it renders an empty view that still takes up layout space. Those are different view trees, so a diff that “just adds a fallback” changes what’s on screen.</p>

<p><strong>Harden server-fed ranges while you’re there.</strong> Any <code class="language-plaintext highlighter-rouge">min...max</code> built from network data gets clamped (<code class="language-plaintext highlighter-rouge">lower...max(lower, upper)</code>), and any <code class="language-plaintext highlighter-rouge">stride</code> gets a <code class="language-plaintext highlighter-rouge">&gt; 0</code> step guard. Review found a real stride-by-zero crash sitting next to one of the hoists.</p>

<p>So the “mechanical” sweep wasn’t mechanical. Every one of those findings came from a reviewer who had no reason to want it to be simple.</p>

<h2 id="green-everywhere-merged-nowhere">Green Everywhere, Merged Nowhere</h2>

<p>By every check we had, the sweep was done. Build and unit tests were green at every commit. It had been through two review rounds, and every finding was settled and fixed. It was 51 files applying one pattern. We closed it anyway.</p>

<p>Nothing was wrong with it. But <strong>51 files is more than a person can review with confidence.</strong> Each review round had found new issues, which is good evidence that another round would too. “All checks passed” only measures what the checks measure. What matters at merge time is whether a human can vouch for the diff. For 51 files that are 90% mechanical and 10% judgment, we couldn’t, because the 10% hides in the 90%.</p>

<p>So the sweep became a <strong>patch source</strong> instead of a merge candidate. We closed it, kept its branch on origin under a stable name, and wrote a plan that split it into 11 slices. Most slices were one to four files. The stats-chart and settings slices ran to eight and twelve. The slices were ordered, and each came with notes for whoever implements it. Two slices change behavior on purpose: the range clamping above, and a rewrite that stops force-unwrapping a nullable timestamp, so a missing time skips a separator instead of crashing. Both are flagged in the plan and <strong>must say so in their PR descriptions</strong>. Each slice either changes no behavior or says exactly what it changes.</p>

<p>The command that pulls a slice out matters more than it looks:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>git diff origin/main...origin/fix/foreach-hoisting -- &lt;this slice's paths&gt;
</code></pre></div></div>

<p>That takes the <strong>latest</strong> state of each file from the banked branch, never the original sweep commit alone. The review-fix commits sit on top of the sweep, so the first commit by itself still has the bugs the reviewers caught. And because <code class="language-plaintext highlighter-rouge">main</code> keeps moving while slices land one at a time, each slice re-checks its files against fresh <code class="language-plaintext highlighter-rouge">main</code> before applying. Each slice PR cites the banked branch and the reviews it already passed, so reviewing a slice is cheap: the reviewer checks that the copy is faithful and the slice stands on its own, and doesn’t re-argue the pattern.</p>

<h2 id="landed-so-far">Landed So Far</h2>

<p><strong>The watch strip</strong> went first, pulled out of the slice order because it blocked a release. One file, copied unchanged from the banked branch; we checked the branch history to make sure the review-fix commits never touched it. Its PR states the expectation up front: dragging with a finger improves a lot, but the momentum glide after you let go stays juddery. The deceleration loop updates state from a <code class="language-plaintext highlighter-rouge">Task.sleep(16ms)</code> loop, and watchOS has no display link to line those ticks up with the screen refresh, so cheaper frames can’t fix uneven presentation. The teammate who reported the choppiness wrote that expectation down before the hands-on device check. The check decides whether a separate paged-navigation rewrite (also built, also banked) ships or closes.</p>

<p><strong>The widget strips</strong> went second: pure hoists in the hourly and minutely widget views, where body cost matters most because widgets render under a time limit. A read-only mini-review returned zero findings and one good nit. With the hoists, an empty data array now evaluates the three getters once instead of zero times. That’s safe, and it’s exactly the kind of edge case a per-slice review can hold in its head.</p>

<p><strong>Since then (as of September 2026).</strong> Slice 3 landed the night before this post. The remaining slices (a twelfth was added when an audit of the banked diff found two minutely views unassigned) all landed the next day, each with a read-only mini-review posted to its PR description. We deleted the plan once the last slice merged. The paged-navigation rewrite was closed after a bake-off on real hardware. Instead, we replaced the momentum loop with a single spring retarget, the smallest change we could make on top of shipped code, and the strip shipped after hardware QA on TestFlight builds.</p>

<h2 id="the-audit-found-more-than-the-sweep-fixed">The Audit Found More Than the Sweep Fixed</h2>

<p>The same audit produced a Phase-2 list that the sweep leaves out on purpose, because these need design work rather than a <code class="language-plaintext highlighter-rouge">let</code>:</p>

<ul>
  <li><strong>Per-row calendar chains.</strong> Each hour column formats its timestamp by resolving a calendar and scanning the daily data for sun events. That’s about 190 <code class="language-plaintext highlighter-rouge">Calendar</code> constructions and 72 linear scans per strip body evaluation, in every target that shares the row view. The fix is a calendar and a sun-event map computed once by the parent and passed down, which touches shared view APIs.</li>
  <li><strong>An O(24²) lookup.</strong> A forecast helper builds its day view with <code class="language-plaintext highlighter-rouge">allHoursInDay.map { first(where:) }</code>, a linear search per hour. It needs a dictionary keyed by <code class="language-plaintext highlighter-rouge">Date</code>.</li>
  <li><strong>The root multiplier.</strong> The localization helper builds a fresh <code class="language-plaintext highlighter-rouge">UserDefaults(suiteName:)</code> and <code class="language-plaintext highlighter-rouge">Locale</code> on <em>every string lookup</em>. Every “rebuilds the names dictionary per row” finding in the sweep was this cost times N. The plan flagged it as needing a cache that clears when the language changes, in its own careful PR.</li>
</ul>

<p>Two of the three shipped the afternoon this post went up. The lookup became a <code class="language-plaintext highlighter-rouge">Date</code>-keyed dictionary with a contract test. The localization fix skipped cache invalidation entirely: it reads the language setting through a store that’s already shared on every call, and caches only the table from identifier to <code class="language-plaintext highlighter-rouge">Locale</code>, which never changes. We parked the calendar chains. A re-check found that the app’s hourly views re-run body about a dozen times per fling, not once per frame, so that cost is about a millisecond, and not often. It now waits for a profile trace or a user report rather than a count of operations from reading the code.</p>

<p>The sweep fixed the call sites, and the audit found the systems behind them. Hoisting removes repeated work at each site. The bigger win is making the expensive thing cheap once, so every call site gets it.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>SwiftUI won’t cache a computed property for you.</strong> A computed property is a function. A <code class="language-plaintext highlighter-rouge">let</code> at the top of the builder is the cache, and it’s refreshed on every body pass.</li>
  <li><strong>Audit for the pattern, not the one instance.</strong> One choppy drag became 51 files across three targets, plus a Phase-2 list of structural fixes that no single-site patch would have found.</li>
  <li><strong>Green and reviewed isn’t the same as reviewable.</strong> If every review round finds new issues, the change is too big to review. Split it.</li>
  <li><strong>A slice that changes behavior says so.</strong> Splitting a “mechanical” change takes away the cover that word gave it. Any slice with a behavior change says so in its PR description, or the split didn’t help.</li>
  <li><strong>Write the expected outcome before the hardware check.</strong> “Tracking improves, judder remains”, recorded in advance, makes the device check a real test instead of an impression.</li>
</ul>]]></content><author><name>Trevor Turk</name></author><category term="swiftui" /><category term="ios" /><category term="performance" /><category term="workflow" /><summary type="html"><![CDATA[The Problem]]></summary></entry><entry><title type="html">Port the Ergonomics, Not the Library</title><link href="https://trevorturk.github.io/port-the-ergonomics-not-the-library/" rel="alternate" type="text/html" title="Port the Ergonomics, Not the Library" /><published>2026-08-05T14:10:00+00:00</published><updated>2026-08-05T14:10:00+00:00</updated><id>https://trevorturk.github.io/port-the-ergonomics-not-the-library</id><content type="html" xml:base="https://trevorturk.github.io/port-the-ergonomics-not-the-library/"><![CDATA[<h2 id="the-problem">The Problem</h2>

<p>A snapshot test records a piece of output the first time it runs and fails whenever that output changes. The <a href="https://helloweather.com">Hello Weather</a> iOS repo had two systems that worked this way, both hand-built for one job. One was a golden-table recorder: it rewrites a committed Swift file with every language x date-format combination. The other compared reports and printed a diff on failure, on a refactor branch. Each one is its own recorder plus its own tests. If we wanted to snapshot something new, a sync payload or a widget timeline, we’d have to build a third.</p>

<p>Our Ruby web repo has had the opposite experience for years. A ~170-line gem (<code class="language-plaintext highlighter-rouge">minitest-snapshots</code>) plus a few house conventions produced about 90 committed snapshot files across nine test suites. Most of them cover things nobody would have built a recorder for. When asserting “this output stays exactly like this” takes one line, people snapshot everything worth snapshotting. When it takes a recorder class, a file-naming decision, and a regeneration flag, they write the one snapshot test that’s clearly worth the trouble and skip the rest.</p>

<p>So we designed a port. Not the gem, not a Swift package, and not a dependency on the well-known Swift snapshot-testing library. We wrote one ~100-line helper file in the test target, because everything risky already worked somewhere in the repo and the only missing piece was the convenience layer.</p>

<p>One caveat up front: this post was written as a design record, not a shipping report. The decision and the full design landed as a plans-only PR (iOS #1484, 2026-08-04). We queued the implementation behind a refactor that was already in flight, so it wouldn’t add moving parts to work that was blocking a release. The queue was short. The helper shipped two days later (iOS #1514, 2026-08-06) with its first adopter, and as of 2026-09-01 it holds 111 snapshot files across six suites. The code below is the helper as it landed, which is the plan’s code with two <code class="language-plaintext highlighter-rouge">Verify:</code> flags resolved. The decision is still the part you can reuse.</p>

<h2 id="the-origin-what-makes-the-ruby-setup-work">The Origin: What Makes the Ruby Setup Work</h2>

<p>The gem is simple. <code class="language-plaintext highlighter-rouge">assert_matches_snapshot value</code> compares the value against <code class="language-plaintext highlighter-rouge">test/snapshots/&lt;suite&gt;/&lt;test&gt;__&lt;n&gt;.snap.yaml</code>. The file is created on the first run, and the number goes up with each call inside a test. <code class="language-plaintext highlighter-rouge">rails test --update-snapshots</code> overwrites everything. Under <code class="language-plaintext highlighter-rouge">ENV["CI"]</code> a missing snapshot is a failure, not a new file, so CI can’t quietly bless one.</p>

<p>Three things fall out of that, and they’re why the tool gets used:</p>

<ol>
  <li><strong>One-line assertion.</strong> Writing the test costs one line. There’s no file to create and no name to invent.</li>
  <li><strong>Automatic naming.</strong> The file path comes from the suite and test names. Nobody decides where a snapshot lives.</li>
  <li><strong>One-flag update.</strong> One command re-records everything the run touches, and then you review the git diff. A behavior change gets reviewed by reading the snapshot diff, the same way a copy change does.</li>
</ol>

<p>Everything else in the web repo is convention built on top of that. Two of those conventions carry over on their own.</p>

<h3 id="snapshot-the-summary-not-the-payload">Snapshot the summary, not the payload</h3>

<p>The most common way to misuse snapshot testing is to freeze a raw payload. That gives you a wall of JSON nobody can review, and every diff is noise. The web repo does the opposite. What it snapshots is usually a readable summary, built to be diffed.</p>

<p>The best example is the weather-data adapters. Each adapter’s output is snapshotted as a table that compares it, field by field, against a reference adapter:</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>+-------------------------------+----------------------+----------------------+
|                               | Adapter Under Test   | Reference            |
+-------------------------------+----------------------+----------------------+
|                      timezone | America/Chicago      | America/Chicago      |
|         currently.temperature | 50.95                | 47.0                 |
|                currently.icon | cloudy               | cloudy               |
|            currently.humidity | 0.5                  | 0.61                 |
|         currently.windBearing | 120                  | 90                   |
</code></pre></div></div>

<p>A reviewer scanning that diff can see whether a parser change moved a field, dropped one, or drifted from the reference. A diff of raw vendor JSON tells you none of that.</p>

<p>The same idea shows up at other layers. SQL behavior is snapshotted as the list of statements the block ran, with numbers replaced by <code class="language-plaintext highlighter-rouge">?</code> and comments stripped. The snapshot captures the shape and count of the queries, not values that change from run to run:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">def</span> <span class="nf">assert_sql</span><span class="p">(</span><span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>
  <span class="n">sql</span> <span class="o">=</span> <span class="p">[]</span>

  <span class="n">subscriber</span> <span class="o">=</span> <span class="o">-&gt;</span><span class="p">(</span><span class="n">_name</span><span class="p">,</span> <span class="n">_start</span><span class="p">,</span> <span class="n">_finish</span><span class="p">,</span> <span class="n">_id</span><span class="p">,</span> <span class="n">payload</span><span class="p">)</span> <span class="k">do</span>
    <span class="n">sql</span> <span class="o">&lt;&lt;</span> <span class="n">payload</span><span class="p">[</span><span class="ss">:sql</span><span class="p">].</span><span class="nf">split</span><span class="p">(</span><span class="s2">"/*"</span><span class="p">).</span><span class="nf">first</span><span class="p">.</span><span class="nf">gsub</span><span class="p">(</span><span class="sr">/\d+/</span><span class="p">,</span> <span class="s2">"?"</span><span class="p">)</span>
  <span class="k">end</span>

  <span class="no">ActiveSupport</span><span class="o">::</span><span class="no">Notifications</span><span class="p">.</span><span class="nf">subscribed</span><span class="p">(</span><span class="n">subscriber</span><span class="p">,</span> <span class="s2">"sql.active_record"</span><span class="p">,</span> <span class="o">&amp;</span><span class="n">block</span><span class="p">)</span>

  <span class="n">assert_matches_snapshot</span> <span class="n">sql</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="s2">"</span><span class="se">\n</span><span class="s2">"</span><span class="p">)</span> <span class="o">+</span> <span class="s2">"</span><span class="se">\n</span><span class="s2">"</span>
<span class="k">end</span>
</code></pre></div></div>

<p>HTTP concurrency is snapshotted as two numbers: how many requests ran serially and how many in parallel, counted by a spy and saved as a two-key YAML hash. A request counts as serial if it finished on the same fiber as the one before it. A change that accidentally makes a parallel fetch serial fails a test with a two-line diff.</p>

<p>In each case the work goes into the code that builds the summary, and <code class="language-plaintext highlighter-rouge">assert_matches_snapshot</code> costs nothing. The split only works because the assertion is cheap.</p>

<h3 id="coverage-by-metaprogramming">Coverage by metaprogramming</h3>

<p>Because the assertion is one line, generating tests in a loop is cheap. The source smoke suite loops over every active data source and every unit system it supports, and defines a snapshot test for each combination:</p>

<div class="language-ruby highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="no">Api</span><span class="o">::</span><span class="no">Weather</span><span class="o">::</span><span class="no">ACTIVE_SOURCES</span><span class="p">.</span><span class="nf">excluding</span><span class="p">(</span><span class="no">REFERENCE_SOURCE</span><span class="p">).</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">source</span><span class="o">|</span>
  <span class="n">define_method</span> <span class="s2">"test_compare_</span><span class="si">#{</span><span class="n">source</span><span class="si">}</span><span class="s2">_to_reference"</span> <span class="k">do</span>
    <span class="n">vcr_use_cassette</span><span class="p">(</span><span class="s2">"smoke_test_</span><span class="si">#{</span><span class="n">source</span><span class="si">}</span><span class="s2">_us_forecast"</span><span class="p">)</span> <span class="k">do</span>
      <span class="n">table</span> <span class="o">=</span> <span class="no">Api</span><span class="o">::</span><span class="no">Table</span><span class="p">.</span><span class="nf">new</span><span class="p">(</span><span class="ss">sources: </span><span class="p">[</span><span class="n">source</span><span class="p">,</span> <span class="no">REFERENCE_SOURCE</span><span class="p">])</span>
      <span class="n">assert_matches_snapshot</span> <span class="n">table</span><span class="p">.</span><span class="nf">pack</span><span class="p">.</span><span class="nf">to_s</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>

<span class="no">Api</span><span class="o">::</span><span class="no">Weather</span><span class="o">::</span><span class="no">ACTIVE_SOURCES</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">source</span><span class="o">|</span>
  <span class="no">Api</span><span class="o">::</span><span class="no">Weather</span><span class="p">.</span><span class="nf">source_class</span><span class="p">(</span><span class="n">source</span><span class="p">).</span><span class="nf">supported_source_units</span><span class="p">.</span><span class="nf">each</span> <span class="k">do</span> <span class="o">|</span><span class="n">units</span><span class="o">|</span>
    <span class="n">define_method</span><span class="p">(</span><span class="s2">"test_</span><span class="si">#{</span><span class="n">source</span><span class="si">}</span><span class="s2">_</span><span class="si">#{</span><span class="n">units</span><span class="si">}</span><span class="s2">"</span><span class="p">)</span> <span class="k">do</span>
      <span class="c1"># ...build the output table for this source + units...</span>
      <span class="n">assert_matches_snapshot</span> <span class="n">table</span><span class="p">.</span><span class="nf">pack</span><span class="p">.</span><span class="nf">to_s</span>
    <span class="k">end</span>
  <span class="k">end</span>
<span class="k">end</span>
</code></pre></div></div>

<p>Adding a new data source adds its comparison test, its per-unit-system output tests, and its request-count tests. The loop picks it up and the first run records the snapshots. Nobody writes tests for a new source. They review what the loop recorded.</p>

<h3 id="two-safety-rules">Two safety rules</h3>

<p>Snapshot suites go wrong in two ways over time, and the web repo has a written rule for each:</p>

<ul>
  <li><strong>English-only snapshots hide non-English drift.</strong> A localized output that’s only asserted in <code class="language-plaintext highlighter-rouge">en</code> keeps passing while every other language regresses. When you snapshot localized content, assert a non-English language too.</li>
  <li><strong>A fix whose diff is mostly snapshot churn is suspicious.</strong> The review checklist flags any PR where the snapshot changes outweigh the code change. Anything that changes what goes over the wire has to be the point of the PR, not a side effect. If regenerating snapshots changed a hundred lines for a one-line fix, either the fix is bigger than it claims or the snapshots are freezing the wrong level of detail.</li>
</ul>

<h2 id="the-decision-no-package-no-dependency">The Decision: No Package, No Dependency</h2>

<p>The obvious move was to adopt the well-known Swift snapshot-testing library. We decided against it. Nothing is wrong with it, but when we audited the repo, every risky mechanic already worked somewhere in-house. The library does a lot we don’t need for text snapshots, like image strategies and a trait system. It would also have been the first package ever linked into the test target, and one more entry in the monthly dependency-update cycle. If we ever want image or SwiftUI-view snapshots, the library’s core module is the upgrade path.</p>

<p>The audit is the part to copy, because “can our simulator tests even do this?” is the question that usually pushes a team toward a dependency. Three mechanics, and three places they were already proven:</p>

<ol>
  <li><strong>Simulator tests can write to the source checkout.</strong> The golden recorder already resolves <code class="language-plaintext highlighter-rouge">URL(fileURLWithPath: #filePath)</code> and rewrites a committed Swift file in place, from a simulator test hosted in the app. The simulator shares the Mac’s filesystem, so <code class="language-plaintext highlighter-rouge">#filePath</code> in a test file is a real, writable path into the repo.</li>
  <li><strong>Environment flags reach the test process.</strong> <code class="language-plaintext highlighter-rouge">xcodebuild</code> doesn’t forward arbitrary environment variables to tests. It forwards only the ones prefixed <code class="language-plaintext highlighter-rouge">TEST_RUNNER_</code>, and strips the prefix. The repo’s <code class="language-plaintext highlighter-rouge">bin/unit-test</code> already passes the golden-record flag through this way.</li>
  <li><strong>A readable failure message already existed</strong> on a refactor branch. It printed the first eight differing lines through <code class="language-plaintext highlighter-rouge">Issue.record</code>, named the file, and gave the regenerate command. We lifted it.</li>
</ol>

<p>With all three proven, the only thing missing was the convenience layer, which is ~100 lines. We ported the three things that make the tool get used instead of importing a library for mechanics we already had.</p>

<h3 id="naming-match-the-origin-literally">Naming: match the origin literally</h3>

<p>The new names match the Ruby ones: <code class="language-plaintext highlighter-rouge">--update-snapshots</code> for the flag, <code class="language-plaintext highlighter-rouge">UPDATE_SNAPSHOTS</code> for the env var, <code class="language-plaintext highlighter-rouge">assertMatchesSnapshot</code> for the helper, and a <code class="language-plaintext highlighter-rouge">snapshots/</code> directory like the web repo’s <code class="language-plaintext highlighter-rouge">test/snapshots/</code>. The repo’s existing env flags carry an app-specific prefix, and we decided not to put that prefix on the new names just to match. When two codebases share a convention, a person or an agent moving between them should find the same words.</p>

<h2 id="the-design">The Design</h2>

<p>The whole helper is one file in the test target. Here it is as it landed, lightly trimmed:</p>

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

<span class="kd">enum</span> <span class="kt">Snapshots</span> <span class="p">{</span>
    <span class="kd">static</span> <span class="k">let</span> <span class="nv">directory</span> <span class="o">=</span> <span class="kt">URL</span><span class="p">(</span><span class="nv">fileURLWithPath</span><span class="p">:</span> <span class="kd">#file</span><span class="kt">Path</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">deletingLastPathComponent</span><span class="p">()</span>
        <span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"snapshots"</span><span class="p">)</span>

    <span class="nf">nonisolated</span><span class="p">(</span><span class="n">unsafe</span><span class="p">)</span> <span class="kd">static</span> <span class="k">var</span> <span class="nv">environment</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">environment</span>

    <span class="kd">static</span> <span class="k">var</span> <span class="nv">updating</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="n">environment</span><span class="p">[</span><span class="s">"UPDATE_SNAPSHOTS"</span><span class="p">]</span> <span class="o">==</span> <span class="s">"1"</span>
    <span class="p">}</span>

    <span class="kd">static</span> <span class="k">var</span> <span class="nv">locked</span><span class="p">:</span> <span class="kt">Bool</span> <span class="p">{</span>
        <span class="n">environment</span><span class="p">[</span><span class="s">"CI"</span><span class="p">]</span> <span class="o">!=</span> <span class="kc">nil</span>
    <span class="p">}</span>

    <span class="kd">private</span> <span class="kd">static</span> <span class="k">let</span> <span class="nv">lock</span> <span class="o">=</span> <span class="kt">NSLock</span><span class="p">()</span>
    <span class="nf">nonisolated</span><span class="p">(</span><span class="n">unsafe</span><span class="p">)</span> <span class="kd">private</span> <span class="kd">static</span> <span class="k">var</span> <span class="nv">counters</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">:</span> <span class="kt">Int</span><span class="p">]</span> <span class="o">=</span> <span class="p">[:]</span>

    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">nextIndex</span><span class="p">(</span><span class="n">forKey</span> <span class="nv">key</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">Int</span> <span class="p">{</span>
        <span class="n">lock</span><span class="o">.</span><span class="nf">lock</span><span class="p">()</span>
        <span class="k">defer</span> <span class="p">{</span> <span class="n">lock</span><span class="o">.</span><span class="nf">unlock</span><span class="p">()</span> <span class="p">}</span>
        <span class="k">let</span> <span class="nv">next</span> <span class="o">=</span> <span class="p">(</span><span class="n">counters</span><span class="p">[</span><span class="n">key</span><span class="p">]</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="n">counters</span><span class="p">[</span><span class="n">key</span><span class="p">]</span> <span class="o">=</span> <span class="n">next</span>
        <span class="k">return</span> <span class="n">next</span>
    <span class="p">}</span>

    <span class="c1">// Ruby-style snake_case: "DateFormatSnapshotTests" -&gt; "date_format_snapshot_tests"</span>
    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">sanitized</span><span class="p">(</span><span class="n">_</span> <span class="nv">component</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
        <span class="n">component</span>
            <span class="o">.</span><span class="nf">replacingOccurrences</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="s">"([a-z0-9])([A-Z])"</span><span class="p">,</span> <span class="nv">with</span><span class="p">:</span> <span class="s">"$1_$2"</span><span class="p">,</span> <span class="nv">options</span><span class="p">:</span> <span class="o">.</span><span class="n">regularExpression</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">replacingOccurrences</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="s">"([A-Z])([A-Z][a-z])"</span><span class="p">,</span> <span class="nv">with</span><span class="p">:</span> <span class="s">"$1_$2"</span><span class="p">,</span> <span class="nv">options</span><span class="p">:</span> <span class="o">.</span><span class="n">regularExpression</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">lowercased</span><span class="p">()</span>
            <span class="o">.</span><span class="nf">replacingOccurrences</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="s">"[^a-z0-9]+"</span><span class="p">,</span> <span class="nv">with</span><span class="p">:</span> <span class="s">"_"</span><span class="p">,</span> <span class="nv">options</span><span class="p">:</span> <span class="o">.</span><span class="n">regularExpression</span><span class="p">)</span>
            <span class="o">.</span><span class="nf">trimmingCharacters</span><span class="p">(</span><span class="nv">in</span><span class="p">:</span> <span class="kt">CharacterSet</span><span class="p">(</span><span class="nv">charactersIn</span><span class="p">:</span> <span class="s">"_"</span><span class="p">))</span>
    <span class="p">}</span>

    <span class="kd">static</span> <span class="kd">func</span> <span class="nf">firstDifferences</span><span class="p">(</span><span class="nv">recorded</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span> <span class="nv">current</span><span class="p">:</span> <span class="kt">String</span><span class="p">)</span> <span class="o">-&gt;</span> <span class="kt">String</span> <span class="p">{</span>
        <span class="k">let</span> <span class="nv">recordedLines</span> <span class="o">=</span> <span class="n">recorded</span><span class="o">.</span><span class="nf">components</span><span class="p">(</span><span class="nv">separatedBy</span><span class="p">:</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span><span class="p">)</span>
        <span class="k">let</span> <span class="nv">currentLines</span> <span class="o">=</span> <span class="n">current</span><span class="o">.</span><span class="nf">components</span><span class="p">(</span><span class="nv">separatedBy</span><span class="p">:</span> <span class="s">"</span><span class="se">\n</span><span class="s">"</span><span class="p">)</span>
        <span class="k">var</span> <span class="nv">differences</span><span class="p">:</span> <span class="p">[</span><span class="kt">String</span><span class="p">]</span> <span class="o">=</span> <span class="p">[]</span>

        <span class="k">for</span> <span class="n">index</span> <span class="k">in</span> <span class="mi">0</span><span class="o">..&lt;</span><span class="nf">max</span><span class="p">(</span><span class="n">recordedLines</span><span class="o">.</span><span class="n">count</span><span class="p">,</span> <span class="n">currentLines</span><span class="o">.</span><span class="n">count</span><span class="p">)</span> <span class="k">where</span> <span class="n">differences</span><span class="o">.</span><span class="n">count</span> <span class="o">&lt;</span> <span class="mi">8</span> <span class="p">{</span>
            <span class="k">let</span> <span class="nv">recordedLine</span> <span class="o">=</span> <span class="n">index</span> <span class="o">&lt;</span> <span class="n">recordedLines</span><span class="o">.</span><span class="n">count</span> <span class="p">?</span> <span class="n">recordedLines</span><span class="p">[</span><span class="n">index</span><span class="p">]</span> <span class="p">:</span> <span class="s">"&lt;missing&gt;"</span>
            <span class="k">let</span> <span class="nv">currentLine</span> <span class="o">=</span> <span class="n">index</span> <span class="o">&lt;</span> <span class="n">currentLines</span><span class="o">.</span><span class="n">count</span> <span class="p">?</span> <span class="n">currentLines</span><span class="p">[</span><span class="n">index</span><span class="p">]</span> <span class="p">:</span> <span class="s">"&lt;missing&gt;"</span>
            <span class="k">if</span> <span class="n">recordedLine</span> <span class="o">!=</span> <span class="n">currentLine</span> <span class="p">{</span>
                <span class="n">differences</span><span class="o">.</span><span class="nf">append</span><span class="p">(</span><span class="s">"line </span><span class="se">\(</span><span class="n">index</span> <span class="o">+</span> <span class="mi">1</span><span class="se">)</span><span class="s">:</span><span class="se">\n</span><span class="s">  recorded: </span><span class="se">\(</span><span class="n">recordedLine</span><span class="se">)\n</span><span class="s">  current:  </span><span class="se">\(</span><span class="n">currentLine</span><span class="se">)</span><span class="s">"</span><span class="p">)</span>
            <span class="p">}</span>
        <span class="p">}</span>

        <span class="k">return</span> <span class="n">differences</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>

<span class="c1">// Parameterized tests (@Test(arguments:)) share one function name — pass named:.</span>
<span class="kd">func</span> <span class="nf">assertMatchesSnapshot</span><span class="p">(</span>
    <span class="n">_</span> <span class="nv">value</span><span class="p">:</span> <span class="kt">String</span><span class="p">,</span>
    <span class="n">named</span> <span class="nv">name</span><span class="p">:</span> <span class="kt">String</span><span class="p">?</span> <span class="o">=</span> <span class="kc">nil</span><span class="p">,</span>
    <span class="nv">filePath</span><span class="p">:</span> <span class="kt">String</span> <span class="o">=</span> <span class="kd">#file</span><span class="kt">Path</span><span class="p">,</span>
    <span class="nv">function</span><span class="p">:</span> <span class="kt">String</span> <span class="o">=</span> <span class="kd">#function</span><span class="p">,</span>
    <span class="nv">sourceLocation</span><span class="p">:</span> <span class="kt">SourceLocation</span> <span class="o">=</span> <span class="err">#</span><span class="n">_sourceLocation</span>
<span class="p">)</span> <span class="p">{</span>
    <span class="k">let</span> <span class="nv">suite</span> <span class="o">=</span> <span class="kt">Snapshots</span><span class="o">.</span><span class="nf">sanitized</span><span class="p">(</span><span class="kt">URL</span><span class="p">(</span><span class="nv">fileURLWithPath</span><span class="p">:</span> <span class="n">filePath</span><span class="p">)</span><span class="o">.</span><span class="nf">deletingPathExtension</span><span class="p">()</span><span class="o">.</span><span class="n">lastPathComponent</span><span class="p">)</span>
    <span class="k">let</span> <span class="nv">test</span> <span class="o">=</span> <span class="kt">Snapshots</span><span class="o">.</span><span class="nf">sanitized</span><span class="p">(</span><span class="n">function</span><span class="o">.</span><span class="nf">replacingOccurrences</span><span class="p">(</span><span class="nv">of</span><span class="p">:</span> <span class="s">"()"</span><span class="p">,</span> <span class="nv">with</span><span class="p">:</span> <span class="s">""</span><span class="p">))</span>
    <span class="k">let</span> <span class="nv">suffix</span> <span class="o">=</span> <span class="n">name</span><span class="o">.</span><span class="nf">map</span><span class="p">(</span><span class="kt">Snapshots</span><span class="o">.</span><span class="n">sanitized</span><span class="p">)</span> <span class="p">??</span> <span class="kt">String</span><span class="p">(</span><span class="kt">Snapshots</span><span class="o">.</span><span class="nf">nextIndex</span><span class="p">(</span><span class="nv">forKey</span><span class="p">:</span> <span class="s">"</span><span class="se">\(</span><span class="n">suite</span><span class="se">)</span><span class="s">/</span><span class="se">\(</span><span class="n">test</span><span class="se">)</span><span class="s">"</span><span class="p">))</span>
    <span class="k">let</span> <span class="nv">snapshotURL</span> <span class="o">=</span> <span class="kt">Snapshots</span><span class="o">.</span><span class="n">directory</span>
        <span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="n">suite</span><span class="p">)</span>
        <span class="o">.</span><span class="nf">appendingPathComponent</span><span class="p">(</span><span class="s">"</span><span class="se">\(</span><span class="n">test</span><span class="se">)</span><span class="s">__</span><span class="se">\(</span><span class="n">suffix</span><span class="se">)</span><span class="s">.snap.txt"</span><span class="p">)</span>
    <span class="k">let</span> <span class="nv">relativePath</span> <span class="o">=</span> <span class="s">"Tests/snapshots/</span><span class="se">\(</span><span class="n">suite</span><span class="se">)</span><span class="s">/</span><span class="se">\(</span><span class="n">test</span><span class="se">)</span><span class="s">__</span><span class="se">\(</span><span class="n">suffix</span><span class="se">)</span><span class="s">.snap.txt"</span>

    <span class="k">if</span> <span class="o">!</span><span class="kt">Snapshots</span><span class="o">.</span><span class="n">updating</span><span class="p">,</span> <span class="k">let</span> <span class="nv">recorded</span> <span class="o">=</span> <span class="k">try</span><span class="p">?</span> <span class="kt">String</span><span class="p">(</span><span class="nv">contentsOf</span><span class="p">:</span> <span class="n">snapshotURL</span><span class="p">,</span> <span class="nv">encoding</span><span class="p">:</span> <span class="o">.</span><span class="n">utf8</span><span class="p">)</span> <span class="p">{</span>
        <span class="k">if</span> <span class="n">recorded</span> <span class="o">==</span> <span class="n">value</span> <span class="p">{</span> <span class="k">return</span> <span class="p">}</span>
        <span class="kt">Issue</span><span class="o">.</span><span class="nf">record</span><span class="p">(</span>
            <span class="s">"output drifted from </span><span class="se">\(</span><span class="n">relativePath</span><span class="se">)</span><span class="s"> — regenerate with bin/unit-test --update-snapshots, then review every changed line. First differences:</span><span class="se">\n\(</span><span class="kt">Snapshots</span><span class="o">.</span><span class="nf">firstDifferences</span><span class="p">(</span><span class="nv">recorded</span><span class="p">:</span> <span class="n">recorded</span><span class="p">,</span> <span class="nv">current</span><span class="p">:</span> <span class="n">value</span><span class="p">)</span><span class="se">)</span><span class="s">"</span><span class="p">,</span>
            <span class="nv">sourceLocation</span><span class="p">:</span> <span class="n">sourceLocation</span>
        <span class="p">)</span>
        <span class="k">return</span>
    <span class="p">}</span>

    <span class="k">guard</span> <span class="o">!</span><span class="kt">Snapshots</span><span class="o">.</span><span class="n">locked</span> <span class="k">else</span> <span class="p">{</span>
        <span class="kt">Issue</span><span class="o">.</span><span class="nf">record</span><span class="p">(</span>
            <span class="s">"snapshot </span><span class="se">\(</span><span class="n">relativePath</span><span class="se">)</span><span class="s"> is missing or an update was requested, but snapshots are locked under CI — record locally and commit the file"</span><span class="p">,</span>
            <span class="nv">sourceLocation</span><span class="p">:</span> <span class="n">sourceLocation</span>
        <span class="p">)</span>
        <span class="k">return</span>
    <span class="p">}</span>

    <span class="k">do</span> <span class="p">{</span>
        <span class="k">try</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">snapshotURL</span><span class="o">.</span><span class="nf">deletingLastPathComponent</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="n">value</span><span class="o">.</span><span class="nf">write</span><span class="p">(</span><span class="nv">to</span><span class="p">:</span> <span class="n">snapshotURL</span><span class="p">,</span> <span class="nv">atomically</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="nv">encoding</span><span class="p">:</span> <span class="o">.</span><span class="n">utf8</span><span class="p">)</span>
    <span class="p">}</span> <span class="k">catch</span> <span class="p">{</span>
        <span class="kt">Issue</span><span class="o">.</span><span class="nf">record</span><span class="p">(</span><span class="s">"failed to write snapshot </span><span class="se">\(</span><span class="n">relativePath</span><span class="se">)</span><span class="s">: </span><span class="se">\(</span><span class="n">error</span><span class="se">)</span><span class="s">"</span><span class="p">,</span> <span class="nv">sourceLocation</span><span class="p">:</span> <span class="n">sourceLocation</span><span class="p">)</span>
    <span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The line to notice is the <code class="language-plaintext highlighter-rouge">guard !Snapshots.locked</code> after the comparison. Under <code class="language-plaintext highlighter-rouge">CI</code>, a missing file and an explicit update both fail instead of writing, which is the same lock the gem calls <code class="language-plaintext highlighter-rouge">lock_snapshots</code>. The <code class="language-plaintext highlighter-rouge">environment</code> variable is there so the helper’s own tests can exercise the lock without a real <code class="language-plaintext highlighter-rouge">CI</code> variable. Values are plain strings in <code class="language-plaintext highlighter-rouge">.snap.txt</code> files rather than the gem’s <code class="language-plaintext highlighter-rouge">.snap.yaml</code>, because we chose not to port the YAML serializer. Callers turn their value into a string first, and overloads for structured values can wait until someone needs one.</p>

<p>The flag side is a few lines in the existing test runner script, using the same <code class="language-plaintext highlighter-rouge">TEST_RUNNER_</code> mechanism the golden recorder already used:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Snapshot update mode: bin/unit-test --update-snapshots (or UPDATE_SNAPSHOTS=1)</span>
<span class="c"># rewrites every snapshot the run touches (Snapshots.swift); review the</span>
<span class="c"># Tests/snapshots/ diff before committing. CI is forwarded so the</span>
<span class="c"># helper can lock snapshots (missing files fail instead of auto-recording).</span>
<span class="k">for </span>arg <span class="k">in</span> <span class="s2">"</span><span class="nv">$@</span><span class="s2">"</span><span class="p">;</span> <span class="k">do
  if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$arg</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"--update-snapshots"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
    </span><span class="nv">UPDATE_SNAPSHOTS</span><span class="o">=</span>1
  <span class="k">fi
done
if</span> <span class="o">[</span> <span class="s2">"</span><span class="nv">$UPDATE_SNAPSHOTS</span><span class="s2">"</span> <span class="o">=</span> <span class="s2">"1"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">export </span><span class="nv">TEST_RUNNER_UPDATE_SNAPSHOTS</span><span class="o">=</span>1
  <span class="nb">echo</span> <span class="s2">"Updating snapshots (review the Tests/snapshots/ diff)"</span>
<span class="k">fi
if</span> <span class="o">[</span> <span class="nt">-n</span> <span class="s2">"</span><span class="nv">$CI</span><span class="s2">"</span> <span class="o">]</span><span class="p">;</span> <span class="k">then
  </span><span class="nb">export </span><span class="nv">TEST_RUNNER_CI</span><span class="o">=</span><span class="s2">"</span><span class="nv">$CI</span><span class="s2">"</span>
<span class="k">fi</span>
</code></pre></div></div>

<p>The last block isn’t optional. GitHub Actions sets <code class="language-plaintext highlighter-rouge">CI</code> in the runner shell, but only <code class="language-plaintext highlighter-rouge">TEST_RUNNER_</code>-prefixed variables cross into the test process, so plain <code class="language-plaintext highlighter-rouge">CI</code> never arrives unless the script forwards it. Miss that and the lock never turns on, and nothing tells you.</p>

<h3 id="what-swift-testing-changes">What Swift Testing changes</h3>

<p>A port isn’t a word-for-word copy. Swift Testing changes three details:</p>

<ul>
  <li><strong>Parallel by default.</strong> Minitest runs a suite’s tests in one process, so a plain counter is enough. Swift Testing runs tests in parallel by default, so the <code class="language-plaintext highlighter-rouge">__N</code> counter has to be a dictionary keyed by suite and test, with a lock around it. Calls inside one test still run in order, so the numbering is stable per test. The lock only protects the dictionary from two tests touching it at once.</li>
  <li><strong>Parameterized tests collide.</strong> <code class="language-plaintext highlighter-rouge">@Test(arguments:)</code> runs one function many times, and every run has the same <code class="language-plaintext highlighter-rouge">#function</code> string. Auto-numbering across those runs would depend on which one ran first. So parameterized tests have to pass an explicit <code class="language-plaintext highlighter-rouge">named:</code> argument. That’s a rule written on the helper, not something the helper enforces at runtime.</li>
  <li><strong>One spelling flagged for verification.</strong> The <code class="language-plaintext highlighter-rouge">sourceLocation: SourceLocation = #_sourceLocation</code> default argument makes a failure point at the caller’s line instead of the helper’s. It’s the documented pattern for custom assertion helpers. The plan marked it <code class="language-plaintext highlighter-rouge">Verify:</code> against the toolchain’s Swift Testing version, and it compiled as written when the helper landed. A design record should say what it isn’t sure of. An agent that hit a compile error on that line would find the plan had already warned about it.</li>
</ul>

<h3 id="what-the-helper-deliberately-does-not-replace">What the helper deliberately does not replace</h3>

<p>The plan said the golden-table system would stay. Its value was that the compiler checked coverage: the table was generated Swift covering <code class="language-plaintext highlighter-rouge">allCases</code> of language x format intent, so a newly added case couldn’t go missing without a compile error. The plan recorded keeping it as a decision, not an oversight, and left the golden’s future as an open question. The landing PR answered it the other way. The date matrix became the helper’s first adopter, rendered from the same <code class="language-plaintext highlighter-rouge">allCases</code> loops into one snapshot file per language (27 files, the same bytes the golden had asserted). The golden recorder, its generated file, and its flag were deleted. Coverage now comes from the loops in the test plus exhaustive switches in the date-format contract tests. The plan’s other targets are still the helper’s to-do list: sync payload shapes, widget timeline dumps, notification content, and turning a couple of standalone validator tools into report-generating tests that run behind an env flag. The report-generating tests are the part that has happened.</p>

<h2 id="lessons-learned">Lessons Learned</h2>

<ul>
  <li><strong>Check what already works in your repo before adding a library.</strong> Writing the host filesystem, passing env vars through <code class="language-plaintext highlighter-rouge">xcodebuild</code>, a readable failure message: all three were already proven in-repo. The library would only have added convenience, which is the cheap part.</li>
  <li><strong>Review the snapshot diff like a copy change.</strong> Recording on the first run and re-recording with one flag are only safe if someone reads every changed snapshot line.</li>
  <li><strong>Check that the CI lock actually turns on.</strong> Under <code class="language-plaintext highlighter-rouge">xcodebuild</code> the <code class="language-plaintext highlighter-rouge">CI</code> variable doesn’t reach the test process unless the script forwards it as <code class="language-plaintext highlighter-rouge">TEST_RUNNER_CI</code>. A lock that never turns on looks the same as one that works.</li>
  <li><strong>Write down what the new framework changes.</strong> A word-for-word port gets parallel-by-default and parameterized tests wrong. List the differences, and flag what you haven’t verified.</li>
  <li><strong>A plan with the full code in it can be picked up by anyone.</strong> Ours had the helper source, the runner patch, non-goals, and open questions. It was picked up two days later, and the landed helper is the plan’s code with the two <code class="language-plaintext highlighter-rouge">Verify:</code> flags resolved.</li>
</ul>]]></content><author><name>Trevor Turk</name></author><category term="testing" /><category term="swift" /><category term="ios" /><category term="ruby" /><category term="snapshot-testing" /><category term="workflow" /><summary type="html"><![CDATA[The Problem]]></summary></entry></feed>