From ed8e707d93c11b5e356f97c3387b3aced89b0bbb Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:18:40 +0000 Subject: [PATCH 01/14] =?UTF-8?q?=F0=9F=90=9B=20Keep=20OnboardingFlowView'?= =?UTF-8?q?s=20page=20list=20stable=20for=20the=20whole=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pending step list was computed in init, and init runs on every parent re-render. Once OnboardingFlow became observable, every recorded answer re-rendered the parent and rebuilt the list without the step just answered, so the TabView's pages shrank mid-flow: index 1 became a different screen (a skipped step), a page changed identity during the slide (the crossed-over transition), and with one step left the dots disappeared and there was nothing to swipe back to. Hold the snapshot in @State so the first one sticks, read storage directly when taking it so the parent doesn't subscribe to the flow, and drop the speculative .id() added while chasing the symptom. Co-Authored-By: Claude Fable 5.1 --- Sources/HowdyKitUI/OnboardingFlowView.swift | 32 ++++++++++++--------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/Sources/HowdyKitUI/OnboardingFlowView.swift b/Sources/HowdyKitUI/OnboardingFlowView.swift index fe4b957..5bc6b9d 100644 --- a/Sources/HowdyKitUI/OnboardingFlowView.swift +++ b/Sources/HowdyKitUI/OnboardingFlowView.swift @@ -7,19 +7,23 @@ import SwiftUI /// and anything after them share the same flow mechanics, not just the same /// per-step look. /// -/// The pending step list is captured once, at init. Steps already answered -/// when the flow starts never appear; a step answered *during* the flow -/// (via its own `OnboardingAction`) doesn't resize the page count out from -/// under an in-progress swipe, it's just skipped over on `advance()`. +/// The pending step list is captured once, when the view first appears, and +/// held in `@State`. Steps already answered when the flow starts never +/// appear; a step answered *during* the flow does not resize the page +/// count out from under the user (which would shift every index, drop +/// pages, and hide the dots once one step is left). public struct OnboardingFlowView: View { private let flow: OnboardingFlow private let welcome: (() -> Welcome)? - private let pendingSteps: [OnboardingStepConfig] private let onFinished: () -> Void private let stepContent: (OnboardingStepConfig, @escaping () -> Void) -> StepContent @State private var currentIndex = 0 @State private var highestVisitedIndex = 0 + // `init` runs on every parent re-render (and every recorded answer + // re-renders the parent now that the flow is observable); `@State` + // keeps the first snapshot and ignores the later `initialValue`s. + @State private var pendingSteps: [OnboardingStepConfig] /// - Parameter welcome: builds the welcome screen, shown while /// `flow.needsWelcome`. Typically an `OnboardingWelcomeView`, which @@ -38,7 +42,7 @@ public struct OnboardingFlowView: View { self.welcome = welcome self.onFinished = onFinished self.stepContent = stepContent - self.pendingSteps = flow.steps.filter { flow.value(for: $0.id) == nil } + _pendingSteps = State(initialValue: Self.snapshot(of: flow)) } public var body: some View { @@ -52,13 +56,6 @@ public struct OnboardingFlowView: View { TabView(selection: $currentIndex) { ForEach(Array(pendingSteps.enumerated()), id: \.element.id) { index, step in stepContent(step, advance) - // Explicit identity per step. Every page shares one - // opaque `StepContent` type (a single closure builds - // them all), so without this SwiftUI may diff - // adjacent pages as one conditional view changing - // content. Added while chasing a crossed-over page - // slide; not yet confirmed as the cause. - .id(step.id.rawValue) .tag(index) } } @@ -89,6 +86,13 @@ public struct OnboardingFlowView: View { onFinished() } } + + // Reads `storage` directly rather than `flow.value(for:)`: this runs + // inside the parent's body, and going through the flow's tracked query + // would make the parent re-render on every answer for no reason. + private static func snapshot(of flow: OnboardingFlow) -> [OnboardingStepConfig] { + flow.steps.filter { flow.storage.value(for: $0.id) == nil } + } } extension OnboardingFlowView where Welcome == EmptyView { @@ -102,6 +106,6 @@ extension OnboardingFlowView where Welcome == EmptyView { self.welcome = nil self.onFinished = onFinished self.stepContent = stepContent - self.pendingSteps = flow.steps.filter { flow.value(for: $0.id) == nil } + _pendingSteps = State(initialValue: Self.snapshot(of: flow)) } } From d4e3f01a20cbca6b85943dd482844612e0ca72d4 Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:22:25 +0000 Subject: [PATCH 02/14] =?UTF-8?q?=F0=9F=92=84=20Give=20the=20step=20footer?= =?UTF-8?q?=2050pt=20of=20clearance=20above=20the=20page=20control?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- Sources/HowdyKitUI/OnboardingStepView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/HowdyKitUI/OnboardingStepView.swift b/Sources/HowdyKitUI/OnboardingStepView.swift index 8846d23..6148720 100644 --- a/Sources/HowdyKitUI/OnboardingStepView.swift +++ b/Sources/HowdyKitUI/OnboardingStepView.swift @@ -89,7 +89,7 @@ public struct OnboardingStepView: View { .tint(theme.primaryColor) .padding(.horizontal, 28) .padding(.top, 8) - .padding(.bottom, 32) + .padding(.bottom, 50) .frame(maxWidth: 560) .frame(maxWidth: .infinity) } From d1472fae0845ac1c4e41b4382e8a2a88089ff64f Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:27:06 +0000 Subject: [PATCH 03/14] =?UTF-8?q?=E2=9C=A8=20Reconcile=20OS-granted=20step?= =?UTF-8?q?s,=20share=20one=20footer=20across=20screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OnboardingFlow.reconcile(isSatisfied:) records true for unanswered steps the app reports as already satisfied outside the flow (a permission iOS already granted). OnboardingFlowView takes an isAlreadySatisfied closure, reconciles before snapshotting its pages and again when the scene becomes active. Resetting the kit's own records, or replaying onboarding, no longer shows a page for something the system already allows. - OnboardingFooter is the one button area the step and welcome views share. Both slots always hold their space, visible or not, so the main button and Skip sit in the same spot on every screen. Tests: 23 (20 flow/storage, 3 theme environment), all passing. Co-Authored-By: Claude Fable 5.1 --- README.md | 9 ++- Sources/HowdyKit/OnboardingFlow.swift | 17 +++++ Sources/HowdyKitUI/OnboardingFlowView.swift | 76 ++++++++++++------- Sources/HowdyKitUI/OnboardingFooter.swift | 54 +++++++++++++ Sources/HowdyKitUI/OnboardingStepView.swift | 36 +-------- .../HowdyKitUI/OnboardingWelcomeView.swift | 21 +++-- Tests/HowdyKitTests/OnboardingFlowTests.swift | 33 ++++++++ 7 files changed, 171 insertions(+), 75 deletions(-) create mode 100644 Sources/HowdyKitUI/OnboardingFooter.swift diff --git a/README.md b/README.md index c06d1ca..169893d 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,10 @@ target that only needs "is setup done" can depend on just this. is the same as tapping Skip), `true` for a non-skippable, acknowledge-only step (there's only one way to leave it). Never overwrites a real answer. + - `reconcile(isSatisfied:)`: records `true` for every unanswered step the + closure reports as already satisfied outside the flow (a permission the OS + already granted). Leaves answered steps alone and only notifies + observers when it records something. - **`OnboardingStepConfig`**: a step's `id` plus two independent flags. `isRequired`: the flow isn't satisfied until the step is recorded `true`. `isSkippable` (default `true`): the step can be left unanswered, which @@ -87,7 +91,10 @@ layer never compiles SwiftUI it doesn't use. swipe-past backfill (`flow.recordIfNeeded(leaving: step)`, driven by `step.isSkippable`; swiping past a required step is allowed and records `false`), and calls `onFinished` once - the last step advances. This is the turnkey piece: CrumbDB and Marie get + the last step advances. Pass `isAlreadySatisfied:` (for example, "is the + OS permission already granted") and steps it accepts are recorded `true` + through `flow.reconcile` before paging, and again when the app becomes + active, so they never get a page. This is the turnkey piece: CrumbDB and Marie get the same flow mechanics, not just the same per-step look. All three are genuinely turnkey. An app can still build its own container diff --git a/Sources/HowdyKit/OnboardingFlow.swift b/Sources/HowdyKit/OnboardingFlow.swift index 549af5b..197d26d 100644 --- a/Sources/HowdyKit/OnboardingFlow.swift +++ b/Sources/HowdyKit/OnboardingFlow.swift @@ -98,6 +98,23 @@ public final class OnboardingFlow { changeToken += 1 } + /// Records `true` for every unanswered step that `isSatisfied` reports + /// as already satisfied outside the flow (a system permission the OS + /// already granted, say). Steps with an answer are left alone. Call it + /// before presenting the flow, and again when the app returns to the + /// foreground, since Settings may have changed a permission meanwhile. + public func reconcile(isSatisfied: (OnboardingStepConfig) -> Bool) { + var recorded = false + for step in steps where storage.value(for: step.id) == nil && isSatisfied(step) { + storage.setValue(true, for: step.id) + recorded = true + } + // Only bump when something changed, so a no-op reconcile re-renders nothing. + if recorded { + changeToken += 1 + } + } + /// Clears every step's record back to unrecorded, plus the welcome's when /// the flow has one, and any IDs in `additionalIDs` that an app stores /// alongside the flow without them being steps. For development/testing - a shipped diff --git a/Sources/HowdyKitUI/OnboardingFlowView.swift b/Sources/HowdyKitUI/OnboardingFlowView.swift index 5bc6b9d..9bd3468 100644 --- a/Sources/HowdyKitUI/OnboardingFlowView.swift +++ b/Sources/HowdyKitUI/OnboardingFlowView.swift @@ -16,8 +16,10 @@ public struct OnboardingFlowView: View { private let flow: OnboardingFlow private let welcome: (() -> Welcome)? private let onFinished: () -> Void + private let isAlreadySatisfied: (OnboardingStepConfig) -> Bool private let stepContent: (OnboardingStepConfig, @escaping () -> Void) -> StepContent + @Environment(\.scenePhase) private var scenePhase @State private var currentIndex = 0 @State private var highestVisitedIndex = 0 // `init` runs on every parent re-render (and every recorded answer @@ -28,6 +30,11 @@ public struct OnboardingFlowView: View { /// - Parameter welcome: builds the welcome screen, shown while /// `flow.needsWelcome`. Typically an `OnboardingWelcomeView`, which /// records the welcome itself. + /// - Parameter isAlreadySatisfied: reports whether a step is already + /// satisfied outside the flow (a permission the OS already granted). + /// Such unanswered steps are recorded `true` via + /// `flow.reconcile(isSatisfied:)` and never get a page. Runs on every + /// init, so keep it cheap. /// - Parameter stepContent: builds one step's view, given the step and /// an `advance` closure the step's own primary action calls after /// recording its answer. The container doesn't record answers itself, @@ -35,46 +42,58 @@ public struct OnboardingFlowView: View { public init( flow: OnboardingFlow, onFinished: @escaping () -> Void = {}, + isAlreadySatisfied: @escaping (OnboardingStepConfig) -> Bool = { _ in false }, @ViewBuilder welcome: @escaping () -> Welcome, @ViewBuilder stepContent: @escaping (OnboardingStepConfig, @escaping () -> Void) -> StepContent ) { self.flow = flow self.welcome = welcome self.onFinished = onFinished + self.isAlreadySatisfied = isAlreadySatisfied self.stepContent = stepContent + flow.reconcile(isSatisfied: isAlreadySatisfied) _pendingSteps = State(initialValue: Self.snapshot(of: flow)) } public var body: some View { - // No state of its own: recording the welcome bumps the flow's change - // token, which `needsWelcome` reads, so this re-renders by itself. - if flow.needsWelcome, let welcome { - welcome() - } else if pendingSteps.isEmpty { - Color.clear.onAppear(perform: onFinished) - } else { - TabView(selection: $currentIndex) { - ForEach(Array(pendingSteps.enumerated()), id: \.element.id) { index, step in - stepContent(step, advance) - .tag(index) + Group { + // No state of its own: recording the welcome bumps the flow's change + // token, which `needsWelcome` reads, so this re-renders by itself. + if flow.needsWelcome, let welcome { + welcome() + } else if pendingSteps.isEmpty { + Color.clear.onAppear(perform: onFinished) + } else { + TabView(selection: $currentIndex) { + ForEach(Array(pendingSteps.enumerated()), id: \.element.id) { index, step in + stepContent(step, advance) + .tag(index) + } } - } - #if os(iOS) - // `.automatic` shows the page dots CrumbDB's own onboarding - // already has (and hides them for a single pending step), not - // suppress them - `.never` also leaves the space iOS reserves - // for the control empty instead of actually removing it. - .tabViewStyle(.page(indexDisplayMode: .automatic)) - #endif - .onChange(of: currentIndex) { _, newIndex in - // A swipe can only move one page, but handle a programmatic - // jump of more than one too: backfill every step passed over. - guard newIndex > highestVisitedIndex else { return } - for passedIndex in highestVisitedIndex.. highestVisitedIndex else { return } + for passedIndex in highestVisitedIndex.. Void = {}, + isAlreadySatisfied: @escaping (OnboardingStepConfig) -> Bool = { _ in false }, @ViewBuilder stepContent: @escaping (OnboardingStepConfig, @escaping () -> Void) -> StepContent ) { self.flow = flow self.welcome = nil self.onFinished = onFinished + self.isAlreadySatisfied = isAlreadySatisfied self.stepContent = stepContent + flow.reconcile(isSatisfied: isAlreadySatisfied) _pendingSteps = State(initialValue: Self.snapshot(of: flow)) } } diff --git a/Sources/HowdyKitUI/OnboardingFooter.swift b/Sources/HowdyKitUI/OnboardingFooter.swift new file mode 100644 index 0000000..4caa099 --- /dev/null +++ b/Sources/HowdyKitUI/OnboardingFooter.swift @@ -0,0 +1,54 @@ +import SwiftUI + +/// The button area every screen shares. Both slots always take their space, +/// visible or not, so the main button and the Skip line sit at the same spot +/// on the welcome, on a step with Skip, and on one without; nothing jumps +/// between pages. +struct OnboardingFooter: View { + let primary: OnboardingAction + let secondary: OnboardingAction? + + @Environment(\.onboardingTheme) private var theme + + var body: some View { + VStack(spacing: 4) { + Group { + if primary.isProminent { + primaryButton.buttonStyle(.borderedProminent) + } else { + primaryButton.buttonStyle(.bordered) + } + } + .controlSize(.large) + + Group { + if let secondary { + Button(secondary.title, action: secondary.handler) + .buttonStyle(.plain) + .font(.subheadline) + .foregroundStyle(theme.primaryColor.opacity(0.85)) + } else { + Color.clear + } + } + .frame(height: 44) + } + // `.borderedProminent`/`.bordered` otherwise fall back to the + // system accent color, not the theme. + .tint(theme.primaryColor) + .padding(.horizontal, 28) + .padding(.top, 8) + // Clearance for the page control iOS overlays on the bottom of a + // paged TabView; it reserves no layout space of its own. + .padding(.bottom, 50) + .frame(maxWidth: 560) + .frame(maxWidth: .infinity) + } + + private var primaryButton: some View { + Button(action: primary.handler) { + Text(primary.title) + .frame(maxWidth: .infinity) + } + } +} diff --git a/Sources/HowdyKitUI/OnboardingStepView.swift b/Sources/HowdyKitUI/OnboardingStepView.swift index 6148720..ba92586 100644 --- a/Sources/HowdyKitUI/OnboardingStepView.swift +++ b/Sources/HowdyKitUI/OnboardingStepView.swift @@ -63,41 +63,7 @@ public struct OnboardingStepView: View { } } - @ViewBuilder private var footer: some View { - VStack(spacing: 4) { - Group { - if primaryAction.isProminent { - primaryButton.buttonStyle(.borderedProminent) - } else { - primaryButton.buttonStyle(.bordered) - } - } - .controlSize(.large) - - if let secondaryAction { - Button(secondaryAction.title, action: secondaryAction.handler) - .buttonStyle(.plain) - .font(.subheadline) - .foregroundStyle(theme.primaryColor.opacity(0.85)) - .frame(minHeight: 44) - } - } - // `.borderedProminent`/`.bordered` otherwise fall back to the - // system accent color, not the theme - without this the header text - // re-themes live but the button never does. - .tint(theme.primaryColor) - .padding(.horizontal, 28) - .padding(.top, 8) - .padding(.bottom, 50) - .frame(maxWidth: 560) - .frame(maxWidth: .infinity) - } - - private var primaryButton: some View { - Button(action: primaryAction.handler) { - Text(primaryAction.title) - .frame(maxWidth: .infinity) - } + OnboardingFooter(primary: primaryAction, secondary: secondaryAction) } } diff --git a/Sources/HowdyKitUI/OnboardingWelcomeView.swift b/Sources/HowdyKitUI/OnboardingWelcomeView.swift index 7df5a72..101c19a 100644 --- a/Sources/HowdyKitUI/OnboardingWelcomeView.swift +++ b/Sources/HowdyKitUI/OnboardingWelcomeView.swift @@ -44,18 +44,15 @@ public struct OnboardingWelcomeView: View { .frame(maxWidth: .infinity, minHeight: geometry.size.height) } } - Button(actionTitle) { - flow.record(true, for: welcomeID) - onContinue() - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .tint(theme.primaryColor) - .frame(maxWidth: 560) - .frame(maxWidth: .infinity) - .padding(.horizontal, 28) - .padding(.top, 8) - .padding(.bottom, 12) + // Same footer as every step, including the empty Skip slot, so + // Continue sits exactly where Next will on the next screen. + OnboardingFooter( + primary: OnboardingAction(title: actionTitle) { + flow.record(true, for: welcomeID) + onContinue() + }, + secondary: nil + ) } .background(theme.background.ignoresSafeArea()) } diff --git a/Tests/HowdyKitTests/OnboardingFlowTests.swift b/Tests/HowdyKitTests/OnboardingFlowTests.swift index 41064f8..7f503ac 100644 --- a/Tests/HowdyKitTests/OnboardingFlowTests.swift +++ b/Tests/HowdyKitTests/OnboardingFlowTests.swift @@ -202,6 +202,39 @@ struct OnboardingFlowTests { flow.record(true, for: health) #expect(flag.value) } + + @Test("Reconcile records true for satisfied unanswered steps and leaves the rest nil") + func reconcileRecordsSatisfiedSteps() { + let flow = threeStepFlow(storage: makeStorage()) + flow.reconcile { $0.id == location } + #expect(flow.value(for: location) == true) + #expect(flow.value(for: health) == nil) + #expect(flow.value(for: photos) == nil) + } + + @Test("Reconcile never changes an existing answer") + func reconcileKeepsExistingAnswers() { + let flow = threeStepFlow(storage: makeStorage()) + flow.record(false, for: photos) + flow.reconcile { _ in true } + #expect(flow.value(for: photos) == false) + #expect(flow.value(for: health) == true) + #expect(flow.value(for: location) == true) + } + + @Test("Reconcile notifies Observation only when it records something") + func reconcileNotifiesOnlyOnChange() { + let flow = threeStepFlow(storage: makeStorage()) + let quiet = Flag() + withObservationTracking({ _ = flow.needsOnboarding }, onChange: { quiet.value = true }) + flow.reconcile { _ in false } + #expect(!quiet.value) + + let loud = Flag() + withObservationTracking({ _ = flow.needsOnboarding }, onChange: { loud.value = true }) + flow.reconcile { $0.id == health } + #expect(loud.value) + } } @Suite("UserDefaults storage") From c089e59fd52b199700dee166c5694b2590dba27d Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:29:02 +0000 Subject: [PATCH 04/14] =?UTF-8?q?=E2=9C=A8=20Render=20the=20footer=20once?= =?UTF-8?q?=20below=20the=20pages,=20dots=20above=20the=20buttons?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each page drew its own footer inside the TabView, so the page control sat on top of the button area. Step and welcome views now publish their actions upward through a preference (keyed by step ID from the environment) and OnboardingFlowView renders one OnboardingFooter under the TabView for the page on screen. A step view used outside a flow still draws its own footer. Public API of the step and welcome views is unchanged; Dolly needed no source changes. Tests: 30 (20 flow/storage, 10 UI), all passing. Co-Authored-By: Claude Fable 5.1 --- README.md | 9 ++- Sources/HowdyKitUI/OnboardingActions.swift | 39 +++++++++++ Sources/HowdyKitUI/OnboardingFlowView.swift | 47 +++++++++++-- Sources/HowdyKitUI/OnboardingFooter.swift | 9 +-- Sources/HowdyKitUI/OnboardingStepView.swift | 16 ++++- .../HowdyKitUI/OnboardingWelcomeView.swift | 24 ++++--- .../OnboardingActionsTests.swift | 69 +++++++++++++++++++ 7 files changed, 189 insertions(+), 24 deletions(-) create mode 100644 Sources/HowdyKitUI/OnboardingActions.swift create mode 100644 Tests/HowdyKitUITests/OnboardingActionsTests.swift diff --git a/README.md b/README.md index 169893d..3bff924 100644 --- a/README.md +++ b/README.md @@ -75,15 +75,20 @@ layer never compiles SwiftUI it doesn't use. which `OnboardingAction` to pass on each render, the view itself just renders whatever it's given right now. - **`OnboardingStepView`**: header + arbitrary `@ViewBuilder` content + - primary/optional-secondary actions, themed. Renders one step. + primary/optional-secondary actions, themed. Renders one step. Inside a flow + it publishes its actions to `OnboardingFlowView`'s shared footer; on its own + it draws the footer itself. - **`OnboardingWelcomeView`**: the first-run screen. No header/content split forced on it (a hero layout usually wants more room than that), no skip, a single action that records `true` for the flow's `welcomeID` itself. Still themed and still the same `OnboardingFlow` API as every other step, just its own config so the layout can be as custom as CrumbDB's brand intro or Marie's - plain "Welcome to Marie" needs. + plain "Welcome to Marie" needs. Publishes its action to the flow's shared + footer the same way a step does. - **`OnboardingFlowView`**: shows the welcome first when the flow still needs it, then sequences the steps a flow still needs into a paged container. + It renders one footer under the pages (page dots above the buttons) for + whichever page is showing. Recording the welcome moves it on to the steps on its own, so an app doesn't branch on `needsWelcome` itself. An app hands it per-step content (keyed by `OnboardingStepConfig`) and an `advance` closure to call once that step's diff --git a/Sources/HowdyKitUI/OnboardingActions.swift b/Sources/HowdyKitUI/OnboardingActions.swift new file mode 100644 index 0000000..19f50b1 --- /dev/null +++ b/Sources/HowdyKitUI/OnboardingActions.swift @@ -0,0 +1,39 @@ +import HowdyKit +import SwiftUI + +/// What a page wants in the shared footer. Published upward by +/// `OnboardingStepView`/`OnboardingWelcomeView`; rendered once, below the +/// TabView, by `OnboardingFlowView`. +public struct OnboardingActions: Equatable { + public var primary: OnboardingAction + public var secondary: OnboardingAction? + + public init(primary: OnboardingAction, secondary: OnboardingAction? = nil) { + self.primary = primary + self.secondary = secondary + } + + // Equality ignores handlers: titles and prominence are what the footer + // renders, and a handler built in the same render captures the same + // state, so a stale one still reads current values. + public static func == (lhs: Self, rhs: Self) -> Bool { + lhs.primary.title == rhs.primary.title + && lhs.primary.isProminent == rhs.primary.isProminent + && lhs.secondary?.title == rhs.secondary?.title + && lhs.secondary?.isProminent == rhs.secondary?.isProminent + } +} + +struct OnboardingActionsKey: PreferenceKey { + static let defaultValue: [OnboardingStepID: OnboardingActions] = [:] + + static func reduce(value: inout Value, nextValue: () -> Value) { + value.merge(nextValue()) { $1 } + } +} + +extension EnvironmentValues { + /// The step a page is showing, so it knows which key to publish under. + /// `nil` outside an `OnboardingFlowView`. + @Entry var onboardingStepID: OnboardingStepID? = nil +} diff --git a/Sources/HowdyKitUI/OnboardingFlowView.swift b/Sources/HowdyKitUI/OnboardingFlowView.swift index 9bd3468..28d5dc9 100644 --- a/Sources/HowdyKitUI/OnboardingFlowView.swift +++ b/Sources/HowdyKitUI/OnboardingFlowView.swift @@ -7,6 +7,11 @@ import SwiftUI /// and anything after them share the same flow mechanics, not just the same /// per-step look. /// +/// One shared footer sits below the pages, so the page dots render above the +/// buttons. Each page keeps owning its actions and state and publishes them +/// upward (`OnboardingActions`); this view draws the footer for the page +/// currently shown. +/// /// The pending step list is captured once, when the view first appears, and /// held in `@State`. Steps already answered when the flow starts never /// appear; a step answered *during* the flow does not resize the page @@ -20,6 +25,8 @@ public struct OnboardingFlowView: View { private let stepContent: (OnboardingStepConfig, @escaping () -> Void) -> StepContent @Environment(\.scenePhase) private var scenePhase + @Environment(\.onboardingTheme) private var theme + @State private var actions: [OnboardingStepID: OnboardingActions] = [:] @State private var currentIndex = 0 @State private var highestVisitedIndex = 0 // `init` runs on every parent re-render (and every recorded answer @@ -56,17 +63,37 @@ public struct OnboardingFlowView: View { } public var body: some View { + VStack(spacing: 0) { + content + footer + } + // The pages draw their own background; this covers the footer strip. + .background(theme.background.ignoresSafeArea()) + .onPreferenceChange(OnboardingActionsKey.self) { actions = $0 } + .onChange(of: scenePhase) { _, phase in + // `pendingSteps` stays as captured: this only affects what shows next + // time, and `advance()` already handles pages answered meanwhile. + if phase == .active { + flow.reconcile(isSatisfied: isAlreadySatisfied) + } + } + } + + @ViewBuilder + private var content: some View { Group { // No state of its own: recording the welcome bumps the flow's change // token, which `needsWelcome` reads, so this re-renders by itself. if flow.needsWelcome, let welcome { welcome() + .environment(\.onboardingStepID, flow.welcomeID) } else if pendingSteps.isEmpty { Color.clear.onAppear(perform: onFinished) } else { TabView(selection: $currentIndex) { ForEach(Array(pendingSteps.enumerated()), id: \.element.id) { index, step in stepContent(step, advance) + .environment(\.onboardingStepID, step.id) .tag(index) } } @@ -89,15 +116,23 @@ public struct OnboardingFlowView: View { } } } - .onChange(of: scenePhase) { _, phase in - // `pendingSteps` stays as captured: this only affects what shows next - // time, and `advance()` already handles pages answered meanwhile. - if phase == .active { - flow.reconcile(isSatisfied: isAlreadySatisfied) - } + } + + @ViewBuilder + private var footer: some View { + if let id = currentPageID, let current = actions[id] { + OnboardingFooter(primary: current.primary, secondary: current.secondary) + } else { + Color.clear.frame(height: 0) } } + private var currentPageID: OnboardingStepID? { + if flow.needsWelcome, welcome != nil { return flow.welcomeID } + guard pendingSteps.indices.contains(currentIndex) else { return nil } + return pendingSteps[currentIndex].id + } + private func advance() { if currentIndex + 1 < pendingSteps.count { currentIndex += 1 diff --git a/Sources/HowdyKitUI/OnboardingFooter.swift b/Sources/HowdyKitUI/OnboardingFooter.swift index 4caa099..4ac6ef0 100644 --- a/Sources/HowdyKitUI/OnboardingFooter.swift +++ b/Sources/HowdyKitUI/OnboardingFooter.swift @@ -1,6 +1,7 @@ import SwiftUI -/// The button area every screen shares. Both slots always take their space, +/// The button area every screen shares, rendered once by +/// `OnboardingFlowView` below the pages. Both slots always take their space, /// visible or not, so the main button and the Skip line sit at the same spot /// on the welcome, on a step with Skip, and on one without; nothing jumps /// between pages. @@ -38,9 +39,9 @@ struct OnboardingFooter: View { .tint(theme.primaryColor) .padding(.horizontal, 28) .padding(.top, 8) - // Clearance for the page control iOS overlays on the bottom of a - // paged TabView; it reserves no layout space of its own. - .padding(.bottom, 50) + // The page control no longer overlaps this: the footer sits below + // the TabView, so the dots are above it. + .padding(.bottom, 12) .frame(maxWidth: 560) .frame(maxWidth: .infinity) } diff --git a/Sources/HowdyKitUI/OnboardingStepView.swift b/Sources/HowdyKitUI/OnboardingStepView.swift index ba92586..872dc75 100644 --- a/Sources/HowdyKitUI/OnboardingStepView.swift +++ b/Sources/HowdyKitUI/OnboardingStepView.swift @@ -1,11 +1,17 @@ +import HowdyKit import SwiftUI /// One step: an optional header, arbitrary content, and up to two actions, /// themed from the environment (`.onboardingTheme(_:)`, set once at the app's /// root). This renders a single step only; /// `OnboardingFlowView` sequences a list of these into an actual flow. +/// +/// Inside an `OnboardingFlowView` the view draws header and content only and +/// publishes its actions upward; the flow renders them once, below the +/// pages. Used on its own (no flow around it), it draws its own footer. public struct OnboardingStepView: View { @Environment(\.onboardingTheme) private var theme + @Environment(\.onboardingStepID) private var stepID private let header: OnboardingHeader? private let primaryAction: OnboardingAction private let secondaryAction: OnboardingAction? @@ -38,9 +44,12 @@ public struct OnboardingStepView: View { .frame(maxWidth: .infinity, minHeight: geometry.size.height) } } - footer + if stepID == nil { + OnboardingFooter(primary: primaryAction, secondary: secondaryAction) + } } .background(theme.background.ignoresSafeArea()) + .preference(key: OnboardingActionsKey.self, value: publishedActions) } @ViewBuilder @@ -63,7 +72,8 @@ public struct OnboardingStepView: View { } } - private var footer: some View { - OnboardingFooter(primary: primaryAction, secondary: secondaryAction) + private var publishedActions: [OnboardingStepID: OnboardingActions] { + guard let stepID else { return [:] } + return [stepID: OnboardingActions(primary: primaryAction, secondary: secondaryAction)] } } diff --git a/Sources/HowdyKitUI/OnboardingWelcomeView.swift b/Sources/HowdyKitUI/OnboardingWelcomeView.swift index 101c19a..cc8f229 100644 --- a/Sources/HowdyKitUI/OnboardingWelcomeView.swift +++ b/Sources/HowdyKitUI/OnboardingWelcomeView.swift @@ -12,6 +12,10 @@ import SwiftUI /// Records `true` for the flow's `welcomeID` when the action fires, there's /// no second storage mechanism and no way to leave this screen unanswered. /// The flow must have been created with a welcome ID. +/// +/// Draws the hero content only and publishes its action under the +/// `welcomeID`; `OnboardingFlowView` renders the footer, so Continue sits +/// where Next will on the next screen. public struct OnboardingWelcomeView: View { private let flow: OnboardingFlow private let welcomeID: OnboardingStepID @@ -44,16 +48,18 @@ public struct OnboardingWelcomeView: View { .frame(maxWidth: .infinity, minHeight: geometry.size.height) } } - // Same footer as every step, including the empty Skip slot, so - // Continue sits exactly where Next will on the next screen. - OnboardingFooter( - primary: OnboardingAction(title: actionTitle) { - flow.record(true, for: welcomeID) - onContinue() - }, - secondary: nil - ) } .background(theme.background.ignoresSafeArea()) + .preference( + key: OnboardingActionsKey.self, + value: [ + welcomeID: OnboardingActions( + primary: OnboardingAction(title: actionTitle) { + flow.record(true, for: welcomeID) + onContinue() + } + ) + ] + ) } } diff --git a/Tests/HowdyKitUITests/OnboardingActionsTests.swift b/Tests/HowdyKitUITests/OnboardingActionsTests.swift new file mode 100644 index 0000000..04accbc --- /dev/null +++ b/Tests/HowdyKitUITests/OnboardingActionsTests.swift @@ -0,0 +1,69 @@ +import HowdyKit +import SwiftUI +import Testing + +@testable import HowdyKitUI + +@MainActor +@Suite("OnboardingActions") +struct OnboardingActionsTests { + private func actions( + _ primary: LocalizedStringKey = "Next", + prominent: Bool = true, + secondary: LocalizedStringKey? = nil, + handler: @escaping () -> Void = {} + ) -> OnboardingActions { + OnboardingActions( + primary: OnboardingAction(title: primary, isProminent: prominent, handler: handler), + secondary: secondary.map { OnboardingAction(title: $0, handler: handler) } + ) + } + + @Test func equalityIgnoresHandlers() { + var calls = 0 + let a = actions(secondary: "Skip") {} + let b = actions(secondary: "Skip") { calls += 1 } + #expect(a == b) + #expect(calls == 0) + } + + @Test func differentTitleIsNotEqual() { + #expect(actions("Next") != actions("Done")) + #expect(actions(secondary: "Skip") != actions(secondary: "Later")) + } + + @Test func differentProminenceIsNotEqual() { + #expect(actions(prominent: true) != actions(prominent: false)) + } + + @Test func nilVersusNonNilSecondaryIsNotEqual() { + #expect(actions(secondary: nil) != actions(secondary: "Skip")) + } + + @Test func reduceMergesWithLaterValueWinning() { + let first = OnboardingStepID("a") + let second = OnboardingStepID("b") + var value: [OnboardingStepID: OnboardingActions] = [ + first: actions("Old"), + second: actions("Kept"), + ] + OnboardingActionsKey.reduce(value: &value) { + [first: actions("New"), OnboardingStepID("c"): actions("Added")] + } + #expect(value.count == 3) + #expect(value[first] == actions("New")) + #expect(value[second] == actions("Kept")) + #expect(value[OnboardingStepID("c")] == actions("Added")) + } + + @Test func defaultPreferenceValueIsEmpty() { + #expect(OnboardingActionsKey.defaultValue.isEmpty) + } + + @Test func stepIDEnvironmentDefaultsToNilAndRoundTrips() { + var env = EnvironmentValues() + #expect(env.onboardingStepID == nil) + env.onboardingStepID = OnboardingStepID("welcome") + #expect(env.onboardingStepID == OnboardingStepID("welcome")) + } +} From ecdb1616ee860920953b7656a7c8487379dbb149 Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:51:52 +0000 Subject: [PATCH 05/14] =?UTF-8?q?=E2=9C=A8=20OnboardingFlow.clear(=5F:)=20?= =?UTF-8?q?for=20one=20step?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Development/testing counterpart to reset(): a tool can put a single step back to unrecorded to set up "this one was never seen" (or keep one in the middle answered) and replay the flow against that mix. Co-Authored-By: Claude Fable 5.1 --- Sources/HowdyKit/OnboardingFlow.swift | 7 +++++++ Tests/HowdyKitTests/OnboardingFlowTests.swift | 11 +++++++++++ 2 files changed, 18 insertions(+) diff --git a/Sources/HowdyKit/OnboardingFlow.swift b/Sources/HowdyKit/OnboardingFlow.swift index 197d26d..66dae65 100644 --- a/Sources/HowdyKit/OnboardingFlow.swift +++ b/Sources/HowdyKit/OnboardingFlow.swift @@ -115,6 +115,13 @@ public final class OnboardingFlow { } } + /// Clears one record back to unrecorded. Development/testing, like + /// `reset`: lets a tool set up "this step was never seen" by itself. + public func clear(_ id: OnboardingStepID) { + storage.setValue(nil, for: id) + changeToken += 1 + } + /// Clears every step's record back to unrecorded, plus the welcome's when /// the flow has one, and any IDs in `additionalIDs` that an app stores /// alongside the flow without them being steps. For development/testing - a shipped diff --git a/Tests/HowdyKitTests/OnboardingFlowTests.swift b/Tests/HowdyKitTests/OnboardingFlowTests.swift index 7f503ac..c1c47f7 100644 --- a/Tests/HowdyKitTests/OnboardingFlowTests.swift +++ b/Tests/HowdyKitTests/OnboardingFlowTests.swift @@ -128,6 +128,17 @@ struct OnboardingFlowTests { #expect(flow.value(for: health) == false) } + @Test("Clear puts one step back to unrecorded and leaves the rest") + func clearOneStep() { + let storage = makeStorage() + let flow = threeStepFlow(storage: storage) + flow.record(true, for: health) + flow.record(false, for: location) + flow.clear(health) + #expect(flow.value(for: health) == nil) + #expect(flow.value(for: location) == false) + } + @Test("Reset clears every step, the welcome, and extra IDs") func resetClearsEverything() { let storage = makeStorage() From f2db9f4071182bc83e30ce37c959147f95c288a0 Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 07:54:43 +0000 Subject: [PATCH 06/14] =?UTF-8?q?=F0=9F=92=84=20Animate=20advancing=20betw?= =?UTF-8?q?een=20pages=20and=20out=20of=20the=20welcome?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A bare TabView index change jumps; wrapping it in withAnimation slides the page the way a swipe does. The welcome records inside withAnimation too so the flow's welcome-to-pages swap crossfades instead of cutting. Co-Authored-By: Claude Fable 5.1 --- Sources/HowdyKitUI/OnboardingFlowView.swift | 5 ++++- Sources/HowdyKitUI/OnboardingWelcomeView.swift | 6 +++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/Sources/HowdyKitUI/OnboardingFlowView.swift b/Sources/HowdyKitUI/OnboardingFlowView.swift index 28d5dc9..5b658ee 100644 --- a/Sources/HowdyKitUI/OnboardingFlowView.swift +++ b/Sources/HowdyKitUI/OnboardingFlowView.swift @@ -135,7 +135,10 @@ public struct OnboardingFlowView: View { private func advance() { if currentIndex + 1 < pendingSteps.count { - currentIndex += 1 + // A bare index change jumps; wrapped, the page slides like a swipe. + withAnimation(.easeInOut) { + currentIndex += 1 + } } else { onFinished() } diff --git a/Sources/HowdyKitUI/OnboardingWelcomeView.swift b/Sources/HowdyKitUI/OnboardingWelcomeView.swift index cc8f229..b7666cd 100644 --- a/Sources/HowdyKitUI/OnboardingWelcomeView.swift +++ b/Sources/HowdyKitUI/OnboardingWelcomeView.swift @@ -55,7 +55,11 @@ public struct OnboardingWelcomeView: View { value: [ welcomeID: OnboardingActions( primary: OnboardingAction(title: actionTitle) { - flow.record(true, for: welcomeID) + // Animated so the flow's welcome-to-pages swap + // crossfades instead of cutting. + withAnimation(.easeInOut) { + flow.record(true, for: welcomeID) + } onContinue() } ) From 6187bc9b58f00c07a1d05e9432e5782fb01037ca Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:01:26 +0000 Subject: [PATCH 07/14] =?UTF-8?q?=E2=9C=A8=20Make=20the=20welcome=20page?= =?UTF-8?q?=200=20of=20the=20TabView=20so=20Continue=20slides=20like=20a?= =?UTF-8?q?=20swipe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The welcome was a separate screen swapped out ahead of the TabView, so leaving it could only crossfade. It's now the first page of the same pager when the flow still needs it: Continue slides to the first step exactly like a swipe, swiping back to it works, and swiping past it records it as seen. OnboardingFlowPage holds the page list logic so it can be tested on its own. Tests: 34 (21 flow/storage, 13 UI), all passing. Co-Authored-By: Claude Fable 5.1 --- Sources/HowdyKitUI/OnboardingFlowPage.swift | 26 ++++ Sources/HowdyKitUI/OnboardingFlowView.swift | 133 +++++++++--------- .../HowdyKitUI/OnboardingWelcomeView.swift | 6 +- .../OnboardingFlowPageTests.swift | 52 +++++++ 4 files changed, 149 insertions(+), 68 deletions(-) create mode 100644 Sources/HowdyKitUI/OnboardingFlowPage.swift create mode 100644 Tests/HowdyKitUITests/OnboardingFlowPageTests.swift diff --git a/Sources/HowdyKitUI/OnboardingFlowPage.swift b/Sources/HowdyKitUI/OnboardingFlowPage.swift new file mode 100644 index 0000000..f2fb813 --- /dev/null +++ b/Sources/HowdyKitUI/OnboardingFlowPage.swift @@ -0,0 +1,26 @@ +import HowdyKit + +/// One page of an `OnboardingFlowView`: the welcome (no step config, keyed +/// by the flow's `welcomeID`) or a step. +struct OnboardingFlowPage: Equatable { + let id: OnboardingStepID + let step: OnboardingStepConfig? + + var isWelcome: Bool { step == nil } + + /// The pages a flow still needs, welcome first when it's wanted and + /// unrecorded, then every unanswered step in declaration order. Reads + /// `storage` directly rather than the flow's tracked queries: this runs + /// inside a parent view's body, and tracking there would re-render the + /// parent on every answer for no reason. + static func pending(in flow: OnboardingFlow, includeWelcome: Bool) -> [OnboardingFlowPage] { + var pages: [OnboardingFlowPage] = [] + if includeWelcome, let welcomeID = flow.welcomeID, flow.storage.value(for: welcomeID) == nil { + pages.append(OnboardingFlowPage(id: welcomeID, step: nil)) + } + for step in flow.steps where flow.storage.value(for: step.id) == nil { + pages.append(OnboardingFlowPage(id: step.id, step: step)) + } + return pages + } +} diff --git a/Sources/HowdyKitUI/OnboardingFlowView.swift b/Sources/HowdyKitUI/OnboardingFlowView.swift index 5b658ee..bfe290b 100644 --- a/Sources/HowdyKitUI/OnboardingFlowView.swift +++ b/Sources/HowdyKitUI/OnboardingFlowView.swift @@ -1,22 +1,27 @@ import HowdyKit import SwiftUI -/// Shows the flow's welcome first when it still needs one, then sequences -/// the steps `flow` still needs, in order, as a paged container. This is the turnkey piece: an app hands it a step's content and gets -/// paging, swipe-past backfill, and completion for free, so CrumbDB, Marie, -/// and anything after them share the same flow mechanics, not just the same -/// per-step look. +/// Sequences everything `flow` still needs as one paged container: the +/// welcome first (when the flow has one and it's unrecorded), then each +/// unanswered step in order. This is the turnkey piece: an app hands it a +/// step's content and gets paging, swipe-past backfill, and completion for +/// free, so CrumbDB, Marie, and anything after them share the same flow +/// mechanics, not just the same per-step look. +/// +/// The welcome is a real page, not a separate screen swapped out before the +/// TabView, so Continue slides to the first step exactly like a swipe and the +/// user can swipe back to it. /// /// One shared footer sits below the pages, so the page dots render above the /// buttons. Each page keeps owning its actions and state and publishes them /// upward (`OnboardingActions`); this view draws the footer for the page /// currently shown. /// -/// The pending step list is captured once, when the view first appears, and -/// held in `@State`. Steps already answered when the flow starts never -/// appear; a step answered *during* the flow does not resize the page -/// count out from under the user (which would shift every index, drop -/// pages, and hide the dots once one step is left). +/// The page list is captured once, when the view first appears, and held in +/// `@State`. Pages already answered when the flow starts never appear; one +/// answered *during* the flow does not resize the page count out from under +/// the user (which would shift every index, drop pages, and hide the dots +/// once one page is left). public struct OnboardingFlowView: View { private let flow: OnboardingFlow private let welcome: (() -> Welcome)? @@ -30,13 +35,13 @@ public struct OnboardingFlowView: View { @State private var currentIndex = 0 @State private var highestVisitedIndex = 0 // `init` runs on every parent re-render (and every recorded answer - // re-renders the parent now that the flow is observable); `@State` - // keeps the first snapshot and ignores the later `initialValue`s. - @State private var pendingSteps: [OnboardingStepConfig] + // re-renders the parent, the flow being observable); `@State` keeps the + // first snapshot and ignores the later `initialValue`s. + @State private var pages: [OnboardingFlowPage] - /// - Parameter welcome: builds the welcome screen, shown while - /// `flow.needsWelcome`. Typically an `OnboardingWelcomeView`, which - /// records the welcome itself. + /// - Parameter welcome: builds the welcome page, shown first while the + /// flow's welcome is unrecorded. Typically an `OnboardingWelcomeView`, + /// which records the welcome itself; this view then slides on. /// - Parameter isAlreadySatisfied: reports whether a step is already /// satisfied outside the flow (a permission the OS already granted). /// Such unanswered steps are recorded `true` via @@ -59,7 +64,7 @@ public struct OnboardingFlowView: View { self.isAlreadySatisfied = isAlreadySatisfied self.stepContent = stepContent flow.reconcile(isSatisfied: isAlreadySatisfied) - _pendingSteps = State(initialValue: Self.snapshot(of: flow)) + _pages = State(initialValue: OnboardingFlowPage.pending(in: flow, includeWelcome: true)) } public var body: some View { @@ -71,7 +76,7 @@ public struct OnboardingFlowView: View { .background(theme.background.ignoresSafeArea()) .onPreferenceChange(OnboardingActionsKey.self) { actions = $0 } .onChange(of: scenePhase) { _, phase in - // `pendingSteps` stays as captured: this only affects what shows next + // `pages` stays as captured: this only affects what shows next // time, and `advance()` already handles pages answered meanwhile. if phase == .active { flow.reconcile(isSatisfied: isAlreadySatisfied) @@ -81,38 +86,43 @@ public struct OnboardingFlowView: View { @ViewBuilder private var content: some View { - Group { - // No state of its own: recording the welcome bumps the flow's change - // token, which `needsWelcome` reads, so this re-renders by itself. - if flow.needsWelcome, let welcome { - welcome() - .environment(\.onboardingStepID, flow.welcomeID) - } else if pendingSteps.isEmpty { - Color.clear.onAppear(perform: onFinished) - } else { - TabView(selection: $currentIndex) { - ForEach(Array(pendingSteps.enumerated()), id: \.element.id) { index, step in - stepContent(step, advance) - .environment(\.onboardingStepID, step.id) - .tag(index) + if pages.isEmpty { + Color.clear.onAppear(perform: onFinished) + } else { + TabView(selection: $currentIndex) { + ForEach(Array(pages.enumerated()), id: \.element.id) { index, page in + Group { + if let step = page.step { + stepContent(step, advance) + } else if let welcome { + welcome() + } } + .environment(\.onboardingStepID, page.id) + .tag(index) } - #if os(iOS) - // `.automatic` shows the page dots CrumbDB's own onboarding - // already has (and hides them for a single pending step), not - // suppress them - `.never` also leaves the space iOS reserves - // for the control empty instead of actually removing it. - .tabViewStyle(.page(indexDisplayMode: .automatic)) - #endif - .onChange(of: currentIndex) { _, newIndex in - // A swipe can only move one page, but handle a programmatic - // jump of more than one too: backfill every step passed over. - guard newIndex > highestVisitedIndex else { return } - for passedIndex in highestVisitedIndex.. highestVisitedIndex else { return } + for passedIndex in highestVisitedIndex..: View { @ViewBuilder private var footer: some View { - if let id = currentPageID, let current = actions[id] { + if pages.indices.contains(currentIndex), let current = actions[pages[currentIndex].id] { OnboardingFooter(primary: current.primary, secondary: current.secondary) } else { Color.clear.frame(height: 0) } } - private var currentPageID: OnboardingStepID? { - if flow.needsWelcome, welcome != nil { return flow.welcomeID } - guard pendingSteps.indices.contains(currentIndex) else { return nil } - return pendingSteps[currentIndex].id - } - private func advance() { - if currentIndex + 1 < pendingSteps.count { + if currentIndex + 1 < pages.count { // A bare index change jumps; wrapped, the page slides like a swipe. withAnimation(.easeInOut) { currentIndex += 1 @@ -144,16 +148,19 @@ public struct OnboardingFlowView: View { } } - // Reads `storage` directly rather than `flow.value(for:)`: this runs - // inside the parent's body, and going through the flow's tracked query - // would make the parent re-render on every answer for no reason. - private static func snapshot(of flow: OnboardingFlow) -> [OnboardingStepConfig] { - flow.steps.filter { flow.storage.value(for: $0.id) == nil } + /// Leaving a page unanswered: a step gets its `isSkippable` default, the + /// welcome (which has no skip path) counts as seen. + private func backfill(_ page: OnboardingFlowPage) { + if let step = page.step { + flow.recordIfNeeded(leaving: step) + } else if flow.value(for: page.id) == nil { + flow.record(true, for: page.id) + } } } extension OnboardingFlowView where Welcome == EmptyView { - /// A flow with no welcome screen: goes straight to the steps. + /// A flow with no welcome page: goes straight to the steps. public init( flow: OnboardingFlow, onFinished: @escaping () -> Void = {}, @@ -166,6 +173,6 @@ extension OnboardingFlowView where Welcome == EmptyView { self.isAlreadySatisfied = isAlreadySatisfied self.stepContent = stepContent flow.reconcile(isSatisfied: isAlreadySatisfied) - _pendingSteps = State(initialValue: Self.snapshot(of: flow)) + _pages = State(initialValue: OnboardingFlowPage.pending(in: flow, includeWelcome: false)) } } diff --git a/Sources/HowdyKitUI/OnboardingWelcomeView.swift b/Sources/HowdyKitUI/OnboardingWelcomeView.swift index b7666cd..cc8f229 100644 --- a/Sources/HowdyKitUI/OnboardingWelcomeView.swift +++ b/Sources/HowdyKitUI/OnboardingWelcomeView.swift @@ -55,11 +55,7 @@ public struct OnboardingWelcomeView: View { value: [ welcomeID: OnboardingActions( primary: OnboardingAction(title: actionTitle) { - // Animated so the flow's welcome-to-pages swap - // crossfades instead of cutting. - withAnimation(.easeInOut) { - flow.record(true, for: welcomeID) - } + flow.record(true, for: welcomeID) onContinue() } ) diff --git a/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift b/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift new file mode 100644 index 0000000..c975aa0 --- /dev/null +++ b/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift @@ -0,0 +1,52 @@ +import Foundation +import HowdyKit +import Testing +@testable import HowdyKitUI + +@Suite("OnboardingFlowPage.pending") +struct OnboardingFlowPageTests { + private let welcome = OnboardingStepID("com.dryan.test.welcome") + private let first = OnboardingStepID("com.dryan.test.first") + private let middle = OnboardingStepID("com.dryan.test.middle") + private let last = OnboardingStepID("com.dryan.test.last") + + private func makeFlow(welcome: OnboardingStepID?) -> OnboardingFlow { + let defaults = UserDefaults(suiteName: "howdykit-ui-tests-\(UUID().uuidString)")! + return OnboardingFlow( + welcome: welcome, + steps: [ + OnboardingStepConfig(id: first, isRequired: false, isSkippable: false), + OnboardingStepConfig(id: middle, isRequired: true), + OnboardingStepConfig(id: last, isRequired: false), + ], + storage: UserDefaultsOnboardingStorage(defaults: defaults) + ) + } + + @Test("A fresh flow pages the welcome first, then every step in order") + func freshFlowStartsWithWelcome() { + let pages = OnboardingFlowPage.pending(in: makeFlow(welcome: welcome), includeWelcome: true) + #expect(pages.map(\.id) == [welcome, first, middle, last]) + #expect(pages.first?.isWelcome == true) + #expect(pages.dropFirst().allSatisfy { !$0.isWelcome }) + } + + @Test("A recorded welcome, or a view without one, leaves the welcome out") + func welcomeOmittedWhenRecordedOrNotWanted() { + let flow = makeFlow(welcome: welcome) + #expect(OnboardingFlowPage.pending(in: flow, includeWelcome: false).map(\.id) == [first, middle, last]) + flow.record(true, for: welcome) + #expect(OnboardingFlowPage.pending(in: flow, includeWelcome: true).map(\.id) == [first, middle, last]) + #expect(OnboardingFlowPage.pending(in: makeFlow(welcome: nil), includeWelcome: true).map(\.id) == [first, middle, last]) + } + + @Test("An answered step in the middle drops out without reordering the rest") + func answeredMiddleStepIsSkipped() { + let flow = makeFlow(welcome: welcome) + flow.record(true, for: welcome) + flow.record(false, for: middle) + let pages = OnboardingFlowPage.pending(in: flow, includeWelcome: true) + #expect(pages.map(\.id) == [first, last]) + #expect(pages.compactMap(\.step?.id) == [first, last]) + } +} From b31bc690c550a725907e46cacffa418d3e0cdc2c Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:03:28 +0000 Subject: [PATCH 08/14] =?UTF-8?q?=F0=9F=90=9B=20Keep=20the=20footer's=20he?= =?UTF-8?q?ight=20constant=20across=20page=20changes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving from a page with Skip to one without swapped the Skip Button for a placeholder inside the animated page change, so for the length of the fade both existed and the footer was briefly 44pt taller: the page content and the dots sat higher, then settled down. The secondary slot is now one stable Button that is hidden instead of swapped, the primary slot gets an identity transition, and the flow renders a hidden footer of the same size while a page's actions haven't arrived yet. Co-Authored-By: Claude Fable 5.1 --- Sources/HowdyKitUI/OnboardingFlowView.swift | 6 ++++- Sources/HowdyKitUI/OnboardingFooter.swift | 27 ++++++++++++--------- 2 files changed, 21 insertions(+), 12 deletions(-) diff --git a/Sources/HowdyKitUI/OnboardingFlowView.swift b/Sources/HowdyKitUI/OnboardingFlowView.swift index bfe290b..8758a96 100644 --- a/Sources/HowdyKitUI/OnboardingFlowView.swift +++ b/Sources/HowdyKitUI/OnboardingFlowView.swift @@ -133,7 +133,11 @@ public struct OnboardingFlowView: View { if pages.indices.contains(currentIndex), let current = actions[pages[currentIndex].id] { OnboardingFooter(primary: current.primary, secondary: current.secondary) } else { - Color.clear.frame(height: 0) + // Same footprint while a page's actions haven't arrived yet, so + // the pages (and the dots at their bottom) never shift to fill a + // missing footer. + OnboardingFooter(primary: OnboardingAction(title: "", handler: {}), secondary: nil) + .hidden() } } diff --git a/Sources/HowdyKitUI/OnboardingFooter.swift b/Sources/HowdyKitUI/OnboardingFooter.swift index 4ac6ef0..bb688f0 100644 --- a/Sources/HowdyKitUI/OnboardingFooter.swift +++ b/Sources/HowdyKitUI/OnboardingFooter.swift @@ -21,18 +21,23 @@ struct OnboardingFooter: View { } } .controlSize(.large) + // No insertion/removal animation when prominence flips between + // pages: an outgoing and incoming button overlapping for a frame + // makes the footer momentarily taller and everything above it + // (content, page dots) visibly settles. + .transition(.identity) - Group { - if let secondary { - Button(secondary.title, action: secondary.handler) - .buttonStyle(.plain) - .font(.subheadline) - .foregroundStyle(theme.primaryColor.opacity(0.85)) - } else { - Color.clear - } - } - .frame(height: 44) + // One stable button, hidden rather than swapped for a placeholder, + // for the same reason: a `Button`/`Color.clear` swap inside an + // animated page change briefly stacks both. + Button(secondary?.title ?? "", action: secondary?.handler ?? {}) + .buttonStyle(.plain) + .font(.subheadline) + .foregroundStyle(theme.primaryColor.opacity(0.85)) + .opacity(secondary == nil ? 0 : 1) + .disabled(secondary == nil) + .accessibilityHidden(secondary == nil) + .frame(height: 44) } // `.borderedProminent`/`.bordered` otherwise fall back to the // system accent color, not the theme. From 95f8c2bb4bb566204e2a9bf5fae60ea32e83cd29 Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:06:11 +0000 Subject: [PATCH 09/14] =?UTF-8?q?=F0=9F=90=9B=20One=20permanent,=20unanima?= =?UTF-8?q?ted=20footer=20so=20pages=20never=20move=20on=20a=20page=20chan?= =?UTF-8?q?ge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The footer was still two conditional branches (real footer vs hidden placeholder) swapped while a page's actions arrived, and its content changes animated inside the page-change transaction. Both nudge the TabView's height mid-slide, which is the residual drift in the content and the dots. OnboardingFooter now takes optional actions and hides its buttons instead of being replaced, and the flow renders it with animation disabled. Co-Authored-By: Claude Fable 5.1 --- Sources/HowdyKitUI/OnboardingFlowView.swift | 21 ++++++++--------- Sources/HowdyKitUI/OnboardingFooter.swift | 25 ++++++++++++++------- Sources/HowdyKitUI/OnboardingStepView.swift | 2 +- 3 files changed, 29 insertions(+), 19 deletions(-) diff --git a/Sources/HowdyKitUI/OnboardingFlowView.swift b/Sources/HowdyKitUI/OnboardingFlowView.swift index 8758a96..79eec1f 100644 --- a/Sources/HowdyKitUI/OnboardingFlowView.swift +++ b/Sources/HowdyKitUI/OnboardingFlowView.swift @@ -128,17 +128,18 @@ public struct OnboardingFlowView: View { } } - @ViewBuilder private var footer: some View { - if pages.indices.contains(currentIndex), let current = actions[pages[currentIndex].id] { - OnboardingFooter(primary: current.primary, secondary: current.secondary) - } else { - // Same footprint while a page's actions haven't arrived yet, so - // the pages (and the dots at their bottom) never shift to fill a - // missing footer. - OnboardingFooter(primary: OnboardingAction(title: "", handler: {}), secondary: nil) - .hidden() - } + // One permanent view, never swapped for another, and never animated: + // a page change is wrapped in `withAnimation`, and any animated change + // in here would move the pages (and the dots at their bottom) while + // they slide. + OnboardingFooter(actions: currentActions) + .transaction { $0.animation = nil } + } + + private var currentActions: OnboardingActions? { + guard pages.indices.contains(currentIndex) else { return nil } + return actions[pages[currentIndex].id] } private func advance() { diff --git a/Sources/HowdyKitUI/OnboardingFooter.swift b/Sources/HowdyKitUI/OnboardingFooter.swift index bb688f0..cfcdf42 100644 --- a/Sources/HowdyKitUI/OnboardingFooter.swift +++ b/Sources/HowdyKitUI/OnboardingFooter.swift @@ -1,16 +1,24 @@ import SwiftUI /// The button area every screen shares, rendered once by -/// `OnboardingFlowView` below the pages. Both slots always take their space, -/// visible or not, so the main button and the Skip line sit at the same spot -/// on the welcome, on a step with Skip, and on one without; nothing jumps -/// between pages. +/// `OnboardingFlowView` below the pages. Its height never changes: both +/// slots always take their space, and `actions == nil` (a page whose actions +/// haven't arrived yet) hides the buttons rather than removing them, so the +/// main button and the Skip line sit at the same spot on every screen and +/// nothing above the footer moves between pages. struct OnboardingFooter: View { - let primary: OnboardingAction - let secondary: OnboardingAction? + let actions: OnboardingActions? @Environment(\.onboardingTheme) private var theme + private var primary: OnboardingAction { + actions?.primary ?? OnboardingAction(title: "", handler: {}) + } + + private var secondary: OnboardingAction? { + actions?.secondary + } + var body: some View { VStack(spacing: 4) { Group { @@ -21,10 +29,11 @@ struct OnboardingFooter: View { } } .controlSize(.large) + .opacity(actions == nil ? 0 : 1) + .disabled(actions == nil) // No insertion/removal animation when prominence flips between // pages: an outgoing and incoming button overlapping for a frame - // makes the footer momentarily taller and everything above it - // (content, page dots) visibly settles. + // makes the footer momentarily taller. .transition(.identity) // One stable button, hidden rather than swapped for a placeholder, diff --git a/Sources/HowdyKitUI/OnboardingStepView.swift b/Sources/HowdyKitUI/OnboardingStepView.swift index 872dc75..7e3261e 100644 --- a/Sources/HowdyKitUI/OnboardingStepView.swift +++ b/Sources/HowdyKitUI/OnboardingStepView.swift @@ -45,7 +45,7 @@ public struct OnboardingStepView: View { } } if stepID == nil { - OnboardingFooter(primary: primaryAction, secondary: secondaryAction) + OnboardingFooter(actions: OnboardingActions(primary: primaryAction, secondary: secondaryAction)) } } .background(theme.background.ignoresSafeArea()) From 6334fd98870c2ea51c6a13751d6df450fd72e7bb Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:19:19 +0000 Subject: [PATCH 10/14] =?UTF-8?q?=E2=9C=A8=20Page=20with=20a=20paging=20Sc?= =?UTF-8?q?rollView=20and=20the=20kit's=20own=20dots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured on the iOS 27 simulator, TabView(.page) rendered a programmatic page change as a cut (not a slide) under both withAnimation and .animation(value:), settled each new page's centered content ~10pt late (its layout box changes after the page is added), and drew no dots at all. All three are its internals. The pager is now a horizontal ScrollView with .paging and a scrollPosition binding: advance() animates the position so Next slides exactly like a swipe, pages are created up front so nothing lays out mid-slide, and OnboardingPageIndicator draws themed dots between the pages and the footer. Verified with a screen recording: every transition is a 7 to 8 frame slide at 20fps and content drift after landing is zero. Tests: 35, all passing. Co-Authored-By: Claude Fable 5.1 --- README.md | 2 +- Sources/HowdyKitUI/OnboardingFlowPage.swift | 6 ++ Sources/HowdyKitUI/OnboardingFlowView.swift | 72 +++++++++++-------- .../HowdyKitUI/OnboardingPageIndicator.swift | 24 +++++++ .../OnboardingFlowPageTests.swift | 8 +++ 5 files changed, 82 insertions(+), 30 deletions(-) create mode 100644 Sources/HowdyKitUI/OnboardingPageIndicator.swift diff --git a/README.md b/README.md index 3bff924..b36f02d 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ layer never compiles SwiftUI it doesn't use. footer the same way a step does. - **`OnboardingFlowView`**: shows the welcome first when the flow still needs it, then sequences the steps a flow still needs into a paged container. - It renders one footer under the pages (page dots above the buttons) for + The pager is a paging `ScrollView` with the kit's own page dots, and one footer under it for whichever page is showing. Recording the welcome moves it on to the steps on its own, so an app doesn't branch on `needsWelcome` itself. An app hands it per-step content (keyed by diff --git a/Sources/HowdyKitUI/OnboardingFlowPage.swift b/Sources/HowdyKitUI/OnboardingFlowPage.swift index f2fb813..d469550 100644 --- a/Sources/HowdyKitUI/OnboardingFlowPage.swift +++ b/Sources/HowdyKitUI/OnboardingFlowPage.swift @@ -8,6 +8,12 @@ struct OnboardingFlowPage: Equatable { var isWelcome: Bool { step == nil } + /// The indices a move from `old` to `new` leaves behind (all of them, + /// for a jump of more than one page); empty when moving backward. + static func indicesPassed(from old: Int, to new: Int) -> Range { + new > old ? old..: View { @Environment(\.scenePhase) private var scenePhase @Environment(\.onboardingTheme) private var theme @State private var actions: [OnboardingStepID: OnboardingActions] = [:] - @State private var currentIndex = 0 + @State private var currentPageID: OnboardingStepID? @State private var highestVisitedIndex = 0 // `init` runs on every parent re-render (and every recorded answer // re-renders the parent, the flow being observable); `@State` keeps the @@ -64,12 +66,16 @@ public struct OnboardingFlowView: View { self.isAlreadySatisfied = isAlreadySatisfied self.stepContent = stepContent flow.reconcile(isSatisfied: isAlreadySatisfied) - _pages = State(initialValue: OnboardingFlowPage.pending(in: flow, includeWelcome: true)) + let pages = OnboardingFlowPage.pending(in: flow, includeWelcome: true) + _pages = State(initialValue: pages) + _currentPageID = State(initialValue: pages.first?.id) } public var body: some View { VStack(spacing: 0) { content + OnboardingPageIndicator(count: pages.count, current: currentIndex) + .transaction { $0.animation = nil } footer } // The pages draw their own background; this covers the footer strip. @@ -89,34 +95,37 @@ public struct OnboardingFlowView: View { if pages.isEmpty { Color.clear.onAppear(perform: onFinished) } else { - TabView(selection: $currentIndex) { - ForEach(Array(pages.enumerated()), id: \.element.id) { index, page in - Group { - if let step = page.step { - stepContent(step, advance) - } else if let welcome { - welcome() + ScrollView(.horizontal) { + // Not lazy: a handful of pages, created up front so none is + // created mid-slide. + HStack(spacing: 0) { + ForEach(pages, id: \.id) { page in + Group { + if let step = page.step { + stepContent(step, advance) + } else if let welcome { + welcome() + } } + .containerRelativeFrame([.horizontal, .vertical]) + .environment(\.onboardingStepID, page.id) + .id(page.id) } - .environment(\.onboardingStepID, page.id) - .tag(index) } + .scrollTargetLayout() } - #if os(iOS) - // `.automatic` shows the page dots CrumbDB's own onboarding - // already has (and hides them for a single page), not suppress - // them - `.never` also leaves the space iOS reserves for the - // control empty instead of actually removing it. - .tabViewStyle(.page(indexDisplayMode: .automatic)) - #endif - .onChange(of: currentIndex) { _, newIndex in + .scrollTargetBehavior(.paging) + .scrollPosition(id: $currentPageID) + .scrollIndicators(.hidden) + .scrollClipDisabled(false) + .onChange(of: currentPageID) { _, _ in // A swipe can only move one page, but handle a programmatic // jump of more than one too: backfill every page passed over. - guard newIndex > highestVisitedIndex else { return } - for passedIndex in highestVisitedIndex..: View { .transaction { $0.animation = nil } } + private var currentIndex: Int { + pages.firstIndex { $0.id == currentPageID } ?? 0 + } + private var currentActions: OnboardingActions? { guard pages.indices.contains(currentIndex) else { return nil } return actions[pages[currentIndex].id] @@ -144,9 +157,8 @@ public struct OnboardingFlowView: View { private func advance() { if currentIndex + 1 < pages.count { - // A bare index change jumps; wrapped, the page slides like a swipe. - withAnimation(.easeInOut) { - currentIndex += 1 + withAnimation(.easeInOut(duration: 0.35)) { + currentPageID = pages[currentIndex + 1].id } } else { onFinished() @@ -178,6 +190,8 @@ extension OnboardingFlowView where Welcome == EmptyView { self.isAlreadySatisfied = isAlreadySatisfied self.stepContent = stepContent flow.reconcile(isSatisfied: isAlreadySatisfied) - _pages = State(initialValue: OnboardingFlowPage.pending(in: flow, includeWelcome: false)) + let pages = OnboardingFlowPage.pending(in: flow, includeWelcome: false) + _pages = State(initialValue: pages) + _currentPageID = State(initialValue: pages.first?.id) } } diff --git a/Sources/HowdyKitUI/OnboardingPageIndicator.swift b/Sources/HowdyKitUI/OnboardingPageIndicator.swift new file mode 100644 index 0000000..c3076e8 --- /dev/null +++ b/Sources/HowdyKitUI/OnboardingPageIndicator.swift @@ -0,0 +1,24 @@ +import SwiftUI + +/// The page dots under an `OnboardingFlowView`'s pager. Always takes the +/// same height and hides (rather than leaves) for a single page, so nothing +/// above or below it shifts. +struct OnboardingPageIndicator: View { + let count: Int + let current: Int + + @Environment(\.onboardingTheme) private var theme + + var body: some View { + HStack(spacing: 8) { + ForEach(0.. 1 ? 1 : 0) + .accessibilityLabel("Page \(current + 1) of \(count)") + } +} diff --git a/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift b/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift index c975aa0..11b6430 100644 --- a/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift +++ b/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift @@ -49,4 +49,12 @@ struct OnboardingFlowPageTests { #expect(pages.map(\.id) == [first, last]) #expect(pages.compactMap(\.step?.id) == [first, last]) } + + @Test("Moving forward passes every index left behind; standing still or going back passes none") + func indicesPassed() { + #expect(Array(OnboardingFlowPage.indicesPassed(from: 0, to: 1)) == [0]) + #expect(Array(OnboardingFlowPage.indicesPassed(from: 1, to: 3)) == [1, 2]) + #expect(OnboardingFlowPage.indicesPassed(from: 2, to: 1).isEmpty) + #expect(OnboardingFlowPage.indicesPassed(from: 2, to: 2).isEmpty) + } } From 131caef8ce2ad984dad268750f3f3be6634375db Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:42:07 +0000 Subject: [PATCH 11/14] =?UTF-8?q?=F0=9F=90=9B=20Size=20pager=20pages=20fro?= =?UTF-8?q?m=20the=20slot=20the=20pager=20gets,=20not=20the=20screen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A horizontal ScrollView takes its height from its content, and containerRelativeFrame(.vertical) measured the pages against the whole screen, so the pager grew to full height and pushed the footer (and its Skip line) below the bottom edge. Pages are now sized from a GeometryReader that owns the pager's actual slot above the dots and footer. Co-Authored-By: Claude Fable 5.1 --- Sources/HowdyKitUI/OnboardingFlowView.swift | 42 ++++++++++++--------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/Sources/HowdyKitUI/OnboardingFlowView.swift b/Sources/HowdyKitUI/OnboardingFlowView.swift index f507f19..b5adabe 100644 --- a/Sources/HowdyKitUI/OnboardingFlowView.swift +++ b/Sources/HowdyKitUI/OnboardingFlowView.swift @@ -95,29 +95,35 @@ public struct OnboardingFlowView: View { if pages.isEmpty { Color.clear.onAppear(perform: onFinished) } else { - ScrollView(.horizontal) { - // Not lazy: a handful of pages, created up front so none is - // created mid-slide. - HStack(spacing: 0) { - ForEach(pages, id: \.id) { page in - Group { - if let step = page.step { - stepContent(step, advance) - } else if let welcome { - welcome() + // A horizontal ScrollView takes its height from its content, so + // the pages are sized from the slot the pager actually gets (the + // GeometryReader fills whatever the VStack leaves above the dots + // and footer). `containerRelativeFrame` measured against the + // whole screen here and pushed the footer below the bottom edge. + GeometryReader { geometry in + ScrollView(.horizontal) { + // Not lazy: a handful of pages, created up front so none + // is created mid-slide. + HStack(spacing: 0) { + ForEach(pages, id: \.id) { page in + Group { + if let step = page.step { + stepContent(step, advance) + } else if let welcome { + welcome() + } } + .frame(width: geometry.size.width, height: geometry.size.height) + .environment(\.onboardingStepID, page.id) + .id(page.id) } - .containerRelativeFrame([.horizontal, .vertical]) - .environment(\.onboardingStepID, page.id) - .id(page.id) } + .scrollTargetLayout() } - .scrollTargetLayout() + .scrollTargetBehavior(.paging) + .scrollPosition(id: $currentPageID) + .scrollIndicators(.hidden) } - .scrollTargetBehavior(.paging) - .scrollPosition(id: $currentPageID) - .scrollIndicators(.hidden) - .scrollClipDisabled(false) .onChange(of: currentPageID) { _, _ in // A swipe can only move one page, but handle a programmatic // jump of more than one too: backfill every page passed over. From ad4d14ecf4e757993e67104737641dc923550261 Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:57:08 +0000 Subject: [PATCH 12/14] =?UTF-8?q?=F0=9F=93=9D=20AGENTS.md:=20how=20to=20in?= =?UTF-8?q?tegrate=20the=20kit,=20in=20order,=20with=20the=20rules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README is the API reference; this is what an agent (or a person) needs to wire the kit into an app without re-learning tonight's lessons: dependency shape, one flow app-wide, needsOnboarding as the only root switch, step views own state and actions, theme from the environment, reconcile OS-granted permissions, analytics and replay stay in the app, and the simulator recording harness. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 173 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 AGENTS.md diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..ccdd1e9 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,173 @@ +# Integrating HowdyKit (for agents) + +HowdyKit is the shared onboarding for dryan-org's apps (CrumbDB, Marie, and +whatever comes next). Integrate it; do not fork its views or reimplement the +flow in the app. `README.md` is the API reference; this file is the order of +operations and the rules that are easy to get wrong. + +## 1. Dependency + +Swift package, two products. Depend on a tag, not `main`. + +XcodeGen (`project.yml`): + +```yaml +packages: + HowdyKit: + url: https://github.com/dryan-org/howdykit + from: "0.1.0" +targets: + : + dependencies: + - package: HowdyKit + product: HowdyKit # storage + flow model, Foundation and Observation only + - package: HowdyKit + product: HowdyKitUI # SwiftUI views; iOS/macOS/tvOS targets only +``` + +A watchOS or widget target that only needs "is onboarding done" links +`HowdyKit` alone and asks `flow.needsOnboarding`. + +Platform floor is 27 across the board. + +## 2. Define the flow once, app-wide + +One `OnboardingFlow` per app, created once and shared (a plain `final class` +holding it is fine; the flow itself is `@Observable`). + +```swift +import HowdyKit + +enum Onboarding { + static let welcome = OnboardingStepID("com.dryan..welcome") + static let location = OnboardingStepID("com.dryan..reqs.location") + static let photos = OnboardingStepID("com.dryan..reqs.photos") + static let tips = OnboardingStepID("com.dryan..info.tips") +} + +let flow = OnboardingFlow( + welcome: Onboarding.welcome, + steps: [ + OnboardingStepConfig(id: Onboarding.location, isRequired: true, isSkippable: true), + OnboardingStepConfig(id: Onboarding.photos, isRequired: true, isSkippable: true), + OnboardingStepConfig(id: Onboarding.tips, isRequired: false, isSkippable: false), + ], + storage: UserDefaultsOnboardingStorage(keyPrefix: ".") +) +``` + +Rules: +- IDs are reverse-DNS and permanent. Never rename one; records are keyed by it. + A new step is a new ID appended to `steps`; returning users see only it. +- `isRequired` and `isSkippable` are independent. Required means the flow is + not satisfied until the step records `true`. Skippable means the step has a + Skip path (button or swipe past), and leaving it unanswered records `false`. + A step with no Skip (an info screen) records `true` when left. A required + permission step is normally skippable too: the user may decline and the app + stays unsatisfied; that is correct, do not record `true` for a denial. +- `keyPrefix` keeps one `UserDefaults` suite safe for more than one flow. Use + an App Group suite only when an extension must read the records on the same + device; it does not reach a paired Watch (use `NSUbiquitousKeyValueStore` + or `WatchConnectivity` for that). + +## 3. Decide "onboarding or app" from the flow, nowhere else + +```swift +if flow.needsOnboarding { OnboardingRoot() } else { MainApp() } +``` + +Full-screen root swap, not a sheet. Do not keep a separate +`hasCompletedOnboarding` flag: `needsOnboarding` is the one source of truth +and it is observable, so the swap happens on its own when the last step is +recorded. If the app already has such a flag, migrate it once at launch +(flag set and every step unrecorded: record each step `true`) and delete it. + +## 4. Build the screens with the kit's views + +```swift +import HowdyKitUI + +OnboardingFlowView( + flow: flow, + onFinished: { /* analytics, then nothing: needsOnboarding already flipped */ }, + isAlreadySatisfied: alreadyGranted, + welcome: { + OnboardingWelcomeView(flow: flow) { /* hero content */ } + }, + stepContent: { step, advance in + switch step.id { + case Onboarding.location: LocationStep(flow: flow, stepID: step.id, advance: advance) + case Onboarding.photos: PhotosStep(flow: flow, stepID: step.id, advance: advance) + case Onboarding.tips: InfoStep(flow: flow, stepID: step.id, advance: advance) + default: EmptyView() + } + } +) +.onboardingTheme(OnboardingTheme(primaryColor: brandColor, titleFont: brandFont, background: { Brand() })) +``` + +Each step is an `OnboardingStepView(header:primaryAction:secondaryAction:content:)`. +The step owns its state (permission status) and its actions; the kit renders +the buttons once, below the pages, with the dots above them, and sequences +the pages. The step's primary action must `flow.record(...)` then call +`advance()`; Skip records `false` then `advance()`. + +Theme comes from the environment (`.onboardingTheme(_:)`), set once at the +root. Never pass a theme into a view; never hard-code brand colors in a step. + +## 5. Reconcile system permissions + +A permission the OS has already granted must not get a page. Pass +`isAlreadySatisfied:` and the kit records such steps `true` before paging and +again when the scene becomes active (Settings may have changed something): + +```swift +func alreadyGranted(_ step: OnboardingStepConfig) -> Bool { + switch step.id { + case Onboarding.location: + let s = CLLocationManager().authorizationStatus + return s == .authorizedAlways || s == .authorizedWhenInUse + case Onboarding.photos: + let s = PHPhotoLibrary.authorizationStatus(for: .readWrite) + return s == .authorized || s == .limited + default: return false + } +} +``` + +Permission steps that can be granted asynchronously (Core Location's +delegate) should also auto-advance on grant: watch the status and call +`flow.record(true, ...)` then `advance()` from `onChange`. + +iOS never re-prompts for a permission once answered, and it shows the +While Using to Always upgrade prompt once per install. After that the only +path is Settings; make the button say "Open Settings" in that state rather +than calling the request again. + +## 6. Analytics and replay are the app's + +The kit fires no analytics. Fire the app's own events from the step actions +and from `onFinished`. For a "replay onboarding" entry in Settings, call +`flow.reset()`; for a dev screen that sets up one scenario, `flow.clear(id)` +and `flow.record(_:for:)` per step. + +## Do not + +- Snapshot `flow` queries into local `@State` to drive navigation. The flow + is `@Observable`; read it in `body`. +- Put the welcome in `steps`. It is `welcome:` on the flow; the kit pages it + first and records it. +- Build a parallel `TabView` or `NavigationStack` around `OnboardingStepView` + for a linear flow. `OnboardingFlowView` is the pager; only a genuinely + branching flow gets its own container. +- Use `#if DEBUG` for a reset button. Put it behind the app's beta flag. + +## Verifying on a simulator without tapping + +A `-autoAdvance` launch-argument hook (see Dolly's `AutoAdvance.swift` in the +dryan-org demo app) lets each step fire its primary path when it becomes the +flow's next unanswered step, so `xcrun simctl io recordVideo` can +capture the whole flow. The recorder only writes frames while the screen +changes, so a short video means the flow finished early or got stuck. Reset +the app's records with uninstall + install; `simctl spawn ... defaults delete` +does not clear what the running app has cached. From d9df87157e897d814fb92d9057d59a057c36beb4 Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 15:59:45 +0000 Subject: [PATCH 13/14] =?UTF-8?q?=E2=9C=A8=20Theme=20accent=20color,=20opt?= =?UTF-8?q?ional=20card,=20onPageShown=20callback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things a faithful CrumbDB port needs: - OnboardingTheme.accentColor (defaults to primaryColor) drives the header icon, the footer buttons, and the current dot, so an app with orange highlights and Moss text keeps both. - cardBackground is optional and, when set, OnboardingStepView draws the header and content inside a rounded card filled with it. - OnboardingFlowView.onPageShown fires once per page shown (first page on appear, then every change), for page-view analytics; onAppear on page content can't do it since every page is created up front. Tests: 39, all passing. Co-Authored-By: Claude Fable 5.1 --- README.md | 8 ++--- Sources/HowdyKitUI/OnboardingFlowPage.swift | 7 +++++ Sources/HowdyKitUI/OnboardingFlowView.swift | 19 ++++++++++++ Sources/HowdyKitUI/OnboardingFooter.swift | 4 +-- .../HowdyKitUI/OnboardingPageIndicator.swift | 2 +- Sources/HowdyKitUI/OnboardingStepView.swift | 29 ++++++++++++++----- Sources/HowdyKitUI/OnboardingTheme.swift | 20 +++++++++---- .../OnboardingFlowPageTests.swift | 9 ++++++ .../OnboardingThemeTests.swift | 18 ++++++++++++ 9 files changed, 97 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index b36f02d..1ad6eed 100644 --- a/README.md +++ b/README.md @@ -62,8 +62,8 @@ target that only needs "is setup done" can depend on just this. Separate from `HowdyKit` so a watchOS target depending only on the storage layer never compiles SwiftUI it doesn't use. -- **`OnboardingTheme`**: primary color, card background (`ShapeStyle`, any - material or color), fonts, and a custom background view, all defaulted to +- **`OnboardingTheme`**: primary color (text) and accent color (header icon, buttons, Skip, page dot; defaults to primary), optional card background (`AnyShapeStyle`, any + material or color; nil means no card), fonts, and a custom background view, all defaulted to plain system styling. Provided through the environment with `.onboardingTheme(_:)` at the root. CrumbDB's brand (custom font, topo background) and Marie's plain look are the same view with different themes, @@ -75,7 +75,7 @@ layer never compiles SwiftUI it doesn't use. which `OnboardingAction` to pass on each render, the view itself just renders whatever it's given right now. - **`OnboardingStepView`**: header + arbitrary `@ViewBuilder` content + - primary/optional-secondary actions, themed. Renders one step. Inside a flow + primary/optional-secondary actions, themed, drawn inside a rounded card when the theme sets `cardBackground`. Renders one step. Inside a flow it publishes its actions to `OnboardingFlowView`'s shared footer; on its own it draws the footer itself. - **`OnboardingWelcomeView`**: the first-run screen. No header/content split @@ -96,7 +96,7 @@ layer never compiles SwiftUI it doesn't use. swipe-past backfill (`flow.recordIfNeeded(leaving: step)`, driven by `step.isSkippable`; swiping past a required step is allowed and records `false`), and calls `onFinished` once - the last step advances. Pass `isAlreadySatisfied:` (for example, "is the + the last step advances and `onPageShown` with the step ID each time a page is shown (for analytics). Pass `isAlreadySatisfied:` (for example, "is the OS permission already granted") and steps it accepts are recorded `true` through `flow.reconcile` before paging, and again when the app becomes active, so they never get a page. This is the turnkey piece: CrumbDB and Marie get diff --git a/Sources/HowdyKitUI/OnboardingFlowPage.swift b/Sources/HowdyKitUI/OnboardingFlowPage.swift index d469550..9a9bf09 100644 --- a/Sources/HowdyKitUI/OnboardingFlowPage.swift +++ b/Sources/HowdyKitUI/OnboardingFlowPage.swift @@ -14,6 +14,13 @@ struct OnboardingFlowPage: Equatable { new > old ? old.. OnboardingStepID? { + guard let id, id != last else { return nil } + return id + } + /// The pages a flow still needs, welcome first when it's wanted and /// unrecorded, then every unanswered step in declaration order. Reads /// `storage` directly rather than the flow's tracked queries: this runs diff --git a/Sources/HowdyKitUI/OnboardingFlowView.swift b/Sources/HowdyKitUI/OnboardingFlowView.swift index b5adabe..708fc0c 100644 --- a/Sources/HowdyKitUI/OnboardingFlowView.swift +++ b/Sources/HowdyKitUI/OnboardingFlowView.swift @@ -28,6 +28,7 @@ public struct OnboardingFlowView: View { private let flow: OnboardingFlow private let welcome: (() -> Welcome)? private let onFinished: () -> Void + private let onPageShown: ((OnboardingStepID) -> Void)? private let isAlreadySatisfied: (OnboardingStepConfig) -> Bool private let stepContent: (OnboardingStepConfig, @escaping () -> Void) -> StepContent @@ -36,6 +37,7 @@ public struct OnboardingFlowView: View { @State private var actions: [OnboardingStepID: OnboardingActions] = [:] @State private var currentPageID: OnboardingStepID? @State private var highestVisitedIndex = 0 + @State private var lastShownPageID: OnboardingStepID? // `init` runs on every parent re-render (and every recorded answer // re-renders the parent, the flow being observable); `@State` keeps the // first snapshot and ignores the later `initialValue`s. @@ -44,6 +46,11 @@ public struct OnboardingFlowView: View { /// - Parameter welcome: builds the welcome page, shown first while the /// flow's welcome is unrecorded. Typically an `OnboardingWelcomeView`, /// which records the welcome itself; this view then slides on. + /// - Parameter onPageShown: called with the page's ID each time a page + /// comes on screen (the first page on appear, then every page change), + /// never twice in a row for the same page. For analytics: the IDs are + /// the flow's step IDs (the welcome reports `welcomeID`), and the app + /// maps them to whatever names its funnel uses. /// - Parameter isAlreadySatisfied: reports whether a step is already /// satisfied outside the flow (a permission the OS already granted). /// Such unanswered steps are recorded `true` via @@ -56,6 +63,7 @@ public struct OnboardingFlowView: View { public init( flow: OnboardingFlow, onFinished: @escaping () -> Void = {}, + onPageShown: ((OnboardingStepID) -> Void)? = nil, isAlreadySatisfied: @escaping (OnboardingStepConfig) -> Bool = { _ in false }, @ViewBuilder welcome: @escaping () -> Welcome, @ViewBuilder stepContent: @escaping (OnboardingStepConfig, @escaping () -> Void) -> StepContent @@ -63,6 +71,7 @@ public struct OnboardingFlowView: View { self.flow = flow self.welcome = welcome self.onFinished = onFinished + self.onPageShown = onPageShown self.isAlreadySatisfied = isAlreadySatisfied self.stepContent = stepContent flow.reconcile(isSatisfied: isAlreadySatisfied) @@ -132,7 +141,9 @@ public struct OnboardingFlowView: View { backfill(pages[passedIndex]) } highestVisitedIndex = max(highestVisitedIndex, newIndex) + reportShown(currentPageID) } + .onAppear { reportShown(currentPageID) } // The welcome records itself (`OnboardingWelcomeView`); slide on // once it has, if it's still the page on screen. .onChange(of: flow.needsWelcome) { _, needsWelcome in @@ -143,6 +154,12 @@ public struct OnboardingFlowView: View { } } + private func reportShown(_ id: OnboardingStepID?) { + guard let shown = OnboardingFlowPage.nextShown(id, after: lastShownPageID) else { return } + lastShownPageID = shown + onPageShown?(shown) + } + private var footer: some View { // One permanent view, never swapped for another, and never animated: // a page change is wrapped in `withAnimation`, and any animated change @@ -187,12 +204,14 @@ extension OnboardingFlowView where Welcome == EmptyView { public init( flow: OnboardingFlow, onFinished: @escaping () -> Void = {}, + onPageShown: ((OnboardingStepID) -> Void)? = nil, isAlreadySatisfied: @escaping (OnboardingStepConfig) -> Bool = { _ in false }, @ViewBuilder stepContent: @escaping (OnboardingStepConfig, @escaping () -> Void) -> StepContent ) { self.flow = flow self.welcome = nil self.onFinished = onFinished + self.onPageShown = onPageShown self.isAlreadySatisfied = isAlreadySatisfied self.stepContent = stepContent flow.reconcile(isSatisfied: isAlreadySatisfied) diff --git a/Sources/HowdyKitUI/OnboardingFooter.swift b/Sources/HowdyKitUI/OnboardingFooter.swift index cfcdf42..416fabc 100644 --- a/Sources/HowdyKitUI/OnboardingFooter.swift +++ b/Sources/HowdyKitUI/OnboardingFooter.swift @@ -42,7 +42,7 @@ struct OnboardingFooter: View { Button(secondary?.title ?? "", action: secondary?.handler ?? {}) .buttonStyle(.plain) .font(.subheadline) - .foregroundStyle(theme.primaryColor.opacity(0.85)) + .foregroundStyle(theme.accentColor.opacity(0.85)) .opacity(secondary == nil ? 0 : 1) .disabled(secondary == nil) .accessibilityHidden(secondary == nil) @@ -50,7 +50,7 @@ struct OnboardingFooter: View { } // `.borderedProminent`/`.bordered` otherwise fall back to the // system accent color, not the theme. - .tint(theme.primaryColor) + .tint(theme.accentColor) .padding(.horizontal, 28) .padding(.top, 8) // The page control no longer overlaps this: the footer sits below diff --git a/Sources/HowdyKitUI/OnboardingPageIndicator.swift b/Sources/HowdyKitUI/OnboardingPageIndicator.swift index c3076e8..c26bd4c 100644 --- a/Sources/HowdyKitUI/OnboardingPageIndicator.swift +++ b/Sources/HowdyKitUI/OnboardingPageIndicator.swift @@ -13,7 +13,7 @@ struct OnboardingPageIndicator: View { HStack(spacing: 8) { ForEach(0..: View { VStack(spacing: 0) { GeometryReader { geometry in ScrollView { - VStack(spacing: 20) { - if let header { - headerView(header) - } - content - } + card .padding(24) .frame(maxWidth: 560) .frame(maxWidth: .infinity, minHeight: geometry.size.height) @@ -52,13 +47,33 @@ public struct OnboardingStepView: View { .preference(key: OnboardingActionsKey.self, value: publishedActions) } + @ViewBuilder + private var card: some View { + if let cardBackground = theme.cardBackground { + stack + .padding(24) + .background(RoundedRectangle(cornerRadius: 20).fill(cardBackground)) + } else { + stack + } + } + + private var stack: some View { + VStack(spacing: 20) { + if let header { + headerView(header) + } + content + } + } + @ViewBuilder private func headerView(_ header: OnboardingHeader) -> some View { VStack(spacing: 10) { if let icon = header.icon { Image(systemName: icon) .font(.system(size: 64)) - .foregroundStyle(theme.primaryColor) + .foregroundStyle(theme.accentColor) } Text(header.title) .font(theme.titleFont) diff --git a/Sources/HowdyKitUI/OnboardingTheme.swift b/Sources/HowdyKitUI/OnboardingTheme.swift index feab99b..3985cf9 100644 --- a/Sources/HowdyKitUI/OnboardingTheme.swift +++ b/Sources/HowdyKitUI/OnboardingTheme.swift @@ -13,8 +13,16 @@ import SwiftUI /// Not `Sendable`: this is SwiftUI configuration data, read on the main /// actor as part of rendering, the same as the views it configures. public struct OnboardingTheme { + /// Text color (titles). See `accentColor` for the interactive highlights. public var primaryColor: Color - public var cardBackground: AnyShapeStyle + /// Interactive and brand highlights: the header icon, the primary button, + /// the Skip text, the current page dot. Primary is for text. Defaults to + /// `primaryColor` when not given. + public var accentColor: Color + /// When set, each step's header and content are drawn inside a rounded + /// card filled with it; `nil` means no card. Pass one as + /// `AnyShapeStyle(.regularMaterial)` or `AnyShapeStyle(Color.x.opacity(0.85))`. + public var cardBackground: AnyShapeStyle? public var titleFont: Font public var headlineFont: Font public var bodyFont: Font @@ -22,22 +30,24 @@ public struct OnboardingTheme { public init( primaryColor: Color = .primary, - cardBackground: some ShapeStyle = .regularMaterial, + accentColor: Color? = nil, + cardBackground: AnyShapeStyle? = nil, titleFont: Font = .title2.bold(), headlineFont: Font = .headline, bodyFont: Font = .subheadline, @ViewBuilder background: () -> some View = { Color.clear } ) { self.primaryColor = primaryColor - self.cardBackground = AnyShapeStyle(cardBackground) + self.accentColor = accentColor ?? primaryColor + self.cardBackground = cardBackground self.titleFont = titleFont self.headlineFont = headlineFont self.bodyFont = bodyFont self.background = AnyView(background()) } - /// Plain system styling: no custom font, no custom background, a - /// materials-based card. What an app reaches for until it wants to + /// Plain system styling: no custom font, no custom background, no + /// card. What an app reaches for until it wants to /// brand the flow. public static let `default` = OnboardingTheme() } diff --git a/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift b/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift index 11b6430..02334db 100644 --- a/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift +++ b/Tests/HowdyKitUITests/OnboardingFlowPageTests.swift @@ -57,4 +57,13 @@ struct OnboardingFlowPageTests { #expect(OnboardingFlowPage.indicesPassed(from: 2, to: 1).isEmpty) #expect(OnboardingFlowPage.indicesPassed(from: 2, to: 2).isEmpty) } + + @Test("A page is reported when it is new, never twice in a row, and never when absent") + func nextShownGuard() { + #expect(OnboardingFlowPage.nextShown(first, after: nil) == first) + #expect(OnboardingFlowPage.nextShown(middle, after: first) == middle) + #expect(OnboardingFlowPage.nextShown(first, after: first) == nil) + #expect(OnboardingFlowPage.nextShown(nil, after: first) == nil) + #expect(OnboardingFlowPage.nextShown(nil, after: nil) == nil) + } } diff --git a/Tests/HowdyKitUITests/OnboardingThemeTests.swift b/Tests/HowdyKitUITests/OnboardingThemeTests.swift index 838d446..ea82eeb 100644 --- a/Tests/HowdyKitUITests/OnboardingThemeTests.swift +++ b/Tests/HowdyKitUITests/OnboardingThemeTests.swift @@ -25,4 +25,22 @@ struct OnboardingThemeTests { #expect(theme.headlineFont == OnboardingTheme.default.headlineFont) #expect(theme.bodyFont == OnboardingTheme.default.bodyFont) } + + @Test func accentDefaultsToPrimary() { + #expect(OnboardingTheme(primaryColor: .red).accentColor == .red) + #expect(OnboardingTheme().accentColor == Color.primary) + } + + @Test func explicitAccentIsKept() { + let theme = OnboardingTheme(primaryColor: .red, accentColor: .orange) + #expect(theme.primaryColor == .red) + #expect(theme.accentColor == .orange) + } + + @Test func cardBackgroundIsNilByDefaultAndSetWhenGiven() { + #expect(OnboardingTheme().cardBackground == nil) + #expect(OnboardingTheme.default.cardBackground == nil) + #expect(OnboardingTheme(cardBackground: AnyShapeStyle(.regularMaterial)).cardBackground != nil) + #expect(OnboardingTheme(cardBackground: AnyShapeStyle(Color.white.opacity(0.85))).cardBackground != nil) + } } From 0e5148a68601fccc531fd904f57b66608cc8d5d2 Mon Sep 17 00:00:00 2001 From: "dryan-gh-actions[bot]" <254598264+dryan-gh-actions[bot]@users.noreply.github.com> Date: Fri, 2 Oct 2026 16:01:06 +0000 Subject: [PATCH 14/14] =?UTF-8?q?=F0=9F=93=9D=20Dependency=20example=20sta?= =?UTF-8?q?rts=20at=201.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index ccdd1e9..b8a157e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,7 @@ XcodeGen (`project.yml`): packages: HowdyKit: url: https://github.com/dryan-org/howdykit - from: "0.1.0" + from: "1.0.0" targets: : dependencies: