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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .agents/skills/building-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,13 @@ module-owned stylesheets.
than remain at zero opacity.
- Keep the visual structure consistent across states and variants unless the
difference is intentional and modeled by the component style.
- Put a large declarative subtree behind a nominal child `View` before passing
it through a custom generic container that stores or repeatedly transforms
its `Content`. In particular, do not pass a multi-section `Form` or `List`
directly into such a wrapper: SwiftUI may copy the full concrete value on the
stack while applying environment or navigation updates. Treat a DEBUG-only
content-footprint guard as a heuristic tripwire for extraction, not a
shipping layout contract or a threshold to retune around.

## Build for accessibility and localization

Expand Down
9 changes: 9 additions & 0 deletions Where/WhereUI/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,15 @@ Layering, localization, preview, and testing conventions live in the feature
([`Sources/Shared/MotionIsStatic.swift`](Sources/Shared/MotionIsStatic.swift))
for its static end-state — never hand-roll the
`\.accessibilityReduceMotion` + `\.isCapturingSnapshot` pair.
- Pass large `Form`/`List` subtrees through a nominal child before
`SettingsFocusScope`; `SettingsRouteViewTests` exercises every push
destination and its nested heterogeneous routes against the DEBUG-only
value-size tripwire.
- Keep the Settings content-footprint tripwire DEBUG-only; Release has no
footprint precondition.
- Diagnostic: `EXC_BAD_ACCESS` / `___chkstk_darwin` followed by
`View.environment<T>` during a navigation push means inspect concrete content
stored by generic wrappers.
- A step joins `WhereLaunch`'s plan through `.measured()` and so must declare a
`budget` (`BudgetedLaunchStep`) — see [Spans](../AGENTS.md#spans). WhereUI also
owns log retention: `LogHistoryPruner` bounds the store by age *and* event
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ struct ShareEvidenceFeaturesView: View {
}
}

private enum Route: Hashable {
enum Route: Hashable {
case archive
}
}
Expand Down
72 changes: 72 additions & 0 deletions Where/WhereUI/Sources/Settings/SettingsRouteView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import SwiftUI

