commit a7c628265152091b8610e0c95899af859c4f3f75
parent 97780727b16e897aa7feb550056d61ca4e9d7077
Author: Michael Camilleri <[email protected]>
Date: Wed, 8 Jul 2026 19:57:36 +0900
Add notes about accessibility and SwiftUI
Diffstat:
2 files changed, 183 insertions(+), 0 deletions(-)
diff --git a/Notes/AccessibilityTesting.md b/Notes/AccessibilityTesting.md
@@ -0,0 +1,123 @@
+# Testing the grid's VoiceOver support
+
+How to verify the Phase 1 grid accessibility work (see PLAN.md): synthetic
+per-cell VoiceOver elements over the Canvas-drawn grid, two-way focus/cursor
+sync, and double-tap activation.
+
+There are two tiers. The Simulator can only verify *structure* — it cannot run
+VoiceOver. Focus behaviour, the typing echo, and trait announcements need a
+physical device.
+
+## Tier 1: Simulator + Accessibility Inspector (structure)
+
+The synthetic elements are normally built only while VoiceOver is running, and
+Accessibility Inspector doesn't set that flag — so debug builds accept a
+launch argument that forces them on.
+
+1. In Xcode: Product → Scheme → Edit Scheme… → Run → Arguments, and add
+ `-ForceGridAccessibility` to "Arguments Passed On Launch". Without it the
+ grid exposes nothing in the Simulator, by design.
+2. Run the app in the Simulator and open a puzzle.
+3. Xcode menu → Open Developer Tool → **Accessibility Inspector**.
+4. In the Inspector's top-left target dropdown, pick the Simulator.
+5. Click the crosshair (inspection pointer) button, then hover/click over grid
+ cells in the Simulator. For each open cell, check:
+ - **Label** reads like "5 Across, letter 2 of 7; 3 Down, letter 4 of 5"
+ (one half only when the cell isn't part of a crossing word; "circled" /
+ "shaded" suffixes on special cells).
+ - **Value** reads "Empty", "R", "R, draft", "R, revealed", "R, incorrect",
+ and in a shared game "R, filled by Alice" / "filled by you".
+ - The **frame highlight** sits exactly on the drawn cell.
+6. Use the Inspector's next/previous element arrows to step through elements —
+ this approximates VoiceOver's flick order. It should walk the grid row by
+ row, skipping blocks entirely. Enable the speak option to hear it.
+
+This proves elements exist, frames line up, strings are right, and blocks are
+gaps. It does **not** exercise VoiceOver's focus engine.
+
+## Tier 2: iPhone/iPad + real VoiceOver (behaviour)
+
+No launch argument needed — real VoiceOver enables the elements.
+
+**Setup — do this first.** Settings → Accessibility → Accessibility Shortcut →
+VoiceOver. Triple-clicking the side button now toggles VoiceOver, so you can
+always bail out. (Or tell Siri "turn VoiceOver on/off".)
+
+**Gesture primer** (VoiceOver changes every gesture):
+
+- **Drag a finger** — touch exploration: speaks whatever is under it.
+- **Flick right / left** — next / previous element.
+- **Double-tap anywhere** — activate the focused element.
+- **Three-finger swipe** — scroll.
+
+**Checklist** — open a puzzle, then verify in order:
+
+1. **Touch exploration.** Slowly drag a finger across the grid. Each open cell
+ speaks its label/value as your finger crosses it; blocks are silent gaps.
+ The boundaries you hear must match the cells you see.
+2. **Flick navigation.** Flick right repeatedly: cells in row-major order, and
+ the clue bar follows your flicks (focus → selection sync).
+3. **Selected trait.** The cell matching the app's cursor is read with
+ "selected", and it tracks as the cursor moves.
+4. **Double-tap.** On the focused cell this toggles Across/Down. With
+ Phase 2 in place this is no longer silent: the toggle announces the new
+ word — "5 Down: <clue text>. 5 letters, 2 filled".
+5. **Typing echo — the big one.** Flick to the on-screen keyboard, double-tap
+ a letter key. VoiceOver focus should jump back to the grid on the *next*
+ cell and read it. This is the open risk flagged in PLAN.md
+ (`accessibilityFocused` inside an `accessibilityChildren` synthetic
+ hierarchy is not a documented pattern). **If focus stays on the keyboard
+ key instead**, the fallback is to switch the synthetic children to a real
+ clear, non-hit-testable overlay of per-cell views — a small, mechanical
+ change.
+
+**Phase 2 checklist** — continue on device:
+
+6. **Clue announcements.** Flick (or double-tap a next-word key) so the
+ cursor crosses into a different word: after the landed cell reads, you
+ should hear "5 Across: <clue text>. 7 letters, 3 filled" — once per word
+ change, silent while moving within a word. Listen for the announcement
+ *colliding* with the cell reading (talking over it or cutting it off);
+ if it does, note it — the fix is lowering the announcement priority.
+7. **Rotors.** With focus on a grid cell, **twist two fingers on the screen**
+ (like turning a dial) — the rotor menu should include "Across clues",
+ "Down clues", and "Incomplete clues" alongside the system entries. Pick
+ one, then **flick up / down** (not left/right) to jump between clues.
+ Each jump should land focus on the word's first cell with the direction
+ set to match, and fire the clue announcement.
+8. **Incomplete rotor narrows.** Fill a word completely, return to the
+ "Incomplete clues" rotor: that clue should be gone. (On a puzzle with
+ deliberate gap cells, a word whose only empty square is a gap counts as
+ complete.)
+
+**Phase 3 checklist** — the last two items need a second device on a shared
+game:
+
+9. **Custom actions.** Focus a grid cell, then **swipe up or down with one
+ finger** to cycle its actions: "Switch Direction", and — unless the
+ puzzle is solved — "Check Square", "Check Word", "Reveal Square".
+ Double-tap to run the highlighted one. Check announces its verdict
+ ("Correct", "1 square incorrect", "No mistakes, 1 square empty");
+ reveal announces the letter ("Revealed R").
+10. **Completion announcements.** Fill the last square of a puzzle with a
+ mistake somewhere: you should hear "The grid is full, but something
+ isn't right". Fix it: "Puzzle solved. Congratulations!" — listen for
+ collision with the success panel's own appearance.
+11. **Peer fills (two devices).** With the puzzle open on this device, have
+ the co-player type a word on the other. About 2 seconds after they
+ stop you should hear one coalesced low-priority announcement — "Alice
+ filled 3 squares in 5 Across", or "Alice filled 5 Across" if they
+ completed it — never one announcement per letter, and never
+ interrupting your own typing echo mid-word.
+12. **Local edits stay silent.** Type and delete letters yourself,
+ including undo/redo: no "filled" announcements should fire for your
+ own activity. (Known gap: a peer *clearing* squares is silent too.)
+
+Tip: Accessibility Inspector can also target a physical device for the
+structural checks, but the focus/typing behaviour only shows up under genuine
+VoiceOver. The rotor, custom actions, and announcements cannot be exercised
+from the Inspector at all — device only.
+
+For the eventual end-to-end test (Phase 2's exit criterion — a full solve with
+the screen off): triple-tap with three fingers toggles Screen Curtain while
+VoiceOver is running.
diff --git a/Notes/ViewStructure.md b/Notes/ViewStructure.md
@@ -0,0 +1,60 @@
+# SwiftUI UI Audit
+
+This audit covers the SwiftUI code under `Crossmate/Views`, reviewed against the `$Swiftie` guidance. No code changes were made as part of the review.
+
+## Findings
+
+### Resolved: Custom Environment and Focused Values Carried Closures
+
+The original audit found custom environment/focused values exposing behavior as closures. This has been addressed by:
+
+- Replacing the app action closure entries in `Crossmate/Sync/SyncEngine.swift` with a stable `AppActions` object injected through `\.appActions`.
+- Moving puzzle reveal confirmation state into `RevealConfirmation`, so `PuzzleActionTarget` stores stable references and methods rather than a `requestReveal` closure.
+
+### Resolved: GameListView Derived Sorted and Filtered Collections in the View Update Path
+
+`GameListView.content(usesRoomierType:)` maps fetched Core Data entities into summaries, filters and sorts in-progress, revoked, and completed collections, slices completed games, builds blocked author IDs, and filters invites before rendering.
+
+`GameSummaryCache` reduces summary construction cost, but the collection derivation still scales with the number of games and repeats whenever this view invalidates. Swiftie recommends caching sorted/filtered collections in a model or view-local state and recomputing them only when their inputs change.
+
+This is most relevant because `GameListView` also reads several environment values and state values that can invalidate the whole view.
+
+This has been addressed by caching a `GameListSections` snapshot in view-local
+state. The snapshot is rebuilt when the managed object context changes or when
+local player identity inputs change, while layout, announcement, Dynamic Type,
+and other unrelated view invalidations reuse the prepared sections.
+
+### Medium: Large Computed Subviews Are Used as Structural Factoring
+
+Several large screens use computed `some View` properties or `@ViewBuilder` helper functions to organize distinct sections:
+
+- `GameListView.content`, `listLayout`, `gridLayout`, invite rows/cards, and section headers.
+- `PuzzleView.phoneLayout`, `landscapePadLayout`, `portraitPadLayout`, `puzzleArea`, `controlsArea`, and `controlsPanel`.
+- `NYTBrowseView.browserView`, `monthHeader`, `weekdayHeader`, `dayGrid`, random controls, and date picker popover.
+- `ClueList.clueList`, `list`, `sidebarList`, row builders, and section headers.
+
+Computed view properties and builder helpers improve readability but do not create independent SwiftUI invalidation boundaries. For the hottest screens, extracting meaningful sections into separate `View` structs with narrow inputs would let SwiftUI skip more work when unrelated state changes.
+
+This does not need to be applied mechanically to every helper. It is most valuable for sections that are large, repeated, inside lazy containers, or depend on different subsets of state.
+
+### Low/Medium: Some User-Facing Text Formatting Is English-Shaped at Runtime
+
+There are several localization-related patterns worth tightening:
+
+- `PuzzleView.formattedList(_:)` manually joins names with comma and `and`. Prefer locale-aware list formatting such as `items.formatted()`.
+- `ClueList.headingRow(_:)` and `sidebarSectionHeader(_:)` use `.textCase(.uppercase)`. Swiftie prefers putting the desired case in the localized string itself so translators can adjust casing per language.
+- `NYTBrowseView.monthTitle` uses `DateFormatter.dateFormat = "MMMM yyyy"`. Prefer a locale-aware format style, or `setLocalizedDateFormatFromTemplate(_:)` if a `DateFormatter` is needed.
+
+These are not correctness blockers, but they are useful cleanup targets before deeper localization work.
+
+## Positive Notes
+
+- The views generally use Observation (`@Observable`, `@Bindable`, `@Environment(Model.self)`) rather than `ObservableObject` / `@ObservedObject`.
+- `@State` properties in the scanned view files are private.
+- Most `ForEach` usage has stable identity. The offset-based calendar/grid cases appear slot-based and low risk unless those rows later gain local state or animation-sensitive behavior.
+- The code already has focused performance-oriented comments and caches in areas such as `GameSummaryCache`, which suggests the project is already paying attention to SwiftUI invalidation cost.
+
+## Suggested Order of Work
+
+1. Extract the largest, hottest computed subviews into real `View` structs with narrow inputs.
+2. Clean up the localization-shaped formatting issues as part of a dedicated localization pass.