← back to the timeline
Jun 13 2026

nine things got done: Built the Progress root: the capability ledger (Phase 3 UI, Slice 12 #354). Tap any of these to read the whole thing.

Built the Progress root: the capability ledger (Phase 3 UI, Slice 12 #354)

The new Progress root screen, the "capability ledger." Instead of the old progress tab, the rebuilt shell now routes to a scrollable list o…

how I tested it

12 new unconditional tests cover: canonical sort order (squat first, isolation last), the 2px spine constant, list-scale band context, the honest-absence annotation (no fabricated session count), cold-start produces zero rows, dormant detection. Image-snapshot tests for light + dim + AX5 are wired up but reference images are not recorded here, the CI record job on Xcode 26.3 does that.

What it is. The new Progress root screen, the "capability ledger." Instead of the old progress tab, the rebuilt shell now routes to a scrollable list of capability bands, one row per movement pattern, all aligned on a shared vertical spine. The spine is the key visual: every row's floor tick sits at the same x-position, so when you look at the screen you see one continuous 2px vertical line running from top to bottom. That line is the app saying "here is every pattern's floor, side by side."

Row anatomy. Each active pattern gets three lines: the pattern name (Squat, Hip Hinge, etc.), a compact band strip showing floor, stretch, and today's dot at list scale, and an annotation line. The annotation always reserves its height so the layout never reflows when content changes. For patterns that are calibrating the annotation says "still calibrating." For everything else, the forward hook reads "ratchet within reach", a permanent instrument annotation telling you the next floor increase is achievable. This is qualitative on purpose: the model doesn't yet expose how many sessions you are away from the next ratchet, so we don't invent a number. That count comes in a later model-API slice.

Canonical order, always. Patterns appear in the model's own taxonomy order (Squat → Hip Hinge → Horizontal Push → Vertical Push → Horizontal Pull → Vertical Pull → Lunge → Isolation) and never reshuffle. Dormant patterns (those you haven't trained recently) hold their position but compact to a single muted line with a date instead of showing a full band strip.

Important: still dormant. The 3-tab shell (AppShell) is behind useNewShell = false in the app, so real users still see the old ContentView. Only the .progress branch of AppShell.surface(for:) changed, one line swapping ProgressTabView(...) for ProgressRootLedgerHost(). ContentView, ProgressTabView, and ProgramOverviewView are untouched.

PR open from feat/354-progress-root. 393 tests (12 new + 381 existing), all green.

When the app reopens an old paused workout, it now checks it's really yours first (4 of 6)

If you paused a workout and came back later, the app would pick it back up and try to save to it.

Done
the problem

If you paused a workout and came back later, the app would pick it back up and try to save to it. But if that paused workout was created under an old/temporary login (which is exactly what was happening), the database rejects every save against it, and worse, the app would try to re-create that workout under the old owner, which also fails. This was the exact thing in your gym log: the app resumed an old session and the saves piled up red.

what I changed

Before resuming a paused workout, the app now asks "does this workout belong to the login I'm signed in as right now?" If yes, it resumes exactly as before. If it belongs to a different (old) login, it doesn't try to replay it, it clears it out and shows a short note: "couldn't confirm your previous workout for this account, it was cleared. Start a new one." So instead of an endless wall of failed saves, you get a clean start.

how I tested it

Two tests: one proves the "clear it out" routine empties the outbox and the paused snapshot; the other drives the real flow, a paused workout stamped with login A, the app signed in as login B, and proves the app stays idle, clears the old workout, and shows the note (it would fail if the ownership check were removed). A review agent confirmed the check runs before any save attempt, that clearing the whole outbox here is correct (everything in it belongs to the old login at that moment), and that the two tests are now load-bearing, I tightened one of them after it pointed out the original didn't really prove anything.

merged as PR 406 (Slice 4 of 6). Next: as a safety net, have the outbox itself refuse to retry a save whose owner doesn't match the current login.

Built the new "do the set" screen (numbers big, one tap to log, then it becomes the rest timer)

The first piece of the redesigned workout screen.

Shipped
how I tested it

16 tests, all green: they drive the real workout engine through start → log → rest → next set, prove the Done→Finish relabel only flips on the true last set, prove the double-tap and stray-brush guards hold, and prove the ink-vs-pencil colour split. Picture-comparison tests for the set and rest screens (light and dark) are wired up but their reference images are recorded later on the build server, by design. The screen builds with no new warnings.

What this is. The first piece of the redesigned workout screen. It shows one set at a time: the exercise name, then the target weight and reps as big numbers you can read from across the gym. A full-width ink bar sits at the bottom that just says Done. Tap it once and the set is logged exactly as prescribed, no pop-up form, no fiddling with fields. The screen then smoothly turns into the rest timer in place, and when rest is over it shows the next set.

Two nice touches from the design. On the very last set of the whole workout, the bottom bar quietly changes its word from "Done" to Finish, so the end of the session is obvious. And there's a rule the designers call "work is ink, time is pencil": the weights and reps (the work you did) are drawn in full-strength ink, while the rest-timer digits (just time passing) are drawn in a lighter grey. Same handwriting, lighter pencil, so the two read as one calm system.

Guards so a tap is never a mistake. The Done bar ignores a stray brush of the thumb, and it goes dead the instant you tap it so a double-tap can't log the same set twice. The little plate-thud buzz fires at the moment the set is committed, not when you first touch down.

Important: this is built but not switched on yet. It's a brand-new screen that nothing in the live app routes to. The old workout screens are completely untouched and still run the real app. This is the same "build it behind a curtain, swap later" approach we've used for the rest of the redesign.

What's deliberately left for the next slice (#351). The "tap a number to change it" adjuster, the AMRAP (max-reps) counter, and the dramatic ink-flood entrance animation, all hooks are in place but the work is parked. This slice is just the core loop: see the set, tap Done, rest, next set.

opened as a PR off feat/350-live-loop-core. Dormant build, new screen only; old views still live.

"Reset All" now truly empties the outbox, not just the on-disk copy (3 of 6)

The app keeps an "outbox" of saves waiting to reach the server.

Done
the problem

The app keeps an "outbox" of saves waiting to reach the server. When you tap "Reset All," the app wiped the saved-to-disk copy of that outbox, but the running copy already loaded in memory survived, and would quietly re-save itself the moment anything new got added. So a reset could leave behind stale, mis-owned saves that fail again after you set the app up fresh.

what I changed

Two small lines in the reset routine: actually tell the live outbox to empty itself (in memory and on disk), and clear any paused-workout snapshot. Now a reset is a true clean slate, provided you quit and reopen afterward, which the reset message already tells you to do.

how I tested it

I added a test that fills the outbox's "failed pile," empties it via the reset call, and then re-opens it from scratch to prove nothing came back, the gap the old test missed (it only ever cleared an already-empty outbox). A review agent confirmed the ordering is safe and flagged one real edge, a workout that's actively in progress at the instant you reset isn't cleared from memory, which I filed as #403 (it's covered in practice by the quit-and-reopen step). I also made the test poll instead of sleeping a fixed time, so it can't flake.

