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
11 changes: 6 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ Xcode project](#generating-the-xcode-project)). On a fresh machine, run `./ide
generating. Plain `./ide` fails fast and points at bootstrap.

The executables in the repo root are the dev scripts. They are `ide`, `test`,
`swiftformat`, `sf-symbols`, `sync-agents`, `profile`, `icons`, `flaky`, `simulator`,
`swiftformat`, `sf-symbols`, `sync-agents`, `profile`, `flyover`, `icons`, `flaky`, `simulator`,
`worktree`, `xcstrings`, `attribution`, `codex-watchdog`, `tla-check`,
`circleci-artifacts`, `loc`. Each takes `--help`. Use one of these scripts instead of
hand-rolling its job. `./test` is the only way to run tests (see [Running
Expand Down Expand Up @@ -530,10 +530,11 @@ management (`./simulator` resolves a UDID — never pass a device name to
the applicable checks.
- **Multi-step work lands one commit per step**, so history stays bisectable and
can land piecewise — including pure-groundwork steps, which say so in the body.
- **Commit completed work eagerly.** Once a coherent change is verified, commit
it without waiting for a separate request. Never hand back a finished task
with task-related changes left local, unpushed, or uncommitted. Honor an
explicit request to keep work uncommitted.
- **Commit and push completed work eagerly.** Once a coherent change is
verified, commit it and push the current feature branch. Do not wait for a
separate request. Never hand back a finished task with task-related changes
left local, unpushed, or uncommitted. Honor an explicit request to keep work
local or uncommitted.

### GitHub

Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@ sync-agents Sync AGENTS.md → CLAUDE.md and .claude/skills/
simulator Resolve/create this checkout's simulator, boot it, print its UDID
worktree Check or safely fast-forward a checkout against origin/main
profile Report build/test hot spots (see `./profile --help`)
flyover Export Where's native Flyover catalog as a static QA atlas
flaky Detect flaky tests, update FLAKY_TESTS.md (see `./flaky --help`)
circleci-artifacts Download every artifact for a CircleCI job
FLAKY_TESTS.md Flaky tests and their flake counts (generated by `./flaky`)
Expand Down
5 changes: 5 additions & 0 deletions Shared/Flyover/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, format
## Scope & dependencies

- **Flyover may import SwiftUI, SFSafeSymbols, BroadwayCore/BroadwayUI, and SnapshotKit.** It must not import WhereCore, WhereUI, persistence frameworks, or any app module.
- **Keep the static exporter generic over `ScreenID`.** Accept the hosted PNG operation as a closure. Never import SnapshotKitTesting.
- **Keep the web shell under [`Web/`](Web).** Do not make it an app-bundle resource or add remote assets.
- **Apps own their typed screen IDs, demo/synthetic state, catalog construction, and the DEBUG-only entry point** that hosts ``FlyoverView``.
- **Use English literals for strings** in this developer-only shared tool. An app localizes the entry point it adds to its own UI.

Expand All @@ -26,6 +28,9 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, format
- **Keep global traits session-only.** Apply them to registered content, not Flyover chrome.
- **Register forward push/modal routes only.** Flyover derives Back/Dismiss cues from incoming routes.
- **Type erase only at the heterogeneous content/control registry boundary.**
- **Validate every stable screen and variant identifier before capture.** Use generated ordinals for image paths.
- **Preserve snapshot-backed capture intent.** Reject mixed sizing matrices unless the app supplies an explicit export policy.
- **Fail full-content export when sizing does not converge.** Never publish a viewport fallback.

## Testing

Expand Down
75 changes: 75 additions & 0 deletions Shared/Flyover/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,81 @@ Then keep their central catalog limited to grouping and assembly.
Swift macros cannot discover all conformers or navigation destinations across a module.
A generated source scan would add build ordering and cache invalidation complexity.

## Static web export

`FlyoverWebExporter` converts a DEBUG catalog into a static QA atlas. It writes
native PNG captures, `manifest.json`, and `manifest.js`. The web shell reads
`manifest.js`, so the atlas works from `file://` and any static host. The
browser changes images and navigation state. It does not run SwiftUI or
serialize `FlyoverControl` actions.

The exporter validates the complete plan before its first capture. The host
provides one stable string for each typed screen ID and one capture closure.
Stable IDs must be nonempty and unique. Variant IDs must also be nonempty and
unique within a screen. Image paths use generated ordinals, never these IDs.

Every variant has a `FlyoverExportPolicy`. Snapshot-backed variants inherit
their settle, readiness, and hook behavior. Their frame matrix reduces to one
capture extent: fixed, intrinsic, full-content, or two-axis full-content. A
mixed matrix is invalid unless the app supplies an explicit policy. Hosted
variants default to a fixed viewport.

Profiles are additive and keep request order. No profile matrix is generated.
The built-in IDs are:

- `phone-light`, `phone-dark`, `tablet-light`, and `phone-landscape`
- `phone-small`, `phone-xxxl`, and `phone-ax3`
- `phone-contrast`, `phone-rtl`, `phone-bold`, and `phone-voiceover`

The first profile is the initial web selection. An empty profile list becomes
`phone-light`. Fixed Flyover viewports keep their size while profile traits
still apply.

Run Where's exporter from the repository root:

```sh
./flyover export
./flyover export --profile phone-light --profile phone-dark
./flyover export --output /tmp/where-flyover --profile tablet-light
```

The default output is `.build/flyover/where`, resolved from the caller's
directory. The command stages the complete site and replaces only an existing
directory marked with `.flyover-generated`. A failed capture leaves the last
successful atlas unchanged.

The manifest compatibility boundary is `schemaVersion: 1`. It contains the
application and build identity, profiles, precomputed canvas geometry, groups,
screens, routes, and image metadata. It contains no local source or account
paths. Full-content sizing uses SnapshotKitTesting limits and convergence
rules. A sizing failure stops the export; it never substitutes a viewport
image.

The website opens the first catalog group in canvas mode. The group navigator
and overview map move between groups without recalculating the graph. Point to
or focus a card to emphasize its connected routes. The site dims unrelated
cards and routes until the focus moves.

Search matches group, screen, state, and connected route names. Filters narrow
the result by group, capture extent, or route state. List mode shows the same
selection as a compact catalog. State and profile changes update the native
image without changing the selected screen.

The inspector shows capture dimensions, profile data, and incoming and
outgoing routes. It also supplies previous-screen and next-screen actions.
Full-content images use a device-width scroll area. The Fit and 100% controls
change the image scale without changing the capture.

The browser hash stores the view, screen, state, and profile. Browser Back and
Forward restore these values. The site also supplies these keyboard controls:

- Press `/` to focus search.
- Press `F` to fit the complete canvas.
- Press `0` to fit the current group.
- Press `+` or `-` to change the canvas zoom.
- Press `[` or `]` to move between screens in the inspector.
- Press Escape to close the inspector.

## Testing

Run unit coverage with:
Expand Down
9 changes: 9 additions & 0 deletions Shared/Flyover/Sources/FlyoverCaptureExtent.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
#if DEBUG
/// The amount of a registered screen that a web export captures.
public enum FlyoverCaptureExtent: String, Codable, CaseIterable, Sendable {
case viewport
case intrinsic
case fullContent
case fullContent2D
}
#endif
181 changes: 181 additions & 0 deletions Shared/Flyover/Sources/FlyoverCaptureProfile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
#if DEBUG
import CoreGraphics
import SnapshotKit
import SwiftUI

/// One additive device and accessibility profile for a static export.
public enum FlyoverCaptureProfile: String, CaseIterable, Codable, Identifiable, Sendable {
case phoneLight = "phone-light"
case phoneDark = "phone-dark"
case tabletLight = "tablet-light"
case phoneLandscape = "phone-landscape"
case phoneSmall = "phone-small"
case phoneXXXL = "phone-xxxl"
case phoneAX3 = "phone-ax3"
case phoneContrast = "phone-contrast"
case phoneRTL = "phone-rtl"
case phoneBold = "phone-bold"
case phoneVoiceOver = "phone-voiceover"

public var id: String {
rawValue
}

public var title: String {
switch self {
case .phoneLight: "Phone Light"
case .phoneDark: "Phone Dark"
case .tabletLight: "Tablet Light"
case .phoneLandscape: "Phone Landscape"
case .phoneSmall: "Phone Small Text"
case .phoneXXXL: "Phone XXXL Text"
case .phoneAX3: "Phone Accessibility 3"
case .phoneContrast: "Phone Increased Contrast"
case .phoneRTL: "Phone Right to Left"
case .phoneBold: "Phone Bold Text"
case .phoneVoiceOver: "Phone VoiceOver"
}
}

public static func parse(_ identifiers: [String]) throws -> [Self] {
let requested = identifiers.isEmpty ? [phoneLight.rawValue] : identifiers
return try orderedUnique(requested.map { identifier in
guard let profile = Self(rawValue: identifier) else {
throw FlyoverExportError.unknownProfile(identifier)
}
return profile
})
}

static func orderedUnique(_ requestedProfiles: [Self]) -> [Self] {
let profiles = requestedProfiles.isEmpty ? [.phoneLight] : requestedProfiles
var seen: Set<Self> = []
return profiles.filter { seen.insert($0).inserted }
}

var deviceName: String {
self == .tabletLight ? "tablet" : "phone"
}

var orientationName: String {
self == .phoneLandscape ? "landscape" : "portrait"
}

var colorSchemeName: String {
colorScheme == .dark ? "dark" : "light"
}

var dynamicTypeName: String {
switch self {
case .phoneSmall:
"small"
case .phoneXXXL:
"xxxl"
case .phoneAX3:
"accessibility3"
case .phoneLight, .phoneDark, .tabletLight, .phoneLandscape,
.phoneContrast, .phoneRTL, .phoneBold, .phoneVoiceOver:
"large"
}
}

var contrastName: String {
contrast == .increased ? "increased" : "standard"
}

var layoutDirectionName: String {
layoutDirection == .rightToLeft ? "right-to-left" : "left-to-right"
}

var legibilityWeightName: String {
legibilityWeight == .bold ? "bold" : "regular"
}

var snapshotTypeName: String {
snapshotType == .accessibility ? "accessibility" : "standard"
}

func configuration(
viewport: FlyoverViewport,
captureExtent: FlyoverCaptureExtent,
) -> SnapshotConfiguration {
let baseSize = switch viewport {
case .device: profileSize
case let .fixed(size): size
}
let frame = switch captureExtent {
case .viewport:
SnapshotConfiguration.Frame(name: rawValue, size: .fixed(baseSize))
case .intrinsic:
SnapshotConfiguration.Frame(
name: rawValue,
size: .intrinsic(maxWidth: baseSize.width),
)
case .fullContent:
SnapshotConfiguration.Frame.fullContent(
name: rawValue,
width: baseSize.width,
minimumHeight: baseSize.height,
)
case .fullContent2D:
SnapshotConfiguration.Frame.fullContent2D(
name: rawValue,
minimumSize: baseSize,
)
}
return SnapshotConfiguration(
colorScheme: colorScheme,
dynamicType: dynamicType,
contrast: contrast,
layoutDirection: layoutDirection,
legibilityWeight: legibilityWeight,
device: frame,
snapshotType: snapshotType,
)
}

private var profileSize: CGSize {
switch self {
case .tabletLight:
CGSize(width: 834, height: 1194)
case .phoneLandscape:
CGSize(width: 874, height: 402)
case .phoneLight, .phoneDark, .phoneSmall, .phoneXXXL,
.phoneAX3, .phoneContrast, .phoneRTL, .phoneBold,
.phoneVoiceOver:
CGSize(width: 402, height: 874)
}
}

private var colorScheme: ColorScheme {
self == .phoneDark ? .dark : .light
}

private var dynamicType: DynamicTypeSize {
switch self {
case .phoneSmall: .small
case .phoneXXXL: .xxxLarge
case .phoneAX3: .accessibility3
case .phoneLight, .phoneDark, .tabletLight, .phoneLandscape,
.phoneContrast, .phoneRTL, .phoneBold, .phoneVoiceOver:
.large
}
}

private var contrast: ColorSchemeContrast {
self == .phoneContrast ? .increased : .standard
}

private var layoutDirection: LayoutDirection {
self == .phoneRTL ? .rightToLeft : .leftToRight
}

private var legibilityWeight: LegibilityWeight {
self == .phoneBold ? .bold : .regular
}

private var snapshotType: SnapshotConfiguration.SnapshotType {
self == .phoneVoiceOver ? .accessibility : .standard
}
}
#endif
51 changes: 51 additions & 0 deletions Shared/Flyover/Sources/FlyoverCaptureRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
#if DEBUG
import SnapshotKit
import SwiftUI

/// One fully resolved image request in a web export plan.
@MainActor
public struct FlyoverCaptureRequest {
public let groupTitle: String
public let screenID: String
public let screenTitle: String
public let variantID: String
public let variantTitle: String
public let profile: FlyoverCaptureProfile
public let configuration: SnapshotConfiguration
public let captureExtent: FlyoverCaptureExtent
public let measurementReadiness: SnapshotMeasurementReadiness
public let settle: SnapshotSettle
public let onReadyToMeasure: (@MainActor () async -> Void)?
public let onReadyToSnapshot: (@MainActor () async -> Void)?
public let captureName: String
public let content: AnyView

init(
groupTitle: String,
screenID: String,
screenTitle: String,
variantID: String,
variantTitle: String,
profile: FlyoverCaptureProfile,
configuration: SnapshotConfiguration,
policy: FlyoverExportPolicy,
captureName: String,
content: AnyView,
) {
self.groupTitle = groupTitle
self.screenID = screenID
self.screenTitle = screenTitle
self.variantID = variantID
self.variantTitle = variantTitle
self.profile = profile
self.configuration = configuration
captureExtent = policy.captureExtent
measurementReadiness = policy.measurementReadiness
settle = policy.settle
onReadyToMeasure = policy.onReadyToMeasure
onReadyToSnapshot = policy.onReadyToSnapshot
self.captureName = captureName
self.content = content
}
}
#endif
Loading
Loading