Skip to content

Region-picking onboarding + per-region customization#95

Draft
kyleve wants to merge 10 commits into
mainfrom
cursor/region-onboarding
Draft

Region-picking onboarding + per-region customization#95
kyleve wants to merge 10 commits into
mainfrom
cursor/region-onboarding

Conversation

@kyleve

@kyleve kyleve commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Builds the deferred Where onboarding step: pick up to 5 US primary regions (map or filterable list, via a top segmented control), then customize each region's color / emoji / icon from predefined lists. Picks become the tracked-region set; picks + appearance persist in CloudKit-mirrored SwiftData and feed region styling. Editable later in Settings.

What's included

WhereCore — persistence & domain

  • New value types RegionColorToken, RegionAppearance, PrimaryRegion.
  • SDTrackedRegion carries appearance (colorRaw / emoji / symbolName) + orderIndex (all optional for CloudKit — additive, no migration scaffolding).
  • WhereStore.primaryRegions() and a single atomic setPrimaryRegions([PrimaryRegion]) (replace semantics: upserts + removals-by-omission in one perform). Picks are the tracked set.

WhereUI — flow & styling

  • PrimaryRegionSelectionModel (US-only, cap 5), RegionPickerView (segmented Map | List — tappable US-state polygons + searchable list), RegionCustomizeView / RegionAppearanceEditor (color/emoji/icon grids + live RegionSummaryCard preview), RegionsSettingsView.
  • OnboardingView is now an intro → pick → customize → location state machine that commits picks before finishing; Settings gains a Regions editor. Both reuse the same picker/customize views, wrapped in NavigationStacks with toolbar controls.
  • Region looks resolve through @Environment(\.regionStyles) (a RegionStyleResolver), seeded by whereBroadwayRoot(regionStyles:) — from WhereSession (live on store changes), the WidgetSnapshot in the widget process, and services in App Intents. Fallbacks come from RegionAppearanceCatalog. There is no global region.style.
  • WidgetSnapshot carries appearances so widgets render user choices; RegionPickerStyle stylesheet group added.

Notes / decisions

  • US-only: the picker offers US jurisdictions only; opening the Settings editor on a legacy default install (CA/NY/Canada/EU) converges to the US picks on save (Canada/EU ship low-res polygons and are being retired). Documented + tested.
  • Home/year ranking still ranks by day count; picking sets the tracked set + appearance, it doesn't re-order the year report.
  • The SDTrackedRegion soft-delete TODO stays open (untracking still deletes the row, matching prior behavior).

Follow-up review fixes (included)

This branch also folds in fixes from a self-review: nav-stack/toolbar consistency, atomic single-transaction commit, environment-injected styling (replacing a global singleton; also makes edits/imports restyle reactively), restored .other glyph, capped-tap haptic + hint, lazy map-geometry parsing (List is the default), and a clamped customize step index.

Testing

  • New/updated unit tests: store round-trip + ordering + replace-removal, selection-model cap + commit + US-only convergence, RegionStyleResolver resolution/fallback, widget-snapshot appearance round-trip, RegionPickerStyle defaults.
  • Full Stuff-iOS-Tests scheme green locally (-destination 'platform=iOS Simulator,name=iPhone 17,OS=26.2'); ./swiftformat --lint clean.

Docs

  • Updated WhereCore and WhereUI README + AGENTS; resolved the RegionStyle bespoke-table TODO; refreshed the now-inaccurate setTrackedRegion(false) comment (untracking is user-reachable now).
Open in Web Open in Cursor 

kyleve added 10 commits July 15, 2026 19:08
Build the deferred onboarding step that lets the user pick up to 5 US
primary regions (map or searchable list, via a top segmented control)
and customize each region's color/emoji/icon from predefined lists.

- WhereCore: RegionColorToken / RegionAppearance / PrimaryRegion value
  types; SDTrackedRegion carries appearance + pick order; WhereStore
  primaryRegions() / setPrimaryRegion(). Picks are the tracked-region set.
- WhereUI: RegionAppearanceCatalog + RegionStyleRegistry make RegionStyle
  data-driven (seeded by WhereSession on launch/changes, and by the widget
  snapshot in the widget process); PrimaryRegionSelectionModel,
  RegionPickerView, RegionCustomizeView, RegionsSettingsView.
