asterism branch T-2298/bugfix-…-clipping commits 3 files 9 touched (1 Swift test file, 8 markdown) lines +460 / -61

Pre-push review: T-2298/bugfix-stats-period-control-accessibility-clipping

PR #64 — T-2298 said the Stats period control clips at the largest accessibility Dynamic Type size. The branch measured it, found the control unclipped and the assertion unsatisfiable, and corrects the UI test and eight spec/notes documents. No app or package code changes.

At a glance

  • The reinterpretation holds. Req 7.8 forbids the period control clipping its labels or overlapping the graph. The old check, window.frame.contains(control.frame) on an unscrolled ScrollView, asserts "on screen", not "unclipped". The frame table is internally consistent (tiles at y 203.3 / 358.0 / 512.7 / 667.3, 142.7 pt tall, 12 pt apart → the fourth ends at y 810.0 of an 874 pt window), so nothing laid out below the header could ever have satisfied it — a Menu included. A criterion no implementation can pass is not measuring the implementation.

  • The new assertion is a faithful, if not complete, Req 7.8 test. After one scroll it asserts each of the six controls has minX ≥ window.minX and maxX ≤ window.maxX, a 44 pt target (Req 7.4), and maxY ≤ firstBand.minY ("not overlapping the graph"); then that the four enabled controls are isHittable. The one thing it cannot see — a label overflowing inside a segment frame — the old check could not see either, and the segment label is .lineLimit(1) + .fixedSize(horizontal: true), so it never truncates and would have to exceed ~342 pt to overflow a 370 pt stacked segment. The PR documents this residual gap in implementation.md rather than claiming it closed.

  • Non-vacuity was checked, not assumed: a temporary .offset(x: 120) on the capsule fails the new width assertion with 506 > 402 (probe reverted, not in the diff). Independent confirmation of the green suite is recorded in the Tests section.

  • Findings are all minor or nits and none block the push. The strongest is that the new minX/maxX pair re-implements assertInsideColumn (UIJourneySupport.swift:136, already called twice in this file), and that the hittability-loop comment overstates what scrollToElement-then-isHittable can catch. Per the caller's brief no code was changed in this review; these are recorded as skipped with reasons.

  • Docs: Decision 1 (full ADR) plus annotations in Q16, Q26, stats-page Q31, OVERVIEW.md, ipad-and-mac-layouts Decision 7 and verification-run.md, implementation.md and docs/agent-notes/testing.md all tell one story, and the third commit reconciled the "two always-enabled" wording to "four enabled". The one miss is ipad-and-mac-layouts/implementation.md:405-410, the only unannotated 'live breach' sentence left in the repo; Decision 1's format, anchor slug and the bugfix report's structure all check out, and CLAUDE.md needs nothing.

Verdict

Ready to push

The reinterpretation is right and the new assertion tests what Req 7.8 says. The measured frame table is arithmetically consistent (the fourth header tile ends at y 810.0 of an 874 pt window), so the old window.frame.contains check was unsatisfiable for any control below the header and a Menu would have failed it identically; the six controls sit at x 16…386 in a 402 pt window with every target ≥ 44 pt. The replacement — scroll once, then width containment, 44 pt, above stats-bar-0, and isHittable on the four enabled controls — is the form Q32 already uses in the same walk, and its non-vacuity was probed. No app code changed. Independent run in this review: make test-only TEST=AsterismUITests/AccessibilityJourneyUITests — 11 tests, 0 failures, 315.8 s; the corrected case in 47.4 s. Eleven findings, all minor or nits, none applied per the review brief. Two are worth the author's minute before pushing: specs/ipad-and-mac-layouts/implementation.md:405-410 still records the breach as live with no annotation (Decision 1's Impact says every such record was annotated), and the 'a disabled button is not hittable' justification for asserting four controls rather than six is stated in the test, Decision 1 and the report without having been verified — if it is wrong the loop could simply assert all six.

Review findings

11 raised · 0 fixed · 11 skipped

Jump to findings →

Tests

Pass rate: n/a

New tests: 0

Diff coverage: n/a

Jump to tests →

Commits

Three-level explanation

What changed

Asterism has an automated test that opens the Stats page with the phone's text set to the largest accessibility size and checks the page still works. One part of that test checks the period control — the Week / Month / All time toggle and the row with the back and forward arrows and the date button. A bug ticket (T-2298) said this control was being cut off at that text size, because the test failed six times, once for each button in the control.

This branch looked at the actual numbers. The phone's screen is 402 points wide and 874 tall. Every one of the six buttons sits between x = 16 and x = 386 — comfortably inside the width. They failed only because they were below the bottom of the screen: at the biggest text size the four number tiles above them are so tall that they alone reach y = 810, so the control starts at y = 830 and you have to scroll to see it. The test was asking "is this button entirely on screen right now?" — which is impossible for anything under those tiles — when the requirement only asks "is it cut off?".

So the app is untouched. The test now scrolls down to the control first, then checks each button stays inside the screen's width, is at least 44 points big, sits above the graph, and (for the four buttons that are enabled) can actually be tapped. The expected-failure wrapper that had been holding the six failures is removed, and eight documents that had recorded the wrong diagnosis are corrected.

Why it matters

Before this, the phone had no real coverage of the Stats page at the largest text size: the test had been running at the default size for its whole life (a launch argument UIKit silently ignored), and once that was fixed the check it ran was one nothing could pass. Now there is a real check, and the ticket is closed without changing a layout that measurement says is correct.

Key concepts

  • Dynamic Type: iOS lets users choose a text size; the accessibility sizes are the very large ones.
  • Clipping: a control is clipped when part of it is drawn outside its container and cut off. Being below the fold of a scrolling page is not clipping.
  • XCUITest frame: the test framework reports each element's rectangle in screen coordinates, even for elements scrolled off screen.
  • XCTExpectFailure: a wrapper that lets a known failure pass while a fix is pending. The strict form fails the test the day the failure stops happening, which is what this branch triggered.

Changes overview

  • Asterism/AsterismUITests/AccessibilityJourneyUITests.swift: in walkStatsAtLargestDynamicType, window.frame.contains(control.frame) for the six period controls becomes scrollToElement(stats-unit-week) once, then per control minX.rounded() ≥ window.minX.rounded() and maxX.rounded() ≤ window.maxX.rounded(), keeping the 44 pt and maxY ≤ firstBand.minY assertions. A second loop scrolls to each of the four enabled controls (stats-unit-week/month/all, stats-period-picker) and asserts isHittable. The strict XCTExpectFailure with its message-matching issueMatcher, and the continueAfterFailure = true it needed, are deleted; the case is back on the suite's continueAfterFailure = false.
  • specs/stats-period-navigation/decision_log.md: new full-format Decision 1 with the measured frame table; Q16 restated and Q26 struck as vacuous.
  • Annotations in stats-page Q31, OVERVIEW.md, ipad-and-mac-layouts Decision 7 and verification-run.md, stats-period-navigation/implementation.md, docs/agent-notes/testing.md; a new bugfix report under specs/bugfixes/.

Implementation approach

The branch treats the failing assertion as the unit under test. Two properties of the measured frames drive the decision: (1) all six controls have x ∈ [16, 386] in a 402 pt window — so no horizontal clipping; (2) the four header tiles end at y = 810 of 874 on their own, so full-window containment is unsatisfiable for any view below the header. The chosen replacement is Q32's form, already used three assertions later for the ranked-list heading, so one walk states one rule. The scroll happens once for the six-control loop so that every frame, including firstBand, is read at the same content offset — which is what makes the "above the graph" comparison meaningful. Hittability is asserted separately because the two disabled chevrons are never hittable.

Trade-offs

  • Weaker vertically, by intent. A control that drifted further below the fold without clipping now passes; the test no longer says anything about scroll distance. Decision 1 lists this as a negative consequence.
  • Not full-window containment after scrolling: rejected as flaky because scrollToElement swipes until hittable, so where a ~380 pt control lands is not controlled.
  • Not the Menu fallback (Q16): it would fail the same assertion and trade three visible labels for a control that has to be opened.
  • Header tiles keep the old form because they genuinely are on screen; a fifth tile would trip the same trap, and the doc comment says so.

Technical deep dive

The arithmetic in Decision 1 checks out: tile pitch is 154.7 = 142.7 + 12 pt spacing; 667.3 + 142.7 = 810.0; stats-unit-week at 830.0 = 810 + 20. Any view in the VStack below the tiles therefore has minY ≥ 830 > 874 − h for every plausible h, so CGRect.contains is false for all of them independent of the control's type — which is the load-bearing point: Q16 pinned a design decision to a single assertion without checking the assertion could come out both ways.

The new horizontal pair is strictly stronger than Q32's width ≤ window.width (which passes an element translated wholly off one edge) and is equivalent to assertInsideColumn in UIJourneySupport.swift modulo tolerance (.rounded() on both operands gives up to ~1 pt asymmetric slack; the helper uses an explicit ±1 pt). Both are frame checks on the Button, not glyph checks: the segment label is Text.lineLimit(1).fixedSize(horizontal: true, vertical: false) inside .frame(maxWidth: fillsWidth ? .infinity : nil), so it never truncates and could only overflow its segment if its ideal width exceeded 370 − 28 pt. ViewThatFits(in: .horizontal) selects the stacked arm exactly when the inline HStack would exceed the proposal, so at XXXL the capsule is three full-width 102 pt rows. None of the old, new, or helper forms could observe intra-frame overflow; the branch's implementation.md note says so explicitly.

scrollToElement is for _ in 0..<attempts where !element.isHittable { app.swipeUp() }. The where clause is a per-iteration filter, not a break, so on an already-hittable element it still performs attempts isHittable snapshot reads. Pre-existing, suite-wide, and harmless for correctness; it means the second loop costs ~4 × 9 snapshot reads in the green case. The isHittable assertion after it is bounded rather than tautological: it fails only if eight swipes never make the control hittable.

Architecture impact

None on the app: StatsView, ConstellationSegmentedControl and StatsPeriodNavigation are untouched. The test-side change removes the only continueAfterFailure = true in this suite, restoring fail-fast semantics for the case. Q16's contract ("the Menu fallback is triggered only by the no-clipping assertion failing") is restated to the scroll-then-width form; it remains a frame criterion, and the glyph-containment gap is now recorded twice (implementation.md and the iPad helper's doc comment).

