From 65410b139ec7fb3e1c345651485b7a411b82dfd4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 13 Aug 2026 23:41:13 -0700 Subject: [PATCH 1/5] Add generic Flyover web exporter --- Shared/Flyover/AGENTS.md | 5 + Shared/Flyover/README.md | 50 ++ .../Sources/FlyoverCaptureExtent.swift | 9 + .../Sources/FlyoverCaptureProfile.swift | 181 ++++++ .../Sources/FlyoverCaptureRequest.swift | 51 ++ .../Sources/FlyoverCapturedImage.swift | 24 + .../Flyover/Sources/FlyoverExportBuild.swift | 30 + .../Sources/FlyoverExportContent.swift | 23 + .../Flyover/Sources/FlyoverExportError.swift | 60 ++ .../Flyover/Sources/FlyoverExportPolicy.swift | 63 ++ .../Sources/FlyoverExportSummary.swift | 29 + Shared/Flyover/Sources/FlyoverVariant.swift | 48 ++ .../Flyover/Sources/FlyoverWebExporter.swift | 582 ++++++++++++++++++ .../Flyover/Sources/FlyoverWebManifest.swift | 160 +++++ .../Tests/FlyoverCaptureProfileTests.swift | 26 + .../Tests/FlyoverExportPolicyTests.swift | 77 +++ Shared/Flyover/Tests/FlyoverTestScreen.swift | 2 +- .../Tests/FlyoverWebExporterTests.swift | 227 +++++++ Shared/Flyover/Web/assets/app.js | 455 ++++++++++++++ Shared/Flyover/Web/assets/styles.css | 159 +++++ Shared/Flyover/Web/index.html | 14 + 21 files changed, 2274 insertions(+), 1 deletion(-) create mode 100644 Shared/Flyover/Sources/FlyoverCaptureExtent.swift create mode 100644 Shared/Flyover/Sources/FlyoverCaptureProfile.swift create mode 100644 Shared/Flyover/Sources/FlyoverCaptureRequest.swift create mode 100644 Shared/Flyover/Sources/FlyoverCapturedImage.swift create mode 100644 Shared/Flyover/Sources/FlyoverExportBuild.swift create mode 100644 Shared/Flyover/Sources/FlyoverExportContent.swift create mode 100644 Shared/Flyover/Sources/FlyoverExportError.swift create mode 100644 Shared/Flyover/Sources/FlyoverExportPolicy.swift create mode 100644 Shared/Flyover/Sources/FlyoverExportSummary.swift create mode 100644 Shared/Flyover/Sources/FlyoverWebExporter.swift create mode 100644 Shared/Flyover/Sources/FlyoverWebManifest.swift create mode 100644 Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift create mode 100644 Shared/Flyover/Tests/FlyoverExportPolicyTests.swift create mode 100644 Shared/Flyover/Tests/FlyoverWebExporterTests.swift create mode 100644 Shared/Flyover/Web/assets/app.js create mode 100644 Shared/Flyover/Web/assets/styles.css create mode 100644 Shared/Flyover/Web/index.html diff --git a/Shared/Flyover/AGENTS.md b/Shared/Flyover/AGENTS.md index b46d4dc0b..772b0ced2 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 47420a874..4b2d90211 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -134,6 +134,56 @@ 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. + ## Testing Run unit coverage with: diff --git a/Shared/Flyover/Sources/FlyoverCaptureExtent.swift b/Shared/Flyover/Sources/FlyoverCaptureExtent.swift new file mode 100644 index 000000000..c40eb9683 --- /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 000000000..40ac8c801 --- /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 000000000..ff32c1312 --- /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 000000000..c2e6e958d --- /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 000000000..b10aca5ff --- /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 000000000..254e65a17 --- /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 000000000..9c45800dd --- /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 000000000..1d0e8e8af --- /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 000000000..2eb469614 --- /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 1d8744e40..ff4ffe813 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 000000000..30e379736 --- /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 000000000..da0cf4ec5 --- /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 000000000..ab15a0ff6 --- /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 000000000..8db72f35a --- /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 3e2399d16..e7c5a6580 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 000000000..e596d61c5 --- /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/Web/assets/app.js b/Shared/Flyover/Web/assets/app.js new file mode 100644 index 000000000..e7e598a74 --- /dev/null +++ b/Shared/Flyover/Web/assets/app.js @@ -0,0 +1,455 @@ +(() => { + "use strict"; + + const root = document.getElementById("app"); + const manifest = window.FLYOVER_MANIFEST; + if (!manifest || manifest.schemaVersion !== 1) { + root.innerHTML = '

