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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
1 change: 1 addition & 0 deletions Shared/Flyover/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, format
- **Load the canvas from the viewport.** Keep at most six automatic screen trees live. A manually requested preview replaces that set with one tree. Presenting the focused inspector suspends the canvas set.
- **Open the canvas fitted to its first group's width.** Reserve whole-graph framing for the explicit Fit All action.
- **Cap automatic graph-depth stacks at the stylesheet row limit.** Spill overflow right inside one labeled depth band while preserving explicit `FlyoverPosition` values exactly.
- **Draw connectors in bounded, viewport-filtered tiles.** Never allocate a single full-graph drawing texture; see `FlyoverConnectorTilePlanTests`.
- **Invoke variant builders through the serial deferred load coordinator.** Never invoke them synchronously from a SwiftUI `body`. Preview fixtures may open expensive in-memory stores.
- **Canvas preview readiness is the latest nonempty visible-load expectation.** Variant or generation changes supersede stale completions. Cancelled waiters must resume. `FlyoverSnapshotTests` awaits it before full-content measurement.
- **Keep global traits session-only.** Apply them to registered content, not Flyover chrome.
Expand Down
2 changes: 2 additions & 0 deletions Shared/Flyover/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ The initial canvas zoom fits the first group to the available width so its cards
Reach later groups by horizontal scrolling.
Pinching or moving the zoom slider preserves the canvas point at the center of the visible viewport.
**Fit All** remains available for a whole-graph overview.
Connectors use bounded drawing tiles and omit tiles outside the visible area.
Large catalogs therefore keep their navigation lines without requiring one oversized drawing texture.

## App integration

Expand Down
6 changes: 5 additions & 1 deletion Shared/Flyover/Sources/FlyoverCanvasView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,11 @@ struct FlyoverCanvasView<ScreenID: Hashable>: View {
FlyoverDepthBandBackdrop(band: band)
}

FlyoverConnectorCanvas(catalog: catalog, layout: layout)
FlyoverConnectorCanvas(
catalog: catalog,
layout: layout,
renderPlan: renderPlan,
)

ForEach(catalog.screens, id: \.id) { screen in
if let frame = layout.screenFrames[screen.id],
Expand Down
26 changes: 22 additions & 4 deletions Shared/Flyover/Sources/FlyoverConnectorCanvas.swift
Original file line number Diff line number Diff line change
@@ -1,13 +1,34 @@
import SwiftUI

/// Draws push and modal navigation relationships behind Flyover cards.
/// Draws navigation relationships in bounded tiles, retaining graph coordinates for every route.
struct FlyoverConnectorCanvas<ScreenID: Hashable>: View {
let catalog: FlyoverCatalog<ScreenID>
let layout: FlyoverLayoutResult<ScreenID>
let renderPlan: FlyoverCanvasRenderPlan<ScreenID>
@Environment(\.flyoverStylesheet) private var stylesheet

var body: some View {
let tiles = FlyoverConnectorTilePlan(canvasSize: layout.canvasSize).tiles
.filter { renderPlan.shouldDisplay($0.frame) }

ZStack(alignment: .topLeading) {
ForEach(tiles) { tile in
canvas(in: tile.frame)
.frame(width: tile.frame.width, height: tile.frame.height)
.clipped(antialiased: false)
.position(x: tile.frame.midX, y: tile.frame.midY)
}
}
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
.allowsHitTesting(false)
.accessibilityHidden(true)
}

private func canvas(in frame: CGRect) -> some View {
Canvas { context, _ in
// Keep one coordinate system so curves, dash phases, and labels
// continue unchanged across tile edges.
context.translateBy(x: -frame.minX, y: -frame.minY)
for transition in catalog.transitions {
guard
let source = layout.screenFrames[transition.source],
Expand All @@ -23,9 +44,6 @@ struct FlyoverConnectorCanvas<ScreenID: Hashable>: View {
)
}
}
.frame(width: layout.canvasSize.width, height: layout.canvasSize.height)
.allowsHitTesting(false)
.accessibilityHidden(true)
}

private func draw(
Expand Down
49 changes: 49 additions & 0 deletions Shared/Flyover/Sources/FlyoverConnectorTilePlan.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import CoreGraphics

/// Partitions graph coordinates into bounded drawing surfaces without gaps or overlaps.
struct FlyoverConnectorTilePlan {
/// A rendering budget, independent of the graph's appearance or current zoom.
/// At 3× display scale and the maximum 1.25× zoom, each edge stays below 4096 pixels.
static let maximumDimension: CGFloat = 1024

struct Tile: Identifiable, Equatable {
struct ID: Hashable {
let column: Int
let row: Int
}

let id: ID
let frame: CGRect
}

let canvasSize: CGSize

var tiles: [Tile] {
precondition(canvasSize.width.isFinite && canvasSize.height.isFinite)
guard canvasSize.width > 0, canvasSize.height > 0 else {
return []
}

let columns = Int(ceil(canvasSize.width / Self.maximumDimension))
let rows = Int(ceil(canvasSize.height / Self.maximumDimension))

return (0 ..< rows).flatMap { row in
(0 ..< columns).map { column in
let origin = CGPoint(
x: CGFloat(column) * Self.maximumDimension,
y: CGFloat(row) * Self.maximumDimension,
)
return Tile(
id: Tile.ID(column: column, row: row),
frame: CGRect(
origin: origin,
size: CGSize(
width: min(Self.maximumDimension, canvasSize.width - origin.x),
height: min(Self.maximumDimension, canvasSize.height - origin.y),
),
),
)
}
}
}
}
58 changes: 58 additions & 0 deletions Shared/Flyover/Tests/FlyoverConnectorTilePlanTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import CoreGraphics
@testable import Flyover
import Testing

struct FlyoverConnectorTilePlanTests {
@Test func partitionsTheOversizedWhereGraphIntoBoundedSurfaces() throws {
// The previous full-graph Canvas requested an 18000×6768-pixel texture
// on iPad and dropped every connector when that allocation failed.
let size = CGSize(width: 9000, height: 3384)
let tiles = FlyoverConnectorTilePlan(canvasSize: size).tiles

#expect(tiles.count == 36)
#expect(Set(tiles.map(\.id)).count == tiles.count)
#expect(tiles.allSatisfy {
$0.frame.width <= FlyoverConnectorTilePlan.maximumDimension
&& $0.frame.height <= FlyoverConnectorTilePlan.maximumDimension
})
#expect(tiles.reduce(CGRect.null) { $0.union($1.frame) } == CGRect(
origin: .zero,
size: size,
))
#expect(tiles.reduce(0) { $0 + $1.frame.width * $1.frame.height } == size.width * size
.height)
for (index, tile) in tiles.enumerated() {
#expect(tiles.dropFirst(index + 1).allSatisfy {
tile.frame.intersection($0.frame).isEmpty
})
}