Potential issues

  • Geometry dependence. The 402 × 874 / 810 / 830 reasoning is iPhone 17 Pro-shaped. On a taller simulator the first scroll may be a no-op and both loops pass at offset 0 — a silent degradation to a still-valid check, not a false failure. Acceptable, but the doc comments hard-code the numbers.
  • The hittability comment overstates. "A segment sitting under the tab bar while its siblings clear it" is resolved, not caught, by scrollToElement — the page always has the graph and ranked lists below the control, so a swipe lifts it out from under the bar. What the loop actually guarantees is "reachable within eight swipes".
  • Second-loop scrolls can move the page after the six frames were read; the subsequent isSelected and label reads do not depend on offset, so the ordering is safe.
  • Rounding on both sides can mask a sub-point overflow on either edge; consistent with Q32, and immaterial at 16 pt margins.
  • Decision 7's Status line in ipad-and-mac-layouts now reads accepted; the expectation was discharged …, which is outside the format's fixed vocabulary; the Outcome block under it carries the information, so the suffix is redundant.

Important changes — detailed

AccessibilityJourneyUITests: full-window containment → scroll, then width containment

Asterism/AsterismUITests/AccessibilityJourneyUITests.swift

Why it matters. This is the whole fix. The six Req 7.8 assertions that were red change from a criterion nothing on the page could satisfy to one that measures clipping. Everything else in the branch documents this.

What to look at. walkStatsAtLargestDynamicType, lines ~553-585: scrollToElement(stats-unit-week) once, then minX/maxX against window per control, plus the unchanged 44 pt and maxY ≤ firstBand.minY checks

Takeaway. On a ScrollView, window.frame.contains(element.frame) asserts 'on screen', not 'unclipped'. Scroll first, then compare the element's horizontal extent with the container's; add isHittable when reachability is the point. The measured frame table (tiles ending at y 810 of 874) is what turned an inference into a finding.
Rationale. Decision 1 in specs/stats-period-navigation: Req 7.8 forbids clipping and overlap, not being below the fold; Q32 already used the scroll-then-width form for the ranked lists in the same walk; the old criterion failed for every possible implementation, so it was not measuring the control.

AccessibilityJourneyUITests: hittability asserted for all four enabled controls

Asterism/AsterismUITests/AccessibilityJourneyUITests.swift

Why it matters. 'Reachable' is what the test case is named for and had never been asserted for this control. Unclipped and reachable are different claims; the second commit widened the loop from two controls to every enabled one.

What to look at. lines ~586-600: for stats-unit-week/month/all and stats-period-picker, scrollToElement(attempts: 8) then XCTAssertTrue(isHittable)

Takeaway. Disabled buttons are never hittable in XCUI, so a hittability loop must enumerate the enabled subset explicitly and say why (here: both chevrons are disabled in the seeded one-capture state).
Rationale. Commit cca0b47: the first draft checked two controls and called them 'the two that are always live', but only the chevrons are disabled, so stats-unit-week and stats-unit-all were never asserted hittable.

AccessibilityJourneyUITests: strict XCTExpectFailure and continueAfterFailure = true removed

Asterism/AsterismUITests/AccessibilityJourneyUITests.swift

Why it matters. Restores fail-fast for the case. The continueAfterFailure = true was there only because a strict expected failure stops the test otherwise; with no expectation, the case is back on the suite's setUp default.

What to look at. lines ~181-215 (doc comment rewritten; the XCTExpectedFailure.Options block and the continueAfterFailure line deleted)

Takeaway. A strict XCTExpectFailure is a tripwire that fires when the bug is fixed — and it fired here in the sense that the 'bug' turned out to be the assertion. Removing it is the correct discharge, not a silencing.
Rationale. ipad-and-mac-layouts Decision 7 held the six failures under a strict expectation so that the day T-2298 was fixed the test would fail on the unfulfilled expectation. T-2298 is fixed; the expectation is discharged and the Outcome note in Decision 7 records why.

stats-period-navigation Decision 1: the finding, with the frame table

specs/stats-period-navigation/decision_log.md

Why it matters. The evidentiary record. Q16 pinned a design decision (capsule vs Menu) to this one assertion, and Q26 recorded a pass at a text size that was never applied; Decision 1 re-establishes the capsule on measured frames and lists four rejected alternatives.

What to look at. Decision 1 (new, ~120 lines) and the Q16 / Q26 rows

