Signed-off-by: Matt Bruce <mbrucedogs@gmail.com>

This commit is contained in:
Matt Bruce 2026-02-08 12:19:33 -06:00
parent 469f960fec
commit efc1ee5fa7
19 changed files with 287 additions and 71 deletions

View File

@ -53,7 +53,7 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
EAC04A982F26BAE8007F87EA /* Andromida.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Andromida.app; sourceTree = BUILT_PRODUCTS_DIR; };
EAC04A982F26BAE8007F87EA /* Rituals.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Rituals.app; sourceTree = BUILT_PRODUCTS_DIR; };
EAC04AA52F26BAE9007F87EA /* AndromidaTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AndromidaTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EAC04AAF2F26BAE9007F87EA /* AndromidaUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AndromidaUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = AndromidaWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@ -166,7 +166,7 @@
EAC04A992F26BAE8007F87EA /* Products */ = {
isa = PBXGroup;
children = (
EAC04A982F26BAE8007F87EA /* Andromida.app */,
EAC04A982F26BAE8007F87EA /* Rituals.app */,
EAC04AA52F26BAE9007F87EA /* AndromidaTests.xctest */,
EAC04AAF2F26BAE9007F87EA /* AndromidaUITests.xctest */,
EAC04D2F2F298D9B007F87EA /* AndromidaWidgetExtension.appex */,
@ -208,7 +208,7 @@
EAC04AED2F26BD5B007F87EA /* Bedrock */,
);
productName = Andromida;
productReference = EAC04A982F26BAE8007F87EA /* Andromida.app */;
productReference = EAC04A982F26BAE8007F87EA /* Rituals.app */;
productType = "com.apple.product-type.application";
};
EAC04AA42F26BAE9007F87EA /* AndromidaTests */ = {

View File

@ -24,19 +24,43 @@ struct AndromidaApp: App {
)
Theme.register(border: AppBorder.self)
let environment = ProcessInfo.processInfo.environment
let isRunningTests = environment["XCTestConfigurationFilePath"] != nil
let isUITesting = environment["UITEST_MODE"] == "1"
if isUITesting {
if environment["UITEST_RESET_USER_DEFAULTS"] == "1",
let bundleIdentifier = Bundle.main.bundleIdentifier {
UserDefaults.standard.removePersistentDomain(forName: bundleIdentifier)
}
if let completedValue = environment["UITEST_HAS_COMPLETED_SETUP_WIZARD"] {
let hasCompleted = completedValue == "1" || completedValue.lowercased() == "true"
UserDefaults.standard.set(hasCompleted, forKey: "hasCompletedSetupWizard")
}
}
// Include all models in schema - Ritual, RitualArc, and ArcHabit
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
let configuration: ModelConfiguration
if isUITesting {
// UI tests should always run with isolated in-memory persistence.
configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none
)
} else {
// Use App Group for shared container between app and widget.
// Disable CloudKit mirroring under XCTest to keep simulator tests deterministic.
let storeURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: AppIdentifiers.appGroupIdentifier)?
.appendingPathComponent("Andromida.sqlite") ?? URL.documentsDirectory.appendingPathComponent("Andromida.sqlite")
let isRunningTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil
let configuration = ModelConfiguration(
configuration = ModelConfiguration(
schema: schema,
url: storeURL,
cloudKitDatabase: isRunningTests ? .none : .private(AppIdentifiers.cloudKitContainerIdentifier)
)
}
let container: ModelContainer
do {

View File

@ -106,7 +106,7 @@ final class Ritual {
// Arcs - each arc represents a time-bound period with its own habits
@Relationship(deleteRule: .cascade)
var arcs: [RitualArc]? = []
var arcs: [RitualArc]?
init(
id: UUID = UUID(),

View File

@ -13,7 +13,7 @@ final class RitualArc {
var isActive: Bool = true
@Relationship(deleteRule: .cascade)
var habits: [ArcHabit]? = []
var habits: [ArcHabit]?
@Relationship(inverse: \Ritual.arcs)
var ritual: Ritual?

View File

@ -78,8 +78,10 @@ final class RitualStore: RitualStoreProviding {
self.currentTimeOfDay = TimeOfDay.current(for: now())
runDataIntegrityMigrationIfNeeded()
loadRitualsIfNeeded()
if !isRunningTests {
observeRemoteChanges()
}
}
private func now() -> Date {
nowProvider()

View File

@ -108,10 +108,12 @@ struct FirstCheckInStepView: View {
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.firstCheckInContinue")
Button(action: onComplete) {
Text(String(localized: "Skip for now")).styled(.subheading, emphasis: .secondary)
}
.accessibilityIdentifier("onboarding.firstCheckInSkip")
}
.padding(.horizontal, Design.Spacing.xxLarge)
@ -155,6 +157,7 @@ struct FirstCheckInStepView: View {
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.firstCheckInContinueToRituals")
.padding(.horizontal, Design.Spacing.xxLarge)
.transition(.move(edge: .bottom).combined(with: .opacity))
}

View File

@ -62,6 +62,7 @@ struct GoalSelectionStepView: View {
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.goalContinue")
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.bottom, Design.Spacing.xxLarge)
.transition(.move(edge: .bottom).combined(with: .opacity))
@ -126,8 +127,11 @@ private struct GoalCardView: View {
)
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityLabel(goal.displayName)
.accessibilityHint(goal.subtitle)
.accessibilityIdentifier("onboarding.goal.\(goal.rawValue)")
}
}

View File

@ -85,6 +85,7 @@ struct NotificationStepView: View {
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.enableNotifications")
.disabled(isRequestingPermission)
// Secondary skip option
@ -93,6 +94,7 @@ struct NotificationStepView: View {
.typography(.body)
.foregroundStyle(AppTextColors.secondary)
}
.accessibilityIdentifier("onboarding.notificationsMaybeLater")
.disabled(isRequestingPermission)
}
.padding(.horizontal, Design.Spacing.xxLarge)

View File

@ -90,11 +90,13 @@ struct RitualPreviewStepView: View {
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.startRitual")
// Skip option
Button(action: onSkip) {
Text(String(localized: "Skip for now")).styled(.subheading, emphasis: .secondary)
}
.accessibilityIdentifier("onboarding.skipRitual")
}
.padding(.horizontal, Design.Spacing.xxLarge)
.opacity(animateContent ? 1 : 0)

View File

@ -62,6 +62,7 @@ struct TimeSelectionStepView: View {
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.timeContinue")
.padding(.horizontal, Design.Spacing.xxLarge)
.padding(.bottom, Design.Spacing.xxLarge)
.transition(.move(edge: .bottom).combined(with: .opacity))
@ -131,8 +132,11 @@ private struct TimeCardView: View {
)
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityAddTraits(.isButton)
.accessibilityLabel(time.displayName)
.accessibilityHint(time.subtitle)
.accessibilityIdentifier("onboarding.time.\(time.rawValue)")
}
}

View File

@ -46,6 +46,7 @@ struct WelcomeStepView: View {
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.getStarted")
.padding(.horizontal, Design.Spacing.xxLarge)
.opacity(animateButton ? 1 : 0)
.offset(y: animateButton ? 0 : 20)

View File

@ -74,6 +74,7 @@ struct WhatsNextStepView: View {
.background(AppAccent.primary)
.clipShape(.rect(cornerRadius: Design.CornerRadius.medium))
}
.accessibilityIdentifier("onboarding.letsGo")
.padding(.horizontal, Design.Spacing.xxLarge)
.opacity(animateContent ? 1 : 0)
.animation(.easeOut(duration: 0.4).delay(0.4), value: animateContent)

View File

@ -72,16 +72,20 @@ struct RitualsView: View {
} label: {
Label(String(localized: "Create New"), systemImage: "plus.circle")
}
.accessibilityIdentifier("rituals.createNew")
Button {
showingPresetLibrary = true
} label: {
Label(String(localized: "Browse Presets"), systemImage: "sparkles.rectangle.stack")
}
.accessibilityIdentifier("rituals.browsePresets")
} label: {
Image(systemName: "plus")
.foregroundStyle(AppAccent.primary)
.accessibilityIdentifier("rituals.addMenu")
}
.accessibilityIdentifier("rituals.addMenu")
}
}
.sheet(isPresented: $showingPresetLibrary) {

View File

@ -62,6 +62,7 @@ struct RitualEditSheet: View {
Button(String(localized: "Cancel")) {
dismiss()
}
.accessibilityIdentifier("ritualEditor.cancelButton")
.foregroundStyle(AppTextColors.secondary)
}
@ -70,6 +71,7 @@ struct RitualEditSheet: View {
saveRitual()
dismiss()
}
.accessibilityIdentifier("ritualEditor.saveButton")
.foregroundStyle(AppAccent.primary)
.disabled(!canSave)
}
@ -112,10 +114,12 @@ struct RitualEditSheet: View {
VStack(alignment: .leading, spacing: Design.Spacing.xSmall) {
TextField(String(localized: "Ritual name"), text: $title)
.typography(.heading)
.accessibilityIdentifier("ritualEditor.titleField")
TextField(String(localized: "Theme or tagline"), text: $theme)
.typography(.subheading)
.foregroundStyle(AppTextColors.secondary)
.accessibilityIdentifier("ritualEditor.themeField")
}
}
.listRowBackground(AppSurface.card)
@ -296,6 +300,7 @@ struct RitualEditSheet: View {
.buttonStyle(.plain)
TextField(String(localized: "Add a habit..."), text: $newHabitTitle)
.accessibilityIdentifier("ritualEditor.newHabitField")
.onSubmit {
addNewHabit()
}
@ -306,6 +311,7 @@ struct RitualEditSheet: View {
Image(systemName: "plus.circle.fill")
.foregroundStyle(AppAccent.primary)
}
.accessibilityIdentifier("ritualEditor.addHabitButton")
.buttonStyle(.plain)
.disabled(newHabitTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
}

View File

@ -6,6 +6,8 @@
//
import Testing
import SwiftData
import Foundation
@testable import Rituals
struct AndromidaTests {
@ -14,4 +16,51 @@ struct AndromidaTests {
// Write your test here and use APIs like `#expect(...)` to check expected conditions.
}
@MainActor
@Test func modelContextCanInsertBareRitual() throws {
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none
)
let container = try ModelContainer(for: schema, configurations: [config])
let context = container.mainContext
let ritual = Ritual(title: "Bare", theme: "Test", arcs: [])
context.insert(ritual)
try context.save()
let rituals = try context.fetch(FetchDescriptor<Ritual>())
#expect(rituals.count == 1)
}
@MainActor
@Test func storeCanCreateQuickRitual() throws {
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
let config = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none
)
let container = try ModelContainer(for: schema, configurations: [config])
let store = RitualStore(
modelContext: container.mainContext,
seedService: EmptySeedService(),
settingsStore: TestFeedbackSettings()
)
store.createQuickRitual()
#expect(store.rituals.count == 1)
}
}
private struct EmptySeedService: RitualSeedProviding {
func makeSeedRituals(startDate: Date) -> [Ritual] { [] }
}
private struct TestFeedbackSettings: RitualFeedbackSettingsProviding {
var hapticsEnabled: Bool = false
var soundEnabled: Bool = false
}

