diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..4063dbe --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "mockup", + "runtimeExecutable": "python3", + "runtimeArgs": ["-m", "http.server", "7331", "--directory", "memlog/requirements/Design/Mockups-v2"], + "port": 7331 + } + ] +} diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..c83ee89 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(awk '/## File System Boundaries/,/## Simulator Detection/' \"/Users/josephmccraw/Dropbox/My Mac \\(MacBook-Air\\)/Documents/GitHub/RA11y-AccessibilityGamification/AGENTS.md\")" + ] + } +} diff --git a/AGENTS.md b/AGENTS.md index 9bbd6eb..3b818c8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,91 @@ CoreSimulatorService crashes, missing runtimes): --- +## File System Boundaries + +All files created or modified by AI agents MUST stay inside the project +directory. This rule applies to every agent, every sub-agent, and every +tool call — without exception. + +### Rule: Never Write Outside the Project Root + +The project root is the directory containing this AGENTS.md file. + +NEVER create, write, or move files to any of the following: +- `/tmp/` or any system temporary directory +- `~/Desktop/`, `~/Downloads/`, `~/Documents/`, or any home directory path +- `/var/`, `/usr/`, `/etc/`, or any system path +- Any absolute path that is not inside the project root +- Any relative path that escapes the project root via `../` + +### Approved Locations for Working Files + +When a task requires scratch space, intermediate output, or generated +artefacts, use directories inside the project: + +| Purpose | Location | +|---|---| +| Design docs, specs, ADRs | `memlog/` | +| Mockup HTML files | `memlog/requirements/Design/Mockups-v2/` | +| Generated assets (pre-import) | `memlog/requirements/Design/Assets/` | +| Build and test output | `build_results/` | +| Utility scripts | `utility/` | + +### Enforcement + +If a task appears to require writing outside the project directory, STOP. +Reframe the task so all output lands inside the project. If it genuinely +cannot be done inside the project, ask the user before proceeding. + +This rule exists to ensure the repository is the single source of truth +and that agent actions are fully auditable via git. + +--- + +## Scripting Language Policy + +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. + +### Preferred Tools (use these first) + +- **Text processing:** `awk`, `sed`, `grep`, `ripgrep (rg)`, `cut`, `tr`, `sort`, `uniq` +- **File operations:** `find`, `ls`, `cp`, `mv`, `mkdir`, `rm`, `xargs` +- **Data / JSON:** `jq` +- **Network:** `curl` +- **Archives:** `tar`, `unzip`, `zip` +- **Conditionals / loops:** `bash` built-ins (`if`, `while`, `for`, `case`) +- **String / math:** `expr`, `printf`, parameter expansion, arithmetic expansion + +### When an Interpreted Language Is Acceptable + +Do not write Python, Perl, Ruby, Node.js, or any other interpreted language +script unless ALL of the following are true: + +1. The task is genuinely impossible or severely impractical with shell tools +2. An explicit interpreter version is already confirmed present on the host +3. The user explicitly approves the use of that language for the task + +Existing project scripts that already use Python are grandfathered: +- `utility/remove_white_background.py` — approved, do not rewrite +- `utility/build_and_test.sh` — shell, already compliant + +### For New Automation + +Write new scripts as `bash`. If a one-liner requires a complex transform, +reach for `awk` or `jq` before reaching for Python. A 10-line `awk` program +is more auditable and portable than a 10-line Python script for the same task. + +### Rationale + +Interpreted language scripts can execute arbitrary code, introduce supply +chain risk via imports, and behave differently across interpreter versions. +Shell tools from POSIX and the standard macOS toolchain have a stable, +well-understood security model and are always available on the host. + +--- + ## Simulator Detection — Required Pattern Hardcoded simulator names (e.g., `"iPhone 17"`, `"iPad (A16)"`) MUST NOT @@ -99,9 +184,10 @@ and break silently. Any code that selects a simulator MUST follow this pattern: 1. Query what is actually available at runtime via `xcrun simctl list devices available --json` -2. Match against a preference list (most to least preferred) -3. Fall back to the best available match if no exact match is found -4. Fail loudly — listing discovered devices — if no match exists at all +2. If a **persisted last-working UDID** for that device family (see below) still appears in the listing, use it and skip the preference list for stable day-to-day runs. +3. Match against a preference list (most to least preferred) +4. Fall back to the best available match if no exact match is found +5. Fail loudly — listing discovered devices — if no match exists at all A preference list of names may be hardcoded. The final resolved device must not be. @@ -129,7 +215,22 @@ If no simulator of the required family exists, STOP and report: - What the user must do (open Simulator.app, check Xcode → Settings → Platforms) Do NOT attempt to create simulators, download runtimes, or modify simulator -state without explicit user approval. +state without explicit user approval. Repo scripts never call +`xcodebuild -download*`, `simctl create`, or other commands that install +runtimes or provision new devices. If macOS or Xcode shows a system dialog +such as "Verifying … simruntime", that is host-level validation of an +already-installed runtime — not something this repository triggers. + +### Persist Last-Working UDID (stable reuse) + +`utility/build_and_test.sh` and `fastlane/Fastfile` store the resolved UDID +per family in `~/.ra11y/last_simulator_iPhone.udid` and +`~/.ra11y/last_simulator_iPad.udid` (set `RA11Y_SIMULATOR_STATE_DIR` to use a +different directory). The next invocation reuses that UDID if it is still +listed as available, then falls back to the preference hierarchy above. + +If the first `simctl` query fails or returns empty output, scripts retry once +after a one-second delay (transient CoreSimulator stalls). ### Files That Must Follow This Pattern @@ -181,18 +282,24 @@ A passing Core build is not sufficient if the iOS build fails, and vice versa. --- -## Fastlane Screenshot Coverage (Required When Views Change) +## Screenshot Automation Contract + +Screenshot automation must remain deterministic and aligned across docs, tests, and fastlane. -When any user-visible view is added, renamed, or significantly restructured, update the -screenshot flow to keep fastlane coverage current. This avoids silent UI regressions. +Authoritative files: +- `RA11y-iOS/RA11y-iOS/App/iOSScreenshotScene.swift` +- `RA11y-iOS/RA11y-iOSUITests/ScreenshotRouteCatalog.md` +- `RA11y-iOS/RA11y-iOSUITests/RA11y_iOSScreenshots.swift` +- `fastlane/Fastfile` (`UI_TEST_IDS` allowlist) -Requirements: -- Add a stable accessibility identifier to the screen root or primary container. -- Update `RA11y-iOS/RA11y-iOSUITests/RA11y_iOSScreenshots.swift` to navigate to the view - and wait on that identifier before capturing the next screenshot. -- If a view is gated (VoiceOver, onboarding, etc.), add a `-uiTesting` bypass so the - screenshot flow can reach it deterministically. -- Keep screenshot names sequential and explicit (e.g., `01_Hub`, `02_EnchantersTrial`). +Required rules: +- Any change to screenshot-covered UI routes, accessibility identifiers, or launch args MUST update all four files in the same change. +- New screenshot-covered screens MUST include: + - A stable root accessibility identifier. + - A deterministic `-screenshotScene ` boot path declared in `iOSScreenshotScene.swift`. + - A route-catalog row with screenshot file name, scene ID, and root anchor identifier. +- Before running `fastlane screenshots`, run: + - `utility/validate_screenshot_contract.sh` --- @@ -278,8 +385,6 @@ These rules are additive to the rest of this document. - Keep actor-isolated critical sections minimal; move heavy work to nonisolated helpers. - Always handle cancellation in long-running tasks and loops (`Task.isCancelled`). - Never block in async contexts; move blocking I/O off the main actor. -- When the app target uses `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`, still keep - heavy or blocking work off the main actor via a dedicated actor or helper. ### Documentation & Review - Document isolation requirements in doc comments for any public/internal async API. diff --git a/Gemfile.lock b/Gemfile.lock index d74f946..3bd47f1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -3,7 +3,7 @@ GEM specs: CFPropertyList (3.0.9) abbrev (0.1.2) - addressable (2.8.8) + addressable (2.9.0) public_suffix (>= 2.0.2, < 8.0) artifactory (3.0.17) atomos (0.1.3) @@ -66,10 +66,10 @@ GEM faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) - faraday-retry (1.0.3) + faraday-retry (1.0.4) faraday_middleware (1.2.1) faraday (~> 1.0) - fastimage (2.4.0) + fastimage (2.4.1) fastlane (2.230.0) CFPropertyList (>= 2.3, < 4.0.0) abbrev (~> 0.1.2) @@ -118,8 +118,7 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.4.1) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) - fastlane-sirp (1.0.0) - sysrandom (~> 1.0) + fastlane-sirp (1.1.0) gh_inspector (1.1.3) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) @@ -179,12 +178,12 @@ GEM os (1.1.4) plist (3.7.2) public_suffix (5.1.1) - rake (13.3.1) + rake (13.4.2) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) - retriable (3.2.1) + retriable (3.4.1) rexml (3.4.4) rouge (3.28.0) ruby2_keywords (0.0.5) @@ -198,7 +197,6 @@ GEM simctl (1.6.10) CFPropertyList naturally - sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) diff --git a/RA11y-iOS/RA11y-iOS.xcodeproj/project.pbxproj b/RA11y-iOS/RA11y-iOS.xcodeproj/project.pbxproj index b3d65a2..e2d87e0 100644 --- a/RA11y-iOS/RA11y-iOS.xcodeproj/project.pbxproj +++ b/RA11y-iOS/RA11y-iOS.xcodeproj/project.pbxproj @@ -28,7 +28,6 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 446CAB7C2F4BD4EF00CF28C9 /* RA11y-tvOS.xcodeproj */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.pb-project"; name = "RA11y-tvOS.xcodeproj"; path = "../RA11y-tvOS/RA11y-tvOS.xcodeproj"; sourceTree = SOURCE_ROOT; }; 44C85C932F4787A5000C982C /* RA11y-iOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "RA11y-iOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 44C85CA02F4787A7000C982C /* RA11y-iOSTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "RA11y-iOSTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 44C85CAA2F4787A7000C982C /* RA11y-iOSUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "RA11y-iOSUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -78,17 +77,9 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 446CAB7F2F4BD4F000CF28C9 /* Products */ = { - isa = PBXGroup; - children = ( - ); - name = Products; - sourceTree = ""; - }; 44C85C8A2F4787A5000C982C = { isa = PBXGroup; children = ( - 446CAB7C2F4BD4EF00CF28C9 /* RA11y-tvOS.xcodeproj */, 44C85C952F4787A5000C982C /* RA11y-iOS */, 44C85CA32F4787A7000C982C /* RA11y-iOSTests */, 44C85CAD2F4787A7000C982C /* RA11y-iOSUITests */, @@ -185,8 +176,8 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = 1; - LastSwiftUpdateCheck = 2620; - LastUpgradeCheck = 2620; + LastSwiftUpdateCheck = 2630; + LastUpgradeCheck = 2630; TargetAttributes = { 44C85C922F4787A5000C982C = { CreatedOnToolsVersion = 26.2; @@ -216,12 +207,6 @@ preferredProjectObjectVersion = 77; productRefGroup = 44C85C942F4787A5000C982C /* Products */; projectDirPath = ""; - projectReferences = ( - { - ProductGroup = 446CAB7F2F4BD4F000CF28C9 /* Products */; - ProjectRef = 446CAB7C2F4BD4EF00CF28C9 /* RA11y-tvOS.xcodeproj */; - }, - ); projectRoot = ""; targets = ( 44C85C922F4787A5000C982C /* RA11y-iOS */, @@ -420,20 +405,21 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = RWZ25NGH8K; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "RA11y Quest"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.showblender.RA11y-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -452,20 +438,21 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = RWZ25NGH8K; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "RA11y Quest"; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = UIInterfaceOrientationPortrait; - INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = UIInterfaceOrientationPortrait; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.showblender.RA11y-iOS"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -483,11 +470,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = RWZ25NGH8K; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.showblender.RA11y-iOSTests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -505,11 +492,11 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = RWZ25NGH8K; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.showblender.RA11y-iOSTests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -526,10 +513,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = RWZ25NGH8K; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.showblender.RA11y-iOSUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -546,10 +533,10 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_TEAM = RWZ25NGH8K; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.showblender.RA11y-iOSUITests"; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/RA11y-iOS/RA11y-iOS/App/iOSAppRouter.swift b/RA11y-iOS/RA11y-iOS/App/iOSAppRouter.swift index 64b364e..4fe0c22 100644 --- a/RA11y-iOS/RA11y-iOS/App/iOSAppRouter.swift +++ b/RA11y-iOS/RA11y-iOS/App/iOSAppRouter.swift @@ -13,12 +13,18 @@ enum AppRoute: Hashable { case hub /// The first-run "VoiceOver Basics" guided sequence. Implemented in M4. case firstRun(mode: FirstRunMode) - /// Active gameplay route for a given game kind (M5+). - case game(kind: GameKind) + /// 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. + case dungeonDescent + /// Crystal Resonance v2 prototype route for iterative design and feedback integration. + case dungeonResonancePrototype /// The shared result screen shown after any game completes. - /// Carries the `GameResult` so the view can display rank, time, and mistakes. - /// Games in M5+ push this route on session completion. - case gameResult(GameResult) + /// Carries the `GameResult`, the `GameKind` (used to render the skill-transfer card), + /// and an optional game-specific flavor announcement appended to the shared summary. + case gameResult(GameResult, gameKind: GameKind, gameSpecificAnnouncement: String?) /// "VoiceOver required" interstitial shown when a user attempts to start a game /// with VoiceOver disabled. Carries the intended `GameKind` so the interstitial @@ -59,6 +65,21 @@ final class iOSAppRouter { /// An empty path displays the root destination (Hub). var path = NavigationPath() + /// `true` while the player is progressing through the M4 guided Basics sequence. + /// + /// Set by `iOSBasicsSequenceView` before pushing each game route. + /// Read by `iOSGameResultView` to show the "Continue Basics" CTA instead of + /// the normal "Try Again" / "Return to Hub" buttons. + /// Cleared when the sequence completes or is exited. + var isInBasicsSequence: Bool = false + + /// Signals that the player tapped "Continue Basics" on the result screen. + /// + /// Set inside `popForBasicsContinue()` and consumed (cleared) by + /// `iOSBasicsSequenceView.onAppear`. Distinguishes a successful step completion + /// from back-navigation mid-game, preventing premature index advancement. + var basicsStepCompleted: Bool = false + // MARK: - Computed /// The destination shown at app launch. @@ -77,6 +98,56 @@ final class iOSAppRouter { path.append(route) } + /// Attempts to open a playable quest route for `kind`. + /// + /// **Users:** Quests require VoiceOver (see ``GameStartDecision``). If VoiceOver is not + /// running, pushes ``AppRoute/voiceOverInterstitial(kind:)`` instead of the game. Product + /// code should use this for entering games — never push playable routes directly except + /// from this method (or UI-test bypass below). + /// + /// **UI testing:** When the process was launched with the `-uiTesting` argument (standard + /// for ``XCTest`` / XCUITest and screenshot lanes), VoiceOver gating is skipped so tests + /// can navigate without enabling VoiceOver on the simulator. + /// + /// - Parameters: + /// - kind: The training game to start. + /// - provider: Defaults to the live UIAccessibility-backed provider; inject a stub in unit tests. + func pushGame( + kind: GameKind, + provider: some VoiceOverStateProvider = iOSLiveVoiceOverStateProvider() + ) { + if Self.isUITestingLaunch { + RA11yLogger.navigation.debug("UI test launch — bypassing VoiceOver gate for \(kind.rawValue)") + pushPlayableRoute(for: kind) + return + } + switch GameStartDecision.evaluate(kind: kind, provider: provider) { + case .proceed(kind: let proceedKind): + RA11yLogger.navigation.debug("Game start gating passed for \(proceedKind.rawValue)") + pushPlayableRoute(for: proceedKind) + case .requireVoiceOver(kind: let blockedKind): + RA11yLogger.navigation.debug("Blocked quest push — VoiceOver off; showing interstitial for \(blockedKind.rawValue)") + push(.voiceOverInterstitial(kind: blockedKind)) + } + } + + /// Pushes the ``AppRoute`` for a playable quest without VoiceOver checks (used by ``pushGame``). + private func pushPlayableRoute(for kind: GameKind) { + switch kind { + case .findAndFocus: + push(.enchantersTrial) + case .activateDoubleTap: + push(.roguesGauntlet) + case .scrollHunt: + push(.dungeonDescent) + } + } + + /// Matches ``RA11y_iOSApp`` / UI tests: automation passes `-uiTesting` in launch arguments. + private static var isUITestingLaunch: Bool { + ProcessInfo.processInfo.arguments.contains("-uiTesting") + } + /// Pops the topmost destination from the navigation stack. /// No-op if the stack is already at its root. func pop() { @@ -91,6 +162,18 @@ final class iOSAppRouter { RA11yLogger.navigation.debug("Navigation reset to root") } + /// Pops the result screen and the completed game back to `iOSBasicsSequenceView`. + /// + /// Called by the "Continue Basics" button on `iOSGameResultView`. + /// Sets `basicsStepCompleted = true` before removing two levels so that the + /// sequence view's `.onAppear` can distinguish this return from back-navigation. + func popForBasicsContinue() { + guard path.count >= 2 else { return } + basicsStepCompleted = true + path.removeLast(2) + RA11yLogger.navigation.debug("Basics step complete. Stack depth: \(self.path.count)") + } + // MARK: - First-Run Routing /// Resolves the initial route based on stored Basics flags. @@ -98,8 +181,9 @@ final class iOSAppRouter { /// - Parameter storage: Persistence layer for first-run flags. /// - Returns: `.firstRun(mode: .entry)` when neither flag is set; otherwise `.hub`. func resolveInitialRoute(using storage: any StorageComponent) async -> AppRoute { - if await storage.isBasicsCompleted() { return .hub } - if await storage.isBasicsDismissed() { return .hub } + let snapshot = await storage.basicsProgressSnapshot() + if snapshot.isCompleted { return .hub } + if snapshot.isDismissed { return .hub } return .firstRun(mode: .entry) } } diff --git a/RA11y-iOS/RA11y-iOS/App/iOSLaunchLoadingView.swift b/RA11y-iOS/RA11y-iOS/App/iOSLaunchLoadingView.swift new file mode 100644 index 0000000..9717d7e --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/App/iOSLaunchLoadingView.swift @@ -0,0 +1,122 @@ +import SwiftUI +import UIKit +import RA11yCore + +// MARK: - iOSLaunchLoadingView + +/// Cold-start overlay with dungeon / guild-hall theming aligned to Crystal Resonance art direction. +/// +/// Renders while `iOSRootView` resolves the initial route asynchronously. Background and orb +/// assets match the resonance shaft catalog when present; fallbacks keep the same torchlit mood. +/// +/// ## Accessibility +/// Decorative imagery is hidden from VoiceOver; a single label conveys loading state so +/// announcements stay clear and are not duplicated by the visible taglines. +/// +/// ## Concurrency +/// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. +struct iOSLaunchLoadingView: View { + + @State private var orbBreathScale: CGFloat = 1.0 + + var body: some View { + ZStack { + launchAtmosphere + RadialGradient( + colors: [Color.black.opacity(0.0), Color.black.opacity(0.78)], + center: .center, + startRadius: 60, + endRadius: 520 + ) + .allowsHitTesting(false) + .accessibilityHidden(true) + + VStack(spacing: RA11ySpacing.lg) { + orbGlyph + .scaleEffect(orbBreathScale) + .accessibilityHidden(true) + + VStack(spacing: RA11ySpacing.sm) { + Text(String(localized: "app.loading.tagline")) + .font(.ra11yTitle) + .fontWeight(.bold) + .foregroundStyle(Color.white.opacity(0.97)) + .multilineTextAlignment(.center) + .shadow(color: .black.opacity(0.4), radius: 6, y: 2) + .accessibilityHidden(true) + + Text(String(localized: "app.loading.flavor")) + .font(.ra11ySubheadline) + .foregroundStyle(Color.white.opacity(0.72)) + .multilineTextAlignment(.center) + .padding(.horizontal, RA11ySpacing.lg) + .accessibilityHidden(true) + } + + ProgressView(String(localized: "app.loading")) + .tint(Color.ra11yAccent) + .controlSize(.regular) + .padding(.top, RA11ySpacing.sm) + } + .padding(RA11ySpacing.xl) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.ra11yGameFallbackBackground) + .preferredColorScheme(.dark) + .accessibilityElement(children: .ignore) + .accessibilityLabel(String(localized: "app.loading.a11yLabel")) + .accessibilityAddTraits(.updatesFrequently) + .onAppear { + withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true)) { + orbBreathScale = 1.06 + } + } + } + + // MARK: - Layers + + private var launchAtmosphere: some View { + Group { + if let ui = UIImage(named: "dungeon_resonance_bg") { + Image(uiImage: ui) + .resizable() + .scaledToFill() + .frame(minWidth: 0, minHeight: 0) + .clipped() + } else { + LinearGradient( + colors: [ + Color(red: 0.05, green: 0.04, blue: 0.09), + Color(red: 0.11, green: 0.06, blue: 0.05), + Color(red: 0.05, green: 0.05, blue: 0.12), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } + .ignoresSafeArea() + .accessibilityHidden(true) + } + + private var orbGlyph: some View { + Group { + if let ui = UIImage(named: "dungeon_resonance_orb_idle") { + Image(uiImage: ui) + .resizable() + .scaledToFit() + .frame(width: 92, height: 92) + .shadow(color: Color.ra11yAccent.opacity(0.45), radius: 16, y: 2) + } else { + Image(systemName: "diamond.circle.fill") + .font(.system(size: 72)) + .symbolRenderingMode(.palette) + .foregroundStyle( + Color.ra11yAccent.opacity(0.95), + Color.white.opacity(0.4) + ) + .shadow(color: Color.ra11yAccent.opacity(0.35), radius: 12) + } + } + } +} diff --git a/RA11y-iOS/RA11y-iOS/App/iOSLightsOffGameplayBlackout.swift b/RA11y-iOS/RA11y-iOS/App/iOSLightsOffGameplayBlackout.swift new file mode 100644 index 0000000..3546163 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/App/iOSLightsOffGameplayBlackout.swift @@ -0,0 +1,45 @@ +import SwiftUI + +// MARK: - iOSLightsOffGameplayBlackoutModifier + +/// Covers the wrapped view with an opaque black layer that does not receive touches or +/// accessibility focus, so VoiceOver continues to navigate interactive content underneath. +/// +/// Used for Lights Off training mode: the player must rely on assistive technology rather +/// than sight. The overlay is visually opaque but excluded from the accessibility tree +/// (`accessibilityHidden(true)`) and does not block hit testing (`allowsHitTesting(false)`). +/// +/// Apply only to gameplay visuals (e.g. relic rows, seal grids, dungeon room icons), not +/// to instructional prompts, timers, or navigation chrome. +/// +/// ## Stability within a run +/// Callers must not reorder or replace the accessibility subtree under this overlay +/// mid-attempt except through normal game rules. Randomize layout only when a level +/// begins, not during active play (`LightsOffMode-Recommendations.txt`). +struct iOSLightsOffGameplayBlackoutModifier: ViewModifier { + + /// When `true`, the blackout layer is drawn over the content. + let isEnabled: Bool + + func body(content: Content) -> some View { + ZStack { + content + if isEnabled { + Color.black + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + } +} + +extension View { + + /// Hides gameplay visuals behind an opaque, accessibility-inert blackout layer. + /// + /// - Parameter isEnabled: When `true`, covers this subtree with black without + /// affecting VoiceOver traversal of the underlying controls. + func ra11yLightsOffGameplayBlackout(isEnabled: Bool) -> some View { + modifier(iOSLightsOffGameplayBlackoutModifier(isEnabled: isEnabled)) + } +} diff --git a/RA11y-iOS/RA11y-iOS/App/iOSRootView.swift b/RA11y-iOS/RA11y-iOS/App/iOSRootView.swift index a18a71f..8300d37 100644 --- a/RA11y-iOS/RA11y-iOS/App/iOSRootView.swift +++ b/RA11y-iOS/RA11y-iOS/App/iOSRootView.swift @@ -4,14 +4,28 @@ import RA11yCore /// Root view of the RA11y iOS app. /// -/// Owns `iOSAppRouter` and provides it to the view hierarchy via the SwiftUI -/// environment. All navigation is driven through the router's `NavigationPath`. +/// Owns `iOSAppRouter` and `UserDefaultsStorageComponent`, providing both to the +/// view hierarchy via the SwiftUI environment. All navigation is driven through +/// the router's `NavigationPath`. +/// +/// ## Startup Rendering +/// `iOSLaunchLoadingView` (torchlit hall / crystal shaft art) is shown until initial route +/// resolution completes. This avoids constructing the full hub, its background image, and +/// storage-backed refresh work during cold start. /// /// ## Startup Logging -/// Logs two milestones: -/// - `rootView.body` — first render, scene is visible to the user (ProgressView shown). -/// - `routeResolution` — signpost interval covering the async storage reads that -/// determine whether to show the hub or the first-run flow. +/// - `rootView.body` — first render, scene visible to the user. +/// - `routeResolution` — signpost interval covering the startup storage read +/// that determines whether to show the first-run flow. +/// +/// ## Screenshot Testing +/// The fastlane `screenshots` lane launches the app with specific arguments: +/// - `-screenshotResetOnboarding`: clears first-run flags → routes to First Run. +/// - `-screenshotMarkOnboardingComplete`: sets `basicsCompleted = true` → routes to hub. +/// - `-screenshotDirectTo{Game}`: pre-populates `router.path` via the `@State` initializer +/// closure so the game view is on the NavigationStack from the very first render. +/// `RA11y_iOSApp.init()` also sets `basicsCompleted = true` for these args so the +/// loading overlay resolves to hub (not first-run) and is removed promptly. /// /// ## Concurrency /// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. @@ -19,43 +33,48 @@ struct iOSRootView: View { // MARK: - State - @State private var router = iOSAppRouter() - @State private var storage: UserDefaultsStorageComponent - @State private var hubViewModel: HubViewModel + /// Navigation router pre-populated with a direct game route when the fastlane screenshot + /// lane launches with a `-screenshotDirectTo*` argument. Pre-populating the path in the + /// `@State` initializer ensures the game destination is present on the NavigationStack's + /// first render — avoiding a race between the async `resolveInitialRouteIfNeeded()` task + /// and SwiftUI's layout pass. + /// + /// In all non-screenshot launches the router is created with an empty path (normal flow). + @State private var router: iOSAppRouter = { + let router = iOSAppRouter() + #if DEBUG + let args = ProcessInfo.processInfo.arguments + 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) + } + } + #endif + return router + }() + @State private var storage = UserDefaultsStorageComponent() @State private var hasResolvedInitialRoute = false - // MARK: - Init - - init() { - let storage = UserDefaultsStorageComponent() - _storage = State(initialValue: storage) - _hubViewModel = State( - initialValue: HubViewModel( - voiceOverProvider: iOSLiveVoiceOverStateProvider(), - storage: storage - ) - ) - } - // MARK: - Body var body: some View { - NavigationStack(path: $router.path) { - Group { - if hasResolvedInitialRoute { - iOSHubView(viewModel: hubViewModel) - } else { - ProgressView(String(localized: "app.loading")) - .onAppear { - RA11yLogger.startup.debug("rootView.body — first render; awaiting route resolution") + Group { + if hasResolvedInitialRoute { + NavigationStack(path: $router.path) { + iOSHubView(storage: storage) + .navigationDestination(for: AppRoute.self) { route in + routeDestination(for: route) } } - } - .navigationDestination(for: AppRoute.self) { route in - routeDestination(for: route) + .environment(router) + } else { + loadingView } } - .environment(router) .task { await resolveInitialRouteIfNeeded() } @@ -67,15 +86,23 @@ struct iOSRootView: View { private func routeDestination(for route: AppRoute) -> some View { switch route { case .hub: - iOSHubView(viewModel: hubViewModel) + iOSHubView(storage: storage) case .firstRun(let mode): iOSFirstRunView(mode: mode, storage: storage) - case .game(let kind): - gameDestination(for: kind) - case .gameResult(let result): + case .enchantersTrial: + iOSEnchantersTrialView(storage: storage) + case .roguesGauntlet: + iOSRogueGauntletView(storage: storage) + case .dungeonDescent: + iOSDungeonDescentView(storage: storage) + case .dungeonResonancePrototype: + iOSDungeonResonanceMockupView() + case .gameResult(let result, let gameKind, let gameSpecificAnnouncement): iOSGameResultView( presenter: GameResultPresenter(result: result), - onPlayAgain: { router.popToRoot() }, // M5+ will push the specific game route here + gameKind: gameKind, + gameSpecificAnnouncement: gameSpecificAnnouncement, + onPlayAgain: { restartGame(for: result) }, onReturnToHub: { router.popToRoot() } ) case .voiceOverInterstitial(let kind): @@ -83,15 +110,23 @@ struct iOSRootView: View { } } - @ViewBuilder - private func gameDestination(for kind: GameKind) -> some View { - switch kind { - case .findAndFocus: - iOSEnchantersTrialView(storage: storage) - case .activateDoubleTap, .scrollHunt: - Text(String(localized: "game.comingSoon")) - .font(.ra11yTitle) - .foregroundStyle(.secondary) + /// Pops the result screen and restarts the correct game based on the result's game ID. + /// + /// Pops back through the navigation stack to the game entry point so the player + /// can retry without returning all the way to the hub. + private func restartGame(for result: GameResult) { + router.popToRoot() + guard let kind = gameKind(forGameID: result.gameID) else { return } + router.pushGame(kind: kind) + } + + /// Maps persisted ``GameResult/gameID`` strings to ``GameKind`` for replay routing. + 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 + default: return nil } } @@ -100,24 +135,66 @@ struct iOSRootView: View { /// Resolves the initial route once and updates navigation state. /// /// ## Startup Instrumentation - /// Emits a `routeResolution` signpost interval covering the two async storage - /// reads (`isBasicsCompleted` + `isBasicsDismissed`) that determine first-run - /// routing. Visible in Instruments → "Points of Interest". If this interval is + /// Emits a `routeResolution` signpost interval covering the startup storage + /// read that determines first-run routing. Visible in Instruments → + /// "Points of Interest". If this interval is /// 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` + /// 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 { guard !hasResolvedInitialRoute else { return } let state = RA11yLogger.startupSignposter.beginInterval("routeResolution") defer { RA11yLogger.startupSignposter.endInterval("routeResolution", state) } + let beginTag = RA11yLogger.startupTimestampTag() + RA11yLogger.startup.debug("\(beginTag) routeResolution — async entry (before storage reads)") + + let wallStart = CFAbsoluteTimeGetCurrent() let initial = await router.resolveInitialRoute(using: storage) + let elapsedMs = (CFAbsoluteTimeGetCurrent() - wallStart) * 1000 if case .firstRun = initial { router.path = NavigationPath([initial]) } hasResolvedInitialRoute = true - RA11yLogger.startup.debug("routeResolution complete — initial route: \(String(describing: initial))") + // Announce to VoiceOver that the app has finished loading and is ready. + // Passing `nil` lets VoiceOver focus the first element naturally (nav title). + UIAccessibility.post(notification: .screenChanged, argument: nil) + + RA11yLogger.startup.debug("\(RA11yLogger.startupTimestampTag()) routeResolution complete — \(String(format: "%.1f", elapsedMs)) ms — initial route: \(String(describing: initial))") + } + + /// Themed startup surface (guild hall / crystal shaft) shown before the initial route is known. + private var loadingView: some View { + iOSLaunchLoadingView() + .onAppear { + RA11yLogger.startup.debug("\(RA11yLogger.startupTimestampTag()) rootView.body — first render; awaiting route resolution") + scheduleSlowLoadingAnnouncementIfStillPending() + } + } + + /// If route resolution takes longer than expected under the debugger, posts a + /// VoiceOver announcement so the user knows the app is still working (not frozen). + /// + /// ## Concurrency + /// Called from the loading overlay's `onAppear`. Spawns an unstructured `Task` + /// that sleeps on the main actor; safe alongside `resolveInitialRouteIfNeeded`. + private func scheduleSlowLoadingAnnouncementIfStillPending() { + Task { @MainActor in + try? await Task.sleep(for: .seconds(2)) + guard !hasResolvedInitialRoute else { return } + UIAccessibility.post( + notification: .announcement, + argument: String(localized: "app.loading.still.a11yAnnouncement") + ) + RA11yLogger.startup.debug("\(RA11yLogger.startupTimestampTag()) routeResolution still pending after 2 s — posted slow-loading announcement") + } } } diff --git a/RA11y-iOS/RA11y-iOS/App/iOSScreenshotRootView.swift b/RA11y-iOS/RA11y-iOS/App/iOSScreenshotRootView.swift new file mode 100644 index 0000000..ee63406 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/App/iOSScreenshotRootView.swift @@ -0,0 +1,81 @@ +import SwiftUI +import RA11yCore + +/// Deterministic root view used by screenshot automation. +/// +/// When the app launches with `-screenshotScene `, `RA11y_iOSApp` bypasses +/// the normal startup router and renders this view instead. That removes onboarding, +/// VoiceOver gating, and navigation timing from the screenshot path. +struct iOSScreenshotRootView: View { + + /// The scene to render. + let scene: iOSScreenshotScene + + @State private var router = iOSAppRouter() + @State private var storage = UserDefaultsStorageComponent() + + var body: some View { + NavigationStack(path: $router.path) { + sceneView + } + .environment(router) + } + + @ViewBuilder + private var sceneView: some View { + switch scene { + case .hub: + iOSHubView() + case .voRequired: + iOSVORequiredView(kind: .findAndFocus) + case .firstRun: + iOSFirstRunView(mode: .entry, storage: storage) + case .enchanterPrologue, + .enchanterAttempt, + .enchanterRising, + .enchanterTimed: + iOSEnchantersTrialView(storage: storage, screenshotScene: scene) + case .dungeonPrologue, + .dungeonFirstAttempt: + iOSDungeonDescentView(storage: storage, screenshotScene: scene) + case .enchanterResult: + iOSGameResultView( + presenter: GameResultPresenter(result: enchanterResult), + gameKind: .findAndFocus, + gameSpecificAnnouncement: String(localized: "simon.results.legendary"), + onPlayAgain: {}, + onReturnToHub: {} + ) + case .dungeonResult: + iOSGameResultView( + presenter: GameResultPresenter(result: dungeonResult), + gameKind: .scrollHunt, + gameSpecificAnnouncement: String(localized: "dungeon.results.legendary"), + onPlayAgain: {}, + onReturnToHub: {} + ) + case .resonanceMockup: + iOSDungeonResonanceMockupView() + } + } + + /// Sample Enchanter result used for deterministic screenshot capture. + private var enchanterResult: GameResult { + GameResult( + gameID: "find-and-focus", + rank: .perfect, + timeSeconds: 8.4, + mistakes: 0 + ) + } + + /// Sample Crystal Resonance (`scroll-hunt`) result used for deterministic screenshot capture. + private var dungeonResult: GameResult { + GameResult( + gameID: "scroll-hunt", + rank: .perfect, + timeSeconds: 18.7, + mistakes: 0 + ) + } +} diff --git a/RA11y-iOS/RA11y-iOS/App/iOSScreenshotScene.swift b/RA11y-iOS/RA11y-iOS/App/iOSScreenshotScene.swift new file mode 100644 index 0000000..30dac5c --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/App/iOSScreenshotScene.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Declares every app scene that screenshot automation is allowed to boot directly. +/// +/// The fastlane screenshot lane and the UI test suite treat this enum as the app-side +/// contract for deterministic capture. Each case maps to one committed PNG. +enum iOSScreenshotScene: String, CaseIterable { + + /// Game hub root screen. + case hub = "hub" + + /// VoiceOver-required interstitial for a game launch. + case voRequired = "voRequired" + + /// First-run entry screen. + case firstRun = "firstRun" + + /// Enchanter L0 prologue. + case enchanterPrologue = "enchanterPrologue" + + /// Enchanter L1 first attempt. + case enchanterAttempt = "enchanterAttempt" + + /// Enchanter L2 rising challenge. + case enchanterRising = "enchanterRising" + + /// Enchanter L3 timed trial. + case enchanterTimed = "enchanterTimed" + + /// Shared result screen using Enchanter sample data. + case enchanterResult = "enchanterResult" + + /// Crystal Resonance L0 prologue (`dungeon*` identifiers retained for routing stability). + case dungeonPrologue = "dungeonPrologue" + + /// Crystal Resonance L1 first attempt. + case dungeonFirstAttempt = "dungeonFirstAttempt" + + /// Shared result screen using Crystal Resonance sample data. + case dungeonResult = "dungeonResult" + + /// Crystal Resonance v2 design mockup (preview-only surface; deterministic capture). + case resonanceMockup = "resonanceMockup" + + /// Process argument name used to request a specific screenshot scene. + static let launchArgument = "-screenshotScene" + + /// Resolves the requested screenshot scene from process arguments. + /// + /// - Parameter arguments: Launch arguments to inspect. Defaults to the current process. + /// - Returns: The requested screenshot scene, or `nil` when no valid scene was requested. + static func current(from arguments: [String] = ProcessInfo.processInfo.arguments) -> Self? { + guard let index = arguments.firstIndex(of: launchArgument) else { return nil } + let sceneIndex = arguments.index(after: index) + guard arguments.indices.contains(sceneIndex) else { return nil } + return Self(rawValue: arguments[sceneIndex]) + } + + /// The committed PNG basename associated with this scene. + var captureName: String { + switch self { + case .hub: return "01_Hub" + case .voRequired: return "02_VORequired" + case .firstRun: return "03_FirstRun" + case .enchanterPrologue: return "04_EnchanterPrologue" + case .enchanterAttempt: return "05_EnchanterAttempt" + case .enchanterRising: return "06_EnchanterRising" + case .enchanterTimed: return "07_EnchanterTimed" + case .enchanterResult: return "08_EnchanterResult" + case .dungeonPrologue: return "09_DungeonPrologue" + case .dungeonFirstAttempt: return "10_DungeonL1" + case .dungeonResult: return "11_DungeonResult" + case .resonanceMockup: return "12_ResonanceMockup" + } + } + + /// The root accessibility identifier that must exist before capture. + var rootAccessibilityIdentifier: String { + switch self { + case .hub: + return "hub.dmGreeting" + case .voRequired: + return "voRequired.title" + case .firstRun: + return "firstRun.title" + case .enchanterPrologue: + return "enchanter.prologue" + case .enchanterAttempt: + return "enchanter.attempt" + case .enchanterRising: + return "enchanter.rising" + case .enchanterTimed: + return "enchanter.timed" + case .enchanterResult, .dungeonResult: + return "gameResult.root" + case .dungeonPrologue: + return "dungeon.prologue" + case .dungeonFirstAttempt: + return "dungeon.firstAttempt" + case .resonanceMockup: + return "resonance.mockup.root" + } + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-dark.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-dark.png new file mode 100644 index 0000000..8d9f273 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-dark.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-tinted.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-tinted.png new file mode 100644 index 0000000..10f5350 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024-tinted.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png new file mode 100644 index 0000000..9521aa7 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/AppIcon-1024.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json index 2305880..59f3014 100644 --- a/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,6 +1,7 @@ { "images" : [ { + "filename" : "AppIcon-1024.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -12,6 +13,7 @@ "value" : "dark" } ], + "filename" : "AppIcon-1024-dark.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" @@ -23,6 +25,7 @@ "value" : "tinted" } ], + "filename" : "AppIcon-1024-tinted.png", "idiom" : "universal", "platform" : "ios", "size" : "1024x1024" diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_ember_shard.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_ember_shard.imageset/Contents.json new file mode 100644 index 0000000..5bfc3de --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_ember_shard.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_decoy_ember_shard.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..4684cdf Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_shadow_glyph.imageset/Contents.json new file mode 100644 index 0000000..a4d4bf5 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_shadow_glyph.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_decoy_shadow_glyph.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..8a8e638 Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_sun_sigil.imageset/Contents.json new file mode 100644 index 0000000..552816e --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_decoy_sun_sigil.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_decoy_sun_sigil.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..91949c2 Binary files /dev/null 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_room_entrance.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_descent_bg.imageset/Contents.json similarity index 75% rename from RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_entrance.imageset/Contents.json rename to RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_descent_bg.imageset/Contents.json index 4ae89b8..f5b8419 100644 --- a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_entrance.imageset/Contents.json +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_descent_bg.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "dungeon_room_entrance.png", + "filename" : "dungeon_descent_bg.png", "idiom" : "universal", "scale" : "1x" } diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_descent_bg.imageset/dungeon_descent_bg.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_descent_bg.imageset/dungeon_descent_bg.png new file mode 100644 index 0000000..14eda17 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_descent_bg.imageset/dungeon_descent_bg.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_hub_icon.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_hub_icon.imageset/Contents.json new file mode 100644 index 0000000..70eacac --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_hub_icon.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "dungeon_hub_icon.png", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_hub_icon.imageset/dungeon_hub_icon.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_hub_icon.imageset/dungeon_hub_icon.png new file mode 100644 index 0000000..8eeb37a Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_hub_icon.imageset/dungeon_hub_icon.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_hub_icon.imageset/dungeon_hub_icon.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_hub_icon.imageset/dungeon_hub_icon.png.bak new file mode 100644 index 0000000..2645467 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_hub_icon.imageset/dungeon_hub_icon.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_lane_marker_neutral.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_lane_marker_neutral.imageset/Contents.json new file mode 100644 index 0000000..3fbd8a2 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_lane_marker_neutral.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_lane_marker_neutral.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..edae37a Binary files /dev/null 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_bg.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_bg.imageset/Contents.json new file mode 100644 index 0000000..b3c9c72 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_bg.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_resonance_bg.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_bg.imageset/dungeon_resonance_bg.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_bg.imageset/dungeon_resonance_bg.png new file mode 100644 index 0000000..358c49b Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_bg.imageset/dungeon_resonance_bg.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_idle.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_idle.imageset/Contents.json new file mode 100644 index 0000000..05427de --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_idle.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_resonance_orb_idle.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..87b033e Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_locked.imageset/Contents.json new file mode 100644 index 0000000..eb7f9b8 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_resonance_orb_locked.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_resonance_orb_locked.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..fa86ffc Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_reticle_ring.imageset/Contents.json new file mode 100644 index 0000000..05fc207 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_reticle_ring.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_reticle_ring.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..c808b44 Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_ancient_vault.imageset/Contents.json new file mode 100644 index 0000000..61fab4d --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_ancient_vault.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_room_ancient_vault.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..440236a Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_armory.imageset/Contents.json new file mode 100644 index 0000000..1aa93df --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_armory.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_room_armory.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..374fb5a Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_barracks.imageset/Contents.json new file mode 100644 index 0000000..6b001f3 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_barracks.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_room_barracks.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..1033f8d Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_bone_room.imageset/Contents.json new file mode 100644 index 0000000..d13c439 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_bone_room.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_room_bone_room.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..11908b2 Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_collapsed_wall.imageset/Contents.json new file mode 100644 index 0000000..6356069 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_collapsed_wall.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_room_collapsed_wall.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..c8c9155 Binary files /dev/null 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_entrance.imageset/dungeon_room_entrance.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_entrance.imageset/dungeon_room_entrance.png deleted file mode 100644 index eade299..0000000 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_entrance.imageset/dungeon_room_entrance.png and /dev/null differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_entry_hall.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_entry_hall.imageset/Contents.json new file mode 100644 index 0000000..8381c8b --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_entry_hall.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_room_entry_hall.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..73702ab Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_flooded_hall.imageset/Contents.json new file mode 100644 index 0000000..032f9bf --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_flooded_hall.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_room_flooded_hall.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..0f99ef0 Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_guard_post.imageset/Contents.json new file mode 100644 index 0000000..1ee547a --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_guard_post.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_room_guard_post.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..300aa70 Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_ritual_chamber.imageset/Contents.json new file mode 100644 index 0000000..a53f54e --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_ritual_chamber.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_room_ritual_chamber.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..8e2e5c7 Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_store_room.imageset/Contents.json new file mode 100644 index 0000000..9a1ea93 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_store_room.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_room_store_room.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..190ebe5 Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_trophy_room.imageset/Contents.json new file mode 100644 index 0000000..1f1b488 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_trophy_room.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_room_trophy_room.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..a31b132 Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_well_chamber.imageset/Contents.json new file mode 100644 index 0000000..a1911f8 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_room_well_chamber.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_room_well_chamber.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..8334e83 Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_spotlight_mask_reference.imageset/Contents.json new file mode 100644 index 0000000..a6cb33b --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_spotlight_mask_reference.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_spotlight_mask_reference.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..bece5e3 Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_success_flare.imageset/Contents.json new file mode 100644 index 0000000..1504c78 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_success_flare.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_success_flare.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..a58f30c Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_target_moonstone.imageset/Contents.json new file mode 100644 index 0000000..ea32dc1 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/dungeon_target_moonstone.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "dungeon_target_moonstone.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000..857afbd Binary files /dev/null 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/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_hub_icon.imageset/Contents.json new file mode 100644 index 0000000..1908991 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_hub_icon.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "enchanter_hub_icon.png", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} 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 new file mode 100644 index 0000000..e5194f2 Binary files /dev/null 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/enchanter_relic_dragon_claw.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_claw.imageset/Contents.json new file mode 100644 index 0000000..ab79cd2 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_claw.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "enchanter_relic_dragon_claw.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_claw.imageset/enchanter_relic_dragon_claw.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_claw.imageset/enchanter_relic_dragon_claw.png new file mode 100644 index 0000000..6c77e90 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_claw.imageset/enchanter_relic_dragon_claw.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_claw.imageset/enchanter_relic_dragon_claw.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_claw.imageset/enchanter_relic_dragon_claw.png.bak new file mode 100644 index 0000000..7820046 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_claw.imageset/enchanter_relic_dragon_claw.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_scale.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_scale.imageset/Contents.json new file mode 100644 index 0000000..5e55451 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_scale.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "enchanter_relic_dragon_scale.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_scale.imageset/enchanter_relic_dragon_scale.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_scale.imageset/enchanter_relic_dragon_scale.png new file mode 100644 index 0000000..afea99b Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_scale.imageset/enchanter_relic_dragon_scale.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_scale.imageset/enchanter_relic_dragon_scale.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_scale.imageset/enchanter_relic_dragon_scale.png.bak new file mode 100644 index 0000000..e41c6c7 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_dragon_scale.imageset/enchanter_relic_dragon_scale.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_ember_glass.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_ember_glass.imageset/Contents.json new file mode 100644 index 0000000..07755da --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_ember_glass.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "enchanter_relic_ember_glass.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_ember_glass.imageset/enchanter_relic_ember_glass.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_ember_glass.imageset/enchanter_relic_ember_glass.png new file mode 100644 index 0000000..8a24848 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_ember_glass.imageset/enchanter_relic_ember_glass.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_ember_glass.imageset/enchanter_relic_ember_glass.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_ember_glass.imageset/enchanter_relic_ember_glass.png.bak new file mode 100644 index 0000000..ec6bb85 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_ember_glass.imageset/enchanter_relic_ember_glass.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_frost_glass.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_frost_glass.imageset/Contents.json new file mode 100644 index 0000000..7a818e9 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_frost_glass.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "enchanter_relic_frost_glass.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_frost_glass.imageset/enchanter_relic_frost_glass.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_frost_glass.imageset/enchanter_relic_frost_glass.png new file mode 100644 index 0000000..aeb20c3 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_frost_glass.imageset/enchanter_relic_frost_glass.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_frost_glass.imageset/enchanter_relic_frost_glass.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_frost_glass.imageset/enchanter_relic_frost_glass.png.bak new file mode 100644 index 0000000..f8227bd Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_frost_glass.imageset/enchanter_relic_frost_glass.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_iron_shard.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_iron_shard.imageset/Contents.json new file mode 100644 index 0000000..a512819 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_iron_shard.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "enchanter_relic_iron_shard.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_iron_shard.imageset/enchanter_relic_iron_shard.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_iron_shard.imageset/enchanter_relic_iron_shard.png new file mode 100644 index 0000000..a11f74c Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_iron_shard.imageset/enchanter_relic_iron_shard.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_iron_shard.imageset/enchanter_relic_iron_shard.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_iron_shard.imageset/enchanter_relic_iron_shard.png.bak new file mode 100644 index 0000000..01eaa5e Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_iron_shard.imageset/enchanter_relic_iron_shard.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_moonstone.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_moonstone.imageset/Contents.json new file mode 100644 index 0000000..ec3bad4 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_moonstone.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "enchanter_relic_moonstone.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_moonstone.imageset/enchanter_relic_moonstone.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_moonstone.imageset/enchanter_relic_moonstone.png new file mode 100644 index 0000000..7dc768c Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_moonstone.imageset/enchanter_relic_moonstone.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_moonstone.imageset/enchanter_relic_moonstone.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_moonstone.imageset/enchanter_relic_moonstone.png.bak new file mode 100644 index 0000000..373fe04 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_moonstone.imageset/enchanter_relic_moonstone.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_shadow_stone.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_shadow_stone.imageset/Contents.json new file mode 100644 index 0000000..ffac5b8 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_shadow_stone.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "enchanter_relic_shadow_stone.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_shadow_stone.imageset/enchanter_relic_shadow_stone.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_shadow_stone.imageset/enchanter_relic_shadow_stone.png new file mode 100644 index 0000000..88a6449 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_shadow_stone.imageset/enchanter_relic_shadow_stone.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_shadow_stone.imageset/enchanter_relic_shadow_stone.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_shadow_stone.imageset/enchanter_relic_shadow_stone.png.bak new file mode 100644 index 0000000..e073a0b Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_shadow_stone.imageset/enchanter_relic_shadow_stone.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_sunstone.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_sunstone.imageset/Contents.json new file mode 100644 index 0000000..c9b240d --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_sunstone.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "enchanter_relic_sunstone.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_sunstone.imageset/enchanter_relic_sunstone.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_sunstone.imageset/enchanter_relic_sunstone.png new file mode 100644 index 0000000..490be6b Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_sunstone.imageset/enchanter_relic_sunstone.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_sunstone.imageset/enchanter_relic_sunstone.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_sunstone.imageset/enchanter_relic_sunstone.png.bak new file mode 100644 index 0000000..c199848 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_sunstone.imageset/enchanter_relic_sunstone.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_target.imageset/enchanter_relic_target.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_target.imageset/enchanter_relic_target.png index 26cb9e8..7b85aa4 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_target.imageset/enchanter_relic_target.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_target.imageset/enchanter_relic_target.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_target.imageset/enchanter_relic_target.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_target.imageset/enchanter_relic_target.png.bak new file mode 100644 index 0000000..26cb9e8 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_relic_target.imageset/enchanter_relic_target.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_tower_shelf_bg.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_tower_shelf_bg.imageset/Contents.json new file mode 100644 index 0000000..651b8de --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_tower_shelf_bg.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "enchanter_tower_shelf_bg.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_tower_shelf_bg.imageset/enchanter_tower_shelf_bg.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_tower_shelf_bg.imageset/enchanter_tower_shelf_bg.png new file mode 100644 index 0000000..89e112d Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/enchanter_tower_shelf_bg.imageset/enchanter_tower_shelf_bg.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_hub_icon.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_hub_icon.imageset/Contents.json new file mode 100644 index 0000000..b5767d7 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_hub_icon.imageset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + { "idiom" : "universal", "filename" : "rogue_hub_icon.png", "scale" : "1x" }, + { "idiom" : "universal", "scale" : "2x" }, + { "idiom" : "universal", "scale" : "3x" } + ], + "info" : { "author" : "xcode", "version" : 1 } +} 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 new file mode 100644 index 0000000..7f12cf4 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_hub_icon.imageset/rogue_hub_icon.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_alarm.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_alarm.imageset/Contents.json new file mode 100644 index 0000000..e359fec --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_alarm.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "rogue_seal_alarm.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_alarm.imageset/rogue_seal_alarm.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_alarm.imageset/rogue_seal_alarm.png new file mode 100644 index 0000000..7ca9dea Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_alarm.imageset/rogue_seal_alarm.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_alarm.imageset/rogue_seal_alarm.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_alarm.imageset/rogue_seal_alarm.png.bak new file mode 100644 index 0000000..7484a66 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_alarm.imageset/rogue_seal_alarm.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_binding.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_binding.imageset/Contents.json new file mode 100644 index 0000000..d4e6cfa --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_binding.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "rogue_seal_binding.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_binding.imageset/rogue_seal_binding.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_binding.imageset/rogue_seal_binding.png new file mode 100644 index 0000000..63974b6 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_binding.imageset/rogue_seal_binding.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_binding.imageset/rogue_seal_binding.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_binding.imageset/rogue_seal_binding.png.bak new file mode 100644 index 0000000..a0a2d80 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_binding.imageset/rogue_seal_binding.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_collapse.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_collapse.imageset/Contents.json new file mode 100644 index 0000000..9d2fb5e --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_collapse.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "rogue_seal_collapse.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_collapse.imageset/rogue_seal_collapse.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_collapse.imageset/rogue_seal_collapse.png new file mode 100644 index 0000000..c3444ea Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_collapse.imageset/rogue_seal_collapse.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_collapse.imageset/rogue_seal_collapse.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_collapse.imageset/rogue_seal_collapse.png.bak new file mode 100644 index 0000000..476717a Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_collapse.imageset/rogue_seal_collapse.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_freeze.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_freeze.imageset/Contents.json new file mode 100644 index 0000000..e5e5b08 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_freeze.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "rogue_seal_freeze.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_freeze.imageset/rogue_seal_freeze.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_freeze.imageset/rogue_seal_freeze.png new file mode 100644 index 0000000..e9d3360 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_freeze.imageset/rogue_seal_freeze.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_freeze.imageset/rogue_seal_freeze.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_freeze.imageset/rogue_seal_freeze.png.bak new file mode 100644 index 0000000..870c65e Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_freeze.imageset/rogue_seal_freeze.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_passage.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_passage.imageset/Contents.json new file mode 100644 index 0000000..b571bde --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_passage.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "rogue_seal_passage.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_passage.imageset/rogue_seal_passage.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_passage.imageset/rogue_seal_passage.png new file mode 100644 index 0000000..539b9a5 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_passage.imageset/rogue_seal_passage.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_passage.imageset/rogue_seal_passage.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_passage.imageset/rogue_seal_passage.png.bak new file mode 100644 index 0000000..1a8cfb8 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_passage.imageset/rogue_seal_passage.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_reveal.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_reveal.imageset/Contents.json new file mode 100644 index 0000000..e755011 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_reveal.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "rogue_seal_reveal.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_reveal.imageset/rogue_seal_reveal.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_reveal.imageset/rogue_seal_reveal.png new file mode 100644 index 0000000..e0765a3 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_reveal.imageset/rogue_seal_reveal.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_reveal.imageset/rogue_seal_reveal.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_reveal.imageset/rogue_seal_reveal.png.bak new file mode 100644 index 0000000..e0cc685 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_reveal.imageset/rogue_seal_reveal.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_silence.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_silence.imageset/Contents.json new file mode 100644 index 0000000..5099610 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_silence.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "rogue_seal_silence.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_silence.imageset/rogue_seal_silence.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_silence.imageset/rogue_seal_silence.png new file mode 100644 index 0000000..2cea830 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_silence.imageset/rogue_seal_silence.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_silence.imageset/rogue_seal_silence.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_silence.imageset/rogue_seal_silence.png.bak new file mode 100644 index 0000000..0aeae11 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_silence.imageset/rogue_seal_silence.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_spike.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_spike.imageset/Contents.json new file mode 100644 index 0000000..74194fe --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_spike.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "rogue_seal_spike.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_spike.imageset/rogue_seal_spike.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_spike.imageset/rogue_seal_spike.png new file mode 100644 index 0000000..d6440e4 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_spike.imageset/rogue_seal_spike.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_spike.imageset/rogue_seal_spike.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_spike.imageset/rogue_seal_spike.png.bak new file mode 100644 index 0000000..6880db4 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_spike.imageset/rogue_seal_spike.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_target.imageset/rogue_seal_target.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_target.imageset/rogue_seal_target.png index 1391647..8e7040f 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_target.imageset/rogue_seal_target.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_target.imageset/rogue_seal_target.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_target.imageset/rogue_seal_target.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_target.imageset/rogue_seal_target.png.bak new file mode 100644 index 0000000..1391647 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_target.imageset/rogue_seal_target.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_ward.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_ward.imageset/Contents.json new file mode 100644 index 0000000..57bd0ac --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_ward.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "rogue_seal_ward.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_ward.imageset/rogue_seal_ward.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_ward.imageset/rogue_seal_ward.png new file mode 100644 index 0000000..2a831aa Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_ward.imageset/rogue_seal_ward.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_ward.imageset/rogue_seal_ward.png.bak b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_ward.imageset/rogue_seal_ward.png.bak new file mode 100644 index 0000000..2374b53 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_seal_ward.imageset/rogue_seal_ward.png.bak differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_trap_door_bg.imageset/Contents.json b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_trap_door_bg.imageset/Contents.json new file mode 100644 index 0000000..5db1a2b --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_trap_door_bg.imageset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "filename" : "rogue_trap_door_bg.png", + "idiom" : "universal", + "scale" : "1x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_trap_door_bg.imageset/rogue_trap_door_bg.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_trap_door_bg.imageset/rogue_trap_door_bg.png new file mode 100644 index 0000000..c81b3f7 Binary files /dev/null and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/rogue_trap_door_bg.imageset/rogue_trap_door_bg.png differ diff --git a/RA11y-iOS/RA11y-iOS/Assets.xcassets/simon_room_bg.imageset/simon_room_bg.png b/RA11y-iOS/RA11y-iOS/Assets.xcassets/simon_room_bg.imageset/simon_room_bg.png index bae58b5..652cd3a 100644 Binary files a/RA11y-iOS/RA11y-iOS/Assets.xcassets/simon_room_bg.imageset/simon_room_bg.png and b/RA11y-iOS/RA11y-iOS/Assets.xcassets/simon_room_bg.imageset/simon_room_bg.png differ diff --git a/RA11y-iOS/RA11y-iOS/Design/iOSTokens.swift b/RA11y-iOS/RA11y-iOS/Design/iOSTokens.swift index f75881d..123449c 100644 --- a/RA11y-iOS/RA11y-iOS/Design/iOSTokens.swift +++ b/RA11y-iOS/RA11y-iOS/Design/iOSTokens.swift @@ -32,31 +32,45 @@ extension Color { static let ra11yLabel = Color(UIColor.label) /// Secondary / supporting text color. + /// + /// - Note: This adapts to system appearance and is designed for use on system + /// backgrounds. Do NOT use on the fixed-dark quest card surface — use + /// `ra11yCardSecondaryText` instead to guarantee contrast. static let ra11ySecondaryLabel = Color(UIColor.secondaryLabel) - /// Warm gold used for headings, borders, and accents in the D&D theme. - static let ra11yGold = Color(red: 0.88, green: 0.72, blue: 0.38) - - /// Deeper gold used for fills and emphasis. - static let ra11yGoldDeep = Color(red: 0.72, green: 0.54, blue: 0.20) - - /// Primary quest card surface color. - static let ra11yCardSurface = Color(red: 0.16, green: 0.12, blue: 0.10) - - /// Highlighted quest card surface tone for subtle gradients. - static let ra11yCardSurfaceHighlight = Color(red: 0.20, green: 0.15, blue: 0.12) + /// Secondary text on the fixed-dark quest card surface. + /// + /// Quest cards use `Color(red: 0.13, green: 0.10, blue: 0.09)` — a fixed dark + /// background that does not adapt to system appearance. Using an adaptive system + /// color like `UIColor.secondaryLabel` here would pass WCAG arithmetic but + /// renders perceptually low-contrast on physical devices. + /// + /// This token provides ≥ 13:1 contrast on the dark card surface (WCAG AAA), + /// giving goal-level text strong legibility across all ambient conditions. + static let ra11yCardSecondaryText = Color.white.opacity(0.85) - /// Quest card border color. - static let ra11yCardBorder = Color(red: 0.78, green: 0.60, blue: 0.22) + /// Tertiary / fine-print text on the fixed-dark quest card surface. + /// + /// Provides ≥ 8:1 contrast (WCAG AAA for large text) on the dark card surface. + /// Use for caption-level content — estimated duration, supplementary labels. + static let ra11yCardTertiaryText = Color.white.opacity(0.65) - /// Footer background surface behind the hub buttons. - static let ra11yFooterSurface = Color(red: 0.12, green: 0.09, blue: 0.07) + /// DM narrative card border — warm gold tone used across all three games. + /// + /// Fixed (non-adaptive) because it appears on fixed-dark in-game surfaces. + static let ra11yDMBorder = Color(red: 0.75, green: 0.55, blue: 0.10) - /// Text color tuned for dark, warm surfaces. - static let ra11yWarmText = Color(red: 0.92, green: 0.88, blue: 0.80) + /// Fallback solid background for game scenes when asset loading fails. + /// + /// A fixed near-black tone matching the expected dark stone/stone-dungeon atmosphere. + static let ra11yGameFallbackBackground = Color(red: 0.08, green: 0.06, blue: 0.04) - /// Secondary text color for warm surfaces. - static let ra11yWarmTextSecondary = Color(red: 0.82, green: 0.76, blue: 0.68) + /// Semantic color for a reachable / confirmed target — used on the target room status icon. + /// + /// Uses a fixed success-green appropriate for fixed-dark game surfaces. + /// The icon shape (checkmark.seal vs lock.fill) conveys the same information + /// without relying on color alone, satisfying WCAG 1.4.1 (Use of Color). + static let ra11yTargetReachable = Color(red: 0.20, green: 0.78, blue: 0.35) } // MARK: - Token Notes diff --git a/RA11y-iOS/RA11y-iOS/Feedback/iOSAudioFeedbackRenderer.swift b/RA11y-iOS/RA11y-iOS/Feedback/iOSAudioFeedbackRenderer.swift new file mode 100644 index 0000000..95b40ae --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Feedback/iOSAudioFeedbackRenderer.swift @@ -0,0 +1,199 @@ +import AVFoundation +import OSLog +import RA11yCore + +/// Renders semantic feedback intents as lightweight generated iOS tones. +/// +/// The renderer stays semantic: quests emit shared feedback intents, and this type maps them +/// to short synthesized cue patterns. No gameplay view needs to know about frequencies, +/// envelopes, or audio engine details. +@MainActor +protocol iOSAudioFeedbackRendering { + /// Renders one semantic feedback cue. + func render(intent: QuestFeedbackIntent, cue: QuestFeedbackCue) +} + +/// Internal tone segment used to synthesize short cue patterns. +private struct ToneSegment { + let primaryFrequency: Double + let secondaryFrequency: Double? + let duration: Double + let amplitude: Float + let trailingSilence: Double +} + +/// Default iOS audio renderer for quest feedback. +@MainActor +final class iOSAudioFeedbackRenderer: iOSAudioFeedbackRendering { + + private let session: AVAudioSession + private let engine = AVAudioEngine() + private let player = AVAudioPlayerNode() + private let sampleRate: Double = 44_100 + private var hasConfiguredSession = false + private var hasPreparedEngine = false + + init(session: AVAudioSession = .sharedInstance()) { + self.session = session + } + + func render(intent: QuestFeedbackIntent, cue: QuestFeedbackCue) { + guard cue.audio != .none else { return } + configureSessionIfNeeded() + prepareEngineIfNeeded() + + guard engine.isRunning 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 + } + + if !player.isPlaying { + player.play() + } + player.scheduleBuffer(buffer, at: nil, options: .interrupts) + RA11yLogger.feedback.debug( + "Audio cue rendered — intent: \(String(describing: intent)), family: \(cue.audio.rawValue)" + ) + } + + private func configureSessionIfNeeded() { + guard !hasConfiguredSession else { return } + do { + try session.setCategory(.ambient, mode: .default, options: [.mixWithOthers]) + try session.setActive(true) + hasConfiguredSession = true + } catch { + RA11yLogger.feedback.error("Failed to configure audio session: \(error.localizedDescription)") + } + } + + private func prepareEngineIfNeeded() { + guard !hasPreparedEngine else { return } + + let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1) + guard let format else { + RA11yLogger.feedback.error("Failed to create audio format for feedback renderer") + return + } + + engine.attach(player) + engine.connect(player, to: engine.mainMixerNode, format: format) + + do { + try engine.start() + hasPreparedEngine = true + } catch { + RA11yLogger.feedback.error("Failed to start audio engine: \(error.localizedDescription)") + } + } + + private func cuePattern(for intent: QuestFeedbackIntent, family: QuestFeedbackAudioFamily) -> [ToneSegment] { + switch family { + case .none: + return [] + case .resonance: + return resonancePattern(for: intent) + case .mutedError: + return [ + ToneSegment(primaryFrequency: 288, secondaryFrequency: 272, duration: 0.08, amplitude: 0.07, trailingSilence: 0.02), + ToneSegment(primaryFrequency: 244, secondaryFrequency: 230, duration: 0.12, amplitude: 0.08, trailingSilence: 0.0), + ] + case .crystallineSuccess: + return [ + ToneSegment(primaryFrequency: 660, secondaryFrequency: 990, duration: 0.08, amplitude: 0.08, trailingSilence: 0.025), + ToneSegment(primaryFrequency: 880, secondaryFrequency: 1_320, duration: 0.1, amplitude: 0.1, trailingSilence: 0.025), + ToneSegment(primaryFrequency: 1_176, secondaryFrequency: 1_760, duration: 0.16, amplitude: 0.12, trailingSilence: 0.0), + ] + case .warningPulse: + return [ + ToneSegment(primaryFrequency: 372, secondaryFrequency: 366, duration: 0.12, amplitude: 0.08, trailingSilence: 0.02), + ToneSegment(primaryFrequency: 332, secondaryFrequency: nil, duration: 0.1, amplitude: 0.06, trailingSilence: 0.0), + ] + case .hintChime: + return [ + ToneSegment(primaryFrequency: 740, secondaryFrequency: 1_110, duration: 0.14, amplitude: 0.08, trailingSilence: 0.0), + ] + } + } + + private func resonancePattern(for intent: QuestFeedbackIntent) -> [ToneSegment] { + switch intent { + case .proximityEntered(.warm): + return [ + ToneSegment(primaryFrequency: 432, secondaryFrequency: 438, duration: 0.12, amplitude: 0.05, trailingSilence: 0.0), + ] + case .proximityEntered(.near): + return [ + ToneSegment(primaryFrequency: 524, secondaryFrequency: 526, duration: 0.12, amplitude: 0.07, trailingSilence: 0.0), + ] + case .proximityEntered(.locked), .lockAcquired: + return [ + ToneSegment(primaryFrequency: 660, secondaryFrequency: 990, duration: 0.18, amplitude: 0.1, trailingSilence: 0.0), + ] + case .lockLost: + return [ + ToneSegment(primaryFrequency: 510, secondaryFrequency: 500, duration: 0.1, amplitude: 0.06, trailingSilence: 0.0), + ] + default: + return [ + ToneSegment(primaryFrequency: 410, secondaryFrequency: 418, duration: 0.1, amplitude: 0.04, trailingSilence: 0.0), + ] + } + } + + private func makeBuffer(for segments: [ToneSegment]) -> AVAudioPCMBuffer? { + guard !segments.isEmpty else { return nil } + guard let format = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1) else { + return nil + } + + let totalFrameCount = segments.reduce(0) { partialResult, segment in + let frames = Int((segment.duration + segment.trailingSilence) * sampleRate) + return partialResult + max(frames, 1) + } + + guard let buffer = AVAudioPCMBuffer( + pcmFormat: format, + frameCapacity: AVAudioFrameCount(totalFrameCount) + ) else { + return nil + } + + buffer.frameLength = AVAudioFrameCount(totalFrameCount) + guard let channelData = buffer.floatChannelData?[0] else { return nil } + + var frameIndex = 0 + for segment in segments { + let toneFrames = max(Int(segment.duration * sampleRate), 1) + let silenceFrames = max(Int(segment.trailingSilence * sampleRate), 0) + for localFrame in 0.. 0 { + for _ in 0..= cue.cooldownSeconds else { return } + lastRenderByKey[key] = now + + if settings.hapticsEnabled { + hapticRenderer.render(intent: intent, cue: cue) + } + if settings.soundEnabled { + audioRenderer.render(intent: intent, cue: cue) + } + } + + private func cue(for intent: QuestFeedbackIntent) -> QuestFeedbackCue { + switch intent { + case .proximityEntered(.far): + return profile.warmCue + case .proximityEntered(.warm): + return profile.warmCue + case .proximityEntered(.near): + return profile.nearCue + case .proximityEntered(.locked): + return profile.lockCue + case .lockAcquired: + return profile.lockCue + case .lockLost: + return profile.lockLostCue + case .wrongActivation: + return profile.wrongActivationCue + case .success: + return profile.successCue + case .timeout: + return profile.timeoutCue + case .hint: + return profile.hintCue + } + } + + private func cooldownKey(for intent: QuestFeedbackIntent) -> String { + switch intent { + case .proximityEntered(let band): + return "proximity-\(band.rawValue)" + case .lockAcquired: + return "lock-acquired" + case .lockLost: + return "lock-lost" + case .wrongActivation: + return "wrong-activation" + case .success: + return "success" + case .timeout: + return "timeout" + case .hint: + return "hint" + } + } +} diff --git a/RA11y-iOS/RA11y-iOS/FirstRun/iOSBasicsSequenceView.swift b/RA11y-iOS/RA11y-iOS/FirstRun/iOSBasicsSequenceView.swift index 4280c89..487bad8 100644 --- a/RA11y-iOS/RA11y-iOS/FirstRun/iOSBasicsSequenceView.swift +++ b/RA11y-iOS/RA11y-iOS/FirstRun/iOSBasicsSequenceView.swift @@ -3,20 +3,38 @@ import RA11yCore // MARK: - iOSBasicsSequenceView -/// Guided VoiceOver Basics sequence that runs the three MVP games in order. +/// Guided VoiceOver Basics sequence that walks the player through all three MVP games +/// in hub unlock order (Enchanter → Crystal Resonance → Rogue). /// -/// This view is a lightweight placeholder until the game screens land (M5–M7). -/// It advances through the sequence and marks completion in storage. +/// 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: +/// +/// 1. Show a skill-intro card for the current game. +/// 2. Player taps "Begin [Game Name]" → game route is pushed onto the stack. +/// 3. Game plays; on L3 completion the game pushes `.gameResult`. +/// 4. Result screen shows "Continue Basics" → `router.popForBasicsContinue()` removes +/// both the result and the game view, returning here. +/// 5. `.onAppear` detects the completed step (via `router.basicsStepCompleted`) and either +/// advances to the next card or marks the sequence complete and pops to root. +/// +/// If the player uses back-navigation from within a game (without completing it), +/// `router.basicsStepCompleted` remains `false` so the index does **not** advance. +/// +/// ## Concurrency +/// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. struct iOSBasicsSequenceView: View { // MARK: - Private Properties private let storage: any StorageComponent - private let steps: [GameKind] = [.findAndFocus, .activateDoubleTap, .scrollHunt] + private let steps: [GameKind] = [.findAndFocus, .scrollHunt, .activateDoubleTap] // MARK: - State @State private var currentIndex = 0 + /// `true` after the player taps "Begin" to launch a game. Consumed by `.onAppear` + /// when the sequence view returns to the top of the nav stack. + @State private var hasLaunchedGame = false // MARK: - Environment @@ -33,16 +51,25 @@ struct iOSBasicsSequenceView: View { // MARK: - Body - /// The primary view content for the Basics sequence. var body: some View { - VStack(alignment: .center, spacing: RA11ySpacing.xl) { - headerSection - currentStepCard - continueButton + GeometryReader { geo in + ScrollView { + VStack(alignment: .center, spacing: RA11ySpacing.xl) { + headerSection + currentStepCard + beginButton + } + .padding(RA11ySpacing.base) + .frame(width: geo.size.width) + .frame(maxWidth: 600) + .frame(maxWidth: .infinity) + } + .clipped() } - .padding(RA11ySpacing.base) - .frame(maxWidth: 600) .frame(maxWidth: .infinity, maxHeight: .infinity) + .onAppear { + handleReappear() + } } // MARK: - Subviews @@ -62,31 +89,57 @@ struct iOSBasicsSequenceView: View { } private var currentStepCard: some View { - let definition = currentDefinition - return VStack(spacing: RA11ySpacing.sm) { - Text(LocalizedStringKey(definition.titleKey)) + VStack(alignment: .leading, spacing: RA11ySpacing.md) { + // Skill badge + Text(skillBadgeText) + .font(.ra11yCaption) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + .textCase(.uppercase) + .tracking(0.5) + .accessibilityHidden(true) + + // Skill title + Text(skillTitle) .font(.ra11yHeadline) - .multilineTextAlignment(.center) + .bold() + .multilineTextAlignment(.leading) - Text(LocalizedStringKey(definition.goalKey)) + // Skill intro body + Text(skillIntro) .font(.ra11yBody) .foregroundStyle(.secondary) - .multilineTextAlignment(.center) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + + Divider() + .padding(.vertical, RA11ySpacing.xs) + + // Game name + Label { + Text(LocalizedStringKey(currentDefinition.titleKey)) + .font(.ra11ySubheadline) + } icon: { + Image(systemName: "gamecontroller.fill") + .foregroundStyle(.secondary) + } } .padding(RA11ySpacing.lg) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, alignment: .leading) .background(.thinMaterial) .clipShape(.rect(cornerRadius: RA11yRadius.card)) + .accessibilityElement(children: .combine) + .accessibilityLabel(cardAccessibilityLabel) } - private var continueButton: some View { - Button(continueButtonTitle) { - advance() + private var beginButton: some View { + Button(beginButtonTitle) { + launchCurrentGame() } .buttonStyle(.borderedProminent) .controlSize(.large) - .accessibilityLabel(continueAccessibilityLabel) - .accessibilityHint(continueAccessibilityHint) + .accessibilityLabel(beginAccessibilityLabel) + .accessibilityHint(beginAccessibilityHint) } // MARK: - Computed @@ -101,39 +154,81 @@ struct iOSBasicsSequenceView: View { return String(format: format, currentIndex + 1, steps.count) } - private var continueButtonTitle: String { - if isLastStep { - return String(localized: "basicsSequence.finish") - } - return String(localized: "basicsSequence.continue") + private var skillBadgeText: String { + let format = String(localized: "basicsSequence.skillBadgeFormat") + return String(format: format, currentIndex + 1, steps.count) } - private var continueAccessibilityLabel: String { - if isLastStep { - return String(localized: "basicsSequence.finish.a11yLabel") + private var skillTitle: String { + 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") } - return String(localized: "basicsSequence.continue.a11yLabel") } - private var continueAccessibilityHint: String { - if isLastStep { - return String(localized: "basicsSequence.finish.a11yHint") + private var skillIntro: String { + 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") } - return String(localized: "basicsSequence.continue.a11yHint") } - private var isLastStep: Bool { - currentIndex == steps.count - 1 + /// Localized game title resolved from the definition key at runtime. + private var localizedGameTitle: String { + NSLocalizedString(currentDefinition.titleKey, comment: "") + } + + private var cardAccessibilityLabel: String { + "\(skillBadgeText). \(skillTitle). \(skillIntro). \(localizedGameTitle)." + } + + private var beginButtonTitle: String { + let format = String(localized: "basicsSequence.beginGame") + return String(format: format, localizedGameTitle) + } + + private var beginAccessibilityLabel: String { + let format = String(localized: "basicsSequence.beginGame.a11yLabel") + return String(format: format, localizedGameTitle) + } + + private var beginAccessibilityHint: String { + String(localized: "basicsSequence.beginGame.a11yHint") } // MARK: - Actions - private func advance() { - if !isLastStep { - currentIndex += 1 + private func launchCurrentGame() { + hasLaunchedGame = true + router.isInBasicsSequence = true + router.pushGame(kind: steps[currentIndex]) + } + + /// Called on every `.onAppear`. Advances the sequence when returning from a completed + /// game step; no-ops on initial appearance or after back-navigation mid-game. + private func handleReappear() { + guard hasLaunchedGame else { return } + hasLaunchedGame = false + + // Only advance if "Continue Basics" was explicitly tapped (not a back swipe). + guard router.basicsStepCompleted else { return } + router.basicsStepCompleted = false + + let nextIndex = currentIndex + 1 + if nextIndex < steps.count { + currentIndex = nextIndex } else { + // All three games completed — mark done and return to hub. + router.isInBasicsSequence = false Task { - guard !Task.isCancelled else { return } await storage.markBasicsCompleted() router.popToRoot() } @@ -141,7 +236,7 @@ struct iOSBasicsSequenceView: View { } } -#Preview("Basics Sequence") { +#Preview("Basics Sequence — Step 1") { NavigationStack { iOSBasicsSequenceView(storage: UserDefaultsStorageComponent()) .environment(iOSAppRouter()) diff --git a/RA11y-iOS/RA11y-iOS/FirstRun/iOSFirstRunView.swift b/RA11y-iOS/RA11y-iOS/FirstRun/iOSFirstRunView.swift index 540149f..a76ceef 100644 --- a/RA11y-iOS/RA11y-iOS/FirstRun/iOSFirstRunView.swift +++ b/RA11y-iOS/RA11y-iOS/FirstRun/iOSFirstRunView.swift @@ -65,39 +65,47 @@ struct iOSFirstRunView: View { // MARK: - Entry Content private var entryContent: some View { - VStack(alignment: .center, spacing: RA11ySpacing.xl) { - VStack(spacing: RA11ySpacing.md) { - Text(String(localized: "firstRun.title")) - .font(.ra11yTitle) - .bold() - .multilineTextAlignment(.center) - - Text(String(localized: "firstRun.body")) - .font(.ra11yBody) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - - VStack(spacing: RA11ySpacing.sm) { - Button(String(localized: "firstRun.startBasics")) { - attemptStartBasics() - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .accessibilityLabel(String(localized: "firstRun.startBasics.a11yLabel")) - .accessibilityHint(String(localized: "firstRun.startBasics.a11yHint")) - - Button(String(localized: "firstRun.goToHub")) { - dismissToHub() + GeometryReader { geo in + ScrollView { + VStack(alignment: .center, spacing: RA11ySpacing.xl) { + VStack(spacing: RA11ySpacing.md) { + Text(String(localized: "firstRun.title")) + .font(.ra11yTitle) + .bold() + .multilineTextAlignment(.center) + .accessibilityIdentifier("firstRun.title") + + Text(String(localized: "firstRun.body")) + .font(.ra11yBody) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + + VStack(spacing: RA11ySpacing.sm) { + Button(String(localized: "firstRun.startBasics")) { + attemptStartBasics() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityLabel(String(localized: "firstRun.startBasics.a11yLabel")) + .accessibilityHint(String(localized: "firstRun.startBasics.a11yHint")) + + Button(String(localized: "firstRun.goToHub")) { + dismissToHub() + } + .buttonStyle(.bordered) + .controlSize(.large) + .accessibilityLabel(String(localized: "firstRun.goToHub.a11yLabel")) + .accessibilityHint(String(localized: "firstRun.goToHub.a11yHint")) + } } - .buttonStyle(.bordered) - .controlSize(.large) - .accessibilityLabel(String(localized: "firstRun.goToHub.a11yLabel")) - .accessibilityHint(String(localized: "firstRun.goToHub.a11yHint")) + .padding(RA11ySpacing.base) + .frame(width: geo.size.width) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) } + .clipped() } - .padding(RA11ySpacing.base) - .frame(maxWidth: 560) .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonDescentView.swift b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonDescentView.swift new file mode 100644 index 0000000..5b7071e --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonDescentView.swift @@ -0,0 +1,1100 @@ +import Observation +import os +import SwiftUI +import UIKit +import RA11yCore + +// MARK: - iOSDungeonDescentView + +/// Container for Crystal Resonance (Scroll Hunt) — M7. +/// +/// Implements the full 4-level game arc defined in `GameSpec-ScrollHunt.txt` +/// and `GameRules-MVP.txt`: +/// +/// - **L0 Prologue**: Retained for deterministic `dungeonPrologue` screenshots (`-screenshotScene`); +/// optional narration, lesson, practice scroll, and begin trial — **not** shown on normal launch. +/// - **L1 First Attempt (cold start)**: Normal entry begins at the resonance shaft — fixed orb, +/// scrolling moonstone lane (ADR-0003), no timer; gesture tip + VoiceOver objective in the HUD. +/// - **L2 Rising Challenge**: 8 rooms, 60 s soft timer — Relic Vault as target +/// - **L3 Timed Trial**: 12 rooms, 45 s hard timer; `GameSession` started here for scoring +/// +/// Only L3 creates a `GameSession`. Gameplay uses ADR-0003 resonance alignment: vertical +/// distance from the moonstone to the screen aim line drives `targetIsReachable`, orb/reticle +/// presentation, and `QuestFeedbackBand` multimodal feedback (`updateResonanceDelta`). +/// +/// ## Concurrency +/// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. +struct iOSDungeonDescentView: View { + + // MARK: - State + + @State private var viewModel: DungeonDescentViewModel + + /// Final timed level only: blackout of room icons and variable layout (`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 + + /// Creates the Crystal Resonance game container. + /// + /// - Parameters: + /// - storage: Persistence used for L3 session results during normal gameplay. + /// - screenshotScene: Optional deterministic screenshot scene override. + init(storage: any StorageComponent, screenshotScene: iOSScreenshotScene? = nil) { + self.storage = storage + _viewModel = State( + initialValue: DungeonDescentViewModel(storage: storage, screenshotScene: screenshotScene) + ) + } + + // MARK: - Body + + /// See `iOSEnchantersTrialView` for the rationale for using `.background {}` over ZStack. + var body: some View { + levelContent + .background { + if viewModel.phase == .prologue { + DungeonBackgroundView() + .ignoresSafeArea() + } else { + Color.clear + } + } + .preferredColorScheme(.dark) + .navigationBarTitleDisplayMode(.inline) + .onChange(of: viewModel.completedResult) { _, result in + guard let result else { return } + router.push(.gameResult(result, gameKind: .scrollHunt, gameSpecificAnnouncement: gameSpecificAnnouncement(for: result))) + } + .onChange(of: viewModel.voiceOverDisabledMidGame) { _, disabled in + if disabled { + router.push(.voiceOverInterstitial(kind: .scrollHunt)) + } + } + .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: + DungeonPrologueView( + practiceScrollObserved: viewModel.practiceScrollObserved, + onPracticeScroll: { viewModel.recordPracticeScroll() }, + onBeginTrial: { viewModel.beginTrial() } + ) + .navigationTitle(String(localized: "dungeon.explain.title")) + .accessibilityIdentifier("dungeon.prologue") + .transition(.identity) + + case .firstAttempt: + iOSDungeonResonancePlayView( + rooms: viewModel.rooms, + targetIsReachable: viewModel.targetIsReachable, + statusMessage: viewModel.statusMessage, + levelComplete: viewModel.levelComplete, + timeRemaining: nil, + timedOut: false, + mistakes: viewModel.mistakes, + lightsOffMode: isLightsOffFinalLevel, + lightsOffFlavorText: nil, + showsFirstLevelGestureTip: true, + onResonanceDeltaChanged: { viewModel.updateResonanceDelta($0) }, + onActivateTarget: { room in await viewModel.activateTarget(room) }, + onHint: nil, + onContinue: { viewModel.advanceToRising() }, + onRetry: nil + ) + .navigationTitle(String(localized: "dungeon.l1.title")) + .onAppear { viewModel.announceObjectivePrompt() } + .accessibilityIdentifier("dungeon.firstAttempt") + .transition(.identity) + + case .rising: + iOSDungeonResonancePlayView( + rooms: viewModel.rooms, + targetIsReachable: viewModel.targetIsReachable, + statusMessage: viewModel.statusMessage, + levelComplete: viewModel.levelComplete, + timeRemaining: viewModel.timeRemaining, + timedOut: viewModel.l2TimedOut, + mistakes: viewModel.mistakes, + lightsOffMode: isLightsOffFinalLevel, + lightsOffFlavorText: nil, + showsFirstLevelGestureTip: false, + onResonanceDeltaChanged: { viewModel.updateResonanceDelta($0) }, + onActivateTarget: { room in await viewModel.activateTarget(room) }, + onHint: { viewModel.requestHint() }, + onContinue: { viewModel.advanceToTimed() }, + onRetry: { viewModel.retryRising() } + ) + .navigationTitle(String(localized: "dungeon.l2.title")) + .onAppear { viewModel.announceObjectivePrompt() } + .accessibilityIdentifier("dungeon.rising") + .transition(.identity) + + case .timed: + iOSDungeonResonancePlayView( + rooms: viewModel.rooms, + targetIsReachable: viewModel.targetIsReachable, + statusMessage: viewModel.statusMessage, + levelComplete: false, // L3 routes via completedResult, not levelComplete + timeRemaining: viewModel.timeRemaining, + timedOut: viewModel.l3TimedOut, + mistakes: viewModel.mistakes, + lightsOffMode: isLightsOffFinalLevel, + lightsOffFlavorText: String(localized: "dungeon.lightsOff.flavor"), + showsFirstLevelGestureTip: false, + onResonanceDeltaChanged: { viewModel.updateResonanceDelta($0) }, + onActivateTarget: { room in await viewModel.activateTarget(room) }, + onHint: { viewModel.requestHint() }, + onContinue: nil, + onRetry: { viewModel.retryTimed() } + ) + .navigationTitle(String(localized: "dungeon.l3.title")) + .onAppear { viewModel.announceObjectivePrompt() } + .accessibilityIdentifier("dungeon.timed") + .transition(.identity) + } + } + + // MARK: - Game-Specific Result Announcements + + private func gameSpecificAnnouncement(for result: GameResult) -> String { + switch result.rank { + case .perfect: return String(localized: "dungeon.results.legendary") + case .good: return String(localized: "dungeon.results.skilled") + case .ok: return String(localized: "dungeon.results.novice") + case .failed: return String(localized: "dungeon.results.defeated") + } + } +} + +// MARK: - DungeonDescentViewModel + +/// Observable view model managing the Crystal Resonance four-level state machine. +/// +/// Owns level phase transitions, room set composition, mistake tracking, timer logic, +/// VoiceOver threshold announcements, and the L3 `GameSession`/`GameSessionCoordinator`. +/// +/// Scroll observability is a collaboration between the view (which provides raw CGRect +/// updates from `onScrollGeometryChange` and preference keys) and the ViewModel (which +/// owns `targetIsReachable` and `updateTargetReachability(visibleRect:targetFrame:)`). +/// +/// ## 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. +@Observable +@MainActor +final class DungeonDescentViewModel { + + // MARK: - Phase + + enum Phase { case prologue, firstAttempt, rising, timed } + + private(set) var phase: Phase = .prologue + + // MARK: - Shared Level State + + private(set) var practiceScrollObserved: Bool = false + private(set) var rooms: [DungeonRoom] = [] + private(set) var targetIsReachable: Bool = false + private(set) var statusMessage: String? + private(set) var mistakes: Int = 0 + 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 (or on timeout as a Defeated sentinel). + /// 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. + private(set) var voiceOverDisabledMidGame: Bool = false + + // MARK: - Private + + private let storage: any StorageComponent + private let screenshotScene: iOSScreenshotScene? + + /// 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? + + /// Multimodal resonance feedback (audio/haptics) for alignment ladder transitions. + private let questFeedbackCoordinator: iOSQuestFeedbackCoordinator + + /// Last band forwarded to `questFeedbackCoordinator` to avoid redundant cues. + private var lastResonanceFeedbackBand: QuestFeedbackBand? + + // MARK: - Init + + /// Creates the Crystal Resonance view model. + /// + /// - Parameters: + /// - storage: Persistence used for normal gameplay session storage. + /// - screenshotScene: Optional deterministic screenshot scene override. When `nil`, the flow + /// skips L0 and begins at L1 (`beginTrial()`) so play teaches scrolling in the real shaft. + init(storage: any StorageComponent, screenshotScene: iOSScreenshotScene? = nil) { + self.storage = storage + self.screenshotScene = screenshotScene + self.questFeedbackCoordinator = iOSQuestFeedbackCoordinator( + profile: .dungeonResonance, + settings: iOSFeedbackSettings() + ) + if let screenshotScene { + applyScreenshotScene(screenshotScene) + } else { + beginTrial() + } + } + + /// Returns whether L3 should randomize room order and target placement (final timed level). + /// + /// Screenshot and UI-test launches keep fixed layouts for determinism. + private func shouldRandomizeDungeonLayoutForTimedLevel() -> 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. + private func roomsForLevel(_ base: [DungeonRoom], randomizeTargetPlacement: Bool) -> [DungeonRoom] { + if randomizeTargetPlacement, shouldRandomizeDungeonLayoutForTimedLevel() { + return DungeonRoom.randomizedRoomsPreservingPool(base) + } + return base + } + + // MARK: - Phase Transitions + + /// Records that the player has scrolled in the L0 practice zone. + func recordPracticeScroll() { + practiceScrollObserved = true + } + + /// Transitions L0 → L1: 4-room dungeon, no timer. + func beginTrial() { + stopTimer() + resetResonanceAlignmentState() + mistakes = 0 + statusMessage = nil + levelComplete = false + targetIsReachable = false + rooms = roomsForLevel(DungeonRoom.l1Rooms, randomizeTargetPlacement: false) + phase = .firstAttempt + } + + /// Transitions L1 → L2: 8-room dungeon with 60 s soft timer. + func advanceToRising() { + stopTimer() + resetResonanceAlignmentState() + mistakes = 0 + statusMessage = nil + levelComplete = false + l2TimedOut = false + targetIsReachable = false + rooms = roomsForLevel(DungeonRoom.l2Rooms, randomizeTargetPlacement: false) + timeRemaining = 60 + phase = .rising + startTimer(total: 60) { [weak self] in await self?.handleL2Timeout() } + } + + /// Retries L2 on timeout without returning to L1. + func retryRising() { + advanceToRising() + } + + /// Transitions L2 → L3: 12-room dungeon with 45 s hard timer and a fresh `GameSession`. + /// + /// ## Concurrency + /// `GameSession.start()` must complete before the timer or any room activation can call + /// `recordMistake()` / `complete()`. Both are sequenced inside a single `Task`. + func advanceToTimed() { + stopTimer() + resetResonanceAlignmentState() + mistakes = 0 + statusMessage = nil + levelComplete = false + l3TimedOut = false + completedResult = nil + voiceOverDisabledMidGame = false + targetIsReachable = false + rooms = roomsForLevel(DungeonRoom.l3Rooms, randomizeTargetPlacement: true) + timeRemaining = 45 + phase = .timed + + let newSession = GameSession( + gameID: "scroll-hunt", + thresholds: .scrollHunt, + storage: storage + ) + let newCoordinator = GameSessionCoordinator( + session: newSession, + gameKind: .scrollHunt, + 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("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 + // 45 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: 45) { [weak self] in await self?.handleL3Timeout() } + self.observeCoordinatorVOState(coordinator: newCoordinator) + } + } + + /// Polls coordinator for VoiceOver-off event and mirrors it onto the ViewModel. + /// + /// ## Concurrency + /// Runs in a `Task` isolated to `@MainActor`. Exits on cancellation or flag set. + 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 45 s timer. + func retryTimed() { + advanceToTimed() + } + + // MARK: - Resonance alignment (ADR-0003) + + /// Updates scroll alignment from the vertical delta between the moonstone and the aim line. + /// + /// Called from `iOSDungeonResonancePlayView` on each preference-driven geometry change. + /// `screenshotScene` launches skip updates so deterministic captures keep VM-forced reachability. + /// + /// ## Concurrency + /// Must run on the main actor in lockstep with SwiftUI layout; the view calls this from + /// `onPreferenceChange` on the main thread. + func updateResonanceDelta(_ deltaPoints: CGFloat) { + guard screenshotScene == nil else { return } + if completedResult != nil { return } + switch phase { + case .firstAttempt, .rising: + if levelComplete { return } + default: + break + } + targetIsReachable = iOSResonanceAlignment.isReachable(deltaPoints: deltaPoints) + let band = iOSResonanceAlignment.questBand(deltaPoints: deltaPoints) + if band != lastResonanceFeedbackBand { + lastResonanceFeedbackBand = band + questFeedbackCoordinator.process(.alignmentBandChanged(band)) + RA11yLogger.scrollInteraction.debug( + "ViewModel alignment band→\(String(describing: band)) reachable=\(self.targetIsReachable) vo=\(UIAccessibility.isVoiceOverRunning)" + ) + #if DEBUG + print("[RA11yScroll] ViewModel band→\(band) reachable=\(self.targetIsReachable)") + #endif + } + } + + /// Clears resonance feedback reducer state when room sets or phases change. + private func resetResonanceAlignmentState() { + lastResonanceFeedbackBand = nil + questFeedbackCoordinator.reset() + } + + // MARK: - Room Activation + + /// Handles activation of the target room for the current level. + /// + /// L1/L2: marks level complete and announces completion. + /// L3: records time-bucket mistakes, completes `GameSession`, navigates to result. + func activateTarget(_ room: DungeonRoom) async { + guard room.isTarget, targetIsReachable else { + if room.isTarget && !targetIsReachable { + statusMessage = String(localized: "dungeon.target.notReachable") + announce(String(localized: "dungeon.target.notReachable")) + } + return + } + guard !levelComplete, !l2TimedOut, !l3TimedOut else { return } + + switch phase { + case .prologue: + break + + case .firstAttempt: + questFeedbackCoordinator.process(.success) + levelComplete = true + announce(String(localized: "dungeon.l1.complete")) + + case .rising: + stopTimer() + questFeedbackCoordinator.process(.success) + levelComplete = true + announce(String(localized: "dungeon.l2.complete")) + + case .timed: + guard let session else { return } + stopTimer() + + // Compute elapsed from the 45 s L3 window and record time-bucket penalty mistakes. + // Bucket size for this game is 15 s per `GameSpec-ScrollHunt.txt`: + // 0–15 s → +0; 15–30 s → +1; 30–45 s → +2. + let elapsed = 45.0 - timeRemaining + let buckets = RankThresholds.bucketMistakes(timeSeconds: elapsed, bucketSize: 15) + 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) + 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: "dungeon.timeout")) + } + + private func handleL3Timeout() async { + guard let session else { return } + l3TimedOut = true + coordinator?.stopMonitoring() + await session.abandon() + announce(String(localized: "dungeon.timeout")) + // Synthesise a Defeated result for display (not stored — session was abandoned). + completedResult = GameResult( + gameID: "scroll-hunt", + rank: .failed, + timeSeconds: 45.0, + mistakes: mistakes + ) + } + + // MARK: - Private: VoiceOver Announcements + + private func announce(_ message: String) { + UIAccessibility.post(notification: .announcement, argument: message) + } + + /// Applies a deterministic static state for screenshot capture. + /// + /// Screenshot scenes intentionally avoid timers, coordinators, and persistence writes. + /// They only shape the visible UI state needed for fastlane capture. + private func applyScreenshotScene(_ scene: iOSScreenshotScene) { + stopTimer() + resetResonanceAlignmentState() + practiceScrollObserved = false + rooms = [] + targetIsReachable = false + statusMessage = nil + mistakes = 0 + levelComplete = false + timeRemaining = 0 + l2TimedOut = false + l3TimedOut = false + completedResult = nil + voiceOverDisabledMidGame = false + + switch scene { + case .dungeonPrologue: + phase = .prologue + practiceScrollObserved = true + case .dungeonFirstAttempt: + phase = .firstAttempt + rooms = DungeonRoom.l1Rooms + targetIsReachable = true + default: + break + } + } + + /// Posts the game-specific timer threshold announcement for the given phase and percent elapsed. + /// + /// L2 announces only at 50% and 25%; 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: "dungeon.a11y.timer.75pct")) + case (.timed, 0.50), (.rising, 0.50): + announce(String(localized: "dungeon.a11y.timer.50pct")) + case (.timed, 0.25), (.rising, 0.25): + announce(String(localized: "dungeon.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: - DungeonRoom + +/// A single chamber marker along the Crystal Resonance scroll shaft. +/// +/// Passage rooms (isTarget) are correct targets; all others are non-interactive decoys. +/// Level-specific room sets are defined in `l1Rooms`, `l2Rooms`, `l3Rooms`. +struct DungeonRoom: Identifiable { + let id: String + let displayName: String + let subtitle: String + let assetName: String + let isTarget: Bool + + // MARK: - Room Pools + + /// L1: 4 rooms (2 visible, 2 below fold). Guard Room is target at index 2. + static let l1Rooms: [DungeonRoom] = [ + DungeonRoom(id: "entry_hall", displayName: "Entry Hall", subtitle: "Torchlit. Safe.", assetName: "dungeon_room_entry_hall", isTarget: false), + DungeonRoom(id: "armory", displayName: "Armory", subtitle: "Empty racks.", assetName: "dungeon_room_armory", isTarget: false), + DungeonRoom(id: "guard_room", displayName: "Guard Room", subtitle: "Empty. The objective.", assetName: "dungeon_room_guard_post", isTarget: true), + DungeonRoom(id: "well_chamber", displayName: "Well Chamber", subtitle: "The water is still.", assetName: "dungeon_room_well_chamber", isTarget: false), + ] + + /// L2: 8 rooms (3 visible, 5 below fold). Relic Vault is target at index 6. + static let l2Rooms: [DungeonRoom] = [ + DungeonRoom(id: "entry_hall", displayName: "Entry Hall", subtitle: "Torchlit.", assetName: "dungeon_room_entry_hall", isTarget: false), + DungeonRoom(id: "guard_post", displayName: "Guard Post", subtitle: "Abandoned.", assetName: "dungeon_room_guard_post", isTarget: false), + DungeonRoom(id: "armory", displayName: "Armory", subtitle: "Empty racks.", assetName: "dungeon_room_armory", isTarget: false), + DungeonRoom(id: "well_chamber", displayName: "Well Chamber", subtitle: "The water is still.", assetName: "dungeon_room_well_chamber", isTarget: false), + DungeonRoom(id: "store_room", displayName: "Store Room", subtitle: "Dusty barrels.", assetName: "dungeon_room_store_room", isTarget: false), + DungeonRoom(id: "barracks", displayName: "Barracks", subtitle: "Cold hearths.", assetName: "dungeon_room_barracks", isTarget: false), + DungeonRoom(id: "relic_vault", displayName: "Relic Vault", subtitle: "The objective.", assetName: "dungeon_room_relic_vault", isTarget: true), + DungeonRoom(id: "trophy_room", displayName: "Trophy Room", subtitle: "Faded banners.", assetName: "dungeon_room_trophy_room", isTarget: false), + ] + + /// L3: 12 rooms (3 visible, 9 below fold). Ancient Vault is target at index 10. + static let l3Rooms: [DungeonRoom] = [ + DungeonRoom(id: "entry_hall", displayName: "Entry Hall", subtitle: "Torchlit.", assetName: "dungeon_room_entry_hall", isTarget: false), + DungeonRoom(id: "guard_post", displayName: "Guard Post", subtitle: "Abandoned.", assetName: "dungeon_room_guard_post", isTarget: false), + DungeonRoom(id: "armory", displayName: "Armory", subtitle: "Empty racks.", assetName: "dungeon_room_armory", isTarget: false), + DungeonRoom(id: "well_chamber", displayName: "Well Chamber", subtitle: "The water is still.", assetName: "dungeon_room_well_chamber", isTarget: false), + DungeonRoom(id: "store_room", displayName: "Store Room", subtitle: "Dusty barrels.", assetName: "dungeon_room_store_room", isTarget: false), + DungeonRoom(id: "barracks", displayName: "Barracks", subtitle: "Cold hearths.", assetName: "dungeon_room_barracks", isTarget: false), + DungeonRoom(id: "trophy_room", displayName: "Trophy Room", subtitle: "Faded banners.", assetName: "dungeon_room_trophy_room", isTarget: false), + DungeonRoom(id: "ritual_chamber", displayName: "Ritual Chamber", subtitle: "Symbols cover the walls.", assetName: "dungeon_room_ritual_chamber", isTarget: false), + DungeonRoom(id: "flooded_hall", displayName: "Flooded Hall", subtitle: "Knee-deep water.", assetName: "dungeon_room_flooded_hall", isTarget: false), + DungeonRoom(id: "bone_room", displayName: "Bone Room", subtitle: "Don't look too closely.", assetName: "dungeon_room_bone_room", isTarget: false), + DungeonRoom(id: "ancient_vault", displayName: "Ancient Vault", subtitle: "The relic glows.", assetName: "dungeon_room_ancient_vault", isTarget: true), + 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) + + /// Returns a copy of `base` with exactly one random target, shuffled order, for Lights Off mode. + /// + /// 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. + static func randomizedRoomsPreservingPool(_ base: [DungeonRoom]) -> [DungeonRoom] { + let targetIndex = Int.random(in: base.indices) + let objectiveSubtitle = String(localized: "dungeon.room.objective.subtitle") + let mapped = base.enumerated().map { i, room -> DungeonRoom in + let isTarget = i == targetIndex + let subtitle: String + if isTarget { + subtitle = objectiveSubtitle + } else { + switch room.id { + case "guard_room": + subtitle = String(localized: "dungeon.l1.guardRoom.decoySubtitle") + case "relic_vault": + subtitle = String(localized: "dungeon.room.relicVault.decoySubtitle") + case "ancient_vault": + subtitle = String(localized: "dungeon.room.ancientVault.decoySubtitle") + default: + subtitle = room.subtitle + } + } + return DungeonRoom( + id: room.id, + displayName: room.displayName, + subtitle: subtitle, + assetName: room.assetName, + isTarget: isTarget + ) + } + return mapped.shuffled() + } +} + +// MARK: - L0: DungeonPrologueView + +/// L0 Prologue — Guide narration, lesson card, gesture guide, practice scroll zone, begin trial. +/// +/// Used when the view model is launched with `-screenshotScene dungeonPrologue` or if L0 is +/// reintroduced as an optional path. The begin trial button is disabled until the player has +/// performed at least one non-zero scroll event in the practice zone. +private struct DungeonPrologueView: View { + + let practiceScrollObserved: Bool + let onPracticeScroll: () -> Void + let onBeginTrial: () -> Void + + @Environment(\.horizontalSizeClass) private var sizeClass + + var body: some View { + ScrollView(.vertical) { + VStack(spacing: RA11ySpacing.lg) { + dmNarrationCard + lessonCard + gestureGuide + practiceZone + beginButton + } + .padding(.horizontal, sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base) + .padding(.vertical, RA11ySpacing.lg) + .frame(maxWidth: sizeClass == .regular ? 720 : .infinity) + .frame(maxWidth: .infinity, alignment: .center) + } + .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: "dungeon.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: "dungeon.explain.lesson.heading")) + .font(.ra11yHeadline) + .bold() + .accessibilityAddTraits(.isHeader) + + Text(String(localized: "dungeon.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: "dungeon.a11y.explain.lesson")) + } + + private var gestureGuide: some View { + VStack(spacing: RA11ySpacing.sm) { + DungeonGestureRow(symbol: "hand.point.right.fill", label: String(localized: "dungeon.explain.gesture.swipe1")) + DungeonGestureRow(symbol: "hand.draw.fill", label: String(localized: "dungeon.explain.gesture.swipe3")) + DungeonGestureRow(symbol: "hand.draw.fill", label: String(localized: "dungeon.explain.gesture.swipe3u")) + } + .accessibilityHidden(true) + } + + private var practiceZone: some View { + VStack(alignment: .leading, spacing: RA11ySpacing.sm) { + Text(String(localized: "dungeon.explain.practice_tip")) + .font(.ra11ySubheadline) + .foregroundStyle(.secondary) + + ScrollView(.vertical) { + VStack(alignment: .leading, spacing: RA11ySpacing.sm) { + ForEach(0..<14, id: \.self) { index in + Text(String(format: String(localized: "dungeon.explain.practice.step"), index + 1)) + .font(.ra11yBody) + .foregroundStyle(Color.ra11yCardSecondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.vertical, 4) + } + } + .padding(RA11ySpacing.sm) + } + .frame(minHeight: 200, maxHeight: 280) + .background(Color.black.opacity(0.35), in: .rect(cornerRadius: RA11yRadius.card)) + .overlay( + RoundedRectangle(cornerRadius: RA11yRadius.card) + .strokeBorder(Color.ra11yAccent.opacity(0.55), lineWidth: 1) + ) + .accessibilityLabel(String(localized: "dungeon.a11y.scroll.container")) + .accessibilityHint(String(localized: "dungeon.a11y.explain.practice.hint")) + .accessibilityIdentifier("dungeon.practiceZone") + .onScrollGeometryChange(for: CGFloat.self, of: { $0.contentOffset.y }) { _, offsetY in + if abs(offsetY) > 2 { onPracticeScroll() } + } + + if practiceScrollObserved { + Text(String(localized: "dungeon.prologue.practice.ready")) + .font(.ra11yCaption) + .foregroundStyle(Color.ra11yCardTertiaryText) + } + } + .padding(RA11ySpacing.base) + .background(.ultraThinMaterial, in: .rect(cornerRadius: RA11yRadius.card)) + .accessibilityIdentifier("dungeon.prologue.practiceSection") + } + + private var beginButton: some View { + Button(action: onBeginTrial) { + Text(String(localized: "level.button.start")) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .tint(Color.ra11yAccent) + .disabled(!practiceScrollObserved) + .accessibilityHint(String(localized: "dungeon.explain.start.hint")) + .accessibilityIdentifier("dungeon.beginDescent") + } +} + +// MARK: - DungeonTimerHUD + +/// Combined timer progress bar + time + mistake count HUD used in L2 and L3. +/// +/// `accessibilityHidden` — the containing play view sets a combined `accessibilityLabel` +/// on the whole HUD group. The bar height decreases at 50% and 25% remaining. +/// +/// ## 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. +struct DungeonTimerHUD: 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) + } + + private var mistakesLabel: some View { + Text(String(format: String(localized: "dungeon.hud.mistakes.format"), mistakes)) + .font(.ra11ySubheadline) + .foregroundStyle(.secondary) + } +} + +// MARK: - DungeonBackgroundView + +/// Full-bleed dungeon background with dark overlay for legibility. +/// +/// Uses an explicit geometry size so `scaledToFill` crops from the center when the view +/// is used as a navigation-stack background (unbounded proposals otherwise skew the image). +private struct DungeonBackgroundView: View { + var body: some View { + GeometryReader { geo in + ZStack { + Color.black + if let image = UIImage(named: "dungeon_descent_bg") { + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + .overlay(Color.black.opacity(0.4)) + } + LinearGradient( + colors: [Color.black.opacity(0.55), Color.black.opacity(0.15), Color.black.opacity(0.55)], + startPoint: .top, + endPoint: .bottom + ) + } + .frame(width: geo.size.width, height: geo.size.height) + } + } +} + +// MARK: - Shared Primitive Subviews + +/// Small gesture guide row used in L0 prologue. +private struct DungeonGestureRow: 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) + } +} + +// MARK: - Preview + +#Preview("L1 First Attempt") { + NavigationStack { + iOSDungeonDescentView(storage: UserDefaultsStorageComponent()) + .environment(iOSAppRouter()) + } +} diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceArt.swift b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceArt.swift new file mode 100644 index 0000000..411d9fe --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceArt.swift @@ -0,0 +1,31 @@ +// MARK: - iOSDungeonResonanceArt + +/// Asset catalog string keys for Crystal Resonance (Scroll Hunt) v2 resonance UI. +/// +/// PNGs live in `Assets.xcassets` as universal 1x imagesets. Layering and point sizes +/// are documented in `memlog/requirements/Design/DungeonResonanceAssetPipeline.txt`. +/// +/// Warm / Near orb states may use code-driven effects (`DesignTicket-DungeonResonancePromptSheet`) +/// rather than additional PNGs until explicitly added. +enum iOSDungeonResonanceArt { + + static let background = "dungeon_resonance_bg" + + static let orbIdle = "dungeon_resonance_orb_idle" + static let orbLocked = "dungeon_resonance_orb_locked" + + static let reticleRing = "dungeon_reticle_ring" + + static let targetMoonstone = "dungeon_target_moonstone" + + static let decoyEmberShard = "dungeon_decoy_ember_shard" + static let decoyShadowGlyph = "dungeon_decoy_shadow_glyph" + static let decoySunSigil = "dungeon_decoy_sun_sigil" + + static let laneMarkerNeutral = "dungeon_lane_marker_neutral" + + /// Optional Lights Off reference; final mask may be code-drawn instead. + static let spotlightMaskReference = "dungeon_spotlight_mask_reference" + + static let successFlare = "dungeon_success_flare" +} diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceComponents.swift b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceComponents.swift new file mode 100644 index 0000000..3f445ee --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceComponents.swift @@ -0,0 +1,463 @@ +import SwiftUI +import UIKit +import RA11yCore + +// MARK: - Alignment (ADR-0003) + +/// Vertical distance bands between the moonstone target and the fixed aim line (screen 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 + 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) + } + + /// Semantic feedback band derived from current alignment (no success state). + static func questBand(deltaPoints: CGFloat) -> QuestFeedbackBand { + if deltaPoints < lockedMaxPoints { return .locked } + if deltaPoints < nearMaxPoints { return .near } + if deltaPoints < warmMaxPoints { return .warm } + return .far + } + + /// Whether the scroll target is close enough to invoke / seal (game rules). + static func isReachable(deltaPoints: CGFloat) -> Bool { + deltaPoints < lockedMaxPoints + } +} + +// MARK: - Presentation band (orb + reticle) + +/// Visual ladder for the center orb and ring, including a brief success state after a level completes. +enum iOSResonanceAimBand: Equatable { + case far + case warm + case near + case locked + case success + + /// Orb and feedback presentation from geometry; `levelComplete` forces `.success` for the flourish. + static func displayBand(deltaPoints: CGFloat, levelComplete: Bool) -> iOSResonanceAimBand { + if levelComplete { return .success } + if deltaPoints < iOSResonanceAlignment.lockedMaxPoints { return .locked } + if deltaPoints < iOSResonanceAlignment.nearMaxPoints { return .near } + if deltaPoints < iOSResonanceAlignment.warmMaxPoints { return .warm } + return .far + } +} + +// MARK: - Preference + +/// Global-space vertical center of the moonstone target (alignment with fixed aim line). +struct iOSResonanceTargetMidYPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = -10_000 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + let next = nextValue() + if next > -1_000 { value = next } + } +} + +// MARK: - Background + +/// Full-bleed shaft background for resonance gameplay (`dungeon_resonance_bg`). +struct iOSShaftResonanceBackground: View { + var body: some View { + Group { + if let uiImage = UIImage(named: iOSDungeonResonanceArt.background) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .frame(minWidth: 0, minHeight: 0) + .clipped() + .overlay { + RadialGradient( + colors: [Color.black.opacity(0.0), Color.black.opacity(0.45)], + center: .center, + startRadius: 40, + endRadius: 420 + ) + } + } else { + LinearGradient( + colors: [ + Color(red: 0.05, green: 0.04, blue: 0.06), + Color(red: 0.12, green: 0.08, blue: 0.06), + Color(red: 0.05, green: 0.04, blue: 0.06), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .overlay { + RadialGradient( + colors: [Color.black.opacity(0.0), Color.black.opacity(0.55)], + center: .center, + startRadius: 40, + endRadius: 420 + ) + } + } + } + .ignoresSafeArea() + } +} + +/// Lights Off vignette: darkens the lane while preserving the center orb anchor (ADR-0003). +struct iOSResonanceLightsOffVignette: View { + var body: some View { + ZStack { + Color.black.opacity(0.88) + RadialGradient( + colors: [Color.black.opacity(0.0), Color.black.opacity(0.95)], + center: .center, + startRadius: 56, + endRadius: 320 + ) + if let spot = UIImage(named: iOSDungeonResonanceArt.spotlightMaskReference) { + Image(uiImage: spot) + .resizable() + .scaledToFill() + .blendMode(.plusLighter) + .opacity(0.35) + } + } + .ignoresSafeArea() + } +} + +// MARK: - Lane content + +/// Visual decoy families for non-target chambers (catalog-first). +enum iOSResonanceDecoyStyle: CaseIterable { + case ember + case shadowGlyph + case sunSigil + + fileprivate var assetName: String { + switch self { + case .ember: return iOSDungeonResonanceArt.decoyEmberShard + case .shadowGlyph: return iOSDungeonResonanceArt.decoyShadowGlyph + case .sunSigil: return iOSDungeonResonanceArt.decoySunSigil + } + } + + /// Rotates through decoy silhouettes by list index so the lane stays visually distinct. + static func forRoomIndex(_ index: Int) -> iOSResonanceDecoyStyle { + let all = iOSResonanceDecoyStyle.allCases + return all[index % all.count] + } +} + +/// Moving resonance target — prefers `dungeon_target_moonstone` from the asset catalog. +struct iOSMoonstoneTargetOrb: View { + @ScaledMetric(relativeTo: .title) private var moonW: CGFloat = 96 + @ScaledMetric(relativeTo: .title) private var moonH: CGFloat = 72 + + 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) + } else { + ZStack { + Ellipse() + .fill( + RadialGradient( + colors: [ + Color.white.opacity(0.95), + Color(red: 0.72, green: 0.78, blue: 0.92).opacity(0.85), + Color(red: 0.35, green: 0.45, blue: 0.62).opacity(0.5), + ], + center: .center, + startRadius: 4, + endRadius: 44 + ) + ) + .frame(width: moonW * 0.92, height: moonH * 0.89) + Ellipse() + .strokeBorder(Color.white.opacity(0.35), lineWidth: 1) + .frame(width: moonW * 0.92, height: moonH * 0.89) + } + } + } + .accessibilityHidden(true) + } +} + +/// Wrong-target glyphs — catalog art with geometric fallbacks if an asset is missing. +struct iOSLaneDecoyChip: View { + let style: iOSResonanceDecoyStyle + var hidesFromAccessibility: Bool = true + + @ScaledMetric(relativeTo: .title) private var chip: CGFloat = 76 + + var body: some View { + Group { + if let ui = UIImage(named: style.assetName) { + Image(uiImage: ui) + .resizable() + .scaledToFit() + .frame(width: chip, height: chip) + } else { + legacyDecoyPlaceholder + } + } + .frame(maxWidth: .infinity) + .opacity(0.9) + .accessibilityHidden(hidesFromAccessibility) + } + + @ViewBuilder + private var legacyDecoyPlaceholder: some View { + switch style { + case .ember: + iOSEmberShardFallbackShape() + .fill(Color.orange.opacity(0.55)) + .frame(width: chip * 0.92, height: chip) + case .shadowGlyph: + Image(systemName: "diamond.fill") + .font(.system(size: chip * 0.72)) + .foregroundStyle(Color.purple.opacity(0.45)) + case .sunSigil: + Image(systemName: "sun.max.fill") + .font(.system(size: chip * 0.72)) + .foregroundStyle(Color.yellow.opacity(0.5)) + } + } +} + +private struct iOSEmberShardFallbackShape: Shape { + func path(in rect: CGRect) -> Path { + var p = Path() + p.move(to: CGPoint(x: rect.midX, y: rect.minY)) + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + p.closeSubpath() + return p + } +} + +/// Neutral lane tick — prefers `dungeon_lane_marker_neutral`. +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) + } else { + RoundedRectangle(cornerRadius: 6) + .fill(Color.white.opacity(0.06)) + .frame(height: 22) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.ra11yDMBorder.opacity(0.2), lineWidth: 1) + } + } + } + .accessibilityHidden(true) + } +} + +// MARK: - Center orb & reticle + +/// Fixed center reticle ring; responds to `iOSResonanceAimBand` for glow/pulse. +struct iOSResonanceReticleRing: View { + let band: iOSResonanceAimBand + + @ScaledMetric(relativeTo: .title) private var ringBase: CGFloat = 172 + + var body: some View { + let pulse: CGFloat = switch band { + case .far: 0.15 + case .warm: 0.35 + case .near: 0.55 + case .locked: 0.85 + case .success: 1.0 + } + + let diameter = ringBase + pulse * 14 + + 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) + } else { + Circle() + .strokeBorder( + LinearGradient( + colors: [ + Color.ra11yDMBorder.opacity(0.25 + pulse * 0.5), + Color(red: 0.55, green: 0.75, blue: 0.95).opacity(0.35 + pulse * 0.45), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + 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) + } +} + +/// Fixed crystal orb — primary VoiceOver anchor for the resonance shaft (spoken label varies by band). +struct iOSResonanceCenterOrb: View { + let band: iOSResonanceAimBand + + @ScaledMetric(relativeTo: .title) private var orbDiameter: CGFloat = 112 + + var body: some View { + ZStack { + if let uiImage = UIImage(named: orbImageName) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .frame(width: orbDiameter, height: orbDiameter) + .saturation(bandSaturation) + .shadow(color: orbGlowColor, radius: orbGlowRadius) + } else { + Circle() + .fill(orbGradient) + .frame(width: orbDiameter, height: orbDiameter) + .shadow(color: orbGlowColor, radius: orbGlowRadius) + } + + if band == .success { + Circle() + .strokeBorder(Color.white.opacity(0.85), lineWidth: 3) + .frame(width: orbDiameter + 12, height: orbDiameter + 12) + .transition(.opacity) + } + + if UIImage(named: orbImageName) == nil { + Circle() + .strokeBorder(Color.white.opacity(0.22 + ringHighlight * 0.5), lineWidth: 1) + .frame(width: orbDiameter + 8, height: orbDiameter + 8) + } + } + .animation(.easeInOut(duration: 0.2), value: band) + .accessibilityLabel(accessibilityLabelText) + } + + private var orbImageName: String { + switch band { + case .locked, .success: + return iOSDungeonResonanceArt.orbLocked + default: + return iOSDungeonResonanceArt.orbIdle + } + } + + private var bandSaturation: Double { + switch band { + case .far: return 0.82 + case .warm: return 0.92 + case .near: return 1.0 + case .locked: return 1.05 + case .success: return 1.12 + } + } + + private var ringHighlight: CGFloat { + switch band { + case .far: return 0.1 + case .warm: return 0.35 + case .near: return 0.55 + case .locked: return 0.85 + case .success: return 1.0 + } + } + + private var orbGlowRadius: CGFloat { + switch band { + case .far: return 6 + case .warm: return 12 + case .near: return 18 + case .locked: return 26 + case .success: return 32 + } + } + + private var orbGlowColor: Color { + switch band { + case .far: + return Color(red: 0.4, green: 0.35, blue: 0.55).opacity(0.35) + case .warm: + return Color.ra11yAccent.opacity(0.35) + case .near: + return Color(red: 0.65, green: 0.82, blue: 1.0).opacity(0.55) + case .locked: + return Color(red: 0.85, green: 0.95, blue: 1.0).opacity(0.75) + case .success: + return Color.ra11yTargetReachable.opacity(0.65) + } + } + + private var orbGradient: LinearGradient { + switch band { + case .success: + return LinearGradient( + colors: [ + Color(red: 0.95, green: 0.98, blue: 1.0), + Color.ra11yTargetReachable.opacity(0.85), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + default: + return LinearGradient( + colors: [ + Color(red: 0.75, green: 0.82, blue: 0.95), + Color(red: 0.35, green: 0.42, blue: 0.62), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } + + private var accessibilityLabelText: String { + switch band { + 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") + } + } +} + +/// Brief success burst over the orb when the chamber is cleared. +struct iOSResonanceSuccessFlareOverlay: View { + @ScaledMetric(relativeTo: .title) private var flareSize: CGFloat = 220 + + var body: some View { + if let ui = UIImage(named: iOSDungeonResonanceArt.successFlare) { + Image(uiImage: ui) + .resizable() + .scaledToFit() + .frame(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 new file mode 100644 index 0000000..ab27483 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonanceMockupView.swift @@ -0,0 +1,776 @@ +import SwiftUI +import UIKit +import Observation +import RA11yCore + +// MARK: - iOSDungeonResonanceMockupView + +/// Prototype-local bridge that routes resonance interactions through the shared quest feedback system. +@MainActor +@Observable +private final class DungeonResonanceFeedbackPreviewModel { + + /// User-facing feedback preferences exposed in the mockup inspector. + let settings: iOSFeedbackSettings + + private let coordinator: iOSQuestFeedbackCoordinator + + init() { + let settings = iOSFeedbackSettings() + self.settings = settings + self.coordinator = iOSQuestFeedbackCoordinator(profile: .dungeonResonance, settings: settings) + } + + /// Applies the currently selected preview profile. + /// + /// - Parameter resetState: Whether reducer and cooldown state should be reset. + func applyCurrentProfile(resetState: Bool = false) { + coordinator.updateProfile( + settings.calmMode ? .calmGuidance : .dungeonResonance, + resetState: resetState + ) + } + + /// Processes a semantic alignment band transition. + /// + /// - Parameter band: Current semantic proximity band. + func processAlignmentBand(_ band: QuestFeedbackBand) { + coordinator.process(.alignmentBandChanged(band)) + } + + /// Emits a hint cue. + func processHintRequest() { + coordinator.process(.hintRequested) + } + + /// Emits a wrong-activation cue. + func processWrongActivation() { + coordinator.process(.wrongActivation) + } + + /// Emits a success cue. + func processSuccess() { + coordinator.process(.success) + } + + /// Resets reducer and cooldown state. + func reset() { + coordinator.reset() + } +} + +/// Crystal Resonance v2 resonance prototype surface. +/// +/// This view encodes ADR-0003 layout intent and currently serves as both the routed +/// prototype destination and the screenshot/mockup surface while gameplay is iterating: +/// fixed center orb + reticle, vertical lane scrolling underneath, multimodal feedback +/// driven by geometry-derived alignment bands and the shared feedback coordinator. +/// +/// ## Iteration controls +/// - **Live alignment**: Scroll the lane; orb reflectance follows distance from the +/// moonstone target to the screen aim line (geometry-driven). +/// - **Manual band**: Override the resonance ladder to snapshot Far → Success for reviews. +/// - **Lights Off**: Dark vignette with orb/reticle preserved per ADR Lights Off rule. +/// +/// ## Concurrency +/// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. +struct iOSDungeonResonanceMockupView: View { + + /// ADR ladder (+ success flourish). + enum ResonanceBand: String, CaseIterable, Identifiable { + case far = "Far" + case warm = "Warm" + case near = "Near" + case locked = "Locked" + case success = "Success" + var id: String { rawValue } + } + + /// When `true`, ignore geometry and use `manualBand`. + @State private var useManualBand = false + @State private var manualBand: ResonanceBand = .far + + @State private var liveDeltaPoints: CGFloat = 500 + + /// Edge darkening for Lights Off exploration (orb stays visible). + @State private var lightsOffPreview = false + + @State private var showInspector = true + @State private var feedbackPreview = DungeonResonanceFeedbackPreviewModel() + + /// Mock tuning: |Δy| bands in points (see inspector footer for current values). + private let bandLockedMax: CGFloat = 26 + private let bandNearMax: CGFloat = 54 + private let bandWarmMax: CGFloat = 118 + + /// Geometry-driven band: largest delta → Far, then Warm, Near, smallest → Locked. + private var liveBand: ResonanceBand { + let d = liveDeltaPoints + if d < bandLockedMax { return .locked } + if d < bandNearMax { return .near } + if d < bandWarmMax { return .warm } + return .far + } + + /// Smooth orb presentation: locked reads more energized than raw distance alone. + private var effectiveBand: ResonanceBand { + if useManualBand { return manualBand } + return liveBand + } + + var body: some View { + GeometryReader { screenGeo in + let aimMidY = screenGeo.frame(in: .global).midY + ZStack { + shaftBackground + + ScrollView(.vertical) { + laneColumn + .padding(.vertical, RA11ySpacing.xl) + } + .scrollIndicators(.visible) + .coordinateSpace(name: "mockupLane") + + centerOrbOverlay + .allowsHitTesting(false) + + if lightsOffPreview { + lightsOffVignette + .allowsHitTesting(false) + } + } + .onPreferenceChange(TargetMidYPreferenceKey.self) { targetY in + guard !useManualBand else { return } + guard targetY > -1_000 else { return } + liveDeltaPoints = abs(targetY - aimMidY) + } + } + .background(Color.ra11yGameFallbackBackground) + .navigationTitle("Crystal Resonance (prototype)") + .navigationBarTitleDisplayMode(.inline) + .toolbarBackground(.visible, for: .navigationBar) + .toolbarBackground(.ultraThinMaterial, for: .navigationBar) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + showInspector.toggle() + } label: { + Image(systemName: "slider.horizontal.3") + } + .accessibilityLabel(showInspector ? "Hide design inspector" : "Show design inspector") + } + } + .safeAreaInset(edge: .bottom) { + if showInspector { + VStack(spacing: RA11ySpacing.sm) { + inspectorBar + feedbackActionBar + } + } + } + .accessibilityIdentifier("resonance.mockup.root") + .preferredColorScheme(.dark) + .onAppear { + feedbackPreview.applyCurrentProfile(resetState: true) + feedbackPreview.processAlignmentBand(feedbackBand(for: effectiveBand)) + } + .onChange(of: effectiveBand) { _, newBand in + feedbackPreview.processAlignmentBand(feedbackBand(for: newBand)) + } + .onChange(of: feedbackPreview.settings.calmMode) { _, _ in + feedbackPreview.applyCurrentProfile(resetState: true) + feedbackPreview.processAlignmentBand(feedbackBand(for: effectiveBand)) + } + } + + // MARK: - Background + + private var shaftBackground: some View { + Group { + if let uiImage = UIImage(named: iOSDungeonResonanceArt.background) { + Image(uiImage: uiImage) + .resizable() + .scaledToFill() + .frame(minWidth: 0, minHeight: 0) + .clipped() + .overlay { + RadialGradient( + colors: [Color.black.opacity(0.0), Color.black.opacity(0.45)], + center: .center, + startRadius: 40, + endRadius: 420 + ) + } + } else { + LinearGradient( + colors: [ + Color(red: 0.05, green: 0.04, blue: 0.06), + Color(red: 0.12, green: 0.08, blue: 0.06), + Color(red: 0.05, green: 0.04, blue: 0.06), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .overlay { + RadialGradient( + colors: [Color.black.opacity(0.0), Color.black.opacity(0.55)], + center: .center, + startRadius: 40, + endRadius: 420 + ) + } + } + } + .ignoresSafeArea() + } + + /// Darkens the lane; keeps orb visible. Optional catalog spotlight softens the center. + private var lightsOffVignette: some View { + ZStack { + Color.black.opacity(0.88) + RadialGradient( + colors: [Color.black.opacity(0.0), Color.black.opacity(0.95)], + center: .center, + startRadius: 56, + endRadius: 320 + ) + if let spot = UIImage(named: iOSDungeonResonanceArt.spotlightMaskReference) { + Image(uiImage: spot) + .resizable() + .scaledToFill() + .blendMode(.plusLighter) + .opacity(0.35) + } + } + .ignoresSafeArea() + } + + // MARK: - Lane (scrollable) + + private var laneColumn: some View { + VStack(spacing: 56) { + laneSectionLabel("Shallow") + + LaneDecoyChip(style: .ember, accessibilityHidden: true) + LaneLaneMarkerNeutral() + + LaneDecoyChip(style: .shadowGlyph, accessibilityHidden: true) + + moonstoneTarget + + LaneLaneMarkerNeutral() + LaneLaneMarkerNeutral() + + LaneDecoyChip(style: .sunSigil, accessibilityHidden: true) + + laneSectionLabel("Deep") + Color.clear.frame(height: 200) + } + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) + .padding(.horizontal, RA11ySpacing.lg) + } + + private func laneSectionLabel(_ text: String) -> some View { + Text(text) + .font(.ra11yCaption) + .foregroundStyle(Color.ra11yCardTertiaryText) + .frame(maxWidth: .infinity) + .accessibilityHidden(true) + } + + private var moonstoneTarget: some View { + MoonstoneTargetOrb() + .background { + GeometryReader { geo in + Color.clear.preference( + key: TargetMidYPreferenceKey.self, + value: geo.frame(in: .global).midY + ) + } + } + } + + // MARK: - Fixed center (orb + reticle) + + /// Sits above the scroll lane; hit testing disabled so 3-finger scroll reaches the lane beneath. + private var centerOrbOverlay: some View { + ZStack { + ResonanceReticleRing(band: effectiveBand) + ResonanceCenterOrb(band: effectiveBand) + if effectiveBand == .success, UIImage(named: iOSDungeonResonanceArt.successFlare) != nil { + ResonanceSuccessFlareOverlay() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Inspector + + private var inspectorBar: some View { + VStack(alignment: .leading, spacing: RA11ySpacing.sm) { + Toggle("Manual band (override)", isOn: $useManualBand) + + if useManualBand { + Picker("Band", selection: $manualBand) { + ForEach(ResonanceBand.allCases) { band in + Text(band.rawValue).tag(band) + } + } + .pickerStyle(.segmented) + } else { + HStack { + Text("Δ to aim") + .font(.ra11yCaption) + Spacer() + Text("\(Int(liveDeltaPoints)) pt") + .font(.ra11yCaption.monospacedDigit()) + .foregroundStyle(Color.ra11yCardSecondaryText) + } + .accessibilityElement(children: .combine) + .accessibilityLabel("Distance to aim line, \(Int(liveDeltaPoints)) points") + } + + Toggle("Lights Off preview", isOn: $lightsOffPreview) + Toggle("Sound cues", isOn: soundEnabledBinding) + Toggle("Haptics", isOn: hapticsEnabledBinding) + Toggle("Spoken hints", isOn: spokenHintsEnabledBinding) + Toggle("Calm feedback mode", isOn: calmModeBinding) + + Text( + "Scroll the lane until the moonstone aligns with the center orb. " + + "Live bands: locked < \(Int(bandLockedMax)), " + + "near < \(Int(bandNearMax)), warm < \(Int(bandWarmMax)), else Far." + ) + .font(.ra11yCaption) + .foregroundStyle(Color.ra11yCardTertiaryText) + .fixedSize(horizontal: false, vertical: true) + } + .padding(RA11ySpacing.md) + .background(.ultraThinMaterial, in: .rect(cornerRadius: RA11yRadius.card)) + .padding(.horizontal, RA11ySpacing.md) + .padding(.bottom, RA11ySpacing.sm) + } + + /// Preview-only row for semantic actions that are not produced by scrolling alone. + private var feedbackActionBar: some View { + HStack(spacing: RA11ySpacing.sm) { + Button("Hint") { + feedbackPreview.processHintRequest() + } + .buttonStyle(.bordered) + + Button("Invoke") { + handleInvokeAction() + } + .buttonStyle(.borderedProminent) + + Button("Reset Cues") { + feedbackPreview.reset() + feedbackPreview.processAlignmentBand(feedbackBand(for: effectiveBand)) + } + .buttonStyle(.bordered) + } + .frame(maxWidth: .infinity) + .padding(.horizontal, RA11ySpacing.md) + .padding(.bottom, RA11ySpacing.sm) + } + + /// Converts mockup presentation bands into reusable semantic feedback bands. + private func feedbackBand(for band: ResonanceBand) -> QuestFeedbackBand { + switch band { + case .far: + return .far + case .warm: + return .warm + case .near: + return .near + case .locked, .success: + return .locked + } + } + + /// Applies the mockup's invoke behavior using shared semantic success/error events. + private func handleInvokeAction() { + if effectiveBand == .locked || effectiveBand == .success { + if useManualBand { + manualBand = .success + } + feedbackPreview.processSuccess() + } else { + feedbackPreview.processWrongActivation() + } + } + + /// Binding wrapper for preview sound settings. + private var soundEnabledBinding: Binding { + Binding( + get: { feedbackPreview.settings.soundEnabled }, + set: { feedbackPreview.settings.soundEnabled = $0 } + ) + } + + /// Binding wrapper for preview haptic settings. + private var hapticsEnabledBinding: Binding { + Binding( + get: { feedbackPreview.settings.hapticsEnabled }, + set: { feedbackPreview.settings.hapticsEnabled = $0 } + ) + } + + /// Binding wrapper for preview spoken-hint settings. + private var spokenHintsEnabledBinding: Binding { + Binding( + get: { feedbackPreview.settings.spokenHintsEnabled }, + set: { feedbackPreview.settings.spokenHintsEnabled = $0 } + ) + } + + /// Binding wrapper for preview calm-mode selection. + private var calmModeBinding: Binding { + Binding( + get: { feedbackPreview.settings.calmMode }, + set: { feedbackPreview.settings.calmMode = $0 } + ) + } +} + +// MARK: - Preference + +/// Global-space vertical center of the moonstone target (for aim delta). +private struct TargetMidYPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = -10_000 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + let next = nextValue() + if next > -1_000 { value = next } + } +} + +// MARK: - Moonstone (catalog + fallback) + +/// Moving resonance target — prefers `dungeon_target_moonstone` from the asset catalog. +private struct MoonstoneTargetOrb: View { + @ScaledMetric(relativeTo: .title) private var moonW: CGFloat = 96 + @ScaledMetric(relativeTo: .title) private var moonH: CGFloat = 72 + + 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) + } else { + ZStack { + Ellipse() + .fill( + RadialGradient( + colors: [ + Color.white.opacity(0.95), + Color(red: 0.72, green: 0.78, blue: 0.92).opacity(0.85), + Color(red: 0.35, green: 0.45, blue: 0.62).opacity(0.5), + ], + center: .center, + startRadius: 4, + endRadius: 44 + ) + ) + .frame(width: moonW * 0.92, height: moonH * 0.89) + Ellipse() + .strokeBorder(Color.white.opacity(0.35), lineWidth: 1) + .frame(width: moonW * 0.92, height: moonH * 0.89) + } + } + } + .accessibilityHidden(true) + } +} + +// MARK: - Decoys & lane markers (catalog + fallback) + +private enum DecoyStyle { + case ember, shadowGlyph, sunSigil + + fileprivate var assetName: String { + switch self { + case .ember: return iOSDungeonResonanceArt.decoyEmberShard + case .shadowGlyph: return iOSDungeonResonanceArt.decoyShadowGlyph + case .sunSigil: return iOSDungeonResonanceArt.decoySunSigil + } + } +} + +/// Wrong-target chips — prefers catalog art; geometric fallbacks if a PNG is missing. +private struct LaneDecoyChip: View { + let style: DecoyStyle + var accessibilityHidden: Bool = true + + @ScaledMetric(relativeTo: .title) private var chip: CGFloat = 76 + + var body: some View { + Group { + if let ui = UIImage(named: style.assetName) { + Image(uiImage: ui) + .resizable() + .scaledToFit() + .frame(width: chip, height: chip) + } else { + legacyDecoyPlaceholder + } + } + .frame(maxWidth: .infinity) + .opacity(0.9) + .accessibilityHidden(accessibilityHidden) + } + + @ViewBuilder + private var legacyDecoyPlaceholder: some View { + switch style { + case .ember: + EmberShardFallbackShape() + .fill(Color.orange.opacity(0.55)) + .frame(width: chip * 0.92, height: chip) + case .shadowGlyph: + Image(systemName: "diamond.fill") + .font(.system(size: chip * 0.72)) + .foregroundStyle(Color.purple.opacity(0.45)) + case .sunSigil: + Image(systemName: "sun.max.fill") + .font(.system(size: chip * 0.72)) + .foregroundStyle(Color.yellow.opacity(0.5)) + } + } +} + +private struct EmberShardFallbackShape: Shape { + func path(in rect: CGRect) -> Path { + var p = Path() + p.move(to: CGPoint(x: rect.midX, y: rect.minY)) + p.addLine(to: CGPoint(x: rect.maxX, y: rect.maxY)) + p.addLine(to: CGPoint(x: rect.minX, y: rect.maxY)) + p.closeSubpath() + return p + } +} + +/// Neutral lane tick — prefers `dungeon_lane_marker_neutral`. +private struct LaneLaneMarkerNeutral: View { + var body: some View { + Group { + if let ui = UIImage(named: iOSDungeonResonanceArt.laneMarkerNeutral) { + Image(uiImage: ui) + .resizable() + .scaledToFit() + .frame(height: 22) + .frame(maxWidth: .infinity) + } else { + RoundedRectangle(cornerRadius: 6) + .fill(Color.white.opacity(0.06)) + .frame(height: 22) + .overlay { + RoundedRectangle(cornerRadius: 6) + .strokeBorder(Color.ra11yDMBorder.opacity(0.2), lineWidth: 1) + } + } + } + .accessibilityHidden(true) + } +} + +// MARK: - Center orb & reticle (catalog + fallback) + +private struct ResonanceReticleRing: View { + let band: iOSDungeonResonanceMockupView.ResonanceBand + + @ScaledMetric(relativeTo: .title) private var ringBase: CGFloat = 172 + + var body: some View { + let pulse: CGFloat = switch band { + case .far: 0.15 + case .warm: 0.35 + case .near: 0.55 + case .locked: 0.85 + case .success: 1.0 + } + + let diameter = ringBase + pulse * 14 + + 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) + } else { + Circle() + .strokeBorder( + LinearGradient( + colors: [ + Color.ra11yDMBorder.opacity(0.25 + pulse * 0.5), + Color(red: 0.55, green: 0.75, blue: 0.95).opacity(0.35 + pulse * 0.45), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + 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) + } +} + +private struct ResonanceCenterOrb: View { + let band: iOSDungeonResonanceMockupView.ResonanceBand + + @ScaledMetric(relativeTo: .title) private var orbDiameter: CGFloat = 112 + + var body: some View { + ZStack { + if let uiImage = UIImage(named: orbImageName) { + Image(uiImage: uiImage) + .resizable() + .scaledToFit() + .frame(width: orbDiameter, height: orbDiameter) + .saturation(bandSaturation) + .shadow(color: orbGlowColor, radius: orbGlowRadius) + } else { + Circle() + .fill(orbGradient) + .frame(width: orbDiameter, height: orbDiameter) + .shadow(color: orbGlowColor, radius: orbGlowRadius) + } + + if band == .success { + Circle() + .strokeBorder(Color.white.opacity(0.85), lineWidth: 3) + .frame(width: orbDiameter + 12, height: orbDiameter + 12) + .transition(.opacity) + } + + if UIImage(named: orbImageName) == nil { + Circle() + .strokeBorder(Color.white.opacity(0.22 + ringHighlight * 0.5), lineWidth: 1) + .frame(width: orbDiameter + 8, height: orbDiameter + 8) + } + } + .animation(.easeInOut(duration: 0.2), value: band) + .accessibilityLabel(accessibilityLabelText) + } + + private var orbImageName: String { + switch band { + case .locked, .success: + return iOSDungeonResonanceArt.orbLocked + default: + return iOSDungeonResonanceArt.orbIdle + } + } + + private var bandSaturation: Double { + switch band { + case .far: return 0.82 + case .warm: return 0.92 + case .near: return 1.0 + case .locked: return 1.05 + case .success: return 1.12 + } + } + + private var ringHighlight: CGFloat { + switch band { + case .far: return 0.1 + case .warm: return 0.35 + case .near: return 0.55 + case .locked: return 0.85 + case .success: return 1.0 + } + } + + private var orbGlowRadius: CGFloat { + switch band { + case .far: return 6 + case .warm: return 12 + case .near: return 18 + case .locked: return 26 + case .success: return 32 + } + } + + private var orbGlowColor: Color { + switch band { + case .far: + return Color(red: 0.4, green: 0.35, blue: 0.55).opacity(0.35) + case .warm: + return Color.ra11yAccent.opacity(0.35) + case .near: + return Color(red: 0.65, green: 0.82, blue: 1.0).opacity(0.55) + case .locked: + return Color(red: 0.85, green: 0.95, blue: 1.0).opacity(0.75) + case .success: + return Color.ra11yTargetReachable.opacity(0.65) + } + } + + private var orbGradient: LinearGradient { + switch band { + case .success: + return LinearGradient( + colors: [ + Color(red: 0.95, green: 0.98, blue: 1.0), + Color.ra11yTargetReachable.opacity(0.85), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + default: + return LinearGradient( + colors: [ + Color(red: 0.75, green: 0.82, blue: 0.95), + Color(red: 0.35, green: 0.42, blue: 0.62), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + } + + 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" + } + } +} + +/// Brief success burst over the orb when the Success band is selected (manual preview). +private struct ResonanceSuccessFlareOverlay: View { + @ScaledMetric(relativeTo: .title) private var flareSize: CGFloat = 220 + + var body: some View { + if let ui = UIImage(named: iOSDungeonResonanceArt.successFlare) { + Image(uiImage: ui) + .resizable() + .scaledToFit() + .frame(width: flareSize, height: flareSize) + .blendMode(.screen) + .opacity(0.88) + .accessibilityHidden(true) + } + } +} + +// MARK: - Preview + +#Preview("Crystal Resonance — mockup") { + iOSDungeonResonanceMockupView() +} diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonancePlayView.swift b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonancePlayView.swift new file mode 100644 index 0000000..8ba86bd --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Games/iOSDungeonResonancePlayView.swift @@ -0,0 +1,488 @@ +import os +import SwiftUI +import UIKit +import RA11yCore + +// MARK: - Resonance scroll diagnostics + +/// Logs Crystal Resonance scroll and alignment diagnostics to OSLog (`scrollInteraction`) and, +/// in DEBUG builds, mirrors a single line to stdout for Xcode console filtering (`[RA11yScroll]`). +private func logResonanceScroll(_ message: String) { + RA11yLogger.scrollInteraction.debug("\(message)") + #if DEBUG + print("[RA11yScroll] \(message)") + #endif +} + +// MARK: - Aim line (global Y) + +/// 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 + + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + let next = nextValue() + if next > -1_000 { value = next } + } +} + +// 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). +/// +/// **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 +/// cannot be entered without VoiceOver (see ``iOSAppRouter/pushGame(kind:provider:)``), so a +/// separate non-VO `ScrollView` path is not maintained here. +/// +/// Lane glyphs are decorative; objectives, seal control, and timer affordances live in the fixed +/// chrome. Multimodal alignment feedback is driven from `DungeonDescentViewModel.updateResonanceDelta`. +/// +/// ## Concurrency +/// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. +struct iOSDungeonResonancePlayView: View { + + let rooms: [DungeonRoom] + let targetIsReachable: Bool + let statusMessage: String? + let levelComplete: Bool + let timeRemaining: Double? + let timedOut: Bool + let mistakes: Int + let lightsOffMode: Bool + let lightsOffFlavorText: String? + let showsFirstLevelGestureTip: Bool + + /// Invoked when the moonstone's vertical alignment changes (global-space delta from aim line). + let onResonanceDeltaChanged: (CGFloat) -> Void + + let onActivateTarget: (DungeonRoom) async -> Void + let onHint: (() -> Void)? + let onContinue: (() -> Void)? + let onRetry: (() -> Void)? + + @State private var displayDeltaPoints: CGFloat = 500 + + /// 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. + @State private var voiceOverProxyScrollOffsetY: CGFloat = 0 + + /// Vertical midpoint (global Y) of the resonance play area aim line (from ``ResonanceAimLineGlobalMidYPreferenceKey``). + @State private var aimLineGlobalMidY: CGFloat = -10_000 + + /// Last moonstone `midY` from ``iOSResonanceTargetMidYPreferenceKey`` (paired with `aimLineGlobalMidY`). + @State private var lastTargetGlobalMidY: CGFloat = -10_000 + + @Environment(\.horizontalSizeClass) private var sizeClass + + 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 + } + + /// Recomputes alignment when either the aim line or moonstone global position changes (preferences can arrive in either order). + private func applyResonanceAlignmentFromLastFrames() { + let targetY = lastTargetGlobalMidY + let aimMidY = aimLineGlobalMidY + guard targetY > -1_000, aimMidY > -1_000 else { return } + let delta = iOSResonanceAlignment.deltaPoints( + targetMidYGlobal: targetY, + aimMidYGlobal: 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)" + ) + } + onResonanceDeltaChanged(delta) + } + + var body: some View { + ZStack { + 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) + .clipped() + .ra11yLightsOffGameplayBlackout(isEnabled: lightsOffMode) + .allowsHitTesting(false) + .accessibilityHidden(true) + + /// UIKit `UIScrollView` proxy (see ``iOSResonanceVoiceOverScrollProxyRepresentable``); visually clear, one AX element. + iOSResonanceVoiceOverScrollProxyRepresentable( + contentBlockHeight: voiceOverProxyScrollableContentHeight, + verticalPadding: RA11ySpacing.xl, + 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))" + ) + } + } + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .accessibilitySortPriority(10) + + centerOrbStack + .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() + } + .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)") + } + } + + // MARK: - Lane + + private var resonanceLaneColumn: some View { + VStack(spacing: 56) { + ForEach(Array(rooms.enumerated()), id: \.element.id) { index, room in + if room.isTarget { + moonstoneRow(room: room) + } else { + iOSLaneDecoyChip(style: .forRoomIndex(index)) + } + } + } + .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`. + .accessibilityElement(children: .ignore) + } + + private func moonstoneRow(room: DungeonRoom) -> some View { + iOSMoonstoneTargetOrb() + .background { + GeometryReader { geo in + Color.clear.preference( + key: iOSResonanceTargetMidYPreferenceKey.self, + value: geo.frame(in: .global).midY + ) + } + } + .accessibilityIdentifier("dungeon.room.\(room.id)") + } + + // MARK: - Center stack + + private var centerOrbStack: some View { + ZStack { + iOSResonanceReticleRing(band: aimBand) + iOSResonanceCenterOrb(band: aimBand) + if aimBand == .success, UIImage(named: iOSDungeonResonanceArt.successFlare) != nil { + iOSResonanceSuccessFlareOverlay() + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + // MARK: - Chrome + + private var topChrome: 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) + } + + 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")) + } + + if let statusMessage { + iOSResonanceStatusRow(message: statusMessage) + } + } + .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 + } + + @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) + } + } + } + } + + /// Timeout uses `timeoutBanner`; L2/L3 retries are triggered from that banner. + private var timeoutBanner: some View { + VStack(spacing: RA11ySpacing.md) { + Text(String(localized: "dungeon.timeout")) + .font(.ra11yHeadline) + .multilineTextAlignment(.center) + if let onRetry { + Button(action: onRetry) { + Text(String(localized: "level.button.retry")) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .tint(Color.ra11yAccent) + .accessibilityIdentifier("dungeon.retry") + } + } + } + + private func hintButton(_ action: @escaping () -> Void) -> some View { + Button(action: action) { + Label(String(localized: "dungeon.hint.button"), systemImage: "ear.fill") + } + .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")) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .tint(Color.ra11yAccent) + .accessibilityIdentifier("dungeon.continue") + .accessibilityHint(String(localized: "dungeon.a11y.continue.nextAscent.hint")) + } + + private var objectiveCard: some View { + VStack(alignment: .leading, spacing: RA11ySpacing.xs) { + Label(String(localized: "dm.label"), systemImage: "scroll.fill") + .font(.ra11yCaption) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + + Text(objectiveText) + .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(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) + } + .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) + } + + 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) + case DungeonRoom.l2Rooms.count: + return String(format: String(localized: "dungeon.l2.objective.format"), target.displayName) + default: + return String(format: String(localized: "dungeon.l3.objective.format"), target.displayName) + } + } + + 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) + case DungeonRoom.l2Rooms.count: + return String(format: String(localized: "dungeon.a11y.l2.objective.format"), target.displayName) + default: + return String(format: String(localized: "dungeon.a11y.l3.objective.format"), target.displayName) + } + } + + private var objectiveA11yHint: String { + switch rooms.count { + case DungeonRoom.l1Rooms.count: + return String(localized: "dungeon.a11y.l1.objective.hint") + case DungeonRoom.l2Rooms.count, DungeonRoom.l3Rooms.count: + return String(localized: "dungeon.a11y.scroll.container.hint") + default: + return String(localized: "dungeon.a11y.l1.objective.hint") + } + } + + private var timerA11yLabel: String { + guard let remaining = timeRemaining else { return "" } + return String(format: String(localized: "dungeon.a11y.l3.timer"), Int(ceil(remaining))) + } + + private var timeTotal: Double? { + switch rooms.count { + case DungeonRoom.l2Rooms.count: return 60 + case DungeonRoom.l3Rooms.count: return 45 + default: return nil + } + } +} + +// MARK: - Status row + +/// Visible-only feedback line below the HUD (VoiceOver still receives hint via `requestHint` paths). +private struct iOSResonanceStatusRow: View { + let message: String + + var body: some View { + Text(message) + .font(.ra11ySubheadline) + .foregroundStyle(Color.ra11yCardSecondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, RA11ySpacing.xs) + } +} diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSEnchantersTrialView.swift b/RA11y-iOS/RA11y-iOS/Games/iOSEnchantersTrialView.swift index d2bd1e1..8f07510 100644 --- a/RA11y-iOS/RA11y-iOS/Games/iOSEnchantersTrialView.swift +++ b/RA11y-iOS/RA11y-iOS/Games/iOSEnchantersTrialView.swift @@ -1,273 +1,782 @@ +import Observation import os import SwiftUI import UIKit import RA11yCore +// MARK: - Enchanter hint presentation + +/// Controls visibility of the in-level "Ask the Enchanter" button on L1–L3. +/// +/// `EnchanterTrialViewModel.requestHint()` only repeats the target relic name—the same fact +/// already on the prompt card and announced shortly after each level appears. Hide the control +/// until hints add distinct value (for example VoiceOver rotor coaching, spatial cueing, or +/// disambiguation between confusable relic names). Flip to `true` when that ships. +private enum EnchanterHintPresentation { + static let showsHintButtonInGameplay = false +} + // MARK: - iOSEnchantersTrialView -/// The Enchanter's Trial (Find & Focus) — L1 minimal playable screen. +/// Container for The Enchanter's Trial (Find & Focus) — M5. /// -/// Presents a prompt card and a grid of relics. The player must select -/// the named relic; mistakes are recorded and the session completes on -/// a correct selection. +/// Implements the full 4-level game arc defined in `GameSpec-FindAndFocus.txt` +/// and `GameRules-MVP.txt`: +/// +/// - **L0 Prologue**: DM narration + VoiceOver lesson card + gesture guide + "Begin Trial" +/// - **L1 First Attempt**: 3 relics, no timer; confidence builder +/// - **L2 Rising Challenge**: 6 relics, 45 s soft timer +/// - **L3 Timed Trial**: All 8 relics, 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 +/// presents a synthesized Defeated result (not stored). +/// +/// ## Concurrency +/// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. struct iOSEnchantersTrialView: View { - // MARK: - Private State + // MARK: - State + + @State private var viewModel: EnchanterTrialViewModel - @State private var session: GameSession - @State private var coordinator: GameSessionCoordinator - @State private var relics: [EnchanterRelic] - @State private var targetRelic: EnchanterRelic - @State private var statusMessage: String? - @State private var hasStartedSession = false - @State private var isCompleting = false + /// Final timed level only: full-screen blackout and gameplay overlays (see `LightsOffMode-Recommendations.txt`). + private var isLightsOffFinalLevel: Bool { viewModel.phase == .timed } // MARK: - Environment @Environment(iOSAppRouter.self) private var router - @Environment(\.horizontalSizeClass) private var sizeClass + + // MARK: - Private + + /// Storage reference for loading Lights Off preference (same backing store as the hub toggle). + private let storage: any StorageComponent // MARK: - Init - /// Creates the Enchanter's Trial view with the provided storage backend. + /// Creates the Enchanter game container. /// - /// - Parameter storage: Persistence layer for game results. - init(storage: any StorageComponent) { - let session = GameSession( - gameID: "find-and-focus", - thresholds: .findAndFocus, - storage: storage + /// - Parameters: + /// - storage: Persistence used for L3 session results during normal gameplay. + /// - screenshotScene: Optional deterministic screenshot scene override. + init(storage: any StorageComponent, screenshotScene: iOSScreenshotScene? = nil) { + self.storage = storage + _viewModel = State( + initialValue: EnchanterTrialViewModel(storage: storage, screenshotScene: screenshotScene) ) - let relics = EnchanterRelic.all - let targetRelic = EnchanterRelic.target(from: relics) - _session = State(initialValue: session) - _coordinator = State( - initialValue: GameSessionCoordinator( - session: session, - gameKind: .findAndFocus, - voiceOverProvider: iOSLiveVoiceOverStateProvider() - ) - ) - _relics = State(initialValue: relics) - _targetRelic = State(initialValue: targetRelic) } // MARK: - Body + /// The game view uses `.background {}` instead of a ZStack containing the background. + /// + /// A `scaledToFill` image inside a ZStack reports its natural scaled width (often wider + /// than the screen for landscape assets) as its preferred layout size. This inflates the + /// ZStack's layout, shifts siblings off-centre, and causes content to overflow the screen + /// horizontally. Using `.background {}` sizes the background to the foreground view, + /// preventing any layout bleed from the image. var body: some View { - content + levelContent .background { - EnchanterBackgroundView() + if isLightsOffFinalLevel { + Color.black + .ignoresSafeArea() + } else { + EnchanterBackgroundView() + .ignoresSafeArea() + } } - .navigationTitle(String(localized: "game.findAndFocus.title")) + .preferredColorScheme(.dark) .navigationBarTitleDisplayMode(.inline) - .task { - await startSessionIfNeeded() + .onChange(of: viewModel.completedResult) { _, result in + guard let result else { return } + let announcement = gameSpecificAnnouncement(for: result) + router.push(.gameResult(result, gameKind: .findAndFocus, gameSpecificAnnouncement: announcement)) } - .onChange(of: coordinator.voiceOverDisabledMidGame) { _, disabled in + .onChange(of: viewModel.voiceOverDisabledMidGame) { _, disabled in if disabled { router.push(.voiceOverInterstitial(kind: .findAndFocus)) } } - .onDisappear { - coordinator.stopMonitoring() - } + .onDisappear { viewModel.handleViewDisappear() } } - // MARK: - Content + // MARK: - Level Routing - private var content: some View { - ScrollView(.vertical) { - VStack(spacing: RA11ySpacing.lg) { - promptCard - relicGrid - statusMessageView - } - .padding(.horizontal, RA11ySpacing.base) - .padding(.vertical, RA11ySpacing.lg) - .frame(maxWidth: contentMaxWidth) - .frame(maxWidth: .infinity) - .accessibilityElement(children: .contain) - .accessibilityIdentifier("enchanter.trial") + /// `.transition(.identity)` on each case suppresses the default opacity fade that + /// SwiftUI applies when a `@ViewBuilder` switch produces a different view type. + /// Without it, if the `@Observable` machinery delivers a phase mutation as a view + /// update (e.g. during screenshot bootstrapping), the incoming view fades in from + /// zero opacity and the screenshot captures it mid-transition. + @ViewBuilder + private var levelContent: some View { + switch viewModel.phase { + case .prologue: + EnchanterPrologueView(onBeginTrial: { viewModel.beginTrial() }) + .navigationTitle(String(localized: "simon.explain.title")) + .accessibilityIdentifier("enchanter.prologue") + .transition(.identity) + case .attempt: + EnchanterAttemptView( + relics: viewModel.relics, + targetRelic: viewModel.targetRelic, + mistakes: viewModel.mistakes, + statusMessage: viewModel.statusMessage, + levelComplete: viewModel.levelComplete, + lightsOffMode: isLightsOffFinalLevel, + onActivate: { relic in await viewModel.activateRelic(relic) }, + onHint: { viewModel.requestHint() }, + onContinue: { viewModel.advanceToRising() } + ) + .navigationTitle(String(localized: "simon.l1.title")) + // Spec: "Enchanter's prompt card reads the target name aloud on screen load." + .onAppear { viewModel.announceTargetPrompt() } + .accessibilityIdentifier("enchanter.attempt") + .transition(.identity) + case .rising: + EnchanterRisingView( + relics: viewModel.relics, + targetRelic: viewModel.targetRelic, + mistakes: viewModel.mistakes, + timeRemaining: viewModel.timeRemaining, + statusMessage: viewModel.statusMessage, + levelComplete: viewModel.levelComplete, + timedOut: viewModel.l2TimedOut, + lightsOffMode: isLightsOffFinalLevel, + onActivate: { relic in await viewModel.activateRelic(relic) }, + onHint: { viewModel.requestHint() }, + onContinue: { viewModel.advanceToTimed() }, + onRetry: { viewModel.retryRising() } + ) + .navigationTitle(String(localized: "simon.l2.title")) + .onAppear { viewModel.announceTargetPrompt() } + .accessibilityIdentifier("enchanter.rising") + .transition(.identity) + case .timed: + EnchanterTimedView( + relics: viewModel.relics, + targetRelic: viewModel.targetRelic, + mistakes: viewModel.mistakes, + timeRemaining: viewModel.timeRemaining, + statusMessage: viewModel.statusMessage, + timedOut: viewModel.l3TimedOut, + lightsOffMode: isLightsOffFinalLevel, + onActivate: { relic in await viewModel.activateRelic(relic) }, + onHint: { viewModel.requestHint() }, + onRetry: { viewModel.retryTimed() } + ) + .navigationTitle(String(localized: "simon.l3.title")) + .onAppear { viewModel.announceTargetPrompt() } + .accessibilityIdentifier("enchanter.timed") + .transition(.identity) } } - private var contentMaxWidth: CGFloat { - sizeClass == .regular ? 720 : .infinity + // MARK: - Game-Specific Result Announcements + + /// Returns the localized per-rank flavor string for the Enchanter's Trial. + /// + /// These strings are defined in `GameRules-MVP.txt` and appended after the shared + /// rank summary on the result screen. + private func gameSpecificAnnouncement(for result: GameResult) -> String { + switch result.rank { + case .perfect: return String(localized: "simon.results.legendary") + case .good: return String(localized: "simon.results.skilled") + case .ok: return String(localized: "simon.results.novice") + case .failed: return String(localized: "simon.results.defeated") + } } +} - private var promptCard: some View { - VStack(alignment: .leading, spacing: RA11ySpacing.sm) { - Text(String(format: String(localized: "enchanter.prompt.format"), targetRelic.displayName)) - .font(.ra11yHeadline) - .bold() - .accessibilityLabel( - String(format: String(localized: "enchanter.prompt.format"), targetRelic.displayName) - ) - .accessibilityHint(String(localized: "enchanter.prompt.a11yHint")) +// MARK: - EnchanterTrialViewModel - Text(String(localized: "enchanter.prompt.instructions")) - .font(.ra11yBody) - .foregroundStyle(.secondary) +/// Observable view model managing the Enchanter's Trial 4-level state machine. +/// +/// Owns level phase transitions, relic 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 stored as `nonisolated(unsafe)` so `deinit` (always nonisolated in +/// Swift 6) can call `cancel()`. `Task.cancel()` is itself nonisolated and thread-safe. +@Observable +@MainActor +final class EnchanterTrialViewModel { - Button(String(localized: "enchanter.hint.button")) { - statusMessage = String( - format: String(localized: "enchanter.hint.format"), - targetRelic.displayName - ) - } - .buttonStyle(.bordered) - .accessibilityLabel(String(localized: "enchanter.hint.a11yLabel")) - .accessibilityHint(String(localized: "enchanter.hint.a11yHint")) + // MARK: - Phase + + enum Phase { case prologue, attempt, rising, timed } + + private(set) var phase: Phase = .prologue + + // MARK: - Shared Level State + + private(set) var relics: [EnchanterRelic] = [] + private(set) var targetRelic: EnchanterRelic = .placeholder + 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.5 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 + private let screenshotScene: iOSScreenshotScene? + + /// 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()`. + /// + /// Declared as a plain stored property (no isolation modifier). 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. + private var timerTask: Task? + + // MARK: - Init + + /// Creates the Enchanter view model. + /// + /// - Parameters: + /// - storage: Persistence used for normal gameplay session storage. + /// - screenshotScene: Optional deterministic screenshot scene override. + init(storage: any StorageComponent, screenshotScene: iOSScreenshotScene? = nil) { + self.storage = storage + self.screenshotScene = screenshotScene + if let screenshotScene { + applyScreenshotScene(screenshotScene) } - .padding(RA11ySpacing.base) - .background( - .ultraThinMaterial, - in: .rect(cornerRadius: RA11yRadius.card) - ) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.card) - .stroke(.white.opacity(0.12), lineWidth: 1) - ) } - private var relicGrid: some View { - VStack(alignment: .leading, spacing: RA11ySpacing.sm) { - Text(String(localized: "enchanter.relics.header")) - .font(.ra11yHeadline) - .accessibilityAddTraits(.isHeader) + // MARK: - Phase Transitions - LazyVGrid(columns: gridColumns, spacing: RA11ySpacing.sm) { - ForEach(relics) { relic in - relicButton(relic) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) + /// Transitions L0 → L1: sets up the 3-relic first-attempt level. + func beginTrial() { + stopTimer() + mistakes = 0 + statusMessage = nil + levelComplete = false + relics = EnchanterRelic.setForL1() + targetRelic = EnchanterRelic.pickTarget(from: relics) + phase = .attempt } - private var gridColumns: [GridItem] { - let count = sizeClass == .regular ? 3 : 2 - return Array(repeating: GridItem(.flexible(), spacing: RA11ySpacing.sm), count: count) + /// Transitions L1 → L2: sets up the 6-relic rising challenge with 45 s timer. + func advanceToRising() { + stopTimer() + mistakes = 0 + statusMessage = nil + levelComplete = false + l2TimedOut = false + relics = EnchanterRelic.setForL2() + targetRelic = EnchanterRelic.pickTarget(from: relics) + timeRemaining = 45 + phase = .rising + startTimer(total: 45) { [weak self] in await self?.handleL2Timeout() } } - private func relicButton(_ relic: EnchanterRelic) -> some View { - Button { - handleRelicSelection(relic) - } label: { - VStack(spacing: RA11ySpacing.sm) { - RelicImage(assetName: relic.assetName) - .frame(height: 72) + /// Retries L2 on timeout without returning to L1. + func retryRising() { + advanceToRising() + } + + /// Transitions L2 → L3: sets up all 8 relics with a 20 s hard timer and creates a fresh `GameSession`. + /// + /// ## Concurrency + /// `GameSession.start()` must complete before the timer or any relic 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 + relics = EnchanterRelic.setForL3() + targetRelic = EnchanterRelic.pickTarget(from: relics) + timeRemaining = 20 + phase = .timed - Text(relic.displayName) - .font(.ra11ySubheadline) - .multilineTextAlignment(.center) + let newSession = GameSession( + gameID: "find-and-focus", + thresholds: .findAndFocus, + storage: storage + ) + let newCoordinator = GameSessionCoordinator( + session: newSession, + gameKind: .findAndFocus, + voiceOverProvider: iOSLiveVoiceOverStateProvider() + ) + session = newSession + coordinator = newCoordinator + + // Start session and timer in sequence: session MUST be in .running before + // the timer fires and any relic activation calls recordMistake/complete. + 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 } - .frame(maxWidth: .infinity) - .padding(RA11ySpacing.md) - .background( - Color.black.opacity(0.2), - in: .rect(cornerRadius: RA11yRadius.card) - ) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.card) - .stroke(.white.opacity(0.15), lineWidth: 1) - ) + 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) } - .accessibilityLabel(relic.displayName) - .accessibilityHint(String(localized: "enchanter.relic.a11yHint")) } - @ViewBuilder - private var statusMessageView: some View { - if let statusMessage { - Text(statusMessage) - .font(.ra11ySubheadline) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) + /// Polls the given coordinator for a VoiceOver-off event and mirrors it onto the ViewModel. + /// + /// Runs in a `Task` so it does not block the calling context. Exits as soon as the flag + /// is set or the task is cancelled (e.g., when the phase transitions or 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 + } + } } } - // MARK: - Actions + /// Retries L3 — creates a fresh session and restarts the 20 s timer. + func retryTimed() { + advanceToTimed() + } + + // MARK: - Relic Activation + + /// Handles a relic button tap for the current level. + /// + /// - Correct: marks level complete, starts L3 session completion if in `.timed` phase. + /// - Wrong: records mistake, announces feedback via VoiceOver. + func activateRelic(_ relic: EnchanterRelic) async { + guard !levelComplete, !l2TimedOut, !l3TimedOut else { return } - private func handleRelicSelection(_ relic: EnchanterRelic) { - guard !isCompleting else { return } if relic == targetRelic { - isCompleting = true - Task { await completeSession() } + await handleCorrectActivation(relic) } else { - Task { await recordMistake(for: relic) } + await handleWrongActivation(relic) } } - private func startSessionIfNeeded() async { - guard !hasStartedSession else { return } - hasStartedSession = true - coordinator.startMonitoring() - do { - try await session.start() - } catch { - statusMessage = String(localized: "enchanter.startError") - RA11yLogger.gameSession.error("Session start failed: \(error.localizedDescription)") - } + /// Announces the hint string to VoiceOver and as a visible status message. + /// + /// - Note: Gameplay does not show a hint button while `EnchanterHintPresentation.showsHintButtonInGameplay` + /// is `false`, because the message duplicates the prompt. This method remains for tests and future UI. + func requestHint() { + let message = String(format: String(localized: "enchanter.hint.format"), targetRelic.displayName) + statusMessage = message + guard screenshotScene == nil else { return } + announce(message) } - private func recordMistake(for relic: EnchanterRelic) async { - do { - try await session.recordMistake() - } catch { - statusMessage = String(localized: "enchanter.mistakeError") - RA11yLogger.gameSession.error("Mistake record failed: \(error.localizedDescription)") + /// Announces the Enchanter's prompt (target name + navigation instruction) to VoiceOver. + /// + /// Called from each level view's `.onAppear` so VoiceOver reads the prompt card when + /// the level first appears — satisfying the spec requirement "prompt card reads the + /// target name aloud on screen load." Uses the same accessibility label as the visible + /// prompt card so sighted and VoiceOver users see/hear identical information. + /// + /// A brief delay is introduced so VoiceOver's transition focus-move completes before + /// the announcement fires; without it the announcement can be swallowed by the + /// navigation transition announcement. + func announceTargetPrompt() { + guard screenshotScene == nil else { return } + let message: String + switch phase { + case .attempt: + message = String(format: String(localized: "simon.a11y.l1.target"), targetRelic.displayName) + case .rising: + message = String(format: String(localized: "simon.a11y.l2.target"), targetRelic.displayName) + case .timed: + message = String(format: String(localized: "simon.a11y.l3.target"), targetRelic.displayName) + case .prologue: return } - statusMessage = String( - format: String(localized: "enchanter.mistake.format"), + + 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(_ relic: EnchanterRelic) async { + switch phase { + case .prologue: + break + + case .attempt: + stopTimer() + levelComplete = true + let message = String(format: String(localized: "simon.feedback.correct"), relic.displayName) + statusMessage = message + // Use the thematic L1-specific announcement ("Trial passed. The Enchanter nods.") + // rather than the generic "Level complete." used by other contexts. + announce(String(localized: "simon.l1.complete")) + + case .rising: + stopTimer() + levelComplete = true + let message = String(format: String(localized: "simon.feedback.correct"), relic.displayName) + statusMessage = message + announce(String(localized: "simon.l2.complete")) + + case .timed: + guard let session else { return } + // Stop the timer first so timeRemaining is stable for bucket calculation. + stopTimer() + + // Compute elapsed from the 20 s L3 window and record any time-bucket penalty + // mistakes before completing the session. The first 10 s bucket is free; + // each additional full 10 s adds +1 (per GameSpec-FindAndFocus.txt). + let elapsed = 20.0 - timeRemaining + let buckets = RankThresholds.bucketMistakes(timeSeconds: elapsed, bucketSize: 10) + 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)) + // Re-check self and cancellation after each sleep — exits cleanly if + // the ViewModel was released or stopTimer() cancelled the task. + guard let self, !Task.isCancelled else { return } + + let elapsed = Date().timeIntervalSince(startDate) + let remaining = max(0, total - elapsed) + self.timeRemaining = remaining + + // Threshold VoiceOver announcements + 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 (only for L3; L2 uses percentage thresholds) + if self.phase == .timed && remaining <= 10 && !announced10 { + announced10 = true + self.announce(String(localized: "a11y.timer.10s")) + } + + // Final 5 s countdown + 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 + } - coordinator.stopMonitoring() - statusMessage = String( - format: String(localized: "enchanter.success.format"), - targetRelic.displayName + /// 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: "simon.timeout")) + } + + private func handleL3Timeout() async { + guard let session else { return } + l3TimedOut = true + coordinator?.stopMonitoring() + await session.abandon() + announce(String(localized: "simon.timeout")) + // Synthesize a Defeated result for display (not stored — session was abandoned). + let elapsed = 20.0 - timeRemaining + completedResult = GameResult( + gameID: "find-and-focus", + rank: .failed, + timeSeconds: elapsed, + mistakes: mistakes ) + } + + // MARK: - Private: VoiceOver Announcements + + private func announce(_ message: String) { + UIAccessibility.post(notification: .announcement, argument: message) + } + + /// Applies a deterministic static state for screenshot capture. + /// + /// Screenshot scenes intentionally avoid timers, coordinators, and persistence writes. + /// They only shape the visible UI state needed for fastlane capture. + private func applyScreenshotScene(_ scene: iOSScreenshotScene) { + stopTimer() + mistakes = 0 + statusMessage = nil + levelComplete = false + l2TimedOut = false + l3TimedOut = false + completedResult = nil + voiceOverDisabledMidGame = false + timeRemaining = 0 + + switch scene { + case .enchanterPrologue: + phase = .prologue + relics = [] + targetRelic = .placeholder + case .enchanterAttempt: + phase = .attempt + relics = EnchanterRelic.setForL1() + targetRelic = EnchanterRelic.pickTarget(from: relics) + case .enchanterRising: + phase = .rising + relics = EnchanterRelic.setForL2() + targetRelic = EnchanterRelic.pickTarget(from: relics) + timeRemaining = 32 + case .enchanterTimed: + phase = .timed + relics = EnchanterRelic.setForL3() + targetRelic = EnchanterRelic.pickTarget(from: relics) + mistakes = 1 + timeRemaining = 12 + default: + break + } + } - let state = await session.state - if case .completed(let result) = state { - router.push(.gameResult(result)) + private func announceTimerThreshold(for phase: Phase, pct: Double) { + switch (phase, pct) { + case (.timed, 0.75): + announce(String(localized: "simon.a11y.timer.75pct")) + case (.timed, 0.50): + announce(String(localized: "simon.a11y.timer.50pct")) + case (.timed, 0.25): + announce(String(localized: "simon.a11y.timer.25pct")) + case (.rising, 0.50): + announce(String(localized: "a11y.timer.50pct")) + case (.rising, 0.25): + announce(String(localized: "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: - EnchanterRelic -private struct EnchanterRelic: Identifiable, Hashable { +/// A single relic in The Enchanter's Trial. +/// +/// All 8 relics share the same pool. Level-specific subsets are composed by +/// `setForL1()`, `setForL2()`, and `setForL3()`. +struct EnchanterRelic: Identifiable, Hashable, Equatable { let id: String let displayName: String let assetName: String + // MARK: - Relic Pool + + /// Full 8-relic pool used in L3. static let all: [EnchanterRelic] = [ EnchanterRelic(id: "dragon_scale", displayName: "Dragon Scale", assetName: "enchanter_relic_dragon_scale"), - EnchanterRelic(id: "dragon_claw", displayName: "Dragon Claw", assetName: "enchanter_relic_dragon_claw"), + EnchanterRelic(id: "dragon_claw", displayName: "Dragon Claw", assetName: "enchanter_relic_dragon_claw"), EnchanterRelic(id: "shadow_stone", displayName: "Shadow Stone", assetName: "enchanter_relic_shadow_stone"), - EnchanterRelic(id: "sunstone", displayName: "Sunstone", assetName: "enchanter_relic_sunstone"), - EnchanterRelic(id: "ember_glass", displayName: "Ember Glass", assetName: "enchanter_relic_ember_glass"), - EnchanterRelic(id: "frost_glass", displayName: "Frost Glass", assetName: "enchanter_relic_frost_glass"), - EnchanterRelic(id: "iron_shard", displayName: "Iron Shard", assetName: "enchanter_relic_iron_shard"), - EnchanterRelic(id: "moonstone", displayName: "Moonstone", assetName: "enchanter_relic_moonstone"), + EnchanterRelic(id: "sunstone", displayName: "Sunstone", assetName: "enchanter_relic_sunstone"), + EnchanterRelic(id: "ember_glass", displayName: "Ember Glass", assetName: "enchanter_relic_ember_glass"), + EnchanterRelic(id: "frost_glass", displayName: "Frost Glass", assetName: "enchanter_relic_frost_glass"), + EnchanterRelic(id: "iron_shard", displayName: "Iron Shard", assetName: "enchanter_relic_iron_shard"), + EnchanterRelic(id: "moonstone", displayName: "Moonstone", assetName: "enchanter_relic_moonstone"), ] - static func target(from relics: [EnchanterRelic]) -> EnchanterRelic { - guard !relics.isEmpty else { return EnchanterRelic(id: "unknown", displayName: "Relic", assetName: "") } + /// Simple-name subset for L1 (no similar-sounding pairs). + private static let l1Pool: [EnchanterRelic] = all.filter { + ["dragon_scale", "iron_shard", "moonstone", "ember_glass"].contains($0.id) + } + + /// Placeholder used as ViewModel initial state before a level is configured. + static let placeholder = EnchanterRelic(id: "", displayName: "", assetName: "") + + // MARK: - Set Builders + + /// Returns a deterministic 3-relic set for L1 (UI-testing friendly). + /// L1 relic selection: shuffled each attempt so index memory alone cannot win. + static func setForL1() -> [EnchanterRelic] { + if ProcessInfo.processInfo.arguments.contains("-uiTesting") { + return Array(l1Pool.prefix(3)) + } + return Array(l1Pool.shuffled().prefix(3)) + } + + /// Returns 6 relics for L2, guaranteed to include at least one similar-sounding pair. + /// + /// Similar-sounding pairs per spec: (Dragon Scale / Dragon Claw), + /// (Shadow Stone / Sunstone), (Ember Glass / Frost Glass). + static func setForL2() -> [EnchanterRelic] { + if ProcessInfo.processInfo.arguments.contains("-uiTesting") { + return Array(all.prefix(6)) + } + // Pick one forced pair, then fill remaining 4 randomly from the rest. + let pairs: [[String]] = [ + ["dragon_scale", "dragon_claw"], + ["shadow_stone", "sunstone"], + ["ember_glass", "frost_glass"], + ] + let forcedPairIDs = pairs.randomElement() ?? pairs[0] + let forcedPair = all.filter { forcedPairIDs.contains($0.id) } + let remaining = all.filter { !forcedPairIDs.contains($0.id) }.shuffled() + return (forcedPair + Array(remaining.prefix(4))).shuffled() + } + + /// Returns all 8 relics in randomized order for L3. + static func setForL3() -> [EnchanterRelic] { + if ProcessInfo.processInfo.arguments.contains("-uiTesting") { + return all + } + return all.shuffled() + } + + /// Picks the target from a relic set. Returns the first element during UI testing for determinism. + static func pickTarget(from relics: [EnchanterRelic]) -> EnchanterRelic { + guard !relics.isEmpty else { return .placeholder } if ProcessInfo.processInfo.arguments.contains("-uiTesting") { return relics[0] } @@ -275,47 +784,747 @@ private struct EnchanterRelic: Identifiable, Hashable { } } -// MARK: - RelicImage +// MARK: - L0: EnchanterPrologueView -private struct RelicImage: View { - let assetName: String +/// L0 Prologue — DM narration, VoiceOver lesson card, gesture guide, and "Begin Trial" button. +/// +/// No game session. Player reads or listens to the lesson, then taps "Begin Trial" to start L1. +/// +/// ## Responsive Layout (SwiftUI Best Practices) +/// - `contentMargins` (iOS 17+) constrains scroll content to safe area without GeometryReader. +/// - `HStack` + `Spacer` centers content on iPad when max width is 600pt. +/// - `frame(minWidth: 0)` prevents content from expanding beyond the container. +private struct EnchanterPrologueView: View { + + let onBeginTrial: () -> Void + + @Environment(\.horizontalSizeClass) private var sizeClass + + private var horizontalPadding: CGFloat { + sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base + } + + private var contentMaxWidth: CGFloat? { + sizeClass == .regular ? 600 : nil + } var body: some View { - if let image = UIImage(named: assetName) { - Image(uiImage: image) - .resizable() - .scaledToFit() + ScrollView(.vertical) { + enchanterContent { + dmNarrationCard + lessonCard + gestureGuide + beginButton + } + } + .contentMargins(.horizontal, horizontalPadding, for: .scrollContent) + .environment(\.colorScheme, .dark) + } + + @ViewBuilder + private func enchanterContent(@ViewBuilder content: () -> Content) -> some View { + let inner = VStack(spacing: RA11ySpacing.lg) { + content() + } + .padding(.vertical, RA11ySpacing.lg) + .frame(minWidth: 0, maxWidth: contentMaxWidth ?? CGFloat.infinity) + .frame(maxWidth: .infinity) + + if sizeClass == .regular { + HStack(spacing: 0) { + Spacer(minLength: 0) + inner + Spacer(minLength: 0) + } } else { - Image(systemName: "sparkles") - .font(.system(size: 36, weight: .semibold)) + inner + } + } + + 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: "simon.explain.narration")) + .font(.ra11yBody) + .italic() + } + .padding(RA11ySpacing.base) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.black.opacity(0.72), 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: "simon.explain.lesson.heading")) + .font(.ra11yHeadline) + .bold() + .accessibilityAddTraits(.isHeader) + + Text(String(localized: "simon.explain.lesson.body")) + .font(.ra11yBody) } + .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(.white.opacity(0.15), lineWidth: 1) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel(String(localized: "simon.a11y.explain.lesson")) + } + + private var gestureGuide: some View { + VStack(spacing: RA11ySpacing.sm) { + GestureRow(symbol: "hand.point.right.fill", label: String(localized: "simon.explain.gesture.swipe")) + GestureRow(symbol: "hand.point.left.fill", label: String(localized: "simon.explain.gesture.back")) + GestureRow(symbol: "hand.tap.fill", label: String(localized: "simon.explain.gesture.tap")) + } + .accessibilityHidden(true) // Gesture guide is decorative; content in lesson card label + } + + 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: "simon.explain.start.hint")) + .accessibilityIdentifier("enchanter.beginTrial") } } -// MARK: - EnchanterBackgroundView +// MARK: - L1: EnchanterAttemptView + +/// L1 First Attempt — 3 relics, no timer, mistake feedback. +/// +/// Displays a prompt card with the target name, then a vertical list of relic buttons. +/// Relics are stacked vertically so VoiceOver's linear swipe navigation mirrors the +/// linear metaphor taught in L0 (swipe right = next item). +private struct EnchanterAttemptView: View { + + let relics: [EnchanterRelic] + let targetRelic: EnchanterRelic + let mistakes: Int + let statusMessage: String? + let levelComplete: Bool + /// When `true`, relic artwork is covered by an accessibility-inert blackout layer. + let lightsOffMode: Bool + let onActivate: (EnchanterRelic) async -> Void + let onHint: () -> Void + let onContinue: () -> Void + + @Environment(\.horizontalSizeClass) private var sizeClass + + private var horizontalPadding: CGFloat { + sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base + } + + private var contentMaxWidth: CGFloat? { + sizeClass == .regular ? 600 : nil + } + + var body: some View { + ScrollView(.vertical) { + enchanterLevelContent { + promptCard( + title: String(format: String(localized: "simon.l1.target.format"), targetRelic.displayName), + a11yLabel: String(format: String(localized: "simon.a11y.l1.target"), targetRelic.displayName), + a11yHint: String(localized: "simon.a11y.l1.target.hint") + ) + + mistakeHUD + + relicStack + + if let statusMessage { + statusRow(statusMessage) + } + + if levelComplete { + continueButton + } else if EnchanterHintPresentation.showsHintButtonInGameplay { + hintButton + } + } + } + .contentMargins(.horizontal, horizontalPadding, for: .scrollContent) + .environment(\.colorScheme, .dark) + } + + @ViewBuilder + private func enchanterLevelContent(@ViewBuilder content: () -> Content) -> some View { + let inner = VStack(spacing: RA11ySpacing.lg) { + content() + } + .padding(.vertical, RA11ySpacing.lg) + .frame(minWidth: 0, maxWidth: contentMaxWidth ?? CGFloat.infinity) + .frame(maxWidth: .infinity) + + if sizeClass == .regular { + HStack(spacing: 0) { + Spacer(minLength: 0) + inner + Spacer(minLength: 0) + } + } else { + inner + } + } + + private var mistakeHUD: some View { + Text(String(format: String(localized: "simon.hud.mistakes.format"), mistakes)) + .font(.ra11ySubheadline) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity, alignment: .trailing) + .accessibilityLabel(String(format: String(localized: "simon.a11y.hud.l1"), mistakes)) + } + + private var relicStack: some View { + VStack(spacing: RA11ySpacing.md) { + ForEach(relics) { relic in + RelicButton(relic: relic, onActivate: onActivate) + } + } + .ra11yLightsOffGameplayBlackout(isEnabled: lightsOffMode) + } + + private var hintButton: some View { + Button(action: onHint) { + Label(String(localized: "enchanter.hint.button"), systemImage: "ear.fill") + } + .buttonStyle(.bordered) + .accessibilityLabel(String(localized: "enchanter.hint.a11yLabel")) + .accessibilityHint(String(localized: "enchanter.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("enchanter.l1.continue") + } +} + +// MARK: - L2: EnchanterRisingView + +/// L2 Rising Challenge — 6 relics with similar-sounding names, 45 s soft timer. +private struct EnchanterRisingView: View { + + let relics: [EnchanterRelic] + let targetRelic: EnchanterRelic + let mistakes: Int + let timeRemaining: Double + let statusMessage: String? + let levelComplete: Bool + let timedOut: Bool + let lightsOffMode: Bool + let onActivate: (EnchanterRelic) async -> Void + let onHint: () -> Void + let onContinue: () -> Void + let onRetry: () -> Void + + @Environment(\.horizontalSizeClass) private var sizeClass + + private var horizontalPadding: CGFloat { + sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base + } + + private var contentMaxWidth: CGFloat? { + sizeClass == .regular ? 600 : nil + } + + var body: some View { + ScrollView(.vertical) { + enchanterLevelContent { + promptCard( + title: String(format: String(localized: "simon.l2.target.format"), targetRelic.displayName), + a11yLabel: String(format: String(localized: "simon.a11y.l2.target"), targetRelic.displayName), + a11yHint: String(localized: "simon.a11y.l1.target.hint") + ) + + TimerHUD(timeRemaining: timeRemaining, total: 45) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + String(format: String(localized: "simon.a11y.l2.timer"), Int(ceil(timeRemaining))) + ) + .accessibilityHint(String(localized: "a11y.timer.group.hint")) + + if timedOut { + timeoutBanner + } else { + relicStack + if let statusMessage { statusRow(statusMessage) } + if levelComplete { + continueButton + } else if EnchanterHintPresentation.showsHintButtonInGameplay { + hintButton + } + } + } + } + .contentMargins(.horizontal, horizontalPadding, for: .scrollContent) + .environment(\.colorScheme, .dark) + } + + @ViewBuilder + private func enchanterLevelContent(@ViewBuilder content: () -> Content) -> some View { + let inner = VStack(spacing: RA11ySpacing.lg) { + content() + } + .padding(.vertical, RA11ySpacing.lg) + .frame(minWidth: 0, maxWidth: contentMaxWidth ?? CGFloat.infinity) + .frame(maxWidth: .infinity) + + if sizeClass == .regular { + HStack(spacing: 0) { + Spacer(minLength: 0) + inner + Spacer(minLength: 0) + } + } else { + inner + } + } + + private var relicStack: some View { + VStack(spacing: RA11ySpacing.md) { + ForEach(relics) { relic in + RelicButton(relic: relic, onActivate: onActivate) + } + } + .ra11yLightsOffGameplayBlackout(isEnabled: lightsOffMode) + } + + private var timeoutBanner: some View { + VStack(spacing: RA11ySpacing.md) { + Text(String(localized: "simon.timeout")) + .font(.ra11yHeadline) + .multilineTextAlignment(.center) + Button(action: onRetry) { + Text(String(localized: "level.button.retry")) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .tint(Color.ra11yAccent) + } + .padding(RA11ySpacing.base) + } + + private var hintButton: some View { + Button(action: onHint) { + Label(String(localized: "enchanter.hint.button"), systemImage: "ear.fill") + } + .buttonStyle(.bordered) + .accessibilityLabel(String(localized: "enchanter.hint.a11yLabel")) + .accessibilityHint(String(localized: "enchanter.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("enchanter.l2.continue") + } +} + +// MARK: - L3: EnchanterTimedView + +/// L3 Timed Trial — all 8 relics, 20 s hard timer, `GameSession` for scoring. +/// +/// On timeout, shows the timeout banner with a "Try Again" button. The ViewModel +/// handles session abandonment; the container view navigates to the result screen +/// when `completedResult` is set. +private struct EnchanterTimedView: View { + + let relics: [EnchanterRelic] + let targetRelic: EnchanterRelic + let mistakes: Int + let timeRemaining: Double + let statusMessage: String? + let timedOut: Bool + let lightsOffMode: Bool + let onActivate: (EnchanterRelic) async -> Void + let onHint: () -> Void + let onRetry: () -> Void + + @Environment(\.horizontalSizeClass) private var sizeClass + + private var horizontalPadding: CGFloat { + sizeClass == .regular ? RA11ySpacing.xl : RA11ySpacing.base + } + + private var contentMaxWidth: CGFloat? { + sizeClass == .regular ? 600 : nil + } + + var body: some View { + ScrollView(.vertical) { + enchanterLevelContent { + enchanterLightsOffFlavorBanner + + promptCard( + title: String(format: String(localized: "simon.l3.target.format"), targetRelic.displayName), + a11yLabel: String(format: String(localized: "simon.a11y.l3.target"), targetRelic.displayName), + a11yHint: nil + ) + + TimerHUD(timeRemaining: timeRemaining, total: 20) + .accessibilityElement(children: .ignore) + .accessibilityLabel( + String(format: String(localized: "simon.a11y.l3.timer"), Int(ceil(timeRemaining))) + ) + .accessibilityHint(String(localized: "a11y.timer.group.hint")) + + if timedOut { + timeoutBanner + } else { + relicStack + if let statusMessage { statusRow(statusMessage) } + if EnchanterHintPresentation.showsHintButtonInGameplay { + hintButton + } + } + } + } + .contentMargins(.horizontal, horizontalPadding, for: .scrollContent) + .environment(\.colorScheme, .dark) + } + @ViewBuilder + private func enchanterLevelContent(@ViewBuilder content: () -> Content) -> some View { + let inner = VStack(spacing: RA11ySpacing.lg) { + content() + } + .padding(.vertical, RA11ySpacing.lg) + .frame(minWidth: 0, maxWidth: contentMaxWidth ?? CGFloat.infinity) + .frame(maxWidth: .infinity) + + if sizeClass == .regular { + HStack(spacing: 0) { + Spacer(minLength: 0) + inner + Spacer(minLength: 0) + } + } else { + inner + } + } + + /// Atmospheric copy for the final timed level (torch / darkness). + private var enchanterLightsOffFlavorBanner: some View { + Text(String(localized: "enchanter.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) + } + + private var relicStack: some View { + VStack(spacing: RA11ySpacing.md) { + ForEach(relics) { relic in + RelicButton(relic: relic, onActivate: onActivate) + } + } + .ra11yLightsOffGameplayBlackout(isEnabled: lightsOffMode) + } + + private var timeoutBanner: some View { + VStack(spacing: RA11ySpacing.md) { + Text(String(localized: "simon.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("enchanter.l3.retry") + } + .padding(RA11ySpacing.base) + } + + private var hintButton: some View { + Button(action: onHint) { + Label(String(localized: "enchanter.hint.button"), systemImage: "ear.fill") + } + .buttonStyle(.bordered) + .accessibilityLabel(String(localized: "enchanter.hint.a11yLabel")) + .accessibilityHint(String(localized: "enchanter.hint.a11yHint")) + } +} + +// MARK: - Shared Subviews + +/// Shared prompt card shown at the top of L1–L3. +private func promptCard(title: String, a11yLabel: String, a11yHint: String?) -> some View { + VStack(alignment: .leading, spacing: RA11ySpacing.xs) { + 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 an activation event. +private func statusRow(_ message: String) -> some View { + Text(message) + .font(.ra11ySubheadline) + .foregroundStyle(Color.ra11yCardSecondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, RA11ySpacing.xs) +} + +/// Single relic button used across L1–L3. +/// +/// Displays the relic image (with SF Symbol fallback) and its display name. +/// Configured as a linear-list item so VoiceOver swipe navigation is predictable. +/// +/// ## Adaptive Layout +/// - Standard (`dynamicTypeSize < .accessibility2`): `HStack` — icon | label +/// - Large accessibility sizes: `VStack` — icon on top, label below (centered) +/// +/// ## Dynamic Type +/// Icon frame uses `@ScaledMetric` so it grows proportionally with text size, +/// capped at 96 pt to prevent outsized icons on large display phones. +private struct RelicButton: View { + + let relic: EnchanterRelic + let onActivate: (EnchanterRelic) async -> Void + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + + /// Scales with the `.headline` text style (base 48 pt, max ~96 pt). + @ScaledMetric(relativeTo: .headline) private var iconSize: CGFloat = 48 + + private var isLargeAccessibilitySize: Bool { dynamicTypeSize >= .accessibility2 } + + var body: some View { + Button { + Task { await onActivate(relic) } + } label: { + if isLargeAccessibilitySize { + VStack(spacing: RA11ySpacing.sm) { + relicIcon + relicLabel + } + .padding(RA11ySpacing.md) + .frame(maxWidth: .infinity) + .background(Color.black.opacity(0.4), in: .rect(cornerRadius: RA11yRadius.card)) + .overlay( + RoundedRectangle(cornerRadius: RA11yRadius.card) + .strokeBorder(.white.opacity(0.2), lineWidth: 1) + ) + } else { + HStack(spacing: RA11ySpacing.md) { + relicIcon + relicLabel + } + .padding(RA11ySpacing.md) + .frame(maxWidth: .infinity) + .background(Color.black.opacity(0.4), in: .rect(cornerRadius: RA11yRadius.card)) + .overlay( + RoundedRectangle(cornerRadius: RA11yRadius.card) + .strokeBorder(.white.opacity(0.2), lineWidth: 1) + ) + } + } + .accessibilityLabel(relic.displayName) + .accessibilityHint(String(localized: "simon.token.hint")) + .accessibilityIdentifier("enchanter.relic.\(relic.id)") + } + + private var relicIcon: some View { + RelicImage(assetName: relic.assetName) + .frame(width: min(iconSize, 96), height: min(iconSize, 96)) + .accessibilityHidden(true) + } + + private var relicLabel: some View { + Text(relic.displayName) + .font(.ra11yHeadline) + .frame(maxWidth: .infinity, alignment: isLargeAccessibilitySize ? .center : .leading) + } +} + +/// Timer progress bar shown in L2 and L3. +/// +/// Depletes left-to-right as time passes. Height decreases in the final 50% and 25% +/// to provide a non-color urgency cue, per `GameRules-MVP.txt` timer spec. +/// The timer element is `accessibilityHidden` — the containing L3 view sets a +/// formatted `accessibilityLabel` on the whole HUD. +/// +/// ## Dynamic Type +/// Bar height scales with `.caption` text style via `@ScaledMetric` so it remains +/// visually proportional to the adjacent time label at all DT sizes. +private struct TimerHUD: View { + + let timeRemaining: Double + let total: Double + + 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 `.caption` style (base 12 pt), so the bar remains visible alongside + /// larger text at high DT sizes. + @ScaledMetric(relativeTo: .caption) private var baseBarHeight: CGFloat = 12 + + var body: some View { + VStack(alignment: .leading, spacing: RA11ySpacing.xs) { + 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) + + Text(String(format: String(localized: "hud.timer.format"), Int(ceil(timeRemaining)))) + .font(.ra11yCaption) + .foregroundStyle(Color.ra11yCardTertiaryText) + } + .accessibilityHidden(true) // L3 view sets accessibilityLabel on this entire HUD via .accessibilityElement(children: .ignore) at the call site + } +} + +/// Small gesture guide row used in the L0 prologue. +private struct GestureRow: 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) + } +} + +/// Relic asset image with SF Symbol fallback. +private struct RelicImage: View { + let assetName: String + + var body: some View { + ZStack { + Color.black.opacity(0.35) + + if let image = UIImage(named: assetName) { + Image(uiImage: image) + .resizable() + .scaledToFit() + .padding(4) + } else { + Image(systemName: "sparkles") + .font(.ra11yTitle) + .fontWeight(.semibold) + .foregroundStyle(.secondary) + } + } + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .stroke(.white.opacity(0.12), lineWidth: 1) + ) + } +} + +/// Background for The Enchanter's Trial — dark gradient over the tower shelf art. private struct EnchanterBackgroundView: View { var body: some View { ZStack { - LinearGradient( - colors: [Color.black, Color.black.opacity(0.85), Color.black.opacity(0.7)], - startPoint: .top, - endPoint: .bottom - ) + Color.black.opacity(0.9) if let image = UIImage(named: "enchanter_tower_shelf_bg") { Image(uiImage: image) .resizable() .scaledToFill() - .overlay(Color.black.opacity(0.45)) + .overlay(Color.black.opacity(0.55)) } } - .ignoresSafeArea() } } -#Preview("Enchanter Trial") { +// MARK: - View+Modify Helper + +private extension View { + /// Applies a transform closure, allowing conditional modifier application. + @ViewBuilder + func modify(@ViewBuilder transform: (Self) -> T) -> some View { + transform(self) + } +} + +// MARK: - Previews + +#Preview("L0 Prologue") { NavigationStack { iOSEnchantersTrialView(storage: UserDefaultsStorageComponent()) .environment(iOSAppRouter()) diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSResonanceVoiceOverScrollProxyRepresentable.swift b/RA11y-iOS/RA11y-iOS/Games/iOSResonanceVoiceOverScrollProxyRepresentable.swift new file mode 100644 index 0000000..15cc27a --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Games/iOSResonanceVoiceOverScrollProxyRepresentable.swift @@ -0,0 +1,185 @@ +import os +import SwiftUI +import UIKit +import RA11yCore + +// MARK: - UIScrollView subclass + +/// Hosts the accessibility element VoiceOver should treat 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 + + override func layoutSubviews() { + super.layoutSubviews() + guard !didFireFirstLayout, + bounds.width > 0.5, + bounds.height > 0.5, + window != nil + else { return } + didFireFirstLayout = true + onFirstReadyForAccessibilityLayout?() + } +} + +// MARK: - Coordinator + +/// Bridges `UIScrollView` offset updates to SwiftUI and schedules initial VoiceOver focus on the UIKit 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 { + + var onContentOffsetYChanged: (CGFloat) -> Void = { _ in } + + private weak var scrollView: UIScrollView? + private weak var contentView: UIView? + private var contentHeightConstraint: NSLayoutConstraint? + + private var didScheduleVoiceOverLanding = false + + func attach(scrollView: UIScrollView, contentView: UIView, heightConstraint: NSLayoutConstraint) { + self.scrollView = scrollView + self.contentView = contentView + self.contentHeightConstraint = heightConstraint + } + + func updateContentHeight(totalHeight: CGFloat, scrollView: UIScrollView) { + guard let constraint = contentHeightConstraint else { return } + if abs(constraint.constant - totalHeight) > 0.5 { + constraint.constant = totalHeight + } + scrollView.layoutIfNeeded() + } + + /// 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). + @MainActor + func scheduleVoiceOverLandingIfNeeded() { + guard !didScheduleVoiceOverLanding else { return } + didScheduleVoiceOverLanding = true + + guard UIAccessibility.isVoiceOverRunning else { + RA11yLogger.scrollInteraction.debug("UIKit proxy: skip programmatic VO focus — VoiceOver off") + #if DEBUG + print("[RA11yScroll] UIKit proxy: skip programmatic VO focus — VoiceOver off") + #endif + return + } + + 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") + #if DEBUG + print("[RA11yScroll] UIKit proxy: posted UIAccessibility.Notification.screenChanged") + #endif + try? await Task.sleep(for: .milliseconds(300)) + guard let sv = scrollView else { return } + 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))") + #if DEBUG + print("[RA11yScroll] UIKit proxy: posted VO focus announcement (length=\(focusLine.count))") + #endif + } + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + onContentOffsetYChanged(scrollView.contentOffset.y) + } +} + +// 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. +/// +/// 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. +struct iOSResonanceVoiceOverScrollProxyRepresentable: UIViewRepresentable { + + /// Total height of scrollable content **excluding** vertical padding (matches SwiftUI `minHeight` block). + var contentBlockHeight: CGFloat + + /// Vertical padding applied inside the scroll content (matches `padding(.vertical, RA11ySpacing.xl)`). + var verticalPadding: CGFloat + + var accessibilityLabelText: String + var accessibilityHintText: String + + var onContentOffsetYChanged: (CGFloat) -> Void + + func makeCoordinator() -> iOSResonanceVoiceOverScrollProxyCoordinator { + iOSResonanceVoiceOverScrollProxyCoordinator() + } + + func makeUIView(context: Context) -> UIScrollView { + let scrollView = ResonanceVoiceOverProxyScrollView() + scrollView.backgroundColor = .clear + scrollView.alwaysBounceVertical = true + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.delegate = context.coordinator + scrollView.contentInsetAdjustmentBehavior = .never + + let contentView = UIView() + contentView.backgroundColor = .clear + contentView.isAccessibilityElement = false + + scrollView.addSubview(contentView) + contentView.translatesAutoresizingMaskIntoConstraints = false + + context.coordinator.onContentOffsetYChanged = onContentOffsetYChanged + + let totalHeight = computeTotalContentHeight() + let heightConstraint = contentView.heightAnchor.constraint(equalToConstant: totalHeight) + context.coordinator.attach(scrollView: scrollView, contentView: contentView, heightConstraint: heightConstraint) + + NSLayoutConstraint.activate([ + contentView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.frameLayoutGuide.widthAnchor), + heightConstraint, + ]) + + configureAccessibility(on: scrollView) + + scrollView.onFirstReadyForAccessibilityLayout = { [weak coordinator = context.coordinator] in + coordinator?.scheduleVoiceOverLandingIfNeeded() + } + + return scrollView + } + + func updateUIView(_ scrollView: UIScrollView, context: Context) { + context.coordinator.onContentOffsetYChanged = onContentOffsetYChanged + context.coordinator.updateContentHeight(totalHeight: computeTotalContentHeight(), scrollView: scrollView) + configureAccessibility(on: scrollView) + } + + private func computeTotalContentHeight() -> CGFloat { + contentBlockHeight + verticalPadding * 2 + } + + private func configureAccessibility(on scrollView: UIScrollView) { + scrollView.isAccessibilityElement = true + scrollView.accessibilityIdentifier = "dungeon.resonance.scrollLane" + scrollView.accessibilityLabel = accessibilityLabelText + scrollView.accessibilityHint = accessibilityHintText + } +} diff --git a/RA11y-iOS/RA11y-iOS/Games/iOSRogueGauntletView.swift b/RA11y-iOS/RA11y-iOS/Games/iOSRogueGauntletView.swift new file mode 100644 index 0000000..3dde5c5 --- /dev/null +++ b/RA11y-iOS/RA11y-iOS/Games/iOSRogueGauntletView.swift @@ -0,0 +1,1344 @@ +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/iOSHubBackgroundView.swift b/RA11y-iOS/RA11y-iOS/Hub/iOSHubBackgroundView.swift index 3da1061..5cb4a34 100644 --- a/RA11y-iOS/RA11y-iOS/Hub/iOSHubBackgroundView.swift +++ b/RA11y-iOS/RA11y-iOS/Hub/iOSHubBackgroundView.swift @@ -42,7 +42,7 @@ struct iOSHubBackgroundView: View { // of `.ignoresSafeArea()` below. `.scaledToFill()` fills that proposal. // No explicit frame is needed; the ZStack's `.ignoresSafeArea()` anchors the // size correctly when used inside `.background {}`. - Image(assetName) + Image(assetName) .resizable() .scaledToFill() // Clip prevents the fill-scaled image from bleeding outside @@ -55,25 +55,13 @@ struct iOSHubBackgroundView: View { // lighter toward the bottom so the dark quest card backgrounds dominate. LinearGradient( colors: [ - Color.black.opacity(0.62), - Color.black.opacity(0.20) + Color.black.opacity(0.55), + Color.black.opacity(0.15) ], startPoint: .top, endPoint: .bottom ) .accessibilityHidden(true) - - // Subtle vignette to focus attention on the quest cards. - RadialGradient( - colors: [ - Color.black.opacity(0.05), - Color.black.opacity(0.55) - ], - center: .center, - startRadius: 120, - endRadius: 520 - ) - .accessibilityHidden(true) } .ignoresSafeArea() } @@ -82,6 +70,6 @@ struct iOSHubBackgroundView: View { // MARK: - Preview #Preview { - iOSHubBackgroundView(assetName: "hub_quest_board_bg") + iOSHubBackgroundView(assetName: "simon_room_bg") .frame(maxWidth: .infinity, maxHeight: .infinity) } diff --git a/RA11y-iOS/RA11y-iOS/Hub/iOSHubDMGreetingView.swift b/RA11y-iOS/RA11y-iOS/Hub/iOSHubDMGreetingView.swift index 2a5202e..a97617e 100644 --- a/RA11y-iOS/RA11y-iOS/Hub/iOSHubDMGreetingView.swift +++ b/RA11y-iOS/RA11y-iOS/Hub/iOSHubDMGreetingView.swift @@ -3,7 +3,7 @@ import RA11yCore // MARK: - iOSHubDMGreetingView -/// Atmospheric "Dungeon Master" greeting header displayed below the navigation bar. +/// Atmospheric greeting header (copy uses `hub.dmGreeting`; narrator label is `dm.label` / Resonance Guide). /// /// Non-interactive. Marked with `.isHeader` so VoiceOver announces it as a /// section heading and users can navigate to it directly via the rotor. @@ -21,15 +21,15 @@ struct iOSHubDMGreetingView: View { Text(String(localized: "hub.dmGreeting")) .font(.ra11yTitle) .fontWeight(.semibold) - .foregroundStyle(Color.ra11yGold) + .foregroundStyle(Color.ra11yAccent) .multilineTextAlignment(.center) // Explicit maxWidth ensures the text view fills the parent's proposed // width so centering and wrapping behave consistently across device sizes. .frame(maxWidth: .infinity) .padding(.horizontal, RA11ySpacing.xl) .padding(.vertical, RA11ySpacing.md) - .shadow(color: Color.black.opacity(0.45), radius: 6, x: 0, y: 2) .accessibilityAddTraits(.isHeader) + .accessibilityIdentifier("hub.dmGreeting") } } diff --git a/RA11y-iOS/RA11y-iOS/Hub/iOSHubFooterView.swift b/RA11y-iOS/RA11y-iOS/Hub/iOSHubFooterView.swift index 5ddc93b..9e36ba7 100644 --- a/RA11y-iOS/RA11y-iOS/Hub/iOSHubFooterView.swift +++ b/RA11y-iOS/RA11y-iOS/Hub/iOSHubFooterView.swift @@ -41,12 +41,7 @@ struct iOSHubFooterView: View { } .padding(.horizontal, RA11ySpacing.lg) .padding(.vertical, RA11ySpacing.md) - .background(footerBackground) - .overlay(alignment: .top) { - Rectangle() - .fill(Color.ra11yGoldDeep.opacity(0.35)) - .frame(height: 1) - } + .background(.ultraThinMaterial) } // MARK: - Subviews @@ -54,98 +49,33 @@ struct iOSHubFooterView: View { /// "VoiceOver Basics" — always visible. private var voiceOverBasicsButton: some View { Button(action: onVoiceOverBasics) { - HStack(spacing: RA11ySpacing.sm) { - Image(systemName: "scroll") - Text(String(localized: "hub.voiceOverBasics")) - .fontWeight(.semibold) - } + Label( + String(localized: "hub.voiceOverBasics"), + systemImage: "scroll" + ) .font(.ra11ySubheadline) + .fontWeight(.medium) .frame(maxWidth: .infinity) } - .buttonStyle(BasicsScrollButtonStyle()) + .buttonStyle(.bordered) .controlSize(.regular) + .tint(Color.ra11yAccent) } /// "Enable VoiceOver to play" — present only when VoiceOver is OFF. private var enableVoiceOverButton: some View { Button(action: onEnableVoiceOver) { - HStack(spacing: RA11ySpacing.sm) { - Image(systemName: "speaker.wave.2") - Text(String(localized: "hub.enableVoiceOver")) - .fontWeight(.semibold) - } + Label( + String(localized: "hub.enableVoiceOver"), + systemImage: "speaker.wave.2" + ) .font(.ra11ySubheadline) + .fontWeight(.semibold) .frame(maxWidth: .infinity) } - .buttonStyle(EnableVoiceOverButtonStyle()) + .buttonStyle(.borderedProminent) .controlSize(.regular) - } - - private var footerBackground: some View { - LinearGradient( - colors: [ - Color.ra11yFooterSurface.opacity(0.95), - Color.black.opacity(0.65) - ], - startPoint: .top, - endPoint: .bottom - ) - } -} - -// MARK: - Button Styles - -private struct BasicsScrollButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: RA11yRadius.button) - .fill( - LinearGradient( - colors: [ - Color(red: 0.88, green: 0.82, blue: 0.70), - Color(red: 0.73, green: 0.64, blue: 0.46) - ], - startPoint: .top, - endPoint: .bottom - ) - ) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.button) - .stroke(Color.ra11yGoldDeep.opacity(0.8), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.25), radius: 4, x: 0, y: 2) - ) - .foregroundStyle(Color.black.opacity(0.85)) - .opacity(configuration.isPressed ? 0.9 : 1.0) - } -} - -private struct EnableVoiceOverButtonStyle: ButtonStyle { - func makeBody(configuration: Configuration) -> some View { - configuration.label - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: RA11yRadius.button) - .fill( - LinearGradient( - colors: [ - Color.ra11yGold, - Color.ra11yGoldDeep - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.button) - .stroke(Color.ra11yGoldDeep.opacity(0.9), lineWidth: 1) - ) - .shadow(color: .black.opacity(0.35), radius: 6, x: 0, y: 3) - ) - .foregroundStyle(Color.black.opacity(0.9)) - .opacity(configuration.isPressed ? 0.9 : 1.0) + .tint(Color(red: 0.75, green: 0.55, blue: 0.10)) } } diff --git a/RA11y-iOS/RA11y-iOS/Hub/iOSHubView.swift b/RA11y-iOS/RA11y-iOS/Hub/iOSHubView.swift index 3509acf..eb72389 100644 --- a/RA11y-iOS/RA11y-iOS/Hub/iOSHubView.swift +++ b/RA11y-iOS/RA11y-iOS/Hub/iOSHubView.swift @@ -7,7 +7,8 @@ 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 -/// D&D-themed quest cards, provides VoiceOver gating and help affordance, and +/// 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. /// /// ## Layout (ZStack) @@ -18,12 +19,29 @@ import RA11yCore /// - iPhone (.compact): full-width content minus 16pt horizontal padding /// - iPad (.regular): content max width 600pt, centered /// +/// ## VoiceOver State +/// VoiceOver state is read from `@Environment(\.accessibilityVoiceOverEnabled)`. +/// SwiftUI propagates changes automatically — no custom observer or stream needed. +/// The affordance footer and game-start gating both read this environment value. +/// +/// ## Accessibility +/// - **Focus management**: When VoiceOver is enabled while the hub is visible, focus +/// is programmatically moved to the first quest card after a short settle delay. +/// - **Quest rotor**: A custom "Quests" rotor entry lets VoiceOver users jump directly +/// between quest cards without swiping through the heading and footer. +/// - **Arrival announcement**: A `.screenChanged` notification is posted on first +/// appearance when VoiceOver is on, orienting the user to the screen. +/// /// ## VoiceOver Reading Order -/// 1. Navigation title "RA11y" -/// 2. "Choose Your Trial, Adventurer" (.isHeader) -/// 3–5. Quest cards (combined label per card) -/// 6. "VoiceOver Basics" -/// 7. "Enable VoiceOver to play" (only if VO OFF) +/// 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) +/// 7. "VoiceOver Basics" +/// 8. "Enable VoiceOver to play" (only if VO OFF) +/// +/// ## Scrolling +/// Standard VoiceOver scroll: three-finger swipe up/down scrolls the quest list. /// /// ## Concurrency /// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. @@ -31,25 +49,70 @@ struct iOSHubView: View { // MARK: - State - @Bindable private var viewModel: HubViewModel + @State private var viewModel: HubViewModel @State private var showHelpSheet = false + private let shouldRefreshOnAppear: Bool // MARK: - Environment @Environment(iOSAppRouter.self) private var router @Environment(\.horizontalSizeClass) private var sizeClass + /// VoiceOver state sourced directly from the SwiftUI environment. + /// SwiftUI propagates updates on every toggle — no custom observation needed. + @Environment(\.accessibilityVoiceOverEnabled) private var voiceOverEnabled - // MARK: - Init + // MARK: - Accessibility Focus - /// Creates the hub view with an injected view model. + /// Drives programmatic VoiceOver focus on quest cards. /// - /// - Parameter viewModel: Observable hub view model owned by the caller. - init(viewModel: HubViewModel) { - self._viewModel = Bindable(wrappedValue: viewModel) - } + /// Set to a `GameKind` value to move VoiceOver focus to the corresponding card. + /// Used when VoiceOver is enabled while the hub is on screen, and by the + /// custom "Quests" rotor to let users jump directly between cards. + @AccessibilityFocusState private var focusedCard: GameKind? // MARK: - Computed + /// Short orientation copy at the top of the scroll view: how to scroll the quest list, + /// and (with VoiceOver on) the minimum tap / double-tap pattern to open a trial. + private var hubOrientationBanner: some View { + VStack(alignment: .leading, spacing: RA11ySpacing.xs) { + Text(String(localized: "hub.orientation.scrollTitle")) + .font(.ra11yCaption) + .fontWeight(.semibold) + .foregroundStyle(Color.ra11yCardSecondaryText) + .accessibilityAddTraits(.isHeader) + + Text(String(localized: "hub.orientation.scrollBody")) + .font(.ra11yCaption) + .foregroundStyle(Color.ra11yCardTertiaryText) + + if voiceOverEnabled { + Text(String(localized: "hub.orientation.voActivation")) + .font(.ra11yCaption) + .foregroundStyle(Color.ra11yCardTertiaryText) + } + } + .padding(RA11ySpacing.md) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.black.opacity(0.42), in: .rect(cornerRadius: RA11yRadius.card)) + .overlay( + RoundedRectangle(cornerRadius: RA11yRadius.card) + .strokeBorder(Color.ra11yDMBorder.opacity(0.35), lineWidth: 1) + ) + .accessibilityElement(children: .combine) + .accessibilityLabel(hubOrientationA11yLabel) + } + + /// Single combined string so VoiceOver reads orientation as one structured block. + private var hubOrientationA11yLabel: String { + let scroll = String(localized: "hub.orientation.scrollBody") + if voiceOverEnabled { + let vo = String(localized: "hub.orientation.voActivation") + return "\(scroll) \(vo)" + } + return scroll + } + private var contentMaxWidth: CGFloat { sizeClass == .regular ? 600 : .infinity } @@ -62,22 +125,47 @@ struct iOSHubView: View { sizeClass == .regular ? RA11ySpacing.lg : RA11ySpacing.md } + // MARK: - Init + + /// Creates the hub view with an injected storage component and refresh policy. + /// + /// - Parameters: + /// - storage: Persistence layer used to load and refresh best-result summaries. + /// - shouldRefreshOnAppear: Whether the hub should immediately read stored results + /// when it appears. `iOSRootView` disables this while initial route resolution is + /// still pending so startup work does not compete with first-run gating. + init( + storage: any StorageComponent = UserDefaultsStorageComponent(), + shouldRefreshOnAppear: Bool = true + ) { + self.shouldRefreshOnAppear = shouldRefreshOnAppear + _viewModel = State(initialValue: HubViewModel(storage: storage)) + } + // MARK: - Body var body: some View { contentLayer - // `.background {}` sizes the background to the content frame, then the - // background extends into safe area regions on its own. This prevents the - // background image's intrinsic size (1920pt wide for landscape assets) - // from widening the ZStack and overflowing the content layout. .background { - iOSHubBackgroundView(assetName: "hub_quest_board_bg") + iOSHubBackgroundView(assetName: "simon_room_bg") + } + .navigationTitle("") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .principal) { + Text(String(localized: "hub.navigationTitle")) + .font(.headline) + .accessibilityLabel(String(localized: "hub.navigationTitle.voiceOver")) + .accessibilityIdentifier("hub.navigationTitle") + } + } + .sheet(isPresented: $showHelpSheet) { + iOSVoiceOverHelpSheet() + } + .task(id: shouldRefreshOnAppear) { + guard shouldRefreshOnAppear else { return } + await viewModel.refreshBestResults() } - .navigationTitle(String(localized: "hub.navigationTitle")) - .navigationBarTitleDisplayMode(.inline) - .sheet(isPresented: $showHelpSheet) { - iOSVoiceOverHelpSheet() - } } // MARK: - Content Layer @@ -85,21 +173,62 @@ struct iOSHubView: View { private var contentLayer: some View { ScrollView(.vertical) { VStack(spacing: 0) { + hubOrientationBanner + .padding(.horizontal, cardHorizontalPadding) + .padding(.top, RA11ySpacing.sm) + iOSHubDMGreetingView() .padding(.top, RA11ySpacing.sm) questCardList } .frame(maxWidth: contentMaxWidth) - .frame(maxWidth: .infinity) + .frame(maxWidth: .infinity, alignment: .center) } + .frame(maxWidth: .infinity, maxHeight: .infinity) .safeAreaInset(edge: .bottom) { iOSHubFooterView( - showHelpAffordance: viewModel.showHelpAffordance, + showHelpAffordance: !voiceOverEnabled, onVoiceOverBasics: navigateToBasics, onEnableVoiceOver: { showHelpSheet = true } ) } + // The hub always renders on a fixed dark background (hub scene image + + // dark gradient overlay). Force dark color scheme here so ALL semantic + // adaptive colors in the hub's content tree — `.primary`, `.secondary`, + // nav bar title, footer material, DM greeting — resolve to white-based values + // in both light mode and dark mode. + .environment(\.colorScheme, .dark) + // MARK: VoiceOver Accessibility + // Custom "Quests" rotor — lets VoiceOver users jump directly between quest + // cards by rotating through the rotor to "Quests" and swiping up/down. + // Each entry's `prepare` closure sets `focusedCard`, which is bound to + // the card via `.accessibilityFocused` in `questCardList`. + .accessibilityRotor(String(localized: "hub.a11y.rotor.quests")) { + ForEach(GameCatalog.all) { game in + AccessibilityRotorEntry( + LocalizedStringKey(game.titleKey), + id: game.id, + prepare: { focusedCard = game.kind } + ) + } + } + // When VoiceOver is enabled while the hub is visible, move focus to the + // first quest card after a brief settle delay. This prevents users from + // being stranded at the top of the UI after toggling VO on. + .onChange(of: voiceOverEnabled) { _, isEnabled in + guard isEnabled else { return } + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(600)) + focusedCard = GameCatalog.all.first?.kind + } + } + // Post a screenChanged notification when the hub first appears with VO on. + // Passing `nil` lets VoiceOver focus the first element naturally (nav title). + .onAppear { + guard voiceOverEnabled else { return } + UIAccessibility.post(notification: .screenChanged, argument: nil) + } } // MARK: - Quest Cards @@ -110,38 +239,32 @@ struct iOSHubView: View { iOSQuestCardView( game: game, rank: viewModel.bestRank(for: game.id), + prerequisiteTitle: prerequisiteTitle(for: game), onTap: { startGame(game) } ) .padding(.horizontal, cardHorizontalPadding) + // Binds this card to the shared AccessibilityFocusState. + // Setting `focusedCard = game.kind` from the rotor or the + // VO-enable handler moves VoiceOver focus here. + .accessibilityFocused($focusedCard, equals: game.kind) } } .padding(.top, RA11ySpacing.md) .padding(.bottom, RA11ySpacing.xl) } + /// The display title of the prerequisite game for a locked card, or `nil` when unlocked. + private func prerequisiteTitle(for game: GameDefinition) -> String? { + guard let prereq = viewModel.prerequisite(for: game) else { return nil } + return String(localized: String.LocalizationValue(prereq.titleKey)) + } + // MARK: - Actions - /// Initiates the VoiceOver gating check before starting a game. - /// - /// If VoiceOver is running: route directly to the game (M5+). - /// If VoiceOver is off: push the interstitial so the user can enable it. + /// Starts a game when the card is unlocked. VoiceOver is required — see ``iOSAppRouter/pushGame(kind:provider:)``. private func startGame(_ game: GameDefinition) { - if ProcessInfo.processInfo.arguments.contains("-uiTesting") { - router.push(.game(kind: game.kind)) - return - } - if viewModel.showHelpAffordance { - // VoiceOver is OFF — route to interstitial - router.push(.voiceOverInterstitial(kind: game.kind)) - } else { - // VoiceOver is ON — proceed to game (M5+: push game route) - switch game.kind { - case .findAndFocus: - router.push(.game(kind: game.kind)) - case .activateDoubleTap, .scrollHunt: - RA11yLogger.navigation.debug("Game start gating passed for \(game.id) (not yet implemented)") - } - } + guard viewModel.isUnlocked(game) else { return } + router.pushGame(kind: game.kind) } private func navigateToBasics() { @@ -153,12 +276,7 @@ struct iOSHubView: View { #Preview("VO OFF — help affordance visible") { NavigationStack { - iOSHubView( - viewModel: HubViewModel( - voiceOverProvider: StubVoiceOverStateProvider(isVoiceOverRunning: false), - storage: UserDefaultsStorageComponent() - ) - ) + iOSHubView() .environment(iOSAppRouter()) } } diff --git a/RA11y-iOS/RA11y-iOS/Hub/iOSQuestCardInfoView.swift b/RA11y-iOS/RA11y-iOS/Hub/iOSQuestCardInfoView.swift index d61bc35..21bb3b2 100644 --- a/RA11y-iOS/RA11y-iOS/Hub/iOSQuestCardInfoView.swift +++ b/RA11y-iOS/RA11y-iOS/Hub/iOSQuestCardInfoView.swift @@ -3,10 +3,11 @@ import RA11yCore // MARK: - iOSQuestCardInfoView -/// Middle text block of a quest card: game title, one-line goal, and estimated duration. +/// Middle text block of a quest card: game title, one-line goal, estimated duration, +/// and an inline rank chip. /// /// Uses `.layoutPriority(1)` when embedded in the parent `HStack` so this block -/// receives available space before the fixed-size thumbnail and badge compete for it. +/// receives available space before the fixed-size thumbnail competes for it. /// Text wraps naturally — card height grows to accommodate; no truncation. /// /// Marked `.accessibilityHidden(true)` because the parent `iOSQuestCardView` Button @@ -28,6 +29,12 @@ struct iOSQuestCardInfoView: View { /// Human-readable estimated duration (e.g., "~5 min"). let estimatedDuration: String + /// Rank display label ("Quest Awaits" or rank name). Shown as a compact inline chip. + let rankLabel: String + + /// Color for the rank chip dot and text. + let rankColor: Color + // MARK: - Body var body: some View { @@ -35,28 +42,66 @@ struct iOSQuestCardInfoView: View { Text(title) .font(.ra11yHeadline) .fontWeight(.bold) - .foregroundStyle(Color.ra11yWarmText) + .foregroundStyle(.primary) Text(goal) .font(.ra11ySubheadline) - .foregroundStyle(Color.ra11yWarmTextSecondary) + .foregroundStyle(Color.ra11yCardSecondaryText) .fixedSize(horizontal: false, vertical: true) - Text(estimatedDuration) - .font(.ra11yCaption) - .foregroundStyle(Color.ra11yWarmTextSecondary.opacity(0.9)) + HStack(alignment: .center, spacing: RA11ySpacing.sm) { + Text(estimatedDuration) + .font(.ra11yCaption) + .foregroundStyle(Color.ra11yCardTertiaryText) + + rankChip + } + .padding(.top, 2) } .accessibilityHidden(true) } + + // MARK: - Rank Chip + + /// Compact inline status indicator: a small colored dot + rank label text. + /// + /// Replaces the large `iOSRankBadgeView` column so the title and goal text have + /// the full card width without wrapping. + private var rankChip: some View { + HStack(spacing: 4) { + Circle() + .fill(rankColor) + .frame(width: 6, height: 6) + + Text(rankLabel) + .font(.ra11yCaption) + .fontWeight(.semibold) + .foregroundStyle(rankColor) + } + } } // MARK: - Preview -#Preview { +#Preview("Unplayed") { + iOSQuestCardInfoView( + title: "The Enchanter's Trial", + goal: "Navigate focus and invoke the named relic.", + estimatedDuration: "~5 min", + rankLabel: "Quest Awaits", + rankColor: Color(white: 0.55) + ) + .padding() + .background(Color(white: 0.15)) +} + +#Preview("Legendary") { iOSQuestCardInfoView( title: "The Enchanter's Trial", goal: "Navigate focus and invoke the named relic.", - estimatedDuration: "~5 min" + estimatedDuration: "~5 min", + rankLabel: "Legendary", + rankColor: Color(red: 1.0, green: 0.84, blue: 0.0) ) .padding() .background(Color(white: 0.15)) diff --git a/RA11y-iOS/RA11y-iOS/Hub/iOSQuestCardView.swift b/RA11y-iOS/RA11y-iOS/Hub/iOSQuestCardView.swift index f5747d5..2d7bb8a 100644 --- a/RA11y-iOS/RA11y-iOS/Hub/iOSQuestCardView.swift +++ b/RA11y-iOS/RA11y-iOS/Hub/iOSQuestCardView.swift @@ -6,20 +6,31 @@ import RA11yCore /// A tappable quest card representing one training game on the hub screen. /// /// The entire card is a single `Button` — one VoiceOver focus element. Sub-views -/// (`iOSQuestThumbnailView`, `iOSQuestCardInfoView`, `iOSRankBadgeView`) are all -/// marked `.accessibilityHidden(true)` and are covered by the button's combined label. +/// (`iOSQuestThumbnailView`, `iOSQuestCardInfoView`) are all marked +/// `.accessibilityHidden(true)` and are covered by the button's combined label. /// /// ## Combined VoiceOver Label -/// Format: "{title}. {goal}. {estimatedDuration}. Rank: {rankLabel}." -/// Hint: "Double-tap to begin this trial." +/// - Unlocked: "{title}. {goal}. {estimatedDuration}. Rank: {rankLabel}." +/// - Locked: "{title}. {lockedMessage}." +/// +/// ## Locked State +/// When `prerequisiteTitle` is non-nil the card is rendered in a dimmed, non-interactive +/// locked state with a lock icon overlay. The button is disabled so VoiceOver does not +/// offer an activation hint. A "Complete [prerequisiteTitle] to unlock" message replaces +/// the goal/duration/rank row in the combined label. +/// +/// ## Layout +/// Thumbnail on the left; info column (title, goal, duration + compact rank chip) fills +/// the remaining width. The large `iOSRankBadgeView` column has been removed from the +/// HStack — its information lives in the inline rank chip inside `iOSQuestCardInfoView`. /// /// ## Adaptive Layout -/// - Standard (`dynamicTypeSize < .accessibility2`): `HStack` — thumbnail | info | badge -/// - Large accessibility sizes: `VStack` — thumbnail + badge row on top, info below +/// - Standard (`dynamicTypeSize < .accessibility2`): `HStack` — thumbnail | info +/// - Large accessibility sizes: `VStack` — thumbnail on top, info below /// /// ## Visual Design -/// Dark warm card surface with an amber/gold border. Slight elevation shadow. -/// Unplayed cards use a slightly desaturated thumbnail (via `.saturation`). +/// Dark warm card surface (15% transparent so the hub background shows through) +/// with an amber/gold border. Slight elevation shadow. /// /// ## Concurrency /// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. @@ -29,6 +40,8 @@ struct iOSQuestCardView: View { let game: GameDefinition let rank: GameRank? + /// Display title of the prerequisite game, or `nil` when unlocked. + let prerequisiteTitle: String? let onTap: () -> Void // MARK: - Environment @@ -38,15 +51,17 @@ struct iOSQuestCardView: View { // MARK: - Computed + private var isLocked: Bool { prerequisiteTitle != nil } + private var isLargeAccessibilitySize: Bool { dynamicTypeSize >= .accessibility2 } private var cardPadding: CGFloat { - sizeClass == .regular ? RA11ySpacing.lg : RA11ySpacing.base + sizeClass == .regular ? RA11ySpacing.lg : RA11ySpacing.md } - private var cardSpacing: CGFloat { 16 } + private var cardSpacing: CGFloat { 12 } private var rankLabel: String { if let rank { @@ -55,26 +70,50 @@ struct iOSQuestCardView: View { return String(localized: "hub.questAwaits") } + /// Thematic color for the inline rank chip. Mirrors the seal colors in `iOSRankBadgeView`. + private var rankChipColor: Color { + guard let rank else { return Color.ra11yCardTertiaryText } + switch rank { + case .perfect: return Color(red: 1.0, green: 0.84, blue: 0.0) + case .good: return Color(red: 0.53, green: 0.81, blue: 0.98) + case .ok: return Color(red: 0.75, green: 0.65, blue: 0.50) + case .failed: return Color(red: 0.55, green: 0.55, blue: 0.55) + } + } + private var combinedAccessibilityLabel: String { - let title = String(localized: String.LocalizationValue(game.titleKey)) + let title = String(localized: String.LocalizationValue(game.titleKey)) + if let prerequisiteTitle { + let lockMsg = String( + format: String(localized: "hub.card.locked.a11yLabel"), + prerequisiteTitle + ) + return "\(title). \(lockMsg)." + } let goal = String(localized: String.LocalizationValue(game.goalKey)) let duration = game.estimatedDuration return "\(title). \(goal). \(duration). Rank: \(rankLabel)." } private var thumbnailSaturation: Double { - rank == nil ? 0.4 : 1.0 + isLocked ? 0.0 : (rank == nil ? 0.4 : 1.0) } // MARK: - Body var body: some View { Button(action: onTap) { - cardContent + ZStack(alignment: .topTrailing) { + cardContent + if isLocked { + lockOverlay + } + } } .buttonStyle(QuestCardButtonStyle()) + .disabled(isLocked) .accessibilityLabel(combinedAccessibilityLabel) - .accessibilityHint(String(localized: "hub.card.accessibilityHint")) + .accessibilityHint(isLocked ? "" : String(localized: "hub.card.accessibilityHint")) .accessibilityAddTraits(.isButton) .accessibilityIdentifier("questCard.\(game.id)") } @@ -91,43 +130,85 @@ struct iOSQuestCardView: View { } /// Standard HStack layout for default through `.accessibility1` type sizes. + /// + /// Thumbnail on the left; info column (with inline rank chip) fills remaining space. + /// The separate rank badge column has been removed — the chip in the info view + /// gives the title and goal text the full available width. private var standardLayout: some View { HStack(alignment: .top, spacing: cardSpacing) { iOSQuestThumbnailView(assetName: game.thumbnailAssetName) .saturation(thumbnailSaturation) - iOSQuestCardInfoView( - title: String(localized: String.LocalizationValue(game.titleKey)), - goal: String(localized: String.LocalizationValue(game.goalKey)), - estimatedDuration: game.estimatedDuration - ) - .layoutPriority(1) - - iOSRankBadgeView(rank: rank, isAccessibilityHidden: true) + if isLocked { + lockedInfoColumn + } else { + iOSQuestCardInfoView( + title: String(localized: String.LocalizationValue(game.titleKey)), + goal: String(localized: String.LocalizationValue(game.goalKey)), + estimatedDuration: game.estimatedDuration, + rankLabel: rankLabel, + rankColor: rankChipColor + ) + .layoutPriority(1) + } } - // Explicit maxWidth makes the card background stretch to the full available - // width rather than hugging its content in context-dependent sizing. .frame(maxWidth: .infinity) .padding(cardPadding) + .opacity(isLocked ? 0.55 : 1.0) } /// VStack layout for `.accessibility2` and above — prevents horizontal crowding. private var largeTypeLayout: some View { VStack(alignment: .leading, spacing: RA11ySpacing.sm) { - HStack { - iOSQuestThumbnailView(assetName: game.thumbnailAssetName) - .saturation(thumbnailSaturation) - Spacer() - iOSRankBadgeView(rank: rank, isAccessibilityHidden: true) + iOSQuestThumbnailView(assetName: game.thumbnailAssetName) + .saturation(thumbnailSaturation) + + if isLocked { + lockedInfoColumn + } else { + iOSQuestCardInfoView( + title: String(localized: String.LocalizationValue(game.titleKey)), + goal: String(localized: String.LocalizationValue(game.goalKey)), + estimatedDuration: game.estimatedDuration, + rankLabel: rankLabel, + rankColor: rankChipColor + ) } - iOSQuestCardInfoView( - title: String(localized: String.LocalizationValue(game.titleKey)), - goal: String(localized: String.LocalizationValue(game.goalKey)), - estimatedDuration: game.estimatedDuration - ) } .frame(maxWidth: .infinity) .padding(cardPadding) + .opacity(isLocked ? 0.55 : 1.0) + } + + /// Info column shown when the game is locked — title + unlock message. + private var lockedInfoColumn: some View { + VStack(alignment: .leading, spacing: RA11ySpacing.xs) { + Text(String(localized: String.LocalizationValue(game.titleKey))) + .font(.ra11yHeadline) + .foregroundStyle(Color.ra11yCardSecondaryText) + if let prerequisiteTitle { + Text( + String( + format: String(localized: "hub.card.locked.message"), + prerequisiteTitle + ) + ) + .font(.ra11ySubheadline) + .foregroundStyle(Color.ra11yCardTertiaryText) + .fixedSize(horizontal: false, vertical: true) + } + } + .layoutPriority(1) + .frame(maxWidth: .infinity, alignment: .leading) + } + + /// Lock icon shown in the top-trailing corner for locked cards. + private var lockOverlay: some View { + Image(systemName: "lock.fill") + .font(.ra11ySubheadline) + .foregroundStyle(Color.ra11yAccent) + .padding(RA11ySpacing.sm) + .accessibilityHidden(true) } } @@ -135,14 +216,23 @@ struct iOSQuestCardView: View { /// Custom button style that renders the quest card's visual chrome. /// -/// Handling press state via `configuration.isPressed` here rather than in the -/// card body keeps the visual treatment decoupled from the content layout. +/// The card fill uses 85% opacity (15% transparent) so the hub background +/// shows through subtly, grounding the cards in the scene. +/// +/// ## Color Scheme Forcing +/// The card surface uses a fixed dark background regardless of the system's +/// light/dark mode setting. `.environment(\.colorScheme, .dark)` is applied to +/// the entire card so ALL semantic adaptive colors — `.primary`, `.secondary`, +/// and any future additions — resolve to their dark-mode values (white, dimmed +/// white, etc.) without requiring explicit color overrides on every `Text` view. +/// +/// This also respects "Increase Contrast": in dark mode + high contrast, system +/// primary = bright white, giving an even higher contrast ratio on the dark surface. /// /// ## Reduce Motion /// When `accessibilityReduceMotion` is `true`, scale and opacity transitions are /// suppressed. Only the `.opacity` feedback remains so the press is still -/// perceptible without motion. This satisfies the M3 and M8 Reduce Motion -/// acceptance criteria. +/// perceptible without motion. private struct QuestCardButtonStyle: ButtonStyle { @Environment(\.accessibilityReduceMotion) private var reduceMotion @@ -150,35 +240,19 @@ private struct QuestCardButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label .background( - RoundedRectangle(cornerRadius: RA11yRadius.card) - .fill( - LinearGradient( - colors: [ - Color.ra11yCardSurfaceHighlight, - Color.ra11yCardSurface - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) + RoundedRectangle(cornerRadius: 12) + .fill(Color(red: 0.13, green: 0.10, blue: 0.09, opacity: 0.85)) .overlay( - RoundedRectangle(cornerRadius: RA11yRadius.card) + RoundedRectangle(cornerRadius: 12) .strokeBorder( - LinearGradient( - colors: [ - Color.ra11yCardBorder.opacity(0.9), - Color.ra11yGoldDeep.opacity(0.55) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - lineWidth: 1.6 + Color(red: 0.75, green: 0.55, blue: 0.10).opacity(0.6), + lineWidth: 1.5 ) ) - .shadow(color: .black.opacity(0.45), radius: 10, x: 0, y: 6) + .shadow(color: .black.opacity(0.4), radius: 6, x: 0, y: 3) ) + .environment(\.colorScheme, .dark) .opacity(configuration.isPressed ? 0.85 : 1.0) - // Scale feedback is motion-based; suppress it when Reduce Motion is ON. .scaleEffect((!reduceMotion && configuration.isPressed) ? 0.98 : 1.0) .animation( reduceMotion ? nil : .easeInOut(duration: 0.12), @@ -193,6 +267,7 @@ private struct QuestCardButtonStyle: ButtonStyle { iOSQuestCardView( game: GameCatalog.all[0], rank: .perfect, + prerequisiteTitle: nil, onTap: {} ) .padding() @@ -203,6 +278,18 @@ private struct QuestCardButtonStyle: ButtonStyle { iOSQuestCardView( game: GameCatalog.all[1], rank: nil, + prerequisiteTitle: nil, + onTap: {} + ) + .padding() + .background(Color(white: 0.08)) +} + +#Preview("Locked — prerequisite not completed") { + iOSQuestCardView( + game: GameCatalog.all[1], + rank: nil, + prerequisiteTitle: "The Enchanter's Trial", onTap: {} ) .padding() diff --git a/RA11y-iOS/RA11y-iOS/Hub/iOSQuestThumbnailView.swift b/RA11y-iOS/RA11y-iOS/Hub/iOSQuestThumbnailView.swift index 7409709..63ba7dd 100644 --- a/RA11y-iOS/RA11y-iOS/Hub/iOSQuestThumbnailView.swift +++ b/RA11y-iOS/RA11y-iOS/Hub/iOSQuestThumbnailView.swift @@ -12,6 +12,9 @@ import SwiftUI /// in Dynamic Type scaling, but is capped to prevent it from dominating the /// card layout at large accessibility sizes. /// +/// Hub icon PNGs ship with a proper alpha channel (background removed in asset prep), +/// so the image can composite directly on the card without blend-mode hacks. +/// /// Marked `.accessibilityHidden(true)` because the thumbnail is purely /// decorative within the quest card — the card's Button provides the full /// accessibility label including the game's name and goal. @@ -59,15 +62,11 @@ struct iOSQuestThumbnailView: View { private var placeholder: some View { ZStack { RoundedRectangle(cornerRadius: 8) - .fill(Color.ra11yCardSurfaceHighlight) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.ra11yCardBorder.opacity(0.6), lineWidth: 1) - ) + .fill(Color(white: 0.25)) Image(systemName: "scroll") .font(.system(size: thumbnailSize * 0.4)) - .foregroundStyle(Color.ra11yGold.opacity(0.7)) + .foregroundStyle(Color.ra11yAccent.opacity(0.6)) } } } @@ -75,7 +74,7 @@ struct iOSQuestThumbnailView: View { // MARK: - Previews #Preview("With asset") { - iOSQuestThumbnailView(assetName: "hub_quest_board_bg") + iOSQuestThumbnailView(assetName: "simon_room_bg") .padding() .background(Color(white: 0.15)) } diff --git a/RA11y-iOS/RA11y-iOS/Hub/iOSRankBadgeView.swift b/RA11y-iOS/RA11y-iOS/Hub/iOSRankBadgeView.swift index 5d493e5..b492715 100644 --- a/RA11y-iOS/RA11y-iOS/Hub/iOSRankBadgeView.swift +++ b/RA11y-iOS/RA11y-iOS/Hub/iOSRankBadgeView.swift @@ -23,6 +23,10 @@ import RA11yCore /// - `.failed` → broken shield seal (Defeated) /// - `nil` → open scroll seal (Quest Awaits) /// +/// ## Dynamic Type +/// Both the canvas size and the outer container width scale via `@ScaledMetric` +/// (relative to `.caption`) so the badge remains proportional to its label at all DT sizes. +/// /// ## Concurrency /// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. struct iOSRankBadgeView: View { @@ -36,9 +40,19 @@ struct iOSRankBadgeView: View { /// Set `true` when used inside a card button that provides a combined label. var isAccessibilityHidden: Bool = true + // MARK: - Scaled Metrics + + /// Canvas size scales with `.caption` style (base 48 pt, max ~80 pt). + @ScaledMetric(relativeTo: .caption) private var shapeSize: CGFloat = 48 + + /// Outer container width scales proportionally (base 60 pt, max ~100 pt). + @ScaledMetric(relativeTo: .caption) private var containerWidth: CGFloat = 60 + + private var clampedShapeSize: CGFloat { min(shapeSize, 80) } + private var clampedContainerWidth: CGFloat { min(containerWidth, 100) } + // MARK: - Constants - private let shapeSize: CGFloat = 56 private let labelFont: Font = .ra11yCaption // MARK: - Body @@ -48,17 +62,16 @@ struct iOSRankBadgeView: View { Canvas { context, size in drawSeal(in: &context, size: size) } - .frame(width: shapeSize, height: shapeSize) + .frame(width: clampedShapeSize, height: clampedShapeSize) Text(rankLabel) .font(labelFont) .fontWeight(.semibold) .foregroundStyle(labelColor) .multilineTextAlignment(.center) - .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } - .frame(width: 72) + .frame(width: clampedContainerWidth) .accessibilityHidden(isAccessibilityHidden) } @@ -69,7 +82,7 @@ struct iOSRankBadgeView: View { } private var labelColor: Color { - rank.map { sealColor(for: $0) } ?? Color.ra11yWarmTextSecondary + rank.map { sealColor(for: $0) } ?? Color.ra11yCardTertiaryText } // MARK: - Canvas Drawing @@ -77,7 +90,7 @@ struct iOSRankBadgeView: View { /// Draws the appropriate seal shape for the given rank (or Quest Awaits scroll). private func drawSeal(in context: inout GraphicsContext, size: CGSize) { let center = CGPoint(x: size.width / 2, y: size.height / 2) - let color = rank.map { sealColor(for: $0) } ?? Color.ra11yWarmTextSecondary + let color = rank.map { sealColor(for: $0) } ?? Color.secondary switch rank { case .perfect: @@ -89,7 +102,7 @@ struct iOSRankBadgeView: View { case .failed: drawBrokenShieldSeal(in: &context, center: center, size: size, color: color) case nil: - drawScrollSeal(in: &context, center: center, size: size, color: color) + drawScrollSeal(in: &context, center: center, size: size) } } @@ -203,8 +216,7 @@ struct iOSRankBadgeView: View { private func drawScrollSeal( in context: inout GraphicsContext, center: CGPoint, - size: CGSize, - color: Color + size: CGSize ) { let w = size.width * 0.65 let h = size.height * 0.55 @@ -214,8 +226,12 @@ struct iOSRankBadgeView: View { width: w, height: h ) + // Use ra11yCardTertiaryText (white @ 0.65 opacity) — adequate contrast on + // the fixed-dark card surface. .secondary would be too dim on dark backgrounds. + let scrollColor = Color.ra11yCardTertiaryText + let scroll = Path(roundedRect: scrollRect, cornerRadius: 4) - context.stroke(scroll, with: .color(color), lineWidth: 1.5) + context.stroke(scroll, with: .color(scrollColor), lineWidth: 1.5) // Curled top and bottom edges let curlH: CGFloat = 6 @@ -233,12 +249,12 @@ struct iOSRankBadgeView: View { ) context.stroke( Path(ellipseIn: topCurlRect), - with: .color(color), + with: .color(scrollColor), lineWidth: 1 ) context.stroke( Path(ellipseIn: botCurlRect), - with: .color(color), + with: .color(scrollColor), lineWidth: 1 ) } diff --git a/RA11y-iOS/RA11y-iOS/Localizable.xcstrings b/RA11y-iOS/RA11y-iOS/Localizable.xcstrings index 70b8013..977b48e 100644 --- a/RA11y-iOS/RA11y-iOS/Localizable.xcstrings +++ b/RA11y-iOS/RA11y-iOS/Localizable.xcstrings @@ -1,370 +1,2761 @@ { "sourceLanguage" : "en", "strings" : { + "" : { + + }, + "%@. %@" : { + "comment" : "A label displaying the title and subtitle of a dungeon room. The argument is optional.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@. %2$@" + } + } + } + }, + "%@. %@ %@" : { + "comment" : "A combined label for the skill transfer card, The first argument is the string “result.skillTransfer.heading”. The second argument is the string “result.skillTransfer.findAndFocus.body”, the string “result.skillTransfer.activateDoubleTap.body”, or the string “result.skillTransfer.scrollHunt.body”. The third argument is the string “result.skillTransfer.findAndFocus.realWorld”, the string “result.skillTransfer.activateDoubleTap.realWorld”, or the string “result.skillTransfer.scrollHunt.realWorld”.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@. %2$@ %3$@" + } + } + } + }, + "%lld pt" : { + "comment" : "A label displaying the distance of the moonstone target from the screen aim line, in points.", + "isCommentAutoGenerated" : true + }, + "a11y.level.completed" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Level complete." + } + } + } + }, + "a11y.level.mistake" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Misstep. Try again." + } + } + } + }, + "a11y.timer.1s" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "One." + } + } + } + }, + "a11y.timer.2s" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Two." + } + } + } + }, + "a11y.timer.3s" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Three." + } + } + } + }, + "a11y.timer.4s" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Four." + } + } + } + }, + "a11y.timer.5s" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Five." + } + } + } + }, + "a11y.timer.10s" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ten seconds." + } + } + } + }, + "a11y.timer.25pct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "A quarter of your time remains." + } + } + } + }, + "a11y.timer.50pct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Half your time remains." + } + } + } + }, + "a11y.timer.75pct" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Three-quarters of your time is gone." + } + } + } + }, + "a11y.timer.group.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Complete your objective before time runs out." + } + } + } + }, "app.loading" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Loading..." + "value" : "Preparing…" + } + } + } + }, + "app.loading.a11yLabel" : { + "comment" : "VoiceOver label for the loading overlay shown while the app resolves its initial route. Speaks “Rally Quest” so the A11y branding is not spelled out.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rally Quest is loading while the guild hall prepares your trials. Please wait." + } + } + } + }, + "app.loading.flavor" : { + "comment" : "Secondary line on the themed cold-start overlay; torchlit dungeon tone.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Echoes gather along the crystal shaft." + } + } + } + }, + "app.loading.still.a11yAnnouncement" : { + "comment" : "VoiceOver announcement if initial route resolution takes longer than two seconds.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The hall is still preparing your path. Please wait." + } + } + } + }, + "app.loading.tagline" : { + "comment" : "Headline on the themed cold-start loading overlay.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The torchlit hall stirs." + } + } + } + }, + "Band" : { + "comment" : "A label for the segmented control in the dungeon resonance mockup view.", + "isCommentAutoGenerated" : true + }, + "basicsSequence.beginGame" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Begin %@" + } + } + } + }, + "basicsSequence.beginGame.a11yHint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starts the game for this skill." + } + } + } + }, + "basicsSequence.beginGame.a11yLabel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Begin %@" + } + } + } + }, + "basicsSequence.continue" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue" + } + } + } + }, + "basicsSequence.continue.a11yHint" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moves to the next Basics step." + } + } + } + }, + "basicsSequence.continue.a11yLabel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue Basics" + } + } + } + }, + "basicsSequence.continueBasics" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue Basics" + } + } + } + }, + "basicsSequence.continueBasics.a11yHint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Returns to the Basics sequence." + } + } + } + }, + "basicsSequence.finish" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finish" + } + } + } + }, + "basicsSequence.finish.a11yHint" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completes Basics and returns to the hub." + } + } + } + }, + "basicsSequence.finish.a11yLabel" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Finish Basics" + } + } + } + }, + "basicsSequence.skill.activateDoubleTap.intro" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap anywhere on screen to activate the element VoiceOver is focused on." + } + } + } + }, + "basicsSequence.skill.activateDoubleTap.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Activate Elements" + } + } + } + }, + "basicsSequence.skill.findAndFocus.intro" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swipe right with one finger to move VoiceOver focus between elements on screen." + } + } + } + }, + "basicsSequence.skill.findAndFocus.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navigate & Focus" + } + } + } + }, + "basicsSequence.skill.scrollHunt.intro" : { + "localizations" : { + "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." + } + } + } + }, + "basicsSequence.skill.scrollHunt.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crystal Resonance" + } + } + } + }, + "basicsSequence.skillBadgeFormat" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skill %d of %d" + } + } + } + }, + "basicsSequence.stepFormat" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Step %d of %d" + } + } + } + }, + "basicsSequence.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VoiceOver Basics" + } + } + } + }, + "Calm feedback mode" : { + "comment" : "A toggle that enables or disables a \"calm\" feedback mode.", + "isCommentAutoGenerated" : true + }, + "Crystal Resonance (prototype)" : { + "comment" : "The title of the Crystal Resonance v2 resonance prototype view.", + "isCommentAutoGenerated" : true + }, + "Distance to aim line, %lld points" : { + "comment" : "A value indicating how far the orb is from aligning with the center orb.", + "isCommentAutoGenerated" : true + }, + "dm.label" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resonance Guide" + } + } + } + }, + "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." + } + } + } + }, + "dungeon.a11y.explain.practice.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Use three fingers to scroll and practice before your trial begins." + } + } + } + }, + "dungeon.a11y.hud.depth" : { + "comment" : "Accessibility label for the depth HUD. Arguments are current visible depth and total rooms.", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Depth: %d of %d." + } + } + } + }, + "dungeon.a11y.hud.l2" : { + "comment" : "Accessibility label for the L2 combined timer+mistake HUD. Argument is mistake count.", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d misstep(s). Time remaining." + } + } + } + }, + "dungeon.a11y.hud.l3" : { + "comment" : "Accessibility label for the L3 combined timer+mistake HUD. Argument is mistake count.", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d misstep(s). The gate descends." + } + } + } + }, + "dungeon.a11y.l1.objective" : { + "localizations" : { + "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." + } + } + } + }, + "dungeon.a11y.l1.objective.format" : { + "comment" : "VoiceOver objective. %@ is the target room display name.", + "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." + } + } + } + }, + "dungeon.a11y.l1.objective.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Three-finger swipes move the lane. If focus moves away, swipe back to Moonstone alignment lane, then try again." + } + } + } + }, + "dungeon.a11y.l2.objective" : { + "localizations" : { + "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." + } + } + } + }, + "dungeon.a11y.l2.objective.format" : { + "comment" : "VoiceOver objective. %@ is the target room display name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objective: Align the %@ with the orb. Keep VoiceOver on Moonstone alignment lane and three-finger scroll to align." + } + } + } + }, + "dungeon.a11y.l2.timer" : { + "comment" : "Accessibility label for the L2 timer HUD group. Argument is seconds remaining.", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time remaining. %d seconds. Descend now." + } + } + } + }, + "dungeon.a11y.l3.objective" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objective: Descend to the Ancient Vault. Act quickly." + } + } + } + }, + "dungeon.a11y.l3.objective.format" : { + "comment" : "VoiceOver objective. %@ is the target room display name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Objective: Align the %@ with the orb. Act quickly. Keep VoiceOver on Moonstone alignment lane and three-finger scroll." + } + } + } + }, + "dungeon.a11y.l3.timer" : { + "comment" : "Accessibility label for the L3 timer HUD group. Argument is seconds remaining.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time remaining. %d seconds. The gate descends." + } + } + } + }, + "dungeon.a11y.scroll.container" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moonstone alignment lane" + } + } + } + }, + "dungeon.a11y.scroll.container.hint" : { + "localizations" : { + "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." + } + } + } + }, + "dungeon.a11y.scroll.vo.focusAnnouncement" : { + "comment" : "VoiceOver announcement after screen-changed and backup focus on the Moonstone alignment lane ScrollView.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moonstone alignment lane is focused. Swipe with three fingers up or down to scroll the Moonstone toward the orb." + } + } + } + }, + "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" : { + "stringUnit" : { + "state" : "translated", + "value" : "The gate is nearly down. Move." + } + } + } + }, + "dungeon.a11y.timer.50pct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Half your time. The torches begin to die." + } + } + } + }, + "dungeon.a11y.timer.75pct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Three-quarters of your time is gone. Keep descending." + } + } + } + }, + "dungeon.explain.gesture.swipe1" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "One-finger swipe — move between chamber markers (navigate)" + } + } + } + }, + "dungeon.explain.gesture.swipe3" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Three-finger swipe — scroll the lane (move Moonstone)" + } + } + } + }, + "dungeon.explain.gesture.swipe3u" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seal button appears when Moonstone aligns with the orb" + } + } + } + }, + "dungeon.explain.lesson.body" : { + "localizations" : { + "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." + } + } + } + }, + "dungeon.explain.lesson.heading" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entering the Crystal Shaft" + } + } + } + }, + "dungeon.explain.narration" : { + "localizations" : { + "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." + } + } + } + }, + "dungeon.explain.practice_tip" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try three-finger scrolling before beginning. Practice here — it's safe." + } + } + } + }, + "dungeon.explain.practice.step" : { + "comment" : "Practice scroll item in the prologue. Argument is the step number (integer).", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Step %lld: keep descending." + } + } + } + }, + "dungeon.explain.start.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Begins your first descent. No timer." + } + } + } + }, + "dungeon.explain.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crystal Resonance" + } + } + } + }, + "dungeon.feedback.non_target" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nothing useful here. Keep descending." + } + } + } + }, + "dungeon.hint.a11yHint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap to hear a reminder about three-finger scrolling." + } + } + } + }, + "dungeon.hint.a11yLabel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask the Resonance Guide for a hint" + } + } + } + }, + "dungeon.hint.button" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask the Guide" + } + } + } + }, + "dungeon.hint.format" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Guide whispers: use three fingers to scroll downward and reveal %@." + } + } + } + }, + "dungeon.hud.depth.format" : { + "comment" : "Depth HUD label. Arguments: current depth index (1-based), total rooms.", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Depth: %d of %d" + } + } + } + }, + "dungeon.hud.mistakes.format" : { + "comment" : "Mistake count label in L2/L3 HUD. Argument is integer mistake count.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Missteps: %d" + } + } + } + }, + "dungeon.l1.complete" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "First descent complete. The crystal yields its first secret." + } + } + } + }, + "dungeon.l1.dm_prompt" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The shaft has two rings of chambers. Descend to the second marker." + } + } + } + }, + "dungeon.l1.guardRoom.decoySubtitle" : { + "comment" : "Guard Room subtitle when it is not the objective (Lights Off random layout).", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Empty." + } + } + } + }, + "dungeon.l1.objective" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descend to: The Guard Room" + } + } + } + }, + "dungeon.l1.objective.format" : { + "comment" : "Visible objective line. %@ is the target room display name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descend to: %@" + } + } + } + }, + "dungeon.l1.title" : { + "comment" : "The title of the first dungeon descent attempt.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "First Attempt" + } + } + } + }, + "dungeon.l2.complete" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relic Vault reached. The vault glows faintly." + } + } + } + }, + "dungeon.l2.dm_prompt" : { + "extractionState" : "stale", + "localizations" : { + "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." + } + } + } + }, + "dungeon.l2.objective" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descend to: The Relic Vault" + } + } + } + }, + "dungeon.l2.objective.format" : { + "comment" : "Visible objective line. %@ is the target room display name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descend to: %@" + } + } + } + }, + "dungeon.l2.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rising Challenge" + } + } + } + }, + "dungeon.l3.dm_prompt" : { + "extractionState" : "stale", + "localizations" : { + "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." + } + } + } + }, + "dungeon.l3.objective" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descend to: The Ancient Vault" + } + } + } + }, + "dungeon.l3.objective.format" : { + "comment" : "Visible objective line. %@ is the target room display name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Descend to: %@" + } + } + } + }, + "dungeon.l3.success" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ancient Vault reached. The relic is yours. Trial passed." + } + } + } + }, + "dungeon.l3.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Timed Trial" + } + } + } + }, + "dungeon.lightsOff.flavor" : { + "comment" : "Atmospheric banner for the final timed Dungeon level when room icons are blacked out.", + "localizations" : { + "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." + } + } + } + }, + "dungeon.prologue.practice.ready" : { + "comment" : "Shown after the player scrolls the practice area in the Crystal Resonance prologue.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resonance felt. You're ready to begin." + } + } + } + }, + "dungeon.resonance.a11y.orb.far" : { + "comment" : "VoiceOver label for the center orb when the objective is far from alignment.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crystal orb, faint resonance" + } + } + } + }, + "dungeon.resonance.a11y.orb.locked" : { + "comment" : "VoiceOver label when the target is aligned and ready to seal.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crystal orb, aligned, ready to confirm" + } + } + } + }, + "dungeon.resonance.a11y.orb.near" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crystal orb, close to lock" + } + } + } + }, + "dungeon.resonance.a11y.orb.success" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crystal orb, resonance sealed" + } + } + } + }, + "dungeon.resonance.a11y.orb.warm" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crystal orb, warmth building" + } + } + } + }, + "dungeon.resonance.seal" : { + "comment" : "Primary action when the moonstone is aligned with the orb.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seal resonance" + } + } + } + }, + "dungeon.resonance.seal.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Completes this chamber when the moonstone is aligned." + } + } + } + }, + "dungeon.resonance.tip.voFocusOnLane" : { + "comment" : "L1 in-game tip: VoiceOver must be focused on the scroll proxy, not chrome, for three-finger scroll to move the Moonstone.", + "localizations" : { + "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." + } + } + } + }, + "dungeon.results.defeated" : { + "comment" : "VoiceOver announcement when the player fails Crystal Resonance.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The gate sealed. The crystal went quiet. Rest, then try again." + } + } + } + }, + "dungeon.results.legendary" : { + "comment" : "VoiceOver announcement for a Legendary rank in Crystal Resonance.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Legendary resonance. Flawless descent. The crystal answers you." + } + } + } + }, + "dungeon.results.novice" : { + "comment" : "VoiceOver announcement for a novice-level rank in Crystal Resonance.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You reached the vault. The crystal tested you—and you endured." + } + } + } + }, + "dungeon.results.skilled" : { + "comment" : "VoiceOver announcement for a skilled rank in Crystal Resonance.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skilled alignment. The shaft yielded to you." + } + } + } + }, + "dungeon.room.ancient_vault" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ancient Vault. The relic glows." + } + } + } + }, + "dungeon.room.ancientVault.decoySubtitle" : { + "comment" : "Ancient Vault subtitle when it is not the objective.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The relic is hidden here." + } + } + } + }, + "dungeon.room.armory" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Armory. Empty racks." + } + } + } + }, + "dungeon.room.barracks" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Barracks. Cold hearths." + } + } + } + }, + "dungeon.room.bone_room" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bone Room. Don't look too closely." + } + } + } + }, + "dungeon.room.collapsed_wall" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Collapsed Wall. A gap you can pass." + } + } + } + }, + "dungeon.room.entry_hall" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Entry Hall. Torchlit. Safe." + } + } + } + }, + "dungeon.room.flooded_hall" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Flooded Hall. Knee-deep water." + } + } + } + }, + "dungeon.room.guard_post" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guard Post. Abandoned." + } + } + } + }, + "dungeon.room.guard_room" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Guard Room. Empty. The objective." + } + } + } + }, + "dungeon.room.nonTarget.hint" : { + "comment" : "Accessibility hint for a non-target dungeon room. Informs the user the room can be tapped.", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap to inspect this room." + } + } + } + }, + "dungeon.room.objective.subtitle" : { + "comment" : "Subtitle for a dungeon room that is the scroll target.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The objective." + } + } + } + }, + "dungeon.room.relic_vault" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relic Vault. The objective." + } + } + } + }, + "dungeon.room.relicVault.decoySubtitle" : { + "comment" : "Relic Vault subtitle when it is not the objective.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The relic is near." + } + } + } + }, + "dungeon.room.ritual_chamber" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ritual Chamber. Symbols cover the walls." + } + } + } + }, + "dungeon.room.store_room" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Store Room. Dusty barrels." + } + } + } + }, + "dungeon.room.target.hint" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap to claim this room." + } + } + } + }, + "dungeon.room.trophy_room" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trophy Room. Faded banners." + } + } + } + }, + "dungeon.room.well_chamber" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Well Chamber. The water is still." + } + } + } + }, + "dungeon.target.notReachable" : { + "comment" : "Feedback when player taps target but hasn't scrolled to reveal it yet.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The objective is still too deep. Keep scrolling down." + } + } + } + }, + "dungeon.timeout" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The gate fell. The relic is sealed below. Trial failed." + } + } + } + }, + "enchanter.hint.a11yHint" : { + "comment" : "An accessibility hint for the \"Hint\" button in the", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap to hear the target relic name announced again." + } + } + } + }, + "enchanter.hint.a11yLabel" : { + "comment" : "The accessibility label for the \"Hint\" button in the", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask the Enchanter for a hint" + } + } + } + }, + "enchanter.hint.button" : { + "comment" : "The text on a button that provides a hint in the Enchanter's Trial.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask the Enchanter" + } + } + } + }, + "enchanter.hint.format" : { + "comment" : "A message displayed when the user taps the \"Hint\" button in the Find & Focus game. The argument is the name of the relic that the player should focus on.", + "isCommentAutoGenerated" : true, + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Enchanter whispers: the target is %@." + } + } + } + }, + "enchanter.lightsOff.flavor" : { + "comment" : "Atmospheric banner for the final timed Enchanter level when visuals are blacked out.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your torch sputters out and darkness takes over. Trust VoiceOver and what you learned—find the relic by sound and memory alone." + } + } + } + }, + "firstRun.body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Learn the three core VoiceOver skills in a guided sequence, or explore the hub on your own." + } + } + } + }, + "firstRun.goToHub" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go to Hub" + } + } + } + }, + "firstRun.goToHub.a11yHint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Skips the Basics sequence and opens the game hub." + } + } + } + }, + "firstRun.goToHub.a11yLabel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Go to Hub" + } + } + } + }, + "firstRun.navigationTitle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VoiceOver Basics" + } + } + } + }, + "firstRun.startBasics" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start Basics" + } + } + } + }, + "firstRun.startBasics.a11yHint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Starts the guided VoiceOver Basics sequence." + } + } + } + }, + "firstRun.startBasics.a11yLabel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start VoiceOver Basics" + } + } + } + }, + "firstRun.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start with VoiceOver Basics" + } + } + } + }, + "game.activateDoubleTap.goal" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Third quest: touch to examine. Double-tap to sever the decoys." + } + } + } + }, + "game.activateDoubleTap.title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Rogue's Gauntlet" + } + } + } + }, + "game.findAndFocus.goal" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navigate focus and invoke the named relic." + } + } + } + }, + "game.findAndFocus.title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Enchanter's Trial" + } + } + } + }, + "game.scrollHunt.goal" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Second quest: scroll with three fingers through the crystal shaft until you can claim the relic." + } + } + } + }, + "game.scrollHunt.title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Crystal Resonance" + } + } + } + }, + "Haptics" : { + "comment" : "A button label for toggling in-game haptic feedback.", + "isCommentAutoGenerated" : true + }, + "Hide design inspector" : { + "comment" : "A button label for hiding the view's design inspector.", + "isCommentAutoGenerated" : true + }, + "Hint" : { + "comment" : "A button that triggers a hint cue in the feedback system.", + "isCommentAutoGenerated" : true + }, + "hub.a11y.rotor.quests" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quests" + } + } + } + }, + "hub.card.accessibilityHint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap to begin this trial." + } + } + } + }, + "hub.card.locked.a11yLabel" : { + "comment" : "VoiceOver label suffix for a locked quest card. %@ is the prerequisite game title.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Locked. Complete %@ to unlock." + } + } + } + }, + "hub.card.locked.message" : { + "comment" : "Shown on a locked quest card. %@ is the prerequisite game title.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Complete %@ to unlock this trial." + } + } + } + }, + "hub.dmGreeting" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose Your Trial, Adventurer" + } + } + } + }, + "hub.enableVoiceOver" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Enable VoiceOver to play" + } + } + } + }, + "hub.helpAffordance" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How to enable VoiceOver" + } + } + } + }, + "hub.navigationTitle" : { + "comment" : "Visible navigation title on the hub. VoiceOver uses hub.navigationTitle.voiceOver.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RA11y Quest" + } + } + } + }, + "hub.navigationTitle.voiceOver" : { + "comment" : "VoiceOver label for the hub navigation title; spoken form of the product name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Rally Quest" + } + } + } + }, + "hub.orientation.scrollBody" : { + "comment" : "Explains VoiceOver scroll gesture for the hub quest list.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Three-finger swipe up or down to scroll the quest list." + } + } + } + }, + "hub.orientation.scrollTitle" : { + "comment" : "Heading for the hub orientation strip above the quest list.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Moving around" + } + } + } + }, + "hub.orientation.voActivation" : { + "comment" : "Shown when VoiceOver is on: minimum gesture pattern to open a trial.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swipe right to move between items. Double-tap to open a trial." + } + } + } + }, + "hub.questAwaits" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quest Awaits" + } + } + } + }, + "hub.questAwaits.accessibility" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quest Awaits. Not yet attempted." + } + } + } + }, + "hub.subtitle" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Choose a trial to begin your VoiceOver training." + } + } + } + }, + "hub.title" : { + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "RA11y Quest" + } + } + } + }, + "hub.voiceOverBasics" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VoiceOver Basics" + } + } + } + }, + "hud.timer.format" : { + "comment" : "Timer label. Argument is the integer seconds remaining.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%ds remaining" + } + } + } + }, + "Invoke" : { + "comment" : "A button that triggers a \"wrong activation\" cue.", + "isCommentAutoGenerated" : true + }, + "level.button.next" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Continue" + } + } + } + }, + "level.button.retry" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try Again" + } + } + } + }, + "level.button.start" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Begin Trial" + } + } + } + }, + "Lights Off preview" : { + "comment" : "A toggle to show a \"Lights Off\" version of the view.", + "isCommentAutoGenerated" : true + }, + "Manual band (override)" : { + "comment" : "A label above a toggle that lets users manually select the resonance band.", + "isCommentAutoGenerated" : true + }, + "Reset Cues" : { + "comment" : "A button that resets all success/hint/wrong-activation cues.", + "isCommentAutoGenerated" : true + }, + "result.a11y.playAgain.hint" : { + "comment" : "Accessibility hint for the Try Again button on the result screen.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap to repeat the trial from the beginning." + } + } + } + }, + "result.a11y.returnToHub.hint" : { + "comment" : "Accessibility hint for the Back to Tavern button on the result screen.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap to return to the hub." + } + } + } + }, + "result.navigationTitle" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Result" + } + } + } + }, + "result.playAgain" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try Again" + } + } + } + }, + "result.returnToHub" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Back to Tavern" + } + } + } + }, + "result.skillTransfer.activateDoubleTap.body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "With VoiceOver on, a single tap reads an element — it does not activate it. To press a button or follow a link, double-tap anywhere on the screen after focusing it." + } + } + } + }, + "result.skillTransfer.activateDoubleTap.realWorld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try it now: swipe to a button in any app, then double-tap anywhere to press it. Notice that your double-tap doesn't need to land on the button." + } + } + } + }, + "result.skillTransfer.findAndFocus.body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "With VoiceOver on, swipe right to move focus to the next element. Swipe left to go back. Double-tap to activate whatever you're focused on." + } + } + } + }, + "result.skillTransfer.findAndFocus.realWorld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try it now: open the App Store, swipe through the featured section, and double-tap any app to open it." + } + } + } + }, + "result.skillTransfer.heading" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "What You Learned" + } + } + } + }, + "result.skillTransfer.scrollHunt.body" : { + "localizations" : { + "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)." + } + } + } + }, + "result.skillTransfer.scrollHunt.realWorld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Try it now: open Settings, swipe to the bottom of the visible list, then use three fingers to scroll further and reveal more options." + } + } + } + }, + "rogue.a11y.explain.lesson" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "VoiceOver Lesson. With VoiceOver on, tapping once reads an element — it does not activate it. To activate, double-tap anywhere on the screen after focusing the element. Touch to examine. Double-tap to sever." + } + } + } + }, + "rogue.a11y.hud.l2" : { + "comment" : "Accessibility label for the combined mistakes+timer HUD in L2. Argument is mistake count.", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d misstep(s). Time remaining." + } + } + } + }, + "rogue.a11y.hud.l3" : { + "comment" : "Accessibility label for the combined mistakes+timer HUD in L3. Argument is mistake count.", + "extractionState" : "stale", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d misstep(s). Timer is running." + } + } + } + }, + "rogue.a11y.l1.objective" : { + "comment" : "Full accessibility label for the L1 objective card. Argument is the seal display name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your objective: sever the %@ seal. Touch it to read it. Double-tap to sever." + } + } + } + }, + "rogue.a11y.l1.objective.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Navigate to the seal and double-tap. Single tap only reads — it does not sever." + } + } + } + }, + "rogue.a11y.l2.objective" : { + "comment" : "Full accessibility label for the L2 objective card. Argument is the seal display name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your objective: sever the %@ seal. Careful — traps surround it. Listen, then double-tap to sever." + } + } + } + }, + "rogue.a11y.l2.objective.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Touch each seal to hear its name. Double-tap only when focused on the correct seal." + } + } + } + }, + "rogue.a11y.l2.timer" : { + "comment" : "Accessibility label for the L2 timer HUD group. Argument is seconds remaining (integer).", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time remaining. %d seconds. Identify the correct seal." + } + } + } + }, + "rogue.a11y.l3.objective" : { + "comment" : "Full accessibility label for the L3 objective card. Argument is the seal display name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your objective: sever the %@ seal. Act quickly — the trap room is charging." + } + } + } + }, + "rogue.a11y.l3.timer" : { + "comment" : "Accessibility label for the L3 timer HUD group. Argument is seconds remaining (integer).", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time remaining. %d seconds. The trap room charges." + } + } + } + }, + "rogue.a11y.timer.25pct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The trap room is nearly ready." + } + } + } + }, + "rogue.a11y.timer.50pct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Half your time. The floor shifts." + } + } + } + }, + "rogue.a11y.timer.75pct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The mechanisms warm." + } + } + } + }, + "rogue.explain.gesture.tap1" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "One tap — read the seal (focus only)" + } + } + } + }, + "rogue.explain.gesture.tap2" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap — sever the seal (activate)" + } + } + } + }, + "rogue.explain.gesture.wrong" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Severing the wrong seal springs the trap" + } + } + } + }, + "rogue.explain.lesson.body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "With VoiceOver on, tapping an element reads it aloud — it does not activate it. To activate the element you have focused, double-tap anywhere on the screen. One touch to examine. Two taps to act. This is different from normal iOS." + } + } + } + }, + "rogue.explain.lesson.heading" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Rogue's Creed" + } + } + } + }, + "rogue.explain.narration" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Trap Room stands between you and the vault. The door is sealed by magical runes. One rune will open it. The others will spring the trap. Touch to sense. Double-tap to sever." + } + } + } + }, + "rogue.explain.start.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Begins your first attempt in the Trap Room. No timer." + } + } + } + }, + "rogue.explain.title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Rogue's Gauntlet" + } + } + } + }, + "rogue.feedback.correct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Seal severed. The door yields." + } + } + } + }, + "rogue.feedback.trap" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trap sprung. Misstep." + } + } + } + }, + "rogue.hint.a11yHint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap to hear the target seal name announced again." + } + } + } + }, + "rogue.hint.a11yLabel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask the DM for a hint" + } + } + } + }, + "rogue.hint.button" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ask the DM" } } } }, - "basicsSequence.continue" : { + "rogue.hint.format" : { + "comment" : "VoiceOver hint announcement. Argument is the correct seal display name.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Continue" + "value" : "The DM whispers: the correct seal is %@. Double-tap to sever it." } } } }, - "basicsSequence.continue.a11yHint" : { + "rogue.hud.mistakes.format" : { + "comment" : "Mistake count label. Argument is integer mistake count.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Moves to the next Basics step." + "value" : "Missteps: %d" } } } }, - "basicsSequence.continue.a11yLabel" : { + "rogue.l1.complete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Continue Basics" + "value" : "First attempt passed. The Rogue's instinct awakens." } } } }, - "basicsSequence.finish" : { + "rogue.l1.dm_prompt" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Finish" + "value" : "One seal stands before you. Touch to sense it. Double-tap to sever it." } } } }, - "basicsSequence.finish.a11yHint" : { + "rogue.l1.objective.format" : { + "comment" : "Displayed objective on L1 prompt card. Argument is the seal display name.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Completes Basics and returns to the hub." + "value" : "Sever the seal: %@" } } } }, - "basicsSequence.finish.a11yLabel" : { + "rogue.l1.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Finish Basics" + "value" : "First Attempt" } } } }, - "basicsSequence.stepFormat" : { + "rogue.l2.complete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Step %d of %d" + "value" : "The door yields. The Rogue's precision holds." } } } }, - "basicsSequence.title" : { + "rogue.l2.dm_prompt" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "VoiceOver Basics" + "value" : "The door has four seals. One opens it. The others will spring the trap. Identify the correct seal before you sever." } } } }, - "enchanter.completeError" : { + "rogue.l2.objective.format" : { + "comment" : "Displayed objective on L2 prompt card. Argument is the seal display name.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The trial could not complete. Please try again." + "value" : "Sever the seal: %@" } } } }, - "enchanter.hint.a11yHint" : { + "rogue.l2.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Speaks the target relic name again without penalty." + "value" : "Rising Challenge" } } } }, - "enchanter.hint.a11yLabel" : { + "rogue.l3.dm_prompt" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Hear a hint" + "value" : "Six seals. One passage. The trap room activates in seconds. Sever the correct seal before the mechanism locks." } } } }, - "enchanter.hint.button" : { + "rogue.l3.objective.format" : { + "comment" : "Displayed objective on L3 prompt card. Argument is the seal display name.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Hint" + "value" : "Sever the seal: %@" } } } }, - "enchanter.hint.format" : { + "rogue.l3.success" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The Enchanter whispers: the target is %@." + "value" : "Seal severed. The door opens just in time." } } } }, - "enchanter.mistake.format" : { + "rogue.l3.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "That was %@. The Enchanter waits." + "value" : "Timed Trial" } } } }, - "enchanter.mistakeError" : { + "rogue.lightsOff.flavor" : { + "comment" : "Atmospheric banner for the final timed Rogue level when seal artwork is blacked out.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "That selection could not be recorded. Try again." + "value" : "A magical wind from deep within the cavern extinguishes your torch. Double-tap by touch and sound alone." } } } }, - "enchanter.prompt.a11yHint" : { + "rogue.results.defeated" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Find the relic in the list below and double-tap to invoke it." + "value" : "The trap room claimed you. Study the seals and return." } } } }, - "enchanter.prompt.format" : { + "rogue.results.legendary" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The Enchanter calls: %@" + "value" : "Flawless. No trap touched you. The Guild is impressed." } } } }, - "enchanter.prompt.instructions" : { + "rogue.results.novice" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Navigate to it and double-tap to invoke it." + "value" : "The trap room tested you. You escaped — barely." } } } }, - "enchanter.relic.a11yHint" : { + "rogue.results.skilled" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Double-tap to invoke this relic." + "value" : "Skilled hands. One misstep, but the door opened." } } } }, - "enchanter.relics.header" : { + "rogue.seal.alarm.label" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Relics" + "value" : "Alarm Seal" } } } }, - "enchanter.startError" : { + "rogue.seal.binding.label" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The trial could not begin. Please try again." + "value" : "Binding Seal" } } } }, - "enchanter.success.format" : { + "rogue.seal.collapse.label" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "%@ invoked in time. The Enchanter is satisfied." + "value" : "Collapse Seal" } } } }, - "firstRun.body" : { + "rogue.seal.freeze.label" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Learn the three core VoiceOver skills in a guided sequence, or explore the hub on your own." + "value" : "Freeze Seal" } } } }, - "firstRun.goToHub" : { + "rogue.seal.hint" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Go to Hub" + "value" : "Double-tap to sever this seal." } } } }, - "firstRun.goToHub.a11yHint" : { + "rogue.seal.passage.label" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Skips the Basics sequence and opens the game hub." + "value" : "Seal of Passage" } } } }, - "firstRun.goToHub.a11yLabel" : { + "rogue.seal.reveal.label" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Go to Hub" + "value" : "Reveal Seal" } } } }, - "firstRun.navigationTitle" : { + "rogue.seal.silence.label" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "VoiceOver Basics" + "value" : "Silence Seal" } } } }, - "firstRun.startBasics" : { + "rogue.seal.spike.label" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Start Basics" + "value" : "Spike Trap Seal" } } } }, - "firstRun.startBasics.a11yHint" : { + "rogue.seal.ward.label" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Starts the guided VoiceOver Basics sequence." + "value" : "Warding Seal" } } } }, - "firstRun.startBasics.a11yLabel" : { + "rogue.timeout" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Start VoiceOver Basics" + "value" : "The trap room sealed. You were caught." } } } }, - "firstRun.title" : { + "Show design inspector" : { + "comment" : "A button label that shows the design inspector.", + "isCommentAutoGenerated" : true + }, + "simon.a11y.explain.lesson" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Start with VoiceOver Basics" + "value" : "VoiceOver Lesson. With VoiceOver on, you navigate one element at a time. Swipe right to move forward, swipe left to move back. Double-tap to activate the element you hear." } } } }, - "game.activateDoubleTap.goal" : { - "extractionState" : "stale", + "simon.a11y.hud.l1" : { + "comment" : "Accessibility label for the mistake counter. Argument is the integer mistake count.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Touch to examine. Double-tap to sever." + "value" : "Missteps: %d" } } } }, - "game.activateDoubleTap.title" : { - "extractionState" : "stale", + "simon.a11y.l1.target" : { + "comment" : "Accessibility label for the target prompt card in L1. Argument is the relic name.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The Rogue's Gauntlet" + "value" : "The Enchanter calls: %@. Navigate to it and double-tap to invoke it." } } } }, - "game.comingSoon" : { + "simon.a11y.l1.target.hint" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Coming soon" + "value" : "Swipe right to move through the relics. Double-tap when you hear the correct name." } } } }, - "game.findAndFocus.goal" : { - "extractionState" : "stale", + "simon.a11y.l2.target" : { + "comment" : "Accessibility label for the target prompt card in L2. Argument is the relic name.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Navigate focus and invoke the named relic." + "value" : "The Enchanter calls: %@. Navigate carefully — names are similar." } } } }, - "game.findAndFocus.title" : { + "simon.a11y.l2.timer" : { + "comment" : "Accessibility label for the timer HUD in L2. Argument is the integer seconds remaining.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time remaining. %d seconds. The Enchanter grows impatient." + } + } + } + }, + "simon.a11y.l3.target" : { + "comment" : "Accessibility label for the target prompt card in L3. Argument is the relic name.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Enchanter commands: %@. Act quickly." + } + } + } + }, + "simon.a11y.l3.timer" : { + "comment" : "Accessibility label for the timer HUD in L3. Argument is the integer seconds remaining.", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Time remaining. %d seconds. The candles burn." + } + } + } + }, + "simon.a11y.timer.25pct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The candles gutter." + } + } + } + }, + "simon.a11y.timer.50pct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Half your time. The Enchanter watches." + } + } + } + }, + "simon.a11y.timer.75pct" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Three-quarters of your time is gone." + } + } + } + }, + "simon.explain.gesture.back" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swipe left — previous item" + } + } + } + }, + "simon.explain.gesture.swipe" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Swipe right — next item" + } + } + } + }, + "simon.explain.gesture.tap" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap — invoke" + } + } + } + }, + "simon.explain.lesson.body" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "With VoiceOver on, the screen is read one element at a time. Swipe right to move to the next item. Swipe left to go back. When you hear the Enchanter's relic, double-tap to activate it." + } + } + } + }, + "simon.explain.lesson.heading" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "How VoiceOver Works Here" + } + } + } + }, + "simon.explain.narration" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Enchanter gestures to a row of relics. \"I will name one. Touch your way through them — one at a time — until you hear it. Then invoke it.\"" + } + } + } + }, + "simon.explain.start.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Begins your first practice attempt. No timer." + } + } + } + }, + "simon.explain.title" : { "localizations" : { "en" : { "stringUnit" : { @@ -374,162 +2765,201 @@ } } }, - "game.scrollHunt.goal" : { - "extractionState" : "stale", + "simon.feedback.correct" : { + "comment" : "Success feedback shown after the correct relic is activated. Argument is the relic name.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Scroll three fingers deep to find the relic." + "value" : "Correct. %@ invoked." } } } }, - "game.scrollHunt.title" : { - "extractionState" : "stale", + "simon.feedback.wrong" : { + "comment" : "Mistake feedback. Argument is the name of the incorrectly activated relic.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "The Dungeon Descent" + "value" : "That was %@. The Enchanter waits. Keep searching." } } } }, - "hub.card.accessibilityHint" : { + "simon.hud.mistakes.format" : { + "comment" : "Mistake counter HUD label. Argument is the integer mistake count.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Double-tap to begin this trial." + "value" : "Missteps: %d" } } } }, - "hub.dmGreeting" : { + "simon.l1.complete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Choose Your Trial, Adventurer" + "value" : "Trial passed. The Enchanter nods." } } } }, - "hub.enableVoiceOver" : { + "simon.l1.target.format" : { + "comment" : "Prompt card text in L1. Argument is the target relic name.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Enable VoiceOver to play" + "value" : "The Enchanter calls: %@" } } } }, - "hub.helpAffordance" : { - "extractionState" : "stale", + "simon.l1.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "How to enable VoiceOver" + "value" : "First Attempt" } } } }, - "hub.navigationTitle" : { + "simon.l2.complete" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "RA11y" + "value" : "Trial passed. The relics grow quiet." } } } }, - "hub.questAwaits" : { + "simon.l2.target.format" : { + "comment" : "Prompt card text in L2. Argument is the target relic name.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Quest Awaits" + "value" : "The Enchanter calls: %@" } } } }, - "hub.questAwaits.accessibility" : { - "extractionState" : "stale", + "simon.l2.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Quest Awaits. Not yet attempted." + "value" : "Rising Challenge" } } } }, - "hub.subtitle" : { - "extractionState" : "stale", + "simon.l3.success" : { + "comment" : "Announced by VoiceOver when L3 is completed. Argument is the relic name.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Choose a trial to begin your VoiceOver training." + "value" : "%@ invoked in time. The Enchanter is satisfied." } } } }, - "hub.title" : { - "extractionState" : "stale", + "simon.l3.target.format" : { + "comment" : "Prompt card text in L3. Argument is the target relic name.", "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "RA11y" + "value" : "The Enchanter commands: %@" } } } }, - "hub.voiceOverBasics" : { + "simon.l3.title" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "VoiceOver Basics" + "value" : "Timed Trial" } } } }, - "result.navigationTitle" : { + "simon.results.defeated" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Result" + "value" : "The relic vanished. Return when you are ready." } } } }, - "result.playAgain" : { + "simon.results.legendary" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Play Again" + "value" : "The Enchanter bows. A perfect invocation." } } } }, - "result.returnToHub" : { + "simon.results.novice" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Return to Hub" + "value" : "The Enchanter sighs. You found it eventually." + } + } + } + }, + "simon.results.skilled" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The Enchanter nods. A solid invocation." + } + } + } + }, + "simon.timeout" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "The candles die. The Enchanter withdraws the relic." + } + } + } + }, + "simon.token.hint" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Double-tap to invoke this relic." } } } }, + "Sound cues" : { + "comment" : "A toggle for enabling or disabling in-game sound effects.", + "isCommentAutoGenerated" : true + }, + "Spoken hints" : { + "comment" : "A toggle that enables or disables spoken hints during gameplay.", + "isCommentAutoGenerated" : true + }, "voiceOverHelp.done" : { "localizations" : { "en" : { @@ -800,6 +3230,36 @@ } } }, + "voiceOverRequired.siri.a11yLabel" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Say to Siri: Hey Siri, turn on VoiceOver. No gestures needed." + } + } + } + }, + "voiceOverRequired.siri.heading" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quickest way: ask Siri" + } + } + } + }, + "voiceOverRequired.siri.phrase" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"Hey Siri, turn on VoiceOver\"" + } + } + } + }, "voiceOverRequired.title" : { "localizations" : { "en" : { @@ -809,7 +3269,10 @@ } } } + }, + "Δ to aim" : { + } }, - "version" : "1.0" + "version" : "1.1" } \ No newline at end of file diff --git a/RA11y-iOS/RA11y-iOS/RA11y_iOSApp.swift b/RA11y-iOS/RA11y-iOS/RA11y_iOSApp.swift index bd652f1..1261e58 100644 --- a/RA11y-iOS/RA11y-iOS/RA11y_iOSApp.swift +++ b/RA11y-iOS/RA11y-iOS/RA11y_iOSApp.swift @@ -12,25 +12,107 @@ import RA11yCore /// the process. Subsequent milestones are logged in `iOSRootView` and /// `HubViewModel`. Filter Console.app by: /// subsystem = com.showblender.RA11y, category = startup +/// +/// ## Screenshot Testing +/// The fastlane `screenshots` lane launches the app with specific arguments: +/// - `-screenshotResetOnboarding`: clears first-run flags → routes to First Run. +/// - `-screenshotMarkOnboardingComplete`: sets `basicsCompleted = true` → routes to hub. +/// - `-screenshotScene `: bypasses normal startup entirely and renders +/// `iOSScreenshotRootView` for a deterministic capture scene. +/// - `-screenshotDirectTo{Game}` remains supported for legacy paths, but the screenshot +/// lane should prefer `-screenshotScene`. @main struct RA11y_iOSApp: App { - /// Creates the app and applies UI-testing defaults when requested. init() { - RA11yLogger.startup.debug("Cold start — RA11y_iOSApp.init") - applyUITestingOverridesIfNeeded() + RA11yLogger.startup.debug("\(RA11yLogger.startupTimestampTag()) Cold start — RA11y_iOSApp.init") + applyScreenshotTestingOverridesIfNeeded() } var body: some Scene { WindowGroup { + screenshotEntryView + } + } + + // MARK: - Screenshot Testing + + /// The root entry view for the current process. + /// + /// When a screenshot scene is requested the app bypasses `iOSRootView` and renders + /// a deterministic capture surface instead. + @ViewBuilder + private var screenshotEntryView: some View { + #if DEBUG + if let scene = iOSScreenshotScene.current() { + iOSScreenshotRootView(scene: scene) + } else { iOSRootView() } + #else + iOSRootView() + #endif } - private func applyUITestingOverridesIfNeeded() { - guard ProcessInfo.processInfo.arguments.contains("-uiTesting") else { return } - Task { - await UserDefaultsStorageComponent().markBasicsDismissed() + /// Applies `UserDefaults` overrides requested by the fastlane screenshot lane. + /// + /// Recognised launch arguments (all require `-uiTesting`): + /// - `-screenshotResetOnboarding`: erases both first-run flags so the app routes + /// to the First Run entry screen. Used by `testScreenshots_FirstRun`. + /// Also clears Lights Off mode so the hub matches baseline screenshots. + /// - `-screenshotMarkOnboardingComplete`: writes `basicsCompleted = true` so the + /// app routes directly to the hub. Used by `testScreenshots_Hub_VORequired` to + /// ensure a deterministic hub route regardless of prior simulator state (e.g. + /// after `testScreenshots_FirstRun` clears the flags in the same xcodebuild run). + /// Also clears Lights Off mode for deterministic hub layout. + /// + /// This function is a no-op in non-DEBUG builds and when `-uiTesting` is absent. + /// + /// - Concurrency: Called synchronously on `@main` during `init`. Safe because + /// `UserDefaultsStorageComponent` is not yet initialised at this point, so + /// there is no actor isolation conflict. + private func applyScreenshotTestingOverridesIfNeeded() { + #if DEBUG + let args = ProcessInfo.processInfo.arguments + guard args.contains("-uiTesting") else { return } + + // `-screenshotScene` bypasses the normal startup router, so no persistence + // overrides are required for those launches. + if iOSScreenshotScene.current(from: args) != nil { + return + } + + if args.contains("-screenshotResetOnboarding") { + UserDefaults.standard.removeObject( + forKey: UserDefaultsStorageComponent.ScreenshotTestingKeys.basicsCompleted + ) + UserDefaults.standard.removeObject( + forKey: UserDefaultsStorageComponent.ScreenshotTestingKeys.basicsDismissed + ) + RA11yLogger.startup.debug("Screenshot: onboarding flags cleared for first-run screen") + } + + if args.contains("-screenshotMarkOnboardingComplete") { + UserDefaults.standard.set( + true, + forKey: UserDefaultsStorageComponent.ScreenshotTestingKeys.basicsCompleted + ) + RA11yLogger.startup.debug("Screenshot: basicsCompleted set for hub route") + } + + // Direct-to-game screenshot args: ensure the hub is the base route so + // the loading overlay resolves to hub (not first-run) while `iOSRootView`'s + // `@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"] + if directGameArgs.contains(where: { args.contains($0) }) { + UserDefaults.standard.set( + true, + forKey: UserDefaultsStorageComponent.ScreenshotTestingKeys.basicsCompleted + ) + RA11yLogger.startup.debug("Screenshot: basicsCompleted set for direct-game route") } + #endif } } diff --git a/RA11y-iOS/RA11y-iOS/Results/iOSGameResultView.swift b/RA11y-iOS/RA11y-iOS/Results/iOSGameResultView.swift index 86d847d..0798fbd 100644 --- a/RA11y-iOS/RA11y-iOS/Results/iOSGameResultView.swift +++ b/RA11y-iOS/RA11y-iOS/Results/iOSGameResultView.swift @@ -5,13 +5,20 @@ import RA11yCore /// Shared result screen displayed by all three training games on completion. /// -/// Accepts a `GameResultPresenter` and two action closures. The view is reused across -/// all games — no per-game subclasses or duplicates. +/// Accepts a `GameResultPresenter`, the `GameKind` (used to render the skill-transfer +/// card), an optional game-specific announcement string, and two action closures. +/// Reused across all games — no per-game subclasses. +/// +/// ## Skill Transfer Card +/// Below the rank summary, a "What You Learned" card explicitly bridges the game's +/// skill back to real-world VoiceOver usage. This is the critical step from game +/// mechanic → transferable behaviour that is missing from most AT onboarding. /// /// ## VoiceOver -/// The result summary (rank + metrics) is collapsed into a single accessibility element -/// using the full announcement string from `GameResultPresenter.accessibilityAnnouncement`. -/// Announced order: rank → time → mistakes, as required by TICKET-M1-SharedGameResultScreen. +/// The result summary (rank + metrics) is a single accessibility element using +/// `GameResultPresenter.accessibilityAnnouncement`. Order: rank → time → mistakes. +/// If `gameSpecificAnnouncement` is provided, it is appended after the shared summary +/// on a separate line. /// /// ## Dynamic Type /// All text uses semantic font styles from `RA11yFont`; layout scrolls at largest sizes. @@ -20,30 +27,57 @@ import RA11yCore /// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. struct iOSGameResultView: View { + // MARK: - Environment + + @Environment(iOSAppRouter.self) private var router + // MARK: - Properties let presenter: GameResultPresenter - /// Called when the user taps "Play Again". Restarts the same game. - /// Wired to a game-specific restart in M5+; pops to hub until then. + /// The kind of game just completed — drives the skill-transfer card content. + let gameKind: GameKind + + /// Optional game-specific flavor text shown below the rank summary. + /// + /// Each game defines per-rank strings in the localization catalog (e.g., + /// `simon.results.legendary`). Nil when no game-specific copy is available. + let gameSpecificAnnouncement: String? + + /// Called when the user taps "Try Again". Restarts the same game. let onPlayAgain: () -> Void - /// Called when the user taps "Return to Hub". Pops the navigation stack to root. + /// Called when the user taps "Back to Tavern". Pops to hub root. let onReturnToHub: () -> Void // MARK: - Body var body: some View { - ScrollView { - VStack(spacing: RA11ySpacing.xl) { - resultSummary - actionButtons + GeometryReader { geo in + ScrollView { + VStack(spacing: RA11ySpacing.xl) { + resultSummary + if let gameSpecificAnnouncement { + Text(gameSpecificAnnouncement) + .font(.ra11yBody) + .multilineTextAlignment(.center) + .foregroundStyle(.secondary) + .padding(.horizontal, RA11ySpacing.base) + } + skillTransferCard + actionButtons + } + .padding(RA11ySpacing.base) + .frame(width: geo.size.width) + .frame(maxWidth: .infinity) } - .padding(RA11ySpacing.base) + .clipped() } + .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle(String(localized: "result.navigationTitle")) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) + .accessibilityIdentifier("gameResult.root") } // MARK: - Subviews @@ -73,44 +107,138 @@ struct iOSGameResultView: View { .accessibilityLabel(presenter.accessibilityAnnouncement) } - /// "Play Again" and "Return to Hub" action buttons. + /// Skill transfer card bridging the game mechanic to real-world VoiceOver use. + /// + /// Explicitly names the gesture or behaviour the player just practised and shows + /// where it applies in any iOS app. This is the critical pedagogical step from + /// "I passed the game" → "I know what to do in the App Store." + private var skillTransferCard: some View { + VStack(alignment: .leading, spacing: RA11ySpacing.sm) { + Label(String(localized: "result.skillTransfer.heading"), systemImage: "lightbulb.fill") + .font(.ra11ySubheadline) + .fontWeight(.semibold) + .foregroundStyle(.primary) + .accessibilityAddTraits(.isHeader) + + Divider() + + Text(skillTransferBody) + .font(.ra11yBody) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Text(skillTransferRealWorld) + .font(.ra11ySubheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding(RA11ySpacing.base) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: RA11yRadius.card)) + .accessibilityElement(children: .combine) + .accessibilityLabel( + "\(String(localized: "result.skillTransfer.heading")). \(skillTransferBody) \(skillTransferRealWorld)" + ) + .accessibilityIdentifier("result.skillTransferCard") + } + + private var skillTransferBody: String { + 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") + } + } + + private var skillTransferRealWorld: String { + 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") + } + } + + /// Action buttons — adapts to Basics sequence context. + /// + /// In the M4 guided Basics sequence, replaces the normal "Try Again" / "Back to + /// Tavern" pair with a single "Continue Basics" button that pops back to + /// `iOSBasicsSequenceView`. In standard play the original two buttons are shown. + @ViewBuilder private var actionButtons: some View { - VStack(spacing: RA11ySpacing.sm) { - Button(String(localized: "result.playAgain")) { - onPlayAgain() + if router.isInBasicsSequence { + Button(String(localized: "basicsSequence.continueBasics")) { + router.popForBasicsContinue() } .buttonStyle(.borderedProminent) .controlSize(.large) - - Button(String(localized: "result.returnToHub")) { - onReturnToHub() + .accessibilityIdentifier("result.continueBasics") + .accessibilityHint(String(localized: "basicsSequence.continueBasics.a11yHint")) + } else { + VStack(spacing: RA11ySpacing.sm) { + Button(String(localized: "result.playAgain")) { + onPlayAgain() + } + .buttonStyle(.borderedProminent) + .controlSize(.large) + .accessibilityIdentifier("result.playAgain") + .accessibilityHint(String(localized: "result.a11y.playAgain.hint")) + + Button(String(localized: "result.returnToHub")) { + onReturnToHub() + } + .buttonStyle(.bordered) + .controlSize(.large) + .accessibilityIdentifier("result.returnToHub") + .accessibilityHint(String(localized: "result.a11y.returnToHub.hint")) } - .buttonStyle(.bordered) - .controlSize(.large) } } } // MARK: - Preview -#Preview("Legendary") { +#Preview("Legendary — Enchanter") { NavigationStack { iOSGameResultView( presenter: GameResultPresenter( result: GameResult(gameID: "find-and-focus", rank: .perfect, timeSeconds: 8.5, mistakes: 0) ), + gameKind: .findAndFocus, + gameSpecificAnnouncement: "The Enchanter bows. A perfect invocation.", + onPlayAgain: {}, + onReturnToHub: {} + ) + } +} + +#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("Defeated") { +#Preview("Skilled — Crystal Resonance") { NavigationStack { iOSGameResultView( presenter: GameResultPresenter( - result: GameResult(gameID: "find-and-focus", rank: .failed, timeSeconds: 46, mistakes: 3) + result: GameResult(gameID: "scroll-hunt", rank: .good, timeSeconds: 28, mistakes: 1) ), + gameKind: .scrollHunt, + gameSpecificAnnouncement: "Skilled explorer. The vault yielded to you.", onPlayAgain: {}, onReturnToHub: {} ) diff --git a/RA11y-iOS/RA11y-iOS/VoiceOver/iOSLiveVoiceOverStateProvider.swift b/RA11y-iOS/RA11y-iOS/VoiceOver/iOSLiveVoiceOverStateProvider.swift index 834cc7b..7bc9827 100644 --- a/RA11y-iOS/RA11y-iOS/VoiceOver/iOSLiveVoiceOverStateProvider.swift +++ b/RA11y-iOS/RA11y-iOS/VoiceOver/iOSLiveVoiceOverStateProvider.swift @@ -19,20 +19,22 @@ import RA11yCore /// ``` /// /// ## Concurrency -/// `isVoiceOverRunning` is thread-safe per Apple documentation. +/// `UIAccessibility.isVoiceOverRunning` is MainActor-isolated in current SDKs; this +/// type implements `VoiceOverStateProvider` (synchronous `Bool`) by reading on the +/// main queue when invoked from a non-main context, preserving the protocol contract. /// `stateChanges` yields on the main actor; callers running on background contexts /// must handle the transfer appropriately. -/// -/// ## Concurrency -/// Implicitly `@MainActor` via `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. +/// The struct defaults to `@MainActor` isolation; the `isVoiceOverRunning` getter is +/// `nonisolated` so `VoiceOverStateProvider` conformance remains usable from +/// nonisolated call sites (e.g. `GameStartDecision.evaluate`). public struct iOSLiveVoiceOverStateProvider: VoiceOverStateProvider { public init() {} // MARK: - VoiceOverStateProvider - public var isVoiceOverRunning: Bool { - UIAccessibility.isVoiceOverRunning + public nonisolated var isVoiceOverRunning: Bool { + Self.readVoiceOverStateSynchronously() } /// Emits the updated `isVoiceOverRunning` value on every VoiceOver toggle. @@ -47,7 +49,6 @@ public struct iOSLiveVoiceOverStateProvider: VoiceOverStateProvider { named: UIAccessibility.voiceOverStatusDidChangeNotification ) for await _ in notifications { - guard !Task.isCancelled else { break } continuation.yield(UIAccessibility.isVoiceOverRunning) } continuation.finish() @@ -58,3 +59,25 @@ public struct iOSLiveVoiceOverStateProvider: VoiceOverStateProvider { } } } + +// MARK: - Main-queue VoiceOver read + +extension iOSLiveVoiceOverStateProvider { + + /// Reads `UIAccessibility.isVoiceOverRunning` on the main actor (UIKit’s required + /// context), using a synchronous main-queue hop when the caller is off the main thread. + private nonisolated static func readVoiceOverStateSynchronously() -> Bool { + if Thread.isMainThread { + return MainActor.assumeIsolated { + UIAccessibility.isVoiceOverRunning + } + } + var value = false + DispatchQueue.main.sync { + value = MainActor.assumeIsolated { + UIAccessibility.isVoiceOverRunning + } + } + return value + } +} diff --git a/RA11y-iOS/RA11y-iOS/VoiceOver/iOSVORequiredView.swift b/RA11y-iOS/RA11y-iOS/VoiceOver/iOSVORequiredView.swift index 449c8d8..f9b057f 100644 --- a/RA11y-iOS/RA11y-iOS/VoiceOver/iOSVORequiredView.swift +++ b/RA11y-iOS/RA11y-iOS/VoiceOver/iOSVORequiredView.swift @@ -9,11 +9,20 @@ import RA11yCore /// /// ## Flow /// 1. Explains that VoiceOver is required. -/// 2. Primary CTA: "Open Accessibility Settings" — deep-links to iOS Accessibility settings. +/// 2. **Primary CTA: "Ask Siri"** — shows Siri instructions for enabling VoiceOver. +/// This is the lowest-friction path for a user who has never used VoiceOver, since +/// Siri can toggle it without requiring any accessibility gestures. +/// 3. Secondary CTA: "Open Accessibility Settings" — deep-links to iOS Accessibility settings. /// Falls back to the general Settings app if the deep link is unavailable. /// If both fail, inline fallback instructions are revealed. -/// 3. Secondary CTA: "How to Enable VoiceOver" — presents `iOSVoiceOverHelpSheet`. -/// 4. Back navigation returns the user to the hub. +/// 4. Tertiary CTA: "How to Enable VoiceOver" — presents `iOSVoiceOverHelpSheet` with +/// step-by-step instructions and gesture guides. +/// 5. Back navigation returns the user to the hub. +/// +/// ## Bootstrapping Problem +/// A user who has never used VoiceOver cannot navigate a VoiceOver-first interface to +/// enable it. Siri is the lowest-friction solution: they can speak the command without +/// any prior knowledge of gestures. The Siri CTA is therefore placed first. /// /// ## No Preview Mode /// There is no way to bypass this screen and enter a game without VoiceOver active. @@ -39,16 +48,22 @@ struct iOSVORequiredView: View { // MARK: - Body var body: some View { - ScrollView { - VStack(alignment: .center, spacing: RA11ySpacing.xl) { - headerSection - ctaSection - if showManualFallback { - manualFallbackSection + GeometryReader { geo in + ScrollView { + VStack(alignment: .center, spacing: RA11ySpacing.xl) { + headerSection + ctaSection + if showManualFallback { + manualFallbackSection + } } + .padding(RA11ySpacing.base) + .frame(width: geo.size.width) + .frame(maxWidth: .infinity) } - .padding(RA11ySpacing.base) + .clipped() } + .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle(String(localized: "voiceOverRequired.navigationTitle")) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) @@ -77,6 +92,7 @@ struct iOSVORequiredView: View { .font(.ra11yTitle) .bold() .multilineTextAlignment(.center) + .accessibilityIdentifier("voRequired.title") Text(String(localized: "voiceOverRequired.body")) .font(.ra11yBody) @@ -87,18 +103,58 @@ struct iOSVORequiredView: View { private var ctaSection: some View { VStack(spacing: RA11ySpacing.sm) { + // Siri is the primary path: no gestures required, works before any AT knowledge. + siriCallout + Button(String(localized: "voiceOverRequired.openSettings")) { openAccessibilitySettings() } .buttonStyle(.borderedProminent) .controlSize(.large) + .frame(maxWidth: .infinity) Button(String(localized: "voiceOverRequired.howToEnable")) { showHelpSheet = true } .buttonStyle(.bordered) .controlSize(.large) + .frame(maxWidth: .infinity) + } + } + + /// Siri shortcut callout — the lowest-friction VoiceOver enablement path for new users. + /// + /// Displayed as an informational card (not a tappable button) since Siri is invoked by + /// the user speaking the phrase, not by the app. The card gives prominence to the phrase + /// so the user knows exactly what to say. + private var siriCallout: some View { + VStack(spacing: RA11ySpacing.sm) { + HStack(spacing: RA11ySpacing.sm) { + Image(systemName: "waveform") + .font(.ra11yHeadline) + .foregroundStyle(.tint) + .accessibilityHidden(true) + Text(String(localized: "voiceOverRequired.siri.heading")) + .font(.ra11ySubheadline) + .fontWeight(.semibold) + .frame(maxWidth: .infinity, alignment: .leading) + } + Text(String(localized: "voiceOverRequired.siri.phrase")) + .font(.ra11yBody) + .italic() + .multilineTextAlignment(.center) + .padding(.horizontal, RA11ySpacing.sm) + .padding(.vertical, RA11ySpacing.xs) + .frame(maxWidth: .infinity) + .background(.quaternary, in: RoundedRectangle(cornerRadius: RA11yRadius.button)) } + .padding(RA11ySpacing.base) + .frame(maxWidth: .infinity) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: RA11yRadius.card)) + .accessibilityElement(children: .combine) + .accessibilityLabel( + "\(String(localized: "voiceOverRequired.siri.heading")). \(String(localized: "voiceOverRequired.siri.a11yLabel"))" + ) } /// Shown only when both the Accessibility deep link and the Settings fallback fail. @@ -123,7 +179,6 @@ struct iOSVORequiredView: View { /// 3. If both fail, reveal inline manual instructions. private func openAccessibilitySettings() { Task { - guard !Task.isCancelled else { return } let accessibilityURL = URL(string: "App-Prefs:root=Accessibility") if let url = accessibilityURL, await UIApplication.shared.open(url) { RA11yLogger.navigation.info("Opened Accessibility settings via deep link.") @@ -137,7 +192,6 @@ struct iOSVORequiredView: View { } RA11yLogger.navigation.error("Both Settings URLs failed; showing manual fallback.") - guard !Task.isCancelled else { return } withAnimation { showManualFallback = true } diff --git a/RA11y-iOS/RA11y-iOS/VoiceOver/iOSVoiceOverHelpSheet.swift b/RA11y-iOS/RA11y-iOS/VoiceOver/iOSVoiceOverHelpSheet.swift index 938848d..ac2b4db 100644 --- a/RA11y-iOS/RA11y-iOS/VoiceOver/iOSVoiceOverHelpSheet.swift +++ b/RA11y-iOS/RA11y-iOS/VoiceOver/iOSVoiceOverHelpSheet.swift @@ -17,19 +17,27 @@ struct iOSVoiceOverHelpSheet: View { var body: some View { NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: RA11ySpacing.lg) { - headerSection - enableStepsSection - Divider() - quickToggleSection - Divider() - scrollGesturesSection - Divider() - inAppUsageSection + GeometryReader { geo in + ScrollView { + VStack(alignment: .leading, spacing: RA11ySpacing.lg) { + headerSection + // Scroll gestures first: users often open this sheet to learn how to + // move inside RA11y before reading full enable steps. + scrollGesturesSection + Divider() + enableStepsSection + Divider() + quickToggleSection + Divider() + inAppUsageSection + } + .padding(RA11ySpacing.xl) + .frame(width: geo.size.width) + .frame(maxWidth: .infinity) } - .padding(RA11ySpacing.xl) + .clipped() } + .frame(maxWidth: .infinity, maxHeight: .infinity) .navigationTitle(String(localized: "voiceOverHelp.navigationTitle")) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/RA11y-iOS/RA11y-iOSTests/RA11y_iOSTests.swift b/RA11y-iOS/RA11y-iOSTests/RA11y_iOSTests.swift index 2e0e221..12437a1 100644 --- a/RA11y-iOS/RA11y-iOSTests/RA11y_iOSTests.swift +++ b/RA11y-iOS/RA11y-iOSTests/RA11y_iOSTests.swift @@ -142,6 +142,11 @@ actor TestStorageComponent: StorageComponent { nil } + /// Returns no stored results; routing tests do not persist game progress. + func bestResults(for gameIDs: [String]) async -> [String : GameResult] { + [:] + } + /// No-op; routing tests do not save results. func saveResultIfBetter(_ result: GameResult) async { } @@ -150,6 +155,14 @@ actor TestStorageComponent: StorageComponent { basicsCompleted } + /// Returns both Basics flags in one read for route-resolution tests. + func basicsProgressSnapshot() async -> BasicsProgressSnapshot { + BasicsProgressSnapshot( + isCompleted: basicsCompleted, + isDismissed: basicsDismissed + ) + } + /// Marks basics as completed. func markBasicsCompleted() async { basicsCompleted = true diff --git a/RA11y-iOS/RA11y-iOSUITests/RA11y_iOSScreenshots.swift b/RA11y-iOS/RA11y-iOSUITests/RA11y_iOSScreenshots.swift index 837c79f..40fd82b 100644 --- a/RA11y-iOS/RA11y-iOSUITests/RA11y_iOSScreenshots.swift +++ b/RA11y-iOS/RA11y-iOSUITests/RA11y_iOSScreenshots.swift @@ -5,54 +5,110 @@ // Screenshot capture for the `fastlane ios screenshots` lane. // // Xcode 16 changed xcresult bundle storage: screenshots are no longer plain -// PNGs inside the bundle — they are zstd-compressed blobs. `fastlane snapshot` -// (which relied on SnapshotHelper) cannot extract them. This file captures -// screenshots as `XCTAttachment` objects with `.keepAlways` lifetime; the -// fastlane lane runs `scripts/extract_screenshots.sh` after the test to -// extract them via `xcrun xcresulttool export attachments`. +// PNGs inside the bundle; they are stored as xcresult attachments. This test +// suite captures screenshots as `XCTAttachment` objects with `.keepAlways` +// lifetime, and the fastlane lane extracts them afterward via +// `scripts/extract_screenshots.sh`. +// +// ## Screens Captured +// | File | Scene ID | Root Anchor | +// |-----------------------|----------------------|----------------------| +// | 01_Hub | hub | hub.dmGreeting | +// | 02_VORequired | voRequired | voRequired.title | +// | 03_FirstRun | firstRun | firstRun.title | +// | 04_EnchanterPrologue | enchanterPrologue | enchanter.prologue | +// | 05_EnchanterAttempt | enchanterAttempt | enchanter.attempt | +// | 06_EnchanterRising | enchanterRising | enchanter.rising | +// | 07_EnchanterTimed | enchanterTimed | enchanter.timed | +// | 08_EnchanterResult | enchanterResult | gameResult.root | +// | 09_DungeonPrologue | dungeonPrologue | dungeon.prologue | +// | 10_DungeonL1 | dungeonFirstAttempt | dungeon.firstAttempt | +// | 11_DungeonResult | dungeonResult | gameResult.root | +// | 12_ResonanceMockup | resonanceMockup | resonance.mockup.root | +// +// ## Navigation Strategy +// Screenshot capture now uses deterministic app-level scene bootstrapping: +// `-uiTesting -screenshotScene `. +// +// The app renders the requested scene directly via `iOSScreenshotRootView`, so +// screenshot tests no longer depend on onboarding state, hub navigation, or +// game progression taps in order to reach the requested screen. // import XCTest -/// UITest suite that captures Hub screenshots as persistent XCT attachments. +/// UITest suite that captures all committed screenshots as persistent XCT attachments. /// /// Run via `fastlane ios screenshots`, not directly in Xcode's test runner. -/// Each `captureScreenshot(_:)` call stores a `keepAlways` PNG attachment -/// inside the xcresult bundle, which is later extracted by the lane. +/// Each `captureScreenshot(_:)` call stores a `keepAlways` PNG attachment inside the +/// xcresult bundle, which is later extracted by the lane. final class RA11y_iOSScreenshots: XCTestCase { override func setUpWithError() throws { - continueAfterFailure = false + // Keep screenshot runs resilient: one missing scene should not prevent later captures. + continueAfterFailure = true } - /// Launches the app and captures the Hub screen. + // MARK: - Pass 1: Hub + VoiceOver Required + + /// Captures the Hub and VoiceOver Required screens using deterministic scene boots. /// - /// - Concurrency: `@MainActor` — XCUIApplication interactions must run on - /// the main thread. Called by the test runner on the main actor. + /// - Concurrency: `@MainActor` — XCUIApplication interactions require the main thread. @MainActor - func testScreenshots() throws { + func testScreenshots_Hub_VORequired() { let app = XCUIApplication() - app.launchArguments.append("-uiTesting") - app.launch() + captureScene("hub", fileName: "01_Hub", anchorIdentifier: "hub.dmGreeting", in: app) + captureScene("voRequired", fileName: "02_VORequired", anchorIdentifier: "voRequired.title", in: app) + } - XCTAssertTrue( - app.staticTexts["Choose Your Trial, Adventurer"].waitForExistence(timeout: 5), - "Hub greeting did not appear. Expected the hub to be visible for screenshots." - ) - captureScreenshot("01_Hub") + // MARK: - Pass 2: First Run - let enchantersTrialCard = app.buttons["questCard.find-and-focus"] - XCTAssertTrue( - enchantersTrialCard.waitForExistence(timeout: 5), - "Enchanter's Trial card did not appear on the hub." - ) - enchantersTrialCard.tap() + /// Captures the First Run entry screen using a deterministic scene boot. + /// + /// - Concurrency: `@MainActor` — XCUIApplication interactions require the main thread. + @MainActor + func testScreenshots_FirstRun() { + let app = XCUIApplication() + captureScene("firstRun", fileName: "03_FirstRun", anchorIdentifier: "firstRun.title", in: app) + } - XCTAssertTrue( - app.otherElements["enchanter.trial"].waitForExistence(timeout: 5), - "Enchanter's Trial screen did not appear." - ) - captureScreenshot("02_EnchantersTrial") + // MARK: - Pass 3: Enchanter + + /// Captures the Enchanter screens using deterministic scene boots. + /// + /// - Concurrency: `@MainActor` — XCUIApplication interactions require the main thread. + @MainActor + func testScreenshots_Enchanter() { + let app = XCUIApplication() + captureScene("enchanterPrologue", fileName: "04_EnchanterPrologue", anchorIdentifier: "enchanter.prologue", in: app) + captureScene("enchanterAttempt", fileName: "05_EnchanterAttempt", anchorIdentifier: "enchanter.attempt", in: app) + captureScene("enchanterRising", fileName: "06_EnchanterRising", anchorIdentifier: "enchanter.rising", in: app) + captureScene("enchanterTimed", fileName: "07_EnchanterTimed", anchorIdentifier: "enchanter.timed", in: app) + captureScene("enchanterResult", fileName: "08_EnchanterResult", anchorIdentifier: "gameResult.root", in: app) + } + + // MARK: - Pass 4: Crystal Resonance (scroll hunt; `dungeon*` scene IDs) + + /// Captures the Crystal Resonance gameplay screens using deterministic scene boots. + /// + /// - Concurrency: `@MainActor` — XCUIApplication interactions require the main thread. + @MainActor + func testScreenshots_Dungeon() { + let app = XCUIApplication() + captureScene("dungeonPrologue", fileName: "09_DungeonPrologue", anchorIdentifier: "dungeon.prologue", in: app) + captureScene("dungeonFirstAttempt", fileName: "10_DungeonL1", anchorIdentifier: "dungeon.firstAttempt", in: app) + captureScene("dungeonResult", fileName: "11_DungeonResult", anchorIdentifier: "gameResult.root", in: app) + } + + // MARK: - Pass 5: Crystal Resonance mockup + + /// Captures the Resonance v2 design mockup using a deterministic scene boot. + /// + /// - Concurrency: `@MainActor` — XCUIApplication interactions require the main thread. + @MainActor + func testScreenshots_ResonanceMockup() { + let app = XCUIApplication() + captureScene("resonanceMockup", fileName: "12_ResonanceMockup", anchorIdentifier: "resonance.mockup.root", in: app) } // MARK: - Private @@ -62,7 +118,7 @@ final class RA11y_iOSScreenshots: XCTestCase { /// The attachment name becomes the suggested human-readable filename in the /// xcresult manifest, used by `extract_screenshots.sh` when renaming. /// - /// - Parameter name: Base filename without extension (e.g. `"01_Hub"`). + /// - Parameter name: Base filename without extension (for example, `"01_Hub"`). /// - Concurrency: Must be called on `@MainActor`; `XCUIScreen.main` is /// a UI API that requires the main thread. @MainActor @@ -77,4 +133,34 @@ final class RA11y_iOSScreenshots: XCTestCase { attachment.lifetime = .keepAlways add(attachment) } + + /// Launches a deterministic screenshot scene and captures it once its root anchor exists. + /// + /// - Parameters: + /// - scene: Scene identifier consumed by `-screenshotScene`. + /// - fileName: PNG basename to attach. + /// - anchorIdentifier: Stable accessibility identifier expected at the scene root. + /// - app: Shared application handle for relaunching between scenes. + /// - timeout: Maximum wait for the root anchor to appear. + /// - Concurrency: `@MainActor` because it drives XCUIApplication and screenshot APIs. + @MainActor + private func captureScene( + _ scene: String, + fileName: String, + anchorIdentifier: String, + in app: XCUIApplication, + timeout: TimeInterval = 8 + ) { + app.terminate() + app.launchArguments = ["-uiTesting", "-screenshotScene", scene] + app.launch() + + let anchor = app.descendants(matching: .any)[anchorIdentifier] + XCTAssertTrue( + anchor.waitForExistence(timeout: timeout), + "Screenshot scene \(scene) failed to render anchor \(anchorIdentifier)" + ) + guard anchor.exists else { return } + captureScreenshot(fileName) + } } diff --git a/RA11y-iOS/RA11y-iOSUITests/RA11y_iOSUITests.swift b/RA11y-iOS/RA11y-iOSUITests/RA11y_iOSUITests.swift index b57eb78..714d9ae 100644 --- a/RA11y-iOS/RA11y-iOSUITests/RA11y_iOSUITests.swift +++ b/RA11y-iOS/RA11y-iOSUITests/RA11y_iOSUITests.swift @@ -7,28 +7,35 @@ import XCTest +/// Non-screenshot UI tests (integration checks against the live app shell). +/// +/// Screenshot capture lives in `RA11y_iOSScreenshots.swift`. This suite holds +/// behavioral tests that do not attach PNGs. final class RA11y_iOSUITests: XCTestCase { override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. } override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. } + /// Verifies `-uiTesting` with `-screenshotMarkOnboardingComplete` reaches the hub + /// with a deterministic greeting (screenshot automation contract). + /// + /// - Important: Requires the hub route (`hub.dmGreeting`). @MainActor - func testExample() throws { - // UI tests must launch the application that they test. + func testScreenshotLaunchArgsReachHubWithBasicsComplete() throws { let app = XCUIApplication() + + app.launchArguments = ["-uiTesting", "-screenshotMarkOnboardingComplete"] app.launch() - // Use XCTAssert and related functions to verify your tests produce the correct results. + let greeting = app.descendants(matching: .any)["hub.dmGreeting"] + XCTAssertTrue( + greeting.waitForExistence(timeout: 15), + "Hub greeting should appear when basics are marked complete" + ) } @MainActor diff --git a/RA11y-iOS/RA11y-iOSUITests/ScreenshotRouteCatalog.md b/RA11y-iOS/RA11y-iOSUITests/ScreenshotRouteCatalog.md new file mode 100644 index 0000000..39737a8 --- /dev/null +++ b/RA11y-iOS/RA11y-iOSUITests/ScreenshotRouteCatalog.md @@ -0,0 +1,50 @@ +# Screenshot Route Catalog + +This file is the single source of truth for screenshot coverage in the iOS UI test suite. + +## Purpose +- Define the screens currently considered completed and required for fastlane export. +- Document the launch arguments, scene identifiers, root accessibility identifiers, and capture paths. +- Keep `RA11y_iOSScreenshots.swift`, `App/iOSScreenshotScene.swift`, and `fastlane/Fastfile` aligned. + +## Required Screens (Current) + +| File | Test Method | Launch Args | Scene ID | Anchor Identifier | Navigation Path | +|---|---|---|---|---|---| +| `01_Hub` | `testScreenshots_Hub_VORequired` | `-uiTesting -screenshotScene hub` | `hub` | `hub.dmGreeting` | Direct scene boot | +| `02_VORequired` | `testScreenshots_Hub_VORequired` | `-uiTesting -screenshotScene voRequired` | `voRequired` | `voRequired.title` | Direct scene boot | +| `03_FirstRun` | `testScreenshots_FirstRun` | `-uiTesting -screenshotScene firstRun` | `firstRun` | `firstRun.title` | Direct scene boot | +| `04_EnchanterPrologue` | `testScreenshots_Enchanter` | `-uiTesting -screenshotScene enchanterPrologue` | `enchanterPrologue` | `enchanter.prologue` | Direct scene boot | +| `05_EnchanterAttempt` | `testScreenshots_Enchanter` | `-uiTesting -screenshotScene enchanterAttempt` | `enchanterAttempt` | `enchanter.attempt` | Direct scene boot | +| `06_EnchanterRising` | `testScreenshots_Enchanter` | `-uiTesting -screenshotScene enchanterRising` | `enchanterRising` | `enchanter.rising` | Direct scene boot | +| `07_EnchanterTimed` | `testScreenshots_Enchanter` | `-uiTesting -screenshotScene enchanterTimed` | `enchanterTimed` | `enchanter.timed` | Direct scene boot | +| `08_EnchanterResult` | `testScreenshots_Enchanter` | `-uiTesting -screenshotScene enchanterResult` | `enchanterResult` | `gameResult.root` | Direct scene boot | +| `09_DungeonPrologue` | `testScreenshots_Dungeon` | `-uiTesting -screenshotScene dungeonPrologue` | `dungeonPrologue` | `dungeon.prologue` | Direct scene boot | +| `10_DungeonL1` | `testScreenshots_Dungeon` | `-uiTesting -screenshotScene dungeonFirstAttempt` | `dungeonFirstAttempt` | `dungeon.firstAttempt` | Direct scene boot | +| `11_DungeonResult` | `testScreenshots_Dungeon` | `-uiTesting -screenshotScene dungeonResult` | `dungeonResult` | `gameResult.root` | Direct scene boot | +| `12_ResonanceMockup` | `testScreenshots_ResonanceMockup` | `-uiTesting -screenshotScene resonanceMockup` | `resonanceMockup` | `resonance.mockup.root` | Direct scene boot | + +Screens `09`–`11` use legacy **`Dungeon*`** file basenames and `dungeon*` scene IDs for contract stability; in-app they present **Crystal Resonance** (Scroll Hunt). + +## Deferred Screens +- Rogue screenshots remain deferred until the Rogue flow is stable enough for deterministic automation. + +## UI integration tests (non-screenshot) + +These tests live in `RA11y_iOSUITests.swift`. They do **not** attach PNGs and are **not** listed in the Fastfile screenshot allowlist. Run with `utility/build_and_test.sh --only-ios --include-ui-tests` (or `-only-testing` a single method). + +| Purpose | Test method | Launch arguments | Anchor identifier | +|---|---|---|---| +| Screenshot-style launch with basics complete reaches hub greeting | `testScreenshotLaunchArgsReachHubWithBasicsComplete` | `-uiTesting` and `-screenshotMarkOnboardingComplete` | `hub.dmGreeting` | + +`RA11y_iOSApp` removes the Lights Off UserDefaults key when those arguments are present; this test enables the toggle, terminates, relaunches with the same args, and asserts the toggle is off. + +## Identifier Rules +- Every captured screen MUST have a stable root accessibility identifier. +- Screenshot tests MUST wait on the documented root anchor before attaching a PNG. +- Avoid text-based queries for navigation-critical capture checks. + +## Fastlane Integration Contract +- `fastlane/Fastfile` must run only an allowlist of completed test methods. +- `fastlane/Fastfile` must fail if any screenshot listed in this file is missing after extraction. +- If a new screenshot test is added, update this file, `App/iOSScreenshotScene.swift`, and the Fastfile allowlist together in one commit. diff --git a/RA11y.xcworkspace/xcuserdata/josephmccraw.xcuserdatad/UserInterfaceState.xcuserstate b/RA11y.xcworkspace/xcuserdata/josephmccraw.xcuserdatad/UserInterfaceState.xcuserstate index 863eb34..19e957a 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/Feedback/QuestFeedbackProfile.swift b/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackProfile.swift new file mode 100644 index 0000000..de56b76 --- /dev/null +++ b/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackProfile.swift @@ -0,0 +1,83 @@ +/// Reusable quest feedback configuration consumed by platform renderers. +/// +/// Each quest can supply its own profile while still using the same reducer and +/// coordinator architecture. +public struct QuestFeedbackProfile: Equatable, Sendable { + /// Human-readable profile name for logging and debugging. + public let name: String + + /// Cue used when entering the warm band. + public let warmCue: QuestFeedbackCue + + /// Cue used when entering the near band. + public let nearCue: QuestFeedbackCue + + /// Cue used when the activation lock is acquired. + public let lockCue: QuestFeedbackCue + + /// Cue used when the activation lock is lost. + public let lockLostCue: QuestFeedbackCue + + /// Cue used when the player activates the wrong object. + public let wrongActivationCue: QuestFeedbackCue + + /// Cue used when the player succeeds. + public let successCue: QuestFeedbackCue + + /// Cue used when the player times out. + public let timeoutCue: QuestFeedbackCue + + /// Cue used when the player requests help. + public let hintCue: QuestFeedbackCue + + /// Creates a reusable profile. + public init( + name: String, + warmCue: QuestFeedbackCue, + nearCue: QuestFeedbackCue, + lockCue: QuestFeedbackCue, + lockLostCue: QuestFeedbackCue, + wrongActivationCue: QuestFeedbackCue, + successCue: QuestFeedbackCue, + timeoutCue: QuestFeedbackCue, + hintCue: QuestFeedbackCue + ) { + self.name = name + self.warmCue = warmCue + self.nearCue = nearCue + self.lockCue = lockCue + self.lockLostCue = lockLostCue + self.wrongActivationCue = wrongActivationCue + self.successCue = successCue + self.timeoutCue = timeoutCue + self.hintCue = hintCue + } +} + +public extension QuestFeedbackProfile { + /// Resonance-oriented cue mapping for Crystal Resonance (Scroll Hunt) v2. + static let dungeonResonance = QuestFeedbackProfile( + name: "dungeonResonance", + warmCue: QuestFeedbackCue(audio: .resonance, haptic: .softTick, cooldownSeconds: 0.25), + nearCue: QuestFeedbackCue(audio: .resonance, haptic: .proximityPulse, cooldownSeconds: 0.18), + lockCue: QuestFeedbackCue(audio: .resonance, haptic: .alignmentSnap, cooldownSeconds: 0.0), + lockLostCue: QuestFeedbackCue(audio: .warningPulse, haptic: .softTick, cooldownSeconds: 0.1), + 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) + ) + + /// Light-touch profile suitable for calmer, tutorial-heavy quests. + static let calmGuidance = QuestFeedbackProfile( + name: "calmGuidance", + warmCue: QuestFeedbackCue(audio: .none, haptic: .softTick, cooldownSeconds: 0.35), + nearCue: QuestFeedbackCue(audio: .none, haptic: .proximityPulse, cooldownSeconds: 0.25), + lockCue: QuestFeedbackCue(audio: .none, haptic: .alignmentSnap, cooldownSeconds: 0.0), + lockLostCue: QuestFeedbackCue(audio: .none, haptic: .softTick, cooldownSeconds: 0.15), + 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) + ) +} diff --git a/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackReducer.swift b/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackReducer.swift new file mode 100644 index 0000000..e9e2fc3 --- /dev/null +++ b/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackReducer.swift @@ -0,0 +1,64 @@ +/// Pure reducer that converts semantic gameplay inputs into reusable feedback intents. +/// +/// The reducer is intentionally platform-agnostic. It should be fed with semantic state +/// changes, not raw scroll deltas, so platform renderers can stay small and reusable. +public enum QuestFeedbackReducer { + + /// Applies one semantic input to the reducer state and returns any feedback intents + /// that should be rendered for the transition. + /// + /// - Parameters: + /// - state: Mutable reducer state. + /// - input: Semantic gameplay input. + /// - Returns: Zero or more feedback intents for the transition. + public static func reduce( + state: inout QuestFeedbackState, + input: QuestFeedbackInput + ) -> [QuestFeedbackIntent] { + switch input { + case .alignmentBandChanged(let newBand): + return reduceBandChange(state: &state, newBand: newBand) + case .wrongActivation: + return [.wrongActivation] + case .success: + return [.success] + case .timeout: + return [.timeout] + case .hintRequested: + return [.hint] + } + } + + private static func reduceBandChange( + state: inout QuestFeedbackState, + newBand: QuestFeedbackBand + ) -> [QuestFeedbackIntent] { + let previous = state.currentBand + state.currentBand = newBand + + guard previous != newBand else { return [] } + + switch (previous, newBand) { + case (_, .far): + if previous == .locked { + return [.lockLost] + } + return [] + + case (_, .warm): + if previous == .locked { + return [.lockLost, .proximityEntered(.warm)] + } + return [.proximityEntered(.warm)] + + case (_, .near): + if previous == .locked { + return [.lockLost, .proximityEntered(.near)] + } + return [.proximityEntered(.near)] + + case (_, .locked): + return [.lockAcquired] + } + } +} diff --git a/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackTypes.swift b/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackTypes.swift new file mode 100644 index 0000000..85669f1 --- /dev/null +++ b/RA11yCore/Sources/RA11yCore/Feedback/QuestFeedbackTypes.swift @@ -0,0 +1,110 @@ +/// Semantic proximity bands shared across quests that use guided positioning. +/// +/// The bands are intentionally generic so games can reuse the same feedback engine +/// without hardcoding quest-specific metaphors such as "resonance" or "seal lock". +public enum QuestFeedbackBand: Int, CaseIterable, Codable, Hashable, Sendable { + case far + case warm + case near + case locked +} + +/// High-level feedback events emitted by game state reducers. +/// +/// These intents are platform-agnostic and safe to compute in `RA11yCore`. +/// Platform targets map them to concrete haptic and audio rendering behavior. +public enum QuestFeedbackIntent: Equatable, Sendable { + /// The player entered a meaningful proximity band. + case proximityEntered(QuestFeedbackBand) + /// The player reached the activation window. + case lockAcquired + /// The player left the activation window after being locked. + case lockLost + /// The player attempted an incorrect activation. + case wrongActivation + /// The player completed the objective successfully. + case success + /// The player ran out of time or otherwise failed the challenge. + case timeout + /// The player explicitly requested help. + case hint +} + +/// Reducer inputs that drive feedback-state transitions. +/// +/// Games should reduce raw geometry or gameplay events into these semantic inputs rather +/// than emitting platform feedback directly from scroll handlers. +public enum QuestFeedbackInput: Equatable, Sendable { + /// Alignment band derived from current gameplay geometry. + case alignmentBandChanged(QuestFeedbackBand) + /// The player activated the wrong object or trigger. + case wrongActivation + /// The player completed the objective. + case success + /// The challenge timed out or failed. + case timeout + /// The player explicitly requested a hint. + case hintRequested +} + +/// Internal reducer state used to prevent feedback spam and preserve transition semantics. +public struct QuestFeedbackState: Equatable, Sendable { + /// The current reduced proximity band, if one has been established. + public var currentBand: QuestFeedbackBand? + + /// Creates a new feedback state. + /// + /// - Parameter currentBand: Initial proximity band when restoring or testing state. + public init(currentBand: QuestFeedbackBand? = nil) { + self.currentBand = currentBand + } +} + +/// Lightweight cue family identifiers that platform targets can map to concrete renderers. +public enum QuestFeedbackAudioFamily: String, Codable, Hashable, Sendable { + case none + case resonance + case mutedError + case crystallineSuccess + case warningPulse + case hintChime +} + +/// Lightweight haptic-family identifiers that platform targets can map to concrete generators. +public enum QuestFeedbackHapticFamily: String, Codable, Hashable, Sendable { + case none + case softTick + case proximityPulse + case alignmentSnap + case errorTap + case successPulse + case warningTap +} + +/// A reusable cue definition for one semantic feedback event. +public struct QuestFeedbackCue: Equatable, Sendable { + /// The audio family to render. + public let audio: QuestFeedbackAudioFamily + + /// The haptic family to render. + public let haptic: QuestFeedbackHapticFamily + + /// Suggested debounce window in seconds before replaying the same semantic cue. + public let cooldownSeconds: Double + + /// Creates a cue definition. + /// + /// - Parameters: + /// - audio: Audio family identifier. + /// - haptic: Haptic family identifier. + /// - cooldownSeconds: Suggested replay debounce interval. + public init( + audio: QuestFeedbackAudioFamily, + haptic: QuestFeedbackHapticFamily, + cooldownSeconds: Double + ) { + self.audio = audio + self.haptic = haptic + self.cooldownSeconds = cooldownSeconds + } +} diff --git a/RA11yCore/Sources/RA11yCore/GameCatalog/GameDefinition.swift b/RA11yCore/Sources/RA11yCore/GameCatalog/GameDefinition.swift index bc165be..cb744cd 100644 --- a/RA11yCore/Sources/RA11yCore/GameCatalog/GameDefinition.swift +++ b/RA11yCore/Sources/RA11yCore/GameCatalog/GameDefinition.swift @@ -10,7 +10,7 @@ public enum GameKind: String, Hashable, Sendable, Codable, CaseIterable { case findAndFocus /// Activate — Bomb Defusal drill training double-tap activation on the correct control. case activateDoubleTap - /// Scroll Hunt — Dungeon Crawl drill training scrolling to reveal hidden content. + /// Scroll Hunt — Crystal Resonance drill training three-finger scrolling to reveal hidden content. case scrollHunt } @@ -42,6 +42,12 @@ public struct GameDefinition: Sendable, Identifiable { /// Primary thumbnail asset name. Placeholder accepted at M1; replaced by M5. public let thumbnailAssetName: String + /// The stable `GameDefinition.id` that must be completed before this game is unlocked. + /// + /// `nil` means the game is always available (i.e. it is the first in the sequence). + /// The hub uses this to gate cards and communicate ordering to the player. + public let prerequisiteID: String? + /// - Parameters: /// - id: Stable storage key — must never change post-ship. /// - titleKey: Localization key for display title. @@ -49,13 +55,15 @@ public struct GameDefinition: Sendable, Identifiable { /// - estimatedDuration: Human-readable duration string. /// - kind: Game logic and routing kind. /// - thumbnailAssetName: Asset catalog name for the hub thumbnail. + /// - prerequisiteID: Stable ID of the game that must be completed first, or `nil`. public init( id: String, titleKey: String, goalKey: String, estimatedDuration: String, kind: GameKind, - thumbnailAssetName: String + thumbnailAssetName: String, + prerequisiteID: String? = nil ) { self.id = id self.titleKey = titleKey @@ -63,6 +71,7 @@ public struct GameDefinition: Sendable, Identifiable { self.estimatedDuration = estimatedDuration self.kind = kind self.thumbnailAssetName = thumbnailAssetName + self.prerequisiteID = prerequisiteID } } @@ -77,6 +86,16 @@ public struct GameDefinition: Sendable, Identifiable { public enum GameCatalog { /// All MVP games in display order. + /// + /// 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. + /// + /// Thumbnails use dedicated square hub icons (`*_hub_icon`) designed for + /// the dark quest card at ~72–96 pt. Background scene images and individual + /// sprite assets are not used here as they either crop poorly or have + /// white backgrounds that clash with the dark card surface. public static let all: [GameDefinition] = [ GameDefinition( id: "find-and-focus", @@ -84,15 +103,8 @@ public enum GameCatalog { goalKey: "game.findAndFocus.goal", estimatedDuration: "~5 min", kind: .findAndFocus, - thumbnailAssetName: "enchanter_relic_target" - ), - GameDefinition( - id: "activate-double-tap", - titleKey: "game.activateDoubleTap.title", - goalKey: "game.activateDoubleTap.goal", - estimatedDuration: "~5 min", - kind: .activateDoubleTap, - thumbnailAssetName: "rogue_seal_target" + thumbnailAssetName: "enchanter_hub_icon", + prerequisiteID: nil ), GameDefinition( id: "scroll-hunt", @@ -100,7 +112,17 @@ public enum GameCatalog { goalKey: "game.scrollHunt.goal", estimatedDuration: "~7 min", kind: .scrollHunt, - thumbnailAssetName: "dungeon_room_entrance" + thumbnailAssetName: "dungeon_hub_icon", + 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", + prerequisiteID: "scroll-hunt" ), ] diff --git a/RA11yCore/Sources/RA11yCore/GameSession/GameSessionCoordinator.swift b/RA11yCore/Sources/RA11yCore/GameSession/GameSessionCoordinator.swift index d1ba7c5..4ab8f67 100644 --- a/RA11yCore/Sources/RA11yCore/GameSession/GameSessionCoordinator.swift +++ b/RA11yCore/Sources/RA11yCore/GameSession/GameSessionCoordinator.swift @@ -54,6 +54,11 @@ public final class GameSessionCoordinator { private let voiceOverProvider: any VoiceOverStateProvider /// Task that observes VoiceOver state changes during an active session. + /// + /// Plain stored property — no isolation modifier required. The monitoring closure + /// captures `[weak self]` and re-checks `self` inside the `for await` loop so the + /// running Task does not retain the coordinator. `stopMonitoring()` provides eager + /// cancellation when the session completes normally. private var monitorTask: Task? // MARK: - Init @@ -76,40 +81,38 @@ public final class GameSessionCoordinator { self.voiceOverProvider = voiceOverProvider } - deinit { - Task { @MainActor [weak self] in - self?.monitorTask?.cancel() - self?.monitorTask = nil - } - } - // MARK: - Monitoring Lifecycle /// Begins monitoring VoiceOver state changes for the active session. /// - /// Call this when gameplay starts (e.g., in a SwiftUI `.task` modifier). - /// The monitor is one-shot: once VoiceOver goes off mid-session, the task + /// Call this when gameplay starts (e.g., directly after `session.start()`). + /// The monitor is one-shot: once VoiceOver goes off mid-session the task /// abandons the session and exits. If the session completes normally, - /// `stopMonitoring()` or `deinit` cleans up the task. + /// `stopMonitoring()` cleans up the task. + /// + /// The closure captures `[weak self]` and re-evaluates `self` on each loop + /// iteration to avoid retaining the coordinator for the lifetime of the task. + /// `voiceOverProvider` is captured strongly by value so the sequence remains + /// live even if the coordinator is released between iterations. /// /// Idempotent — calling when already monitoring replaces the previous task. public func startMonitoring() { monitorTask?.cancel() - monitorTask = Task { @MainActor [weak self] in - guard let self else { return } - - for await isRunning in voiceOverProvider.stateChanges { - guard !Task.isCancelled else { break } // stopMonitoring() was called + monitorTask = Task { @MainActor [weak self, provider = voiceOverProvider] in + for await isRunning in provider.stateChanges { + // Re-acquire self and check cancellation on every event. + // If the coordinator was released or stopMonitoring() was called, exit. + guard let self, !Task.isCancelled else { return } guard !isRunning else { continue } // VO still on — keep watching - let currentState = await session.state + let currentState = await self.session.state guard currentState == .running || currentState == .paused else { break // session already in a terminal state — nothing to do } - await session.abandon() - voiceOverDisabledMidGame = true + await self.session.abandon() + self.voiceOverDisabledMidGame = true RA11yLogger.voiceOver.info("VoiceOver disabled mid-session for \(self.gameKind.rawValue); session abandoned.") break // one-shot: exit after handling the event } diff --git a/RA11yCore/Sources/RA11yCore/Hub/HubViewModel.swift b/RA11yCore/Sources/RA11yCore/Hub/HubViewModel.swift index f44ce8b..7877abf 100644 --- a/RA11yCore/Sources/RA11yCore/Hub/HubViewModel.swift +++ b/RA11yCore/Sources/RA11yCore/Hub/HubViewModel.swift @@ -4,85 +4,46 @@ import Observation /// Observable view model driving the game hub screen. /// -/// Exposes: -/// - `showHelpAffordance` — whether the "Enable VoiceOver" banner is visible. -/// Derived from live VoiceOver state via `VoiceOverStateProvider`. -/// - `bestResults` — a map of game ID → best `GameRank` for each played game. -/// Loaded asynchronously from `StorageComponent` on init; refreshable after a -/// game session completes via `refreshBestResults()`. +/// Owns best-result data for all catalog games. VoiceOver state is intentionally +/// not observed here — the hub view reads `@Environment(\.accessibilityVoiceOverEnabled)` +/// directly, letting SwiftUI propagate state changes automatically without any custom +/// `AsyncStream` or `NotificationCenter` subscription. /// -/// The hub view uses `if viewModel.showHelpAffordance { ... }` (not `.hidden()`) -/// to ensure the affordance is fully removed from the view hierarchy — and -/// therefore not focusable by VoiceOver — when VoiceOver is active. +/// `bestResults` is loaded via `refreshBestResults()`, which the hub view calls +/// from its `.task` modifier on every appearance (initial load and return from a game). /// /// ## Concurrency -/// `@MainActor` isolation ensures all property mutations are serialized on the -/// main thread, keeping the `@Observable` observation graph consistent with SwiftUI. -/// The VoiceOver observation task and the initial results-loading task are both -/// confined to `@MainActor`. +/// `@MainActor` isolation ensures all property mutations are serialized on the main +/// thread, consistent with SwiftUI's `@Observable` observation graph. @Observable @MainActor public final class HubViewModel { // MARK: - Public State - /// Whether the "How to enable VoiceOver" help affordance should be shown. - /// - /// `true` when VoiceOver is OFF — the user may need guidance to enable it. - /// `false` when VoiceOver is ON — the affordance is removed from the hierarchy. - public private(set) var showHelpAffordance: Bool - /// Best rank achieved per game ID, keyed by `GameDefinition.id`. /// /// An absent key means the game has not been played (displays "Quest Awaits"). - /// Populated asynchronously on init; updated by calling `refreshBestResults()`. + /// Populated by `refreshBestResults()`, which the hub view drives via `.task`. public private(set) var bestResults: [String: GameRank] = [:] // MARK: - Private private let storage: any StorageComponent - /// Task that observes VoiceOver state changes for this view model. - private var stateObservationTask: Task? - // MARK: - Init - /// Creates a view model subscribed to the given VoiceOver provider and storage. + /// Creates a view model backed by the given storage component. /// - /// - `showHelpAffordance` is set synchronously from `provider.isVoiceOverRunning`. - /// - Subsequent VoiceOver changes are delivered via `provider.stateChanges`. - /// - `bestResults` is populated asynchronously from `storage` on init. + /// `bestResults` starts empty; call `refreshBestResults()` (or let the hub + /// view's `.task` do so) to populate from storage. /// - /// - Parameters: - /// - voiceOverProvider: Source of VoiceOver state. Inject - /// `iOSLiveVoiceOverStateProvider()` in production and - /// `StubVoiceOverStateProvider` in tests. - /// - storage: Persistence layer for best results. Inject - /// `UserDefaultsStorageComponent()` in production and - /// `InMemoryStorageComponent` in tests. - public init(voiceOverProvider: some VoiceOverStateProvider, storage: any StorageComponent) { + /// - Parameter storage: Persistence layer for best results. Inject + /// `UserDefaultsStorageComponent()` in production and + /// `InMemoryStorageComponent` in tests. + public init(storage: any StorageComponent) { self.storage = storage - self.showHelpAffordance = !voiceOverProvider.isVoiceOverRunning - - RA11yLogger.startup.debug("HubViewModel.init — voiceOverRunning: \(voiceOverProvider.isVoiceOverRunning)") - - stateObservationTask = Task { @MainActor [weak self] in - for await isRunning in voiceOverProvider.stateChanges { - guard !Task.isCancelled else { break } - self?.showHelpAffordance = !isRunning - } - } - - Task { @MainActor [weak self] in - await self?.loadBestResults() - } - } - - deinit { - Task { @MainActor [weak self] in - self?.stateObservationTask?.cancel() - self?.stateObservationTask = nil - } + RA11yLogger.startup.debug("HubViewModel.init") } // MARK: - Public API @@ -96,6 +57,33 @@ public final class HubViewModel { bestResults[gameID] } + /// Whether the given game is unlocked and available to start. + /// + /// A game with no `prerequisiteID` is always unlocked. A game with a prerequisite + /// is unlocked as soon as the prerequisite has been completed at any rank. + /// + /// The hub uses this to render locked cards for games the player has not yet + /// earned access to, enforcing the pedagogical sequence. + /// + /// - Parameter game: The `GameDefinition` to evaluate. + /// - Returns: `true` if the game can be started. + public func isUnlocked(_ game: GameDefinition) -> Bool { + guard let prerequisiteID = game.prerequisiteID else { return true } + return bestResults[prerequisiteID] != nil + } + + /// The `GameDefinition` whose completion would unlock `game`, or `nil` if `game` + /// is already unlocked or has no prerequisite. + /// + /// Used by the hub to display "Complete [prerequisite title] to unlock" messaging. + /// + /// - Parameter game: The locked game. + /// - Returns: The predecessor `GameDefinition`, or `nil`. + public func prerequisite(for game: GameDefinition) -> GameDefinition? { + guard let prerequisiteID = game.prerequisiteID, !isUnlocked(game) else { return nil } + return GameCatalog.definition(for: prerequisiteID) + } + /// Reloads best results from storage. /// /// Call this after a game session completes and the user returns to the hub, @@ -103,8 +91,8 @@ public final class HubViewModel { /// /// ## Concurrency /// `@MainActor` — safe to call from SwiftUI `.task` or `.onAppear`. - /// Internally awaits `storage.bestResult(for:)` per game, hopping to the - /// storage actor and back for each call. + /// Internally uses `storage.bestResults(for:)` so the hub refresh pays one + /// storage hop for the whole catalog instead of one hop per game. public func refreshBestResults() async { await loadBestResults() } @@ -113,9 +101,8 @@ public final class HubViewModel { /// Iterates all catalog games and loads their best result from storage. /// - /// Runs on `@MainActor`; awaits the storage actor for each game read. - /// Each `await` is a cross-actor hop to `UserDefaultsStorageComponent` and back. - /// Writes `bestResults` atomically after all reads complete. + /// Runs on `@MainActor`; awaits the storage actor once for the whole catalog and + /// writes `bestResults` atomically after the batch read completes. /// /// ## Startup Instrumentation /// Emits a `hubResultsLoad` signpost interval covering all storage reads. @@ -127,11 +114,12 @@ public final class HubViewModel { RA11yLogger.startup.debug("hubResultsLoad started — \(GameCatalog.all.count) games") + let storedResults = await storage.bestResults(for: GameCatalog.all.map(\.id)) + var results: [String: GameRank] = [:] - for game in GameCatalog.all { - if let result = await storage.bestResult(for: game.id) { - results[game.id] = result.rank - } + results.reserveCapacity(storedResults.count) + for (gameID, result) in storedResults { + results[gameID] = result.rank } bestResults = results diff --git a/RA11yCore/Sources/RA11yCore/Logging/RA11yLogger.swift b/RA11yCore/Sources/RA11yCore/Logging/RA11yLogger.swift index a10b2b3..3c19e08 100644 --- a/RA11yCore/Sources/RA11yCore/Logging/RA11yLogger.swift +++ b/RA11yCore/Sources/RA11yCore/Logging/RA11yLogger.swift @@ -1,3 +1,4 @@ +import Foundation import OSLog /// Provides named OSLog loggers for each RA11y subsystem. @@ -9,6 +10,7 @@ import OSLog /// ## Usage /// ```swift /// RA11yLogger.navigation.debug("Pushing route: hub") +/// RA11yLogger.scrollInteraction.debug("lane scroll offset …") /// RA11yLogger.storage.error("Failed to persist result: \(error)") /// ``` /// @@ -43,12 +45,21 @@ public enum RA11yLogger { /// Score evaluation and best-result comparisons. public static let scoring = Logger(subsystem: subsystem, category: "scoring") + /// Cross-quest haptic and audio feedback coordination. + public static let feedback = Logger(subsystem: subsystem, category: "feedback") + /// App startup phase milestones and hangs. /// /// Logs key moments from cold start through the hub becoming interactive. /// Visible in Console.app filtered by subsystem + category = "startup". public static let startup = Logger(subsystem: subsystem, category: "startup") + /// VoiceOver scroll routing, `ScrollView` focus moves, and resonance alignment telemetry. + /// + /// Filter in Console.app: `subsystem:com.showblender.RA11y category:scrollInteraction` + /// Use when diagnosing three-finger scroll and moonstone alignment issues on device or Simulator. + public static let scrollInteraction = Logger(subsystem: subsystem, category: "scrollInteraction") + /// Timed interval signposter for startup phases. /// /// Produces intervals visible in Instruments → "Points of Interest" instrument. @@ -59,4 +70,19 @@ public enum RA11yLogger { /// `OSSignposter` is safe to use across actor boundaries; `beginInterval` /// and `endInterval` may be called from any isolation context. public static let startupSignposter = OSSignposter(subsystem: subsystem, category: "startup") + + /// Bracketed wall-clock time plus monotonic system uptime for correlating startup + /// log lines in Console.app when diagnosing hangs (attach timestamps to each milestone). + /// + /// Uptime restarts on device reboot and is unaffected by user date changes. + /// + /// - Note: Allocates a fresh `ISO8601DateFormatter` per call so the package stays + /// free of non-`Sendable` static formatter state under Swift 6. + public static func startupTimestampTag() -> String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let wall = formatter.string(from: Date()) + let uptime = ProcessInfo.processInfo.systemUptime + return "[\(wall) uptime=\(String(format: "%.3f", uptime))s]" + } } diff --git a/RA11yCore/Sources/RA11yCore/RA11yCore.swift b/RA11yCore/Sources/RA11yCore/RA11yCore.swift index ee898cb..d2d8deb 100644 --- a/RA11yCore/Sources/RA11yCore/RA11yCore.swift +++ b/RA11yCore/Sources/RA11yCore/RA11yCore.swift @@ -4,3 +4,4 @@ /// - `RA11ySpacing` / `RA11yRadius` — layout token enums /// - `Font.ra11y*` extensions — semantic Dynamic Type font styles /// - `RA11yLogger` — categorized OSLog subsystem loggers +/// - `QuestFeedback*` — reusable semantic feedback primitives for audio/haptics diff --git a/RA11yCore/Sources/RA11yCore/Scoring/RankThresholds.swift b/RA11yCore/Sources/RA11yCore/Scoring/RankThresholds.swift index 4b9f5b7..bb3366e 100644 --- a/RA11yCore/Sources/RA11yCore/Scoring/RankThresholds.swift +++ b/RA11yCore/Sources/RA11yCore/Scoring/RankThresholds.swift @@ -52,7 +52,7 @@ public struct RankThresholds: Sendable { /// /// - Parameters: /// - timeSeconds: Elapsed active session time. - /// - mistakes: Total mistakes recorded. + /// - mistakes: Total mistakes recorded (include bucket mistakes via `bucketMistakes()`). /// - Returns: The highest rank the session qualifies for, or `.failed`. public func evaluate(timeSeconds: Double, mistakes: Int) -> GameRank { guard timeSeconds <= timeoutSeconds else { return .failed } @@ -61,39 +61,75 @@ public struct RankThresholds: Sendable { if timeSeconds <= okMaxTime && mistakes <= okMaxMistakes { return .ok } return .failed } + + // MARK: - Bucket Mistake Calculation + + /// Computes time-based "bucket" mistake penalties per `GameSpec-FindAndFocus.txt`. + /// + /// The first bucket (0–`bucketSize` seconds) is free; each subsequent full bucket + /// beyond that adds one penalty mistake. Used by the Enchanter's Trial (and any + /// future game that uses bucket penalties) to add time-pressure mistakes to the session + /// before calling `GameSession.complete()`. + /// + /// ## Spec Reference + /// > "Bucket size: 10 seconds. Each full 10s bucket beyond the first adds +1 mistake. + /// > Example: 0–10s → +0; 10–20s → +1; 20–30s → +2." + /// + /// - Parameters: + /// - timeSeconds: Elapsed time at completion. + /// - bucketSize: Size of each bucket in seconds. Defaults to 10. + /// - Returns: Number of bucket-penalty mistakes to record (≥ 0). + public static func bucketMistakes(timeSeconds: Double, bucketSize: Double = 10) -> Int { + guard timeSeconds > 0, bucketSize > 0 else { return 0 } + // ceil(t / bucketSize) gives the number of buckets touched. + // Subtract 1 for the free first bucket. + return max(0, Int(ceil(timeSeconds / bucketSize)) - 1) + } } // MARK: - Per-Game Presets public extension RankThresholds { - /// Thresholds for Find & Focus (Game 1 — Simon Says). + /// Thresholds for Find & Focus — The Enchanter's Trial (Game 1). /// - /// Perfect: ≤15s, 0 mistakes. Timeout: 45s. + /// Per `GameSpec-FindAndFocus.txt` and `GameRules-MVP.txt`: + /// - Legendary (Perfect): 0 mistakes, ≤10s + /// - Skilled (Good): ≤1 mistake, ≤20s + /// - Novice (Ok): completed, ≤45s (matches L3 timeout ceiling) + /// - Defeated (Failed): timed out OR ≥5 mistakes (okMaxMistakes = 4 captures the boundary) static let findAndFocus = RankThresholds( timeoutSeconds: 45, - perfectMaxTime: 15, perfectMaxMistakes: 0, - goodMaxTime: 25, goodMaxMistakes: 1, - okMaxTime: 45, okMaxMistakes: 2 + perfectMaxTime: 10, perfectMaxMistakes: 0, + goodMaxTime: 20, goodMaxMistakes: 1, + okMaxTime: 45, okMaxMistakes: 4 ) - /// Thresholds for Activate (Game 2 — Bomb Defusal). + /// Thresholds for Activate — The Rogue's Gauntlet (Game 2). /// - /// Perfect: ≤20s, 0 mistakes. Timeout: 60s. + /// 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: 60, - perfectMaxTime: 20, perfectMaxMistakes: 0, - goodMaxTime: 35, goodMaxMistakes: 1, - okMaxTime: 60, okMaxMistakes: 2 + timeoutSeconds: 40, + perfectMaxTime: 8, perfectMaxMistakes: 0, + goodMaxTime: 16, goodMaxMistakes: 1, + okMaxTime: 40, okMaxMistakes: 4 ) - /// Thresholds for Scroll Hunt (Game 3 — Dungeon Crawl). + /// Thresholds for Scroll Hunt — Crystal Resonance (Game 3). /// - /// Perfect: ≤15s, 0 mistakes. Timeout: 60s. + /// Per `GameSpec-ScrollHunt.txt` and `GameRules-MVP.txt`: + /// - Legendary (Perfect): 0 mistakes, ≤15s + /// - Skilled (Good): ≤1 mistake, ≤30s + /// - Novice (Ok): completed, ≤60s + /// - Defeated (Failed): timed out OR ≥6 mistakes static let scrollHunt = RankThresholds( timeoutSeconds: 60, perfectMaxTime: 15, perfectMaxMistakes: 0, goodMaxTime: 30, goodMaxMistakes: 1, - okMaxTime: 60, okMaxMistakes: 2 + okMaxTime: 60, okMaxMistakes: 5 ) } diff --git a/RA11yCore/Sources/RA11yCore/Storage/StorageComponent.swift b/RA11yCore/Sources/RA11yCore/Storage/StorageComponent.swift index 74191f1..7491a39 100644 --- a/RA11yCore/Sources/RA11yCore/Storage/StorageComponent.swift +++ b/RA11yCore/Sources/RA11yCore/Storage/StorageComponent.swift @@ -1,3 +1,28 @@ +// MARK: - BasicsProgressSnapshot + +/// Snapshot of the first-run Basics flags used during startup routing. +/// +/// Reading both flags in one storage call avoids repeated actor hops on launch and +/// keeps route resolution deterministic. +public struct BasicsProgressSnapshot: Sendable { + + /// Whether the player completed the VoiceOver Basics sequence. + public let isCompleted: Bool + + /// Whether the player dismissed the first-run prompt without completing Basics. + public let isDismissed: Bool + + /// Creates a snapshot of the current Basics flags. + /// + /// - Parameters: + /// - isCompleted: `true` when Basics was completed. + /// - isDismissed: `true` when the prompt was dismissed. + public init(isCompleted: Bool, isDismissed: Bool) { + self.isCompleted = isCompleted + self.isDismissed = isDismissed + } +} + // MARK: - StorageComponent /// Abstraction over result and progress persistence. @@ -32,12 +57,27 @@ public protocol StorageComponent: AnyObject, Sendable { /// Returns whether the VoiceOver Basics sequence has been completed. func isBasicsCompleted() async -> Bool + /// Returns both first-run Basics flags in a single storage read. + /// + /// This is the preferred startup API because it avoids multiple cross-actor + /// hops during route resolution. + func basicsProgressSnapshot() async -> BasicsProgressSnapshot + /// Persists the "Basics completed" flag. func markBasicsCompleted() async /// Returns whether the user dismissed the first-run Basics prompt. func isBasicsDismissed() async -> Bool + /// Returns best stored results for the provided game IDs. + /// + /// Implementations should batch the read when possible so hub refresh does not + /// pay one actor hop per game. + /// + /// - Parameter gameIDs: Stable catalog IDs. + /// - Returns: A dictionary keyed by game ID for IDs that have a stored result. + func bestResults(for gameIDs: [String]) async -> [String: GameResult] + /// Persists the "Basics dismissed" flag when the user opts out of first-run. func markBasicsDismissed() async } diff --git a/RA11yCore/Sources/RA11yCore/Storage/UserDefaultsStorageComponent.swift b/RA11yCore/Sources/RA11yCore/Storage/UserDefaultsStorageComponent.swift index 0b62342..b74f0fb 100644 --- a/RA11yCore/Sources/RA11yCore/Storage/UserDefaultsStorageComponent.swift +++ b/RA11yCore/Sources/RA11yCore/Storage/UserDefaultsStorageComponent.swift @@ -57,6 +57,14 @@ public actor UserDefaultsStorageComponent: StorageComponent { defaults.bool(forKey: Self.basicsKey) } + /// Returns both Basics flags in one actor-isolated read. + public func basicsProgressSnapshot() async -> BasicsProgressSnapshot { + BasicsProgressSnapshot( + isCompleted: defaults.bool(forKey: Self.basicsKey), + isDismissed: defaults.bool(forKey: Self.dismissedKey) + ) + } + /// Marks the Basics sequence as completed in UserDefaults. public func markBasicsCompleted() async { defaults.set(true, forKey: Self.basicsKey) @@ -68,6 +76,18 @@ public actor UserDefaultsStorageComponent: StorageComponent { defaults.bool(forKey: Self.dismissedKey) } + /// Returns best results for all requested game IDs in one actor call. + public func bestResults(for gameIDs: [String]) async -> [String: GameResult] { + var results: [String: GameResult] = [:] + results.reserveCapacity(gameIDs.count) + for gameID in gameIDs { + if let result = _bestResult(for: gameID) { + results[gameID] = result + } + } + return results + } + /// Marks the Basics sequence as dismissed in UserDefaults. public func markBasicsDismissed() async { defaults.set(true, forKey: Self.dismissedKey) @@ -86,3 +106,21 @@ public actor UserDefaultsStorageComponent: StorageComponent { "\(Self.keyPrefix)\(gameID).bestResult" } } + +// MARK: - Screenshot Testing Support + +#if DEBUG +public extension UserDefaultsStorageComponent { + + /// Keys exposed for the screenshot automation reset handler in `RA11y_iOSApp`. + /// + /// - Warning: Use only from the `-screenshotResetOnboarding` launch argument + /// handler. Never call from production paths. + enum ScreenshotTestingKeys { + /// Key for the "basics completed" flag. Mirrors `UserDefaultsStorageComponent.basicsKey`. + public static let basicsCompleted = UserDefaultsStorageComponent.basicsKey + /// Key for the "basics dismissed" flag. Mirrors `UserDefaultsStorageComponent.dismissedKey`. + public static let basicsDismissed = UserDefaultsStorageComponent.dismissedKey + } +} +#endif diff --git a/RA11yCore/Tests/RA11yCoreTests/GameSessionTests.swift b/RA11yCore/Tests/RA11yCoreTests/GameSessionTests.swift index 8d626d4..25ef68a 100644 --- a/RA11yCore/Tests/RA11yCoreTests/GameSessionTests.swift +++ b/RA11yCore/Tests/RA11yCoreTests/GameSessionTests.swift @@ -201,13 +201,41 @@ struct GameSessionTests { } } - /// Mistakes may only be recorded while running. Paused sessions reject them. - @Test func recordMistakeFromPausedThrows() async throws { - let session = makeSession() + // MARK: - Full-Screen Exit (M8 Contract) + + /// Validates TICKET-M8 acceptance criterion: "Leaving full-screen stops gameplay + /// and does not write a result." + /// + /// The view layer calls `handleViewDisappear()` → `session.abandon()`. + /// This test verifies the resulting state is `.abandoned` and storage is untouched. + @Test func fullScreenExitedFromRunningTransitionsToAbandonedWithNoStorageWrite() async throws { + let storage = InMemoryStorageComponent() + let session = makeSession(storage: storage) + try await session.start(at: t0) - try await session.pause(at: t10) - await #expect(throws: GameSessionError.self) { - try await session.recordMistake() - } + // Simulate the user backing out of a running L3 game via navigation. + await session.abandon() + + #expect(await session.state == .abandoned) + #expect(await storage.bestResult(for: "find-and-focus") == nil, + "Back-navigation must not write a result to storage.") + } + + /// Validates that full-screen exit after a completed game does not overwrite the result. + /// + /// A completed session is in a terminal state — `abandon()` is a no-op per the state + /// machine contract. The stored result must remain intact. + @Test func fullScreenExitedAfterCompletionDoesNotEraseResult() async throws { + let storage = InMemoryStorageComponent() + let session = makeSession(storage: storage) + + try await session.start(at: t0) + try await session.complete(at: t10) // 10s, 0 mistakes → Perfect + // Simulate a delayed back-navigation after result is already written. + await session.abandon() + + let stored = await storage.bestResult(for: "find-and-focus") + #expect(stored != nil, "Completed result must persist after a no-op abandon.") + #expect(stored?.rank == .perfect) } } diff --git a/RA11yCore/Tests/RA11yCoreTests/HubViewModelTests.swift b/RA11yCore/Tests/RA11yCoreTests/HubViewModelTests.swift index 6a19836..edbdeda 100644 --- a/RA11yCore/Tests/RA11yCoreTests/HubViewModelTests.swift +++ b/RA11yCore/Tests/RA11yCoreTests/HubViewModelTests.swift @@ -3,14 +3,14 @@ import Testing // MARK: - HubViewModelTests -/// Tests for `HubViewModel` — VoiceOver affordance visibility and best-result loading. +/// Tests for `HubViewModel` — best-result loading and refresh. /// -/// Covers: -/// - `showHelpAffordance` driven by VoiceOver state (TICKET-M2-HelpAffordance-Visibility) -/// - `bestResults` / `bestRank(for:)` driven by StorageComponent (TICKET-M3-Hub-UI-Progress) +/// VoiceOver affordance visibility is intentionally not tested here. +/// `HubViewModel` no longer observes VoiceOver state; the hub view reads +/// `@Environment(\.accessibilityVoiceOverEnabled)` directly, which is +/// propagated by SwiftUI and does not require unit verification. /// -/// All tests use `StubVoiceOverStateProvider` and `InMemoryStorageComponent` — -/// no UIKit, no system state, no disk access. +/// All tests use `InMemoryStorageComponent` — no disk access, no simulators. /// `@MainActor` required because `HubViewModel` is `@MainActor`. @MainActor struct HubViewModelTests { @@ -18,101 +18,43 @@ struct HubViewModelTests { // MARK: - Helpers private func makeViewModel( - isVoiceOverRunning: Bool, - stateChanges: AsyncStream = AsyncStream { _ in }, storage: InMemoryStorageComponent = InMemoryStorageComponent() ) -> HubViewModel { - let stub = StubVoiceOverStateProvider( - isVoiceOverRunning: isVoiceOverRunning, - stateChanges: stateChanges - ) - return HubViewModel(voiceOverProvider: stub, storage: storage) + HubViewModel(storage: storage) } - // MARK: - Help Affordance: Initial State + // MARK: - Best Results: Initial State - /// VO ON → affordance is absent from hierarchy (TICKET-M2-HelpAffordance-Visibility). - @Test func voiceOverOnSetsShowHelpAffordanceToFalse() { - let viewModel = makeViewModel(isVoiceOverRunning: true) - #expect(viewModel.showHelpAffordance == false) - } - - /// VO OFF → affordance is present; user may need guidance. - @Test func voiceOverOffSetsShowHelpAffordanceToTrue() { - let viewModel = makeViewModel(isVoiceOverRunning: false) - #expect(viewModel.showHelpAffordance == true) - } - - // MARK: - Help Affordance: Reactive Updates - - /// VoiceOver toggled off mid-session → affordance appears without relaunching. - @Test func stateChangeFromOnToOffShowsAffordance() async { - let (stream, continuation) = AsyncStream.makeStream() - let viewModel = makeViewModel(isVoiceOverRunning: true, stateChanges: stream) - - #expect(viewModel.showHelpAffordance == false) - - continuation.yield(false) - try? await Task.sleep(nanoseconds: 10_000_000) // 10 ms - - #expect(viewModel.showHelpAffordance == true) - continuation.finish() - } - - /// VoiceOver toggled on mid-session → affordance disappears without relaunching. - @Test func stateChangeFromOffToOnHidesAffordance() async { - let (stream, continuation) = AsyncStream.makeStream() - let viewModel = makeViewModel(isVoiceOverRunning: false, stateChanges: stream) - - #expect(viewModel.showHelpAffordance == true) - - continuation.yield(true) - try? await Task.sleep(nanoseconds: 10_000_000) - - #expect(viewModel.showHelpAffordance == false) - continuation.finish() - } - - /// Rapid toggles converge to the final state — no race condition in the async stream handler. - @Test func rapidTogglesConvergeToFinalState() async { - let (stream, continuation) = AsyncStream.makeStream() - let viewModel = makeViewModel(isVoiceOverRunning: true, stateChanges: stream) - - continuation.yield(false) - continuation.yield(true) - continuation.yield(false) // final: VO off → affordance shown - continuation.finish() - - try? await Task.sleep(nanoseconds: 20_000_000) // 20 ms - - #expect(viewModel.showHelpAffordance == true) + /// 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) } - // MARK: - Best Results: Initial Load - - /// Unplayed games produce nil from `bestRank(for:)` — displayed as "Quest Awaits" in the hub. + /// `refreshBestResults()` returns nil for games that have never been played. @Test func unplayedGamesHaveNilBestRank() async { - let viewModel = makeViewModel(isVoiceOverRunning: true) - try? await Task.sleep(nanoseconds: 20_000_000) + let viewModel = makeViewModel() + 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) } - /// Results stored before the view model is created are loaded on init. - /// - /// Validates the async init-time load — the view model must be ready before the - /// first hub render populates the quest card rank badges. - @Test func storedResultsLoadOnInit() async { + // MARK: - Best Results: Load from Storage + + /// Results stored before `refreshBestResults()` is called are surfaced correctly. + @Test func storedResultsLoadOnRefresh() async { let storage = InMemoryStorageComponent() let legendary = GameResult(gameID: "find-and-focus", rank: .perfect, timeSeconds: 8.0, mistakes: 0) let novice = GameResult(gameID: "scroll-hunt", rank: .ok, timeSeconds: 40.0, mistakes: 2) await storage.saveResultIfBetter(legendary) await storage.saveResultIfBetter(novice) - let viewModel = makeViewModel(isVoiceOverRunning: true, storage: storage) - try? await Task.sleep(nanoseconds: 20_000_000) + let viewModel = makeViewModel(storage: storage) + await viewModel.refreshBestResults() #expect(viewModel.bestRank(for: "find-and-focus") == .perfect) #expect(viewModel.bestRank(for: "activate-double-tap") == nil) @@ -124,8 +66,8 @@ struct HubViewModelTests { /// After a first play, `refreshBestResults()` reflects the new result without relaunch. @Test func refreshBestResultsReflectsNewlySavedResult() async { let storage = InMemoryStorageComponent() - let viewModel = makeViewModel(isVoiceOverRunning: true, storage: storage) - try? await Task.sleep(nanoseconds: 20_000_000) + let viewModel = makeViewModel(storage: storage) + await viewModel.refreshBestResults() #expect(viewModel.bestRank(for: "find-and-focus") == nil) @@ -145,8 +87,8 @@ struct HubViewModelTests { let novice = GameResult(gameID: "find-and-focus", rank: .ok, timeSeconds: 35, mistakes: 2) await storage.saveResultIfBetter(novice) - let viewModel = makeViewModel(isVoiceOverRunning: true, storage: storage) - try? await Task.sleep(nanoseconds: 20_000_000) + let viewModel = makeViewModel(storage: storage) + await viewModel.refreshBestResults() #expect(viewModel.bestRank(for: "find-and-focus") == .ok) let legendary = GameResult(gameID: "find-and-focus", rank: .perfect, timeSeconds: 8, mistakes: 0) @@ -158,8 +100,8 @@ struct HubViewModelTests { /// An unknown game ID returns nil gracefully — no crash, no phantom data. @Test func bestRankForUnknownIDReturnsNil() async { - let viewModel = makeViewModel(isVoiceOverRunning: true) - try? await Task.sleep(nanoseconds: 20_000_000) + let viewModel = makeViewModel() + await viewModel.refreshBestResults() #expect(viewModel.bestRank(for: "nonexistent-game") == nil) } } diff --git a/RA11yCore/Tests/RA11yCoreTests/QuestFeedbackReducerTests.swift b/RA11yCore/Tests/RA11yCoreTests/QuestFeedbackReducerTests.swift new file mode 100644 index 0000000..9c845c0 --- /dev/null +++ b/RA11yCore/Tests/RA11yCoreTests/QuestFeedbackReducerTests.swift @@ -0,0 +1,106 @@ +import Testing +@testable import RA11yCore + +// MARK: - QuestFeedbackReducerTests + +/// Tests for reusable semantic feedback reduction used by multiple quests. +struct QuestFeedbackReducerTests { + + @Test func enteringWarmBandEmitsWarmProximityIntent() { + var state = QuestFeedbackState() + + let intents = QuestFeedbackReducer.reduce( + state: &state, + input: .alignmentBandChanged(.warm) + ) + + #expect(intents == [.proximityEntered(.warm)]) + #expect(state.currentBand == .warm) + } + + @Test func movingFromWarmToNearEmitsNearProximityIntent() { + var state = QuestFeedbackState(currentBand: .warm) + + let intents = QuestFeedbackReducer.reduce( + state: &state, + input: .alignmentBandChanged(.near) + ) + + #expect(intents == [.proximityEntered(.near)]) + #expect(state.currentBand == .near) + } + + @Test func enteringLockedBandEmitsLockAcquired() { + var state = QuestFeedbackState(currentBand: .near) + + let intents = QuestFeedbackReducer.reduce( + state: &state, + input: .alignmentBandChanged(.locked) + ) + + #expect(intents == [.lockAcquired]) + #expect(state.currentBand == .locked) + } + + @Test func leavingLockedForNearEmitsLockLostThenNear() { + var state = QuestFeedbackState(currentBand: .locked) + + let intents = QuestFeedbackReducer.reduce( + state: &state, + input: .alignmentBandChanged(.near) + ) + + #expect(intents == [.lockLost, .proximityEntered(.near)]) + #expect(state.currentBand == .near) + } + + @Test func repeatingSameBandEmitsNothing() { + var state = QuestFeedbackState(currentBand: .near) + + let intents = QuestFeedbackReducer.reduce( + state: &state, + input: .alignmentBandChanged(.near) + ) + + #expect(intents.isEmpty) + } + + @Test func wrongActivationEmitsWrongActivationIntent() { + var state = QuestFeedbackState(currentBand: .near) + + let intents = QuestFeedbackReducer.reduce(state: &state, input: .wrongActivation) + + #expect(intents == [.wrongActivation]) + #expect(state.currentBand == .near) + } + + @Test func successEmitsSuccessIntent() { + var state = QuestFeedbackState(currentBand: .locked) + + let intents = QuestFeedbackReducer.reduce(state: &state, input: .success) + + #expect(intents == [.success]) + } + + @Test func timeoutEmitsTimeoutIntent() { + var state = QuestFeedbackState(currentBand: .warm) + + let intents = QuestFeedbackReducer.reduce(state: &state, input: .timeout) + + #expect(intents == [.timeout]) + } + + @Test func hintRequestEmitsHintIntent() { + var state = QuestFeedbackState(currentBand: .far) + + let intents = QuestFeedbackReducer.reduce(state: &state, input: .hintRequested) + + #expect(intents == [.hint]) + } + + @Test func dungeonResonanceProfileUsesAlignmentSnapForLockCue() { + let profile = QuestFeedbackProfile.dungeonResonance + #expect(profile.lockCue.haptic == .alignmentSnap) + #expect(profile.lockCue.audio == .resonance) + } +} diff --git a/RA11yCore/Tests/RA11yCoreTests/RA11yCoreTests.swift b/RA11yCore/Tests/RA11yCoreTests/RA11yCoreTests.swift index 55af711..03be44b 100644 --- a/RA11yCore/Tests/RA11yCoreTests/RA11yCoreTests.swift +++ b/RA11yCore/Tests/RA11yCoreTests/RA11yCoreTests.swift @@ -6,4 +6,5 @@ // GameSessionCoordinatorTests.swift // GameResultPresenterTests.swift // HubViewModelTests.swift +// QuestFeedbackReducerTests.swift // VoiceOverTests.swift diff --git a/RA11yCore/Tests/RA11yCoreTests/ScoringModelTests.swift b/RA11yCore/Tests/RA11yCoreTests/ScoringModelTests.swift index f6978dc..b047916 100644 --- a/RA11yCore/Tests/RA11yCoreTests/ScoringModelTests.swift +++ b/RA11yCore/Tests/RA11yCoreTests/ScoringModelTests.swift @@ -3,16 +3,16 @@ import Testing // MARK: - ScoringModelTests -/// Tests for `GameRank` ordering and display, `GameResult.isBetter(than:)`, and -/// `RankThresholds.evaluate(timeSeconds:mistakes:)`. +/// Tests for `GameRank`, `GameResult.isBetter(than:)`, and `RankThresholds.evaluate`. /// -/// Validates TICKET-M1-ScoringModel-RankMetrics acceptance criteria. +/// All threshold assertions use values from `GameSpec-FindAndFocus.txt`, +/// `GameSpec-ActivateDoubleTap.txt`, `GameSpec-ScrollHunt.txt`, and +/// `GameRules-MVP.txt`. Update tests here whenever a spec changes. struct ScoringModelTests { // MARK: - GameRank Display Names - /// All four rank cases must use D&D-themed display text. - /// Regression guard: changing "Legendary" back to "Perfect" breaks this test. + /// Regression guard: D&D display names must not regress to code-level names. @Test func gameRankDisplayNamesMatchDandDTheme() { #expect(GameRank.perfect.displayText == "Legendary") #expect(GameRank.good.displayText == "Skilled") @@ -22,7 +22,6 @@ struct ScoringModelTests { // MARK: - GameRank Comparable Ordering - /// `isBetter(than:)` and storage promotion depend on `Comparable` being correct. @Test func gameRankComparableOrderingIsCorrect() { #expect(GameRank.failed < GameRank.ok) #expect(GameRank.ok < GameRank.good) @@ -43,113 +42,307 @@ struct ScoringModelTests { /// On equal rank, fewer mistakes wins. @Test func fewerMistakesWinsOnEqualRank() { - let fewer = GameResult(gameID: "g", rank: .good, timeSeconds: 25, mistakes: 1) - let more = GameResult(gameID: "g", rank: .good, timeSeconds: 20, mistakes: 2) + let fewer = GameResult(gameID: "g", rank: .good, timeSeconds: 15, mistakes: 0) + let more = GameResult(gameID: "g", rank: .good, timeSeconds: 10, mistakes: 1) #expect(fewer.isBetter(than: more)) } /// On equal rank and equal mistakes, faster time wins. @Test func fasterTimeWinsOnEqualRankAndMistakes() { - let faster = GameResult(gameID: "g", rank: .good, timeSeconds: 15, mistakes: 1) - let slower = GameResult(gameID: "g", rank: .good, timeSeconds: 20, mistakes: 1) + let faster = GameResult(gameID: "g", rank: .good, timeSeconds: 12, mistakes: 1) + let slower = GameResult(gameID: "g", rank: .good, timeSeconds: 18, mistakes: 1) #expect(faster.isBetter(than: slower)) } /// A result is never better than an identical result — prevents infinite promotion loops. @Test func resultIsNotBetterThanItself() { - let result = GameResult(gameID: "g", rank: .perfect, timeSeconds: 10, mistakes: 0) + let result = GameResult(gameID: "g", rank: .perfect, timeSeconds: 8, mistakes: 0) #expect(!result.isBetter(than: result)) } - // MARK: - RankThresholds — Find & Focus (Game 1) + // MARK: - RankThresholds — Find & Focus (Game 1 — The Enchanter's Trial) + // Spec: Legendary ≤10s, 0 mistakes | Skilled ≤20s, ≤1 mistake | Novice ≤45s | Defeated ≥5 mistakes - /// Perfect: ≤15s, 0 mistakes. - @Test func findAndFocusPerfectWithinThreshold() { - let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 15, mistakes: 0) + /// Legendary: 0 mistakes AND ≤10s — exact ticket spec value. + @Test func findAndFocusLegendaryWithinThreshold() { + let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 9, mistakes: 0) #expect(rank == .perfect) } - /// Exactly at the perfect time boundary is still Perfect. - @Test func findAndFocusPerfectAtExactTimeBoundary() { - let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 15.0, mistakes: 0) + /// Exactly 10s with 0 mistakes is Legendary (boundary included). + @Test func findAndFocusLegendaryAtExactBoundary() { + let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 10, mistakes: 0) #expect(rank == .perfect) } - /// One mistake at perfect time → degrades to Good. - @Test func findAndFocusPerfectDegradesToGoodWithMistake() { + /// 11s with 0 mistakes drops to Skilled (just past perfect boundary). + @Test func findAndFocusJustPastPerfectTimeDropsToSkilled() { + let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 11, mistakes: 0) + #expect(rank == .good) + } + + /// 1 mistake at ≤10s drops to Skilled (mistake exceeds perfect threshold). + @Test func findAndFocusOneMistakeDropsFromLegendary() { let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 9, mistakes: 1) #expect(rank == .good) } - /// Good: ≤25s, ≤1 mistake. - @Test func findAndFocusGoodWithinThreshold() { - let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 25, mistakes: 1) + /// Skilled: ≤20s, ≤1 mistake — exact spec values. + @Test func findAndFocusSkilledWithinThreshold() { + let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 20, mistakes: 1) #expect(rank == .good) } - /// Ok: ≤45s (timeout), ≤2 mistakes — the untested ok tier. - @Test func findAndFocusOkRankWithinBoundary() { - let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 40, mistakes: 2) + /// 21s with 1 mistake drops to Novice. + @Test func findAndFocusJustPastSkilledTimeDropsToNovice() { + let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 21, mistakes: 1) + #expect(rank == .ok) + } + + /// Novice: success within 45s with 2 mistakes (well below defeat threshold of 5). + @Test func findAndFocusNoviceWithinBoundary() { + let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 40, mistakes: 3) #expect(rank == .ok) } - /// Exactly at the timeout ceiling is still evaluated (not auto-failed). - /// The guard is `timeSeconds > timeoutSeconds` (strict), so timeout itself passes. - @Test func findAndFocusExactTimeoutIsNotFailed() { + /// Exactly at the 45s timeout is still Novice (guard is strict >). + @Test func findAndFocusExactTimeoutIsNovice() { let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 45, mistakes: 0) #expect(rank == .ok) } - /// One fraction over the timeout → failed, regardless of mistakes. - @Test func findAndFocusJustOverTimeoutIsFailed() { + /// Fractionally past 45s is Defeated — session would have been abandoned by timer. + @Test func findAndFocusJustOverTimeoutIsDefeated() { let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 45.001, mistakes: 0) #expect(rank == .failed) } - /// Absolute timeout regardless of time: mistakes > okMaxMistakes → failed even with fast time. - @Test func findAndFocusExcessMistakesFailEvenWithFastTime() { - // 3 mistakes exceeds all tiers (okMaxMistakes=2); time is well within range - let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 5, mistakes: 3) + /// 5 mistakes triggers Defeated regardless of time (okMaxMistakes = 4). + @Test func findAndFocusFiveMistakesDefeated() { + let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 5, mistakes: 5) #expect(rank == .failed) } - // MARK: - RankThresholds — Activate Double-Tap (Game 2) + /// 4 mistakes with fast time is still Novice (boundary: okMaxMistakes = 4). + @Test func findAndFocusFourMistakesIsNovice() { + let rank = RankThresholds.findAndFocus.evaluate(timeSeconds: 5, mistakes: 4) + #expect(rank == .ok) + } - /// Perfect: ≤20s, 0 mistakes. - @Test func activateDoubleTapPerfectWithinThreshold() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 20, mistakes: 0) + // 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) } - /// Good: ≤35s, ≤1 mistake. - @Test func activateDoubleTapGoodWithinThreshold() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 35, mistakes: 1) + /// 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) } - /// Ok: ≤60s (timeout ceiling), ≤2 mistakes. - @Test func activateDoubleTapOkWithinBoundary() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 55, mistakes: 2) + /// Novice: completed within 40s. + @Test func activateDoubleTapNoviceWithinBoundary() { + let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 35, mistakes: 2) #expect(rank == .ok) } - /// Over 60s → failed. + /// Timeout: >40s → Defeated. @Test func activateDoubleTapFailedOnTimeout() { - let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 60.001, mistakes: 0) + let rank = RankThresholds.activateDoubleTap.evaluate(timeSeconds: 40.001, mistakes: 0) #expect(rank == .failed) } - // MARK: - RankThresholds — Scroll Hunt (Game 3) + /// 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) + // Spec: Legendary ≤15s, 0 mistakes | Skilled ≤30s, ≤1 mistake | Novice ≤60s | Defeated ≥6 mistakes - /// Perfect: ≤15s, 0 mistakes (exact boundary). - @Test func scrollHuntPerfectAtExactThreshold() { + /// Legendary: ≤15s, 0 mistakes (spec exact value from GameSpec-ScrollHunt.txt). + @Test func scrollHuntLegendaryAtExactThreshold() { let rank = RankThresholds.scrollHunt.evaluate(timeSeconds: 15, mistakes: 0) #expect(rank == .perfect) } - /// Over 60s → failed. + /// Skilled: ≤30s, ≤1 mistake. + @Test func scrollHuntSkilledWithinThreshold() { + let rank = RankThresholds.scrollHunt.evaluate(timeSeconds: 28, mistakes: 1) + #expect(rank == .good) + } + + /// Novice: completed, ≤60s, <6 mistakes. + @Test func scrollHuntNoviceWithinBoundary() { + let rank = RankThresholds.scrollHunt.evaluate(timeSeconds: 55, mistakes: 4) + #expect(rank == .ok) + } + + /// 6 mistakes → Defeated (okMaxMistakes = 5, so 6 exceeds it). + @Test func scrollHuntSixMistakesDefeated() { + let rank = RankThresholds.scrollHunt.evaluate(timeSeconds: 20, mistakes: 6) + #expect(rank == .failed) + } + + /// >60s → Defeated. @Test func scrollHuntFailedOnTimeout() { let rank = RankThresholds.scrollHunt.evaluate(timeSeconds: 60.001, mistakes: 0) #expect(rank == .failed) } + + // MARK: - RankThresholds.bucketMistakes + // + // Spec (GameSpec-FindAndFocus.txt): + // "Bucket size: 10s. First bucket (0–10s) is free. Each subsequent full 10s adds +1. + // Example: 0–10s → +0; 10–20s → +1; 20–30s → +2." + + /// 0s (no time elapsed) → 0 bucket mistakes. + @Test func bucketMistakesZeroTime() { + #expect(RankThresholds.bucketMistakes(timeSeconds: 0) == 0) + } + + /// < 10s: first bucket still running → 0 penalties. + @Test func bucketMistakesWithinFirstBucket() { + #expect(RankThresholds.bucketMistakes(timeSeconds: 5) == 0) + #expect(RankThresholds.bucketMistakes(timeSeconds: 9.9) == 0) + } + + /// Exactly 10s: edge of first bucket → 0 penalties (first bucket is free). + @Test func bucketMistakesExactlyAtFirstBucketBoundary() { + #expect(RankThresholds.bucketMistakes(timeSeconds: 10) == 0) + } + + /// 10–20s range: first paid bucket → +1. + @Test func bucketMistakesSecondBucket() { + #expect(RankThresholds.bucketMistakes(timeSeconds: 10.001) == 1) + #expect(RankThresholds.bucketMistakes(timeSeconds: 15) == 1) + #expect(RankThresholds.bucketMistakes(timeSeconds: 20) == 1) + } + + /// 20–30s range → +2. + @Test func bucketMistakesThirdBucket() { + #expect(RankThresholds.bucketMistakes(timeSeconds: 20.001) == 2) + #expect(RankThresholds.bucketMistakes(timeSeconds: 25) == 2) + #expect(RankThresholds.bucketMistakes(timeSeconds: 30) == 2) + } + + /// Custom bucket size: 5s. + @Test func bucketMistakesCustomBucketSize() { + #expect(RankThresholds.bucketMistakes(timeSeconds: 5, bucketSize: 5) == 0) + #expect(RankThresholds.bucketMistakes(timeSeconds: 5.01, bucketSize: 5) == 1) + #expect(RankThresholds.bucketMistakes(timeSeconds: 10, bucketSize: 5) == 1) + #expect(RankThresholds.bucketMistakes(timeSeconds: 10.01, bucketSize: 5) == 2) + } + + /// Negative or zero bucket size → 0 (guard against bad input). + @Test func bucketMistakesInvalidBucketSize() { + #expect(RankThresholds.bucketMistakes(timeSeconds: 30, bucketSize: 0) == 0) + #expect(RankThresholds.bucketMistakes(timeSeconds: 30, bucketSize: -1) == 0) + } + + // MARK: - RankThresholds.bucketMistakes (8s bucket — Rogue's Gauntlet) + // + // 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." + + /// Within first 8s bucket → 0 penalties. + @Test func bucketMistakesRogue_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() { + #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() { + #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/TestHelpers.swift b/RA11yCore/Tests/RA11yCoreTests/TestHelpers.swift index 8a9760d..e994fb4 100644 --- a/RA11yCore/Tests/RA11yCoreTests/TestHelpers.swift +++ b/RA11yCore/Tests/RA11yCoreTests/TestHelpers.swift @@ -29,6 +29,11 @@ actor InMemoryStorageComponent: StorageComponent { _basicsCompleted } + /// Returns both Basics flags in one read for startup routing tests. + func basicsProgressSnapshot() async -> BasicsProgressSnapshot { + BasicsProgressSnapshot(isCompleted: _basicsCompleted, isDismissed: _basicsDismissed) + } + /// Marks the Basics sequence as completed. func markBasicsCompleted() async { _basicsCompleted = true @@ -39,6 +44,14 @@ actor InMemoryStorageComponent: StorageComponent { _basicsDismissed } + /// Returns best stored results for the requested game IDs. + func bestResults(for gameIDs: [String]) async -> [String : GameResult] { + Dictionary(uniqueKeysWithValues: gameIDs.compactMap { id in + guard let result = results[id] else { return nil } + return (id, result) + }) + } + /// Marks the Basics sequence as dismissed. func markBasicsDismissed() async { _basicsDismissed = true diff --git a/README.md b/README.md index 861e364..83f03e1 100644 --- a/README.md +++ b/README.md @@ -12,75 +12,175 @@ Most accessibility training is passive: read a doc, watch a video, move on. RA11y makes the VoiceOver interaction model the game mechanic itself. Players cannot succeed without using VoiceOver correctly — the game enforces it. -The app uses a D&D fantasy theme as narrative scaffolding. Three games, each -targeting a distinct VoiceOver behavior that trips up new users: +The app uses a D&D fantasy theme as narrative scaffolding. Each quest targets +one VoiceOver behaviour that trips up new users, taught through three stages: +an untimed practice, a scored timed trial, and a Lights Off stage where haptics +and audio replace visual cues entirely. -| Game | VoiceOver Skill | -|---|---| -| The Enchanter's Trial | Navigate focus with swipe; activate the correct element | -| The Rogue's Gauntlet | Single tap examines; double-tap activates — not the same | -| The Dungeon Descent | One-finger swipe navigates elements; three fingers scroll the page | +| Quest | VoiceOver Skill | One-line lesson | +|---|---|---| +| The Enchanter's Trial | Swipe to navigate focus; double-tap to activate | The screen is linear — walk through it one element at a time | +| Crystal Resonance | Three-finger scroll | One finger navigates; three fingers move the world | +| The Banishment *(in design)* | Two-finger scrub to dismiss | Any time VoiceOver traps you, the Mark of Z banishes it | + +Each quest follows a three-stage arc: untimed guided practice, scored timed +trial, then a Lights Off stage relying entirely on VoiceOver, haptics, and audio. + +--- + +## Screenshots -Each game follows a four-level arc: guided explanation, untimed practice, -rising complexity, then a timed trial with a progress bar. +Screenshots are captured automatically via `bundle exec fastlane screenshots` +and committed to `docs/screenshots/`. --- -## Current State — Hub Screen +### Hub -The Hub is the first screen a player sees. It presents the three game quests, -detects VoiceOver state, and surfaces the help affordance for new users. +The Hub is the first screen a returning player sees after the first-run sequence. +It presents each quest as a card on the quest board. With VoiceOver, the player +swipes right to move focus from card to card, hears the quest name and objective +announced, and double-taps to enter. A help affordance in the top-right opens a +VoiceOver quick-reference sheet for players who need a reminder. - - - + + - - +
Hub on iPhone 17Hub on iPhone 16eHub on iPad + Hub on iPhone 17 — three quest cards on the board + + Hub on iPad — quest board with wider layout +
iPhone (large)iPhone (small)iPhone 17 iPad
-Screenshots captured automatically via `bundle exec fastlane screenshots`. +Each card shows the quest thumbnail, name, goal, and best-rank badge once a run +has been completed. Cards are full accessibility elements: the label includes the +quest name and objective; the hint says "Double-tap to begin." --- -## Design Vision — Where We're Going +### The Enchanter's Trial + +**Skill taught:** VoiceOver focus navigation — swipe right to move focus element +by element; double-tap to activate the focused element. -### Hub — Quest Board +**The lesson:** The screen is now linear. You navigate one element at a time. +Swipe to walk through the room. Double-tap when you find the named relic. -Hub quest board mockup +The quest follows a three-stage arc. Each stage removes a layer of scaffolding +until the player is navigating entirely on their own under a hard time limit. -### VoiceOver Basics — First-Run Lesson Card (M4) +#### L0 — The Prologue + +The Dungeon Master introduces the quest and explains the VoiceOver focus model +before the player attempts anything. A lesson card names the gesture. The player +cannot proceed until they have read (or listened to) the objective. + + + + + + + + + + +
+ Enchanter's Trial — prologue lesson card + + Enchanter's Trial prologue on iPad +
iPhone 17iPad
+ +#### L1 — First Attempt (untimed) + +Three relics on the shelf. The Enchanter names the target. No timer, no pressure. +The player swipes through the relics, hears each name announced by VoiceOver, and +double-taps the correct one. A wrong activation is noted but does not end the +attempt. Goal: build confidence — "I can do this." + + + + + + + + + + +
+ Enchanter's Trial — first attempt, three relics, no timer + + Enchanter's Trial first attempt on iPad +
iPhone 17iPad
-VoiceOver Basics lesson card +#### L2 — Rising Challenge (soft timer) -### Game Screens (M5–M7) +Six relics with deliberately similar-sounding names (Dragon Scale vs Dragon Claw, +Shadow Stone vs Sunstone). A soft 45-second timer introduces gentle urgency. +VoiceOver announces the timer at 50% and 25% elapsed. Mistakes count. The player +must listen carefully — two items sound nearly identical. - - - + + - - - + + +
The Enchanter's TrialThe Rogue's GauntletThe Dungeon Descent + Enchanter's Trial — rising challenge, six relics, soft timer + + Enchanter's Trial rising challenge on iPad +
Enchanter's Trial — in playRogue's Gauntlet — in playDungeon Descent — in playiPhone 17iPad
+ +#### L3 — Timed Trial (hard timer, scored) + +Eight relics. Twenty seconds. No hints. VoiceOver counts down at 10 seconds then +5-4-3-2-1. The progress bar depletes in real time. A rank is awarded at the end: +Legendary, Skilled, Novice, or Defeated. This is the only scored stage. + + - - - + + + + + +
Find the named relic (VoiceOver focus + invoke)Sever the correct seal (touch to examine, double-tap to act)Scroll three fingers to reach the Ancient Vault + Enchanter's Trial — timed trial, eight relics, hard 20-second timer + + Enchanter's Trial timed trial on iPad +
iPhone 17iPad
-### Shared Results Screen +#### Result Screen + +Rank, time, and mistake count are displayed with D&D-flavoured copy. The result +is persisted as the player's best run for that quest. VoiceOver reads the full +result as a single announcement on screen load. -Results screen — Legendary rank + + + + + + + + + +
+ Enchanter's Trial — result screen showing rank and score + + Enchanter's Trial result screen on iPad +
iPhone 17iPad
--- @@ -227,8 +327,10 @@ higher bar than most apps: | M1 | Core models — scoring, session, storage | Done | | M2 | VoiceOver gating, interstitial, help affordance | Done | | M3 | Hub UI — quest board, D&D theming, all device sizes | Done | -| M4 | First-run basics sequence | Ready | -| M5–M7 | Game implementation (Enchanter, Rogue, Dungeon) | Pending | +| M4 | First-run basics sequence | Done | +| M5 | The Enchanter's Trial | Done | +| M6 | Crystal Resonance (Dungeon Descent v2) | In Design | +| M7 | The Banishment | In Design | | M8 | Full accessibility audit | Pending | See `memlog/requirements/TicketBreakdown.txt` for the full ticket breakdown. diff --git a/docs/screenshots/en-US/iPad/01_Hub.png b/docs/screenshots/en-US/iPad/01_Hub.png index e72b77e..826046b 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 new file mode 100644 index 0000000..f80cb2d Binary files /dev/null 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 new file mode 100644 index 0000000..00becd8 Binary files /dev/null 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 new file mode 100644 index 0000000..b6b23de Binary files /dev/null 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 new file mode 100644 index 0000000..7115028 Binary files /dev/null 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 new file mode 100644 index 0000000..1bf6a14 Binary files /dev/null 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 new file mode 100644 index 0000000..d839a83 Binary files /dev/null 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 new file mode 100644 index 0000000..17885f6 Binary files /dev/null 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 new file mode 100644 index 0000000..672824c Binary files /dev/null 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 new file mode 100644 index 0000000..0bfe5de Binary files /dev/null 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 new file mode 100644 index 0000000..f08b4fb Binary files /dev/null and b/docs/screenshots/en-US/iPad/11_DungeonResult.png differ diff --git a/docs/screenshots/en-US/iPhone_17/01_Hub.png b/docs/screenshots/en-US/iPhone_17/01_Hub.png new file mode 100644 index 0000000..509a729 Binary files /dev/null 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 new file mode 100644 index 0000000..6e2198d Binary files /dev/null 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 new file mode 100644 index 0000000..19873c9 Binary files /dev/null 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 new file mode 100644 index 0000000..abda9ca Binary files /dev/null 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 new file mode 100644 index 0000000..95229b4 Binary files /dev/null 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 new file mode 100644 index 0000000..3a8a272 Binary files /dev/null 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 new file mode 100644 index 0000000..5f67423 Binary files /dev/null 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 new file mode 100644 index 0000000..a558c2b Binary files /dev/null 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 new file mode 100644 index 0000000..93e1c6e Binary files /dev/null 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 new file mode 100644 index 0000000..2f1941e Binary files /dev/null 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 new file mode 100644 index 0000000..b9188f7 Binary files /dev/null and b/docs/screenshots/en-US/iPhone_17/11_DungeonResult.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 182ae60..257a252 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 new file mode 100644 index 0000000..a54d0bd Binary files /dev/null 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 new file mode 100644 index 0000000..7fc840b Binary files /dev/null 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 new file mode 100644 index 0000000..5e7c80f Binary files /dev/null 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 new file mode 100644 index 0000000..280e3ea Binary files /dev/null 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 new file mode 100644 index 0000000..c7f0043 Binary files /dev/null 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 new file mode 100644 index 0000000..fdc5ed1 Binary files /dev/null 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 new file mode 100644 index 0000000..de1d20f Binary files /dev/null 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 new file mode 100644 index 0000000..883877c Binary files /dev/null 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 new file mode 100644 index 0000000..850cd90 Binary files /dev/null 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 new file mode 100644 index 0000000..d1dc3cb Binary files /dev/null and b/docs/screenshots/en-US/iPhone_large/11_DungeonResult.png differ diff --git a/docs/screenshots/en-US/iPhone_small/01_Hub.png b/docs/screenshots/en-US/iPhone_small/01_Hub.png index 1dc5a88..bd33faa 100644 Binary files a/docs/screenshots/en-US/iPhone_small/01_Hub.png and b/docs/screenshots/en-US/iPhone_small/01_Hub.png differ diff --git a/docs/screenshots/en-US/iPhone_small/02_VORequired.png b/docs/screenshots/en-US/iPhone_small/02_VORequired.png new file mode 100644 index 0000000..68a01d1 Binary files /dev/null and b/docs/screenshots/en-US/iPhone_small/02_VORequired.png differ diff --git a/docs/screenshots/en-US/iPhone_small/03_FirstRun.png b/docs/screenshots/en-US/iPhone_small/03_FirstRun.png new file mode 100644 index 0000000..47c6c57 Binary files /dev/null and b/docs/screenshots/en-US/iPhone_small/03_FirstRun.png differ diff --git a/docs/screenshots/en-US/iPhone_small/04_EnchanterPrologue.png b/docs/screenshots/en-US/iPhone_small/04_EnchanterPrologue.png new file mode 100644 index 0000000..80a038d Binary files /dev/null and b/docs/screenshots/en-US/iPhone_small/04_EnchanterPrologue.png differ diff --git a/docs/screenshots/en-US/iPhone_small/05_EnchanterAttempt.png b/docs/screenshots/en-US/iPhone_small/05_EnchanterAttempt.png new file mode 100644 index 0000000..7f7fe56 Binary files /dev/null and b/docs/screenshots/en-US/iPhone_small/05_EnchanterAttempt.png differ diff --git a/docs/screenshots/en-US/iPhone_small/06_EnchanterRising.png b/docs/screenshots/en-US/iPhone_small/06_EnchanterRising.png new file mode 100644 index 0000000..3ee1ae4 Binary files /dev/null and b/docs/screenshots/en-US/iPhone_small/06_EnchanterRising.png differ diff --git a/docs/screenshots/en-US/iPhone_small/07_EnchanterTimed.png b/docs/screenshots/en-US/iPhone_small/07_EnchanterTimed.png new file mode 100644 index 0000000..9117cce Binary files /dev/null and b/docs/screenshots/en-US/iPhone_small/07_EnchanterTimed.png differ diff --git a/docs/screenshots/en-US/iPhone_small/08_EnchanterResult.png b/docs/screenshots/en-US/iPhone_small/08_EnchanterResult.png new file mode 100644 index 0000000..cadddab Binary files /dev/null and b/docs/screenshots/en-US/iPhone_small/08_EnchanterResult.png differ diff --git a/docs/screenshots/en-US/iPhone_small/09_DungeonPrologue.png b/docs/screenshots/en-US/iPhone_small/09_DungeonPrologue.png new file mode 100644 index 0000000..e29de81 Binary files /dev/null and b/docs/screenshots/en-US/iPhone_small/09_DungeonPrologue.png differ diff --git a/docs/screenshots/en-US/iPhone_small/10_DungeonL1.png b/docs/screenshots/en-US/iPhone_small/10_DungeonL1.png new file mode 100644 index 0000000..9f7b28e Binary files /dev/null and b/docs/screenshots/en-US/iPhone_small/10_DungeonL1.png differ diff --git a/docs/screenshots/en-US/iPhone_small/11_DungeonResult.png b/docs/screenshots/en-US/iPhone_small/11_DungeonResult.png new file mode 100644 index 0000000..81cc31a Binary files /dev/null and b/docs/screenshots/en-US/iPhone_small/11_DungeonResult.png differ diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 705033b..d06d6b9 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -7,10 +7,21 @@ default_platform(:ios) # Paths are relative to the fastlane/ directory; `../` escapes to repo root. WORKSPACE = "../RA11y.xcworkspace".freeze SCHEME = "RA11y-iOS".freeze -UI_TEST_ID = "RA11y-iOSUITests/RA11y_iOSScreenshots/testScreenshots".freeze +# Explicit allowlist of completed screenshot tests. +# This prevents in-progress game flows from failing the capture lane. +UI_TEST_IDS = [ + "RA11y-iOSUITests/RA11y_iOSScreenshots/testScreenshots_Hub_VORequired", + "RA11y-iOSUITests/RA11y_iOSScreenshots/testScreenshots_FirstRun", + "RA11y-iOSUITests/RA11y_iOSScreenshots/testScreenshots_Enchanter", + "RA11y-iOSUITests/RA11y_iOSScreenshots/testScreenshots_Dungeon", + "RA11y-iOSUITests/RA11y_iOSScreenshots/testScreenshots_ResonanceMockup", +].freeze OUTPUT_BASE = "../fastlane/screenshots/en-US".freeze DOCS_BASE = "../docs/screenshots/en-US".freeze EXTRACT_SCRIPT = "../scripts/extract_screenshots.sh".freeze +DERIVED_DATA = "/tmp/RA11y_screenshots_DerivedData".freeze +CONTRACT_CHECK = "../utility/validate_screenshot_contract.sh".freeze +ROUTE_CATALOG = "../RA11y-iOS/RA11y-iOSUITests/ScreenshotRouteCatalog.md".freeze # --------------------------------------------------------------------------- # Simulator preferences @@ -24,15 +35,18 @@ EXTRACT_SCRIPT = "../scripts/extract_screenshots.sh".freeze # --------------------------------------------------------------------------- # Per-family fallback hierarchy (prefix-matched, newest first within each tier). +# iPhone 16e added for iPhone_small; iPhone SE (3rd generation) is a stable small-form fallback. SIM_PREFS = { "iPhone" => [ "iPhone 17", "iPhone 16 Pro", "iPhone 16", + "iPhone 16e", "iPhone 15 Pro", "iPhone 15", "iPhone 14 Pro", "iPhone 14", + "iPhone SE", ].freeze, "iPad" => [ "iPad Pro (13-inch)", @@ -43,27 +57,6 @@ SIM_PREFS = { ].freeze, }.freeze -# Form-factors to capture. Each entry has: -# label: used as the output subdirectory name -# family: "iPhone" or "iPad" — controls fallback hierarchy -# preferred: exact device name to try first (may be unavailable) -# -# Override the entire set via SNAPSHOT_DEVICES env var: -# SNAPSHOT_DEVICES="iPhone 16,iPad Pro (13-inch)" bundle exec fastlane screenshots -SCREENSHOT_DEVICE_SPECS = - if ENV["SNAPSHOT_DEVICES"] - ENV["SNAPSHOT_DEVICES"].split(",").map(&:strip).reject(&:empty?).map do |name| - family = name.include?("iPad") ? "iPad" : "iPhone" - { label: name.gsub(/[^A-Za-z0-9]/, "_"), family: family, preferred: name } - end - else - [ - { label: "iPhone_large", family: "iPhone", preferred: "iPhone 17" }, - { label: "iPhone_small", family: "iPhone", preferred: "iPhone 16e" }, - { label: "iPad", family: "iPad", preferred: "iPad" }, - ] - end.freeze - # --------------------------------------------------------------------------- # Lanes # --------------------------------------------------------------------------- @@ -86,14 +79,32 @@ platform :ios do DESC lane :screenshots do require "json" + only_testing_flags = UI_TEST_IDS.map { |id| " -only-testing:'#{id}'" }.join + expected_files = expected_screenshot_files + + # Contract guard: fail fast if test allowlist, route catalog, and UI test methods drift. + sh("bash '#{CONTRACT_CHECK}'") sh("rm -rf '#{OUTPUT_BASE}' && mkdir -p '#{OUTPUT_BASE}'") + sh("rm -rf '#{DERIVED_DATA}'") + + # Build once, then run test-without-building per simulator. + # This avoids recompiling the app/test bundle for every device pass. + UI.message("Prebuilding UI tests once (build-for-testing)") + sh( + "xcodebuild build-for-testing" \ + " -workspace '#{WORKSPACE}'" \ + " -scheme '#{SCHEME}'" \ + " -destination 'generic/platform=iOS Simulator'" \ + "#{only_testing_flags}" \ + " -derivedDataPath '#{DERIVED_DATA}'" + ) succeeded = [] failed = [] seen_udids = [] - SCREENSHOT_DEVICE_SPECS.each do |spec| + screenshot_device_specs.each do |spec| udid = resolve_simulator_udid(family: spec[:family], preferred: spec[:preferred]) if seen_udids.include?(udid) @@ -109,25 +120,58 @@ platform :ios do sh("rm -rf '#{result_path}'") UI.message("Capturing screenshots — #{label} (id: #{udid[0..7]}...)") + # Run xcodebuild but do NOT let a non-zero exit abort this block. + # A single failing test method exits non-zero yet still writes a valid xcresult + # bundle containing XCTAttachments from all *passing* test methods. + # Extraction is always attempted below so partial runs still yield screenshots. + xcodebuild_ok = true begin sh( - "xcodebuild test" \ + "xcodebuild test-without-building" \ " -workspace '#{WORKSPACE}'" \ " -scheme '#{SCHEME}'" \ + " -derivedDataPath '#{DERIVED_DATA}'" \ " -destination 'platform=iOS Simulator,id=#{udid}'" \ - " -only-testing:'#{UI_TEST_ID}'" \ + "#{only_testing_flags}" \ " -resultBundlePath '#{result_path}'" ) - sh("bash '#{EXTRACT_SCRIPT}' '#{result_path}' '#{output_dir}'") + rescue => e + UI.error("xcodebuild test non-zero for #{label}: #{e.message}") + xcodebuild_ok = false + end - # Publish to docs/screenshots/ so the README can reference committed PNGs. - # fastlane/screenshots/ is gitignored (build output); docs/screenshots/ is not. - docs_dir = "#{DOCS_BASE}/#{label}" - sh("mkdir -p '#{docs_dir}' && cp -f '#{output_dir}'/*.png '#{docs_dir}/' 2>/dev/null || true") + # Always attempt extraction if an xcresult bundle was produced. + unless File.exist?(result_path) + UI.error("No xcresult bundle at #{result_path}; build failed before any tests ran.") + failed << label + next + end - succeeded << label - rescue => e - UI.error("FAILED for #{label}: #{e.message}") + begin + sh("bash '#{EXTRACT_SCRIPT}' '#{result_path}' '#{output_dir}'") + + png_files = Dir["#{output_dir}/*.png"] + missing_files = expected_files.reject { |name| File.exist?("#{output_dir}/#{name}.png") } + if png_files.empty? + UI.error("Extraction produced no PNGs for #{label}") + failed << label + elsif !missing_files.empty? + UI.error("Missing expected screenshots for #{label}: #{missing_files.join(', ')}") + failed << label + else + # Publish to docs/screenshots/ so the README can reference committed PNGs. + # fastlane/screenshots/ is gitignored (build output); docs/screenshots/ is not. + docs_dir = "#{DOCS_BASE}/#{label}" + sh("mkdir -p '#{docs_dir}' && cp -f '#{output_dir}'/*.png '#{docs_dir}/' 2>/dev/null || true") + succeeded << label + if xcodebuild_ok + UI.success("#{png_files.length} screenshot(s) captured for #{label}") + else + UI.important("#{png_files.length} screenshot(s) captured for #{label} (xcodebuild had failures — some screens may be missing)") + end + end + rescue => extract_err + UI.error("Extraction failed for #{label}: #{extract_err.message}") failed << label end end @@ -145,6 +189,12 @@ platform :ios do UI.success("Screenshots written to: fastlane/screenshots/en-US/") end end + + desc "Fast screenshot pass for iteration: one preferred iPhone device" + lane :screenshots_quick do + ENV["SNAPSHOT_DEVICES"] = "iPhone 17" if ENV["SNAPSHOT_DEVICES"].to_s.strip.empty? + screenshots + end end # --------------------------------------------------------------------------- @@ -157,6 +207,10 @@ end # first (exact match), then falls back through SIM_PREFS for the given # family, then picks any available device in that family as a last resort. # +# Reuses a persisted last-working UDID (per family) when still listed by +# simctl — same directory as utility/build_and_test.sh (`~/.ra11y` by default). +# Never creates simulators, erases devices, or downloads runtimes. +# # Calls UI.user_error! if no simulator of the requested family is found at all, # listing all available simulators to help the user diagnose the problem. # @@ -165,12 +219,27 @@ end # @return [String] UDID of the resolved simulator def resolve_simulator_udid(family:, preferred: "") require "json" + require "fileutils" - raw = `xcrun simctl list devices available --json 2>/dev/null` + state_dir = ENV["RA11Y_SIMULATOR_STATE_DIR"].to_s.strip + state_dir = File.join(Dir.home, ".ra11y") if state_dir.empty? + FileUtils.mkdir_p(state_dir) + state_file = File.join(state_dir, "last_simulator_#{family}.udid") + + raw = nil + 2.times do |i| + raw = `xcrun simctl list devices available --json 2>/dev/null` + break if raw && !raw.strip.empty? + + if i.zero? + UI.important("[simulator] simctl list failed or returned empty; retrying once in 1 s...") + sleep(1) + end + end - if raw.strip.empty? + if raw.nil? || raw.strip.empty? UI.user_error!( - "xcrun simctl returned no output.\n" \ + "xcrun simctl returned no output after retry.\n" \ "Ensure Xcode is installed and Simulator.app has been opened at least\n" \ "once since the last reboot. Check Xcode → Settings → Platforms." ) @@ -197,11 +266,26 @@ def resolve_simulator_udid(family:, preferred: "") ) end + # 0. Reuse last-working UDID if that device is still available. + if File.file?(state_file) + persisted = File.read(state_file).strip + if !persisted.empty? + match = available.find { |d| d["udid"] == persisted && d["name"].include?(family) } + if match + UI.message("[simulator] Reusing last-working UDID from #{state_file}: #{match['name']} (#{match['udid'][0..7]}...)") + persist_simulator_udid_state(state_file, match["udid"]) + return match["udid"] + end + UI.important("[simulator] Last-working UDID is no longer available; re-resolving.") + end + end + # 1. Exact preferred name match. unless preferred.empty? exact = available.find { |d| d["name"] == preferred } if exact UI.message("[simulator] Resolved: #{exact['name']} (#{exact['udid'][0..7]}...)") + persist_simulator_udid_state(state_file, exact["udid"]) return exact["udid"] end UI.important("[simulator] '#{preferred}' not found; falling back to preference list") @@ -214,11 +298,67 @@ def resolve_simulator_udid(family:, preferred: "") chosen = candidates.max_by { |d| d["name"] } UI.message("[simulator] Resolved via preference list: #{chosen['name']} (#{chosen['udid'][0..7]}...)") + persist_simulator_udid_state(state_file, chosen["udid"]) return chosen["udid"] end # 3. Last-resort: any available device in the requested family. chosen = available.max_by { |d| d["name"] } UI.important("[simulator] WARNING: Using last-resort fallback: #{chosen['name']} (#{chosen['udid'][0..7]}...)") + persist_simulator_udid_state(state_file, chosen["udid"]) chosen["udid"] end + +# Writes the resolved UDID for reuse on the next fastlane or build script run. +def persist_simulator_udid_state(state_file, udid) + File.write(state_file, udid) +rescue StandardError => e + UI.important("[simulator] (non-fatal) Could not persist UDID: #{e.message}") +end + +# Reads the screenshot route catalog and returns the ordered list of required PNG basenames. +# +# The catalog is the human-maintained source of truth for committed screenshots. +# Fastlane uses it to fail when a simulator run produces only a partial export. +# +# @return [Array] ordered screenshot basenames without extension +def expected_screenshot_files + File.readlines(ROUTE_CATALOG, chomp: true).map do |line| + next unless line.start_with?("| `") + + columns = line.split("|").map(&:strip) + next if columns.length < 2 + + file_name = columns[1].delete("`") + next unless file_name.match?(/^\d+_/) + + file_name + end.compact +end + +# Resolves the form-factors to capture for the current lane invocation. +# +# Each entry includes: +# - `label`: output subdirectory name +# - `family`: `iPhone` or `iPad` +# - `preferred`: exact preferred device name +# +# `screenshots_quick` works by setting `SNAPSHOT_DEVICES` before invoking the lane, +# so this list must be computed at runtime rather than frozen at file load. +# +# @return [Array] device specs used by the screenshots lane +def screenshot_device_specs + if ENV["SNAPSHOT_DEVICES"].to_s.strip != "" + ENV["SNAPSHOT_DEVICES"].split(",").map(&:strip).reject(&:empty?).map do |name| + family = name.include?("iPad") ? "iPad" : "iPhone" + { label: name.gsub(/[^A-Za-z0-9]/, "_"), family: family, preferred: name } + end + else + [ + { label: "iPhone_large", family: "iPhone", preferred: "iPhone 17" }, + # iPhone SE (3rd generation) preferred for small form; 16e can fail xcresulttool export. + { label: "iPhone_small", family: "iPhone", preferred: "iPhone SE (3rd generation)" }, + { label: "iPad", family: "iPad", preferred: "iPad" }, + ] + end +end diff --git a/fastlane/README.md b/fastlane/README.md index c88439f..47a3ebe 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -36,6 +36,14 @@ Screenshots land in fastlane/screenshots/en-US/