Takeaway. When a spec ties a decision to a single measured criterion, check the criterion can discriminate — that it can fail for a wrong implementation and pass for a right one. Q16 had the right instinct (measure, don't eyeball) and the wrong instrument.
Rationale. Stated in the entry: Req 7.8's wording, Q32's precedent, the unsatisfiability argument, and the .offset(x: 120) non-vacuity probe.

Seven documents annotated rather than rewritten

specs/ipad-and-mac-layouts/verification-run.md

Why it matters. The old diagnosis ('it runs off an edge') appears in Decision 7, verification-run.md, Q31, OVERVIEW.md and testing.md. Each is corrected with a dated note that keeps the original sentence as the record of what was believed on 2026-09-01.

What to look at. verification-run.md §Tasks 32–34 (two blockquotes and a strike-through); Decision 7 Outcome block; stats-page Q31; OVERVIEW.md two rows; testing.md 'general lesson' paragraph

Takeaway. Annotate-and-date is the repo's convention for superseding a recorded belief: the history stays readable and every pointer resolves to the correction.
Rationale. Commit c374016 body: 'the vacuous records it corrects annotated in …'; the Decision 7 note states explicitly why the 'live breach' sentence is left as written.

Key decisions

Correct the assertion, not the control.

Req 7.8 forbids clipping and overlap; being below the fold is neither. The measured frames put all six controls at x 16…386 in a 402 pt window with the stacked ViewThatFits arm taken and every target over 44 pt. The old window.frame.contains check fails for anything under the four 142.7 pt tiles (which end at y 810 of 874), so a Menu would have failed it identically. Source: Decision 1.

Use Q32's scroll-then-width form, not full-window containment after scrolling.

Containment-after-scroll was rejected as flaky: scrollToElement swipes until hittable, so where a ~380 pt control lands is uncontrolled and a run that leaves the picker under the tab bar would be a red that says nothing. Width containment is the criterion the same walk already uses for the ranked-list heading, so one page states one rule. Source: Decision 1, alternatives.

One scroll for the six-control loop; a per-control scroll for the hittability loop.

The six frames (and firstBand's) must be read at one content offset for maxY ≤ firstBand.minY to mean anything, and the capsule plus chevron row is under 400 pt so one scroll brings all of it on screen. The hittability loop re-scrolls defensively per control. Source: inline comments at lines ~553-557 and ~586-591. The reuse reviewer notes the two comments sit slightly at odds — if one scroll puts everything on screen, the per-control scrolls are no-ops — which is a comment nit, not a logic error.

Assert hittability for the four enabled controls, not two.

Only the chevrons are disabled in the seeded state (one capture, today), and a disabled button is never hittable. The first draft asserted two and called them 'the two that are always live'; commit cca0b47 widened it to every segment plus the picker, and 9cad2d8 reconciled the docs. Source: commit bodies.

Keep the header tiles on the old containment form.

At this size the four tiles genuinely are on screen (the last ends at y 810 of 874), so the assertion is meaningful there. The doc comment warns that a fifth figure would trip the same trap and names the fix. Source: Decision 1, negative consequences; doc comment on walkStatsAtLargestDynamicType.

Annotate the superseded records; do not rewrite them.

Decision 7, verification-run.md, Q26 and Q31 keep their original sentences with a dated correction beside them (strike-through, blockquote, or 'Restated'/'vacuous' marker). Decision 7 says explicitly the 'live breach' sentence 'is left as written, because what it records is what was believed on 2026-09-01'. Source: the diff.

Rounding both operands of the width comparison.

Both the new pair and Q32's existing form call .rounded() on both sides. No rationale is stated; the effect is up to ~1 pt of slack in either direction. assertInsideColumn uses an explicit ±1 pt instead. Immaterial at 16 pt margins; noted so a reader does not take the rounding for a precision guarantee.

(inferred — not stated by the author.)

Review findings

SeverityAreaFindingResolution
minorAccessibilityJourneyUITests.swift:575-581 (reuse)The new minX/maxX pair against the window re-implements assertInsideColumn (UIJourneySupport.swift:136), which this same file already calls at lines 883 and 892 and whose doc comment records the 'a clipped label still reports a frame inside its box' caveat the new comment restates. The one behavioural difference is tolerance: the helper uses an explicit ±1 pt, the new code rounds both operands.Replace the pair with assertInsideColumn(control, column: window, what: "\(identifier) at the largest Dynamic Type size"). Not applied: this review was asked not to change code. Author's call; either form is correct.
minorspecs/ipad-and-mac-layouts/implementation.md:405-410, 513-516 (docs)Still reads 'a live breach of stats-page Req 7.8 filed as T-2298 … turns red the day T-2298 lands', with no dated annotation. Decision 1's Impact section says every record of the assumed breach was annotated; this one was missed. It is the only remaining unannotated T-2298 mention in specs/, docs/ and the test bundle.Add the same one-line 'Superseded in its diagnosis (2026-09-05, T-2298) — see stats-period-navigation Decision 1' note verification-run.md uses. Not applied: no files were modified in this review. Recommended before push.
minorAccessibilityJourneyUITests.swift:586-591 and Decision 1 (correctness of a stated premise)The chevrons are excluded from the hittability loop because 'a disabled button is not hittable'. XCUIElement.isHittable is a geometric property (a hit point can be computed); isEnabled is separate, and a visible disabled button ordinarily reports hittable. Nothing in the repo verifies the premise. If it is wrong, the loop could assert all six controls, and the comment, Decision 1 and the report state something false about XCUI.Verify with one run; if disabled controls are hittable, widen the loop to all six and drop the justification. Not applied: unverified and requires a code change.
minorAccessibilityJourneyUITests.swift:586-600 (comment vs behaviour)'A segment sitting under the tab bar while its siblings clear it is exactly the regression this is for' — but scrollToElement swipes until hittable and the page always has the graph and ranked lists below the control, so a tab-bar occlusion is scrolled away rather than caught. What the loop guarantees is 'reachable within eight swipes'.Reword the comment to the reachability claim, or assert isHittable at the offset the frames were read at (no re-scroll) if the tab-bar case is the one intended. Comment-only; not applied.
nitAccessibilityJourneyUITests.swift:534-537 (Q16 comment)'This is the only criterion for the capsule … by this assertion failing' overstates: a frame check cannot see a label overflowing inside its segment. Pre-existing — the old containment check had the same blind spot — and documented in implementation.md; the label is .lineLimit(1).fixedSize(horizontal: true) so truncation is impossible by construction.Optional softening of the comment. Not applied.
nitAccessibilityJourneyUITests.swift:674-678 scrollToElement (pre-existing)for _ in 0..<attempts where !element.isHittable { app.swipeUp() } — the where clause is a per-iteration filter, not a break, so an already-hittable element still costs `attempts` isHittable snapshot reads. The new four-control loop therefore does ~36 reads in the green case.Pre-existing helper used by ~15 call sites; out of scope for this branch. A guard/break form would help suite-wide.
nitAccessibilityJourneyUITests.swift:563-585 (efficiency)window.frame is read three times and firstBand.frame once per iteration for values that cannot change inside the loop: ~24 avoidable XCUI snapshots, roughly 1–4 s of a ~52 s case.Hoist let windowFrame = window.frame and let graphTop = firstBand.frame.minY above the loop — which would also document the same-offset invariant the comment asserts. Not applied.
nitAccessibilityJourneyUITests.swift:575-581 (messages, rounding)Both width assertions carry an identical failure message, so a red does not say which edge breached; and rounding both operands gives up to ~1 pt of slack whose sign depends on the fractional parts. Consistent with the Q32 form already in the file.Distinct leading/trailing messages; an explicit epsilon if precision matters (it does not at 16 pt margins). Not applied.
nitAccessibilityJourneyUITests.swift:559-561, 592-594 (duplication)The four-identifier hittability list is a literal subset of the six-identifier list; a single constant would remove the drift risk between the two loops.let unitSegments = […]; let periodControls = unitSegments + […]. Not applied.
nitspecs/ipad-and-mac-layouts/decision_log.md Decision 7 (format)Status is 'accepted; the expectation was discharged on 2026-09-05 (T-2298)', outside the decision-log format's fixed vocabulary. The Outcome blockquote directly below already carries the information.Revert the Status value to 'accepted'. Not applied.
nitTransit T-2298 comment; specs/stats-period-navigation/smolspec.md:46 (docs)The spec reviewer reports the 2026-09-05 06:53 comment on T-2298 still says 'its two always-enabled controls are hittable' (the repo docs were reconciled to four; the ticket was not). smolspec.md:46 still names 'the no-clipping assertion' as the capsule's arbiter with no pointer to Decision 1.A short follow-up comment on the ticket when it is closed; an optional '(see Decision 1)' in the smolspec.

Tests

Source: local run at 2026-09-05T17:22:00+10:00 · snapshot 9cad2d8b6d1dd1155545199b8f76b1c6835bbbb5

Baseline: none

Execution: passed · JUnit: none · Coverage: none · Baseline: absent

Coverage scope: as the project configures it

No test results

The test runner could not be detected.

New and removed tests

Derived by declaration name, from the diff (no baseline run).

No new or removed test declarations.

Blast radius

Files that import a changed file on the left, changed files in the centre, files a changed file imports on the right. Snapshot working-tree against base 30c1573050a3432567c9cbdf96d94eab46194f0f.

addedmodifieddeletedrenamedunchangedcollapsed package group or +N more⚑N test files with an edge to the node

Per-file diffs

Click to expand.

Asterism/AsterismUITests/AccessibilityJourneyUITests.swift Modified +60 / -42
diff --git a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swiftindex 35f4eeb..6092090 100644--- a/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift+++ b/Asterism/AsterismUITests/AccessibilityJourneyUITests.swift@@ -181,29 +181,22 @@ final class AccessibilityJourneyUITests: XCTestCase {     /// the wrong reason. Found while writing `WideLayoutAccessibilityUITests`     /// (which uses the real spellings) and corrected here.     ///-    /// Correcting it exposes one live breach, and only one: at the true XXXL-    /// the Stats period row runs past the window's edge — all six of its-    /// controls, one failure each, all the same sentence — which is a-    /// `specs/stats-page/` Req 7.8 breach on the **phone** layout, filed as-    /// **T-2298**. Nothing else in the journey fails at that size. `ipad-and-mac-layouts` changes no iPhone layout (its-    /// non-goals say so), so the breach is not fixed here; it is held under a-    /// *strict* `XCTExpectFailure` scoped to that one message, so the rest of-    /// the journey runs at the size it always claimed to, an unrelated-    /// regression at this size is still a red, and the day T-2298 is fixed this-    /// test fails on the unfulfilled expectation rather than staying quietly-    /// green.+    /// Correcting it turned the Stats period-control loop red six times over —+    /// `stats-unit-week` / `-month` / `-all`, `stats-period-back` / `-picker` /+    /// `-forward`, all "must stay inside the window at the largest Dynamic Type+    /// size" — and that was carried as a strict `XCTExpectFailure` naming+    /// **T-2298** while `ipad-and-mac-layouts`, which changes no iPhone layout,+    /// went out. **T-2298 measured it and the breach was the assertion's, not+    /// the control's** (`specs/stats-period-navigation/` Decision 1): on a 402 × 874+    /// window every one of the six sits at x 16…386 — inside the window's width,+    /// unclipped, over 44 pt — and fails only because the page has scrolled it+    /// past y = 874. The four header tiles are 142 pt each at this size, so they+    /// alone end at y = 810; *nothing* placed under them can be wholly inside+    /// the window, whatever control it is. The expectation is gone and the loop+    /// now asserts what Req 7.8 actually forbids — see+    /// `walkStatsAtLargestDynamicType`.     @MainActor     func testDarkReduceTransparencyLargestDynamicTypeKeepsPrimaryJourneysReachable() {-        // **This one case runs on past a failure**, and it has to. `setUp` sets-        // `continueAfterFailure = false` for the suite, and that stops the test-        // on an *expected* failure too — measured here with a probe `XCTFail` at-        // the end of the journey, which never fired while the T-2298 expectation-        // below was being absorbed. The case would have gone green having walked-        // only as far as the Stats period control. Everything after the-        // expectation is the journey this test is named for, so the flag is-        // lifted for the case rather than the expectation being dropped.-        continueAfterFailure = true-         launchSeeded(             extraArguments: [                 "-AppleInterfaceStyle", "Dark",@@ -212,24 +205,7 @@ final class AccessibilityJourneyUITests: XCTestCase {             ]         ) -        // Matched on the message, not on one identifier: the whole period row-        // is outside the window, so the same assertion fails six times, once-        // per control. Every other assertion in the walk — the labels, the 44 pt-        // targets, the controls sitting above the graph, the four totals — is-        // expected to hold, and an `issueMatcher` this narrow is what keeps a-        // regression in those a red.-        let clipping = XCTExpectedFailure.Options()-        clipping.issueMatcher = { issue in-            issue.compactDescription.contains(-                "must stay inside the window at the largest Dynamic Type size")-        }-        XCTExpectFailure(-            "T-2298: the Stats period control runs past the window at accessibility5 "-                + "(stats-page Req 7.8, a phone-layout breach this feature does not own)",-            options: clipping-        ) {-            walkStatsAtLargestDynamicType()-        }+        walkStatsAtLargestDynamicType()          selectTab(.recent, in: app)         openSeededEntry()@@ -527,6 +503,13 @@ final class AccessibilityJourneyUITests: XCTestCase {     /// above the topmost band control — the bands are the graph's own geometry,     /// laid out by the chart's scale, so nothing between the two can have been     /// pushed over them either.+    ///+    /// The header tiles keep whole-window containment because at this size they+    /// genuinely are on screen; the period controls below them are not, so they+    /// are scrolled to and measured on width alone (`stats-period-navigation`+    /// Decision 1). Adding a fifth header figure would push the fourth off the+    /// bottom and fail that first loop for a reason that is not a defect —+    /// change it to the scroll-then-width form when that day comes.     private func walkStatsAtLargestDynamicType() {         let stats = app.tabControl(.stats)         XCTAssertTrue(stats.waitForExistence(timeout: 30), "Stats tab should exist")@@ -552,10 +535,27 @@ final class AccessibilityJourneyUITests: XCTestCase {         // segments cannot be drawn unclipped at this size, the toggle becomes a         // `Menu` of the three units — by this assertion failing, not by an         // implementer's eye.+        //+        // **Clipping is a width question, not an is-it-on-screen one**+        // (`stats-period-navigation` Decision 1; Q32 there drew the same line+        // for the ranked headings below). This loop used to demand+        // `window.frame.contains(control.frame)` on an unscrolled page, which at+        // this text size asks the whole page to fit one screen — the four tiles+        // above end at y = 810 of an 874 pt window on their own, so no control+        // under them could ever satisfy it and a `Menu` would fail it exactly as+        // the capsule does. So the page is scrolled to the control first, and+        // what is then asserted is that it runs off neither side and is+        // reachable.         let firstBand = app.buttons["stats-bar-0"]         XCTAssertTrue(firstBand.waitForExistence(timeout: 15), "The graph's bands should render")          let period = app.buttons["stats-period-picker"]+        // One scroll for all six, not one each: the capsule and the chevron row+        // together are under 400 pt at this size, so bringing the first segment+        // into view puts the whole control on screen and every frame below is+        // read at the same offset — which is what makes the "above the graph"+        // comparison mean anything.+        scrollToElement(app.buttons["stats-unit-week"], attempts: 8)         for identifier in [             "stats-unit-week", "stats-unit-month", "stats-unit-all",             "stats-period-back", "stats-period-picker", "stats-period-forward",@@ -573,13 +573,31 @@ final class AccessibilityJourneyUITests: XCTestCase {             XCTAssertGreaterThanOrEqual(                 control.frame.height.rounded(), 44,                 "\(identifier) should be at least 44 points tall")-            XCTAssertTrue(-                window.frame.contains(control.frame),-                "\(identifier) must stay inside the window at the largest Dynamic Type size")+            XCTAssertGreaterThanOrEqual(+                control.frame.minX.rounded(), window.frame.minX.rounded(),+                "\(identifier) must stay inside the window's width at the largest Dynamic Type size")+            XCTAssertLessThanOrEqual(+                control.frame.maxX.rounded(), window.frame.maxX.rounded(),+                "\(identifier) must stay inside the window's width at the largest Dynamic Type size")             XCTAssertLessThanOrEqual(                 control.frame.maxY, firstBand.frame.minY,                 "\(identifier) must sit above the graph, not over it")         }+        // Unclipped is half of it; the reader also has to be able to work the+        // control once they have scrolled to it. The chevrons are disabled here+        // (one capture, today), and a disabled button is not hittable, so the+        // four that are live are the ones asserted — every segment, the+        // selected one included, because a segment sitting under the tab bar+        // while its siblings clear it is exactly the regression this is for.+        for identifier in [+            "stats-unit-week", "stats-unit-month", "stats-unit-all", "stats-period-picker",+        ] {+            let control = app.buttons[identifier]+            scrollToElement(control, attempts: 8)+            XCTAssertTrue(+                control.isHittable,+                "\(identifier) must be reachable at the largest Dynamic Type size")+        }         XCTAssertTrue(             app.buttons["stats-unit-week"].isSelected,             "The page opens on Week, which is the unit this journey walks")
docs/agent-notes/testing.md Modified +18 / -7
diff --git a/docs/agent-notes/testing.md b/docs/agent-notes/testing.mdindex be5cd09..e939460 100644--- a/docs/agent-notes/testing.md+++ b/docs/agent-notes/testing.md@@ -520,13 +520,24 @@ non-accessibility size. The same launch with `…AccessibilityXXXL` collapses it **The consequence, and how it was settled** (T-2298): `testDarkReduceTransparencyLargestDynamicTypeKeepsPrimaryJourneysReachable` and the `walkStatsAtLargestDynamicType` pass inside it had therefore never run at an-accessibility size. Corrected on the iPhone destination, that journey fails on-exactly one assertion — `stats-unit-week must stay inside the window at the-largest Dynamic Type size` — which is a live `specs/stats-page/` Req 7.8 breach-on the phone's Stats period control. The string is now correct and that one-failure is held under a **strict** `XCTExpectFailure` naming T-2298, so the-journey runs at the size it claims and the day the control is fixed the-expectation goes unfulfilled and the test says so.+accessibility size. Corrected on the iPhone destination, the six controls of the+Stats period row failed one assertion each —+`… must stay inside the window at the largest Dynamic Type size` — and that was+carried under a strict `XCTExpectFailure` naming T-2298 while+`ipad-and-mac-layouts` went out.++**T-2298 then measured it, and the assertion was the defect, not the control**+(`specs/stats-period-navigation/` Decision 1). On a 402 × 874 window the six sit+at x 16…386 — inside the width, unclipped, the capsule's stacked `ViewThatFits`+arm already taken, every target over 44 pt — and fail only because the page has+scrolled them past y = 874. **The general lesson:**+`window.frame.contains(element.frame)` on a `ScrollView` asks the whole page to+fit one screen, which at an accessibility text size it never does. Here the four+header tiles are 142.7 pt each and end at y = 810 on their own, so no control+below them could pass and Q16's `Menu` fallback would not have helped. Assert+**width** inside the window *after scrolling the element into view* — that is+what "does not clip" means — plus `isHittable` for "reachable". The expectation+and its `continueAfterFailure = true` are gone.  Two things that cost a run each, worth knowing before writing the next one: 
specs/OVERVIEW.md Modified +3 / -3
diff --git a/specs/OVERVIEW.md b/specs/OVERVIEW.mdindex 969c962..2a07a72 100644--- a/specs/OVERVIEW.md+++ b/specs/OVERVIEW.md@@ -33,8 +33,8 @@ | [Rule Citation by UUID](#rule-citation-by-uuid) | 2026-08-29 | Done — all 10 tasks complete 2026-08-29; `make test-core`, `make test-quick` and `make build` green with no new warnings, the Req 7.1 grep gate clean, and two `make test-performance-m4` runs inside every ceiling with the Req 5.4 capture-projection arms unmoved (verification-run.md). Branch not yet merged | Full spec (T-2281), spec B of Post-V8 Convergence; supersedes T-2055. `CitedRule` becomes `{id}` — the version integer leaves every citation, and with it the Site-unique/greatest-version invariants, `SiteUnionProjection` renumbering, both reconcilers' citation-rewrite walks and the export rewrite map. "Current" is the marked row with a `(createdAt desc, id)` tiebreak behind two `Site` accessors (Decision 1, Q11); the row `version` column stays advisory. No schema stage; archive 7/8 → 8/9 with the `BackupV7*` → `BackupV8*` rename (Q14). Single device, so no rollout gate (Q8). | | [Wrong-Host Work URL Heal](#wrong-host-work-url-heal) | 2026-08-29 | Done | Full spec (T-2294). A membership Work URL on another host — residue of builds before per-hostname Work URLs — becomes a Work-keyed tolerated diagnosis that quarantines nothing, produced by both the full validation and the foreground scan; a `MembershipReconciler` phase moves each value to the membership for its host (minting the membership, never a Site row), backup import applies the same precedence where the destination write is known to land, and the three membership folds carry a discarded row's URL. Supersedes multi-site-works Q97; amends library-integrity-tolerance Decisions 3 and 4. | | [Character Ranking](#character-ranking) | 2026-08-30 | Done | T-2273. Orders a work's characters by prominence instead of name: each character's facts are bucketed by story position, scored `Σ 2^(-d/10) · log2(n+1)` with `d` the ordinal distance from the latest chapter, and ranked descending with name order as the tie-break. One derived order for the work page and the share sheet (overrules share-sheet-characters Q1); no schema change. |-| [Stats Period Navigation](#stats-period-navigation) | 2026-08-30 | Done — all 7 tasks implemented 2026-08-31 across three phases (derivation, view, documentation); `make test-quick`, `AsterismUITests/StatsUITests` and `AsterismUITests/AccessibilityJourneyUITests` green with no new warnings. Q16's `Menu` fallback was **not** needed: the capsule passes the no-clipping assertion at `AccessibilityExtraExtraExtraLarge` (Q26) | Smolspec (T-2216). Replaces the Stats page's five-period `Menu` with a Week / Month / All time toggle, back and forward chevrons and a date picker, so any week or month in the library's history is reachable directly and clamped at both ends; an All-time bar switches to that month in place of the pushed month screen. Adds two top-five ranked lists for the shown period — most-read works and most-read sites (capture hostnames), counted on first capture. App-layer only; supersedes in part `stats-page` Reqs 3.1, 3.2, 5.2–5.5, Req 2.10's All-time exclusion, its site and "five named periods" non-goals, Decision 3's drill-down half, Q30, Q31 and Q55, and rewrites design §5.4. |-| [iPad and Mac Layouts](#ipad-and-mac-layouts) | 2026-08-28 | Done — all 34 tasks implemented 2026-09-01 across five phases and four review-fix rounds; `make verify-identity`, `make test-quick` (with the Mac build and appex), `make test-ui-ipad` (12/12) and the iPhone journeys green (pre-existing M4Scale sim trio excepted). Remaining the owner's: the 46-row manual Mac/iPad checklist in `verification-run.md` (the Mac sky is still visually unverified), and four open questions — Req 1.7's pane-wide pushes (F4), the detail column's missing title (A10/F6), Req 6.1's wording (C4, Q49), and Req 9.4 vs `AdaptiveColorTests` (G1). T-2298 filed for the pre-existing phone Stats accessibility breach the new suite exposed | T-2286. Gives the iPad and the Mac a layout of their own — a sidebar with the three tabs beside list and detail columns, collapsing to the phone layout as the window narrows — and brings the app and a share extension to the Mac as a native SwiftUI build against the same CloudKit-mirrored library. Navigation state moves into one `AppNavigation` object owned by the App; two files hold every platform conditional; a spool directory watcher and a visibility-based lifecycle replace the phone's activation semantics on the Mac. Design canvas in `docs/ipad-and-mac/`. |+| [Stats Period Navigation](#stats-period-navigation) | 2026-08-30 | Done — all 7 tasks implemented 2026-08-31 across three phases (derivation, view, documentation); `make test-quick`, `AsterismUITests/StatsUITests` and `AsterismUITests/AccessibilityJourneyUITests` green with no new warnings. Q16's `Menu` fallback was **not** needed — re-established on measured frames by Decision 1 (T-2298, 2026-09-05) after Q26's original pass turned out to have been taken at a text size UIKit never applied | Smolspec (T-2216). Replaces the Stats page's five-period `Menu` with a Week / Month / All time toggle, back and forward chevrons and a date picker, so any week or month in the library's history is reachable directly and clamped at both ends; an All-time bar switches to that month in place of the pushed month screen. Adds two top-five ranked lists for the shown period — most-read works and most-read sites (capture hostnames), counted on first capture. App-layer only; supersedes in part `stats-page` Reqs 3.1, 3.2, 5.2–5.5, Req 2.10's All-time exclusion, its site and "five named periods" non-goals, Decision 3's drill-down half, Q30, Q31 and Q55, and rewrites design §5.4. |+| [iPad and Mac Layouts](#ipad-and-mac-layouts) | 2026-08-28 | Done — all 34 tasks implemented 2026-09-01 across five phases and four review-fix rounds; `make verify-identity`, `make test-quick` (with the Mac build and appex), `make test-ui-ipad` (12/12) and the iPhone journeys green (pre-existing M4Scale sim trio excepted). Remaining the owner's: the 46-row manual Mac/iPad checklist in `verification-run.md` (the Mac sky is still visually unverified), and four open questions — Req 1.7's pane-wide pushes (F4), the detail column's missing title (A10/F6), Req 6.1's wording (C4, Q49), and Req 9.4 vs `AdaptiveColorTests` (G1). T-2298 filed for the pre-existing phone Stats accessibility breach the new suite exposed — **closed 2026-09-05** with no layout change: the assertion was the defect, not the control (`stats-period-navigation` Decision 1) | T-2286. Gives the iPad and the Mac a layout of their own — a sidebar with the three tabs beside list and detail columns, collapsing to the phone layout as the window narrows — and brings the app and a share extension to the Mac as a native SwiftUI build against the same CloudKit-mirrored library. Navigation state moves into one `AppNavigation` object owned by the App; two files hold every platform conditional; a spool directory watcher and a visibility-based lifecycle replace the phone's activation semantics on the Mac. Design canvas in `docs/ipad-and-mac/`. | | [Works List Options](#works-list-options) | 2026-09-03 | Done — all 10 tasks implemented 2026-09-03 across three phases (pure logic, the list, fixture and journeys); `make test-quick` and `make test-ui` green (pre-existing M4Scale sim trio excepted) with no new warnings. The Mac toolbar menu's rendering remains the owner's manual check | Smolspec (T-2302). A four-way sort for the Works list (Newest first, Oldest first, A to Z, Z to A) and one-value filters by type, tag and site, from one toolbar menu on iPhone, iPad and Mac. The sort persists on the device; filters are view state with the search query's lifetime. Empty works keep their trailing section under the date sorts and join one section under the title sorts. App-layer only, on `WorkSnapshot`; amends `polish-and-export` Req 4.1's fixed-ordering clause. | | [Background Export](#background-export) | 2026-09-02 | Done — all 12 tasks implemented 2026-09-03 across four phases (Core, App, Extension, Documentation). Device verification remains the owner's: the eight-step runbook in `runbook.md`, `Development` first and `Personal` last after a container download, every step approved at the moment of running | Full spec (T-2052). An iOS app-refresh background task that lets the app's CloudKit mirror export captures the share extension committed with mirroring off, so a share on the phone reaches other devices without the app being opened. The extension leaves one empty UUID-named marker file per commit; a marker is settled by any successful export whose start is later than the marker's creation, checked against a persisted export start before any wait. The pass is a mode of `AppLibraryModel`: it reuses a live library or opens and shuts one of its own, and a foreground open pre-empts it. Refresh-only, 20 s budget, iOS only (BackgroundTasks does not exist on macOS); the Mac keeps the foreground marker clearing. | | [Site Display Names](#site-display-names) | 2026-09-04 | Done — all 9 tasks implemented and verified 2026-09-04; `make test-core`, `make test-quick` and `make test-ui` green (the three pre-existing M4Scale sim cases excepted) with no new warnings | Smolspec (T-2303). The reader can rename a site from its Settings screen, and the display name replaces the hostname on every surface that names a site — Sites screens, work detail row and pickers, works rows and their site filter, merge preview, Stats most-read sites, entry detail, Recent's accessibility label — with the hostname appended wherever two sites share a name. The site screen also links to `https://<hostname>/`. Uses the existing `Site.displayName` column: no schema, archive-format or sync-attribute change. One name rule for a hostname with several rows (first custom name in resolution order) shared by the Sites read, export and the archive projection; the rename writes every row for the hostname; names reach the views through an app-layer lookup published as a SwiftUI environment value, never via core snapshots. |@@ -575,7 +575,7 @@ Smolspec (T-2216). The Stats page's `Menu` of five fixed periods becomes a Week - **Anchor nil means "the current period"** (Q9, Q15): a stored week start goes stale at midnight; nil re-resolves against the temporal key, and a step or pick that lands back on the current period stores nil again, so stats-page Req 3.7 holds without a timer. - **The pushed month screen is gone** (Q5): tapping an All-time bar switches the toggle to Month at that month, and the chevrons supply the "next month" the push never could. Supersedes stats-page Decision 3's drill-down half and Q30. - **Sites are capture hostnames** (Q4): every `RecentPresentationRow` already carries one, so the rankings need no new snapshot and `AsterismCore` is untouched (stats-page Decision 5 stands). Unattached and unresolved-title notes rank under sites but not under works (Q13, Q17).-- **A three-segment capsule, not a `Menu`** (Q11, Q16, Q26): three short labels fit where five long ones did not, and the fallback was not needed — the accessibility journey's no-clipping assertion, the only criterion Q16 admits, passes at `AccessibilityExtraExtraExtraLarge`. The capsule is the shared `ConstellationSegmentedControl`, extracted from work detail's sort control rather than copied (Q28).+- **A three-segment capsule, not a `Menu`** (Q11, Q16, Q26, Decision 1): three short labels fit where five long ones did not, and the fallback was not needed. Q26 first recorded that at `AccessibilityExtraExtraExtraLarge`, a name UIKit ignores, so the journey had run at the default size; T-2298 re-measured at the real `…AccessibilityXXXL`, found the capsule unclipped (370 pt in a 402 pt window, stacked arm, over 44 pt) and the failing assertion — full-window containment on a scrolled page — unsatisfiable for any control below the header, and corrected the assertion instead of the control (Decision 1). The capsule is the shared `ConstellationSegmentedControl`, extracted from work detail's sort control rather than copied (Q28). - **The picker is presented, not inline** (Q6): a `.compact` `DatePicker` draws its own date text and cannot read "This week", so the label is a button presenting a bounded `.graphical` picker. A nil anchor resolves to `now` for the binding, not to the period's start (Q24) — the anchor is the period's **midpoint** (Q18), so that nothing re-resolves a boundary instant under a calendar at a lower UTC offset and lands in the period before. - **Enablement comes from the bounds, never from the stored anchor** (Q20, Q31): a republish that moves the earliest usable capture disables a chevron rather than allowing a step past it, and the shown period stays where it is; a forward step from a period the bounds have left behind clamps to the new lower bound, and the picker's range widens so it is never handed a selection outside itself. 
specs/bugfixes/stats-period-control-accessibility-clipping/report.md Added +194 / -0
diff --git a/specs/bugfixes/stats-period-control-accessibility-clipping/report.md b/specs/bugfixes/stats-period-control-accessibility-clipping/report.mdnew file mode 100644index 0000000..558e41e--- /dev/null+++ b/specs/bugfixes/stats-period-control-accessibility-clipping/report.md@@ -0,0 +1,194 @@+# Bugfix Report: Stats Period Control "Clipping" at the Largest Accessibility Dynamic Type Size++**Date:** 2026-09-05+**Status:** Fixed — and the diagnosis in the ticket is corrected: the defect was+the assertion, not the Stats period control.+**Transit:** T-2298++## Description of the Issue++`AccessibilityJourneyUITests.testDarkReduceTransparencyLargestDynamicTypeKeepsPrimaryJourneysReachable`+launched the app with+`-UIPreferredContentSizeCategoryName UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge`,+which is not a `UIContentSizeCategory` raw value. UIKit ignores an unrecognised+name without failing the launch, so the app came up at the *default* text size+and the whole journey — including the `walkStatsAtLargestDynamicType` pass that+exists to police `specs/stats-page/` Req 7.8 — held for the wrong reason.++**Part 1 already landed on `main`** (commit `107b9ad`, `ipad-and-mac-layouts`+Decision 7): the string is now `UICTContentSizeCategoryAccessibilityXXXL`, and+the six assertions that then went red were held under a strict+`XCTExpectFailure` naming T-2298.++**Part 2, this change**, is what those six failures actually were.++**Reproduction steps (part 2, as it stood):**+1. `make test-only TEST=AsterismUITests/AccessibilityJourneyUITests/testDarkReduceTransparencyLargestDynamicTypeKeepsPrimaryJourneysReachable`+   with the `XCTExpectFailure` removed, on `iPhone 17 Pro` / iOS 26.5.+2. Six failures, one per control of the Stats period row — `stats-unit-week` /+   `-month` / `-all`, `stats-period-back` / `-picker` / `-forward` — every one+   of them `must stay inside the window at the largest Dynamic Type size`.++**Impact:** the phone had no working largest-text-size coverage of the Stats+page at all, and the pass `stats-period-navigation` Q26 recorded for the capsule+at that size was vacuous. The user-facing layout was never affected.++## Investigation Summary++- **Symptoms examined:** which assertion fails, and for which elements. The+  message is shared by two loops in the same walk (the four header totals and+  the six period controls); only the second failed.+- **Code inspected:** `Asterism/Asterism/Views/StatsView.swift` (`periodControl`,+  `unitCapsule`, `chevronRow`, `figures`),+  `Packages/AsterismCore/Sources/ConstellationKit/ConstellationRecipes.swift`+  (`ConstellationSegmentedControl`), and the walk itself.+- **Hypotheses tested:** the ticket and `ipad-and-mac-layouts`'s write-up both+  read the failure as "the row runs off an edge", i.e. horizontal overflow. That+  was an inference from the assertion's name, never measured — so it was+  measured. A temporary diagnostic case dumped the XCUI frames of the window,+  the four tiles, the three segments, both chevrons and the picker at the+  corrected category.++**Measured** (iPhone 17 Pro simulator, iOS 26.5, `Development`,+`UICTContentSizeCategoryAccessibilityXXXL`):++| Element | x | y | w | h |+|---|---|---|---|---|+| window | 0 | 0 | **402** | **874** |+| `stats-total-notes` | 16 | 203.3 | 370 | 142.7 |+| `stats-total-works` | 16 | 358.0 | 370 | 142.7 |+| `stats-period-notes` | 16 | 512.7 | 370 | 142.7 |+| `stats-period-works` | 16 | 667.3 | 370 | 142.7 |+| `stats-unit-week` | 16 | 830.0 | 370 | 102.0 |+| `stats-unit-month` | 16 | 932.0 | 370 | 102.0 |+| `stats-unit-all` | 16 | 1034.0 | 370 | 102.0 |+| `stats-period-back` | 24.7 | 1157.7 | 44.0 | 51.7 |+| `stats-period-picker` | 76.7 | 1146.0 | 248.7 | 74.7 |+| `stats-period-forward` | 333.3 | 1157.7 | 44.0 | 51.7 |++Every one of the six lies between x = 16 and x = 386 in a 402 pt window. Nothing+clips, `ViewThatFits` has already taken the capsule's stacked arm, and every+target clears 44 pt.++## Discovered Root Cause++`window.frame.contains(control.frame)` on a `ScrollView` is a claim that the+element is **on screen**, not that it is **unclipped**. At an accessibility text+size the Stats page is roughly 1.6 screens tall, so the period control is below+the fold at launch and the containment check is false for a control that is+perfectly drawn.++**Defect type:** wrong assertion — a test asserting a stronger property than the+requirement states, which only became observable once the launch argument was+corrected.++**Why it occurred:** the assertion was written and had only ever run at the+default text size, where the whole control fits above the fold. It encoded "on+screen" and "unclipped" as one check because at that size they coincided.++**Contributing factors:**+- The ignored launch argument hid it for the life of the assertion.+- Q32 had already drawn the right line for the ranked lists three assertions+  later in the same walk ("width ≤ window width after scrolling"), so the file+  contained both forms and the older one was never revisited.+- The criterion is **unsatisfiable** for this page, which is the strongest+  evidence it is not measuring the control: the four header tiles are 142.7 pt+  each and end at y = 810 on their own, so anything under them starts below+  y = 810 in an 874 pt window. Q16's `Menu` fallback would have failed exactly+  the same assertion.++## Resolution for the Issue++**Changes made:**+- `Asterism/AsterismUITests/AccessibilityJourneyUITests.swift` —+  `walkStatsAtLargestDynamicType` scrolls the period control into view, then+  asserts `minX >= window.minX` and `maxX <= window.maxX` (clipping), the 44 pt+  targets, the position above the graph, and — new — that all four enabled+  controls (`stats-unit-week`, `stats-unit-month`, `stats-unit-all`,+  `stats-period-picker`; the chevrons are disabled in the seeded state) are+  `isHittable` (reachability, which nothing asserted before). The+  strict `XCTExpectFailure` and the `continueAfterFailure = true` it needed are+  removed, so the case is back on the suite's `continueAfterFailure = false`.+- **No app or package code changed.** `StatsView`, `ConstellationSegmentedControl`+  and the derivation are untouched.++**Approach rationale:** Req 7.8 forbids the control *clipping*. Being below the+fold of a scroll view is not clipping, and the same walk already treats it that+way for the ranked-list heading (Q32). Fixing the control instead would have+meant changing a layout that measurement says is correct, to satisfy a check no+layout on this page can satisfy.++**Alternatives considered:** recorded in full as+`specs/stats-period-navigation/decision_log.md` **Decision 1** — apply Q16's+`Menu` fallback (does not fix the assertion, and trades three visible labels for+a control that must be opened); move the period control above the header figures+(a page-order change for every reader at every size, to satisfy a wrong test);+shrink the header tiles (Req 7.8 forbids truncating a total); keep full-window+containment but scroll first (flaky — `scrollToElement` swipes until hittable,+so where the 390 pt control lands is uncontrolled).++## Regression Test++**Test file:** `Asterism/AsterismUITests/AccessibilityJourneyUITests.swift`+**Test name:** `testDarkReduceTransparencyLargestDynamicTypeKeepsPrimaryJourneysReachable`++**What it verifies:** at `UICTContentSizeCategoryAccessibilityXXXL`, every one of+the six period controls renders, is labelled, keeps a 44 pt target, stays inside+the window's **width**, and sits above the graph; and the unit segments and the+period picker are hittable once scrolled to.++**Run command:**+`make test-only TEST=AsterismUITests/AccessibilityJourneyUITests/testDarkReduceTransparencyLargestDynamicTypeKeepsPrimaryJourneysReachable`++**The corrected assertion is not vacuous — checked, not assumed.** With a+temporary `.offset(x: 120)` on `unitCapsule` in `StatsView` the loop fails with+`XCTAssertLessThanOrEqual failed: ("506.0") is greater than ("402.0") -+stats-unit-week must stay inside the window's width at the largest Dynamic Type+size`. The probe was reverted; it is not in the diff.++## Affected Files++| File | Change |+|------|--------|+| `Asterism/AsterismUITests/AccessibilityJourneyUITests.swift` | The corrected assertion; the expectation and `continueAfterFailure = true` removed; doc comments rewritten |+| `specs/stats-period-navigation/decision_log.md` | **Decision 1** (the finding, in full); Q16 restated, Q26 marked vacuous |+| `specs/stats-period-navigation/implementation.md` | The "Q16's criterion" potential-issue bullet answered |+| `specs/stats-page/decision_log.md` | Q31's supersession note: the vacuous verification struck, the re-measured one put in its place |+| `specs/OVERVIEW.md` | Both lines that repeated Q26's vacuous pass |+| `specs/ipad-and-mac-layouts/decision_log.md` | Decision 7: outcome note, and the rejected-alternative bullet annotated |+| `specs/ipad-and-mac-layouts/verification-run.md` | §"Tasks 32–34": the "runs off an edge" diagnosis superseded; the expectation recorded as discharged |+| `docs/agent-notes/testing.md` | The general lesson: `window.frame.contains` is not a clipping check on a `ScrollView` |++## Verification++**Automated:**+- [x] Regression test passes — 52.156 s, iPhone 17 Pro / iOS 26.5 simulator+- [x] `make test-ui` (full iPhone UI bundle) green+- [x] `make test-quick` (unit bundle + `build-mac`) green+- [x] No linter configured in this repo; no new compiler warnings++**Manual verification:** none needed and none claimed — the frames above are the+evidence, and no physical device or Mac app was launched.++## Prevention++- **`window.frame.contains(element.frame)` is not a clipping check** on any+  scrollable screen. It conflates "unclipped" with "above the fold", and at an+  accessibility text size the second is false for most of a page. Scroll the+  element into view, then assert its width lies inside the window's; add+  `isHittable` if reachability is the point. Recorded in+  `docs/agent-notes/testing.md`.+- **A criterion no implementation could satisfy is not measuring the+  implementation.** Q16 named one measured criterion precisely to avoid an+  implementer's eye — the right instinct — but nobody checked that the criterion+  could discriminate. When a spec pins a decision to a single assertion, check+  that the assertion can come out both ways.+- **Do not read a diagnosis out of an assertion's wording.** "Must stay inside+  the window" was taken to mean horizontal overflow by two write-ups in a row.+  One diagnostic dump of the frames settled it.++## Related++- Transit **T-2298**; `specs/ipad-and-mac-layouts/` Decision 7 (part 1, commit `107b9ad`)+- `specs/stats-period-navigation/` Q11, Q16, Q26, Q32, and **Decision 1**+- `specs/stats-page/` Req 7.8, Q31
specs/ipad-and-mac-layouts/decision_log.md Modified +17 / -2
diff --git a/specs/ipad-and-mac-layouts/decision_log.md b/specs/ipad-and-mac-layouts/decision_log.mdindex c8f3cac..0380c80 100644--- a/specs/ipad-and-mac-layouts/decision_log.md+++ b/specs/ipad-and-mac-layouts/decision_log.md@@ -345,7 +345,21 @@ when task 33 writes it. ## Decision 7: Correct the phone journey's Dynamic Type argument and hold the breach it exposes under a strict expectation  **Date**: 2026-09-01-**Status**: accepted+**Status**: accepted; the expectation was discharged on 2026-09-05 (T-2298)++> **Outcome** (2026-09-05). The string correction stands and was the whole of+> the value here. The *breach* it was held against did not survive measurement:+> `stats-period-navigation` Decision 1 read the frames and found the six+> controls unclipped — x 16…386 in a 402 pt window, the capsule's stacked arm+> taken, every target over 44 pt — failing only `window.frame.contains` because+> the page had scrolled them past y = 874. The four header tiles end at y = 810+> on their own, so no control below them could have satisfied that assertion and+> Q16's `Menu` fallback would not have fixed it. The assertion was corrected to+> the scroll-then-width form Q32 already used in the same walk; the strict+> expectation and the `continueAfterFailure = true` beside it are gone, and the+> case is green on its own terms (52 s, iPhone 17 Pro / iOS 26.5). The sentence+> below calling it "a live breach" is left as written, because what it records+> is what was believed on 2026-09-01.  ### Context @@ -413,7 +427,8 @@ of test time, the probe firing). - **Correct the string and fix the Stats period control here**: makes the suite   honestly green - Rejected: it is a phone-layout change, which this feature's   non-goals exclude, and the control belongs to `specs/stats-page/` and-  `specs/stats-period-navigation/`. T-2298 owns it.+  `specs/stats-period-navigation/`. T-2298 owns it. *(T-2298 found there was no+  control change to make — see the Outcome note above.)* - **A non-strict `XCTExpectFailure`, or one without an `issueMatcher`**: simpler   to write - Rejected: non-strict passes whether or not the breach is still   there, and an unmatched expectation would absorb *any* failure in the Stats
specs/ipad-and-mac-layouts/verification-run.md Modified +26 / -4
diff --git a/specs/ipad-and-mac-layouts/verification-run.md b/specs/ipad-and-mac-layouts/verification-run.mdindex ab011ae..7e3d655 100644--- a/specs/ipad-and-mac-layouts/verification-run.md+++ b/specs/ipad-and-mac-layouts/verification-run.md@@ -625,6 +625,18 @@ from the first run, which is what made the pair diagnostic.) had always passed the long spelling, so it and the `walkStatsAtLargestDynamicType` pass inside it had never run at an accessibility size. +> **Superseded in its diagnosis** (2026-09-05, T-2298). Everything below about+> the argument and about *which* assertions fail is accurate and was re-measured.+> The reading of *why* they fail — "it runs off an edge" — was an inference, and+> it was wrong. The frames say the six controls run off the **bottom** of a+> scrolled page, not off a side: x 16…386 in a 402 pt window, the capsule's+> stacked `ViewThatFits` arm already taken, every target over 44 pt. The four+> header tiles are 142.7 pt each and end at y = 810 of an 874 pt window on their+> own, so nothing placed under them can be wholly inside the window and a `Menu`+> would have failed the same assertion. See `specs/stats-period-navigation/`+> Decision 1: the assertion was corrected to the scroll-then-width form (Q32's),+> the control was not changed, and the expectation is removed.+ **The repro, exactly.** Change that test's launch argument from `UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge` to `UICTContentSizeCategoryAccessibilityXXXL` and run@@ -644,10 +656,12 @@ XCTAssertTrue failed - stats-unit-week must stay inside the window at the larges "Inside the window" is `window.frame.contains(control.frame)`, where `window` is `app.windows.firstMatch` and `control` is the XCUI frame of the button carrying `stats-unit-week`. So the failure says the Week segment's own rectangle is not-wholly within the window's — it runs off an edge, which is what-`specs/stats-page/` Req 7.8 forbids at this size. It is **not** a claim about-truncation inside the segment: XCUI reports a label in full whether or not it is-drawn clipped, which is why the geometry is asserted rather than the text.+wholly within the window's — ~~it runs off an edge, which is what+`specs/stats-page/` Req 7.8 forbids at this size~~ *(the edge is the window's+**bottom**, reached by scrolling, not a side — see the note above)*. It is+**not** a claim about truncation inside the segment: XCUI reports a label in+full whether or not it is drawn clipped, which is why the geometry is asserted+rather than the text.  **The breach is the whole period row, and nothing else in the journey.** Run with `continueAfterFailure` lifted and no expectation, the corrected journey@@ -667,6 +681,14 @@ unattached. That is why the expectation is matched on the message rather than on one identifier: six assertions of one breach, and the xcresult of the expectant run records exactly six absorbed expected failures. +**T-2298 landed on 2026-09-05 and the expectation is gone.** It found no control+change to make: the assertion, not the layout, was the defect+(`specs/stats-period-navigation/` Decision 1). The loop now scrolls to the+control and asserts width-inside-the-window, the 44 pt targets, the position+above the graph, and hittability — and the case runs the whole journey in 52 s+with `continueAfterFailure` back at the suite's `false`. What follows is the+2026-09-01 state, kept as the record of it.+ So the string is corrected and the breach is held under a **strict** `XCTExpectFailure` whose `issueMatcher` matches only the `must stay inside the window at the largest Dynamic Type size` message and names
specs/stats-page/decision_log.md Modified +1 / -1
diff --git a/specs/stats-page/decision_log.md b/specs/stats-page/decision_log.mdindex 907b2d2..eee10b0 100644--- a/specs/stats-page/decision_log.md+++ b/specs/stats-page/decision_log.md@@ -34,7 +34,7 @@ | Q28 | 2026-08-16 | Bounded periods use `chartXSelection`; All time uses `chartGesture` with a spatial tap | Keeps `chartXSelection`'s built-in gesture off the one graph that scrolls, so the two are never combined. A tap and a scroll pan are distinguishable by construction, where a built-in selection gesture of unknown kind is not | | Q29 | 2026-08-16 | The pre-publication state is distinguished by `snapshotGeneration == 0` | Both snapshots initialise empty, so an unread library and an empty one are otherwise the same values — and Req 1.7 forbids reporting the second when it is the first. Production reaches `.ready` only after `refreshAll()`, so a zero generation means that refresh threw | | Q30 | 2026-08-16 | An opened month is a `NavigationStack` push | Supplies Req 5.4's back affordance and Req 5.3's month title without hand-built controls, and matches Decision 2's stated consequence that Stats owns a stack of its own. **Superseded** (2026-08-31) by [`stats-period-navigation`](../stats-period-navigation/decision_log.md) Q5: with Month a first-class unit the push duplicates it, so an All-time bar switches the unit instead, the chevron row carries the month title, and the chevrons supply the "next month" the push never could. The `NavigationStack` stays, with one screen in it |-| Q31 | 2026-08-16 | The period control is a `Menu`, not a segmented picker | Five labels of "This month" length cannot fit a segmented control at accessibility Dynamic Type sizes, which Req 7.8 forbids clipping. A menu is one 44 pt target at every size. **Superseded** (2026-08-31) by [`stats-period-navigation`](../stats-period-navigation/decision_log.md) Q11 and Q26: there are three short labels now, not five long ones, and the segmented capsule recipe stacks its segments full width at those sizes. Verified rather than judged — the accessibility journey's no-clipping assertion passes at `AccessibilityExtraExtraExtraLarge`. The reasoning stands as written for five long labels, and the style guide keeps the `Menu` for that case |+| Q31 | 2026-08-16 | The period control is a `Menu`, not a segmented picker | Five labels of "This month" length cannot fit a segmented control at accessibility Dynamic Type sizes, which Req 7.8 forbids clipping. A menu is one 44 pt target at every size. **Superseded** (2026-08-31) by [`stats-period-navigation`](../stats-period-navigation/decision_log.md) Q11 and Q26: there are three short labels now, not five long ones, and the segmented capsule recipe stacks its segments full width at those sizes. ~~Verified rather than judged — the accessibility journey's no-clipping assertion passes at `AccessibilityExtraExtraExtraLarge`.~~ **That verification was vacuous** (T-2298): the size named was a launch argument UIKit ignored. Re-verified at the real `UICTContentSizeCategoryAccessibilityXXXL` by `stats-period-navigation` Decision 1 — the stacked capsule renders 370 pt wide inside a 402 pt window, unclipped, over 44 pt, so the supersession stands on measured frames. The reasoning stands as written for five long labels, and the style guide keeps the `Menu` for that case | | Q32 | 2026-08-16 | The x scale is declared categorical: `.chartXScale(domain: bars.map(\.index), type: .category)` | An `Int` x value alone yields a *quantitative* scale — `Int` is `Plottable` with a `Double` primitive — which centres bars with gaps and resolves touches by interpolation, giving "nearest bar" a silent half-band error. Q23 named the right axis and not the mechanism that produces it | | Q33 | 2026-08-16 | Zero-count bars are load-bearing twice | They are Req 4.6, and they are what keeps the categorical domain hole-free. Dropping them as an optimisation would puncture the domain and silently change what `chartXVisibleDomain(length:)` counts | | Q34 | 2026-08-16 | The chart carries an `.accessibilityRepresentation` of real `Button`s, one per bar | `ChartContent` exposes only label, value, identifier and hidden — no per-mark action and no `.isSelected` trait — so labelled marks satisfy neither Req 7.5 nor Req 7.7. A representation is a separate view tree, which also makes every bar of the scrolling graph reachable regardless of the viewport |
specs/stats-period-navigation/decision_log.md Modified +126 / -2
diff --git a/specs/stats-period-navigation/decision_log.md b/specs/stats-period-navigation/decision_log.mdindex f6b6ca6..c8f59ec 100644--- a/specs/stats-period-navigation/decision_log.md+++ b/specs/stats-period-navigation/decision_log.md@@ -19,7 +19,7 @@ | Q13 | 2026-08-30 | Unattached and unresolved-work notes are excluded from the works ranking, included in the sites ranking | They are not works; they do have a hostname | | Q14 | 2026-08-30 | `StatsInputKey` keys on `(unit, anchor)`; the scope is resolved inside `deriveIfNeeded()` | Keying on a resolved scope would put a `Date()` / `Calendar.current` read in `body`; the page's stance is that nothing derives on a body evaluation | | Q15 | 2026-08-30 | A step or pick landing on the current period stores `anchor = nil` | Otherwise a period reached by navigation stops tracking the clock at midnight — the failure Q9 exists to prevent |-| Q16 | 2026-08-30 | The `Menu` fallback for the toggle is triggered only by the accessibility journey test's no-clipping assertion failing | One measured criterion rather than an implementer's eye |+| Q16 | 2026-08-30 | The `Menu` fallback for the toggle is triggered only by the accessibility journey test's no-clipping assertion failing | One measured criterion rather than an implementer's eye. **Restated** (2026-09-05, T-2298) by [Decision 1](#decision-1-the-period-control-stays-as-it-is-the-largest-text-size-assertion-was-measuring-the-wrong-thing): the assertion as written asked the whole page to fit one screen, which no control below the header could satisfy and a `Menu` could not have fixed. The criterion is now the same one Q32 drew for the ranked lists — width inside the window after scrolling — and on it the capsule passes | | Q17 | 2026-08-30 | Rankings classify from `workID` / `workDisplayTitle` / `hostname` on the row, never by consulting the `works` array | Same rule as `category(of:)`; keeps the works array the source of the works total only (stats-page Decision 5) | | Q18 | 2026-08-31 | The anchor stores the shown period's **midpoint**, not its start | A period start is a boundary instant, and re-resolving it under a calendar at a lower UTC offset lands in the period before — 2026-08-03 00:00 UTC is the Sunday of the *previous* Monday-start week in Honolulu, so a zone move would silently shift the shown week. The midpoint is half a period from either boundary, further than any zone change moves it. Nil still means the current period, and every read still normalises through `dateInterval(of:for:)` | | Q19 | 2026-08-31 | The Req 2.10 tiles keep the preposition on a dated period: "in August 2026", "in 23–29 Aug 2026" | The tile reads as a sentence and sits beside "80 notes in all"; "12 notes August 2026" next to it reads as a defect. The bare label stays available for the chevron row, which is not a sentence |@@ -29,7 +29,7 @@ | Q23 | 2026-08-31 | The naming rules (`label`, tile phrase, ranking phrase) live in `StatsPeriodNaming` beside the derivation, not as computed properties on `StatsView` | Q41's rule: no app-layer test instantiates a view, so a rule left in `body` is a rule nothing can check — and the three arms differ only by a preposition. The calendar is a parameter, so a test can state the first weekday a week's dates depend on | | Q24 | 2026-08-31 | The date picker's binding resolves a nil anchor to `now`, not to the current period's start | Q18 made an anchor an instant *inside* its period rather than its boundary, and `now` is that instant for the current period. It is inside the picker's range by construction, where a period start re-introduces the boundary Q18 exists to avoid. The pick is normalised and clamped by `show(_:)` either way | | Q25 | 2026-08-31 | A period that holds notes but ranks no work (or no site) shows the heading with a one-line explanation, not a bare heading | The requirement is that both sections appear wherever the period holds a note, and Q13 means a period of unattached notes ranks no work at all. The precedent is Req 6.9: a zero-value bar says so rather than rendering an empty list. The two explanations carry their own identifiers — `stats-top-works-empty` and `stats-top-sites-empty` — so a test can tell "the section is present and says why it is empty" from "the section is missing", which is the whole distinction this decision draws |-| Q26 | 2026-08-31 | The capsule stays; Q16's `Menu` fallback is not applied | The accessibility journey's no-clipping assertion — the only criterion Q16 admits — passes at `AccessibilityExtraExtraExtraLarge`: all three segments, both chevrons and the picker render inside the window, at 44 pt, above the graph |+| Q26 | 2026-08-31 | The capsule stays; Q16's `Menu` fallback is not applied | ~~The accessibility journey's no-clipping assertion — the only criterion Q16 admits — passes at `AccessibilityExtraExtraExtraLarge`: all three segments, both chevrons and the picker render inside the window, at 44 pt, above the graph.~~ **This pass was vacuous** (2026-09-01, `ipad-and-mac-layouts` Decision 7): `UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge` is not a `UIContentSizeCategory` raw value, UIKit ignored it, and the journey ran at the *default* text size. The conclusion is re-reached on measured evidence by [Decision 1](#decision-1-the-period-control-stays-as-it-is-the-largest-text-size-assertion-was-measuring-the-wrong-thing) — the capsule does stay, for a reason that was checked | | Q27 | 2026-08-31 | A unit switch from a **nil** anchor keeps the anchor nil, exempting it from Q10 | Nil means "the current period" (Q15), so a switch from the current period must land on the current period. Q10's rule carries the shown period's *start*, and the current week's start lies in the previous month whenever the week spans a boundary — so from the default state, tapping Month showed last month. A non-nil anchor keeps Q10 unchanged | | Q28 | 2026-08-31 | The segmented capsule is one shared recipe, `ConstellationSegmentedControl` in `ConstellationKit` | Stats' three-segment unit toggle and work detail's two-segment sort were line-for-line copies — the `ViewThatFits` pair, the `@ScaledMetric` visual, the fills, the 44 pt target and the `.isSelected` trait, twice. A copy is a chance for the two to disagree, and §7 describes one recipe, not two. The control takes an optional container label so an existing caller's accessibility output is unchanged unless it asks otherwise (Q29's sibling: the Stats capsule asks). **This overrules the smolspec's scope line** ("`AsterismCore` is not modified"; "out of scope: any `AsterismCore` change"): the shared recipe is a new public SwiftUI view in `ConstellationKit`, which is a package the app links rather than a stored shape — no schema change, no archive or backup format change, no snapshot published or altered, so nothing the scope line exists to protect is touched. **Amended** (2026-08-31, pre-push review): the container label is now **required** rather than optional. A row of segments that never says what it is *for* is a gap rather than a caller's choice, and with both callers passing one — work detail's sort passes "Sort order" — the optional arm had no user left, only a second accessibility shape to keep working. `children: .contain` keeps every segment individually reachable, which is what "an existing caller unchanged" actually meant, and the work-detail accessibility journey now pins it | | Q29 | 2026-08-31 | `body` may read `Date()` for the period control's enablement | Q14's rule is that nothing *derives* on a body evaluation; comparing the shown period with `now` to decide whether a chevron is disabled derives nothing and issues no library read. It re-renders correctly for the same reason Q22's formatting does — the `temporal` key already forces a re-render on a significant time change or a zone change. Extends Q22 from formatting to enablement |@@ -38,3 +38,127 @@ | Q32 | 2026-08-31 | Task 6's "above `stats-bar-0`" wording is not applied to the ranked sections; the journey asserts width ≤ window width after scrolling | The requirement puts the ranked lists *below* the graph and breakdown, so nothing about them can be asserted by position above the first bar. What the no-clipping criterion (Q16, Q26) actually needs is that no element runs off the window, which the journey checks by frame width after scrolling to each section | | Q33 | 2026-08-31 | The period control is **centred** on the Stats page (commit 329f3a2) | The style guide's §7 entry for the segmented capsule states its recipe and its stacking but no alignment, because its only caller until now sat beside a section header that fixed its place. The Stats control has no header to sit beside — it is the unit toggle with the chevron row beneath it, governing the whole page below — and left-aligning a two-part control under a leading-aligned column left the chevron row hanging off one edge of what it names. Centred, both parts read as one control over the graph. This settles the alignment for Stats; §7 and §10 now say which of the two placements applies to which caller rather than leaving it unstated | | Q34 | 2026-08-31 | The works ranking publishes two identifiers, not one: `stats-top-work-row` for the routable row and `stats-top-row` for the inert one | Whether a ranked work can be opened is a property of the *current* snapshot, not of the derivation — a work merged away since the graph was derived carries no route (Req 6.10) — so the two rows are genuinely different controls: one is a `Button` announcing "Open Work …", the other a plain row announcing its name and count. One identifier over both would let a test that means "this row opens a work" pass on a row that opens nothing. The same split the breakdown already draws between `stats-breakdown-work-row` and `stats-breakdown-row`, and since the pre-push review all four come from one row helper in `StatsView`, so the two lists cannot come to disagree about the shape |++---++## Decision 1: The period control stays as it is; the largest-text-size assertion was measuring the wrong thing++**Date**: 2026-09-05+**Status**: accepted++### Context++Q16 named one criterion for whether the three-segment capsule survives at the+accessibility Dynamic Type sizes: the accessibility journey's no-clipping+assertion. Q26 recorded that it passed. Both were decided under a launch+argument UIKit silently ignored — `UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge`+is not a `UIContentSizeCategory` raw value — so the journey had never run at an+accessibility size at all and Q26's pass said nothing (`ipad-and-mac-layouts`+Decision 7). With the spelling corrected, the six controls of the period row+failed that assertion, one failure each, and the breach was filed as **T-2298**+against the control.++Measured on the iPhone 17 Pro simulator, iOS 26.5, `Development`, at+`UICTContentSizeCategoryAccessibilityXXXL`, the frames are:++| Element | Frame (x, y, w, h) |+|---|---|+| window | 0, 0, **402**, **874** |+| `stats-total-notes` | 16, 203.3, 370, 142.7 |+| `stats-total-works` | 16, 358.0, 370, 142.7 |+| `stats-period-notes` | 16, 512.7, 370, 142.7 |+| `stats-period-works` | 16, 667.3, 370, 142.7 |+| `stats-unit-week` | 16, 830.0, 370, 102.0 |+| `stats-unit-month` | 16, 932.0, 370, 102.0 |+| `stats-unit-all` | 16, 1034.0, 370, 102.0 |+| `stats-period-back` | 24.7, 1157.7, 44.0, 51.7 |+| `stats-period-picker` | 76.7, 1146.0, 248.7, 74.7 |+| `stats-period-forward` | 333.3, 1157.7, 44.0, 51.7 |++Every one of the six lies between x = 16 and x = 386 inside a 402 pt window.+Nothing is clipped, nothing is truncated, `ViewThatFits` has already taken the+capsule's stacked arm, and every target is over 44 pt. The six failed because+`window.frame.contains(control.frame)` is false for a rectangle that starts at+y = 830 on a scrolled page whose window ends at y = 874.++### Decision++The Stats period control is not changed. The assertion is: the journey scrolls+the control into view and then asserts that it runs off neither side of the+window, that it keeps its 44 pt targets, that it sits above the graph, and that+its four enabled controls (the three segments and the picker; the chevrons are+disabled in the seeded state) are hittable. The strict `XCTExpectFailure`+holding T-2298, and the `continueAfterFailure = true` it needed, are removed.++### Rationale++Req 7.8 forbids the control **clipping** at the accessibility sizes. Being below+the fold of a scroll view is not clipping; it is what a scroll view is for, and+the same journey already treats it that way three assertions later, where Q32+scrolls to the ranked-list heading before checking its width — "what the+no-clipping criterion actually needs is that no element runs off the window",+in Q32's own words.++The assertion as written could not have discriminated the thing Q16 wanted it+to. The four header tiles are 142.7 pt each at this size and end, on their own,+at y = 810 of an 874 pt window. Anything placed under them starts below y = 810+and cannot be wholly inside the window — a `Menu`, a capsule, a single button,+anything. So "apply Q16's `Menu` fallback" would have left the assertion red and+the page worse: one tap to read the state instead of three labels visible at+once, which is exactly what §7's capsule exists to avoid. A criterion that fails+for every possible implementation is not measuring the implementation.++That the corrected assertion is not itself vacuous was checked rather than+assumed: with a temporary `.offset(x: 120)` on the capsule the loop fails with+`stats-unit-week must stay inside the window's width` (506 > 402), and the probe+was then reverted.++### Alternatives Considered++- **Apply Q16's `Menu` fallback**: replace the capsule with a three-item `Menu`+  — Rejected. It does not fix the failing assertion (a `Menu` under those four+  tiles is below the window too), and it trades three visible labels for a+  control that has to be opened to be read.+- **Move the period control above the header figures**: it would put the control+  on screen at launch at every text size — Rejected here. It changes the page's+  reading order for every reader at every size to satisfy a test assertion that+  was wrong, and the page's order (figures, control, graph, ranked lists) is the+  spec's, not an accident. Worth raising as a design question on its own+  evidence; it is not this bug.+- **Make the header tiles smaller at accessibility sizes**: Rejected. Req 7.8+  forbids truncating a total, which is precisely why the tiles wrap to 142.7 pt.+- **Keep full-window containment but scroll first**: assert+  `window.frame.contains` after scrolling the control into view — Rejected as+  flaky rather than wrong. `scrollToElement` swipes until the element is+  hittable, so where the 390 pt control lands is not controlled; a run that+  leaves the picker under the tab bar would be a red that says nothing.++### Consequences++**Positive:**+- The phone suite has real largest-text-size coverage of the period control, on+  a criterion that measures clipping.+- The criterion is now the same one Q32 uses for the ranked lists in the same+  walk, so one page's journey states one rule.+- Hittability is asserted where it was not before: unclipped and reachable are+  different claims, and only the first was being made.++**Negative:**+- The assertion is weaker than the one it replaces. A control that drifted below+  the fold *without* being clipped would no longer be caught — which is the+  intent, but it does mean this journey no longer says anything about how far a+  reader scrolls to reach the control.+- The four header tiles keep the old containment form, so the trap survives+  there: adding a fifth figure would fail that loop for the same non-reason.+  Left as it is because the header genuinely is on screen and the assertion is+  meaningful there; the comment in the walk names the difference.++### Impact++`Asterism/AsterismUITests/AccessibilityJourneyUITests.swift` only. No app or+package code changes: `StatsView`, `ConstellationSegmentedControl` and the+derivation are untouched. Q16, Q26, `stats-page` Q31, `specs/OVERVIEW.md` and+`ipad-and-mac-layouts` Decision 7 are annotated, since each records the vacuous+pass or the assumed breach.++---
specs/stats-period-navigation/implementation.md Modified +15 / -0
diff --git a/specs/stats-period-navigation/implementation.md b/specs/stats-period-navigation/implementation.mdindex 7f13874..f4e3b4e 100644--- a/specs/stats-period-navigation/implementation.md+++ b/specs/stats-period-navigation/implementation.md@@ -270,6 +270,21 @@ condition.   mode is more likely a stacked layout than a clipped one — but the assertion   itself does not close the gap. Q26 was decided on that assertion, so this   wants an on-device look.++  **Worse than stated, and now settled** (2026-09-05, T-2298, Decision 1). The+  size in that sentence was never applied:+  `UICTContentSizeCategoryAccessibilityExtraExtraExtraLarge` is not a+  `UIContentSizeCategory` raw value, UIKit ignored it, and the journey ran at+  the default text size — so Q26 rests on nothing (`ipad-and-mac-layouts`+  Decision 7). At the real `…AccessibilityXXXL` the containment form of the+  assertion turned out to be unsatisfiable for *any* control on this page: the+  four header tiles are 142.7 pt each and end at y = 810 of an 874 pt window on+  their own. The frames were measured, the capsule is unclipped (x 16…386 in a+  402 pt window, stacked arm taken, every target over 44 pt), and the assertion+  was corrected to Q32's form — scroll, then width — rather than the control.+  The glyph-containment gap this bullet names is unchanged and still not closed+  by the assertion; it is bounded by the same `.lineLimit(1)` /+  `.fixedSize(horizontal: true)` reasoning against a 370 pt segment frame. - **The naming test pins an ICU literal.** `"10\u{2009}\u{2013}\u{2009}16 Aug   2026"` — thin space, en dash, thin space, which is what ICU produces for en_AU   and not the bare en dash the requirement's prose writes. Pinned as produced,

Things to double-check

The residual glyph-containment gap.

Neither the old nor the new assertion can see a label overflowing inside a segment frame. The segment Text is .lineLimit(1).fixedSize(horizontal: true, vertical: false), so it never truncates; overflow would need an ideal width over ~342 pt against 'All time' at caption weight. The branch records the gap in implementation.md and the iPad helper's doc comment already says a maxX check is 'close to a tautology' for truncation. Acceptable, and honestly stated; worth knowing before citing Q16 as a clipping guarantee.

Simulator geometry the reasoning depends on.

The numbers in the doc comments (402 × 874, 142.7 pt tiles, y 810 / 830) are iPhone 17 Pro / iOS 26.5. On a taller destination stats-unit-week may already be hittable at launch, so no swipe happens and both loops pass at offset 0 — still a valid check, but the 'scroll first' framing would then be idle. The Makefile pins the simulator, so this is not a live risk.

The second loop's comment vs what it catches.

'A segment sitting under the tab bar while its siblings clear it is exactly the regression this is for' — but scrollToElement swipes until hittable and the page always has content below the control, so that regression is scrolled away, not caught. The loop guarantees 'reachable within eight swipes', which is the right claim for a journey named KeepsPrimaryJourneysReachable; only the comment overstates.

Decision 7's Status line.

**Status**: accepted; the expectation was discharged on 2026-09-05 (T-2298) is outside the format's fixed vocabulary (proposed / accepted / rejected / deprecated / superseded by Decision X). The Outcome block immediately below carries the same information. A one-word revert to accepted would satisfy the format; not blocking.