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 @@ -46,7 +46,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`, `shellcheck`, `codex-watchdog`, `tla-check`,
`circleci-artifacts`, `snapshot-shards`, `loc`. Each takes `--help`. Use one of these scripts instead of
hand-rolling its job. `./test` owns iOS tests; the native-macOS Ledger scheme is the exception
Expand Down Expand Up @@ -565,10 +565,11 @@ generates the project or starts a simulator.
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 @@ -174,6 +174,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 and preview 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
snapshot-shards Validate and rebalance snapshot suite assignments
Expand Down
8 changes: 8 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,12 @@ 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.
- **Serve only a validated generated artifact.** Bind to loopback unless the user selects LAN access.
- **Load a thumbnail for every visible web screen.** Reserve full-resolution captures for the inspector and raw PNG link.
- **Apply web residency limits only to offscreen preload candidates.**
- **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
132 changes: 132 additions & 0 deletions Shared/Flyover/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,138 @@ 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 and card-size thumbnails. It also writes `manifest.json`
and `manifest.js`. The exporter derives each thumbnail from its hosted native
capture. It does not render the SwiftUI content again. 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.

A thumbnail uses at most 1,024 pixels on its longest axis. A full-content
thumbnail shows the top device viewport. The inspector and raw PNG link use the
complete capture.

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.

Hosted variants have a `FlyoverExportPolicy` with a fixed viewport by default.
Snapshot-backed variants inherit their settle, readiness, and hook behavior.
Their frame matrix must resolve to one capture extent: fixed, intrinsic,
full-content, or two-axis full-content. A mixed matrix has no resolved policy.
The app must supply an explicit policy before export.

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` followed by `phone-dark`. Fixed Flyover viewports keep their size
while profile traits still apply. Tablet and landscape profiles also apply an
explicit interface idiom and size classes to adaptive content.

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. Before replacement, the command validates all
schema fields, references, image mappings, and generated PNG files. A repeated
in-repository export excludes its prior generated directory from dirty-build
metadata. A Git status error stops the export.

### Preview the export

Serve the default export on this computer:

```sh
./flyover preview
```

The command selects a free port and prints the local URL. Press Control-C to
stop the server.

Use `--lan` to open the preview to other devices on the local network:

```sh
./flyover preview --lan
./flyover preview --output /tmp/where-flyover --lan --port 8080
```

The command prints one URL for each network address that it finds. The other
device must be able to reach this computer. A macOS firewall prompt can appear.
The server pins the validated directory for its lifetime. It does not follow a
symbolic link that replaces an allowed file or directory after startup.

WARNING: LAN preview has no authentication or TLS. Any device that can reach
the computer can view the native screenshots. Stop and restart the preview
after each export.

For a static host, upload the contents of the generated directory. Put
`index.html` at the selected host root or subpath. The site needs no build step.
All site URLs are relative.

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. New image records include the optional thumbnail path and pixel size.
Older schema-1 artifacts remain readable. The web shell uses the full capture
when thumbnail metadata is absent. Full-content sizing uses SnapshotKitTesting
limits and convergence rules. A sizing error stops the export. The exporter
never substitutes a viewport image.

The website opens the first catalog group in canvas mode. A floating control
dock keeps the canvas visible. The group panel and overview map move between
groups without recalculating the graph. The canvas keeps its position when a
state, profile, or panel changes.

Canvas and list views give an active thumbnail source to every visible screen.
The list can also preload nearby thumbnails. This preload targets six active
images and 24 million thumbnail pixels. Visible screens override both targets.
The inspector removes these sources while it shows one full-resolution capture.

Point to or focus a card to emphasize its connected routes. The site dims
unrelated cards and routes until the focus moves. Filters for groups, capture
extents, and route states stay in a separate panel.

Search opens a command palette. It matches group, screen, state, and connected
route names. A result opens its screen or fits its group. List mode shows the
same selection in grouped rows. State and profile changes update the native
image without changing the selected screen.

The inspector uses the full browser window. The image stays central while a
drawer supplies capture data and route links. Full-content images use a
device-width scroll area. The Fit and 100% controls change the image scale
without changing the capture.

Atlas controls, labels, and screenshots do not support browser text selection
or image dragging. Search text, screen titles, error details, and metadata
values remain selectable.

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 `/` or `Command-K` to open search.
- Press `F` to fit the complete canvas.
- Press `0` to fit the current group.
- Press `+` or `-` to change the canvas zoom.
- Press an arrow key, `[` or `]`, to move between inspector screens.
- Press `I` to show or hide the inspector details.
- 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
191 changes: 191 additions & 0 deletions Shared/Flyover/Sources/FlyoverCaptureProfile.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
#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] {
try orderedUnique(identifiers.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, .phoneDark] : 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,
layoutTraits: layoutTraits,
device: frame,
snapshotType: snapshotType,
)
}

private var profileSize: CGSize {

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.

Is this duplicated from anywhere?

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 layoutTraits: SnapshotConfiguration.LayoutTraits {
switch self {
case .tabletLight: .tabletPortrait
case .phoneLandscape: .phoneLandscape
case .phoneLight, .phoneDark, .phoneSmall, .phoneXXXL,
.phoneAX3, .phoneContrast, .phoneRTL, .phoneBold,
.phoneVoiceOver: .phonePortrait
}
}

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
Loading
Loading