Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
ed8e707
🐛 Keep OnboardingFlowView's page list stable for the whole flow
dryan-gh-actions[bot] Oct 2, 2026
d4e3f01
💄 Give the step footer 50pt of clearance above the page control
dryan-gh-actions[bot] Oct 2, 2026
d1472fa
✨ Reconcile OS-granted steps, share one footer across screens
dryan-gh-actions[bot] Oct 2, 2026
c089e59
✨ Render the footer once below the pages, dots above the buttons
dryan-gh-actions[bot] Oct 2, 2026
ecdb161
✨ OnboardingFlow.clear(_:) for one step
dryan-gh-actions[bot] Oct 2, 2026
f2db9f4
💄 Animate advancing between pages and out of the welcome
dryan-gh-actions[bot] Oct 2, 2026
6187bc9
✨ Make the welcome page 0 of the TabView so Continue slides like a swipe
dryan-gh-actions[bot] Oct 2, 2026
b31bc69
🐛 Keep the footer's height constant across page changes
dryan-gh-actions[bot] Oct 2, 2026
95f8c2b
🐛 One permanent, unanimated footer so pages never move on a page change
dryan-gh-actions[bot] Oct 2, 2026
6334fd9
✨ Page with a paging ScrollView and the kit's own dots
dryan-gh-actions[bot] Oct 2, 2026
131caef
🐛 Size pager pages from the slot the pager gets, not the screen
dryan-gh-actions[bot] Oct 2, 2026
67d92ce
Merge pull request #7 from dryan-org/reconcile-and-footer
dryan Oct 2, 2026
ad4d14e
📝 AGENTS.md: how to integrate the kit, in order, with the rules
dryan-gh-actions[bot] Oct 2, 2026
d9df871
✨ Theme accent color, optional card, onPageShown callback
dryan-gh-actions[bot] Oct 2, 2026
0e5148a
📝 Dependency example starts at 1.0.0
dryan-gh-actions[bot] Oct 2, 2026
a3ecb0f
Merge pull request #8 from dryan-org/agents-guide
dryan Oct 2, 2026
61e9b29
Merge pull request #9 from dryan-org/theme-accent-card-callback
dryan Oct 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
173 changes: 173 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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: "1.0.0"
targets:
<App>:
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.<app>.welcome")
static let location = OnboardingStepID("com.dryan.<app>.reqs.location")
static let photos = OnboardingStepID("com.dryan.<app>.reqs.photos")
static let tips = OnboardingStepID("com.dryan.<app>.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: "<app>.")
)
```

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 <device> 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.
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -58,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,
Expand All @@ -71,23 +75,31 @@ 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, 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
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.
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
`OnboardingStepConfig`) and an `advance` closure to call once that step's
own action has recorded an answer; the container handles paging,
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 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
the same flow mechanics, not just the same per-step look.

All three are genuinely turnkey. An app can still build its own container
Expand Down
24 changes: 24 additions & 0 deletions Sources/HowdyKit/OnboardingFlow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,30 @@ 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 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
Expand Down
39 changes: 39 additions & 0 deletions Sources/HowdyKitUI/OnboardingActions.swift
Original file line number Diff line number Diff line change
@@ -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
}
39 changes: 39 additions & 0 deletions Sources/HowdyKitUI/OnboardingFlowPage.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
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 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<Int> {
new > old ? old..<new : 0..<0
}

/// The id to report as shown, or `nil` when there is nothing new to
/// report (no page, or the same page as last time).
static func nextShown(_ id: OnboardingStepID?, after last: OnboardingStepID?) -> 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
/// 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
}
}
Loading
Loading