diff --git a/AGENTS.md b/AGENTS.md index b448cee1..79209b6a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 @@ -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 diff --git a/README.md b/README.md index 9a3c8121..ee64f9f0 100644 --- a/README.md +++ b/README.md @@ -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`) diff --git a/Shared/Flyover/AGENTS.md b/Shared/Flyover/AGENTS.md index b46d4dc0..772b0ced 100644 --- a/Shared/Flyover/AGENTS.md +++ b/Shared/Flyover/AGENTS.md @@ -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. @@ -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 diff --git a/Shared/Flyover/README.md b/Shared/Flyover/README.md index 47420a87..c47ef30e 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -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: diff --git a/Shared/Flyover/Sources/FlyoverCaptureExtent.swift b/Shared/Flyover/Sources/FlyoverCaptureExtent.swift new file mode 100644 index 00000000..c40eb968 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverCaptureExtent.swift @@ -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 diff --git a/Shared/Flyover/Sources/FlyoverCaptureProfile.swift b/Shared/Flyover/Sources/FlyoverCaptureProfile.swift new file mode 100644 index 00000000..40ac8c80 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverCaptureProfile.swift @@ -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 = [] + 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 diff --git a/Shared/Flyover/Sources/FlyoverCaptureRequest.swift b/Shared/Flyover/Sources/FlyoverCaptureRequest.swift new file mode 100644 index 00000000..ff32c131 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverCaptureRequest.swift @@ -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 diff --git a/Shared/Flyover/Sources/FlyoverCapturedImage.swift b/Shared/Flyover/Sources/FlyoverCapturedImage.swift new file mode 100644 index 00000000..c2e6e958 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverCapturedImage.swift @@ -0,0 +1,24 @@ +#if DEBUG + import CoreGraphics + import Foundation + + /// PNG bytes and dimensions returned by a hosted capture closure. + public struct FlyoverCapturedImage: Sendable { + public let pngData: Data + public let pointSize: CGSize + public let pixelSize: CGSize + public let scale: CGFloat + + public init( + pngData: Data, + pointSize: CGSize, + pixelSize: CGSize, + scale: CGFloat, + ) { + self.pngData = pngData + self.pointSize = pointSize + self.pixelSize = pixelSize + self.scale = scale + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverExportBuild.swift b/Shared/Flyover/Sources/FlyoverExportBuild.swift new file mode 100644 index 00000000..b10aca5f --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverExportBuild.swift @@ -0,0 +1,30 @@ +#if DEBUG + /// Source and simulator metadata recorded with an export. + public struct FlyoverExportBuild: Codable, Equatable, Sendable { + public let commit: String + public let dirty: Bool + public let branch: String? + public let generatedAt: String + public let xcodeVersion: String + public let simulatorDevice: String + public let simulatorOS: String + + public init( + commit: String, + dirty: Bool, + branch: String?, + generatedAt: String, + xcodeVersion: String, + simulatorDevice: String, + simulatorOS: String, + ) { + self.commit = commit + self.dirty = dirty + self.branch = branch + self.generatedAt = generatedAt + self.xcodeVersion = xcodeVersion + self.simulatorDevice = simulatorDevice + self.simulatorOS = simulatorOS + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverExportContent.swift b/Shared/Flyover/Sources/FlyoverExportContent.swift new file mode 100644 index 00000000..254e65a1 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverExportContent.swift @@ -0,0 +1,23 @@ +#if DEBUG + import SwiftUI + + /// The native registered content that the web exporter sends to the host. + struct FlyoverExportContent: View { + let navigationContainer: FlyoverNavigationContainer + let content: AnyView + + var body: some View { + Group { + switch navigationContainer { + case .stack: + NavigationStack { + content + } + case .none: + content + } + } + .allowsHitTesting(false) + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverExportError.swift b/Shared/Flyover/Sources/FlyoverExportError.swift new file mode 100644 index 00000000..9c45800d --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverExportError.swift @@ -0,0 +1,60 @@ +#if DEBUG + import Foundation + + /// A structural, capture, or artifact error from a static export. + public enum FlyoverExportError: Error, Equatable, Sendable { + case invalidCatalog(issueCount: Int) + case emptyApplicationIdentifier + case emptyScreenIdentifier(screenTitle: String) + case duplicateScreenIdentifier(String) + case emptyVariantIdentifier(screen: String) + case duplicateVariantIdentifier(screen: String, variant: String) + case mixedSizingPolicy(screen: String, variant: String, extents: [String]) + case missingManifestGeometry(kind: String, identifier: String) + case unknownProfile(String) + case captureFailed( + group: String, + screen: String, + variant: String, + profile: String, + phase: String, + reason: String, + ) + case emptyPNG(screen: String, variant: String, profile: String) + case assetCountMismatch(expected: Int, actual: Int) + case outputWriteFailed(path: String, reason: String) + } + + extension FlyoverExportError: LocalizedError { + public var errorDescription: String? { + switch self { + case let .invalidCatalog(issueCount): + "The Flyover catalog has \(issueCount) validation errors." + case .emptyApplicationIdentifier: + "The export application identifier is empty." + case let .emptyScreenIdentifier(screenTitle): + "The export identifier for \(screenTitle) is empty." + case let .duplicateScreenIdentifier(identifier): + "The screen export identifier \(identifier) is not unique." + case let .emptyVariantIdentifier(screen): + "A variant export identifier is empty in \(screen)." + case let .duplicateVariantIdentifier(screen, variant): + "The variant export identifier \(variant) is not unique in \(screen)." + case let .mixedSizingPolicy(screen, variant, extents): + "The export policy for \(screen) / \(variant) mixes sizing classes: \(extents.joined(separator: ", "))." + case let .missingManifestGeometry(kind, identifier): + "The Flyover layout has no \(kind) geometry for \(identifier)." + case let .unknownProfile(identifier): + "The Flyover capture profile \(identifier) is unknown." + case let .captureFailed(group, screen, variant, profile, phase, reason): + "The Flyover export failed during \(phase) for \(group) / \(screen) / \(variant) / \(profile): \(reason)" + case let .emptyPNG(screen, variant, profile): + "The capture for \(screen) / \(variant) / \(profile) returned an empty PNG." + case let .assetCountMismatch(expected, actual): + "The Flyover artifact contains \(actual) images. The manifest requires \(expected)." + case let .outputWriteFailed(path, reason): + "The Flyover exporter could not write \(path): \(reason)" + } + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverExportPolicy.swift b/Shared/Flyover/Sources/FlyoverExportPolicy.swift new file mode 100644 index 00000000..1d0e8e8a --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverExportPolicy.swift @@ -0,0 +1,63 @@ +#if DEBUG + import SnapshotKit + + /// The sizing and readiness behavior for one exported Flyover variant. + @MainActor + public struct FlyoverExportPolicy { + 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 init( + captureExtent: FlyoverCaptureExtent, + measurementReadiness: SnapshotMeasurementReadiness, + settle: SnapshotSettle, + onReadyToMeasure: (@MainActor () async -> Void)?, + onReadyToSnapshot: (@MainActor () async -> Void)?, + ) { + self.captureExtent = captureExtent + self.measurementReadiness = measurementReadiness + self.settle = settle + self.onReadyToMeasure = onReadyToMeasure + self.onReadyToSnapshot = onReadyToSnapshot + } + + public static var hosted: Self { + FlyoverExportPolicy( + captureExtent: .viewport, + measurementReadiness: .sameAsCapture, + settle: .settled, + onReadyToMeasure: nil, + onReadyToSnapshot: nil, + ) + } + + static func resolution(for snapshotCase: SnapshotCase) -> FlyoverExportPolicyResolution { + let extents = Set(snapshotCase.configurations.map { configuration in + switch configuration.device.size { + case .fixed: FlyoverCaptureExtent.viewport + case .intrinsic: FlyoverCaptureExtent.intrinsic + case .fullContent: FlyoverCaptureExtent.fullContent + case .fullContent2D: FlyoverCaptureExtent.fullContent2D + } + }) + guard extents.count <= 1 else { + return .mixed(extents.sorted { $0.rawValue < $1.rawValue }) + } + return .policy(FlyoverExportPolicy( + captureExtent: extents.first ?? .viewport, + measurementReadiness: snapshotCase.measurementReadiness, + settle: snapshotCase.settle, + onReadyToMeasure: snapshotCase.onReadyToMeasure, + onReadyToSnapshot: snapshotCase.onReadyToSnapshot, + )) + } + } + + enum FlyoverExportPolicyResolution { + case policy(FlyoverExportPolicy) + case mixed([FlyoverCaptureExtent]) + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverExportSummary.swift b/Shared/Flyover/Sources/FlyoverExportSummary.swift new file mode 100644 index 00000000..2eb46961 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverExportSummary.swift @@ -0,0 +1,29 @@ +#if DEBUG + import Foundation + + /// Counts and output details from a completed static export. + public struct FlyoverExportSummary: Equatable, Sendable { + public let screenCount: Int + public let stateCount: Int + public let profileCount: Int + public let imageCount: Int + public let outputByteCount: Int + public let outputDirectory: URL + + public init( + screenCount: Int, + stateCount: Int, + profileCount: Int, + imageCount: Int, + outputByteCount: Int, + outputDirectory: URL, + ) { + self.screenCount = screenCount + self.stateCount = stateCount + self.profileCount = profileCount + self.imageCount = imageCount + self.outputByteCount = outputByteCount + self.outputDirectory = outputDirectory + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverVariant.swift b/Shared/Flyover/Sources/FlyoverVariant.swift index 1d8744e4..ff4ffe81 100644 --- a/Shared/Flyover/Sources/FlyoverVariant.swift +++ b/Shared/Flyover/Sources/FlyoverVariant.swift @@ -8,6 +8,10 @@ public struct FlyoverVariant { public let title: String let overviewContent: @MainActor () -> AnyView let focusedContent: @MainActor () -> AnyView + #if DEBUG + public let exportPolicy: FlyoverExportPolicy + let exportPolicyResolution: FlyoverExportPolicyResolution + #endif public init( id: FlyoverVariantID, @@ -18,6 +22,10 @@ public struct FlyoverVariant { self.title = title overviewContent = { AnyView(content()) } focusedContent = { AnyView(content()) } + #if DEBUG + exportPolicy = .hosted + exportPolicyResolution = .policy(.hosted) + #endif } public init( @@ -30,6 +38,10 @@ public struct FlyoverVariant { self.title = title overviewContent = { AnyView(overview()) } focusedContent = { AnyView(focused()) } + #if DEBUG + exportPolicy = .hosted + exportPolicyResolution = .policy(.hosted) + #endif } /// Adapts existing snapshot content into a Flyover variant. @@ -38,5 +50,41 @@ public struct FlyoverVariant { title = snapshotCase.name overviewContent = { snapshotCase.content } focusedContent = { snapshotCase.content } + #if DEBUG + exportPolicyResolution = FlyoverExportPolicy.resolution(for: snapshotCase) + exportPolicy = switch exportPolicyResolution { + case let .policy(policy): policy + case .mixed: .hosted + } + #endif } + + #if DEBUG + public init( + id: FlyoverVariantID, + title: String, + exportPolicy: FlyoverExportPolicy, + @ViewBuilder content: @escaping @MainActor () -> some View, + ) { + self.id = id + self.title = title + overviewContent = { AnyView(content()) } + focusedContent = { AnyView(content()) } + self.exportPolicy = exportPolicy + exportPolicyResolution = .policy(exportPolicy) + } + + public init( + id: FlyoverVariantID, + snapshotCase: SnapshotCase, + exportPolicy: FlyoverExportPolicy, + ) { + self.id = id + title = snapshotCase.name + overviewContent = { snapshotCase.content } + focusedContent = { snapshotCase.content } + self.exportPolicy = exportPolicy + exportPolicyResolution = .policy(exportPolicy) + } + #endif } diff --git a/Shared/Flyover/Sources/FlyoverWebExporter.swift b/Shared/Flyover/Sources/FlyoverWebExporter.swift new file mode 100644 index 00000000..30e37973 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverWebExporter.swift @@ -0,0 +1,582 @@ +#if DEBUG + import Foundation + import SwiftUI + + /// Converts a typed Flyover catalog into a static manifest and PNG set. + @MainActor + public struct FlyoverWebExporter { + private let catalog: FlyoverCatalog + private let applicationID: String + private let title: String + private let screenIdentifier: (ScreenID) -> String + + public init( + catalog: FlyoverCatalog, + applicationID: String, + title: String, + screenIdentifier: @escaping (ScreenID) -> String, + ) { + self.catalog = catalog + self.applicationID = applicationID + self.title = title + self.screenIdentifier = screenIdentifier + } + + public func export( + to directory: URL, + profiles requestedProfiles: [FlyoverCaptureProfile], + build: FlyoverExportBuild, + capture: @escaping (FlyoverCaptureRequest) async throws -> FlyoverCapturedImage, + ) async throws -> FlyoverExportSummary { + let profiles = FlyoverCaptureProfile.orderedUnique(requestedProfiles) + let preparedScreens = try prepareScreens() + try validatePolicies(in: preparedScreens) + let layout = FlyoverLayout(catalog: catalog, style: FlyoverStylesheet.default.layout) + .resolve() + let preparedRoutes = try prepareRoutes(screens: preparedScreens, layout: layout) + + let fileManager = FileManager.default + let imagesDirectory = directory.appending(path: "images", directoryHint: .isDirectory) + do { + try fileManager.createDirectory( + at: imagesDirectory, + withIntermediateDirectories: true, + ) + } catch { + throw FlyoverExportError.outputWriteFailed( + path: imagesDirectory.path, + reason: error.localizedDescription, + ) + } + + var images: [FlyoverWebManifest.Image] = [] + var pathsByVariant: [VariantKey: [String: String]] = [:] + let captureCount = preparedScreens.reduce(0) { count, prepared in + count + prepared.screen.variants.count * profiles.count + } + var captureIndex = 0 + + for prepared in preparedScreens { + for (variantIndex, variant) in prepared.screen.variants.enumerated() { + let policy = try resolvedPolicy( + variant, + screen: prepared.stableID, + ) + for profile in profiles { + try Task.checkCancellation() + captureIndex += 1 + prepared.screen.resetAction() + let configuration = profile.configuration( + viewport: prepared.screen.viewport, + captureExtent: policy.captureExtent, + ) + let relativePath = String( + format: "images/screen-%04d/variant-%04d/%@.png", + prepared.screenOrdinal, + variantIndex + 1, + profile.rawValue, + ) + let captureName = "\(prepared.stableID).\(variant.id.rawValue).\(profile.rawValue)" + let request = FlyoverCaptureRequest( + groupTitle: prepared.groupTitle, + screenID: prepared.stableID, + screenTitle: prepared.screen.title, + variantID: variant.id.rawValue, + variantTitle: variant.title, + profile: profile, + configuration: configuration, + policy: policy, + captureName: captureName, + content: AnyView(FlyoverExportContent( + navigationContainer: prepared.screen.navigationContainer, + content: variant.overviewContent(), + )), + ) + print( + "FLYOVER_EXPORT \(captureIndex)/\(captureCount) " + + + "\(prepared.screen.title) / \(variant.title) / \(profile.rawValue)", + ) + + let captured: FlyoverCapturedImage + do { + captured = try await capture(request) + } catch is CancellationError { + throw CancellationError() + } catch { + throw FlyoverExportError.captureFailed( + group: prepared.groupTitle, + screen: prepared.screen.title, + variant: variant.title, + profile: profile.rawValue, + phase: "capture", + reason: error.localizedDescription, + ) + } + guard captured.pngData.isEmpty == false else { + throw FlyoverExportError.emptyPNG( + screen: prepared.screen.title, + variant: variant.title, + profile: profile.rawValue, + ) + } + + let imageURL = directory.appending(path: relativePath) + do { + try fileManager.createDirectory( + at: imageURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + ) + try captured.pngData.write(to: imageURL, options: .atomic) + } catch { + throw FlyoverExportError.outputWriteFailed( + path: imageURL.path, + reason: error.localizedDescription, + ) + } + + let key = VariantKey( + screenID: prepared.stableID, + variantID: variant.id.rawValue, + ) + pathsByVariant[key, default: [:]][profile.rawValue] = relativePath + images.append(FlyoverWebManifest.Image( + screenID: prepared.stableID, + variantID: variant.id.rawValue, + profileID: profile.rawValue, + relativePath: relativePath, + pointWidth: Double(captured.pointSize.width), + pointHeight: Double(captured.pointSize.height), + pixelWidth: Int(captured.pixelSize.width.rounded()), + pixelHeight: Int(captured.pixelSize.height.rounded()), + scale: Double(captured.scale), + captureExtent: policy.captureExtent.rawValue, + )) + } + } + } + + let manifest = try makeManifest( + build: build, + profiles: profiles, + screens: preparedScreens, + routes: preparedRoutes, + layout: layout, + pathsByVariant: pathsByVariant, + images: images, + ) + try write(manifest: manifest, to: directory) + let actualImageCount = try pngCount(in: imagesDirectory) + guard actualImageCount == images.count else { + throw FlyoverExportError.assetCountMismatch( + expected: images.count, + actual: actualImageCount, + ) + } + + return try FlyoverExportSummary( + screenCount: preparedScreens.count, + stateCount: preparedScreens.reduce(0) { $0 + $1.screen.variants.count }, + profileCount: profiles.count, + imageCount: images.count, + outputByteCount: directoryByteCount(directory), + outputDirectory: directory, + ) + } + + private func prepareScreens() throws -> [PreparedScreen] { + guard catalog.isValid else { + throw FlyoverExportError.invalidCatalog(issueCount: catalog.validationIssues.count) + } + guard applicationID.isEmpty == false else { + throw FlyoverExportError.emptyApplicationIdentifier + } + + var seenScreenIDs: Set = [] + var prepared: [PreparedScreen] = [] + var screenOrdinal = 0 + for (groupIndex, group) in catalog.groups.enumerated() { + for (screenIndex, screen) in group.screens.enumerated() { + screenOrdinal += 1 + let stableID = screenIdentifier(screen.id) + guard stableID.isEmpty == false else { + throw FlyoverExportError.emptyScreenIdentifier( + screenTitle: screen.title, + ) + } + guard seenScreenIDs.insert(stableID).inserted else { + throw FlyoverExportError.duplicateScreenIdentifier(stableID) + } + var seenVariants: Set = [] + for variant in screen.variants { + guard variant.id.rawValue.isEmpty == false else { + throw FlyoverExportError.emptyVariantIdentifier(screen: stableID) + } + guard seenVariants.insert(variant.id.rawValue).inserted else { + throw FlyoverExportError.duplicateVariantIdentifier( + screen: stableID, + variant: variant.id.rawValue, + ) + } + } + prepared.append(PreparedScreen( + groupID: group.id.rawValue, + groupTitle: group.title, + groupOrder: groupIndex, + screenOrder: screenIndex, + screenOrdinal: screenOrdinal, + stableID: stableID, + screen: screen, + )) + } + } + return prepared + } + + private func validatePolicies(in screens: [PreparedScreen]) throws { + for prepared in screens { + for variant in prepared.screen.variants { + _ = try resolvedPolicy(variant, screen: prepared.stableID) + } + } + } + + private func resolvedPolicy( + _ variant: FlyoverVariant, + screen: String, + ) throws -> FlyoverExportPolicy { + switch variant.exportPolicyResolution { + case let .policy(policy): + return policy + case let .mixed(extents): + throw FlyoverExportError.mixedSizingPolicy( + screen: screen, + variant: variant.id.rawValue, + extents: extents.map(\.rawValue), + ) + } + } + + private func prepareRoutes( + screens: [PreparedScreen], + layout: FlyoverLayoutResult, + ) throws -> [PreparedRoute] { + let stableIDs = stableIDMap(for: screens) + return try catalog.transitions.enumerated().map { index, transition in + guard let sourceID = stableIDs[transition.source], + let destinationID = stableIDs[transition.destination], + let sourceFrame = layout.screenFrames[transition.source], + let destinationFrame = layout.screenFrames[transition.destination] + else { + throw FlyoverExportError.missingManifestGeometry( + kind: "route", + identifier: String(format: "route-%04d", index + 1), + ) + } + let geometry = FlyoverConnectorGeometry( + source: sourceFrame, + destination: destinationFrame, + style: FlyoverStylesheet.default.connector, + ) + return PreparedRoute( + id: String(format: "route-%04d", index + 1), + sourceID: sourceID, + destinationID: destinationID, + kind: transition.kind.rawValue, + label: transition.label, + geometry: geometry, + ) + } + } + + private func makeManifest( + build: FlyoverExportBuild, + profiles: [FlyoverCaptureProfile], + screens: [PreparedScreen], + routes: [PreparedRoute], + layout: FlyoverLayoutResult, + pathsByVariant: [VariantKey: [String: String]], + images: [FlyoverWebManifest.Image], + ) throws -> FlyoverWebManifest { + let stableIDs = stableIDMap(for: screens) + let groups = try catalog.groups.enumerated().map { index, group in + guard let rootScreenID = stableIDs[group.root] else { + throw FlyoverExportError.missingManifestGeometry( + kind: "group root", + identifier: group.id.rawValue, + ) + } + let screenIDs = try group.screens.map { screen in + guard let stableID = stableIDs[screen.id] else { + throw FlyoverExportError.missingManifestGeometry( + kind: "screen identity", + identifier: screen.title, + ) + } + return stableID + } + return FlyoverWebManifest.Group( + id: group.id.rawValue, + title: group.title, + order: index, + rootScreenID: rootScreenID, + screenIDs: screenIDs, + ) + } + let manifestRoutes = routes.map { route in + FlyoverWebManifest.Route( + id: route.id, + sourceScreenID: route.sourceID, + destinationScreenID: route.destinationID, + kind: route.kind, + label: route.label, + geometry: route.geometry.manifestValue, + ) + } + let manifestScreens = try screens.map { prepared in + guard let screenFrame = layout.screenFrames[prepared.screen.id] else { + throw FlyoverExportError.missingManifestGeometry( + kind: "screen frame", + identifier: prepared.stableID, + ) + } + let variants = try prepared.screen.variants.map { variant in + let policy = try resolvedPolicy(variant, screen: prepared.stableID) + let key = VariantKey( + screenID: prepared.stableID, + variantID: variant.id.rawValue, + ) + return FlyoverWebManifest.Variant( + id: variant.id.rawValue, + title: variant.title, + captureExtent: policy.captureExtent.rawValue, + imagesByProfile: pathsByVariant[key] ?? [:], + ) + } + return FlyoverWebManifest.Screen( + id: prepared.stableID, + title: prepared.screen.title, + groupID: prepared.groupID, + groupOrder: prepared.groupOrder, + screenOrder: prepared.screenOrder, + viewport: prepared.screen.viewport.manifestValue, + navigationContainer: prepared.screen.navigationContainer.manifestValue, + frame: FlyoverWebManifest.Rect(screenFrame), + variants: variants, + incomingRouteIDs: routes + .filter { $0.destinationID == prepared.stableID } + .map(\.id), + outgoingRouteIDs: routes + .filter { $0.sourceID == prepared.stableID } + .map(\.id), + ) + } + let groupFrames = try catalog.groups + .map { group -> FlyoverWebManifest.IdentifiedFrame in + guard let frame = layout.groupFrames[group.id] else { + throw FlyoverExportError.missingManifestGeometry( + kind: "group frame", + identifier: group.id.rawValue, + ) + } + return FlyoverWebManifest.IdentifiedFrame( + id: group.id.rawValue, + frame: FlyoverWebManifest.Rect(frame), + ) + } + let screenFrames = try screens + .map { prepared -> FlyoverWebManifest.IdentifiedFrame in + guard let frame = layout.screenFrames[prepared.screen.id] else { + throw FlyoverExportError.missingManifestGeometry( + kind: "screen frame", + identifier: prepared.stableID, + ) + } + return FlyoverWebManifest.IdentifiedFrame( + id: prepared.stableID, + frame: FlyoverWebManifest.Rect(frame), + ) + } + let depthBands = layout.depthBands.map { band in + let kind: String + let depth: Int? + switch band.kind { + case let .route(value): + kind = "route" + depth = value + case .unlinked: + kind = "unlinked" + depth = nil + } + return FlyoverWebManifest.DepthBandFrame( + groupID: band.id.group.rawValue, + kind: kind, + depth: depth, + frame: FlyoverWebManifest.Rect(band.frame), + ) + } + return FlyoverWebManifest( + schemaVersion: 1, + application: FlyoverWebManifest.Application(id: applicationID, title: title), + build: build, + profiles: profiles.map(\.manifestValue), + canvas: FlyoverWebManifest.Canvas( + size: FlyoverWebManifest.Size(layout.canvasSize), + initialFitSize: FlyoverWebManifest.Size(layout.initialCanvasSize), + groupFrames: groupFrames, + depthBandFrames: depthBands, + screenFrames: screenFrames, + connectors: routes.map { + FlyoverWebManifest.Connector( + routeID: $0.id, + geometry: $0.geometry.manifestValue, + ) + }, + ), + groups: groups, + screens: manifestScreens, + routes: manifestRoutes, + images: images, + ) + } + + private func stableIDMap(for screens: [PreparedScreen]) -> [ScreenID: String] { + screens.reduce(into: [:]) { result, prepared in + result[prepared.screen.id] = prepared.stableID + } + } + + private func write(manifest: FlyoverWebManifest, to directory: URL) throws { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + let data: Data + do { + data = try encoder.encode(manifest) + } catch { + throw FlyoverExportError.outputWriteFailed( + path: directory.appending(path: "manifest.json").path, + reason: error.localizedDescription, + ) + } + do { + try data.write( + to: directory.appending(path: "manifest.json"), + options: .atomic, + ) + var script = Data("window.FLYOVER_MANIFEST = ".utf8) + script.append(data) + script.append(Data(";\n".utf8)) + try script.write( + to: directory.appending(path: "manifest.js"), + options: .atomic, + ) + } catch { + throw FlyoverExportError.outputWriteFailed( + path: directory.path, + reason: error.localizedDescription, + ) + } + } + + private func pngCount(in directory: URL) throws -> Int { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: nil, + ) else { + return 0 + } + return enumerator.compactMap { $0 as? URL } + .count(where: { $0.pathExtension == "png" }) + } + + private func directoryByteCount(_ directory: URL) throws -> Int { + guard let enumerator = FileManager.default.enumerator( + at: directory, + includingPropertiesForKeys: [.fileSizeKey], + ) else { + return 0 + } + return try enumerator.compactMap { value -> Int? in + guard let url = value as? URL else { return nil } + return try url.resourceValues(forKeys: [.fileSizeKey]).fileSize + }.reduce(0, +) + } + + private struct PreparedScreen { + let groupID: String + let groupTitle: String + let groupOrder: Int + let screenOrder: Int + let screenOrdinal: Int + let stableID: String + let screen: FlyoverScreen + } + + private struct PreparedRoute { + let id: String + let sourceID: String + let destinationID: String + let kind: String + let label: String? + let geometry: FlyoverConnectorGeometry + } + + private struct VariantKey: Hashable { + let screenID: String + let variantID: String + } + } + + extension FlyoverCaptureProfile { + fileprivate var manifestValue: FlyoverWebManifest.Profile { + FlyoverWebManifest.Profile( + id: rawValue, + title: title, + device: deviceName, + orientation: orientationName, + colorScheme: colorSchemeName, + dynamicType: dynamicTypeName, + contrast: contrastName, + layoutDirection: layoutDirectionName, + legibilityWeight: legibilityWeightName, + snapshotType: snapshotTypeName, + ) + } + } + + extension FlyoverViewport { + fileprivate var manifestValue: FlyoverWebManifest.Viewport { + switch self { + case .device: + FlyoverWebManifest.Viewport(kind: "device", fixedSize: nil) + case let .fixed(size): + FlyoverWebManifest.Viewport( + kind: "fixed", + fixedSize: FlyoverWebManifest.Size(size), + ) + } + } + } + + extension FlyoverNavigationContainer { + fileprivate var manifestValue: String { + switch self { + case .stack: "stack" + case .none: "none" + } + } + } + + extension FlyoverConnectorGeometry { + fileprivate var manifestValue: FlyoverWebManifest.ConnectorGeometry { + FlyoverWebManifest.ConnectorGeometry( + start: FlyoverWebManifest.Point(start), + end: FlyoverWebManifest.Point(end), + firstControl: FlyoverWebManifest.Point(firstControl), + secondControl: FlyoverWebManifest.Point(secondControl), + firstArrowPoint: FlyoverWebManifest.Point(firstArrowPoint), + secondArrowPoint: FlyoverWebManifest.Point(secondArrowPoint), + ) + } + } +#endif diff --git a/Shared/Flyover/Sources/FlyoverWebManifest.swift b/Shared/Flyover/Sources/FlyoverWebManifest.swift new file mode 100644 index 00000000..da0cf4ec --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverWebManifest.swift @@ -0,0 +1,160 @@ +#if DEBUG + import CoreGraphics + import Foundation + + /// The versioned data model consumed by a generated Flyover website. + public struct FlyoverWebManifest: Codable, Sendable { + public let schemaVersion: Int + public let application: Application + public let build: FlyoverExportBuild + public let profiles: [Profile] + public let canvas: Canvas + public let groups: [Group] + public let screens: [Screen] + public let routes: [Route] + public let images: [Image] + + public struct Application: Codable, Sendable { + public let id: String + public let title: String + } + + public struct Profile: Codable, Sendable { + public let id: String + public let title: String + public let device: String + public let orientation: String + public let colorScheme: String + public let dynamicType: String + public let contrast: String + public let layoutDirection: String + public let legibilityWeight: String + public let snapshotType: String + } + + public struct Canvas: Codable, Sendable { + public let size: Size + public let initialFitSize: Size + public let groupFrames: [IdentifiedFrame] + public let depthBandFrames: [DepthBandFrame] + public let screenFrames: [IdentifiedFrame] + public let connectors: [Connector] + } + + public struct Group: Codable, Sendable { + public let id: String + public let title: String + public let order: Int + public let rootScreenID: String + public let screenIDs: [String] + } + + public struct Screen: Codable, Sendable { + public let id: String + public let title: String + public let groupID: String + public let groupOrder: Int + public let screenOrder: Int + public let viewport: Viewport + public let navigationContainer: String + public let frame: Rect + public let variants: [Variant] + public let incomingRouteIDs: [String] + public let outgoingRouteIDs: [String] + } + + public struct Viewport: Codable, Sendable { + public let kind: String + public let fixedSize: Size? + } + + public struct Variant: Codable, Sendable { + public let id: String + public let title: String + public let captureExtent: String + public let imagesByProfile: [String: String] + } + + public struct Route: Codable, Sendable { + public let id: String + public let sourceScreenID: String + public let destinationScreenID: String + public let kind: String + public let label: String? + public let geometry: ConnectorGeometry + } + + public struct Image: Codable, Sendable { + public let screenID: String + public let variantID: String + public let profileID: String + public let relativePath: String + public let pointWidth: Double + public let pointHeight: Double + public let pixelWidth: Int + public let pixelHeight: Int + public let scale: Double + public let captureExtent: String + } + + public struct IdentifiedFrame: Codable, Sendable { + public let id: String + public let frame: Rect + } + + public struct DepthBandFrame: Codable, Sendable { + public let groupID: String + public let kind: String + public let depth: Int? + public let frame: Rect + } + + public struct Connector: Codable, Sendable { + public let routeID: String + public let geometry: ConnectorGeometry + } + + public struct ConnectorGeometry: Codable, Sendable { + public let start: Point + public let end: Point + public let firstControl: Point + public let secondControl: Point + public let firstArrowPoint: Point + public let secondArrowPoint: Point + } + + public struct Rect: Codable, Equatable, Sendable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + + init(_ value: CGRect) { + x = Double(value.origin.x) + y = Double(value.origin.y) + width = Double(value.size.width) + height = Double(value.size.height) + } + } + + public struct Point: Codable, Equatable, Sendable { + public let x: Double + public let y: Double + + init(_ value: CGPoint) { + x = Double(value.x) + y = Double(value.y) + } + } + + public struct Size: Codable, Equatable, Sendable { + public let width: Double + public let height: Double + + init(_ value: CGSize) { + width = Double(value.width) + height = Double(value.height) + } + } + } +#endif diff --git a/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift b/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift new file mode 100644 index 00000000..ab15a0ff --- /dev/null +++ b/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift @@ -0,0 +1,26 @@ +#if DEBUG + @testable import Flyover + import Testing + + struct FlyoverCaptureProfileTests { + @Test func emptyRequestUsesPhoneLight() throws { + #expect(try FlyoverCaptureProfile.parse([]) == [.phoneLight]) + } + + @Test func preservesFirstOccurrenceOrderAndRemovesDuplicates() throws { + let profiles = try FlyoverCaptureProfile.parse([ + "phone-dark", + "phone-light", + "phone-dark", + ]) + + #expect(profiles == [.phoneDark, .phoneLight]) + } + + @Test func rejectsUnknownProfile() { + #expect(throws: FlyoverExportError.unknownProfile("system")) { + try FlyoverCaptureProfile.parse(["system"]) + } + } + } +#endif diff --git a/Shared/Flyover/Tests/FlyoverExportPolicyTests.swift b/Shared/Flyover/Tests/FlyoverExportPolicyTests.swift new file mode 100644 index 00000000..8db72f35 --- /dev/null +++ b/Shared/Flyover/Tests/FlyoverExportPolicyTests.swift @@ -0,0 +1,77 @@ +#if DEBUG + @testable import Flyover + import SnapshotKit + import SwiftUI + import Testing + + @MainActor + struct FlyoverExportPolicyTests { + @Test func emptySnapshotMatrixUsesViewportAndPreservesReadiness() { + let snapshotCase = SnapshotCase( + name: "Empty", + configurations: [], + measurementReadiness: .immediate, + settle: .immediate, + ) { + EmptyView() + } + + let resolution = FlyoverExportPolicy.resolution(for: snapshotCase) + guard case let .policy(policy) = resolution else { + Issue.record("Expected a resolved export policy.") + return + } + #expect(policy.captureExtent == .viewport) + #expect(policy.measurementReadiness == .immediate) + #expect(policy.settle == .immediate) + } + + @Test( + arguments: [ + SizingExpectation(frame: .iPhone, extent: .viewport), + SizingExpectation(frame: .component, extent: .intrinsic), + SizingExpectation(frame: .iPhoneFullContent, extent: .fullContent), + SizingExpectation(frame: .iPhoneFullContent2D, extent: .fullContent2D), + ], + ) + func reducesOneSizingClass(expectation: SizingExpectation) { + let snapshotCase = SnapshotCase( + name: "Sizing", + configurations: [SnapshotConfiguration(device: expectation.frame)], + ) { + EmptyView() + } + + let resolution = FlyoverExportPolicy.resolution(for: snapshotCase) + guard case let .policy(policy) = resolution else { + Issue.record("Expected a resolved export policy.") + return + } + #expect(policy.captureExtent == expectation.extent) + } + + @Test func reportsMixedSizingClasses() { + let snapshotCase = SnapshotCase( + name: "Mixed", + configurations: [ + SnapshotConfiguration(device: .iPhone), + SnapshotConfiguration(device: .iPhoneFullContent), + ], + ) { + EmptyView() + } + + let resolution = FlyoverExportPolicy.resolution(for: snapshotCase) + guard case let .mixed(extents) = resolution else { + Issue.record("Expected a mixed sizing policy.") + return + } + #expect(Set(extents) == [.viewport, .fullContent]) + } + + struct SizingExpectation { + let frame: SnapshotConfiguration.Frame + let extent: FlyoverCaptureExtent + } + } +#endif diff --git a/Shared/Flyover/Tests/FlyoverTestScreen.swift b/Shared/Flyover/Tests/FlyoverTestScreen.swift index 3e2399d1..e7c5a658 100644 --- a/Shared/Flyover/Tests/FlyoverTestScreen.swift +++ b/Shared/Flyover/Tests/FlyoverTestScreen.swift @@ -1,4 +1,4 @@ -enum FlyoverTestScreen: Hashable { +enum FlyoverTestScreen: String, Hashable { case root case pushed case modal diff --git a/Shared/Flyover/Tests/FlyoverWebExporterTests.swift b/Shared/Flyover/Tests/FlyoverWebExporterTests.swift new file mode 100644 index 00000000..e596d61c --- /dev/null +++ b/Shared/Flyover/Tests/FlyoverWebExporterTests.swift @@ -0,0 +1,227 @@ +#if DEBUG + @testable import Flyover + import Foundation + import SnapshotKit + import SwiftUI + import Testing + + @MainActor + struct FlyoverWebExporterTests { + @Test func writesAStableManifestAndEveryRequestedImage() async throws { + var resetCount = 0 + let catalog = makeCatalog(reset: { resetCount += 1 }) + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "test-app", + title: "Test App", + screenIdentifier: \FlyoverTestScreen.rawValue, + ) + + let summary = try await exporter.export( + to: directory, + profiles: [.phoneLight, .phoneDark, .phoneLight], + build: build, + ) { request in + FlyoverCapturedImage( + pngData: Data([0x89, 0x50, 0x4E, 0x47]), + pointSize: request.configuration.device.testPointSize, + pixelSize: CGSize(width: 1206, height: 2622), + scale: 3, + ) + } + + #expect(summary.screenCount == 3) + #expect(summary.stateCount == 4) + #expect(summary.profileCount == 2) + #expect(summary.imageCount == 8) + #expect(resetCount == 4) + + let data = try Data(contentsOf: directory.appending(path: "manifest.json")) + let manifest = try JSONDecoder().decode(FlyoverWebManifest.self, from: data) + #expect(manifest.schemaVersion == 1) + #expect(manifest.application.id == "test-app") + #expect(manifest.application.title == "Test App") + #expect(manifest.groups.map(\.id) == ["main"]) + #expect(manifest.screens.map(\.id) == ["root", "pushed", "modal"]) + #expect(manifest.routes.map(\.id) == ["route-0001", "route-0002"]) + #expect(manifest.images.count == 8) + #expect(manifest.images.allSatisfy { image in + image.relativePath.hasPrefix("images/screen-") + && image.relativePath.hasPrefix("/") == false + && FileManager.default.fileExists( + atPath: directory.appending(path: image.relativePath).path, + ) + }) + let script = try String( + contentsOf: directory.appending(path: "manifest.js"), + encoding: .utf8, + ) + #expect(script.hasPrefix("window.FLYOVER_MANIFEST = {")) + } + + @Test func validatesEverySizingPolicyBeforeTheFirstCapture() async throws { + let mixed = SnapshotCase( + name: "Mixed", + configurations: [ + SnapshotConfiguration(device: .iPhone), + SnapshotConfiguration(device: .iPhoneFullContent), + ], + ) { + EmptyView() + } + let screen = FlyoverScreen( + id: FlyoverTestScreen.root, + title: "Root", + variants: [FlyoverVariant(id: FlyoverVariantID("mixed"), snapshotCase: mixed)], + ) + let catalog = FlyoverCatalog(groups: [ + FlyoverGroup( + id: FlyoverGroupID("main"), + title: "Main", + root: .root, + screens: [screen], + ), + ]) + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + var captureCount = 0 + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "test", + title: "Test", + screenIdentifier: \FlyoverTestScreen.rawValue, + ) + + do { + _ = try await exporter.export( + to: directory, + profiles: [.phoneLight], + build: build, + ) { _ in + captureCount += 1 + return FlyoverCapturedImage( + pngData: Data([1]), + pointSize: CGSize(width: 1, height: 1), + pixelSize: CGSize(width: 1, height: 1), + scale: 1, + ) + } + Issue.record("Expected mixed sizing to fail export planning.") + } catch let error as FlyoverExportError { + #expect(error == .mixedSizingPolicy( + screen: "root", + variant: "mixed", + extents: ["fullContent", "viewport"], + )) + } catch { + Issue.record("Unexpected error: \(error)") + } + #expect(captureCount == 0) + } + + @Test func rejectsDuplicateStableScreenIdentifiers() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let exporter = FlyoverWebExporter( + catalog: makeCatalog(), + applicationID: "test", + title: "Test", + screenIdentifier: { _ in "duplicate" }, + ) + + do { + _ = try await exporter.export( + to: directory, + profiles: [.phoneLight], + build: build, + ) { _ in + Issue.record("Capture must not run after validation fails.") + return FlyoverCapturedImage( + pngData: Data([1]), + pointSize: .zero, + pixelSize: .zero, + scale: 1, + ) + } + Issue.record("Expected duplicate identifiers to fail.") + } catch let error as FlyoverExportError { + #expect(error == .duplicateScreenIdentifier("duplicate")) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + private var build: FlyoverExportBuild { + FlyoverExportBuild( + commit: "abc123", + dirty: false, + branch: "tests", + generatedAt: "2026-08-13T12:00:00Z", + xcodeVersion: "Xcode 27.0", + simulatorDevice: "iPhone 17", + simulatorOS: "27.0", + ) + } + + private func makeCatalog( + reset: @escaping @MainActor () -> Void = {}, + ) -> FlyoverCatalog { + FlyoverCatalog( + groups: [ + FlyoverGroup( + id: FlyoverGroupID("main"), + title: "Main", + root: .root, + screens: [ + FlyoverScreen( + id: FlyoverTestScreen.root, + title: "Root", + variants: [ + FlyoverVariant( + id: FlyoverVariantID("default"), + title: "Default", + ) { Text("Root") }, + FlyoverVariant( + id: FlyoverVariantID("empty"), + title: "Empty", + ) { EmptyView() }, + ], + reset: reset, + ), + makeFlyoverTestScreen(.pushed, title: "Pushed"), + makeFlyoverTestScreen(.modal, title: "Modal"), + ], + ), + ], + transitions: [ + FlyoverTransition(from: .root, to: .pushed, kind: .push), + FlyoverTransition(from: .root, to: .modal, kind: .modal), + ], + ) + } + + private func makeTemporaryDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appending(path: "FlyoverWebExporterTests-\(UUID().uuidString)") + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true, + ) + return directory + } + } + + extension SnapshotConfiguration.Frame { + fileprivate var testPointSize: CGSize { + switch size { + case let .fixed(size): size + case let .intrinsic(width): CGSize(width: width ?? 402, height: 1) + case let .fullContent(width, minimumHeight): + CGSize(width: width, height: minimumHeight ?? 1) + case let .fullContent2D(minimumSize): minimumSize + } + } + } +#endif diff --git a/Shared/Flyover/Tools/Tests/flyover_test.sh b/Shared/Flyover/Tools/Tests/flyover_test.sh new file mode 100755 index 00000000..2e2152d3 --- /dev/null +++ b/Shared/Flyover/Tools/Tests/flyover_test.sh @@ -0,0 +1,116 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/../../../.." && pwd -P)" +TEMP="$(mktemp -d "${TMPDIR:-/tmp}/flyover-command-tests.XXXXXX")" +trap 'rm -rf "$TEMP"' EXIT INT TERM + +fail() { + echo "flyover command test failed: $*" >&2 + exit 1 +} + +expect_failure() { + if "$@" >"$TEMP/stdout" 2>"$TEMP/stderr"; then + fail "command unexpectedly succeeded: $*" + fi +} + +SUCCESS_RUNNER="$TEMP/success-runner" +cat >"$SUCCESS_RUNNER" <<'RUNNER' +#!/bin/bash +set -euo pipefail +python3 - <<'PY' +import json, os, pathlib +root = pathlib.Path(os.environ['FLYOVER_EXPORT_DIRECTORY']) +profiles = os.environ['FLYOVER_EXPORT_PROFILES'].split(',') +images = [] +paths = {} +for index, profile in enumerate(profiles, 1): + relative = f'images/screen-0001/variant-0001/{profile}.png' + path = root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b'PNG') + paths[profile] = relative + images.append({'screenID': 'screen', 'variantID': 'default', 'profileID': profile, + 'relativePath': relative, 'pointWidth': 1, 'pointHeight': 1, + 'pixelWidth': 3, 'pixelHeight': 3, 'scale': 3, + 'captureExtent': 'viewport'}) +manifest = { + 'schemaVersion': 1, + 'application': {'id': 'where', 'title': 'Where'}, + 'build': {}, + 'profiles': [{'id': profile} for profile in profiles], + 'canvas': {}, + 'groups': [], + 'screens': [{'id': 'screen', 'variants': [{'id': 'default', 'imagesByProfile': paths}]}], + 'routes': [], + 'images': images, +} +data = json.dumps(manifest, sort_keys=True, indent=2) +(root / 'manifest.json').write_text(data) +(root / 'manifest.js').write_text('window.FLYOVER_MANIFEST = ' + data + ';\n') +PY +RUNNER +chmod +x "$SUCCESS_RUNNER" + +FAILURE_RUNNER="$TEMP/failure-runner" +cat >"$FAILURE_RUNNER" <<'RUNNER' +#!/bin/bash +exit 19 +RUNNER +chmod +x "$FAILURE_RUNNER" + +"$ROOT/flyover" --help | grep -q 'flyover export' || fail "help text is incomplete" +expect_failure "$ROOT/flyover" export --profile system +grep -q "unknown profile" "$TEMP/stderr" || fail "unknown profile error is unclear" +expect_failure "$ROOT/flyover" export --output +expect_failure "$ROOT/flyover" export --profile +expect_failure "$ROOT/flyover" export --output / +expect_failure "$ROOT/flyover" export --output "$HOME" +expect_failure "$ROOT/flyover" export --output "$ROOT" + +UNMARKED="$TEMP/unmarked" +mkdir -p "$UNMARKED" +expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests "$ROOT/flyover" export --output "$UNMARKED" +grep -q "unmarked" "$TEMP/stderr" || fail "unmarked-directory error is unclear" + +CALLER="$TEMP/caller" +mkdir -p "$CALLER" +( + cd "$CALLER" + FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$ROOT/flyover" export >/dev/null +) +[ -f "$CALLER/.build/flyover/where/.flyover-generated" ] \ + || fail "default output path did not resolve from the caller directory" + +OUTPUT="$TEMP/output" +FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$ROOT/flyover" export --output "$OUTPUT" \ + --profile phone-dark --profile phone-light --profile phone-dark >/dev/null +python3 - "$OUTPUT/manifest.json" <<'PY' +import json, pathlib, sys +profiles = [item['id'] for item in json.loads(pathlib.Path(sys.argv[1]).read_text())['profiles']] +if profiles != ['phone-dark', 'phone-light']: + raise SystemExit(f'profile order or deduplication is wrong: {profiles}') +PY + +printf '%s\n' old >"$OUTPUT/old-file" +FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$ROOT/flyover" export --output "$OUTPUT" >/dev/null +[ ! -e "$OUTPUT/old-file" ] || fail "a marked directory was not replaced" + +printf '%s\n' retained >"$OUTPUT/retained" +expect_failure env FLYOVER_CAPTURE_RUNNER="$FAILURE_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests "$ROOT/flyover" export --output "$OUTPUT" +[ -f "$OUTPUT/retained" ] || fail "a failed export replaced the last successful output" +if find "$TEMP" -maxdepth 1 -name '.flyover-staging.*' | grep -q .; then + fail "a failed export left a staging directory" +fi + +if grep -R -E 'fetch\(|https?://' "$ROOT/Shared/Flyover/Web" \ + | grep -v 'http://www.w3.org/2000/svg' >/dev/null; then + fail "the web shell contains a network dependency" +fi diff --git a/Shared/Flyover/Web/assets/app.js b/Shared/Flyover/Web/assets/app.js new file mode 100644 index 00000000..fa728f3f --- /dev/null +++ b/Shared/Flyover/Web/assets/app.js @@ -0,0 +1,1102 @@ +(() => { + "use strict"; + + const root = document.getElementById("app"); + const manifest = window.FLYOVER_MANIFEST; + if (!manifest || manifest.schemaVersion !== 1) { + root.innerHTML = '

Manifest error

Flyover cannot open this atlas

This site requires manifest schema version 1.

'; + return; + } + + const screenByID = new Map(manifest.screens.map(screen => [screen.id, screen])); + const groupByID = new Map(manifest.groups.map(group => [group.id, group])); + const routeByID = new Map(manifest.routes.map(route => [route.id, route])); + const profileByID = new Map(manifest.profiles.map(profile => [profile.id, profile])); + const imageByKey = new Map(manifest.images.map(image => [ + imageKey(image.screenID, image.variantID, image.profileID), + image, + ])); + const selectedVariants = new Map(manifest.screens.map(screen => [screen.id, screen.variants[0]?.id])); + const state = { + view: "canvas", + profile: manifest.profiles[0]?.id, + screen: null, + search: "", + zoom: 1, + group: manifest.groups[0]?.id, + routeFocus: null, + inspectorScale: "fit", + filters: { + group: "all", + extent: "all", + routes: "all", + }, + }; + + function element(tag, className, text) { + const value = document.createElement(tag); + if (className) value.className = className; + if (text !== undefined) value.textContent = text; + return value; + } + + function screenVariant(screen) { + const id = selectedVariants.get(screen.id); + return screen.variants.find(variant => variant.id === id) || screen.variants[0]; + } + + function imagePath(screen) { + return screenVariant(screen)?.imagesByProfile[state.profile] || ""; + } + + function imageKey(screenID, variantID, profileID) { + return JSON.stringify([screenID, variantID, profileID]); + } + + function imageMetadata(screen) { + const variant = screenVariant(screen); + return imageByKey.get(imageKey(screen.id, variant?.id, state.profile)); + } + + function connectedRoutes(screen) { + return [...screen.incomingRouteIDs, ...screen.outgoingRouteIDs] + .map(id => routeByID.get(id)) + .filter(Boolean); + } + + function searchableText(screen) { + const group = groupByID.get(screen.groupID); + const routeText = connectedRoutes(screen).flatMap(route => { + const source = screenByID.get(route.sourceScreenID); + const destination = screenByID.get(route.destinationScreenID); + return [route.label, route.kind, source?.title, destination?.title]; + }); + return [group?.title, screen.title, ...screen.variants.map(variant => variant.title), ...routeText] + .filter(Boolean) + .join(" ") + .toLocaleLowerCase(); + } + + function matchesSearch(screen) { + const terms = state.search.trim().toLocaleLowerCase().split(/ +/).filter(Boolean); + if (!terms.every(term => searchableText(screen).includes(term))) return false; + if (state.filters.group !== "all" && screen.groupID !== state.filters.group) return false; + if (state.filters.extent !== "all" && screenVariant(screen)?.captureExtent !== state.filters.extent) { + return false; + } + if (state.filters.routes === "incoming" && screen.incomingRouteIDs.length === 0) return false; + if (state.filters.routes === "outgoing" && screen.outgoingRouteIDs.length === 0) return false; + if (state.filters.routes === "linked" + && screen.incomingRouteIDs.length + screen.outgoingRouteIDs.length === 0) return false; + if (state.filters.routes === "unlinked" + && screen.incomingRouteIDs.length + screen.outgoingRouteIDs.length !== 0) return false; + return true; + } + + function visibleScreenIDs() { + return new Set(manifest.screens.filter(matchesSearch).map(screen => screen.id)); + } + + function parseHash() { + const values = new URLSearchParams(location.hash.replace(/^#/, "")); + const view = values.get("view"); + const profile = values.get("profile"); + const screenID = values.get("screen"); + const variantID = values.get("variant"); + if (view === "canvas" || view === "list") state.view = view; + if (profileByID.has(profile)) state.profile = profile; + if (screenByID.has(screenID)) { + state.screen = screenID; + const screen = screenByID.get(screenID); + if (screen.variants.some(variant => variant.id === variantID)) { + selectedVariants.set(screenID, variantID); + } + } else { + state.screen = null; + } + } + + function writeHash() { + const values = new URLSearchParams(); + values.set("view", state.view); + values.set("profile", state.profile); + if (state.screen) { + values.set("screen", state.screen); + values.set("variant", selectedVariants.get(state.screen)); + } + const next = "#" + values; + if (location.hash === next) return false; + location.hash = next; + return true; + } + + function renderOrNavigate() { + if (!writeHash()) render(); + } + + function chooseView(view) { + state.view = view; + renderOrNavigate(); + } + + function chooseProfile(profile) { + state.profile = profile; + renderOrNavigate(); + } + + function chooseVariant(screen, variantID, updateHistory = false) { + selectedVariants.set(screen.id, variantID); + if (state.screen === screen.id || updateHistory) { + state.screen = screen.id; + renderOrNavigate(); + } else { + render(); + } + } + + function openScreen(screenID, preserveVariant = false) { + const screen = screenByID.get(screenID); + if (!screen) return; + if (!preserveVariant) selectedVariants.set(screen.id, screen.variants[0]?.id); + state.screen = screen.id; + state.routeFocus = screen.id; + state.inspectorScale = "fit"; + renderOrNavigate(); + } + + function neighboringScreen(offset) { + const index = manifest.screens.findIndex(screen => screen.id === state.screen); + if (index < 0) return null; + const next = (index + offset + manifest.screens.length) % manifest.screens.length; + return manifest.screens[next]; + } + + function formatGeneratedAt(value) { + const date = new Date(value); + if (Number.isNaN(date.valueOf())) return value; + return new Intl.DateTimeFormat(undefined, { + dateStyle: "medium", + timeStyle: "short", + }).format(date); + } + + function labeledSelect(title, select, className) { + const label = element("label", className || "field"); + label.append(element("span", "field-label", title), select); + return label; + } + + function profileSelect() { + const select = element("select", ""); + select.setAttribute("aria-label", "Capture profile"); + for (const item of manifest.profiles) { + const option = element("option", "", item.title); + option.value = item.id; + option.selected = item.id === state.profile; + select.append(option); + } + select.addEventListener("change", () => chooseProfile(select.value)); + return select; + } + + function toolbar() { + const shell = element("header", "app-header"); + const masthead = element("div", "masthead"); + const brand = element("div", "brand"); + brand.append(element("span", "brand-mark", "F")); + const brandCopy = element("div", ""); + brandCopy.append(element("p", "eyebrow", "Flyover QA Atlas")); + brandCopy.append(element("h1", "", manifest.application.title)); + brand.append(brandCopy); + masthead.append(brand); + + const build = element("details", "build-details"); + const summary = element("summary", ""); + summary.append(element("span", "commit-dot"), element("code", "", manifest.build.commit.slice(0, 8))); + if (manifest.build.dirty) summary.append(element("span", "dirty", "Dirty")); + build.append(summary); + const buildPanel = element("div", "build-panel"); + buildPanel.append( + metadataRow("Generated", formatGeneratedAt(manifest.build.generatedAt)), + metadataRow("Branch", manifest.build.branch || "Detached"), + metadataRow("Commit", manifest.build.commit), + metadataRow("Xcode", manifest.build.xcodeVersion), + metadataRow("Simulator", manifest.build.simulatorDevice + " · iOS " + manifest.build.simulatorOS), + ); + build.append(buildPanel); + masthead.append(build); + + const primary = element("div", "primary-toolbar"); + const tabs = element("div", "view-tabs"); + tabs.setAttribute("aria-label", "Atlas view"); + for (const view of ["canvas", "list"]) { + const button = element("button", "", view[0].toUpperCase() + view.slice(1)); + button.type = "button"; + button.setAttribute("aria-pressed", String(state.view === view)); + button.addEventListener("click", () => chooseView(view)); + tabs.append(button); + } + primary.append(tabs); + + const searchField = element("label", "search-field"); + searchField.append(element("span", "search-icon", "⌕")); + const search = element("input", ""); + search.id = "global-search"; + search.type = "search"; + search.placeholder = "Find screens, states, or routes"; + search.setAttribute("aria-label", "Search screens, states, and routes"); + search.setAttribute("aria-keyshortcuts", "/"); + search.value = state.search; + search.addEventListener("input", () => { + state.search = search.value; + updateSearchVisibility(); + }); + searchField.append(search, element("kbd", "", "/")); + primary.append(searchField); + primary.append(filterMenu()); + primary.append(labeledSelect("Profile", profileSelect(), "profile-field")); + shell.append(masthead, primary); + + const context = element("div", "context-toolbar"); + const resultCount = element("p", "result-count"); + resultCount.id = "result-count"; + resultCount.setAttribute("aria-live", "polite"); + context.append(resultCount); + if (state.view === "canvas") context.append(canvasControls()); + shell.append(context); + return shell; + } + + function metadataRow(label, value) { + const row = element("div", "metadata-row"); + row.append(element("dt", "", label), element("dd", "", value)); + return row; + } + + function filterMenu() { + const details = element("details", "filter-menu"); + const summary = element("summary", "", "Filters"); + details.append(summary); + const panel = element("div", "filter-panel"); + const groupOptions = [["all", "All groups"], ...manifest.groups.map(group => [group.id, group.title])]; + panel.append( + filterSelect("Group", state.filters.group, groupOptions, value => { + state.filters.group = value; + render(); + }), + filterSelect("Capture", state.filters.extent, [ + ["all", "All captures"], + ["viewport", "Viewport"], + ["intrinsic", "Intrinsic"], + ["fullContent", "Full content"], + ["fullContent2D", "Full content 2D"], + ], value => { + state.filters.extent = value; + render(); + }), + filterSelect("Routes", state.filters.routes, [ + ["all", "Any route state"], + ["linked", "Linked"], + ["unlinked", "Unlinked"], + ["incoming", "Has incoming"], + ["outgoing", "Has outgoing"], + ], value => { + state.filters.routes = value; + render(); + }), + ); + const clear = element("button", "clear-filters", "Clear filters"); + clear.type = "button"; + clear.addEventListener("click", () => { + state.filters = { group: "all", extent: "all", routes: "all" }; + state.search = ""; + render(); + }); + panel.append(clear); + details.append(panel); + return details; + } + + function filterSelect(labelText, value, options, onChange) { + const select = element("select", ""); + for (const pair of options) { + const option = element("option", "", pair[1]); + option.value = pair[0]; + option.selected = pair[0] === value; + select.append(option); + } + select.addEventListener("change", () => onChange(select.value)); + return labeledSelect(labelText, select); + } + + function canvasControls() { + const controls = element("div", "canvas-controls"); + const minus = element("button", "icon-button", "−"); + minus.type = "button"; + minus.setAttribute("aria-label", "Zoom out"); + minus.addEventListener("click", () => setZoom(state.zoom - 0.1)); + const zoom = element("input", ""); + zoom.id = "canvas-zoom"; + zoom.type = "range"; + zoom.min = "0.1"; + zoom.max = "1.5"; + zoom.step = "0.05"; + zoom.value = String(state.zoom); + zoom.setAttribute("aria-label", "Canvas zoom"); + zoom.addEventListener("input", () => setZoom(Number(zoom.value))); + const plus = element("button", "icon-button", "+"); + plus.type = "button"; + plus.setAttribute("aria-label", "Zoom in"); + plus.addEventListener("click", () => setZoom(state.zoom + 0.1)); + const value = element("output", "zoom-value", Math.round(state.zoom * 100) + "%"); + value.id = "zoom-value"; + value.setAttribute("for", "canvas-zoom"); + const fitGroup = element("button", "", "Fit group"); + fitGroup.type = "button"; + fitGroup.setAttribute("aria-keyshortcuts", "0"); + fitGroup.addEventListener("click", fitCurrentGroup); + const fit = element("button", "primary-button", "Fit all"); + fit.type = "button"; + fit.setAttribute("aria-keyshortcuts", "F"); + fit.addEventListener("click", fitAll); + controls.append(minus, zoom, plus, value, fitGroup, fit); + return controls; + } + + function variantSelector(screen, updateHistory = false) { + const select = element("select", ""); + select.setAttribute("aria-label", "State for " + screen.title); + for (const variant of screen.variants) { + const option = element("option", "", variant.title); + option.value = variant.id; + option.selected = variant.id === selectedVariants.get(screen.id); + select.append(option); + } + select.addEventListener("change", event => { + event.stopPropagation(); + chooseVariant(screen, select.value, updateHistory); + }); + return select; + } + + function routeButton(route, screen, direction) { + const destinationID = direction === "outgoing" ? route.destinationScreenID : route.sourceScreenID; + const destination = screenByID.get(destinationID); + const cue = direction === "outgoing" + ? (route.kind === "modal" ? "Modal" : "Push") + : (route.kind === "modal" ? "Presented from" : "Back to"); + const button = element("button", "route-chip " + route.kind, cue + " · " + (destination?.title || destinationID)); + button.type = "button"; + button.addEventListener("click", event => { + event.stopPropagation(); + openScreen(destinationID); + }); + return button; + } + + function routeButtons(screen, direction) { + const links = element("div", "route-links"); + const ids = direction === "incoming" ? screen.incomingRouteIDs : screen.outgoingRouteIDs; + for (const id of ids) { + const route = routeByID.get(id); + if (route) links.append(routeButton(route, screen, direction)); + } + if (ids.length === 0) links.append(element("span", "no-routes", "None")); + return links; + } + + function screenImage(screen, className) { + const image = element("img", className || ""); + image.loading = "lazy"; + image.decoding = "async"; + image.src = imagePath(screen); + image.alt = screen.title + " — " + (screenVariant(screen)?.title || "Default"); + return image; + } + + function captureLabel(extent) { + if (extent === "fullContent") return "Full content"; + if (extent === "fullContent2D") return "Full content 2D"; + return extent[0].toUpperCase() + extent.slice(1); + } + + function canvasView() { + const layout = element("main", "canvas-layout"); + const sidebar = canvasSidebar(); + const viewport = element("section", "canvas-viewport"); + viewport.id = "canvas-viewport"; + viewport.setAttribute("aria-label", "Screen atlas canvas"); + const scaled = element("div", "canvas-scaled"); + scaled.id = "canvas-scaled"; + const stage = element("div", "canvas-stage"); + stage.id = "canvas-stage"; + stage.style.width = manifest.canvas.size.width + "px"; + stage.style.height = manifest.canvas.size.height + "px"; + + for (const item of manifest.canvas.groupFrames) { + const group = groupByID.get(item.id); + const shelf = element("section", "group-shelf"); + shelf.dataset.groupId = item.id; + Object.assign(shelf.style, rectStyle(item.frame)); + const shelfHeader = element("div", "shelf-header"); + shelfHeader.append( + element("p", "eyebrow", "Group " + (group?.order + 1)), + element("h2", "", group?.title || item.id), + ); + shelf.append(shelfHeader); + stage.append(shelf); + } + for (const item of manifest.canvas.depthBandFrames) { + const band = element("div", "depth-band"); + band.dataset.groupId = item.groupID; + Object.assign(band.style, rectStyle(item.frame)); + band.append(element("span", "", item.kind === "unlinked" ? "Unlinked" : "Depth " + item.depth)); + stage.append(band); + } + stage.append(routeCanvas()); + + for (const screen of manifest.screens) stage.append(screenCard(screen)); + scaled.append(stage); + viewport.append(scaled); + viewport.addEventListener("scroll", scheduleCanvasNavigationUpdate, { passive: true }); + layout.append(sidebar, viewport, emptyResults()); + requestAnimationFrame(() => { + applyZoom(); + updateSearchVisibility(); + if (!state.screen && !location.hash.includes("view=")) fitFirstGroup(); + }); + return layout; + } + + function canvasSidebar() { + const sidebar = element("aside", "canvas-sidebar"); + const heading = element("div", "sidebar-heading"); + heading.append(element("p", "eyebrow", "Browse"), element("h2", "", "Groups")); + sidebar.append(heading); + const navigation = element("nav", "group-navigation"); + navigation.setAttribute("aria-label", "Canvas groups"); + for (const group of manifest.groups) { + const button = element("button", "group-button"); + button.type = "button"; + button.dataset.groupId = group.id; + button.setAttribute("aria-current", String(group.id === state.group)); + const copy = element("span", ""); + copy.append(element("strong", "", group.title), element("small", "", group.screenIDs.length + " screens")); + button.append(element("span", "group-index", String(group.order + 1).padStart(2, "0")), copy); + button.addEventListener("click", () => { + state.group = group.id; + updateActiveGroup(); + fitCurrentGroup(); + }); + navigation.append(button); + } + sidebar.append(navigation, miniMap()); + const legend = element("div", "route-legend"); + legend.append( + legendItem("push", "Push"), + legendItem("modal", "Modal"), + ); + sidebar.append(legend); + const hint = element("p", "keyboard-hint", "Press / to search · F to fit all"); + sidebar.append(hint); + return sidebar; + } + + function legendItem(kind, title) { + const item = element("span", ""); + item.append(element("i", kind), document.createTextNode(title)); + return item; + } + + function miniMap() { + const namespace = "http://www.w3.org/2000/svg"; + const wrapper = element("div", "mini-map"); + wrapper.append(element("p", "eyebrow", "Overview")); + const svg = document.createElementNS(namespace, "svg"); + svg.id = "mini-map-svg"; + svg.setAttribute("viewBox", "0 0 " + manifest.canvas.size.width + " " + manifest.canvas.size.height); + svg.setAttribute("role", "img"); + svg.setAttribute("aria-label", "Canvas overview. Select to move around the atlas."); + for (const group of manifest.canvas.groupFrames) { + const rect = document.createElementNS(namespace, "rect"); + Object.entries(rectAttributes(group.frame)).forEach(([key, value]) => rect.setAttribute(key, value)); + rect.setAttribute("class", "mini-group"); + rect.dataset.groupId = group.id; + svg.append(rect); + } + for (const screen of manifest.screens) { + const rect = document.createElementNS(namespace, "rect"); + Object.entries(rectAttributes(screen.frame)).forEach(([key, value]) => rect.setAttribute(key, value)); + rect.setAttribute("class", "mini-screen"); + rect.dataset.screenId = screen.id; + svg.append(rect); + } + const visible = document.createElementNS(namespace, "rect"); + visible.id = "mini-map-viewport"; + visible.setAttribute("class", "mini-viewport"); + svg.append(visible); + svg.addEventListener("click", event => { + const viewport = document.getElementById("canvas-viewport"); + if (!viewport) return; + const bounds = svg.getBoundingClientRect(); + const x = (event.clientX - bounds.left) / bounds.width * manifest.canvas.size.width; + const y = (event.clientY - bounds.top) / bounds.height * manifest.canvas.size.height; + viewport.scrollTo({ + left: x * state.zoom - viewport.clientWidth / 2, + top: y * state.zoom - viewport.clientHeight / 2, + behavior: "smooth", + }); + }); + wrapper.append(svg); + return wrapper; + } + + function rectAttributes(rect) { + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + }; + } + + function screenCard(screen) { + const variant = screenVariant(screen); + const card = element("article", "screen-card"); + card.dataset.screenId = screen.id; + card.dataset.groupId = screen.groupID; + card.dataset.hidden = String(!matchesSearch(screen)); + card.tabIndex = 0; + Object.assign(card.style, rectStyle(screen.frame)); + const cardHeader = element("header", "card-header"); + const title = element("div", ""); + title.append(element("h3", "", screen.title), element("p", "", groupByID.get(screen.groupID)?.title)); + const routeCount = screen.incomingRouteIDs.length + screen.outgoingRouteIDs.length; + cardHeader.append(title, element("span", "route-count", routeCount + (routeCount === 1 ? " route" : " routes"))); + card.append(cardHeader); + if (screen.variants.length > 1) { + card.append(labeledSelect("State", variantSelector(screen), "card-state")); + } else { + card.append(element("p", "single-state", variant?.title || "Default")); + } + const imageButton = element("button", "card-image-button"); + imageButton.type = "button"; + imageButton.setAttribute("aria-label", "Inspect " + screen.title); + const device = element("span", "device-preview"); + device.append(screenImage(screen)); + imageButton.append(device); + imageButton.addEventListener("click", () => openScreen(screen.id, true)); + card.append(imageButton); + const footer = element("footer", "card-footer"); + footer.append(element("span", "capture-badge", captureLabel(variant?.captureExtent || "viewport"))); + const inspect = element("button", "inspect-link", "Inspect →"); + inspect.type = "button"; + inspect.addEventListener("click", () => openScreen(screen.id, true)); + footer.append(inspect); + card.append(footer); + card.addEventListener("pointerenter", () => setRouteFocus(screen.id)); + card.addEventListener("pointerleave", () => { + if (!card.matches(":focus-within")) setRouteFocus(null); + }); + card.addEventListener("focusin", () => setRouteFocus(screen.id)); + card.addEventListener("focusout", event => { + if (!card.contains(event.relatedTarget)) setRouteFocus(null); + }); + card.addEventListener("keydown", event => { + if (event.key === "Enter" && event.target === card) openScreen(screen.id, true); + }); + return card; + } + + function routeCanvas() { + const namespace = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(namespace, "svg"); + svg.classList.add("canvas-routes"); + svg.setAttribute("width", manifest.canvas.size.width); + svg.setAttribute("height", manifest.canvas.size.height); + svg.setAttribute("viewBox", "0 0 " + manifest.canvas.size.width + " " + manifest.canvas.size.height); + for (const route of manifest.routes) { + const group = document.createElementNS(namespace, "g"); + group.setAttribute("class", "route route-" + route.kind); + group.dataset.routeId = route.id; + group.dataset.sourceScreenId = route.sourceScreenID; + group.dataset.destinationScreenId = route.destinationScreenID; + const geometry = route.geometry; + const path = document.createElementNS(namespace, "path"); + path.setAttribute("d", "M " + geometry.start.x + " " + geometry.start.y + + " C " + geometry.firstControl.x + " " + geometry.firstControl.y + + ", " + geometry.secondControl.x + " " + geometry.secondControl.y + + ", " + geometry.end.x + " " + geometry.end.y); + const arrow = document.createElementNS(namespace, "polygon"); + arrow.setAttribute("points", geometry.end.x + "," + geometry.end.y + + " " + geometry.firstArrowPoint.x + "," + geometry.firstArrowPoint.y + + " " + geometry.secondArrowPoint.x + "," + geometry.secondArrowPoint.y); + group.append(path, arrow); + svg.append(group); + } + return svg; + } + + function rectStyle(rect) { + return { + left: rect.x + "px", + top: rect.y + "px", + width: rect.width + "px", + height: rect.height + "px", + }; + } + + function listView() { + const main = element("main", "list"); + const intro = element("div", "list-intro"); + intro.append( + element("p", "eyebrow", "Catalog"), + element("h2", "", manifest.screens.length + " screens across " + manifest.groups.length + " groups"), + element("p", "", "Review captured states in a compact, scannable inventory."), + ); + main.append(intro); + for (const group of manifest.groups) { + const section = element("section", "list-group"); + section.dataset.listGroupId = group.id; + const heading = element("header", "list-group-header"); + heading.append(element("h2", "", group.title), element("span", "", group.screenIDs.length + " screens")); + section.append(heading); + const rows = element("div", "list-rows"); + for (const screenID of group.screenIDs) { + const screen = screenByID.get(screenID); + if (screen) rows.append(listRow(screen)); + } + section.append(rows); + main.append(section); + } + main.append(emptyResults()); + requestAnimationFrame(updateSearchVisibility); + return main; + } + + function listRow(screen) { + const variant = screenVariant(screen); + const row = element("article", "list-row"); + row.dataset.screenId = screen.id; + row.dataset.groupId = screen.groupID; + row.dataset.hidden = String(!matchesSearch(screen)); + const thumbnail = element("button", "list-thumbnail"); + thumbnail.type = "button"; + thumbnail.setAttribute("aria-label", "Inspect " + screen.title); + thumbnail.append(screenImage(screen)); + thumbnail.addEventListener("click", () => openScreen(screen.id, true)); + const identity = element("div", "list-identity"); + identity.append( + element("p", "eyebrow", captureLabel(variant?.captureExtent || "viewport")), + element("h3", "", screen.title), + ); + if (screen.variants.length > 1) { + identity.append(labeledSelect("State", variantSelector(screen), "list-state")); + } else { + identity.append(element("p", "secondary", variant?.title || "Default")); + } + const profile = profileByID.get(state.profile); + const traits = element("div", "list-traits"); + traits.append( + element("span", "", profile?.title || state.profile), + element("span", "", profile?.device || ""), + element("span", "", profile?.colorScheme || ""), + ); + const routes = element("div", "list-routes"); + const routeSummary = element("p", "secondary", screen.incomingRouteIDs.length + " incoming · " + + screen.outgoingRouteIDs.length + " outgoing"); + routes.append(routeSummary); + const quickRoutes = [...screen.outgoingRouteIDs, ...screen.incomingRouteIDs].slice(0, 2); + for (const id of quickRoutes) { + const route = routeByID.get(id); + if (!route) continue; + const direction = route.sourceScreenID === screen.id ? "outgoing" : "incoming"; + routes.append(routeButton(route, screen, direction)); + } + const inspect = element("button", "list-inspect primary-button", "Inspect"); + inspect.type = "button"; + inspect.addEventListener("click", () => openScreen(screen.id, true)); + row.append(thumbnail, identity, traits, routes, inspect); + return row; + } + + function inspector() { + const screen = screenByID.get(state.screen); + if (!screen) return null; + const variant = screenVariant(screen); + const metadata = imageMetadata(screen); + const group = groupByID.get(screen.groupID); + const screenIndex = manifest.screens.findIndex(item => item.id === screen.id); + const dialog = element("dialog", "inspector"); + dialog.id = "inspector"; + dialog.setAttribute("aria-labelledby", "inspector-title"); + const header = element("header", "inspector-header"); + const heading = element("div", ""); + heading.append( + element("p", "eyebrow", (group?.title || "Ungrouped") + " · " + + (screenIndex + 1) + " of " + manifest.screens.length), + element("h2", "", screen.title), + ); + heading.querySelector("h2").id = "inspector-title"; + const headerActions = element("div", "inspector-header-actions"); + const previous = element("button", "icon-button", "←"); + previous.type = "button"; + previous.setAttribute("aria-label", "Previous screen"); + previous.setAttribute("aria-keyshortcuts", "["); + previous.addEventListener("click", () => openScreen(neighboringScreen(-1)?.id)); + const next = element("button", "icon-button", "→"); + next.type = "button"; + next.setAttribute("aria-label", "Next screen"); + next.setAttribute("aria-keyshortcuts", "]"); + next.addEventListener("click", () => openScreen(neighboringScreen(1)?.id)); + const close = element("button", "close", "Close"); + close.type = "button"; + close.autofocus = true; + close.addEventListener("click", closeInspector); + headerActions.append(previous, next, close); + header.append(heading, headerActions); + dialog.append(header); + + const controls = element("div", "inspector-controls"); + controls.append( + labeledSelect("State", variantSelector(screen, true)), + labeledSelect("Profile", profileSelect()), + ); + const scale = element("div", "scale-tabs"); + scale.setAttribute("aria-label", "Image scale"); + for (const option of [["fit", "Fit"], ["actual", "100%"]]) { + const button = element("button", "", option[1]); + button.type = "button"; + button.setAttribute("aria-pressed", String(state.inspectorScale === option[0])); + button.addEventListener("click", () => { + state.inspectorScale = option[0]; + updateInspectorScale(); + }); + scale.append(button); + } + controls.append(scale); + dialog.append(controls); + + const body = element("div", "inspector-body"); + const preview = element("section", "inspector-preview"); + const previewToolbar = element("div", "preview-toolbar"); + previewToolbar.append( + element("span", "capture-badge", captureLabel(variant.captureExtent)), + element("span", "secondary", imageDimensions(metadata)), + ); + const raw = element("a", "raw-link", "Open raw PNG ↗"); + raw.href = imagePath(screen); + raw.target = "_blank"; + raw.rel = "noopener"; + previewToolbar.append(raw); + const extentClass = variant.captureExtent === "fullContent" + || variant.captureExtent === "fullContent2D" ? " full-content" : ""; + const imageFrame = element("div", "inspector-image " + state.inspectorScale + extentClass); + imageFrame.id = "inspector-image"; + const device = element("div", "inspector-device"); + const fullImage = screenImage(screen); + if (metadata) device.style.setProperty("--point-width", metadata.pointWidth + "px"); + device.append(fullImage); + imageFrame.append(device); + preview.append(previewToolbar, imageFrame); + + const details = element("aside", "inspector-details"); + details.append(inspectorMetadata(screen, variant, metadata)); + details.append(routeSection("Outgoing", screen, "outgoing")); + details.append(routeSection("Incoming", screen, "incoming")); + body.append(preview, details); + dialog.append(body); + dialog.addEventListener("cancel", event => { + event.preventDefault(); + closeInspector(); + }); + return dialog; + } + + function imageDimensions(metadata) { + if (!metadata) return "Image metadata unavailable"; + return Math.round(metadata.pointWidth) + " × " + Math.round(metadata.pointHeight) + " pt · " + + metadata.pixelWidth + " × " + metadata.pixelHeight + " px @" + metadata.scale + "×"; + } + + function inspectorMetadata(screen, variant, metadata) { + const section = element("section", "detail-section"); + section.append(element("p", "eyebrow", "Capture details")); + const list = element("dl", "detail-list"); + list.append( + metadataRow("State", variant.title), + metadataRow("Extent", captureLabel(variant.captureExtent)), + metadataRow("Profile", profileByID.get(state.profile)?.title || state.profile), + metadataRow("Viewport", screen.viewport.kind === "fixed" && screen.viewport.fixedSize + ? Math.round(screen.viewport.fixedSize.width) + " × " + Math.round(screen.viewport.fixedSize.height) + : "Profile device"), + metadataRow("Scale", metadata ? metadata.scale + "×" : "Unknown"), + ); + section.append(list); + return section; + } + + function routeSection(title, screen, direction) { + const section = element("section", "detail-section"); + const ids = direction === "incoming" ? screen.incomingRouteIDs : screen.outgoingRouteIDs; + section.append(element("p", "eyebrow", title + " · " + ids.length)); + section.append(routeButtons(screen, direction)); + return section; + } + + function updateInspectorScale() { + const image = document.getElementById("inspector-image"); + if (image) image.className = image.className.replace(/ fit| actual/g, "") + " " + state.inspectorScale; + for (const button of document.querySelectorAll(".scale-tabs button")) { + button.setAttribute("aria-pressed", String( + (button.textContent === "Fit" && state.inspectorScale === "fit") + || (button.textContent === "100%" && state.inspectorScale === "actual"), + )); + } + } + + function closeInspector() { + state.screen = null; + state.routeFocus = null; + renderOrNavigate(); + } + + function emptyResults() { + const empty = element("section", "empty-results"); + empty.id = "empty-results"; + empty.hidden = true; + empty.append( + element("p", "eyebrow", "No matches"), + element("h2", "", "Try a broader search"), + element("p", "", "Search includes group, screen, state, and connected route names."), + ); + const clear = element("button", "primary-button", "Clear search and filters"); + clear.type = "button"; + clear.addEventListener("click", () => { + state.search = ""; + state.filters = { group: "all", extent: "all", routes: "all" }; + render(); + }); + empty.append(clear); + return empty; + } + + function updateSearchVisibility() { + const visible = visibleScreenIDs(); + for (const node of document.querySelectorAll("[data-screen-id]")) { + if (node.matches(".route")) continue; + node.dataset.hidden = String(!visible.has(node.dataset.screenId)); + } + for (const group of manifest.groups) { + const hasResults = group.screenIDs.some(id => visible.has(id)); + for (const node of document.querySelectorAll('[data-group-id="' + CSS.escape(group.id) + '"]')) { + node.dataset.empty = String(!hasResults); + } + const listGroup = document.querySelector('[data-list-group-id="' + CSS.escape(group.id) + '"]'); + if (listGroup) listGroup.hidden = !hasResults; + } + for (const route of document.querySelectorAll(".route")) { + const sourceVisible = visible.has(route.dataset.sourceScreenId); + const destinationVisible = visible.has(route.dataset.destinationScreenId); + route.dataset.hidden = String(!sourceVisible && !destinationVisible); + } + const count = document.getElementById("result-count"); + if (count) count.textContent = visible.size + " of " + manifest.screens.length + " screens"; + const empty = document.getElementById("empty-results"); + if (empty) empty.hidden = visible.size !== 0; + updateRouteFocus(); + } + + function setRouteFocus(screenID) { + state.routeFocus = screenID; + updateRouteFocus(); + } + + function updateRouteFocus() { + const focused = state.routeFocus; + const connected = new Map(); + if (focused) { + const screen = screenByID.get(focused); + for (const id of screen?.incomingRouteIDs || []) { + const route = routeByID.get(id); + if (route) connected.set(route.sourceScreenID, "upstream"); + } + for (const id of screen?.outgoingRouteIDs || []) { + const route = routeByID.get(id); + if (route) connected.set(route.destinationScreenID, "downstream"); + } + } + for (const card of document.querySelectorAll(".screen-card")) { + card.dataset.routeRelation = card.dataset.screenId === focused + ? "focus" + : (connected.get(card.dataset.screenId) || (focused ? "unrelated" : "none")); + } + for (const route of document.querySelectorAll(".route")) { + const isConnected = route.dataset.sourceScreenId === focused + || route.dataset.destinationScreenId === focused; + route.dataset.routeRelation = isConnected ? "focus" : (focused ? "unrelated" : "none"); + } + } + + function applyZoom() { + const stage = document.getElementById("canvas-stage"); + const scaled = document.getElementById("canvas-scaled"); + if (!stage || !scaled) return; + stage.style.transform = "scale(" + state.zoom + ")"; + scaled.style.width = manifest.canvas.size.width * state.zoom + "px"; + scaled.style.height = manifest.canvas.size.height * state.zoom + "px"; + const slider = document.getElementById("canvas-zoom"); + if (slider) slider.value = String(state.zoom); + const value = document.getElementById("zoom-value"); + if (value) value.textContent = Math.round(state.zoom * 100) + "%"; + updateMiniMapViewport(); + } + + function setZoom(next) { + const viewport = document.getElementById("canvas-viewport"); + const oldZoom = state.zoom; + const center = viewport ? { + x: (viewport.scrollLeft + viewport.clientWidth / 2) / oldZoom, + y: (viewport.scrollTop + viewport.clientHeight / 2) / oldZoom, + } : null; + state.zoom = Math.max(0.1, Math.min(1.5, next)); + applyZoom(); + if (viewport && center) { + viewport.scrollTo({ + left: center.x * state.zoom - viewport.clientWidth / 2, + top: center.y * state.zoom - viewport.clientHeight / 2, + }); + } + } + + function fitFrame(frame) { + const viewport = document.getElementById("canvas-viewport"); + if (!viewport) return; + const padding = 48; + const horizontal = Math.max(viewport.clientWidth - padding * 2, 1) / Math.max(frame.width, 1); + const vertical = Math.max(viewport.clientHeight - padding * 2, 1) / Math.max(frame.height, 1); + state.zoom = Math.max(0.1, Math.min(1.5, horizontal, vertical)); + applyZoom(); + viewport.scrollTo({ + left: (frame.x + frame.width / 2) * state.zoom - viewport.clientWidth / 2, + top: (frame.y + frame.height / 2) * state.zoom - viewport.clientHeight / 2, + behavior: "smooth", + }); + } + + function fitAll() { + fitFrame({ x: 0, y: 0, width: manifest.canvas.size.width, height: manifest.canvas.size.height }); + } + + function fitCurrentGroup() { + const group = manifest.canvas.groupFrames.find(item => item.id === state.group) + || manifest.canvas.groupFrames[0]; + if (group) fitFrame(group.frame); + } + + function fitFirstGroup() { + const first = manifest.canvas.groupFrames[0]; + if (first) fitFrame(first.frame); + } + + let canvasNavigationFrame = null; + function scheduleCanvasNavigationUpdate() { + if (canvasNavigationFrame !== null) return; + canvasNavigationFrame = requestAnimationFrame(() => { + canvasNavigationFrame = null; + updateCanvasNavigation(); + }); + } + + function updateCanvasNavigation() { + const viewport = document.getElementById("canvas-viewport"); + if (!viewport) return; + const centerX = (viewport.scrollLeft + viewport.clientWidth / 2) / state.zoom; + const centerY = (viewport.scrollTop + viewport.clientHeight / 2) / state.zoom; + let nearest = null; + let nearestDistance = Number.POSITIVE_INFINITY; + for (const group of manifest.canvas.groupFrames) { + const frame = group.frame; + const dx = Math.max(frame.x - centerX, 0, centerX - frame.x - frame.width); + const dy = Math.max(frame.y - centerY, 0, centerY - frame.y - frame.height); + const distance = dx * dx + dy * dy; + if (distance < nearestDistance) { + nearest = group.id; + nearestDistance = distance; + } + } + if (nearest) state.group = nearest; + updateActiveGroup(); + updateMiniMapViewport(); + } + + function updateActiveGroup() { + for (const button of document.querySelectorAll(".group-button")) { + button.setAttribute("aria-current", String(button.dataset.groupId === state.group)); + } + for (const group of document.querySelectorAll(".mini-group")) { + group.dataset.active = String(group.dataset.groupId === state.group); + } + } + + function updateMiniMapViewport() { + const viewport = document.getElementById("canvas-viewport"); + const rect = document.getElementById("mini-map-viewport"); + if (!viewport || !rect) return; + rect.setAttribute("x", viewport.scrollLeft / state.zoom); + rect.setAttribute("y", viewport.scrollTop / state.zoom); + rect.setAttribute("width", viewport.clientWidth / state.zoom); + rect.setAttribute("height", viewport.clientHeight / state.zoom); + } + + function render() { + root.replaceChildren(toolbar(), state.view === "canvas" ? canvasView() : listView()); + const dialog = inspector(); + if (dialog) { + root.append(dialog); + requestAnimationFrame(() => dialog.showModal()); + } + requestAnimationFrame(updateSearchVisibility); + } + + window.addEventListener("hashchange", () => { + parseHash(); + render(); + }); + window.addEventListener("resize", scheduleCanvasNavigationUpdate); + window.addEventListener("keydown", event => { + if (event.metaKey || event.ctrlKey || event.altKey) return; + const target = event.target; + const isEditing = target instanceof HTMLInputElement + || target instanceof HTMLSelectElement + || target instanceof HTMLTextAreaElement + || target?.isContentEditable; + if (event.key === "/" && !isEditing) { + event.preventDefault(); + document.getElementById("global-search")?.focus(); + return; + } + if (isEditing) return; + if (state.screen && event.key === "[") { + event.preventDefault(); + openScreen(neighboringScreen(-1)?.id); + } else if (state.screen && event.key === "]") { + event.preventDefault(); + openScreen(neighboringScreen(1)?.id); + } else if (!state.screen && state.view === "canvas" && event.key.toLocaleLowerCase() === "f") { + event.preventDefault(); + fitAll(); + } else if (!state.screen && state.view === "canvas" && event.key === "0") { + event.preventDefault(); + fitCurrentGroup(); + } else if (!state.screen && state.view === "canvas" && (event.key === "+" || event.key === "=")) { + event.preventDefault(); + setZoom(state.zoom + 0.1); + } else if (!state.screen && state.view === "canvas" && event.key === "-") { + event.preventDefault(); + setZoom(state.zoom - 0.1); + } + }); + parseHash(); + render(); +})(); diff --git a/Shared/Flyover/Web/assets/styles.css b/Shared/Flyover/Web/assets/styles.css new file mode 100644 index 00000000..74b1242e --- /dev/null +++ b/Shared/Flyover/Web/assets/styles.css @@ -0,0 +1,1524 @@ +:root { + color-scheme: light dark; + font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif; + font-synthesis: none; + --background: #f4f5f7; + --canvas: #e9ebef; + --surface: #ffffff; + --surface-raised: #ffffff; + --surface-muted: #f1f2f5; + --surface-hover: #e9ecf3; + --text: #17191f; + --secondary: #656b78; + --tertiary: #8a909c; + --border: #d9dce3; + --border-strong: #c6cad3; + --accent: #405de6; + --accent-strong: #2f48c7; + --accent-soft: #e8ecff; + --modal: #8c52d8; + --modal-soft: #f0e7fc; + --success: #198754; + --danger: #a94300; + --focus: #4967ff; + --shadow-sm: 0 1px 2px rgb(19 25 39 / 7%), 0 2px 8px rgb(19 25 39 / 5%); + --shadow-md: 0 12px 36px rgb(19 25 39 / 13%), 0 2px 8px rgb(19 25 39 / 7%); + --shadow-lg: 0 28px 80px rgb(8 12 24 / 28%); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #111318; + --canvas: #0d0f13; + --surface: #1a1d24; + --surface-raised: #20242c; + --surface-muted: #252932; + --surface-hover: #2c313c; + --text: #f4f5f7; + --secondary: #b0b5c0; + --tertiary: #818894; + --border: #343944; + --border-strong: #474d5a; + --accent: #8398ff; + --accent-strong: #9cabff; + --accent-soft: #252d52; + --modal: #c18cf6; + --modal-soft: #352749; + --success: #5bc990; + --danger: #ffad70; + --focus: #91a3ff; + --shadow-sm: 0 1px 2px rgb(0 0 0 / 25%), 0 3px 12px rgb(0 0 0 / 18%); + --shadow-md: 0 14px 42px rgb(0 0 0 / 35%); + --shadow-lg: 0 32px 90px rgb(0 0 0 / 55%); + } +} + +* { + box-sizing: border-box; +} + +html, +body { + width: 100%; + min-width: 20rem; + height: 100%; + margin: 0; +} + +body { + overflow: hidden; + background: var(--background); + color: var(--text); + font-size: 14px; + -webkit-font-smoothing: antialiased; +} + +#app { + display: flex; + min-height: 100%; + flex-direction: column; +} + +button, +input, +select { + font: inherit; +} + +button, +select, +input[type="search"] { + min-height: 2.25rem; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface); + color: var(--text); +} + +button { + padding: 0.45rem 0.75rem; + cursor: pointer; + font-weight: 650; + transition: border-color 120ms ease, background-color 120ms ease, color 120ms ease, transform 120ms ease; +} + +button:hover { + border-color: var(--border-strong); + background: var(--surface-hover); +} + +button:active { + transform: translateY(1px); +} + +select { + min-width: 8rem; + padding: 0.4rem 2rem 0.4rem 0.65rem; +} + +:focus-visible { + outline: 3px solid color-mix(in srgb, var(--focus) 75%, transparent); + outline-offset: 2px; +} + +a { + color: var(--accent); +} + +h1, +h2, +h3, +p, +dl, +dd { + margin: 0; +} + +.eyebrow { + color: var(--tertiary); + font-size: 0.68rem; + font-weight: 750; + letter-spacing: 0.095em; + line-height: 1.3; + text-transform: uppercase; +} + +.secondary { + color: var(--secondary); +} + +.error { + width: min(42rem, calc(100% - 2rem)); + margin: 10vh auto; + padding: 2rem; + border: 1px solid #d35a5a; + border-radius: 18px; + background: var(--surface); + box-shadow: var(--shadow-md); +} + +.error h1 { + margin: 0.3rem 0 0.75rem; +} + +.app-header { + position: relative; + z-index: 30; + flex: none; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 94%, transparent); + box-shadow: 0 1px 0 rgb(255 255 255 / 25%); + backdrop-filter: blur(18px) saturate(145%); +} + +.masthead, +.primary-toolbar, +.context-toolbar { + display: flex; + min-width: 0; + align-items: center; +} + +.masthead { + min-height: 4.25rem; + justify-content: space-between; + gap: 1rem; + padding: 0.65rem 1rem 0.55rem; +} + +.brand { + display: flex; + min-width: 0; + gap: 0.7rem; + align-items: center; +} + +.brand-mark { + display: grid; + width: 2.35rem; + height: 2.35rem; + flex: none; + place-items: center; + border-radius: 11px; + background: linear-gradient(145deg, #6079f7, #334cc8); + box-shadow: 0 5px 14px rgb(64 93 230 / 30%); + color: white; + font-size: 1.05rem; + font-weight: 800; +} + +.brand h1 { + overflow: hidden; + font-size: 1.15rem; + letter-spacing: -0.025em; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; +} + +.build-details { + position: relative; +} + +.build-details summary, +.filter-menu summary { + display: flex; + min-height: 2.25rem; + cursor: pointer; + list-style: none; + align-items: center; + gap: 0.5rem; + padding: 0.42rem 0.65rem; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface); + color: var(--secondary); + font-size: 0.78rem; + font-weight: 650; +} + +.build-details summary::-webkit-details-marker, +.filter-menu summary::-webkit-details-marker { + display: none; +} + +.build-details summary::after, +.filter-menu summary::after { + content: "⌄"; + color: var(--tertiary); +} + +.build-details[open] summary, +.filter-menu[open] summary { + border-color: var(--accent); +} + +.commit-dot { + width: 0.45rem; + height: 0.45rem; + border-radius: 50%; + background: var(--success); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--success) 15%, transparent); +} + +.build-details code { + color: var(--text); + font: 0.76rem ui-monospace, "SFMono-Regular", Consolas, monospace; +} + +.dirty { + padding: 0.18rem 0.4rem; + border-radius: 999px; + background: color-mix(in srgb, var(--danger) 15%, transparent); + color: var(--danger); + font-size: 0.65rem; + font-weight: 800; + text-transform: uppercase; +} + +.build-panel, +.filter-panel { + position: absolute; + z-index: 50; + top: calc(100% + 0.45rem); + right: 0; + min-width: 22rem; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 13px; + background: var(--surface-raised); + box-shadow: var(--shadow-md); +} + +.build-panel { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.55rem 1rem; +} + +.metadata-row { + display: contents; +} + +.metadata-row dt { + color: var(--tertiary); + font-size: 0.75rem; + font-weight: 650; +} + +.metadata-row dd { + overflow-wrap: anywhere; + color: var(--text); + font-size: 0.75rem; +} + +.primary-toolbar { + min-height: 3.25rem; + gap: 0.6rem; + padding: 0 1rem 0.65rem; +} + +.view-tabs, +.scale-tabs { + display: inline-flex; + flex: none; + gap: 0.18rem; + padding: 0.2rem; + border-radius: 10px; + background: var(--surface-muted); +} + +.view-tabs button, +.scale-tabs button { + min-height: 1.9rem; + padding: 0.3rem 0.72rem; + border-color: transparent; + background: transparent; + color: var(--secondary); +} + +.view-tabs button[aria-pressed="true"], +.scale-tabs button[aria-pressed="true"] { + background: var(--surface-raised); + box-shadow: var(--shadow-sm); + color: var(--text); +} + +.search-field { + display: flex; + min-width: 12rem; + max-width: 34rem; + height: 2.25rem; + flex: 1 1 28rem; + align-items: center; + gap: 0.45rem; + padding: 0 0.55rem; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--surface); +} + +.search-field:focus-within { + border-color: var(--focus); + outline: 3px solid color-mix(in srgb, var(--focus) 18%, transparent); +} + +.search-field input { + width: 100%; + min-width: 0; + min-height: auto; + padding: 0; + border: 0; + outline: 0; + background: transparent; +} + +.search-icon { + color: var(--tertiary); + font-size: 1.25rem; + line-height: 1; +} + +kbd { + padding: 0.08rem 0.35rem; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--surface-muted); + color: var(--tertiary); + font: 0.68rem ui-monospace, monospace; +} + +.filter-menu { + position: relative; + flex: none; +} + +.filter-menu summary::before { + content: "≡"; + color: var(--secondary); + font-size: 1rem; +} + +.filter-panel { + display: grid; + min-width: 17rem; + gap: 0.8rem; +} + +.filter-panel .field { + display: grid; + grid-template-columns: 5rem minmax(0, 1fr); +} + +.filter-panel select { + width: 100%; +} + +.clear-filters { + justify-self: end; +} + +.profile-field, +.field { + display: flex; + align-items: center; + gap: 0.45rem; +} + +.field-label { + color: var(--secondary); + font-size: 0.72rem; + font-weight: 700; +} + +.context-toolbar { + min-height: 2.6rem; + justify-content: space-between; + gap: 1rem; + padding: 0.35rem 1rem; + border-top: 1px solid color-mix(in srgb, var(--border) 70%, transparent); + background: color-mix(in srgb, var(--surface-muted) 75%, transparent); +} + +.result-count { + flex: none; + color: var(--secondary); + font-size: 0.76rem; + font-weight: 650; +} + +.canvas-controls { + display: flex; + min-width: 0; + align-items: center; + gap: 0.35rem; +} + +.canvas-controls input[type="range"] { + width: clamp(5rem, 10vw, 9rem); + accent-color: var(--accent); +} + +.icon-button { + display: inline-grid; + width: 2.25rem; + padding: 0; + place-items: center; + font-size: 1rem; +} + +.zoom-value { + width: 3.2rem; + color: var(--secondary); + font: 0.72rem ui-monospace, monospace; + text-align: center; +} + +.primary-button { + border-color: var(--accent); + background: var(--accent); + color: white; +} + +.primary-button:hover { + border-color: var(--accent-strong); + background: var(--accent-strong); +} + +.canvas-layout { + position: relative; + display: grid; + min-height: 0; + flex: 1 1 auto; + grid-template-columns: 15.5rem minmax(0, 1fr); + overflow: hidden; +} + +.canvas-sidebar { + position: relative; + z-index: 10; + display: flex; + min-height: 0; + flex-direction: column; + gap: 1rem; + padding: 1.15rem 0.9rem; + overflow: auto; + border-right: 1px solid var(--border); + background: var(--surface); +} + +.sidebar-heading { + padding: 0 0.4rem; +} + +.sidebar-heading h2 { + margin-top: 0.18rem; + font-size: 1rem; +} + +.group-navigation { + display: grid; + gap: 0.28rem; +} + +.group-button { + display: grid; + width: 100%; + min-height: 3.35rem; + grid-template-columns: 2rem minmax(0, 1fr); + gap: 0.55rem; + align-items: center; + padding: 0.45rem; + border-color: transparent; + background: transparent; + text-align: left; +} + +.group-button[aria-current="true"] { + border-color: color-mix(in srgb, var(--accent) 22%, transparent); + background: var(--accent-soft); + color: var(--accent-strong); +} + +.group-button[data-empty="true"] { + opacity: 0.42; +} + +.group-index { + display: grid; + width: 2rem; + height: 2rem; + place-items: center; + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface-raised); + color: var(--tertiary); + font: 0.68rem ui-monospace, monospace; +} + +.group-button > span:last-child { + display: grid; + min-width: 0; + gap: 0.12rem; +} + +.group-button strong { + overflow: hidden; + font-size: 0.82rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.group-button small { + color: var(--tertiary); + font-size: 0.7rem; + font-weight: 500; +} + +.mini-map { + margin-top: auto; + padding: 0.75rem; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--surface-muted); +} + +.mini-map .eyebrow { + margin-bottom: 0.55rem; +} + +.mini-map svg { + display: block; + width: 100%; + height: 7.5rem; + cursor: crosshair; +} + +.mini-group { + fill: color-mix(in srgb, var(--secondary) 9%, transparent); + stroke: var(--border-strong); + stroke-width: 22px; +} + +.mini-group[data-active="true"] { + fill: color-mix(in srgb, var(--accent) 10%, transparent); + stroke: var(--accent); +} + +.mini-screen { + fill: var(--secondary); + opacity: 0.5; +} + +.mini-screen[data-hidden="true"] { + opacity: 0.08; +} + +.mini-viewport { + fill: color-mix(in srgb, var(--accent) 8%, transparent); + stroke: var(--accent); + stroke-width: 28px; + vector-effect: non-scaling-stroke; +} + +.route-legend { + display: flex; + justify-content: center; + gap: 1rem; + color: var(--secondary); + font-size: 0.7rem; +} + +.route-legend span { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.route-legend i { + display: block; + width: 1rem; + height: 2px; + background: var(--accent); +} + +.route-legend i.modal { + background: repeating-linear-gradient(90deg, var(--modal) 0 4px, transparent 4px 7px); +} + +.keyboard-hint { + color: var(--tertiary); + font-size: 0.68rem; + text-align: center; +} + +.canvas-viewport { + position: relative; + min-width: 0; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + background-color: var(--canvas); + background-image: + linear-gradient(color-mix(in srgb, var(--secondary) 7%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--secondary) 7%, transparent) 1px, transparent 1px); + background-size: 24px 24px; + scrollbar-color: var(--border-strong) transparent; +} + +.canvas-scaled { + position: relative; +} + +.canvas-stage { + position: absolute; + transform-origin: top left; +} + +.group-shelf, +.depth-band, +.screen-card, +.canvas-routes { + position: absolute; +} + +.group-shelf { + border: 1px solid color-mix(in srgb, var(--border-strong) 75%, transparent); + border-radius: 32px; + background: color-mix(in srgb, var(--surface) 48%, transparent); + box-shadow: inset 0 1px 0 rgb(255 255 255 / 38%); +} + +.group-shelf[data-empty="true"] { + opacity: 0.35; +} + +.shelf-header { + position: sticky; + left: 0; + width: max-content; + padding: 17px 22px 0; +} + +.shelf-header h2 { + margin-top: 2px; + font-size: 20px; + letter-spacing: -0.025em; +} + +.depth-band { + border: 1px dashed color-mix(in srgb, var(--secondary) 35%, transparent); + border-radius: 22px; +} + +.depth-band[data-empty="true"] { + opacity: 0.24; +} + +.depth-band span { + position: absolute; + top: -10px; + left: 14px; + padding: 2px 7px; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: 999px; + background: var(--surface); + color: var(--secondary); + font-size: 10px; + font-weight: 750; + letter-spacing: 0.045em; + text-transform: uppercase; +} + +.canvas-routes { + inset: 0; + overflow: visible; + pointer-events: none; +} + +.route { + transition: opacity 130ms ease; +} + +.route path { + fill: none; + stroke-width: 3; + vector-effect: non-scaling-stroke; +} + +.route-push path { + stroke: var(--accent); +} + +.route-push polygon { + fill: var(--accent); +} + +.route-modal path { + stroke: var(--modal); + stroke-dasharray: 12 8; +} + +.route-modal polygon { + fill: var(--modal); +} + +.route[data-route-relation="focus"] path { + stroke-width: 5; +} + +.route[data-route-relation="unrelated"] { + opacity: 0.1; +} + +.route[data-hidden="true"] { + opacity: 0.04; +} + +.screen-card { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 20px; + background: var(--surface-raised); + box-shadow: var(--shadow-sm); + transition: opacity 140ms ease, border-color 140ms ease, box-shadow 140ms ease, transform 140ms ease; +} + +.screen-card:hover, +.screen-card:focus-within, +.screen-card[data-route-relation="focus"] { + z-index: 4; + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 11%, transparent), var(--shadow-md); + transform: translateY(-3px); +} + +.screen-card[data-route-relation="upstream"] { + border-color: var(--secondary); +} + +.screen-card[data-route-relation="downstream"] { + border-color: var(--accent); +} + +.screen-card[data-route-relation="unrelated"] { + opacity: 0.26; +} + +.screen-card[data-hidden="true"] { + visibility: hidden; + pointer-events: none; + opacity: 0; +} + +.card-header { + display: flex; + min-width: 0; + align-items: flex-start; + justify-content: space-between; + gap: 8px; + padding: 16px 16px 7px; +} + +.card-header > div { + min-width: 0; +} + +.card-header h3 { + overflow: hidden; + font-size: 16px; + letter-spacing: -0.018em; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.card-header p { + margin-top: 2px; + overflow: hidden; + color: var(--tertiary); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.route-count { + flex: none; + padding: 4px 7px; + border-radius: 999px; + background: var(--surface-muted); + color: var(--secondary); + font-size: 10px; + font-weight: 700; +} + +.card-state { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 7px; + align-items: center; + padding: 0 16px 10px; +} + +.card-state select { + width: 100%; + min-width: 0; + min-height: 30px; + padding-top: 3px; + padding-bottom: 3px; + font-size: 11px; +} + +.single-state { + padding: 1px 16px 11px; + color: var(--secondary); + font-size: 11px; +} + +.card-image-button { + display: grid; + min-height: 0; + margin: 0 12px; + padding: 12px; + overflow: hidden; + place-items: center; + border-color: color-mix(in srgb, var(--border) 75%, transparent); + border-radius: 14px; + background: + radial-gradient(circle at 50% 25%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 56%), + var(--surface-muted); +} + +.device-preview { + display: flex; + width: 100%; + height: 100%; + min-height: 0; + align-items: center; + justify-content: center; +} + +.device-preview img { + display: block; + max-width: 100%; + height: 100%; + max-height: 100%; + border-radius: 9px; + object-fit: contain; + box-shadow: 0 6px 22px rgb(12 18 35 / 18%); +} + +.card-footer { + display: flex; + min-height: 47px; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 7px 13px 9px; +} + +.capture-badge { + display: inline-flex; + align-items: center; + gap: 4px; + color: var(--secondary); + font-size: 10px; + font-weight: 750; + letter-spacing: 0.035em; + text-transform: uppercase; +} + +.capture-badge::before { + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--success); + content: ""; +} + +.inspect-link { + min-height: auto; + padding: 5px 7px; + border-color: transparent; + background: transparent; + color: var(--accent); + font-size: 11px; +} + +.list { + flex: 1 1 auto; + padding: 2rem clamp(1rem, 4vw, 4rem) 5rem; + overflow: auto; + background: var(--background); +} + +.list-intro, +.list-group { + width: min(88rem, 100%); + margin-right: auto; + margin-left: auto; +} + +.list-intro { + padding: 0.5rem 0 2rem; +} + +.list-intro h2 { + margin: 0.25rem 0 0.45rem; + font-size: clamp(1.5rem, 2.5vw, 2.2rem); + letter-spacing: -0.035em; +} + +.list-intro > p:last-child { + color: var(--secondary); +} + +.list-group { + margin-bottom: 2.3rem; +} + +.list-group-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 1rem; + padding: 0 0.25rem 0.7rem; +} + +.list-group-header h2 { + font-size: 1rem; +} + +.list-group-header span { + color: var(--tertiary); + font-size: 0.75rem; +} + +.list-rows { + display: grid; + gap: 0.55rem; +} + +.list-row { + display: grid; + min-width: 0; + grid-template-columns: 7rem minmax(12rem, 1.2fr) minmax(9rem, 0.65fr) minmax(14rem, 1fr) auto; + gap: 1rem; + align-items: center; + padding: 0.7rem; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--surface); + box-shadow: var(--shadow-sm); + transition: border-color 120ms ease, box-shadow 120ms ease; +} + +.list-row:hover { + border-color: var(--border-strong); + box-shadow: var(--shadow-md); +} + +.list-row[data-hidden="true"] { + display: none; +} + +.list-thumbnail { + display: grid; + width: 7rem; + height: 6.25rem; + padding: 0.45rem; + overflow: hidden; + place-items: center; + background: var(--surface-muted); +} + +.list-thumbnail img { + width: 100%; + height: 100%; + border-radius: 5px; + object-fit: contain; +} + +.list-identity { + display: grid; + min-width: 0; + gap: 0.22rem; +} + +.list-identity h3 { + overflow: hidden; + font-size: 1rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.list-state { + margin-top: 0.35rem; +} + +.list-state select { + min-width: 0; + max-width: 15rem; +} + +.list-traits { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; +} + +.list-traits span { + padding: 0.25rem 0.45rem; + border-radius: 6px; + background: var(--surface-muted); + color: var(--secondary); + font-size: 0.68rem; +} + +.list-routes { + display: flex; + min-width: 0; + flex-wrap: wrap; + gap: 0.35rem; + align-items: center; +} + +.list-routes > p { + width: 100%; + font-size: 0.72rem; +} + +.route-chip { + min-height: 1.8rem; + padding: 0.25rem 0.5rem; + overflow: hidden; + border-color: color-mix(in srgb, var(--accent) 25%, var(--border)); + background: var(--accent-soft); + color: var(--accent-strong); + font-size: 0.68rem; + text-align: left; + text-overflow: ellipsis; + white-space: nowrap; +} + +.route-chip.modal { + border-color: color-mix(in srgb, var(--modal) 25%, var(--border)); + background: var(--modal-soft); + color: var(--modal); +} + +.list-inspect { + min-width: 5rem; +} + +.empty-results { + position: absolute; + z-index: 12; + top: 50%; + left: calc(50% + 7.75rem); + width: min(27rem, calc(100% - 2rem)); + padding: 2rem; + transform: translate(-50%, -50%); + border: 1px solid var(--border); + border-radius: 18px; + background: var(--surface-raised); + box-shadow: var(--shadow-md); + text-align: center; +} + +.list > .empty-results { + position: relative; + top: auto; + left: auto; + margin: 4rem auto; + transform: none; +} + +.empty-results[hidden] { + display: none; +} + +.empty-results h2 { + margin: 0.3rem 0 0.45rem; +} + +.empty-results > p:not(.eyebrow) { + margin-bottom: 1rem; + color: var(--secondary); +} + +dialog.inspector { + width: min(96vw, 88rem); + height: min(92vh, 66rem); + max-width: none; + max-height: none; + padding: 0; + overflow: hidden; + border: 1px solid var(--border-strong); + border-radius: 20px; + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow-lg); +} + +dialog.inspector::backdrop { + background: rgb(7 10 18 / 68%); + backdrop-filter: blur(5px); +} + +.inspector-header { + display: flex; + min-height: 4.6rem; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.8rem 1rem 0.8rem 1.25rem; + border-bottom: 1px solid var(--border); +} + +.inspector-header h2 { + margin-top: 0.18rem; + font-size: 1.25rem; + letter-spacing: -0.025em; +} + +.inspector-header-actions { + display: flex; + gap: 0.4rem; +} + +.close { + margin-left: 0.2rem; +} + +.inspector-controls { + display: flex; + min-height: 3.6rem; + flex-wrap: wrap; + align-items: center; + gap: 0.75rem; + padding: 0.55rem 1.25rem; + border-bottom: 1px solid var(--border); + background: var(--surface-muted); +} + +.inspector-controls .scale-tabs { + margin-left: auto; +} + +.inspector-body { + display: grid; + height: calc(100% - 8.2rem); + min-height: 0; + grid-template-columns: minmax(0, 1fr) 20rem; +} + +.inspector-preview { + display: flex; + min-width: 0; + min-height: 0; + flex-direction: column; + background: var(--canvas); +} + +.preview-toolbar { + display: flex; + min-height: 2.9rem; + flex: none; + align-items: center; + gap: 0.7rem; + padding: 0.5rem 0.8rem; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 88%, transparent); +} + +.preview-toolbar .secondary { + overflow: hidden; + font: 0.68rem ui-monospace, monospace; + text-overflow: ellipsis; + white-space: nowrap; +} + +.raw-link { + margin-left: auto; + font-size: 0.72rem; + font-weight: 700; + text-decoration: none; +} + +.raw-link:hover { + text-decoration: underline; +} + +.inspector-image { + display: flex; + min-height: 0; + flex: 1 1 auto; + align-items: flex-start; + justify-content: center; + padding: clamp(1rem, 3vw, 2.5rem); + overflow: auto; + overscroll-behavior: contain; + background-image: + linear-gradient(45deg, color-mix(in srgb, var(--secondary) 5%, transparent) 25%, transparent 25%), + linear-gradient(-45deg, color-mix(in srgb, var(--secondary) 5%, transparent) 25%, transparent 25%); + background-position: 0 0, 8px 8px; + background-size: 16px 16px; +} + +.inspector-device { + display: flex; + width: calc(var(--point-width) + 16px); + max-width: 100%; + flex: none; + align-items: flex-start; + justify-content: center; + padding: 8px; + border-radius: 18px; + background: #11141a; + box-shadow: 0 18px 50px rgb(5 8 16 / 30%); +} + +.inspector-image img { + display: block; + width: 100%; + max-width: 100%; + height: auto; + border-radius: 10px; +} + +.inspector-image.fit .inspector-device { + max-width: 100%; +} + +.inspector-image.fit img { + max-width: 100%; +} + +.inspector-image.actual { + justify-content: flex-start; +} + +.inspector-image.actual img { + width: 100%; + max-width: none; +} + +.inspector-image.actual .inspector-device { + max-width: none; +} + +.inspector-image.full-content { + justify-content: center; +} + +.inspector-image.full-content .inspector-device { + max-width: none; +} + +.inspector-details { + min-height: 0; + padding: 1.1rem; + overflow: auto; + border-left: 1px solid var(--border); + background: var(--surface); +} + +.detail-section { + padding-bottom: 1.15rem; + margin-bottom: 1.15rem; + border-bottom: 1px solid var(--border); +} + +.detail-section:last-child { + margin-bottom: 0; + border-bottom: 0; +} + +.detail-list { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.55rem 0.8rem; + margin-top: 0.75rem; +} + +.detail-list dt { + color: var(--tertiary); + font-size: 0.72rem; +} + +.detail-list dd { + overflow-wrap: anywhere; + font-size: 0.72rem; + text-align: right; +} + +.detail-section .route-links { + display: grid; + gap: 0.4rem; + margin-top: 0.65rem; +} + +.detail-section .route-chip { + width: 100%; +} + +.no-routes { + color: var(--tertiary); + font-size: 0.75rem; +} + +@media (max-width: 1050px) { + .canvas-layout { + grid-template-columns: 12.5rem minmax(0, 1fr); + } + + .mini-map { + display: none; + } + + .keyboard-hint { + margin-top: auto; + } + + .list-row { + grid-template-columns: 6rem minmax(12rem, 1fr) minmax(12rem, 1fr) auto; + } + + .list-thumbnail { + width: 6rem; + } + + .list-traits { + display: none; + } + + .inspector-body { + grid-template-columns: minmax(0, 1fr) 17rem; + } +} + +@media (max-width: 760px) { + body { + overflow: auto; + } + + #app { + min-height: 100dvh; + } + + .masthead { + min-height: 3.7rem; + } + + .build-details { + display: none; + } + + .primary-toolbar { + flex-wrap: wrap; + } + + .search-field { + order: 3; + max-width: none; + flex-basis: 100%; + } + + .profile-field .field-label { + display: none; + } + + .context-toolbar { + overflow-x: auto; + } + + .canvas-controls { + margin-left: auto; + } + + .canvas-controls input[type="range"], + .zoom-value, + .canvas-controls button:nth-last-child(2) { + display: none; + } + + .canvas-layout { + min-height: 34rem; + grid-template-columns: 1fr; + } + + .canvas-sidebar { + display: none; + } + + .empty-results { + left: 50%; + } + + .list { + overflow: visible; + } + + .list-row { + grid-template-columns: 5rem minmax(0, 1fr) auto; + } + + .list-thumbnail { + width: 5rem; + height: 5rem; + } + + .list-routes { + display: none; + } + + dialog.inspector { + width: 100vw; + height: 100dvh; + max-height: 100dvh; + border: 0; + border-radius: 0; + } + + .inspector-controls { + flex-wrap: nowrap; + overflow-x: auto; + } + + .inspector-controls .field-label { + display: none; + } + + .inspector-controls .scale-tabs { + margin-left: 0; + } + + .inspector-body { + height: calc(100% - 8.2rem); + grid-template-columns: 1fr; + overflow: auto; + } + + .inspector-preview { + min-height: 65vh; + } + + .inspector-details { + overflow: visible; + border-top: 1px solid var(--border); + border-left: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + transition: none !important; + animation: none !important; + } +} diff --git a/Shared/Flyover/Web/index.html b/Shared/Flyover/Web/index.html new file mode 100644 index 00000000..475c50a2 --- /dev/null +++ b/Shared/Flyover/Web/index.html @@ -0,0 +1,16 @@ + + + + + + + Flyover QA Atlas + + + + +
+ + + + diff --git a/Shared/SnapshotKitTesting/AGENTS.md b/Shared/SnapshotKitTesting/AGENTS.md index be26a63d..ba830ccf 100644 --- a/Shared/SnapshotKitTesting/AGENTS.md +++ b/Shared/SnapshotKitTesting/AGENTS.md @@ -21,6 +21,8 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. ## Invariants an agent can't re-derive - **The rendering pipeline is one async function.** All captures (standard and accessibility) flow through `renderSnapshotImage(...)`. +- **The hosted PNG API uses the same pipeline and capture lock.** It returns bytes and dimensions without comparing a reference. +- **Keep PNG export hosted.** `captureSnapshotPNG` requires `StuffTestHost`'s key window and must not gain a headless renderer. - **Its `async` is load-bearing.** A synchronous `Snapshotting` pullback could never settle `.task`-driven content. - **Accessibility annotations use AccessibilitySnapshot's SwiftUI renderer.** Keep the focused `AccessibilitySnapshotCore` + `AccessibilitySnapshotPreviews` products. - **The umbrella also links the upstream SnapshotTesting integration that this module replaces.** diff --git a/Shared/SnapshotKitTesting/README.md b/Shared/SnapshotKitTesting/README.md index 415f12b1..f87a76b4 100644 --- a/Shared/SnapshotKitTesting/README.md +++ b/Shared/SnapshotKitTesting/README.md @@ -102,6 +102,19 @@ Reference images are written next to the test file under `__Snapshots__/` and are stored in Git LFS (see the root `.gitattributes`). Recording a new image is a failure by design, so a run that records can't be mistaken for a pass. +## Hosted PNG capture + +`captureSnapshotPNG` uses the same hosted renderer without comparing a +reference image. It accepts the view, configuration, name, sizing strategy, +safe-area insets, readiness hooks, and settle policy. The returned +`SnapshotPNG` contains PNG data, point and pixel dimensions, and image scale. + +The operation holds the existing capture lock. It also applies snapshot traits, +full-content measurement, accessibility annotation, settling, and PNG +round-tripping. Call it only from a test bundle hosted by `StuffTestHost`; it +needs the host app's key window. A rendering or encoding failure throws. The +caller must not publish a partial artifact. + ## Recording `assertSnapshots` defaults to the `.missing` mode (records only images that diff --git a/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift b/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift index 9f6b949a..0afd5ba0 100644 --- a/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift +++ b/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift @@ -305,57 +305,3 @@ private func simulatorMatchesSnapshotExpectations() -> Bool { } return true } - -/// Builds a hosting controller for `view` with the configuration's appearance -/// traits applied and a starting frame set. Dynamic Type, color scheme, layout -/// direction, and legibility weight are applied through the SwiftUI environment -/// (so measurement reflects them) and mirrored onto UIKit trait overrides (for -/// any embedded UIKit); increased contrast — which SwiftUI can't set — is a -/// trait override only. Intrinsic components get only their width here; the -/// pipeline measures their height after the content settles. -/// -/// SwiftUI transaction animations are disabled at the root: every state change -/// in the hosted tree commits its end state instantly instead of animating, so -/// finite time-based reveals no longer "run to completion" during settle — there -/// is no mid-flight frame to catch. The settle loop remains for `.task`-driven -/// async content, which still needs real suspension time to load. -@MainActor -private func makeHostingController( - for view: some View, - configuration: SnapshotConfiguration, -) -> UIViewController { - let styled = view - .environment(\.colorScheme, configuration.colorScheme) - .dynamicTypeSize(configuration.dynamicType) - .environment(\.layoutDirection, configuration.layoutDirection) - .environment(\.legibilityWeight, configuration.legibilityWeight) - .transaction { - $0.disablesAnimations = true - $0.animation = nil - } - let hostingController = UIHostingController(rootView: styled) - hostingController.view.backgroundColor = .clear - - let traits = configuration.uiTraitCollection - hostingController.traitOverrides.userInterfaceStyle = traits.userInterfaceStyle - hostingController.traitOverrides.preferredContentSizeCategory = traits - .preferredContentSizeCategory - hostingController.traitOverrides.accessibilityContrast = traits.accessibilityContrast - hostingController.traitOverrides.layoutDirection = traits.layoutDirection - hostingController.traitOverrides.legibilityWeight = traits.legibilityWeight - - switch configuration.device.size { - case let .fixed(size): - hostingController.view.frame = CGRect(origin: .zero, size: size) - case let .intrinsic(maxWidth): - let width = maxWidth ?? UIScreen.main.bounds.width - hostingController.view.frame = CGRect(x: 0, y: 0, width: width, height: 1) - case let .fullContent(width, minimumHeight): - let height = minimumHeight ?? 1 - hostingController.view.frame = CGRect(x: 0, y: 0, width: width, height: height) - case let .fullContent2D(minimumSize): - hostingController.view.frame = CGRect(origin: .zero, size: minimumSize) - } - - return hostingController -} diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotHostingController.swift b/Shared/SnapshotKitTesting/Sources/SnapshotHostingController.swift new file mode 100644 index 00000000..eed7e079 --- /dev/null +++ b/Shared/SnapshotKitTesting/Sources/SnapshotHostingController.swift @@ -0,0 +1,45 @@ +import SnapshotKit +import SwiftUI +import UIKit + +/// Builds a host with the configuration's SwiftUI and UIKit traits. +@MainActor +func makeHostingController( + for view: some View, + configuration: SnapshotConfiguration, +) -> UIViewController { + let styled = view + .environment(\.colorScheme, configuration.colorScheme) + .dynamicTypeSize(configuration.dynamicType) + .environment(\.layoutDirection, configuration.layoutDirection) + .environment(\.legibilityWeight, configuration.legibilityWeight) + .transaction { + $0.disablesAnimations = true + $0.animation = nil + } + let hostingController = UIHostingController(rootView: styled) + hostingController.view.backgroundColor = .clear + + let traits = configuration.uiTraitCollection + hostingController.traitOverrides.userInterfaceStyle = traits.userInterfaceStyle + hostingController.traitOverrides.preferredContentSizeCategory = traits + .preferredContentSizeCategory + hostingController.traitOverrides.accessibilityContrast = traits.accessibilityContrast + hostingController.traitOverrides.layoutDirection = traits.layoutDirection + hostingController.traitOverrides.legibilityWeight = traits.legibilityWeight + + switch configuration.device.size { + case let .fixed(size): + hostingController.view.frame = CGRect(origin: .zero, size: size) + case let .intrinsic(maxWidth): + let width = maxWidth ?? UIScreen.main.bounds.width + hostingController.view.frame = CGRect(x: 0, y: 0, width: width, height: 1) + case let .fullContent(width, minimumHeight): + let height = minimumHeight ?? 1 + hostingController.view.frame = CGRect(x: 0, y: 0, width: width, height: height) + case let .fullContent2D(minimumSize): + hostingController.view.frame = CGRect(origin: .zero, size: minimumSize) + } + + return hostingController +} diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift b/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift index 2fc3f9c7..6f83692a 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift @@ -58,6 +58,20 @@ public enum SnapshotRenderingError: Error, Equatable, Sendable { maximumPixelDimension: Int, maximumPixelCount: Int, ) + /// The rendered image could not be encoded as PNG bytes. + case pngEncodingFailed(name: String) + /// The PNG round-trip did not produce a Core Graphics image. + case missingCGImage(name: String) + /// The hosted pixels were still changing when the settle budget ended. + case settleTimedOut(name: String, phase: String, viewType: String, budget: TimeInterval) + /// The host could not complete enough render passes to prove stability. + case settleStarved( + name: String, + phase: String, + viewType: String, + passes: Int, + cap: TimeInterval, + ) } extension SnapshotRenderingError: LocalizedError { @@ -86,6 +100,14 @@ extension SnapshotRenderingError: LocalizedError { maximumPixelCount, ): return "Snapshot \(name) would render at \(pixelWidth)×\(pixelHeight) pixels; two-axis captures are limited to \(maximumPixelDimension) pixels per dimension and \(maximumPixelCount) pixels total." + case let .pngEncodingFailed(name): + return "Snapshot \(name) could not be encoded as a PNG image." + case let .missingCGImage(name): + return "Snapshot \(name) did not produce a Core Graphics image." + case let .settleTimedOut(name, phase, viewType, budget): + return "Snapshot \(name) never settled during \(phase) for \(viewType) within \(budget.formatted())s. Freeze endless motion behind `\\.isCapturingSnapshot`, or raise the settle floor for finite work." + case let .settleStarved(name, phase, viewType, passes, cap): + return "Snapshot \(name) settle starved during \(phase) for \(viewType): only \(passes) render passes completed in \(cap.formatted())s." } } } @@ -292,7 +314,7 @@ private func renderSnapshotImageLocked( } defer { removeChildAfterCapture(wrappingViewController) } - await reportIfUnsettled( + try await throwIfUnsettled( timing.measure(.settle) { await settleForCapture( wrappingViewController.view, @@ -311,7 +333,7 @@ private func renderSnapshotImageLocked( // effects (a focused field, a presented state) are settled before the // accessibility parse and capture below reflect them. if let onReadyToSnapshot { - await reportIfUnsettled( + try await throwIfUnsettled( timing.measure(.hook) { await onReadyToSnapshot() wrappingViewController.view.setNeedsLayout() @@ -354,11 +376,11 @@ private func renderSnapshotImageLocked( // Round-trip through PNG bytes (preserving scale, which `UIImage(data:)` // alone would reset to 1) so the compare and the disk artifact are the // same bytes — see the doc comment above. - return timing.measure(.pngRoundTrip) { + return try timing.measure(.pngRoundTrip) { guard let pngData = image.pngData(), let decoded = UIImage(data: pngData, scale: image.scale) else { - preconditionFailure("Snapshot capture could not be PNG-encoded.") + throw SnapshotRenderingError.pngEncodingFailed(name: name) } return SnapshotCapture(image: decoded, pngData: pngData) } @@ -492,7 +514,7 @@ private func resolveContentSize( } } - await reportIfUnsettled( + try await throwIfUnsettled( timing.measure(.intrinsicMeasure) { await settleForCapture( probeWrapper.view, diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotPNG.swift b/Shared/SnapshotKitTesting/Sources/SnapshotPNG.swift new file mode 100644 index 00000000..0516ce8a --- /dev/null +++ b/Shared/SnapshotKitTesting/Sources/SnapshotPNG.swift @@ -0,0 +1,57 @@ +import SnapshotKit +import SwiftUI +import UIKit + +/// PNG bytes and dimensions from a hosted capture without a reference compare. +public struct SnapshotPNG: Sendable { + public let data: Data + public let pointSize: CGSize + public let pixelSize: CGSize + public let scale: CGFloat + + public init(data: Data, pointSize: CGSize, pixelSize: CGSize, scale: CGFloat) { + self.data = data + self.pointSize = pointSize + self.pixelSize = pixelSize + self.scale = scale + } +} + +/// Captures a configured SwiftUI view through the hosted snapshot pipeline. +@MainActor +public func captureSnapshotPNG( + of view: some View, + configuration: SnapshotConfiguration, + named name: String, + sizing: SnapshotSizing, + safeAreaInsets: UIEdgeInsets?, + measurementReadiness: SnapshotMeasurementReadiness, + onReadyToMeasure: (@MainActor () async -> Void)?, + settle: SnapshotSettle, + onReadyToSnapshot: (@MainActor () async -> Void)?, +) async throws -> SnapshotPNG { + let controller = makeHostingController(for: view, configuration: configuration) + let timeoutPolicy = try SnapshotSettleTimeoutPolicy.fromEnvironment() + let capture = try await renderSnapshotCapture( + of: controller, + named: name, + sizing: sizing, + safeAreaInsets: safeAreaInsets, + isAccessibility: configuration.snapshotType == .accessibility, + measurementReadiness: measurementReadiness, + onReadyToMeasure: onReadyToMeasure, + settle: settle, + onReadyToSnapshot: onReadyToSnapshot, + settleTimeoutPolicy: timeoutPolicy, + timing: SnapshotCaptureTiming(identifier: name, isEnabled: false), + ) + guard let image = capture.image.cgImage else { + throw SnapshotRenderingError.missingCGImage(name: name) + } + return SnapshotPNG( + data: capture.pngData, + pointSize: capture.image.size, + pixelSize: CGSize(width: image.width, height: image.height), + scale: capture.image.scale, + ) +} diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift b/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift index ae1b831d..971b23df 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift @@ -1,6 +1,5 @@ import SnapshotKit import SwiftUI -import Testing import UIKit /// How a settle phase ended. Only ``settled`` and ``skipped`` mean the pixels @@ -26,48 +25,34 @@ import UIKit case cancelled } -/// Fails the test when a settle phase ended somewhere the capture can't be -/// trusted from. A view still in motion at the budget records an arbitrary -/// frame, which is precisely how a flaky reference lands — the failure class -/// the settle loop exists to prevent — so it's louder than a log: a silent -/// timeout is indistinguishable from a clean capture in CI output. +/// Throws when a settle phase cannot produce a trustworthy capture. @MainActor -func reportIfUnsettled( +func throwIfUnsettled( _ outcome: SettleOutcome, phase: String, of viewController: UIViewController, named name: String, -) { +) throws { switch outcome { case .settled, .skipped: return case let .timedOut(budget): - Issue.record( - """ - Snapshot content never settled: the \(phase) phase for "\(name)" \ - (\(type(of: viewController))) was observed still changing \(budget.formatted())s \ - after hosting, so this capture is an arbitrary frame of whatever is still moving. \ - Freeze the motion at a deterministic phase behind `\\.isCapturingSnapshot` (the \ - Where app does this with `MotionIsStatic`), or — if the content is merely slow \ - rather than endless — raise the floor with `.settledAtLeast(minDuration:)`. - """, + throw SnapshotRenderingError.settleTimedOut( + name: name, + phase: phase, + viewType: String(reflecting: type(of: viewController)), + budget: budget, ) case let .starved(passes, cap): - Issue.record( - """ - Snapshot settle starved: the \(phase) phase for "\(name)" \ - (\(type(of: viewController))) completed only \(passes) render pass(es) in \ - \(cap.formatted())s without ever observing the content change, so pixel \ - stability could not be confirmed. This is an environment problem (a machine too \ - loaded to complete render passes) or a view that renders no pixels (a zero-sized \ - frame) — not view motion; widening the settle budget won't fix it. - """, + throw SnapshotRenderingError.settleStarved( + name: name, + phase: phase, + viewType: String(reflecting: type(of: viewController)), + passes: passes, + cap: cap, ) case .cancelled: - // Deliberately quiet: a cancelled test is already being torn down and - // a second issue would just bury the cancellation. Skipping the assert - // outright is the real fix, tracked in TODOs.md. - return + throw CancellationError() } } diff --git a/Shared/SnapshotKitTesting/TODOs.md b/Shared/SnapshotKitTesting/TODOs.md index cb094bb0..57504bfd 100644 --- a/Shared/SnapshotKitTesting/TODOs.md +++ b/Shared/SnapshotKitTesting/TODOs.md @@ -23,12 +23,12 @@ ## P2s (Nice to have) - perf [needs-design]: A byte-equality fast path around `assertSnapshot` was measured and **declined** — recorded so it isn't re-proposed without new numbers. 49 of 52 captures are byte-identical to their references, so the hit rate is there, but the comparison is only ~7% of a capture (mean 35ms) once the drain stall is gone, capping the win at ~6% of the suite. Paying for it means letting `snapshotReferenceURL`'s replication of swift-snapshot-testing's private layout gate the pass/fail verdict: a wrong path there currently degrades to a `referenceMissing` diff line (harmless), but on the verdict path it would skip a real comparison and read as a pass. Revisit if the comparison's share grows or the library exposes its reference URL. (agent) -- fix: Cancellation mid-settle proceeds to capture and assert — `settleContent` now reports `.cancelled` but `reportIfUnsettled` deliberately stays quiet on it (`Sources/SnapshotRenderingSupport.swift:66-69`), so a cancelled test (e.g. a future time-limit trait) still captures half-settled content and records a spurious image mismatch on top of the cancellation. Propagate the outcome out of `renderSnapshotImage` so `assertSnapshots` can skip the comparison entirely, keeping cancelled tests clean. (From the July 2026 snapshot-testing PR review.) - fix: The duplicate-identifier guard only protects the provider overload of `assertSnapshots` (`Sources/AssertSnapshots.swift:30-41`) — the inline `assertSnapshots(of:named:configurations:)` overload (`:63-75`) accepts a `configurations` array containing duplicates and silently compares the second against the first's recording. Run the same guard over `[SnapshotCase(name:configurations:)]` there. (From the July 2026 snapshot-testing PR review.) # Completed issues +- fix: Cancellation mid-settle proceeded to capture and assert — settle validation now throws `CancellationError`, so `assertSnapshots` skips comparison and hosted PNG callers stop without publishing a half-settled image. (From the July 2026 snapshot-testing PR review.) (Resolved with the hosted PNG export API.) - fix: The reporting tests wrote **fabricated rows into the reports** — `./test --review` listed a reference that does not exist, at the top of the list, and `--timings` counted captures that never happened. `./test` recovers both channels by grepping `SNAPSHOT_DIFF` / `SNAPSHOT_TIMING` out of the run logs (and counts timing lines as images for the progress line), while `SnapshotDiffReporting.report(...)` and `SnapshotCaptureTiming.emit()` each encoded *and* printed in one function — so the tests pinning those wire formats emitted real lines. The diff fixture sorted first, because its numbers were borrowed from the genuine `swiftDataInspector` regression (max delta 203, 7430 pixels, 0.235%), which made the one row most demanding investigation the one that wasn't real; `SnapshotCaptureTimingTests` contributed five invented captures, so `./test --only SnapshotKitTestingTests --timings` reported "5 captures, 0.1s total, 0.024s per image" for a run that captured nothing, and `--everything --timings` blended those into the aggregate the suite's perf decisions are read off. The `SNAPSHOT_DIFF` env gate never helped: it is checked by the *pipeline*, not inside `report`. (Resolved: each channel is split so printing is the pipeline's alone and the payload is separately askable — `SnapshotDiffReporting.line(describing:…)`, `SnapshotCaptureTiming.line()`, and `SnapshotSettleReporting.line(…)` return the JSON without emitting, and all four test sites call those. `SnapshotSettleReporting` got the same treatment for symmetry, though nothing aggregates that channel yet. Verified both ways: a unit-only run now reports no timing lines and no differing captures, while a real snapshot run still produces the full phase breakdown and diff table. The rule is now an invariant in [`AGENTS.md`](AGENTS.md).) - refactor: Drop the unused `AccessibilitySnapshot` umbrella product dependency (root `Package.swift`, SnapshotKitTesting target) — only `AccessibilitySnapshotCore` is imported; the umbrella product just widens the statically-embedded closure of the consuming test bundle. (From the July 2026 snapshot-testing PR review.) (Resolved: the umbrella product is gone from the target's dependencies; only `AccessibilitySnapshotCore` remains, which is the sole import.) - fix: The settle timeout fired on starved-but-static content — under CPU starvation (a cold, loaded CI runner) a single settle pass (16ms sleep + layout + quarter-res render) cost over a second, so the three passes stability needs didn't fit the 2.5s budget and `settleContent` failed static content as "never settled" (~50% of cold CI runs on the About screen, whose capture still matched the reference; reproduced locally by duty-cycling SIGSTOP/SIGCONT on `StuffTestHost`). (Resolved: the budget now bounds *observed motion* — `.timedOut` requires a change seen past anchor establishment, a change-free loop keeps running until it proves stability, and a hard cap at 4× the budget gives up as the new `.starved` outcome naming the pass count. Settle failures also now name the full snapshot identifier via `renderSnapshotImage(of:named:...)`, so a matrix timeout says which configuration. Guarded by `SnapshotRenderingSupportTests`.) - refactor: Replace wall-clock `Date()` deadlines in `settleContent` and `drainInFlightAnimations` (`Sources/SnapshotRenderingSupport.swift`) with `ContinuousClock` — the modern, suspension-proof way to measure elapsed time per the concurrency skill. Behavior-neutral cleanup. (From the July 2026 snapshot-testing PR review.) (Resolved: both now measure with `ContinuousClock`, landed with the starved-settle fix above.) -- fix: The settle loop timed out silently — when content never reached pixel stability, `settleContent`'s loop just exited and the capture proceeded with a mid-animation frame, with nothing distinguishing "settled" from "gave up after 2.5s". (Resolved: `settleContent`/`settleForCapture` return a `SettleOutcome`, and `reportIfUnsettled` records an `Issue` naming the phase, the view controller, and the budget when the content never stopped moving. All 232 WhereUI references capture without a single timeout, so the failure path costs nothing today and catches the next un-frozen animation.) +- fix: The settle loop timed out silently — when content never reached pixel stability, `settleContent`'s loop just exited and the capture proceeded with a mid-animation frame, with nothing distinguishing "settled" from "gave up after 2.5s". (Resolved: settle validation now throws a typed error that names the phase, view controller, and budget. Snapshot assertions record it, while hosted PNG callers stop immediately. All 232 WhereUI references capture without a single timeout, so the failure path costs nothing today and catches the next un-frozen animation.) diff --git a/Shared/SnapshotKitTesting/Tests/ConcurrentCaptureTests.swift b/Shared/SnapshotKitTesting/Tests/ConcurrentCaptureTests.swift index d27710ee..113db5f1 100644 --- a/Shared/SnapshotKitTesting/Tests/ConcurrentCaptureTests.swift +++ b/Shared/SnapshotKitTesting/Tests/ConcurrentCaptureTests.swift @@ -11,7 +11,7 @@ import UIKit /// exchange is a parity toggle, and a second capture's swizzle un-swizzled the /// first's (the Phase 13 parallel experiment produced 24+ spurious mismatches /// this way). `renderSnapshotImage` now serializes captures through a FIFO -/// mutex, so concurrent calls must produce exactly the images serial calls do. +/// mutex, so concurrent hosted PNG calls must match serial calls exactly. /// /// The probe view paints its safe area red over a green backdrop that ignores /// it, so the rendered green strip *is* the effective top inset: capture A @@ -89,13 +89,24 @@ private struct SafeAreaProbeView: View { @MainActor private func captureProbeImage(topInset: CGFloat) async throws -> UIImage { - let host = UIHostingController(rootView: SafeAreaProbeView()) - host.view.frame = CGRect(x: 0, y: 0, width: 100, height: 100) - return try await renderSnapshotImage( - of: host, + let configuration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "safe-area-probe", + size: .fixed(CGSize(width: 100, height: 100)), + ), + ) + let png = try await captureSnapshotPNG( + of: SafeAreaProbeView(), + configuration: configuration, named: "safe-area-\(Int(topInset))pt-probe", + sizing: .fixed, safeAreaInsets: UIEdgeInsets(top: topInset, left: 0, bottom: 0, right: 0), + measurementReadiness: .sameAsCapture, + onReadyToMeasure: nil, + settle: .settled, + onReadyToSnapshot: nil, ) + return try #require(UIImage(data: png.data, scale: png.scale)) } private func expectations(for image: UIImage) -> ProbedCapture { diff --git a/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift b/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift new file mode 100644 index 00000000..dc507014 --- /dev/null +++ b/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift @@ -0,0 +1,192 @@ +import SnapshotKitTesting +import SwiftUI +import TestHostSupport +import Testing +import UIKit + +@MainActor +struct SnapshotPNGTests { + @Test func returnsPNGBytesAndPointAndPixelDimensions() async throws { + try waitFor { hostKeyWindow() != nil } + let configuration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "probe", + size: .fixed(CGSize(width: 80, height: 60)), + ), + ) + + let png = try await captureSnapshotPNG( + of: Color.red, + configuration: configuration, + named: "png-api-fixed-probe", + sizing: .fixed, + safeAreaInsets: .zero, + measurementReadiness: .sameAsCapture, + onReadyToMeasure: nil, + settle: .immediate, + onReadyToSnapshot: nil, + ) + + #expect(png.data.isEmpty == false) + #expect(png.pointSize == CGSize(width: 80, height: 60)) + #expect(png.pixelSize.width == png.pointSize.width * png.scale) + #expect(png.pixelSize.height == png.pointSize.height * png.scale) + } + + @Test func runsReadinessHooksThroughTheSharedPipeline() async throws { + try waitFor { hostKeyWindow() != nil } + var measurementHookRan = false + var finalHookRan = false + let configuration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "intrinsic-probe", + size: .intrinsic(maxWidth: 100), + ), + ) + + _ = try await captureSnapshotPNG( + of: Color.blue.frame(height: 40), + configuration: configuration, + named: "png-api-hooks-probe", + sizing: .intrinsic(width: 100, minimumHeight: 0), + safeAreaInsets: .zero, + measurementReadiness: .immediate, + onReadyToMeasure: { measurementHookRan = true }, + settle: .immediate, + onReadyToSnapshot: { finalHookRan = true }, + ) + + #expect(measurementHookRan) + #expect(finalHookRan) + } + + @Test func capturesFullHeightContent() async throws { + try waitFor { hostKeyWindow() != nil } + let content = ScrollView { + VStack(spacing: 0) { + Color.red.frame(height: 100) + Color.blue.frame(height: 100) + } + } + let configuration = SnapshotConfiguration( + device: .fullContent(name: "full-height-probe", width: 100, minimumHeight: 60), + ) + + let png = try await captureSnapshotPNG( + of: content, + configuration: configuration, + named: "png-api-full-height-probe", + sizing: .intrinsic(width: 100, minimumHeight: 60), + safeAreaInsets: .zero, + measurementReadiness: .immediate, + onReadyToMeasure: nil, + settle: .immediate, + onReadyToSnapshot: nil, + ) + + #expect(png.pointSize == CGSize(width: 100, height: 200)) + } + + @Test func capturesTwoAxisFullContent() async throws { + try waitFor { hostKeyWindow() != nil } + let content = ScrollView([.horizontal, .vertical]) { + Color.green.frame(width: 180, height: 160) + } + let minimumSize = CGSize(width: 80, height: 60) + let configuration = SnapshotConfiguration( + device: .fullContent2D(name: "two-axis-probe", minimumSize: minimumSize), + ) + + let png = try await captureSnapshotPNG( + of: content, + configuration: configuration, + named: "png-api-two-axis-probe", + sizing: .fullContent2D(minimumSize: minimumSize), + safeAreaInsets: .zero, + measurementReadiness: .immediate, + onReadyToMeasure: nil, + settle: .immediate, + onReadyToSnapshot: nil, + ) + + #expect(png.pointSize == CGSize(width: 180, height: 160)) + } + + @Test func capturesAccessibilityAnnotations() async throws { + try waitFor { hostKeyWindow() != nil } + let configuration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "accessibility-probe", + size: .fixed(CGSize(width: 240, height: 160)), + ), + snapshotType: .accessibility, + ) + + let png = try await captureSnapshotPNG( + of: Text("Atlas item").accessibilityLabel("Atlas accessibility item"), + configuration: configuration, + named: "png-api-accessibility-probe", + sizing: .fixed, + safeAreaInsets: .zero, + measurementReadiness: .sameAsCapture, + onReadyToMeasure: nil, + settle: .immediate, + onReadyToSnapshot: nil, + ) + + #expect(png.data.isEmpty == false) + #expect(png.pointSize.width >= 240) + #expect(png.pointSize.height >= 160) + } + + @Test func propagatesSettleFailures() async throws { + try waitFor { hostKeyWindow() != nil } + let configuration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "moving-probe", + size: .fixed(CGSize(width: 80, height: 60)), + ), + ) + + let error = await #expect(throws: SnapshotRenderingError.self) { + try await captureSnapshotPNG( + of: NonSettlingPNGView(), + configuration: configuration, + named: "png-api-moving-probe", + sizing: .fixed, + safeAreaInsets: .zero, + measurementReadiness: .sameAsCapture, + onReadyToMeasure: nil, + settle: .settled, + onReadyToSnapshot: nil, + ) + } + guard case let .settleTimedOut(name, phase, _, _) = error else { + Issue.record("Expected a settle timeout, got \(String(describing: error)).") + return + } + #expect(name == "png-api-moving-probe") + #expect(phase == "content") + } +} + +private struct NonSettlingPNGView: View { + @State private var isRed = false + + var body: some View { + (isRed ? Color.red : Color.blue) + .task { + while Task.isCancelled == false { + isRed.toggle() + do { + try await Task.sleep(for: .milliseconds(40)) + } catch is CancellationError { + return + } catch { + Issue.record(error) + return + } + } + } + } +} diff --git a/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 7d48ecc6..5541a31a 100644 --- a/Where/WhereUI/AGENTS.md +++ b/Where/WhereUI/AGENTS.md @@ -63,6 +63,10 @@ Layering, localization, preview, and testing conventions live in the feature factory methods. - Construct and retain the Where Flyover catalog once after its world loads. Never rebuild fixture state from a SwiftUI `body`. +- Build one `WhereFlyoverWorld` for each hosted web export. Reuse it for every + capture. Never activate its scope or read user data. +- Export stable screen IDs from reflected screen and context type names. Use + snapshot case names for snapshot-backed variant IDs. - Present Where Flyover from the developer accordion with `fullScreenCover`. Place it outside the selected-tool `NavigationStack`. - Register leaf screens against Flyover's default navigation container. Use diff --git a/Where/WhereUI/README.md b/Where/WhereUI/README.md index 7f10d70c..22e8f423 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -341,9 +341,9 @@ the happy path. See the feature ## Flyover -`Sources/Developer/Flyover` owns an explicit `WhereFlyoverScreenID` catalog. -The enum is exhaustive and completeness-tested, so adding a top-level screen -produces one obvious registration update rather than depending on source +`Sources/Developer/Flyover` owns an explicit typed screen catalog. +Its registration list is completeness-tested, so adding a top-level screen +produces one obvious update rather than depending on source scanning or a macro that cannot discover navigation across the module. Opening Flyover asynchronously builds one `WhereScope.demo` and shares its @@ -365,6 +365,19 @@ interactive viewport. Flyover's appearance, device, Dynamic Type, contrast, layout-direction, and bold-text choices are session-only and apply only to registered content. +`WhereFlyoverWebExportTests` is the hosted static-export adapter. It reads the +request supplied by `./flyover export` and otherwise returns without work. One +`WhereFlyoverWorld` supplies every capture in an export. Its frozen date, +in-memory store, in-memory preferences, no-op services, private logs, and +Broadway root are the same synthetic fixtures used by native Flyover. The +export never activates that scope or reads app data. + +`WhereFlyoverScreenID.exportIdentifier` maps normal screens to their reflected +type name. Contextual screens include both reflected type names. Snapshot-backed +variant IDs use the snapshot case name. These identifiers are stable web and +deep-link identities; process-local `ObjectIdentifier` values never leave the +runtime catalog. + ## Testing Swift Testing in [`Tests/`](Tests) (`WhereUITests`), hosted in `StuffTestHost` diff --git a/Where/WhereUI/SnapshotTests/WhereFlyoverWebExportTests.swift b/Where/WhereUI/SnapshotTests/WhereFlyoverWebExportTests.swift new file mode 100644 index 00000000..7545dfb9 --- /dev/null +++ b/Where/WhereUI/SnapshotTests/WhereFlyoverWebExportTests.swift @@ -0,0 +1,96 @@ +import Flyover +import Foundation +import SnapshotKitTesting +import Testing +import UIKit +@testable import WhereUI + +@MainActor +struct WhereFlyoverWebExportTests { + @Test func exportsRequestedAtlas() async throws { + guard let environment = try WhereFlyoverExportEnvironment.current() else { + return + } + + let world = try await WhereFlyoverWorld.build() + let catalog = WhereFlyoverCatalog.make(world: world) + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "where", + title: "Where", + screenIdentifier: \WhereFlyoverScreenID.exportIdentifier, + ) + let summary = try await exporter.export( + to: environment.outputDirectory, + profiles: environment.profiles, + build: environment.build, + ) { request in + let capture = try await captureSnapshotPNG( + of: request.content, + configuration: request.configuration, + named: request.captureName, + sizing: request.configuration.snapshotSizing, + safeAreaInsets: request.configuration.device.safeAreaInsets.uiEdgeInsets, + measurementReadiness: request.measurementReadiness, + onReadyToMeasure: request.onReadyToMeasure, + settle: request.settle, + onReadyToSnapshot: request.onReadyToSnapshot, + ) + return FlyoverCapturedImage( + pngData: capture.data, + pointSize: capture.pointSize, + pixelSize: capture.pixelSize, + scale: capture.scale, + ) + } + print( + "FLYOVER_EXPORT_COMPLETE \(summary.screenCount) screens " + + "\(summary.stateCount) states \(summary.imageCount) images " + + "\(summary.outputByteCount) bytes", + ) + } +} + +private struct WhereFlyoverExportEnvironment { + let outputDirectory: URL + let profiles: [FlyoverCaptureProfile] + let build: FlyoverExportBuild + + static func current() throws -> Self? { + let values = ProcessInfo.processInfo.environment + guard let output = values["FLYOVER_EXPORT_DIRECTORY"], output.isEmpty == false else { + return nil + } + let identifiers = values["FLYOVER_EXPORT_PROFILES"]? + .split(separator: ",") + .map(String.init) ?? [] + return try WhereFlyoverExportEnvironment( + outputDirectory: URL(filePath: output, directoryHint: .isDirectory), + profiles: FlyoverCaptureProfile.parse(identifiers), + build: FlyoverExportBuild( + commit: values["FLYOVER_EXPORT_COMMIT"] ?? "unknown", + dirty: values["FLYOVER_EXPORT_DIRTY"] == "true", + branch: values["FLYOVER_EXPORT_BRANCH"].flatMap { $0.isEmpty ? nil : $0 }, + generatedAt: values["FLYOVER_EXPORT_GENERATED_AT"] ?? "unknown", + xcodeVersion: values["FLYOVER_EXPORT_XCODE_VERSION"] ?? "unknown", + simulatorDevice: values["FLYOVER_EXPORT_SIMULATOR_DEVICE"] ?? "unknown", + simulatorOS: values["FLYOVER_EXPORT_SIMULATOR_OS"] ?? "unknown", + ), + ) + } +} + +extension SnapshotConfiguration { + fileprivate var snapshotSizing: SnapshotSizing { + switch device.size { + case .fixed: + .fixed + case let .intrinsic(maxWidth): + .intrinsic(width: maxWidth ?? UIScreen.main.bounds.width, minimumHeight: 0) + case let .fullContent(width, minimumHeight): + .intrinsic(width: width, minimumHeight: minimumHeight ?? 0) + case let .fullContent2D(minimumSize): + .fullContent2D(minimumSize: minimumSize) + } + } +} diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverData.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverData.swift index 72eb76f4..e052ab3b 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverData.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverData.swift @@ -77,9 +77,9 @@ title: title, viewport: viewport, navigationContainer: navigationContainer, - variants: Screen.snapshots.enumerated().map { index, snapshotCase in + variants: Screen.snapshots.map { snapshotCase in FlyoverVariant( - id: FlyoverVariantID("\(id).\(index)"), + id: FlyoverVariantID(snapshotCase.name), snapshotCase: snapshotCase, ) }, diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverScreenID.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverScreenID.swift index dfcef09a..d3f37011 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverScreenID.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverScreenID.swift @@ -21,6 +21,10 @@ typeName } + var exportIdentifier: String { + typeName + } + static func == (lhs: Self, rhs: Self) -> Bool { lhs.value == rhs.value } diff --git a/Where/WhereUI/Tests/WhereFlyoverCatalogTests.swift b/Where/WhereUI/Tests/WhereFlyoverCatalogTests.swift index 0007aa3f..a366c4b9 100644 --- a/Where/WhereUI/Tests/WhereFlyoverCatalogTests.swift +++ b/Where/WhereUI/Tests/WhereFlyoverCatalogTests.swift @@ -14,6 +14,8 @@ #expect(Set(registered) == Set(declared)) #expect(registered.count == declared.count) #expect(declared.count == Set(declared).count) + let exportIdentifiers = catalog.screens.map(\.id.exportIdentifier) + #expect(exportIdentifiers.count == Set(exportIdentifiers).count) } @Test func recordsOnlyForwardPushAndModalRoutes() async throws { diff --git a/Where/WhereUI/Tests/WhereFlyoverDataTests.swift b/Where/WhereUI/Tests/WhereFlyoverDataTests.swift index d8e272e1..be6b6cbb 100644 --- a/Where/WhereUI/Tests/WhereFlyoverDataTests.swift +++ b/Where/WhereUI/Tests/WhereFlyoverDataTests.swift @@ -1,5 +1,6 @@ #if DEBUG import Flyover + import SnapshotKit import SwiftUI import Testing @testable import WhereUI @@ -29,5 +30,28 @@ #expect(transitions[1].destination == presented) #expect(transitions[1].kind == .modal) } + + @Test func snapshotVariantsUseSnapshotNamesAsStableIdentifiers() { + let data = WhereFlyoverData.snapshots( + SnapshotScreen.self, + title: "Snapshot", + ) + + let screen = data.screen(in: .preview()) + #expect(screen.variants.map(\.id.rawValue) == ["First", "Second"]) + } + + private struct SnapshotScreen: View, SnapshotProviding { + var body: some View { + EmptyView() + } + + static var snapshots: [SnapshotCase] { + [ + SnapshotCase(name: "First", configurations: []) { EmptyView() }, + SnapshotCase(name: "Second", configurations: []) { EmptyView() }, + ] + } + } } #endif diff --git a/Where/WhereUI/Tests/WhereFlyoverScreenIDTests.swift b/Where/WhereUI/Tests/WhereFlyoverScreenIDTests.swift index c5b393b5..1cdbab99 100644 --- a/Where/WhereUI/Tests/WhereFlyoverScreenIDTests.swift +++ b/Where/WhereUI/Tests/WhereFlyoverScreenIDTests.swift @@ -15,6 +15,12 @@ #expect(Set([first, same, second, contextual]).count == 3) #expect(first.description.contains("FirstScreen")) #expect(contextual.description.contains("SecondScreen")) + #expect(first.exportIdentifier == String(reflecting: FirstScreen.self)) + #expect( + contextual.exportIdentifier + == + "\(String(reflecting: FirstScreen.self)) in \(String(reflecting: SecondScreen.self))", + ) } private enum FirstScreen {} diff --git a/flyover b/flyover new file mode 100755 index 00000000..db4d624e --- /dev/null +++ b/flyover @@ -0,0 +1,215 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")" && pwd -P)" +DEFAULT_OUTPUT=".build/flyover/where" +OUTPUT="$DEFAULT_OUTPUT" +PROFILES=() +STAGING="" +BACKUP="" +DESTINATION="" + +usage() { + cat <<'USAGE' +Usage: + ./flyover export [options] + ./flyover --help + +Export Where's native Flyover catalog as a portable static website. + +Options: + --output DIR Generated website directory (default: .build/flyover/where) + --profile NAME Add a capture profile. Repeat the flag for more profiles. + The default is phone-light. + -h, --help Show this help. + +Profiles: + phone-light, phone-dark, tablet-light, phone-landscape, phone-small, + phone-xxxl, phone-ax3, phone-contrast, phone-rtl, phone-bold, + phone-voiceover + +Examples: + ./flyover export + ./flyover export --profile phone-light --profile phone-dark + ./flyover export --output /tmp/where-flyover --profile tablet-light +USAGE +} + +error() { + echo "flyover: $*" >&2 + exit 1 +} + +is_known_profile() { + case "$1" in + phone-light | phone-dark | tablet-light | phone-landscape | phone-small | \ + phone-xxxl | phone-ax3 | phone-contrast | phone-rtl | phone-bold | \ + phone-voiceover) + return 0 + ;; + *) return 1 ;; + esac +} + +add_profile() { + local candidate="$1" existing + is_known_profile "$candidate" || error "unknown profile '$candidate' (see ./flyover --help)" + for existing in ${PROFILES[@]+"${PROFILES[@]}"}; do + [ "$existing" = "$candidate" ] && return 0 + done + PROFILES+=("$candidate") +} + +resolve_path() { + python3 - "$1" <<'PY' +import os, sys +print(os.path.abspath(os.path.expanduser(sys.argv[1]))) +PY +} + +cleanup() { + if [ -n "$STAGING" ] && [ -d "$STAGING" ]; then + rm -rf "$STAGING" + fi + if [ -n "$BACKUP" ] && [ -e "$BACKUP" ] && [ -n "$DESTINATION" ] && [ ! -e "$DESTINATION" ]; then + mv "$BACKUP" "$DESTINATION" + fi +} +trap cleanup EXIT INT TERM + +case "${1-}" in + -h | --help) + usage + exit 0 + ;; + export) + shift + ;; + "") + usage >&2 + exit 1 + ;; + *) error "unknown command '${1-}' (see ./flyover --help)" ;; +esac + +while [ "$#" -gt 0 ]; do + case "$1" in + --output) + shift + [ "$#" -gt 0 ] || error "--output requires a directory" + OUTPUT="$1" + ;; + --profile) + shift + [ "$#" -gt 0 ] || error "--profile requires a name" + add_profile "$1" + ;; + -h | --help) + usage + exit 0 + ;; + *) error "unknown option '$1' (see ./flyover --help)" ;; + esac + shift +done + +[ "${#PROFILES[@]}" -gt 0 ] || PROFILES=(phone-light) +DESTINATION="$(resolve_path "$OUTPUT")" +HOME_PATH="$(resolve_path "$HOME")" +WORKSPACE_PATH="$(resolve_path "$ROOT")" +case "$DESTINATION" in + /) error "refusing to replace the filesystem root" ;; +esac +[ "$DESTINATION" != "$HOME_PATH" ] || error "refusing to replace the home directory" +[ "$DESTINATION" != "$WORKSPACE_PATH" ] || error "refusing to replace the workspace root" + +if [ -e "$DESTINATION" ] && [ ! -f "$DESTINATION/.flyover-generated" ]; then + error "refusing to replace unmarked directory '$DESTINATION'" +fi + +PARENT="$(dirname "$DESTINATION")" +mkdir -p "$PARENT" +STAGING="$(mktemp -d "$PARENT/.flyover-staging.XXXXXX")" +mkdir -p "$STAGING/assets" +cp "$ROOT/Shared/Flyover/Web/index.html" "$STAGING/index.html" +cp "$ROOT/Shared/Flyover/Web/assets/app.js" "$STAGING/assets/app.js" +cp "$ROOT/Shared/Flyover/Web/assets/styles.css" "$STAGING/assets/styles.css" + +COMMIT="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo unknown)" +if [ -n "$(git -C "$ROOT" status --porcelain 2>/dev/null || true)" ]; then + DIRTY=true +else + DIRTY=false +fi +BRANCH="$(git -C "$ROOT" branch --show-current 2>/dev/null || true)" +GENERATED_AT="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" +XCODE_VERSION="${FLYOVER_XCODE_VERSION_OVERRIDE:-$(xcodebuild -version 2>/dev/null | tr '\n' ' ' | sed 's/ $//' || true)}" +[ -n "$XCODE_VERSION" ] || XCODE_VERSION=unknown +PROFILE_LIST="$(IFS=,; echo "${PROFILES[*]}")" +CAPTURE_RUNNER="${FLYOVER_CAPTURE_RUNNER:-$ROOT/test}" +EXPORT_STARTED_AT=$SECONDS + +echo "==> Exporting ${#PROFILES[@]} profile(s) to $DESTINATION" +env \ + FLYOVER_EXPORT_DIRECTORY="$STAGING" \ + FLYOVER_EXPORT_PROFILES="$PROFILE_LIST" \ + FLYOVER_EXPORT_COMMIT="$COMMIT" \ + FLYOVER_EXPORT_DIRTY="$DIRTY" \ + FLYOVER_EXPORT_BRANCH="$BRANCH" \ + FLYOVER_EXPORT_GENERATED_AT="$GENERATED_AT" \ + FLYOVER_EXPORT_XCODE_VERSION="$XCODE_VERSION" \ + FLYOVER_EXPORT_SIMULATOR_DEVICE="iPhone 17" \ + FLYOVER_EXPORT_SIMULATOR_OS="27.0" \ + "$CAPTURE_RUNNER" --only 'WhereUISnapshotTests/WhereFlyoverWebExportTests' + +python3 - "$STAGING" <<'PY' +import json, os, pathlib, sys +root = pathlib.Path(sys.argv[1]) +required = [root / 'index.html', root / 'manifest.json', root / 'manifest.js', + root / 'assets' / 'app.js', root / 'assets' / 'styles.css'] +missing = [str(path) for path in required if not path.is_file()] +if missing: + raise SystemExit('flyover: generated artifact is missing: ' + ', '.join(missing)) +manifest = json.loads((root / 'manifest.json').read_text()) +if manifest.get('schemaVersion') != 1: + raise SystemExit('flyover: generated manifest does not use schemaVersion 1') +images = manifest.get('images') +if not isinstance(images, list): + raise SystemExit('flyover: generated manifest has no image list') +for image in images: + relative = pathlib.PurePosixPath(image.get('relativePath', '')) + if relative.is_absolute() or '..' in relative.parts: + raise SystemExit(f'flyover: unsafe image path in manifest: {relative}') + if not (root / pathlib.Path(*relative.parts)).is_file(): + raise SystemExit(f'flyover: manifest image is missing: {relative}') +actual = sum(1 for path in (root / 'images').rglob('*.png')) if (root / 'images').is_dir() else 0 +if actual != len(images): + raise SystemExit(f'flyover: manifest requires {len(images)} images, found {actual}') +PY + +printf '%s\n' 'schemaVersion=1' >"$STAGING/.flyover-generated" + +if [ -e "$DESTINATION" ]; then + BACKUP="$PARENT/.flyover-previous.$(basename "$DESTINATION").$$" + mv "$DESTINATION" "$BACKUP" +fi +mv "$STAGING" "$DESTINATION" +STAGING="" +if [ -n "$BACKUP" ]; then + rm -rf "$BACKUP" + BACKUP="" +fi + +ELAPSED_SECONDS=$((SECONDS - EXPORT_STARTED_AT)) +python3 - "$DESTINATION" "$ELAPSED_SECONDS" <<'PY' +import json, pathlib, sys +root = pathlib.Path(sys.argv[1]) +elapsed = int(sys.argv[2]) +manifest = json.loads((root / 'manifest.json').read_text()) +size = sum(path.stat().st_size for path in root.rglob('*') if path.is_file()) +print(f"Flyover export complete: {len(manifest['screens'])} screens, " + f"{sum(len(screen['variants']) for screen in manifest['screens'])} states, " + f"{len(manifest['profiles'])} profiles, {len(manifest['images'])} images, " + f"{size} bytes in {elapsed}s") +print(root) +PY diff --git a/loc b/loc index b6f71c5c..8e122538 100755 --- a/loc +++ b/loc @@ -19,6 +19,7 @@ ROOT_SCRIPTS=( attribution codex-watchdog flaky + flyover icons ide loc diff --git a/test b/test index 364f40d0..2678a3b4 100755 --- a/test +++ b/test @@ -87,7 +87,8 @@ Runs this repo's tests against the simulator this checkout owns, streaming progress as it goes. With no arguments it runs only the bundles affected by the working tree's changes. Every normal invocation first validates and tests the Bumper Bowling rules, then enforces the architecture. Unit and affected scopes -also run the fast host-side backup-upgrader regression suite. +also run the fast host-side backup-upgrader regression suite. Every normal +invocation runs the fast Flyover command regression suite. Scope: (no arguments) Bundles affected by the diff against origin/main, including @@ -202,6 +203,9 @@ if [ "$ARCHITECTURE_ONLY" = true ]; then exit 0 fi +echo "==> Testing flyover command" +bash Shared/Flyover/Tools/Tests/flyover_test.sh + WORKSPACE="Stuff.xcworkspace" UNIT_SCHEME="Stuff-iOS-Tests" SNAPSHOT_SCHEME="StuffSnapshotTests" @@ -381,7 +385,7 @@ if problems: # the shared scripts, the format config, CI. Over-select rather than skip the # bundle that would have caught it. GLOBAL = {'Package.swift', 'Package.resolved', 'Project.swift', 'Tuist.swift', - '.swiftformat', '.mise.toml', 'test', 'simulator', 'ide'} + '.swiftformat', '.mise.toml', 'test', 'simulator', 'ide', 'flyover'} selected, changed_targets, everything = set(), set(), False for path in changed: @@ -550,6 +554,12 @@ for raw in sys.stdin: images += 1 emit() continue + if line.startswith('FLYOVER_EXPORT '): + if is_terminal: + sys.stdout.write('\r\033[K') + print(f' {line}', flush=True) + last_emit = 0.0 + continue match = SUITE.match(line) if match: if match.group(2) == 'started': @@ -724,6 +734,12 @@ RUN_ENV=() [ "$REVIEW" = true ] && RUN_ENV+=("TEST_RUNNER_SNAPSHOT_DIFF=1") [ -n "${SNAPSHOT_SETTLE_TIMEOUT_MULTIPLIER:-}" ] \ && RUN_ENV+=("TEST_RUNNER_SNAPSHOT_SETTLE_TIMEOUT_MULTIPLIER=$SNAPSHOT_SETTLE_TIMEOUT_MULTIPLIER") +for flyover_name in DIRECTORY PROFILES COMMIT DIRTY BRANCH GENERATED_AT XCODE_VERSION SIMULATOR_DEVICE SIMULATOR_OS; do + flyover_variable="FLYOVER_EXPORT_$flyover_name" + if [ -n "${!flyover_variable:-}" ]; then + RUN_ENV+=("TEST_RUNNER_$flyover_variable=${!flyover_variable}") + fi +done # SwiftPM's generated `Bundle.module` accessors honor # PACKAGE_RESOURCE_BUNDLE_PATH as their first lookup candidate (DEBUG-only,