Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .cursor/rules/agents-md-authority.mdc
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
description: Repo agent guidelines — follow AGENTS.md (safety, tooling, workflow)
alwaysApply: true
---

# AGENTS.md is authoritative

For this repository, **read and follow** the root **`AGENTS.md`** file. It takes precedence over generic coding advice for:

- Prohibited commands and simulator safety
- Filesystem boundaries and where to write artefacts
- **Scripting / shell policy** (bash, Unix CLI, Swift & Xcode only for improvised commands; no ad-hoc Python/Ruby/Node one-liners)
- Build/test patterns (`utility/build_and_test.sh`, etc.)
- iOS/macOS project conventions noted there

Do **not** contradict `AGENTS.md`. If a task needs detail, **open `AGENTS.md`** rather than guessing. Keeping one canonical doc avoids duplicating long policy in Cursor rules.
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
RA11y.xcworkspace/xcuserdata/josephmccraw.xcuserdatad/UserInterfaceState.xcuserstate
/build_results

# PNG backups from utility/ensure_png_rgba.py and utility/remove_white_background.py
**/Assets.xcassets/**/*.png.bak
**/Assets.xcassets/**/*.png.bak.*

# Fastlane
/fastlane/screenshots
/fastlane/test_output
Expand Down
28 changes: 28 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@ Follow every section. These are not suggestions.
If any guidance below conflicts, the repository operational rules in this file take precedence for work in this repo.
The SwiftUI rules are intended for design and implementation guidance and should not override repo safety or workflow rules.

### How coding agents discover this file

- **OpenAI Codex** loads **`AGENTS.md`** from the repository root (and parent paths) automatically—no duplicate policy file needed for Codex.
- **Anthropic Claude Code** loads **`CLAUDE.md`** by default; this repo keeps a **short root `CLAUDE.md`** that points here so behavior stays single-source.
- **Cursor** uses **`.cursor/rules/agents-md-authority.mdc`** to require following this document.

---

## Project Structure
Expand Down Expand Up @@ -135,6 +141,27 @@ Prefer standard shell tools over interpreted scripting languages. This
reduces interpreter version dependencies, limits arbitrary code execution
surface, and keeps scripts auditable by anyone with basic shell knowledge.

### Maintainer toolchain (agents — required)

The primary maintainer reviews agent work using **bash**, **standard Unix
command-line tools**, and **Swift / Xcode** (`swift`, `xcodebuild`, etc.)
only. They do **not** use Python or other extra runtimes for day-to-day
review.

**Agents MUST follow that boundary for anything they *run* in the shell:**