/// Builds one pushed Settings destination from the root's typed route and
/// view-scoped collaborators. Keeping route rendering in a nominal child lets
/// tests exercise the same destination tree without a test-only Settings API.
struct SettingsRouteView: View {
let route: SettingsRoute
let report: YearReportModel
let backup: BackupModel
let reminders: RemindersSettingsModel

@Environment(WhereSession.self) private var session

var body: some View {
switch route.destination {
case .attachments:
EvidenceListView(report: report)
case .loggedDays:
LoggedDaysView(report: report)
case .devices:
DevicesSettingsView(session: session, focus: route.focus)
case .regions:
// Regions is presented as a sheet (`isSheet`), so it's never
// routed here; this arm only keeps the switch exhaustive.
EmptyView()
case .alerts:
AlertsSettingsView(report: report, reminders: reminders, focus: route.focus)
case .appearance:
AppearanceSettingsView(report: report, focus: route.focus)
case .year:
VisibleYearSettingsView(report: report, focus: route.focus)
case .siri:
SiriFeaturesView(
focus: route.focus,
presentation: featureDiscoveryPresentation,
)
case .widgets:
WidgetFeaturesView(
focus: route.focus,
presentation: featureDiscoveryPresentation,
)
case .shareEvidence:
ShareEvidenceFeaturesView(
report: report,
focus: route.focus,
presentation: featureDiscoveryPresentation,
)
case .estimatedTime:
EstimatedTimeFeaturesView(report: report, focus: route.focus)
case .insightsAccuracy:
InsightsAccuracyFeaturesView(
report: report,
focus: route.focus,
)
case .personalization:
PersonalizationFeaturesView(report: report, focus: route.focus)
case .data:
DataSettingsView(report: report, backup: backup, focus: route.focus)
case .about:
AboutSettingsView(focus: route.focus)
}
}

private var featureDiscoveryPresentation: FeatureDiscoveryPresentation {
FeatureDiscoveryPresentation(
report: report.report,
selectedYear: report.selectedYear,
referenceDate: report.referenceDate,
calendar: report.calendar,
)
}
}
18 changes: 18 additions & 0 deletions Where/WhereUI/Sources/Settings/SettingsRow.swift
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,16 @@ struct SettingsRowModifier: ViewModifier {
/// (no fade — still scrolls and shows a brief static highlight). Flashes once per
/// appearance so returning to the screen doesn't re-flash.
struct SettingsFocusScope<Content: View>: View {
#if DEBUG
/// A DEBUG-only heuristic separating the largest known-safe Settings form
/// (46,912 bytes) from the 62,680-byte concrete value that overflowed a
/// device stack while SwiftUI applied this scope's environment during a
/// navigation push. Large trees belong behind a small nominal child view.
static var maximumContentFootprint: Int {

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.\n\nThis threshold is intentionally a DEBUG/CI heuristic, not a portable ABI boundary. If a future destination trips it, extract the large declarative subtree behind a nominal child view rather than raising the threshold; Release deliberately has no corresponding precondition.

56 * 1024
}
#endif

let focus: SettingsFocus?
let isReady: Bool
let content: Content
Expand All @@ -113,6 +123,14 @@ struct SettingsFocusScope<Content: View>: View {
) {
self.focus = focus
self.isReady = isReady
#if DEBUG
let contentFootprint = MemoryLayout<Content>.size
precondition(
contentFootprint <= Self.maximumContentFootprint,
"SettingsFocusScope Content is \(contentFootprint) bytes; extract its large "
+ "Form/List subtree behind a nominal child View.",
)
#endif
self.content = content()
}

Expand Down
4 changes: 2 additions & 2 deletions Where/WhereUI/Sources/Settings/SettingsSearch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import SwiftUI

/// The top-level Settings groups. Each drills into its own sub-screen; the
/// top-level list and `SettingsRoute` route on these, and the
/// `navigationDestination` switch (in `SettingsView`) builds a screen for every
/// case with no `default:`, so adding a case is a compile error until wired.
/// `SettingsRouteView` switch builds a screen for every case with no `default:`,
/// so adding a case is a compile error until wired.
enum SettingsDestination: Hashable, CaseIterable {
case attachments
case loggedDays
Expand Down
67 changes: 6 additions & 61 deletions Where/WhereUI/Sources/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,12 @@ struct SettingsView: View {
}
}
.navigationDestination(for: SettingsRoute.self) { route in
destination(for: route)
SettingsRouteView(
route: route,
report: report,
backup: backup,
reminders: reminders,
)
}
.sheet(isPresented: $showRegions) {
RegionsSettingsView(usedThisYear: regionsUsedThisYear)
Expand Down Expand Up @@ -254,73 +259,13 @@ struct SettingsView: View {
}
}

@ViewBuilder
private func destination(for route: SettingsRoute) -> some View {
switch route.destination {
case .attachments:
EvidenceListView(report: report)
case .loggedDays:
LoggedDaysView(report: report)
case .devices:
DevicesSettingsView(session: session, focus: route.focus)
case .regions:
// Regions is presented as a sheet (`isSheet`), so it's never
// routed here; this arm only keeps the switch exhaustive.
EmptyView()
case .alerts:
AlertsSettingsView(report: report, reminders: reminders, focus: route.focus)
case .appearance:
AppearanceSettingsView(report: report, focus: route.focus)
case .year:
VisibleYearSettingsView(report: report, focus: route.focus)
case .siri:
SiriFeaturesView(
focus: route.focus,
presentation: featureDiscoveryPresentation,
)
case .widgets:
WidgetFeaturesView(
focus: route.focus,
presentation: featureDiscoveryPresentation,
)
case .shareEvidence:
ShareEvidenceFeaturesView(
report: report,
focus: route.focus,
presentation: featureDiscoveryPresentation,
)
case .estimatedTime:
EstimatedTimeFeaturesView(report: report, focus: route.focus)
case .insightsAccuracy:
InsightsAccuracyFeaturesView(
report: report,
focus: route.focus,
)
case .personalization:
PersonalizationFeaturesView(report: report, focus: route.focus)
case .data:
DataSettingsView(report: report, backup: backup, focus: route.focus)
case .about:
AboutSettingsView(focus: route.focus)
}
}

/// Regions with days in the selected report year, so the region editor can
/// surface a "used this year" group (grouping order only — it doesn't affect
/// what's saved). `.other` isn't a pickable region, so it's dropped.
private var regionsUsedThisYear: Set<Region> {
guard let totals = report.report?.totals else { return [] }
return Set(totals.filter { $0.key != .other && $0.value > 0 }.map(\.key))
}

private var featureDiscoveryPresentation: FeatureDiscoveryPresentation {
FeatureDiscoveryPresentation(
report: report.report,
selectedYear: report.selectedYear,
referenceDate: report.referenceDate,
calendar: report.calendar,
)
}
}

#if DEBUG
Expand Down
Loading
Loading