let last = try #require(tiles.last)
#expect(last.frame == CGRect(x: 8192, y: 3072, width: 808, height: 312))
}

@Test func retainsFractionalEdgeCoverageAndStableTileIdentities() {
let size = CGSize(width: 1024.5, height: 2048.25)
let initial = FlyoverConnectorTilePlan(canvasSize: size).tiles
let expanded = FlyoverConnectorTilePlan(canvasSize: CGSize(width: 1500, height: 2500)).tiles
let expectedArea: CGFloat = size.width * size.height
let actualArea: CGFloat = initial.reduce(0) { $0 + $1.frame.width * $1.frame.height }

#expect(initial.count == 6)
#expect(initial.map(\.id) == expanded.map(\.id))
#expect(initial.last?.frame == CGRect(x: 1024, y: 2048, width: 0.5, height: 0.25))
#expect(actualArea == expectedArea)
}

@Test func exactMultiplesDoNotCreateEmptyEdgeTiles() {
let tiles = FlyoverConnectorTilePlan(canvasSize: CGSize(width: 2048, height: 1024)).tiles

#expect(tiles.count == 2)
#expect(tiles.allSatisfy { $0.frame.size == CGSize(width: 1024, height: 1024) })
}

@Test(arguments: [CGSize.zero, CGSize(width: 0, height: 100), CGSize(width: 100, height: 0)])
func emptyCanvasHasNoDrawingSurfaces(size: CGSize) {
#expect(FlyoverConnectorTilePlan(canvasSize: size).tiles.isEmpty)
}
}
8 changes: 6 additions & 2 deletions Where/TODOs.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,12 @@ The item format and the placement rule live in the root
- refactor(WhereCore): Durable write-back is **read-repair**, decoupled from read correctness: opportunistically (batched, on `.NSPersistentStoreRemoteChange` + launch) rewrite stale records to the current version and stamp it, so old builds can honor exclusion. Transforms must be deterministic + commutative so two devices healing the same record via CloudKit converge (LWW-safe). (agent)
- design(WhereCore): Open question — the exclusion UX, where an older device progressively hides days a newer device has touched, needs a deliberate warning surface, not a silent drop. (agent)
- fix(WhereUI) [needs-design]: broken-snapshots — the snapshot suite pinned genuinely broken renderings as references, flagged with `[Fix later]` review comments on PR #101 and merged anyway to land the suite. These are not flaky captures (those have their own ledger below) — each is a faithful, reproducible image of something actually wrong, so re-recording is never the fix. Fix the view, the capture frame, or the pipeline as each item says, then re-record just that reference under `Where/WhereUI/SnapshotTests/__Snapshots__/`. Most cluster on the accessibility axes `.screenDefaults` added — the ax5 Dynamic Type and VoiceOver-annotated configurations that nothing rendered before this suite existed. (pr#101 review)
- fix(WhereUI) [quick-win]: broken-snapshots: the calendar day grid breaks at accessibility Dynamic Type. Every two-digit date truncates to its first digit — the 10th–31st render as "1", "2", or "3" — because the day number is clamped to a fixed square (`DayCell` at `CalendarContentView.swift:489-493`, `.frame(width: calendar.day.numberSize, height: calendar.day.numberSize)`), and the weekday header row wraps mid-word ("Sun" over two lines, "Wed" over three) because each symbol is a plain `Text` in an equal-width grid column (`:313-317`). Both show in `calendarContent.WithData_iPhone_ax5.png`; the digit truncation also hits `..._iPad_ax5.png`, where the extra width goes to inter-column gaps instead of the numbers. Showing "1" where the date is 10 is wrong content, not merely tight layout. **The references have now been re-recorded twice with the layout code unchanged — by PR #196 and again by PR #297's Xcode 27 beta 6 refresh — so they pin the same defect at a third recording.** Re-check the current image before fixing, and re-record after. (pr#101 review; re-verified 2026-09-06)
- fix(WhereUI) [needs-design]: broken-snapshots: `YearView` overflows horizontally at ax5. In `year.Loaded_iPhone_ax5.png` the month title reads "nuary", the day grid is clipped on both edges, and the Calendar/Timeline pill runs off the trailing edge. The suspect is `YearModePicker`, whose segment labels take their intrinsic width via `.fixedSize()` (`YearView.swift:110`, with an in-source comment explaining it keeps labels from truncating mid-animation) inside a bottom `safeAreaInset` (`:40-43`), making it wider than the screen at ax5. Confirm the oversized inset is what widens the layout beneath it, then make the picker fit at accessibility sizes (icon-only, wrapped, or scrollable) — note the `.fixedSize()` is deliberate, so the fix has to keep animation from truncating too. (pr#101 review; re-verified 2026-08-09)
- fix(WhereUI) [quick-win]: broken-snapshots: the Resolve toolbar badge sits awkwardly on the iOS 26 glass toolbar button. `ResolveToolbarLabel` hand-rolls the badge as a red `Capsule` overlaid on the `checklist` symbol and pushes it out with a fixed `.offset(x: spacing.small, y: -spacing.small)` (`LocationsView.swift:367-382`, the red capsule + offset at `:380-381` — moved by PR #302's card work), landing it half outside the button's own glass capsule — visible in `root.LoggedIn_iPhone.png`. Use SwiftUI's `.badge()` on the toolbar item, or offset against the resolved chrome rather than a fixed spacing token. (pr#101 review; re-verified 2026-09-06)
- fix(WhereUI): broken-snapshots: `locations.Loaded_iPad.png` bakes in raw inflection markup — the Elsewhere card's subtitle renders literally as `^[3 region](inflect: true)`. This is the `locations.elsewhere.subtitle` P1 filed above, now pinned as a reference; recorded here so the image isn't mistaken for correct output, and so that reference is re-recorded when the fix lands. (pr#101 review)

## P2s (Nice to have)
- test(WhereUI) [needs-design]: Stabilize initial calendar positioning in the app shell. Repeated July `MainTabs` captures place the current month either fully visible or below the tab accessory. `CalendarContentView.swift:172-176` marks positioning complete immediately after scrolling to a lazily laid-out month. Reproduce outside capture and tie completion to layout before changing production behavior. The welcome-overlay fixtures use January to avoid this unrelated dependency; dedicated calendar snapshots retain longer-year coverage. (agent 2026-09-17)
- test(WhereUI) [quick-win]: Investigate the native iPad confirmation-button snapshot artifact — the system Save button contains a horizontal gray band in `PlannedStayEditor.swift:112` toolbar captures. It also appears in the previous `NewPlan_iPad.png` reference and persists with a one-second settle floor. Verify on a live iPad runtime and isolate the native glass capture before changing production appearance; keep the button accessibility in that probe. (agent 2026-09-13)
- test(WhereUI) [quick-win]: Cover the welcome overlay's scrolling and modal semantics — `WelcomeFirst` / `WelcomeBack` use fixed `.phoneLightDark` frames and one fixed AX5 frame (`Primary/LocationsView.swift:447-462`) while the modal contains a `ScrollView` inside a greedy `GeometryReader` (`Primary/LocationWelcomeOverlay.swift:25-39`). No welcome configuration uses the semantic `.accessibility` capture or an iPad frame. Capture the shared scrolling child with full-content sizing if the bounded modal cannot converge, and add a semantic modal case plus iPad coverage. Keep the production modal/focus wiring; a fixed AX5 image cannot prove that all scrollable controls or VoiceOver elements remain reachable. (audit 2026-09-07, PRs #309/#311)
- feat(Where): Consider the user-assigned device-name entitlement and matching provisioning-profile support so the Devices screen can offer a better initial label than the generic hardware family. Keep the current generic name until the entitlement is intentionally provisioned; never silently depend on an entitlement absent from developer signing. (`InstallationRecordingContextStore.swift:227-242` still derives `systemName` from `UIDevice.current.model`, and `Project.swift` declares no such entitlement; PR #160 review, re-verified 2026-08-09)
- feat(WhereUI) [needs-design]: Give the app a branded launch screen. `UILaunchScreen` is an empty dictionary (`Project.swift`), so the pre-main frame is plain white. Measured from a fresh-install simulator recording, a first run reads as ~1.7s of white → ~0.25s of the dark `LaunchSplashView` → the light onboarding screen, so the splash registers as a quarter-second dark blip between two light screens rather than as the app opening. A launch screen matching the splash's background + icon would make that continuous. Note this is the right layer to fix it at: the splash's own `minimumSplashDuration` hold deliberately gates only the `.ready` reveal, not a gate transition like onboarding, so lengthening the hold would just delay interactive UI. (agent)
Expand Down Expand Up @@ -104,6 +104,10 @@ re-recording:

# Completed issues

- fix(WhereUI) [needs-design]: broken-snapshots: `YearView` overflows horizontally at ax5. In `year.Loaded_iPhone_ax5.png` the month title reads "nuary", the day grid is clipped on both edges, and the Calendar/Timeline pill runs off the trailing edge. The suspect is `YearModePicker`, whose segment labels take their intrinsic width via `.fixedSize()` (`YearView.swift:110`, with an in-source comment explaining it keeps labels from truncating mid-animation) inside a bottom `safeAreaInset` (`:40-43`), making it wider than the screen at ax5. Confirm the oversized inset is what widens the layout beneath it, then make the picker fit at accessibility sizes (icon-only, wrapped, or scrollable) — note the `.fixedSize()` is deliberate, so the fix has to keep animation from truncating too. (pr#101 review; re-verified 2026-08-09) Resolved by stacking the Calendar/Timeline choices at accessibility sizes while preserving their intrinsic labels and selection animation; corrected phone/iPad AX5 captures reviewed.

- fix(WhereUI) [quick-win]: broken-snapshots: the calendar day grid breaks at accessibility Dynamic Type. Every two-digit date truncates to its first digit — the 10th–31st render as "1", "2", or "3" — because the day number is clamped to a fixed square (`DayCell` at `CalendarContentView.swift:489-493`, `.frame(width: calendar.day.numberSize, height: calendar.day.numberSize)`), and the weekday header row wraps mid-word ("Sun" over two lines, "Wed" over three) because each symbol is a plain `Text` in an equal-width grid column (`:313-317`). Both show in `calendarContent.WithData_iPhone_ax5.png`; the digit truncation also hits `..._iPad_ax5.png`, where the extra width goes to inter-column gaps instead of the numbers. Showing "1" where the date is 10 is wrong content, not merely tight layout. **The references have now been re-recorded twice with the layout code unchanged — by PR #196 and again by PR #297's Xcode 27 beta 6 refresh — so they pin the same defect at a third recording.** Re-check the current image before fixing, and re-record after. (pr#101 review; re-verified 2026-09-06) Resolved with fixed compact date fonts, larger accessibility date cells, single-line weekdays, and stacked month summaries; corrected phone/iPad AX5 captures reviewed.

- fix(WhereUI) [quick-win]: Refresh the live-region welcome when the scene becomes active. Closed 2026-09-13: `MainTabs` now owns the welcome state and keys a bounded, confidence-gated lookup to foreground activity regardless of the selected tab. The source rejects stale one-shot callbacks, the resolver enforces the 1 km and boundary confidence gates, and app-shell tests and snapshots cover cancellation, repeated resolution, actionable status, and presentation over multiple tabs. (audit 2026-09-07, PR #309)

- docs(WhereCore) [quick-win]: Refresh stale doc claims. Closed 2026-09-07: the module AGENTS.md names the existing reconciliation exceptions; the README states the missing daily-summary fan-out and the failed-badge zero fallback; and the ingestor comment credits typed WhereLog events. PR #172 had already corrected the RegionViewer data-source description and RegionKit decoding-coverage claim; the earlier RootView and share-extension doc corrections remain shipped. The underlying summary, picker, badge, and GeoJSON-test items stay open. (audit 2026-07-26)
Expand Down
Loading
Loading