- **Allowed:** `bash` / `sh`, POSIX and macOS CLI (`grep`, `sed`, `awk`, `jq`,
`plutil`, `xcrun`, `xcodebuild`, `swift`, committed `utility/*.sh`, and
other tools that are clearly “plain Unix + Apple toolchain.”
- **Forbidden for improvised use:** `python`, `python3`, `ruby`, `node`,
`npm`, `npx`, or similar **one-liners and ad-hoc scripts**—including
“quick” JSON/XML validation—so the maintainer can reason about safety
without learning another language. Use `./utility/build_and_test.sh`,
Xcode build output, or shell tools (`plutil`, etc.) instead.
- **Exception:** Run **only** the already-committed, documented Python
utilities under `utility/` when the task is explicitly the asset/PNG
pipeline listed below—not new `python3 -c '…'` invocations.

### Preferred Tools (use these first)

- **Text processing:** `awk`, `sed`, `grep`, `ripgrep (rg)`, `cut`, `tr`, `sort`, `uniq`
Expand All @@ -156,6 +183,7 @@ script unless ALL of the following are true:

Existing project scripts that already use Python are grandfathered:
- `utility/remove_white_background.py` — approved, do not rewrite
- `utility/ensure_png_rgba.py` — batch RGBA normalization for `Assets.xcassets` composited art (uses edge logic from `remove_white_background.py`)
- `utility/build_and_test.sh` — shell, already compliant

### For New Automation
Expand Down
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Claude Code — use AGENTS.md

All repository rules for contributors and AI agents are defined in **`AGENTS.md`** at the root of this repo (safety, prohibited commands, filesystem boundaries, shell/tooling policy, build and test workflow, SwiftUI guidance).

**Read and follow `AGENTS.md` before substantial work.** Do not contradict it. Put any Claude-only preferences here only if they truly cannot live in `AGENTS.md`; keep them minimal.

**Codex** uses the same `AGENTS.md` directly. **Cursor** is wired via `.cursor/rules/agents-md-authority.mdc` to honor `AGENTS.md`.
10 changes: 5 additions & 5 deletions RA11y-iOS/RA11y-iOS/App/iOSAppRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,10 @@ enum AppRoute: Hashable {
case firstRun(mode: FirstRunMode)
/// Game 1 — The Enchanter's Trial (Find & Focus). Implemented in M5.
case enchantersTrial
/// Game 2 — The Rogue's Gauntlet (Activate / Double-Tap). Implemented in M6.
case roguesGauntlet
/// Game 3 — Crystal Resonance (Scroll Hunt). Implemented in M7.
/// Game 2 — Crystal Resonance (`GameKind.scrollHunt`). Implemented in M7.
case dungeonDescent
/// Game 3 — The Banishment (`GameKind.banishment`). Escape-gesture training.
case theBanishment
/// Crystal Resonance v2 prototype route for iterative design and feedback integration.
case dungeonResonancePrototype
/// The shared result screen shown after any game completes.
Expand Down Expand Up @@ -136,10 +136,10 @@ final class iOSAppRouter {
switch kind {
case .findAndFocus:
push(.enchantersTrial)
case .activateDoubleTap:
push(.roguesGauntlet)
case .scrollHunt:
push(.dungeonDescent)
case .banishment:
push(.theBanishment)
}
}

Expand Down
12 changes: 5 additions & 7 deletions RA11y-iOS/RA11y-iOS/App/iOSRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,6 @@ struct iOSRootView: View {
if args.contains("-uiTesting") {
if args.contains("-screenshotDirectToEnchanter") {
router.pushGame(kind: .findAndFocus)
} else if args.contains("-screenshotDirectToRogue") {
router.pushGame(kind: .activateDoubleTap)
} else if args.contains("-screenshotDirectToDungeon") {
router.pushGame(kind: .scrollHunt)
}
Expand Down Expand Up @@ -91,10 +89,10 @@ struct iOSRootView: View {
iOSFirstRunView(mode: mode, storage: storage)
case .enchantersTrial:
iOSEnchantersTrialView(storage: storage)
case .roguesGauntlet:
iOSRogueGauntletView(storage: storage)
case .dungeonDescent:
iOSDungeonDescentView(storage: storage)
case .theBanishment:
iOSBanishmentQuestView(storage: storage)
case .dungeonResonancePrototype:
iOSDungeonResonanceMockupView()
case .gameResult(let result, let gameKind, let gameSpecificAnnouncement):
Expand Down Expand Up @@ -124,8 +122,8 @@ struct iOSRootView: View {
private func gameKind(forGameID gameID: String) -> GameKind? {
switch gameID {
case "find-and-focus": return .findAndFocus
case "rogue-gauntlet": return .activateDoubleTap
case "scroll-hunt": return .scrollHunt
case "the-banishment": return .banishment
default: return nil
}
}
Expand All @@ -141,8 +139,8 @@ struct iOSRootView: View {
/// long (> ~50 ms) the storage actor or UserDefaults is the bottleneck.
///
/// ## Screenshot Testing — Direct Route Override
/// When launched with `-screenshotDirectToEnchanter` (or `-screenshotDirectToRogue` /
/// `-screenshotDirectToDungeon`), the router's `path` is pre-populated by the `@State`
/// When launched with `-screenshotDirectToEnchanter` or `-screenshotDirectToDungeon`,
/// the router's `path` is pre-populated by the `@State`
/// initializer before this task even runs. This function does not touch the path for
/// those scenarios — it only removes the loading overlay by setting `hasResolvedInitialRoute`.
private func resolveInitialRouteIfNeeded() async {
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 27 additions & 7 deletions RA11y-iOS/RA11y-iOS/Feedback/iOSAudioFeedbackRenderer.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import AudioToolbox
import AVFoundation
import OSLog
import RA11yCore
import UIKit

/// Renders semantic feedback intents as lightweight generated iOS tones.
///
Expand Down Expand Up @@ -39,15 +41,29 @@ final class iOSAudioFeedbackRenderer: iOSAudioFeedbackRendering {

func render(intent: QuestFeedbackIntent, cue: QuestFeedbackCue) {
guard cue.audio != .none else { return }

// `1520` — system “peek” tap; louder than `1104` and still short enough to read through VoiceOver.
if cue.audio == .laneSlotStep, UIAccessibility.isVoiceOverRunning {
AudioServicesPlaySystemSound(1520)
RA11yLogger.feedback.debug("Lane slot audio: system peek (VoiceOver on)")
return
}

configureSessionIfNeeded()
prepareEngineIfNeeded()

let segments = cuePattern(for: intent, family: cue.audio)

guard engine.isRunning else {
RA11yLogger.feedback.error("Audio cue dropped because engine is not running")
if cue.audio == .laneSlotStep {
AudioServicesPlaySystemSound(1520)
RA11yLogger.feedback.warning("Lane slot: engine down; played system sound fallback")
} else {
RA11yLogger.feedback.error("Audio cue dropped because engine is not running")
}
return
}

let segments = cuePattern(for: intent, family: cue.audio)
guard let buffer = makeBuffer(for: segments) else {
RA11yLogger.feedback.error("Failed to synthesize audio cue for family \(cue.audio.rawValue)")
return
Expand Down Expand Up @@ -99,6 +115,10 @@ final class iOSAudioFeedbackRenderer: iOSAudioFeedbackRendering {
return []
case .resonance:
return resonancePattern(for: intent)
case .laneSlotStep:
return [
ToneSegment(primaryFrequency: 588, secondaryFrequency: 884, duration: 0.09, amplitude: 0.22, trailingSilence: 0.0),
]
case .mutedError:
return [
ToneSegment(primaryFrequency: 288, secondaryFrequency: 272, duration: 0.08, amplitude: 0.07, trailingSilence: 0.02),
Expand Down Expand Up @@ -126,23 +146,23 @@ final class iOSAudioFeedbackRenderer: iOSAudioFeedbackRendering {
switch intent {
case .proximityEntered(.warm):
return [
ToneSegment(primaryFrequency: 432, secondaryFrequency: 438, duration: 0.12, amplitude: 0.05, trailingSilence: 0.0),
ToneSegment(primaryFrequency: 432, secondaryFrequency: 438, duration: 0.12, amplitude: 0.09, trailingSilence: 0.0),
]
case .proximityEntered(.near):
return [
ToneSegment(primaryFrequency: 524, secondaryFrequency: 526, duration: 0.12, amplitude: 0.07, trailingSilence: 0.0),
ToneSegment(primaryFrequency: 524, secondaryFrequency: 526, duration: 0.12, amplitude: 0.12, trailingSilence: 0.0),
]
case .proximityEntered(.locked), .lockAcquired:
return [
ToneSegment(primaryFrequency: 660, secondaryFrequency: 990, duration: 0.18, amplitude: 0.1, trailingSilence: 0.0),
ToneSegment(primaryFrequency: 660, secondaryFrequency: 990, duration: 0.18, amplitude: 0.14, trailingSilence: 0.0),
]
case .lockLost:
return [
ToneSegment(primaryFrequency: 510, secondaryFrequency: 500, duration: 0.1, amplitude: 0.06, trailingSilence: 0.0),
ToneSegment(primaryFrequency: 510, secondaryFrequency: 500, duration: 0.1, amplitude: 0.09, trailingSilence: 0.0),
]
default:
return [
ToneSegment(primaryFrequency: 410, secondaryFrequency: 418, duration: 0.1, amplitude: 0.04, trailingSilence: 0.0),
ToneSegment(primaryFrequency: 410, secondaryFrequency: 418, duration: 0.1, amplitude: 0.08, trailingSilence: 0.0),
]
}
}
Expand Down
14 changes: 14 additions & 0 deletions RA11y-iOS/RA11y-iOS/Feedback/iOSHapticFeedbackRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,29 +17,43 @@ final class iOSHapticFeedbackRenderer: iOSHapticFeedbackRendering {
private let mediumImpact = UIImpactFeedbackGenerator(style: .medium)
private let rigidImpact = UIImpactFeedbackGenerator(style: .rigid)
private let notification = UINotificationFeedbackGenerator()
private let selectionChanged = UISelectionFeedbackGenerator()

init() {
lightImpact.prepare()
mediumImpact.prepare()
rigidImpact.prepare()
notification.prepare()
selectionChanged.prepare()
}

func render(intent: QuestFeedbackIntent, cue: QuestFeedbackCue) {
switch cue.haptic {
case .none:
return
case .softTick:
lightImpact.prepare()
lightImpact.impactOccurred(intensity: 0.55)
case .selectionChanged:
// `UISelectionFeedbackGenerator` is easy to miss with VoiceOver; pair with a light impact.
selectionChanged.prepare()
selectionChanged.selectionChanged()
lightImpact.prepare()
lightImpact.impactOccurred(intensity: 0.95)
case .proximityPulse:
mediumImpact.prepare()
mediumImpact.impactOccurred(intensity: 0.7)
case .alignmentSnap:
rigidImpact.prepare()
rigidImpact.impactOccurred(intensity: 0.95)
case .errorTap:
notification.prepare()
notification.notificationOccurred(.error)
case .successPulse:
notification.prepare()
notification.notificationOccurred(.success)
case .warningTap:
notification.prepare()
notification.notificationOccurred(.warning)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,8 @@ final class iOSQuestFeedbackCoordinator {
return profile.timeoutCue
case .hint:
return profile.hintCue
case .laneSlotTick:
return profile.laneSlotCue
}
}

Expand All @@ -112,6 +114,8 @@ final class iOSQuestFeedbackCoordinator {
return "timeout"
case .hint:
return "hint"
case .laneSlotTick:
return "lane-slot"
}
}
}
16 changes: 8 additions & 8 deletions RA11y-iOS/RA11y-iOS/FirstRun/iOSBasicsSequenceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import RA11yCore

// MARK: - iOSBasicsSequenceView

/// Guided VoiceOver Basics sequence that walks the player through all three MVP games
/// in hub unlock order (Enchanter → Crystal Resonance → Rogue).
/// Guided VoiceOver Basics sequence that walks the player through MVP games
/// in hub unlock order (Enchanter → Crystal Resonance → The Banishment).
///
/// This view acts as the **conductor** for the M4 basics flow. It stays on the navigation
/// stack throughout the sequence and orchestrates the push-and-return loop:
Expand All @@ -27,7 +27,7 @@ struct iOSBasicsSequenceView: View {
// MARK: - Private Properties

private let storage: any StorageComponent
private let steps: [GameKind] = [.findAndFocus, .scrollHunt, .activateDoubleTap]
private let steps: [GameKind] = [.findAndFocus, .scrollHunt, .banishment]

// MARK: - State

Expand Down Expand Up @@ -163,21 +163,21 @@ struct iOSBasicsSequenceView: View {
switch steps[currentIndex] {
case .findAndFocus:
return String(localized: "basicsSequence.skill.findAndFocus.title")
case .activateDoubleTap:
return String(localized: "basicsSequence.skill.activateDoubleTap.title")
case .scrollHunt:
return String(localized: "basicsSequence.skill.scrollHunt.title")
case .banishment:
return String(localized: "basicsSequence.skill.banishment.title")
}
}

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")
case .banishment:
return String(localized: "basicsSequence.skill.banishment.intro")
}
}

Expand Down Expand Up @@ -226,7 +226,7 @@ struct iOSBasicsSequenceView: View {
if nextIndex < steps.count {
currentIndex = nextIndex
} else {
// All three games completed — mark done and return to hub.
// All Basics steps completed — mark done and return to hub.
router.isInBasicsSequence = false
Task {
await storage.markBasicsCompleted()
Expand Down
Loading
Loading