View File

@ -3,6 +3,7 @@ import SwiftData
import Testing
@testable import Rituals
@Suite(.serialized)
struct RitualStoreTests {
@MainActor
@Test func quickRitualStartsIncomplete() throws {
@ -411,8 +412,18 @@ struct RitualStoreTests {
@MainActor
private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore {
let container = SharedTestContainer.container
clearSharedTestContainer(container.mainContext)
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none
)
let container: ModelContainer
do {
container = try ModelContainer(for: schema, configurations: [configuration])
} catch {
fatalError("Test container failed: \(error)")
}
return RitualStore(
modelContext: container.mainContext,
@ -422,42 +433,6 @@ private func makeStore(now: @escaping () -> Date = Date.init) -> RitualStore {
)
}
@MainActor
private enum SharedTestContainer {
static let container: ModelContainer = {
let schema = Schema([Ritual.self, RitualArc.self, ArcHabit.self])
let configuration = ModelConfiguration(
schema: schema,
isStoredInMemoryOnly: true,
cloudKitDatabase: .none
)
do {
return try ModelContainer(for: schema, configurations: [configuration])
} catch {
fatalError("Test container failed: \(error)")
}
}()
}
@MainActor
private func clearSharedTestContainer(_ context: ModelContext) {
do {
for habit in try context.fetch(FetchDescriptor<ArcHabit>()) {
context.delete(habit)
}
for arc in try context.fetch(FetchDescriptor<RitualArc>()) {
context.delete(arc)
}
for ritual in try context.fetch(FetchDescriptor<Ritual>()) {
context.delete(ritual)
}
try context.save()
} catch {
fatalError("Failed to reset shared test container: \(error)")
}
}
private struct EmptySeedService: RitualSeedProviding {
func makeSeedRituals(startDate: Date) -> [Ritual] {
[]

View File

@ -10,32 +10,166 @@ import XCTest
final class AndromidaUITests: 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 its 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.
}
@MainActor
func testExample() throws {
// UI tests must launch the application that they test.
let app = XCUIApplication()
func testOnboardingFlowCompletes() throws {
let onboardingApp = makeApp(resetDefaults: true, hasCompletedSetupWizard: false)
onboardingApp.launch()
let getStarted = onboardingApp.buttons["onboarding.getStarted"]
XCTAssertTrue(getStarted.waitForExistence(timeout: 8))
onboardingApp.terminate()
let completedApp = makeApp(resetDefaults: false, hasCompletedSetupWizard: true)
completedApp.launch()
let ritualsTab = completedApp.tabBars.buttons["Rituals"]
XCTAssertTrue(ritualsTab.waitForExistence(timeout: 8))
}
@MainActor
func testOnboardingHappyPath() throws {
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: false)
app.launch()
// Use XCTAssert and related functions to verify your tests produce the correct results.
XCTAssertTrue(app.buttons["onboarding.getStarted"].waitForExistence(timeout: 8))
app.buttons["onboarding.getStarted"].tap()
tapFirstAvailableElement(
app: app,
identifiers: ["onboarding.goal.health"],
fallbackLabels: ["Health"]
)
XCTAssertTrue(app.buttons["onboarding.goalContinue"].waitForExistence(timeout: 8))
app.buttons["onboarding.goalContinue"].tap()
tapFirstAvailableElement(
app: app,
identifiers: ["onboarding.time.morning"],
fallbackLabels: ["Morning"]
)
XCTAssertTrue(app.buttons["onboarding.timeContinue"].waitForExistence(timeout: 8))
app.buttons["onboarding.timeContinue"].tap()
if app.buttons["onboarding.startRitual"].waitForExistence(timeout: 8) {
app.buttons["onboarding.startRitual"].tap()
} else {
XCTAssertTrue(app.buttons["onboarding.skipRitual"].waitForExistence(timeout: 8))
app.buttons["onboarding.skipRitual"].tap()
}
if app.buttons["onboarding.firstCheckInSkip"].waitForExistence(timeout: 8) {
app.buttons["onboarding.firstCheckInSkip"].tap()
} else {
XCTAssertTrue(app.buttons["onboarding.firstCheckInContinueToRituals"].waitForExistence(timeout: 8))
app.buttons["onboarding.firstCheckInContinueToRituals"].tap()
}
XCTAssertTrue(app.buttons["onboarding.notificationsMaybeLater"].waitForExistence(timeout: 8))
app.buttons["onboarding.notificationsMaybeLater"].tap()
XCTAssertTrue(app.buttons["onboarding.letsGo"].waitForExistence(timeout: 8))
app.buttons["onboarding.letsGo"].tap()
XCTAssertTrue(app.tabBars.buttons["Rituals"].waitForExistence(timeout: 8))
}
@MainActor
func testCreateRitualFromRitualsTab() throws {
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
app.launch()
let ritualsTab = app.tabBars.buttons["Rituals"]
XCTAssertTrue(ritualsTab.waitForExistence(timeout: 8))
ritualsTab.tap()
let addMenu = app.buttons["rituals.addMenu"]
XCTAssertTrue(addMenu.waitForExistence(timeout: 8))
addMenu.tap()
let createNew = app.buttons["rituals.createNew"]
XCTAssertTrue(createNew.waitForExistence(timeout: 8))
createNew.tap()
let titleField = app.textFields["ritualEditor.titleField"]
XCTAssertTrue(titleField.waitForExistence(timeout: 8))
titleField.tap()
titleField.typeText("UI Test Ritual")
let themeField = app.textFields["ritualEditor.themeField"]
XCTAssertTrue(themeField.waitForExistence(timeout: 8))
themeField.tap()
themeField.typeText("Daily focus")
let newHabitField = app.textFields["ritualEditor.newHabitField"]
XCTAssertTrue(newHabitField.waitForExistence(timeout: 8))
newHabitField.tap()
newHabitField.typeText("Stretch")
let addHabitButton = app.buttons["ritualEditor.addHabitButton"]
XCTAssertTrue(addHabitButton.waitForExistence(timeout: 8))
addHabitButton.tap()
let saveButton = app.buttons["ritualEditor.saveButton"]
XCTAssertTrue(saveButton.waitForExistence(timeout: 8))
saveButton.tap()
XCTAssertTrue(app.staticTexts["UI Test Ritual"].waitForExistence(timeout: 8))
}
@MainActor
func testLaunchPerformance() throws {
// This measures how long it takes to launch your application.
let app = makeApp(resetDefaults: true, hasCompletedSetupWizard: true)
measure(metrics: [XCTApplicationLaunchMetric()]) {
XCUIApplication().launch()
app.launch()
app.terminate()
}
}
private func makeApp(resetDefaults: Bool, hasCompletedSetupWizard: Bool) -> XCUIApplication {
let app = XCUIApplication()
app.launchEnvironment["UITEST_MODE"] = "1"
if resetDefaults {
app.launchEnvironment["UITEST_RESET_USER_DEFAULTS"] = "1"
}
app.launchEnvironment["UITEST_HAS_COMPLETED_SETUP_WIZARD"] = hasCompletedSetupWizard ? "1" : "0"
return app
}
private func tapFirstAvailableElement(
app: XCUIApplication,
identifiers: [String],
fallbackLabels: [String],
timeout: TimeInterval = 8
) {
for identifier in identifiers {
let candidates = [
app.buttons[identifier],
app.otherElements[identifier],
app.staticTexts[identifier],
app.descendants(matching: .any)[identifier]
]
for element in candidates where element.waitForExistence(timeout: 1) {
element.tap()
return
}
}
for label in fallbackLabels {
let candidates = [
app.buttons[label],
app.otherElements[label],
app.staticTexts[label]
]
for element in candidates where element.waitForExistence(timeout: timeout) {
element.tap()
return
}
}
XCTFail("Unable to locate tap target. identifiers=\(identifiers), labels=\(fallbackLabels)")
}
}

5
PRD.md
View File

@ -592,8 +592,10 @@ Andromida/
| Unit tests in `AndromidaTests/` covering store logic and analytics |
| UI tests in `AndromidaUITests/` for critical user flows |
| Unit-test harness should use deterministic in-memory SwiftData setup to prevent host-app test instability |
| UI-test harness should run app launches with deterministic flags (`UITEST_MODE`, optional reset/onboarding overrides) and in-memory SwiftData |
| UI launch coverage should prioritize stable smoke validation over exhaustive simulator configuration matrices |
| Test command: `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'` |
| UI coverage should include onboarding state transition validation and ritual creation flow from the Rituals tab |
| Test command: `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2'` |
---
@ -603,3 +605,4 @@ Andromida/
|---------|------|-------------|
| 1.0 | February 2026 | Initial PRD based on implemented features |
| 1.1 | February 2026 | Fixed time-of-day refresh bug in Today view and Widget; added debug time simulation |
| 1.2 | February 2026 | Added deterministic UI-test launch harness and expanded critical UI flow coverage |

View File

@ -182,10 +182,12 @@ String catalogs are used for English (en), Spanish (es-MX), and French (fr-CA):
- Unit tests in `AndromidaTests/`
- Run via Xcode Test navigator or:
- `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro'`
- `xcodebuild test -scheme Andromida -destination 'platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2'`
- XCTest runs disable SwiftData CloudKit mirroring in the host app to keep simulator tests deterministic.
- `RitualStoreTests` use a shared in-memory SwiftData container with per-test cleanup to avoid host-process container churn.
- `AndromidaUITestsLaunchTests` runs a single launch configuration to reduce flaky simulator timeouts.
- `AndromidaUITests` launch with `UITEST_MODE=1`, forcing in-memory SwiftData and optional launch-state overrides (`UITEST_RESET_USER_DEFAULTS`, `UITEST_HAS_COMPLETED_SETUP_WIZARD`) for deterministic UI runs.
- UI coverage includes stable onboarding-state transition validation and ritual creation from the Rituals tab.
## Notes