Flyover export error

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, + }; + + 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 matchesSearch(screen) { + const query = state.search.trim().toLocaleLowerCase(); + if (!query) return true; + const group = groupByID.get(screen.groupID); + return [group?.title, screen.title, ...screen.variants.map(variant => variant.title)] + .some(value => value?.toLocaleLowerCase().includes(query)); + } + + 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) location.hash = next; + } + + function chooseView(view) { + state.view = view; + writeHash(); + render(); + } + + function chooseProfile(profile) { + state.profile = profile; + writeHash(); + render(); + } + + function chooseVariant(screen, variantID, updateHistory = false) { + selectedVariants.set(screen.id, variantID); + if (state.screen === screen.id || updateHistory) { + state.screen = screen.id; + writeHash(); + } + render(); + } + + function openScreen(screenID) { + const screen = screenByID.get(screenID); + if (!screen) return; + selectedVariants.set(screen.id, screen.variants[0]?.id); + state.screen = screen.id; + writeHash(); + render(); + } + + function toolbar() { + const bar = element("header", "toolbar"); + bar.append(element("h1", "", manifest.application.title)); + bar.append(element("span", "build", manifest.build.commit.slice(0, 12))); + if (manifest.build.dirty) bar.append(element("span", "dirty", "Dirty build")); + bar.append(element("span", "build", manifest.build.generatedAt)); + bar.append(element("span", "toolbar-spacer")); + + const profileLabel = element("label", ""); + profileLabel.append(element("span", "", "Profile")); + const profile = element("select", ""); + profile.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; + profile.append(option); + } + profile.addEventListener("change", () => chooseProfile(profile.value)); + profileLabel.append(profile); + bar.append(profileLabel); + + const tabs = element("div", "view-tabs"); + 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); + } + bar.append(tabs); + + const search = element("input", ""); + search.type = "search"; + search.placeholder = "Search screens and states"; + search.setAttribute("aria-label", "Search screens and states"); + search.value = state.search; + search.addEventListener("input", () => { + state.search = search.value; + updateSearchVisibility(); + }); + bar.append(search); + + if (state.view === "canvas") { + const zoomLabel = element("label", ""); + zoomLabel.append(element("span", "", "Zoom")); + const zoom = element("input", ""); + zoom.type = "range"; + zoom.min = "0.1"; + zoom.max = "1.5"; + zoom.step = "0.05"; + zoom.value = String(state.zoom); + zoom.addEventListener("input", () => { + state.zoom = Number(zoom.value); + applyZoom(); + }); + zoomLabel.append(zoom); + bar.append(zoomLabel); + const fit = element("button", "", "Fit All"); + fit.type = "button"; + fit.addEventListener("click", fitAll); + bar.append(fit); + } + return bar; + } + + 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 routeButtons(screen) { + const links = element("div", "route-links"); + const routeIDs = [...screen.incomingRouteIDs, ...screen.outgoingRouteIDs]; + for (const id of routeIDs) { + const route = routeByID.get(id); + const isOutgoing = route.sourceScreenID === screen.id; + const destinationID = isOutgoing ? route.destinationScreenID : route.sourceScreenID; + const destination = screenByID.get(destinationID); + const cue = isOutgoing ? (route.kind === "modal" ? "Modal" : "Push") : "Back"; + const button = element("button", "", `${cue}: ${destination?.title || destinationID}`); + button.type = "button"; + button.addEventListener("click", event => { + event.stopPropagation(); + openScreen(destinationID); + }); + links.append(button); + } + return links; + } + + function screenImage(screen, className) { + const image = element("img", className || ""); + image.loading = "lazy"; + image.src = imagePath(screen); + image.alt = `${screen.title} — ${screenVariant(screen)?.title || "Default"}`; + return image; + } + + function canvasView() { + const viewport = element("main", "canvas-viewport"); + viewport.id = "canvas-viewport"; + 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"); + Object.assign(shelf.style, rectStyle(item.frame)); + shelf.append(element("h2", "", group?.title || item.id)); + stage.append(shelf); + } + for (const item of manifest.canvas.depthBandFrames) { + const band = element("div", "depth-band"); + 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) { + const card = element("article", "screen-card"); + card.dataset.screenId = screen.id; + card.dataset.hidden = String(!matchesSearch(screen)); + Object.assign(card.style, rectStyle(screen.frame)); + card.append(element("h3", "", screen.title)); + card.append(variantSelector(screen)); + const imageButton = element("button", "card-image-button"); + imageButton.type = "button"; + imageButton.setAttribute("aria-label", `Inspect ${screen.title}`); + imageButton.append(screenImage(screen)); + imageButton.addEventListener("click", () => openScreen(screen.id)); + card.append(imageButton); + card.append(routeButtons(screen)); + stage.append(card); + } + scaled.append(stage); + viewport.append(scaled); + requestAnimationFrame(() => { + applyZoom(); + if (!state.screen && !location.hash.includes("view=")) fitFirstGroup(); + }); + return viewport; + } + + 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 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}`); + path.setAttribute("class", route.kind === "modal" ? "route-modal" : "route-push"); + svg.append(path); + 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}`); + arrow.setAttribute("class", route.kind === "modal" ? "route-arrow-modal" : "route-arrow-push"); + svg.append(arrow); + } + 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"); + for (const group of manifest.groups) { + const section = element("section", ""); + section.append(element("h2", "", group.title)); + for (const screenID of group.screenIDs) { + const screen = screenByID.get(screenID); + if (!screen) continue; + const row = element("article", "list-row"); + row.dataset.screenId = screen.id; + row.dataset.hidden = String(!matchesSearch(screen)); + const heading = element("strong", "", screen.title); + row.append(heading); + row.append(variantSelector(screen)); + row.append(screenImage(screen)); + const summary = element("div", "route-summary"); + summary.textContent = `${screen.incomingRouteIDs.length} incoming · ${screen.outgoingRouteIDs.length} outgoing`; + summary.append(routeButtons(screen)); + row.append(summary); + const inspect = element("button", "", "Inspect"); + inspect.type = "button"; + inspect.addEventListener("click", () => openScreen(screen.id)); + row.append(inspect); + section.append(row); + } + main.append(section); + } + return main; + } + + function inspector() { + const screen = screenByID.get(state.screen); + if (!screen) return null; + const variant = screenVariant(screen); + const dialog = element("dialog", ""); + dialog.id = "inspector"; + dialog.setAttribute("aria-labelledby", "inspector-title"); + const header = element("header", "inspector-header"); + const title = element("h2", "", screen.title); + title.id = "inspector-title"; + header.append(title); + const close = element("button", "close", "Close"); + close.type = "button"; + close.addEventListener("click", closeInspector); + header.append(close); + dialog.append(header); + + const controls = element("div", "inspector-controls"); + const stateLabel = element("label", ""); + stateLabel.append(element("span", "", "State")); + const states = variantSelector(screen, true); + stateLabel.append(states); + controls.append(stateLabel); + const profileLabel = element("label", ""); + profileLabel.append(element("span", "", "Profile")); + const profiles = element("select", ""); + for (const item of manifest.profiles) { + const option = element("option", "", item.title); + option.value = item.id; + option.selected = item.id === state.profile; + profiles.append(option); + } + profiles.addEventListener("change", () => chooseProfile(profiles.value)); + profileLabel.append(profiles); + controls.append(profileLabel); + controls.append(routeButtons(screen)); + dialog.append(controls); + + const body = element("div", "inspector-body"); + const imageFrame = element("div", `inspector-image ${variant.captureExtent === "fullContent" || variant.captureExtent === "fullContent2D" ? "full-content" : ""}`); + const fullImage = screenImage(screen); + const metadata = imageMetadata(screen); + if (variant.captureExtent === "fullContent" && metadata) { + fullImage.style.width = `${metadata.pointWidth}px`; + } + imageFrame.append(fullImage); + body.append(imageFrame); + const raw = element("a", "raw-link", "Open raw PNG"); + raw.href = imagePath(screen); + body.append(raw); + dialog.append(body); + dialog.addEventListener("close", closeInspector); + return dialog; + } + + function closeInspector() { + state.screen = null; + writeHash(); + render(); + } + + function updateSearchVisibility() { + for (const node of document.querySelectorAll("[data-screen-id]")) { + const screen = screenByID.get(node.dataset.screenId); + node.dataset.hidden = String(!matchesSearch(screen)); + } + } + + 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.querySelector('.toolbar input[type="range"]'); + if (slider) slider.value = String(state.zoom); + } + + function fitFrame(frame) { + const viewport = document.getElementById("canvas-viewport"); + if (!viewport) return; + const horizontal = Math.max(viewport.clientWidth - 32, 1) / Math.max(frame.width, 1); + const vertical = Math.max(viewport.clientHeight - 32, 1) / Math.max(frame.height, 1); + state.zoom = Math.max(0.1, Math.min(1.5, horizontal, vertical)); + applyZoom(); + viewport.scrollTo({ left: frame.x * state.zoom, top: frame.y * state.zoom }); + } + + function fitAll() { + fitFrame({ x: 0, y: 0, width: manifest.canvas.size.width, height: manifest.canvas.size.height }); + } + + function fitFirstGroup() { + const first = manifest.canvas.groupFrames[0]; + if (first) fitFrame(first.frame); + } + + function render() { + root.replaceChildren(toolbar(), state.view === "canvas" ? canvasView() : listView()); + const dialog = inspector(); + if (dialog) { + root.append(dialog); + requestAnimationFrame(() => dialog.showModal()); + } + } + + window.addEventListener("hashchange", () => { + parseHash(); + render(); + }); + parseHash(); + render(); +})(); diff --git a/Shared/Flyover/Web/assets/styles.css b/Shared/Flyover/Web/assets/styles.css new file mode 100644 index 000000000..2e6ae1799 --- /dev/null +++ b/Shared/Flyover/Web/assets/styles.css @@ -0,0 +1,159 @@ +:root { + color-scheme: light dark; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + --background: #eef1f5; + --surface: #ffffff; + --surface-muted: #f7f8fa; + --text: #16181d; + --secondary: #5f6672; + --border: #cfd4dc; + --accent: #1668dc; + --modal: #8950c9; + --shadow: 0 8px 30px rgb(20 27 45 / 14%); +} + +@media (prefers-color-scheme: dark) { + :root { + --background: #12151a; + --surface: #1d2128; + --surface-muted: #252a33; + --text: #f5f7fa; + --secondary: #b0b7c3; + --border: #424957; + --accent: #6ca8ff; + --modal: #c49aff; + --shadow: 0 8px 30px rgb(0 0 0 / 35%); + } +} + +* { box-sizing: border-box; } +html, body, #app { min-height: 100%; margin: 0; } +body { background: var(--background); color: var(--text); } +button, input, select { font: inherit; } +button, select, input[type="search"], input[type="range"] { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--surface); + color: var(--text); +} +button { cursor: pointer; padding: 0.45rem 0.7rem; } +button:hover { border-color: var(--accent); } +:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } + +.error { + max-width: 42rem; + margin: 10vh auto; + padding: 2rem; + border: 1px solid #d33; + border-radius: 12px; + background: var(--surface); +} + +.toolbar { + position: sticky; + z-index: 20; + top: 0; + display: flex; + flex-wrap: wrap; + gap: 0.7rem; + align-items: center; + padding: 0.75rem 1rem; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--surface) 94%, transparent); + backdrop-filter: blur(14px); +} +.toolbar h1 { margin: 0; font-size: 1.05rem; } +.build { color: var(--secondary); font: 0.78rem ui-monospace, monospace; } +.dirty { padding: 0.2rem 0.45rem; border-radius: 999px; background: #a74700; color: white; } +.toolbar-spacer { flex: 1 1 1rem; } +.toolbar label { display: flex; gap: 0.35rem; align-items: center; font-size: 0.82rem; } +.toolbar select, .toolbar input[type="search"] { min-height: 2.1rem; padding: 0.35rem 0.5rem; } +.toolbar input[type="search"] { width: min(17rem, 48vw); } +.view-tabs { display: flex; gap: 0.25rem; } +.view-tabs button[aria-pressed="true"] { background: var(--accent); color: white; border-color: var(--accent); } + +.canvas-viewport { overflow: auto; height: calc(100vh - 62px); } +.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: 2px solid var(--border); + border-radius: 28px; + background: color-mix(in srgb, var(--surface) 82%, transparent); +} +.group-shelf h2 { margin: 14px 20px; font-size: 1.2rem; } +.depth-band { border: 1px dashed color-mix(in srgb, var(--secondary) 45%, transparent); border-radius: 18px; } +.depth-band span { position: absolute; top: -0.75rem; left: 10px; padding: 0 0.35rem; background: var(--surface); color: var(--secondary); font-size: 0.72rem; font-weight: 700; } +.canvas-routes { inset: 0; overflow: visible; pointer-events: none; } +.route-push { fill: none; stroke: var(--accent); stroke-width: 3; } +.route-modal { fill: none; stroke: var(--modal); stroke-width: 3; stroke-dasharray: 12 8; } +.route-arrow-push { fill: var(--accent); } +.route-arrow-modal { fill: var(--modal); } +.screen-card { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr) auto; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 22px; + background: var(--surface); + box-shadow: var(--shadow); +} +.screen-card[data-hidden="true"] { display: none; } +.screen-card h3 { margin: 0; padding: 0.8rem 0.9rem 0.35rem; font-size: 1rem; } +.screen-card select { margin: 0.25rem 0.9rem 0.6rem; width: calc(100% - 1.8rem); padding: 0.35rem; } +.card-image-button { display: grid; min-height: 0; margin: 0 0.75rem; padding: 0; overflow: hidden; background: var(--surface-muted); } +.card-image-button img { width: 100%; height: 100%; object-fit: contain; } +.route-links { display: flex; flex-wrap: wrap; gap: 0.35rem; padding: 0.65rem 0.75rem 0.8rem; } +.route-links button { font-size: 0.72rem; padding: 0.3rem 0.45rem; } + +.list { max-width: 88rem; margin: 0 auto; padding: 1.5rem; } +.list section { margin-bottom: 2rem; } +.list h2 { margin: 0 0 0.75rem; } +.list-row { + display: grid; + grid-template-columns: minmax(10rem, 1fr) minmax(9rem, 0.8fr) minmax(11rem, 1fr) minmax(10rem, 1fr) auto; + gap: 0.8rem; + align-items: center; + padding: 0.75rem; + border: 1px solid var(--border); + border-bottom-width: 0; + background: var(--surface); +} +.list-row:last-child { border-bottom-width: 1px; border-radius: 0 0 10px 10px; } +.list-row:first-of-type { border-radius: 10px 10px 0 0; } +.list-row[data-hidden="true"] { display: none; } +.list-row img { width: 7rem; height: 7rem; object-fit: contain; background: var(--surface-muted); border-radius: 8px; } +.list-row select { width: 100%; padding: 0.4rem; } +.route-summary { color: var(--secondary); font-size: 0.78rem; } + +dialog { + width: min(94vw, 75rem); + max-height: 92vh; + padding: 0; + border: 1px solid var(--border); + border-radius: 16px; + background: var(--surface); + color: var(--text); + box-shadow: var(--shadow); +} +dialog::backdrop { background: rgb(0 0 0 / 58%); } +.inspector-header { display: flex; gap: 0.75rem; align-items: center; padding: 1rem; border-bottom: 1px solid var(--border); } +.inspector-header h2 { margin: 0; } +.inspector-header .close { margin-left: auto; } +.inspector-controls { display: flex; flex-wrap: wrap; gap: 0.8rem; padding: 0.8rem 1rem; background: var(--surface-muted); } +.inspector-controls label { display: flex; gap: 0.4rem; align-items: center; } +.inspector-body { padding: 1rem; } +.inspector-image { display: flex; justify-content: center; overflow: auto; max-height: 65vh; background: var(--surface-muted); border-radius: 10px; } +.inspector-image img { max-width: 100%; height: auto; object-fit: contain; } +.inspector-image.full-content img { max-width: none; } +.raw-link { display: inline-block; margin-top: 0.75rem; color: var(--accent); } + +@media (max-width: 900px) { + .list-row { grid-template-columns: 1fr auto; } + .list-row > :not(:first-child):not(:last-child) { grid-column: 1 / -1; } + .build { display: none; } +} + +@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 000000000..50d4d731a --- /dev/null +++ b/Shared/Flyover/Web/index.html @@ -0,0 +1,14 @@ + + + + + + Flyover QA Atlas + + + +
+ + + + From 191a051496881080cfbc7303917fdd9afea77767 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 13 Aug 2026 23:41:48 -0700 Subject: [PATCH 2/5] Expose hosted snapshot PNG captures --- Shared/SnapshotKitTesting/AGENTS.md | 2 + Shared/SnapshotKitTesting/README.md | 13 ++ .../Sources/AssertSnapshots.swift | 54 ----- .../Sources/SnapshotHostingController.swift | 45 ++++ .../Sources/SnapshotImageRendering.swift | 32 ++- .../Sources/SnapshotPNG.swift | 57 ++++++ .../Sources/SnapshotRenderingSupport.swift | 45 ++-- Shared/SnapshotKitTesting/TODOs.md | 4 +- .../Tests/ConcurrentCaptureTests.swift | 21 +- .../Tests/SnapshotPNGTests.swift | 192 ++++++++++++++++++ 10 files changed, 369 insertions(+), 96 deletions(-) create mode 100644 Shared/SnapshotKitTesting/Sources/SnapshotHostingController.swift create mode 100644 Shared/SnapshotKitTesting/Sources/SnapshotPNG.swift create mode 100644 Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift diff --git a/Shared/SnapshotKitTesting/AGENTS.md b/Shared/SnapshotKitTesting/AGENTS.md index be26a63db..ba830ccfe 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 415f12b1d..f87a76b46 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 9f6b949a7..0afd5ba0f 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 000000000..eed7e0797 --- /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 2fc3f9c79..6f83692ad 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 000000000..0516ce8af --- /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 ae1b831df..971b23df8 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 cb094bb0a..57504bfdf 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 d27710ee7..113db5f12 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 000000000..dc5070142 --- /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 + } + } + } + } +} From 14254e08463ace3926236040fb884b30881b6d01 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 13 Aug 2026 23:42:31 -0700 Subject: [PATCH 3/5] Export the Where Flyover atlas --- AGENTS.md | 2 +- README.md | 1 + Shared/Flyover/Tools/Tests/flyover_test.sh | 116 ++++++++++ Where/WhereUI/AGENTS.md | 4 + Where/WhereUI/README.md | 19 +- .../WhereFlyoverWebExportTests.swift | 96 ++++++++ .../Developer/Flyover/WhereFlyoverData.swift | 4 +- .../Flyover/WhereFlyoverScreenID.swift | 4 + .../Tests/WhereFlyoverCatalogTests.swift | 2 + .../WhereUI/Tests/WhereFlyoverDataTests.swift | 24 ++ .../Tests/WhereFlyoverScreenIDTests.swift | 6 + flyover | 215 ++++++++++++++++++ loc | 1 + test | 20 +- 14 files changed, 505 insertions(+), 9 deletions(-) create mode 100755 Shared/Flyover/Tools/Tests/flyover_test.sh create mode 100644 Where/WhereUI/SnapshotTests/WhereFlyoverWebExportTests.swift create mode 100755 flyover diff --git a/AGENTS.md b/AGENTS.md index 52b2a61a8..76fee7305 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 diff --git a/README.md b/README.md index 44231cc56..beb3e636f 100644 --- a/README.md +++ b/README.md @@ -161,6 +161,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/Tools/Tests/flyover_test.sh b/Shared/Flyover/Tools/Tests/flyover_test.sh new file mode 100755 index 000000000..2e2152d37 --- /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/Where/WhereUI/AGENTS.md b/Where/WhereUI/AGENTS.md index 7d48ecc6f..5541a31ad 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 50cae74aa..d88825dc2 100644 --- a/Where/WhereUI/README.md +++ b/Where/WhereUI/README.md @@ -336,9 +336,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 @@ -360,6 +360,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 000000000..7545dfb93 --- /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 72eb76f49..e052ab3b6 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 dfcef09af..d3f37011a 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 0007aa3f2..a366c4b9c 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 d8e272e1d..be6b6cbb4 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 c5b393b5c..1cdbab99e 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 000000000..db4d624ec --- /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 b6f71c5c2..8e1225380 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 cfbc2e203..e6ec0a8e0 100755 --- a/test +++ b/test @@ -82,8 +82,8 @@ Usage: ./test [options] [BundleName ...] 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 test run also runs the fast host-side backup -upgrader regression suite before selecting an iOS test scope. +working tree's changes. Every test run also runs the fast host-side tooling +regression suites before selecting an iOS test scope. Scope: (no arguments) Bundles affected by the diff against origin/main, including @@ -165,6 +165,8 @@ esac echo "==> Testing backup upgrader" mise exec -- ruby Where/Tools/Tests/upgrade_backup_test.rb +echo "==> Testing flyover command" +bash Shared/Flyover/Tools/Tests/flyover_test.sh WORKSPACE="Stuff.xcworkspace" UNIT_SCHEME="Stuff-iOS-Tests" @@ -311,7 +313,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: @@ -480,6 +482,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': @@ -654,6 +662,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, From 720ce86155988d4ce61b73301eb2f87a0a5eba2b Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 14 Aug 2026 17:59:07 -0700 Subject: [PATCH 4/5] Polish the Flyover web atlas review flow --- Shared/Flyover/README.md | 25 + Shared/Flyover/Web/assets/app.js | 989 +++++++++++++--- Shared/Flyover/Web/assets/styles.css | 1609 ++++++++++++++++++++++++-- Shared/Flyover/Web/index.html | 4 +- 4 files changed, 2333 insertions(+), 294 deletions(-) diff --git a/Shared/Flyover/README.md b/Shared/Flyover/README.md index 4b2d90211..c47ef30e1 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -184,6 +184,31 @@ 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/Web/assets/app.js b/Shared/Flyover/Web/assets/app.js index e7e598a74..fa728f3fd 100644 --- a/Shared/Flyover/Web/assets/app.js +++ b/Shared/Flyover/Web/assets/app.js @@ -4,7 +4,7 @@ const root = document.getElementById("app"); const manifest = window.FLYOVER_MANIFEST; if (!manifest || manifest.schemaVersion !== 1) { - root.innerHTML = '

Flyover export error

This site requires manifest schema version 1.

'; + root.innerHTML = '

Manifest error

Flyover cannot open this atlas

This site requires manifest schema version 1.

'; return; } @@ -23,6 +23,14 @@ 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) { @@ -50,12 +58,43 @@ return imageByKey.get(imageKey(screen.id, variant?.id, state.profile)); } - function matchesSearch(screen) { - const query = state.search.trim().toLocaleLowerCase(); - if (!query) return true; + function connectedRoutes(screen) { + return [...screen.incomingRouteIDs, ...screen.outgoingRouteIDs] + .map(id => routeByID.get(id)) + .filter(Boolean); + } + + function searchableText(screen) { const group = groupByID.get(screen.groupID); - return [group?.title, screen.title, ...screen.variants.map(variant => variant.title)] - .some(value => value?.toLocaleLowerCase().includes(query)); + 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() { @@ -85,63 +124,111 @@ values.set("screen", state.screen); values.set("variant", selectedVariants.get(state.screen)); } - const next = `#${values}`; - if (location.hash !== next) location.hash = next; + 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; - writeHash(); - render(); + renderOrNavigate(); } function chooseProfile(profile) { state.profile = profile; - writeHash(); - render(); + renderOrNavigate(); } function chooseVariant(screen, variantID, updateHistory = false) { selectedVariants.set(screen.id, variantID); if (state.screen === screen.id || updateHistory) { state.screen = screen.id; - writeHash(); + renderOrNavigate(); + } else { + render(); } - render(); } - function openScreen(screenID) { + function openScreen(screenID, preserveVariant = false) { const screen = screenByID.get(screenID); if (!screen) return; - selectedVariants.set(screen.id, screen.variants[0]?.id); + if (!preserveVariant) selectedVariants.set(screen.id, screen.variants[0]?.id); state.screen = screen.id; - writeHash(); - render(); + state.routeFocus = screen.id; + state.inspectorScale = "fit"; + renderOrNavigate(); } - function toolbar() { - const bar = element("header", "toolbar"); - bar.append(element("h1", "", manifest.application.title)); - bar.append(element("span", "build", manifest.build.commit.slice(0, 12))); - if (manifest.build.dirty) bar.append(element("span", "dirty", "Dirty build")); - bar.append(element("span", "build", manifest.build.generatedAt)); - bar.append(element("span", "toolbar-spacer")); - - const profileLabel = element("label", ""); - profileLabel.append(element("span", "", "Profile")); - const profile = element("select", ""); - profile.setAttribute("aria-label", "Capture profile"); + 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; - profile.append(option); + select.append(option); } - profile.addEventListener("change", () => chooseProfile(profile.value)); - profileLabel.append(profile); - bar.append(profileLabel); + 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"; @@ -149,45 +236,136 @@ button.addEventListener("click", () => chooseView(view)); tabs.append(button); } - bar.append(tabs); + 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 = "Search screens and states"; - search.setAttribute("aria-label", "Search screens and states"); + 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(); }); - bar.append(search); - - if (state.view === "canvas") { - const zoomLabel = element("label", ""); - zoomLabel.append(element("span", "", "Zoom")); - const zoom = element("input", ""); - zoom.type = "range"; - zoom.min = "0.1"; - zoom.max = "1.5"; - zoom.step = "0.05"; - zoom.value = String(state.zoom); - zoom.addEventListener("input", () => { - state.zoom = Number(zoom.value); - applyZoom(); - }); - zoomLabel.append(zoom); - bar.append(zoomLabel); - const fit = element("button", "", "Fit All"); - fit.type = "button"; - fit.addEventListener("click", fitAll); - bar.append(fit); + 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); } - return bar; + 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}`); + select.setAttribute("aria-label", "State for " + screen.title); for (const variant of screen.variants) { const option = element("option", "", variant.title); option.value = variant.id; @@ -201,82 +379,233 @@ return select; } - function routeButtons(screen) { + 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 routeIDs = [...screen.incomingRouteIDs, ...screen.outgoingRouteIDs]; - for (const id of routeIDs) { + const ids = direction === "incoming" ? screen.incomingRouteIDs : screen.outgoingRouteIDs; + for (const id of ids) { const route = routeByID.get(id); - const isOutgoing = route.sourceScreenID === screen.id; - const destinationID = isOutgoing ? route.destinationScreenID : route.sourceScreenID; - const destination = screenByID.get(destinationID); - const cue = isOutgoing ? (route.kind === "modal" ? "Modal" : "Push") : "Back"; - const button = element("button", "", `${cue}: ${destination?.title || destinationID}`); - button.type = "button"; - button.addEventListener("click", event => { - event.stopPropagation(); - openScreen(destinationID); - }); - links.append(button); + 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"}`; + 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 viewport = element("main", "canvas-viewport"); + 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`; + 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)); - shelf.append(element("h2", "", group?.title || item.id)); + 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}`)); + band.append(element("span", "", item.kind === "unlinked" ? "Unlinked" : "Depth " + item.depth)); stage.append(band); } stage.append(routeCanvas()); - for (const screen of manifest.screens) { - const card = element("article", "screen-card"); - card.dataset.screenId = screen.id; - card.dataset.hidden = String(!matchesSearch(screen)); - Object.assign(card.style, rectStyle(screen.frame)); - card.append(element("h3", "", screen.title)); - card.append(variantSelector(screen)); - const imageButton = element("button", "card-image-button"); - imageButton.type = "button"; - imageButton.setAttribute("aria-label", `Inspect ${screen.title}`); - imageButton.append(screenImage(screen)); - imageButton.addEventListener("click", () => openScreen(screen.id)); - card.append(imageButton); - card.append(routeButtons(screen)); - stage.append(card); - } + 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 viewport; + 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() { @@ -285,125 +614,327 @@ 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}`); + 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}`); - path.setAttribute("class", route.kind === "modal" ? "route-modal" : "route-push"); - svg.append(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}`); - arrow.setAttribute("class", route.kind === "modal" ? "route-arrow-modal" : "route-arrow-push"); - svg.append(arrow); + 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`, + 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", ""); - section.append(element("h2", "", group.title)); + 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) continue; - const row = element("article", "list-row"); - row.dataset.screenId = screen.id; - row.dataset.hidden = String(!matchesSearch(screen)); - const heading = element("strong", "", screen.title); - row.append(heading); - row.append(variantSelector(screen)); - row.append(screenImage(screen)); - const summary = element("div", "route-summary"); - summary.textContent = `${screen.incomingRouteIDs.length} incoming · ${screen.outgoingRouteIDs.length} outgoing`; - summary.append(routeButtons(screen)); - row.append(summary); - const inspect = element("button", "", "Inspect"); - inspect.type = "button"; - inspect.addEventListener("click", () => openScreen(screen.id)); - row.append(inspect); - section.append(row); + 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 dialog = element("dialog", ""); + 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 title = element("h2", "", screen.title); - title.id = "inspector-title"; - header.append(title); + 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); - header.append(close); + headerActions.append(previous, next, close); + header.append(heading, headerActions); dialog.append(header); const controls = element("div", "inspector-controls"); - const stateLabel = element("label", ""); - stateLabel.append(element("span", "", "State")); - const states = variantSelector(screen, true); - stateLabel.append(states); - controls.append(stateLabel); - const profileLabel = element("label", ""); - profileLabel.append(element("span", "", "Profile")); - const profiles = element("select", ""); - for (const item of manifest.profiles) { - const option = element("option", "", item.title); - option.value = item.id; - option.selected = item.id === state.profile; - profiles.append(option); + 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); } - profiles.addEventListener("change", () => chooseProfile(profiles.value)); - profileLabel.append(profiles); - controls.append(profileLabel); - controls.append(routeButtons(screen)); + controls.append(scale); dialog.append(controls); const body = element("div", "inspector-body"); - const imageFrame = element("div", `inspector-image ${variant.captureExtent === "fullContent" || variant.captureExtent === "fullContent2D" ? "full-content" : ""}`); - const fullImage = screenImage(screen); - const metadata = imageMetadata(screen); - if (variant.captureExtent === "fullContent" && metadata) { - fullImage.style.width = `${metadata.pointWidth}px`; - } - imageFrame.append(fullImage); - body.append(imageFrame); - const raw = element("a", "raw-link", "Open raw PNG"); + 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); - body.append(raw); + 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("close", closeInspector); + 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; - writeHash(); - render(); + 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]")) { - const screen = screenByID.get(node.dataset.screenId); - node.dataset.hidden = String(!matchesSearch(screen)); + 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"); } } @@ -411,32 +942,113 @@ 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.querySelector('.toolbar input[type="range"]'); + 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 horizontal = Math.max(viewport.clientWidth - 32, 1) / Math.max(frame.width, 1); - const vertical = Math.max(viewport.clientHeight - 32, 1) / Math.max(frame.height, 1); + 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 * state.zoom, top: frame.y * state.zoom }); + 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(); @@ -444,12 +1056,47 @@ 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 index 2e6ae1799..74b1242ef 100644 --- a/Shared/Flyover/Web/assets/styles.css +++ b/Shared/Flyover/Web/assets/styles.css @@ -1,159 +1,1524 @@ :root { color-scheme: light dark; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - --background: #eef1f5; + 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-muted: #f7f8fa; - --text: #16181d; - --secondary: #5f6672; - --border: #cfd4dc; - --accent: #1668dc; - --modal: #8950c9; - --shadow: 0 8px 30px rgb(20 27 45 / 14%); + --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: #12151a; - --surface: #1d2128; - --surface-muted: #252a33; - --text: #f5f7fa; - --secondary: #b0b7c3; - --border: #424957; - --accent: #6ca8ff; - --modal: #c49aff; - --shadow: 0 8px 30px rgb(0 0 0 / 35%); - } -} - -* { box-sizing: border-box; } -html, body, #app { min-height: 100%; margin: 0; } -body { background: var(--background); color: var(--text); } -button, input, select { font: inherit; } -button, select, input[type="search"], input[type="range"] { + --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: 8px; + border-radius: 9px; background: var(--surface); color: var(--text); } -button { cursor: pointer; padding: 0.45rem 0.7rem; } -button:hover { border-color: var(--accent); } -:focus-visible { outline: 3px solid var(--accent); outline-offset: 2px; } + +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 { - max-width: 42rem; + width: min(42rem, calc(100% - 2rem)); margin: 10vh auto; padding: 2rem; - border: 1px solid #d33; - border-radius: 12px; + border: 1px solid #d35a5a; + border-radius: 18px; background: var(--surface); + box-shadow: var(--shadow-md); } -.toolbar { - position: sticky; - z-index: 20; - top: 0; +.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; - flex-wrap: wrap; + 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; - padding: 0.75rem 1rem; - border-bottom: 1px solid var(--border); - background: color-mix(in srgb, var(--surface) 94%, transparent); - backdrop-filter: blur(14px); -} -.toolbar h1 { margin: 0; font-size: 1.05rem; } -.build { color: var(--secondary); font: 0.78rem ui-monospace, monospace; } -.dirty { padding: 0.2rem 0.45rem; border-radius: 999px; background: #a74700; color: white; } -.toolbar-spacer { flex: 1 1 1rem; } -.toolbar label { display: flex; gap: 0.35rem; align-items: center; font-size: 0.82rem; } -.toolbar select, .toolbar input[type="search"] { min-height: 2.1rem; padding: 0.35rem 0.5rem; } -.toolbar input[type="search"] { width: min(17rem, 48vw); } -.view-tabs { display: flex; gap: 0.25rem; } -.view-tabs button[aria-pressed="true"] { background: var(--accent); color: white; border-color: var(--accent); } - -.canvas-viewport { overflow: auto; height: calc(100vh - 62px); } -.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: 2px solid var(--border); - border-radius: 28px; - background: color-mix(in srgb, var(--surface) 82%, transparent); -} -.group-shelf h2 { margin: 14px 20px; font-size: 1.2rem; } -.depth-band { border: 1px dashed color-mix(in srgb, var(--secondary) 45%, transparent); border-radius: 18px; } -.depth-band span { position: absolute; top: -0.75rem; left: 10px; padding: 0 0.35rem; background: var(--surface); color: var(--secondary); font-size: 0.72rem; font-weight: 700; } -.canvas-routes { inset: 0; overflow: visible; pointer-events: none; } -.route-push { fill: none; stroke: var(--accent); stroke-width: 3; } -.route-modal { fill: none; stroke: var(--modal); stroke-width: 3; stroke-dasharray: 12 8; } -.route-arrow-push { fill: var(--accent); } -.route-arrow-modal { fill: var(--modal); } -.screen-card { +} + +.brand-mark { display: grid; - grid-template-rows: auto auto minmax(0, 1fr) auto; + 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: 22px; + border-radius: 9px; background: var(--surface); - box-shadow: var(--shadow); -} -.screen-card[data-hidden="true"] { display: none; } -.screen-card h3 { margin: 0; padding: 0.8rem 0.9rem 0.35rem; font-size: 1rem; } -.screen-card select { margin: 0.25rem 0.9rem 0.6rem; width: calc(100% - 1.8rem); padding: 0.35rem; } -.card-image-button { display: grid; min-height: 0; margin: 0 0.75rem; padding: 0; overflow: hidden; background: var(--surface-muted); } -.card-image-button img { width: 100%; height: 100%; object-fit: contain; } -.route-links { display: flex; flex-wrap: wrap; gap: 0.35rem; padding: 0.65rem 0.75rem 0.8rem; } -.route-links button { font-size: 0.72rem; padding: 0.3rem 0.45rem; } - -.list { max-width: 88rem; margin: 0 auto; padding: 1.5rem; } -.list section { margin-bottom: 2rem; } -.list h2 { margin: 0 0 0.75rem; } -.list-row { + 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: minmax(10rem, 1fr) minmax(9rem, 0.8fr) minmax(11rem, 1fr) minmax(10rem, 1fr) auto; - gap: 0.8rem; + 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; - padding: 0.75rem; + gap: 0.45rem; + padding: 0 0.55rem; border: 1px solid var(--border); - border-bottom-width: 0; + border-radius: 9px; background: var(--surface); } -.list-row:last-child { border-bottom-width: 1px; border-radius: 0 0 10px 10px; } -.list-row:first-of-type { border-radius: 10px 10px 0 0; } -.list-row[data-hidden="true"] { display: none; } -.list-row img { width: 7rem; height: 7rem; object-fit: contain; background: var(--surface-muted); border-radius: 8px; } -.list-row select { width: 100%; padding: 0.4rem; } -.route-summary { color: var(--secondary); font-size: 0.78rem; } -dialog { - width: min(94vw, 75rem); - max-height: 92vh; +.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: 16px; + 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(--text); - box-shadow: var(--shadow); -} -dialog::backdrop { background: rgb(0 0 0 / 58%); } -.inspector-header { display: flex; gap: 0.75rem; align-items: center; padding: 1rem; border-bottom: 1px solid var(--border); } -.inspector-header h2 { margin: 0; } -.inspector-header .close { margin-left: auto; } -.inspector-controls { display: flex; flex-wrap: wrap; gap: 0.8rem; padding: 0.8rem 1rem; background: var(--surface-muted); } -.inspector-controls label { display: flex; gap: 0.4rem; align-items: center; } -.inspector-body { padding: 1rem; } -.inspector-image { display: flex; justify-content: center; overflow: auto; max-height: 65vh; background: var(--surface-muted); border-radius: 10px; } -.inspector-image img { max-width: 100%; height: auto; object-fit: contain; } -.inspector-image.full-content img { max-width: none; } -.raw-link { display: inline-block; margin-top: 0.75rem; color: var(--accent); } - -@media (max-width: 900px) { - .list-row { grid-template-columns: 1fr auto; } - .list-row > :not(:first-child):not(:last-child) { grid-column: 1 / -1; } - .build { display: none; } + color: var(--secondary); + font-size: 10px; + font-weight: 750; + letter-spacing: 0.045em; + text-transform: uppercase; } -@media (prefers-reduced-motion: reduce) { - *, *::before, *::after { scroll-behavior: auto !important; transition: none !important; animation: none !important; } +.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 index 50d4d731a..475c50a27 100644 --- a/Shared/Flyover/Web/index.html +++ b/Shared/Flyover/Web/index.html @@ -3,11 +3,13 @@ + Flyover QA Atlas + -
+
From 41fa7652ade0733ca66cf44d2118ffe6949923af Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 14 Aug 2026 17:59:42 -0700 Subject: [PATCH 5/5] Require completed work to be pushed --- AGENTS.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 76fee7305..3a84f872d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -528,10 +528,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