diff --git a/.cursor/rules/agents-md-authority.mdc b/.cursor/rules/agents-md-authority.mdc new file mode 100644 index 0000000..cbb2314 --- /dev/null +++ b/.cursor/rules/agents-md-authority.mdc @@ -0,0 +1,16 @@ +--- +description: Repo agent guidelines — follow AGENTS.md (safety, tooling, workflow) +alwaysApply: true +--- + +# AGENTS.md is authoritative + +For this repository, **read and follow** the root **`AGENTS.md`** file. It takes precedence over generic coding advice for: + +- Prohibited commands and simulator safety +- Filesystem boundaries and where to write artefacts +- **Scripting / shell policy** (bash, Unix CLI, Swift & Xcode only for improvised commands; no ad-hoc Python/Ruby/Node one-liners) +- Build/test patterns (`utility/build_and_test.sh`, etc.) +- iOS/macOS project conventions noted there + +Do **not** contradict `AGENTS.md`. If a task needs detail, **open `AGENTS.md`** rather than guessing. Keeping one canonical doc avoids duplicating long policy in Cursor rules. diff --git a/.gitignore b/.gitignore index a48d954..4589fbf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ RA11y.xcworkspace/xcuserdata/josephmccraw.xcuserdatad/UserInterfaceState.xcuserstate /build_results +# PNG backups from utility/ensure_png_rgba.py and utility/remove_white_background.py +**/Assets.xcassets/**/*.png.bak +**/Assets.xcassets/**/*.png.bak.* + # Fastlane /fastlane/screenshots /fastlane/test_output diff --git a/AGENTS.md b/AGENTS.md index 3b818c8..92caae6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,6 +10,12 @@ Follow every section. These are not suggestions. If any guidance below conflicts, the repository operational rules in this file take precedence for work in this repo. The SwiftUI rules are intended for design and implementation guidance and should not override repo safety or workflow rules. +### How coding agents discover this file + +- **OpenAI Codex** loads **`AGENTS.md`** from the repository root (and parent paths) automatically—no duplicate policy file needed for Codex. +- **Anthropic Claude Code** loads **`CLAUDE.md`** by default; this repo keeps a **short root `CLAUDE.md`** that points here so behavior stays single-source. +- **Cursor** uses **`.cursor/rules/agents-md-authority.mdc`** to require following this document. + --- ## Project Structure @@ -135,6 +141,27 @@ Prefer standard shell tools over interpreted scripting languages. This reduces interpreter version dependencies, limits arbitrary code execution surface, and keeps scripts auditable by anyone with basic shell knowledge. +### Maintainer toolchain (agents — required) + +The primary maintainer reviews agent work using **bash**, **standard Unix +command-line tools**, and **Swift / Xcode** (`swift`, `xcodebuild`, etc.) +only. They do **not** use Python or other extra runtimes for day-to-day +review. + +**Agents MUST follow that boundary for anything they *run* in the shell:** + +- **Allowed:** `bash` / `sh`, POSIX and macOS CLI (`grep`, `sed`, `awk`, `jq`, + `plutil`, `xcrun`, `xcodebuild`, `swift`, committed `utility/*.sh`, and + other tools that are clearly “plain Unix + Apple toolchain.” +- **Forbidden for improvised use:** `python`, `python3`, `ruby`, `node`, + `npm`, `npx`, or similar **one-liners and ad-hoc scripts**—including + “quick” JSON/XML validation—so the maintainer can reason about safety + without learning another language. Use `./utility/build_and_test.sh`, + Xcode build output, or shell tools (`plutil`, etc.) instead. +- **Exception:** Run **only** the already-committed, documented Python + utilities under `utility/` when the task is explicitly the asset/PNG + pipeline listed below—not new `python3 -c '…'` invocations. + ### Preferred Tools (use these first) - **Text processing:** `awk`, `sed`, `grep`, `ripgrep (rg)`, `cut`, `tr`, `sort`, `uniq` @@ -156,6 +183,7 @@ script unless ALL of the following are true: Existing project scripts that already use Python are grandfathered: - `utility/remove_white_background.py` — approved, do not rewrite +- `utility/ensure_png_rgba.py` — batch RGBA normalization for `Assets.xcassets` composited art (uses edge logic from `remove_white_background.py`) - `utility/build_and_test.sh` — shell, already compliant ### For New Automation diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dce3606 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,7 @@ +# Claude Code — use AGENTS.md + +All repository rules for contributors and AI agents are defined in **`AGENTS.md`** at the root of this repo (safety, prohibited commands, filesystem boundaries, shell/tooling policy, build and test workflow, SwiftUI guidance). + +**Read and follow `AGENTS.md` before substantial work.** Do not contradict it. Put any Claude-only preferences here only if they truly cannot live in `AGENTS.md`; keep them minimal. + +**Codex** uses the same `AGENTS.md` directly. **Cursor** is wired via `.cursor/rules/agents-md-authority.mdc` to honor `AGENTS.md`. diff --git a/RA11y-iOS/RA11y-iOS/App/iOSAppRouter.swift b/RA11y-iOS/RA11y-iOS/App/iOSAppRouter.swift index 4fe0c22..520824f 100644 --- a/RA11y-iOS/RA11y-iOS/App/iOSAppRouter.swift +++ b/RA11y-iOS/RA11y-iOS/App/iOSAppRouter.swift @@ -15,10 +15,10 @@ enum AppRoute: Hashable { case firstRun(mode: FirstRunMode) /// Game 1 — The Enchanter's Trial (Find & Focus). Implemented in M5. case enchantersTrial - /// Game 2 — The Rogue's Gauntlet (Activate / Double-Tap). Implemented in M6. - case roguesGauntlet - /// Game 3 — Crystal Resonance (Scroll Hunt). Implemented in M7. + /// Game 2 — Crystal Resonance (`GameKind.scrollHunt`). Implemented in M7. case dungeonDescent + /// Game 3 — The Banishment (`GameKind.banishment`). Escape-gesture training. + case theBanishment /// Crystal Resonance v2 prototype route for iterative design and feedback integration. case dungeonResonancePrototype /// The shared result screen shown after any game completes. @@ -136,10 +136,10 @@ final class iOSAppRouter { switch kind { case .findAndFocus: push(.enchantersTrial) - case .activateDoubleTap: - push(.roguesGauntlet) case .scrollHunt: push(.dungeonDescent) + case .banishment: + push(.theBanishment) } } diff --git a/RA11y-iOS/RA11y-iOS/App/iOSRootView.swift b/RA11y-iOS/RA11y-iOS/App/iOSRootView.swift index 8300d37..a9f7277 100644 --- a/RA11y-iOS/RA11y-iOS/App/iOSRootView.swift +++ b/RA11y-iOS/RA11y-iOS/App/iOSRootView.swift @@ -47,8 +47,6 @@ struct iOSRootView: View { if args.contains("-uiTesting") { if args.contains("-screenshotDirectToEnchanter") { router.pushGame(kind: .findAndFocus) - } else if args.contains("-screenshotDirectToRogue") { - router.pushGame(kind: .activateDoubleTap) } else if args.contains("-screenshotDirectToDungeon") { router.pushGame(kind: .scrollHunt) } @@ -91,10 +89,10 @@ struct iOSRootView: View { iOSFirstRunView(mode: mode, storage: storage) case .enchantersTrial: iOSEnchantersTrialView(storage: storage) - case .roguesGauntlet: - iOSRogueGauntletView(storage: storage) case .dungeonDescent: iOSDungeonDescentView(storage: storage) + case .theBanishment: + iOSBanishmentQuestView(storage: storage) case .dungeonResonancePrototype: iOSDungeonResonanceMockupView() case .gameResult(let result, let gameKind, let gameSpecificAnnouncement): @@ -124,8 +122,8 @@ struct iOSRootView: View { private func gameKind(forGameID gameID: String) -> GameKind? { switch gameID { case "find-and-focus": return .findAndFocus - case "rogue-gauntlet": return .activateDoubleTap case "scroll-hunt": return .scrollHunt + case "the-banishment": return .banishment default: return nil } } @@ -141,8 +139,8 @@ struct iOSRootView: View { /// long (> ~50 ms) the storage actor or UserDefaults is the bottleneck. /// /// ## Screenshot Testing — Direct Route Override - /// When launched with `-screenshotDirectToEnchanter` (or `-screenshotDirectToRogue` / - /// `-screenshotDirectToDungeon`), the router's `path` is pre-populated by the `@State` + /// When launched with `-screenshotDirectToEnchanter` or `-screenshotDirectToDungeon`, + /// the router's `path` is pre-populated by the `@State` /// initializer before this task even runs. This function does not touch the path for /// those scenarios — it only removes the loading overlay by setting `hasResolvedInitialRoute`. private func resolveInitialRouteIfNeeded() async { diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_ember_shard.imageset/dungeon_decoy_ember_shard.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_ember_shard.imageset/dungeon_decoy_ember_shard.png index 4684cdf..ce765e2 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_ember_shard.imageset/dungeon_decoy_ember_shard.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_ember_shard.imageset/dungeon_decoy_ember_shard.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_shadow_glyph.imageset/dungeon_decoy_shadow_glyph.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_shadow_glyph.imageset/dungeon_decoy_shadow_glyph.png index 8a8e638..555c8d1 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_shadow_glyph.imageset/dungeon_decoy_shadow_glyph.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_shadow_glyph.imageset/dungeon_decoy_shadow_glyph.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_sun_sigil.imageset/dungeon_decoy_sun_sigil.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_sun_sigil.imageset/dungeon_decoy_sun_sigil.png index 91949c2..1f7b3e0 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_sun_sigil.imageset/dungeon_decoy_sun_sigil.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_sun_sigil.imageset/dungeon_decoy_sun_sigil.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_lane_marker_neutral.imageset/dungeon_lane_marker_neutral.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_lane_marker_neutral.imageset/dungeon_lane_marker_neutral.png index edae37a..004c6e5 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_lane_marker_neutral.imageset/dungeon_lane_marker_neutral.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_lane_marker_neutral.imageset/dungeon_lane_marker_neutral.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_idle.imageset/dungeon_resonance_orb_idle.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_idle.imageset/dungeon_resonance_orb_idle.png index 87b033e..0c804fb 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_idle.imageset/dungeon_resonance_orb_idle.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_idle.imageset/dungeon_resonance_orb_idle.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_locked.imageset/dungeon_resonance_orb_locked.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_locked.imageset/dungeon_resonance_orb_locked.png index fa86ffc..ac65c4f 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_locked.imageset/dungeon_resonance_orb_locked.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_locked.imageset/dungeon_resonance_orb_locked.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_reticle_ring.imageset/dungeon_reticle_ring.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_reticle_ring.imageset/dungeon_reticle_ring.png index c808b44..53021fe 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_reticle_ring.imageset/dungeon_reticle_ring.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_reticle_ring.imageset/dungeon_reticle_ring.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_ancient_vault.imageset/dungeon_room_ancient_vault.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_ancient_vault.imageset/dungeon_room_ancient_vault.png index 440236a..696361b 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_ancient_vault.imageset/dungeon_room_ancient_vault.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_ancient_vault.imageset/dungeon_room_ancient_vault.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_armory.imageset/dungeon_room_armory.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_armory.imageset/dungeon_room_armory.png index 374fb5a..d9b5d5d 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_armory.imageset/dungeon_room_armory.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_armory.imageset/dungeon_room_armory.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_barracks.imageset/dungeon_room_barracks.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_barracks.imageset/dungeon_room_barracks.png index 1033f8d..89a8842 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_barracks.imageset/dungeon_room_barracks.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_barracks.imageset/dungeon_room_barracks.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_bone_room.imageset/dungeon_room_bone_room.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_bone_room.imageset/dungeon_room_bone_room.png index 11908b2..c073158 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_bone_room.imageset/dungeon_room_bone_room.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_bone_room.imageset/dungeon_room_bone_room.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_collapsed_wall.imageset/dungeon_room_collapsed_wall.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_collapsed_wall.imageset/dungeon_room_collapsed_wall.png index c8c9155..dbdb4c9 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_collapsed_wall.imageset/dungeon_room_collapsed_wall.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_collapsed_wall.imageset/dungeon_room_collapsed_wall.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_entry_hall.imageset/dungeon_room_entry_hall.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_entry_hall.imageset/dungeon_room_entry_hall.png index 73702ab..ff798e3 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_entry_hall.imageset/dungeon_room_entry_hall.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_entry_hall.imageset/dungeon_room_entry_hall.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_flooded_hall.imageset/dungeon_room_flooded_hall.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_flooded_hall.imageset/dungeon_room_flooded_hall.png index 0f99ef0..a7b6f3d 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_flooded_hall.imageset/dungeon_room_flooded_hall.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_flooded_hall.imageset/dungeon_room_flooded_hall.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_guard_post.imageset/dungeon_room_guard_post.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_guard_post.imageset/dungeon_room_guard_post.png index 300aa70..0e53de3 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_guard_post.imageset/dungeon_room_guard_post.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_guard_post.imageset/dungeon_room_guard_post.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_ritual_chamber.imageset/dungeon_room_ritual_chamber.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_ritual_chamber.imageset/dungeon_room_ritual_chamber.png index 8e2e5c7..f10558c 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_ritual_chamber.imageset/dungeon_room_ritual_chamber.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_ritual_chamber.imageset/dungeon_room_ritual_chamber.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_store_room.imageset/dungeon_room_store_room.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_store_room.imageset/dungeon_room_store_room.png index 190ebe5..17405ac 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_store_room.imageset/dungeon_room_store_room.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_store_room.imageset/dungeon_room_store_room.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_trophy_room.imageset/dungeon_room_trophy_room.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_trophy_room.imageset/dungeon_room_trophy_room.png index a31b132..a2fd390 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_trophy_room.imageset/dungeon_room_trophy_room.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_trophy_room.imageset/dungeon_room_trophy_room.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_well_chamber.imageset/dungeon_room_well_chamber.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_well_chamber.imageset/dungeon_room_well_chamber.png index 8334e83..df4994a 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_well_chamber.imageset/dungeon_room_well_chamber.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_well_chamber.imageset/dungeon_room_well_chamber.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_spotlight_mask_reference.imageset/dungeon_spotlight_mask_reference.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_spotlight_mask_reference.imageset/dungeon_spotlight_mask_reference.png index bece5e3..d0ccea7 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_spotlight_mask_reference.imageset/dungeon_spotlight_mask_reference.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_spotlight_mask_reference.imageset/dungeon_spotlight_mask_reference.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_success_flare.imageset/dungeon_success_flare.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_success_flare.imageset/dungeon_success_flare.png index a58f30c..1f76a3e 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_success_flare.imageset/dungeon_success_flare.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_success_flare.imageset/dungeon_success_flare.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_target_moonstone.imageset/dungeon_target_moonstone.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_target_moonstone.imageset/dungeon_target_moonstone.png index 857afbd..bf4b060 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_target_moonstone.imageset/dungeon_target_moonstone.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_target_moonstone.imageset/dungeon_target_moonstone.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_hub_icon.imageset/enchanter_hub_icon.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_hub_icon.imageset/enchanter_hub_icon.png index e5194f2..ec8e182 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_hub_icon.imageset/enchanter_hub_icon.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_hub_icon.imageset/enchanter_hub_icon.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_hub_icon.imageset/rogue_hub_icon.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_hub_icon.imageset/rogue_hub_icon.png index 7f12cf4..0d74bdc 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_hub_icon.imageset/rogue_hub_icon.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_hub_icon.imageset/rogue_hub_icon.png differ diff --git a/RA11y-iOS/RA11y-iOS/Feedback/iOSAudioFeedbackRenderer.swift b/RA11y-iOS/RA11y-iOS/Feedback/iOSAudioFeedbackRenderer.swift index 95b40ae..1e2a130 100644 --- a/RA11y-iOS/RA11y-iOS/Feedback/iOSAudioFeedbackRenderer.swift +++ b/RA11y-iOS/RA11y-iOS/Feedback/iOSAudioFeedbackRenderer.swift @@ -1,6 +1,8 @@ +import AudioToolbox import AVFoundation import OSLog import RA11yCore +import UIKit /// Renders semantic feedback intents as lightweight generated iOS tones. /// @@ -39,15 +41,29 @@ final class iOSAudioFeedbackRenderer: iOSAudioFeedbackRendering { func render(intent: QuestFeedbackIntent, cue: QuestFeedbackCue) { guard cue.audio != .none else { return } + + // `1520` — system “peek” tap; louder than `1104` and still short enough to read through VoiceOver. + if cue.audio == .laneSlotStep, UIAccessibility.isVoiceOverRunning { + AudioServicesPlaySystemSound(1520) + RA11yLogger.feedback.debug("Lane slot audio: system peek (VoiceOver on)") + return + } + configureSessionIfNeeded() prepareEngineIfNeeded() + let segments = cuePattern(for: intent, family: cue.audio) + guard engine.isRunning else { - RA11yLogger.feedback.error("Audio cue dropped because engine is not running") + if cue.audio == .laneSlotStep { + AudioServicesPlaySystemSound(1520) + RA11yLogger.feedback.warning("Lane slot: engine down; played system sound fallback") + } else { + RA11yLogger.feedback.error("Audio cue dropped because engine is not running") + } return } - let segments = cuePattern(for: intent, family: cue.audio) guard let buffer = makeBuffer(for: segments) else { RA11yLogger.feedback.error("Failed to synthesize audio cue for family \(cue.audio.rawValue)") return @@ -99,6 +115,10 @@ final class iOSAudioFeedbackRenderer: iOSAudioFeedbackRendering { return [] case .resonance: return resonancePattern(for: intent) + case .laneSlotStep: + return [ + ToneSegment(primaryFrequency: 588, secondaryFrequency: 884, duration: 0.09, amplitude: 0.22, trailingSilence: 0.0), + ] case .mutedError: return [ ToneSegment(primaryFrequency: 288, secondaryFrequency: 272, duration: 0.08, amplitude: 0.07, trailingSilence: 0.02), @@ -126,23 +146,23 @@ final class iOSAudioFeedbackRenderer: iOSAudioFeedbackRendering { switch intent { case .proximityEntered(.warm): return [ - ToneSegment(primaryFrequency: 432, secondaryFrequency: 438, duration: 0.12, amplitude: 0.05, trailingSilence: 0.0), + ToneSegment(primaryFrequency: 432, secondaryFrequency: 438, duration: 0.12, amplitude: 0.09, trailingSilence: 0.0), ] case .proximityEntered(.near): return [ - ToneSegment(primaryFrequency: 524, secondaryFrequency: 526, duration: 0.12, amplitude: 0.07, trailingSilence: 0.0), + ToneSegment(primaryFrequency: 524, secondaryFrequency: 526, duration: 0.12, amplitude: 0.12, trailingSilence: 0.0), ] case .proximityEntered(.locked), .lockAcquired: return [ - ToneSegment(primaryFrequency: 660, secondaryFrequency: 990, duration: 0.18, amplitude: 0.1, trailingSilence: 0.0), + ToneSegment(primaryFrequency: 660, secondaryFrequency: 990, duration: 0.18, amplitude: 0.14, trailingSilence: 0.0), ] case .lockLost: return [ - ToneSegment(primaryFrequency: 510, secondaryFrequency: 500, duration: 0.1, amplitude: 0.06, trailingSilence: 0.0), + ToneSegment(primaryFrequency: 510, secondaryFrequency: 500, duration: 0.1, amplitude: 0.09, trailingSilence: 0.0), ] default: return [ - ToneSegment(primaryFrequency: 410, secondaryFrequency: 418, duration: 0.1, amplitude: 0.04, trailingSilence: 0.0), + ToneSegment(primaryFrequency: 410, secondaryFrequency: 418, duration: 0.1, amplitude: 0.08, trailingSilence: 0.0), ] } } diff --git a/RA11y-iOS/RA11y-iOS/Feedback/iOSHapticFeedbackRenderer.swift b/RA11y-iOS/RA11y-iOS/Feedback/iOSHapticFeedbackRenderer.swift index 7e9df3d..7f986d0 100644 --- a/RA11y-iOS/RA11y-iOS/Feedback/iOSHapticFeedbackRenderer.swift +++ b/RA11y-iOS/RA11y-iOS/Feedback/iOSHapticFeedbackRenderer.swift @@ -17,12 +17,14 @@ final class iOSHapticFeedbackRenderer: iOSHapticFeedbackRendering { private let mediumImpact = UIImpactFeedbackGenerator(style: .medium) private let rigidImpact = UIImpactFeedbackGenerator(style: .rigid) private let notification = UINotificationFeedbackGenerator() + private let selectionChanged = UISelectionFeedbackGenerator() init() { lightImpact.prepare() mediumImpact.prepare() rigidImpact.prepare() notification.prepare() + selectionChanged.prepare() } func render(intent: QuestFeedbackIntent, cue: QuestFeedbackCue) { @@ -30,16 +32,28 @@ final class iOSHapticFeedbackRenderer: iOSHapticFeedbackRendering { case .none: return case .softTick: + lightImpact.prepare() lightImpact.impactOccurred(intensity: 0.55) + case .selectionChanged: + // `UISelectionFeedbackGenerator` is easy to miss with VoiceOver; pair with a light impact. + selectionChanged.prepare() + selectionChanged.selectionChanged() + lightImpact.prepare() + lightImpact.impactOccurred(intensity: 0.95) case .proximityPulse: + mediumImpact.prepare() mediumImpact.impactOccurred(intensity: 0.7) case .alignmentSnap: + rigidImpact.prepare() rigidImpact.impactOccurred(intensity: 0.95) case .errorTap: + notification.prepare() notification.notificationOccurred(.error) case .successPulse: + notification.prepare() notification.notificationOccurred(.success) case .warningTap: + notification.prepare() notification.notificationOccurred(.warning) } diff --git a/RA11y-iOS/RA11y-iOS/Feedback/iOSQuestFeedbackCoordinator.swift b/RA11y-iOS/RA11y-iOS/Feedback/iOSQuestFeedbackCoordinator.swift index c505b73..8bcb3dd 100644 --- a/RA11y-iOS/RA11y-iOS/Feedback/iOSQuestFeedbackCoordinator.swift +++ b/RA11y-iOS/RA11y-iOS/Feedback/iOSQuestFeedbackCoordinator.swift @@ -93,6 +93,8 @@ final class iOSQuestFeedbackCoordinator { return profile.timeoutCue case .hint: return profile.hintCue + case .laneSlotTick: + return profile.laneSlotCue } } @@ -112,6 +114,8 @@ final class iOSQuestFeedbackCoordinator { return "timeout" case .hint: return "hint" + case .laneSlotTick: + return "lane-slot" } } } diff --git a/RA11y-iOS/RA11y-iOS/FirstRun/iOSBasicsSequenceView.swift b/RA11y-iOS/RA11y-iOS/FirstRun/iOSBasicsSequenceView.swift index 487bad8..86f20e9 100644 --- a/RA11y-iOS/RA11y-iOS/FirstRun/iOSBasicsSequenceView.swift +++ b/RA11y-iOS/RA11y-iOS/FirstRun/iOSBasicsSequenceView.swift @@ -3,8 +3,8 @@ import RA11yCore // MARK: - iOSBasicsSequenceView -/// Guided VoiceOver Basics sequence that walks the player through all three MVP games -/// in hub unlock order (Enchanter → Crystal Resonance → Rogue). +/// Guided VoiceOver Basics sequence that walks the player through MVP games +/// in hub unlock order (Enchanter → Crystal Resonance → The Banishment). /// /// This view acts as the **conductor** for the M4 basics flow. It stays on the navigation /// stack throughout the sequence and orchestrates the push-and-return loop: @@ -27,7 +27,7 @@ struct iOSBasicsSequenceView: View { // MARK: - Private Properties private let storage: any StorageComponent - private let steps: [GameKind] = [.findAndFocus, .scrollHunt, .activateDoubleTap] + private let steps: [GameKind] = [.findAndFocus, .scrollHunt, .banishment] // MARK: - State @@ -163,10 +163,10 @@ struct iOSBasicsSequenceView: View { switch steps[currentIndex] { case .findAndFocus: return String(localized: "basicsSequence.skill.findAndFocus.title") - case .activateDoubleTap: - return String(localized: "basicsSequence.skill.activateDoubleTap.title") case .scrollHunt: return String(localized: "basicsSequence.skill.scrollHunt.title") + case .banishment: + return String(localized: "basicsSequence.skill.banishment.title") } } @@ -174,10 +174,10 @@ struct iOSBasicsSequenceView: View { switch steps[currentIndex] { case .findAndFocus: return String(localized: "basicsSequence.skill.findAndFocus.intro") - case .activateDoubleTap: - return String(localized: "basicsSequence.skill.activateDoubleTap.intro") case .scrollHunt: return String(localized: "basicsSequence.skill.scrollHunt.intro") + case .banishment: + return String(localized: "basicsSequence.skill.banishment.intro") } } @@ -226,7 +226,7 @@ struct iOSBasicsSequenceView: View { if nextIndex < steps.count { currentIndex = nextIndex } else { - // All three games completed — mark done and return to hub. + // All Basics steps completed — mark done and return to hub. router.isInBasicsSequence = false Task { await storage.markBasicsCompleted() diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSBanishmentQuestView.swift b/RA11y-iOS/RA11y-iOS/Games/iOSBanishmentQuestView.swift new file mode 100644 index 0000000..bf99e75 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Games/iOSBanishmentQuestView.swift @@ -0,0 +1,557 @@ +import Observation +import OSLog +import SwiftUI +import UIKit +import RA11yCore + +// MARK: - Banishment phase + +/// High-level progression for The Banishment (greybox: SF Symbols, escape-first). +enum BanishmentPhase: Equatable { + case prologue + /// Practice trap — two-finger scrub only; hints visible. + case wardTrap + /// Post-ward beat before the timed run. + case wardIntermission + /// Timed gauntlet: index 0..? + + private let storage: any StorageComponent + + private static let wardThreat = BanishmentThreat( + id: "ward_rat", + symbolName: "hare.fill", + spokenName: String(localized: "banishment.threat.ward.spoken") + ) + + private static let towerThreats: [BanishmentThreat] = [ + BanishmentThreat(id: "tower_rat", symbolName: "hare.fill", spokenName: String(localized: "banishment.threat.rat.spoken")), + BanishmentThreat(id: "tower_golem", symbolName: "shield.lefthalf.filled", spokenName: String(localized: "banishment.threat.golem.spoken")), + BanishmentThreat(id: "tower_wisp", symbolName: "sparkles", spokenName: String(localized: "banishment.threat.wisp.spoken")), + ] + + private static let darkThreat = BanishmentThreat( + id: "dark_shade", + symbolName: "moon.stars.fill", + spokenName: String(localized: "banishment.threat.dark.spoken") + ) + + /// Must match ``RankThresholds/banishment`` `timeoutSeconds`. + private static let scoredDuration: Double = 55 + + init(storage: any StorageComponent) { + self.storage = storage + } + + var isLightsOffPhase: Bool { + if case .darkTower = phase { return true } + return false + } + + var showsTimedHUD: Bool { + switch phase { + case .tower, .darkTower: return true + default: return false + } + } + + var currentThreat: BanishmentThreat? { + switch phase { + case .wardTrap: return Self.wardThreat + case .tower(let i): + guard i >= 0, i < Self.towerThreats.count else { return nil } + return Self.towerThreats[i] + case .darkTower: return Self.darkThreat + default: return nil + } + } + + var showsTrapHint: Bool { + if case .wardTrap = phase { return true } + return false + } + + func beginTrial() { + phase = .wardTrap + statusMessage = nil + announceThreatArrival() + } + + func continueAfterWard() { + phase = .wardIntermission + statusMessage = String(localized: "banishment.ward.cleared") + } + + /// Starts the scored session + countdown after the player leaves the ward. + func beginScoredGauntlet() { + phase = .tower(0) + mistakes = 0 + timedOut = false + completedResult = nil + statusMessage = nil + timeRemaining = Self.scoredDuration + voiceOverDisabledMidGame = false + + let newSession = GameSession( + gameID: "the-banishment", + thresholds: .banishment, + storage: storage + ) + let newCoordinator = GameSessionCoordinator( + session: newSession, + gameKind: .banishment, + voiceOverProvider: iOSLiveVoiceOverStateProvider() + ) + session = newSession + coordinator = newCoordinator + + Task { @MainActor [weak self] in + guard let self else { return } + do { + try await newSession.start() + } catch { + RA11yLogger.gameSession.error("Banishment session start failed: \(error.localizedDescription)") + return + } + newCoordinator.startMonitoring() + self.observeCoordinatorVOState(coordinator: newCoordinator) + if UIAccessibility.isVoiceOverRunning { + try? await Task.sleep(for: .seconds(3)) + guard !Task.isCancelled else { return } + } + self.startScoredTimer() + } + announceThreatArrival() + } + + /// VoiceOver two-finger scrub / escape — dismisses the active trap when allowed. + func performBanishEscape() { + switch phase { + case .wardTrap: + announce(String(localized: "banishment.feedback.banished")) + continueAfterWard() + case .tower(let index): + if index + 1 < Self.towerThreats.count { + phase = .tower(index + 1) + announce(String(localized: "banishment.feedback.banished")) + announceThreatArrival() + } else { + phase = .darkTower + announce(String(localized: "banishment.feedback.enterDark")) + announceThreatArrival() + } + case .darkTower: + Task { await self.completeScoredRun() } + default: + break + } + } + + func wrongDecoyTapped() { + switch phase { + case .wardTrap: + statusMessage = String(localized: "banishment.feedback.wrong.ward") + announce(String(localized: "banishment.feedback.wrong.ward")) + case .tower, .darkTower: + mistakes += 1 + statusMessage = String(localized: "banishment.feedback.wrong.decoy") + announce(String(localized: "a11y.level.mistake")) + if let session { + Task { try? await session.recordMistake() } + } + default: + break + } + } + + func handleViewDisappear() { + stopTimer() + coordinator?.stopMonitoring() + guard let session else { return } + Task { await session.abandon() } + } + + // MARK: - Private + + private func announceThreatArrival() { + guard let threat = currentThreat else { return } + let format = String(localized: "banishment.a11y.threatArrived") + announce(String(format: format, threat.spokenName)) + } + + private func completeScoredRun() async { + stopTimer() + guard let session else { return } + coordinator?.stopMonitoring() + do { + try await session.complete() + if case .completed(let result) = await session.state { + completedResult = result + } + } catch { + RA11yLogger.gameSession.error("Banishment complete failed: \(error.localizedDescription)") + } + } + + private func handleTimeout() async { + guard let session else { return } + timedOut = true + stopTimer() + coordinator?.stopMonitoring() + await session.abandon() + announce(String(localized: "banishment.timeout")) + let elapsed = Self.scoredDuration + completedResult = GameResult( + gameID: "the-banishment", + rank: .failed, + timeSeconds: elapsed, + mistakes: mistakes + ) + } + + private func startScoredTimer() { + timerTask?.cancel() + let total = Self.scoredDuration + timerTask = Task { @MainActor [weak self] in + let startDate = Date() + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(250)) + guard let self, !Task.isCancelled else { return } + let elapsed = Date().timeIntervalSince(startDate) + self.timeRemaining = max(0, total - elapsed) + if self.timeRemaining <= 0 { + await self.handleTimeout() + break + } + } + } + } + + private func stopTimer() { + timerTask?.cancel() + timerTask = nil + } + + private func observeCoordinatorVOState(coordinator: GameSessionCoordinator) { + Task { @MainActor [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .milliseconds(200)) + guard let self, !Task.isCancelled else { return } + if coordinator.voiceOverDisabledMidGame { + self.voiceOverDisabledMidGame = true + self.stopTimer() + return + } + } + } + } + + private func announce(_ message: String) { + UIAccessibility.post(notification: .announcement, argument: message) + } +} + +// MARK: - iOSBanishmentQuestView + +/// The Banishment — teaches VoiceOver’s two-finger scrub escape using SF Symbol greybox UI. +/// +/// ## Concurrency +/// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. +struct iOSBanishmentQuestView: View { + + @State private var viewModel: BanishmentQuestViewModel + @Environment(iOSAppRouter.self) private var router + + private let storage: any StorageComponent + + init(storage: any StorageComponent) { + self.storage = storage + _viewModel = State(initialValue: BanishmentQuestViewModel(storage: storage)) + } + + var body: some View { + ZStack { + background + mainContent + if viewModel.currentThreat != nil { + trapOverlay + } + } + .navigationTitle(navigationTitle) + .navigationBarTitleDisplayMode(.inline) + .onChange(of: viewModel.completedResult) { _, result in + guard let result else { return } + router.push(.gameResult(result, gameKind: .banishment, gameSpecificAnnouncement: announcement(for: result))) + } + .onChange(of: viewModel.voiceOverDisabledMidGame) { _, disabled in + if disabled { + router.push(.voiceOverInterstitial(kind: .banishment)) + } + } + .onDisappear { viewModel.handleViewDisappear() } + } + + private var navigationTitle: String { + switch viewModel.phase { + case .prologue: return String(localized: "banishment.nav.prologue") + case .wardTrap, .wardIntermission: return String(localized: "banishment.nav.ward") + case .tower: return String(localized: "banishment.nav.tower") + case .darkTower: return String(localized: "banishment.nav.dark") + } + } + + @ViewBuilder + private var background: some View { + if viewModel.isLightsOffPhase { + Color.black.ignoresSafeArea() + } else { + LinearGradient( + colors: [ + Color(red: 0.08, green: 0.06, blue: 0.12), + Color(red: 0.05, green: 0.04, blue: 0.08), + ], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() + } + } + + @ViewBuilder + private var mainContent: some View { + switch viewModel.phase { + case .prologue: + prologueBody + .accessibilityIdentifier("banishment.prologue") + case .wardTrap: + Color.clear + .accessibilityHidden(true) + case .wardIntermission: + wardIntermissionBody + .accessibilityIdentifier("banishment.wardIntermission") + case .tower, .darkTower: + scoredChrome + .accessibilityIdentifier("banishment.scored") + } + } + + private var prologueBody: some View { + ScrollView { + VStack(alignment: .leading, spacing: RA11ySpacing.lg) { + Label(String(localized: "banishment.prologue.kicker"), systemImage: "hand.draw.fill") + .font(.ra11yCaption) + .foregroundStyle(.secondary) + Text(String(localized: "banishment.prologue.title")) + .font(.ra11yTitle) + .bold() + Text(String(localized: "banishment.prologue.body")) + .font(.ra11yBody) + .foregroundStyle(.secondary) + Button(String(localized: "banishment.prologue.begin")) { + viewModel.beginTrial() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityIdentifier("banishment.beginTrial") + } + .padding(RA11ySpacing.lg) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) + } + } + + private var wardIntermissionBody: some View { + VStack(spacing: RA11ySpacing.lg) { + Image(systemName: "door.left.hand.open") + .font(.system(size: 56)) + .foregroundStyle(Color.ra11yAccent) + .accessibilityHidden(true) + Text(String(localized: "banishment.ward.intermission.title")) + .font(.ra11yHeadline) + .multilineTextAlignment(.center) + if let statusMessage = viewModel.statusMessage { + Text(statusMessage) + .font(.ra11yBody) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + Button(String(localized: "banishment.ward.continueGauntlet")) { + viewModel.beginScoredGauntlet() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityIdentifier("banishment.continueGauntlet") + } + .padding(RA11ySpacing.xl) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var scoredChrome: some View { + VStack(spacing: RA11ySpacing.md) { + if viewModel.showsTimedHUD { + HStack { + Label { + Text(String(localized: "banishment.hud.timeRemaining")) + } icon: { + Image(systemName: "timer") + } + .font(.ra11yCaption) + Spacer() + Text(String(format: "%.0f", viewModel.timeRemaining)) + .font(.ra11yHeadline) + .monospacedDigit() + .accessibilityLabel( + String(format: String(localized: "banishment.a11y.secondsLeft"), Int(ceil(viewModel.timeRemaining))) + ) + } + .padding(RA11ySpacing.md) + .background(.ultraThinMaterial, in: .rect(cornerRadius: RA11yRadius.card)) + .padding(.horizontal, RA11ySpacing.lg) + .accessibilityElement(children: .combine) + } + if let msg = viewModel.statusMessage { + Text(msg) + .font(.ra11yCaption) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal) + } + Spacer(minLength: 0) + } + .padding(.top, RA11ySpacing.md) + } + + private var trapOverlay: some View { + BanishmentTrapOverlay( + threat: viewModel.currentThreat, + showsHint: viewModel.showsTrapHint, + isLightsOff: viewModel.isLightsOffPhase, + onEscape: { viewModel.performBanishEscape() }, + onWrongDecoy: { viewModel.wrongDecoyTapped() } + ) + } + + private func announcement(for result: GameResult) -> String { + switch result.rank { + case .perfect: return String(localized: "banishment.results.legendary") + case .good: return String(localized: "banishment.results.skilled") + case .ok: return String(localized: "banishment.results.novice") + case .failed: return String(localized: "banishment.results.defeated") + } + } +} + +// MARK: - Trap overlay + +/// Full-screen trap: combined instruction + escape, plus an optional wrong decoy control. +private struct BanishmentTrapOverlay: View { + let threat: BanishmentThreat? + let showsHint: Bool + let isLightsOff: Bool + let onEscape: () -> Void + let onWrongDecoy: () -> Void + + var body: some View { + ZStack { + Color.black.opacity(isLightsOff ? 0.92 : 0.78) + .ignoresSafeArea() + .accessibilityHidden(true) + + VStack(spacing: RA11ySpacing.xl) { + if let threat { + Image(systemName: threat.symbolName) + .font(.system(size: 72)) + .foregroundStyle(isLightsOff ? Color.white.opacity(0.85) : Color.ra11yAccent) + .accessibilityHidden(true) + + VStack(spacing: RA11ySpacing.sm) { + Text(String(localized: "banishment.trap.title")) + .font(.ra11yHeadline) + .multilineTextAlignment(.center) + Text(String(format: String(localized: "banishment.trap.creatureFormat"), threat.spokenName)) + .font(.ra11yTitle3) + .bold() + .multilineTextAlignment(.center) + if showsHint { + Text(String(localized: "banishment.trap.hint")) + .font(.ra11yBody) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .padding(RA11ySpacing.lg) + .background(.thinMaterial, in: .rect(cornerRadius: RA11yRadius.card)) + .accessibilityElement(children: .combine) + .accessibilityLabel(accessibilityInstructionLabel(threat: threat)) + .accessibilityHint(String(localized: "banishment.trap.escape.hint")) + .accessibilityAction(.escape, onEscape) + + Button(String(localized: "banishment.trap.decoyButton")) { + onWrongDecoy() + } + .buttonStyle(.bordered) + .accessibilityIdentifier("banishment.decoy") + .accessibilityHint(String(localized: "banishment.trap.decoy.hint")) + .accessibilityAction(.escape, onEscape) + } + } + .padding(RA11ySpacing.xl) + } + .accessibilityElement(children: .contain) + .accessibilityAction(.escape, onEscape) + .accessibilityIdentifier("banishment.trap.root") + } + + private func accessibilityInstructionLabel(threat: BanishmentThreat) -> String { + let base = String(format: String(localized: "banishment.a11y.trapCombined"), threat.spokenName) + if showsHint { + return "\(base) \(String(localized: "banishment.trap.hint"))" + } + return base + } +} + +#Preview { + NavigationStack { + iOSBanishmentQuestView(storage: UserDefaultsStorageComponent()) + .environment(iOSAppRouter()) + } +} diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonDescentView.swift b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonDescentView.swift index 5b7071e..1efc83b 100644 --- a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonDescentView.swift +++ b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonDescentView.swift @@ -6,7 +6,7 @@ import RA11yCore // MARK: - iOSDungeonDescentView -/// Container for Crystal Resonance (Scroll Hunt) — M7. +/// Container for Crystal Resonance (Moonstone alignment lane) — M7. /// /// Implements the full 4-level game arc defined in `GameSpec-ScrollHunt.txt` /// and `GameRules-MVP.txt`: @@ -110,14 +110,16 @@ struct iOSDungeonDescentView: View { lightsOffMode: isLightsOffFinalLevel, lightsOffFlavorText: nil, showsFirstLevelGestureTip: true, + continueButtonTitle: String(localized: "dungeon.resonance.continue.toRising"), + initialLaneIndexOverride: viewModel.resonanceInitialLaneIndexOverride, onResonanceDeltaChanged: { viewModel.updateResonanceDelta($0) }, onActivateTarget: { room in await viewModel.activateTarget(room) }, + onLaneSlotChanged: { viewModel.notifyResonanceLaneSlotChanged() }, onHint: nil, onContinue: { viewModel.advanceToRising() }, onRetry: nil ) .navigationTitle(String(localized: "dungeon.l1.title")) - .onAppear { viewModel.announceObjectivePrompt() } .accessibilityIdentifier("dungeon.firstAttempt") .transition(.identity) @@ -133,14 +135,16 @@ struct iOSDungeonDescentView: View { lightsOffMode: isLightsOffFinalLevel, lightsOffFlavorText: nil, showsFirstLevelGestureTip: false, + continueButtonTitle: String(localized: "dungeon.resonance.continue.toFinalTrial"), + initialLaneIndexOverride: viewModel.resonanceInitialLaneIndexOverride, onResonanceDeltaChanged: { viewModel.updateResonanceDelta($0) }, onActivateTarget: { room in await viewModel.activateTarget(room) }, + onLaneSlotChanged: { viewModel.notifyResonanceLaneSlotChanged() }, onHint: { viewModel.requestHint() }, onContinue: { viewModel.advanceToTimed() }, onRetry: { viewModel.retryRising() } ) .navigationTitle(String(localized: "dungeon.l2.title")) - .onAppear { viewModel.announceObjectivePrompt() } .accessibilityIdentifier("dungeon.rising") .transition(.identity) @@ -156,14 +160,16 @@ struct iOSDungeonDescentView: View { lightsOffMode: isLightsOffFinalLevel, lightsOffFlavorText: String(localized: "dungeon.lightsOff.flavor"), showsFirstLevelGestureTip: false, + continueButtonTitle: nil, + initialLaneIndexOverride: viewModel.resonanceInitialLaneIndexOverride, onResonanceDeltaChanged: { viewModel.updateResonanceDelta($0) }, onActivateTarget: { room in await viewModel.activateTarget(room) }, + onLaneSlotChanged: { viewModel.notifyResonanceLaneSlotChanged() }, onHint: { viewModel.requestHint() }, onContinue: nil, onRetry: { viewModel.retryTimed() } ) .navigationTitle(String(localized: "dungeon.l3.title")) - .onAppear { viewModel.announceObjectivePrompt() } .accessibilityIdentifier("dungeon.timed") .transition(.identity) } @@ -214,6 +220,10 @@ final class DungeonDescentViewModel { private(set) var mistakes: Int = 0 private(set) var levelComplete: Bool = false + /// When set, ``iOSDungeonResonancePlayView`` seeds the scroll proxy to this lane index (screenshot determinism). + /// Normal play leaves this `nil` so the play surface picks a **decoy** under the hub first. + private(set) var resonanceInitialLaneIndexOverride: Int? + // MARK: - Timer State (L2 + L3) /// Remaining seconds for the active level timer. Updated approximately every 0.2 s. @@ -273,18 +283,18 @@ final class DungeonDescentViewModel { } } - /// Returns whether L3 should randomize room order and target placement (final timed level). + /// Returns whether lane order and Moonstone position should be randomized (L1–L3). /// /// Screenshot and UI-test launches keep fixed layouts for determinism. - private func shouldRandomizeDungeonLayoutForTimedLevel() -> Bool { + private func shouldRandomizeResonanceLaneLayout() -> Bool { if screenshotScene != nil { return false } if ProcessInfo.processInfo.arguments.contains("-uiTesting") { return false } return true } - /// Builds the room list for the current level, optionally randomizing target placement on L3. + /// Builds the room list for the current level, optionally shuffling order and picking a random Moonstone slot. private func roomsForLevel(_ base: [DungeonRoom], randomizeTargetPlacement: Bool) -> [DungeonRoom] { - if randomizeTargetPlacement, shouldRandomizeDungeonLayoutForTimedLevel() { + if randomizeTargetPlacement, shouldRandomizeResonanceLaneLayout() { return DungeonRoom.randomizedRoomsPreservingPool(base) } return base @@ -305,7 +315,8 @@ final class DungeonDescentViewModel { statusMessage = nil levelComplete = false targetIsReachable = false - rooms = roomsForLevel(DungeonRoom.l1Rooms, randomizeTargetPlacement: false) + resonanceInitialLaneIndexOverride = nil + rooms = roomsForLevel(DungeonRoom.l1Rooms, randomizeTargetPlacement: true) phase = .firstAttempt } @@ -318,7 +329,8 @@ final class DungeonDescentViewModel { levelComplete = false l2TimedOut = false targetIsReachable = false - rooms = roomsForLevel(DungeonRoom.l2Rooms, randomizeTargetPlacement: false) + resonanceInitialLaneIndexOverride = nil + rooms = roomsForLevel(DungeonRoom.l2Rooms, randomizeTargetPlacement: true) timeRemaining = 60 phase = .rising startTimer(total: 60) { [weak self] in await self?.handleL2Timeout() } @@ -344,6 +356,7 @@ final class DungeonDescentViewModel { completedResult = nil voiceOverDisabledMidGame = false targetIsReachable = false + resonanceInitialLaneIndexOverride = nil rooms = roomsForLevel(DungeonRoom.l3Rooms, randomizeTargetPlacement: true) timeRemaining = 45 phase = .timed @@ -440,6 +453,21 @@ final class DungeonDescentViewModel { } } + /// Fires multimodal feedback when the VoiceOver scroll proxy snaps to another lane item. + /// + /// ## Concurrency + /// Must run on the main actor with SwiftUI; the play view invokes this from scroll offset updates. + func notifyResonanceLaneSlotChanged() { + guard screenshotScene == nil else { return } + if completedResult != nil { return } + switch phase { + case .firstAttempt, .rising, .timed: + questFeedbackCoordinator.process(.laneSlotChanged) + default: + break + } + } + /// Clears resonance feedback reducer state when room sets or phases change. private func resetResonanceAlignmentState() { lastResonanceFeedbackBand = nil @@ -506,7 +534,8 @@ final class DungeonDescentViewModel { /// Handles activation of a non-target room (decoy). /// - /// Increments the mistake counter, announces feedback, and records the mistake + /// Increments the mistake counter, announces feedback, emits quest multimodal wrong-activation + /// feedback (when not in a screenshot scene), and records the mistake /// against the L3 `GameSession` if one is running. func activateNonTarget(_ room: DungeonRoom) async { guard !room.isTarget else { return } @@ -515,16 +544,20 @@ final class DungeonDescentViewModel { mistakes += 1 statusMessage = String(localized: "dungeon.feedback.non_target") announce(String(localized: "dungeon.feedback.non_target")) + if screenshotScene == nil { + questFeedbackCoordinator.process(.wrongActivation) + } if phase == .timed, let session { do { try await session.recordMistake() } catch { /* session in non-running state */ } } } - /// Announces the scroll hint to VoiceOver and as a visible status message. + /// Announces the resonance alignment hint to VoiceOver and as a visible status message. + /// + /// Copy is intentionally Moonstone-and-orb alignment only; room asset names are not part of this hint. func requestHint() { - let targetName = rooms.first(where: \.isTarget)?.displayName ?? "the target" - let message = String(format: String(localized: "dungeon.hint.format"), targetName) + let message = String(localized: "dungeon.resonance.hint") statusMessage = message guard screenshotScene == nil else { return } questFeedbackCoordinator.process(.hintRequested) @@ -684,11 +717,14 @@ final class DungeonDescentViewModel { case .dungeonPrologue: phase = .prologue practiceScrollObserved = true + resonanceInitialLaneIndexOverride = nil case .dungeonFirstAttempt: phase = .firstAttempt rooms = DungeonRoom.l1Rooms targetIsReachable = true + resonanceInitialLaneIndexOverride = rooms.firstIndex(where: \.isTarget) default: + resonanceInitialLaneIndexOverride = nil break } } @@ -723,10 +759,11 @@ final class DungeonDescentViewModel { // MARK: - DungeonRoom -/// A single chamber marker along the Crystal Resonance scroll shaft. +/// One vertical slot in the resonance lane (scene art and icon assets). /// -/// Passage rooms (isTarget) are correct targets; all others are non-interactive decoys. -/// Level-specific room sets are defined in `l1Rooms`, `l2Rooms`, `l3Rooms`. +/// The scroll game is **alignment**: move the Moonstone glyph to the orb. `displayName` exists for +/// catalog / Lights Off copy, not as a navigation metaphor in VO—player-facing objectives use alignment strings. +/// Exactly one room per level has `isTarget`; the rest are visual decoys. struct DungeonRoom: Identifiable { let id: String let displayName: String @@ -772,12 +809,11 @@ struct DungeonRoom: Identifiable { DungeonRoom(id: "collapsed_wall", displayName: "Collapsed Wall", subtitle: "A gap you can pass.", assetName: "dungeon_room_collapsed_wall", isTarget: false), ] - // MARK: - Lights Off (random target position) + // MARK: - Lane randomization (Moonstone slot + vertical order) - /// Returns a copy of `base` with exactly one random target, shuffled order, for Lights Off mode. + /// Returns a copy of `base` with exactly one random Moonstone row and **shuffled** vertical order. /// - /// Preserves stable semantics within a run: the player must scroll to the target and activate it. - /// Across runs, which room is correct and its vertical position vary so positional memory alone is insufficient. + /// Used for L1–L3 during normal play so repeat runs cannot rely on memorized shaft positions. static func randomizedRoomsPreservingPool(_ base: [DungeonRoom]) -> [DungeonRoom] { let targetIndex = Int.random(in: base.indices) let objectiveSubtitle = String(localized: "dungeon.room.objective.subtitle") diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceArt.swift b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceArt.swift index 411d9fe..f59dff2 100644 --- a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceArt.swift +++ b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceArt.swift @@ -1,9 +1,16 @@ // MARK: - iOSDungeonResonanceArt -/// Asset catalog string keys for Crystal Resonance (Scroll Hunt) v2 resonance UI. +/// Asset catalog string keys for Crystal Resonance (v2 alignment / Moonstone lane UI). /// /// PNGs live in `Assets.xcassets` as universal 1x imagesets. Layering and point sizes /// are documented in `memlog/requirements/Design/DungeonResonanceAssetPipeline.txt`. +/// Wide masters use ``iOSResonanceWideCanvasImage`` (fill + center crop) so centered +/// glyphs are not shrunk by `scaledToFit` inside small frames. +/// +/// **Lane glyphs** (moonstone + decoys): source PNGs must be **RGBA** (true transparency). +/// Flat RGB mats (no alpha) show as grey or white rectangles behind `Image`. If a source file +/// regresses to RGB-only, run `utility/remove_white_background.py` (see asset pipeline) or +/// re-export from the art tool with alpha. /// /// Warm / Near orb states may use code-driven effects (`DesignTicket-DungeonResonancePromptSheet`) /// rather than additional PNGs until explicitly added. diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceComponents.swift b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceComponents.swift index 3f445ee..b261e94 100644 --- a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceComponents.swift +++ b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceComponents.swift @@ -4,18 +4,22 @@ import RA11yCore // MARK: - Alignment (ADR-0003) -/// Vertical distance bands between the moonstone target and the fixed aim line (screen center). +/// Vertical distance bands between the moonstone target and the fixed aim line (playfield center). /// /// Values match the tuned mockup in `iOSDungeonResonanceMockupView` and drive orb/reticle /// presentation plus `QuestFeedbackBand` for multimodal feedback. enum iOSResonanceAlignment { - static let lockedMaxPoints: CGFloat = 26 + /// Wider than the original 26 pt lock window so coarse VoiceOver scroll increments can still + /// settle into the seal state instead of bouncing off the lower edge immediately. + static let lockedMaxPoints: CGFloat = 52 static let nearMaxPoints: CGFloat = 54 static let warmMaxPoints: CGFloat = 118 - /// Distance magnitude in points; callers supply global-space Y centers. - static func deltaPoints(targetMidYGlobal: CGFloat, aimMidYGlobal: CGFloat) -> CGFloat { - abs(targetMidYGlobal - aimMidYGlobal) + /// Distance magnitude in points; callers supply vertical centers in the **same** coordinate space. + /// + /// Crystal Resonance gameplay uses the named `resonancePlayfield` coordinate space. + static func deltaPoints(targetMidY: CGFloat, aimMidY: CGFloat) -> CGFloat { + abs(targetMidY - aimMidY) } /// Semantic feedback band derived from current alignment (no success state). @@ -32,6 +36,24 @@ enum iOSResonanceAlignment { } } +// MARK: - Lane layout (visual + VoiceOver scroll proxy) + +/// Shared metrics so the UIKit VoiceOver scroll proxy’s content height matches the on-screen glyph stack. +/// +/// Row height uses the same default as ``iOSLaneDecoyChip``’s ``SwiftUI/ScaledMetric`` base (`.title`, 76 pt). +/// At very large Dynamic Type sizes the visual chips may grow slightly; the proxy still uses these +/// nominal values so scroll distance stays close to the shaft layout. +enum iOSDungeonResonanceLaneLayout { + /// Vertical space between stacked decoys and Moonstone rows in the playfield. + static let rowSpacingPoints: CGFloat = 56 + /// Nominal content height per lane row for scroll content math and the UIKit accessibility rows. + static let rowContentHeightPoints: CGFloat = 76 + /// Extra vertical slack between lane rows so three-finger VoiceOver scrolling crosses a larger range per slot. + static let voiceOverLaneStrideSlackPoints: CGFloat = 64 + /// Matches ``iOSMoonstoneTargetOrb`` default width ÷ height so the reticle’s **elliptical** hole fits the oblong Moonstone. + static let moonstoneWidthOverHeight: CGFloat = 96.0 / 72.0 +} + // MARK: - Presentation band (orb + reticle) /// Visual ladder for the center orb and ring, including a brief success state after a level completes. @@ -54,7 +76,7 @@ enum iOSResonanceAimBand: Equatable { // MARK: - Preference -/// Global-space vertical center of the moonstone target (alignment with fixed aim line). +/// Vertical center of the moonstone in the same coordinate space as the playfield aim line (named `resonancePlayfield`). struct iOSResonanceTargetMidYPreferenceKey: PreferenceKey { static var defaultValue: CGFloat = -10_000 @@ -121,16 +143,43 @@ struct iOSResonanceLightsOffVignette: View { ) if let spot = UIImage(named: iOSDungeonResonanceArt.spotlightMaskReference) { Image(uiImage: spot) + .renderingMode(.original) + .interpolation(.high) .resizable() .scaledToFill() .blendMode(.plusLighter) - .opacity(0.35) + .opacity(0.32) } } .ignoresSafeArea() } } +// MARK: - Wide canvas catalog scaling + +/// Draw helper for Crystal Resonance PNGs authored on **wide landscape** masters (~1376×768) with the +/// subject centered (see ``iOSDungeonResonanceArt`` and the design pipeline note). +/// +/// ``SwiftUI/View/scaledToFit()`` fits the **entire** bitmap into small point frames, so the centered +/// glyph shrinks to a faint smear. ``SwiftUI/View/scaledToFill()`` with a fixed ``SwiftUI/View/frame`` +/// and ``SwiftUI/View/clipped()`` center-crops and fills the allotted points so silhouettes read at the +/// intended visual weight on phone and iPad. +struct iOSResonanceWideCanvasImage: View { + let uiImage: UIImage + var width: CGFloat? + var height: CGFloat? + + var body: some View { + Image(uiImage: uiImage) + .renderingMode(.original) + .interpolation(.high) + .resizable() + .scaledToFill() + .frame(width: width, height: height) + .clipped() + } +} + // MARK: - Lane content /// Visual decoy families for non-target chambers (catalog-first). @@ -152,9 +201,24 @@ enum iOSResonanceDecoyStyle: CaseIterable { let all = iOSResonanceDecoyStyle.allCases return all[index % all.count] } + + /// VoiceOver name for the visible glyph — Crystal Resonance uses relic motifs, not dungeon room titles. + var localizedAccessibilityItemName: String { + switch self { + case .ember: + String(localized: "dungeon.resonance.item.emberShard") + case .shadowGlyph: + String(localized: "dungeon.resonance.item.shadowGlyph") + case .sunSigil: + String(localized: "dungeon.resonance.item.sunSigil") + } + } } /// Moving resonance target — prefers `dungeon_target_moonstone` from the asset catalog. +/// +/// In play, ``iOSDungeonResonancePlayView`` applies a **larger** center-row scale to this view than to decoys so the +/// Moonstone oval reads as seating into the reticle while echo glyphs stay visually “off.” struct iOSMoonstoneTargetOrb: View { @ScaledMetric(relativeTo: .title) private var moonW: CGFloat = 96 @ScaledMetric(relativeTo: .title) private var moonH: CGFloat = 72 @@ -162,11 +226,8 @@ struct iOSMoonstoneTargetOrb: View { var body: some View { Group { if let ui = UIImage(named: iOSDungeonResonanceArt.targetMoonstone) { - Image(uiImage: ui) - .resizable() - .scaledToFit() - .frame(width: moonW, height: moonH) - .shadow(color: Color(red: 0.7, green: 0.85, blue: 1.0).opacity(0.4), radius: 10) + iOSResonanceWideCanvasImage(uiImage: ui, width: moonW, height: moonH) + // Avoid shadow / compositingGroup — both can read as grey boxes on dark shaft art. } else { ZStack { Ellipse() @@ -203,10 +264,7 @@ struct iOSLaneDecoyChip: View { var body: some View { Group { if let ui = UIImage(named: style.assetName) { - Image(uiImage: ui) - .resizable() - .scaledToFit() - .frame(width: chip, height: chip) + iOSResonanceWideCanvasImage(uiImage: ui, width: chip, height: chip) } else { legacyDecoyPlaceholder } @@ -251,11 +309,11 @@ struct iOSLaneLaneMarkerNeutral: View { var body: some View { Group { if let ui = UIImage(named: iOSDungeonResonanceArt.laneMarkerNeutral) { - Image(uiImage: ui) - .resizable() - .scaledToFit() - .frame(height: 22) - .frame(maxWidth: .infinity) + GeometryReader { geo in + iOSResonanceWideCanvasImage(uiImage: ui, width: geo.size.width, height: 22) + } + .frame(height: 22) + .frame(maxWidth: .infinity) } else { RoundedRectangle(cornerRadius: 6) .fill(Color.white.opacity(0.06)) @@ -272,6 +330,46 @@ struct iOSLaneLaneMarkerNeutral: View { // MARK: - Center orb & reticle +/// Cuts an **elliptical** window in the middle of the reticle (Moonstone oblong inside a circular ring PNG) so lane glyphs +/// read through the hole and the lock shape matches the Moonstone aspect ratio. +/// +/// Uses even-odd fill instead of ``blendMode(.destinationOut)`` so UI updates (e.g. seal press / state changes) +/// do not flash a transient grey compositing slab around the hub. +private struct iOSResonanceReticleDonutMask: View { + let diameter: CGFloat + /// Inner hole **width** as a fraction of the square mask’s width (height follows ``iOSDungeonResonanceLaneLayout.moonstoneWidthOverHeight``). + var innerWidthFraction: CGFloat = 0.58 + + var body: some View { + iOSResonanceReticleDonutMaskShape(diameter: diameter, innerWidthFraction: innerWidthFraction) + .fill(Color.white, style: FillStyle(eoFill: true)) + .frame(width: diameter, height: diameter) + } +} + +/// Even-odd donut for ``iOSResonanceReticleDonutMask`` — stable masking without destination-out compositing. +private struct iOSResonanceReticleDonutMaskShape: Shape { + var diameter: CGFloat + var innerWidthFraction: CGFloat + + func path(in rect: CGRect) -> Path { + let innerW = diameter * innerWidthFraction + // Matches `iOSDungeonResonanceLaneLayout.moonstoneWidthOverHeight` (Shape `path(in:)` is nonisolated). + let moonstoneWidthOverHeight: CGFloat = 96.0 / 72.0 + let innerH = innerW / moonstoneWidthOverHeight + let outerOrigin = CGPoint(x: rect.midX - diameter * 0.5, y: rect.midY - diameter * 0.5) + var path = Path() + path.addEllipse(in: CGRect(origin: outerOrigin, size: CGSize(width: diameter, height: diameter))) + path.addEllipse(in: CGRect( + x: rect.midX - innerW * 0.5, + y: rect.midY - innerH * 0.5, + width: innerW, + height: innerH + )) + return path + } +} + /// Fixed center reticle ring; responds to `iOSResonanceAimBand` for glow/pulse. struct iOSResonanceReticleRing: View { let band: iOSResonanceAimBand @@ -291,12 +389,11 @@ struct iOSResonanceReticleRing: View { Group { if let ui = UIImage(named: iOSDungeonResonanceArt.reticleRing) { - Image(uiImage: ui) - .resizable() - .scaledToFit() - .frame(width: diameter, height: diameter) - .opacity(0.42 + pulse * 0.5) - .shadow(color: Color.ra11yAccent.opacity(0.12 + pulse * 0.38), radius: 6 + pulse * 10) + iOSResonanceWideCanvasImage(uiImage: ui, width: diameter, height: diameter) + .opacity(0.62 + pulse * 0.32) + .mask { + iOSResonanceReticleDonutMask(diameter: diameter, innerWidthFraction: 0.58) + } } else { Circle() .strokeBorder( @@ -311,7 +408,6 @@ struct iOSResonanceReticleRing: View { lineWidth: 2 + pulse * 2 ) .frame(width: diameter, height: diameter) - .shadow(color: Color.ra11yAccent.opacity(0.15 + pulse * 0.35), radius: 8 + pulse * 10) } } .accessibilityHidden(true) @@ -327,12 +423,28 @@ struct iOSResonanceCenterOrb: View { var body: some View { ZStack { if let uiImage = UIImage(named: orbImageName) { - Image(uiImage: uiImage) - .resizable() - .scaledToFit() - .frame(width: orbDiameter, height: orbDiameter) + iOSResonanceWideCanvasImage(uiImage: uiImage, width: orbDiameter, height: orbDiameter) .saturation(bandSaturation) - .shadow(color: orbGlowColor, radius: orbGlowRadius) + /// Fully clear core so the Moonstone lane reads through the hub (avoids grey “film” / checkerboard seams with the reticle hole). + .mask { + RadialGradient( + stops: [ + .init(color: .clear, location: 0.0), + .init(color: Color.white.opacity(0.28), location: 0.32), + .init(color: Color.white.opacity(0.78), location: 0.58), + .init(color: Color.white, location: 1.0), + ], + center: .center, + startRadius: 0, + endRadius: orbDiameter * 0.56 + ) + } + .overlay { + Circle() + .strokeBorder(orbGlowColor.opacity(0.4), lineWidth: 1.5) + .frame(width: orbDiameter + 6, height: orbDiameter + 6) + } + .clipShape(Circle()) } else { Circle() .fill(orbGradient) @@ -451,10 +563,7 @@ struct iOSResonanceSuccessFlareOverlay: View { var body: some View { if let ui = UIImage(named: iOSDungeonResonanceArt.successFlare) { - Image(uiImage: ui) - .resizable() - .scaledToFit() - .frame(width: flareSize, height: flareSize) + iOSResonanceWideCanvasImage(uiImage: ui, width: flareSize, height: flareSize) .blendMode(.screen) .opacity(0.88) .accessibilityHidden(true) diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceMockupView.swift b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceMockupView.swift index 1c73fa8..2b9cd89 100644 --- a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceMockupView.swift +++ b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceMockupView.swift @@ -239,10 +239,12 @@ struct iOSDungeonResonanceMockupView: View { ) if let spot = UIImage(named: iOSDungeonResonanceArt.spotlightMaskReference) { Image(uiImage: spot) + .renderingMode(.original) + .interpolation(.high) .resizable() .scaledToFill() .blendMode(.plusLighter) - .opacity(0.35) + .opacity(0.32) } } .ignoresSafeArea() @@ -252,7 +254,7 @@ struct iOSDungeonResonanceMockupView: View { private var laneColumn: some View { VStack(spacing: 56) { - laneSectionLabel("Shallow") + laneSectionLabel(String(localized: "dungeon.resonance.mockup.laneSectionAbove")) LaneDecoyChip(style: .ember, accessibilityHidden: true) LaneLaneMarkerNeutral() @@ -266,7 +268,7 @@ struct iOSDungeonResonanceMockupView: View { LaneDecoyChip(style: .sunSigil, accessibilityHidden: true) - laneSectionLabel("Deep") + laneSectionLabel(String(localized: "dungeon.resonance.mockup.laneSectionBelow")) Color.clear.frame(height: 200) } .frame(maxWidth: 520) @@ -274,6 +276,7 @@ struct iOSDungeonResonanceMockupView: View { .padding(.horizontal, RA11ySpacing.lg) } + /// Preview-only lane captions; aligns mockup labels with the Moonstone / echo-glyph metaphor. private func laneSectionLabel(_ text: String) -> some View { Text(text) .font(.ra11yCaption) @@ -460,11 +463,7 @@ private struct MoonstoneTargetOrb: View { var body: some View { Group { if let ui = UIImage(named: iOSDungeonResonanceArt.targetMoonstone) { - Image(uiImage: ui) - .resizable() - .interpolation(.high) - .scaledToFit() - .frame(width: moonW, height: moonH) + iOSResonanceWideCanvasImage(uiImage: ui, width: moonW, height: moonH) .shadow(color: Color(red: 0.7, green: 0.85, blue: 1.0).opacity(0.4), radius: 10) } else { ZStack { @@ -517,11 +516,7 @@ private struct LaneDecoyChip: View { var body: some View { Group { if let ui = UIImage(named: style.assetName) { - Image(uiImage: ui) - .resizable() - .interpolation(.high) - .scaledToFit() - .frame(width: chip, height: chip) + iOSResonanceWideCanvasImage(uiImage: ui, width: chip, height: chip) } else { legacyDecoyPlaceholder } @@ -567,12 +562,11 @@ private struct LaneLaneMarkerNeutral: View { var body: some View { Group { if let ui = UIImage(named: iOSDungeonResonanceArt.laneMarkerNeutral) { - Image(uiImage: ui) - .resizable() - .interpolation(.high) - .scaledToFit() - .frame(height: 22) - .frame(maxWidth: .infinity) + GeometryReader { geo in + iOSResonanceWideCanvasImage(uiImage: ui, width: geo.size.width, height: 22) + } + .frame(height: 22) + .frame(maxWidth: .infinity) } else { RoundedRectangle(cornerRadius: 6) .fill(Color.white.opacity(0.06)) @@ -607,10 +601,7 @@ private struct ResonanceReticleRing: View { Group { if let ui = UIImage(named: iOSDungeonResonanceArt.reticleRing) { - Image(uiImage: ui) - .resizable() - .scaledToFit() - .frame(width: diameter, height: diameter) + iOSResonanceWideCanvasImage(uiImage: ui, width: diameter, height: diameter) .opacity(0.42 + pulse * 0.5) .shadow(color: Color.ra11yAccent.opacity(0.12 + pulse * 0.38), radius: 6 + pulse * 10) } else { @@ -642,10 +633,7 @@ private struct ResonanceCenterOrb: View { var body: some View { ZStack { if let uiImage = UIImage(named: orbImageName) { - Image(uiImage: uiImage) - .resizable() - .scaledToFit() - .frame(width: orbDiameter, height: orbDiameter) + iOSResonanceWideCanvasImage(uiImage: uiImage, width: orbDiameter, height: orbDiameter) .saturation(bandSaturation) .shadow(color: orbGlowColor, radius: orbGlowRadius) } else { @@ -751,11 +739,11 @@ private struct ResonanceCenterOrb: View { private var accessibilityLabelText: String { switch band { - case .far: return "Crystal orb, faint resonance" - case .warm: return "Crystal orb, warmth building" - case .near: return "Crystal orb, close to lock" - case .locked: return "Crystal orb, aligned, ready to confirm" - case .success: return "Crystal orb, resonance sealed" + case .far: return String(localized: "dungeon.resonance.a11y.orb.far") + case .warm: return String(localized: "dungeon.resonance.a11y.orb.warm") + case .near: return String(localized: "dungeon.resonance.a11y.orb.near") + case .locked: return String(localized: "dungeon.resonance.a11y.orb.locked") + case .success: return String(localized: "dungeon.resonance.a11y.orb.success") } } } @@ -766,10 +754,7 @@ private struct ResonanceSuccessFlareOverlay: View { var body: some View { if let ui = UIImage(named: iOSDungeonResonanceArt.successFlare) { - Image(uiImage: ui) - .resizable() - .scaledToFit() - .frame(width: flareSize, height: flareSize) + iOSResonanceWideCanvasImage(uiImage: ui, width: flareSize, height: flareSize) .blendMode(.screen) .opacity(0.88) .accessibilityHidden(true) diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonancePlayView.swift b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonancePlayView.swift index 8ba86bd..fa24c93 100644 --- a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonancePlayView.swift +++ b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonancePlayView.swift @@ -14,37 +14,75 @@ private func logResonanceScroll(_ message: String) { #endif } -// MARK: - Aim line (global Y) +// MARK: - Playfield coordinate space -/// Supplies the screen-global Y of the resonance play area vertical midpoint (fixed orb / aim line). -/// Measured from a hit-invisible `GeometryReader` overlay so the play surface does not wrap in a -/// root-level `GeometryReader` (those often register an unnamed VoiceOver frame). -private struct ResonanceAimLineGlobalMidYPreferenceKey: PreferenceKey { - static var defaultValue: CGFloat = -10_000 +/// Named space for alignment math: origin at top-left of the gameplay `ZStack` (between safe-area insets). +/// Moonstone `midY` and aim (`playfieldHeight * 0.5`) are both measured here—**not** `.global`, which is unstable during layout and produced bogus deltas (e.g. `targetMidY=10` vs `aimMidY=139`). +private enum ResonancePlayfieldCoordinateSpace { + static let name = "resonancePlayfield" +} + +/// Height of the playfield `ZStack` (between top/bottom chrome regions) for scroll content sizing. +/// +/// The top and bottom chrome reserve a stable footprint, so this value should not jump when +/// controls appear or disappear during a phase. +private struct ResonancePlayfieldViewportHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + let next = nextValue() + if next > 0 { value = next } + } +} + +/// Measured top chrome height so the playfield can use only the visible remaining space. +private struct ResonanceTopChromeHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { let next = nextValue() - if next > -1_000 { value = next } + if next > 0 { value = next } } } +/// Measured bottom chrome height so the playfield can use only the visible remaining space. +private struct ResonanceBottomChromeHeightPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + let next = nextValue() + if next > 0 { value = next } + } +} + +// MARK: - VoiceOver sort tiers (playfield) + +/// `accessibilitySortPriority` — **higher** values are read **earlier** in VoiceOver swipe order among peers. +/// The lane must come first so Crystal Resonance is immediately scrollable on entry. +private enum iOSResonancePlayAccessibilitySortTier { + static let moonstoneLaneScrollProxy: Double = 30 + static let topChrome: Double = 20 + static let bottomChrome: Double = 10 +} + // MARK: - iOSDungeonResonancePlayView /// Crystal Resonance gameplay surface (ADR-0003): fixed center orb, scrolling target lane, /// activation only when the moonstone aligns with the aim line. /// /// **VoiceOver:** The scroll lane is a UIKit ``iOSResonanceVoiceOverScrollProxyRepresentable`` (`UIScrollView`) -/// with higher `accessibilitySortPriority` than chrome. Initial focus uses `UIAccessibility.post` -/// (`.screenChanged`, then `.layoutChanged` with the scroll view, then announcement). The play surface -/// avoids a **root** `GeometryReader` (measurement uses a hidden overlay `GeometryReader` + preference key). +/// — a **single** element named “Moonstone alignment lane” for three-finger shaft scrolling. The UIKit +/// proxy restores deterministic VoiceOver landing on entry, while sort priority remains a secondary aid. +/// Aim/orb alignment and viewport height use scoped `GeometryReader` backgrounds + preference keys +/// (not a root wrapping `GeometryReader`). /// /// **VoiceOver lane proxy:** The **visual** lane is a clipped, **non-scrolling** stack whose vertical -/// offset mirrors the UIKit scroller’s content offset (same lane accessibility label on the scroll view). -/// VoiceOver scrolls that proxy only; the glyph column stays `accessibilityHidden`. On iOS, -/// three-finger scrolling affects the scroll view associated with the **current VoiceOver focus**—if -/// focus is on objective/timer chrome, three-finger swipes will not move the lane; the player must -/// navigate until VoiceOver announces the scroll proxy (localized `dungeon.a11y.scroll.container`; see -/// hints and L1 tip copy). Quests +/// offset mirrors the UIKit scroller’s content offset (same lane label on the scroll view). Chamber +/// names on decoys are decorative; they are not separate VoiceOver items. The glyph column stays +/// `accessibilityHidden`. On iOS, +/// three-finger scrolling affects the scroll view associated with the **current VoiceOver focus**. The +/// lane proxy is auto-focused on entry, but the decorative glyph column still remains hidden from +/// accessibility so there is only one playfield scroll surface. Quests /// cannot be entered without VoiceOver (see ``iOSAppRouter/pushGame(kind:provider:)``), so a /// separate non-VO `ScrollView` path is not maintained here. /// @@ -66,10 +104,18 @@ struct iOSDungeonResonancePlayView: View { let lightsOffFlavorText: String? let showsFirstLevelGestureTip: Bool - /// Invoked when the moonstone's vertical alignment changes (global-space delta from aim line). + /// When set, replaces the generic “Next” label after a level completes (Crystal Resonance ascent copy). + let continueButtonTitle: String? + + /// Screenshot / deterministic builds: pin the first scroll offset to this lane index. `nil` = prefer a decoy under the hub. + let initialLaneIndexOverride: Int? + + /// Invoked when the moonstone's vertical alignment changes (playfield-space delta from the aim line). let onResonanceDeltaChanged: (CGFloat) -> Void let onActivateTarget: (DungeonRoom) async -> Void + /// Invoked when the VoiceOver scroll proxy snaps to a different lane slot (after ``handleProxyScrollOffsetChange``). + let onLaneSlotChanged: (() -> Void)? let onHint: (() -> Void)? let onContinue: (() -> Void)? let onRetry: (() -> Void)? @@ -79,145 +125,378 @@ struct iOSDungeonResonancePlayView: View { /// Limits alignment telemetry spam while still capturing motion during scroll debugging. @State private var lastAlignmentLogTime: Date = .distantPast - /// Vertical scroll offset driven by the UIKit proxy ``iOSResonanceVoiceOverScrollProxyRepresentable``; applied to the visual lane below. + /// Snapped vertical offset for the selected room slot. The UIKit proxy still receives the three-finger + /// swipe, but gameplay uses discrete room positions rather than raw inertial scrolling. @State private var voiceOverProxyScrollOffsetY: CGFloat = 0 + @State private var selectedLaneIndex: Int = 0 - /// Vertical midpoint (global Y) of the resonance play area aim line (from ``ResonanceAimLineGlobalMidYPreferenceKey``). - @State private var aimLineGlobalMidY: CGFloat = -10_000 + /// Last moonstone vertical center in ``ResonancePlayfieldCoordinateSpace`` (paired with aim = `playfieldViewportHeight * 0.5`). + @State private var lastTargetMidYInPlayfield: CGFloat = -10_000 - /// Last moonstone `midY` from ``iOSResonanceTargetMidYPreferenceKey`` (paired with `aimLineGlobalMidY`). - @State private var lastTargetGlobalMidY: CGFloat = -10_000 + /// Playfield height between safe-area insets; drives minimum UIKit scroll content height so VoiceOver can scroll. + @State private var playfieldViewportHeight: CGFloat = 600 + @State private var topChromeHeight: CGFloat = 0 + @State private var bottomChromeHeight: CGFloat = 0 @Environment(\.horizontalSizeClass) private var sizeClass + /// Stable identity for the current lane room set (L1/L2/L3 transitions and L3 reshuffles). + private var roomLaneIdentity: String { + rooms.map(\.id).joined(separator: "\u{1e}") + } + private var aimBand: iOSResonanceAimBand { iOSResonanceAimBand.displayBand(deltaPoints: displayDeltaPoints, levelComplete: levelComplete) } - /// Minimum height for the VoiceOver proxy scroll content so three-finger scroll has room (mirrors L0 practice). - private var voiceOverProxyScrollableContentHeight: CGFloat { - let rowCount = max(rooms.count * 2, 18) - return CGFloat(rowCount) * 44 + 2 * RA11ySpacing.xl + /// Reserve enough height for the status row even when no transient message is visible. + private var topChromeStatusSizingMessage: String { + String(localized: "dungeon.resonance.hint") + } + + /// Distance between successive lane row centers (matches ``resonanceLaneColumn`` `VStack` spacing + row height). + private var laneColumnInterItemSpacing: CGFloat { + iOSDungeonResonanceLaneLayout.rowSpacingPoints + iOSDungeonResonanceLaneLayout.voiceOverLaneStrideSlackPoints } - /// Recomputes alignment when either the aim line or moonstone global position changes (preferences can arrive in either order). + private var laneStepPoints: CGFloat { + iOSDungeonResonanceLaneLayout.rowContentHeightPoints + laneColumnInterItemSpacing + } + + /// Symmetric spacer that makes every room a centerable snap target. + private var laneCenterSpacerHeight: CGFloat { + max(0, playfieldViewportHeight * 0.5 - iOSDungeonResonanceLaneLayout.rowContentHeightPoints * 0.5) + } + + /// Extra scrollable runway below the lane so VoiceOver three-finger paging can exceed the last snap + /// without `contentOffset` sitting on the `maxY` clamp (see repo `memlog/research/CrystalResonance-Asset-And-Scroll-QC.md`). + private var voiceOverLaneTrailingSlack: CGFloat { + max(160, playfieldViewportHeight * 0.28) + } + + /// Full height of ``resonanceLaneColumn`` (top/bottom center spacers, lane rows, inter-row gaps, trailing slack). + private var voiceOverLaneIntrinsicBlockHeight: CGFloat { + let n = rooms.count + guard n > 0 else { return RA11ySpacing.xl * 2 } + /// One more gap than ``n + 1``: the stack is top spacer, ``n`` rows, bottom spacer, **trailing** clear. + let gapCount = CGFloat(n + 2) + let rowsBlock = CGFloat(n) * iOSDungeonResonanceLaneLayout.rowContentHeightPoints + + gapCount * laneColumnInterItemSpacing + + 2 * laneCenterSpacerHeight + + voiceOverLaneTrailingSlack + /// Reserve enough runway for ``snappedLaneOffset`` (includes the first `VStack` gap after the top spacer). + let minForLastSlot = + CGFloat(max(0, n - 1)) * laneStepPoints + + playfieldViewportHeight + + 48 + + laneColumnInterItemSpacing + return max(rowsBlock, minForLastSlot) + } + + /// Scroll content block height passed to the UIKit proxy and mirrored by the visual lane. + /// + /// Center spacers turn the lane into a discrete selector: offset `index * laneStepPoints` centers room `index`. + private var voiceOverLaneTotalScrollBlockHeight: CGFloat { + voiceOverLaneIntrinsicBlockHeight + } + + private var currentVoiceOverScrollStatusText: String { + let selectedName = currentLaneSelectionName + let bandText = currentAlignmentAnnouncementText + return "\(selectedName). \(bandText)" + } + + /// Row whose **layout** center is nearest the aim line for the current scroll offset (authoritative for scroll status). + private var laneIndexAtAimLine: Int { + laneIndexClosestToAimLine(scrollOffsetY: voiceOverProxyScrollOffsetY) + } + + private var currentLaneSelectionName: String { + localizedResonanceItemName(atLaneIndex: laneIndexAtAimLine) + } + + /// Maps the lane index to Moonstone / decoy glyph names for VoiceOver scroll status (not ``DungeonRoom/displayName``). + private func localizedResonanceItemName(atLaneIndex index: Int) -> String { + guard !rooms.isEmpty else { return String(localized: "dungeon.a11y.scroll.container") } + let i = clampedLaneIndex(index) + if rooms[i].isTarget { + return String(localized: "dungeon.resonance.item.moonstone") + } + return iOSResonanceDecoyStyle.forRoomIndex(i).localizedAccessibilityItemName + } + + private var currentAlignmentAnnouncementText: String { + switch iOSResonanceAlignment.questBand(deltaPoints: displayDeltaPoints) { + case .far: + return String(localized: "dungeon.resonance.a11y.orb.far") + case .warm: + return String(localized: "dungeon.resonance.a11y.orb.warm") + case .near: + return String(localized: "dungeon.resonance.a11y.orb.near") + case .locked: + return String(localized: "dungeon.resonance.a11y.orb.locked") + } + } + + /// Recomputes alignment from moonstone position vs. playfield vertical center (fixed orb line). private func applyResonanceAlignmentFromLastFrames() { - let targetY = lastTargetGlobalMidY - let aimMidY = aimLineGlobalMidY - guard targetY > -1_000, aimMidY > -1_000 else { return } + let playfieldH = playfieldViewportHeight + guard playfieldH > 50 else { return } + let aimMidY = playfieldH * 0.5 + let targetY = lastTargetMidYInPlayfield + guard targetY > -1_000 else { return } let delta = iOSResonanceAlignment.deltaPoints( - targetMidYGlobal: targetY, - aimMidYGlobal: aimMidY + targetMidY: targetY, + aimMidY: aimMidY ) displayDeltaPoints = delta let now = Date() if now.timeIntervalSince(lastAlignmentLogTime) >= 0.28 { lastAlignmentLogTime = now logResonanceScroll( - "alignment sample targetMidY=\(String(format: "%.1f", targetY)) aimMidY=\(String(format: "%.1f", aimMidY)) delta=\(String(format: "%.1f", delta)) reachable=\(iOSResonanceAlignment.isReachable(deltaPoints: delta)) vo=\(UIAccessibility.isVoiceOverRunning)" + "alignment sample playfieldH=\(String(format: "%.1f", playfieldH)) targetMidY=\(String(format: "%.1f", targetY)) aimMidY=\(String(format: "%.1f", aimMidY)) delta=\(String(format: "%.1f", delta)) reachable=\(iOSResonanceAlignment.isReachable(deltaPoints: delta)) vo=\(UIAccessibility.isVoiceOverRunning)" ) } onResonanceDeltaChanged(delta) } + private func clampedLaneIndex(_ index: Int) -> Int { + guard !rooms.isEmpty else { return 0 } + return min(max(index, 0), rooms.count - 1) + } + + /// Vertical center of the playfield aim line (orb / reticle hub), in ``ResonancePlayfieldCoordinateSpace``. + private var resonanceAimLineMidY: CGFloat { + playfieldViewportHeight * 0.5 + } + + /// Y coordinate of lane row `laneIndex`’s vertical center in **lane content space** (before ``View/offset(y:)``). + /// + /// ``resonanceLaneColumn`` is a `VStack` with ``View/spacing`` between **every** adjacent child, including the gap + /// between the top centering spacer and the first row. Scroll math must add that leading gap or row centers sit + /// one spacing interval too low on screen (Moonstone below the hub while VoiceOver names Moonstone). + private func laneRowCenterContentY(laneIndex: Int) -> CGFloat { + let s = laneCenterSpacerHeight + let g = laneColumnInterItemSpacing + let h = iOSDungeonResonanceLaneLayout.rowContentHeightPoints + let i = clampedLaneIndex(laneIndex) + return s + g + CGFloat(i) * laneStepPoints + h * 0.5 + } + + /// `UIScrollView.contentOffset.y` that places ``laneRowCenterContentY`` on ``resonanceAimLineMidY`` for `index`. + private func snappedLaneOffset(for index: Int) -> CGFloat { + laneRowCenterContentY(laneIndex: index) - resonanceAimLineMidY + } + + /// Which lane row is physically centered on the aim line for a given scroll offset (matches ``resonanceLaneColumn`` geometry). + /// + /// Using nearest-row geometry avoids VoiceOver scroll status naming the **next** slot when float rounding or + /// transient `contentOffset` drifts from ``snappedLaneOffset(for:)``. + private func laneIndexClosestToAimLine(scrollOffsetY: CGFloat) -> Int { + guard !rooms.isEmpty, playfieldViewportHeight > 50 else { return 0 } + let aimMidY = resonanceAimLineMidY + var best = 0 + var bestDist = CGFloat.greatestFiniteMagnitude + for i in 0.. 0.5 { + logResonanceScroll( + "VO proxy scroll contentOffset.y \(String(format: "%.1f", oldY)) → \(String(format: "%.1f", snappedOffset))" + ) + } + } + + /// Snaps VoiceOver scroll state whenever the room list changes. + /// + /// Normal play starts on a **decoy** under the hub so the Moonstone is never pre-aligned and repeat players + /// cannot rely on muscle memory. Optional ``initialLaneIndexOverride`` keeps marketing screenshots stable. + private func applyLaneSelectionForCurrentRooms() { + guard !rooms.isEmpty else { return } + let idx: Int + if let initialLaneIndexOverride { + idx = clampedLaneIndex(initialLaneIndexOverride) + } else { + idx = initialLaneIndexPreferringDecoy() + } + selectedLaneIndex = idx + voiceOverProxyScrollOffsetY = snappedLaneOffset(for: idx) + } + + /// Picks a lane index whose row is **not** the Moonstone when possible (deterministic under UI tests). + private func initialLaneIndexPreferringDecoy() -> Int { + let decoyIndices = rooms.indices.filter { !rooms[$0].isTarget } + guard !decoyIndices.isEmpty else { + return clampedLaneIndex(rooms.firstIndex(where: \.isTarget) ?? 0) + } + if ProcessInfo.processInfo.arguments.contains("-uiTesting") { + return clampedLaneIndex(decoyIndices.min() ?? 0) + } + return clampedLaneIndex(decoyIndices.randomElement() ?? decoyIndices[0]) + } + + /// The playfield `ZStack` must always contain the UIKit proxy unconditionally. New overlays above it + /// must disable hit testing, and new playfield content must remain hidden from accessibility. var body: some View { + GeometryReader { geometry in + let clampedPlayfieldHeight = max( + 240, + geometry.size.height - topChromeHeight - bottomChromeHeight + ) + VStack(spacing: 0) { + topChrome + + playfieldContent(height: clampedPlayfieldHeight) + + bottomChrome + } + .frame(width: geometry.size.width, height: geometry.size.height, alignment: .top) + .background(Color.ra11yGameFallbackBackground) + .environment(\.colorScheme, .dark) + .onAppear { + playfieldViewportHeight = clampedPlayfieldHeight + applyLaneSelectionForCurrentRooms() + logResonanceScroll("playSurface.onAppear vo=\(UIAccessibility.isVoiceOverRunning) reducedMotion=\(UIAccessibility.isReduceMotionEnabled)") + } + .onChange(of: roomLaneIdentity) { _, _ in + DispatchQueue.main.async { + applyLaneSelectionForCurrentRooms() + } + } + .onChange(of: clampedPlayfieldHeight) { _, newHeight in + guard abs(newHeight - playfieldViewportHeight) > 0.5 else { return } + DispatchQueue.main.async { + playfieldViewportHeight = newHeight + applyResonanceAlignmentFromLastFrames() + } + } + } + } + + private func playfieldContent(height: CGFloat) -> some View { ZStack { - iOSShaftResonanceBackground() + /// L3 Lights Off: solid playfield (Enchanter-style) so shaft art and hub cannot cue alignment visually. + if lightsOffMode { + Color.black + } else { + iOSShaftResonanceBackground() + } /// Decorative lane: offset tracks the proxy scroller; hidden from VoiceOver. resonanceLaneColumn - .padding(.vertical, RA11ySpacing.xl) .offset(y: -voiceOverProxyScrollOffsetY) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .frame(maxWidth: .infinity, minHeight: height, maxHeight: height, alignment: .top) .clipped() + /// L3: opaque blackout over glyphs only (``ra11yLightsOffGameplayBlackout``), matching ``iOSEnchantersTrialView`` relic treatment. .ra11yLightsOffGameplayBlackout(isEnabled: lightsOffMode) .allowsHitTesting(false) .accessibilityHidden(true) - /// UIKit `UIScrollView` proxy (see ``iOSResonanceVoiceOverScrollProxyRepresentable``); visually clear, one AX element. + /// UIKit `UIScrollView` proxy (see ``iOSResonanceVoiceOverScrollProxyRepresentable``); visually clear, one AX element (the lane). iOSResonanceVoiceOverScrollProxyRepresentable( - contentBlockHeight: voiceOverProxyScrollableContentHeight, - verticalPadding: RA11ySpacing.xl, + contentBlockHeight: voiceOverLaneTotalScrollBlockHeight, + verticalPadding: 0, accessibilityLabelText: String(localized: "dungeon.a11y.scroll.container"), accessibilityHintText: String(localized: "dungeon.a11y.scroll.container.hint"), - onContentOffsetYChanged: { newY in - let oldY = voiceOverProxyScrollOffsetY - voiceOverProxyScrollOffsetY = newY - if abs(newY - oldY) > 0.5 { - logResonanceScroll( - "VO proxy scroll contentOffset.y \(String(format: "%.1f", oldY)) → \(String(format: "%.1f", newY))" - ) - } - } + accessibilityScrollStatusText: currentVoiceOverScrollStatusText, + desiredContentOffsetY: snappedLaneOffset(for: selectedLaneIndex), + onContentOffsetYChanged: handleProxyScrollOffsetChange ) - .frame(maxWidth: .infinity, maxHeight: .infinity) - .accessibilitySortPriority(10) + .frame(maxWidth: .infinity) + .frame(height: height) + .accessibilitySortPriority(iOSResonancePlayAccessibilitySortTier.moonstoneLaneScrollProxy) centerOrbStack + .opacity(lightsOffMode ? 0 : 1) + .frame(maxWidth: .infinity, minHeight: height, maxHeight: height) .allowsHitTesting(false) /// Hides the decorative orb/reticle from the VoiceOver rotor. .accessibilityHidden(true) - - if lightsOffMode { - iOSResonanceLightsOffVignette() - .allowsHitTesting(false) - } - } - .overlay { - GeometryReader { geo in - Color.clear - .preference( - key: ResonanceAimLineGlobalMidYPreferenceKey.self, - value: geo.frame(in: .global).midY - ) - } - .allowsHitTesting(false) - .accessibilityHidden(true) - } - .onPreferenceChange(ResonanceAimLineGlobalMidYPreferenceKey.self) { midY in - aimLineGlobalMidY = midY - applyResonanceAlignmentFromLastFrames() - } - .onPreferenceChange(iOSResonanceTargetMidYPreferenceKey.self) { targetY in - lastTargetGlobalMidY = targetY - applyResonanceAlignmentFromLastFrames() } + .frame(maxWidth: .infinity) + .frame(height: height) + .clipped() + .coordinateSpace(name: ResonancePlayfieldCoordinateSpace.name) .background(Color.ra11yGameFallbackBackground) - .safeAreaInset(edge: .top, spacing: 0) { topChrome } - .safeAreaInset(edge: .bottom, spacing: 0) { bottomChrome } - .environment(\.colorScheme, .dark) - .onAppear { - logResonanceScroll("playSurface.onAppear vo=\(UIAccessibility.isVoiceOverRunning) reducedMotion=\(UIAccessibility.isReduceMotionEnabled)") + .onPreferenceChange(iOSResonanceTargetMidYPreferenceKey.self) { targetY in + DispatchQueue.main.async { + guard targetY > -500, abs(targetY - lastTargetMidYInPlayfield) > 0.25 else { return } + lastTargetMidYInPlayfield = targetY + applyResonanceAlignmentFromLastFrames() + } } } // MARK: - Lane private var resonanceLaneColumn: some View { - VStack(spacing: 56) { + VStack(alignment: .center, spacing: laneColumnInterItemSpacing) { + Color.clear + .frame(height: laneCenterSpacerHeight) + .accessibilityHidden(true) ForEach(Array(rooms.enumerated()), id: \.element.id) { index, room in - if room.isTarget { - moonstoneRow(room: room) - } else { - iOSLaneDecoyChip(style: .forRoomIndex(index)) + Group { + if room.isTarget { + moonstoneRow(room: room, index: index) + } else { + iOSLaneDecoyChip(style: .forRoomIndex(index)) + .modifier( + iOSResonanceLaneSelectionModifier( + distanceFromSelection: abs(index - laneIndexAtAimLine), + isMoonstone: false + ) + ) + } } + .frame(height: iOSDungeonResonanceLaneLayout.rowContentHeightPoints) + .frame(maxWidth: .infinity) } + Color.clear + .frame(height: laneCenterSpacerHeight) + .accessibilityHidden(true) + Color.clear + .frame(height: voiceOverLaneTrailingSlack) + .accessibilityHidden(true) } .frame(maxWidth: 520) .frame(maxWidth: .infinity) .padding(.horizontal, RA11ySpacing.lg) - /// Single scroll target for VoiceOver: lane glyphs stay visual-only; without this, nested - /// identifiers can receive focus and three-finger swipes no longer move the `ScrollView`. + /// Lane glyphs stay visual-only; VoiceOver uses the single UIKit scroll surface. This prevents duplicate + /// focusable elements inside the decorative stack. .accessibilityElement(children: .ignore) } - private func moonstoneRow(room: DungeonRoom) -> some View { + private func moonstoneRow(room: DungeonRoom, index: Int) -> some View { iOSMoonstoneTargetOrb() + .modifier( + iOSResonanceLaneSelectionModifier( + distanceFromSelection: abs(index - laneIndexAtAimLine), + isMoonstone: true + ) + ) .background { GeometryReader { geo in Color.clear.preference( key: iOSResonanceTargetMidYPreferenceKey.self, - value: geo.frame(in: .global).midY + value: geo.frame(in: .named(ResonancePlayfieldCoordinateSpace.name)).midY ) } } @@ -228,8 +507,9 @@ struct iOSDungeonResonancePlayView: View { private var centerOrbStack: some View { ZStack { - iOSResonanceReticleRing(band: aimBand) + /// Orb first so the reticle’s punched center looks through to the lane (orb uses a transparent core mask). iOSResonanceCenterOrb(band: aimBand) + iOSResonanceReticleRing(band: aimBand) if aimBand == .success, UIImage(named: iOSDungeonResonanceArt.successFlare) != nil { iOSResonanceSuccessFlareOverlay() } @@ -240,20 +520,68 @@ struct iOSDungeonResonancePlayView: View { // MARK: - Chrome private var topChrome: some View { + ZStack(alignment: .top) { + topChromeSizingTemplate + .hidden() + .allowsHitTesting(false) + .accessibilityHidden(true) + + topChromeVisibleContent + } + .padding(.horizontal, sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base) + .padding(.bottom, RA11ySpacing.sm) + .background(Color.black.opacity(0.5)) + .background { + GeometryReader { geo in + Color.clear + .preference( + key: ResonanceTopChromeHeightPreferenceKey.self, + value: geo.size.height + ) + } + } + .accessibilitySortPriority(iOSResonancePlayAccessibilitySortTier.topChrome) + .onPreferenceChange(ResonanceTopChromeHeightPreferenceKey.self) { height in + guard height > 0, abs(height - topChromeHeight) > 0.5 else { return } + DispatchQueue.main.async { + topChromeHeight = height + } + } + } + + private var bottomChrome: some View { + ZStack(alignment: .top) { + bottomChromeSizingTemplate + .hidden() + .allowsHitTesting(false) + .accessibilityHidden(true) + + bottomChromeVisibleContent + } + .padding(.horizontal, sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base) + .padding(.vertical, RA11ySpacing.md) + .background { + GeometryReader { geo in + Color.clear + .preference( + key: ResonanceBottomChromeHeightPreferenceKey.self, + value: geo.size.height + ) + } + } + .accessibilitySortPriority(iOSResonancePlayAccessibilitySortTier.bottomChrome) + .onPreferenceChange(ResonanceBottomChromeHeightPreferenceKey.self) { height in + guard height > 0, abs(height - bottomChromeHeight) > 0.5 else { return } + DispatchQueue.main.async { + bottomChromeHeight = height + } + } + } + + private var topChromeVisibleContent: some View { VStack(spacing: RA11ySpacing.base) { if let lightsOffFlavorText { - Text(lightsOffFlavorText) - .font(.ra11ySubheadline) - .foregroundStyle(Color.ra11yCardSecondaryText) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(RA11ySpacing.base) - .background(Color.black.opacity(0.5), in: .rect(cornerRadius: RA11yRadius.card)) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.card) - .strokeBorder(Color.ra11yDMBorder.opacity(0.35), lineWidth: 1) - ) - .accessibilityAddTraits(.isStaticText) + lightsOffFlavorCard(message: lightsOffFlavorText) } objectiveCard @@ -271,65 +599,68 @@ struct iOSDungeonResonancePlayView: View { if let statusMessage { iOSResonanceStatusRow(message: statusMessage) + } else { + iOSResonanceStatusRow(message: topChromeStatusSizingMessage) + .hidden() + .allowsHitTesting(false) + .accessibilityHidden(true) } } - .padding(.horizontal, sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base) - .padding(.bottom, RA11ySpacing.sm) - .background(.ultraThinMaterial.opacity(0.92)) - /// After the playfield scroll proxy (`accessibilitySortPriority` 10); keeps objectives and HUD - /// from preempting swipe order when the platform merges inset and content accessibility trees. - .accessibilitySortPriority(-5) } - /// `false` during most of L1 when the seal is unavailable and there is no hint — avoids an empty - /// `VStack` inset that VoiceOver can focus as an unnamed rectangle. - private var hasPlayfieldBottomControls: Bool { - if levelComplete, onContinue != nil { return true } - if targetIsReachable, rooms.contains(where: \.isTarget) { return true } - if onHint != nil { return true } - return false + private var topChromeSizingTemplate: some View { + VStack(spacing: RA11ySpacing.base) { + if let lightsOffFlavorText { + lightsOffFlavorCard(message: lightsOffFlavorText) + } + + objectiveCard + + if showsFirstLevelGestureTip { + firstLevelGestureTipCard + } + + if let total = timeTotal, let remaining = timeRemaining { + DungeonTimerHUD(timeRemaining: remaining, total: total, mistakes: mistakes) + .accessibilityElement(children: .ignore) + .accessibilityLabel(timerA11yLabel) + .accessibilityHint(String(localized: "a11y.timer.group.hint")) + } + + iOSResonanceStatusRow(message: topChromeStatusSizingMessage) + } } - @ViewBuilder - private var bottomChrome: some View { - if timedOut { - timeoutBanner - .padding(.horizontal, sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base) - .padding(.vertical, RA11ySpacing.md) - .background(.ultraThinMaterial.opacity(0.95)) - .accessibilitySortPriority(-5) - } else if hasPlayfieldBottomControls { - sealOrProgressControls - .padding(.horizontal, sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base) - .padding(.vertical, RA11ySpacing.md) - .background(.ultraThinMaterial.opacity(0.95)) - .accessibilitySortPriority(-5) - } - } - - @ViewBuilder - private var sealOrProgressControls: some View { - if levelComplete, let onContinue { - continueButton(onContinue) - } else if !timedOut { - // At least one subview when `hasPlayfieldBottomControls` is true (see `bottomChrome`). - VStack(spacing: RA11ySpacing.sm) { - if targetIsReachable, let targetRoom = rooms.first(where: \.isTarget) { - Button { - Task { await onActivateTarget(targetRoom) } - } label: { - Text(String(localized: "dungeon.resonance.seal")) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .tint(Color.ra11yAccent) - .accessibilityIdentifier("dungeon.resonance.seal") - .accessibilityHint(String(localized: "dungeon.resonance.seal.hint")) - } - if let onHint { - hintButton(onHint) - } + private var bottomChromeVisibleContent: some View { + Group { + if timedOut { + timeoutBanner + .background(Color.black.opacity(0.55)) + } else if levelComplete, let continueAction = onContinue { + continueButton(continueAction) + .background(Color.black.opacity(0.55)) + } else if let targetRoom = rooms.first(where: \.isTarget) { + playfieldControlsContent(targetRoom: targetRoom, isEnabled: targetIsReachable) + .background(Color.black.opacity(0.55)) + } else { + Color.clear + .frame(maxWidth: .infinity, minHeight: 0) + .accessibilityHidden(true) + } + } + } + + /// Hidden sizing template that keeps the playfield height stable while lock state changes. + private var bottomChromeSizingTemplate: some View { + Group { + if timedOut { + timeoutBanner + } else if levelComplete, let continueAction = onContinue { + continueButton(continueAction) + } else if let targetRoom = rooms.first(where: \.isTarget) { + playfieldControlsContent(targetRoom: targetRoom, isEnabled: true) + } else { + EmptyView() } } } @@ -353,24 +684,57 @@ struct iOSDungeonResonancePlayView: View { } } - private func hintButton(_ action: @escaping () -> Void) -> some View { - Button(action: action) { - Label(String(localized: "dungeon.hint.button"), systemImage: "ear.fill") + private func lightsOffFlavorCard(message: String) -> some View { + Text(message) + .font(.ra11ySubheadline) + .foregroundStyle(Color.ra11yCardSecondaryText) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(RA11ySpacing.base) + .background(Color.black.opacity(0.5), in: .rect(cornerRadius: RA11yRadius.card)) + .accessibilityAddTraits(.isStaticText) + } + + private func playfieldControlsContent(targetRoom: DungeonRoom, isEnabled: Bool) -> some View { + VStack(spacing: RA11ySpacing.sm) { + Button { + Task { await onActivateTarget(targetRoom) } + } label: { + Text(String(localized: "dungeon.resonance.seal")) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .tint(Color.ra11yAccent) + .accessibilityIdentifier("dungeon.resonance.seal") + .accessibilityHint( + isEnabled + ? String(localized: "dungeon.resonance.seal.hint") + : String(localized: "dungeon.target.notReachable") + ) + Text( + isEnabled + ? String(localized: "dungeon.resonance.a11y.orb.locked") + : String(localized: "dungeon.target.notReachable") + ) + .font(.ra11yCaption) + .foregroundStyle(Color.ra11yCardSecondaryText) + .frame(maxWidth: .infinity) } - .buttonStyle(.bordered) - .accessibilityLabel(String(localized: "dungeon.hint.a11yLabel")) - .accessibilityHint(String(localized: "dungeon.hint.a11yHint")) } private func continueButton(_ action: @escaping () -> Void) -> some View { - Button(action: action) { - Text(String(localized: "level.button.next")) + let title = continueButtonTitle ?? String(localized: "level.button.next") + return Button(action: action) { + Text(title) .frame(maxWidth: .infinity) + .multilineTextAlignment(.center) } .buttonStyle(.borderedProminent) .controlSize(.large) .tint(Color.ra11yAccent) .accessibilityIdentifier("dungeon.continue") + .accessibilityLabel(title) .accessibilityHint(String(localized: "dungeon.a11y.continue.nextAscent.hint")) } @@ -388,62 +752,61 @@ struct iOSDungeonResonancePlayView: View { .padding(RA11ySpacing.base) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.black.opacity(0.66), in: .rect(cornerRadius: RA11yRadius.card)) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.card) - .strokeBorder(Color.ra11yDMBorder.opacity(0.5), lineWidth: 1) - ) .accessibilityElement(children: .combine) .accessibilityLabel(objectiveA11yLabel) .accessibilityHint(objectiveA11yHint) } private var firstLevelGestureTipCard: some View { - VStack(alignment: .leading, spacing: RA11ySpacing.xs) { - Text(String(localized: "dungeon.explain.gesture.swipe3")) - .font(.ra11ySubheadline) - .foregroundStyle(Color.ra11yCardSecondaryText) - .multilineTextAlignment(.leading) - Text(String(localized: "dungeon.explain.gesture.swipe3u")) - .font(.ra11ySubheadline) - .foregroundStyle(Color.ra11yCardSecondaryText) - .multilineTextAlignment(.leading) - Text(String(localized: "dungeon.resonance.tip.voFocusOnLane")) - .font(.ra11ySubheadline) - .foregroundStyle(Color.ra11yCardSecondaryText) - .multilineTextAlignment(.leading) + ScrollView(.vertical, showsIndicators: true) { + VStack(alignment: .leading, spacing: RA11ySpacing.xs) { + Text(String(localized: "dungeon.explain.gesture.swipe3")) + .font(.ra11ySubheadline) + .foregroundStyle(Color.ra11yCardSecondaryText) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + Text(String(localized: "dungeon.explain.gesture.swipe3u")) + .font(.ra11ySubheadline) + .foregroundStyle(Color.ra11yCardSecondaryText) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + Text(String(localized: "dungeon.resonance.tip.voFocusOnLane")) + .font(.ra11ySubheadline) + .foregroundStyle(Color.ra11yCardSecondaryText) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity, alignment: .leading) } + .frame(maxHeight: 220) .padding(RA11ySpacing.base) .frame(maxWidth: .infinity, alignment: .leading) .background(Color.black.opacity(0.45), in: .rect(cornerRadius: RA11yRadius.card)) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.card) - .strokeBorder(Color.ra11yDMBorder.opacity(0.35), lineWidth: 1) - ) .accessibilityElement(children: .combine) .accessibilityAddTraits(.isStaticText) } + /// On-screen objective line: alignment task only (decoy art uses room-themed assets without naming them here). private var objectiveText: String { - guard let target = rooms.first(where: \.isTarget) else { return "" } switch rooms.count { case DungeonRoom.l1Rooms.count: - return String(format: String(localized: "dungeon.l1.objective.format"), target.displayName) + return String(localized: "dungeon.l1.objective.format") case DungeonRoom.l2Rooms.count: - return String(format: String(localized: "dungeon.l2.objective.format"), target.displayName) + return String(localized: "dungeon.l2.objective.format") default: - return String(format: String(localized: "dungeon.l3.objective.format"), target.displayName) + return String(localized: "dungeon.l3.objective.format") } } + /// VoiceOver objective: same metaphor as ``objectiveText``—Moonstone vs orb, not dungeon navigation. private var objectiveA11yLabel: String { - guard let target = rooms.first(where: \.isTarget) else { return "" } switch rooms.count { case DungeonRoom.l1Rooms.count: - return String(format: String(localized: "dungeon.a11y.l1.objective.format"), target.displayName) + return String(localized: "dungeon.a11y.l1.objective.format") case DungeonRoom.l2Rooms.count: - return String(format: String(localized: "dungeon.a11y.l2.objective.format"), target.displayName) + return String(localized: "dungeon.a11y.l2.objective.format") default: - return String(format: String(localized: "dungeon.a11y.l3.objective.format"), target.displayName) + return String(localized: "dungeon.a11y.l3.objective.format") } } @@ -472,6 +835,35 @@ struct iOSDungeonResonancePlayView: View { } } +// MARK: - Lane emphasis + +/// Keeps the lane visually focused on one slot and sells the **fit** metaphor: the Moonstone nests into the hub when +/// centered; echo glyphs stay slightly undersized and canted so they read as “almost but not quite” the lock shape. +private struct iOSResonanceLaneSelectionModifier: ViewModifier { + let distanceFromSelection: Int + let isMoonstone: Bool + + /// Centered Moonstone: modest boost so the oval meets the reticle’s inner opening; centered decoy: smaller than 1 so it never “seats.” + private var centerScale: CGFloat { isMoonstone ? 1.04 : 0.91 } + + private var offRowScale: CGFloat { 0.88 } + + /// Tiny yaw on centered decoys only — enough to break symmetry against the circular orb without clownish spin. + private var decoyCenterTilt: Angle { + guard !isMoonstone, distanceFromSelection == 0 else { return .zero } + return .degrees(3.5) + } + + func body(content: Content) -> some View { + content + .scaleEffect(distanceFromSelection == 0 ? centerScale : offRowScale) + .rotationEffect(decoyCenterTilt) + .opacity(distanceFromSelection == 0 ? 1.0 : (distanceFromSelection == 1 ? 0.28 : 0.0)) + // Avoid blur — it composites into dark rectangular bands behind PNG glyphs in the shaft. + .animation(.easeOut(duration: 0.18), value: distanceFromSelection) + } +} + // MARK: - Status row /// Visible-only feedback line below the HUD (VoiceOver still receives hint via `requestHint` paths). diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSResonanceVoiceOverScrollProxyRepresentable.swift b/RA11y-iOS/RA11y-iOS/Games/iOSResonanceVoiceOverScrollProxyRepresentable.swift index 15cc27a..804fbe5 100644 --- a/RA11y-iOS/RA11y-iOS/Games/iOSResonanceVoiceOverScrollProxyRepresentable.swift +++ b/RA11y-iOS/RA11y-iOS/Games/iOSResonanceVoiceOverScrollProxyRepresentable.swift @@ -5,16 +5,28 @@ import RA11yCore // MARK: - UIScrollView subclass -/// Hosts the accessibility element VoiceOver should treat as the **sole** scroll surface for the lane. +/// Hosts the accessibility element VoiceOver treats as the **sole** scroll surface for the lane. /// Invokes a one-shot callback after first valid layout so programmatic focus can target this view. private final class ResonanceVoiceOverProxyScrollView: UIScrollView { var onFirstReadyForAccessibilityLayout: (() -> Void)? private var didFireFirstLayout = false + private var lastLoggedBoundsHeight: CGFloat = -1 override func layoutSubviews() { super.layoutSubviews() + if abs(bounds.height - lastLoggedBoundsHeight) > 0.5 { + lastLoggedBoundsHeight = bounds.height + RA11yLogger.scrollInteraction.debug( + "UIKit proxy: layoutSubviews bounds.height=\(self.bounds.height) contentSize.height=\(self.contentSize.height)" + ) + #if DEBUG + print( + "[RA11yScroll] UIKit proxy: layoutSubviews bounds.height \(String(format: "%.1f", self.bounds.height)) contentSize.height \(String(format: "%.1f", self.contentSize.height))" + ) + #endif + } guard !didFireFirstLayout, bounds.width > 0.5, bounds.height > 0.5, @@ -23,24 +35,57 @@ private final class ResonanceVoiceOverProxyScrollView: UIScrollView { didFireFirstLayout = true onFirstReadyForAccessibilityLayout?() } + + override func accessibilityElementDidBecomeFocused() { + super.accessibilityElementDidBecomeFocused() + RA11yLogger.scrollInteraction.debug( + "UIKit proxy: accessibilityElementDidBecomeFocused contentOffset.y=\(self.contentOffset.y) contentSize.height=\(self.contentSize.height) bounds.height=\(self.bounds.height)" + ) + #if DEBUG + print( + "[RA11yScroll] UIKit proxy: accessibilityElementDidBecomeFocused contentOffset.y \(String(format: "%.1f", self.contentOffset.y)) contentSize.height \(String(format: "%.1f", self.contentSize.height)) bounds.height \(String(format: "%.1f", self.bounds.height))" + ) + #endif + } + + override func accessibilityElementDidLoseFocus() { + super.accessibilityElementDidLoseFocus() + RA11yLogger.scrollInteraction.debug("UIKit proxy: accessibilityElementDidLoseFocus") + #if DEBUG + print("[RA11yScroll] UIKit proxy: accessibilityElementDidLoseFocus") + #endif + } } // MARK: - Coordinator -/// Bridges `UIScrollView` offset updates to SwiftUI and schedules initial VoiceOver focus on the UIKit view. +/// Bridges `UIScrollView` offset updates to SwiftUI and schedules initial VoiceOver focus on the UIKit scroll view. /// /// ## Concurrency /// `scrollViewDidScroll` and layout callbacks run on the main thread; `UIAccessibility.post` and `Task` /// for focus scheduling use `@MainActor`. -final class iOSResonanceVoiceOverScrollProxyCoordinator: NSObject, UIScrollViewDelegate { +final class iOSResonanceVoiceOverScrollProxyCoordinator: NSObject, UIScrollViewAccessibilityDelegate { var onContentOffsetYChanged: (CGFloat) -> Void = { _ in } + var accessibilityScrollStatusText: () -> String? = { nil } private weak var scrollView: UIScrollView? private weak var contentView: UIView? private var contentHeightConstraint: NSLayoutConstraint? + /// When true, ``scrollViewDidScroll`` ignores callbacks — ``setContentOffset`` from SwiftUI must not + /// mutate ``@State`` during ``UIViewRepresentable/updateUIView`` (undefined behavior + bad VoiceOver landing). + private var isApplyingProgrammaticContentOffset = false + private var didScheduleVoiceOverLanding = false + private var lastLoggedScrollRange: CGFloat = -10_000 + + /// One-shot DEBUG warning if scroll range is too small for reliable VoiceOver lane travel. + private var didLogInsufficientScrollRange = false + + /// Coalesces ``updateUIView`` layout work — SwiftUI can emit many updates per frame; without this, + /// queued `DispatchQueue.main.async` blocks apply stale `bounds`/`contentSize` pairs. + private var deferredScrollLayoutWorkItem: DispatchWorkItem? func attach(scrollView: UIScrollView, contentView: UIView, heightConstraint: NSLayoutConstraint) { self.scrollView = scrollView @@ -54,10 +99,45 @@ final class iOSResonanceVoiceOverScrollProxyCoordinator: NSObject, UIScrollViewD constraint.constant = totalHeight } scrollView.layoutIfNeeded() + let boundsH = scrollView.bounds.height + let contentH = scrollView.contentSize.height + let scrollRange = contentH - boundsH + if abs(scrollRange - lastLoggedScrollRange) > 0.5 { + lastLoggedScrollRange = scrollRange + RA11yLogger.scrollInteraction.debug( + "UIKit proxy: updateContentHeight totalHeight=\(totalHeight) contentSize.height=\(contentH) bounds.height=\(boundsH) scrollRange=\(scrollRange)" + ) + #if DEBUG + print( + "[RA11yScroll] UIKit proxy: updateContentHeight totalHeight \(String(format: "%.1f", totalHeight)) contentSize.height \(String(format: "%.1f", contentH)) bounds.height \(String(format: "%.1f", boundsH)) scrollRange \(String(format: "%.1f", scrollRange))" + ) + #endif + } + #if DEBUG + if !didLogInsufficientScrollRange { + if boundsH > 10, contentH > 0, scrollRange <= 96 { + didLogInsufficientScrollRange = true + RA11yLogger.scrollInteraction.warning( + "Resonance scroll proxy: scroll range (\(scrollRange)) is too small for reliable three-finger VoiceOver travel; contentSize.height=\(contentH), bounds.height=\(boundsH)" + ) + } + } + #endif + } + + /// Keeps the UIKit proxy aligned with the snapped SwiftUI slot selection instead of free-running on + /// inertial scroll physics. This makes VoiceOver page scroll behave like a discrete room selector. + func updateDesiredContentOffsetY(_ desiredOffsetY: CGFloat, scrollView: UIScrollView) { + let maxY = max(0, scrollView.contentSize.height - scrollView.bounds.height) + let clampedOffsetY = max(0, min(desiredOffsetY, maxY)) + guard abs(clampedOffsetY - scrollView.contentOffset.y) > 0.5 else { return } + isApplyingProgrammaticContentOffset = true + scrollView.setContentOffset(CGPoint(x: 0, y: clampedOffsetY), animated: false) + isApplyingProgrammaticContentOffset = false } - /// Called once after the scroll view has a non-empty frame in a window. Posts the same sequence as - /// the former SwiftUI path (`screenChanged` → delay → move focus via `layoutChanged` → announcement). + /// Called once after the scroll view has a non-empty frame in a window. Restores the original + /// VoiceOver landing sequence so the lane is immediately ready for three-finger scroll. @MainActor func scheduleVoiceOverLandingIfNeeded() { guard !didScheduleVoiceOverLanding else { return } @@ -72,40 +152,73 @@ final class iOSResonanceVoiceOverScrollProxyCoordinator: NSObject, UIScrollViewD } Task { @MainActor in - try? await Task.sleep(for: .milliseconds(500)) - UIAccessibility.post(notification: .screenChanged, argument: nil) - RA11yLogger.scrollInteraction.debug("UIKit proxy: posted UIAccessibility.Notification.screenChanged") + try? await Task.sleep(for: .milliseconds(650)) + guard let sv = scrollView else { return } + UIAccessibility.post(notification: .screenChanged, argument: sv) + RA11yLogger.scrollInteraction.debug("UIKit proxy: posted UIAccessibility.Notification.screenChanged with UIScrollView") #if DEBUG - print("[RA11yScroll] UIKit proxy: posted UIAccessibility.Notification.screenChanged") + print("[RA11yScroll] UIKit proxy: posted UIAccessibility.Notification.screenChanged with UIScrollView") #endif - try? await Task.sleep(for: .milliseconds(300)) - guard let sv = scrollView else { return } + + try? await Task.sleep(for: .milliseconds(250)) UIAccessibility.post(notification: .layoutChanged, argument: sv) RA11yLogger.scrollInteraction.debug("UIKit proxy: posted layoutChanged with UIScrollView") #if DEBUG print("[RA11yScroll] UIKit proxy: posted layoutChanged with UIScrollView") #endif - let focusLine = String(localized: "dungeon.a11y.scroll.vo.focusAnnouncement") - UIAccessibility.post(notification: .announcement, argument: focusLine) - RA11yLogger.scrollInteraction.debug("UIKit proxy: posted VO focus announcement (length=\(focusLine.count))") + + /// Second `layoutChanged` after SwiftUI + UIKit finish settling—**without** this, focus often stays on + /// chrome on iPad; users can still swipe to the lane, but auto-landing regresses. We intentionally + /// **do not** re-add `.announcement`: that duplicated the scroll view’s label/hint and sounded like + /// the same instruction twice. + try? await Task.sleep(for: .milliseconds(320)) + UIAccessibility.post(notification: .layoutChanged, argument: sv) + RA11yLogger.scrollInteraction.debug("UIKit proxy: posted follow-up layoutChanged with UIScrollView") #if DEBUG - print("[RA11yScroll] UIKit proxy: posted VO focus announcement (length=\(focusLine.count))") + print("[RA11yScroll] UIKit proxy: posted follow-up layoutChanged with UIScrollView") #endif } } func scrollViewDidScroll(_ scrollView: UIScrollView) { - onContentOffsetYChanged(scrollView.contentOffset.y) + guard !isApplyingProgrammaticContentOffset else { return } + let y = scrollView.contentOffset.y + DispatchQueue.main.async { [onContentOffsetYChanged] in + onContentOffsetYChanged(y) + } + } + + func accessibilityScrollStatus(for scrollView: UIScrollView) -> String? { + accessibilityScrollStatusText() + } + + /// Applies scroll content height and snapped offset on the next main-queue turn with a stable `bounds` rect. + func scheduleDeferredContentLayout(totalHeight: CGFloat, desiredOffsetY: CGFloat, scrollView: UIScrollView) { + deferredScrollLayoutWorkItem?.cancel() + let work = DispatchWorkItem { [weak self, weak scrollView] in + guard let self, let scrollView else { return } + self.updateContentHeight(totalHeight: totalHeight, scrollView: scrollView) + self.updateDesiredContentOffsetY(desiredOffsetY, scrollView: scrollView) + } + deferredScrollLayoutWorkItem = work + DispatchQueue.main.async(execute: work) } } // MARK: - UIViewRepresentable -/// Transparent `UIScrollView` VoiceOver proxy for Crystal Resonance: one accessibility element with a -/// large content size so three-finger scrolling moves `contentOffset` and drives the mirrored lane offset. +/// Transparent `UIScrollView` VoiceOver proxy for Crystal Resonance: **one** accessibility element (the lane) +/// with a large content size so three-finger scrolling moves `contentOffset` and drives the mirrored lane offset. /// -/// SwiftUI’s `ScrollView` delegated accessibility through the SwiftUI runtime inconsistently in swipe -/// tests; hosting UIKit preserves a first-class scroll view in the platform accessibility tree. +/// **VoiceOver three-finger scroll** is implemented by the system for focused `UIScrollView` instances; it does **not** +/// surface as a normal `UIGestureRecognizer` you can log from `UIResponder`/`touches`. Verify behavior with +/// ``scrollViewDidScroll`` / `contentOffset` (and the `[RA11yScroll]` logs from the SwiftUI bridge). +/// +/// Chamber labels in ``DungeonRoom`` are not exposed here—they are decorative narrative tied to decoys and the +/// objective string in the HUD, not separate VoiceOver destinations. +/// +/// SwiftUI’s `ScrollView` delegated accessibility through the SwiftUI runtime inconsistently in swipe tests; +/// hosting UIKit preserves a first-class scroll view in the platform accessibility tree. /// /// ## Concurrency /// `UIViewRepresentable` updates occur on the main thread; offset callbacks are main-thread. @@ -119,6 +232,8 @@ struct iOSResonanceVoiceOverScrollProxyRepresentable: UIViewRepresentable { var accessibilityLabelText: String var accessibilityHintText: String + var accessibilityScrollStatusText: String? + var desiredContentOffsetY: CGFloat var onContentOffsetYChanged: (CGFloat) -> Void @@ -129,9 +244,11 @@ struct iOSResonanceVoiceOverScrollProxyRepresentable: UIViewRepresentable { func makeUIView(context: Context) -> UIScrollView { let scrollView = ResonanceVoiceOverProxyScrollView() scrollView.backgroundColor = .clear + scrollView.isScrollEnabled = true scrollView.alwaysBounceVertical = true scrollView.showsVerticalScrollIndicator = false scrollView.showsHorizontalScrollIndicator = false + scrollView.accessibilityRespondsToUserInteraction = true scrollView.delegate = context.coordinator scrollView.contentInsetAdjustmentBehavior = .never @@ -143,6 +260,7 @@ struct iOSResonanceVoiceOverScrollProxyRepresentable: UIViewRepresentable { contentView.translatesAutoresizingMaskIntoConstraints = false context.coordinator.onContentOffsetYChanged = onContentOffsetYChanged + context.coordinator.accessibilityScrollStatusText = { accessibilityScrollStatusText } let totalHeight = computeTotalContentHeight() let heightConstraint = contentView.heightAnchor.constraint(equalToConstant: totalHeight) @@ -168,8 +286,15 @@ struct iOSResonanceVoiceOverScrollProxyRepresentable: UIViewRepresentable { func updateUIView(_ scrollView: UIScrollView, context: Context) { context.coordinator.onContentOffsetYChanged = onContentOffsetYChanged - context.coordinator.updateContentHeight(totalHeight: computeTotalContentHeight(), scrollView: scrollView) + context.coordinator.accessibilityScrollStatusText = { accessibilityScrollStatusText } + let totalHeight = computeTotalContentHeight() + let desiredY = desiredContentOffsetY configureAccessibility(on: scrollView) + context.coordinator.scheduleDeferredContentLayout( + totalHeight: totalHeight, + desiredOffsetY: desiredY, + scrollView: scrollView + ) } private func computeTotalContentHeight() -> CGFloat { @@ -181,5 +306,6 @@ struct iOSResonanceVoiceOverScrollProxyRepresentable: UIViewRepresentable { scrollView.accessibilityIdentifier = "dungeon.resonance.scrollLane" scrollView.accessibilityLabel = accessibilityLabelText scrollView.accessibilityHint = accessibilityHintText + scrollView.accessibilityRespondsToUserInteraction = true } } diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSRogueGauntletView.swift b/RA11y-iOS/RA11y-iOS/Games/iOSRogueGauntletView.swift deleted file mode 100644 index 3dde5c5..0000000 --- a/RA11y-iOS/RA11y-iOS/Games/iOSRogueGauntletView.swift +++ /dev/null @@ -1,1344 +0,0 @@ -import Observation -import os -import SwiftUI -import UIKit -import RA11yCore - -// MARK: - iOSRogueGauntletView - -/// Container for The Rogue's Gauntlet (Activate / Double-Tap) — M6. -/// -/// Implements the full 4-level game arc defined in `GameSpec-ActivateDoubleTap.txt` -/// and `GameRules-MVP.txt`: -/// -/// - **L0 Prologue**: DM narration + "The Rogue's Creed" lesson + gesture guide + "Begin Trial" -/// - **L1 First Attempt**: 1 seal (Seal of Passage), no timer — confidence builder for double-tap -/// - **L2 Rising Challenge**: 4 seals in a 2×2 grid, 40 s soft timer -/// - **L3 Timed Trial**: 6 seals in a 2×3 grid, 20 s hard timer; `GameSession` started here for scoring -/// -/// Only L3 creates a `GameSession`. Completion writes the result to storage and navigates to -/// the shared `iOSGameResultView`. Timeout abandons the session and synthesises a Defeated result -/// (not stored). -/// -/// ## Concurrency -/// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. -struct iOSRogueGauntletView: View { - - // MARK: - State - - @State private var viewModel: RogueGauntletViewModel - - /// Final timed level only: blackout of seal artwork (`LightsOffMode-Recommendations.txt`). - private var isLightsOffFinalLevel: Bool { viewModel.phase == .timed } - - // MARK: - Environment - - @Environment(iOSAppRouter.self) private var router - - // MARK: - Private - - private let storage: any StorageComponent - - // MARK: - Init - - init(storage: any StorageComponent) { - self.storage = storage - _viewModel = State(initialValue: RogueGauntletViewModel(storage: storage)) - } - - // MARK: - Body - - /// See `iOSEnchantersTrialView` for the rationale for using `.background {}` over ZStack. - var body: some View { - levelContent - .background { - if isLightsOffFinalLevel { - Color.black - .ignoresSafeArea() - } else { - RogueBackgroundView() - .ignoresSafeArea() - } - } - .preferredColorScheme(.dark) - .navigationBarTitleDisplayMode(.inline) - .onChange(of: viewModel.completedResult) { _, result in - guard let result else { return } - let announcement = gameSpecificAnnouncement(for: result) - router.push(.gameResult(result, gameKind: .activateDoubleTap, gameSpecificAnnouncement: announcement)) - } - .onChange(of: viewModel.voiceOverDisabledMidGame) { _, disabled in - if disabled { - router.push(.voiceOverInterstitial(kind: .activateDoubleTap)) - } - } - .onDisappear { viewModel.handleViewDisappear() } - } - - // MARK: - Level Routing - - /// See `iOSEnchantersTrialView` for the rationale for `.transition(.identity)` on each case. - @ViewBuilder - private var levelContent: some View { - switch viewModel.phase { - case .prologue: - RoguePrologueView(onBeginTrial: { viewModel.beginTrial() }) - .navigationTitle(String(localized: "rogue.explain.title")) - .accessibilityIdentifier("rogue.prologue") - .transition(.identity) - - case .firstAttempt: - RogueFirstAttemptView( - seal: viewModel.targetSeal ?? .placeholder, - statusMessage: viewModel.statusMessage, - levelComplete: viewModel.levelComplete, - lightsOffMode: isLightsOffFinalLevel, - onActivate: { seal in await viewModel.activateSeal(seal) }, - onContinue: { viewModel.advanceToRising() } - ) - .navigationTitle(String(localized: "rogue.l1.title")) - .onAppear { viewModel.announceObjectivePrompt() } - .accessibilityIdentifier("rogue.firstAttempt") - .transition(.identity) - - case .rising: - RogueRisingView( - seals: viewModel.seals, - targetSeal: viewModel.targetSeal ?? .placeholder, - mistakes: viewModel.mistakes, - timeRemaining: viewModel.timeRemaining, - statusMessage: viewModel.statusMessage, - levelComplete: viewModel.levelComplete, - timedOut: viewModel.l2TimedOut, - lightsOffMode: isLightsOffFinalLevel, - onActivate: { seal in await viewModel.activateSeal(seal) }, - onHint: { viewModel.requestHint() }, - onContinue: { viewModel.advanceToTimed() }, - onRetry: { viewModel.retryRising() } - ) - .navigationTitle(String(localized: "rogue.l2.title")) - .onAppear { viewModel.announceObjectivePrompt() } - .accessibilityIdentifier("rogue.rising") - .transition(.identity) - - case .timed: - RogueTimedView( - seals: viewModel.seals, - targetSeal: viewModel.targetSeal ?? .placeholder, - mistakes: viewModel.mistakes, - timeRemaining: viewModel.timeRemaining, - statusMessage: viewModel.statusMessage, - timedOut: viewModel.l3TimedOut, - lightsOffMode: isLightsOffFinalLevel, - onActivate: { seal in await viewModel.activateSeal(seal) }, - onHint: { viewModel.requestHint() }, - onRetry: { viewModel.retryTimed() } - ) - .navigationTitle(String(localized: "rogue.l3.title")) - .onAppear { viewModel.announceObjectivePrompt() } - .accessibilityIdentifier("rogue.timed") - .transition(.identity) - } - } - - // MARK: - Game-Specific Result Announcements - - /// Returns the per-rank thematic result string for The Rogue's Gauntlet. - /// - /// Displayed on the shared result screen below the rank summary. - private func gameSpecificAnnouncement(for result: GameResult) -> String { - switch result.rank { - case .perfect: return String(localized: "rogue.results.legendary") - case .good: return String(localized: "rogue.results.skilled") - case .ok: return String(localized: "rogue.results.novice") - case .failed: return String(localized: "rogue.results.defeated") - } - } -} - -// MARK: - RogueGauntletViewModel - -/// Observable view model managing The Rogue's Gauntlet 4-level state machine. -/// -/// Owns level phase transitions, seal set composition, mistake tracking, timer logic, -/// VoiceOver threshold announcements, and the L3 `GameSession`/`GameSessionCoordinator`. -/// -/// ## Concurrency -/// `@MainActor` isolated for safe SwiftUI observation. The countdown timer runs in a -/// `Task` confined to `@MainActor` to avoid data races on view-observable state. -/// -/// Timer task is a plain `private var`. The timer closure uses `[weak self]` on every -/// iteration so the running `Task` does not retain the ViewModel — when the ViewModel -/// is released the next `guard let self` exits cleanly. `stopTimer()` provides eager -/// cancellation before any phase transition. -@Observable -@MainActor -final class RogueGauntletViewModel { - - // MARK: - Phase - - enum Phase { case prologue, firstAttempt, rising, timed } - - private(set) var phase: Phase = .prologue - - // MARK: - Shared Level State - - private(set) var seals: [RogueSeal] = [] - private(set) var targetSeal: RogueSeal? - private(set) var mistakes: Int = 0 - private(set) var statusMessage: String? - private(set) var levelComplete: Bool = false - - // MARK: - Timer State (L2 + L3) - - /// Remaining seconds for the active level timer. Updated approximately every 0.2 s. - private(set) var timeRemaining: Double = 0 - - private(set) var l2TimedOut: Bool = false - private(set) var l3TimedOut: Bool = false - - // MARK: - L3 Session State - - /// Set when L3 `GameSession.complete()` succeeds. The container view navigates - /// to the result screen when this becomes non-nil. - private(set) var completedResult: GameResult? - - /// Set to `true` when `GameSessionCoordinator` detects VoiceOver going off mid-game. - /// The container view pushes the VO interstitial route when this becomes `true`. - private(set) var voiceOverDisabledMidGame: Bool = false - - // MARK: - Private - - private let storage: any StorageComponent - - /// L3 session — created fresh each time L3 starts (including retries). - private var session: GameSession? - - /// VO monitor for L3. Nil during L0–L2. - private var coordinator: GameSessionCoordinator? - - /// Countdown task running during L2 or L3. Cancelled on phase change via `stopTimer()`. - private var timerTask: Task? - - // MARK: - Init - - init(storage: any StorageComponent) { - self.storage = storage - } - - // MARK: - Phase Transitions - - /// Transitions L0 → L1: sets the single Seal of Passage for the confidence-builder level. - func beginTrial() { - stopTimer() - mistakes = 0 - statusMessage = nil - levelComplete = false - let (set, target) = RogueSeal.setForL1() - seals = set - targetSeal = target - phase = .firstAttempt - } - - /// Transitions L1 → L2: 4-seal grid with 40 s soft timer. - func advanceToRising() { - stopTimer() - mistakes = 0 - statusMessage = nil - levelComplete = false - l2TimedOut = false - let (set, target) = RogueSeal.setForL2() - seals = set - targetSeal = target - timeRemaining = 40 - phase = .rising - startTimer(total: 40) { [weak self] in await self?.handleL2Timeout() } - } - - /// Retries L2 on timeout without returning to L1. - func retryRising() { - advanceToRising() - } - - /// Transitions L2 → L3: 6-seal grid with 20 s hard timer and a fresh `GameSession`. - /// - /// ## Concurrency - /// `GameSession.start()` must complete before the timer or any seal activation can call - /// `recordMistake()` / `complete()`, otherwise those actor methods throw `invalidTransition`. - /// Both are `await`-ed inside a single `Task` so the timer only starts once the session - /// is in the `.running` state. `GameSession` and `GameSessionCoordinator` are created fresh - /// on each call (including retries) so previous state is never reused. - func advanceToTimed() { - stopTimer() - mistakes = 0 - statusMessage = nil - levelComplete = false - l3TimedOut = false - completedResult = nil - voiceOverDisabledMidGame = false - let (set, target) = RogueSeal.setForL3() - seals = set - targetSeal = target - timeRemaining = 20 - phase = .timed - - let newSession = GameSession( - gameID: "rogue-gauntlet", - thresholds: .activateDoubleTap, - storage: storage - ) - let newCoordinator = GameSessionCoordinator( - session: newSession, - gameKind: .activateDoubleTap, - voiceOverProvider: iOSLiveVoiceOverStateProvider() - ) - session = newSession - coordinator = newCoordinator - - // Start session first — session MUST be in .running before any activation - // calls recordMistake/complete or they throw invalidTransition. - Task { @MainActor [weak self] in - guard let self else { return } - do { - try await newSession.start() - } catch { - RA11yLogger.gameSession.error("L3 session start failed: \(error.localizedDescription)") - return - } - newCoordinator.startMonitoring() - // Grace period for VoiceOver users: the navigation transition announcement - // and target prompt together take ~4–6 s to read aloud. Without a delay the - // 20 s window is already draining while VoiceOver is still speaking, making - // the trial nearly impossible. The grace period lets the user hear the prompt - // and orient before the countdown begins. - if UIAccessibility.isVoiceOverRunning { - try? await Task.sleep(for: .seconds(5)) - guard !Task.isCancelled else { return } - } - self.startTimer(total: 20) { [weak self] in await self?.handleL3Timeout() } - self.observeCoordinatorVOState(coordinator: newCoordinator) - } - } - - /// Polls the given coordinator for a VoiceOver-off event and mirrors it onto the ViewModel. - /// - /// ## Concurrency - /// Runs in a `Task` so it does not block the calling context. Exits as soon as the flag - /// is set, the task is cancelled, or the ViewModel is released. - private func observeCoordinatorVOState(coordinator: GameSessionCoordinator) { - Task { @MainActor [weak self] in - while !Task.isCancelled { - try? await Task.sleep(for: .milliseconds(200)) - guard let self, !Task.isCancelled else { return } - if coordinator.voiceOverDisabledMidGame { - self.voiceOverDisabledMidGame = true - self.stopTimer() - return - } - } - } - } - - /// Retries L3 — creates a fresh session and restarts the 20 s timer. - func retryTimed() { - advanceToTimed() - } - - // MARK: - Seal Activation - - /// Handles a seal button tap for the current level. - /// - /// - Correct seal: marks level complete; starts L3 session completion if in `.timed` phase. - /// - Wrong seal: records mistake, announces feedback via VoiceOver. - func activateSeal(_ seal: RogueSeal) async { - guard !levelComplete, !l2TimedOut, !l3TimedOut else { return } - - if seal == targetSeal { - await handleCorrectActivation(seal) - } else { - await handleWrongActivation(seal) - } - } - - /// Announces the hint string via VoiceOver and sets it as the visible status message. - func requestHint() { - guard let target = targetSeal else { return } - let message = String(format: String(localized: "rogue.hint.format"), target.displayName) - statusMessage = message - announce(message) - } - - /// Announces the objective prompt to VoiceOver when a level view appears. - /// - /// Called from each level view's `.onAppear` so VoiceOver reads the target seal name - /// aloud on screen load — satisfying the spec requirement "announce objective on level load." - /// A 500 ms delay prevents the announcement from being swallowed by the navigation - /// transition focus announcement. - func announceObjectivePrompt() { - guard let target = targetSeal else { return } - let message: String - switch phase { - case .firstAttempt: - message = String(format: String(localized: "rogue.a11y.l1.objective"), target.displayName) - case .rising: - message = String(format: String(localized: "rogue.a11y.l2.objective"), target.displayName) - case .timed: - message = String(format: String(localized: "rogue.a11y.l3.objective"), target.displayName) - case .prologue: - return - } - Task { @MainActor [weak self] in - guard let self else { return } - try? await Task.sleep(for: .milliseconds(500)) - self.announce(message) - } - } - - // MARK: - Private: Activation Handling - - private func handleCorrectActivation(_ seal: RogueSeal) async { - switch phase { - case .prologue: - break - - case .firstAttempt: - stopTimer() - levelComplete = true - statusMessage = String(localized: "rogue.feedback.correct") - announce(String(localized: "rogue.l1.complete")) - - case .rising: - stopTimer() - levelComplete = true - statusMessage = String(localized: "rogue.feedback.correct") - announce(String(localized: "rogue.l2.complete")) - - case .timed: - guard let session else { return } - stopTimer() - - // Compute elapsed from the 20 s L3 window and record any time-bucket penalty - // mistakes before completing the session. Bucket size for this game is 8 s - // (per GameSpec-ActivateDoubleTap.txt): first 8 s is free, each subsequent - // full 8 s adds +1 mistake. - let elapsed = 20.0 - timeRemaining - let buckets = RankThresholds.bucketMistakes(timeSeconds: elapsed, bucketSize: 8) - for _ in 0.. Void) { - timerTask?.cancel() - timerTask = Task { @MainActor [weak self] in - let startDate = Date() - var announced75 = false - var announced50 = false - var announced25 = false - var announced10 = false - var announcedCountdown = Set() - - while !Task.isCancelled { - try? await Task.sleep(for: .milliseconds(200)) - guard let self, !Task.isCancelled else { return } - - let elapsed = Date().timeIntervalSince(startDate) - let remaining = max(0, total - elapsed) - self.timeRemaining = remaining - - let pctElapsed = elapsed / total - if pctElapsed >= 0.75 && !announced75 { - announced75 = true - self.announceTimerThreshold(for: self.phase, pct: 0.75) - } else if pctElapsed >= 0.50 && !announced50 { - announced50 = true - self.announceTimerThreshold(for: self.phase, pct: 0.50) - } else if pctElapsed >= 0.25 && !announced25 { - announced25 = true - self.announceTimerThreshold(for: self.phase, pct: 0.25) - } - - // 10 s remaining — L3 only - if self.phase == .timed && remaining <= 10 && !announced10 { - announced10 = true - self.announce(String(localized: "a11y.timer.10s")) - } - - // Final 5 s countdown (shared across games) - if remaining <= 5 { - let secondsLeft = Int(ceil(remaining)) - if secondsLeft > 0 && !announcedCountdown.contains(secondsLeft) { - announcedCountdown.insert(secondsLeft) - self.announceCountdown(secondsLeft) - } - } - - if remaining <= 0 { - await onTimeout() - break - } - } - } - } - - private func stopTimer() { - timerTask?.cancel() - timerTask = nil - } - - /// Handles the view disappearing from the navigation stack (e.g., user taps back). - /// - /// Stops any active timer and abandons the L3 `GameSession` if it is still running, - /// ensuring no result is written for an incomplete play-through. - /// - /// ## Concurrency - /// Called from `@MainActor` context via `.onDisappear`. The inner `Task` inherits - /// `@MainActor` isolation and safely `await`s the actor-isolated `abandon()`. - func handleViewDisappear() { - stopTimer() - coordinator?.stopMonitoring() - guard let session else { return } - Task { await session.abandon() } - } - - private func handleL2Timeout() async { - l2TimedOut = true - announce(String(localized: "rogue.timeout")) - } - - private func handleL3Timeout() async { - guard let session else { return } - l3TimedOut = true - coordinator?.stopMonitoring() - await session.abandon() - announce(String(localized: "rogue.timeout")) - // Synthesise a Defeated result for display (not stored — session was abandoned). - completedResult = GameResult( - gameID: "rogue-gauntlet", - rank: .failed, - timeSeconds: 20.0, - mistakes: mistakes - ) - } - - // MARK: - Private: VoiceOver Announcements - - private func announce(_ message: String) { - UIAccessibility.post(notification: .announcement, argument: message) - } - - /// Posts the game-specific timer threshold announcement for the given phase and elapsed percent. - /// - /// L2 only announces at 50% and 25% (no 75% for the soft timer). - /// L3 announces at 75%, 50%, and 25%. - private func announceTimerThreshold(for phase: Phase, pct: Double) { - switch (phase, pct) { - case (.timed, 0.75): - announce(String(localized: "rogue.a11y.timer.75pct")) - case (.timed, 0.50), (.rising, 0.50): - announce(String(localized: "rogue.a11y.timer.50pct")) - case (.timed, 0.25), (.rising, 0.25): - announce(String(localized: "rogue.a11y.timer.25pct")) - default: - break - } - } - - private func announceCountdown(_ seconds: Int) { - switch seconds { - case 5: announce(String(localized: "a11y.timer.5s")) - case 4: announce(String(localized: "a11y.timer.4s")) - case 3: announce(String(localized: "a11y.timer.3s")) - case 2: announce(String(localized: "a11y.timer.2s")) - case 1: announce(String(localized: "a11y.timer.1s")) - default: break - } - } -} - -// MARK: - RogueSeal - -/// A single seal in The Rogue's Gauntlet. -/// -/// Passage seals are the correct target; trap seals spring missteps when activated. -/// Level-specific sets are composed by `setForL1()`, `setForL2()`, and `setForL3()`. -struct RogueSeal: Identifiable, Hashable, Equatable { - let id: String - let displayName: String - let assetName: String - /// `true` for passage seals (correct target); `false` for trap seals. - let isPassage: Bool - - // MARK: - Seal Pools - - /// All passage seals. L1 always uses `passage`; L2/L3 pick randomly from this pool. - static let passageSeals: [RogueSeal] = [ - RogueSeal(id: "passage", displayName: "Seal of Passage", assetName: "rogue_seal_passage", isPassage: true), - RogueSeal(id: "ward", displayName: "Warding Seal", assetName: "rogue_seal_ward", isPassage: true), - RogueSeal(id: "binding", displayName: "Binding Seal", assetName: "rogue_seal_binding", isPassage: true), - ] - - /// All trap seals. Wrong activations increment mistakes. - static let trapSeals: [RogueSeal] = [ - RogueSeal(id: "spike", displayName: "Spike Trap Seal", assetName: "rogue_seal_spike", isPassage: false), - RogueSeal(id: "alarm", displayName: "Alarm Seal", assetName: "rogue_seal_alarm", isPassage: false), - RogueSeal(id: "collapse", displayName: "Collapse Seal", assetName: "rogue_seal_collapse", isPassage: false), - RogueSeal(id: "reveal", displayName: "Reveal Seal", assetName: "rogue_seal_reveal", isPassage: false), - RogueSeal(id: "freeze", displayName: "Freeze Seal", assetName: "rogue_seal_freeze", isPassage: false), - RogueSeal(id: "silence", displayName: "Silence Seal", assetName: "rogue_seal_silence", isPassage: false), - ] - - /// Placeholder used as ViewModel initial state before a level is configured. - static let placeholder = RogueSeal(id: "", displayName: "", assetName: "", isPassage: false) - - // MARK: - Set Builders - - /// Returns the single-seal L1 set. Always Seal of Passage — never randomised. - static func setForL1() -> (seals: [RogueSeal], target: RogueSeal) { - let seal = passageSeals[0] // always "passage" - return ([seal], seal) - } - - /// Returns 4 seals for L2: 1 passage (random) + 3 trap seals (random). - /// - /// UI-testing mode uses a fixed, deterministic selection for screenshot repeatability. - static func setForL2() -> (seals: [RogueSeal], target: RogueSeal) { - let isTesting = ProcessInfo.processInfo.arguments.contains("-uiTesting") - let passage = isTesting ? passageSeals[0] : (passageSeals.randomElement() ?? passageSeals[0]) - let traps = isTesting ? Array(trapSeals.prefix(3)) : Array(trapSeals.shuffled().prefix(3)) - let seals = isTesting ? [passage] + traps : ([passage] + traps).shuffled() - return (seals, passage) - } - - /// Returns 6 seals for L3: 1 passage (random) + 5 trap seals (random). - /// - /// UI-testing mode uses a fixed, deterministic selection for screenshot repeatability. - static func setForL3() -> (seals: [RogueSeal], target: RogueSeal) { - let isTesting = ProcessInfo.processInfo.arguments.contains("-uiTesting") - let passage = isTesting ? passageSeals[0] : (passageSeals.randomElement() ?? passageSeals[0]) - let traps = isTesting ? Array(trapSeals.prefix(5)) : Array(trapSeals.shuffled().prefix(5)) - let seals = isTesting ? [passage] + traps : ([passage] + traps).shuffled() - return (seals, passage) - } -} - -// MARK: - L0: RoguePrologueView - -/// L0 Prologue — DM narration, "The Rogue's Creed" lesson card, gesture guide, and "Begin Trial". -/// -/// No game session. Player reads (or VoiceOver reads) the paradigm shift, then taps "Begin Trial" -/// to start L1. -private struct RoguePrologueView: View { - - let onBeginTrial: () -> Void - - @Environment(\.horizontalSizeClass) private var sizeClass - - var body: some View { - GeometryReader { geo in - ScrollView(.vertical) { - VStack(spacing: RA11ySpacing.lg) { - dmNarrationCard - lessonCard - gestureGuide - beginButton - } - .padding(.horizontal, sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base) - .padding(.vertical, RA11ySpacing.lg) - .frame(width: geo.size.width) - .frame(maxWidth: sizeClass == .regular ? 600 : .infinity) - } - .clipped() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .environment(\.colorScheme, .dark) - } - - private var dmNarrationCard: some View { - VStack(alignment: .leading, spacing: RA11ySpacing.sm) { - Label(String(localized: "dm.label"), systemImage: "scroll.fill") - .font(.ra11yCaption) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - - Text(String(localized: "rogue.explain.narration")) - .font(.ra11yBody) - .italic() - } - .padding(RA11ySpacing.base) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.ultraThinMaterial, in: .rect(cornerRadius: RA11yRadius.card)) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.card) - .strokeBorder(Color.ra11yDMBorder.opacity(0.5), lineWidth: 1) - ) - } - - private var lessonCard: some View { - VStack(alignment: .leading, spacing: RA11ySpacing.sm) { - Text(String(localized: "rogue.explain.lesson.heading")) - .font(.ra11yHeadline) - .bold() - .accessibilityAddTraits(.isHeader) - - Text(String(localized: "rogue.explain.lesson.body")) - .font(.ra11yBody) - } - .padding(RA11ySpacing.base) - .frame(maxWidth: .infinity, alignment: .leading) - .background(.regularMaterial, in: .rect(cornerRadius: RA11yRadius.card)) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.card) - .strokeBorder(.white.opacity(0.15), lineWidth: 1) - ) - .accessibilityElement(children: .combine) - .accessibilityLabel(String(localized: "rogue.a11y.explain.lesson")) - } - - private var gestureGuide: some View { - VStack(spacing: RA11ySpacing.sm) { - RogueGestureRow(symbol: "hand.tap.fill", label: String(localized: "rogue.explain.gesture.tap1")) - RogueGestureRow(symbol: "hand.tap.fill", label: String(localized: "rogue.explain.gesture.tap2")) - RogueGestureRow(symbol: "exclamationmark.triangle.fill", label: String(localized: "rogue.explain.gesture.wrong")) - } - .accessibilityHidden(true) - } - - private var beginButton: some View { - Button(action: onBeginTrial) { - Text(String(localized: "level.button.start")) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .tint(Color.ra11yAccent) - .accessibilityHint(String(localized: "rogue.explain.start.hint")) - .accessibilityIdentifier("rogue.beginTrial") - } -} - -// MARK: - L1: RogueFirstAttemptView - -/// L1 First Attempt — single Seal of Passage, no timer, no hint button, no mistake HUD. -/// -/// Layout: prompt card + instructional text + large centered seal button. -/// Teaches the double-tap mechanic with zero ambiguity — only one interactive element exists. -private struct RogueFirstAttemptView: View { - - let seal: RogueSeal - let statusMessage: String? - let levelComplete: Bool - let lightsOffMode: Bool - let onActivate: (RogueSeal) async -> Void - let onContinue: () -> Void - - @Environment(\.horizontalSizeClass) private var sizeClass - - var body: some View { - GeometryReader { geo in - ScrollView(.vertical) { - VStack(spacing: RA11ySpacing.lg) { - roguePromptCard( - title: String(format: String(localized: "rogue.l1.objective.format"), seal.displayName), - a11yLabel: String(format: String(localized: "rogue.a11y.l1.objective"), seal.displayName), - a11yHint: String(localized: "rogue.a11y.l1.objective.hint") - ) - - Text(String(localized: "rogue.l1.dm_prompt")) - .font(.ra11yBody) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .accessibilityHidden(true) - - // Large centered seal — the only interactive element on this screen. - largeSealButton - - if let statusMessage { - rogueStatusRow(statusMessage) - } - - if levelComplete { - continueButton - } - } - .padding(.horizontal, sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base) - .padding(.vertical, RA11ySpacing.lg) - .frame(width: geo.size.width) - .frame(maxWidth: sizeClass == .regular ? 600 : .infinity) - } - .clipped() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .environment(\.colorScheme, .dark) - } - - private var largeSealButton: some View { - Button { - Task { await onActivate(seal) } - } label: { - VStack(spacing: RA11ySpacing.sm) { - SealImage(assetName: seal.assetName) - .frame(width: 160, height: 160) - .accessibilityHidden(true) - - Text(seal.displayName) - .font(.ra11yHeadline) - .bold() - } - .frame(maxWidth: .infinity) - .padding(RA11ySpacing.base) - .background(Color.black.opacity(0.25), in: .rect(cornerRadius: RA11yRadius.card)) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.card) - .strokeBorder(.white.opacity(0.12), lineWidth: 1) - ) - } - .accessibilityLabel(seal.displayName) - .accessibilityHint(String(localized: "rogue.seal.hint")) - .accessibilityIdentifier("rogue.seal.\(seal.id)") - .ra11yLightsOffGameplayBlackout(isEnabled: lightsOffMode) - } - - private var continueButton: some View { - Button(action: onContinue) { - Text(String(localized: "level.button.next")) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .tint(Color.ra11yAccent) - .accessibilityIdentifier("rogue.l1.continue") - } -} - -// MARK: - L2: RogueRisingView - -/// L2 Rising Challenge — 4 seals in a 2×2 grid, 40 s soft timer, hint button. -/// -/// VoiceOver reads the LazyVGrid in row-major order (left→right, top→bottom), -/// which is linear and deterministic — equivalent to a vertical list for accessibility. -private struct RogueRisingView: View { - - let seals: [RogueSeal] - let targetSeal: RogueSeal - let mistakes: Int - let timeRemaining: Double - let statusMessage: String? - let levelComplete: Bool - let timedOut: Bool - let lightsOffMode: Bool - let onActivate: (RogueSeal) async -> Void - let onHint: () -> Void - let onContinue: () -> Void - let onRetry: () -> Void - - @Environment(\.horizontalSizeClass) private var sizeClass - - var body: some View { - GeometryReader { geo in - ScrollView(.vertical) { - VStack(spacing: RA11ySpacing.lg) { - roguePromptCard( - title: String(format: String(localized: "rogue.l2.objective.format"), targetSeal.displayName), - a11yLabel: String(format: String(localized: "rogue.a11y.l2.objective"), targetSeal.displayName), - a11yHint: String(localized: "rogue.a11y.l2.objective.hint") - ) - - RogueTimerHUD(timeRemaining: timeRemaining, total: 40, mistakes: mistakes) - .accessibilityElement(children: .ignore) - .accessibilityLabel( - String(format: String(localized: "rogue.a11y.l2.timer"), Int(ceil(timeRemaining))) - ) - .accessibilityHint(String(localized: "a11y.timer.group.hint")) - - if timedOut { - timeoutBanner - } else { - SealGrid(seals: seals, columns: 2, onActivate: onActivate) - .ra11yLightsOffGameplayBlackout(isEnabled: lightsOffMode) - - if let statusMessage { rogueStatusRow(statusMessage) } - - if levelComplete { - continueButton - } else { - hintButton - } - } - } - .padding(.horizontal, sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base) - .padding(.vertical, RA11ySpacing.lg) - .frame(width: geo.size.width) - .frame(maxWidth: sizeClass == .regular ? 600 : .infinity) - } - .clipped() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .environment(\.colorScheme, .dark) - } - - private var timeoutBanner: some View { - VStack(spacing: RA11ySpacing.md) { - Text(String(localized: "rogue.timeout")) - .font(.ra11yHeadline) - .multilineTextAlignment(.center) - Button(action: onRetry) { - Text(String(localized: "level.button.retry")) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .tint(Color.ra11yAccent) - .accessibilityIdentifier("rogue.l2.retry") - } - .padding(RA11ySpacing.base) - } - - private var hintButton: some View { - Button(action: onHint) { - Label(String(localized: "rogue.hint.button"), systemImage: "ear.fill") - } - .buttonStyle(.bordered) - .accessibilityLabel(String(localized: "rogue.hint.a11yLabel")) - .accessibilityHint(String(localized: "rogue.hint.a11yHint")) - } - - private var continueButton: some View { - Button(action: onContinue) { - Text(String(localized: "level.button.next")) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .tint(Color.ra11yAccent) - .accessibilityIdentifier("rogue.l2.continue") - } -} - -// MARK: - L3: RogueTimedView - -/// L3 Timed Trial — 6 seals in a 2×3 grid, 20 s hard timer, `GameSession` for scoring. -/// -/// On timeout, shows the timeout banner with "Try Again". The ViewModel handles session -/// abandonment; the container view navigates to the result screen when `completedResult` is set. -private struct RogueTimedView: View { - - let seals: [RogueSeal] - let targetSeal: RogueSeal - let mistakes: Int - let timeRemaining: Double - let statusMessage: String? - let timedOut: Bool - let lightsOffMode: Bool - let onActivate: (RogueSeal) async -> Void - let onHint: () -> Void - let onRetry: () -> Void - - @Environment(\.horizontalSizeClass) private var sizeClass - - var body: some View { - GeometryReader { geo in - ScrollView(.vertical) { - VStack(spacing: RA11ySpacing.lg) { - Text(String(localized: "rogue.lightsOff.flavor")) - .font(.ra11ySubheadline) - .foregroundStyle(Color.ra11yCardSecondaryText) - .multilineTextAlignment(.leading) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(RA11ySpacing.base) - .background(Color.black.opacity(0.5), in: .rect(cornerRadius: RA11yRadius.card)) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.card) - .strokeBorder(Color.ra11yDMBorder.opacity(0.35), lineWidth: 1) - ) - .accessibilityAddTraits(.isStaticText) - - roguePromptCard( - title: String(format: String(localized: "rogue.l3.objective.format"), targetSeal.displayName), - a11yLabel: String(format: String(localized: "rogue.a11y.l3.objective"), targetSeal.displayName), - a11yHint: nil - ) - - RogueTimerHUD(timeRemaining: timeRemaining, total: 20, mistakes: mistakes) - .accessibilityElement(children: .ignore) - .accessibilityLabel( - String(format: String(localized: "rogue.a11y.l3.timer"), Int(ceil(timeRemaining))) - ) - .accessibilityHint(String(localized: "a11y.timer.group.hint")) - - if timedOut { - timeoutBanner - } else { - SealGrid(seals: seals, columns: 2, onActivate: onActivate) - .ra11yLightsOffGameplayBlackout(isEnabled: lightsOffMode) - - if let statusMessage { rogueStatusRow(statusMessage) } - - hintButton - } - } - .padding(.horizontal, sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base) - .padding(.vertical, RA11ySpacing.lg) - .frame(width: geo.size.width) - .frame(maxWidth: sizeClass == .regular ? 600 : .infinity) - } - .clipped() - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .environment(\.colorScheme, .dark) - } - - private var timeoutBanner: some View { - VStack(spacing: RA11ySpacing.md) { - Text(String(localized: "rogue.timeout")) - .font(.ra11yHeadline) - .multilineTextAlignment(.center) - Button(action: onRetry) { - Text(String(localized: "level.button.retry")) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .tint(Color.ra11yAccent) - .accessibilityIdentifier("rogue.l3.retry") - } - .padding(RA11ySpacing.base) - } - - private var hintButton: some View { - Button(action: onHint) { - Label(String(localized: "rogue.hint.button"), systemImage: "ear.fill") - } - .buttonStyle(.bordered) - .accessibilityLabel(String(localized: "rogue.hint.a11yLabel")) - .accessibilityHint(String(localized: "rogue.hint.a11yHint")) - } -} - -// MARK: - Shared: SealGrid - -/// Responsive grid of seal buttons: 2-column by default, collapses to 1-column at -/// `.accessibility2`+ Dynamic Type sizes to prevent horizontal crowding. -/// -/// VoiceOver reads LazyVGrid in row-major order: left→right within each row, -/// then the next row — deterministic and linear, matching the accessibility teaching goal. -private struct SealGrid: View { - - let seals: [RogueSeal] - let columns: Int - let onActivate: (RogueSeal) async -> Void - - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - - private var isLargeAccessibilitySize: Bool { dynamicTypeSize >= .accessibility2 } - - private var activeColumns: Int { isLargeAccessibilitySize ? 1 : columns } - - private var gridColumns: [GridItem] { - Array(repeating: GridItem(.flexible(), spacing: RA11ySpacing.sm), count: activeColumns) - } - - var body: some View { - LazyVGrid(columns: gridColumns, spacing: RA11ySpacing.sm) { - ForEach(seals) { seal in - SealButton(seal: seal, onActivate: onActivate) - } - } - } -} - -// MARK: - Shared: SealButton - -/// Single seal button used across L1–L3 (grid cells in L2/L3; standalone large button in L1 -/// uses a bespoke layout — see `RogueFirstAttemptView.largeSealButton`). -/// -/// Each button announces its name and hint to VoiceOver, fulfilling the accessibility requirement -/// "all seals: accessibilityLabel (seal name) + accessibilityHint (Double-tap to sever)." -/// -/// ## Dynamic Type -/// The seal name label has no `lineLimit` so it can expand to as many lines as needed at -/// large DT sizes. `SealGrid` collapses to 1 column at `.accessibility2`+ which provides -/// sufficient width for any reasonable label. -private struct SealButton: View { - - let seal: RogueSeal - let onActivate: (RogueSeal) async -> Void - - var body: some View { - Button { - Task { await onActivate(seal) } - } label: { - VStack(spacing: RA11ySpacing.xs) { - SealImage(assetName: seal.assetName) - .aspectRatio(1, contentMode: .fit) - .accessibilityHidden(true) - - Text(seal.displayName) - .font(.ra11yCaption) - .multilineTextAlignment(.center) - .fixedSize(horizontal: false, vertical: true) - .frame(maxWidth: .infinity) - } - .padding(RA11ySpacing.sm) - .background(Color.black.opacity(0.35), in: .rect(cornerRadius: RA11yRadius.card)) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.card) - .strokeBorder(.white.opacity(0.12), lineWidth: 1) - ) - } - .accessibilityLabel(seal.displayName) - .accessibilityHint(String(localized: "rogue.seal.hint")) - .accessibilityIdentifier("rogue.seal.\(seal.id)") - } -} - -// MARK: - Shared: SealImage - -/// Circular medallion image for a seal sprite, with a fallback SF Symbol. -/// -/// Seal sprites ship with alpha; they composite on the dark circle without multiply. -private struct SealImage: View { - let assetName: String - - var body: some View { - ZStack { - Circle() - .fill(Color(white: 0.12)) - - if let image = UIImage(named: assetName) { - Image(uiImage: image) - .resizable() - .scaledToFit() - .padding(8) - } else { - Image(systemName: "seal.fill") - .resizable() - .scaledToFit() - .padding(16) - .foregroundStyle(.gray) - } - } - .clipShape(Circle()) - } -} - -// MARK: - Shared: RogueTimerHUD - -/// Combined timer progress bar + time + mistake count HUD used in L2 and L3. -/// -/// The HUD is `accessibilityHidden` — the containing level view sets a combined -/// `accessibilityLabel` on the whole HUD group so VoiceOver reads time + mistakes together. -/// The bar height decreases at 50% and 25% remaining to provide a non-color urgency cue. -/// -/// ## Dynamic Type -/// Bar height scales with `.subheadline` via `@ScaledMetric`. -/// At `.accessibility4`+ the label row switches to `VStack` so the timer and mistake -/// count don't crowd each other in the ~200 pt available width. -private struct RogueTimerHUD: View { - - let timeRemaining: Double - let total: Double - let mistakes: Int - - @Environment(\.dynamicTypeSize) private var dynamicTypeSize - - private var fraction: Double { max(0, min(1, timeRemaining / total)) } - - private var barHeightMultiplier: Double { - if fraction > 0.50 { return 1.0 } - if fraction > 0.25 { return 0.75 } - return 0.50 - } - - private var barColor: Color { - if fraction > 0.50 { return .green } - if fraction > 0.25 { return .yellow } - return .red - } - - /// Scales with `.subheadline` style (base 12 pt). - @ScaledMetric(relativeTo: .subheadline) private var baseBarHeight: CGFloat = 12 - - var body: some View { - VStack(alignment: .leading, spacing: RA11ySpacing.xs) { - hudLabels - - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 4) - .fill(.white.opacity(0.1)) - .frame(height: baseBarHeight) - RoundedRectangle(cornerRadius: 4) - .fill(barColor) - .frame( - width: geo.size.width * fraction, - height: baseBarHeight * barHeightMultiplier - ) - .animation(.linear(duration: 0.2), value: fraction) - } - } - .frame(height: baseBarHeight) - } - .accessibilityHidden(true) - } - - /// Timer + mistakes labels. Switches to `VStack` at `.accessibility4`+ to prevent - /// horizontal crowding at large Dynamic Type sizes. - @ViewBuilder - private var hudLabels: some View { - if dynamicTypeSize >= .accessibility4 { - VStack(alignment: .leading, spacing: RA11ySpacing.xs) { - timerLabel - mistakesLabel - } - } else { - HStack { - timerLabel - Spacer() - mistakesLabel - } - } - } - - private var timerLabel: some View { - Label( - String(format: String(localized: "hud.timer.format"), Int(ceil(timeRemaining))), - systemImage: "clock" - ) - .font(.ra11ySubheadline) - .foregroundStyle(.primary) - } - - private var mistakesLabel: some View { - Text(String(format: String(localized: "rogue.hud.mistakes.format"), mistakes)) - .font(.ra11ySubheadline) - .foregroundStyle(.secondary) - } -} - -// MARK: - Shared: RogueBackgroundView - -/// Full-bleed background for all Rogue's Gauntlet levels. -/// -/// Uses `rogue_trap_door_bg` with a dark overlay for legibility. Falls back to a solid -/// dark stone color if the asset is unavailable. -private struct RogueBackgroundView: View { - var body: some View { - if let image = UIImage(named: "rogue_trap_door_bg") { - Image(uiImage: image) - .resizable() - .scaledToFill() - .overlay(Color.black.opacity(0.45)) - } else { - Color.ra11yGameFallbackBackground - } - } -} - -// MARK: - Shared: Primitive Subviews - -/// Small gesture guide row used in L0 prologue. -private struct RogueGestureRow: View { - let symbol: String - let label: String - - var body: some View { - HStack(spacing: RA11ySpacing.md) { - Image(systemName: symbol) - .font(.ra11yHeadline) - .frame(minWidth: 32, alignment: .leading) - .foregroundStyle(Color.ra11yCardTertiaryText) - Text(label) - .font(.ra11yBody) - .foregroundStyle(Color.ra11yCardSecondaryText) - } - .frame(maxWidth: .infinity, alignment: .leading) - } -} - -/// Prompt card shown at the top of L1–L3. Displays the DM objective + accessibility metadata. -private func roguePromptCard(title: String, a11yLabel: String, a11yHint: String?) -> some View { - VStack(alignment: .leading, spacing: RA11ySpacing.xs) { - Label(String(localized: "dm.label"), systemImage: "scroll.fill") - .font(.ra11yCaption) - .foregroundStyle(.secondary) - .accessibilityHidden(true) - - Text(title) - .font(.ra11yHeadline) - .bold() - } - .padding(RA11ySpacing.base) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.black.opacity(0.66), in: .rect(cornerRadius: RA11yRadius.card)) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.card) - .strokeBorder(Color.ra11yDMBorder.opacity(0.5), lineWidth: 1) - ) - .accessibilityElement(children: .combine) - .accessibilityLabel(a11yLabel) - .modify { view in - if let hint = a11yHint { - view.accessibilityHint(hint) - } else { - view - } - } -} - -/// Status feedback row shown after a seal activation event. -private func rogueStatusRow(_ message: String) -> some View { - Text(message) - .font(.ra11ySubheadline) - .foregroundStyle(Color.ra11yCardSecondaryText) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, RA11ySpacing.xs) -} - -// MARK: - View+Modify Helper - -private extension View { - /// Applies a transform closure, enabling conditional modifier application without AnyView erasure. - @ViewBuilder - func modify(@ViewBuilder transform: (Self) -> T) -> some View { - transform(self) - } -} - -// MARK: - Preview - -#Preview("L0 Prologue") { - NavigationStack { - iOSRogueGauntletView(storage: UserDefaultsStorageComponent()) - .environment(iOSAppRouter()) - } -} diff --git a/RA11y-iOS/RA11y-iOS/Hub/iOSHubView.swift b/RA11y-iOS/RA11y-iOS/Hub/iOSHubView.swift index eb72389..4f1f045 100644 --- a/RA11y-iOS/RA11y-iOS/Hub/iOSHubView.swift +++ b/RA11y-iOS/RA11y-iOS/Hub/iOSHubView.swift @@ -6,7 +6,7 @@ import RA11yCore /// The game hub — the player's home base and quest board. /// -/// Implemented per `TICKET-M3-Hub-UI-Progress`. Renders three training games as +/// Implemented per `TICKET-M3-Hub-UI-Progress`. Renders catalog training games as /// D&D-themed quest cards. Quest starts call ``iOSAppRouter/pushGame(kind:provider:)`` so /// games are never entered without VoiceOver. Also provides help affordance and /// reflects best results from storage without requiring an app relaunch. @@ -36,7 +36,7 @@ import RA11yCore /// 1. Navigation title — visible text "RA11y Quest"; VoiceOver label "Rally Quest" /// 2. Orientation strip — scroll gesture; with VoiceOver on, also tap / double-tap to open /// 3. "Choose Your Trial, Adventurer" (.isHeader) -/// 4–6. Quest cards (combined label per card) +/// 4+. Quest cards (combined label per card) /// 7. "VoiceOver Basics" /// 8. "Enable VoiceOver to play" (only if VO OFF) /// @@ -47,6 +47,20 @@ import RA11yCore /// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. struct iOSHubView: View { + // MARK: - DEBUG audit + + /// Unlocks every quest card for local QA without prerequisite clears. + /// + /// Enable: Xcode → Product → Scheme → Edit Scheme → Run → Arguments → add + /// `-unlockAllQuestsForAudit`. **DEBUG builds only** — release always behaves as `false`. + private static var unlockAllQuestsForAudit: Bool { + #if DEBUG + ProcessInfo.processInfo.arguments.contains("-unlockAllQuestsForAudit") + #else + false + #endif + } + // MARK: - State @State private var viewModel: HubViewModel @@ -139,7 +153,12 @@ struct iOSHubView: View { shouldRefreshOnAppear: Bool = true ) { self.shouldRefreshOnAppear = shouldRefreshOnAppear - _viewModel = State(initialValue: HubViewModel(storage: storage)) + _viewModel = State( + initialValue: HubViewModel( + storage: storage, + unlockAllQuestsForAudit: Self.unlockAllQuestsForAudit + ) + ) } // MARK: - Body diff --git a/RA11y-iOS/RA11y-iOS/Localizable.xcstrings b/RA11y-iOS/RA11y-iOS/Localizable.xcstrings index 977b48e..be98b0f 100644 --- a/RA11y-iOS/RA11y-iOS/Localizable.xcstrings +++ b/RA11y-iOS/RA11y-iOS/Localizable.xcstrings @@ -212,6 +212,356 @@ "comment" : "A label for the segmented control in the dungeon resonance mockup view.", "isCommentAutoGenerated" : true }, + "banishment.a11y.secondsLeft" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld seconds remaining" + } + } + } + }, + "banishment.a11y.threatArrived" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ approaches." + } + } + } + }, + "banishment.a11y.trapCombined" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Banishment trap: %@." + } + } + } + }, + "banishment.feedback.banished" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Banished." + } + } + } + }, + "banishment.feedback.enterDark" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The tower falls silent. Something follows in the dark." + } + } + } + }, + "banishment.feedback.wrong.decoy" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "That seal was a decoy. Cast the scrub, not a tap." + } + } + } + }, + "banishment.feedback.wrong.ward" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The creature laughs. Only the two-finger Z scrub banishes it." + } + } + } + }, + "banishment.hud.timeRemaining" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time remaining" + } + } + } + }, + "banishment.nav.dark" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dark Tower" + } + } + } + }, + "banishment.nav.prologue" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Banishment" + } + } + } + }, + "banishment.nav.tower" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lich's Tower" + } + } + } + }, + "banishment.nav.ward" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Ward" + } + } + } + }, + "banishment.prologue.begin" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enter the Ward" + } + } + } + }, + "banishment.prologue.body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VoiceOver’s escape gesture is a two-finger Z scribble. It closes sheets, alerts, and other traps when focus feels stuck. Practice on a calm ward first, then run the timed tower." + } + } + } + }, + "banishment.prologue.kicker" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escape gesture" + } + } + } + }, + "banishment.prologue.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Mark of Z" + } + } + } + }, + "banishment.results.defeated" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The darkness wins this round. Try again." + } + } + } + }, + "banishment.results.legendary" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The dungeon falls silent. Every trap broke." + } + } + } + }, + "banishment.results.novice" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You survived. Practice the scrub until it is instinct." + } + } + } + }, + "banishment.results.skilled" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "They fled. The ward holds." + } + } + } + }, + "banishment.threat.dark.spoken" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Night shade" + } + } + } + }, + "banishment.threat.golem.spoken" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stone golem" + } + } + } + }, + "banishment.threat.rat.spoken" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shadow rat" + } + } + } + }, + "banishment.threat.ward.spoken" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shadow rat" + } + } + } + }, + "banishment.threat.wisp.spoken" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Venomous wisp" + } + } + } + }, + "banishment.timeout" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time is out. The tower claims you." + } + } + } + }, + "banishment.trap.creatureFormat" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + } + } + }, + "banishment.trap.decoy.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap to try a decoy seal. It will not banish the trap." + } + } + } + }, + "banishment.trap.decoyButton" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try a false seal" + } + } + } + }, + "banishment.trap.escape.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Draw the VoiceOver two-finger Z scrub to banish this trap." + } + } + } + }, + "banishment.trap.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Draw two fingers in a Z shape—the VoiceOver escape gesture—to banish it." + } + } + } + }, + "banishment.trap.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trapped" + } + } + } + }, + "banishment.ward.cleared" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The ward is clear." + } + } + } + }, + "banishment.ward.continueGauntlet" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue to the tower" + } + } + } + }, + "banishment.ward.intermission.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The first trap is gone." + } + } + } + }, "basicsSequence.beginGame" : { "localizations" : { "en" : { @@ -329,6 +679,7 @@ } }, "basicsSequence.skill.activateDoubleTap.intro" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -339,6 +690,7 @@ } }, "basicsSequence.skill.activateDoubleTap.title" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -348,6 +700,26 @@ } } }, + "basicsSequence.skill.banishment.intro" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You will practice the two-finger Z scrub—the same escape gesture that closes sheets and alerts when VoiceOver feels stuck." + } + } + } + }, + "basicsSequence.skill.banishment.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Escape with the scrub" + } + } + } + }, "basicsSequence.skill.findAndFocus.intro" : { "localizations" : { "en" : { @@ -373,7 +745,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Use three fingers to scroll content that extends beyond the visible area—this is how you descend the crystal shaft in Crystal Resonance." + "value" : "Use three fingers to scroll content that extends beyond the visible area—the same gesture moves the Moonstone alignment lane in Crystal Resonance." } } } @@ -440,12 +812,23 @@ } } }, + "dungeon.a11y.continue.nextAscent.hint" : { + "comment" : "VoiceOver hint for the Next control shown when a Crystal Resonance sub-level is complete.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Advances to the next level when this trial is finished." + } + } + } + }, "dungeon.a11y.explain.lesson" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "VoiceOver Lesson. With VoiceOver on, swiping moves between items, not the page. To scroll the page down, use three fingers and swipe up. Three fingers swipe down to scroll back up." + "value" : "VoiceOver lesson. With VoiceOver on, one-finger swipes move focus between items—they do not move the Moonstone alignment lane. To scroll the lane, use three fingers: swipe up or down until the Moonstone meets the center orb." } } } @@ -461,13 +844,13 @@ } }, "dungeon.a11y.hud.depth" : { - "comment" : "Accessibility label for the depth HUD. Arguments are current visible depth and total rooms.", + "comment" : "Accessibility label for the lane position HUD. Arguments are current index and total glyphs.", "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Depth: %d of %d." + "value" : "Glyph position: %d of %d." } } } @@ -491,7 +874,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "%d misstep(s). The gate descends." + "value" : "%d misstep(s). Time is running out—finish the alignment." } } } @@ -501,18 +884,18 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Objective: align the Guard Room Moonstone with the orb. Use the Moonstone alignment lane and three-finger scroll until you feel the lock." + "value" : "Objective: align the Moonstone with the center orb. Keep VoiceOver on the Moonstone alignment lane and three-finger scroll until the lock cue." } } } }, "dungeon.a11y.l1.objective.format" : { - "comment" : "VoiceOver objective. %@ is the target room display name.", + "comment" : "VoiceOver objective for L1 resonance play; alignment metaphor only.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Objective: Align the %@ with the orb. VoiceOver is focused on the Moonstone alignment lane. Use three fingers to scroll up or down until you feel the lock." + "value" : "Objective: Align the Moonstone with the center orb. VoiceOver is on the Moonstone alignment lane. Three-finger scroll up or down until the lock cue." } } } @@ -532,18 +915,18 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Objective: align the Relic Vault Moonstone with the orb. Keep VoiceOver on the Moonstone alignment lane and three-finger scroll to align." + "value" : "Objective: align the Moonstone with the center orb before time runs out. Keep VoiceOver on the Moonstone alignment lane and three-finger scroll." } } } }, "dungeon.a11y.l2.objective.format" : { - "comment" : "VoiceOver objective. %@ is the target room display name.", + "comment" : "VoiceOver objective for L2 resonance play.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Objective: Align the %@ with the orb. Keep VoiceOver on Moonstone alignment lane and three-finger scroll to align." + "value" : "Objective: Align the Moonstone with the center orb. Timer is running. Keep VoiceOver on the Moonstone alignment lane; three-finger scroll to align." } } } @@ -555,7 +938,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Time remaining. %d seconds. Descend now." + "value" : "Time remaining. %d seconds. Align while you can." } } } @@ -566,18 +949,18 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Objective: Descend to the Ancient Vault. Act quickly." + "value" : "Objective: align the Moonstone with the center orb. Act quickly—listen to the timer." } } } }, "dungeon.a11y.l3.objective.format" : { - "comment" : "VoiceOver objective. %@ is the target room display name.", + "comment" : "VoiceOver objective for L3 resonance play.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Objective: Align the %@ with the orb. Act quickly. Keep VoiceOver on Moonstone alignment lane and three-finger scroll." + "value" : "Objective: Align the Moonstone with the center orb. Act quickly. Keep VoiceOver on the Moonstone alignment lane; three-finger scroll to align." } } } @@ -588,7 +971,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Time remaining. %d seconds. The gate descends." + "value" : "Time remaining. %d seconds. Finish the alignment." } } } @@ -608,13 +991,14 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "This is the invisible scroll layer behind the lane—not the objective row. Three-finger swipes scroll only while VoiceOver focus is on Moonstone alignment lane; if you hear the objective or timer, swipe until you hear this name, then scroll. Align the Moonstone with the orb at the center of the screen." + "value" : "Invisible scroll layer behind the Moonstone lane. Three-finger swipes move the lane only when VoiceOver is focused here. If you hear the objective or timer instead, swipe until you hear Moonstone alignment lane, then scroll. Align the Moonstone with the center orb." } } } }, "dungeon.a11y.scroll.vo.focusAnnouncement" : { "comment" : "VoiceOver announcement after screen-changed and backup focus on the Moonstone alignment lane ScrollView.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -624,17 +1008,6 @@ } } }, - "dungeon.a11y.continue.nextAscent.hint" : { - "comment" : "VoiceOver hint for the Next control shown when a Crystal Resonance sub-level is complete.", - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Advances to the next ascent when this chamber trial is finished." - } - } - } - }, "dungeon.a11y.timer.25pct" : { "localizations" : { "en" : { @@ -650,7 +1023,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Half your time. The torches begin to die." + "value" : "Half your time is gone. Align the Moonstone while you can." } } } @@ -660,7 +1033,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Three-quarters of your time is gone. Keep descending." + "value" : "Three-quarters of your time is gone. Keep scrolling the alignment lane." } } } @@ -670,7 +1043,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "One-finger swipe — move between chamber markers (navigate)" + "value" : "One-finger swipe — move VoiceOver focus between items (navigate)" } } } @@ -700,7 +1073,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "With VoiceOver on, swiping with one finger moves focus between items — it does NOT scroll the page. To scroll down and reveal deeper content, use three fingers and swipe up. To scroll back up, use three fingers and swipe down." + "value" : "With VoiceOver on, one-finger swipes move focus between items—they do not scroll the Moonstone alignment lane. To move the lane, use three fingers: swipe up to shift the glyphs one way, swipe down to shift them the other, until the Moonstone meets the center orb." } } } @@ -720,7 +1093,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "The relic sings from deep in the crystal shaft—below what glance alone can see. You must descend. Here, one finger navigates; it does not scroll. To go deeper, use three fingers and listen for the resonance." + "value" : "The crystal shaft hums with echoes—glyphs that almost fit, but only the Moonstone locks to the orb. One finger moves VoiceOver focus; it does not move the lane. Use three fingers on the Moonstone alignment lane and listen for the resonance as the Moonstone nears the center." } } } @@ -741,7 +1114,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Step %lld: keep descending." + "value" : "Step %lld: keep three-finger scrolling the practice lane." } } } @@ -751,7 +1124,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Begins your first descent. No timer." + "value" : "Begins your first Crystal Resonance trial. No timer." } } } @@ -771,12 +1144,13 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Nothing useful here. Keep descending." + "value" : "That glyph is not the Moonstone. Scroll the alignment lane until the Moonstone meets the center orb." } } } }, "dungeon.hint.a11yHint" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -787,6 +1161,7 @@ } }, "dungeon.hint.a11yLabel" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -797,6 +1172,7 @@ } }, "dungeon.hint.button" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -807,23 +1183,24 @@ } }, "dungeon.hint.format" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The Guide whispers: use three fingers to scroll downward and reveal %@." + "value" : "The Guide whispers: three-finger scroll the Moonstone alignment lane until the Moonstone lines up with the center orb." } } } }, "dungeon.hud.depth.format" : { - "comment" : "Depth HUD label. Arguments: current depth index (1-based), total rooms.", + "comment" : "Lane position HUD label (legacy). Arguments: current index (1-based), total glyphs.", "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Depth: %d of %d" + "value" : "Glyph position: %d of %d" } } } @@ -844,7 +1221,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "First descent complete. The crystal yields its first secret." + "value" : "First resonance sealed. The crystal yields its first secret." } } } @@ -855,7 +1232,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "The shaft has two rings of chambers. Descend to the second marker." + "value" : "The shaft fills with echo glyphs—only the Moonstone answers the orb. Scroll the alignment lane until they lock." } } } @@ -877,18 +1254,18 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Descend to: The Guard Room" + "value" : "Align the Moonstone with the center orb." } } } }, "dungeon.l1.objective.format" : { - "comment" : "Visible objective line. %@ is the target room display name.", + "comment" : "Visible objective line for L1: alignment only.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Descend to: %@" + "value" : "Align the Moonstone with the center orb." } } } @@ -910,7 +1287,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Relic Vault reached. The vault glows faintly." + "value" : "Resonance holds. The vault stirs—the Moonstone found its lock." } } } @@ -921,7 +1298,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "The crystal deepens. Eight chambers. The Relic Vault waits at the bottom. Some markers will call to you—ignore them. Descend." + "value" : "More echoes crowd the shaft—tempting shapes that are not the Moonstone. Ignore them; align the Moonstone with the center orb before time runs out." } } } @@ -932,18 +1309,18 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Descend to: The Relic Vault" + "value" : "Align the Moonstone with the center orb before time runs out." } } } }, "dungeon.l2.objective.format" : { - "comment" : "Visible objective line. %@ is the target room display name.", + "comment" : "Visible objective line for L2: alignment with timer pressure.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Descend to: %@" + "value" : "Align the Moonstone with the center orb before time runs out." } } } @@ -964,7 +1341,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "The shaft seals at midnight. Twelve chambers deep. The Ancient Vault holds the relic. Descend and claim it before the gate falls." + "value" : "The crystal tightens—twelve positions, one true resonance. Find the Moonstone, align it with the orb, and seal before the gate falls." } } } @@ -975,18 +1352,18 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Descend to: The Ancient Vault" + "value" : "Align the Moonstone with the center orb before the timer ends." } } } }, "dungeon.l3.objective.format" : { - "comment" : "Visible objective line. %@ is the target room display name.", + "comment" : "Visible objective line for L3: timed alignment.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Descend to: %@" + "value" : "Align the Moonstone with the center orb before the timer ends." } } } @@ -996,7 +1373,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Ancient Vault reached. The relic is yours. Trial passed." + "value" : "Ancient vault opens—the relic answers your resonance. Trial passed." } } } @@ -1017,7 +1394,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Your potion had a side effect—your vision begins to dim. Listen for the room you need and scroll with care." + "value" : "Your potion had a side effect—your vision begins to dim. Trust the Moonstone alignment lane: listen, feel the lock cues, and three-finger scroll with care." } } } @@ -1050,7 +1427,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Crystal orb, aligned, ready to confirm" + "value" : "Crystal orb, aligned, ready to seal resonance" } } } @@ -1085,13 +1462,90 @@ } } }, + "dungeon.resonance.hint" : { + "comment" : "Hint button copy for Crystal Resonance: alignment task, no room names.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Scroll the Moonstone alignment lane until the Moonstone lines up with the center orb, then Activate the Seal." + } + } + } + }, + "dungeon.resonance.item.emberShard" : { + "comment" : "VoiceOver / scroll status name for the ember decoy glyph in Crystal Resonance.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ember Shard" + } + } + } + }, + "dungeon.resonance.item.moonstone" : { + "comment" : "VoiceOver / scroll status name for the Moonstone target in Crystal Resonance.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moonstone" + } + } + } + }, + "dungeon.resonance.item.shadowGlyph" : { + "comment" : "VoiceOver / scroll status name for the shadow-glyph decoy in Crystal Resonance.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Shadow Glyph" + } + } + } + }, + "dungeon.resonance.item.sunSigil" : { + "comment" : "VoiceOver / scroll status name for the sun-sigil decoy in Crystal Resonance.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sun Sigil" + } + } + } + }, + "dungeon.resonance.mockup.laneSectionAbove" : { + "comment" : "Developer mockup caption above the sample Moonstone row.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Above the Moonstone (preview)" + } + } + } + }, + "dungeon.resonance.mockup.laneSectionBelow" : { + "comment" : "Developer mockup caption below the sample Moonstone row.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Below the Moonstone (preview)" + } + } + } + }, "dungeon.resonance.seal" : { "comment" : "Primary action when the moonstone is aligned with the orb.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Seal resonance" + "value" : "Activate the Seal" } } } @@ -1101,7 +1555,29 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Completes this chamber when the moonstone is aligned." + "value" : "Seals the resonance when the Moonstone matches the center orb." + } + } + } + }, + "dungeon.resonance.continue.toRising" : { + "comment" : "L1 complete — primary button advances to Rising Challenge.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue to Rising Challenge" + } + } + } + }, + "dungeon.resonance.continue.toFinalTrial" : { + "comment" : "L2 complete — primary button advances to timed final trial.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue to Final Trial" } } } @@ -1112,7 +1588,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "If nothing moves: you are not on the scroll lane. Move VoiceOver until it reads Moonstone alignment lane—the invisible scroll area over the chamber lane—then try three fingers again." + "value" : "If nothing moves, VoiceOver is not on the scroll lane. Swipe until you hear “Moonstone alignment lane,” then scroll with three fingers." } } } @@ -1134,7 +1610,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Legendary resonance. Flawless descent. The crystal answers you." + "value" : "Legendary resonance. Flawless alignment. The crystal answers you." } } } @@ -1145,7 +1621,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "You reached the vault. The crystal tested you—and you endured." + "value" : "You sealed the resonance. The crystal tested you—and you endured." } } } @@ -1563,6 +2039,28 @@ } } }, + "game.banishment.goal" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Third quest: when a trap blocks the way, draw the two-finger Z scrub to banish it—then survive the timed gauntlet in the dark." + } + } + } + }, + "game.banishment.title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Banishment" + } + } + } + }, "game.findAndFocus.goal" : { "extractionState" : "stale", "localizations" : { @@ -1591,7 +2089,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "Second quest: scroll with three fingers through the crystal shaft until you can claim the relic." + "value" : "Second quest: three-finger scroll the Moonstone alignment lane until the Moonstone locks to the center orb—then seal resonance." } } } @@ -1910,6 +2408,7 @@ } }, "result.skillTransfer.activateDoubleTap.body" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1920,6 +2419,7 @@ } }, "result.skillTransfer.activateDoubleTap.realWorld" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -1929,6 +2429,26 @@ } } }, + "result.skillTransfer.banishment.body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You used VoiceOver’s two-finger scrub (Z-shape) to dismiss traps—the system escape that clears modals, sheets, and many stuck states." + } + } + } + }, + "result.skillTransfer.banishment.realWorld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Any time VoiceOver lands inside an alert, pop-up, or full-screen sheet, try the scrub before hunting for a visible close button." + } + } + } + }, "result.skillTransfer.findAndFocus.body" : { "localizations" : { "en" : { @@ -1964,7 +2484,7 @@ "en" : { "stringUnit" : { "state" : "translated", - "value" : "In Crystal Resonance you practiced the rule: with VoiceOver on, one-finger swipes move between elements—they do not scroll the page. To scroll, use three fingers and swipe up (scroll down) or down (scroll up)." + "value" : "In Crystal Resonance you practiced: with VoiceOver on, one-finger swipes move focus—they do not move the Moonstone alignment lane. Use three fingers to scroll the lane until the Moonstone meets the center orb, then seal resonance." } } } @@ -1980,6 +2500,7 @@ } }, "rogue.a11y.explain.lesson" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2015,6 +2536,7 @@ }, "rogue.a11y.l1.objective" : { "comment" : "Full accessibility label for the L1 objective card. Argument is the seal display name.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2025,6 +2547,7 @@ } }, "rogue.a11y.l1.objective.hint" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2036,6 +2559,7 @@ }, "rogue.a11y.l2.objective" : { "comment" : "Full accessibility label for the L2 objective card. Argument is the seal display name.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2046,6 +2570,7 @@ } }, "rogue.a11y.l2.objective.hint" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2057,6 +2582,7 @@ }, "rogue.a11y.l2.timer" : { "comment" : "Accessibility label for the L2 timer HUD group. Argument is seconds remaining (integer).", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2068,6 +2594,7 @@ }, "rogue.a11y.l3.objective" : { "comment" : "Full accessibility label for the L3 objective card. Argument is the seal display name.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2079,6 +2606,7 @@ }, "rogue.a11y.l3.timer" : { "comment" : "Accessibility label for the L3 timer HUD group. Argument is seconds remaining (integer).", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2089,6 +2617,7 @@ } }, "rogue.a11y.timer.25pct" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2099,6 +2628,7 @@ } }, "rogue.a11y.timer.50pct" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2109,6 +2639,7 @@ } }, "rogue.a11y.timer.75pct" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2119,6 +2650,7 @@ } }, "rogue.explain.gesture.tap1" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2129,6 +2661,7 @@ } }, "rogue.explain.gesture.tap2" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2139,6 +2672,7 @@ } }, "rogue.explain.gesture.wrong" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2149,6 +2683,7 @@ } }, "rogue.explain.lesson.body" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2159,6 +2694,7 @@ } }, "rogue.explain.lesson.heading" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2169,6 +2705,7 @@ } }, "rogue.explain.narration" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2179,6 +2716,7 @@ } }, "rogue.explain.start.hint" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2189,6 +2727,7 @@ } }, "rogue.explain.title" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2199,6 +2738,7 @@ } }, "rogue.feedback.correct" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2209,6 +2749,7 @@ } }, "rogue.feedback.trap" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2219,6 +2760,7 @@ } }, "rogue.hint.a11yHint" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2229,6 +2771,7 @@ } }, "rogue.hint.a11yLabel" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2239,6 +2782,7 @@ } }, "rogue.hint.button" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2250,6 +2794,7 @@ }, "rogue.hint.format" : { "comment" : "VoiceOver hint announcement. Argument is the correct seal display name.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2261,6 +2806,7 @@ }, "rogue.hud.mistakes.format" : { "comment" : "Mistake count label. Argument is integer mistake count.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2271,6 +2817,7 @@ } }, "rogue.l1.complete" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2281,6 +2828,7 @@ } }, "rogue.l1.dm_prompt" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2292,6 +2840,7 @@ }, "rogue.l1.objective.format" : { "comment" : "Displayed objective on L1 prompt card. Argument is the seal display name.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2302,6 +2851,7 @@ } }, "rogue.l1.title" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2312,6 +2862,7 @@ } }, "rogue.l2.complete" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2334,6 +2885,7 @@ }, "rogue.l2.objective.format" : { "comment" : "Displayed objective on L2 prompt card. Argument is the seal display name.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2344,6 +2896,7 @@ } }, "rogue.l2.title" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2366,6 +2919,7 @@ }, "rogue.l3.objective.format" : { "comment" : "Displayed objective on L3 prompt card. Argument is the seal display name.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2376,6 +2930,7 @@ } }, "rogue.l3.success" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2386,6 +2941,7 @@ } }, "rogue.l3.title" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2397,6 +2953,7 @@ }, "rogue.lightsOff.flavor" : { "comment" : "Atmospheric banner for the final timed Rogue level when seal artwork is blacked out.", + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2407,6 +2964,7 @@ } }, "rogue.results.defeated" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2417,6 +2975,7 @@ } }, "rogue.results.legendary" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2427,6 +2986,7 @@ } }, "rogue.results.novice" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2437,6 +2997,7 @@ } }, "rogue.results.skilled" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2491,6 +3052,7 @@ } }, "rogue.seal.hint" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -2556,6 +3118,7 @@ } }, "rogue.timeout" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { diff --git a/RA11y-iOS/RA11y-iOS/RA11y_iOSApp.swift b/RA11y-iOS/RA11y-iOS/RA11y_iOSApp.swift index 1261e58..e72ca68 100644 --- a/RA11y-iOS/RA11y-iOS/RA11y_iOSApp.swift +++ b/RA11y-iOS/RA11y-iOS/RA11y_iOSApp.swift @@ -105,7 +105,7 @@ struct RA11y_iOSApp: App { // `@State router` — which has the game destination pre-populated in its path // via its initializer closure — is unblocked quickly. // Any new game-direct arg should be listed here alongside -screenshotMarkOnboardingComplete. - let directGameArgs = ["-screenshotDirectToEnchanter", "-screenshotDirectToRogue", "-screenshotDirectToDungeon"] + let directGameArgs = ["-screenshotDirectToEnchanter", "-screenshotDirectToDungeon"] if directGameArgs.contains(where: { args.contains($0) }) { UserDefaults.standard.set( true, diff --git a/RA11y-iOS/RA11y-iOS/Results/iOSGameResultView.swift b/RA11y-iOS/RA11y-iOS/Results/iOSGameResultView.swift index 0798fbd..9464586 100644 --- a/RA11y-iOS/RA11y-iOS/Results/iOSGameResultView.swift +++ b/RA11y-iOS/RA11y-iOS/Results/iOSGameResultView.swift @@ -3,7 +3,7 @@ import RA11yCore // MARK: - iOSGameResultView -/// Shared result screen displayed by all three training games on completion. +/// Shared result screen displayed by training games on completion. /// /// Accepts a `GameResultPresenter`, the `GameKind` (used to render the skill-transfer /// card), an optional game-specific announcement string, and two action closures. @@ -146,10 +146,10 @@ struct iOSGameResultView: View { switch gameKind { case .findAndFocus: return String(localized: "result.skillTransfer.findAndFocus.body") - case .activateDoubleTap: - return String(localized: "result.skillTransfer.activateDoubleTap.body") case .scrollHunt: return String(localized: "result.skillTransfer.scrollHunt.body") + case .banishment: + return String(localized: "result.skillTransfer.banishment.body") } } @@ -157,10 +157,10 @@ struct iOSGameResultView: View { switch gameKind { case .findAndFocus: return String(localized: "result.skillTransfer.findAndFocus.realWorld") - case .activateDoubleTap: - return String(localized: "result.skillTransfer.activateDoubleTap.realWorld") case .scrollHunt: return String(localized: "result.skillTransfer.scrollHunt.realWorld") + case .banishment: + return String(localized: "result.skillTransfer.banishment.realWorld") } } @@ -217,20 +217,6 @@ struct iOSGameResultView: View { } } -#Preview("Defeated — Rogue") { - NavigationStack { - iOSGameResultView( - presenter: GameResultPresenter( - result: GameResult(gameID: "activate-double-tap", rank: .failed, timeSeconds: 40, mistakes: 3) - ), - gameKind: .activateDoubleTap, - gameSpecificAnnouncement: "The trap room claimed you. Study the seals and return.", - onPlayAgain: {}, - onReturnToHub: {} - ) - } -} - #Preview("Skilled — Crystal Resonance") { NavigationStack { iOSGameResultView( diff --git a/RA11y-iOS/RA11y-iOS/VoiceOver/iOSVORequiredView.swift b/RA11y-iOS/RA11y-iOS/VoiceOver/iOSVORequiredView.swift index f9b057f..31702f7 100644 --- a/RA11y-iOS/RA11y-iOS/VoiceOver/iOSVORequiredView.swift +++ b/RA11y-iOS/RA11y-iOS/VoiceOver/iOSVORequiredView.swift @@ -210,7 +210,7 @@ struct iOSVORequiredView: View { #Preview("VO Required — Manual Fallback") { NavigationStack { - iOSVORequiredView(kind: .activateDoubleTap) + iOSVORequiredView(kind: .scrollHunt) .environment(iOSAppRouter()) } } diff --git a/RA11y-iOS/RA11y-iOSTests/RA11y_iOSTests.swift b/RA11y-iOS/RA11y-iOSTests/RA11y_iOSTests.swift index 12437a1..a111d0f 100644 --- a/RA11y-iOS/RA11y-iOSTests/RA11y_iOSTests.swift +++ b/RA11y-iOS/RA11y-iOSTests/RA11y_iOSTests.swift @@ -72,8 +72,8 @@ struct VoiceOverGatingTests { /// VO ON → proceed decision (game route, added in M5). @Test func voiceOverOnProceeds() { let stub = StubVoiceOverStateProvider(isVoiceOverRunning: true) - let decision = GameStartDecision.evaluate(kind: .activateDoubleTap, provider: stub) - #expect(decision == .proceed(kind: .activateDoubleTap)) + let decision = GameStartDecision.evaluate(kind: .scrollHunt, provider: stub) + #expect(decision == .proceed(kind: .scrollHunt)) } /// Interstitial route can be pushed onto the navigation stack. @@ -83,7 +83,7 @@ struct VoiceOverGatingTests { #expect(!router.path.isEmpty) } - /// Gating applies to all three MVP game kinds, not just one. + /// Gating applies to every MVP game kind, not just one. @Test func voiceOverOffGatesAllGameKinds() { let stub = StubVoiceOverStateProvider(isVoiceOverRunning: false) for kind in GameKind.allCases { @@ -129,6 +129,24 @@ struct FirstRunRoutingTests { } } +// MARK: - Crystal Resonance lane randomization + +/// Business rules for shuffled Moonstone lanes (``DungeonRoom/randomizedRoomsPreservingPool``). +@MainActor +struct DungeonRoomRandomizationTests { + + @Test func randomizedPoolPreservesCountAndExactlyOneTarget() { + let bases = [DungeonRoom.l1Rooms, DungeonRoom.l2Rooms, DungeonRoom.l3Rooms] + for base in bases { + let r = DungeonRoom.randomizedRoomsPreservingPool(base) + #expect(r.count == base.count) + #expect(r.filter(\.isTarget).count == 1) + let targetIDs = Set(r.filter(\.isTarget).map(\.id)) + #expect(targetIDs.count == 1) + } + } +} + // MARK: - Test Storage /// In-memory storage used by iOS routing tests. diff --git a/RA11y.xcworkspace/xcuserdata/josephmccraw.xcuserdatad/UserInterfaceState.xcuserstate b/RA11y.xcworkspace/xcuserdata/josephmccraw.xcuserdatad/UserInterfaceState.xcuserstate index a3ca3dc..8292210 100644 Binary files a/RA11y.xcworkspace/xcuserdata/josephmccraw.xcuserdatad/UserInterfaceState.xcuserstate and b/RA11y.xcworkspace/xcuserdata/josephmccraw.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/RA11yCore/Sources/RA11yCore/Design/RA11yFont.swift b/RA11yCore/Sources/RA11yCore/Design/RA11yFont.swift index 7722cd5..1d6176e 100644 --- a/RA11yCore/Sources/RA11yCore/Design/RA11yFont.swift +++ b/RA11yCore/Sources/RA11yCore/Design/RA11yFont.swift @@ -18,6 +18,9 @@ public extension Font { /// Major heading; section or page title. static let ra11yTitle: Font = .title2 + /// Emphasized line between headline and title (e.g. trap callouts). + static let ra11yTitle3: Font = .title3 + /// Sub-heading; card title or list header. static let ra11yHeadline: Font = .headline diff --git a/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackProfile.swift b/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackProfile.swift index de56b76..90868c7 100644 --- a/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackProfile.swift +++ b/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackProfile.swift @@ -30,6 +30,9 @@ public struct QuestFeedbackProfile: Equatable, Sendable { /// Cue used when the player requests help. public let hintCue: QuestFeedbackCue + /// Cue used when the player moves between items in a scroll alignment lane. + public let laneSlotCue: QuestFeedbackCue + /// Creates a reusable profile. public init( name: String, @@ -40,7 +43,8 @@ public struct QuestFeedbackProfile: Equatable, Sendable { wrongActivationCue: QuestFeedbackCue, successCue: QuestFeedbackCue, timeoutCue: QuestFeedbackCue, - hintCue: QuestFeedbackCue + hintCue: QuestFeedbackCue, + laneSlotCue: QuestFeedbackCue ) { self.name = name self.warmCue = warmCue @@ -51,11 +55,12 @@ public struct QuestFeedbackProfile: Equatable, Sendable { self.successCue = successCue self.timeoutCue = timeoutCue self.hintCue = hintCue + self.laneSlotCue = laneSlotCue } } public extension QuestFeedbackProfile { - /// Resonance-oriented cue mapping for Crystal Resonance (Scroll Hunt) v2. + /// Resonance-oriented cue mapping for Crystal Resonance (Moonstone alignment lane) v2. static let dungeonResonance = QuestFeedbackProfile( name: "dungeonResonance", warmCue: QuestFeedbackCue(audio: .resonance, haptic: .softTick, cooldownSeconds: 0.25), @@ -65,7 +70,8 @@ public extension QuestFeedbackProfile { wrongActivationCue: QuestFeedbackCue(audio: .mutedError, haptic: .errorTap, cooldownSeconds: 0.0), successCue: QuestFeedbackCue(audio: .crystallineSuccess, haptic: .successPulse, cooldownSeconds: 0.0), timeoutCue: QuestFeedbackCue(audio: .warningPulse, haptic: .warningTap, cooldownSeconds: 0.0), - hintCue: QuestFeedbackCue(audio: .hintChime, haptic: .softTick, cooldownSeconds: 0.4) + hintCue: QuestFeedbackCue(audio: .hintChime, haptic: .softTick, cooldownSeconds: 0.4), + laneSlotCue: QuestFeedbackCue(audio: .laneSlotStep, haptic: .selectionChanged, cooldownSeconds: 0.08) ) /// Light-touch profile suitable for calmer, tutorial-heavy quests. @@ -78,6 +84,7 @@ public extension QuestFeedbackProfile { wrongActivationCue: QuestFeedbackCue(audio: .mutedError, haptic: .errorTap, cooldownSeconds: 0.0), successCue: QuestFeedbackCue(audio: .crystallineSuccess, haptic: .successPulse, cooldownSeconds: 0.0), timeoutCue: QuestFeedbackCue(audio: .warningPulse, haptic: .warningTap, cooldownSeconds: 0.0), - hintCue: QuestFeedbackCue(audio: .hintChime, haptic: .softTick, cooldownSeconds: 0.4) + hintCue: QuestFeedbackCue(audio: .hintChime, haptic: .softTick, cooldownSeconds: 0.4), + laneSlotCue: QuestFeedbackCue(audio: .none, haptic: .selectionChanged, cooldownSeconds: 0.12) ) } diff --git a/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackReducer.swift b/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackReducer.swift index e9e2fc3..9c498db 100644 --- a/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackReducer.swift +++ b/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackReducer.swift @@ -18,6 +18,8 @@ public enum QuestFeedbackReducer { switch input { case .alignmentBandChanged(let newBand): return reduceBandChange(state: &state, newBand: newBand) + case .laneSlotChanged: + return [.laneSlotTick] case .wrongActivation: return [.wrongActivation] case .success: diff --git a/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackTypes.swift b/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackTypes.swift index 85669f1..7c7e76a 100644 --- a/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackTypes.swift +++ b/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackTypes.swift @@ -16,6 +16,8 @@ public enum QuestFeedbackBand: Int, CaseIterable, Codable, Hashable, Sendable { public enum QuestFeedbackIntent: Equatable, Sendable { /// The player entered a meaningful proximity band. case proximityEntered(QuestFeedbackBand) + /// The player moved VoiceOver / scroll focus to another slot in a multi-item alignment lane (e.g. Crystal Resonance). + case laneSlotTick /// The player reached the activation window. case lockAcquired /// The player left the activation window after being locked. @@ -37,6 +39,8 @@ public enum QuestFeedbackIntent: Equatable, Sendable { public enum QuestFeedbackInput: Equatable, Sendable { /// Alignment band derived from current gameplay geometry. case alignmentBandChanged(QuestFeedbackBand) + /// Discrete lane slot changed while scrolling the Moonstone alignment lane (semantic focus move, not raw geometry). + case laneSlotChanged /// The player activated the wrong object or trigger. case wrongActivation /// The player completed the objective. @@ -64,6 +68,8 @@ public struct QuestFeedbackState: Equatable, Sendable { public enum QuestFeedbackAudioFamily: String, Codable, Hashable, Sendable { case none case resonance + /// Short, clear step tone for discrete lane / list moves (Crystal Resonance shaft slots). + case laneSlotStep case mutedError case crystallineSuccess case warningPulse @@ -74,6 +80,8 @@ public enum QuestFeedbackAudioFamily: String, Codable, Hashable, Sendable { public enum QuestFeedbackHapticFamily: String, Codable, Hashable, Sendable { case none case softTick + /// Discrete selection nudge — maps to `UISelectionFeedbackGenerator` for VoiceOver-friendly shaft steps. + case selectionChanged case proximityPulse case alignmentSnap case errorTap diff --git a/RA11yCore/Sources/RA11yCore/GameCatalog/GameDefinition.swift b/RA11yCore/Sources/RA11yCore/GameCatalog/GameDefinition.swift index cb744cd..62e0268 100644 --- a/RA11yCore/Sources/RA11yCore/GameCatalog/GameDefinition.swift +++ b/RA11yCore/Sources/RA11yCore/GameCatalog/GameDefinition.swift @@ -8,10 +8,10 @@ import Foundation public enum GameKind: String, Hashable, Sendable, Codable, CaseIterable { /// Find & Focus — Simon Says drill training focus navigation and element identification. case findAndFocus - /// Activate — Bomb Defusal drill training double-tap activation on the correct control. - case activateDoubleTap - /// Scroll Hunt — Crystal Resonance drill training three-finger scrolling to reveal hidden content. + /// Crystal Resonance (player-facing name). Internal catalog id `scroll-hunt`; trains three-finger scrolling of the Moonstone alignment lane. case scrollHunt + /// The Banishment — two-finger scrub / accessibility escape (`the-banishment`). + case banishment } // MARK: - GameDefinition @@ -89,8 +89,7 @@ public enum GameCatalog { /// /// Games are sequentially gated: each game after the first has a `prerequisiteID` /// pointing to the game that must be beaten before it is unlocked. Display order - /// matches unlock order: **Enchanter (focus) → Crystal Resonance (scrolling) → Rogue (activation)** — - /// scrolling before the gauntlet matches the hub progression after the Enchanter's Trial. + /// matches unlock order: **Enchanter → Crystal Resonance → The Banishment**. /// /// Thumbnails use dedicated square hub icons (`*_hub_icon`) designed for /// the dark quest card at ~72–96 pt. Background scene images and individual @@ -116,12 +115,12 @@ public enum GameCatalog { prerequisiteID: "find-and-focus" ), GameDefinition( - id: "activate-double-tap", - titleKey: "game.activateDoubleTap.title", - goalKey: "game.activateDoubleTap.goal", - estimatedDuration: "~5 min", - kind: .activateDoubleTap, - thumbnailAssetName: "rogue_hub_icon", + id: "the-banishment", + titleKey: "game.banishment.title", + goalKey: "game.banishment.goal", + estimatedDuration: "~6 min", + kind: .banishment, + thumbnailAssetName: "banishment_hub_icon", prerequisiteID: "scroll-hunt" ), ] diff --git a/RA11yCore/Sources/RA11yCore/Hub/HubViewModel.swift b/RA11yCore/Sources/RA11yCore/Hub/HubViewModel.swift index 7877abf..7328394 100644 --- a/RA11yCore/Sources/RA11yCore/Hub/HubViewModel.swift +++ b/RA11yCore/Sources/RA11yCore/Hub/HubViewModel.swift @@ -31,6 +31,14 @@ public final class HubViewModel { private let storage: any StorageComponent + /// When `true`, every catalog game is treated as unlocked regardless of ``bestResults``. + /// + /// Used only for **local audit** (e.g. skipping prerequisite clears on a dev build) without + /// farming prerequisites. The iOS host enables this in **DEBUG** via launch argument + /// `-unlockAllQuestsForAudit` (Xcode → Scheme → Run → Arguments). Release builds + /// must always pass `false`. + private let unlockAllQuestsForAudit: Bool + // MARK: - Init /// Creates a view model backed by the given storage component. @@ -38,12 +46,16 @@ public final class HubViewModel { /// `bestResults` starts empty; call `refreshBestResults()` (or let the hub /// view's `.task` do so) to populate from storage. /// - /// - Parameter storage: Persistence layer for best results. Inject - /// `UserDefaultsStorageComponent()` in production and - /// `InMemoryStorageComponent` in tests. - public init(storage: any StorageComponent) { + /// - Parameters: + /// - storage: Persistence layer for best results. Inject + /// `UserDefaultsStorageComponent()` in production and + /// `InMemoryStorageComponent` in tests. + /// - unlockAllQuestsForAudit: Bypass prerequisite gating for QA/design audits. + /// Defaults to `false`; the iOS app sets this from a DEBUG launch flag only. + public init(storage: any StorageComponent, unlockAllQuestsForAudit: Bool = false) { self.storage = storage - RA11yLogger.startup.debug("HubViewModel.init") + self.unlockAllQuestsForAudit = unlockAllQuestsForAudit + RA11yLogger.startup.debug("HubViewModel.init unlockAllQuestsForAudit=\(unlockAllQuestsForAudit)") } // MARK: - Public API @@ -68,6 +80,7 @@ public final class HubViewModel { /// - Parameter game: The `GameDefinition` to evaluate. /// - Returns: `true` if the game can be started. public func isUnlocked(_ game: GameDefinition) -> Bool { + if unlockAllQuestsForAudit { return true } guard let prerequisiteID = game.prerequisiteID else { return true } return bestResults[prerequisiteID] != nil } diff --git a/RA11yCore/Sources/RA11yCore/Scoring/RankThresholds.swift b/RA11yCore/Sources/RA11yCore/Scoring/RankThresholds.swift index bb3366e..6a7a4a6 100644 --- a/RA11yCore/Sources/RA11yCore/Scoring/RankThresholds.swift +++ b/RA11yCore/Sources/RA11yCore/Scoring/RankThresholds.swift @@ -105,21 +105,7 @@ public extension RankThresholds { okMaxTime: 45, okMaxMistakes: 4 ) - /// Thresholds for Activate — The Rogue's Gauntlet (Game 2). - /// - /// Per `GameSpec-ActivateDoubleTap.txt` and `GameRules-MVP.txt`: - /// - Legendary (Perfect): 0 mistakes, ≤8s - /// - Skilled (Good): ≤1 mistake, ≤16s - /// - Novice (Ok): completed, ≤40s - /// - Defeated (Failed): timed out OR ≥5 mistakes - static let activateDoubleTap = RankThresholds( - timeoutSeconds: 40, - perfectMaxTime: 8, perfectMaxMistakes: 0, - goodMaxTime: 16, goodMaxMistakes: 1, - okMaxTime: 40, okMaxMistakes: 4 - ) - - /// Thresholds for Scroll Hunt — Crystal Resonance (Game 3). + /// Thresholds for Crystal Resonance (Game 2; catalog id `scroll-hunt`). /// /// Per `GameSpec-ScrollHunt.txt` and `GameRules-MVP.txt`: /// - Legendary (Perfect): 0 mistakes, ≤15s @@ -132,4 +118,15 @@ public extension RankThresholds { goodMaxTime: 30, goodMaxMistakes: 1, okMaxTime: 60, okMaxMistakes: 5 ) + + /// Thresholds for The Banishment (`the-banishment`) — timed gauntlet (tower + Lights Off). + /// + /// Greybox tuning: one continuous scored session after the practice ward; `timeoutSeconds` + /// must match the iOS playfield countdown for The Banishment. + static let banishment = RankThresholds( + timeoutSeconds: 55, + perfectMaxTime: 14, perfectMaxMistakes: 0, + goodMaxTime: 28, goodMaxMistakes: 1, + okMaxTime: 55, okMaxMistakes: 4 + ) } diff --git a/RA11yCore/Tests/RA11yCoreTests/GameCatalogTests.swift b/RA11yCore/Tests/RA11yCoreTests/GameCatalogTests.swift index 2b8aab7..f2deeb6 100644 --- a/RA11yCore/Tests/RA11yCoreTests/GameCatalogTests.swift +++ b/RA11yCore/Tests/RA11yCoreTests/GameCatalogTests.swift @@ -42,12 +42,18 @@ struct GameCatalogTests { } } - /// All three MVP games must be present in the catalog. - @Test func allThreeMVPGamesPresent() { + /// All shipped quest kinds must appear in the catalog. + @Test func allQuestKindsPresentInCatalog() { let kinds = Set(GameCatalog.all.map(\.kind)) #expect(kinds.contains(.findAndFocus)) - #expect(kinds.contains(.activateDoubleTap)) #expect(kinds.contains(.scrollHunt)) + #expect(kinds.contains(.banishment)) + } + + @Test func banishmentDefinitionLookup() { + let def = GameCatalog.definition(for: "the-banishment") + #expect(def?.kind == .banishment) + #expect(def?.prerequisiteID == "scroll-hunt") } // MARK: - Per-Game Data Contract diff --git a/RA11yCore/Tests/RA11yCoreTests/HubViewModelTests.swift b/RA11yCore/Tests/RA11yCoreTests/HubViewModelTests.swift index edbdeda..9293c73 100644 --- a/RA11yCore/Tests/RA11yCoreTests/HubViewModelTests.swift +++ b/RA11yCore/Tests/RA11yCoreTests/HubViewModelTests.swift @@ -23,14 +23,49 @@ struct HubViewModelTests { HubViewModel(storage: storage) } + // MARK: - Unlock / audit + + /// Local audit flag must bypass prerequisite gating without mutating storage. + @Test func auditUnlockExposesAllQuestsWithoutStoredProgress() async throws { + let storage = InMemoryStorageComponent() + let banishment = try #require(GameCatalog.definition(for: "the-banishment")) + let viewModel = HubViewModel(storage: storage, unlockAllQuestsForAudit: true) + await viewModel.refreshBestResults() + + #expect(viewModel.isUnlocked(banishment)) + #expect(viewModel.prerequisite(for: banishment) == nil) + } + + /// Default behavior: Crystal Resonance stays locked until the Enchanter has a stored result. + @Test func scrollHuntLockedUntilFindAndFocusClearsWithoutAuditFlag() async throws { + let storage = InMemoryStorageComponent() + let resonance = try #require(GameCatalog.definition(for: "scroll-hunt")) + let viewModel = makeViewModel(storage: storage) + await viewModel.refreshBestResults() + + #expect(!viewModel.isUnlocked(resonance)) + #expect(viewModel.prerequisite(for: resonance)?.id == "find-and-focus") + } + + /// The Banishment stays locked until Crystal Resonance has a stored result. + @Test func banishmentLockedUntilScrollHuntClearsWithoutAuditFlag() async throws { + let storage = InMemoryStorageComponent() + let banishment = try #require(GameCatalog.definition(for: "the-banishment")) + let viewModel = makeViewModel(storage: storage) + await viewModel.refreshBestResults() + + #expect(!viewModel.isUnlocked(banishment)) + #expect(viewModel.prerequisite(for: banishment)?.id == "scroll-hunt") + } + // MARK: - Best Results: Initial State /// A freshly created view model has no results until `refreshBestResults()` is called. @Test func freshViewModelHasNoBestResults() async { let viewModel = makeViewModel() #expect(viewModel.bestRank(for: "find-and-focus") == nil) - #expect(viewModel.bestRank(for: "activate-double-tap") == nil) #expect(viewModel.bestRank(for: "scroll-hunt") == nil) + #expect(viewModel.bestRank(for: "the-banishment") == nil) } /// `refreshBestResults()` returns nil for games that have never been played. @@ -39,8 +74,8 @@ struct HubViewModelTests { await viewModel.refreshBestResults() #expect(viewModel.bestRank(for: "find-and-focus") == nil) - #expect(viewModel.bestRank(for: "activate-double-tap") == nil) #expect(viewModel.bestRank(for: "scroll-hunt") == nil) + #expect(viewModel.bestRank(for: "the-banishment") == nil) } // MARK: - Best Results: Load from Storage @@ -56,9 +91,9 @@ struct HubViewModelTests { let viewModel = makeViewModel(storage: storage) await viewModel.refreshBestResults() - #expect(viewModel.bestRank(for: "find-and-focus") == .perfect) - #expect(viewModel.bestRank(for: "activate-double-tap") == nil) - #expect(viewModel.bestRank(for: "scroll-hunt") == .ok) + #expect(viewModel.bestRank(for: "find-and-focus") == .perfect) + #expect(viewModel.bestRank(for: "scroll-hunt") == .ok) + #expect(viewModel.bestRank(for: "the-banishment") == nil) } // MARK: - Best Results: Refresh diff --git a/RA11yCore/Tests/RA11yCoreTests/QuestFeedbackReducerTests.swift b/RA11yCore/Tests/RA11yCoreTests/QuestFeedbackReducerTests.swift index 9c845c0..ce1cf6c 100644 --- a/RA11yCore/Tests/RA11yCoreTests/QuestFeedbackReducerTests.swift +++ b/RA11yCore/Tests/RA11yCoreTests/QuestFeedbackReducerTests.swift @@ -98,6 +98,15 @@ struct QuestFeedbackReducerTests { #expect(intents == [.hint]) } + @Test func laneSlotChangeEmitsLaneSlotTickWithoutMutatingBandState() { + var state = QuestFeedbackState(currentBand: .near) + + let intents = QuestFeedbackReducer.reduce(state: &state, input: .laneSlotChanged) + + #expect(intents == [.laneSlotTick]) + #expect(state.currentBand == .near) + } + @Test func dungeonResonanceProfileUsesAlignmentSnapForLockCue() { let profile = QuestFeedbackProfile.dungeonResonance #expect(profile.lockCue.haptic == .alignmentSnap) diff --git a/RA11yCore/Tests/RA11yCoreTests/ScoringModelTests.swift b/RA11yCore/Tests/RA11yCoreTests/ScoringModelTests.swift index b047916..6423014 100644 --- a/RA11yCore/Tests/RA11yCoreTests/ScoringModelTests.swift +++ b/RA11yCore/Tests/RA11yCoreTests/ScoringModelTests.swift @@ -129,47 +129,7 @@ struct ScoringModelTests { #expect(rank == .ok) } - // MARK: - RankThresholds — Activate Double-Tap (Game 2 — The Rogue's Gauntlet) - // Spec: Legendary ≤8s, 0 mistakes | Skilled ≤16s, ≤1 mistake | Novice ≤40s | Defeated ≥5 mistakes - - /// Legendary: ≤8s, 0 mistakes. - @Test func activateDoubleTapLegendaryWithinThreshold() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 7, mistakes: 0) - #expect(rank == .perfect) - } - - /// 9s with 0 mistakes drops to Skilled. - @Test func activateDoubleTapJustPastPerfectDropsToSkilled() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 9, mistakes: 0) - #expect(rank == .good) - } - - /// Skilled: ≤16s, ≤1 mistake. - @Test func activateDoubleTapSkilledWithinThreshold() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 16, mistakes: 1) - #expect(rank == .good) - } - - /// Novice: completed within 40s. - @Test func activateDoubleTapNoviceWithinBoundary() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 35, mistakes: 2) - #expect(rank == .ok) - } - - /// Timeout: >40s → Defeated. - @Test func activateDoubleTapFailedOnTimeout() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 40.001, mistakes: 0) - #expect(rank == .failed) - } - - /// Equal rank: fewer mistakes wins. - @Test func activateDoubleTapFewerMistakesWins() { - let fewer = GameResult(gameID: "activate-double-tap", rank: .good, timeSeconds: 15, mistakes: 0) - let more = GameResult(gameID: "activate-double-tap", rank: .good, timeSeconds: 12, mistakes: 1) - #expect(fewer.isBetter(than: more)) - } - - // MARK: - RankThresholds — Scroll Hunt (Game 3 — Crystal Resonance) + // MARK: - RankThresholds — Crystal Resonance (Game 2) // Spec: Legendary ≤15s, 0 mistakes | Skilled ≤30s, ≤1 mistake | Novice ≤60s | Defeated ≥6 mistakes /// Legendary: ≤15s, 0 mistakes (spec exact value from GameSpec-ScrollHunt.txt). @@ -202,6 +162,18 @@ struct ScoringModelTests { #expect(rank == .failed) } + // MARK: - RankThresholds — The Banishment + + @Test func banishmentLegendaryWithinThreshold() { + let rank = RankThresholds.banishment.evaluate(timeSeconds: 12, mistakes: 0) + #expect(rank == .perfect) + } + + @Test func banishmentFailedOnTimeout() { + let rank = RankThresholds.banishment.evaluate(timeSeconds: 55.001, mistakes: 0) + #expect(rank == .failed) + } + // MARK: - RankThresholds.bucketMistakes // // Spec (GameSpec-FindAndFocus.txt): @@ -252,97 +224,27 @@ struct ScoringModelTests { #expect(RankThresholds.bucketMistakes(timeSeconds: 30, bucketSize: -1) == 0) } - // MARK: - RankThresholds.bucketMistakes (8s bucket — Rogue's Gauntlet) + // MARK: - RankThresholds.bucketMistakes (8s bucket regression) // - // Spec (GameSpec-ActivateDoubleTap.txt): - // "Bucket size: 8s. First bucket (0–8s) is free. Each subsequent full 8s adds +1. - // Example: 0–8s → +0; 8–16s → +1; 16–24s → +2." + // Exercises a non-default bucket size; retained after retiring the old activate quest. /// Within first 8s bucket → 0 penalties. - @Test func bucketMistakesRogue_withinFirstBucket() { + @Test func bucketMistakesEightSecond_withinFirstBucket() { #expect(RankThresholds.bucketMistakes(timeSeconds: 0, bucketSize: 8) == 0) #expect(RankThresholds.bucketMistakes(timeSeconds: 4, bucketSize: 8) == 0) #expect(RankThresholds.bucketMistakes(timeSeconds: 8, bucketSize: 8) == 0) } /// Just past 8s → enters second bucket → +1. - @Test func bucketMistakesRogue_secondBucket() { + @Test func bucketMistakesEightSecond_secondBucket() { #expect(RankThresholds.bucketMistakes(timeSeconds: 8.001, bucketSize: 8) == 1) #expect(RankThresholds.bucketMistakes(timeSeconds: 12, bucketSize: 8) == 1) #expect(RankThresholds.bucketMistakes(timeSeconds: 16, bucketSize: 8) == 1) } /// Just past 16s → enters third bucket → +2. - @Test func bucketMistakesRogue_thirdBucket() { + @Test func bucketMistakesEightSecond_thirdBucket() { #expect(RankThresholds.bucketMistakes(timeSeconds: 16.001, bucketSize: 8) == 2) #expect(RankThresholds.bucketMistakes(timeSeconds: 20, bucketSize: 8) == 2) } - - // MARK: - RankThresholds.activateDoubleTap (Rogue's Gauntlet) - // - // Spec (GameSpec-ActivateDoubleTap.txt): - // Legendary (Perfect): mistakes == 0 AND timeSeconds <= 8 - // Skilled (Good): mistakes <= 1 AND timeSeconds <= 16 - // Novice (Ok): completed AND timeSeconds <= 40 - // Defeated (Failed): timeout at 40s OR mistakes >= 5 - - /// Legendary: ≤8s, 0 mistakes. - @Test func rogueGauntletLegendary() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 7, mistakes: 0) - #expect(rank == .perfect) - } - - /// Legendary boundary: exactly 8s, 0 mistakes. - @Test func rogueGauntletLegendaryBoundary() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 8, mistakes: 0) - #expect(rank == .perfect) - } - - /// 1 mistake at 8s falls to Skilled (perfectMaxMistakes = 0). - @Test func rogueGauntletOneMistakeIsNotLegendary() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 8, mistakes: 1) - #expect(rank == .good) - } - - /// Skilled: ≤16s, ≤1 mistake. - @Test func rogueGauntletSkilled() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 15, mistakes: 1) - #expect(rank == .good) - } - - /// Skilled boundary: exactly 16s, 1 mistake. - @Test func rogueGauntletSkilledBoundary() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 16, mistakes: 1) - #expect(rank == .good) - } - - /// Just past 16s with 1 mistake → Novice. - @Test func rogueGauntletNoviceAfterSkilledBoundary() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 16.001, mistakes: 1) - #expect(rank == .ok) - } - - /// Novice: completed within 40s, ≤4 mistakes. - @Test func rogueGauntletNovice() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 35, mistakes: 3) - #expect(rank == .ok) - } - - /// 5 mistakes → Defeated (okMaxMistakes = 4, so 5 exceeds boundary). - @Test func rogueGauntletFiveMistakesDefeated() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 10, mistakes: 5) - #expect(rank == .failed) - } - - /// >40s → Defeated (timeout). - @Test func rogueGauntletFailedOnTimeout() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 40.001, mistakes: 0) - #expect(rank == .failed) - } - - /// Exactly 40s → Novice (boundary is inclusive). - @Test func rogueGauntletNoviceAtTimeoutBoundary() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 40, mistakes: 0) - #expect(rank == .ok) - } } diff --git a/RA11yCore/Tests/RA11yCoreTests/StorageTests.swift b/RA11yCore/Tests/RA11yCoreTests/StorageTests.swift index c124a82..b986c58 100644 --- a/RA11yCore/Tests/RA11yCoreTests/StorageTests.swift +++ b/RA11yCore/Tests/RA11yCoreTests/StorageTests.swift @@ -65,7 +65,7 @@ struct StorageTests { await storage.saveResultIfBetter(result) - let other = await storage.bestResult(for: "activate-double-tap") + let other = await storage.bestResult(for: "scroll-hunt") #expect(other == nil) } diff --git a/docs/screenshots/en-US/iPad/01_Hub.png b/docs/screenshots/en-US/iPad/01_Hub.png index f614e66..816eedc 100644 Binary files a/docs/screenshots/en-US/iPad/01_Hub.png and b/docs/screenshots/en-US/iPad/01_Hub.png differ diff --git a/docs/screenshots/en-US/iPad/02_VORequired.png b/docs/screenshots/en-US/iPad/02_VORequired.png index 6f986ab..f7aee80 100644 Binary files a/docs/screenshots/en-US/iPad/02_VORequired.png and b/docs/screenshots/en-US/iPad/02_VORequired.png differ diff --git a/docs/screenshots/en-US/iPad/03_FirstRun.png b/docs/screenshots/en-US/iPad/03_FirstRun.png index 2411d4b..78b203d 100644 Binary files a/docs/screenshots/en-US/iPad/03_FirstRun.png and b/docs/screenshots/en-US/iPad/03_FirstRun.png differ diff --git a/docs/screenshots/en-US/iPad/04_EnchanterPrologue.png b/docs/screenshots/en-US/iPad/04_EnchanterPrologue.png index 5a22709..dac1006 100644 Binary files a/docs/screenshots/en-US/iPad/04_EnchanterPrologue.png and b/docs/screenshots/en-US/iPad/04_EnchanterPrologue.png differ diff --git a/docs/screenshots/en-US/iPad/05_EnchanterAttempt.png b/docs/screenshots/en-US/iPad/05_EnchanterAttempt.png index eb616d4..f97b4db 100644 Binary files a/docs/screenshots/en-US/iPad/05_EnchanterAttempt.png and b/docs/screenshots/en-US/iPad/05_EnchanterAttempt.png differ diff --git a/docs/screenshots/en-US/iPad/06_EnchanterRising.png b/docs/screenshots/en-US/iPad/06_EnchanterRising.png index 706ab8a..5bd031d 100644 Binary files a/docs/screenshots/en-US/iPad/06_EnchanterRising.png and b/docs/screenshots/en-US/iPad/06_EnchanterRising.png differ diff --git a/docs/screenshots/en-US/iPad/07_EnchanterTimed.png b/docs/screenshots/en-US/iPad/07_EnchanterTimed.png index cf7505c..ff5e993 100644 Binary files a/docs/screenshots/en-US/iPad/07_EnchanterTimed.png and b/docs/screenshots/en-US/iPad/07_EnchanterTimed.png differ diff --git a/docs/screenshots/en-US/iPad/08_EnchanterResult.png b/docs/screenshots/en-US/iPad/08_EnchanterResult.png index 3044ea4..7d84bec 100644 Binary files a/docs/screenshots/en-US/iPad/08_EnchanterResult.png and b/docs/screenshots/en-US/iPad/08_EnchanterResult.png differ diff --git a/docs/screenshots/en-US/iPad/09_DungeonPrologue.png b/docs/screenshots/en-US/iPad/09_DungeonPrologue.png index b4c9436..dfd19f9 100644 Binary files a/docs/screenshots/en-US/iPad/09_DungeonPrologue.png and b/docs/screenshots/en-US/iPad/09_DungeonPrologue.png differ diff --git a/docs/screenshots/en-US/iPad/10_DungeonL1.png b/docs/screenshots/en-US/iPad/10_DungeonL1.png index e0ba49c..fbf4802 100644 Binary files a/docs/screenshots/en-US/iPad/10_DungeonL1.png and b/docs/screenshots/en-US/iPad/10_DungeonL1.png differ diff --git a/docs/screenshots/en-US/iPad/11_DungeonResult.png b/docs/screenshots/en-US/iPad/11_DungeonResult.png index 593ed47..c60d0e6 100644 Binary files a/docs/screenshots/en-US/iPad/11_DungeonResult.png and b/docs/screenshots/en-US/iPad/11_DungeonResult.png differ diff --git a/docs/screenshots/en-US/iPad/12_ResonanceMockup.png b/docs/screenshots/en-US/iPad/12_ResonanceMockup.png index 4557b1e..2f7c258 100644 Binary files a/docs/screenshots/en-US/iPad/12_ResonanceMockup.png and b/docs/screenshots/en-US/iPad/12_ResonanceMockup.png differ diff --git a/docs/screenshots/en-US/iPhone_17/01_Hub.png b/docs/screenshots/en-US/iPhone_17/01_Hub.png index 509a729..75e0694 100644 Binary files a/docs/screenshots/en-US/iPhone_17/01_Hub.png and b/docs/screenshots/en-US/iPhone_17/01_Hub.png differ diff --git a/docs/screenshots/en-US/iPhone_17/02_VORequired.png b/docs/screenshots/en-US/iPhone_17/02_VORequired.png index 6e2198d..4663d2f 100644 Binary files a/docs/screenshots/en-US/iPhone_17/02_VORequired.png and b/docs/screenshots/en-US/iPhone_17/02_VORequired.png differ diff --git a/docs/screenshots/en-US/iPhone_17/03_FirstRun.png b/docs/screenshots/en-US/iPhone_17/03_FirstRun.png index 19873c9..2b3b38b 100644 Binary files a/docs/screenshots/en-US/iPhone_17/03_FirstRun.png and b/docs/screenshots/en-US/iPhone_17/03_FirstRun.png differ diff --git a/docs/screenshots/en-US/iPhone_17/04_EnchanterPrologue.png b/docs/screenshots/en-US/iPhone_17/04_EnchanterPrologue.png index abda9ca..1d05278 100644 Binary files a/docs/screenshots/en-US/iPhone_17/04_EnchanterPrologue.png and b/docs/screenshots/en-US/iPhone_17/04_EnchanterPrologue.png differ diff --git a/docs/screenshots/en-US/iPhone_17/05_EnchanterAttempt.png b/docs/screenshots/en-US/iPhone_17/05_EnchanterAttempt.png index 95229b4..5aa27af 100644 Binary files a/docs/screenshots/en-US/iPhone_17/05_EnchanterAttempt.png and b/docs/screenshots/en-US/iPhone_17/05_EnchanterAttempt.png differ diff --git a/docs/screenshots/en-US/iPhone_17/06_EnchanterRising.png b/docs/screenshots/en-US/iPhone_17/06_EnchanterRising.png index 3a8a272..203589c 100644 Binary files a/docs/screenshots/en-US/iPhone_17/06_EnchanterRising.png and b/docs/screenshots/en-US/iPhone_17/06_EnchanterRising.png differ diff --git a/docs/screenshots/en-US/iPhone_17/07_EnchanterTimed.png b/docs/screenshots/en-US/iPhone_17/07_EnchanterTimed.png index 5f67423..c91d248 100644 Binary files a/docs/screenshots/en-US/iPhone_17/07_EnchanterTimed.png and b/docs/screenshots/en-US/iPhone_17/07_EnchanterTimed.png differ diff --git a/docs/screenshots/en-US/iPhone_17/08_EnchanterResult.png b/docs/screenshots/en-US/iPhone_17/08_EnchanterResult.png index a558c2b..6b8652a 100644 Binary files a/docs/screenshots/en-US/iPhone_17/08_EnchanterResult.png and b/docs/screenshots/en-US/iPhone_17/08_EnchanterResult.png differ diff --git a/docs/screenshots/en-US/iPhone_17/09_DungeonPrologue.png b/docs/screenshots/en-US/iPhone_17/09_DungeonPrologue.png index 93e1c6e..80f24f6 100644 Binary files a/docs/screenshots/en-US/iPhone_17/09_DungeonPrologue.png and b/docs/screenshots/en-US/iPhone_17/09_DungeonPrologue.png differ diff --git a/docs/screenshots/en-US/iPhone_17/10_DungeonL1.png b/docs/screenshots/en-US/iPhone_17/10_DungeonL1.png index 2f1941e..7d6ef28 100644 Binary files a/docs/screenshots/en-US/iPhone_17/10_DungeonL1.png and b/docs/screenshots/en-US/iPhone_17/10_DungeonL1.png differ diff --git a/docs/screenshots/en-US/iPhone_17/11_DungeonResult.png b/docs/screenshots/en-US/iPhone_17/11_DungeonResult.png index b9188f7..ba38964 100644 Binary files a/docs/screenshots/en-US/iPhone_17/11_DungeonResult.png and b/docs/screenshots/en-US/iPhone_17/11_DungeonResult.png differ diff --git a/docs/screenshots/en-US/iPhone_17/12_ResonanceMockup.png b/docs/screenshots/en-US/iPhone_17/12_ResonanceMockup.png new file mode 100644 index 0000000..125ddce Binary files /dev/null and b/docs/screenshots/en-US/iPhone_17/12_ResonanceMockup.png differ diff --git a/docs/screenshots/en-US/iPhone_large/01_Hub.png b/docs/screenshots/en-US/iPhone_large/01_Hub.png index e63c2d7..81cb651 100644 Binary files a/docs/screenshots/en-US/iPhone_large/01_Hub.png and b/docs/screenshots/en-US/iPhone_large/01_Hub.png differ diff --git a/docs/screenshots/en-US/iPhone_large/02_VORequired.png b/docs/screenshots/en-US/iPhone_large/02_VORequired.png index 1f07e21..f1d1fcc 100644 Binary files a/docs/screenshots/en-US/iPhone_large/02_VORequired.png and b/docs/screenshots/en-US/iPhone_large/02_VORequired.png differ diff --git a/docs/screenshots/en-US/iPhone_large/03_FirstRun.png b/docs/screenshots/en-US/iPhone_large/03_FirstRun.png index 866865b..7a04cc5 100644 Binary files a/docs/screenshots/en-US/iPhone_large/03_FirstRun.png and b/docs/screenshots/en-US/iPhone_large/03_FirstRun.png differ diff --git a/docs/screenshots/en-US/iPhone_large/04_EnchanterPrologue.png b/docs/screenshots/en-US/iPhone_large/04_EnchanterPrologue.png index bea9c1f..022a168 100644 Binary files a/docs/screenshots/en-US/iPhone_large/04_EnchanterPrologue.png and b/docs/screenshots/en-US/iPhone_large/04_EnchanterPrologue.png differ diff --git a/docs/screenshots/en-US/iPhone_large/05_EnchanterAttempt.png b/docs/screenshots/en-US/iPhone_large/05_EnchanterAttempt.png index 185e514..635d076 100644 Binary files a/docs/screenshots/en-US/iPhone_large/05_EnchanterAttempt.png and b/docs/screenshots/en-US/iPhone_large/05_EnchanterAttempt.png differ diff --git a/docs/screenshots/en-US/iPhone_large/06_EnchanterRising.png b/docs/screenshots/en-US/iPhone_large/06_EnchanterRising.png index 7c85e86..ed52ac4 100644 Binary files a/docs/screenshots/en-US/iPhone_large/06_EnchanterRising.png and b/docs/screenshots/en-US/iPhone_large/06_EnchanterRising.png differ diff --git a/docs/screenshots/en-US/iPhone_large/07_EnchanterTimed.png b/docs/screenshots/en-US/iPhone_large/07_EnchanterTimed.png index c01d5b1..4a2066b 100644 Binary files a/docs/screenshots/en-US/iPhone_large/07_EnchanterTimed.png and b/docs/screenshots/en-US/iPhone_large/07_EnchanterTimed.png differ diff --git a/docs/screenshots/en-US/iPhone_large/08_EnchanterResult.png b/docs/screenshots/en-US/iPhone_large/08_EnchanterResult.png index d00eeec..f59bb02 100644 Binary files a/docs/screenshots/en-US/iPhone_large/08_EnchanterResult.png and b/docs/screenshots/en-US/iPhone_large/08_EnchanterResult.png differ diff --git a/docs/screenshots/en-US/iPhone_large/09_DungeonPrologue.png b/docs/screenshots/en-US/iPhone_large/09_DungeonPrologue.png index 5442d9b..49678cc 100644 Binary files a/docs/screenshots/en-US/iPhone_large/09_DungeonPrologue.png and b/docs/screenshots/en-US/iPhone_large/09_DungeonPrologue.png differ diff --git a/docs/screenshots/en-US/iPhone_large/10_DungeonL1.png b/docs/screenshots/en-US/iPhone_large/10_DungeonL1.png index 3719976..e0c0439 100644 Binary files a/docs/screenshots/en-US/iPhone_large/10_DungeonL1.png and b/docs/screenshots/en-US/iPhone_large/10_DungeonL1.png differ diff --git a/docs/screenshots/en-US/iPhone_large/11_DungeonResult.png b/docs/screenshots/en-US/iPhone_large/11_DungeonResult.png index dffe894..6daefe5 100644 Binary files a/docs/screenshots/en-US/iPhone_large/11_DungeonResult.png and b/docs/screenshots/en-US/iPhone_large/11_DungeonResult.png differ diff --git a/docs/screenshots/en-US/iPhone_large/12_ResonanceMockup.png b/docs/screenshots/en-US/iPhone_large/12_ResonanceMockup.png index 53d20c7..d948424 100644 Binary files a/docs/screenshots/en-US/iPhone_large/12_ResonanceMockup.png and b/docs/screenshots/en-US/iPhone_large/12_ResonanceMockup.png differ diff --git a/memlog/DirectoryTree.txt b/memlog/DirectoryTree.txt index 63f391c..4124afc 100644 --- a/memlog/DirectoryTree.txt +++ b/memlog/DirectoryTree.txt @@ -387,6 +387,7 @@ Current structure (as of 2026-04-19) | | |-- ADR-0001-Concurrency-Strategy.md | | |-- ADR-0002-AI-Assisted-Screenshot-Validation.md | | |-- ADR-0003-Dungeon-Resonance-Scroll-Interaction.md +| | |-- CrystalResonance-Asset-And-Scroll-QC.md | | |-- CrystalResonance-VoiceOverScrollProxy-Investigation.md | | |-- AccessibilityFeatureGamification.md | | |-- InitialBrainstorming.txt @@ -400,6 +401,7 @@ Current structure (as of 2026-04-19) | |-- ExtractScreenshots.swift | |-- ValidateScreenshotContract.swift | |-- build_and_test.sh +| |-- ensure_png_rgba.py | |-- remove_white_background.py | |-- resolve_simulator_udid.jq | `-- validate_screenshot_contract.sh diff --git a/memlog/requirements/Design/CrystalResonance-ImageGen-PromptTemplate.txt b/memlog/requirements/Design/CrystalResonance-ImageGen-PromptTemplate.txt new file mode 100644 index 0000000..8d74324 --- /dev/null +++ b/memlog/requirements/Design/CrystalResonance-ImageGen-PromptTemplate.txt @@ -0,0 +1,86 @@ +Crystal Resonance — generative image prompt template (RA11y) +============================================================= +Date: 2026-04-21 +Purpose: Copy, adapt, or prepend these blocks when using LLMs or diffusion tools +to create **initial** PNGs for the iOS asset catalog. One generation = one asset. +This file is only **generation prompt** text — not Xcode or CI instructions. + +---------------------------------------------------------------------- +GLOBAL — prepend to EVERY sprite / glyph generation +---------------------------------------------------------------------- +Style: Painterly fantasy, torchlit stone dungeon, warm amber light, dark umber +and slate shadows, subtle gold magical accents. High contrast, readable at small +size on phone. One subject only, centered in frame. + +Output: PNG with **true transparency** everywhere except the painted subject. +The area outside the subject must be **fully transparent** (not white, not +light grey, not checkerboard, not soft fog filling the canvas). + +Forbidden in every prompt: text, letters, numbers, logos, watermarks, UI chrome, +multiple unrelated objects, sprite sheets, contact sheets, before/after grids. + +Negative hints to add when the tool supports them: white background, grey +background, parchment mat, drop shadow as a separate layer, vignette on the +whole canvas, film grain across transparent pixels, low-resolution export. + +Wide canvas: landscape aspect (~1376×768 or 16:9) is fine; keep the **subject +visually centered** so SwiftUI can center-crop to a square or oval frame. + +---------------------------------------------------------------------- +MOONSTONE (dungeon_target_moonstone) — lane target glyph +---------------------------------------------------------------------- +A single rough-cut magical moonstone crystal, cool violet-blue inner glow, +faceted silhouette, slightly elongated oval footprint. Centered. Transparent +background only. No pedestal, no full scene, no dungeon room behind it. Crisp +silhouette at ~96×72 pt equivalent. + +---------------------------------------------------------------------- +ECHO GLYPH / DECOY (dungeon_decoy_*) — wrong-lane distractors +---------------------------------------------------------------------- +One abstract magical sigil or shard each generation (ember-like shard, shadow +rune, sun motif). Distinct silhouette from the moonstone; slightly smaller +visual mass. Centered, transparent background, no scene. Bold readable shape. + +---------------------------------------------------------------------- +RETICLE RING (dungeon_reticle_ring) — hub framing ring +---------------------------------------------------------------------- +Ornate circular aiming ring: engraved gold or brass on dark metal, **donut +shape** with a **clear transparent hole in the middle** large enough that a +moonstone glyph could sit there in gameplay. No solid disc filling the center. +Subject centered; outer edge may fade but **no** full-canvas haze. + +---------------------------------------------------------------------- +ORB — idle / locked (dungeon_resonance_orb_idle, dungeon_resonance_orb_locked) +---------------------------------------------------------------------- +Crystal or glass orb for a fixed hub: **soft transparent or highly translucent +core** so content behind can read through; bright rim glow. Idle = calmer; +locked = brighter, tighter resonance. Circular overall; centered; transparent +outside the orb body. Not an opaque flat circle. + +---------------------------------------------------------------------- +SUCCESS FLARE (dungeon_success_flare) — optional burst +---------------------------------------------------------------------- +Brief radial magical burst, screen blend friendly, **mostly transparent** with +bright highlights; no solid rectangle. Centered. + +---------------------------------------------------------------------- +LANE MARKER (dungeon_lane_marker_neutral) — thin tick / divider +---------------------------------------------------------------------- +Subtle horizontal lane tick or neutral marker, low visual noise, centered +vertically in a short strip. Transparent background. + +---------------------------------------------------------------------- +BACKGROUND ONLY (dungeon_resonance_bg) +---------------------------------------------------------------------- +Full-bleed vertical interior: grand torchlit stone hall, stairs, arches, +atmospheric depth. **Fully opaque** illustration (no transparency). No UI, no +characters, no single floating crystal — environment only. + +---------------------------------------------------------------------- +After generation (for humans — do not paste into image models) +---------------------------------------------------------------------- +- Export sprites as PNG with a real alpha channel; background as a separate + opaque environment illustration. +- If the export has a white or light-grey mat around the subject, fix alpha in + post (re-export or cleanup) before importing — the app expects clean + transparency, not stacked “backdrop” layers in code for lane glyphs. diff --git a/memlog/requirements/Design/CrystalResonance-Terminology.txt b/memlog/requirements/Design/CrystalResonance-Terminology.txt new file mode 100644 index 0000000..85dea0e --- /dev/null +++ b/memlog/requirements/Design/CrystalResonance-Terminology.txt @@ -0,0 +1,33 @@ +Crystal Resonance — canonical player-facing metaphor (2026-04-21) +================================================================== + +Use this vocabulary everywhere the player reads or hears copy about Game 3. + +Core +---- +- Quest name: **Crystal Resonance** (hub title `game.scrollHunt.title`; internal code id may stay `scroll-hunt`). +- Training goal: three-finger scrolling with VoiceOver to move the **Moonstone alignment lane**, not “descending chambers.” + +Hero object & aim +----------------- +- **Moonstone** — the one correct glyph; must meet the **center orb** (also **crystal orb** in orb VoiceOver labels). +- **Seal resonance** / **Seal** — primary action when aligned (not “descend” or “enter room”). + +Lane & scrolling +---------------- +- **Moonstone alignment lane** — the VoiceOver scroll surface; three-finger scroll moves glyphs vertically. +- Avoid **chamber**, **marker**, **descend** as the main verb for this mechanic; optional light **crystal shaft** flavor only where tone fits. + +Distractors +----------- +- **Echo glyphs** (implementation: Ember shard, Shadow glyph, Sun sigil) — wrong shapes that *almost* fit; not navigation targets. +- Do not use **dungeon room** titles in VoiceOver for lane rows (see `dungeon.resonance.item.*`). + +Deprecated for this quest +------------------------- +- “Scroll Hunt” as user-facing quest name (keep only in internal enum `GameKind.scrollHunt` if needed). +- “Crystal shaft scroll list”, “descend to X chamber”, “second marker”, “eight chambers” as *mechanical* instructions. + +Related docs +------------ +- `DungeonResonanceAssetPipeline.txt`, `CrystalResonance-Asset-And-Scroll-QC.md`, ADR-0003. diff --git a/memlog/requirements/Design/DesignProcess.md b/memlog/requirements/Design/DesignProcess.md index 55eae02..9a89ba5 100644 --- a/memlog/requirements/Design/DesignProcess.md +++ b/memlog/requirements/Design/DesignProcess.md @@ -196,6 +196,22 @@ Prompt sheet requirements (one file per quest) master for tall backgrounds - No sprite sheets, contact sheets, or multi-variation layouts +Generative image tools (LLM / diffusion) — Crystal Resonance +------------------------------------------------------------- + When commissioning **new** PNGs (not hand-painting), start from the shared + template of positive/negative constraints and per-asset paragraphs: + + memlog/requirements/Design/CrystalResonance-ImageGen-PromptTemplate.txt + + That file is written for **image generation** prompts only (style, alpha, + donut reticle, centered wide masters). It does not describe QA scripts or + Xcode steps. To print the same text in a terminal:: + + python3 utility/qa_crystal_resonance_png_assets.py --llm-snippet + + Fold the relevant paragraphs into your quest prompt sheet (Phase 4) per + filename; keep one generation per asset. + Global style constraints for RA11y (include in every prompt) ------------------------------------------------------------- - Painterly flat fantasy illustration consistent with existing RA11y mockups @@ -285,6 +301,28 @@ Asset registry test UIImage(named: assetName) != nil for every name in the prompt sheet. This test should be added to the unit test suite and run on every build. +Automated PNG QA (Crystal Resonance lane / hub art) +--------------------------------------------------- + Run from repo root whenever Crystal Resonance PNGs are added, re-exported, or + batch-normalized:: + + python3 utility/qa_crystal_resonance_png_assets.py + + - **FAIL** (exit 1): missing imageset, unreadable file, or **sprite saved as RGB** + (common cause of grey mats / checkerboard fringes on dark UI in SwiftUI). + - **WARN**: odd color modes, nearly empty RGBA, possible premultiplied halos + (use ``--strict-warnings`` in CI if those should fail the job). + - **Does not replace** on-device checks or VoiceOver scroll QA — see + ``memlog/research/CrystalResonance-Asset-And-Scroll-QC.md``. + + **Image generation** (what to ask DALL·E, Midjourney, Firefly, etc.) is + documented in ``CrystalResonance-ImageGen-PromptTemplate.txt`` — not here. + After assets exist, this Python QA validates files on disk (RGB vs RGBA, etc.). + + **Engineering note:** opaque SwiftUI back-plates behind every lane PNG were + tried to hide checkerboard; they **regressed** other hub art. Prefer fixing + **source alpha** (template + ``remove_white_background.py`` / ``ensure_png_rgba.py``). + --- Phase 6 — SwiftUI Implementation @@ -454,7 +492,9 @@ Phase 2 GameSpec-[Name].txt Phase 3 Mockups-v2/[name]_mockup.html Phase 4 DesignTicket-[Name]PromptSheet.txt [Name]AssetPipeline.txt + (Crystal Resonance: CrystalResonance-ImageGen-PromptTemplate.txt) Phase 5 Assets imported to Assets.xcassets + ``python3 utility/qa_crystal_resonance_png_assets.py`` passing (Crystal Resonance) Asset registry unit test passing Phase 6 Quest SwiftUI implementation ViewModel with state machine @@ -470,6 +510,9 @@ Phase 8 VoiceOver walkthrough complete Revision History ================ +2026-04-21 Phase 5: ``utility/qa_crystal_resonance_png_assets.py`` for on-disk + PNG checks; ``CrystalResonance-ImageGen-PromptTemplate.txt`` for + generative **image** prompts (separate from QA). 2026-04-19 Initial version. Derived from Dungeon Resonance v2 design process, existing DesignTicket conventions, ADR-0003, and observed patterns from Enchanter, Rogue, and Crystal Resonance quest development. diff --git a/memlog/requirements/Design/DungeonResonanceAssetPipeline.txt b/memlog/requirements/Design/DungeonResonanceAssetPipeline.txt index f7a8f91..209d48a 100644 --- a/memlog/requirements/Design/DungeonResonanceAssetPipeline.txt +++ b/memlog/requirements/Design/DungeonResonanceAssetPipeline.txt @@ -1,7 +1,8 @@ RA11y — Dungeon Resonance v2 Asset Pipeline ============================================ Date: 2026-04-19 -Related: ADR-0003, DesignTicket-DungeonResonancePromptSheet.txt +Related: ADR-0003, DesignTicket-DungeonResonancePromptSheet.txt, + `CrystalResonance-Terminology.txt` (canonical Moonstone / alignment lane copy). Purpose ------- @@ -27,9 +28,9 @@ Layer stack (back → front) fixed center; `allowsHitTesting(false)` so scroll passes through. 5. Optional `dungeon_success_flare` — short-lived overlay on Success; compose with `.opacity` + blend mode; respect Reduce Motion (skip or shorten). -6. Lights Off: darken world with **code** radial gradient **plus** optional - `dungeon_spotlight_mask_reference` multiply/softLight; orb and reticle stay - fully opaque above the mask. +6. Lights Off (L3): darken the playfield with ``iOSResonanceLightsOffVignette`` (radial gradient **plus** + optional `dungeon_spotlight_mask_reference`). Do **not** stack ``ra11yLightsOffGameplayBlackout`` on the + glyph lane — it paints an opaque plate and duplicates the vignette. Orb and reticle stay above the vignette. Runtime sizing (SwiftUI) ------------------------ @@ -40,7 +41,8 @@ Use **points**, not raw pixels. Recommended defaults (tune in mockup / final gam - **Orb:** `@ScaledMetric(relativeTo: .title)` base **112 pt** diameter, clamp 88–140 pt; same visual weight on iPhone and iPad. - **Reticle ring:** ~1.45–1.55× orb diameter so the ring frames the orb without - covering it; single asset scales with `.resizable` + `aspectFit` in a square frame. + covering it; single asset uses ``iOSResonanceWideCanvasImage`` (`.scaledToFill` + + `.clipped()` in a square frame) so wide masters still read at full weight. - **Moonstone (target):** ~72–96 pt wide (oval); decoys same order of magnitude; distinct silhouettes remain readable at Dynamic Type–adjacent sizes. - **Lane marker:** low visual weight; ~22–28 pt height bars or proportional glyph. @@ -49,9 +51,20 @@ Transparency rules ------------------ - **Opaque:** `dungeon_resonance_bg` only. - **Alpha:** all other listed assets. Subjects centered with **padding inside the PNG** - so SwiftUI can scale without clipping highlights. If generator emits flat white - behind glyphs, run `utility/remove_white_background.py --mode edge` **only** when - the subject is isolated on white; review edges for fringing. + so SwiftUI can scale without clipping highlights. +- **Glyph lane exports:** `dungeon_target_moonstone`, `dungeon_decoy_*` shipped historically as + **RGB-only** wide canvases; SwiftUI drew obvious grey/light boxes around scaled glyphs. They + must be **PNG RGBA** (true alpha). If a revision lands as flat RGB again, run + `utility/remove_white_background.py --mode edge` on edge-connected near-white (backs up then + replaces in place), or re-export from the art pipeline with proper alpha. Review on a dark + background (game shaft) on device. + For **light grey** export mats touching the border, try `--edge-matte` (riskier — QC glyphs). + +Living QC / failure modes (scroll sync, grey boxes, centre transparency): see + `memlog/research/CrystalResonance-Asset-And-Scroll-QC.md`. + +Batch normalize catalog PNGs (RGBA for composited art, RGB kept for full-bleed bgs): run + `python3 utility/ensure_png_rgba.py` from repo root (see script docstring). Multiple form factors ---------------------- diff --git a/memlog/requirements/Design/QuestConcept-Banishment.txt b/memlog/requirements/Design/QuestConcept-Banishment.txt index 4e950d9..e610dd1 100644 --- a/memlog/requirements/Design/QuestConcept-Banishment.txt +++ b/memlog/requirements/Design/QuestConcept-Banishment.txt @@ -1,15 +1,17 @@ RA11y — Quest Concept The Banishment -Date: 2026-04-19 +Date: 2026-04-19 (revised 2026-04-20) Owner: Product / Design (Requirements) -Status: Draft +Status: Draft — gameplay & accessibility architecture before art Process: See DesignProcess.md +Related: ADR-0003 (Resonance interaction model), CrystalResonance-VoiceOverScrollProxy-Investigation.md Skill ===== Two-finger scrub (Z-shape) — the VoiceOver escape gesture. -Triggers accessibilityPerformEscape() on the current view. +Maps to accessibilityPerformEscape() / the platform escape path for the focused +accessibility subtree. One-Sentence Lesson =================== @@ -18,112 +20,212 @@ banishes it. Real-World Mapping ================== -This gesture dismisses alerts, modals, sheets, action sheets, and navigates -back in navigation controllers. It is the universal escape hatch in every -iOS app. Without it, users get stranded inside presented views with no way -out. With it, no presented UI can trap them permanently. +This gesture dismisses alerts, modals, sheets, action sheets, and often pops +navigation when focus is inside presented UI. It is the primary escape hatch +on iOS when focus feels “stuck.” Players should connect the in-quest “creature” +to “anything that blocked the rest of the app.” Prerequisites ============= - Enchanter's Trial (swipe to navigate, double-tap to activate) - Crystal Resonance (three-finger scroll) -The player must already have focus navigation and activation in muscle memory. -This quest introduces the first two-finger gesture in the curriculum and lays -the foundation for Magic Tap and the Rotor. +The player already practices double-tap and scroll in context. This quest is the +first deliberate two-finger gesture in the curriculum and sets up Magic Tap and +the Rotor later. Downstream Skills Unlocked =========================== -- Magic Tap (two-finger double-tap) — context-aware action -- The Rotor (two-finger twist) — change navigation granularity -Both build on the two-finger gesture vocabulary introduced here. - -Stage Structure -=============== +- Magic Tap (two-finger double-tap) +- The Rotor (two-finger twist) +Both extend the two-finger vocabulary introduced here. + +================================================================================ +0. PROCESS: GAMEPLAY FIRST, SF SYMBOLS FIRST, ART LAST +================================================================================ + +Milestone order (non-negotiable for this quest): + + Phase A — Paper + this doc + Lock stage outcomes, failure modes, copy themes, and rank rules. + + Phase B — Greybox implementation (SF Symbols + system materials only) + No creature PNGs, no bespoke backgrounds. Use SF Symbols for “creature”, + “threat,” “ward,” “tower,” etc. until escape + focus model are proven on + device with VoiceOver. + + Phase C — Accessibility tree freeze + Document the exact VoiceOver swipe order and the single “escape surface” + contract (see §6). No new focusable chrome may be added without re-running + the device checklist. + + Phase D — Asset generation + Only after Phase B/C exit criteria pass: replace placeholders with catalog + art using the same layout frames and accessibility identifiers. + +Rationale: Crystal Resonance showed that as the playfield gained layers (chrome, +lane, proxy, vignette), programmatic focus and linear navigation became the hardest +part of the ship. Banishment must not repeat “beautiful art, broken tree.” + +================================================================================ +1. LESSONS FROM CRYSTAL RESONANCE (APPLY TO BANISHMENT) +================================================================================ + +Focus and navigation +-------------------- +- Programmatic focus (screenChanged, delayed Tasks, sortPriority) can announce + correctly and still fail day-to-day discovery: the next right-swipe may never + reach the element that must receive the lesson gesture. +- Three-finger scroll only reaches the UIScrollView (or scrollable ancestor) that + has VoiceOver focus. Banishment’s analogue: two-finger scrub must apply to a + view hierarchy that actually implements / forwards performEscape for the + focused element. +- “Mystery” VoiceOver targets often come from empty containers, GeometryReader + shells, or invisible spacers. Banishment UI must avoid placeholder stacks that + synthesize unnamed rectangles. + +Complexity budget +----------------- +- Prefer ONE primary interactive accessibility element during a trap (e.g. the + presented “creature” surface) plus a small, documented set of chrome items + (objective, optional timer). Everything else: accessibilityHidden(true) or + merged into a single combined element. +- When a UIKit host is required for predictable behaviour (Resonance uses a + UIScrollView proxy), prefer that path early for Banishment if SwiftUI + presentation + escape testing is flaky on device. + +Layout vs rules +--------------- +- Any “did the player succeed?” rule must use the same coordinate / state model + as the view that receives the gesture. Avoid duplicating geometry in a second + layer that can drift (Resonance: VStack spacing had to match scroll math). + +Lights Off +---------- +- Keep one stable visual anchor (e.g. centre glyph) visible; narrative darkness + affects the frame, not the escape contract. + +================================================================================ +2. GAMEPLAY OVERVIEW (LOCK BEFORE ART) +================================================================================ + +Core loop (all stages) +---------------------- +1. Player is in a “safe” state (short reading order, can reach hub affordances). +2. A trap state presents: something blocks continuation until dismissed. +3. Wrong actions (swipe to move focus, double-tap on decoy) produce immediate + thematic feedback and restate the required gesture (copy + optional audio). +4. Successful two-finger scrub dismisses the trap and advances the beat. +5. Stage 2+ adds time pressure and sequence; Stage 3 removes non-essential + visuals, not the escape mechanism. + +“Creature” abstraction +---------------------- +Design and copy treat each threat as a modal family. Implementation maps each +to a real dismissal path that responds to performEscape (native alert, sheet, +or a custom container that overrides accessibilityPerformEscape() to dismiss). + +Stage structure (unchanged intent, implementation-constrained) +---------------------------------------------------------------- Stage 1 — The Ward (untimed, hints visible) -------------------------------------------- -A single Shadow Rat ambushes the player. A modal overlay fills the screen. -VoiceOver announces the creature and that the Mark of Z banishes it. -The player draws the two-finger Z. The creature flees. A door opens. - -One creature. Hint visible. Zero pressure. No rank. - -If the player tries double-tap: "The Shadow Rat laughs. Only Fear can banish it." -If the player tries to swipe: "There is nothing to navigate to. Cast the Mark of Z." - -Goal: confidence — "I can make this gesture." - -Stage 2 — The Lich's Tower (hard timer, no hints) --------------------------------------------------- -The Lich sends a sequence of creatures in rapid succession. Each creature is -a modal or sheet that must be dismissed with the Z gesture. The Lich itself -is a stack of three layered threats — each layer requires one scrub to dismiss. - -No hint. Escalating pace. Scored. - -Creatures / modal types: - Shadow Rat — basic alert (one scrub) - Stone Golem — full-screen blocking sheet (one scrub) - Venomous Wisp — action sheet with options (scrub dismisses all options) - The Lich (boss) — three stacked layers, one scrub per layer - -Wrong-action feedback (same as Stage 1, no hint): - The creature shrugs or laughs. VoiceOver confirms the gesture required. - -Goal: mastery under pressure — the gesture becomes automatic. - -Stage 3 — Lights Off: The Dark Tower (hard timer, no visuals) --------------------------------------------------------------- -The dungeon goes dark. No visual creature. No visual Z indicator. -All gameplay information is delivered through three channels only: - - VoiceOver: creature name and "Cast Fear" on arrival; - "Struck" if player hesitates too long; - "[creature] flees" on successful dismissal - Haptics: rhythmic pulse escalating in tempo as creature closes in; - heavy impact if the creature strikes; - success pulse on dismissal - Audio: low growl building in volume as creature approaches; - sharp screech + silence on successful dismissal; - impact sound if struck - -The orb / anchor glyph at screen centre remains visible as the only visual -element — the player has a stable focal point even in darkness. - -Goal: internalisation — the player proves the skill requires no visual support. -This stage demonstrates that VoiceOver + haptics + audio is a complete -information system. - -Wrong-Action Behaviour -====================== -This quest explicitly teaches that prior strategies (double-tap, swipe) do not -work inside a trapped state. Wrong-action feedback must be immediate, thematic, -and non-punishing: - - Wrong gesture: creature mocks the attempt; VoiceOver names the correct gesture - Hesitation: creature strikes; short cooldown; player can immediately retry - Cooldown: minimal — never longer than the haptic + audio cue duration; - the player must be able to retry within two seconds - -Ranks (Stage 2 and Stage 3 scored separately) -============================================= - Legendary: no strikes received, fast completion + - One trap. One successful scrub ends the stage. + - Hint text and VoiceOver copy may name the gesture explicitly. + - No rank; teach the motion. + - Wrong-action lines remain as in §5. + +Stage 2 — The Lich's Tower (timed, hints off) + - Sequence of traps (each dismissible by scrub). + - “Stacked” boss beats may be implemented as successive presentations or a + single container that decrements layers on each successful escape — choose + the variant that keeps the accessibility tree shallow (decision in Phase B). + - Scored; strikes and time feed rank. + +Stage 3 — Lights Off: The Dark Tower + - Same escape contract as Stage 2; creature identity via VO + haptics + + audio; centre anchor remains visible per Lights Off rules in ADR-0003 spirit. + - Scored separately or combined with Stage 2 per storage design (TBD in tech + spec). + +================================================================================ +3. ACCESSIBILITY ARCHITECTURE CONTRACT (DRAFT — REFINE IN PHASE B) +================================================================================ + +During an active trap +--------------------- +- [ ] The view that must be dismissed is reachable without traversing decorative + layers. +- [ ] Performing escape while focus is on that view (or its documented subtree) + dismisses the trap — verified on hardware VoiceOver. +- [ ] Optional: one combined “Quest instruction” element for static copy; not + duplicated as separate hidden views. + +During safe / between traps +--------------------------- +- [ ] Reading order matches hub conventions where possible (short path back to + primary CTA). + +Explicitly out of scope for default play +----------------------------------------- +- Requiring the player to rotor-hunt or manually explore a long list of lane-like + elements to find the escape target. + +================================================================================ +4. WRONG-ACTION BEHAVIOUR +================================================================================ + +Same principles as prior draft: + + Wrong gesture: creature mocks; VoiceOver names scrub / “Mark of Z” (or neutral + skill label if we avoid fantasy in a11y strings — TBD in copy pass). + Hesitation: strike + short recovery; retry within ~2s feel. + Cooldown: minimal; never blocks retry longer than cue playback. + +Double-tap on decoy: “The Shadow Rat laughs. Only Fear can banish it.” (example) +Swipe when nothing to navigate: “There is nothing to navigate to. Cast the Mark of Z.” + +================================================================================ +5. RANKS +================================================================================ + +(unchanged intent) + + Legendary: no strikes, fast completion Skilled: one strike or slightly over threshold time Novice: completed with multiple strikes Defeated: timer expired -Thematic Rank Copy (examples) ------------------------------- - Legendary: "The dungeon falls silent. The creatures will not return." - Skilled: "They fled. The Ward holds." - Novice: "You survived. Practice the Mark until it is instinct." - Defeated: "The darkness wins this round. Try again." - -Acceptance Criteria -=================== -- The quest concept names exactly one skill and one lesson. -- Three stages defined: practice / trial / lights off. -- Wrong-action behaviour specified for each stage. -- Prerequisites and downstream skills identified. -- No overlap with Enchanter's Trial or Crystal Resonance skill content. +Exact thresholds live in GameRules / RankThresholds when implementation starts; +they must be tuned after greybox timing data. + +================================================================================ +6. ACCEPTANCE CRITERIA (UPDATED) +================================================================================ + +Concept +------- +- Names exactly one new skill (two-finger scrub / escape) and one lesson. +- Three stages: practice / timed / lights off. +- Wrong-action behaviour specified; prerequisites and downstream skills stated. +- No reliance on bespoke art for core mechanics (SF Symbol phase must suffice). + +Engineering / QA (before art) +----------------------------- +- [ ] Device VoiceOver: each trap dismisses with scrub without rotor workarounds. +- [ ] No unnamed focusable rects in trap or safe states (audit with linear nav). +- [ ] Wrong-action and success paths announced at appropriate verbosity (rate-limited). +- [ ] Lights Off: escape still works; anchor remains visible; multimodal cues sufficient. + +Art +--- +- [ ] Asset list generated only after criteria above pass; filenames and sizes + align with final layout metrics from Phase B. + +================================================================================ +7. OPEN DECISIONS (RESOLVE IN PHASE B TECH NOTES) +================================================================================ + +- SwiftUI-only presentation vs UIKit-hosted presentation for dismissible traps. +- Single “layer stack” boss vs three sequential modals (accessibility depth vs drama). +- Whether fantasy proper nouns appear in accessibilityLabel or only in visual text + (prefer plain-language a11y strings; see DesignProcess.md). diff --git a/memlog/requirements/Phase2-Roadmap.txt b/memlog/requirements/Phase2-Roadmap.txt index be501f1..a9eaf3f 100644 --- a/memlog/requirements/Phase2-Roadmap.txt +++ b/memlog/requirements/Phase2-Roadmap.txt @@ -72,7 +72,7 @@ M3 - Game Hub (Sandbox) v1 ------------------------- Goal: A usable hub that launches games and shows progress. Includes: -- Hub UI listing 3 games: +- Hub UI listing MVP games from catalog (currently two): - Title, goal, estimated duration. - Best rank badge ("Not played" if absent). - Start action. @@ -103,27 +103,36 @@ Exit Criteria: - Quest completable reliably with VoiceOver on. - Rank persists as best score. -M6 - Crystal Resonance / Dungeon Descent v2 (SwiftUI) [IN DESIGN] +M6 - Crystal Resonance / Dungeon Descent v2 (SwiftUI) [IN PROGRESS] ----------------------------------------------- Goal: Teach three-finger scroll via resonance alignment mechanic. Skill: Three fingers move the page; one finger navigates within it. Note: Replaces the original Scroll Hunt room-list mechanic (see ADR-0003). The Rogue's Gauntlet (originally M6) has been retired from the curriculum. + Implementation status: L0–L3 shell, resonance feedback, and UIKit VoiceOver + scroll proxy (`iOSResonanceVoiceOverScrollProxyRepresentable`) are in-repo; + device QA, copy alignment to Crystal Resonance terminology, and final art/QC + remain open (see `CrystalResonance-Asset-And-Scroll-QC.md`). Spec: ADR-0003, DesignTicket-DungeonResonancePromptSheet.txt Exit Criteria: - Player must scroll to align target with fixed orb to succeed. - Multimodal feedback (haptics, sound, VoiceOver) guides Lights Off stage. - Result persists. -M7 - The Banishment (SwiftUI) [IN DESIGN] +M7 - The Banishment (SwiftUI) [IN PROGRESS] ------------------------------------- Goal: Teach two-finger scrub (Z-shape) to dismiss any trapped state. Skill: VoiceOver escape gesture — exits modals, alerts, sheets, and navigation. -Spec: QuestConcept-Banishment.txt +Spec: QuestConcept-Banishment.txt (gameplay + accessibility tree locked before art; + SF Symbol greybox phase required). +Note: Greybox flow (`the-banishment`), routing, scoring, and localized copy are + implemented; Lights Off polish and on-device VoiceOver escape verification + remain exit criteria. Exit Criteria: - Player banishes creatures (modal presentations) using two-finger scrub. - Lights Off stage: creature approach communicated via haptics and audio only. - Result persists. +- VoiceOver linear navigation and escape verified on device with placeholder UI. M8 - MVP Polish + Release Readiness ---------------------------------- @@ -142,7 +151,7 @@ MVP Definition ============== MVP is M0 through M8. The smallest "complete" MVP for a user is: - Hub + VO gate/help + at least 1 game + persistence. -However, the planned MVP release target includes all 3 games. +However, the planned MVP release target includes the full catalog shipped in-app (currently two quests plus future milestones such as Banishment). Post-MVP Roadmap (High-Level) ============================= diff --git a/memlog/research/CrystalResonance-Asset-And-Scroll-QC.md b/memlog/research/CrystalResonance-Asset-And-Scroll-QC.md new file mode 100644 index 0000000..d444c32 --- /dev/null +++ b/memlog/research/CrystalResonance-Asset-And-Scroll-QC.md @@ -0,0 +1,114 @@ +# Crystal Resonance — asset & VoiceOver scroll QC (living notes) + +**Purpose:** Capture what repeatedly breaks so art, scripts, and UI don’t loop on the same failures. + +**Related:** `DungeonResonanceAssetPipeline.txt`, `ADR-0003`, `CrystalResonance-VoiceOverScrollProxy-Investigation.md`, `utility/remove_white_background.py`. + +--- + +## 1. VoiceOver three-finger scroll — “can’t reach last lane item” + +### Symptoms +- Scroll offset jumps backward (e.g. 588 → 392) after reaching the last slot. +- Logs: `UIKit proxy: updateContentHeight` with **wrong** `bounds.height` (e.g. full-screen) then a smaller playfield height — `maxY = contentSize - bounds` **clamps** `contentOffset` below `(n-1) × laneStep`. + +### Code invariants (iOS) +1. **Lane selection** must start at the **Moonstone row** (`firstIndex(where: \.isTarget)`) when the play surface appears and whenever the room-list identity changes — not slot `0` by default. +2. **UIKit proxy** must not call SwiftUI state from `scrollViewDidScroll` **during** `UIViewRepresentable.updateUIView` (use programmatic flag + deferred callbacks; defer height/offset apply to next run loop). +3. **Scroll content height** must satisfy + `contentHeight ≥ boundsHeight + (n-1) × laneStep + headroom` + for the **smallest** stable playfield height, not a transient layout size. +4. **Trailing slack:** extra `Color.clear` + height term so paging past the last snap does not sit on the `maxY` rail. +5. **`laneStepPoints`** must match **actual** lane row stride. `@ScaledMetric` rows that grow without a fixed frame **drift** from the nominal `rowContentHeightPoints` used for the UIKit proxy — **pin each row** to `iOSDungeonResonanceLaneLayout.rowContentHeightPoints`. + +### When “regenerating” won’t help +This class of bug is **layout/sync**, not PNG. + +--- + +## 2. Grey boxes / dark rectangles around glyphs or reticle + +### Root causes (check in order) +| Cause | How to detect | Fix | +|--------|----------------|-----| +| **RGB PNG** (no alpha) on dark UI | Xcode asset inspector; file shows opaque where art should float | Re-export **RGBA** or run cleanup script | +| **Flat light-grey mat** (not white) | Eyedropper ~230–245 RGB, uniform | Re-export with alpha, or `remove_white_background.py --edge-matte` (QC!), or careful global mode | +| **SwiftUI `.shadow` on `Image(uiImage:)`** | Grey slab behind sprite in ZStack | Remove shadow or use stroke-only highlight | +| **`.compositingGroup()`** on small images | Odd premultiplied bands | Remove unless required | +| **`.blur` on lane rows** | Dark fringes | Avoid blur on stacked PNG lanes | +| **Template rendering** | Tinted / boxed | `.renderingMode(.original)` | +| **Opaque centre in reticle/orb art** | Cannot see lane through ring | **Art:** donut PNG; **Code:** donut `mask` + radial orb mask (see `iOSResonanceReticleRing` / `iOSResonanceCenterOrb`) | +| **L3 Lights Off visuals** | Player peeks glyphs/orb through dim vignette | **Final trial (L3)** uses Enchanter parity: **black playfield** + ``ra11yLightsOffGameplayBlackout`` on `resonanceLaneColumn` only + **hidden** center orb stack—no vignette. Do not add a second global blackout on the scroll proxy (stays clear for VO). | + +### Script vs regenerate +- **`utility/remove_white_background.py`:** Best for **near-white** mats touching the **edge** (`--mode edge`). Does **not** fix mid-image grey boxes unless they touch the border and match matte heuristics (`--edge-matte`). +- **Regenerate:** Prefer when exports are **RGB**, wrong colour space, or grey mat is **interior** / connects to glyph. + +--- + +## 3. Centre stack — “see the glyph underneath” + +**Design intent:** Fixed reticle + orb sit **above** the scrolling lane. If assets are **opaque discs**, the Moonstone disappears under the hub. + +**Mitigations:** +1. **Reticle:** Donut mask (transparent centre) so the ring frames the aim line but the lane shows through (`iOSResonanceReticleDonutMask`). +2. **Orb:** Radial mask (softer centre opacity) + thin stroke glow instead of large `shadow`. +3. **Art:** Prompt for **true alpha** in ring interior and semi-transparent orb core if the code mask is not enough. + +--- + +## 4. Quick device QC checklist (Crystal Resonance L1) + +- [ ] VoiceOver lands on **Moonstone alignment lane**; three-finger scroll moves **all** items including **last**. +- [ ] No grey plate behind Moonstone / decoys / reticle on **dark** shaft. +- [ ] With Moonstone under centre, **glyph remains visible** through ring (and ideally through orb centre). +- [ ] Console: no “**Modifying state during view update**” when scrolling. +- [ ] Assets: lane glyphs are **PNG RGBA** in catalog. + +--- + +## 5. Automated QA script (repeatable PNG gate) + +From repo root, after imports or re-exports: + +```bash +python3 utility/qa_crystal_resonance_png_assets.py +python3 utility/qa_crystal_resonance_png_assets.py --verbose-ok # wide-master OK lines +python3 utility/qa_crystal_resonance_png_assets.py --strict-warnings +``` + +- **Catches:** missing imageset, sprites as **RGB** (no alpha), nearly empty RGBA, suspicious semi-transparent halos (sampled). +- **Does not catch:** GPU-specific checkerboard with technically valid RGBA — still use §4 device checklist. +- **Generative image prompts (DALL·E / Midjourney / etc.):** + ``memlog/requirements/Design/CrystalResonance-ImageGen-PromptTemplate.txt`` + (same content as ``python3 utility/qa_crystal_resonance_png_assets.py --llm-snippet``). + +## 6. Command examples (glyphs only — normalize / repair) + +```bash +# Normalize entire catalog: backgrounds stay RGB; sprites get edge alpha; dungeon_room_* → RGBA opaque +python3 utility/ensure_png_rgba.py --dry-run +python3 utility/ensure_png_rgba.py + +# Safe first pass — white edge mat (single file) +python3 utility/remove_white_background.py --in-place path/to/dungeon_target_moonstone.png + +# Light grey edge mat (inspect output — can eat silver highlights) +python3 utility/remove_white_background.py --in-place --edge-matte path/to/dungeon_decoy_*.png +``` + +--- + +## 7. Off-by-one hub alignment (VoiceOver name vs visible Moonstone) + +### Cause +`resonanceLaneColumn` uses `VStack(spacing:)`, so there is an extra **leading** gap between the top centering spacer and row `0`. Scroll math that used `s + i × laneStep + h/2` omitted that gap: row centers were **one spacing interval** too low, Moonstone sat **below** the reticle hub, and `laneIndexClosestToAimLine` could disagree with the eye. + +### Fix (iOS) +- Row center in content space: `s + laneColumnInterItemSpacing + i × laneStep + h/2`. +- Snapped `contentOffset.y`: `laneRowCenterContentY(i) − playfieldHeight/2` (not `i × laneStep` alone). +- Extra `minForLastSlot` headroom for the larger maximum offset. + +--- + +*Last updated: 2026-04-21 (§5 ``utility/qa_crystal_resonance_png_assets.py``; §6 command renumber; DesignProcess Phase 5 link).* diff --git a/memlog/research/CrystalResonance-VoiceOverScrollProxy-Investigation.md b/memlog/research/CrystalResonance-VoiceOverScrollProxy-Investigation.md index f07d179..67e10f7 100644 --- a/memlog/research/CrystalResonance-VoiceOverScrollProxy-Investigation.md +++ b/memlog/research/CrystalResonance-VoiceOverScrollProxy-Investigation.md @@ -1,7 +1,7 @@ # Crystal Resonance — VoiceOver Scroll Proxy Investigation (SwiftUI) **Date:** 2026-04-19 (ongoing) -**Status:** Open — SwiftUI-based proxy remains unreliable in VoiceOver swipe order; **next step is a UIKit `UIScrollView` integration** for a predictable accessibility tree and scroll behavior. +**Status:** Open for **device QA and layout invariants** — UIKit scroll proxy is **shipped** (`iOSResonanceVoiceOverScrollProxyRepresentable`); remaining risk is focus order, content-height/sync bugs, and regression when chrome or assets change (see QC doc §1). **Related:** [ADR-0003-Dungeon-Resonance-Scroll-Interaction.md](./ADR-0003-Dungeon-Resonance-Scroll-Interaction.md) (design intent; not a guarantee of SwiftUI runtime behavior) --- @@ -36,7 +36,7 @@ Primary implementation: `RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonancePlayView.s ## What we observed (evidence) - Console / `[RA11yScroll]` logs sometimes show **`VO proxy scroll contentOffset.y`** changing — the **underlying** scroll view can move even when VoiceOver focus is wrong. -- Logs show **`playSurface.onAppear`**, **`UIAccessibility.Notification.screenChanged`**, **`accessibilityFocusScrollLane`** binding, and **`dungeon.a11y.scroll.vo.focusAnnouncement`** being posted — **programmatic focus/announce** runs, but does not prove linear swipe order later reaches the scroll proxy. +- Logs show **`playSurface.onAppear`**, **`UIAccessibility.Notification.screenChanged`**, **`layoutChanged`** with the `UIScrollView`, and (historically) a trailing **announcement** — **programmatic focus** runs, but does not prove linear swipe order later reaches the scroll proxy. The extra announcement was removed to avoid double-speak with the scroll view’s label and hint. - Occasional **`Failed to create … image slot … wide=0`** messages — likely a **zero-width layer** (asset/FX path); treat as **separate** from scroll proxy until traced; may correlate with layout glitches. --- @@ -101,7 +101,7 @@ Keep this document updated when the UIKit path lands (files, API boundaries, any | Play surface | `RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonancePlayView.swift` | | Game container / phases | `RA11y-iOS/RA11y-iOS/Games/iOSDungeonDescentView.swift` | | Router / UI testing gate | `RA11y-iOS/RA11y-iOS/App/iOSAppRouter.swift` (`-uiTesting`) | -| Localized scroll proxy label / hint / focus line | `dungeon.a11y.scroll.container`, `dungeon.a11y.scroll.container.hint`, `dungeon.a11y.scroll.vo.focusAnnouncement`; L1 tip `dungeon.resonance.tip.voFocusOnLane` — `RA11y-iOS/RA11y-iOS/Localizable.xcstrings` | +| Localized scroll proxy label / hint (optional legacy announcement key) | `dungeon.a11y.scroll.container`, `dungeon.a11y.scroll.container.hint`, `dungeon.a11y.scroll.vo.focusAnnouncement` (unused by coordinator after 2026-04); L1 tip `dungeon.resonance.tip.voFocusOnLane` — `RA11y-iOS/RA11y-iOS/Localizable.xcstrings` | --- @@ -111,3 +111,6 @@ Keep this document updated when the UIKit path lands (files, API boundaries, any |------|------| | 2026-04-19 | Initial capture after SwiftUI mitigation attempts; records UIKit as follow-up. | | 2026-04-19 | **Shipped interop:** `iOSResonanceVoiceOverScrollProxyRepresentable.swift` — transparent `UIScrollView` with Auto Layout content height, `UIScrollViewDelegate` for `contentOffset.y`, accessibility id/label/hint on the scroll view, initial VO sequence (`screenChanged` → delay → `layoutChanged` with `UIScrollView` → announcement). Wired from `iOSDungeonResonancePlayView` replacing SwiftUI `ScrollView`. | +| 2026-04-21 | Header status updated: problem history remains valid; **current** work is QC invariants + on-device verification, not “waiting on UIKit.” | +| 2026-04-21 | Coordinator: removed redundant `.announcement` (was double-speak with label+hint). | +| 2026-04-21 | Coordinator: **two** `layoutChanged` posts (second after 320 ms) restored for iPad focus landing; still no `.announcement`. | diff --git a/memlog/research/cursor_lights_off_mode_project_planning.md b/memlog/research/cursor_lights_off_mode_project_planning.md new file mode 100644 index 0000000..a3dda8a --- /dev/null +++ b/memlog/research/cursor_lights_off_mode_project_planning.md @@ -0,0 +1,1410 @@ +# Lights off mode project planning +_Exported on 4/20/2026 at 08:56:28 PDT from Cursor (3.1.17)_ + +--- + +**User** + +You can highlight the vertical view for voiceover but the 3 finger scrolling doesn't work. I'm not sure we're on the right track +[2026-04-19T21:34:26.037Z uptime=220885.982s] Cold start — RA11y_iOSApp.init +[2026-04-19T21:34:33.181Z uptime=220893.125s] rootView.body — first render; awaiting route resolution +[2026-04-19T21:34:33.257Z uptime=220893.201s] routeResolution — async entry (before storage reads) +[2026-04-19T21:34:33.259Z uptime=220893.203s] routeResolution complete — 1.4 ms — initial route: hub +HubViewModel.init +hubResultsLoad started — 3 games +hubResultsLoad complete — 1 stored result(s) +App is being debugged, do not track this hang +Hang detected: 4.03s (debugger attached, not reporting) +Unknown client: RA11y-iOS +Game start gating passed for scroll-hunt +Push → dungeonDescent +HubViewModel.init +alignment sample targetMidY=27.5 aimMidY=86.5 delta=59.0 reachable=false vo=true +[RA11yScroll] alignment sample targetMidY=27.5 aimMidY=86.5 delta=59.0 reachable=false vo=true +Haptic cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.warm), family: softTick +Audio cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.warm), family: resonance +ViewModel alignment band→warm reachable=false vo=true +[RA11yScroll] ViewModel band→warm reachable=false +playSurface.onAppear vo=true reducedMotion=false +[RA11yScroll] playSurface.onAppear vo=true reducedMotion=false +Failed to create 0x88 image slot (alpha=1 wide=0) (client=0xdf5569c5) [0x5 (os/kern) failure] +alignment sample targetMidY=637.5 aimMidY=661.5 delta=24.0 reachable=true vo=true +[RA11yScroll] alignment sample targetMidY=637.5 aimMidY=661.5 delta=24.0 reachable=true vo=true +Haptic cue rendered — intent: lockAcquired, family: alignmentSnap +Audio cue rendered — intent: lockAcquired, family: resonance +ViewModel alignment band→locked reachable=true vo=true +[RA11yScroll] ViewModel band→locked reachable=true +lane scroll contentOffset.y 0.0 → -197.0 vo=true +[RA11yScroll] lane scroll contentOffset.y 0.0 → -197.0 vo=true +lane scroll contentOffset.y -197.0 → -267.0 vo=true +[RA11yScroll] lane scroll contentOffset.y -197.0 → -267.0 vo=true +App is being debugged, do not track this hang +Hang detected: 3.66s (debugger attached, not reporting) +accessibilityFocus set to scrollLane (dungeon.resonance.scrollLane) +[RA11yScroll] accessibilityFocus set to scrollLane (dungeon.resonance.scrollLane) +posted VO focus announcement (length=91) +[RA11yScroll] posted VO focus announcement (length=91) +App is being debugged, do not track this hang +Hang detected: 0.94s (debugger attached, not reporting) +App is being debugged, do not track this hang +Hang detected: 4.08s (debugger attached, not reporting) + +--- + +**Cursor** + +Investigating: logs show `contentOffset` changing with `vo=true`, so scrolling can occur—but SwiftUI's `ScrollView` often doesn't forward VoiceOver's three-finger scroll reliably. Exploring ScrollViewReader and a VoiceOver fallback (adjustable actions or nudge controls) so scrolling doesn't depend solely on three-finger gestures. + + + +Confirming logs: `contentOffset` changes while VoiceOver is on — scrolling works, but SwiftUI’s `ScrollView` often doesn’t reliably map VoiceOver’s three-finger scroll to the underlying scroll view. Adding a first-class VoiceOver fallback: `accessibilityAdjustableAction` (one-finger swipe up/down) plus named `accessibilityAction`s that call `ScrollViewReader.scrollTo` to advance the lane by chamber. + + + +Fixing mismatched braces and correcting `onAppear` structure. + + + +Here is what your log line means, and what we changed. + +## What the log already showed + +These lines: + +```text +lane scroll contentOffset.y 0.0 → -197.0 vo=true +``` + +mean the **lane really did scroll** (the `ScrollView`’s offset changed) while VoiceOver was on. So the failure is not “nothing scrolls,” it’s that **VoiceOver’s three-finger scroll often does not map cleanly onto SwiftUI’s `ScrollView`** in the accessibility bridge—on simulator and device—so it *feels* broken even when other code paths move the content. + +Chasing “perfect” three-finger scroll on a SwiftUI `ScrollView` alone is the wrong bet for an a11y-first game. + +## Direction we took: supported VoiceOver controls + +We treated **`ScrollViewReader` + programmatic jumps** as the **supported** way to move the shaft with VoiceOver: + +1. **`accessibilityAdjustableAction`** — With focus on **Crystal shaft scroll list**, use **one-finger swipe up / down** the way VoiceOver uses an *adjustable* control. That calls `scrollTo(_:anchor:)` to move **one chamber** toward deeper or shallower (aligned with `increment` / `decrement`). + +2. **Named Actions** (VoiceOver rotor → **Actions**) — **“Scroll one chamber deeper”** and **“Scroll one chamber shallower”** — same behavior, easier if someone prefers Actions over adjustable. + +3. **Stable IDs** — Each lane row has `.id(room.id)` so `scrollTo` matches the same ids as `DungeonRoom` (`entry_hall`, `guard_room`, …). + +4. **Copy** — Hints and the post-focus announcement now say to use **one-finger adjust** / **Actions**, and that **three-finger may not work** on this SwiftUI surface so expectations match behavior. + +You should see `[RA11yScroll]` lines like `programmatic VO shaft nudge → index …` when using adjust or Actions. + +## Files touched + +- `iOSDungeonResonancePlayView.swift` — `ScrollViewReader`, `nudgeCrystalShaftForVoiceOver`, adjustable + custom actions, `.id(room.id)`, brace/layout fix for `GeometryReader` / `ZStack`. +- `Localizable.xcstrings` — new action names + updated hint / focus announcement. + +**Build:** `RA11y-iOS` builds successfully. + +If you want to go further later, a **UIKit `UIScrollView`** wrapper for the lane is the nuclear option for OS three-finger scroll—but for RA11y, **adjustable + Actions** is the dependable, standards-aligned path. + +--- + +**User** + +I don't want to introduce rotor actions yet this early into the flow. + +--- + +**Cursor** + +Removing VoiceOver custom Actions (rotor) while keeping the one-finger adjustable control and updating copy that references the Actions menu. + + + +--- + +**User** + +1 So swiftUI is the limiting agent right now? +2 we are seeing the accessibillity feature work right with that console output right? +3 Why not use the UIKit solution is that not a good idea for some reason? +4 What about this consideration? +"# Crystal Resonance: VoiceOver Focus & Copy Fix + +## Context + +The player reports: "The 3-finger swiping isn't doing anything. When you tap it says First Attempt. You aren't in the scroll area for the 3-finger swipe to work." + +Three-finger scroll only works in VoiceOver when a `ScrollView` has focus. The current implementation tries to route focus to the scroll lane with a 1.3 s `Task.sleep` hack in `iOSDungeonResonancePlayView.onAppear`. This fails because: + +1. **Navigation title wins.** The NavigationStack title "First Attempt" is the first accessible element VoiceOver lands on after the push transition. The 1.3 s delay expires while VoiceOver is still on the nav bar (or mid-announcement from `announceObjectivePrompt()`), so the `@AccessibilityFocusState` assignment is silently dropped. +2. **Copy is misaligned.** All localization strings still reference the old Scroll Hunt room-list metaphor ("Crystal shaft scroll list", "Guard Room", "Descend to…"). The Crystal Resonance mechanic is about *aligning the Moonstone with the orb*, not descending through chambers. + +The **design approach** (fixed orb + scrollable lane + VoiceOver focus on a `ScrollView` → three-finger scroll) is **architecturally correct** and must be preserved. Only the focus routing and copy need to change. + +--- + +## Critical Files + +| File | Role | +|------|------| +| `RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonancePlayView.swift` | Fix focus routing; add `.accessibilitySortPriority(10)` to ScrollView; replace 1.3 s hack | +| `RA11y-iOS/RA11y-iOS/Localizable.xcstrings` | Update scroll container label/hint + all `dungeon.a11y.*` strings to Crystal Resonance language | + +`iOSDungeonDescentView.swift` and the ViewModel do **not** need structural changes. + +--- + +## Implementation Plan + +### Step 1 — Give the ScrollView natural priority in VoiceOver order + +Add `.accessibilitySortPriority(10)` to the `ScrollView` in `iOSDungeonResonancePlayView`. SwiftUI's default sort priority is `0`; a positive value makes this element come before the `safeAreaInset` chrome elements (objective card, gesture tips, timer) in the VoiceOver swipe order. + +```swift +ScrollView(.vertical) { + resonanceLaneColumn + .padding(.vertical, RA11ySpacing.xl) +} +.scrollIndicators(.visible) +// ... existing modifiers … +.accessibilitySortPriority(10) // ← ADD: surfaces scroll lane before top chrome +``` + +After this change, a player swiping right from the navigation title will land directly on the scroll lane — no programmatic intervention needed. + +### Step 2 — Replace the 1.3 s timing hack with `.screenChanged` + +Remove the `onAppear` `Task` that sleeps 1 300 ms and then assigns `accessibilityFocusScrollLane`. +Replace it with a two-phase approach that: +1. Posts `.screenChanged` (resets VoiceOver traversal to the first accessible element — now the scroll lane due to Step 1) +2. Assigns the `@AccessibilityFocusState` binding 300 ms later as a fallback for the case where the sort priority alone doesn't win + +```swift +.onAppear { + logResonanceScroll("playSurface.onAppear vo=\(UIAccessibility.isVoiceOverRunning) reducedMotion=\(UIAccessibility.isReduceMotionEnabled)") + guard UIAccessibility.isVoiceOverRunning else { + logResonanceScroll("skip programmatic VO focus — VoiceOver off") + return + } + Task { @MainActor in + // Allow the NavigationStack push animation to settle, then + // tell VoiceOver the screen has changed so it refocuses from + // the top (which is now the scroll lane due to sort priority). + try? await Task.sleep(for: .milliseconds(500)) + UIAccessibility.post(notification: .screenChanged, argument: nil) + logResonanceScroll("posted .screenChanged to reset VO traversal") + // Bind as backup in case screenChanged alone doesn't move focus. + try? await Task.sleep(for: .milliseconds(300)) + accessibilityFocusScrollLane = .scrollLane + logResonanceScroll("accessibilityFocus set to scrollLane (backup binding)") + UIAccessibility.post( + notification: .announcement, + argument: String(localized: "dungeon.a11y.scroll.vo.focusAnnouncement") + ) + } +} +``` + +> **Note:** Remove the `guard UIAccessibility.isVoiceOverRunning else { return }` that was *inside* the old Task (it was in the right place but the structure is now cleaner by guarding at the top of `onAppear`). + +### Step 3 — Update localization strings to Crystal Resonance language + +All of the strings below are in `RA11y-iOS/RA11y-iOS/Localizable.xcstrings`. Update only the `"value"` field of the `"en"` entry for each key. + +| Key | Current (Scroll Hunt language) | New (Crystal Resonance language) | +|-----|-------------------------------|----------------------------------| +| `dungeon.a11y.scroll.container` | "Crystal shaft scroll list" | "Moonstone alignment lane" | +| `dungeon.a11y.scroll.container.hint` | "Main scrolling area for this ascent. Three-finger swipes move the lane only while VoiceOver is focused here—not on the navigation title or buttons." | "Scroll lane for this chamber. Three-finger swipes move the lane only while VoiceOver is focused here. Scroll to align the Moonstone with the orb at the center of the screen." | +| `dungeon.a11y.scroll.vo.focusAnnouncement` | "Crystal shaft scroll list is focused. Swipe with three fingers up or down to move the lane." | "Moonstone alignment lane is focused. Swipe with three fingers up or down to scroll the Moonstone toward the orb." | +| `dungeon.a11y.l1.objective.format` | "Objective: Descend to %@. Move VoiceOver to Crystal shaft scroll list (below the heading). Three-finger swipes work only while that item has focus." | "Objective: Align the %@ with the orb. VoiceOver is focused on the Moonstone alignment lane. Use three fingers to scroll up or down until you feel the lock." | +| `dungeon.a11y.l1.objective.hint` | "One-finger swipes move VoiceOver focus only. Focus Crystal shaft scroll list, then three-finger swipe up or down on it." | "Three-finger swipes move the lane. If focus moves away, swipe back to Moonstone alignment lane, then try again." | +| `dungeon.a11y.l2.objective.format` | "Objective: Descend to %@. Three-finger swipes work when Crystal shaft scroll list is focused." | "Objective: Align the %@ with the orb. Keep VoiceOver on Moonstone alignment lane and three-finger scroll to align." | +| `dungeon.a11y.l3.objective.format` | "Objective: Descend to %@. Act quickly. Keep VoiceOver on Crystal shaft scroll list to scroll with three fingers." | "Objective: Align the %@ with the orb. Act quickly. Keep VoiceOver on Moonstone alignment lane and three-finger scroll." | +| `dungeon.explain.gesture.swipe3` | "Three-finger swipe up — descend (scroll down)" | "Three-finger swipe — scroll the lane (move Moonstone)" | +| `dungeon.explain.gesture.swipe3u` | "Three-finger swipe down — ascend (scroll up)" | "Seal button appears when Moonstone aligns with the orb" | + +> `dungeon.explain.gesture.swipe1` ("One-finger swipe — move between chamber markers") is **not shown** in Crystal Resonance (`firstLevelGestureTipCard` only renders `swipe3` and `swipe3u`), so no change needed there. The old `dungeon.a11y.l1.objective` (non-format variant) appears stale — leave as-is unless it causes a compile warning. + +--- + +## What Stays the Same + +- **Design approach**: `ScrollView` + `@AccessibilityFocusState` + `accessibilityRespondsToUserInteraction(true)` — all correct, keep as-is +- **Lane accessibility structure**: `resonanceLaneColumn` with `.accessibilityElement(children: .ignore)` — correct, prevents lane objects from stealing focus +- **Orb/reticle**: `accessibilityHidden(true)` — correct, decorative only +- **Seal button** appearance logic: `targetIsReachable` drive — no change +- **`announceObjectivePrompt()`** in `iOSDungeonDescentView` — keep; fires at 500 ms and is compatible with the new 500 ms `screenChanged` post (they may overlap slightly but both announcements are additive) +- All ViewModel logic, haptics, geometry tracking — no change + +--- + +## Verification + +1. **VoiceOver on, navigate into Crystal Resonance (L1):** After the push transition, VoiceOver should announce "Moonstone alignment lane" within ~1 s without the player swiping at all. +2. **Three-finger swipe immediately after:** Should move the lane. Verify in the Xcode console that `[RA11yScroll]` logs show `contentOffset.y` changing and `vo=true`. +3. **Player swipes right from nav title:** First stop should be "Moonstone alignment lane" (due to sort priority), not the objective card. +4. **Player swipes right from scroll lane:** Should reach the Seal button (when visible) or Hint button in bottom chrome. +5. **VoiceOver off:** `onAppear` guard exits early — no announcements, no focus assignment — no regression for non-VO play. +6. **Seal button appears:** When Moonstone aligns with orb, Seal button appears at bottom; player can swipe to it and double-tap to activate." + +--- + +**Cursor** + +Answering your four points briefly, then implementing the spec: `accessibilitySortPriority`, replacing the timing hack with `screenChanged` + short fallback delay, and updating localization strings. + + + +Implementing the plan: answering 1–3 in prose, then applying code and localization updates. + + + +## 1. Is SwiftUI the limiting piece? + +**Mostly, yes** — for **VoiceOver + three-finger scroll**, the weak link is usually how **SwiftUI’s `ScrollView`** is bridged to the accessibility/scroll system, not your game logic. Your logs already showed **`contentOffset` changing** (`lane scroll contentOffset.y …`), so layout and tracking are fine; the problem is whether **VO’s three-finger gesture** is consistently bound to that scroll view. + +## 2. Does the console output mean accessibility is “working”? + +**Partly.** Lines like **`alignment sample … vo=true`**, **`lane scroll contentOffset.y …`**, and **`ViewModel band→…`** mean: + +- VoiceOver is **on** +- Geometry / reachability / feedback are **updating** +- The **scroll view’s offset can change** (from any scroll path: finger, focus, etc.) + +So **something** in the a11y + scroll pipeline is alive. It does **not** prove three-finger scroll always maps correctly — only that **scroll + VO state** can be observed together. + +## 3. Why not UIKit (`UIScrollView`)? + +**It’s a reasonable trade**, not a mistake: + +- **Pros:** Often **more predictable** VoiceOver + scroll behavior; closer to what Apple documents for scrollable regions. +- **Cons:** **`UIViewRepresentable`**, syncing content with SwiftUI state, safe area / materials / blackout overlays, and **ongoing maintenance** between two UI idioms. + +So: **good idea if three-finger on SwiftUI stays unacceptable in testing**; **not required** if sort priority + `screenChanged` + focus + your **adjustable fallback** are enough. + +--- + +## 4. Your design note — implemented + +Your spec is **applied** as follows: + +| Piece | Change | +|--------|--------| +| **Swipe order** | `.accessibilitySortPriority(10)` on the lane **`ScrollView`** (chrome stays default ~0). | +| **Focus hack** | Replaced **1.3 s sleep-only** with **500 ms** → **`UIAccessibility.post(.screenChanged, …)`** → **300 ms** → **`accessibilityFocusScrollLane = .scrollLane`** → **announcement** string. | +| **`onAppear`** | **`guard UIAccessibility.isVoiceOverRunning`** at **start** of `onAppear` (after initializing `voiceOverLaneRoomIndex`); Task only runs when VO is on. | +| **Copy** | Updated **`dungeon.a11y.*`** scroll + L1/L2/L3 objective strings and **`dungeon.explain.gesture.swipe3` / `swipe3u`** per your table; **`dungeon.a11y.l1.objective`** (announce) aligned to the same **Moonstone / orb** framing. | + +**Still there (per earlier product decision):** `accessibilityAdjustableAction` + **`ScrollViewReader`** nudges as a **fallback** when SwiftUI still doesn’t take three-finger scroll — noted in the type’s doc comment. + +Build **RA11y-iOS** succeeds after these edits. + +**Caveat:** `accessibilitySortPriority` affects **sorting among sibling elements**; with **`safeAreaInset`**, the real VO order can still vary by OS. Treat **sort priority + `screenChanged` + backup focus** as **best-effort**; validate on a **device** with VoiceOver. + +**Note:** The second L1 tip line (`swipe3u`) is now **about the Seal button** per your spec — it’s accurate but **no longer a paired “opposite direction”** gesture to the first line; if that feels odd in UI, we can split into a third line later. + +--- + +**User** + +It still isn't working. 3 finger swiping was working in the practice area we had previously. Why doesn't that translate to our Game? + +--- + +**Cursor** + +Comparing the prologue practice scroll implementation with the Crystal Resonance play view to identify structural differences. + + + +Tracing differences: the prologue practice zone uses a plain, nested `ScrollView` with minimal accessibility modifiers. The game attaches `accessibilityAdjustableAction` to the same `ScrollView`, which can make VoiceOver treat it as an adjustable control (one-finger increment/decrement) and conflict with three-finger scrolling. Removing the adjustable action from the scroll surface and simplifying the scroll view’s accessibility profile to match the practice zone; keeping `ScrollViewReader` only if we add a non-rotor fallback (e.g. small optional controls). Checking the file and applying the fix. + + + +Removing `accessibilityAdjustableAction` from the gameplay `ScrollView` — it makes VoiceOver treat the view as an adjustable control, which hijacks vertical gestures and conflicts with three-finger scrolling. The prologue practice zone is a plain `ScrollView` with only label/hint and geometry observation. Removing the adjustable action, `ScrollViewReader`, and nudge plumbing so the game scroll surface matches that simpler profile. + + + +## Why practice “worked” but the game didn’t + +**L0 practice** (`DungeonPrologueView` → `practiceZone`) uses a **very small, simple `ScrollView`**: text lines, fixed height, `.accessibilityLabel` / `.accessibilityHint`, and **`onScrollGeometryChange`** only. No `ScrollViewReader`, no `accessibilityAdjustableAction`, no focus tricks beyond whatever the system does. + +**Crystal Resonance gameplay** added **`accessibilityAdjustableAction`** on the **same** `ScrollView` you want to three-finger scroll. On that control, VoiceOver treats the element as **adjustable**: vertical one-finger swipes are meant to **increment/decrement**, not to scroll. That **fights** the scroll trait and is the main reason it **doesn’t behave like the practice box**—practice never marked the scroll surface as adjustable. + +Other differences (full-screen **ZStack**, center orb overlay, `safeAreaInset` chrome, flattened lane with **`accessibilityElement(children: .ignore)`**, programmatic **focus** / **`screenChanged`**) can change feel and focus, but the **adjustable-vs-scroll conflict** on one element is the big behavioral mismatch with L0. + +## What we changed in code + +We **removed** from the lane `ScrollView`: + +- **`accessibilityAdjustableAction`** (and the **`ScrollViewReader`** / chamber nudge path that only existed to support it) +- **`accessibilityRespondsToUserInteraction`** (practice doesn’t use it; keeps parity with the working pattern) + +So the lane scroll surface is **much closer to the practice `ScrollView` profile** while keeping **sort priority**, **focus**, and **`screenChanged`** as-is. + +Build **succeeds**. + +--- + +**If three-finger is still weak after this:** the next lever is **UIKit `UIScrollView`** for the lane (same UX, more reliable VO scroll), or a **dedicated** small nudge control that does **not** share the scroll view’s accessibility element (so we never attach adjustable to the scroller again). + +--- + +**User** + +It still didn't work. What if we had a simple invisible view that the accesibility system can interact with that we know works from the practice attempts, and use those scrolls to influence the more complex UI elements hidden beneath it? + +--- + +**Cursor** + +Implementing a VoiceOver-only proxy `ScrollView` (practice-style, minimal accessibility) that drives the lane via clipped offset, and keeping the normal `ScrollView` for non–VoiceOver users. + + + +Implementing a VoiceOver-only, practice-style transparent `ScrollView` overlay that receives three-finger scrolling, while the visible lane is offset to match. Using `@Environment(\.accessibilityVoiceOverEnabled)` so layout switches when VoiceOver is on. + + + +Here’s what we implemented and why it addresses the “practice works, game doesn’t” gap. + +## Idea + +**L0 practice** is a **small, plain `ScrollView`** with simple content and almost no extra accessibility API — that’s the pattern VoiceOver’s **three-finger scroll** attaches to reliably. + +**Crystal Resonance** was one **heavy `ScrollView`** (ZStack, orb, flattened lane, focus, sort priority, etc.), which doesn’t behave the same in the accessibility tree. + +So we **split**: + +1. **VoiceOver on** (`@Environment(\.accessibilityVoiceOverEnabled)`): + - **Underneath:** the real lane (moonstone + decoys) is **not** scrolled by UIKit/SwiftUI; it only **moves** with `.offset(y: -proxyOffset)`. It uses **`allowsHitTesting(false)`** and **`accessibilityHidden(true)`** so VO never tries to scroll “the art,” only the proxy. + - **On top:** a **full-screen, clear, practice-style `ScrollView`** (stack of `Color.clear` rows, same idea as the prologue practice list). Only this view gets **`dungeon.resonance.scrollLane`** + label/hint + focus. + - **`onScrollGeometryChange`** on the proxy sets `voiceOverProxyScrollOffsetY`, which drives the offset of the visual lane so alignment math stays consistent. + +2. **VoiceOver off:** unchanged — a normal **`ScrollView`** for the lane (no proxy, no offset state). + +## What you should see in logs + +When VO is on and you three-finger scroll, look for: + +`VO proxy scroll contentOffset.y …` + +That confirms the **proxy** is scrolling; the lane should move with it. If that appears but the art doesn’t line up, we’d tune **proxy content height** vs. real lane height (they’re approximated from `rooms` today). + +## Tradeoffs + +- **Synchronization:** Proxy scroll extent is an **estimate** (`max(rooms.count * 2, 18)` × 44pt + padding). If it feels “short” or “long” vs. the visible lane, we should tie this to the **same** layout metrics as `resonanceLaneColumn`. +- **`accessibilityVoiceOverEnabled`:** Matches **`iOSHubView`**. If you need the proxy whenever `UIAccessibility.isVoiceOverRunning` is true and the environment lags, we can align the branch with runtime VO as a follow-up. + +Build **RA11y-iOS** succeeds with this change. + +--- + +**User** + +You can never enter a quest without VO enabled. There is no point. I want this explicitly disabled from occuring. We don't need to maintain two flows either for simplicity. + +--- + +**Cursor** + +Searching for quest entry points and existing VoiceOver gating so we can enforce VO-only entry and remove the dual gameplay path. + + + +Adding centralized `pushGame(kind:)` on the router (using `GameStartDecision` + `UIAccessibility`), wiring all entry/restart paths through it, and collapsing `iOSDungeonResonancePlayView` to the VoiceOver proxy path only. + + + +Here is what changed. + +## 1. Single gate: `iOSAppRouter.pushGame(kind:provider:)` + +All normal paths into the three MVP games now go through **`GameStartDecision`** and **`VoiceOverStateProvider`** (default **`iOSLiveVoiceOverStateProvider`**, i.e. **`UIAccessibility.isVoiceOverRunning`**): + +- If VoiceOver **is** running → push **`.enchantersTrial` / `.roguesGauntlet` / `.dungeonDescent`** +- If **not** → push **`.voiceOverInterstitial(kind:)`** (same as before from the hub, but enforced everywhere) + +**Wired to `pushGame`:** + +- **`iOSHubView.startGame`** — removed the duplicate `voiceOverEnabled` branch; only unlock + `router.pushGame(kind:)`. +- **`iOSBasicsSequenceView.launchCurrentGame`** — `router.pushGame(kind: steps[currentIndex])` (removed `gameRoute(for:)`). +- **`iOSRootView`** — **`restartGame(for:)`** maps **`GameResult.gameID`** → **`GameKind`** and calls **`pushGame`** (no more direct game pushes after “Try again”). +- **DEBUG UI-testing** screenshot shortcuts (`-screenshotDirectTo*`) — use **`pushGame`** so they follow the same rule (with VO off you’ll get the interstitial unless the simulator session has VoiceOver on). + +**Manual `push(.enchantersTrial)` etc. should not be used for entering quests** — only **`pushGame`** or **`push(.voiceOverInterstitial)`** as documented on the router. + +## 2. Crystal Resonance: one lane implementation + +**`iOSDungeonResonancePlayView`** no longer branches on **`accessibilityVoiceOverEnabled`**. It always uses the **visual lane + offset** and the **practice-style proxy `ScrollView`**. **`@Environment(\.accessibilityVoiceOverEnabled)`** and the **`onChange` that cleared offset** were removed. + +Quest entry is already gated, so this surface is only reached with VoiceOver on in normal play; **`onAppear`** still guards focus work when VO is off (e.g. SwiftUI previews). + +## Build + +**`xcodebuild` for RA11y-iOS completed successfully** after these edits. + +**Note:** Fastlane / `-screenshotDirectToDungeon` (and similar) now need **VoiceOver enabled** in that run if you want the game screen instead of the VO interstitial. + +--- + +**User** + +The UI tests need to be able to run without VO Enabled. These rules are for user interactions, where we are requireming the user to learn to use VO. This shouldn't impact UI Testing. + +--- + +**Cursor** + +Allowing game routes during UI tests by bypassing VoiceOver gating when the UI-testing launch argument is present. Searching the codebase for how UI testing is detected: + + + +Bypassing VoiceOver gating in `pushGame` when `-uiTesting` is present (matching the project's existing UI-test/snapshot conventions). + + + +Here’s where things stand: + +### Done +- **`pushGame` in `iOSAppRouter`** treats **`ProcessInfo.processInfo.arguments` containing `-uiTesting`** as automation: it **does not** show the VoiceOver interstitial and goes straight to the playable route via `pushPlayableRoute(for:)`. Real launches keep **`GameStartDecision`** + interstitial when VO is off. +- **RA11y-iOS build** (`xcodebuild` for `RA11y-iOS`, generic iOS Simulator) **completed with exit code 0**. +- **No linter issues** on `iOSAppRouter.swift`. + +### For your UITests +- `RA11y_iOSUITests.swift` and screenshot tests already use **`-uiTesting`** in `launchArguments`, so they align with this bypass. + +If you add a new test that **navigates to a game from the hub (or any path that calls `pushGame`)** without launching with `-uiTesting`, it would still hit the interstitial — add **`-uiTesting`** there too. + +--- + +**User** + +The 3 finger swipe still doesn't work. Are the missing instructions to find the correct proxy view? + +--- + +**Cursor** + +Tracing how the three-finger swipe and proxy scroll views are implemented and documented in the app. + + + +Identifying the issue: VoiceOver's three-finger scroll scrolls the scrollable region associated with the focused element—if focus is on the objective/timer (top chrome), three-finger swipes won't move the lane. Checking chrome vs. playfield ordering and strengthening on-screen + hint copy. + + + +Confirming: iOS only scrolls the scrollable view whose accessibility element has focus (or contains the focused element). The container hint already says three-finger swipes work "only while VoiceOver is focused here," but the visible gesture lines don't—and ADR-0003's "focus on tutorial" conflicts with that. Adding explicit visible copy and lowering chrome accessibility sort priority so the lane surfaces earlier in swipe order. + + + +Updating the L1 tip, clarifying the gesture line, strengthening the scroll container hint, and applying negative `accessibilitySortPriority` to chrome so the proxy lane is easier to reach in swipe order. + + + +Restoring the shorter `dungeon.explain.gesture.swipe3` for the L0 prologue gesture rows; resonance-specific guidance lives in the new tip line. + + + +Here is what was going wrong and what we changed. + +### Why it felt “broken” +On iOS, **three-finger scroll is tied to VoiceOver focus**. It scrolls the **scrollable control that owns the focused accessibility element** (or the focused element itself when it is that control). Your **proxy** is the full-screen `ScrollView` labeled **Moonstone alignment lane** (`dungeon.resonance.scrollLane`). If focus is on the **objective / timer / status** in the top inset, a three-finger swipe usually **does not** move that invisible lane. + +So it was not mainly “missing code”—it was **missing or easy-to-miss guidance** that players must be on **that** element, not on chrome. The **hint** on the scroll container already said that; the **visible** L1 tip did not spell out how to recover. + +### What we changed +1. **`dungeon.a11y.scroll.container.hint`** — Clarifies that this is the invisible layer behind the lane, that chrome is different, and that you should **swipe until you hear “Moonstone alignment lane”** before three-finger scrolling. +2. **New `dungeon.resonance.tip.voFocusOnLane`** — Shown on the L1 gesture tip card: if nothing moves, **move VO until it reads Moonstone alignment lane**, then try three fingers again. +3. **`iOSDungeonResonancePlayView`** — Doc comment explaining focus vs. three-finger scroll, and **`.accessibilitySortPriority(-5)`** on top and bottom chrome so the **lane proxy (`10`)** is less likely to lose swipe order to inset content when the system merges trees. +4. Build **succeeds** for `RA11y-iOS`. + +### Quick check when testing +- **Device:** Three fingers, decisive motion; VO should announce **Moonstone alignment lane** (or your focus announcement) before you rely on scrolling. +- **Simulator:** Three-finger simulation is often awkward; if the proxy works on device but not in Sim, it may be the environment, not your layout. + +If you want, we can next add a **one-line rotor hint** (e.g. “Containers”) only in the help sheet or as a short second string—kept separate so we do not overload the lane hint. + +--- + +**User** + +This still doesn't work. There isn't any moonstone aligment lane when swiping from view to view. After the instructions swiping right finds a small rectancle view in the middle of the bottom of the screen, but doesn't announce anything or respond to 3 finger scrolls. + +hubResultsLoad started — 3 games +App is being debugged, do not track this hang +Hang detected: 3.43s (debugger attached, not reporting) +hubResultsLoad complete — 1 stored result(s) +Unknown client: RA11y-iOS +Game start gating passed for scrollHunt +Push → dungeonDescent +HubViewModel.init +alignment sample targetMidY=109.1 aimMidY=127.3 delta=18.2 reachable=true vo=true +[RA11yScroll] alignment sample targetMidY=109.1 aimMidY=127.3 delta=18.2 reachable=true vo=true +Haptic cue rendered — intent: lockAcquired, family: alignmentSnap +Audio cue rendered — intent: lockAcquired, family: resonance +ViewModel alignment band→locked reachable=true vo=true +[RA11yScroll] ViewModel band→locked reachable=true +playSurface.onAppear vo=true reducedMotion=false +[RA11yScroll] playSurface.onAppear vo=true reducedMotion=false +Failed to create 0x88 image slot (alpha=1 wide=0) (client=0xff4f0f37) [0x5 (os/kern) failure] +alignment sample targetMidY=719.1 aimMidY=673.5 delta=45.5 reachable=false vo=true +[RA11yScroll] alignment sample targetMidY=719.1 aimMidY=673.5 delta=45.5 reachable=false vo=true +Haptic cue rendered — intent: lockLost, family: softTick +Audio cue rendered — intent: lockLost, family: warningPulse +Haptic cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.near), family: proximityPulse +Audio cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.near), family: resonance +ViewModel alignment band→near reachable=false vo=true +[RA11yScroll] ViewModel band→near reachable=false +VO proxy scroll contentOffset.y 0.0 → -278.5 +[RA11yScroll] VO proxy scroll contentOffset.y 0.0 → -278.5 +ViewModel alignment band→far reachable=false vo=true +[RA11yScroll] ViewModel band→far reachable=false +VO proxy scroll contentOffset.y -278.5 → -348.5 +[RA11yScroll] VO proxy scroll contentOffset.y -278.5 → -348.5 +App is being debugged, do not track this hang +Hang detected: 3.14s (debugger attached, not reporting) +posted UIAccessibility.Notification.screenChanged to reset VO traversal +[RA11yScroll] posted UIAccessibility.Notification.screenChanged to reset VO traversal +accessibilityFocus set to scrollLane (dungeon.resonance.scrollLane) backup binding +[RA11yScroll] accessibilityFocus set to scrollLane (dungeon.resonance.scrollLane) backup binding +posted VO focus announcement (length=112) +[RA11yScroll] posted VO focus announcement (length=112 + +--- + +**Cursor** + +Tracing the dungeon gameplay view hierarchy and VoiceOver attributes to find the silent bottom rectangle and why the scroll lane isn't in the rotor. + + + +Identifying the issue: when the seal button isn't shown and there's no hint, `sealOrProgressControls` still builds an empty `VStack`, which can produce an unnamed, focusable accessibility element (matches a small silent rectangle at the bottom). Removing the outer `GeometryReader` that can introduce extra accessibility nodes, and collapsing the ScrollView's accessibility representation. + + + +Fixing an empty `VStack` in the bottom chrome (common when the seal isn't available and there's no hint on L1) that can create a nameless VoiceOver focus target, and removing the root `GeometryReader` that can register as an extra accessibility node. Applying the overlay + preference pattern for the aim line and tightening ScrollView accessibility. + + + +Recomputing alignment when either the aim line or target preference updates (target may fire before aim on first layout). + + + +Here’s what was going wrong and what we changed. + +### Root causes (not “missing instructions” only) + +1. **Unnamed bottom rectangle** + On L1, when the moonstone is **not** aligned, **`onHint` is nil**, so there is **no seal** and **no hint**. + `sealOrProgressControls` still built an **`VStack { }` with no children**, and `bottomChrome` always wrapped that in padded material. VoiceOver can focus that as a **blank, non-labeled control** — consistent with “small rectangle at the bottom middle” that **does not speak** and **does not scroll**. + +2. **Root `GeometryReader`** + Wrapping the whole playfield in `GeometryReader { … ZStack }` is a known SwiftUI pattern that often adds an **extra accessibility frame** in the swipe order. That can sit between chrome and the scroll proxy and behave like a mystery target. + +3. **Scroll proxy not a single AX element** + Extra `.accessibilityElement(children: .ignore)` on the scroll content (and the `ScrollView`) reduces the chance that inner placeholders show up as separate focus targets. + +### Code fixes (in `iOSDungeonResonancePlayView.swift`) + +- **`hasPlayfieldBottomControls`**: only add the bottom safe-area inset when there is something to show (continue, seal, or hint). **No empty bottom bar** during most of L1. +- **`ResonanceAimLineGlobalMidYPreferenceKey` + overlay `GeometryReader`**: measure aim-line `midY` from a **hit-invisible, `accessibilityHidden` overlay** instead of a root `GeometryReader`. +- **`applyResonanceAlignmentFromLastFrames()`**: preferences can arrive in **either** order; alignment updates when **either** aim or target updates. +- **ScrollView**: `.accessibilityElement(children: .ignore)` on the scroll view and inner `VStack` so the **Moonstone alignment lane** stays **one** VoiceOver element with your label/hint. + +Your logs already showed **`VO proxy scroll contentOffset`** changing and the **focus announcement posting** — the scroll machinery was fine; focus was likely landing on the **bogus bottom inset** or the **reader frame** instead of `dungeon.resonance.scrollLane`. + +Build **succeeds** after these edits. + +**Note:** `Failed to create 0x88 image slot … wide=0` is a separate Core Animation / asset sizing warning (often a **zero-width** layer). If it persists, we can trace which view still reports zero width; it’s probably not the main VO issue. + +After a rebuild, run through L1 again: when the seal row is empty, the **bottom material bar should disappear**, and swiping should reach **Moonstone alignment lane** with the usual label. If anything still mis-focuses, say whether you’re on **device or Simulator** (Simulator’s 3-finger simulation is unreliable compared to device). + +--- + +**User** + +right swiping from the instructions you can't get to the moonstone alignment lane. This is getting worse. Lets capture what we know about what we've tried into a document for posterity. Then we will just implement the UI kit version. + +--- + +**Cursor** + +Creating a posterity document summarizing the VoiceOver scroll-proxy investigation and attempted fixes. Checking the repo's memlog conventions and the current implementation for accuracy. + + + +Here’s what was added for posterity. + +### New document +**[`memlog/research/CrystalResonance-VoiceOverScrollProxy-Investigation.md`](memlog/research/CrystalResonance-VoiceOverScrollProxy-Investigation.md)** + +It captures: + +- **Problem:** VO swipe order never reliably reaches **Moonstone alignment lane** after instructions; unnamed rects; three-finger scroll depends on focus. +- **Current SwiftUI design:** Transparent full-screen proxy `ScrollView`, mirrored lane offset, `safeAreaInset` chrome, aim line via overlay preference key (no root `GeometryReader`). +- **What we tried:** Copy/hints, `accessibilitySortPriority`, `@AccessibilityFocusState` + `screenChanged` + announcement, collapsing the scroll AX tree, overlay aim measurement + preference ordering, removing empty bottom inset, `-uiTesting` on `pushGame` (automation only). +- **Evidence:** Logs where proxy offset changes vs. focus still wrong; optional note on `wide=0` image warnings. +- **Hypothesis:** SwiftUI + insets + navigation makes linear VO traversal unreliable for this scroll proxy. +- **Next step:** **`UIScrollView` via UIKit interop** with explicit accessibility on the scroll view. +- **Pointers:** Main source file, router, relevant `Localizable` keys. + +### Also updated +- **`memlog/DirectoryTree.txt`** — lists the new file under `memlog/research/`. +- **`memlog/research/ADR-0003-Dungeon-Resonance-Scroll-Interaction.md`** — short **Related engineering notes** section linking to this investigation. + +When you implement the UIKit scroll surface, add a short “Revision history” row and file names to that investigation doc so the story stays complete. + +--- + +**User** + +Proceeed with the UI kit interop version of this game, otherwise we need to come up with another approach. + +--- + +**Cursor** + +Implementing a `UIScrollView`-based VoiceOver scroll proxy via `UIViewRepresentable` and integrating it into `iOSDungeonResonancePlayView`. + + + +Creating the UIKit scroll proxy representable and wiring it into the play view. Updating the investigation doc and DirectoryTree. + + + +Fixing the coordinator: replacing incorrect static storage with instance properties and correcting the VoiceOver scheduling guard. + + + +Fixing content height: `voiceOverProxyScrollableContentHeight` is the inner block (rows + 2·xl); representable adds vertical padding again. + + + +Making the coordinator internal (not `private`) so `UIViewRepresentable` can satisfy the protocol. + + + +Here is what was implemented. + +### UIKit scroll proxy +New file: [`RA11y-iOS/RA11y-iOS/Games/iOSResonanceVoiceOverScrollProxyRepresentable.swift`](RA11y-iOS/RA11y-iOS/Games/iOSResonanceVoiceOverScrollProxyRepresentable.swift) + +- **`ResonanceVoiceOverProxyScrollView`** (`UIScrollView`) — fires a **one-time** callback after first layout in a window so VO setup can run when the view has a real size. +- **`iOSResonanceVoiceOverScrollProxyCoordinator`** — `UIScrollViewDelegate` forwards **`contentOffset.y`** to SwiftUI; runs the **initial VO sequence** when VoiceOver is on: + `500 ms` delay → **`.screenChanged`** → `300 ms` → **`.layoutChanged`** with the **`UIScrollView`** (replaces the old `AccessibilityFocusState` + SwiftUI focus) → **announcement** using `dungeon.a11y.scroll.vo.focusAnnouncement`. +- **Content** — clear `UIView` pinned to `contentLayoutGuide` with a **height constraint** = `contentBlockHeight + 2 × verticalPadding` (aligned with the old SwiftUI `minHeight` + vertical padding). +- **Accessibility** — `isAccessibilityElement = true`, `dungeon.resonance.scrollLane`, label/hint from the caller (same strings as before). + +### Play surface +[`iOSDungeonResonancePlayView.swift`](RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonancePlayView.swift) — SwiftUI **`ScrollView` + `onScrollGeometryChange` + `@AccessibilityFocusState`** removed; replaced by the representable above. **`playSurface.onAppear`** only logs environment; focus is owned by the UIKit coordinator. + +### Docs / tree +- [`memlog/research/CrystalResonance-VoiceOverScrollProxy-Investigation.md`](memlog/research/CrystalResonance-VoiceOverScrollProxy-Investigation.md) — revision row describing the shipped interop. +- [`memlog/DirectoryTree.txt`](memlog/DirectoryTree.txt) — includes the new file under `Games/`. + +### Build +`xcodebuild` for **RA11y-iOS** (**generic iOS Simulator**) **succeeds**. + +`./utility/build_and_test.sh --only-ios` was started; it reached the **Testing** phase and was still running in the environment after several minutes (simulator tests can be slow). Re-run that locally if you want a full green test run. + +**Next step for you:** On a **device** with VoiceOver, confirm swipe order reaches the scroll view and **three-finger** scroll moves the lane; if focus order is still wrong, the next knob is often **container / rotor** or adjusting **when** `layoutChanged` is posted relative to navigation chrome. + +--- + +**User** + +That worked! We'll need a bunch of polish but i'm happy to stop for a bit. Lets take a look at this branch and come up with a PR Description and we're at a good point to merge. + +--- + +**Cursor** + +Reviewing the branch’s changes against the base branch to draft an accurate PR description. + + + +Here is a PR description you can paste into GitHub. The branch `updates-with-enchant-assets-refactor` is large relative to `main`; the text below frames the full scope and highlights the resonance / VoiceOver work you just validated. + +--- + +## PR Title (suggestion) + +**RA11y iOS: games expansion, Crystal Resonance, VoiceOver & screenshot pipeline** + +— + +## Summary + +This branch delivers a substantial slice of the iOS product: three training games with updated art and hub integration, **Crystal Resonance** (scroll-hunt / alignment) gameplay with multimodal feedback, **VoiceOver-first** routing and messaging, **Lights Off** and basics-flow support where wired, a **deterministic screenshot / UI-test harness** (`-screenshotScene`, `-uiTesting`), and **RA11yCore** updates (hub, storage, quest feedback, scoring tests). Repository **agent/build docs** (`AGENTS.md`), **Fastlane**, and **utility scripts** are aligned with dynamic simulator selection and screenshot validation. + +--- + +## User-visible behavior + +- **Hub & navigation:** Quest cards, routing, and loading behavior updated for current game set and flows. +- **Games:** Enchanter’s Trial, Rogue’s Gauntlet, and Dungeon Descent / **Crystal Resonance** with resonance alignment (orb / reticle, lane, seal when aligned), timers on later levels, and result flow. +- **VoiceOver:** Starting a quest still expects VoiceOver for real users; **UI tests and automation** can pass **`-uiTesting`** so **game entry does not require VoiceOver** on the simulator. +- **Crystal Resonance / three-finger scroll:** The playable scroll proxy is a **UIKit `UIScrollView`** bridged via **`iOSResonanceVoiceOverScrollProxyRepresentable`**, replacing the SwiftUI-only scroll proxy so VoiceOver can focus and scroll the lane reliably; programmatic focus uses `screenChanged` → `layoutChanged` (scroll view) → announcement. +- **Screenshots:** Regenerated / extended **iPad** and **iPhone** doc screenshots; catalog and Fastlane allowlists updated where required. + +--- + +## Technical notes + +- **iOS:** Notable files include `iOSAppRouter.swift` (including `-uiTesting` bypass for `pushGame`), `iOSDungeonDescentView.swift` / `iOSDungeonResonancePlayView.swift`, `iOSResonanceVoiceOverScrollProxyRepresentable.swift`, hub/first-run/results/VoiceOver views, `Localizable.xcstrings`, and asset catalog additions. +- **RA11yCore:** Hub view model, storage batching, game session coordinator, quest feedback types/reducer, logging, scoring tests—see package tests for behavior. +- **Tooling:** `utility/build_and_test.sh`, `utility/validate_screenshot_contract.sh`, `utility/ExtractScreenshots.swift`, `utility/ValidateScreenshotContract.swift`, Fastlane/Snapfile, `resolve_simulator_udid.jq` (simulator discovery per `AGENTS.md`). +- **Documentation / memlog:** `DirectoryTree`, requirements and design notes, **ADR-0003** (resonance design), **`CrystalResonance-VoiceOverScrollProxy-Investigation.md`** (history of SwiftUI issues + UIKit follow-up). + +--- + +## Testing / merge checklist + +- [ ] `utility/build_and_test.sh` (or Core + iOS as you usually run). +- [ ] If screenshot routes or anchors changed: `utility/validate_screenshot_contract.sh`. +- [ ] Smoke **VoiceOver on device**: hub → Dungeon / Crystal Resonance → three-finger scroll + seal when aligned. +- [ ] Optional: confirm no **unwanted** tracked files (e.g. IDE `xcuserstate`, `.bak` assets, local `.claude` config) if those are not meant for the repo. + +--- + +## Follow-ups (not blocking this PR) + +- UI/copy **polish** on Crystal Resonance and related strings. +- Any **screenshot** re-runs after final polish. +- Continued tightening of **navigation / focus** order if further VoiceOver tuning is needed outside the scroll proxy. + +--- + +You can shorten the “Summary” for reviewers who only care about the latest commit by adding a one-line callout at the top: + +> **Latest:** VoiceOver scroll for Crystal Resonance now uses a **UIKit `UIScrollView` representable**; confirmed working on device. + +--- + +**User** + +lets grab new screenshots. + +--- + +**Cursor** + +Validating the screenshot contract, then running Fastlane snapshot to capture screenshots. + + + +Here’s what ran and what changed. + +### Ran +1. **`./utility/validate_screenshot_contract.sh`** — passed (Fastfile, scenes, catalog aligned). +2. **`bundle exec fastlane screenshots`** (from `fastlane/`) — **exit 0** (~7.3 minutes). + +### Outputs +- **`docs/screenshots/en-US/iPhone_large/`** — `01_`–`11_` PNGs **updated**; **`12_ResonanceMockup.png`** **new** (untracked). +- **`docs/screenshots/en-US/iPad/`** — same **`01_`–`11_` updated**; **`12_ResonanceMockup.png`** **new** (untracked). +- **`fastlane/screenshots`** is the usual build output (often gitignored); the lane **copies** into **`docs/screenshots/`** for commits. + +### Simulator note +The lane logged **`[simulator] Skipping duplicate: iPhone_small resolved to same UDID as a prior device`**, so **`iPhone_small` was not run** in this pass (same UDID as `iPhone_large`). **`docs/screenshots/en-US/iPhone_small/`** was **not** refreshed this time. + +To capture **small** only on the next run (example): + +```bash +cd fastlane && SNAPSHOT_DEVICES="iPhone SE (3rd generation)" bundle exec fastlane screenshots +``` + +(or pick another small phone name that appears in `xcrun simctl list devices available`). + +### Next step for you +Stage and commit the **`docs/screenshots`** updates, including the new **`12_ResonanceMockup.png`** files on both iPad and iPhone_large. + +--- + +**User** + +docs/screenshots/en-US/iPad/12_ResonanceMockup.png Take a look. There are some weird artifacts in the image. Like the grey rectangles around the Glyphs. + +--- + +**Cursor** + +Inspecting the resonance mockup view and glyph rendering for sources of grey rectangles. + + + +Identifying the cause: iOS 16+ `ScrollView` applies a default scroll content background (often a light grey material) that shows through transparent PNG edges and in letterboxed areas around `scaledToFit()` images. Hiding the scroll content background and tightening image rendering. + + + +Here is what was going on and what we changed. + +### Cause +On recent iOS versions, **`ScrollView` draws a default scroll content background** (a light, material-like layer). That sits **behind** your lane content. For **transparent PNGs** (decoys, moonstone, lane ticks), that layer shows through the soft edges and in the **letterboxed** parts of a square `frame` when using **`scaledToFit()`**, which reads as **grey boxes or halos** around the art—especially in **XCTest screenshots**, where compositing is a bit harsher than on device. + +### Changes (in `iOSDungeonResonanceMockupView.swift`) +1. **`.scrollContentBackground(.hidden)`** on the mockup **`ScrollView`** so the shaft background is what shows through transparency, not the system scroll surface. +2. **`.interpolation(.high)`** on catalog **`Image`**s (decoys, moonstone, lane marker) to reduce scaling fringe. +3. **`.compositingGroup()`** on decoy chips and moonstone so alpha is flattened with the layer before the row’s opacity/compositing (helps odd edge blending in some cases). + +Build succeeds. + +### What you should do +Re-run the **resonance mockup** screenshot (or full `fastlane screenshots`) and check `12_ResonanceMockup.png` again. + +If you still see **grey at the very edge of the PNGs** only, that is often **asset** (semi-transparent grey in the file or export). In Xcode, open the imageset and confirm **Render As** is **Default** (not **Template Image**), and consider re-exporting with a clean alpha. + +--- + +**User** + +You can only 3 finger swipe up and down between 2 ui states. We need to be able to traverse all the items in the vertical stack. Also thee UI needs some work. They should align visually in a satisfying way. Also we need to use the haptic and audio components we developed into the game. +Console snippet. + +[RA11yScroll] VO proxy scroll contentOffset.y 0.0 → 7.0 +alignment sample targetMidY=675.4 aimMidY=603.0 delta=72.3 reachable=false vo=true +[RA11yScroll] alignment sample targetMidY=675.4 aimMidY=603.0 delta=72.3 reachable=false vo=true +VO proxy scroll contentOffset.y 7.0 → 16.7 +[RA11yScroll] VO proxy scroll contentOffset.y 7.0 → 16.7 +VO proxy scroll contentOffset.y 16.7 → 30.0 +[RA11yScroll] VO proxy scroll contentOffset.y 16.7 → 30.0 +Haptic cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.near), family: proximityPulse +Audio cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.near), family: resonance +ViewModel alignment band→near reachable=false vo=true +[RA11yScroll] ViewModel band→near reachable=false +VO proxy scroll contentOffset.y 30.0 → 47.0 +[RA11yScroll] VO proxy scroll contentOffset.y 30.0 → 47.0 +VO proxy scroll contentOffset.y 47.0 → 77.3 +[RA11yScroll] VO proxy scroll contentOffset.y 47.0 → 77.3 +Haptic cue rendered — intent: lockAcquired, family: alignmentSnap +Audio cue rendered — intent: lockAcquired, family: resonance +ViewModel alignment band→locked reachable=true vo=true +[RA11yScroll] ViewModel band→locked reachable=true +Haptic cue rendered — intent: lockLost, family: softTick +Audio cue rendered — intent: lockLost, family: warningPulse +ViewModel alignment band→near reachable=false vo=true +[RA11yScroll] ViewModel band→near reachable=false +Haptic cue rendered — intent: lockAcquired, family: alignmentSnap +Audio cue rendered — intent: lockAcquired, family: resonance +ViewModel alignment band→locked reachable=true vo=true +[RA11yScroll] ViewModel band→locked reachable=true +VO proxy scroll contentOffset.y 77.3 → 112.3 +[RA11yScroll] VO proxy scroll contentOffset.y 77.3 → 112.3 +ViewModel alignment band→near reachable=false vo=true +[RA11yScroll] ViewModel band→near reachable=false +Haptic cue rendered — intent: lockAcquired, family: alignmentSnap +Audio cue rendered — intent: lockAcquired, family: resonance +ViewModel alignment band→locked reachable=true vo=true +[RA11yScroll] ViewModel band→locked reachable=true +ViewModel alignment band→near reachable=false vo=true +[RA11yScroll] ViewModel band→near reachable=false +VO proxy scroll contentOffset.y 112.3 → 149.0 +[RA11yScroll] VO proxy scroll contentOffset.y 112.3 → 149.0 +Haptic cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.warm), family: softTick +Audio cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.warm), family: resonance +ViewModel alignment band→warm reachable=false vo=true +[RA11yScroll] ViewModel band→warm reachable=false +VO proxy scroll contentOffset.y 149.0 → 185.3 +[RA11yScroll] VO proxy scroll contentOffset.y 149.0 → 185.3 +VO proxy scroll contentOffset.y 185.3 → 218.7 +[RA11yScroll] VO proxy scroll contentOffset.y 185.3 → 218.7 +ViewModel alignment band→far reachable=false vo=true +[RA11yScroll] ViewModel band→far reachable=false +VO proxy scroll contentOffset.y 218.7 → 267.3 +[RA11yScroll] VO proxy scroll contentOffset.y 218.7 → 267.3 +VO proxy scroll contentOffset.y 267.3 → 279.7 +[RA11yScroll] VO proxy scroll contentOffset.y 267.3 → 279.7 +VO proxy scroll contentOffset.y 279.7 → 282.7 +[RA11yScroll] VO proxy scroll contentOffset.y 279.7 → + +--- + +**Cursor** + +Implementing VoiceOver traversal of each lane row (scroll view exposes per-row accessibility), aligning lane layout, and wiring quest feedback (haptics/audio) into the gameplay surface. + + + +Implementing per-row VoiceOver elements in the UIKit scroll proxy, aligning scroll content height with the visual lane, and wiring wrong-activation feedback. Checking chip sizes and localization keys. + + + +Implementing: adding layout constants to components, refactoring the scroll proxy, updating the play view and localization, and wiring wrong-activation feedback. + + + +Implementing lane layout constants, refactoring the scroll proxy to add per-row accessibility elements, updating the play view, localization, and wrong-activation feedback. + + + +Writing the refactored scroll proxy representable. + + + +Fixing view-builder type erasure: `Group` prevents the `Image` chain from exposing `.interpolation`. Extracting image branches so modifiers apply to `Image` directly. + + + +Applying `.interpolation` to `Image` before `.resizable()`, which erases the `Image` type. + + + +Here’s what was implemented to finish the handoff. + +### VoiceOver: traverse every chamber row +- `iOSResonanceVoiceOverScrollProxyRepresentable` no longer makes the `UIScrollView` a **single** accessibility element. +- It builds a vertical `UIStackView` of **`ResonanceLaneAccessibilityRowView`** instances—**one label per room**, ids `dungeon.resonance.lane.row.0…`, shared hint `dungeon.a11y.lane.row.hint`. +- Initial focus uses `.layoutChanged` on the **first row** when VoiceOver is on (fallback: scroll view). +- `scheduleVoiceOverLandingIfNeeded` and the struct’s doc comments are updated to match. + +### Scroll ↔ visual alignment +- Added `iOSDungeonResonanceLaneLayout` (`rowSpacingPoints` 56, `rowContentHeightPoints` 76) in `iOSDungeonResonanceComponents.swift` and wired the play `VStack` plus **content height** to the same math (replacing the old `44 × arbitrary rowCount` formula that diverged from the UI). + +### UI polish (grey / stacking) +- Decoy and Moonstone catalog images use `.interpolation(.high)` **before** `.resizable()`, plus `.compositingGroup()` to reduce stray compositing rectangles. + +### Haptics & audio +- **Alignment:** unchanged path—your logs already show `questFeedbackCoordinator` driving proximity/lock; that remains. +- **Wrong seal:** `DungeonDescentViewModel.activateNonTarget` now calls `questFeedbackCoordinator.process(.wrongActivation)` when `screenshotScene == nil`, matching the `hintRequested` guard pattern. + +### Copy +- New keys: `dungeon.a11y.lane.row.decoy`, `dungeon.a11y.lane.row.moonstone`, `dungeon.a11y.lane.row.hint`. +- Updated: `dungeon.a11y.scroll.vo.focusAnnouncement`, `dungeon.a11y.scroll.container.hint` (row-by-row + three-finger behavior). + +`utility/build_and_test.sh` (Core + iOS) completed successfully. + +If anything still feels off on device: very large Dynamic Type can make the **visual** chips taller than the **fixed 76 pt** proxy rows—say if you want those tied to the same `ScaledMetric` next. + +--- + +**User** + +This totally broke the 3 finger swipe. and ability to reach the moonsteon alignment lane. Now there are bunch of views that you can navigate to armory, guardroom, well chamber, etc. These are not related to the moonstone resonance guide at all. What are they doing here? This is a massive regression. + +--- + +**Cursor** + +Reverting the per-row VoiceOver elements — they exposed dungeon room names as separate rotor targets and disrupted VoiceOver's single scrollable surface, breaking three-finger scroll. Restoring a single accessibility element for the Moonstone alignment lane while keeping the corrected scroll-content height and other non-VO fixes. + + + +Restoring the proven single-element scroll proxy: one VoiceOver "Moonstone alignment lane" surface so three-finger scroll works. The per-room labels were incorrect for this metaphor and split focus across chamber names. + + + +--- + +**User** + +Why are there chambers mentioned at all this is a game where you align objects, not navigate a dungeon. You are mixing things up with old requirements. Also there are no audio cues or haptics, but it could just be the error state we are in with weird room navigation artifacts. + +--- + +**Cursor** + +Searching for chamber/dungeon language in resonance + VoiceOver copy and tracing the quest feedback path for alignment haptics/audio. + + + +Reframing copy around alignment (Moonstone, orb, lane)—not dungeon navigation. Removing room names from objectives and hints. Updating the ViewModel hint and localization entries. + + + +Here is what was going wrong and what we changed. + +### Why “chambers” showed up at all +Crystal Resonance is implemented as a **scroll-alignment** game, but the **strings were still written for an older “dungeon descent” pitch**: visible objectives and VoiceOver text used `String(format: …, target.displayName)`, so you got **Guard Room, Armory, Relic Vault**, etc. Those names come from `DungeonRoom.displayName` (art / data labels), not from the resonance mechanic. Putting them in the HUD/objective made it sound like **navigation**, not **lining up the Moonstone with the orb**. + +### What we changed +- **`iOSDungeonResonancePlayView`** – `objectiveText` and `objectiveA11yLabel` no longer take `displayName`. They use fixed copy per level (`dungeon.l1/l2/l3.objective.format` and `dungeon.a11y.l*.objective.format`) that only talk about **Moonstone, center orb, timer, Moonstone alignment lane**. +- **`DungeonDescentViewModel.requestHint()`** – uses new **`dungeon.resonance.hint`** (no room name); doc comments note alignment-only copy. +- **`Localizable.xcstrings`** – updated objectives, timer blurbs, scroll hint, L1 tip, seal hint, continue hint, and stale `dungeon.l*.objective` entries so they match the **alignment** metaphor. Added **`dungeon.resonance.hint`**. +- **`DungeonRoom`** doc comment – clarifies that **`displayName` is for assets / edge cases**, not VO “where to go next.” + +Narration/prologue lines that still say “shaft / chambers” (e.g. DM prompts) are **story flavor**; the **live HUD + VO objectives** for the shaft are now consistent with “align objects.” + +### Haptics and audio +Feedback is still driven from **`updateResonanceDelta`** → `questFeedbackCoordinator.process(.alignmentBandChanged(band))` whenever **`screenshotScene == nil`** and the band actually changes. The profile **`QuestFeedbackProfile.dungeonResonance`** maps those to haptics and synthesized tones. + +If cues feel missing: + +- **Simulator:** Taptic feedback is not produced the same way as on device; rely on logs like `Haptic cue rendered` / `Audio cue rendered` or test on hardware. +- **Device:** Confirm **Settings → Sound**, ringer, and that feedback is not covered by system volume; alignment must **cross band thresholds** (idle at one band won’t repeat cues until the band changes). + +`utility/build_and_test.sh --only-ios` completed successfully after these edits. + +--- + +**User** + +Are you sure that we don't need to fix the images. They still have these big grey borders around the glyphs. +We will only be manually verifying on device, so no worries about the simulator for that context. + +--- + +**Cursor** + +Tracing how glyph images are drawn and inspecting asset configuration to determine whether borders come from the assets or from rendering. + + + +Identifying the issue: all four glyph PNGs are RGB (color type 2) — no alpha. They include opaque canvas areas, so SwiftUI scales the full rectangle and shows visible borders. Documenting this and adding contributor guidance. Re-exporting assets as RGBA with tight crops will fix it; code can't add transparency that isn't in the files. + + + +Confirming: all four glyphs are PNG color type 2 (RGB) — no alpha. Opaque letterboxing/light backgrounds become visible rectangles when scaled. Running the repo's edge white-removal utility to produce RGBA assets. + + + +Yes — the **images needed fixing**, not just SwiftUI. + +### What was wrong +Those glyph PNGs (`dungeon_target_moonstone`, `dungeon_decoy_*`) were **PNG RGB (color type 2)** — **no alpha**. The artwork sat on a full **1376×768 opaque canvas** (white / light matte). SwiftUI scales the **whole bitmap** into the small frame, so you see a **big rectangular panel** behind the sprite. That reads as grey or light “borders” on your dark shaft, and no amount of `.compositingGroup()` can invent transparency that isn’t in the file. + +### What we did +1. Ran the repo’s **`utility/remove_white_background.py`** in **`--in-place`** mode (edge-connected near‑white → transparent) on all four glyphs so they are now **PNG RGBA (color type 6)**. +2. Removed the generated **`.png.bak`** backups so they aren’t committed. +3. Documented the requirement in **`iOSDungeonResonanceArt.swift`** and **`DungeonResonanceAssetPipeline.txt`**. + +### What you should check on device +- **Fringing / holes:** Edge mode only clears background that is **white and connected to the image border**. If any glyph had **white that connects art to the frame** in a bad way, you could get odd transparency; also **internal** light areas that touch the border might be affected. Your manual pass on hardware is the right check. +- **Finer art pass:** For pixel‑perfect edges, re‑export from the design tool with **proper alpha** (and tighter crops) is still the gold standard; the script is a solid engineering fix for the old RGB masters. + +So: **yes, it was an asset issue (missing alpha)**; we’ve corrected the shipped PNGs accordingly. SwiftUI tweaks alone wouldn’t have been enough. + +--- + +**User** + +Moonstone lane is focused but 3 finger swiping is broken. You can also not navigate to the instructions or title views anymore in the right order. You have to go past the moonstone lane a few swipes before you can find it. We are breaking lots of things here. + +--- + +**Cursor** + +Inspecting VoiceOver sort priorities and view layering — likely causes of wrong reading order and broken three-finger scroll. + + + +Identifying issues: +1. Programmatic `UIAccessibility.post(.layoutChanged, argument: scrollView)` forces focus onto the lane, skipping the navigation title and top chrome (objectives/instructions). +2. `accessibilitySortPriority(10)` on the scroll proxy vs `-5` on chrome makes the lane sort before the HUD — backwards for top-to-bottom reading. + +Fixing both: removing the forced focus chain and correcting sort priorities so top chrome → lane → bottom chrome. + + + +Stopping programmatic `layoutChanged` that moved VoiceOver to the scroll view (skipping the nav title and instructions). Fixing inverted `accessibilitySortPriority`: higher values sort first, so the lane at `10` was appearing before chrome at `-5`. + + + +Here’s what was going wrong and what we changed. + +### 1. Programmatic focus was skipping the HUD +`scheduleVoiceOverLandingIfNeeded` was posting **`UIAccessibility.layoutChanged` with the `UIScrollView`**, which **moved VoiceOver straight to “Moonstone alignment lane”** after the delays. That **skipped the navigation chrome and the top HUD** (objective + gesture tips), so the experience felt out of order and people didn’t get instructions before the lane. + +**Change:** That path **no longer** posts `screenChanged`, `layoutChanged`, or the lane **announcement**. Focus follows the normal view order so the **title and instructions come first**; people move to the lane when they’re ready (same idea as your L1 copy about focusing the lane). + +### 2. `accessibilitySortPriority` was backwards +In SwiftUI, **larger** `accessibilitySortPriority` values are **earlier** in the VoiceOver order. + +You had: + +- Moonstone lane: **`10`** +- Top / bottom chrome: **`-5`** + +So the **lane sorted before the HUD**—the opposite of top-to-bottom reading. That matches “you have to swipe past the lane a bunch before things make sense.” + +**Change:** Introduced explicit tiers: + +- **Top HUD** (objective, timer, gesture tips): **`30`** +- **Moonstone alignment lane** (UIKit scroll proxy): **`20`** +- **Bottom** (seal, hint, continue, timeout): **`10`** + +So the intended swipe flow is: **HUD → lane → bottom actions** (with the system nav title where the stack puts it). + +### Files touched +- `iOSResonanceVoiceOverScrollProxyRepresentable.swift` — removed forced VO posts; documented why. +- `iOSDungeonResonancePlayView.swift` — `iOSResonancePlayAccessibilitySortTier` + updated priorities and docs. + +`utility/build_and_test.sh --only-ios` completed successfully. + +On device, confirm **linear swipe** order and that **three-finger scroll** works with focus on **Moonstone alignment lane** after you’ve reached it in order; if anything still feels off, say whether **RTL** or a **custom nav** is in play and we can tune further (e.g. container grouping). + +--- + +**User** + +Moonstone alignment lane is focused, but the 3 finger swipe doesn't work] ViewModel band→near reachable=false + +[RA11yScroll] playSurface.onAppear vo=true reducedMotion=false +Failed to create 0x88 image slot (alpha=1 wide=0) (client=0x1a6865d1) [0x5 (os/kern) failure] +alignment sample targetMidY=719.1 aimMidY=139.3 delta=579.8 reachable=false vo=true +[RA11yScroll] alignment sample targetMidY=719.1 aimMidY=139.3 delta=579.8 reachable=false vo=true +ViewModel alignment band→far reachable=false vo=true +[RA11yScroll] ViewModel band→far reachable=false +Haptic cue rendered — intent: lockAcquired, family: alignmentSnap +Audio cue rendered — intent: lockAcquired, family: resonance +ViewModel alignment band→locked reachable=true vo=true +[RA11yScroll] ViewModel band→locked reachable=true +Haptic cue rendered — intent: lockLost, family: softTick +Audio cue rendered — intent: lockLost, family: warningPulse +Haptic cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.near), family: proximityPulse +Audio cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.near), family: resonance +ViewModel alignment band→near reachable=false vo=true +[RA11yScroll] ViewModel band→near reachable=false +Haptic cue rendered — intent: lockAcquired, family: alignmentSnap +Audio cue rendered — intent: lockAcquired, family: resonance +ViewModel alignment band→locked reachable=true vo=true +[RA11yScroll] ViewModel band→locked reachable=true +ViewModel alignment band→near reachable=false vo=true +[RA11yScroll] ViewModel band→near reachable=false +Haptic cue rendered — intent: lockAcquired, family: alignmentSnap +Audio cue rendered — intent: lockAcquired, family: resonance +ViewModel alignment band→locked reachable=true vo=true +[RA11yScroll] ViewModel band→locked reachable=true +ViewModel alignment band→near reachable=false vo=true +[RA11yScroll] ViewModel band→near reachable=false +UIKit proxy: no programmatic layoutChanged — preserves nav + HUD VoiceOver order before the lane +[RA11yScroll] UIKit proxy: skipped layoutChanged/announcement — VO order uses sortPriority + swipe +Haptic cue rendered — intent: lockAcquired, family: alignmentSnap +Audio cue rendered — intent: lockAcquired, family: resonance +ViewModel alignment band→locked reachable=true vo=true +[RA11yScroll] ViewModel band→locked reachable=true +Haptic cue rendered — intent: lockLost, family: softTick +Audio cue rendered — intent: lockLost, family: warningPulse +ViewModel alignment band→near reachable=false vo=true +[RA11yScroll] ViewModel band→near reachable=false +Haptic cue rendered — intent: lockAcquired, family: alignmentSnap +Audio cue rendered — intent: lockAcquired, family: resonance +ViewModel alignment band→locked reachable=true vo=true +[RA11yScroll] ViewModel band→locked reachable=true +App is being debugged, do not track this hang +Hang detected: 2.98s (debugger attached, not reporting) +ViewModel alignment band→near reachable=false vo=true +[RA11yScroll] ViewModel band→near reachable=false +Bound preference ResonanceAimLineGlobalMidYPreferenceKey tried to update multiple times per frame.SPACE + +--- + +**Cursor** + +Tracing geometry: logs show aimMidY ≈ 139 vs targetMidY ≈ 719 — aim-line measurement looks wrong. Checking how we measure the aim line and whether the layout causes a zero-size geometry (matches the `wide=0` error). + + + +Identifying two issues: +1. Scroll: UIScrollView only scrolls when content height > bounds. We switched to accurate lane math; if intrinsic lane height < the viewport, content won't scroll — breaking three-finger scroll. +2. Aim line: Overlay `GeometryReader` without a guaranteed size can collapse (matches `wide=0` / bogus `aimMidY≈139`). We should measure from the full play area (or orb stack) and coalesce preference updates. + +Implementing fixes: measuring viewport height, setting `contentBlockHeight = max(intrinsicLane, viewport - 2*padding + slack)`, moving aim-line preference to a full-frame background on `centerOrbStack`, and debouncing preference updates. + + + +Implementing: ensuring scroll content height exceeds the viewport (with matching SwiftUI spacer so offsets stay aligned), fixing aim-line geometry via a full-size measurement on `centerOrbStack`, and coalescing preference updates. + + + +Here’s what was going wrong and what we changed. + +### 1. **Why three-finger scroll did nothing** +`UIScrollView` only scrolls when **`contentSize.height > bounds.height`**. The lane’s **intrinsic** height (a few rows) was often **shorter than the playfield**. Content height matched the lane, so there was **nothing to scroll** — `contentOffset` barely moved, so VoiceOver’s three-finger scroll had no effect even though the lane was focused. + +**Change:** Measure the playfield with `ResonancePlayfieldViewportHeightPreferenceKey`, then set +`voiceOverLaneTotalScrollBlockHeight = max(intrinsicLane, playfieldHeight - 2×verticalPadding + 32)`. +Add a **`Color.clear` spacer** at the bottom of the lane `VStack` with height +`max(0, totalScrollBlock − intrinsic)` so the **SwiftUI lane** matches the **UIKit** scroll content height and offsets stay in sync. + +### 2. **Why `aimMidY` looked like ~139 vs target ~719** +The old **overlay** `GeometryReader` wasn’t guaranteed to fill the area (collapsed / wrong layout). That produced a bad **global** frame (and could contribute to **`Failed to create 0x88 image slot ... wide=0`** style issues). + +**Change:** Stop using that overlay for the aim line. Attach the aim measurement to **`centerOrbStack`** with a **full-size** `GeometryReader` in a **background** and `.frame(maxWidth: .infinity, maxHeight: .infinity)`, so **`geo.frame(in: .global).midY`** is the **vertical center of the orb stack** (the real aim line). + +### 3. **Preference churn** +Throttled `onPreferenceChange` for aim and moonstone (ignore changes **< 0.25 pt**) to cut duplicate work and the **“tried to update multiple times per frame”** warning. + +--- + +`utility/build_and_test.sh --only-ios` completed successfully. On device you should see **sane alignment deltas** (aim and moonstone in the same vertical ballpark when lined up) and **scroll** with **contentOffset** changing in logs when using three fingers on the lane. If anything is still off, say whether it’s **L1 with few rooms** or **iPad** so we can tune the **+32** slack. + +--- + +**User** + +Still broken, even when focused on the right view. + +HubViewModel.init +alignment sample targetMidY=109.1 aimMidY=139.3 delta=30.2 reachable=false vo=true +[RA11yScroll] alignment sample targetMidY=109.1 aimMidY=139.3 delta=30.2 reachable=false vo=true +Haptic cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.near), family: proximityPulse +Audio cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.near), family: resonance +ViewModel alignment band→near reachable=false vo=true +[RA11yScroll] ViewModel band→near reachable=false +playSurface.onAppear vo=true reducedMotion=false +[RA11yScroll] playSurface.onAppear vo=true reducedMotion=false +alignment sample targetMidY=10.3 aimMidY=139.3 delta=129.0 reachable=false vo=true +[RA11yScroll] alignment sample targetMidY=10.3 aimMidY=139.3 delta=129.0 reachable=false vo=true +ViewModel alignment band→far reachable=false vo=true +[RA11yScroll] ViewModel band→far reachable=false +Haptic cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.warm), family: softTick +Audio cue rendered — intent: proximityEntered(RA11yCore.QuestFeedbackBand.warm), family: resonance +ViewModel alignment band→warm reachable=false vo=true +[RA11yScroll] ViewModel band→warm reachable=false +Bound preference iOSResonanceTargetMidYPreferenceKey tried to update multiple times per frame. +Bound preference ResonancePlayfieldViewportHeightPreferenceKey tried to update multiple times per frame. +Failed to create 0x88 image slot (alpha=1 wide=0) (client=0x8f58f896) [0x5 (os/kern) failure] +Bound preference ResonanceAimLineGlobalMidYPreferenceKey tried to update multiple times per frame. +UIKit proxy: no programmatic layoutChanged — preserves nav + HUD VoiceOver order before the lane +[RA11yScroll] UIKit proxy: skipped layoutChanged/announcement — VO order uses sortPriority + swipe +App is being debugged, do not track this hang +Does VO accessibility swipes for example register inthe UIResponder chain that you can examine and add print statements? This could be helpful for debugging. + +What about this feedback +# Crystal Resonance 3-Finger Swipe: Why It's Brittle & How to Protect It + +## Context + +The 3-finger VoiceOver swipe in Crystal Resonance relies on a transparent `UIScrollView` proxy overlaid on the entire playfield. iOS natively delivers 3-finger scroll events to whatever `UIScrollView` has current VoiceOver focus — **no gesture recognizer, no `accessibilityAction`**. That means the gesture lives entirely at the mercy of VoiceOver's focus chain. Any change to the view hierarchy that disrupts focus order, intercepts touches, or hides the scroll proxy from VoiceOver silently kills it. + +The current working tree has uncommitted changes across all five gameplay files. The committed code (HEAD) appears correct; the breakage is in-progress work. This plan identifies the specific failure modes and provides a repeatable checklist. + +--- + +## Why It Breaks: The Five Root Causes + +### 1. VoiceOver focus not landing on the scroll proxy +**The mechanism:** Three-finger swipe only scrolls the view that *currently* has VoiceOver focus. If focus is on the objective card, timer HUD, or any other element, the gesture is swallowed by iOS and the lane doesn't move. + +**What breaks it when iterating:** +- Adding a new accessibility element (status badge, progress ring, overlay card) in the ZStack without an explicit `accessibilitySortPriority` that places it at tier 30 (top chrome) or tier 10 (bottom chrome). If its priority falls at 20 or is unset, VoiceOver may stop there instead of reaching the scroll proxy. +- Adding `.isAccessibilityElement = true` to any view inside the playfield ZStack. +- Removing `.accessibilityHidden(true)` from the decorative lane column — each lane row then becomes a separate VoiceOver stop between the top chrome and the scroll proxy. +- Removing or weakening `.accessibilityElement(children: .ignore)` on `resonanceLaneColumn`. + +**The invariant to protect:** The `iOSResonanceVoiceOverScrollProxyRepresentable` must be the **only** interactive AX element in the ZStack body. Everything else is either `accessibilityHidden(true)` or lives in `topChrome`/`bottomChrome` at their sort tiers. + +--- + +### 2. Hit testing intercepting the scroll view +**The mechanism:** The UIScrollView receives touches via UIKit's responder chain. If any SwiftUI view is layered above it in the ZStack with `allowsHitTesting(true)` (the default), that view consumes touch events before they reach the scroll view. + +**What breaks it when iterating:** +- Adding a non-decorative overlay (e.g., a fullscreen success flash, a progress vignette, a "lights on" mode reveal) above the scroll proxy in ZStack order without `allowsHitTesting(false)`. +- The existing `iOSResonanceLightsOffVignette` already has `allowsHitTesting(false)` — new overlays need the same. + +**The invariant to protect:** Every view in the ZStack above the `iOSResonanceVoiceOverScrollProxyRepresentable` entry must carry `.allowsHitTesting(false)`. + +--- + +### 3. Content height dropping below viewport (scroll range collapses to zero) +**The mechanism:** `UIScrollView` only scrolls when `contentSize.height > bounds.height`. The proxy calculates `voiceOverLaneTotalScrollBlockHeight` to ensure this, but the calculation depends on `playfieldViewportHeight` which comes from a `GeometryReader` preference key. + +**What breaks it when iterating:** +- New `safeAreaInset` additions change the available height but `playfieldViewportHeight` reflects the ZStack frame, not the insets. If viewport shrinks without the scroll proxy knowing, content height may no longer exceed bounds. +- Changes to `RA11ySpacing.xl` (used as `verticalPadding` in `computeTotalContentHeight`) will mis-size the proxy relative to the lane. +- Changing `rowContentHeightPoints` or `rowSpacingPoints` in `iOSDungeonResonanceLaneLayout` without adjusting `voiceOverLaneIntrinsicBlockHeight` — the proxy and the visual lane grow apart, so the scroll stops where the lane does. + +**The invariant to protect:** `computeTotalContentHeight()` in the proxy must always exceed the proxy's rendered height. After any layout change, log `[RA11yScroll]` lines and verify `contentOffset.y` changes when scrolling. + +--- + +### 4. UIViewRepresentable identity reset (offset snaps to zero) +**The mechanism:** SwiftUI calls `makeUIView` (not `updateUIView`) when the representable's identity changes. `makeUIView` constructs a fresh UIScrollView at offset 0. The SwiftUI state `voiceOverProxyScrollOffsetY` may still hold the old value, creating a visual/scroll mismatch. + +**What breaks it when iterating:** +- Wrapping `iOSResonanceVoiceOverScrollProxyRepresentable(...)` in a new container with a changing `id(...)` modifier — this forces identity reset on every render. +- Moving the representable inside a conditional `if/else` block or a `switch` — SwiftUI may tear down and rebuild it on state changes. +- Phase transitions (L1 → L2 → L3) already reconstruct the view because `iOSDungeonResonancePlayView` is replaced wholesale in the `switch viewModel.phase` block in `iOSDungeonDescentView`. This is fine. What would break it is any new `if` branch *inside* `iOSDungeonResonancePlayView`'s `body` that conditionally shows/hides the proxy. + +**The invariant to protect:** The proxy should be unconditionally present in the ZStack body (no `if` guards around it). State changes should flow through `updateUIView` props, not view identity. + +--- + +### 5. Offset sync gap on level transition +**The mechanism:** When the phase changes, `voiceOverProxyScrollOffsetY` resets to 0 (fresh `@State`). But the newly-created UIScrollView also starts at 0, so they're in sync. The risk is partial resets: if `voiceOverProxyScrollOffsetY` is manually set to 0 (e.g., in a retry path) but the UIScrollView retains its old `contentOffset`, the visual lane and scroll position diverge. + +**What breaks it when iterating:** +- Adding a "reset lane" action (e.g., on wrong-target activation, on hint, on timer threshold) that sets `voiceOverProxyScrollOffsetY = 0` without also scrolling the UIScrollView back to `contentOffset = .zero`. +- The proxy has no `scrollToTop()` API surface currently. If a reset is needed, it must be added. + +--- + +## Best Practices Checklist (Run After Any Change to These Files) + +``` +iOSDungeonResonancePlayView.swift +iOSResonanceVoiceOverScrollProxyRepresentable.swift +iOSDungeonResonanceComponents.swift +iOSDungeonDescentView.swift +``` + +### Before building: +- [ ] Every new view added to the ZStack body: does it have `allowsHitTesting(false)` if it's above the scroll proxy entry? +- [ ] Every new accessibility element added to the ZStack body: is it `accessibilityHidden(true)` OR does it have an explicit `accessibilitySortPriority` at tier 30 or 10? +- [ ] Is `iOSResonanceVoiceOverScrollProxyRepresentable` unconditionally rendered (no `if` guards)? +- [ ] Did you change `RA11ySpacing.xl`, `rowContentHeightPoints`, or `rowSpacingPoints`? → Update the lane height math. + +### After building, on device with VoiceOver on: +- [ ] Swipe right until VoiceOver announces the scroll proxy label ("Moonstone alignment lane" / localized). This must be reachable. +- [ ] While focused on the proxy, 3-finger swipe down. Lane moves. 3-finger swipe up. Lane moves back. +- [ ] Log line `[RA11yScroll] VO proxy scroll contentOffset.y` appears in Xcode console on swipe. +- [ ] Alignment `[RA11yScroll] alignment sample` lines appear as the lane scrolls. +- [ ] The Seal button appears in bottom chrome when the moonstone reaches the aim line. + +--- + +## Files to Modify + +| File | Change | +|------|--------| +| `iOSDungeonResonancePlayView.swift` | Primary surface for new requirements; guard ZStack additions with the checklist above | +| `iOSResonanceVoiceOverScrollProxyRepresentable.swift` | Only touch if scroll range or reset behavior changes | +| `iOSDungeonResonanceComponents.swift` | Lane layout constants live here; changes cascade to scroll math | + +## Verification + +End-to-end test on device (Simulator doesn't reproduce VoiceOver scroll behavior reliably): +1. VoiceOver on → launch → navigate past nav title and top HUD +2. Hear the scroll proxy announced → 3-finger swipe → lane moves → orb changes band +3. Align moonstone → Seal button activates → level completes +4. Advance to L2 → repeat; verify swipe still works after phase transition + + +--- + +**Cursor** + +Fixing alignment by using a single named coordinate space (replacing unstable `.global` frames) and adding scroll-proxy debug hooks. Reading the file and implementing. + + + +Switching alignment to a named playfield coordinate space (replacing unstable `.global` frames) and adding scroll-range diagnostics. Applying the changes. + + + +Here’s what was going wrong, what we changed, and how it ties to your questions. + +### Root cause: `.global` frames were wrong +`geo.frame(in: .global).midY` for the moonstone and aim line could be **nonsense during layout** (e.g. `targetMidY=10.3` vs `aimMidY=139.3`). That’s the same class of issue as **`Failed to create 0x88 image slot … wide=0`**: geometry wasn’t stable yet, so alignment and feedback were unreliable. + +### Fix: one named playfield space +1. **`ResonancePlayfieldCoordinateSpace.name`** (`"resonancePlayfield"`) on the gameplay **`ZStack`** via `.coordinateSpace(name:)`. +2. **Moonstone** preference now uses + `geo.frame(in: .named(ResonancePlayfieldCoordinateSpace.name)).midY` + so the target is in **playfield** coordinates. +3. **Aim line** is no longer measured with a separate `GeometryReader`. It’s **`playfieldViewportHeight * 0.5`** in that same space (vertical center of the playfield = center orb line). +4. **`iOSResonanceAlignment.deltaPoints(targetMidY:aimMidY:)`** — parameters renamed; still `abs(a - b)` in one shared space. + +Logs should look like: +`alignment sample playfieldH=… targetMidY=… aimMidY=…` with **target** and **aim** both near **`playfieldH/2`** when the Moonstone is aligned. + +### Preference spam +`onPreferenceChange` for viewport and target now **`await Task.yield()`** before updating state so updates tend to land on the **next** frame, which reduces “multiple times per frame” churn (may not remove it entirely; that’s a SwiftUI quirk). + +### VoiceOver swipe vs `UIResponder` +**Three-finger VoiceOver scrolling** is handled by the system for the focused `UIScrollView`; it is **not** a normal `UIGestureRecognizer` you can trace like pan gestures. The practical way to debug is what you already have: **`scrollViewDidScroll`**, **`contentOffset`**, and **`[RA11yScroll] VO proxy scroll contentOffset.y`** in the bridge. + +Documentation was added on **`iOSResonanceVoiceOverScrollProxyRepresentable`**. We also set **`isScrollEnabled = true`** explicitly and, in **DEBUG**, log **once** if **`contentSize.height <= bounds.height`** (no scroll range → VO scroll cannot move offset). + +### Your checklist (the “five root causes”) +The code already follows most of it: lane **`accessibilityHidden`**, **`allowsHitTesting(false)`** on overlays, proxy **not** behind conditional identity, sort tiers for VO. The alignment bug was **geometry**, not focus order. Keeping **`iOSDungeonResonancePlayView` / proxy / components** in sync when changing spacing or lane math is still the right manual checklist. + +`utility/build_and_test.sh --only-ios` completed successfully. On device, you should see sane **`playfieldH` / `targetMidY` / `aimMidY`** and scroll logs when the lane scrolls. + diff --git a/utility/__pycache__/remove_white_background.cpython-314.pyc b/utility/__pycache__/remove_white_background.cpython-314.pyc index 42cb1dd..3e4ca7d 100644 Binary files a/utility/__pycache__/remove_white_background.cpython-314.pyc and b/utility/__pycache__/remove_white_background.cpython-314.pyc differ diff --git a/utility/ensure_png_rgba.py b/utility/ensure_png_rgba.py new file mode 100644 index 0000000..3e92db6 --- /dev/null +++ b/utility/ensure_png_rgba.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +""" +Ensure PNGs in the iOS asset catalog use RGBA where compositing expects an alpha channel. + +- **Backgrounds** (full-bleed scenes): left as **RGB** — no alpha required per design. +- **Sprites / overlays** (orbs, reticle, markers, flares, hub icons, mask reference): run the same + edge flood-fill as ``remove_white_background.py`` so near-white mats touching the border become + transparent (imports shared helpers). +- **Scene cards** (e.g. ``dungeon_room_*``): **RGB → RGBA** with alpha=255 everywhere — no flood-fill, + so illustrated interiors are not damaged; fixes “grey matte” class issues in some pipelines by + using a consistent color type. + +Usage (from repo root):: + + python3 utility/ensure_png_rgba.py --dry-run + python3 utility/ensure_png_rgba.py + +Requires Pillow (same as ``remove_white_background.py``). +""" + +from __future__ import annotations + +import argparse +import shutil +import sys +from pathlib import Path + +try: + from PIL import Image +except ImportError as exc: + print("error: install Pillow: pip install pillow", file=sys.stderr) + raise SystemExit(1) from exc + +# Reuse edge logic from sibling module (same directory). +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from remove_white_background import ( # noqa: E402 + next_backup_path, + remove_edge_connected_near_white, + warn_if_screenshot_like, +) + +ASSETS_DEFAULT = Path("RA11y-iOS/RA11y-iOS/Assets.xcassets") + +# Basenames (no path) that must stay **RGB** — full-bleed backgrounds only. +OPAQUE_BACKGROUND_STEMS: frozenset[str] = frozenset( + { + "dungeon_descent_bg", + "dungeon_resonance_bg", + "enchanter_tower_shelf_bg", + "hub_quest_board_bg", + "rogue_trap_door_bg", + "simon_room_bg", + } +) + +# Imagesets whose PNGs should get **edge** transparency (not just RGBA re-encode). +EDGE_SPRITE_IMAGESET_PREFIXES: tuple[str, ...] = ( + "dungeon_lane_marker_neutral", + "dungeon_resonance_orb_", + "dungeon_reticle_ring", + "dungeon_target_", + "dungeon_decoy_", + "dungeon_success_flare", + "dungeon_spotlight_mask_reference", + "dungeon_hub_icon", + "enchanter_hub_icon", + "rogue_hub_icon", +) + +# ``dungeon_room_*`` and any future “card” art: RGBA opaque only. +ROOM_SCENE_PREFIX = "dungeon_room_" + + +def is_opaque_background(path: Path) -> bool: + stem = path.stem + return stem in OPAQUE_BACKGROUND_STEMS + + +def wants_edge_sprite(path: Path) -> bool: + parent = path.parent.name + if not parent.endswith(".imageset"): + return False + base = parent[: -len(".imageset")] + for prefix in EDGE_SPRITE_IMAGESET_PREFIXES: + if base == prefix or base.startswith(prefix): + return True + return False + + +def is_room_scene(path: Path) -> bool: + return path.parent.name.startswith(ROOM_SCENE_PREFIX) + + +def rgba_opaque_convert(path: Path, *, dry_run: bool) -> str: + img = Image.open(path) + warn_if_screenshot_like(img.width, img.height, allow_large=True) + if img.mode == "RGBA": + return "skip-already-rgba" + if dry_run: + return "would-rgba-opaque" + backup = next_backup_path(path) + shutil.copy2(path, backup) + rgba = img.convert("RGBA") + rgba.save(path, format="PNG") + return f"wrote-rgba-opaque backup={backup.name}" + + +def edge_convert(path: Path, *, fuzz: int, edge_matte: bool, dry_run: bool) -> str: + img = Image.open(path) + warn_if_screenshot_like(img.width, img.height, allow_large=True) + if dry_run: + return "would-edge" + backup = next_backup_path(path) + shutil.copy2(path, backup) + out = remove_edge_connected_near_white(img, fuzz, edge_matte=edge_matte) + out.save(path, format="PNG") + return f"wrote-edge backup={backup.name}" + + +def main() -> None: + parser = argparse.ArgumentParser(description="Normalize PNG alpha usage in Assets.xcassets.") + parser.add_argument( + "--assets-root", + type=Path, + default=ASSETS_DEFAULT, + help=f"Asset catalog root (default: {ASSETS_DEFAULT})", + ) + parser.add_argument( + "--fuzz", + type=int, + default=20, + help="Edge flood fuzz (same as remove_white_background.py)", + ) + parser.add_argument( + "--edge-matte", + action="store_true", + help="Pass through to edge removal for light grey edge mats (riskier).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print planned actions only", + ) + args = parser.parse_args() + root: Path = args.assets_root.resolve() + if not root.is_dir(): + print(f"error: not a directory: {root}", file=sys.stderr) + raise SystemExit(1) + + pngs = sorted(root.rglob("*.png")) + if not pngs: + print(f"error: no PNGs under {root}", file=sys.stderr) + raise SystemExit(1) + + summary: dict[str, int] = {} + + for path in pngs: + if "AppIcon" in str(path): + continue + rel = path.relative_to(root) + try: + im = Image.open(path) + except OSError as e: + print(f"skip unreadable {rel}: {e}") + continue + + if is_opaque_background(path): + if im.mode != "RGB": + print(f"{rel}\tbackground\tmode={im.mode}\t(leave file; verify art intent)") + summary["background_checked"] = summary.get("background_checked", 0) + 1 + continue + + action = None + if wants_edge_sprite(path): + action = edge_convert(path, fuzz=args.fuzz, edge_matte=args.edge_matte, dry_run=args.dry_run) + elif is_room_scene(path) and im.mode == "RGB": + action = rgba_opaque_convert(path, dry_run=args.dry_run) + elif im.mode == "RGB": + # Other RGB (unexpected): promote to RGBA opaque for consistent encoding. + action = rgba_opaque_convert(path, dry_run=args.dry_run) + elif im.mode != "RGBA": + if args.dry_run: + action = f"would-convert-{im.mode}-to-rgba" + else: + backup = next_backup_path(path) + shutil.copy2(path, backup) + Image.open(path).convert("RGBA").save(path, format="PNG") + action = f"wrote-{im.mode}-to-rgba backup={backup.name}" + + if action: + key = action.split()[0] if action else "noop" + summary[key] = summary.get(key, 0) + 1 + print(f"{rel}\t{action}") + + print("\nSummary:", dict(sorted(summary.items()))) + + +if __name__ == "__main__": + main() diff --git a/utility/qa_crystal_resonance_png_assets.py b/utility/qa_crystal_resonance_png_assets.py new file mode 100644 index 0000000..442918f --- /dev/null +++ b/utility/qa_crystal_resonance_png_assets.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +Automated QA for Crystal Resonance PNGs in Assets.xcassets. + +**What this catches (common failures):** + - Missing imageset or PNG + - Lane / hub sprites saved as **RGB** instead of **RGBA** (grey mats, checkerboard fringes in SwiftUI) + - Accidentally empty or nearly empty RGBA assets + - Odd color modes (P, LA, …) that Xcode may mishandle + +**What this does *not* replace:** + - On-device VoiceOver / scroll proxy checks → see memlog QC doc §1, §4 + - Subjective art review → DesignProcess Phase 5 checklist + - Fixing files → ``utility/ensure_png_rgba.py`` and ``utility/remove_white_background.py`` + +Usage (repo root):: + + python3 utility/qa_crystal_resonance_png_assets.py + python3 utility/qa_crystal_resonance_png_assets.py --strict-warnings + +**Generative art:** prompt text for diffusion / LLM **image** tools is maintained in +``memlog/requirements/Design/CrystalResonance-ImageGen-PromptTemplate.txt``. +``--llm-snippet`` prints that file to stdout (convenience only). + +Exit codes: 0 = no FAIL; 1 = one or more FAIL (or WARN with ``--strict-warnings``). + +Requires Pillow (``pip install pillow``), same as other utility scripts. +""" + +from __future__ import annotations + +import argparse +import sys +from dataclasses import dataclass, field +from enum import Enum +from pathlib import Path + +try: + from PIL import Image +except ImportError as exc: + print("error: install Pillow: pip install pillow", file=sys.stderr) + raise SystemExit(1) from exc + +ASSETS_DEFAULT = Path("RA11y-iOS/RA11y-iOS/Assets.xcassets") +IMAGEGEN_PROMPT_TEMPLATE = ( + Path(__file__).resolve().parent.parent + / "memlog/requirements/Design/CrystalResonance-ImageGen-PromptTemplate.txt" +) + + +class Severity(str, Enum): + OK = "OK" + WARN = "WARN" + FAIL = "FAIL" + + +@dataclass +class Finding: + severity: Severity + message: str + + +@dataclass +class AssetReport: + stem: str + role: str + png_path: Path | None + findings: list[Finding] = field(default_factory=list) + + @property + def worst(self) -> Severity: + if any(f.severity == Severity.FAIL for f in self.findings): + return Severity.FAIL + if any(f.severity == Severity.WARN for f in self.findings): + return Severity.WARN + return Severity.OK + + +# Keys must match ``iOSDungeonResonanceArt`` and related catalog names. +# role: "background" = full-bleed, RGB preferred; "sprite" = must be RGBA for compositing. +CRYSTAL_RESONANCE_ASSETS: dict[str, str] = { + "dungeon_resonance_bg": "background", + "dungeon_resonance_orb_idle": "sprite", + "dungeon_resonance_orb_locked": "sprite", + "dungeon_reticle_ring": "sprite", + "dungeon_target_moonstone": "sprite", + "dungeon_decoy_ember_shard": "sprite", + "dungeon_decoy_shadow_glyph": "sprite", + "dungeon_decoy_sun_sigil": "sprite", + "dungeon_lane_marker_neutral": "sprite", + "dungeon_spotlight_mask_reference": "sprite", + "dungeon_success_flare": "sprite", +} + + +def find_png_in_imageset(assets_root: Path, imageset_stem: str) -> Path | None: + imageset_dir = assets_root / f"{imageset_stem}.imageset" + if not imageset_dir.is_dir(): + return None + pngs = sorted(imageset_dir.glob("*.png")) + if not pngs: + return None + return pngs[0] + + +def analyze_png(path: Path, *, role: str) -> list[Finding]: + findings: list[Finding] = [] + try: + im = Image.open(path) + except OSError as e: + return [Finding(Severity.FAIL, f"unreadable: {e}")] + + mode = im.mode + w, h = im.size + + if w < 32 or h < 32: + findings.append(Finding(Severity.WARN, f"very small dimensions {w}×{h}px (verify export scale)")) + + if role == "background": + if mode not in ("RGB", "RGBA"): + findings.append(Finding(Severity.WARN, f"mode={mode} (backgrounds usually RGB)")) + if mode == "RGBA" and im.getbands() == ("R", "G", "B", "A"): + alpha = im.getchannel("A") + extrema = alpha.getextrema() + if extrema != (255, 255): + findings.append( + Finding( + Severity.WARN, + f"RGBA background with non-opaque alpha range {extrema} — full-bleed bg normally fully opaque RGB", + ) + ) + return findings + + # sprite + if mode == "RGB": + findings.append( + Finding( + Severity.FAIL, + "RGB (no alpha) — lane/orb/reticle sprites need RGBA or iOS often shows grey mats / checkerboard " + "fringe on dark UI. Re-export with alpha or run utility/ensure_png_rgba.py", + ) + ) + elif mode != "RGBA": + findings.append(Finding(Severity.WARN, f"mode={mode} — expected RGBA for sprites; verify in Xcode")) + + if mode == "RGBA": + rgba = im.convert("RGBA") + w, h = rgba.size + # Grid sample — full ``getdata()`` on wide masters (~1M px) is unnecessarily slow. + target_samples = 55_000 + step = max(1, int(((w * h) / target_samples) ** 0.5)) + pixels = rgba.load() + transparent = semi = opaqueish = n = 0 + for y in range(0, h, step): + for x in range(0, w, step): + _, _, _, a = pixels[x, y] + n += 1 + if a == 0: + transparent += 1 + elif a < 255: + semi += 1 + if a >= 128: + opaqueish += 1 + if n == 0: + findings.append(Finding(Severity.FAIL, "empty image")) + else: + if transparent / n > 0.97: + findings.append(Finding(Severity.WARN, f"{transparent/n:.0%} fully transparent — likely empty or wrong crop")) + + if opaqueish > 0 and semi / opaqueish > 0.35: + findings.append( + Finding( + Severity.WARN, + "high semi-transparent pixel ratio vs opaque — check for grey halos or premultiply issues", + ) + ) + + # Wide-canvas authoring note (informational) + if w > h * 1.25: + findings.append( + Finding( + Severity.OK, + f"wide master {w}×{h} — OK if authored as landscape master; SwiftUI uses center crop " + f"(see iOSResonanceWideCanvasImage)", + ) + ) + + return findings + + +def run_qa(assets_root: Path) -> list[AssetReport]: + reports: list[AssetReport] = [] + for stem, role in sorted(CRYSTAL_RESONANCE_ASSETS.items()): + png = find_png_in_imageset(assets_root, stem) + report = AssetReport(stem=stem, role=role, png_path=png) + if png is None: + report.findings.append( + Finding( + Severity.FAIL, + f"missing imageset or PNG under {assets_root}/{stem}.imageset/", + ) + ) + else: + report.findings.extend(analyze_png(png, role=role)) + if not report.findings: + report.findings.append(Finding(Severity.OK, "checks passed")) + reports.append(report) + return reports + + +def print_reports(reports: list[AssetReport], *, verbose_ok: bool) -> tuple[int, int, int]: + ok_c = warn_c = fail_c = 0 + for r in reports: + worst = r.worst + if worst == Severity.OK: + ok_c += 1 + elif worst == Severity.WARN: + warn_c += 1 + else: + fail_c += 1 + rel = r.png_path.relative_to(Path.cwd()) if r.png_path and r.png_path.is_absolute() else r.png_path + prefix = f"[{worst.value}] {r.stem} ({r.role})" + if r.png_path: + try: + rel = r.png_path.resolve().relative_to(Path.cwd().resolve()) + except ValueError: + rel = r.png_path + print(f"{prefix}\n file: {rel}") + else: + print(f"{prefix}") + for f in r.findings: + if f.severity == Severity.OK and not verbose_ok: + continue + print(f" {f.severity.value}: {f.message}") + print() + print(f"Summary: {ok_c} OK, {warn_c} WARN, {fail_c} FAIL (assets: {len(reports)})") + return ok_c, warn_c, fail_c + + +def print_llm_snippet() -> None: + """Print the canonical **image generation** prompt template (stdout only).""" + path = IMAGEGEN_PROMPT_TEMPLATE + if path.is_file(): + print(path.read_text(encoding="utf-8").rstrip()) + return + print(f"error: missing prompt template: {path}", file=sys.stderr) + raise SystemExit(1) + + +def main() -> None: + parser = argparse.ArgumentParser(description="QA Crystal Resonance PNG assets in Assets.xcassets.") + parser.add_argument( + "--assets-root", + type=Path, + default=ASSETS_DEFAULT, + help=f"Asset catalog root (default: {ASSETS_DEFAULT})", + ) + parser.add_argument( + "--strict-warnings", + action="store_true", + help="Exit 1 if any WARN (default: only FAIL fails the run)", + ) + parser.add_argument( + "--verbose-ok", + action="store_true", + help="Print OK-level informational findings (e.g. wide-master note)", + ) + parser.add_argument( + "--llm-snippet", + action="store_true", + help="Print CrystalResonance-ImageGen-PromptTemplate.txt (for diffusion/LLM image tools) and exit 0", + ) + args = parser.parse_args() + + if args.llm_snippet: + print_llm_snippet() + raise SystemExit(0) + + root = args.assets_root.resolve() + if not root.is_dir(): + print(f"error: not a directory: {root}", file=sys.stderr) + raise SystemExit(1) + + reports = run_qa(root) + _, warn_c, fail_c = print_reports(reports, verbose_ok=args.verbose_ok) + + if fail_c > 0: + raise SystemExit(1) + if args.strict_warnings and warn_c > 0: + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/utility/remove_white_background.py b/utility/remove_white_background.py index 879dbf3..e7a9790 100644 --- a/utility/remove_white_background.py +++ b/utility/remove_white_background.py @@ -16,6 +16,12 @@ ``*_bg.png``, ``dungeon_room_*.png``): only on relic / seal / hub icon sprites that sit on a flat white field. +**Light grey mattes** (e.g. ``#ececec`` export beds) are **not** removed by default edge +mode. Options: (1) re-export PNG with true alpha from the art tool; (2) try ``--edge-matte`` +(flood-fills light neutral greys from the border — **verify** glyphs did not connect to the +edge through grey); (3) ``--mode global`` with a tight ``--fuzz`` only when the glyph uses +no near-white interior (aggressive). + **Dimensions:** MVP game sprites are often landscape RGB (e.g. 1376×768) with no alpha until processed. The default size guard distinguishes those from device screenshots (tall portrait or large two-axis tablet frames) so you usually **do not** need @@ -77,7 +83,23 @@ def is_near_white(r: int, g: int, b: int, fuzz: int) -> bool: return r >= 255 - fuzz and g >= 255 - fuzz and b >= 255 - fuzz -def remove_edge_connected_near_white(img: Image.Image, fuzz: int) -> Image.Image: +def is_near_light_neutral_matte(r: int, g: int, b: int, fuzz: int) -> bool: + """ + True for flat light grey / off-white mats that touch the image edge (common bad exports). + + More aggressive than ``is_near_white`` — use only with ``--edge-matte`` and inspect output: + silver highlights on glyphs can connect to the border through a grey path and be erased. + """ + mx = max(r, g, b) + mn = min(r, g, b) + if mx < 200 - fuzz: + return False + if mx - mn > 28 + fuzz // 2: + return False + return mx >= 235 - fuzz + + +def remove_edge_connected_near_white(img: Image.Image, fuzz: int, *, edge_matte: bool = False) -> Image.Image: """ Sets alpha to 0 for near-white pixels connected to any image edge. @@ -98,7 +120,10 @@ def try_seed(x: int, y: int) -> None: if a == 0: visited.add((x, y)) return - if is_near_white(r, g, b, fuzz): + is_bg = is_near_white(r, g, b, fuzz) or ( + edge_matte and is_near_light_neutral_matte(r, g, b, fuzz) + ) + if is_bg: visited.add((x, y)) q.append((x, y)) @@ -124,7 +149,10 @@ def try_seed(x: int, y: int) -> None: if na == 0: visited.add((nx, ny)) continue - if is_near_white(nr, ng, nb, fuzz): + is_nb = is_near_white(nr, ng, nb, fuzz) or ( + edge_matte and is_near_light_neutral_matte(nr, ng, nb, fuzz) + ) + if is_nb: visited.add((nx, ny)) q.append((nx, ny)) @@ -199,11 +227,12 @@ def process_file( mode: str, output_path: Path, allow_large: bool, + edge_matte: bool, ) -> None: img = Image.open(path) warn_if_screenshot_like(img.width, img.height, allow_large) if mode == "edge": - out = remove_edge_connected_near_white(img, fuzz) + out = remove_edge_connected_near_white(img, fuzz, edge_matte=edge_matte) else: out = remove_global_near_white(img, fuzz) output_path.parent.mkdir(parents=True, exist_ok=True) @@ -233,6 +262,11 @@ def main() -> None: help="edge: only white connected to border (safer for glyphs). " "global: all near-white transparent (ImageMagick-like).", ) + parser.add_argument( + "--edge-matte", + action="store_true", + help="With --mode edge, also flood-fill light neutral greys touching the border (riskier; QC on device).", + ) parser.add_argument( "-o", "--output", @@ -282,6 +316,7 @@ def main() -> None: mode=args.mode, output_path=out_path, allow_large=args.allow_large, + edge_matte=args.edge_matte, ) print(f"updated {path} (backup {backup})") return @@ -301,6 +336,7 @@ def main() -> None: mode=args.mode, output_path=target, allow_large=args.allow_large, + edge_matte=args.edge_matte, ) print(f"wrote {target}") return @@ -319,6 +355,7 @@ def main() -> None: mode=args.mode, output_path=target, allow_large=args.allow_large, + edge_matte=args.edge_matte, ) print(f"wrote {target}")