- OnboardingView is now an intro -> pick -> customize -> location state
  machine that commits picks before finishing; Settings gains a Regions
  editor. WidgetSnapshot carries appearances so widgets render user looks.
- Tests: store round-trip/ordering, selection-model cap, registry
  resolution, widget appearance; RegionPickerStyle stylesheet group + docs.
Address code-review finding #1 (onboarding List had no search field
because .searchable needs a navigation container) and make the
onboarding and Settings region flows visually consistent:

- Onboarding pick phase is a NavigationStack with a large title and a
  top-right Next (forward-only after the intro); its list is now
  filterable via .searchable.
- RegionCustomizeView moves Back/Next (Done on the last region) into the
  nav bar with the region name as the inline title and an X-of-N
  indicator; callers place it in a NavigationStack.
- Onboarding customize phase wraps it in a NavigationStack.
- RegionsSettingsView: pick shows Cancel + Next (large title, matching
  onboarding), customize delegates to RegionCustomizeView's toolbar;
  dropped the old bottom buttons so each phase owns its own bar.
Address code-review finding #2: the commit looped per-region
setPrimaryRegion/removePrimaryRegion calls, each its own store.perform,
so N picks fired N changes() pings (attribution rebuild, widget
republish, report reload, registry reseed each) and a mid-loop failure
left partial state.

Replace those single-region WhereServices/WhereStore calls with one
batch setPrimaryRegions([PrimaryRegion]) that has replace semantics:
upsert each entry (appearance + order) and delete any tracked row not in
the list, all inside a single perform. Removals now happen by omission,
so the model no longer tracks an initial set to diff against — the
selection fully describes the primary set. One transaction, one ping,
atomic. Tests updated to the batch API.
Address code-review finding #3. The app is US-only now (the legacy
Canada/EU regions ship low-resolution polygons and aren't pickable), so
seeding the editor from the default set and saving intentionally drops
the non-US regions. Spell that out in PrimaryRegionSelectionModel's
init(existing:) doc and pin it with a test: loading the default set into
the model selects only CA/NY, and committing narrows the tracked set to
those.
Address code-review finding #4: RegionStyle read from a process-wide
RegionStyleRegistry.shared singleton — fine in production but a latent
test-isolation hazard in the shared multi-bundle host, and it made
styles update lazily rather than reactively.

Replace it with a RegionStyleResolver injected through the environment
(\.regionStyles), seeded by whereBroadwayRoot(regionStyles:):
- the app passes WhereSession's live resolver (updated on launch +
  store changes), so a Settings edit / remote import now restyles the UI
  reactively;
- the widget process builds one from WidgetSnapshot.appearances;
- App Intents snippets build one from their services.
The default empty resolver yields fallback looks (previews, RegionViewer).

Views now read @Environment(\.regionStyles) and call
regionStyles.style(for:); the global region.style / RegionStyle.style(for:)
and the registry are gone. Renamed the file/test to RegionStyleResolver
and updated docs.
Address code-review finding #5: unifying the bespoke look table into
RegionAppearanceCatalog.defaultAppearance changed the user-visible
.other glyph. Restore location.magnifyingglass (the fallback default
isn't constrained to the picker's symbol catalog). EU/Canada looks are
left as-is since those regions are being retired (not pickable).
Address code-review finding #6: the List disables unselected rows at the
5-region cap, but the map silently ignored a tap on a new state. Now a
capped map tap fires a warning haptic (.sensoryFeedback), and an
at-capacity hint appears under the selection count in both modes, so the
map is as responsive/legible as the list.
Address code-review finding #7: the picker parsed all ~52 US-region
GeoJSON files on appear to back Map mode. Default to List (no geometry
needed) and only run the parse when Map mode is first shown (.task(id:
mode)), so a List-only user never pays the cost.
Address code-review finding #8: harden RegionCustomizeView so a
stale/out-of-range step index can never strand the user on the empty
state. Navigation and the step indicator now use a clamped effectiveIndex;
the empty (nil) state is reserved for a genuinely empty selection, which
the current flow never reaches.
The test helper primary(_:_:_:) collided with the local
'let primary = try await store.primaryRegions()' variables. Local batch
compilation resolved the call to the method, but CI's compilation mode
resolved it to the [PrimaryRegion] local ('cannot call value of
non-function type'). Rename the helper to pick() to remove the ambiguity.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant