A QA swarm: eleven new test suites, six production defects, and the docs made true - #12
Merged
Merged
Conversation
An in-flight snapshot of the QA swarm's first wave, committed to keep the working tree clean while the remaining sessions run. NOT YET REVIEWED AND NOT YET COMPILED — there is no Swift toolchain in this environment, so CI is the only compiler and none of these files has met it. No pull request is opened against this commit on purpose: nothing here may reach main until the 1:1 reviewer wave has checked every symbol reference, actor isolation and determinism hazard, and until CI is green. Eight isolated sessions were dispatched, each owning a disjoint set of new test files so no two could touch the same bytes. Present here: the stores (affirming-only invariants, unlock boundaries across the catalogue, persistence round-trips), the pure core (SplitMix64 determinism and the value-semantics property the weather seed depends on, entity value types), the field plan and map generation (stage composition swept exactly, lineage inheritance, distributions sampled with wide tolerances), and the audio and haptics engines. Still to land: the view-model bridge, the rendering helpers, the composed world path, and the end-to-end journeys.
…uite
The swarm was told to report production defects rather than fix them — no
session owns production files — so these are the orchestrator acting on
what came back. All four are cases where correct-looking code can take the
app, or CI, down.
A CORRUPT TALLY CRASHED THE APP ON EVERY LAUNCH. ProgressionStore built
its per-pop counts with Dictionary(uniqueKeysWithValues:), which TRAPS on
a duplicate key — and two stored keys can parse to the same number without
this store ever having written them that way: "7" and "07" both answer 7.
The failure mode is the worst one this file could have. Her whole journey
lives in it, and the only way out of a launch crash is deleting the app,
which is also deleting everything it was protecting. Damaged defaults must
cost her a tally, never the app.
featuredPop = 0 IS NORMALISED ON THE WAY IN, not only on the way out. 0 is
the on-disk spelling of "no favourite", so a 0 assigned in memory survived
until the next launch and meant pop #0 in between — and there is no pop #0.
fieldPops() would answer [0], breaking its own promise that a field only
holds pops she has earned. Nothing assigns 0 today; now nothing can, and
the value read from disk is checked against the catalogue rather than
against a sentinel.
TWO TRAP PATHS IN THE SOUND ENGINE, in a file whose header promises that
"every failure path degrades to silence, never to a crash":
* three separate inline Int(freq.rounded()) conversions, two of which
then did UInt64() on the result. Int() of a Double traps on NaN, on
either infinity, and past Int's range; UInt64() of an Int traps again
on a negative. There is now exactly one frequency-to-key conversion in
the file and it is total.
* every buffer maker computed AVAudioFrameCount(sampleRate * duration)
BEFORE its own guard on that count — and AVAudioFrameCount is a UInt32,
so a negative, NaN, or over-97-second duration trapped at the
conversion. The guard could only ever catch exactly 0, the one bad
value that does not trap. All five makers now go through one guarded
helper. A profile is hand-authored data and prepare() renders whatever
it is handed, so a mistyped duration must cost that pop its sound and
nothing else.
AND A FLAKE IN OUR OWN SUITE. Two MapStoreTests asserted that a road brings
a pop its parent did not have. It reads well and it is not guaranteed:
branchedSet builds its pool as unlocked minus the siblings' takings minus
what it has already chosen, and the parent's other pops are in none of
those, so a child may legitimately redraw one. Genesis seeds are random, so
this was an intermittent failure on correct code — a few percent per road,
and a fork has up to three. Both now assert the form that is universally
true by construction: a road always carries more than the one pop it
inherited, and still keeps something from its parent.
Still in flight and still unreviewed — no pull request against this commit either. The field-plan and map-generation suites landed their final edits, and the view-model, rendering and end-to-end journey files arrived. The 1:1 reviewer wave is what turns any of this into something that may reach main.
The 1:1 reviewer for the SplitMix64 and Entities suites recomputed all ten pinned golden vectors independently in Python — including the UInt64.max case that exercises the wrapping add — and every one matches byte for byte. It measured each statistical bound against the real fixed stream rather than trusting the builder's word: bit balance sits about fourteen standard deviations inside its bound, the avalanche mean thirty, and the tightest of them (the low-nibble bucket) still 2.6 sigma clear. Nothing needed widening, and every one of those tests is deterministic, so none of them can flake. Five constructs were rewritten because they had no precedent anywhere in this suite and could not be checked without a compiler — a mixed-type UInt64 stride, two mutating draws inside an assertion's autoclosures, an inout argument inside string interpolation, and an implicit member on the far side of a generic pair. One test was a tautology and is now stronger: it read every field of a copied orb back off the object it was copied from, which is true under value semantics, reference semantics and a broken copy alike. It now pins those twelve fields against the literals the parent was built from. Its sibling — the tripwire that fails if Orb ever becomes a class — was checked and really does do what it claims.
GameViewModelTests (21) pins the scoring formula factor by factor through the published surface — each rarity's base, the size factor as a multiplier rather than an addend, the fortune bonus added after the size factor rather than scaled by it, the chain curve to its saturation point, and a scripted three-field evening in which no lifetime number ever goes down. RenderingTests (20) covers what can be reached without a GraphicsContext: totality across 1,440 horizon geometries, the sRGB luminance measure that guardrail 4 is enforced with, 444 pop colours and every shell colour against saturation and pure-white ceilings, and the property that every pose in the Anima library fits inside the halo the renderer sizes from it. Both still await their 1:1 reviewers, and neither has been compiled.
A rolling snapshot while the reviewer wave works. The stores, audio and haptics suites are being audited symbol by symbol by their 1:1 reviewers, and the end-to-end journey suite is still being written. Nothing here is cleared yet and there is still no pull request against it.
A rolling snapshot. An orchestrator-level integration check passed meanwhile — the one no individual reviewer can perform, since each sees only its own files: all 37 test classes across the suite are uniquely named, and none of the eleven new files declares anything at top level, so every helper is nested inside its own class and a cross-file symbol collision is structurally impossible.
Twelve hermetic journeys — own defaults suite per test, own injected clock, fixed seeds, hard caps on every loop — covering a fresh install through a dozen fields, unlock monotonicity across a whole evening, the map growing and never shrinking, a replayed stone getting bigger while staying bounded and finishable, the deepest field the game can build, persistence across a relaunch, and the three-day settling probed one second either side of its threshold and with the clock moved backwards to the epoch. Its most valuable output is a finding rather than a test: the balloon animal's startle appears to be deleted by the weather in five of the six airs, and the comment in stepOrbs claims the ordering exists precisely to prevent that. Its reviewer is verifying the frame order and computing the actual clamped speeds before I touch anything — this one changes how the game feels, so it is the owner's call, not mine.
Two tracks that run safely beside the QA swarm because they touch no Swift: the docs are being reconciled against the code (pop_map's lineage rule still described the inverted version, pop_points had no row for the animal multiplier), and the store-facing material is being brought up to the build main actually carries. Also the map-generation suite's reviewer edits. All still in progress.
Every hard-coded table was re-derived from source rather than trusted: all seven rows of the stage composition, all seven reachable-orb counts (which compound splitter generations, so they were recomputed in Python), all six firework spot values, and the claim that generation twelve is the exact first one at the cap. Every one matched. The statistics were measured rather than assumed, and then measured again: because no Swift runs here, the reviewer re-ran all six proportions under four plausible models of how the standard library extracts bits from the generator. Every model landed inside every bound, so the result does not depend on the port being byte-exact. The tightest margin is the visitor share's upper edge at about three sigma. Nothing needed widening, and all three tests draw from one fixed seed stream, so they cannot flake. One hardening change: the determinism fingerprint was a seventeen-element heterogeneous array literal mixing property reads, ternaries and call expressions — the classic shape that makes Swift give up type-checking an expression. With no local compiler that is not a risk worth carrying, so it is now a homogeneous literal with the ternaries hoisted out. Two comments were corrected because they overstated the tests: the road roll's bounds are not all plus or minus 0.08 (the three-way is tighter), and the pop-count test pins the ROLL, not what a child stone ends up holding — branchedSet raises that to at least two.
The release-readiness track has reached the description and release notes — both were still written for v1.2, a game without weather, animals, fireworks or One World — and has begun docs/RELEASE_v1.3.md. The docs track is working through BUILD_PLAN, marking what is done, what the GDD superseded, and what remains, so a reader is not misled about where the project actually stands. Both still in progress.
…s rewritten MY OWN DOCUMENT WAS WRONG about the most basic gesture in the game. The e2e walkthrough said the journal is reached by swiping UP from the field. It is not. WorldCamera's sign convention is explicit — a finger moving up the screen makes a negative translation, which drags the camera to a negative offset, which is the SKY — and commitDirection agrees: a negative translation commits .up. So the journal is below, reached by swiping down. The release-readiness track caught the contradiction between the code and the checklist; the code is right. Both the committed walkthrough and the published checklist now say so, and section III gained an explicit direction check so a tester can tell a bug from the design rather than wondering which of us is confused. THE STORE LISTING IS REWRITTEN for the build main actually carries. The description still sold v1.2 and contained a claim that is now false — the
A rolling snapshot: the S4 and S6 reviewers are mid-audit on their files, and the docs track has reached CLAUDE.md. None of it cleared yet.
The world-integration session found the exact class of defect it was created to find, in code from earlier in this same session. SkyScroll's began() says a finger coming down on a gliding sky is a CATCH — the glide stops where it actually is, the way a real scroll view does. It did not. began() is reached only from .scrollBegan, and the arbiter emits that only from the slop-arming path, ten points into a drag. So the content coasted on under her finger for those ten points, and a TAP on a gliding sky never stopped it at all — while the stars she was pressing, which are positioned from the scroll offset, kept moving out from under the press. Each piece was individually right and the call meant to connect them sat on a path a touch-down never reaches. That is the same shape as the unreachable sky scroll this branch already fixed, which is why a session was pointed at the composed path rather than at any one component. The catch now happens where it belongs. The input layer gained an onTouchDown hook — strictly observational, like onPointer: it produces no outcome, consumes none, and cannot change what the arbiter decides — and it fires unconditionally in touchesBegan, before anything is arbitrated, so a tap stops a glide exactly as a drag does. began() keeps its second job of anchoring the gesture at arm time.
…laimed The view-model session's reviewer confirmed all three of its builder's findings against source. These are the fixes. RESTART NOW CANCELS THE ONWARD SEQUENCE, and this is the one that matters to her. During the 2.2-second pause in the sky — precisely the moment she is looking at the roads ahead — choosing a stone, leaving the Path, or beginning the field again did not stop the step that was already scheduled. Two seconds later it overwrote her choice and carried her somewhere she had not asked to go. The sequence is meant to be an offer she can decline, and choosing something else is the clearest way anyone declines anything. The step item also gained the sim.completed re-check its sibling always had, as the belt to that brace. A FIELD IN FLIGHT IS NOW STAMPED WITH AN EPOCH. frame() applies its work a main-queue hop later, and a restart can land in between — UIKit touch delivery is a run-loop source, not FIFO with the main queue, so it genuinely can. The stale block then did two bad things to a field that had already begun again: it set renderingPaused on a LIVE field, which freezes it permanently because that flag gates the timeline and the timeline is what would have cleared it; and it applied the previous field's events into the new session, where a pending clear would draw a verse and show the done card over a field with orbs still on it. Work from a field she has already left is now discarded whole. AND THE FORTUNE IS PINNED. Every document says exactly one fortune orb per field; nothing held the code to it. seedField picks it from the surface orbs that are plain, with randomElement, which answers nil for an empty collection — so a plan that dealt no ordinary orb onto the surface would give her a field with no fortune, silently. The new test sweeps every stage against every generation the plan reaches and proves that cannot happen, which is a better answer than defending against a case that does not exist. It also pins that the fortune never rides a splitter, a generator or a creature: on the busiest thing on screen it would be a prize, and on a plain orb it is a gift. Also corrected two comments that disagreed with their own code: a stone's family tie-break is the alphabetically first family name, not the lowest pop number (a stone holding one Vesper and one Aurora leans Aurora), and the counter is 30 pt, not the 40 the decluttering note claimed or the 62 a later comment still described.
Its reviewer is auditing the harness against GameViewModel — that comparison is the whole value of the file, since the harness re-implements the real bridge's event-to-store path and a divergence would mean the journeys assert a game that does not exist.
The reviewer audited the harness against GameViewModel line for line, which is the whole value of this file — the harness re-implements the real bridge because the bridge is not injectable, and a divergence would mean twelve journeys asserting a game that does not exist. They agree on all four seed inputs, on every event-to-store recording, and on the order of the two clears, which matters. It found and fixed one real divergence: the harness sized the field before saying what the field was, so it seeded a stage-0 field, threw it away, and re-seeded from an already-drawn generator. It also closed a hermeticity leak the builder missed — the threshold probes reused one UserDefaults instance across five stores, relying on a domain wipe being visible to an object that had already read those keys — and added popCounts to the ledger, so a per-pop tally that silently lost an entry is now caught rather than passing as "no counter decreased". The stall guard now lives inside the field-playing helper and names the stage, generation, orb count and taps, so any journey that fails to progress fails at the field where it happened rather than at whichever test happened to assert the cap. It confirmed the animal-startle bug with numbers: the dart is 6.3 to 9.4 times shorter in weather, covering about 25 points in clear air and about 8 in rain, at a constant speed — so it does not read as a dart at all. Two consequences follow that nothing had noticed: evadeMaxSpeed and animalMaxSpeed are both unreachable outside clear air, which means the creature is permanently slower than the constant that governs it. The calm test's ceilings are written to the INTENDED speeds, so they survive a fix rather than having to be corrected downward afterwards, and the comment now says why.
… complete The last of the eight. Its reviewer compared the harness against WorldInputView and WorldView line by line — ten points of comparison — and found the harness was missing both observational hooks production calls: onTouchDown (the glide catch this branch added an hour ago) and onPointer, in all four handlers. Both are now called in production's exact positions. Two further differences, the lost-touch recovery and multi-touch adoption, are deliberate and now documented as unreachable here rather than dropped. A new test pins the touch-down path: a TAP at the resting sky asks the arbiter for nothing, which is precisely why arming was too late a place to catch a glide; the touch-down call nevertheless runs, leaves offset, room, camera and both flags untouched, is idempotent, and does not come between her finger and a pop at the field. The reviewer declined to write the obvious companion test and was right to. Catching a glide is only observable as an offset that stops moving, and the glide steps on a Task that sleeps sixteen milliseconds and reads the wall clock — so any assertion would either wait on real time or pass for the wrong reason. It says so in the file instead, and names the fix: an injectable clock on the scroll state. One more vacuous test found, in the place hardest to notice. The fuzz session's latch implications held trivially for all fourteen thousand frames, because worldSettled and worldQuietened clear their flags on a main-queue hop and nothing in the loop ever yielded the main actor — so both flags were set once, early, and never cleared, while ten thousand deferred blocks queued up for a single drain at the end. The loop now drains every ten steps, which turns the clearing path into real coverage and bounds the backlog. Every loop in the file now fails rather than gives up: runToIdle used to hit its twenty-thousand-frame cap silently.
631 tests ran on the first build of eight thousand new lines. Everything compiled — the reviewer wave's symbol audits did the job they were for — and 626 passed. The 136 failure lines collapse to five tests and two causes, and neither was a mistake in the test's reasoning. SWIFTUI'S Path ROUND-TRIPS COORDINATES THROUGH SINGLE PRECISION. Four rendering tests read points back out of a Path and compared them to the Doubles that went in. The evidence is unambiguous: sin(50°) went in as 0.766044443118978 and came back as 0.7660444378852844, which is exactly the nearest Float; a 736-point coordinate came back one Float ulp away. The session's own reviewer had flagged Path inspection as the file's one unexercised platform unknown, since nothing else in the repo walks a Path — this is that risk arriving, and it is a fact about the platform rather than a fault in the code under test. A fixed absolute tolerance cannot serve those four tests, because the same error is five parts in a billion on a unit circle and six parts in a hundred thousand on a screen coordinate. They now share one documented helper that scales with the magnitude being compared: eighty times Float's own resolution, which absorbs the accumulation and is still far tighter than any real geometric error — a silhouette drawn in the wrong place is wrong by points, not by millionths. AND BUILDING A STORE WROTE TO DISK. "Constructing the store wrote vesper.progress.featured before the player did anything" — assigning that property in init runs its observer, and the observer persists. Reading is not writing, and a store that writes on launch cannot answer whether anyone has ever played. It now refuses to write a value the store already holds, which is the smaller and more honest guard: it fixes construction without adding a flag for init to special-case, and it stops needless churn everywhere else too. Also in this push, from the performance track: the sky's deep field — 338 ellipse fills, two screen-sized gradients and about sixteen hundred RNG steps — was being redrawn on every published scroll change, which during a drag is digitizer rate, despite depending on nothing but the view size. It is now an Equatable subview keyed on that size, which is a purely subtractive change: it removes SwiftUI's reason to re-run the subtree and cannot alter a pixel or a hit target. The star labels also stop rebuilding a lowercased catalogue name per star per frame.
The dead-code track's design note on the two authored-but-unimplemented break behaviours: what each was meant to do, which catalogue entries set them and what their flavour text promises, where each would have to be consumed, and the photosensitivity question the twinkle raises for a game whose guardrails forbid anything flashing. It ends where it should — with the choice between implementing them and retiring the flags, which is the owner's.
Removed, each proved dead by a repo-wide grep including the tests: three Strings entries, GameConfig.paintCount, FireworkKind.name (which was rawValue with no authored content, and whose doc comment claimed a VoiceOver use it never had), and PopFamily.displayName — not on the list, found by the sweep, and Title Case, which the lowercase-calm guardrail would have rejected the moment it reached a surface. Weather.name was KEPT. Unlike FireworkKind.name it is six hand-authored words with no equivalent anywhere, and it is what a VoiceOver reading of the field would need. Its comment now says plainly that nothing uses it, that the air is deliberately never named in UI, and that those six words are not in Strings.allStrings — so the voice guardrail does not cover them, and they must move there before they ever ship. This test's comment named the three deleted strings as examples. It now records what happened to them, which of them are recoverable and which is gone for good, and — while it is being edited — the hole in the test itself: it cannot tell the two navigations apart, so fortuneDismissHint counts as wired on the strength of a call site only the classic build constructs.
…ssumed From 136 failures to three, and the three corrected my explanation. I had reasoned that SwiftUI's Path round-trips through single precision, which is true of one vertex — sin(50°) comes back as exactly the nearest Float — but understates what happens to a value DERIVED from a path by about two orders of magnitude. CI measured it at three scales in one run: 3.899719 against 3.899658, 26.323059 against 26.322693, and 779.942352 against 779.931641. That is a relative error of 1.4e-5 at every magnitude, near enough 2^-16 — roughly a hundred times coarser than Float's own 1.2e-7, because the comparison is between two separately quantised measurements rather than between one value and its rounding. My 1e-5 bound sat just underneath it. The tolerance is now 1e-4 relative, which is about seven times the worst error observed and still far tighter than any real geometric fault: a silhouette drawn at the wrong scale is wrong by percent, not by thousandths of a percent. The comment records the measured numbers rather than the theory, so the next person to touch this does not have to rediscover them. Two assertions were also keyed to the wrong quantity — they scaled their tolerance to the centre a measurement was taken from rather than to the extent or delta actually being compared. They now account for both, since both measurements are quantised and one of them is quantised against a much larger number.
GLESAV
marked this pull request as ready for review
August 29, 2026 20:14
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Sixteen agents in two waves: eight isolated builder sessions, each owning a disjoint set of new test files so no two could touch the same bytes, then eight 1:1 reviewer sessions, one per builder, each re-deriving that builder's constants, auditing every symbol reference, and hunting tests that cannot fail. Alongside them, two cleanup tracks on non-Swift files.
The suite goes from 23 files to 35, and from 11,238 lines to 18,887. Every session's verdict was PASS.
Why the reviewer wave was not optional
There is no Swift toolchain in this environment — CI is the only compiler — so a builder cannot check its own work. But the reviewers earned their cost on test quality, not just compilation:
checkUnlocks()deleted entirely, because on nearly every run no threshold is crossed and only the "nothing new" branch executes. It was rebuilt to arrange the unlock, with a falsifiable guard.worldSettled/worldQuietenedclear their flags on a main-queue hop, and nothing in the loop yielded, so both were set once and never cleared while ~10,000 deferred blocks queued for one drain at the end.UserDefaultsinstance.Production defects found and fixed
Sessions were told to report, never fix — none owns production files — so these are the orchestrator acting on what came back.
Crashes.
ProgressionStorebuilt its per-pop tally withDictionary(uniqueKeysWithValues:), which traps on a duplicate key — and"7"and"07"both parse to 7. That is a crash on every launch, escapable only by deleting the app, which also deletes the journey the store exists to protect. In the sound engine, three inlineInt(freq.rounded())conversions (two feedingUInt64(), which traps again on a negative) and fiveAVAudioFrameCount(sampleRate * duration)conversions that ran before their own guard — so the guard could only catch exactly0, the one bad value that does not trap.A frozen field.
frame()applies its work a main-queue hop later, and arestart()can land in between. The stale block setrenderingPaused = trueon a live field — which freezes it permanently, because that flag gates the timeline and the timeline is the only writer that would clear it — and applied the previous field's events into the new session. Work is now stamped with a field epoch and discarded whole.A shove, in a game whose first rule is no pressure. During the 2.2-second pause in the sky, choosing a stone, leaving the Path or beginning again did not stop the already-scheduled step; two seconds later it overwrote her choice and carried her somewhere she had not asked to go.
restart()now cancels the onward sequence, and the step item gained thesim.completedre-check its sibling always had.A glide that would not stop.
SkyScrollState.began()says a finger landing on a coasting sky catches it. It did not:began()is reached only from.scrollBegan, which the arbiter emits only from the slop-arming path — so the sky coasted on under her finger for ten points, and a tap never stopped it at all while the stars she was pressing kept moving. This is the same shape as the unreachable sky scroll the last PR fixed: each piece right, the connecting call on a path a touch-down never reaches. The input layer now has anonTouchDownhook, observational likeonPointer, fired before any arbitration.A flaky test in our own suite. Two
MapStoreTestsasserted a road brings a pop its parent lacked.branchedSet's pool excludes siblings and already-chosen pops but not the parent's others, so that was an intermittent failure on correct code.Left for the owner — a feel decision, not mine
The balloon animal's startle is deleted by the weather in five of the six airs.
applyWeatherruns beforeAnimalMotion.stepand ends by clamping every orb to the weather ceiling; the startle's 2.0 is gone before the animal's own raised ceiling is consulted, and a ceiling can only lower a speed. Measured: 6.3× to 9.4× shorter — about 25pt in clear air against about 8pt in rain, at constant speed, so it does not read as a dart at all.evadeMaxSpeedandanimalMaxSpeedare likewise unreachable outside clear air.stepOrbs' own comment says the ordering exists precisely to prevent this. Fixing it changes how the game feels in five of six weathers, and guardrail 5 says tuning changes are deliberate and one at a time — so it is flagged, not changed. The e2e suite's ceilings are written to the intended speeds so they survive a fix rather than needing correction downward.Also open: the
bankLimitof 14 against ~100 profiles prepared (which defeats the pre-render and pushes synthesis onto the tap path), the detune variants that render identically because pentatonic snapping happens after detune, and three authored event pitches that clamp to one bucket.Cleanup tracks
Docs vs. code.
pop_map.mdstill described the inverted lineage rule;pop_points.mdhad no row for the animal multiplier and described UI that exists only in the classic navigation;RESUME.mdclaimed "VesperApp still launches ContentView", which is false;PLAYTEST.mdtold testers to use a DEBUG reset that has no call site anywhere. Also fixed: my own e2e walkthrough had the navigation backwards —WorldCamera's sign convention is explicit that a finger moving up reaches the sky, so the journal is below.Release readiness. The store listing described the v1.2 game and carried a claim W08 made false ("the road behind fades"). Description, release notes, promotional text, subtitle (28/30) and keywords (99/100) are rewritten;
anxietydropped as a health-claim risk. The privacy page now names what is kept on device and admits the one honest caveat. No-network re-verified by ~40 searches with zero real hits.docs/RELEASE_v1.3.mdadded.Blocking for submission, and not fixable here: all ten screenshots render UI that no longer exists — a four-icon top bar, a counter labelled
DETONATED, modal sheets titled "The Journey", and a caption reading "the road behind fades after three days". That is an App Store 2.3.3 accuracy risk. A scene-by-scene recapture plan is in the release doc. The project is also still atMARKETING_VERSION 1.2; 1.3 with build 2 is recommended, and whether this should be 2.0 is an open question given it is a navigation rebuild plus five systems.Verification
Nothing here has been compiled — that is what this PR is for. An orchestrator-level check no single reviewer could do: all 35 test classes are uniquely named and none of the new files declares anything at top level, so cross-file symbol collision is structurally impossible. Estimated CI cost of the new suites: ~20–40s across both nav configurations.
Generated by Claude Code