merged as PR 404 (Slice 3 of 6). Next: when the app reopens an old paused workout, check it still belongs to you before replaying it.

Make sure every new login gets a profile row, automatically (2 of 6)

Lots of saves point back to a "profile" row keyed to your login.

Shipped
the problem

Lots of saves point back to a "profile" row keyed to your login. Today that row is only created during onboarding. So if a save ever fires before onboarding finishes, or if onboarding is skipped, there's no profile row and the database refuses the save. Belt with no suspenders.

what I changed

Two things. (1) A tiny database rule (a "trigger") that automatically creates a bare profile row the instant a new login is made, server-side, before the app even asks. Onboarding then fills in the details on top. (2) Changed onboarding's profile write from "insert" to "upsert" (insert-or-update) so it cleanly lands on top of the row the trigger just made instead of colliding with it, and I made it only write the fields you actually provided, so re-doing onboarding can't blank out details you'd already set.

how I tested it

New tests prove the upsert sends the right "merge, don't collide" instruction and that a plain insert still doesn't. An adversarial review agent did the most important check by hand: reading the real table definition to confirm the auto-create rule cannot accidentally block new sign-ins (the only required field is the id, which the rule always provides). It also caught that the auto-create rule's safety depends on it running as the database owner, so I made that explicit instead of relying on a default, matching how the app's other database rules are written.

code merged as PR 402 (Slice 2 of 6). One deliberate step remains: actually switching the database rule on in production, I'm gating that on a careful go-live check because a faulty rule there could block sign-ins, so it's not something to flip casually.

Started the real fix for the gym-save failures: wait for login before starting a workout (1 of 6)

Even after login was fixed, saving a workout still failed.

Done
the problem

Even after login was fixed, saving a workout still failed. A team of review agents traced it to one habit the whole app shares: it stamps "who owns this data" at the moment a row is created and never re-checks it when the data is actually sent. So if a workout was started in the split-second before login finished, it got stamped with a stand-in "nobody" id, and the database later rejected every save tied to it.

what I changed

The first and most important fix: when you tap "Start Workout," the app now waits for your real login to be ready before creating the workout, instead of grabbing whatever id is lying around. If login genuinely can't be established, it does nothing rather than create a doomed, unsavable workout. I built this as a single shared "get the real owner, or stop" helper so the other five fixes all use the exact same rule instead of five slightly-different versions.

how I tested it

A new test proves the order is right: before login lands the helper says "not ready" (so nothing is stamped), and the instant login lands it returns your real id, never the stand-in. All 14 login/identity tests pass; the whole app still compiles. An independent review agent (a second, adversarial pass) found no blockers and caught one real thing, a fast double-tap could start two workouts, which I fixed by adding the same guard the other buttons already use. It also flagged that tapping Start and silently getting nothing is poor feedback; I filed that as #399.

merged as PR 400 (Slice 1 of 6). Next: make sure a brand-new login always gets a profile row (so the very first save can't fail), then the reset/cleanup and replay-safety slices.

Built the Lens: a 6-blade camera-iris readiness gauge (Phase 3 UI, Slice 5)

The Lens is a camera-iris aperture drawn in code, six blade shapes that rotate into alignment and open wider as readiness goes up.

how I tested it

15 unconditional tests pass: all four label cases, the unknown/calibrating case, the computing case, lexicon length, longest-word sizing, accessibility labels (number + state word, not just "image"), WCAG-relevant token hygiene, isFocused logic, aperture proportionality. Gated snapshot cases (APEX_SNAPSHOT_TESTS=1) cover all states × light + dim for both compact and sheet, wired but reference-pending per the CI record discipline.

What it is. The Lens is a camera-iris aperture drawn in code, six blade shapes that rotate into alignment and open wider as readiness goes up. It always shows a literal number plus a state word so it is readable in a dark gym without needing to understand the shape. It is built DORMANT: finished and tested, but not yet wired into the live shell.

Three states. Focused iris + number (resolved, score known); unfocused iris + "—" (calibrating / unknown / first day); slow oscillation + "Updating" (computing in the background). The state word lexicon has five entries: Optimal, Good, Reduced, Poor, Calibrating, Updating. Layout is sized to "Calibrating", the longest, so the compact gauge never reflows when the word changes.

The disclosure sheet. Tapping the gauge opens a small sheet: big iris + number + state word, then one or two training-load numbers explaining why, a line saying "Based on your training load, no sleep or HRV data", and two expandable sections ("How to read this" / "How it's calculated"). No deep-view creep, it stays small.

Colour rule. Only DesignSystem ink and accent-ink tokens. The legacy ReadinessScore.tintColor multi-hue palette is deliberately ignored.

Motion. Blades animate via the gauge-focus spring (response 0.5, damping 0.7, tiny overshoot) when state changes. Reduce Motion falls back to a 150ms crossfade. The bare component has no entrance or idle animation so it snapshots at frame 1.

PR opened, Closes 346.

Built the capability band: one component, three contexts (Slice 4, #345)

What the band draws.

how I tested it

Twelve unconditional geometry/token tests run on every push: tick widths, fill opacities for light and dim, the full four-case confidence mapping, minimum band width enforcement, and out-of-band dot plotting. Seven snapshot cases are wired in but gated, they will record references when CI runs on the pinned Xcode 26.3 toolchain.

The design specced a single band drawing that works in three places, onboarding model reveal, post-workout evidence strip, and the Progress ledger row. Instead of building three separate views, the spec said build one and configure it. That's what shipped today.

What the band draws. A filled region between the floor (the heaviest tick, 2 px, full ink) and the stretch (a hairline tick, 1 px). The fill is the accent color at 8% in light mode, 12% in dim. Today's dot is solid when the model has enough data to trust the number ("measured"), hollow when it's still a guess ("estimated"). The dashed band edges give the same estimated/measured signal on the band itself, solid edges when established or seasoned, dashed when still calibrating.

The three contexts. .full is the complete drawing with labels and a caption slot, this is what the post-workout strip and the Progress detail use. .onboarding is the same anatomy at a slightly smaller height, same component, same labeling. .list strips it down to an unlabeled 5 pt dot and no bracket, because the Progress root rows put the numbers in the row annotation below, not on the drawing itself.

The binding. The component takes a PatternProjection (floor/stretch/progress) plus an AxisConfidence separately, because PatternProjection has no confidence field. The caller supplies confidence from PatternProfile.confidence.

DORMANT. The component is built and tested but not wired into any live screen, the old views are untouched. The post-workout, Progress, and onboarding slices will each pull it in when they build.

PR open, 324/324 tests passing.

Proved the server was fine, then stopped the app from using the connection type that was hanging

Phones and servers can talk over two connection types: a newer one (HTTP/3, also called "QUIC") and an older, rock-solid one (HTTP/2).

Done
the problem

Phones and servers can talk over two connection types: a newer one (HTTP/3, also called "QUIC") and an older, rock-solid one (HTTP/2). The app shares one connection pool for everything, and once it learned the server offers the newer type, it tried to use it for login, and on this phone that newer connection just hangs. My own test from the computer used the older type and worked in a fifth of a second. So: healthy server, but the phone was knocking on a door that wouldn't open.

what I changed

Two earlier fixes hadn't cleared the problem, so instead of guessing again I called the real login endpoint myself from my computer. It answered instantly with a valid login. That proved the server, the key, and the login feature are all healthy, it is not a rate limit and not the wifi. The problem had to be in how the app makes the connection.

I gave the login its own private connection that starts fresh and uses the older, reliable type, so it stops trying the one that hangs. I also added plain status messages to the log (does it restore an old login, start a new one, succeed, or fail, and the exact reason) so if anything is still off, the next run tells us instead of leaving us to guess.

how I tested it

The live endpoint test returned success over the older connection type; all 7 login tests pass; the app builds.

merged as PR 394. Real root-cause fix on top of the earlier two (389 reset, 392 retry). Remaining safety net tracked in 391.