From 65410b139ec7fb3e1c345651485b7a411b82dfd4 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 13 Aug 2026 23:41:13 -0700 Subject: [PATCH 01/15] 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 02/15] 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 03/15] 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 04/15] 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 05/15] 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 From 770ed515ec779a96a71d0a5b5c687659e1bb8eda Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 3 Sep 2026 14:25:25 -0700 Subject: [PATCH 06/15] Polish the Flyover web experience Make the atlas canvas-first with a compact floating dock, command-palette navigation, grouped list rows, and an immersive screenshot inspector. Preserve manifest and deep-link compatibility while keeping secondary review tools available in focused panels.\n\nValidation:\n- ./flyover export --output /tmp/where-flyover-codex --profile phone-light\n- ./test FlyoverTests\n- bash Shared/Flyover/Tools/Tests/flyover_test.sh\n- ./swiftformat --lint\n- JavaScriptCore syntax parse --- Shared/Flyover/README.md | 33 +- Shared/Flyover/Web/assets/app.js | 1224 +++++++----- Shared/Flyover/Web/assets/styles.css | 2616 ++++++++++++++++---------- Shared/Flyover/Web/index.html | 2 +- 4 files changed, 2431 insertions(+), 1444 deletions(-) diff --git a/Shared/Flyover/README.md b/Shared/Flyover/README.md index c47ef30e1..8af34bb55 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -184,29 +184,34 @@ 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 +The website opens the first catalog group in canvas mode. A floating control +dock keeps the canvas visible. The group panel and overview map move between +groups without recalculating the graph. The canvas keeps its position when a +state, profile, or panel changes. + +Point to or focus a card to emphasize its connected routes. The site dims +unrelated cards and routes until the focus moves. Filters for groups, capture +extents, and route states stay in a separate panel. + +Search opens a command palette. It matches group, screen, state, and connected +route names. A result opens its screen or fits its group. List mode shows the +same selection in grouped rows. State and profile changes update the native image without changing the selected screen. -The inspector 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 inspector uses the full browser window. The image stays central while a +drawer supplies capture data and route links. Full-content images use a +device-width scroll area. The Fit and 100% controls change the image scale +without changing the capture. 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 `/` or `Command-K` to open search. - Press `F` to fit the complete canvas. - Press `0` to fit the current group. - Press `+` or `-` to change the canvas zoom. -- Press `[` or `]` to move between screens in the inspector. +- Press an arrow key, `[` or `]`, to move between inspector screens. +- Press `I` to show or hide the inspector details. - Press Escape to close the inspector. ## Testing diff --git a/Shared/Flyover/Web/assets/app.js b/Shared/Flyover/Web/assets/app.js index fa728f3fd..f5c1f4a5e 100644 --- a/Shared/Flyover/Web/assets/app.js +++ b/Shared/Flyover/Web/assets/app.js @@ -21,11 +21,20 @@ view: "canvas", profile: manifest.profiles[0]?.id, screen: null, - search: "", zoom: 1, group: manifest.groups[0]?.id, routeFocus: null, inspectorScale: "fit", + inspectorDetails: false, + panel: null, + panelTrigger: null, + commandQuery: "", + pendingCanvasAction: null, + canvas: { + initialized: false, + scrollLeft: 0, + scrollTop: 0, + }, filters: { group: "all", extent: "all", @@ -33,6 +42,23 @@ }, }; + const iconPaths = { + canvas: "M4 4h6v6H4zM14 4h6v6h-6zM4 14h6v6H4zM14 14h6v6h-6z", + close: "M6 6l12 12M18 6 6 18", + external: "M14 4h6v6M20 4l-9 9M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6", + filter: "M4 6h16M7 12h10M10 18h4", + fit: "M8 3H3v5M16 3h5v5M8 21H3v-5M16 21h5v-5", + info: "M12 11v6M12 7h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z", + left: "m15 18-6-6 6-6", + list: "M9 6h11M9 12h11M9 18h11M4 6h.01M4 12h.01M4 18h.01", + map: "m3 6 6-3 6 3 6-3v15l-6 3-6-3-6 3V6Zm6-3v15m6-12v15", + minus: "M5 12h14", + plus: "M12 5v14M5 12h14", + right: "m9 18 6-6-6-6", + route: "M5 6h7a4 4 0 0 1 4 4v8m-4-4 4 4 4-4", + search: "m21 21-4.35-4.35M19 11a8 8 0 1 1-16 0 8 8 0 0 1 16 0Z", + }; + function element(tag, className, text) { const value = document.createElement(tag); if (className) value.className = className; @@ -40,6 +66,28 @@ return value; } + function icon(name) { + const namespace = "http://www.w3.org/2000/svg"; + const svg = document.createElementNS(namespace, "svg"); + svg.setAttribute("viewBox", "0 0 24 24"); + svg.setAttribute("aria-hidden", "true"); + svg.classList.add("icon"); + const path = document.createElementNS(namespace, "path"); + path.setAttribute("d", iconPaths[name]); + svg.append(path); + return svg; + } + + function iconButton(iconName, label, className = "icon-button", visibleLabel = false) { + const button = element("button", className); + button.type = "button"; + button.setAttribute("aria-label", label); + button.title = label; + button.append(icon(iconName)); + if (visibleLabel) button.append(element("span", "button-label", label)); + return button; + } + function screenVariant(screen) { const id = selectedVariants.get(screen.id); return screen.variants.find(variant => variant.id === id) || screen.variants[0]; @@ -77,9 +125,7 @@ .toLocaleLowerCase(); } - function matchesSearch(screen) { - const terms = state.search.trim().toLocaleLowerCase().split(/ +/).filter(Boolean); - if (!terms.every(term => searchableText(screen).includes(term))) return false; + function matchesFilters(screen) { 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; @@ -94,7 +140,7 @@ } function visibleScreenIDs() { - return new Set(manifest.screens.filter(matchesSearch).map(screen => screen.id)); + return new Set(manifest.screens.filter(matchesFilters).map(screen => screen.id)); } function parseHash() { @@ -108,9 +154,7 @@ if (screenByID.has(screenID)) { state.screen = screenID; const screen = screenByID.get(screenID); - if (screen.variants.some(variant => variant.id === variantID)) { - selectedVariants.set(screenID, variantID); - } + if (screen.variants.some(variant => variant.id === variantID)) selectedVariants.set(screenID, variantID); } else { state.screen = null; } @@ -136,6 +180,8 @@ function chooseView(view) { state.view = view; + state.panel = null; + if (view === "canvas" && !state.canvas.initialized) state.pendingCanvasAction = "fit-group"; renderOrNavigate(); } @@ -161,6 +207,8 @@ state.screen = screen.id; state.routeFocus = screen.id; state.inspectorScale = "fit"; + state.inspectorDetails = false; + state.panel = null; renderOrNavigate(); } @@ -174,20 +222,17 @@ 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); + 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 metadataRow(label, value) { + const row = element("div", "metadata-row"); + row.append(element("dt", "", label), element("dd", "", value)); + return row; } - function profileSelect() { - const select = element("select", ""); + function profileSelect(className = "") { + const select = element("select", className); select.setAttribute("aria-label", "Capture profile"); for (const item of manifest.profiles) { const option = element("option", "", item.title); @@ -199,172 +244,91 @@ return select; } - function toolbar() { - const shell = element("header", "app-header"); - const masthead = element("div", "masthead"); - const brand = element("div", "brand"); - brand.append(element("span", "brand-mark", "F")); - const brandCopy = element("div", ""); - brandCopy.append(element("p", "eyebrow", "Flyover QA Atlas")); - brandCopy.append(element("h1", "", manifest.application.title)); - brand.append(brandCopy); - masthead.append(brand); - - const build = element("details", "build-details"); - const summary = element("summary", ""); - summary.append(element("span", "commit-dot"), element("code", "", manifest.build.commit.slice(0, 8))); - if (manifest.build.dirty) summary.append(element("span", "dirty", "Dirty")); - build.append(summary); - const buildPanel = element("div", "build-panel"); - buildPanel.append( - metadataRow("Generated", formatGeneratedAt(manifest.build.generatedAt)), - metadataRow("Branch", manifest.build.branch || "Detached"), - metadataRow("Commit", manifest.build.commit), - metadataRow("Xcode", manifest.build.xcodeVersion), - metadataRow("Simulator", manifest.build.simulatorDevice + " · iOS " + manifest.build.simulatorOS), - ); - build.append(buildPanel); - masthead.append(build); - - const primary = element("div", "primary-toolbar"); - const tabs = element("div", "view-tabs"); - tabs.setAttribute("aria-label", "Atlas view"); - for (const view of ["canvas", "list"]) { - const button = element("button", "", view[0].toUpperCase() + view.slice(1)); - button.type = "button"; - button.setAttribute("aria-pressed", String(state.view === view)); - button.addEventListener("click", () => chooseView(view)); - tabs.append(button); - } - primary.append(tabs); - - const searchField = element("label", "search-field"); - searchField.append(element("span", "search-icon", "⌕")); - const search = element("input", ""); - search.id = "global-search"; - search.type = "search"; - search.placeholder = "Find screens, states, or routes"; - search.setAttribute("aria-label", "Search screens, states, and routes"); - search.setAttribute("aria-keyshortcuts", "/"); - search.value = state.search; - search.addEventListener("input", () => { - state.search = search.value; - updateSearchVisibility(); - }); - searchField.append(search, element("kbd", "", "/")); - primary.append(searchField); - primary.append(filterMenu()); - primary.append(labeledSelect("Profile", profileSelect(), "profile-field")); - shell.append(masthead, primary); - - const context = element("div", "context-toolbar"); - const resultCount = element("p", "result-count"); - resultCount.id = "result-count"; - resultCount.setAttribute("aria-live", "polite"); - context.append(resultCount); - if (state.view === "canvas") context.append(canvasControls()); - shell.append(context); - return shell; + function appHeader() { + const header = element("header", "app-header"); + const identity = element("div", "app-identity"); + identity.append(element("span", "brand-mark", "F")); + const copy = element("div", "identity-copy"); + copy.append(element("span", "product-label", "Flyover"), element("h1", "", manifest.application.title)); + identity.append(copy); + + const actions = element("div", "header-actions"); + const profile = element("label", "header-profile"); + profile.append(element("span", "visually-hidden", "Capture profile"), profileSelect()); + actions.append(profile); + + const search = iconButton("search", "Search", "header-button search-trigger", true); + search.id = "search-trigger"; + search.setAttribute("aria-keyshortcuts", "/ Meta+K Control+K"); + search.append(element("kbd", "", "⌘K")); + search.addEventListener("click", () => openPanel("search", search.id)); + actions.append(search); + + const build = iconButton("info", "Build details", "header-button build-trigger"); + build.id = "build-trigger"; + build.append(element("code", "commit-label", manifest.build.commit.slice(0, 8))); + if (manifest.build.dirty) build.append(element("span", "dirty-dot")); + build.addEventListener("click", () => openPanel("build", build.id)); + actions.append(build); + header.append(identity, actions); + return header; } - function metadataRow(label, value) { - const row = element("div", "metadata-row"); - row.append(element("dt", "", label), element("dd", "", value)); - return row; - } + function bottomDock() { + const dock = element("nav", "bottom-dock"); + dock.setAttribute("aria-label", "Atlas controls"); + + const viewTabs = element("div", "segmented-control view-tabs"); + viewTabs.setAttribute("aria-label", "Atlas view"); + for (const item of [["canvas", "canvas", "Canvas"], ["list", "list", "List"]]) { + const button = iconButton(item[1], item[2], "segment", true); + button.setAttribute("aria-pressed", String(state.view === item[0])); + button.addEventListener("click", () => chooseView(item[0])); + viewTabs.append(button); + } + dock.append(viewTabs, element("span", "dock-separator")); + + if (state.view === "canvas") { + const zoom = element("div", "zoom-controls"); + const minus = iconButton("minus", "Zoom out"); + minus.addEventListener("click", () => setZoom(state.zoom - 0.1)); + const value = element("output", "zoom-value", Math.round(state.zoom * 100) + "%"); + value.id = "zoom-value"; + const plus = iconButton("plus", "Zoom in"); + plus.addEventListener("click", () => setZoom(state.zoom + 0.1)); + zoom.append(minus, value, plus); + + const fitGroup = iconButton("fit", "Fit current group", "dock-action", true); + fitGroup.setAttribute("aria-keyshortcuts", "0"); + fitGroup.addEventListener("click", () => fitCurrentGroup()); + const fitAllButton = iconButton("fit", "Fit all", "dock-action fit-all", true); + fitAllButton.setAttribute("aria-keyshortcuts", "F"); + fitAllButton.addEventListener("click", () => fitAll()); + dock.append(zoom, fitGroup, fitAllButton, element("span", "dock-separator")); + } - 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; + const groups = iconButton("map", "Browse groups", "dock-action group-trigger", true); + groups.id = "group-trigger"; + const currentGroup = groupByID.get(state.group); + groups.querySelector(".button-label").textContent = currentGroup?.title || "Groups"; + groups.addEventListener("click", () => openPanel("groups", groups.id)); + dock.append(groups); + + const filters = iconButton("filter", "Filters", "dock-action filter-trigger", true); + filters.id = "filter-trigger"; + filters.append(element("span", "filter-badge")); + filters.addEventListener("click", () => openPanel("filters", filters.id)); + dock.append(filters); + + const count = element("span", "result-count"); + count.id = "result-count"; + count.setAttribute("aria-live", "polite"); + dock.append(count); + return dock; } - function filterSelect(labelText, value, options, onChange) { - const select = element("select", ""); - for (const pair of options) { - const option = element("option", "", pair[1]); - option.value = pair[0]; - option.selected = pair[0] === value; - select.append(option); - } - select.addEventListener("change", () => onChange(select.value)); - return labeledSelect(labelText, select); - } - - function canvasControls() { - const controls = element("div", "canvas-controls"); - const minus = element("button", "icon-button", "−"); - minus.type = "button"; - minus.setAttribute("aria-label", "Zoom out"); - minus.addEventListener("click", () => setZoom(state.zoom - 0.1)); - const zoom = element("input", ""); - zoom.id = "canvas-zoom"; - zoom.type = "range"; - zoom.min = "0.1"; - zoom.max = "1.5"; - zoom.step = "0.05"; - zoom.value = String(state.zoom); - zoom.setAttribute("aria-label", "Canvas zoom"); - zoom.addEventListener("input", () => setZoom(Number(zoom.value))); - const plus = element("button", "icon-button", "+"); - plus.type = "button"; - plus.setAttribute("aria-label", "Zoom in"); - plus.addEventListener("click", () => setZoom(state.zoom + 0.1)); - const value = element("output", "zoom-value", Math.round(state.zoom * 100) + "%"); - value.id = "zoom-value"; - value.setAttribute("for", "canvas-zoom"); - const fitGroup = element("button", "", "Fit group"); - fitGroup.type = "button"; - fitGroup.setAttribute("aria-keyshortcuts", "0"); - fitGroup.addEventListener("click", fitCurrentGroup); - const fit = element("button", "primary-button", "Fit all"); - fit.type = "button"; - fit.setAttribute("aria-keyshortcuts", "F"); - fit.addEventListener("click", fitAll); - controls.append(minus, zoom, plus, value, fitGroup, fit); - return controls; - } - - function variantSelector(screen, updateHistory = false) { - const select = element("select", ""); + function variantSelector(screen, updateHistory = false, className = "") { + const select = element("select", className); select.setAttribute("aria-label", "State for " + screen.title); for (const variant of screen.variants) { const option = element("option", "", variant.title); @@ -385,8 +349,9 @@ 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)); + const button = element("button", "route-chip " + route.kind); button.type = "button"; + button.append(icon("route"), element("span", "", cue + " · " + (destination?.title || destinationID))); button.addEventListener("click", event => { event.stopPropagation(); openScreen(destinationID); @@ -405,9 +370,9 @@ return links; } - function screenImage(screen, className) { - const image = element("img", className || ""); - image.loading = "lazy"; + function screenImage(screen, className = "", eager = false) { + const image = element("img", className); + image.loading = eager ? "eager" : "lazy"; image.decoding = "async"; image.src = imagePath(screen); image.alt = screen.title + " — " + (screenVariant(screen)?.title || "Default"); @@ -417,12 +382,27 @@ function captureLabel(extent) { if (extent === "fullContent") return "Full content"; if (extent === "fullContent2D") return "Full content 2D"; + if (!extent) return "Viewport"; return extent[0].toUpperCase() + extent.slice(1); } function canvasView() { - const layout = element("main", "canvas-layout"); - const sidebar = canvasSidebar(); + const main = element("main", "canvas-view"); + const context = element("button", "canvas-context"); + context.type = "button"; + context.setAttribute("aria-label", "Browse canvas groups"); + const group = groupByID.get(state.group) || manifest.groups[0]; + context.append( + element("span", "context-kicker", "Group " + ((group?.order || 0) + 1) + " of " + manifest.groups.length), + element("strong", "", group?.title || "Atlas"), + ); + context.addEventListener("click", () => openPanel("groups", "group-trigger")); + main.append(context); + + const legend = element("div", "route-legend"); + legend.append(legendItem("push", "Push"), legendItem("modal", "Modal")); + main.append(legend); + const viewport = element("section", "canvas-viewport"); viewport.id = "canvas-viewport"; viewport.setAttribute("aria-label", "Screen atlas canvas"); @@ -434,14 +414,14 @@ stage.style.height = manifest.canvas.size.height + "px"; for (const item of manifest.canvas.groupFrames) { - const group = groupByID.get(item.id); + const catalogGroup = groupByID.get(item.id); const shelf = element("section", "group-shelf"); shelf.dataset.groupId = item.id; Object.assign(shelf.style, rectStyle(item.frame)); - const shelfHeader = element("div", "shelf-header"); + const shelfHeader = element("header", "shelf-header"); shelfHeader.append( - element("p", "eyebrow", "Group " + (group?.order + 1)), - element("h2", "", group?.title || item.id), + element("span", "shelf-index", String((catalogGroup?.order || 0) + 1).padStart(2, "0")), + element("h2", "", catalogGroup?.title || item.id), ); shelf.append(shelfHeader); stage.append(shelf); @@ -454,52 +434,30 @@ stage.append(band); } stage.append(routeCanvas()); - for (const screen of manifest.screens) stage.append(screenCard(screen)); scaled.append(stage); viewport.append(scaled); viewport.addEventListener("scroll", scheduleCanvasNavigationUpdate, { passive: true }); - layout.append(sidebar, viewport, emptyResults()); + main.append(viewport, emptyResults()); + requestAnimationFrame(() => { applyZoom(); updateSearchVisibility(); - if (!state.screen && !location.hash.includes("view=")) fitFirstGroup(); + if (state.pendingCanvasAction === "fit-group") { + state.pendingCanvasAction = null; + fitCurrentGroup("auto"); + } else if (state.pendingCanvasAction === "fit-all") { + state.pendingCanvasAction = null; + fitAll("auto"); + } else if (!state.canvas.initialized) { + fitFirstGroup(); + } else { + viewport.scrollTo({ left: state.canvas.scrollLeft, top: state.canvas.scrollTop }); + updateCanvasNavigation(); + } + state.canvas.initialized = true; }); - 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; + return main; } function legendItem(kind, title) { @@ -508,77 +466,22 @@ 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.dataset.hidden = String(!matchesFilters(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 header = element("header", "card-header"); + const heading = element("div", "card-heading"); + heading.append(element("h3", "", screen.title)); + const inspect = iconButton("fit", "Inspect " + screen.title, "card-inspect"); + inspect.addEventListener("click", () => openScreen(screen.id, true)); + header.append(heading, inspect); + const imageButton = element("button", "card-image-button"); imageButton.type = "button"; imageButton.setAttribute("aria-label", "Inspect " + screen.title); @@ -586,14 +489,23 @@ device.append(screenImage(screen)); imageButton.append(device); imageButton.addEventListener("click", () => openScreen(screen.id, true)); - card.append(imageButton); + + const imageMeta = element("div", "card-image-meta"); + const routeCount = screen.incomingRouteIDs.length + screen.outgoingRouteIDs.length; + imageMeta.append( + element("span", "capture-badge", captureLabel(variant?.captureExtent)), + element("span", "route-count", routeCount + (routeCount === 1 ? " route" : " routes")), + ); + imageButton.append(imageMeta); + 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); + if (screen.variants.length > 1) { + footer.append(element("span", "footer-label", "State"), variantSelector(screen, false, "card-state-select")); + } else { + footer.append(element("span", "state-dot"), element("span", "single-state", variant?.title || "Default")); + } + card.append(header, imageButton, footer); + card.addEventListener("pointerenter", () => setRouteFocus(screen.id)); card.addEventListener("pointerleave", () => { if (!card.matches(":focus-within")) setRouteFocus(null); @@ -638,28 +550,28 @@ } function rectStyle(rect) { - return { - left: rect.x + "px", - top: rect.y + "px", - width: rect.width + "px", - height: rect.height + "px", - }; + return { left: rect.x + "px", top: rect.y + "px", width: rect.width + "px", height: rect.height + "px" }; } function listView() { - const main = element("main", "list"); - const intro = element("div", "list-intro"); - intro.append( - element("p", "eyebrow", "Catalog"), - element("h2", "", manifest.screens.length + " screens across " + manifest.groups.length + " groups"), - element("p", "", "Review captured states in a compact, scannable inventory."), - ); + const main = element("main", "list-view"); + const intro = element("header", "list-intro"); + const copy = element("div", ""); + copy.append(element("p", "eyebrow", "Captured catalog")); + copy.append(element("h2", "", manifest.screens.length + " screens, ready to review")); + intro.append(copy, element("p", "list-summary", manifest.groups.length + " groups · " + + manifest.routes.length + " routes · " + manifest.profiles.length + " profiles")); main.append(intro); + for (const group of manifest.groups) { const section = element("section", "list-group"); section.dataset.listGroupId = group.id; const heading = element("header", "list-group-header"); - heading.append(element("h2", "", group.title), element("span", "", group.screenIDs.length + " screens")); + heading.append( + element("span", "group-number", String(group.order + 1).padStart(2, "0")), + element("h2", "", group.title), + element("span", "group-count", group.screenIDs.length + " screens"), + ); section.append(heading); const rows = element("div", "list-rows"); for (const screenID of group.screenIDs) { @@ -679,44 +591,42 @@ const row = element("article", "list-row"); row.dataset.screenId = screen.id; row.dataset.groupId = screen.groupID; - row.dataset.hidden = String(!matchesSearch(screen)); + row.dataset.hidden = String(!matchesFilters(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), - ); + identity.append(element("h3", "", screen.title)); if (screen.variants.length > 1) { - identity.append(labeledSelect("State", variantSelector(screen), "list-state")); + const stateField = element("label", "list-state"); + stateField.append(element("span", "", "State"), variantSelector(screen)); + identity.append(stateField); } 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 facts = element("div", "list-facts"); + facts.append( + element("span", "capture-badge", captureLabel(variant?.captureExtent)), + element("span", "secondary", screen.incomingRouteIDs.length + " in · " + + screen.outgoingRouteIDs.length + " out"), ); + 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 quickRouteID = screen.outgoingRouteIDs[0] || screen.incomingRouteIDs[0]; + const quickRoute = routeByID.get(quickRouteID); + if (quickRoute) { + const direction = quickRoute.sourceScreenID === screen.id ? "outgoing" : "incoming"; + routes.append(routeButton(quickRoute, screen, direction)); } - const inspect = element("button", "list-inspect primary-button", "Inspect"); - inspect.type = "button"; + + const inspect = iconButton("right", "Inspect " + screen.title, "list-inspect"); inspect.addEventListener("click", () => openScreen(screen.id, true)); - row.append(thumbnail, identity, traits, routes, inspect); + row.append(thumbnail, identity, facts, routes, inspect); return row; } @@ -729,90 +639,108 @@ const screenIndex = manifest.screens.findIndex(item => item.id === screen.id); const dialog = element("dialog", "inspector"); dialog.id = "inspector"; + dialog.dataset.details = String(state.inspectorDetails); dialog.setAttribute("aria-labelledby", "inspector-title"); + const header = element("header", "inspector-header"); - const heading = element("div", ""); + const heading = element("div", "inspector-heading"); heading.append( element("p", "eyebrow", (group?.title || "Ungrouped") + " · " + (screenIndex + 1) + " of " + manifest.screens.length), element("h2", "", screen.title), ); heading.querySelector("h2").id = "inspector-title"; - const headerActions = element("div", "inspector-header-actions"); - const previous = element("button", "icon-button", "←"); - previous.type = "button"; - previous.setAttribute("aria-label", "Previous screen"); - previous.setAttribute("aria-keyshortcuts", "["); - previous.addEventListener("click", () => openScreen(neighboringScreen(-1)?.id)); - const next = element("button", "icon-button", "→"); - next.type = "button"; - next.setAttribute("aria-label", "Next screen"); - next.setAttribute("aria-keyshortcuts", "]"); - next.addEventListener("click", () => openScreen(neighboringScreen(1)?.id)); - const close = element("button", "close", "Close"); - close.type = "button"; - close.autofocus = true; - close.addEventListener("click", closeInspector); - headerActions.append(previous, next, close); - header.append(heading, headerActions); - dialog.append(header); - - const controls = element("div", "inspector-controls"); - controls.append( - labeledSelect("State", variantSelector(screen, true)), - labeledSelect("Profile", profileSelect()), - ); - const scale = element("div", "scale-tabs"); - scale.setAttribute("aria-label", "Image scale"); - for (const option of [["fit", "Fit"], ["actual", "100%"]]) { - const button = element("button", "", option[1]); - button.type = "button"; - button.setAttribute("aria-pressed", String(state.inspectorScale === option[0])); - button.addEventListener("click", () => { - state.inspectorScale = option[0]; - updateInspectorScale(); - }); - scale.append(button); - } - controls.append(scale); - dialog.append(controls); - - const body = element("div", "inspector-body"); - const preview = element("section", "inspector-preview"); - const previewToolbar = element("div", "preview-toolbar"); - previewToolbar.append( - element("span", "capture-badge", captureLabel(variant.captureExtent)), - element("span", "secondary", imageDimensions(metadata)), - ); - const raw = element("a", "raw-link", "Open raw PNG ↗"); + const actions = element("div", "inspector-header-actions"); + const raw = element("a", "inspector-action", "PNG"); raw.href = imagePath(screen); raw.target = "_blank"; raw.rel = "noopener"; - previewToolbar.append(raw); + raw.setAttribute("aria-label", "Open raw PNG"); + raw.append(icon("external")); + const details = iconButton("info", "Capture details", "inspector-action", true); + details.id = "details-trigger"; + details.setAttribute("aria-expanded", String(state.inspectorDetails)); + details.addEventListener("click", toggleInspectorDetails); + const close = iconButton("close", "Close inspector", "inspector-action close-inspector"); + close.autofocus = true; + close.addEventListener("click", closeInspector); + actions.append(raw, details, close); + header.append(heading, actions); + + const canvas = element("section", "inspection-canvas"); 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"); + const fullImage = screenImage(screen, "", true); + if (metadata) { + device.style.setProperty("--point-width", metadata.pointWidth + "px"); + device.style.setProperty("--aspect-ratio", metadata.pointWidth / metadata.pointHeight); + } 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); + const caption = element("div", "image-caption"); + caption.append(element("span", "capture-badge", captureLabel(variant.captureExtent)), element("span", "", imageDimensions(metadata))); + canvas.append(imageFrame, caption); + + const drawer = element("aside", "inspector-details"); + drawer.id = "inspector-details"; + drawer.setAttribute("aria-hidden", String(!state.inspectorDetails)); + drawer.inert = !state.inspectorDetails; + const drawerHeader = element("header", "details-header"); + drawerHeader.append(element("p", "eyebrow", "Screen details"), element("h3", "", screen.title)); + const drawerClose = iconButton("close", "Close details", "icon-button details-close"); + drawerClose.addEventListener("click", toggleInspectorDetails); + drawerHeader.append(drawerClose); + drawer.append(drawerHeader, inspectorMetadata(screen, variant, metadata)); + drawer.append(routeSection("Outgoing", screen, "outgoing")); + drawer.append(routeSection("Incoming", screen, "incoming")); + + dialog.append(header, canvas, drawer, inspectorDock(screen)); dialog.addEventListener("cancel", event => { event.preventDefault(); - closeInspector(); + if (state.inspectorDetails) toggleInspectorDetails(); + else closeInspector(); }); return dialog; } + function inspectorDock(screen) { + const dock = element("nav", "inspector-dock"); + dock.setAttribute("aria-label", "Inspector controls"); + const previous = iconButton("left", "Previous screen", "icon-button inspector-previous"); + previous.setAttribute("aria-keyshortcuts", "ArrowLeft ["); + previous.addEventListener("click", () => openScreen(neighboringScreen(-1)?.id)); + dock.append(previous, element("span", "dock-separator")); + + const stateField = element("label", "inspector-field"); + stateField.append(element("span", "field-label", "State"), variantSelector(screen, true)); + const profileField = element("label", "inspector-field profile-field"); + profileField.append(element("span", "field-label", "Profile"), profileSelect()); + dock.append(stateField, profileField, element("span", "dock-separator")); + + const scale = element("div", "segmented-control scale-tabs"); + scale.setAttribute("aria-label", "Image scale"); + for (const option of [["fit", "Fit"], ["actual", "100%"]]) { + const button = element("button", "segment", 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); + } + dock.append(scale, element("span", "dock-separator")); + + const next = iconButton("right", "Next screen", "icon-button inspector-next"); + next.setAttribute("aria-keyshortcuts", "ArrowRight ]"); + next.addEventListener("click", () => openScreen(neighboringScreen(1)?.id)); + dock.append(next); + return dock; + } + function imageDimensions(metadata) { if (!metadata) return "Image metadata unavailable"; return Math.round(metadata.pointWidth) + " × " + Math.round(metadata.pointHeight) + " pt · " @@ -821,7 +749,7 @@ function inspectorMetadata(screen, variant, metadata) { const section = element("section", "detail-section"); - section.append(element("p", "eyebrow", "Capture details")); + section.append(element("p", "eyebrow", "Capture")); const list = element("dl", "detail-list"); list.append( metadataRow("State", variant.title), @@ -844,6 +772,21 @@ return section; } + function toggleInspectorDetails() { + state.inspectorDetails = !state.inspectorDetails; + const dialog = document.getElementById("inspector"); + const drawer = document.getElementById("inspector-details"); + const trigger = document.getElementById("details-trigger"); + if (dialog) dialog.dataset.details = String(state.inspectorDetails); + if (drawer) { + drawer.setAttribute("aria-hidden", String(!state.inspectorDetails)); + drawer.inert = !state.inspectorDetails; + } + if (trigger) trigger.setAttribute("aria-expanded", String(state.inspectorDetails)); + if (state.inspectorDetails) drawer?.querySelector(".details-close")?.focus(); + else trigger?.focus(); + } + function updateInspectorScale() { const image = document.getElementById("inspector-image"); if (image) image.className = image.className.replace(/ fit| actual/g, "") + " " + state.inspectorScale; @@ -858,22 +801,387 @@ function closeInspector() { state.screen = null; state.routeFocus = null; + state.inspectorDetails = false; renderOrNavigate(); } + function openPanel(panel, triggerID) { + state.panel = panel; + state.panelTrigger = triggerID; + if (panel !== "search") state.commandQuery = ""; + render(); + } + + function closePanel() { + const triggerID = state.panelTrigger; + state.panel = null; + state.commandQuery = ""; + render(); + requestAnimationFrame(() => document.getElementById(triggerID)?.focus()); + } + + function activePanel() { + if (!state.panel || state.screen) return null; + if (state.panel === "search") return searchPanel(); + if (state.panel === "groups") return groupsPanel(); + if (state.panel === "filters") return filtersPanel(); + if (state.panel === "build") return buildPanel(); + return null; + } + + function panelShell(className, label) { + const dialog = element("dialog", "panel " + className); + dialog.setAttribute("aria-label", label); + dialog.addEventListener("cancel", event => { + event.preventDefault(); + closePanel(); + }); + dialog.addEventListener("click", event => { + if (event.target === dialog) closePanel(); + }); + return dialog; + } + + function panelHeader(kicker, title) { + const header = element("header", "panel-header"); + const copy = element("div", ""); + copy.append(element("p", "eyebrow", kicker), element("h2", "", title)); + const close = iconButton("close", "Close", "icon-button panel-close"); + close.addEventListener("click", closePanel); + header.append(copy, close); + return header; + } + + function searchPanel() { + const dialog = panelShell("command-palette", "Search the atlas"); + const surface = element("div", "panel-surface command-surface"); + const field = element("label", "command-field"); + field.append(icon("search")); + const input = element("input", ""); + input.id = "command-search"; + input.type = "search"; + input.placeholder = "Find a group, screen, state, or route"; + input.autocomplete = "off"; + input.value = state.commandQuery; + input.setAttribute("aria-label", "Search groups, screens, states, and routes"); + const close = element("button", "escape-key", "Esc"); + close.type = "button"; + close.setAttribute("aria-label", "Close search"); + close.addEventListener("click", closePanel); + field.append(input, close); + const results = element("div", "command-results"); + results.id = "command-results"; + renderCommandResults(results, input.value); + input.addEventListener("input", () => { + state.commandQuery = input.value; + renderCommandResults(results, input.value); + }); + input.addEventListener("keydown", event => moveCommandFocus(event, results)); + surface.append(field, results); + dialog.append(surface); + requestAnimationFrame(() => input.focus()); + return dialog; + } + + function commandEntries(query) { + const terms = query.trim().toLocaleLowerCase().split(/ +/).filter(Boolean); + if (terms.length === 0) { + const currentScreens = manifest.screens + .filter(screen => screen.groupID === state.group) + .slice(0, 6) + .map(screen => screenCommand(screen)); + return [...manifest.groups.map(groupCommand), ...currentScreens]; + } + + const entries = []; + for (const group of manifest.groups) entries.push(groupCommand(group)); + for (const screen of manifest.screens) { + entries.push(screenCommand(screen)); + for (const variant of screen.variants) entries.push(variantCommand(screen, variant)); + } + for (const route of manifest.routes) entries.push(routeCommand(route)); + return entries + .filter(entry => terms.every(term => entry.search.includes(term))) + .sort((first, second) => commandScore(first, terms) - commandScore(second, terms) + || first.title.localeCompare(second.title)) + .slice(0, 14); + } + + function commandScore(entry, terms) { + const title = entry.title.toLocaleLowerCase(); + if (terms.every(term => title === term)) return 0; + if (terms.every(term => title.startsWith(term))) return 1; + if (terms.every(term => title.includes(term))) return 2; + return entry.kind === "Screen" ? 3 : 4; + } + + function groupCommand(group) { + return { + kind: "Group", + title: group.title, + subtitle: group.screenIDs.length + " screens", + search: (group.title + " group").toLocaleLowerCase(), + action: () => { + state.group = group.id; + state.view = "canvas"; + state.panel = null; + state.pendingCanvasAction = "fit-group"; + renderOrNavigate(); + }, + }; + } + + function screenCommand(screen) { + const group = groupByID.get(screen.groupID); + return { + kind: "Screen", + title: screen.title, + subtitle: (group?.title || "Ungrouped") + " · " + screen.variants.length + + (screen.variants.length === 1 ? " state" : " states"), + search: searchableText(screen), + action: () => openScreen(screen.id, true), + }; + } + + function variantCommand(screen, variant) { + const group = groupByID.get(screen.groupID); + return { + kind: "State", + title: screen.title + " — " + variant.title, + subtitle: group?.title || "Ungrouped", + search: (screen.title + " " + variant.title + " " + (group?.title || "")).toLocaleLowerCase(), + action: () => { + selectedVariants.set(screen.id, variant.id); + openScreen(screen.id, true); + }, + }; + } + + function routeCommand(route) { + const source = screenByID.get(route.sourceScreenID); + const destination = screenByID.get(route.destinationScreenID); + const title = route.label || (source?.title || route.sourceScreenID) + " → " + + (destination?.title || route.destinationScreenID); + return { + kind: route.kind === "modal" ? "Modal route" : "Push route", + title, + subtitle: (source?.title || route.sourceScreenID) + " → " + + (destination?.title || route.destinationScreenID), + search: (title + " " + route.kind + " " + (source?.title || "") + " " + + (destination?.title || "")).toLocaleLowerCase(), + action: () => openScreen(route.destinationScreenID), + }; + } + + function renderCommandResults(container, query) { + container.replaceChildren(); + const entries = commandEntries(query); + container.append(element("p", "command-section-label", query.trim() ? "Best matches" : "Jump to")); + if (entries.length === 0) { + const empty = element("div", "command-empty"); + empty.append(element("strong", "", "No matches"), element("span", "", "Try another screen or state name.")); + container.append(empty); + return; + } + entries.forEach((entry, index) => { + const button = element("button", "command-result"); + button.type = "button"; + button.dataset.commandIndex = String(index); + const glyph = element("span", "command-glyph"); + glyph.append(icon(entry.kind === "Group" ? "map" : entry.kind.includes("route") ? "route" : "canvas")); + const copy = element("span", "command-copy"); + copy.append(element("strong", "", entry.title), element("small", "", entry.subtitle)); + button.append(glyph, copy, element("span", "command-kind", entry.kind), icon("right")); + button.addEventListener("click", entry.action); + button.addEventListener("keydown", event => { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return; + event.preventDefault(); + const buttons = [...container.querySelectorAll(".command-result")]; + const offset = event.key === "ArrowDown" ? 1 : -1; + const next = (index + offset + buttons.length) % buttons.length; + buttons[next].focus(); + }); + container.append(button); + }); + } + + function moveCommandFocus(event, results) { + if (event.key !== "ArrowDown" && event.key !== "ArrowUp" && event.key !== "Enter") return; + const buttons = [...results.querySelectorAll(".command-result")]; + if (buttons.length === 0) return; + event.preventDefault(); + if (event.key === "Enter") { + buttons[0].click(); + return; + } + (event.key === "ArrowDown" ? buttons[0] : buttons[buttons.length - 1]).focus(); + } + + function groupsPanel() { + const dialog = panelShell("sheet-panel groups-panel", "Browse groups"); + const surface = element("div", "panel-surface sheet-surface"); + surface.append(panelHeader("Navigate", "Groups")); + surface.append(miniMap()); + 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, icon("right")); + button.addEventListener("click", () => { + state.group = group.id; + state.view = "canvas"; + state.panel = null; + state.pendingCanvasAction = "fit-group"; + renderOrNavigate(); + }); + navigation.append(button); + } + surface.append(navigation); + dialog.append(surface); + return dialog; + } + + function miniMap() { + const namespace = "http://www.w3.org/2000/svg"; + const wrapper = element("div", "mini-map"); + 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 a position 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 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; + state.canvas.scrollLeft = x * state.zoom - window.innerWidth / 2; + state.canvas.scrollTop = y * state.zoom - window.innerHeight / 2; + state.canvas.initialized = true; + state.view = "canvas"; + state.panel = null; + renderOrNavigate(); + }); + wrapper.append(svg); + return wrapper; + } + + function rectAttributes(rect) { + return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; + } + + function filtersPanel() { + const dialog = panelShell("sheet-panel filters-panel", "Filter screens"); + const surface = element("div", "panel-surface sheet-surface"); + surface.append(panelHeader("Refine", "Filters")); + const fields = element("div", "filter-fields"); + const groupOptions = [["all", "All groups"], ...manifest.groups.map(group => [group.id, group.title])]; + fields.append( + filterSelect("Group", state.filters.group, groupOptions, value => { + state.filters.group = value; + updateSearchVisibility(); + }), + filterSelect("Capture", state.filters.extent, [ + ["all", "All captures"], ["viewport", "Viewport"], ["intrinsic", "Intrinsic"], + ["fullContent", "Full content"], ["fullContent2D", "Full content 2D"], + ], value => { + state.filters.extent = value; + updateSearchVisibility(); + }), + filterSelect("Routes", state.filters.routes, [ + ["all", "Any route state"], ["linked", "Linked"], ["unlinked", "Unlinked"], + ["incoming", "Has incoming"], ["outgoing", "Has outgoing"], + ], value => { + state.filters.routes = value; + updateSearchVisibility(); + }), + ); + surface.append(fields); + const footer = element("footer", "panel-footer"); + const clear = element("button", "secondary-button", "Reset filters"); + clear.type = "button"; + clear.addEventListener("click", () => { + state.filters = { group: "all", extent: "all", routes: "all" }; + state.panel = null; + render(); + }); + const done = element("button", "primary-button", "Show results"); + done.type = "button"; + done.addEventListener("click", closePanel); + footer.append(clear, done); + surface.append(footer); + dialog.append(surface); + return dialog; + } + + function filterSelect(labelText, value, options, onChange) { + const label = element("label", "filter-field"); + label.append(element("span", "field-label", labelText)); + const select = element("select", ""); + for (const pair of options) { + const option = element("option", "", pair[1]); + option.value = pair[0]; + option.selected = pair[0] === value; + select.append(option); + } + select.addEventListener("change", () => onChange(select.value)); + label.append(select); + return label; + } + + function buildPanel() { + const dialog = panelShell("sheet-panel build-panel", "Build details"); + const surface = element("div", "panel-surface sheet-surface"); + surface.append(panelHeader("Artifact", "Build details")); + const status = element("div", "build-status"); + status.append(element("span", manifest.build.dirty ? "status-dot dirty" : "status-dot")); + const copy = element("div", ""); + copy.append( + element("strong", "", manifest.build.dirty ? "Uncommitted changes" : "Clean build"), + element("span", "", formatGeneratedAt(manifest.build.generatedAt)), + ); + status.append(copy); + const list = element("dl", "detail-list build-list"); + list.append( + metadataRow("Commit", manifest.build.commit), metadataRow("Branch", manifest.build.branch || "Detached"), + metadataRow("Xcode", manifest.build.xcodeVersion), metadataRow("Simulator", manifest.build.simulatorDevice), + metadataRow("Simulator OS", manifest.build.simulatorOS), + ); + surface.append(status, list); + dialog.append(surface); + return dialog; + } + 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"); + empty.append(icon("filter"), element("h2", "", "No screens match")); + empty.append(element("p", "", "Change the filters to show more of the atlas.")); + const clear = element("button", "primary-button", "Reset filters"); clear.type = "button"; clear.addEventListener("click", () => { - state.search = ""; state.filters = { group: "all", extent: "all", routes: "all" }; render(); }); @@ -901,12 +1209,23 @@ route.dataset.hidden = String(!sourceVisible && !destinationVisible); } const count = document.getElementById("result-count"); - if (count) count.textContent = visible.size + " of " + manifest.screens.length + " screens"; + if (count) count.textContent = visible.size + " / " + manifest.screens.length; const empty = document.getElementById("empty-results"); if (empty) empty.hidden = visible.size !== 0; + updateFilterStatus(); updateRouteFocus(); } + function updateFilterStatus() { + const activeCount = Object.values(state.filters).filter(value => value !== "all").length; + const button = document.getElementById("filter-trigger"); + if (!button) return; + button.dataset.active = String(activeCount > 0); + button.setAttribute("aria-label", activeCount > 0 ? "Filters, " + activeCount + " active" : "Filters"); + const badge = button.querySelector(".filter-badge"); + if (badge) badge.textContent = activeCount > 0 ? String(activeCount) : ""; + } + function setRouteFocus(screenID) { state.routeFocus = screenID; updateRouteFocus(); @@ -928,8 +1247,7 @@ } for (const card of document.querySelectorAll(".screen-card")) { card.dataset.routeRelation = card.dataset.screenId === focused - ? "focus" - : (connected.get(card.dataset.screenId) || (focused ? "unrelated" : "none")); + ? "focus" : (connected.get(card.dataset.screenId) || (focused ? "unrelated" : "none")); } for (const route of document.querySelectorAll(".route")) { const isConnected = route.dataset.sourceScreenId === focused @@ -938,6 +1256,13 @@ } } + function captureCanvasPosition() { + const viewport = document.getElementById("canvas-viewport"); + if (!viewport) return; + state.canvas.scrollLeft = viewport.scrollLeft; + state.canvas.scrollTop = viewport.scrollTop; + } + function applyZoom() { const stage = document.getElementById("canvas-stage"); const scaled = document.getElementById("canvas-scaled"); @@ -945,8 +1270,6 @@ 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(); @@ -966,37 +1289,38 @@ left: center.x * state.zoom - viewport.clientWidth / 2, top: center.y * state.zoom - viewport.clientHeight / 2, }); + captureCanvasPosition(); } } - function fitFrame(frame) { + function fitFrame(frame, behavior = "smooth") { const viewport = document.getElementById("canvas-viewport"); if (!viewport) return; - const padding = 48; + const padding = Math.min(Math.max(viewport.clientWidth * 0.055, 32), 80); const horizontal = Math.max(viewport.clientWidth - padding * 2, 1) / Math.max(frame.width, 1); const vertical = Math.max(viewport.clientHeight - padding * 2, 1) / Math.max(frame.height, 1); state.zoom = Math.max(0.1, Math.min(1.5, horizontal, vertical)); applyZoom(); - viewport.scrollTo({ - left: (frame.x + frame.width / 2) * state.zoom - viewport.clientWidth / 2, - top: (frame.y + frame.height / 2) * state.zoom - viewport.clientHeight / 2, - behavior: "smooth", - }); + const left = (frame.x + frame.width / 2) * state.zoom - viewport.clientWidth / 2; + const top = (frame.y + frame.height / 2) * state.zoom - viewport.clientHeight / 2; + viewport.scrollTo({ left, top, behavior }); + state.canvas.scrollLeft = Math.max(0, left); + state.canvas.scrollTop = Math.max(0, top); } - function fitAll() { - fitFrame({ x: 0, y: 0, width: manifest.canvas.size.width, height: manifest.canvas.size.height }); + function fitAll(behavior = "smooth") { + fitFrame({ x: 0, y: 0, width: manifest.canvas.size.width, height: manifest.canvas.size.height }, behavior); } - function fitCurrentGroup() { + function fitCurrentGroup(behavior = "smooth") { const group = manifest.canvas.groupFrames.find(item => item.id === state.group) || manifest.canvas.groupFrames[0]; - if (group) fitFrame(group.frame); + if (group) fitFrame(group.frame, behavior); } function fitFirstGroup() { const first = manifest.canvas.groupFrames[0]; - if (first) fitFrame(first.frame); + if (first) fitFrame(first.frame, "auto"); } let canvasNavigationFrame = null; @@ -1004,6 +1328,7 @@ if (canvasNavigationFrame !== null) return; canvasNavigationFrame = requestAnimationFrame(() => { canvasNavigationFrame = null; + captureCanvasPosition(); updateCanvasNavigation(); }); } @@ -1025,8 +1350,10 @@ nearestDistance = distance; } } - if (nearest) state.group = nearest; - updateActiveGroup(); + if (nearest && nearest !== state.group) { + state.group = nearest; + updateActiveGroup(); + } updateMiniMapViewport(); } @@ -1037,6 +1364,15 @@ for (const group of document.querySelectorAll(".mini-group")) { group.dataset.active = String(group.dataset.groupId === state.group); } + const current = groupByID.get(state.group); + const context = document.querySelector(".canvas-context"); + if (context && current) { + context.querySelector(".context-kicker").textContent = "Group " + (current.order + 1) + + " of " + manifest.groups.length; + context.querySelector("strong").textContent = current.title; + } + const dockLabel = document.querySelector(".group-trigger .button-label"); + if (dockLabel && current) dockLabel.textContent = current.title; } function updateMiniMapViewport() { @@ -1050,39 +1386,58 @@ } function render() { - root.replaceChildren(toolbar(), state.view === "canvas" ? canvasView() : listView()); - const dialog = inspector(); - if (dialog) { - root.append(dialog); - requestAnimationFrame(() => dialog.showModal()); + captureCanvasPosition(); + const app = element("div", "application"); + app.append(appHeader()); + app.append(state.view === "canvas" ? canvasView() : listView()); + app.append(bottomDock()); + root.replaceChildren(app); + + const inspection = inspector(); + if (inspection) { + root.append(inspection); + requestAnimationFrame(() => inspection.showModal()); + } else { + const panel = activePanel(); + if (panel) { + root.append(panel); + requestAnimationFrame(() => { + panel.showModal(); + updateMiniMapViewport(); + }); + } } requestAnimationFrame(updateSearchVisibility); } window.addEventListener("hashchange", () => { + captureCanvasPosition(); 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) { + const commandSearch = (event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === "k"; + if (commandSearch || (event.key === "/" && !isEditing && !event.metaKey && !event.ctrlKey && !event.altKey)) { event.preventDefault(); - document.getElementById("global-search")?.focus(); + if (!state.screen) openPanel("search", "search-trigger"); return; } - if (isEditing) return; - if (state.screen && event.key === "[") { + if (isEditing || event.metaKey || event.ctrlKey || event.altKey || state.panel) return; + if (state.screen && (event.key === "ArrowLeft" || event.key === "[")) { event.preventDefault(); openScreen(neighboringScreen(-1)?.id); - } else if (state.screen && event.key === "]") { + } else if (state.screen && (event.key === "ArrowRight" || event.key === "]")) { event.preventDefault(); openScreen(neighboringScreen(1)?.id); + } else if (state.screen && event.key.toLocaleLowerCase() === "i") { + event.preventDefault(); + toggleInspectorDetails(); } else if (!state.screen && state.view === "canvas" && event.key.toLocaleLowerCase() === "f") { event.preventDefault(); fitAll(); @@ -1097,6 +1452,7 @@ setZoom(state.zoom - 0.1); } }); + parseHash(); render(); })(); diff --git a/Shared/Flyover/Web/assets/styles.css b/Shared/Flyover/Web/assets/styles.css index 74b1242ef..667a2dacf 100644 --- a/Shared/Flyover/Web/assets/styles.css +++ b/Shared/Flyover/Web/assets/styles.css @@ -1,55 +1,46 @@ :root { color-scheme: light dark; - font-family: Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "SF Pro Text", "Segoe UI", sans-serif; + --accent: #3157e8; + --accent-strong: #2444c4; + --accent-soft: #e8edff; + --modal: #c73adb; + --background: #eef0f5; + --canvas: #e9ebf1; + --surface: rgba(255, 255, 255, 0.88); + --surface-solid: #ffffff; + --surface-muted: #f5f6f9; + --text: #151721; + --secondary: #6f7482; + --tertiary: #9ca1ad; + --line: rgba(33, 37, 48, 0.12); + --line-strong: rgba(33, 37, 48, 0.2); + --shadow-small: 0 1px 2px rgba(20, 24, 36, 0.08), 0 8px 24px rgba(20, 24, 36, 0.08); + --shadow-large: 0 24px 80px rgba(20, 24, 36, 0.2), 0 4px 18px rgba(20, 24, 36, 0.1); + --glass: blur(22px) saturate(150%); + --radius-small: 10px; + --radius-medium: 16px; + --radius-large: 24px; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif; font-synthesis: none; - --background: #f4f5f7; - --canvas: #e9ebef; - --surface: #ffffff; - --surface-raised: #ffffff; - --surface-muted: #f1f2f5; - --surface-hover: #e9ecf3; - --text: #17191f; - --secondary: #656b78; - --tertiary: #8a909c; - --border: #d9dce3; - --border-strong: #c6cad3; - --accent: #405de6; - --accent-strong: #2f48c7; - --accent-soft: #e8ecff; - --modal: #8c52d8; - --modal-soft: #f0e7fc; - --success: #198754; - --danger: #a94300; - --focus: #4967ff; - --shadow-sm: 0 1px 2px rgb(19 25 39 / 7%), 0 2px 8px rgb(19 25 39 / 5%); - --shadow-md: 0 12px 36px rgb(19 25 39 / 13%), 0 2px 8px rgb(19 25 39 / 7%); - --shadow-lg: 0 28px 80px rgb(8 12 24 / 28%); } @media (prefers-color-scheme: dark) { :root { - --background: #111318; - --canvas: #0d0f13; - --surface: #1a1d24; - --surface-raised: #20242c; - --surface-muted: #252932; - --surface-hover: #2c313c; - --text: #f4f5f7; - --secondary: #b0b5c0; - --tertiary: #818894; - --border: #343944; - --border-strong: #474d5a; - --accent: #8398ff; - --accent-strong: #9cabff; - --accent-soft: #252d52; - --modal: #c18cf6; - --modal-soft: #352749; - --success: #5bc990; - --danger: #ffad70; - --focus: #91a3ff; - --shadow-sm: 0 1px 2px rgb(0 0 0 / 25%), 0 3px 12px rgb(0 0 0 / 18%); - --shadow-md: 0 14px 42px rgb(0 0 0 / 35%); - --shadow-lg: 0 32px 90px rgb(0 0 0 / 55%); + --accent: #7891ff; + --accent-strong: #9eafff; + --accent-soft: #242b4d; + --background: #15161a; + --canvas: #111216; + --surface: rgba(38, 39, 45, 0.88); + --surface-solid: #24252b; + --surface-muted: #1c1d22; + --text: #f4f4f7; + --secondary: #a9acb6; + --tertiary: #747782; + --line: rgba(255, 255, 255, 0.11); + --line-strong: rgba(255, 255, 255, 0.2); + --shadow-small: 0 1px 2px rgba(0, 0, 0, 0.25), 0 10px 30px rgba(0, 0, 0, 0.22); + --shadow-large: 0 28px 90px rgba(0, 0, 0, 0.5), 0 4px 18px rgba(0, 0, 0, 0.3); } } @@ -58,9 +49,9 @@ } html, -body { +body, +#app { width: 100%; - min-width: 20rem; height: 100%; margin: 0; } @@ -69,1448 +60,2069 @@ 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 { +select, +input { + color: inherit; font: inherit; } button, select, -input[type="search"] { - min-height: 2.25rem; - border: 1px solid var(--border); - border-radius: 9px; - background: var(--surface); - color: var(--text); +a { + -webkit-tap-highlight-color: transparent; } button { - padding: 0.45rem 0.75rem; + border: 0; 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); +button:focus-visible, +select:focus-visible, +input:focus-visible, +a:focus-visible, +[tabindex]:focus-visible { + outline: 3px solid color-mix(in srgb, var(--accent) 65%, transparent); + outline-offset: 3px; } 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); + min-height: 36px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 0 30px 0 11px; + background: color-mix(in srgb, var(--surface-solid) 92%, transparent); } h1, h2, h3, -p, -dl, -dd { +p { margin: 0; } -.eyebrow { - color: var(--tertiary); - font-size: 0.68rem; - font-weight: 750; - letter-spacing: 0.095em; - line-height: 1.3; - text-transform: uppercase; +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; } -.secondary { +.icon { + width: 19px; + height: 19px; + flex: 0 0 auto; + fill: none; + stroke: currentColor; + stroke-width: 1.8; + stroke-linecap: round; + stroke-linejoin: round; +} + +.eyebrow, +.product-label, +.field-label, +.context-kicker, +.command-section-label { color: var(--secondary); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + line-height: 1.2; + text-transform: uppercase; } -.error { - width: min(42rem, calc(100% - 2rem)); - margin: 10vh auto; - padding: 2rem; - border: 1px solid #d35a5a; - border-radius: 18px; - background: var(--surface); - box-shadow: var(--shadow-md); +.secondary { + color: var(--secondary); } -.error h1 { - margin: 0.3rem 0 0.75rem; +.application { + display: grid; + grid-template-rows: 64px minmax(0, 1fr); + width: 100%; + height: 100dvh; + min-height: 0; } .app-header { position: relative; z-index: 30; - flex: none; - border-bottom: 1px solid var(--border); - background: color-mix(in srgb, var(--surface) 94%, transparent); - box-shadow: 0 1px 0 rgb(255 255 255 / 25%); - backdrop-filter: blur(18px) saturate(145%); -} - -.masthead, -.primary-toolbar, -.context-toolbar { display: flex; - min-width: 0; align-items: center; -} - -.masthead { - min-height: 4.25rem; justify-content: space-between; - gap: 1rem; - padding: 0.65rem 1rem 0.55rem; + gap: 24px; + min-width: 0; + padding: 0 22px; + border-bottom: 1px solid var(--line); + background: color-mix(in srgb, var(--surface) 90%, transparent); + -webkit-backdrop-filter: var(--glass); + backdrop-filter: var(--glass); +} + +.app-identity, +.header-actions, +.header-button, +.dock-action, +.icon-button, +.segment, +.zoom-controls, +.inspector-action { + display: flex; + align-items: center; } -.brand { - display: flex; +.app-identity { min-width: 0; - gap: 0.7rem; - align-items: center; + gap: 11px; } .brand-mark { display: grid; - width: 2.35rem; - height: 2.35rem; - flex: none; place-items: center; + width: 34px; + height: 34px; border-radius: 11px; - background: linear-gradient(145deg, #6079f7, #334cc8); - box-shadow: 0 5px 14px rgb(64 93 230 / 30%); + background: linear-gradient(145deg, #5270f4, #2b48ca); + box-shadow: 0 7px 18px rgba(49, 87, 232, 0.3), inset 0 1px rgba(255, 255, 255, 0.28); color: white; - font-size: 1.05rem; - font-weight: 800; + font-size: 18px; + font-weight: 750; +} + +.identity-copy { + display: grid; + grid-template-columns: auto auto; + align-items: baseline; + gap: 8px; + min-width: 0; } -.brand h1 { +.identity-copy h1 { overflow: hidden; - font-size: 1.15rem; - letter-spacing: -0.025em; - line-height: 1.2; + font-size: 17px; + font-weight: 680; + letter-spacing: -0.015em; text-overflow: ellipsis; white-space: nowrap; } -.build-details { - position: relative; -} - -.build-details summary, -.filter-menu summary { - display: flex; - min-height: 2.25rem; - cursor: pointer; - list-style: none; - align-items: center; - gap: 0.5rem; - padding: 0.42rem 0.65rem; - border: 1px solid var(--border); - border-radius: 9px; - background: var(--surface); - color: var(--secondary); - font-size: 0.78rem; - font-weight: 650; -} - -.build-details summary::-webkit-details-marker, -.filter-menu summary::-webkit-details-marker { - display: none; +.product-label { + color: var(--accent); } -.build-details summary::after, -.filter-menu summary::after { - content: "⌄"; - color: var(--tertiary); +.header-actions { + gap: 8px; } -.build-details[open] summary, -.filter-menu[open] summary { - border-color: var(--accent); +.header-profile select { + max-width: 180px; + height: 38px; + border-color: transparent; + background-color: var(--surface-muted); + font-size: 13px; + font-weight: 600; } -.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); +.header-button, +.dock-action, +.icon-button, +.inspector-action { + justify-content: center; + min-width: 38px; + min-height: 38px; + gap: 7px; + border: 1px solid transparent; + border-radius: 11px; + background: transparent; + color: var(--secondary); + text-decoration: none; } -.build-details code { +.header-button:hover, +.dock-action:hover, +.icon-button:hover, +.inspector-action:hover { + background: var(--surface-muted); 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; +.search-trigger { + min-width: 156px; + justify-content: flex-start; + padding: 0 8px 0 11px; + border-color: var(--line); + background: var(--surface-muted); } -.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); +.search-trigger .button-label { + flex: 1; + text-align: left; } -.build-panel { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 0.55rem 1rem; +kbd, +.escape-key { + min-width: 29px; + border: 1px solid var(--line); + border-radius: 7px; + padding: 3px 6px; + background: var(--surface-solid); + box-shadow: 0 1px 1px rgba(0, 0, 0, 0.06); + color: var(--secondary); + font-family: inherit; + font-size: 10px; + line-height: 1.2; + text-align: center; } -.metadata-row { - display: contents; +.build-trigger { + position: relative; + padding: 0 9px; } -.metadata-row dt { - color: var(--tertiary); - font-size: 0.75rem; - font-weight: 650; +.commit-label { + color: var(--secondary); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 11px; } -.metadata-row dd { - overflow-wrap: anywhere; - color: var(--text); - font-size: 0.75rem; +.dirty-dot { + position: absolute; + top: 7px; + right: 5px; + width: 7px; + height: 7px; + border: 2px solid var(--surface-solid); + border-radius: 50%; + background: #f59e0b; } -.primary-toolbar { - min-height: 3.25rem; - gap: 0.6rem; - padding: 0 1rem 0.65rem; +.canvas-view { + position: relative; + min-width: 0; + min-height: 0; + overflow: hidden; + background: var(--canvas); } -.view-tabs, -.scale-tabs { - display: inline-flex; - flex: none; - gap: 0.18rem; - padding: 0.2rem; - border-radius: 10px; - background: var(--surface-muted); +.canvas-viewport { + position: absolute; + inset: 0; + overflow: auto; + overscroll-behavior: contain; + background-color: var(--canvas); + background-image: radial-gradient(circle, color-mix(in srgb, var(--text) 12%, transparent) 1px, transparent 1px); + background-position: 0 0; + background-size: 24px 24px; + scrollbar-color: color-mix(in srgb, var(--secondary) 32%, transparent) transparent; } -.view-tabs button, -.scale-tabs button { - min-height: 1.9rem; - padding: 0.3rem 0.72rem; - border-color: transparent; - background: transparent; - color: var(--secondary); +.canvas-scaled { + position: relative; } -.view-tabs button[aria-pressed="true"], -.scale-tabs button[aria-pressed="true"] { - background: var(--surface-raised); - box-shadow: var(--shadow-sm); - color: var(--text); +.canvas-stage { + position: absolute; + top: 0; + left: 0; + transform-origin: top left; } -.search-field { - display: flex; - min-width: 12rem; - max-width: 34rem; - height: 2.25rem; - flex: 1 1 28rem; - align-items: center; - gap: 0.45rem; - padding: 0 0.55rem; - border: 1px solid var(--border); - border-radius: 9px; +.canvas-context, +.route-legend { + position: absolute; + z-index: 12; + top: 18px; + border: 1px solid color-mix(in srgb, var(--line) 78%, transparent); + border-radius: 14px; background: var(--surface); + box-shadow: var(--shadow-small); + -webkit-backdrop-filter: var(--glass); + backdrop-filter: var(--glass); } -.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; +.canvas-context { + left: 20px; + display: grid; + min-width: 164px; + padding: 9px 13px 10px; + color: var(--text); + text-align: left; } -.search-icon { - color: var(--tertiary); - font-size: 1.25rem; - line-height: 1; +.canvas-context strong { + max-width: 240px; + overflow: hidden; + margin-top: 2px; + font-size: 14px; + text-overflow: ellipsis; + white-space: nowrap; } -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; +.route-legend { + right: 20px; + display: flex; + gap: 13px; + padding: 9px 12px; + color: var(--secondary); + font-size: 11px; + font-weight: 650; } -.filter-menu { - position: relative; - flex: none; +.route-legend span { + display: flex; + align-items: center; + gap: 6px; } -.filter-menu summary::before { - content: "≡"; - color: var(--secondary); - font-size: 1rem; +.route-legend i { + width: 18px; + height: 3px; + border-radius: 99px; + background: var(--accent); } -.filter-panel { - display: grid; - min-width: 17rem; - gap: 0.8rem; +.route-legend i.modal { + background: repeating-linear-gradient(90deg, var(--modal) 0 5px, transparent 5px 8px); } -.filter-panel .field { - display: grid; - grid-template-columns: 5rem minmax(0, 1fr); +.group-shelf, +.depth-band, +.screen-card, +.canvas-routes { + position: absolute; } -.filter-panel select { - width: 100%; +.group-shelf { + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--line-strong) 86%, transparent); + border-radius: 30px; + background: color-mix(in srgb, var(--surface-solid) 42%, transparent); } -.clear-filters { - justify-self: end; +.group-shelf[data-empty="true"] { + opacity: 0.35; } -.profile-field, -.field { +.shelf-header { display: flex; align-items: center; - gap: 0.45rem; + gap: 12px; + height: 76px; + padding: 0 30px; + border-bottom: 1px solid var(--line); } -.field-label { - color: var(--secondary); - font-size: 0.72rem; +.shelf-header h2 { + font-size: 22px; font-weight: 700; + letter-spacing: -0.025em; } -.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); +.shelf-index, +.group-number { + color: var(--accent); + font-family: ui-monospace, "SFMono-Regular", Menlo, monospace; + font-size: 12px; + font-weight: 700; } -.result-count { - flex: none; - color: var(--secondary); - font-size: 0.76rem; - font-weight: 650; +.depth-band { + border-left: 1px solid var(--line); + pointer-events: none; } -.canvas-controls { - display: flex; - min-width: 0; - align-items: center; - gap: 0.35rem; +.depth-band span { + position: absolute; + top: 16px; + left: 18px; + color: var(--tertiary); + font-size: 12px; + font-weight: 650; } -.canvas-controls input[type="range"] { - width: clamp(5rem, 10vw, 9rem); - accent-color: var(--accent); +.depth-band[data-empty="true"] { + opacity: 0.25; } -.icon-button { - display: inline-grid; - width: 2.25rem; - padding: 0; - place-items: center; - font-size: 1rem; +.screen-card { + z-index: 4; + display: grid; + grid-template-rows: 58px minmax(0, 1fr) 58px; + overflow: hidden; + border: 1px solid color-mix(in srgb, var(--line-strong) 90%, transparent); + border-radius: 25px; + background: var(--surface-solid); + box-shadow: 0 4px 10px rgba(25, 30, 44, 0.08), 0 20px 45px rgba(25, 30, 44, 0.1); + transition: border-color 180ms ease, box-shadow 180ms ease, opacity 180ms ease, transform 180ms ease; } -.zoom-value { - width: 3.2rem; - color: var(--secondary); - font: 0.72rem ui-monospace, monospace; - text-align: center; +.screen-card:hover, +.screen-card:focus-within, +.screen-card[data-route-relation="focus"] { + border-color: color-mix(in srgb, var(--accent) 60%, var(--line)); + box-shadow: 0 8px 18px rgba(25, 30, 44, 0.1), 0 28px 60px rgba(25, 30, 44, 0.16); + transform: translateY(-3px); } -.primary-button { - border-color: var(--accent); - background: var(--accent); - color: white; +.screen-card[data-hidden="true"] { + visibility: hidden; + opacity: 0; } -.primary-button:hover { - border-color: var(--accent-strong); - background: var(--accent-strong); +.screen-card[data-route-relation="unrelated"] { + opacity: 0.34; } -.canvas-layout { - position: relative; - display: grid; - min-height: 0; - flex: 1 1 auto; - grid-template-columns: 15.5rem minmax(0, 1fr); - overflow: hidden; +.screen-card[data-route-relation="upstream"], +.screen-card[data-route-relation="downstream"] { + border-color: color-mix(in srgb, var(--accent) 36%, var(--line)); } -.canvas-sidebar { - position: relative; - z-index: 10; +.card-header, +.card-footer { 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); + align-items: center; + min-width: 0; + padding: 0 17px; + background: var(--surface-solid); } -.sidebar-heading { - padding: 0 0.4rem; +.card-header { + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--line); } -.sidebar-heading h2 { - margin-top: 0.18rem; - font-size: 1rem; +.card-heading { + min-width: 0; } -.group-navigation { - display: grid; - gap: 0.28rem; +.card-heading h3 { + overflow: hidden; + font-size: 16px; + font-weight: 680; + letter-spacing: -0.015em; + text-overflow: ellipsis; + white-space: nowrap; } -.group-button { +.card-inspect { 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; + place-items: center; + width: 34px; + height: 34px; + border-radius: 10px; background: transparent; - text-align: left; + color: var(--accent); } -.group-button[aria-current="true"] { - border-color: color-mix(in srgb, var(--accent) 22%, transparent); +.card-inspect:hover { background: var(--accent-soft); - color: var(--accent-strong); } -.group-button[data-empty="true"] { - opacity: 0.42; +.card-image-button { + position: relative; + display: grid; + min-width: 0; + min-height: 0; + place-items: center; + overflow: hidden; + padding: 0; + background: linear-gradient(145deg, color-mix(in srgb, var(--surface-muted) 96%, var(--accent-soft)), var(--surface-muted)); } -.group-index { +.device-preview { display: grid; - width: 2rem; - height: 2rem; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; place-items: center; - border: 1px solid var(--border); - border-radius: 8px; - background: var(--surface-raised); - color: var(--tertiary); - font: 0.68rem ui-monospace, monospace; + overflow: hidden; } -.group-button > span:last-child { - display: grid; +.device-preview img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.card-image-meta { + position: absolute; + right: 11px; + bottom: 11px; + left: 11px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + opacity: 0; + transform: translateY(5px); + transition: opacity 160ms ease, transform 160ms ease; +} + +.screen-card:hover .card-image-meta, +.screen-card:focus-within .card-image-meta { + opacity: 1; + transform: translateY(0); +} + +.capture-badge, +.route-count { + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 99px; + padding: 5px 8px; + background: rgba(14, 16, 22, 0.74); + color: white; + font-size: 10px; + font-weight: 700; + line-height: 1; + -webkit-backdrop-filter: blur(10px); + backdrop-filter: blur(10px); +} + +.card-footer { + gap: 9px; + border-top: 1px solid var(--line); +} + +.footer-label { + color: var(--secondary); + font-size: 11px; + font-weight: 650; + text-transform: uppercase; +} + +.card-state-select { + flex: 1; min-width: 0; - gap: 0.12rem; + border: 0; + background-color: var(--surface-muted); + font-size: 13px; + font-weight: 600; } -.group-button strong { +.state-dot { + width: 7px; + height: 7px; + border-radius: 50%; + background: var(--accent); +} + +.single-state { overflow: hidden; - font-size: 0.82rem; + color: var(--secondary); + font-size: 13px; + font-weight: 600; text-overflow: ellipsis; white-space: nowrap; } -.group-button small { - color: var(--tertiary); - font-size: 0.7rem; - font-weight: 500; +.canvas-routes { + z-index: 3; + inset: 0; + overflow: visible; + pointer-events: none; } -.mini-map { - margin-top: auto; - padding: 0.75rem; - border: 1px solid var(--border); - border-radius: 12px; - background: var(--surface-muted); +.route path { + fill: none; + stroke: var(--accent); + stroke-linecap: round; + stroke-width: 3; + vector-effect: non-scaling-stroke; } -.mini-map .eyebrow { - margin-bottom: 0.55rem; +.route polygon { + fill: var(--accent); } -.mini-map svg { - display: block; - width: 100%; - height: 7.5rem; - cursor: crosshair; +.route-modal path { + stroke: var(--modal); + stroke-dasharray: 9 8; } -.mini-group { - fill: color-mix(in srgb, var(--secondary) 9%, transparent); - stroke: var(--border-strong); - stroke-width: 22px; +.route-modal polygon { + fill: var(--modal); } -.mini-group[data-active="true"] { - fill: color-mix(in srgb, var(--accent) 10%, transparent); - stroke: var(--accent); +.route { + opacity: 0.55; + transition: opacity 160ms ease; } -.mini-screen { - fill: var(--secondary); - opacity: 0.5; +.route[data-route-relation="focus"] { + opacity: 1; } -.mini-screen[data-hidden="true"] { - opacity: 0.08; +.route[data-route-relation="unrelated"] { + opacity: 0.07; } -.mini-viewport { - fill: color-mix(in srgb, var(--accent) 8%, transparent); - stroke: var(--accent); - stroke-width: 28px; - vector-effect: non-scaling-stroke; +.route[data-hidden="true"] { + display: none; } -.route-legend { +.bottom-dock { + position: fixed; + z-index: 40; + bottom: max(18px, env(safe-area-inset-bottom)); + left: 50%; display: flex; - justify-content: center; - gap: 1rem; - color: var(--secondary); - font-size: 0.7rem; + align-items: center; + gap: 5px; + max-width: calc(100vw - 32px); + min-height: 58px; + padding: 7px; + border: 1px solid color-mix(in srgb, var(--line-strong) 82%, transparent); + border-radius: 18px; + background: color-mix(in srgb, var(--surface) 94%, transparent); + box-shadow: var(--shadow-large); + transform: translateX(-50%); + -webkit-backdrop-filter: var(--glass); + backdrop-filter: var(--glass); } -.route-legend span { +.segmented-control, +.zoom-controls { display: flex; align-items: center; - gap: 0.35rem; + padding: 3px; + border-radius: 12px; + background: var(--surface-muted); } -.route-legend i { - display: block; - width: 1rem; - height: 2px; - background: var(--accent); +.segment { + justify-content: center; + min-height: 38px; + gap: 7px; + border-radius: 9px; + padding: 0 12px; + background: transparent; + color: var(--secondary); + font-size: 13px; + font-weight: 650; } -.route-legend i.modal { - background: repeating-linear-gradient(90deg, var(--modal) 0 4px, transparent 4px 7px); +.segment[aria-pressed="true"] { + background: var(--surface-solid); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 4px 12px rgba(0, 0, 0, 0.06); + color: var(--text); } -.keyboard-hint { - color: var(--tertiary); - font-size: 0.68rem; +.zoom-controls .icon-button { + min-width: 34px; + min-height: 34px; +} + +.zoom-value { + width: 46px; + color: var(--secondary); + font-size: 11px; + font-variant-numeric: tabular-nums; + font-weight: 700; text-align: center; } -.canvas-viewport { +.dock-action { + min-height: 42px; + padding: 0 10px; + font-size: 12px; + font-weight: 650; + white-space: nowrap; +} + +.dock-action[data-active="true"] { + background: var(--accent-soft); + color: var(--accent); +} + +.dock-separator { + width: 1px; + height: 28px; + margin: 0 3px; + background: var(--line); +} + +.filter-trigger { position: relative; - min-width: 0; +} + +.filter-badge:not(:empty) { + display: grid; + width: 17px; + height: 17px; + place-items: center; + border-radius: 99px; + background: var(--accent); + color: white; + font-size: 9px; + font-weight: 750; +} + +.result-count { + min-width: 48px; + padding: 0 6px; + color: var(--tertiary); + font-size: 10px; + font-variant-numeric: tabular-nums; + font-weight: 700; + text-align: center; +} + +.empty-results { + position: absolute; + z-index: 20; + top: 50%; + left: 50%; + display: grid; + width: min(360px, calc(100% - 40px)); + place-items: center; + padding: 34px; + border: 1px solid var(--line); + border-radius: var(--radius-large); + background: var(--surface-solid); + box-shadow: var(--shadow-large); + text-align: center; + transform: translate(-50%, -50%); +} + +.empty-results[hidden] { + display: none; +} + +.empty-results .icon { + width: 28px; + height: 28px; + margin-bottom: 16px; + color: var(--accent); +} + +.empty-results h2 { + font-size: 19px; +} + +.empty-results p { + margin: 7px 0 20px; + color: var(--secondary); + font-size: 13px; +} + +.primary-button, +.secondary-button { + min-height: 42px; + border-radius: 11px; + padding: 0 16px; + font-size: 13px; + font-weight: 700; +} + +.primary-button { + background: var(--accent); + color: white; +} + +.primary-button:hover { + background: var(--accent-strong); +} + +.secondary-button { + background: var(--surface-muted); + color: var(--text); +} + +.list-view { 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; + padding: 54px max(28px, calc((100vw - 1180px) / 2)) 120px; + background: var(--background); } -.canvas-scaled { - position: relative; +.list-intro { + display: flex; + align-items: end; + justify-content: space-between; + gap: 24px; + margin-bottom: 48px; } -.canvas-stage { - position: absolute; - transform-origin: top left; +.list-intro h2 { + margin-top: 7px; + font-size: clamp(28px, 3vw, 42px); + font-weight: 720; + letter-spacing: -0.04em; } -.group-shelf, -.depth-band, -.screen-card, -.canvas-routes { - position: absolute; +.list-summary { + color: var(--secondary); + font-size: 13px; } -.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%); +.list-group { + margin-bottom: 48px; } -.group-shelf[data-empty="true"] { - opacity: 0.35; +.list-group-header { + position: sticky; + z-index: 5; + top: 0; + display: grid; + grid-template-columns: 40px 1fr auto; + align-items: center; + gap: 10px; + min-height: 60px; + border-bottom: 1px solid var(--line-strong); + background: color-mix(in srgb, var(--background) 90%, transparent); + -webkit-backdrop-filter: blur(18px); + backdrop-filter: blur(18px); +} + +.list-group-header h2 { + font-size: 19px; + font-weight: 690; + letter-spacing: -0.02em; +} + +.group-count { + color: var(--secondary); + font-size: 12px; +} + +.list-rows { + display: grid; +} + +.list-row { + display: grid; + grid-template-columns: 112px minmax(190px, 1fr) 150px minmax(180px, 0.8fr) 44px; + align-items: center; + gap: 22px; + min-height: 146px; + border-bottom: 1px solid var(--line); + transition: opacity 160ms ease, background 160ms ease; +} + +.list-row:hover { + background: color-mix(in srgb, var(--surface-solid) 40%, transparent); +} + +.list-row[data-hidden="true"] { + display: none; +} + +.list-thumbnail { + display: grid; + width: 96px; + height: 118px; + place-items: center; + overflow: hidden; + border: 1px solid var(--line); + border-radius: 13px; + padding: 0; + background: var(--surface-muted); + box-shadow: var(--shadow-small); +} + +.list-thumbnail img { + display: block; + width: 100%; + height: 100%; + object-fit: contain; +} + +.list-identity { + display: grid; + gap: 9px; + min-width: 0; +} + +.list-identity h3 { + overflow: hidden; + font-size: 17px; + font-weight: 680; + text-overflow: ellipsis; + white-space: nowrap; +} + +.list-state { + display: flex; + align-items: center; + gap: 8px; + color: var(--secondary); + font-size: 11px; + font-weight: 650; + text-transform: uppercase; +} + +.list-state select { + max-width: 210px; + min-width: 0; + text-transform: none; +} + +.list-facts { + display: grid; + justify-items: start; + gap: 9px; + font-size: 12px; +} + +.list-facts .capture-badge { + border-color: var(--line); + background: var(--surface-muted); + color: var(--secondary); +} + +.list-routes { + min-width: 0; +} + +.route-chip { + display: flex; + align-items: center; + gap: 7px; + max-width: 100%; + min-height: 36px; + border: 1px solid color-mix(in srgb, var(--accent) 24%, var(--line)); + border-radius: 10px; + padding: 7px 10px; + background: color-mix(in srgb, var(--accent-soft) 54%, transparent); + color: var(--accent); + font-size: 11px; + font-weight: 650; + text-align: left; +} + +.route-chip span { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.route-chip.modal { + border-color: color-mix(in srgb, var(--modal) 28%, var(--line)); + background: color-mix(in srgb, var(--modal) 9%, var(--surface-solid)); + color: var(--modal); +} + +.route-chip .icon { + width: 15px; + height: 15px; +} + +.list-inspect { + display: grid; + width: 42px; + height: 42px; + place-items: center; + border-radius: 50%; + background: var(--surface-muted); + color: var(--accent); +} + +.list-inspect:hover { + background: var(--accent-soft); +} + +dialog { + color: var(--text); +} + +dialog::backdrop { + background: rgba(8, 10, 16, 0.42); + -webkit-backdrop-filter: blur(5px); + backdrop-filter: blur(5px); +} + +.panel { + width: 100vw; + max-width: none; + height: 100dvh; + max-height: none; + margin: 0; + overflow: hidden; + border: 0; + padding: 0; + background: transparent; +} + +.panel-surface { + border: 1px solid var(--line-strong); + background: var(--surface-solid); + box-shadow: var(--shadow-large); +} + +.command-surface { + width: min(680px, calc(100vw - 32px)); + max-height: min(680px, calc(100dvh - 100px)); + margin: clamp(36px, 10vh, 100px) auto 0; + overflow: hidden; + border-radius: 22px; + animation: panel-in 180ms ease-out both; +} + +.command-field { + display: flex; + align-items: center; + gap: 12px; + min-height: 64px; + border-bottom: 1px solid var(--line); + padding: 0 15px 0 20px; +} + +.command-field > .icon { + width: 22px; + height: 22px; + color: var(--secondary); +} + +.command-field input { + flex: 1; + min-width: 0; + border: 0; + outline: 0; + background: transparent; + font-size: 18px; + font-weight: 500; +} + +.command-results { + max-height: min(610px, calc(100dvh - 180px)); + overflow: auto; + padding: 12px; +} + +.command-section-label { + padding: 8px 10px 10px; +} + +.command-result { + display: grid; + grid-template-columns: 38px minmax(0, 1fr) auto 18px; + align-items: center; + gap: 12px; + width: 100%; + min-height: 58px; + border-radius: 12px; + padding: 7px 11px; + background: transparent; + color: var(--text); + text-align: left; +} + +.command-result:hover, +.command-result:focus-visible { + background: var(--accent-soft); } -.shelf-header { - position: sticky; - left: 0; - width: max-content; - padding: 17px 22px 0; +.command-glyph { + display: grid; + width: 36px; + height: 36px; + place-items: center; + border: 1px solid var(--line); + border-radius: 10px; + background: var(--surface-muted); + color: var(--accent); } -.shelf-header h2 { - margin-top: 2px; - font-size: 20px; - letter-spacing: -0.025em; +.command-copy { + display: grid; + gap: 3px; + min-width: 0; } -.depth-band { - border: 1px dashed color-mix(in srgb, var(--secondary) 35%, transparent); - border-radius: 22px; +.command-copy strong, +.command-copy small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.depth-band[data-empty="true"] { - opacity: 0.24; +.command-copy strong { + font-size: 14px; + font-weight: 650; } -.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); +.command-copy small, +.command-kind { color: var(--secondary); - font-size: 10px; - font-weight: 750; - letter-spacing: 0.045em; - text-transform: uppercase; + font-size: 11px; } -.canvas-routes { - inset: 0; - overflow: visible; - pointer-events: none; +.command-result > .icon { + color: var(--tertiary); } -.route { - transition: opacity 130ms ease; +.command-empty { + display: grid; + place-items: center; + padding: 58px 20px; + color: var(--secondary); + font-size: 13px; } -.route path { - fill: none; - stroke-width: 3; - vector-effect: non-scaling-stroke; +.command-empty strong { + margin-bottom: 5px; + color: var(--text); + font-size: 16px; } -.route-push path { - stroke: var(--accent); +.sheet-surface { + position: absolute; + top: 80px; + right: 20px; + bottom: 20px; + display: flex; + flex-direction: column; + width: min(410px, calc(100vw - 40px)); + overflow: auto; + border-radius: 22px; + animation: sheet-in 200ms ease-out both; } -.route-push polygon { - fill: var(--accent); +.panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; + min-height: 84px; + padding: 18px 20px; + border-bottom: 1px solid var(--line); } -.route-modal path { - stroke: var(--modal); - stroke-dasharray: 12 8; +.panel-header h2 { + margin-top: 3px; + font-size: 22px; + letter-spacing: -0.025em; } -.route-modal polygon { - fill: var(--modal); +.panel-close { + flex: 0 0 auto; + background: var(--surface-muted); } -.route[data-route-relation="focus"] path { - stroke-width: 5; +.mini-map { + padding: 18px 20px; + border-bottom: 1px solid var(--line); } -.route[data-route-relation="unrelated"] { - opacity: 0.1; +.mini-map svg { + display: block; + width: 100%; + height: 170px; + border: 1px solid var(--line); + border-radius: 14px; + background: var(--canvas); + cursor: crosshair; } -.route[data-hidden="true"] { - opacity: 0.04; +.mini-group { + fill: color-mix(in srgb, var(--surface-solid) 76%, transparent); + stroke: var(--line-strong); + stroke-width: 10; } -.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; +.mini-group[data-active="true"] { + fill: color-mix(in srgb, var(--accent) 12%, var(--surface-solid)); + stroke: var(--accent); } -.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); +.mini-screen { + fill: color-mix(in srgb, var(--text) 28%, transparent); } -.screen-card[data-route-relation="upstream"] { - border-color: var(--secondary); +.mini-screen[data-hidden="true"] { + opacity: 0.08; } -.screen-card[data-route-relation="downstream"] { - border-color: var(--accent); +.mini-viewport { + fill: color-mix(in srgb, var(--accent) 10%, transparent); + stroke: var(--accent); + stroke-width: 12; + vector-effect: non-scaling-stroke; } -.screen-card[data-route-relation="unrelated"] { - opacity: 0.26; +.group-navigation { + display: grid; + gap: 5px; + padding: 12px; } -.screen-card[data-hidden="true"] { - visibility: hidden; - pointer-events: none; - opacity: 0; +.group-button { + display: grid; + grid-template-columns: 38px 1fr 20px; + align-items: center; + gap: 10px; + min-height: 58px; + border-radius: 13px; + padding: 7px 10px; + background: transparent; + color: var(--text); + text-align: left; } -.card-header { - display: flex; - min-width: 0; - align-items: flex-start; - justify-content: space-between; - gap: 8px; - padding: 16px 16px 7px; +.group-button:hover, +.group-button[aria-current="true"] { + background: var(--accent-soft); } -.card-header > div { - min-width: 0; +.group-button > span:nth-child(2) { + display: grid; + gap: 3px; } -.card-header h3 { - overflow: hidden; - font-size: 16px; - letter-spacing: -0.018em; - line-height: 1.25; - text-overflow: ellipsis; - white-space: nowrap; +.group-button strong { + font-size: 14px; } -.card-header p { - margin-top: 2px; - overflow: hidden; - color: var(--tertiary); +.group-button small { + color: var(--secondary); 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; +.group-button > .icon { + color: var(--tertiary); } -.card-state { +.filter-fields { + display: grid; + gap: 20px; + padding: 24px 20px; +} + +.filter-field, +.inspector-field { display: grid; - grid-template-columns: auto minmax(0, 1fr); gap: 7px; - align-items: center; - padding: 0 16px 10px; } -.card-state select { +.filter-field select { width: 100%; - min-width: 0; - min-height: 30px; - padding-top: 3px; - padding-bottom: 3px; - font-size: 11px; + min-height: 44px; } -.single-state { - padding: 1px 16px 11px; - color: var(--secondary); - font-size: 11px; +.panel-footer { + display: flex; + gap: 10px; + margin-top: auto; + padding: 18px 20px; + border-top: 1px solid var(--line); } -.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); +.panel-footer button { + flex: 1; } -.device-preview { +.build-status { display: flex; - width: 100%; - height: 100%; - min-height: 0; align-items: center; - justify-content: center; + gap: 13px; + margin: 20px; + border-radius: 14px; + padding: 15px; + background: var(--surface-muted); } -.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%); +.status-dot { + width: 10px; + height: 10px; + border-radius: 50%; + background: #22a06b; + box-shadow: 0 0 0 5px color-mix(in srgb, #22a06b 14%, transparent); } -.card-footer { - display: flex; - min-height: 47px; - align-items: center; - justify-content: space-between; - gap: 8px; - padding: 7px 13px 9px; +.status-dot.dirty { + background: #f59e0b; + box-shadow: 0 0 0 5px color-mix(in srgb, #f59e0b 14%, transparent); } -.capture-badge { - display: inline-flex; - align-items: center; +.build-status div { + display: grid; 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: ""; +.build-status strong { + font-size: 14px; } -.inspect-link { - min-height: auto; - padding: 5px 7px; - border-color: transparent; - background: transparent; - color: var(--accent); +.build-status span:last-child { + color: var(--secondary); 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; +.detail-list { + margin: 0; } -.list-intro { - padding: 0.5rem 0 2rem; +.build-list { + padding: 0 20px 24px; } -.list-intro h2 { - margin: 0.25rem 0 0.45rem; - font-size: clamp(1.5rem, 2.5vw, 2.2rem); - letter-spacing: -0.035em; +.metadata-row { + display: grid; + grid-template-columns: 94px minmax(0, 1fr); + gap: 16px; + padding: 11px 0; + border-bottom: 1px solid var(--line); + font-size: 12px; } -.list-intro > p:last-child { +.metadata-row dt { 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; +.metadata-row dd { + overflow-wrap: anywhere; + margin: 0; + font-weight: 550; } -.list-group-header span { - color: var(--tertiary); - font-size: 0.75rem; +.inspector { + width: 100vw; + max-width: none; + height: 100dvh; + max-height: none; + margin: 0; + overflow: hidden; + border: 0; + padding: 0; + background: #090a0e; + color: #f7f7f9; } -.list-rows { - display: grid; - gap: 0.55rem; +.inspector::backdrop { + background: #090a0e; } -.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; +.inspector-header { + position: absolute; + z-index: 10; + top: 16px; + right: 18px; + left: 18px; + display: flex; 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; + justify-content: space-between; + gap: 24px; + min-height: 64px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 17px; + padding: 9px 10px 9px 17px; + background: rgba(31, 32, 38, 0.78); + box-shadow: 0 16px 50px rgba(0, 0, 0, 0.28); + -webkit-backdrop-filter: blur(22px) saturate(140%); + backdrop-filter: blur(22px) saturate(140%); + transition: right 220ms ease; } -.list-thumbnail { - display: grid; - width: 7rem; - height: 6.25rem; - padding: 0.45rem; - overflow: hidden; - place-items: center; - background: var(--surface-muted); +.inspector[data-details="true"] .inspector-header { + right: 398px; } -.list-thumbnail img { - width: 100%; - height: 100%; - border-radius: 5px; - object-fit: contain; +.inspector-heading { + min-width: 0; } -.list-identity { - display: grid; - min-width: 0; - gap: 0.22rem; +.inspector-heading .eyebrow { + color: #979ba8; } -.list-identity h3 { +.inspector-heading h2 { overflow: hidden; - font-size: 1rem; + margin-top: 3px; + font-size: 18px; + letter-spacing: -0.02em; text-overflow: ellipsis; white-space: nowrap; } -.list-state { - margin-top: 0.35rem; +.inspector-header-actions { + display: flex; + align-items: center; + gap: 5px; } -.list-state select { - min-width: 0; - max-width: 15rem; +.inspector-action { + min-height: 42px; + padding: 0 10px; + color: #c6c8d0; + font-size: 12px; + font-weight: 650; +} + +a.inspector-action { + flex-direction: row-reverse; } -.list-traits { - display: flex; - flex-wrap: wrap; - gap: 0.3rem; +.inspector-action:hover { + background: rgba(255, 255, 255, 0.1); + color: white; } -.list-traits span { - padding: 0.25rem 0.45rem; - border-radius: 6px; - background: var(--surface-muted); - color: var(--secondary); - font-size: 0.68rem; +.close-inspector { + width: 42px; + border-radius: 50%; + padding: 0; + background: rgba(255, 255, 255, 0.09); } -.list-routes { - display: flex; +.inspection-canvas { + position: absolute; + inset: 0; + display: grid; min-width: 0; - flex-wrap: wrap; - gap: 0.35rem; - align-items: center; + min-height: 0; + place-items: center; + overflow: hidden; + padding: 98px 44px 116px; + background: + radial-gradient(circle at 50% 42%, rgba(57, 62, 80, 0.3), transparent 46%), + #090a0e; + transition: margin-right 220ms ease; } -.list-routes > p { - width: 100%; - font-size: 0.72rem; +.inspector[data-details="true"] .inspection-canvas { + margin-right: 380px; } -.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; +.inspector-image { + display: grid; + width: 100%; + height: 100%; + min-width: 0; + min-height: 0; + place-items: center; + overflow: auto; + scrollbar-color: rgba(255, 255, 255, 0.24) transparent; } -.route-chip.modal { - border-color: color-mix(in srgb, var(--modal) 25%, var(--border)); - background: var(--modal-soft); - color: var(--modal); +.inspector-device { + --point-width: 402px; + --aspect-ratio: 0.46; + display: grid; + flex: 0 0 auto; + overflow: hidden; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 24px; + background: #15161b; + box-shadow: 0 30px 100px rgba(0, 0, 0, 0.55), 0 4px 22px rgba(0, 0, 0, 0.45); } -.list-inspect { - min-width: 5rem; +.inspector-device img { + display: block; + max-width: none; } -.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; +.inspector-image.fit:not(.full-content) .inspector-device { + width: min(var(--point-width), calc((100dvh - 214px) * var(--aspect-ratio)), 100%); + aspect-ratio: var(--aspect-ratio); } -.list > .empty-results { - position: relative; - top: auto; - left: auto; - margin: 4rem auto; - transform: none; +.inspector-image.fit:not(.full-content) img { + width: 100%; + height: 100%; + object-fit: fill; } -.empty-results[hidden] { - display: none; +.inspector-image.actual { + place-items: start center; } -.empty-results h2 { - margin: 0.3rem 0 0.45rem; +.inspector-image.actual .inspector-device, +.inspector-image.actual img { + width: auto; + height: auto; } -.empty-results > p:not(.eyebrow) { - margin-bottom: 1rem; - color: var(--secondary); +.inspector-image.full-content.fit .inspector-device { + width: min(var(--point-width), 100%); + height: 100%; + overflow: auto; } -dialog.inspector { - width: min(96vw, 88rem); - height: min(92vh, 66rem); +.inspector-image.full-content.fit img { + width: 100%; max-width: none; + height: auto; 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 { +.image-caption { + position: absolute; + bottom: 88px; + left: 24px; 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); + gap: 9px; + color: #90949f; + font-size: 10px; } -.inspector-header h2 { - margin-top: 0.18rem; - font-size: 1.25rem; - letter-spacing: -0.025em; +.image-caption .capture-badge { + border-color: rgba(255, 255, 255, 0.12); + background: rgba(31, 32, 38, 0.74); } -.inspector-header-actions { +.inspector-dock { + position: absolute; + z-index: 12; + bottom: max(18px, env(safe-area-inset-bottom)); + left: 50%; display: flex; - gap: 0.4rem; + align-items: center; + gap: 6px; + min-height: 64px; + max-width: calc(100vw - 48px); + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 19px; + padding: 7px; + background: rgba(31, 32, 38, 0.84); + box-shadow: 0 22px 70px rgba(0, 0, 0, 0.42); + transform: translateX(-50%); + -webkit-backdrop-filter: blur(22px) saturate(140%); + backdrop-filter: blur(22px) saturate(140%); + transition: margin-left 220ms ease; } -.close { - margin-left: 0.2rem; +.inspector[data-details="true"] .inspector-dock { + margin-left: -190px; } -.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-dock .icon-button { + color: #d0d2d8; } -.inspector-controls .scale-tabs { - margin-left: auto; +.inspector-dock .icon-button:hover { + background: rgba(255, 255, 255, 0.1); + color: white; } -.inspector-body { - display: grid; - height: calc(100% - 8.2rem); - min-height: 0; - grid-template-columns: minmax(0, 1fr) 20rem; +.inspector-dock .dock-separator { + background: rgba(255, 255, 255, 0.12); } -.inspector-preview { - display: flex; - min-width: 0; - min-height: 0; - flex-direction: column; - background: var(--canvas); +.inspector-field { + gap: 3px; + padding: 0 4px; } -.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); +.inspector-field .field-label { + color: #8e929d; + font-size: 9px; } -.preview-toolbar .secondary { - overflow: hidden; - font: 0.68rem ui-monospace, monospace; - text-overflow: ellipsis; - white-space: nowrap; +.inspector-field select { + max-width: 190px; + min-height: 30px; + border: 0; + padding-left: 0; + background-color: transparent; + color: #f7f7f9; + font-size: 12px; + font-weight: 650; } -.raw-link { - margin-left: auto; - font-size: 0.72rem; - font-weight: 700; - text-decoration: none; +.inspector-dock .segmented-control { + background: rgba(255, 255, 255, 0.08); } -.raw-link:hover { - text-decoration: underline; +.inspector-dock .segment { + min-height: 34px; + color: #aeb1bb; } -.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-dock .segment[aria-pressed="true"] { + background: rgba(255, 255, 255, 0.15); + box-shadow: none; + color: white; } -.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-details { + position: absolute; + z-index: 11; + top: 0; + right: 0; + bottom: 0; + width: 380px; + overflow: auto; + border-left: 1px solid rgba(255, 255, 255, 0.12); + padding: 0 20px 40px; + background: rgba(25, 26, 31, 0.96); + box-shadow: -24px 0 70px rgba(0, 0, 0, 0.3); + transform: translateX(100%); + transition: transform 220ms ease; + -webkit-backdrop-filter: blur(22px); + backdrop-filter: blur(22px); } -.inspector-image img { - display: block; - width: 100%; - max-width: 100%; - height: auto; - border-radius: 10px; +.inspector[data-details="true"] .inspector-details { + transform: translateX(0); } -.inspector-image.fit .inspector-device { - max-width: 100%; +.details-header { + position: sticky; + z-index: 2; + top: 0; + display: grid; + grid-template-columns: 1fr auto; + padding: 24px 0 18px; + border-bottom: 1px solid rgba(255, 255, 255, 0.11); + background: rgba(25, 26, 31, 0.94); + -webkit-backdrop-filter: blur(18px); + backdrop-filter: blur(18px); } -.inspector-image.fit img { - max-width: 100%; +.details-header h3 { + margin-top: 4px; + font-size: 20px; } -.inspector-image.actual { - justify-content: flex-start; +.details-header .eyebrow, +.detail-section .eyebrow { + color: #8e929d; } -.inspector-image.actual img { - width: 100%; - max-width: none; +.details-close { + grid-row: 1 / 3; + grid-column: 2; + background: rgba(255, 255, 255, 0.08); + color: #d1d3da; } -.inspector-image.actual .inspector-device { - max-width: none; +.detail-section { + padding: 22px 0 4px; } -.inspector-image.full-content { - justify-content: center; +.detail-section .metadata-row { + border-color: rgba(255, 255, 255, 0.1); } -.inspector-image.full-content .inspector-device { - max-width: none; +.detail-section .metadata-row dt, +.no-routes { + color: #8e929d; } -.inspector-details { - min-height: 0; - padding: 1.1rem; - overflow: auto; - border-left: 1px solid var(--border); - background: var(--surface); +.route-links { + display: grid; + gap: 8px; + margin-top: 12px; } -.detail-section { - padding-bottom: 1.15rem; - margin-bottom: 1.15rem; - border-bottom: 1px solid var(--border); +.inspector-details .route-chip { + width: 100%; + min-height: 42px; + border-color: rgba(120, 145, 255, 0.24); + background: rgba(120, 145, 255, 0.1); + color: #9aabff; } -.detail-section:last-child { - margin-bottom: 0; - border-bottom: 0; +.inspector-details .route-chip.modal { + border-color: rgba(211, 76, 230, 0.25); + background: rgba(211, 76, 230, 0.09); + color: #e28bee; } -.detail-list { - display: grid; - grid-template-columns: auto minmax(0, 1fr); - gap: 0.55rem 0.8rem; - margin-top: 0.75rem; +.no-routes { + padding: 8px 0; + font-size: 12px; } -.detail-list dt { - color: var(--tertiary); - font-size: 0.72rem; +.error { + display: grid; + width: min(620px, calc(100% - 40px)); + min-height: 100%; + align-content: center; + margin: auto; } -.detail-list dd { - overflow-wrap: anywhere; - font-size: 0.72rem; - text-align: right; +.error h1 { + margin: 8px 0 10px; + font-size: 34px; } -.detail-section .route-links { - display: grid; - gap: 0.4rem; - margin-top: 0.65rem; +.error p:last-child { + color: var(--secondary); } -.detail-section .route-chip { - width: 100%; +@keyframes panel-in { + from { opacity: 0; transform: translateY(-10px) scale(0.98); } + to { opacity: 1; transform: translateY(0) scale(1); } } -.no-routes { - color: var(--tertiary); - font-size: 0.75rem; +@keyframes sheet-in { + from { opacity: 0; transform: translateX(18px); } + to { opacity: 1; transform: translateX(0); } } @media (max-width: 1050px) { - .canvas-layout { - grid-template-columns: 12.5rem minmax(0, 1fr); + .list-row { + grid-template-columns: 104px minmax(170px, 1fr) 130px 44px; } - .mini-map { + .list-routes { display: none; } - .keyboard-hint { - margin-top: auto; + .fit-all .button-label { + display: none; } - .list-row { - grid-template-columns: 6rem minmax(12rem, 1fr) minmax(12rem, 1fr) auto; + .inspector[data-details="true"] .inspection-canvas { + margin-right: 330px; } - .list-thumbnail { - width: 6rem; + .inspector[data-details="true"] .inspector-header { + right: 348px; } - .list-traits { - display: none; + .inspector-details { + width: 330px; } - .inspector-body { - grid-template-columns: minmax(0, 1fr) 17rem; + .inspector[data-details="true"] .inspector-dock { + margin-left: -165px; } } @media (max-width: 760px) { - body { - overflow: auto; + .application { + grid-template-rows: 56px minmax(0, 1fr); + } + + .app-header { + gap: 10px; + padding: 0 11px; } - #app { - min-height: 100dvh; + .app-identity { + gap: 8px; } - .masthead { - min-height: 3.7rem; + .brand-mark { + width: 32px; + height: 32px; + border-radius: 10px; } - .build-details { + .product-label, + .commit-label, + .search-trigger .button-label, + .search-trigger kbd { display: none; } - .primary-toolbar { - flex-wrap: wrap; + .identity-copy { + display: block; + } + + .identity-copy h1 { + max-width: 105px; + font-size: 15px; + } + + .header-actions { + gap: 2px; + } + + .header-profile select { + max-width: 122px; + height: 36px; + padding-right: 24px; + font-size: 11px; + } + + .search-trigger, + .build-trigger { + min-width: 38px; + width: 38px; + padding: 0; + justify-content: center; + } + + .canvas-context, + .route-legend { + top: 12px; + } + + .canvas-context { + left: 12px; + min-width: 140px; + } + + .route-legend { + right: 12px; } - .search-field { - order: 3; + .bottom-dock { + right: 10px; + bottom: max(10px, env(safe-area-inset-bottom)); + left: 10px; max-width: none; - flex-basis: 100%; + min-height: 56px; + justify-content: center; + gap: 2px; + overflow-x: auto; + padding: 6px; + transform: none; } - .profile-field .field-label { + .bottom-dock .dock-separator, + .bottom-dock .result-count, + .bottom-dock .dock-action .button-label { display: none; } - .context-toolbar { - overflow-x: auto; + .bottom-dock .dock-action { + min-width: 40px; + padding: 0 8px; } - .canvas-controls { - margin-left: auto; + .view-tabs .segment { + padding: 0 9px; } - .canvas-controls input[type="range"], - .zoom-value, - .canvas-controls button:nth-last-child(2) { - display: none; + .zoom-value { + width: 39px; } - .canvas-layout { - min-height: 34rem; - grid-template-columns: 1fr; + .list-view { + padding: 34px 16px 104px; } - .canvas-sidebar { - display: none; + .list-intro { + display: grid; + gap: 12px; + margin-bottom: 32px; + } + + .list-intro h2 { + font-size: 30px; } - .empty-results { - left: 50%; + .list-group { + margin-bottom: 36px; } - .list { - overflow: visible; + .list-group-header { + grid-template-columns: 32px 1fr auto; } .list-row { - grid-template-columns: 5rem minmax(0, 1fr) auto; + grid-template-columns: 82px minmax(0, 1fr) 42px; + gap: 14px; + min-height: 122px; } .list-thumbnail { - width: 5rem; - height: 5rem; + width: 76px; + height: 96px; } + .list-facts, .list-routes { display: none; } - dialog.inspector { - width: 100vw; - height: 100dvh; - max-height: 100dvh; - border: 0; - border-radius: 0; + .list-state { + display: grid; + gap: 4px; } - .inspector-controls { - flex-wrap: nowrap; - overflow-x: auto; + .list-state select { + max-width: 100%; + } + + .command-surface { + width: calc(100vw - 20px); + max-height: calc(100dvh - 32px); + margin-top: 10px; + border-radius: 18px; + } + + .command-field { + min-height: 58px; + padding-left: 15px; + } + + .command-field input { + font-size: 16px; } - .inspector-controls .field-label { + .command-results { + max-height: calc(100dvh - 102px); + } + + .command-kind { + display: none; + } + + .command-result { + grid-template-columns: 38px minmax(0, 1fr) 18px; + } + + .sheet-surface { + top: auto; + right: 8px; + bottom: max(8px, env(safe-area-inset-bottom)); + left: 8px; + width: auto; + max-height: calc(100dvh - 24px); + border-radius: 22px; + animation-name: mobile-sheet-in; + } + + .mini-map svg { + height: 145px; + } + + .inspector-header { + top: 8px; + right: 8px; + left: 8px; + min-height: 58px; + border-radius: 15px; + padding: 7px 7px 7px 13px; + } + + .inspector[data-details="true"] .inspector-header { + right: 8px; + } + + .inspector-heading h2 { + max-width: 150px; + font-size: 15px; + } + + .inspector-heading .eyebrow { + font-size: 9px; + } + + .inspector-action { + min-width: 38px; + min-height: 38px; + padding: 0 7px; + } + + .inspector-action .button-label, + a.inspector-action { + font-size: 0; + } + + a.inspector-action .icon { + width: 19px; + height: 19px; + } + + .inspection-canvas, + .inspector[data-details="true"] .inspection-canvas { + margin-right: 0; + padding: 78px 12px 116px; + } + + .inspector-device { + border-radius: 18px; + } + + .image-caption { + bottom: 82px; + left: 12px; + } + + .image-caption span:last-child { display: none; } - .inspector-controls .scale-tabs { + .inspector-dock, + .inspector[data-details="true"] .inspector-dock { + right: 8px; + bottom: max(8px, env(safe-area-inset-bottom)); + left: 8px; + min-height: 62px; + max-width: none; + gap: 2px; margin-left: 0; + overflow-x: auto; + border-radius: 17px; + padding: 6px; + transform: none; + } + + .inspector-dock .dock-separator { + display: none; } - .inspector-body { - height: calc(100% - 8.2rem); - grid-template-columns: 1fr; - overflow: auto; + .inspector-field { + min-width: 112px; } - .inspector-preview { - min-height: 65vh; + .inspector-field select { + max-width: 132px; + } + + .inspector-field.profile-field { + min-width: 108px; } .inspector-details { - overflow: visible; - border-top: 1px solid var(--border); + z-index: 20; + top: 72px; + width: 100%; + border-top: 1px solid rgba(255, 255, 255, 0.12); border-left: 0; + border-radius: 24px 24px 0 0; + transform: translateY(100%); + } + + .inspector[data-details="true"] .inspector-details { + transform: translateY(0); + } +} + +@media (max-width: 440px) { + .route-legend { + display: none; + } + + .identity-copy h1 { + max-width: 72px; + } + + .header-profile select { + max-width: 104px; } + + .view-tabs .button-label { + display: none; + } + + .view-tabs .segment { + width: 39px; + padding: 0; + } + + .inspector-heading h2 { + max-width: 105px; + } + + .inspector-dock .scale-tabs { + display: none; + } +} + +@keyframes mobile-sheet-in { + from { opacity: 0; transform: translateY(24px); } + to { opacity: 1; transform: translateY(0); } } @media (prefers-reduced-motion: reduce) { @@ -1518,7 +2130,21 @@ dialog.inspector::backdrop { *::before, *::after { scroll-behavior: auto !important; - transition: none !important; - animation: none !important; + animation-duration: 0.001ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.001ms !important; + } +} + +@media (prefers-reduced-transparency: reduce) { + .app-header, + .bottom-dock, + .canvas-context, + .route-legend, + .inspector-header, + .inspector-dock, + .inspector-details { + -webkit-backdrop-filter: none; + backdrop-filter: none; } } diff --git a/Shared/Flyover/Web/index.html b/Shared/Flyover/Web/index.html index 475c50a27..37c754467 100644 --- a/Shared/Flyover/Web/index.html +++ b/Shared/Flyover/Web/index.html @@ -3,7 +3,7 @@ - + Flyover QA Atlas From ac259f5d1c81d363ef297164d328de03cff17c54 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 3 Sep 2026 15:04:10 -0700 Subject: [PATCH 07/15] Fix test runner contract fixtures Run the Flyover shell self-test when its module fixture is present. Keep extracted test-runner contract fixtures independent of module-specific files. --- Tools/Tests/test_xcode_command_contracts.py | 14 ++++++++++++++ test | 6 ++++-- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Tools/Tests/test_xcode_command_contracts.py b/Tools/Tests/test_xcode_command_contracts.py index c721aa3f6..c7ef7096f 100644 --- a/Tools/Tests/test_xcode_command_contracts.py +++ b/Tools/Tests/test_xcode_command_contracts.py @@ -253,6 +253,20 @@ def test_unit_run_does_not_require_the_snapshot_xcode_build(self): self.assertEqual(0, result.returncode, result.stdout + result.stderr) self.assertNotIn("xcodebuild -version", fixture.command_log()) + def test_unit_run_does_not_require_the_flyover_command_fixture(self): + fixture = self.fixture() + + result = fixture.run( + "test", + "--skip-architecture", + "--no-generate", + "--no-build", + "CoreTests", + ) + + self.assertEqual(0, result.returncode, result.stdout + result.stderr) + self.assertNotIn("Testing flyover command", result.stdout) + def test_test_preserves_xcode_failure_through_the_progress_pipeline(self): fixture = self.fixture() diff --git a/test b/test index 23b92dae4..97c3cf215 100755 --- a/test +++ b/test @@ -269,8 +269,10 @@ if [ "$ARCHITECTURE_ONLY" = true ]; then exit 0 fi -echo "==> Testing flyover command" -bash Shared/Flyover/Tools/Tests/flyover_test.sh +if [ -f Shared/Flyover/Tools/Tests/flyover_test.sh ]; then + echo "==> Testing flyover command" + bash Shared/Flyover/Tools/Tests/flyover_test.sh +fi WORKSPACE="Stuff.xcworkspace" UNIT_SCHEME="Stuff-iOS-Tests" From ce106980bfcbc0881dd798ee776fc79e51de4ac5 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 3 Sep 2026 16:22:30 -0700 Subject: [PATCH 08/15] Harden Flyover web parity and performance --- Shared/Flyover/README.md | 14 +- Shared/Flyover/Sources/FlyoverVariant.swift | 4 +- .../Flyover/Sources/FlyoverWebExporter.swift | 2 + .../Flyover/Tests/FlyoverVariantTests.swift | 26 + .../Tests/FlyoverWebExporterTests.swift | 55 ++ Shared/Flyover/Tools/Tests/flyover_test.sh | 73 ++- Shared/Flyover/Web/assets/app.js | 563 +++++++++++++++--- Shared/Flyover/Web/assets/styles.css | 157 ++++- .../Sources/AssertSnapshots.swift | 2 + .../Sources/SnapshotCaptureLock.swift | 8 +- .../Sources/SnapshotImageRendering.swift | 5 +- .../Sources/SnapshotMeasurementHook.swift | 1 + .../Sources/SnapshotPNG.swift | 2 + .../Sources/SnapshotRenderingSupport.swift | 1 + .../Tests/AssertSnapshotsTests.swift | 69 +++ .../Tests/SnapshotPNGTests.swift | 82 ++- .../WhereFlyoverWebExportTests.swift | 291 ++++++++- .../Flyover/OpenSpansView+WhereFlyover.swift | 4 +- .../Developer/Flyover/WhereFlyoverWorld.swift | 24 + .../Tests/WhereFlyoverWorldTests.swift | 9 + flyover | 63 +- 21 files changed, 1313 insertions(+), 142 deletions(-) diff --git a/Shared/Flyover/README.md b/Shared/Flyover/README.md index 8af34bb55..70e52c64c 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -147,11 +147,11 @@ 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. +Hosted variants have a `FlyoverExportPolicy` with a fixed viewport by default. +Snapshot-backed variants inherit their settle, readiness, and hook behavior. +Their frame matrix must resolve to one capture extent: fixed, intrinsic, +full-content, or two-axis full-content. A mixed matrix has no resolved policy. +The app must supply an explicit policy before export. Profiles are additive and keep request order. No profile matrix is generated. The built-in IDs are: @@ -189,6 +189,10 @@ dock keeps the canvas visible. The group panel and overview map move between groups without recalculating the graph. The canvas keeps its position when a state, profile, or panel changes. +Canvas and list views give active image sources to at most six nearby screenshots. +They target 24 million pixels and always show the nearest screenshot. +The inspector removes these sources while it shows one full-resolution capture. + Point to or focus a card to emphasize its connected routes. The site dims unrelated cards and routes until the focus moves. Filters for groups, capture extents, and route states stay in a separate panel. diff --git a/Shared/Flyover/Sources/FlyoverVariant.swift b/Shared/Flyover/Sources/FlyoverVariant.swift index ff4ffe813..301e326d5 100644 --- a/Shared/Flyover/Sources/FlyoverVariant.swift +++ b/Shared/Flyover/Sources/FlyoverVariant.swift @@ -9,7 +9,7 @@ public struct FlyoverVariant { let overviewContent: @MainActor () -> AnyView let focusedContent: @MainActor () -> AnyView #if DEBUG - public let exportPolicy: FlyoverExportPolicy + public let exportPolicy: FlyoverExportPolicy? let exportPolicyResolution: FlyoverExportPolicyResolution #endif @@ -54,7 +54,7 @@ public struct FlyoverVariant { exportPolicyResolution = FlyoverExportPolicy.resolution(for: snapshotCase) exportPolicy = switch exportPolicyResolution { case let .policy(policy): policy - case .mixed: .hosted + case .mixed: nil } #endif } diff --git a/Shared/Flyover/Sources/FlyoverWebExporter.swift b/Shared/Flyover/Sources/FlyoverWebExporter.swift index 30e379736..938018fae 100644 --- a/Shared/Flyover/Sources/FlyoverWebExporter.swift +++ b/Shared/Flyover/Sources/FlyoverWebExporter.swift @@ -113,6 +113,7 @@ reason: error.localizedDescription, ) } + try Task.checkCancellation() guard captured.pngData.isEmpty == false else { throw FlyoverExportError.emptyPNG( screen: prepared.screen.title, @@ -156,6 +157,7 @@ } } + try Task.checkCancellation() let manifest = try makeManifest( build: build, profiles: profiles, diff --git a/Shared/Flyover/Tests/FlyoverVariantTests.swift b/Shared/Flyover/Tests/FlyoverVariantTests.swift index 0fd60ad29..805b23722 100644 --- a/Shared/Flyover/Tests/FlyoverVariantTests.swift +++ b/Shared/Flyover/Tests/FlyoverVariantTests.swift @@ -55,4 +55,30 @@ struct FlyoverVariantTests { _ = variant.overviewContent() #expect(buildCount == 1) } + + @Test func mixedSnapshotSizingDoesNotExposeAFallbackPolicy() { + let snapshotCase = SnapshotCase( + name: "Mixed", + configurations: [ + SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "viewport", + size: .fixed(CGSize(width: 100, height: 100)), + ), + ), + SnapshotConfiguration( + device: .fullContent(name: "full-content", width: 100), + ), + ], + ) { + Color.red + } + + let variant = FlyoverVariant( + id: FlyoverVariantID("mixed"), + snapshotCase: snapshotCase, + ) + + #expect(variant.exportPolicy == nil) + } } diff --git a/Shared/Flyover/Tests/FlyoverWebExporterTests.swift b/Shared/Flyover/Tests/FlyoverWebExporterTests.swift index e596d61c5..9d0376993 100644 --- a/Shared/Flyover/Tests/FlyoverWebExporterTests.swift +++ b/Shared/Flyover/Tests/FlyoverWebExporterTests.swift @@ -153,6 +153,50 @@ } } + @Test func cancellationAfterCaptureDoesNotPublishTheImageOrManifest() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let screen = makeFlyoverTestScreen(.root, title: "Root") + let catalog = FlyoverCatalog(groups: [ + FlyoverGroup( + id: FlyoverGroupID("main"), + title: "Main", + root: .root, + screens: [screen], + ), + ]) + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "test", + title: "Test", + screenIdentifier: \FlyoverTestScreen.rawValue, + ) + + let export = Task { @MainActor in + try await exporter.export( + to: directory, + profiles: [.phoneLight], + build: build, + ) { _ in + withUnsafeCurrentTask { task in task?.cancel() } + return FlyoverCapturedImage( + pngData: Data([1]), + pointSize: CGSize(width: 1, height: 1), + pixelSize: CGSize(width: 1, height: 1), + scale: 1, + ) + } + } + + await #expect(throws: CancellationError.self) { + try await export.value + } + #expect(FileManager.default.fileExists( + atPath: directory.appending(path: "manifest.json").path, + ) == false) + #expect(try pngCount(in: directory) == 0) + } + private var build: FlyoverExportBuild { FlyoverExportBuild( commit: "abc123", @@ -211,6 +255,17 @@ ) return directory } + + 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" }) + } } extension SnapshotConfiguration.Frame { diff --git a/Shared/Flyover/Tools/Tests/flyover_test.sh b/Shared/Flyover/Tools/Tests/flyover_test.sh index 2e2152d37..9eada1130 100755 --- a/Shared/Flyover/Tools/Tests/flyover_test.sh +++ b/Shared/Flyover/Tools/Tests/flyover_test.sh @@ -20,6 +20,11 @@ SUCCESS_RUNNER="$TEMP/success-runner" cat >"$SUCCESS_RUNNER" <<'RUNNER' #!/bin/bash set -euo pipefail +expected='WhereUISnapshotTests/WhereFlyoverWebExportTests/exportsRequestedAtlas()' +if [ "$#" -ne 2 ] || [ "${1-}" != --only ] || [ "${2-}" != "$expected" ]; then + echo "unexpected capture runner arguments: $*" >&2 + exit 64 +fi python3 - <<'PY' import json, os, pathlib root = pathlib.Path(os.environ['FLYOVER_EXPORT_DIRECTORY']) @@ -39,7 +44,7 @@ for index, profile in enumerate(profiles, 1): manifest = { 'schemaVersion': 1, 'application': {'id': 'where', 'title': 'Where'}, - 'build': {}, + 'build': {'dirty': os.environ.get('FLYOVER_EXPORT_DIRTY') == 'true'}, 'profiles': [{'id': profile} for profile in profiles], 'canvas': {}, 'groups': [], @@ -51,6 +56,13 @@ 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 +if [ -n "${FLYOVER_RACE_DESTINATION:-}" ]; then + mkdir -p "$FLYOVER_RACE_DESTINATION" + printf '%s\n' retained >"$FLYOVER_RACE_DESTINATION/retained" +fi +if [ -n "${FLYOVER_RACE_SYMLINK_DESTINATION:-}" ]; then + ln -s "$FLYOVER_RACE_SYMLINK_DESTINATION.missing" "$FLYOVER_RACE_SYMLINK_DESTINATION" +fi RUNNER chmod +x "$SUCCESS_RUNNER" @@ -70,12 +82,38 @@ expect_failure "$ROOT/flyover" export --output / expect_failure "$ROOT/flyover" export --output "$HOME" expect_failure "$ROOT/flyover" export --output "$ROOT" +ln -s / "$TEMP/root-alias" +expect_failure "$ROOT/flyover" export --output "$TEMP/root-alias" +grep -q "filesystem root" "$TEMP/stderr" || fail "root alias was not rejected as root" +ln -s "$HOME" "$TEMP/home-alias" +expect_failure "$ROOT/flyover" export --output "$TEMP/home-alias" +grep -q "home directory" "$TEMP/stderr" || fail "home alias was not rejected as home" +ln -s "$ROOT" "$TEMP/workspace-alias" +expect_failure "$ROOT/flyover" export --output "$TEMP/workspace-alias" +grep -q "workspace root" "$TEMP/stderr" || fail "workspace alias was not rejected as workspace" + 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" +RACED="$TEMP/raced" +expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests FLYOVER_RACE_DESTINATION="$RACED" \ + "$ROOT/flyover" export --output "$RACED" +grep -q "unmarked" "$TEMP/stderr" || fail "publish-time destination change was not rejected" +[ -f "$RACED/retained" ] || fail "publish-time destination change was deleted" + +RACED_SYMLINK="$TEMP/raced-symlink" +expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + FLYOVER_RACE_SYMLINK_DESTINATION="$RACED_SYMLINK" \ + "$ROOT/flyover" export --output "$RACED_SYMLINK" +grep -q "non-directory" "$TEMP/stderr" \ + || fail "publish-time dangling symlink was not rejected" +[ -L "$RACED_SYMLINK" ] || fail "publish-time dangling symlink was deleted" + CALLER="$TEMP/caller" mkdir -p "$CALLER" ( @@ -90,11 +128,38 @@ 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']] +python3 - "$OUTPUT" <<'PY' +import json, pathlib, stat, sys +root = pathlib.Path(sys.argv[1]) +profiles = [item['id'] for item in json.loads((root / 'manifest.json').read_text())['profiles']] if profiles != ['phone-dark', 'phone-light']: raise SystemExit(f'profile order or deduplication is wrong: {profiles}') +bad_directories = [path for path in [root, *root.rglob('*')] + if path.is_dir() and stat.S_IMODE(path.stat().st_mode) != 0o755] +bad_files = [path for path in root.rglob('*') + if path.is_file() and stat.S_IMODE(path.stat().st_mode) != 0o644] +if bad_directories or bad_files: + raise SystemExit(f'unsafe published modes: directories={bad_directories}, files={bad_files}') +PY + +CLEAN_REPO="$TEMP/clean-repo" +mkdir -p "$CLEAN_REPO/Shared/Flyover/Web/assets" +cp "$ROOT/flyover" "$CLEAN_REPO/flyover" +cp "$ROOT/Shared/Flyover/Web/index.html" "$CLEAN_REPO/Shared/Flyover/Web/index.html" +cp "$ROOT/Shared/Flyover/Web/assets/app.js" "$CLEAN_REPO/Shared/Flyover/Web/assets/app.js" +cp "$ROOT/Shared/Flyover/Web/assets/styles.css" "$CLEAN_REPO/Shared/Flyover/Web/assets/styles.css" +chmod +x "$CLEAN_REPO/flyover" +git -C "$CLEAN_REPO" init -q +git -C "$CLEAN_REPO" add . +git -C "$CLEAN_REPO" -c user.name=Flyover -c user.email=flyover@example.com \ + -c commit.gpgsign=false commit -qm fixture +FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$CLEAN_REPO/flyover" export --output "$CLEAN_REPO/site" >/dev/null +python3 - "$CLEAN_REPO/site/manifest.json" <<'PY' +import json, pathlib, sys +dirty = json.loads(pathlib.Path(sys.argv[1]).read_text())['build']['dirty'] +if dirty: + raise SystemExit('staging a clean in-repository export marked the source dirty') PY printf '%s\n' old >"$OUTPUT/old-file" diff --git a/Shared/Flyover/Web/assets/app.js b/Shared/Flyover/Web/assets/app.js index f5c1f4a5e..44f60e994 100644 --- a/Shared/Flyover/Web/assets/app.js +++ b/Shared/Flyover/Web/assets/app.js @@ -16,10 +16,15 @@ imageKey(image.screenID, image.variantID, image.profileID), image, ])); + const defaultView = "canvas"; + const defaultProfile = manifest.profiles[0]?.id; + const maximumResidentImageCount = 6; + const maximumResidentPixelCount = 24_000_000; + let inspectorResizeObserver = null; const selectedVariants = new Map(manifest.screens.map(screen => [screen.id, screen.variants[0]?.id])); const state = { - view: "canvas", - profile: manifest.profiles[0]?.id, + view: defaultView, + profile: defaultProfile, screen: null, zoom: 1, group: manifest.groups[0]?.id, @@ -30,10 +35,18 @@ panelTrigger: null, commandQuery: "", pendingCanvasAction: null, + pendingCanvasPosition: null, + pendingFocusKey: null, + inspectorReturnFocusKey: null, canvas: { initialized: false, scrollLeft: 0, scrollTop: 0, + viewportWidth: 0, + viewportHeight: 0, + }, + list: { + scrollTop: 0, }, filters: { group: "all", @@ -66,6 +79,10 @@ return value; } + function quantity(count, singular) { + return count + " " + (count === 1 ? singular : singular + "s"); + } + function icon(name) { const namespace = "http://www.w3.org/2000/svg"; const svg = document.createElementNS(namespace, "svg"); @@ -144,19 +161,25 @@ } function parseHash() { + const previousScreenID = state.screen; 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; + state.view = view === "canvas" || view === "list" ? view : defaultView; + state.profile = profileByID.has(profile) ? profile : defaultProfile; if (screenByID.has(screenID)) { state.screen = screenID; const screen = screenByID.get(screenID); - if (screen.variants.some(variant => variant.id === variantID)) selectedVariants.set(screenID, variantID); + const selectedVariant = screen.variants.some(variant => variant.id === variantID) + ? variantID : screen.variants[0]?.id; + selectedVariants.set(screenID, selectedVariant); } else { state.screen = null; + if (previousScreenID) { + state.pendingFocusKey = state.inspectorReturnFocusKey || "atlas-screen-" + previousScreenID; + } } } @@ -181,7 +204,7 @@ function chooseView(view) { state.view = view; state.panel = null; - if (view === "canvas" && !state.canvas.initialized) state.pendingCanvasAction = "fit-group"; + if (view === "canvas" && !state.canvas.initialized) state.pendingCanvasAction = "fit-initial"; renderOrNavigate(); } @@ -203,6 +226,7 @@ function openScreen(screenID, preserveVariant = false) { const screen = screenByID.get(screenID); if (!screen) return; + if (!state.screen) state.inspectorReturnFocusKey = "atlas-screen-" + screenID; if (!preserveVariant) selectedVariants.set(screen.id, screen.variants[0]?.id); state.screen = screen.id; state.routeFocus = screen.id; @@ -231,9 +255,10 @@ return row; } - function profileSelect(className = "") { + function profileSelect(className = "", focusKey = "profile") { const select = element("select", className); select.setAttribute("aria-label", "Capture profile"); + select.dataset.focusKey = focusKey; for (const item of manifest.profiles) { const option = element("option", "", item.title); option.value = item.id; @@ -247,14 +272,19 @@ function appHeader() { const header = element("header", "app-header"); const identity = element("div", "app-identity"); - identity.append(element("span", "brand-mark", "F")); + const brandMark = element("span", "brand-mark", "F"); + brandMark.setAttribute("aria-hidden", "true"); + identity.append(brandMark); const copy = element("div", "identity-copy"); copy.append(element("span", "product-label", "Flyover"), element("h1", "", manifest.application.title)); identity.append(copy); const actions = element("div", "header-actions"); const profile = element("label", "header-profile"); - profile.append(element("span", "visually-hidden", "Capture profile"), profileSelect()); + profile.append( + element("span", "visually-hidden", "Capture profile"), + profileSelect("", "header-profile"), + ); actions.append(profile); const search = iconButton("search", "Search", "header-button search-trigger", true); @@ -267,7 +297,11 @@ const build = iconButton("info", "Build details", "header-button build-trigger"); build.id = "build-trigger"; build.append(element("code", "commit-label", manifest.build.commit.slice(0, 8))); - if (manifest.build.dirty) build.append(element("span", "dirty-dot")); + if (manifest.build.dirty) { + build.setAttribute("aria-label", "Build details, uncommitted changes"); + build.title = "Build details, uncommitted changes"; + build.append(element("span", "dirty-dot")); + } build.addEventListener("click", () => openPanel("build", build.id)); actions.append(build); header.append(identity, actions); @@ -283,6 +317,7 @@ for (const item of [["canvas", "canvas", "Canvas"], ["list", "list", "List"]]) { const button = iconButton(item[1], item[2], "segment", true); button.setAttribute("aria-pressed", String(state.view === item[0])); + button.dataset.focusKey = "view-" + item[0]; button.addEventListener("click", () => chooseView(item[0])); viewTabs.append(button); } @@ -316,6 +351,7 @@ const filters = iconButton("filter", "Filters", "dock-action filter-trigger", true); filters.id = "filter-trigger"; + filters.dataset.focusKey = "filters-control"; filters.append(element("span", "filter-badge")); filters.addEventListener("click", () => openPanel("filters", filters.id)); dock.append(filters); @@ -330,6 +366,7 @@ function variantSelector(screen, updateHistory = false, className = "") { const select = element("select", className); select.setAttribute("aria-label", "State for " + screen.title); + select.dataset.focusKey = (updateHistory ? "inspector-state-" : "atlas-state-") + screen.id; for (const variant of screen.variants) { const option = element("option", "", variant.title); option.value = variant.id; @@ -349,9 +386,11 @@ const cue = direction === "outgoing" ? (route.kind === "modal" ? "Modal" : "Push") : (route.kind === "modal" ? "Presented from" : "Back to"); + const title = direction === "outgoing" && route.label + ? route.label : (destination?.title || destinationID); const button = element("button", "route-chip " + route.kind); button.type = "button"; - button.append(icon("route"), element("span", "", cue + " · " + (destination?.title || destinationID))); + button.append(icon("route"), element("span", "", cue + " · " + title)); button.addEventListener("click", event => { event.stopPropagation(); openScreen(destinationID); @@ -374,11 +413,29 @@ const image = element("img", className); image.loading = eager ? "eager" : "lazy"; image.decoding = "async"; - image.src = imagePath(screen); + const source = imagePath(screen); + if (eager) image.src = source; + else image.dataset.src = source; + image.dataset.captureExtent = screenVariant(screen)?.captureExtent || "viewport"; image.alt = screen.title + " — " + (screenVariant(screen)?.title || "Default"); return image; } + function captureViewportSize(screen) { + if (screen.viewport.kind === "fixed" && screen.viewport.fixedSize) { + return screen.viewport.fixedSize; + } + const profile = profileByID.get(state.profile); + if (profile?.device === "tablet") return { width: 834, height: 1194 }; + if (profile?.orientation === "landscape") return { width: 874, height: 402 }; + return { width: 402, height: 874 }; + } + + function captureViewportAspect(screen) { + const size = captureViewportSize(screen); + return size.width / size.height; + } + function captureLabel(extent) { if (extent === "fullContent") return "Full content"; if (extent === "fullContent2D") return "Full content 2D"; @@ -429,8 +486,10 @@ for (const item of manifest.canvas.depthBandFrames) { const band = element("div", "depth-band"); band.dataset.groupId = item.groupID; + band.dataset.entry = String(item.kind !== "unlinked" && item.depth === 0); Object.assign(band.style, rectStyle(item.frame)); - band.append(element("span", "", item.kind === "unlinked" ? "Unlinked" : "Depth " + item.depth)); + const title = item.kind === "unlinked" ? "Unlinked" : (item.depth === 0 ? "Entry" : "Depth " + item.depth); + band.append(element("span", "", title)); stage.append(band); } stage.append(routeCanvas()); @@ -442,13 +501,22 @@ requestAnimationFrame(() => { applyZoom(); - updateSearchVisibility(); - if (state.pendingCanvasAction === "fit-group") { + if (state.pendingCanvasAction === "fit-initial") { + state.pendingCanvasAction = null; + fitFirstGroup(); + } else if (state.pendingCanvasAction === "fit-group") { state.pendingCanvasAction = null; fitCurrentGroup("auto"); } else if (state.pendingCanvasAction === "fit-all") { state.pendingCanvasAction = null; fitAll("auto"); + } else if (state.pendingCanvasPosition) { + const position = state.pendingCanvasPosition; + state.pendingCanvasPosition = null; + viewport.scrollTo(position); + state.canvas.scrollLeft = position.left; + state.canvas.scrollTop = position.top; + updateCanvasNavigation(); } else if (!state.canvas.initialized) { fitFirstGroup(); } else { @@ -456,7 +524,9 @@ updateCanvasNavigation(); } state.canvas.initialized = true; + updateCanvasImageResidency(); }); + installCanvasPinch(viewport); return main; } @@ -472,7 +542,6 @@ card.dataset.screenId = screen.id; card.dataset.groupId = screen.groupID; card.dataset.hidden = String(!matchesFilters(screen)); - card.tabIndex = 0; Object.assign(card.style, rectStyle(screen.frame)); const header = element("header", "card-header"); @@ -485,7 +554,10 @@ const imageButton = element("button", "card-image-button"); imageButton.type = "button"; imageButton.setAttribute("aria-label", "Inspect " + screen.title); + imageButton.dataset.focusKey = "atlas-screen-" + screen.id; const device = element("span", "device-preview"); + device.dataset.captureExtent = variant?.captureExtent || "viewport"; + device.style.setProperty("--viewport-aspect", captureViewportAspect(screen)); device.append(screenImage(screen)); imageButton.append(device); imageButton.addEventListener("click", () => openScreen(screen.id, true)); @@ -514,9 +586,6 @@ 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; } @@ -555,12 +624,13 @@ function listView() { const main = element("main", "list-view"); + main.addEventListener("scroll", scheduleListImageResidencyUpdate, { passive: true }); const intro = element("header", "list-intro"); const copy = element("div", ""); copy.append(element("p", "eyebrow", "Captured catalog")); - copy.append(element("h2", "", manifest.screens.length + " screens, ready to review")); - intro.append(copy, element("p", "list-summary", manifest.groups.length + " groups · " - + manifest.routes.length + " routes · " + manifest.profiles.length + " profiles")); + copy.append(element("h2", "", quantity(manifest.screens.length, "screen") + ", ready to review")); + intro.append(copy, element("p", "list-summary", quantity(manifest.groups.length, "group") + " · " + + quantity(manifest.routes.length, "route") + " · " + quantity(manifest.profiles.length, "profile"))); main.append(intro); for (const group of manifest.groups) { @@ -570,7 +640,7 @@ heading.append( element("span", "group-number", String(group.order + 1).padStart(2, "0")), element("h2", "", group.title), - element("span", "group-count", group.screenIDs.length + " screens"), + element("span", "group-count", quantity(group.screenIDs.length, "screen")), ); section.append(heading); const rows = element("div", "list-rows"); @@ -582,7 +652,10 @@ main.append(section); } main.append(emptyResults()); - requestAnimationFrame(updateSearchVisibility); + requestAnimationFrame(() => { + main.scrollTop = state.list.scrollTop; + updateListImageResidency(); + }); return main; } @@ -595,7 +668,9 @@ const thumbnail = element("button", "list-thumbnail"); thumbnail.type = "button"; - thumbnail.setAttribute("aria-label", "Inspect " + screen.title); + thumbnail.setAttribute("aria-label", "Inspect " + screen.title + ", " + + screen.incomingRouteIDs.length + " incoming and " + screen.outgoingRouteIDs.length + " outgoing routes"); + thumbnail.dataset.focusKey = "atlas-screen-" + screen.id; thumbnail.append(screenImage(screen)); thumbnail.addEventListener("click", () => openScreen(screen.id, true)); @@ -608,6 +683,8 @@ } else { identity.append(element("p", "secondary", variant?.title || "Default")); } + identity.append(element("p", "list-route-summary secondary", screen.incomingRouteIDs.length + " in · " + + screen.outgoingRouteIDs.length + " out")); const facts = element("div", "list-facts"); facts.append( @@ -617,11 +694,13 @@ ); const routes = element("div", "list-routes"); - const quickRouteID = screen.outgoingRouteIDs[0] || screen.incomingRouteIDs[0]; - const quickRoute = routeByID.get(quickRouteID); - if (quickRoute) { - const direction = quickRoute.sourceScreenID === screen.id ? "outgoing" : "incoming"; - routes.append(routeButton(quickRoute, screen, direction)); + for (const routeID of screen.outgoingRouteIDs) { + const route = routeByID.get(routeID); + if (route) routes.append(routeButton(route, screen, "outgoing")); + } + for (const routeID of screen.incomingRouteIDs) { + const route = routeByID.get(routeID); + if (route) routes.append(routeButton(route, screen, "incoming")); } const inspect = iconButton("right", "Inspect " + screen.title, "list-inspect"); @@ -659,6 +738,7 @@ raw.append(icon("external")); const details = iconButton("info", "Capture details", "inspector-action", true); details.id = "details-trigger"; + details.setAttribute("aria-controls", "inspector-details"); details.setAttribute("aria-expanded", String(state.inspectorDetails)); details.addEventListener("click", toggleInspectorDetails); const close = iconButton("close", "Close inspector", "inspector-action close-inspector"); @@ -668,14 +748,25 @@ header.append(heading, actions); const canvas = element("section", "inspection-canvas"); - const extentClass = variant.captureExtent === "fullContent" - || variant.captureExtent === "fullContent2D" ? " full-content" : ""; + const extentClass = variant.captureExtent === "fullContent2D" + ? " full-content full-content-2d" + : (variant.captureExtent === "fullContent" ? " full-content" : ""); const imageFrame = element("div", "inspector-image " + state.inspectorScale + extentClass); imageFrame.id = "inspector-image"; const device = element("div", "inspector-device"); + if (extentClass) { + device.tabIndex = 0; + device.dataset.captureScroller = ""; + device.setAttribute("role", "region"); + device.setAttribute("aria-label", screen.title + " scrollable full-content capture"); + } const fullImage = screenImage(screen, "", true); if (metadata) { + const viewportSize = captureViewportSize(screen); device.style.setProperty("--point-width", metadata.pointWidth + "px"); + device.style.setProperty("--point-height", metadata.pointHeight + "px"); + device.style.setProperty("--viewport-width", viewportSize.width + "px"); + device.style.setProperty("--viewport-height", viewportSize.height + "px"); device.style.setProperty("--aspect-ratio", metadata.pointWidth / metadata.pointHeight); } device.append(fullImage); @@ -711,13 +802,17 @@ dock.setAttribute("aria-label", "Inspector controls"); const previous = iconButton("left", "Previous screen", "icon-button inspector-previous"); previous.setAttribute("aria-keyshortcuts", "ArrowLeft ["); - previous.addEventListener("click", () => openScreen(neighboringScreen(-1)?.id)); + previous.dataset.focusKey = "inspector-previous"; + previous.addEventListener("click", () => openScreen(neighboringScreen(-1)?.id, true)); dock.append(previous, element("span", "dock-separator")); const stateField = element("label", "inspector-field"); stateField.append(element("span", "field-label", "State"), variantSelector(screen, true)); const profileField = element("label", "inspector-field profile-field"); - profileField.append(element("span", "field-label", "Profile"), profileSelect()); + profileField.append( + element("span", "field-label", "Profile"), + profileSelect("", "inspector-profile"), + ); dock.append(stateField, profileField, element("span", "dock-separator")); const scale = element("div", "segmented-control scale-tabs"); @@ -736,7 +831,8 @@ const next = iconButton("right", "Next screen", "icon-button inspector-next"); next.setAttribute("aria-keyshortcuts", "ArrowRight ]"); - next.addEventListener("click", () => openScreen(neighboringScreen(1)?.id)); + next.dataset.focusKey = "inspector-next"; + next.addEventListener("click", () => openScreen(neighboringScreen(1)?.id, true)); dock.append(next); return dock; } @@ -785,11 +881,27 @@ if (trigger) trigger.setAttribute("aria-expanded", String(state.inspectorDetails)); if (state.inspectorDetails) drawer?.querySelector(".details-close")?.focus(); else trigger?.focus(); + updateInspectorDetailsAccessibility(); + fitInspectorImage(); + } + + function updateInspectorDetailsAccessibility() { + const detailsCoverContent = Boolean( + state.inspectorDetails + && window.matchMedia?.("(max-width: 760px), (max-height: 520px)")?.matches, + ); + for (const selector of [".inspection-canvas", ".inspector-dock"]) { + const content = document.querySelector(selector); + if (!content) continue; + content.inert = detailsCoverContent; + content.setAttribute("aria-hidden", String(detailsCoverContent)); + } } function updateInspectorScale() { const image = document.getElementById("inspector-image"); if (image) image.className = image.className.replace(/ fit| actual/g, "") + " " + state.inspectorScale; + fitInspectorImage(); for (const button of document.querySelectorAll(".scale-tabs button")) { button.setAttribute("aria-pressed", String( (button.textContent === "Fit" && state.inspectorScale === "fit") @@ -798,7 +910,37 @@ } } + function fitInspectorImage() { + const frame = document.getElementById("inspector-image"); + const device = frame?.querySelector(".inspector-device"); + const screen = screenByID.get(state.screen); + const variant = screen ? screenVariant(screen) : null; + const metadata = screen ? imageMetadata(screen) : null; + if (!frame || !device || !metadata || !variant) return; + if (state.inspectorScale !== "fit" + || variant.captureExtent === "fullContent" + || variant.captureExtent === "fullContent2D") { + device.style.removeProperty("width"); + device.style.removeProperty("height"); + return; + } + const aspect = metadata.pointWidth / metadata.pointHeight; + const width = Math.min(metadata.pointWidth, frame.clientWidth, frame.clientHeight * aspect); + device.style.width = Math.max(width, 1) + "px"; + device.style.height = Math.max(width / aspect, 1) + "px"; + } + + function observeInspectorSize() { + inspectorResizeObserver?.disconnect(); + inspectorResizeObserver = null; + const frame = document.getElementById("inspector-image"); + if (!frame || typeof ResizeObserver === "undefined") return; + inspectorResizeObserver = new ResizeObserver(fitInspectorImage); + inspectorResizeObserver.observe(frame); + } + function closeInspector() { + state.pendingFocusKey = state.inspectorReturnFocusKey; state.screen = null; state.routeFocus = null; state.inspectorDetails = false; @@ -860,6 +1002,7 @@ const input = element("input", ""); input.id = "command-search"; input.type = "search"; + input.autofocus = true; input.placeholder = "Find a group, screen, state, or route"; input.autocomplete = "off"; input.value = state.commandQuery; @@ -879,7 +1022,6 @@ input.addEventListener("keydown", event => moveCommandFocus(event, results)); surface.append(field, results); dialog.append(surface); - requestAnimationFrame(() => input.focus()); return dialog; } @@ -919,7 +1061,7 @@ return { kind: "Group", title: group.title, - subtitle: group.screenIDs.length + " screens", + subtitle: quantity(group.screenIDs.length, "screen"), search: (group.title + " group").toLocaleLowerCase(), action: () => { state.group = group.id; @@ -1030,7 +1172,10 @@ 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")); + copy.append( + element("strong", "", group.title), + element("small", "", quantity(group.screenIDs.length, "screen")), + ); button.append(element("span", "group-index", String(group.order + 1).padStart(2, "0")), copy, icon("right")); button.addEventListener("click", () => { state.group = group.id; @@ -1052,8 +1197,9 @@ 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 a position to move around the atlas."); + svg.setAttribute("role", "group"); + svg.setAttribute("tabindex", "0"); + svg.setAttribute("aria-label", "Canvas overview. Select a position or use arrow keys to pan 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)); @@ -1072,21 +1218,57 @@ visible.id = "mini-map-viewport"; visible.setAttribute("class", "mini-viewport"); svg.append(visible); - svg.addEventListener("click", event => { - 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; - state.canvas.scrollLeft = x * state.zoom - window.innerWidth / 2; - state.canvas.scrollTop = y * state.zoom - window.innerHeight / 2; - state.canvas.initialized = true; - state.view = "canvas"; - state.panel = null; - renderOrNavigate(); + svg.addEventListener("click", event => moveFromMiniMap(svg, event.clientX, event.clientY)); + svg.addEventListener("keydown", event => { + const offsets = { + ArrowLeft: [-0.4, 0], + ArrowRight: [0.4, 0], + ArrowUp: [0, -0.4], + ArrowDown: [0, 0.4], + }; + const offset = offsets[event.key]; + if (!offset) return; + event.preventDefault(); + const viewport = document.getElementById("canvas-viewport"); + const { width, height } = canvasViewportSize(viewport); + if (viewport) { + viewport.scrollBy({ left: width * offset[0], top: height * offset[1] }); + scheduleCanvasNavigationUpdate(); + return; + } + queueCanvasPosition( + state.canvas.scrollLeft + width * offset[0], + state.canvas.scrollTop + height * offset[1], + ); }); wrapper.append(svg); return wrapper; } + function moveFromMiniMap(svg, clientX, clientY) { + const matrix = svg.getScreenCTM(); + if (!matrix) return; + const point = new DOMPoint(clientX, clientY).matrixTransform(matrix.inverse()); + const viewport = document.getElementById("canvas-viewport"); + const { width, height } = canvasViewportSize(viewport); + queueCanvasPosition(point.x * state.zoom - width / 2, point.y * state.zoom - height / 2); + } + + function queueCanvasPosition(left, top) { + const viewport = document.getElementById("canvas-viewport"); + const { width: viewportWidth, height: viewportHeight } = canvasViewportSize(viewport); + const maximumLeft = Math.max(manifest.canvas.size.width * state.zoom - viewportWidth, 0); + const maximumTop = Math.max(manifest.canvas.size.height * state.zoom - viewportHeight, 0); + state.pendingCanvasPosition = { + left: Math.min(Math.max(left, 0), maximumLeft), + top: Math.min(Math.max(top, 0), maximumTop), + }; + state.canvas.initialized = true; + state.view = "canvas"; + state.panel = null; + renderOrNavigate(); + } + function rectAttributes(rect) { return { x: rect.x, y: rect.y, width: rect.width, height: rect.height }; } @@ -1123,18 +1305,31 @@ clear.type = "button"; clear.addEventListener("click", () => { state.filters = { group: "all", extent: "all", routes: "all" }; - state.panel = null; - render(); + showFilteredResults(); }); const done = element("button", "primary-button", "Show results"); done.type = "button"; - done.addEventListener("click", closePanel); + done.addEventListener("click", showFilteredResults); footer.append(clear, done); surface.append(footer); dialog.append(surface); return dialog; } + function showFilteredResults() { + const matchingScreens = manifest.screens.filter(matchesFilters); + if (state.view === "canvas" && matchingScreens.length > 0) { + const currentGroupHasMatch = matchingScreens.some(screen => screen.groupID === state.group); + state.group = currentGroupHasMatch ? state.group : matchingScreens[0].groupID; + state.pendingCanvasAction = "fit-group"; + } else if (state.view === "list") { + const list = document.querySelector(".list-view"); + if (list) list.scrollTop = 0; + state.list.scrollTop = 0; + } + closePanel(); + } + function filterSelect(labelText, value, options, onChange) { const label = element("label", "filter-field"); label.append(element("span", "field-label", labelText)); @@ -1184,6 +1379,7 @@ clear.addEventListener("click", () => { state.filters = { group: "all", extent: "all", routes: "all" }; render(); + requestAnimationFrame(() => document.getElementById("filter-trigger")?.focus()); }); empty.append(clear); return empty; @@ -1206,7 +1402,7 @@ 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); + route.dataset.hidden = String(!sourceVisible || !destinationVisible); } const count = document.getElementById("result-count"); if (count) count.textContent = visible.size + " / " + manifest.screens.length; @@ -1214,6 +1410,8 @@ if (empty) empty.hidden = visible.size !== 0; updateFilterStatus(); updateRouteFocus(); + if (state.view === "canvas") updateCanvasImageResidency(); + else updateListImageResidency(); } function updateFilterStatus() { @@ -1256,11 +1454,170 @@ } } + function residentScreenIDs(candidates) { + const result = new Set(); + let pixels = 0; + for (const candidate of candidates) { + if (result.size >= maximumResidentImageCount) break; + const metadata = imageMetadata(candidate.screen); + const imagePixels = metadata ? metadata.pixelWidth * metadata.pixelHeight : 0; + if (result.size > 0 && pixels + imagePixels > maximumResidentPixelCount) continue; + result.add(candidate.screen.id); + pixels += imagePixels; + } + return result; + } + + function updateCanvasImageResidency() { + const viewport = document.getElementById("canvas-viewport"); + if (!viewport || state.view !== "canvas") return; + if (state.screen) { + updateResidentImages(".screen-card", new Set()); + return; + } + const visibleRect = { + left: viewport.scrollLeft / state.zoom, + top: viewport.scrollTop / state.zoom, + right: (viewport.scrollLeft + viewport.clientWidth) / state.zoom, + bottom: (viewport.scrollTop + viewport.clientHeight) / state.zoom, + }; + const center = { + x: (visibleRect.left + visibleRect.right) / 2, + y: (visibleRect.top + visibleRect.bottom) / 2, + }; + const candidates = manifest.screens + .filter(screen => matchesFilters(screen) && rectIntersects(screen.frame, visibleRect)) + .map(screen => ({ + screen, + distance: squaredDistance(screen.frame.x + screen.frame.width / 2, + screen.frame.y + screen.frame.height / 2, center.x, center.y), + })) + .sort((lhs, rhs) => lhs.distance - rhs.distance + || lhs.screen.frame.y - rhs.screen.frame.y + || lhs.screen.frame.x - rhs.screen.frame.x); + updateResidentImages(".screen-card", residentScreenIDs(candidates)); + } + + let listResidencyFrame = null; + function scheduleListImageResidencyUpdate() { + if (listResidencyFrame !== null) return; + listResidencyFrame = requestAnimationFrame(() => { + listResidencyFrame = null; + updateListImageResidency(); + }); + } + + function updateListImageResidency() { + const list = document.querySelector(".list-view"); + if (!list || state.view !== "list") return; + if (state.screen) { + updateResidentImages(".list-row", new Set()); + return; + } + const viewport = list.getBoundingClientRect(); + const centerY = (viewport.top + viewport.bottom) / 2; + const candidates = []; + for (const row of document.querySelectorAll(".list-row")) { + const screen = screenByID.get(row.dataset.screenId); + if (!screen || !matchesFilters(screen)) continue; + const frame = row.getBoundingClientRect(); + if (frame.bottom < viewport.top - 120 || frame.top > viewport.bottom + 120) continue; + candidates.push({ screen, distance: Math.abs((frame.top + frame.bottom) / 2 - centerY) }); + } + candidates.sort((lhs, rhs) => lhs.distance - rhs.distance || lhs.screen.screenOrder - rhs.screen.screenOrder); + updateResidentImages(".list-row", residentScreenIDs(candidates)); + } + + function updateResidentImages(containerSelector, residentIDs) { + for (const container of document.querySelectorAll(containerSelector)) { + const image = container.querySelector("img[data-src]"); + if (!image) continue; + if (residentIDs.has(container.dataset.screenId)) { + if (image.getAttribute("src") !== image.dataset.src) image.src = image.dataset.src; + } else { + image.removeAttribute("src"); + } + } + } + + function rectIntersects(frame, visibleRect) { + return frame.x < visibleRect.right && frame.x + frame.width > visibleRect.left + && frame.y < visibleRect.bottom && frame.y + frame.height > visibleRect.top; + } + + function squaredDistance(x1, y1, x2, y2) { + const x = x1 - x2; + const y = y1 - y2; + return x * x + y * y; + } + + function installCanvasPinch(viewport) { + let gesture = null; + viewport.addEventListener("touchstart", event => { + if (event.touches.length !== 2) return; + const midpoint = touchMidpoint(event.touches); + const bounds = viewport.getBoundingClientRect(); + gesture = { + distance: touchDistance(event.touches), + zoom: state.zoom, + canvasX: (viewport.scrollLeft + midpoint.x - bounds.left) / state.zoom, + canvasY: (viewport.scrollTop + midpoint.y - bounds.top) / state.zoom, + }; + }, { passive: true }); + viewport.addEventListener("touchmove", event => { + if (!gesture || event.touches.length !== 2) return; + event.preventDefault(); + const midpoint = touchMidpoint(event.touches); + const bounds = viewport.getBoundingClientRect(); + const nextZoom = Math.max(0.1, Math.min(1.5, + gesture.zoom * touchDistance(event.touches) / Math.max(gesture.distance, 1))); + state.zoom = nextZoom; + applyZoom(); + viewport.scrollTo({ + left: gesture.canvasX * nextZoom - (midpoint.x - bounds.left), + top: gesture.canvasY * nextZoom - (midpoint.y - bounds.top), + }); + captureCanvasPosition(); + scheduleCanvasNavigationUpdate(); + }, { passive: false }); + const endGesture = event => { + if (event.touches.length < 2) gesture = null; + }; + viewport.addEventListener("touchend", endGesture, { passive: true }); + viewport.addEventListener("touchcancel", endGesture, { passive: true }); + } + + function touchMidpoint(touches) { + return { + x: (touches[0].clientX + touches[1].clientX) / 2, + y: (touches[0].clientY + touches[1].clientY) / 2, + }; + } + + function touchDistance(touches) { + return Math.hypot( + touches[0].clientX - touches[1].clientX, + touches[0].clientY - touches[1].clientY, + ); + } + function captureCanvasPosition() { const viewport = document.getElementById("canvas-viewport"); if (!viewport) return; state.canvas.scrollLeft = viewport.scrollLeft; state.canvas.scrollTop = viewport.scrollTop; + state.canvas.viewportWidth = viewport.clientWidth; + state.canvas.viewportHeight = viewport.clientHeight; + } + + function captureListPosition() { + const list = document.querySelector(".list-view"); + if (list) state.list.scrollTop = list.scrollTop; + } + + function captureViewPosition() { + captureCanvasPosition(); + captureListPosition(); } function applyZoom() { @@ -1291,6 +1648,7 @@ }); captureCanvasPosition(); } + updateCanvasImageResidency(); } function fitFrame(frame, behavior = "smooth") { @@ -1303,7 +1661,8 @@ applyZoom(); const left = (frame.x + frame.width / 2) * state.zoom - viewport.clientWidth / 2; const top = (frame.y + frame.height / 2) * state.zoom - viewport.clientHeight / 2; - viewport.scrollTo({ left, top, behavior }); + const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches; + viewport.scrollTo({ left, top, behavior: prefersReducedMotion ? "auto" : behavior }); state.canvas.scrollLeft = Math.max(0, left); state.canvas.scrollTop = Math.max(0, top); } @@ -1319,8 +1678,19 @@ } function fitFirstGroup() { - const first = manifest.canvas.groupFrames[0]; - if (first) fitFrame(first.frame, "auto"); + const viewport = document.getElementById("canvas-viewport"); + if (!viewport) return; + const initialWidth = manifest.canvas.initialFitSize?.width + || manifest.canvas.groupFrames[0]?.frame.width; + if (!initialWidth) return; + const framingInset = 16; + const horizontal = Math.max(viewport.clientWidth - framingInset * 2, 1) / initialWidth; + state.zoom = Math.max(0.15, Math.min(1, horizontal)); + applyZoom(); + viewport.scrollTo({ left: 0, top: 0, behavior: "auto" }); + state.canvas.scrollLeft = 0; + state.canvas.scrollTop = 0; + updateCanvasNavigation(); } let canvasNavigationFrame = null; @@ -1330,6 +1700,7 @@ canvasNavigationFrame = null; captureCanvasPosition(); updateCanvasNavigation(); + updateCanvasImageResidency(); }); } @@ -1378,15 +1749,49 @@ 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); + if (!rect) return; + const { width, height } = canvasViewportSize(viewport); + const scrollLeft = viewport?.scrollLeft ?? state.canvas.scrollLeft; + const scrollTop = viewport?.scrollTop ?? state.canvas.scrollTop; + rect.setAttribute("x", scrollLeft / state.zoom); + rect.setAttribute("y", scrollTop / state.zoom); + rect.setAttribute("width", width / state.zoom); + rect.setAttribute("height", height / state.zoom); + } + + function canvasViewportSize(viewport) { + return { + width: viewport?.clientWidth || state.canvas.viewportWidth || window.innerWidth, + height: viewport?.clientHeight || state.canvas.viewportHeight || window.innerHeight, + }; + } + + function visibleFocusTarget(focusKey) { + if (!focusKey) return null; + const target = document.querySelector('[data-focus-key="' + CSS.escape(focusKey) + '"]'); + if (!target || target.closest('[data-hidden="true"], [aria-hidden="true"]')) return null; + const style = window.getComputedStyle(target); + return style.display === "none" || style.visibility === "hidden" ? null : target; } function render() { - captureCanvasPosition(); + if (!state.pendingCanvasPosition) captureViewPosition(); + if (canvasNavigationFrame !== null) { + cancelAnimationFrame(canvasNavigationFrame); + canvasNavigationFrame = null; + } + if (listResidencyFrame !== null) { + cancelAnimationFrame(listResidencyFrame); + listResidencyFrame = null; + } + inspectorResizeObserver?.disconnect(); + inspectorResizeObserver = null; + const activeElement = document.activeElement; + const activeFocusKey = activeElement?.dataset.focusKey; + const canRestoreActiveFocus = !state.panel + && (!state.screen || activeElement?.closest(".inspector")); + const focusKey = state.pendingFocusKey || (canRestoreActiveFocus ? activeFocusKey : null); + state.pendingFocusKey = null; const app = element("div", "application"); app.append(appHeader()); app.append(state.view === "canvas" ? canvasView() : listView()); @@ -1396,7 +1801,12 @@ const inspection = inspector(); if (inspection) { root.append(inspection); - requestAnimationFrame(() => inspection.showModal()); + requestAnimationFrame(() => { + inspection.showModal(); + updateInspectorDetailsAccessibility(); + fitInspectorImage(); + observeInspectorSize(); + }); } else { const panel = activePanel(); if (panel) { @@ -1407,34 +1817,47 @@ }); } } - requestAnimationFrame(updateSearchVisibility); + requestAnimationFrame(() => { + updateSearchVisibility(); + if (focusKey) { + (visibleFocusTarget(focusKey) || visibleFocusTarget("filters-control"))?.focus(); + } + }); } window.addEventListener("hashchange", () => { - captureCanvasPosition(); + if (!state.pendingCanvasPosition) captureViewPosition(); parseHash(); render(); }); - window.addEventListener("resize", scheduleCanvasNavigationUpdate); + window.addEventListener("resize", () => { + if (state.screen) requestAnimationFrame(() => { + updateInspectorDetailsAccessibility(); + fitInspectorImage(); + }); + if (state.view === "canvas") scheduleCanvasNavigationUpdate(); + else scheduleListImageResidencyUpdate(); + }); window.addEventListener("keydown", event => { const target = event.target; const isEditing = target instanceof HTMLInputElement || target instanceof HTMLSelectElement || target instanceof HTMLTextAreaElement || target?.isContentEditable; + const isCaptureScroller = target?.closest?.("[data-capture-scroller]"); const commandSearch = (event.metaKey || event.ctrlKey) && event.key.toLocaleLowerCase() === "k"; if (commandSearch || (event.key === "/" && !isEditing && !event.metaKey && !event.ctrlKey && !event.altKey)) { event.preventDefault(); if (!state.screen) openPanel("search", "search-trigger"); return; } - if (isEditing || event.metaKey || event.ctrlKey || event.altKey || state.panel) return; + if (isEditing || isCaptureScroller || event.metaKey || event.ctrlKey || event.altKey || state.panel) return; if (state.screen && (event.key === "ArrowLeft" || event.key === "[")) { event.preventDefault(); - openScreen(neighboringScreen(-1)?.id); + openScreen(neighboringScreen(-1)?.id, true); } else if (state.screen && (event.key === "ArrowRight" || event.key === "]")) { event.preventDefault(); - openScreen(neighboringScreen(1)?.id); + openScreen(neighboringScreen(1)?.id, true); } else if (state.screen && event.key.toLocaleLowerCase() === "i") { event.preventDefault(); toggleInspectorDetails(); diff --git a/Shared/Flyover/Web/assets/styles.css b/Shared/Flyover/Web/assets/styles.css index 667a2dacf..0bdcb0e42 100644 --- a/Shared/Flyover/Web/assets/styles.css +++ b/Shared/Flyover/Web/assets/styles.css @@ -3,15 +3,15 @@ --accent: #3157e8; --accent-strong: #2444c4; --accent-soft: #e8edff; - --modal: #c73adb; + --modal: #a12aad; --background: #eef0f5; --canvas: #e9ebf1; --surface: rgba(255, 255, 255, 0.88); --surface-solid: #ffffff; --surface-muted: #f5f6f9; --text: #151721; - --secondary: #6f7482; - --tertiary: #9ca1ad; + --secondary: #5d6270; + --tertiary: #686d7b; --line: rgba(33, 37, 48, 0.12); --line-strong: rgba(33, 37, 48, 0.2); --shadow-small: 0 1px 2px rgba(20, 24, 36, 0.08), 0 8px 24px rgba(20, 24, 36, 0.08); @@ -29,6 +29,7 @@ --accent: #7891ff; --accent-strong: #9eafff; --accent-soft: #242b4d; + --modal: #e28bee; --background: #15161a; --canvas: #111216; --surface: rgba(38, 39, 45, 0.88); @@ -36,7 +37,7 @@ --surface-muted: #1c1d22; --text: #f4f4f7; --secondary: #a9acb6; - --tertiary: #747782; + --tertiary: #9a9eaa; --line: rgba(255, 255, 255, 0.11); --line-strong: rgba(255, 255, 255, 0.2); --shadow-small: 0 1px 2px rgba(0, 0, 0, 0.25), 0 10px 30px rgba(0, 0, 0, 0.22); @@ -440,6 +441,10 @@ kbd, pointer-events: none; } +.depth-band[data-entry="true"] { + border-left: 0; +} + .depth-band span { position: absolute; top: 16px; @@ -555,6 +560,26 @@ kbd, width: 100%; height: 100%; object-fit: contain; + transition: opacity 120ms ease; +} + +.device-preview[data-capture-extent="fullContent"], +.device-preview[data-capture-extent="fullContent2D"] { + width: auto; + max-width: 100%; + aspect-ratio: var(--viewport-aspect); + place-items: start center; +} + +.device-preview[data-capture-extent="fullContent"] img, +.device-preview[data-capture-extent="fullContent2D"] img { + width: 100%; + height: auto; + object-fit: fill; +} + +img[data-src]:not([src]) { + opacity: 0; } .card-image-meta { @@ -940,6 +965,12 @@ kbd, object-fit: contain; } +.list-thumbnail img[data-capture-extent="fullContent"], +.list-thumbnail img[data-capture-extent="fullContent2D"] { + object-fit: cover; + object-position: top center; +} + .list-identity { display: grid; gap: 9px; @@ -984,7 +1015,16 @@ kbd, } .list-routes { + display: flex; + gap: 6px; min-width: 0; + overflow-x: auto; + padding: 4px 0; + scrollbar-width: thin; +} + +.list-route-summary { + display: none; } .route-chip { @@ -1001,6 +1041,7 @@ kbd, font-size: 11px; font-weight: 650; text-align: left; + flex: 0 0 auto; } .route-chip span { @@ -1509,6 +1550,9 @@ a.inspector-action { .inspector-device { --point-width: 402px; + --point-height: 874px; + --viewport-width: 402px; + --viewport-height: 874px; --aspect-ratio: 0.46; display: grid; flex: 0 0 auto; @@ -1525,14 +1569,21 @@ a.inspector-action { } .inspector-image.fit:not(.full-content) .inspector-device { - width: min(var(--point-width), calc((100dvh - 214px) * var(--aspect-ratio)), 100%); - aspect-ratio: var(--aspect-ratio); + width: 100%; + height: 100%; + overflow: visible; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; } .inspector-image.fit:not(.full-content) img { width: 100%; height: 100%; - object-fit: fill; + border-radius: 18px; + object-fit: contain; + filter: drop-shadow(0 24px 48px rgba(0, 0, 0, 0.42)); } .inspector-image.actual { @@ -1558,6 +1609,17 @@ a.inspector-action { max-height: none; } +.inspector-image.full-content-2d.fit .inspector-device { + width: min(var(--viewport-width), 100%); + height: min(var(--viewport-height), 100%); + place-items: start; +} + +.inspector-image.full-content-2d.fit img { + width: var(--point-width); + height: var(--point-height); +} + .image-caption { position: absolute; bottom: 88px; @@ -1943,6 +2005,11 @@ a.inspector-action { max-width: 100%; } + .list-route-summary { + display: block; + font-size: 11px; + } + .command-surface { width: calc(100vw - 20px); max-height: calc(100dvh - 32px); @@ -2089,6 +2156,82 @@ a.inspector-action { } } +@media (pointer: coarse), (max-width: 760px) { + .header-profile select, + .header-button, + .bottom-dock button, + .bottom-dock .segment, + .bottom-dock .zoom-controls .icon-button, + .list-state select, + .card-state-select, + .list-inspect, + .card-inspect, + .route-chip, + .escape-key, + .panel-close, + .details-close, + .primary-button, + .secondary-button, + .inspector-action, + .inspector-dock button, + .inspector-dock .segment, + .inspector-dock .inspector-field select { + min-width: 44px; + min-height: 44px; + } + + .header-profile select { + height: 44px; + } +} + +@media (max-height: 520px) { + .inspector-header { + top: 6px; + right: 8px; + left: 8px; + min-height: 50px; + border-radius: 14px; + padding: 5px 7px 5px 12px; + } + + .inspection-canvas, + .inspector[data-details="true"] .inspection-canvas { + margin-right: 0; + padding: 64px 12px 70px; + } + + .image-caption { + display: none; + } + + .inspector-dock, + .inspector[data-details="true"] .inspector-dock { + right: 8px; + bottom: max(6px, env(safe-area-inset-bottom)); + left: 8px; + min-height: 54px; + max-width: none; + margin-left: 0; + padding: 4px; + transform: none; + } + + .inspector-details { + z-index: 20; + top: 58px; + width: min(380px, 100%); + border-top: 1px solid rgba(255, 255, 255, 0.12); + border-left: 0; + border-radius: 20px 20px 0 0; + transform: translateY(100%); + } + + .inspector[data-details="true"] .inspector-details { + transform: translateY(0); + } +} + @media (max-width: 440px) { .route-legend { display: none; diff --git a/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift b/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift index 0afd5ba0f..1eb0ca22c 100644 --- a/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift +++ b/Shared/SnapshotKitTesting/Sources/AssertSnapshots.swift @@ -47,6 +47,7 @@ public func assertSnapshots( return } for snapshotCase in snapshots { + guard Task.isCancelled == false else { return } await assertSnapshots( of: snapshotCase.content, named: snapshotCase.name, @@ -119,6 +120,7 @@ public func assertSnapshots( let isDiffReportingEnabled = SnapshotDiffReporting.isEnabledByEnvironment for configuration in configurations { + guard Task.isCancelled == false else { return } let hostingController = makeHostingController(for: view, configuration: configuration) let sizing: SnapshotSizing = switch configuration.device.size { case .fixed: diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotCaptureLock.swift b/Shared/SnapshotKitTesting/Sources/SnapshotCaptureLock.swift index 21f9e4a87..bc9722733 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotCaptureLock.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotCaptureLock.swift @@ -12,10 +12,10 @@ import Foundation /// starts while another is in flight parks here and runs when the first /// finishes, in FIFO order. /// -/// Waiting is not cancellation-aware by design: captures are bounded (a few -/// seconds), and a cancelled test's capture simply runs to completion when its -/// turn comes — the extra work is preferable to a cancellation path that could -/// leak the lock. +/// Waiting is not cancellation-aware by design. A cancelled waiter stays in the +/// FIFO so its continuation cannot strand the lock. When its turn arrives, the +/// rendering pipeline observes cancellation before it hosts content, then this +/// type releases the lock normally. /// /// Re-entering from a task that already holds the lock (an `onReadyToSnapshot` /// hook rendering another snapshot) would deadlock, so it traps as a diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift b/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift index f9e17ca5b..4b40b4d12 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotImageRendering.swift @@ -225,7 +225,8 @@ public func renderSnapshotImage( timing: SnapshotCaptureTiming, ) async throws -> SnapshotCapture { try await SnapshotCaptureLock.withLock { - try await renderSnapshotImageLocked( + try Task.checkCancellation() + return try await renderSnapshotImageLocked( of: viewController, named: name, sizing: sizing, @@ -333,6 +334,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 { + try Task.checkCancellation() try await throwIfUnsettled( timing.measure(.hook) { await onReadyToSnapshot() @@ -396,6 +398,7 @@ private func renderSnapshotImageLocked( } } + try Task.checkCancellation() viewController.view.hideTextInputCursors() timing.measure(.drain) { drainInFlightAnimations() } diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotMeasurementHook.swift b/Shared/SnapshotKitTesting/Sources/SnapshotMeasurementHook.swift index 862432d02..0f592765d 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotMeasurementHook.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotMeasurementHook.swift @@ -15,6 +15,7 @@ private enum SnapshotMeasurementHookResult { maximumDuration: TimeInterval, hook: @MainActor @escaping () async -> Void, ) async throws { + try Task.checkCancellation() let result = await withTaskGroup(of: SnapshotMeasurementHookResult.self) { group in group.addTask { await hook() diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotPNG.swift b/Shared/SnapshotKitTesting/Sources/SnapshotPNG.swift index 0516ce8af..71a822eab 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotPNG.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotPNG.swift @@ -1,5 +1,6 @@ import SnapshotKit import SwiftUI +import TestHostSupport import UIKit /// PNG bytes and dimensions from a hosted capture without a reference compare. @@ -30,6 +31,7 @@ public func captureSnapshotPNG( settle: SnapshotSettle, onReadyToSnapshot: (@MainActor () async -> Void)?, ) async throws -> SnapshotPNG { + try waitFor { hostKeyWindow()?.rootViewController != nil } let controller = makeHostingController(for: view, configuration: configuration) let timeoutPolicy = try SnapshotSettleTimeoutPolicy.fromEnvironment() let capture = try await renderSnapshotCapture( diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift b/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift index 971b23df8..7497c7e74 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotRenderingSupport.swift @@ -91,6 +91,7 @@ func settleForCapture( ) case .immediate: await Task.yield() + guard Task.isCancelled == false else { return .cancelled } CATransaction.performWithoutAnimation(view.layoutIfNeeded) return .skipped } diff --git a/Shared/SnapshotKitTesting/Tests/AssertSnapshotsTests.swift b/Shared/SnapshotKitTesting/Tests/AssertSnapshotsTests.swift index 188726fa6..09c36a8fd 100644 --- a/Shared/SnapshotKitTesting/Tests/AssertSnapshotsTests.swift +++ b/Shared/SnapshotKitTesting/Tests/AssertSnapshotsTests.swift @@ -31,9 +31,78 @@ struct AssertSnapshotsTests { assertion.cancel() await assertion.value } + + @Test func cancelledProviderDoesNotBuildItsNextCase() async throws { + try waitFor { hostKeyWindow() != nil } + let probe = MeasurementHookCancellationProbe() + let hooks = ProviderCancellationHooks( + waitForCancellation: { + probe.didStart = true + while Task.isCancelled == false { + await Task.yield() + } + }, + didBuildSecondCase: { + probe.didBuildSecondCase = true + }, + ) + + await ProviderCancellationContext.$hooks.withValue(hooks) { + let assertion = Task { @MainActor in + await assertSnapshots(of: CancellationSnapshotProvider.self) + } + while probe.didStart == false { + await Task.yield() + } + assertion.cancel() + await assertion.value + } + + #expect(probe.didBuildSecondCase == false) + } } @MainActor private final class MeasurementHookCancellationProbe { var didStart = false + var didBuildSecondCase = false +} + +private struct ProviderCancellationHooks { + let waitForCancellation: @MainActor @Sendable () async -> Void + let didBuildSecondCase: @MainActor @Sendable () -> Void +} + +private enum ProviderCancellationContext { + @TaskLocal static var hooks: ProviderCancellationHooks? +} + +private struct CancellationSnapshotProvider: SnapshotProviding { + @MainActor static var snapshots: [SnapshotCase] { + let hooks = ProviderCancellationContext.hooks + SnapshotCase( + name: "cancelled-first-case", + configurations: [ + SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "intrinsic", + size: .intrinsic(maxWidth: 100), + ), + ), + ], + measurementReadiness: .immediate, + onReadyToMeasure: hooks?.waitForCancellation, + settle: .immediate, + ) { + Color.red.frame(height: 40) + } + SnapshotCase( + name: "must-not-build", + configurations: [SnapshotConfiguration()], + settle: .immediate, + ) { + let _ = hooks?.didBuildSecondCase() + Color.blue + } + } } diff --git a/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift b/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift index dc5070142..74858e356 100644 --- a/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift +++ b/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift @@ -1,13 +1,11 @@ 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", @@ -34,7 +32,6 @@ struct SnapshotPNGTests { } @Test func runsReadinessHooksThroughTheSharedPipeline() async throws { - try waitFor { hostKeyWindow() != nil } var measurementHookRan = false var finalHookRan = false let configuration = SnapshotConfiguration( @@ -61,7 +58,6 @@ struct SnapshotPNGTests { } @Test func capturesFullHeightContent() async throws { - try waitFor { hostKeyWindow() != nil } let content = ScrollView { VStack(spacing: 0) { Color.red.frame(height: 100) @@ -88,7 +84,6 @@ struct SnapshotPNGTests { } @Test func capturesTwoAxisFullContent() async throws { - try waitFor { hostKeyWindow() != nil } let content = ScrollView([.horizontal, .vertical]) { Color.green.frame(width: 180, height: 160) } @@ -113,7 +108,6 @@ struct SnapshotPNGTests { } @Test func capturesAccessibilityAnnotations() async throws { - try waitFor { hostKeyWindow() != nil } let configuration = SnapshotConfiguration( device: SnapshotConfiguration.Frame( name: "accessibility-probe", @@ -139,8 +133,73 @@ struct SnapshotPNGTests { #expect(png.pointSize.height >= 160) } + @Test func cancelledQueuedCaptureDoesNotRunMeasurementHook() async throws { + let probe = QueuedCaptureCancellationProbe() + let fixedConfiguration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "queued-cancellation-holder", + size: .fixed(CGSize(width: 80, height: 60)), + ), + ) + let firstCapture = Task { @MainActor in + try await captureSnapshotPNG( + of: Color.red, + configuration: fixedConfiguration, + named: "queued-cancellation-holder", + sizing: .fixed, + safeAreaInsets: .zero, + measurementReadiness: .sameAsCapture, + onReadyToMeasure: nil, + settle: .immediate, + onReadyToSnapshot: { + probe.firstCaptureStarted = true + while probe.canFinishFirstCapture == false { + await Task.yield() + } + }, + ) + } + while probe.firstCaptureStarted == false { + await Task.yield() + } + + let intrinsicConfiguration = SnapshotConfiguration( + device: SnapshotConfiguration.Frame( + name: "queued-cancellation-waiter", + size: .intrinsic(maxWidth: 80), + ), + ) + let queuedCapture = Task { @MainActor in + probe.queuedCaptureStarted = true + return try await captureSnapshotPNG( + of: Color.blue.frame(height: 60).onAppear { + probe.queuedContentAppeared = true + }, + configuration: intrinsicConfiguration, + named: "queued-cancellation-waiter", + sizing: .intrinsic(width: 80, minimumHeight: 0), + safeAreaInsets: .zero, + measurementReadiness: .immediate, + onReadyToMeasure: { probe.measurementHookRan = true }, + settle: .immediate, + onReadyToSnapshot: nil, + ) + } + while probe.queuedCaptureStarted == false { + await Task.yield() + } + + queuedCapture.cancel() + probe.canFinishFirstCapture = true + _ = try await firstCapture.value + await #expect(throws: CancellationError.self) { + try await queuedCapture.value + } + #expect(probe.measurementHookRan == false) + #expect(probe.queuedContentAppeared == false) + } + @Test func propagatesSettleFailures() async throws { - try waitFor { hostKeyWindow() != nil } let configuration = SnapshotConfiguration( device: SnapshotConfiguration.Frame( name: "moving-probe", @@ -170,6 +229,15 @@ struct SnapshotPNGTests { } } +@MainActor +private final class QueuedCaptureCancellationProbe { + var firstCaptureStarted = false + var canFinishFirstCapture = false + var queuedCaptureStarted = false + var measurementHookRan = false + var queuedContentAppeared = false +} + private struct NonSettlingPNGView: View { @State private var isRed = false diff --git a/Where/WhereUI/SnapshotTests/WhereFlyoverWebExportTests.swift b/Where/WhereUI/SnapshotTests/WhereFlyoverWebExportTests.swift index 7545dfb93..8f0d0d447 100644 --- a/Where/WhereUI/SnapshotTests/WhereFlyoverWebExportTests.swift +++ b/Where/WhereUI/SnapshotTests/WhereFlyoverWebExportTests.swift @@ -1,6 +1,7 @@ import Flyover import Foundation import SnapshotKitTesting +import SwiftUI import Testing import UIKit @testable import WhereUI @@ -12,7 +13,7 @@ struct WhereFlyoverWebExportTests { return } - let world = try await WhereFlyoverWorld.build() + let world = try await WhereFlyoverWorld.buildForWebExport() let catalog = WhereFlyoverCatalog.make(world: world) let exporter = FlyoverWebExporter( catalog: catalog, @@ -24,31 +25,281 @@ struct WhereFlyoverWebExportTests { 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, - ) - } + capture: capture, + ) print( "FLYOVER_EXPORT_COMPLETE \(summary.screenCount) screens " + "\(summary.stateCount) states \(summary.imageCount) images " + "\(summary.outputByteCount) bytes", ) } + + @Test func exportsHostedSmokeAtlas() async throws { + let output = FileManager.default.temporaryDirectory + .appending(path: "WhereFlyoverHostedSmoke-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: output, withIntermediateDirectories: true) + defer { + do { + try FileManager.default.removeItem(at: output) + } catch { + Issue.record(error) + } + } + try copyWebShell(to: output) + + let catalog = FlyoverCatalog( + groups: [ + FlyoverGroup( + id: FlyoverGroupID("primary"), + title: "Primary", + root: SmokeScreen.root, + screens: [ + FlyoverScreen( + id: SmokeScreen.root, + title: "Root", + variants: [ + FlyoverVariant( + id: FlyoverVariantID("viewport"), + title: "Viewport", + ) { + Text("Root") + }, + ], + ), + ], + ), + FlyoverGroup( + id: FlyoverGroupID("details"), + title: "Details", + root: SmokeScreen.details, + screens: [ + FlyoverScreen( + id: SmokeScreen.details, + title: "Details", + variants: [ + FlyoverVariant( + id: FlyoverVariantID("full-content"), + title: "Full Content", + exportPolicy: FlyoverExportPolicy( + captureExtent: .fullContent, + measurementReadiness: .immediate, + settle: .immediate, + onReadyToMeasure: nil, + onReadyToSnapshot: nil, + ), + ) { + ScrollView { + VStack(spacing: 0) { + Color.red.frame(height: 500) + Color.blue.frame(height: 500) + } + } + }, + ], + ), + ], + ), + ], + transitions: [ + FlyoverTransition(from: .root, to: .details, kind: .push), + FlyoverTransition(from: .details, to: .root, kind: .modal), + ], + ) + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "smoke", + title: "Hosted Smoke", + screenIdentifier: \SmokeScreen.rawValue, + ) + let summary = try await exporter.export( + to: output, + profiles: [.phoneLight, .phoneDark], + build: FlyoverExportBuild( + commit: "smoke", + dirty: false, + branch: nil, + generatedAt: "2026-09-03T00:00:00Z", + xcodeVersion: "Tests", + simulatorDevice: "StuffTestHost", + simulatorOS: "Tests", + ), + capture: capture, + ) + + let data = try Data(contentsOf: output.appending(path: "manifest.json")) + let manifest = try JSONDecoder().decode(FlyoverWebManifest.self, from: data) + #expect(manifest.schemaVersion == 1) + #expect(summary.screenCount == 2) + #expect(summary.stateCount == 2) + #expect(summary.profileCount == 2) + #expect(summary.imageCount == 4) + #expect(summary.outputByteCount > 0) + #expect(summary.outputDirectory == output) + #expect(manifest.profiles.map(\.id) == ["phone-light", "phone-dark"]) + #expect(manifest.groups.map { group in + [group.id, group.rootScreenID] + group.screenIDs + } == [ + ["primary", "root", "root"], + ["details", "details", "details"], + ]) + + let root = try #require(manifest.screens.first { $0.id == "root" }) + let details = try #require(manifest.screens.first { $0.id == "details" }) + let rootVariant = try #require(root.variants.first) + let detailsVariant = try #require(details.variants.first) + #expect(rootVariant.id == "viewport") + #expect(rootVariant.captureExtent == "viewport") + #expect(rootVariant.imagesByProfile == [ + "phone-light": "images/screen-0001/variant-0001/phone-light.png", + "phone-dark": "images/screen-0001/variant-0001/phone-dark.png", + ]) + #expect(detailsVariant.id == "full-content") + #expect(detailsVariant.captureExtent == "fullContent") + #expect(detailsVariant.imagesByProfile == [ + "phone-light": "images/screen-0002/variant-0001/phone-light.png", + "phone-dark": "images/screen-0002/variant-0001/phone-dark.png", + ]) + + #expect(manifest.routes.map { route in + [ + route.id, + route.sourceScreenID, + route.destinationScreenID, + route.kind, + route.label ?? "", + ] + } == [ + ["route-0001", "root", "details", "push", ""], + ["route-0002", "details", "root", "modal", ""], + ]) + #expect(root.incomingRouteIDs == ["route-0002"]) + #expect(root.outgoingRouteIDs == ["route-0001"]) + #expect(details.incomingRouteIDs == ["route-0001"]) + #expect(details.outgoingRouteIDs == ["route-0002"]) + #expect(manifest.canvas.connectors.map(\.routeID) == ["route-0001", "route-0002"]) + + #expect(manifest.images.count == 4) + #expect(manifest.images.map { image in + [ + image.screenID, + image.variantID, + image.profileID, + image.relativePath, + image.captureExtent, + ] + } == [ + [ + "root", + "viewport", + "phone-light", + "images/screen-0001/variant-0001/phone-light.png", + "viewport", + ], + [ + "root", + "viewport", + "phone-dark", + "images/screen-0001/variant-0001/phone-dark.png", + "viewport", + ], + [ + "details", + "full-content", + "phone-light", + "images/screen-0002/variant-0001/phone-light.png", + "fullContent", + ], + [ + "details", + "full-content", + "phone-dark", + "images/screen-0002/variant-0001/phone-dark.png", + "fullContent", + ], + ]) + for image in manifest.images { + #expect(image.relativePath.hasPrefix("/") == false) + #expect(image.pointWidth == 402) + if image.screenID == "root" { + #expect(image.pointHeight == 874) + } else { + #expect(image.pointHeight > 874) + } + #expect(image.pixelWidth == Int((image.pointWidth * image.scale).rounded())) + #expect(image.pixelHeight == Int((image.pointHeight * image.scale).rounded())) + + let imageURL = output.appending(path: image.relativePath) + let pngData = try Data(contentsOf: imageURL) + let decodedImage = try #require(UIImage(data: pngData, scale: CGFloat(image.scale))) + let pixels = try #require(decodedImage.cgImage) + #expect(pixels.width == image.pixelWidth) + #expect(pixels.height == image.pixelHeight) + } + for path in [ + "index.html", + "manifest.json", + "manifest.js", + "assets/app.js", + "assets/styles.css", + ] { + #expect(FileManager.default.fileExists( + atPath: output.appending(path: path).path, + )) + } + let shell = try ["index.html", "assets/app.js", "assets/styles.css"] + .map { path in try String(contentsOf: output.appending(path: path), encoding: .utf8) } + .joined(separator: "\n") + .replacingOccurrences(of: "http://www.w3.org/2000/svg", with: "") + #expect(shell.contains("http://") == false) + #expect(shell.contains("https://") == false) + #expect(shell.contains("fetch(") == false) + } + + private func capture(_ request: FlyoverCaptureRequest) async throws -> FlyoverCapturedImage { + 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, + ) + } + + private func copyWebShell(to output: URL) throws { + let repository = URL(filePath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + let source = repository.appending(path: "Shared/Flyover/Web") + let assets = output.appending(path: "assets", directoryHint: .isDirectory) + try FileManager.default.createDirectory(at: assets, withIntermediateDirectories: true) + try FileManager.default.copyItem( + at: source.appending(path: "index.html"), + to: output.appending(path: "index.html"), + ) + for filename in ["app.js", "styles.css"] { + try FileManager.default.copyItem( + at: source.appending(path: "assets/\(filename)"), + to: assets.appending(path: filename), + ) + } + } +} + +private enum SmokeScreen: String { + case root + case details } private struct WhereFlyoverExportEnvironment { diff --git a/Where/WhereUI/Sources/Developer/Flyover/OpenSpansView+WhereFlyover.swift b/Where/WhereUI/Sources/Developer/Flyover/OpenSpansView+WhereFlyover.swift index 6b8fc5ae6..c8320ca43 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/OpenSpansView+WhereFlyover.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/OpenSpansView+WhereFlyover.swift @@ -6,8 +6,8 @@ static let flyoverData = WhereFlyoverData.hosted( OpenSpansView.self, title: "Open Spans", - ) { _ in - OpenSpansView(system: .shared) + ) { world in + OpenSpansView(system: world.openSpansLogSystem) } } #endif diff --git a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift index 6d9c57711..69bc502cb 100644 --- a/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift +++ b/Where/WhereUI/Sources/Developer/Flyover/WhereFlyoverWorld.swift @@ -7,6 +7,7 @@ @MainActor final class WhereFlyoverWorld { let scope: WhereScope + let openSpansLogSystem: Periscope let model: WhereModel let session: WhereSession let report: YearReportModel @@ -15,6 +16,7 @@ private init( scope: WhereScope, + openSpansLogSystem: Periscope, model: WhereModel, session: WhereSession, report: YearReportModel, @@ -22,6 +24,7 @@ backup: BackupModel, ) { self.scope = scope + self.openSpansLogSystem = openSpansLogSystem self.model = model self.session = session self.report = report @@ -30,6 +33,16 @@ } static func build() async throws -> WhereFlyoverWorld { + try await build(openSpansSource: .shared) + } + + static func buildForWebExport() async throws -> WhereFlyoverWorld { + try await build(openSpansSource: .syntheticWorld) + } + + private static func build( + openSpansSource: OpenSpansSource, + ) async throws -> WhereFlyoverWorld { let now: @Sendable () -> Date = { PreviewSupport.referenceNow } let logSystem = Periscope( configuration: Periscope.Configuration(), @@ -61,9 +74,14 @@ now: now, ) await report.activate() + let openSpansLogSystem: Periscope = switch openSpansSource { + case .shared: .shared + case .syntheticWorld: logSystem + } return WhereFlyoverWorld( scope: scope, + openSpansLogSystem: openSpansLogSystem, model: model, session: session, report: report, @@ -104,6 +122,7 @@ ) return WhereFlyoverWorld( scope: scope, + openSpansLogSystem: .shared, model: model, session: session, report: report, @@ -115,5 +134,10 @@ backup: BackupModel(services: services), ) } + + private enum OpenSpansSource { + case shared + case syntheticWorld + } } #endif diff --git a/Where/WhereUI/Tests/WhereFlyoverWorldTests.swift b/Where/WhereUI/Tests/WhereFlyoverWorldTests.swift index c0a116d29..4c311f8d6 100644 --- a/Where/WhereUI/Tests/WhereFlyoverWorldTests.swift +++ b/Where/WhereUI/Tests/WhereFlyoverWorldTests.swift @@ -1,4 +1,5 @@ #if DEBUG + import PeriscopeCore import Testing @testable import WhereUI @@ -13,5 +14,13 @@ #expect(world.model.activeScope !== world.scope) #expect(world.report.report?.days.isEmpty == false) } + + @Test func keepsNativeSpansLiveAndWebExportSpansSynthetic() async throws { + let nativeWorld = try await WhereFlyoverWorld.build() + #expect(nativeWorld.openSpansLogSystem === Periscope.shared) + + let exportWorld = try await WhereFlyoverWorld.buildForWebExport() + #expect(exportWorld.openSpansLogSystem !== Periscope.shared) + } } #endif diff --git a/flyover b/flyover index db4d624ec..c0da4e087 100755 --- a/flyover +++ b/flyover @@ -63,10 +63,32 @@ add_profile() { resolve_path() { python3 - "$1" <<'PY' import os, sys -print(os.path.abspath(os.path.expanduser(sys.argv[1]))) +print(os.path.realpath(os.path.abspath(os.path.expanduser(sys.argv[1])))) PY } +paths_are_same() { + python3 - "$1" "$2" <<'PY' +import os, sys +first, second = sys.argv[1:] +if first == second: + raise SystemExit(0) +try: + raise SystemExit(0 if os.path.samefile(first, second) else 1) +except OSError: + raise SystemExit(1) +PY +} + +validate_destination() { + if [ -e "$DESTINATION" ] || [ -L "$DESTINATION" ]; then + [ -d "$DESTINATION" ] \ + || error "refusing to replace non-directory '$DESTINATION'" + [ -f "$DESTINATION/.flyover-generated" ] \ + || error "refusing to replace unmarked directory '$DESTINATION'" + fi +} + cleanup() { if [ -n "$STAGING" ] && [ -d "$STAGING" ]; then rm -rf "$STAGING" @@ -117,23 +139,11 @@ done 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" +paths_are_same "$DESTINATION" / && error "refusing to replace the filesystem root" +paths_are_same "$DESTINATION" "$HOME_PATH" && error "refusing to replace the home directory" +paths_are_same "$DESTINATION" "$WORKSPACE_PATH" \ + && error "refusing to replace the workspace root" +validate_destination COMMIT="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo unknown)" if [ -n "$(git -C "$ROOT" status --porcelain 2>/dev/null || true)" ]; then @@ -149,6 +159,15 @@ PROFILE_LIST="$(IFS=,; echo "${PROFILES[*]}")" CAPTURE_RUNNER="${FLYOVER_CAPTURE_RUNNER:-$ROOT/test}" EXPORT_STARTED_AT=$SECONDS +PARENT="$(dirname "$DESTINATION")" +mkdir -p "$PARENT" +STAGING="$(mktemp -d "$PARENT/.flyover-staging.XXXXXX")" +chmod 0755 "$STAGING" +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" + echo "==> Exporting ${#PROFILES[@]} profile(s) to $DESTINATION" env \ FLYOVER_EXPORT_DIRECTORY="$STAGING" \ @@ -160,7 +179,8 @@ env \ FLYOVER_EXPORT_XCODE_VERSION="$XCODE_VERSION" \ FLYOVER_EXPORT_SIMULATOR_DEVICE="iPhone 17" \ FLYOVER_EXPORT_SIMULATOR_OS="27.0" \ - "$CAPTURE_RUNNER" --only 'WhereUISnapshotTests/WhereFlyoverWebExportTests' + "$CAPTURE_RUNNER" --only \ + 'WhereUISnapshotTests/WhereFlyoverWebExportTests/exportsRequestedAtlas()' python3 - "$STAGING" <<'PY' import json, os, pathlib, sys @@ -188,8 +208,11 @@ if actual != len(images): PY printf '%s\n' 'schemaVersion=1' >"$STAGING/.flyover-generated" +find "$STAGING" -type d -exec chmod 0755 {} + +find "$STAGING" -type f -exec chmod 0644 {} + -if [ -e "$DESTINATION" ]; then +validate_destination +if [ -e "$DESTINATION" ] || [ -L "$DESTINATION" ]; then BACKUP="$PARENT/.flyover-previous.$(basename "$DESTINATION").$$" mv "$DESTINATION" "$BACKUP" fi From 180a5a8b561ff9b2f101a075a059839f0f68b615 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 4 Sep 2026 17:30:06 -0700 Subject: [PATCH 09/15] Add Flyover preview server --- README.md | 2 +- Shared/Flyover/AGENTS.md | 1 + Shared/Flyover/README.md | 29 ++ Shared/Flyover/Tools/Tests/flyover_test.sh | 230 +++++++++++++- Tools/ADVERSARIAL_TEST_PLAN.md | 11 + Tools/README.md | 2 + Tools/Tests/test_flyover_preview.py | 281 ++++++++++++++++++ Tools/flyover_preview.py | 329 +++++++++++++++++++++ flyover | 153 ++++++++-- 9 files changed, 1009 insertions(+), 29 deletions(-) create mode 100644 Tools/Tests/test_flyover_preview.py create mode 100644 Tools/flyover_preview.py diff --git a/README.md b/README.md index 915e9f1d6..f9a3dfd79 100644 --- a/README.md +++ b/README.md @@ -171,7 +171,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 +flyover Export and preview Where's native Flyover catalog as a static QA atlas flaky Detect flaky tests, update FLAKY_TESTS.md (see `./flaky --help`) circleci-artifacts Download every artifact for a CircleCI job snapshot-shards Validate and rebalance snapshot suite assignments diff --git a/Shared/Flyover/AGENTS.md b/Shared/Flyover/AGENTS.md index 772b0ced2..6034b3226 100644 --- a/Shared/Flyover/AGENTS.md +++ b/Shared/Flyover/AGENTS.md @@ -29,6 +29,7 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, format - **Register forward push/modal routes only.** Flyover derives Back/Dismiss cues from incoming routes. - **Type erase only at the heterogeneous content/control registry boundary.** - **Validate every stable screen and variant identifier before capture.** Use generated ordinals for image paths. +- **Serve only a validated generated artifact.** Bind to loopback unless the user selects LAN access. - **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. diff --git a/Shared/Flyover/README.md b/Shared/Flyover/README.md index 70e52c64c..8fe7d0d1b 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -177,6 +177,35 @@ 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. +### Preview the export + +Serve the default export on this computer: + +```sh +./flyover preview +``` + +The command selects a free port and prints the local URL. Press Control-C to +stop the server. + +Use `--lan` to open the preview to other devices on the local network: + +```sh +./flyover preview --lan +./flyover preview --output /tmp/where-flyover --lan --port 8080 +``` + +The command prints one URL for each network address that it finds. The other +device must be able to reach this computer. A macOS firewall prompt can appear. + +WARNING: LAN preview has no authentication or TLS. Any device that can reach +the computer can view the native screenshots. Stop and restart the preview +after each export. + +For a static host, upload the contents of the generated directory. Put +`index.html` at the selected host root or subpath. The site needs no build step. +All site URLs are relative. + The manifest compatibility boundary is `schemaVersion: 1`. It contains the application and build identity, profiles, precomputed canvas geometry, groups, screens, routes, and image metadata. It contains no local source or account diff --git a/Shared/Flyover/Tools/Tests/flyover_test.sh b/Shared/Flyover/Tools/Tests/flyover_test.sh index 9eada1130..ec1a288a7 100755 --- a/Shared/Flyover/Tools/Tests/flyover_test.sh +++ b/Shared/Flyover/Tools/Tests/flyover_test.sh @@ -73,7 +73,12 @@ exit 19 RUNNER chmod +x "$FAILURE_RUNNER" -"$ROOT/flyover" --help | grep -q 'flyover export' || fail "help text is incomplete" +"$ROOT/flyover" --help | grep -q 'flyover export' || fail "export help is incomplete" +"$ROOT/flyover" --help | grep -q 'flyover preview' || fail "preview help is incomplete" +"$ROOT/flyover" export --help | grep -q 'phone-voiceover' \ + || fail "export help does not list the supported profiles" +"$ROOT/flyover" preview --help | grep -q 'no authentication or TLS' \ + || fail "preview help does not explain LAN exposure" 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 @@ -81,6 +86,19 @@ 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" +expect_failure "$ROOT/flyover" preview --output +expect_failure "$ROOT/flyover" preview --port +expect_failure "$ROOT/flyover" preview --port nope +expect_failure "$ROOT/flyover" preview --port -1 +expect_failure "$ROOT/flyover" preview --port 65536 +expect_failure "$ROOT/flyover" preview --profile phone-light +expect_failure "$ROOT/flyover" preview --output "$TEMP/missing" +grep -q "Run ./flyover export first" "$TEMP/stderr" \ + || fail "missing-preview error does not name the next action" +printf '%s\n' not-a-directory >"$TEMP/preview-file" +expect_failure "$ROOT/flyover" preview --output "$TEMP/preview-file" +grep -q "not a directory" "$TEMP/stderr" \ + || fail "preview-file error is unclear" ln -s / "$TEMP/root-alias" expect_failure "$ROOT/flyover" export --output "$TEMP/root-alias" @@ -98,6 +116,24 @@ 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" +UNSUPPORTED_MARKER="$TEMP/unsupported-marker" +mkdir -p "$UNSUPPORTED_MARKER" +printf '%s\n' schemaVersion=2 >"$UNSUPPORTED_MARKER/.flyover-generated" +expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$ROOT/flyover" export --output "$UNSUPPORTED_MARKER" +grep -q "unsupported marker" "$TEMP/stderr" \ + || fail "unsupported generated marker was not rejected" + +SYMLINK_MARKER="$TEMP/symlink-marker" +mkdir -p "$SYMLINK_MARKER" +printf '%s\n' schemaVersion=1 >"$TEMP/marker-target" +ln -s "$TEMP/marker-target" "$SYMLINK_MARKER/.flyover-generated" +expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests "$ROOT/flyover" export --output "$SYMLINK_MARKER" +grep -q "symbolic-link marker" "$TEMP/stderr" \ + || fail "symbolic-link generated marker was not rejected" + RACED="$TEMP/raced" expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ FLYOVER_XCODE_VERSION_OVERRIDE=Tests FLYOVER_RACE_DESTINATION="$RACED" \ @@ -142,9 +178,201 @@ if bad_directories or bad_files: raise SystemExit(f'unsafe published modes: directories={bad_directories}, files={bad_files}') PY +PREVIEW_ARGUMENTS="$TEMP/preview-arguments" +PREVIEW_RUNNER="$TEMP/preview-runner" +cat >"$PREVIEW_RUNNER" <<'RUNNER' +#!/bin/bash +set -euo pipefail +printf '%s\n' "$@" >"$FLYOVER_PREVIEW_ARGUMENTS" +RUNNER +chmod +x "$PREVIEW_RUNNER" +FLYOVER_PREVIEW_PYTHON="$PREVIEW_RUNNER" \ + FLYOVER_PREVIEW_ARGUMENTS="$PREVIEW_ARGUMENTS" \ + "$ROOT/flyover" preview --output "$OUTPUT" --lan --port 8080 +python3 - "$PREVIEW_ARGUMENTS" "$ROOT" "$OUTPUT" <<'PY' +import pathlib, sys +arguments = pathlib.Path(sys.argv[1]).read_text().splitlines() +expected = [ + f'{sys.argv[2]}/Tools/flyover_preview.py', + 'serve', + str(pathlib.Path(sys.argv[3]).resolve()), + '--port', + '8080', + '--lan', +] +if arguments != expected: + raise SystemExit(f'preview runner arguments are wrong: {arguments}') +PY + +( + cd "$CALLER" + FLYOVER_PREVIEW_PYTHON="$PREVIEW_RUNNER" \ + FLYOVER_PREVIEW_ARGUMENTS="$PREVIEW_ARGUMENTS" \ + "$ROOT/flyover" preview --port 0 +) +python3 - "$PREVIEW_ARGUMENTS" "$ROOT" "$CALLER/.build/flyover/where" <<'PY' +import pathlib, sys +arguments = pathlib.Path(sys.argv[1]).read_text().splitlines() +expected = [ + f'{sys.argv[2]}/Tools/flyover_preview.py', + 'serve', + str(pathlib.Path(sys.argv[3]).resolve()), + '--port', + '0', +] +if arguments != expected: + raise SystemExit(f'default preview arguments are wrong: {arguments}') +PY + +expect_failure "$ROOT/flyover" preview --output "$UNMARKED" +grep -q "not a generated Flyover atlas" "$TEMP/stderr" \ + || fail "preview accepted an unmarked directory" + +PREVIEW_WITH_SPACES="$TEMP/preview output" +cp -R "$OUTPUT" "$PREVIEW_WITH_SPACES" +python3 - "$ROOT/flyover" "$PREVIEW_WITH_SPACES" <<'PY' +import http.client +import os +import pathlib +import re +import selectors +import signal +import socket +import subprocess +import sys +import time +import urllib.error +import urllib.request + +command, directory = sys.argv[1:] +(pathlib.Path(directory) / 'http:' / 'remote.example').mkdir(parents=True) +(pathlib.Path(directory) / 'http:' / 'remote.example' / 'manifest.json').write_text( + 'not the atlas manifest' +) +process = subprocess.Popen( + [command, 'preview', '--output', directory, '--port', '0'], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, +) +assert process.stdout is not None +assert process.stderr is not None +selector = selectors.DefaultSelector() +selector.register(process.stdout, selectors.EVENT_READ) +selector.register(process.stderr, selectors.EVENT_READ) +chunks = [] +deadline = time.monotonic() + 10 +try: + while time.monotonic() < deadline and b'Press Ctrl-C' not in b''.join(chunks): + for key, _ in selector.select(timeout=max(0, deadline - time.monotonic())): + chunk = os.read(key.fileobj.fileno(), 4096) + if chunk: + chunks.append(chunk) + if process.poll() is not None: + break + output = b''.join(chunks).decode(errors='replace') + match = re.search(r'^Local:\s+(http://127\.0\.0\.1:\d+/)$', output, re.MULTILINE) + if match is None: + raise SystemExit(f'preview did not print a usable local URL:\n{output}') + if f'Flyover preview: {pathlib.Path(directory).resolve()}' not in output: + raise SystemExit(f'preview did not print the resolved directory:\n{output}') + + base_url = match.group(1) + if urllib.request.urlopen(base_url, timeout=5).status != 200: + raise SystemExit('preview index request failed') + manifest = urllib.request.urlopen(base_url + 'manifest.json', timeout=5).read() + if b'"schemaVersion": 1' not in manifest: + raise SystemExit('preview served an unexpected manifest') + image = urllib.request.urlopen( + base_url + 'images/screen-0001/variant-0001/phone-dark.png', + timeout=5, + ).read() + if image != b'PNG': + raise SystemExit('preview served unexpected image data') + try: + urllib.request.urlopen(base_url + 'assets/', timeout=5) + raise SystemExit('preview exposed a directory listing') + except urllib.error.HTTPError as error: + if error.code != 404: + raise + + port = int(base_url.rstrip('/').rsplit(':', 1)[1]) + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=5) + connection.request('GET', '/%2e%2e/manifest.json') + response = connection.getresponse() + if response.status != 404: + raise SystemExit(f'preview accepted a traversal request: {response.status}') + response.read() + connection.close() + + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=5) + connection.request('GET', 'http://remote.example/manifest.json') + response = connection.getresponse() + if response.status != 404: + raise SystemExit(f'preview accepted an absolute request target: {response.status}') + response.read() + connection.close() + + with socket.create_connection(('127.0.0.1', port), timeout=5) as client: + client.sendall( + b'GET http://[/manifest.json HTTP/1.1\r\n' + b'Host: 127.0.0.1\r\nConnection: close\r\n\r\n' + ) + response_data = client.recv(4096) + if b' 404 ' not in response_data: + raise SystemExit('preview did not reject a malformed absolute request target') + + with socket.create_connection(('127.0.0.1', port), timeout=5) as client: + client.sendall( + b'GET /\x1b]0;untrusted\x07 HTTP/1.1\r\n' + b'Host: 127.0.0.1\r\nConnection: close\r\n\r\n' + ) + response_data = client.recv(4096) + if b' 404 ' not in response_data: + raise SystemExit('preview did not reject the terminal-control request') +finally: + if process.poll() is None: + process.send_signal(signal.SIGINT) + try: + status = process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + status = process.wait(timeout=5) + stderr = process.stderr.read().decode(errors='replace') + if status != 0: + raise SystemExit(f'preview exited with {status}:\n{output}\n{stderr}') + if stderr: + raise SystemExit(f'preview logged untrusted request data:\n{stderr}') +PY + +INVALID_PREVIEW="$TEMP/invalid-preview" +cp -R "$OUTPUT" "$INVALID_PREVIEW" +printf '%s\n' schemaVersion=2 >"$INVALID_PREVIEW/.flyover-generated" +expect_failure "$ROOT/flyover" preview --output "$INVALID_PREVIEW" +grep -q "generated marker is unsupported" "$TEMP/stderr" \ + || fail "preview accepted an unsupported marker" + +INVALID_UTF8_PREVIEW="$TEMP/invalid-utf8-preview" +cp -R "$OUTPUT" "$INVALID_UTF8_PREVIEW" +printf '\377' >"$INVALID_UTF8_PREVIEW/.flyover-generated" +expect_failure "$ROOT/flyover" preview --output "$INVALID_UTF8_PREVIEW" +grep -q "could not read" "$TEMP/stderr" \ + || fail "preview did not report an invalid marker encoding" +if grep -q "Traceback" "$TEMP/stderr"; then + fail "preview printed a traceback for an invalid marker encoding" +fi + +SYMLINK_PREVIEW="$TEMP/symlink-preview" +cp -R "$OUTPUT" "$SYMLINK_PREVIEW" +ln -s "$TEMP/marker-target" "$SYMLINK_PREVIEW/leak" +expect_failure "$ROOT/flyover" preview --output "$SYMLINK_PREVIEW" +grep -q "contains a symbolic link" "$TEMP/stderr" \ + || fail "preview accepted a symbolic link" + CLEAN_REPO="$TEMP/clean-repo" mkdir -p "$CLEAN_REPO/Shared/Flyover/Web/assets" +mkdir -p "$CLEAN_REPO/Tools" cp "$ROOT/flyover" "$CLEAN_REPO/flyover" +cp "$ROOT/Tools/flyover_preview.py" "$CLEAN_REPO/Tools/flyover_preview.py" cp "$ROOT/Shared/Flyover/Web/index.html" "$CLEAN_REPO/Shared/Flyover/Web/index.html" cp "$ROOT/Shared/Flyover/Web/assets/app.js" "$CLEAN_REPO/Shared/Flyover/Web/assets/app.js" cp "$ROOT/Shared/Flyover/Web/assets/styles.css" "$CLEAN_REPO/Shared/Flyover/Web/assets/styles.css" diff --git a/Tools/ADVERSARIAL_TEST_PLAN.md b/Tools/ADVERSARIAL_TEST_PLAN.md index 7c4033712..21e781a95 100644 --- a/Tools/ADVERSARIAL_TEST_PLAN.md +++ b/Tools/ADVERSARIAL_TEST_PLAN.md @@ -26,6 +26,7 @@ functions returned expected values. | `Where/install` | macOS/Xcode/device | signing and device inventory | physical device | Dry run performs no build/install/launch | | `Ledger/install` | macOS/Xcode | built/installed apps | `/Applications/Ledger.app` | Exact-process and transactional replacement | | `tla-check` | macOS/Linux-compatible tooling | manifests/specs | retained run artifacts | Pinned tools and honest pass/fail policy | +| `flyover` | macOS/Xcode for export, Python 3 for preview | catalog, generated atlas | generated directory | Export is atomic. Preview serves only validated files | Existing flags and their observable behavior are compared with `main`. New behavior must be called out explicitly; the planned additions are the @@ -134,6 +135,16 @@ After failure, exactly one state is legal: A mixture is never accepted. +### Flyover export and preview + +Cover unsafe output aliases, replacement races, invalid markers, incomplete +manifests, missing or extra images, symbolic links, path traversal, paths with +spaces, automatic ports, occupied ports, loopback and LAN binding, and clean +Control-C shutdown. + +The preview server must expose only files in its validated allowlist. It must +bind to loopback unless the user selects LAN access. + ### Installers For `Where/install`, cover unset teams and malformed mise configuration; diff --git a/Tools/README.md b/Tools/README.md index 2238e1c2b..eec9b260c 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -13,6 +13,8 @@ result reporting without requiring Java in its tests. The Xcode-facing root commands keep process and simulator orchestration in shell; `xcode_results.py` shares xcresult traversal, `snapshot_reports.py` shares capture reports, and the command-specific Python modules retain each command's distinct policy. +`flyover_preview.py` validates generated atlases and serves their allowlisted +files from a local HTTP server. Filesystem-heavy Ruby generators are require-safe so their behavior can be exercised against temporary repositories. `SimulatorRegistry` validates exact runtime ownership from captured `simctl` JSON and persists private claims diff --git a/Tools/Tests/test_flyover_preview.py b/Tools/Tests/test_flyover_preview.py new file mode 100644 index 000000000..71b006965 --- /dev/null +++ b/Tools/Tests/test_flyover_preview.py @@ -0,0 +1,281 @@ +import importlib.util +import io +import json +import sys +import tempfile +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).resolve().parents[1] / "flyover_preview.py" +SPEC = importlib.util.spec_from_file_location("flyover_preview", MODULE_PATH) +flyover_preview = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +sys.modules[SPEC.name] = flyover_preview +SPEC.loader.exec_module(flyover_preview) + + +class FlyoverPreviewTests(unittest.TestCase): + def write_manifest(self, root: Path, manifest: object) -> None: + data = json.dumps(manifest) + (root / "manifest.json").write_text(data) + (root / "manifest.js").write_text( + "window.FLYOVER_MANIFEST = " + data + ";\n" + ) + + def fixture(self, root: Path) -> Path: + (root / "assets").mkdir(parents=True) + image = root / "images/screen-0001/variant-0001/phone-light.png" + image.parent.mkdir(parents=True) + image.write_bytes(b"PNG") + (root / ".flyover-generated").write_text("schemaVersion=1\n") + (root / "index.html").write_text("") + (root / "assets/app.js").write_text("") + (root / "assets/styles.css").write_text("") + self.write_manifest( + root, + { + "schemaVersion": 1, + "images": [ + { + "relativePath": ( + "images/screen-0001/variant-0001/phone-light.png" + ) + } + ], + }, + ) + return root + + def test_validates_generated_artifact_and_builds_allowlist(self): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + + artifact = flyover_preview.validate_artifact(root) + + self.assertEqual(artifact.root, root) + self.assertEqual( + artifact.allowed_paths, + frozenset( + { + "index.html", + "manifest.json", + "manifest.js", + "assets/app.js", + "assets/styles.css", + "images/screen-0001/variant-0001/phone-light.png", + } + ), + ) + + def test_rejects_invalid_marker_and_symbolic_links(self): + with tempfile.TemporaryDirectory() as temporary: + temporary_root = Path(temporary) + root = self.fixture(temporary_root / "atlas") + (root / ".flyover-generated").write_text("schemaVersion=2\n") + with self.assertRaisesRegex( + flyover_preview.FlyoverArtifactError, + "generated marker is unsupported", + ): + flyover_preview.validate_artifact(root) + + (root / ".flyover-generated").write_bytes(b"\xff") + with self.assertRaisesRegex( + flyover_preview.FlyoverArtifactError, + "could not read", + ): + flyover_preview.validate_artifact(root) + + (root / ".flyover-generated").write_text("schemaVersion=1\n") + (root / "leak").symlink_to(temporary_root / "outside") + with self.assertRaisesRegex( + flyover_preview.FlyoverArtifactError, + "contains a symbolic link", + ): + flyover_preview.validate_artifact(root) + + def test_rejects_unsafe_missing_duplicate_and_extra_images(self): + scenarios = ( + ("../outside.png", "unsafe image path"), + ("/outside.png", "unsafe image path"), + ("images/missing.png", "manifest image is missing"), + ) + for relative_path, message in scenarios: + with self.subTest(relative_path=relative_path): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["images"][0]["relativePath"] = relative_path + self.write_manifest(root, manifest) + with self.assertRaisesRegex( + flyover_preview.FlyoverArtifactError, + message, + ): + flyover_preview.validate_artifact(root) + + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["images"].append(dict(manifest["images"][0])) + self.write_manifest(root, manifest) + with self.assertRaisesRegex( + flyover_preview.FlyoverArtifactError, + "duplicate image path", + ): + flyover_preview.validate_artifact(root) + + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + (root / "images/extra.png").write_bytes(b"PNG") + with self.assertRaisesRegex( + flyover_preview.FlyoverArtifactError, + "image files do not match", + ): + flyover_preview.validate_artifact(root) + + def test_rejects_non_object_boolean_schema_and_mismatched_script(self): + scenarios = ( + ([], "not a JSON object"), + ({"schemaVersion": True, "images": []}, "schemaVersion 1"), + ) + for manifest, message in scenarios: + with self.subTest(manifest=manifest): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + self.write_manifest(root, manifest) + with self.assertRaisesRegex( + flyover_preview.FlyoverArtifactError, + message, + ): + flyover_preview.validate_artifact(root) + + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + (root / "manifest.js").write_text("window.FLYOVER_MANIFEST = {};\n") + with self.assertRaisesRegex( + flyover_preview.FlyoverArtifactError, + "manifest.js does not match manifest.json", + ): + flyover_preview.validate_artifact(root) + + def test_filters_and_sorts_usable_ipv4_addresses(self): + self.assertEqual( + flyover_preview.usable_ipv4_addresses( + ( + "192.168.1.20", + "127.0.0.1", + "0.0.0.0", + "224.0.0.1", + "169.254.2.3", + "10.0.0.7", + "192.168.1.20", + "::1", + "invalid", + ) + ), + ("10.0.0.7", "169.254.2.3", "192.168.1.20"), + ) + + def test_serves_loopback_on_an_automatic_port(self): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + servers = [] + + class FakeServer: + def __init__(self, address, handler): + self.address = address + self.handler = handler + self.server_address = (address[0], 53142) + self.did_serve = False + servers.append(self) + + def __enter__(self): + return self + + def __exit__(self, *_): + return None + + def serve_forever(self): + self.did_serve = True + + output = io.StringIO() + flyover_preview.serve( + root, + lan=False, + port=0, + output=output, + server_factory=FakeServer, + ) + + self.assertEqual(servers[0].address, ("127.0.0.1", 0)) + self.assertTrue(servers[0].did_serve) + self.assertEqual( + output.getvalue().splitlines(), + [ + f"Flyover preview: {root}", + "Local: http://127.0.0.1:53142/", + "Press Ctrl-C to stop.", + ], + ) + + def test_lan_preview_prints_each_network_url_and_warning(self): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + addresses = [] + + class FakeServer: + server_address = ("0.0.0.0", 8080) + + def __init__(self, address, _): + addresses.append(address) + + def __enter__(self): + return self + + def __exit__(self, *_): + return None + + def serve_forever(self): + return None + + output = io.StringIO() + flyover_preview.serve( + root, + lan=True, + port=8080, + output=output, + server_factory=FakeServer, + address_provider=lambda: ("10.0.0.7", "192.168.1.20"), + ) + + self.assertEqual(addresses, [("0.0.0.0", 8080)]) + self.assertIn("Network: http://10.0.0.7:8080/", output.getvalue()) + self.assertIn("Network: http://192.168.1.20:8080/", output.getvalue()) + self.assertIn( + "Warning: LAN preview has no authentication or TLS.", + output.getvalue(), + ) + + def test_reports_bind_errors_without_starting_the_server(self): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + + def failed_server(*_): + raise OSError("address already in use") + + with self.assertRaisesRegex( + flyover_preview.FlyoverArtifactError, + "could not start the preview server.*address already in use", + ): + flyover_preview.serve( + root, + lan=False, + port=4173, + server_factory=failed_server, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/flyover_preview.py b/Tools/flyover_preview.py new file mode 100644 index 000000000..6a662e28a --- /dev/null +++ b/Tools/flyover_preview.py @@ -0,0 +1,329 @@ +"""Validation and local HTTP serving for generated Flyover atlases.""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import socket +import sys +from dataclasses import dataclass +from functools import partial +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path, PurePosixPath +from typing import Callable, Iterable, TextIO +from urllib.parse import unquote, urlsplit + + +MARKER_CONTENT = "schemaVersion=1" +REQUIRED_FILES = ( + "index.html", + "manifest.json", + "manifest.js", + "assets/app.js", + "assets/styles.css", +) + + +class FlyoverArtifactError(ValueError): + """A generated atlas is incomplete or unsafe to serve.""" + + +@dataclass(frozen=True) +class FlyoverArtifact: + root: Path + allowed_paths: frozenset[str] + + +def validate_artifact( + directory: Path, + *, + require_marker: bool = True, +) -> FlyoverArtifact: + """Validate a generated atlas and return its HTTP allowlist.""" + root = directory + if root.is_symlink(): + raise FlyoverArtifactError(f"the atlas directory is a symbolic link: {root}") + if not root.exists(): + raise FlyoverArtifactError( + f"no generated atlas exists at {root}. Run ./flyover export first." + ) + if not root.is_dir(): + raise FlyoverArtifactError(f"the atlas path is not a directory: {root}") + + symbolic_link = next((path for path in root.rglob("*") if path.is_symlink()), None) + if symbolic_link is not None: + relative = symbolic_link.relative_to(root) + raise FlyoverArtifactError(f"the atlas contains a symbolic link: {relative}") + + marker = root / ".flyover-generated" + if require_marker: + if not marker.is_file(): + raise FlyoverArtifactError( + f"the directory is not a generated Flyover atlas: {root}" + ) + try: + marker_content = marker.read_text(encoding="utf-8").strip() + except (OSError, UnicodeError) as error: + raise FlyoverArtifactError(f"could not read {marker}: {error}") from error + if marker_content != MARKER_CONTENT: + raise FlyoverArtifactError( + f"the generated marker is unsupported: {marker_content or 'empty'}" + ) + + for relative in REQUIRED_FILES: + path = root / relative + if not path.is_file(): + raise FlyoverArtifactError(f"the generated atlas is missing {relative}") + + manifest_path = root / "manifest.json" + try: + manifest_data = manifest_path.read_bytes() + manifest = json.loads(manifest_data) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise FlyoverArtifactError(f"could not read manifest.json: {error}") from error + if not isinstance(manifest, dict): + raise FlyoverArtifactError("manifest.json is not a JSON object") + if ( + type(manifest.get("schemaVersion")) is not int + or manifest["schemaVersion"] != 1 + ): + raise FlyoverArtifactError("manifest.json does not use schemaVersion 1") + + manifest_script_path = root / "manifest.js" + try: + manifest_script = manifest_script_path.read_bytes() + except OSError as error: + raise FlyoverArtifactError(f"could not read manifest.js: {error}") from error + expected_script = b"window.FLYOVER_MANIFEST = " + manifest_data + b";\n" + if manifest_script != expected_script: + raise FlyoverArtifactError("manifest.js does not match manifest.json") + + images = manifest.get("images") + if not isinstance(images, list): + raise FlyoverArtifactError("manifest.json has no image list") + + image_paths: list[str] = [] + for image in images: + if not isinstance(image, dict): + raise FlyoverArtifactError("manifest.json contains an invalid image record") + relative_value = image.get("relativePath") + if not isinstance(relative_value, str): + raise FlyoverArtifactError("manifest.json contains an invalid image path") + relative = _safe_image_path(relative_value) + path = root.joinpath(*relative.parts) + if not path.is_file(): + raise FlyoverArtifactError(f"the manifest image is missing: {relative}") + image_paths.append(relative.as_posix()) + + if len(set(image_paths)) != len(image_paths): + raise FlyoverArtifactError("manifest.json contains a duplicate image path") + + images_directory = root / "images" + if images_directory.is_dir(): + actual_paths = { + path.relative_to(root).as_posix() + for path in images_directory.rglob("*.png") + if path.is_file() + } + else: + actual_paths = set() + declared_paths = set(image_paths) + if actual_paths != declared_paths: + raise FlyoverArtifactError( + "the image files do not match the manifest " + f"({len(declared_paths)} declared, {len(actual_paths)} found)" + ) + + return FlyoverArtifact( + root=root, + allowed_paths=frozenset((*REQUIRED_FILES, *image_paths)), + ) + + +def _safe_image_path(value: str) -> PurePosixPath: + relative = PurePosixPath(value) + if ( + not value + or "\\" in value + or relative.is_absolute() + or ".." in relative.parts + or relative.parts[:1] != ("images",) + or relative.suffix.lower() != ".png" + ): + raise FlyoverArtifactError(f"the manifest contains an unsafe image path: {value}") + return relative + + +def usable_ipv4_addresses(candidates: Iterable[str]) -> tuple[str, ...]: + """Return deterministic non-loopback IPv4 addresses.""" + addresses: set[ipaddress.IPv4Address] = set() + for candidate in candidates: + try: + address = ipaddress.ip_address(candidate) + except ValueError: + continue + if not isinstance(address, ipaddress.IPv4Address): + continue + if address.is_unspecified or address.is_loopback or address.is_multicast: + continue + addresses.add(address) + return tuple(str(address) for address in sorted(addresses)) + + +def discover_network_addresses() -> tuple[str, ...]: + """Discover IPv4 addresses that can identify this host on a local network.""" + candidates: list[str] = [] + try: + candidates.extend( + item[4][0] + for item in socket.getaddrinfo( + socket.gethostname(), + None, + family=socket.AF_INET, + type=socket.SOCK_STREAM, + ) + ) + except OSError: + pass + + try: + with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe: + probe.connect(("192.0.2.1", 9)) + candidates.append(probe.getsockname()[0]) + except OSError: + pass + return usable_ipv4_addresses(candidates) + + +class FlyoverRequestHandler(SimpleHTTPRequestHandler): + """Serve only files declared by a validated Flyover artifact.""" + + def __init__( + self, + *args: object, + directory: str, + allowed_paths: frozenset[str], + **kwargs: object, + ) -> None: + self.allowed_paths = allowed_paths + super().__init__(*args, directory=directory, **kwargs) + + def do_GET(self) -> None: # noqa: N802 - stdlib handler API + if not self._request_is_allowed(): + self.send_error(404) + return + super().do_GET() + + def do_HEAD(self) -> None: # noqa: N802 - stdlib handler API + if not self._request_is_allowed(): + self.send_error(404) + return + super().do_HEAD() + + def list_directory(self, path: str) -> None: + self.send_error(404) + return None + + def log_message(self, message: str, *args: object) -> None: + """Keep untrusted HTTP request data out of the terminal.""" + + def _request_is_allowed(self) -> bool: + try: + target = urlsplit(self.path) + except ValueError: + return False + if target.scheme or target.netloc or target.fragment: + return False + raw_path = unquote(target.path) + if raw_path == "/": + return "index.html" in self.allowed_paths + if not raw_path.startswith("/") or raw_path.endswith("/"): + return False + segments = raw_path[1:].split("/") + if any(segment in ("", ".", "..") for segment in segments): + return False + relative = "/".join(segments) + return relative in self.allowed_paths + + +ServerFactory = Callable[..., ThreadingHTTPServer] + + +def serve( + directory: Path, + *, + lan: bool, + port: int, + output: TextIO = sys.stdout, + server_factory: ServerFactory = ThreadingHTTPServer, + address_provider: Callable[[], tuple[str, ...]] = discover_network_addresses, +) -> None: + artifact = validate_artifact(directory) + host = "0.0.0.0" if lan else "127.0.0.1" + handler = partial( + FlyoverRequestHandler, + directory=str(artifact.root), + allowed_paths=artifact.allowed_paths, + ) + try: + server = server_factory((host, port), handler) + except OSError as error: + raise FlyoverArtifactError( + f"could not start the preview server on {host}:{port}: {error}" + ) from error + + with server: + selected_port = int(server.server_address[1]) + print(f"Flyover preview: {artifact.root}", file=output) + print(f"Local: http://127.0.0.1:{selected_port}/", file=output) + if lan: + addresses = address_provider() + for address in addresses: + print(f"Network: http://{address}:{selected_port}/", file=output) + if not addresses: + print( + "Network: bound to all interfaces, but no LAN address was found.", + file=output, + ) + print("Warning: LAN preview has no authentication or TLS.", file=output) + print("Press Ctrl-C to stop.", file=output, flush=True) + try: + server.serve_forever() + except KeyboardInterrupt: + pass + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(add_help=False) + subparsers = parser.add_subparsers(dest="command", required=True) + + validate = subparsers.add_parser("validate", add_help=False) + validate.add_argument("directory", type=Path) + validate.add_argument("--without-marker", action="store_true") + + preview = subparsers.add_parser("serve", add_help=False) + preview.add_argument("directory", type=Path) + preview.add_argument("--port", type=int, required=True) + preview.add_argument("--lan", action="store_true") + return parser + + +def main(arguments: list[str] | None = None) -> int: + try: + options = _parser().parse_args(arguments) + if options.command == "validate": + validate_artifact( + options.directory, + require_marker=not options.without_marker, + ) + elif options.command == "serve": + serve(options.directory, lan=options.lan, port=options.port) + return 0 + except FlyoverArtifactError as error: + print(f"flyover: {error}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/flyover b/flyover index c0da4e087..14180c035 100755 --- a/flyover +++ b/flyover @@ -8,14 +8,56 @@ PROFILES=() STAGING="" BACKUP="" DESTINATION="" +COMMAND="" +PREVIEW_LAN=false +PREVIEW_PORT=0 usage() { cat <<'USAGE' Usage: ./flyover export [options] + ./flyover preview [options] ./flyover --help -Export Where's native Flyover catalog as a portable static website. +Export or preview Where's native Flyover catalog as a static website. + +Commands: + export Capture the catalog and write a portable website. + preview Serve an existing export on a local web server. + +Export 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. + +Preview options: + --output DIR Existing generated directory (default: .build/flyover/where) + --port PORT TCP port. The default selects a free port. + --lan Also serve on this computer's local network interfaces. + +General options: + -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 + ./flyover preview + ./flyover preview --lan + ./flyover preview --output /tmp/where-flyover --lan --port 8080 +USAGE +} + +export_usage() { + cat <<'USAGE' +Usage: ./flyover export [options] + +Capture Where's native Flyover catalog as a portable static website. Options: --output DIR Generated website directory (default: .build/flyover/where) @@ -35,6 +77,23 @@ Examples: USAGE } +preview_usage() { + cat <<'USAGE' +Usage: ./flyover preview [options] + +Serve an existing Flyover export on a local web server. + +Options: + --output DIR Existing generated directory (default: .build/flyover/where) + --port PORT TCP port from 0 through 65535. The default 0 selects a free port. + --lan Also bind to local network interfaces and print network URLs. + -h, --help Show this help. + +The default server accepts connections from this computer only. LAN preview +has no authentication or TLS. Press Ctrl-C to stop the server. +USAGE +} + error() { echo "flyover: $*" >&2 exit 1 @@ -84,8 +143,14 @@ validate_destination() { if [ -e "$DESTINATION" ] || [ -L "$DESTINATION" ]; then [ -d "$DESTINATION" ] \ || error "refusing to replace non-directory '$DESTINATION'" - [ -f "$DESTINATION/.flyover-generated" ] \ + local marker="$DESTINATION/.flyover-generated" marker_content + [ ! -L "$marker" ] \ + || error "refusing to replace directory with a symbolic-link marker '$DESTINATION'" + [ -f "$marker" ] \ || error "refusing to replace unmarked directory '$DESTINATION'" + marker_content="$(< "$marker")" + [ "$marker_content" = "schemaVersion=1" ] \ + || error "refusing to replace directory with an unsupported marker '$DESTINATION'" fi } @@ -105,6 +170,11 @@ case "${1-}" in exit 0 ;; export) + COMMAND="export" + shift + ;; + preview) + COMMAND="preview" shift ;; "") @@ -114,6 +184,58 @@ case "${1-}" in *) error "unknown command '${1-}' (see ./flyover --help)" ;; esac +if [ "$COMMAND" = preview ]; then + while [ "$#" -gt 0 ]; do + case "$1" in + --output) + shift + [ "$#" -gt 0 ] || error "--output requires a directory" + OUTPUT="$1" + ;; + --port) + shift + [ "$#" -gt 0 ] || error "--port requires a number" + PREVIEW_PORT="$1" + ;; + --lan) + PREVIEW_LAN=true + ;; + -h | --help) + preview_usage + exit 0 + ;; + *) error "unknown preview option '$1' (see ./flyover preview --help)" ;; + esac + shift + done + + case "$PREVIEW_PORT" in + "" | *[!0-9]*) error "--port must be a number from 0 through 65535" ;; + esac + [ "$PREVIEW_PORT" -le 65535 ] \ + || error "--port must be a number from 0 through 65535" + + DESTINATION="$(resolve_path "$OUTPUT")" + HOME_PATH="$(resolve_path "$HOME")" + WORKSPACE_PATH="$(resolve_path "$ROOT")" + paths_are_same "$DESTINATION" / && error "refusing to serve the filesystem root" + paths_are_same "$DESTINATION" "$HOME_PATH" && error "refusing to serve the home directory" + paths_are_same "$DESTINATION" "$WORKSPACE_PATH" \ + && error "refusing to serve the workspace root" + + PREVIEW_ARGUMENTS=( + "$ROOT/Tools/flyover_preview.py" + serve + "$DESTINATION" + --port + "$PREVIEW_PORT" + ) + if [ "$PREVIEW_LAN" = true ]; then + PREVIEW_ARGUMENTS+=(--lan) + fi + exec "${FLYOVER_PREVIEW_PYTHON:-python3}" "${PREVIEW_ARGUMENTS[@]}" +fi + while [ "$#" -gt 0 ]; do case "$1" in --output) @@ -127,7 +249,7 @@ while [ "$#" -gt 0 ]; do add_profile "$1" ;; -h | --help) - usage + export_usage exit 0 ;; *) error "unknown option '$1' (see ./flyover --help)" ;; @@ -182,30 +304,7 @@ env \ "$CAPTURE_RUNNER" --only \ 'WhereUISnapshotTests/WhereFlyoverWebExportTests/exportsRequestedAtlas()' -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 +python3 "$ROOT/Tools/flyover_preview.py" validate "$STAGING" --without-marker printf '%s\n' 'schemaVersion=1' >"$STAGING/.flyover-generated" find "$STAGING" -type d -exec chmod 0755 {} + From b21f3ac5db89c1e968864c4bcbbbca1db34a565d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 4 Sep 2026 18:01:19 -0700 Subject: [PATCH 10/15] Load every visible Flyover screenshot --- Shared/Flyover/AGENTS.md | 1 + Shared/Flyover/README.md | 6 +- Shared/Flyover/Tools/Tests/flyover_test.sh | 206 +++++++++++++++++++++ Shared/Flyover/Web/assets/app.js | 35 +++- 4 files changed, 238 insertions(+), 10 deletions(-) diff --git a/Shared/Flyover/AGENTS.md b/Shared/Flyover/AGENTS.md index 6034b3226..bad29dc97 100644 --- a/Shared/Flyover/AGENTS.md +++ b/Shared/Flyover/AGENTS.md @@ -30,6 +30,7 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, format - **Type erase only at the heterogeneous content/control registry boundary.** - **Validate every stable screen and variant identifier before capture.** Use generated ordinals for image paths. - **Serve only a validated generated artifact.** Bind to loopback unless the user selects LAN access. +- **Load every visible web screenshot.** Apply the residency limits only to offscreen preload candidates. - **Preserve snapshot-backed capture intent.** Reject mixed sizing matrices unless the app supplies an explicit export policy. - **Fail full-content export when sizing does not converge.** Never publish a viewport fallback. diff --git a/Shared/Flyover/README.md b/Shared/Flyover/README.md index 8fe7d0d1b..a2fe32d13 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -218,8 +218,10 @@ dock keeps the canvas visible. The group panel and overview map move between groups without recalculating the graph. The canvas keeps its position when a state, profile, or panel changes. -Canvas and list views give active image sources to at most six nearby screenshots. -They target 24 million pixels and always show the nearest screenshot. +Canvas and list views give an active image source to every visible screenshot. +The list can also preload nearby screenshots. This preload targets six active +images and 24 million pixels. +Visible screenshots override both targets. The inspector removes these sources while it shows one full-resolution capture. Point to or focus a card to emphasize its connected routes. The site dims diff --git a/Shared/Flyover/Tools/Tests/flyover_test.sh b/Shared/Flyover/Tools/Tests/flyover_test.sh index ec1a288a7..95c1c6823 100755 --- a/Shared/Flyover/Tools/Tests/flyover_test.sh +++ b/Shared/Flyover/Tools/Tests/flyover_test.sh @@ -407,3 +407,209 @@ 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 + +JSC="/System/Library/Frameworks/JavaScriptCore.framework/Versions/A/Helpers/jsc" +[ -x "$JSC" ] || fail "JavaScriptCore is unavailable" +python3 - "$ROOT/Shared/Flyover/Web/assets/app.js" "$TEMP/residency-test.js" <<'PY' +import pathlib +import sys +import textwrap + +source = pathlib.Path(sys.argv[1]).read_text() +settings_start_marker = ' const targetResidentImageCount = ' +settings_end_marker = '\n let inspectorResizeObserver' +start_marker = ' function residentScreenIDs(candidates) {' +end_marker = '\n\n function installCanvasPinch(viewport) {' +fit_start_marker = ' function fitFrame(frame, behavior = "smooth") {' +fit_end_marker = '\n\n function fitAll(behavior = "smooth") {' +settings_start = source.index(settings_start_marker) +settings_end = source.index(settings_end_marker, settings_start) +start = source.index(start_marker) +end = source.index(end_marker, start) +fit_start = source.index(fit_start_marker) +fit_end = source.index(fit_end_marker, fit_start) +settings = textwrap.dedent(source[settings_start:settings_end]) +residency = textwrap.dedent(source[start:end]) +fit_frame = textwrap.dedent(source[fit_start:fit_end]) +test = ''' +%s +%s +%s + +let document; +let manifest; +let state; +let screenByID; +const window = { + matchMedia: () => ({ matches: true }), +}; + +function requestAnimationFrame(callback) { + callback(); + return 1; +} + +function matchesFilters() { + return true; +} + +function imageMetadata(screen) { + return screen.imageMetadata; +} + +function applyZoom() {} + +function candidate(id, isVisible, imagePixels) { + return { screen: { id }, isVisible, imagePixels }; +} + +function expect(condition, message) { + if (!condition) throw new Error(message); +} + +function expectIDs(actual, expected, message) { + const actualIDs = Array.from(actual); + if (JSON.stringify(actualIDs) !== JSON.stringify(expected)) { + throw new Error(message + ': ' + JSON.stringify(actualIDs)); + } +} + +const visible = Array.from( + { length: 20 }, + (_, index) => candidate('visible-' + index, true, 5_000_000), +); +expectIDs( + residentScreenIDs(visible), + visible.map(item => item.screen.id), + 'all visible screens must remain resident above both budgets', +); + +const mixed = [ + ...Array.from({ length: 10 }, (_, index) => candidate('nearby-' + index, false, 1_000_000)), + candidate('visible-a', true, 1_000_000), + candidate('visible-b', true, 1_000_000), +]; +expectIDs( + residentScreenIDs(mixed), + ['visible-a', 'visible-b', 'nearby-0', 'nearby-1', 'nearby-2', 'nearby-3'], + 'offscreen prefetch must stop at the resident count budget', +); + +const largeNearby = Array.from( + { length: 8 }, + (_, index) => candidate('large-' + index, false, 5_000_000), +); +expectIDs( + residentScreenIDs(largeNearby), + ['large-0', 'large-1', 'large-2', 'large-3'], + 'offscreen prefetch must stop at the resident pixel budget', +); + +function imageContainer(screenID, initiallyResident = false) { + const attributes = new Map(); + if (initiallyResident) attributes.set('src', screenID + '.png'); + const image = { + dataset: { src: screenID + '.png' }, + loading: 'lazy', + getAttribute: name => attributes.get(name) || null, + removeAttribute: name => attributes.delete(name), + set src(value) { attributes.set('src', value); }, + get src() { return attributes.get('src'); }, + }; + return { + dataset: { screenId: screenID }, + image, + querySelector: () => image, + }; +} + +const canvasScreens = Array.from({ length: 20 }, (_, index) => ({ + id: 'canvas-' + index, + frame: { x: 10, y: 10, width: 20, height: 20 }, + imageMetadata: { pixelWidth: 2_500, pixelHeight: 2_000 }, +})); +const canvasContainers = canvasScreens.map(screen => imageContainer(screen.id)); +const canvasViewport = { + clientHeight: 100, + clientWidth: 100, + scrollLeft: 0, + scrollTop: 0, +}; +manifest = { screens: canvasScreens }; +state = { screen: null, view: 'canvas', zoom: 1 }; +document = { + getElementById: id => id === 'canvas-viewport' ? canvasViewport : null, + querySelectorAll: selector => selector === '.screen-card' ? canvasContainers : [], +}; +updateCanvasImageResidency(); +expect( + canvasContainers.every(container => container.image.getAttribute('src') !== null), + 'all 20 visible canvas cards must receive an image source', +); +expect( + canvasContainers.every(container => container.image.loading === 'eager'), + 'visible canvas images must load eagerly', +); + +const visibleListScreens = Array.from({ length: 20 }, (_, index) => ({ + id: 'list-visible-' + index, + imageMetadata: { pixelWidth: 2_500, pixelHeight: 2_000 }, + screenOrder: index, +})); +const nearbyListScreens = Array.from({ length: 10 }, (_, index) => ({ + id: 'list-nearby-' + index, + imageMetadata: { pixelWidth: 1_000, pixelHeight: 1_000 }, + screenOrder: visibleListScreens.length + index, +})); +const listRows = [ + ...visibleListScreens.map(screen => ({ + ...imageContainer(screen.id), + getBoundingClientRect: () => ({ top: 10, bottom: 90 }), + })), + ...nearbyListScreens.map((screen, index) => ({ + ...imageContainer(screen.id, true), + getBoundingClientRect: () => ({ top: 101 + index, bottom: 111 + index }), + })), +]; +const listViewport = { + getBoundingClientRect: () => ({ top: 0, bottom: 100 }), +}; +screenByID = new Map( + [...visibleListScreens, ...nearbyListScreens].map(screen => [screen.id, screen]), +); +state = { screen: null, view: 'list' }; +document = { + querySelector: selector => selector === '.list-view' ? listViewport : null, + querySelectorAll: selector => selector === '.list-row' ? listRows : [], +}; +updateListImageResidency(); +expect( + listRows.slice(0, 20).every(row => row.image.getAttribute('src') !== null), + 'all 20 visible list rows must receive an image source', +); +expect( + listRows.slice(20).every(row => row.image.getAttribute('src') === null), + 'offscreen list preloads must yield when visible rows exceed the targets', +); + +let residencyRefreshCount = 0; +const fitViewport = { + clientHeight: 600, + clientWidth: 800, + scrollTo: () => {}, +}; +state = { canvas: {}, zoom: 1 }; +document = { + getElementById: id => id === 'canvas-viewport' ? fitViewport : null, +}; +updateCanvasImageResidency = () => { residencyRefreshCount += 1; }; +fitFrame({ x: 0, y: 0, width: 1_600, height: 1_200 }, 'auto'); +expect( + residencyRefreshCount === 1, + 'fitting the canvas must refresh image residency after changing zoom', +); +''' % (settings, residency, fit_frame) +pathlib.Path(sys.argv[2]).write_text(test) +PY +"$JSC" "$TEMP/residency-test.js" \ + || fail "the web thumbnail residency policy is incorrect" diff --git a/Shared/Flyover/Web/assets/app.js b/Shared/Flyover/Web/assets/app.js index 44f60e994..5baf85630 100644 --- a/Shared/Flyover/Web/assets/app.js +++ b/Shared/Flyover/Web/assets/app.js @@ -18,8 +18,8 @@ ])); const defaultView = "canvas"; const defaultProfile = manifest.profiles[0]?.id; - const maximumResidentImageCount = 6; - const maximumResidentPixelCount = 24_000_000; + const targetResidentImageCount = 6; + const targetResidentPixelCount = 24_000_000; let inspectorResizeObserver = null; const selectedVariants = new Map(manifest.screens.map(screen => [screen.id, screen.variants[0]?.id])); const state = { @@ -1458,16 +1458,25 @@ const result = new Set(); let pixels = 0; for (const candidate of candidates) { - if (result.size >= maximumResidentImageCount) break; - const metadata = imageMetadata(candidate.screen); - const imagePixels = metadata ? metadata.pixelWidth * metadata.pixelHeight : 0; - if (result.size > 0 && pixels + imagePixels > maximumResidentPixelCount) continue; + if (!candidate.isVisible) continue; result.add(candidate.screen.id); - pixels += imagePixels; + pixels += candidate.imagePixels; + } + for (const candidate of candidates) { + if (candidate.isVisible) continue; + if (result.size >= targetResidentImageCount) break; + if (result.size > 0 && pixels + candidate.imagePixels > targetResidentPixelCount) continue; + result.add(candidate.screen.id); + pixels += candidate.imagePixels; } return result; } + function screenImagePixels(screen) { + const metadata = imageMetadata(screen); + return metadata ? metadata.pixelWidth * metadata.pixelHeight : 0; + } + function updateCanvasImageResidency() { const viewport = document.getElementById("canvas-viewport"); if (!viewport || state.view !== "canvas") return; @@ -1489,6 +1498,8 @@ .filter(screen => matchesFilters(screen) && rectIntersects(screen.frame, visibleRect)) .map(screen => ({ screen, + imagePixels: screenImagePixels(screen), + isVisible: true, distance: squaredDistance(screen.frame.x + screen.frame.width / 2, screen.frame.y + screen.frame.height / 2, center.x, center.y), })) @@ -1522,7 +1533,12 @@ if (!screen || !matchesFilters(screen)) continue; const frame = row.getBoundingClientRect(); if (frame.bottom < viewport.top - 120 || frame.top > viewport.bottom + 120) continue; - candidates.push({ screen, distance: Math.abs((frame.top + frame.bottom) / 2 - centerY) }); + candidates.push({ + screen, + imagePixels: screenImagePixels(screen), + isVisible: frame.bottom > viewport.top && frame.top < viewport.bottom, + distance: Math.abs((frame.top + frame.bottom) / 2 - centerY), + }); } candidates.sort((lhs, rhs) => lhs.distance - rhs.distance || lhs.screen.screenOrder - rhs.screen.screenOrder); updateResidentImages(".list-row", residentScreenIDs(candidates)); @@ -1533,9 +1549,11 @@ const image = container.querySelector("img[data-src]"); if (!image) continue; if (residentIDs.has(container.dataset.screenId)) { + image.loading = "eager"; if (image.getAttribute("src") !== image.dataset.src) image.src = image.dataset.src; } else { image.removeAttribute("src"); + image.loading = "lazy"; } } } @@ -1665,6 +1683,7 @@ viewport.scrollTo({ left, top, behavior: prefersReducedMotion ? "auto" : behavior }); state.canvas.scrollLeft = Math.max(0, left); state.canvas.scrollTop = Math.max(0, top); + updateCanvasImageResidency(); } function fitAll(behavior = "smooth") { From 26315d1f8189150e2b09976316657a6bc1bb93d2 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 4 Sep 2026 19:35:14 -0700 Subject: [PATCH 11/15] Prevent accidental Flyover page selection --- Shared/Flyover/README.md | 4 ++ Shared/Flyover/Tools/Tests/flyover_test.sh | 58 ++++++++++++++++++++++ Shared/Flyover/Web/assets/app.js | 12 +++-- Shared/Flyover/Web/assets/styles.css | 18 +++++++ 4 files changed, 88 insertions(+), 4 deletions(-) diff --git a/Shared/Flyover/README.md b/Shared/Flyover/README.md index a2fe32d13..9180bde57 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -238,6 +238,10 @@ drawer supplies capture data and route links. Full-content images use a device-width scroll area. The Fit and 100% controls change the image scale without changing the capture. +Atlas controls, labels, and screenshots do not support browser text selection +or image dragging. Search text, screen titles, error details, and metadata +values remain selectable. + The browser hash stores the view, screen, state, and profile. Browser Back and Forward restore these values. The site also supplies these keyboard controls: diff --git a/Shared/Flyover/Tools/Tests/flyover_test.sh b/Shared/Flyover/Tools/Tests/flyover_test.sh index 95c1c6823..8b9b5eee3 100755 --- a/Shared/Flyover/Tools/Tests/flyover_test.sh +++ b/Shared/Flyover/Tools/Tests/flyover_test.sh @@ -408,6 +408,64 @@ if grep -R -E 'fetch\(|https?://' "$ROOT/Shared/Flyover/Web" \ fail "the web shell contains a network dependency" fi +python3 - "$ROOT/Shared/Flyover/Web/assets/styles.css" \ + "$ROOT/Shared/Flyover/Web/assets/app.js" <<'PY' +import pathlib +import re +import sys + +styles = pathlib.Path(sys.argv[1]).read_text() +javascript = pathlib.Path(sys.argv[2]).read_text() +properties_by_selector = {} +for selector_group, declaration_group in re.findall(r'([^{}]+)\{([^{}]*)\}', styles): + declarations = {} + for declaration in declaration_group.split(';'): + if ':' not in declaration: + continue + name, value = declaration.split(':', 1) + declarations[name.strip()] = value.strip() + for selector in selector_group.split(','): + properties_by_selector.setdefault(selector.strip(), {}).update(declarations) + +def require_properties(selector, expected): + actual = properties_by_selector.get(selector, {}) + for name, value in expected.items(): + if actual.get(name) != value: + raise SystemExit(f'{selector} must set {name}: {value}') + +for selector in ('#app', '#app > dialog'): + require_properties(selector, { + '-webkit-user-select': 'none', + 'user-select': 'none', + }) +for selector in ( + '#app input', + '#app textarea', + '#app [contenteditable="true"]', + '#app .selectable-text', +): + require_properties(selector, { + '-webkit-user-select': 'text', + 'user-select': 'text', + }) +require_properties('#app img', {'-webkit-user-drag': 'none'}) + +factory_start = javascript.index(' function screenImage(screen, className = "", eager = false) {') +factory_end = javascript.index('\n\n function captureViewportSize(screen) {', factory_start) +factory = javascript[factory_start:factory_end] +if 'image.draggable = false;' not in factory: + raise SystemExit('screen images must disable native dragging') + +for fragment in ( + 'class="error selectable-text"', + 'element("dd", "selectable-text", value)', + 'element("h2", "selectable-text", screen.title)', + 'element("h3", "selectable-text", screen.title)', +): + if fragment not in javascript: + raise SystemExit('missing intentional text-selection surface: ' + fragment) +PY + JSC="/System/Library/Frameworks/JavaScriptCore.framework/Versions/A/Helpers/jsc" [ -x "$JSC" ] || fail "JavaScriptCore is unavailable" python3 - "$ROOT/Shared/Flyover/Web/assets/app.js" "$TEMP/residency-test.js" <<'PY' diff --git a/Shared/Flyover/Web/assets/app.js b/Shared/Flyover/Web/assets/app.js index 5baf85630..6f4fbc4a3 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 = '

Manifest error

Flyover cannot open this atlas

This site requires manifest schema version 1.

'; + root.innerHTML = '

Manifest error

Flyover cannot open this atlas

This site requires manifest schema version 1.

'; return; } @@ -251,7 +251,7 @@ function metadataRow(label, value) { const row = element("div", "metadata-row"); - row.append(element("dt", "", label), element("dd", "", value)); + row.append(element("dt", "", label), element("dd", "selectable-text", value)); return row; } @@ -411,6 +411,7 @@ function screenImage(screen, className = "", eager = false) { const image = element("img", className); + image.draggable = false; image.loading = eager ? "eager" : "lazy"; image.decoding = "async"; const source = imagePath(screen); @@ -726,7 +727,7 @@ heading.append( element("p", "eyebrow", (group?.title || "Ungrouped") + " · " + (screenIndex + 1) + " of " + manifest.screens.length), - element("h2", "", screen.title), + element("h2", "selectable-text", screen.title), ); heading.querySelector("h2").id = "inspector-title"; const actions = element("div", "inspector-header-actions"); @@ -780,7 +781,10 @@ drawer.setAttribute("aria-hidden", String(!state.inspectorDetails)); drawer.inert = !state.inspectorDetails; const drawerHeader = element("header", "details-header"); - drawerHeader.append(element("p", "eyebrow", "Screen details"), element("h3", "", screen.title)); + drawerHeader.append( + element("p", "eyebrow", "Screen details"), + element("h3", "selectable-text", screen.title), + ); const drawerClose = iconButton("close", "Close details", "icon-button details-close"); drawerClose.addEventListener("click", toggleInspectorDetails); drawerHeader.append(drawerClose); diff --git a/Shared/Flyover/Web/assets/styles.css b/Shared/Flyover/Web/assets/styles.css index 0bdcb0e42..f000968f8 100644 --- a/Shared/Flyover/Web/assets/styles.css +++ b/Shared/Flyover/Web/assets/styles.css @@ -57,6 +57,24 @@ body, margin: 0; } +#app, +#app > dialog { + -webkit-user-select: none; + user-select: none; +} + +#app input, +#app textarea, +#app [contenteditable="true"], +#app .selectable-text { + -webkit-user-select: text; + user-select: text; +} + +#app img { + -webkit-user-drag: none; +} + body { overflow: hidden; background: var(--background); From 0975d222abc80635169c2d97f0a213cbcfb891b9 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 6 Sep 2026 18:00:39 -0700 Subject: [PATCH 12/15] Export light and dark Flyover profiles by default --- Shared/Flyover/README.md | 4 ++-- Shared/Flyover/Sources/FlyoverCaptureProfile.swift | 5 ++--- Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift | 8 ++++++-- Shared/Flyover/Tools/Tests/flyover_test.sh | 11 +++++++++++ flyover | 7 ++++--- 5 files changed, 25 insertions(+), 10 deletions(-) diff --git a/Shared/Flyover/README.md b/Shared/Flyover/README.md index 9180bde57..a09b9ed1b 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -161,8 +161,8 @@ The built-in IDs are: - `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. +`phone-light` followed by `phone-dark`. Fixed Flyover viewports keep their size +while profile traits still apply. Run Where's exporter from the repository root: diff --git a/Shared/Flyover/Sources/FlyoverCaptureProfile.swift b/Shared/Flyover/Sources/FlyoverCaptureProfile.swift index 40ac8c801..465e86a1d 100644 --- a/Shared/Flyover/Sources/FlyoverCaptureProfile.swift +++ b/Shared/Flyover/Sources/FlyoverCaptureProfile.swift @@ -38,8 +38,7 @@ } public static func parse(_ identifiers: [String]) throws -> [Self] { - let requested = identifiers.isEmpty ? [phoneLight.rawValue] : identifiers - return try orderedUnique(requested.map { identifier in + try orderedUnique(identifiers.map { identifier in guard let profile = Self(rawValue: identifier) else { throw FlyoverExportError.unknownProfile(identifier) } @@ -48,7 +47,7 @@ } static func orderedUnique(_ requestedProfiles: [Self]) -> [Self] { - let profiles = requestedProfiles.isEmpty ? [.phoneLight] : requestedProfiles + let profiles = requestedProfiles.isEmpty ? [.phoneLight, .phoneDark] : requestedProfiles var seen: Set = [] return profiles.filter { seen.insert($0).inserted } } diff --git a/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift b/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift index ab15a0ff6..3bdae5668 100644 --- a/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift +++ b/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift @@ -3,8 +3,12 @@ import Testing struct FlyoverCaptureProfileTests { - @Test func emptyRequestUsesPhoneLight() throws { - #expect(try FlyoverCaptureProfile.parse([]) == [.phoneLight]) + @Test func emptyRequestUsesLightAndDarkPhones() throws { + #expect(try FlyoverCaptureProfile.parse([]) == [.phoneLight, .phoneDark]) + } + + @Test func emptyTypedRequestUsesLightAndDarkPhones() { + #expect(FlyoverCaptureProfile.orderedUnique([]) == [.phoneLight, .phoneDark]) } @Test func preservesFirstOccurrenceOrderAndRemovesDuplicates() throws { diff --git a/Shared/Flyover/Tools/Tests/flyover_test.sh b/Shared/Flyover/Tools/Tests/flyover_test.sh index 8b9b5eee3..81bae1cca 100755 --- a/Shared/Flyover/Tools/Tests/flyover_test.sh +++ b/Shared/Flyover/Tools/Tests/flyover_test.sh @@ -77,6 +77,8 @@ chmod +x "$FAILURE_RUNNER" "$ROOT/flyover" --help | grep -q 'flyover preview' || fail "preview help is incomplete" "$ROOT/flyover" export --help | grep -q 'phone-voiceover' \ || fail "export help does not list the supported profiles" +"$ROOT/flyover" export --help | grep -q 'defaults are phone-light and phone-dark' \ + || fail "export help does not list both default profiles" "$ROOT/flyover" preview --help | grep -q 'no authentication or TLS' \ || fail "preview help does not explain LAN exposure" expect_failure "$ROOT/flyover" export --profile system @@ -159,6 +161,15 @@ mkdir -p "$CALLER" ) [ -f "$CALLER/.build/flyover/where/.flyover-generated" ] \ || fail "default output path did not resolve from the caller directory" +python3 - "$CALLER/.build/flyover/where/manifest.json" <<'PY' +import json, pathlib, sys +manifest = json.loads(pathlib.Path(sys.argv[1]).read_text()) +profiles = [item['id'] for item in manifest['profiles']] +if profiles != ['phone-light', 'phone-dark']: + raise SystemExit(f'default profiles are wrong: {profiles}') +if len(manifest['images']) != 2: + raise SystemExit(f'default image count is wrong: {len(manifest["images"])}') +PY OUTPUT="$TEMP/output" FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ diff --git a/flyover b/flyover index 14180c035..897f9709a 100755 --- a/flyover +++ b/flyover @@ -3,6 +3,7 @@ set -euo pipefail ROOT="$(cd "$(dirname "$0")" && pwd -P)" DEFAULT_OUTPUT=".build/flyover/where" +DEFAULT_PROFILES=(phone-light phone-dark) OUTPUT="$DEFAULT_OUTPUT" PROFILES=() STAGING="" @@ -28,7 +29,7 @@ Commands: Export 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. + The defaults are phone-light and phone-dark. Preview options: --output DIR Existing generated directory (default: .build/flyover/where) @@ -62,7 +63,7 @@ Capture 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. + The defaults are phone-light and phone-dark. -h, --help Show this help. Profiles: @@ -257,7 +258,7 @@ while [ "$#" -gt 0 ]; do shift done -[ "${#PROFILES[@]}" -gt 0 ] || PROFILES=(phone-light) +[ "${#PROFILES[@]}" -gt 0 ] || PROFILES=("${DEFAULT_PROFILES[@]}") DESTINATION="$(resolve_path "$OUTPUT")" HOME_PATH="$(resolve_path "$HOME")" WORKSPACE_PATH="$(resolve_path "$ROOT")" From 2df66ee7d3dffb88f2857c9835731a2de25e49d8 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Sun, 6 Sep 2026 18:33:41 -0700 Subject: [PATCH 13/15] Make Flyover preview shutdown reliable --- Tools/Tests/test_flyover_preview.py | 88 +++++++++++++++++++++++++++++ Tools/flyover_preview.py | 55 ++++++++++++++++-- 2 files changed, 137 insertions(+), 6 deletions(-) diff --git a/Tools/Tests/test_flyover_preview.py b/Tools/Tests/test_flyover_preview.py index 71b006965..7540cbe6d 100644 --- a/Tools/Tests/test_flyover_preview.py +++ b/Tools/Tests/test_flyover_preview.py @@ -1,8 +1,10 @@ import importlib.util import io import json +import signal import sys import tempfile +import threading import unittest from pathlib import Path @@ -220,6 +222,92 @@ def serve_forever(self): ], ) + def test_ctrl_c_stops_preview_and_restores_signal_handler(self): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + shutdown_called = threading.Event() + servers = [] + + class InterruptingOutput(io.StringIO): + def __init__(self): + super().__init__() + self.did_interrupt = False + + def flush(self): + super().flush() + if not self.did_interrupt: + self.did_interrupt = True + signal.raise_signal(signal.SIGINT) + + class FakeServer: + server_address = ("127.0.0.1", 53142) + + def __init__(self, *_): + self.serving_thread = None + self.shutdown_thread = None + servers.append(self) + + def __enter__(self): + return self + + def __exit__(self, *_): + return None + + def serve_forever(self): + self.serving_thread = threading.current_thread() + if not shutdown_called.wait(timeout=5): + raise AssertionError("preview shutdown was not requested") + + def shutdown(self): + self.shutdown_thread = threading.current_thread() + shutdown_called.set() + + previous_handler = signal.getsignal(signal.SIGINT) + + flyover_preview.serve( + root, + lan=False, + port=0, + output=InterruptingOutput(), + server_factory=FakeServer, + ) + + self.assertTrue(shutdown_called.is_set()) + self.assertIsNot(servers[0].serving_thread, servers[0].shutdown_thread) + self.assertEqual(signal.getsignal(signal.SIGINT), previous_handler) + + def test_server_failure_is_rethrown_and_restores_signal_handler(self): + with tempfile.TemporaryDirectory() as temporary: + root = self.fixture(Path(temporary)) + + class FakeServer: + server_address = ("127.0.0.1", 53142) + + def __init__(self, *_): + pass + + def __enter__(self): + return self + + def __exit__(self, *_): + return None + + def serve_forever(self): + raise RuntimeError("server failed") + + previous_handler = signal.getsignal(signal.SIGINT) + + with self.assertRaisesRegex(RuntimeError, "server failed"): + flyover_preview.serve( + root, + lan=False, + port=0, + output=io.StringIO(), + server_factory=FakeServer, + ) + + self.assertEqual(signal.getsignal(signal.SIGINT), previous_handler) + def test_lan_preview_prints_each_network_url_and_warning(self): with tempfile.TemporaryDirectory() as temporary: root = self.fixture(Path(temporary)) diff --git a/Tools/flyover_preview.py b/Tools/flyover_preview.py index 6a662e28a..88c90c738 100644 --- a/Tools/flyover_preview.py +++ b/Tools/flyover_preview.py @@ -5,13 +5,16 @@ import argparse import ipaddress import json +import signal import socket import sys +import threading +from contextlib import contextmanager from dataclasses import dataclass from functools import partial from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path, PurePosixPath -from typing import Callable, Iterable, TextIO +from typing import Callable, Iterable, Iterator, TextIO from urllib.parse import unquote, urlsplit @@ -250,6 +253,50 @@ def _request_is_allowed(self) -> bool: ServerFactory = Callable[..., ThreadingHTTPServer] +@contextmanager +def _serve_until_interrupted(server: ThreadingHTTPServer) -> Iterator[None]: + """Serve on a worker until the main process receives Ctrl-C.""" + stop_requested = threading.Event() + serving_finished = threading.Event() + serving_errors: list[BaseException] = [] + + def request_shutdown(_signal_number: int, _frame: object) -> None: + stop_requested.set() + + def serve_forever() -> None: + try: + server.serve_forever() + except BaseException as error: + serving_errors.append(error) + finally: + serving_finished.set() + stop_requested.set() + + server_thread = threading.Thread( + target=serve_forever, + name="flyover-preview-server", + daemon=True, + ) + previous_handler = signal.signal(signal.SIGINT, request_shutdown) + thread_started = False + try: + server_thread.start() + thread_started = True + yield + stop_requested.wait() + finally: + try: + if thread_started and not serving_finished.is_set(): + server.shutdown() + if thread_started: + server_thread.join() + finally: + signal.signal(signal.SIGINT, previous_handler) + + if serving_errors: + raise serving_errors[0] + + def serve( directory: Path, *, @@ -273,7 +320,7 @@ def serve( f"could not start the preview server on {host}:{port}: {error}" ) from error - with server: + with server, _serve_until_interrupted(server): selected_port = int(server.server_address[1]) print(f"Flyover preview: {artifact.root}", file=output) print(f"Local: http://127.0.0.1:{selected_port}/", file=output) @@ -288,10 +335,6 @@ def serve( ) print("Warning: LAN preview has no authentication or TLS.", file=output) print("Press Ctrl-C to stop.", file=output, flush=True) - try: - server.serve_forever() - except KeyboardInterrupt: - pass def _parser() -> argparse.ArgumentParser: From ef7c3e2f036c410e07a22f4842f7b958dcb3399f Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 7 Sep 2026 21:04:04 -0700 Subject: [PATCH 14/15] Fix final Flyover atlas review issues --- Shared/Flyover/AGENTS.md | 3 +- Shared/Flyover/README.md | 33 +- .../Sources/FlyoverCaptureProfile.swift | 11 + .../Flyover/Sources/FlyoverExportError.swift | 3 + .../Flyover/Sources/FlyoverWebExporter.swift | 75 +- .../Flyover/Sources/FlyoverWebManifest.swift | 3 + .../Flyover/Sources/FlyoverWebThumbnail.swift | 128 +++ .../Tests/FlyoverCaptureProfileTests.swift | 32 + .../Tests/FlyoverWebExporterTests.swift | 130 ++- .../Tests/FlyoverWebThumbnailTests.swift | 126 +++ Shared/Flyover/Tools/Tests/flyover_test.sh | 289 +++++- Shared/Flyover/Web/assets/app.js | 28 +- Shared/Flyover/Web/assets/styles.css | 3 +- Shared/SnapshotKit/AGENTS.md | 1 + Shared/SnapshotKit/README.md | 29 +- .../SnapshotConfiguration+Combinations.swift | 33 +- .../SnapshotConfiguration+Traits.swift | 37 +- .../Sources/SnapshotConfiguration.swift | 81 +- .../SnapshotKit/Sources/SnapshotTraits.swift | 45 +- .../Tests/SnapshotConfigurationTests.swift | 26 + .../Tests/SnapshotTraitsTests.swift | 36 + Shared/SnapshotKitTesting/README.md | 9 +- .../Sources/SnapshotHostingController.swift | 5 + .../Tests/SnapshotPNGTests.swift | 136 ++- Tools/README.md | 5 +- Tools/Tests/flyover_test_support.py | 121 +++ Tools/Tests/test_flyover_manifest.py | 242 +++++ Tools/Tests/test_flyover_preview.py | 171 +--- Tools/flyover_manifest.py | 838 ++++++++++++++++++ Tools/flyover_preview.py | 314 +++---- flyover | 38 +- 31 files changed, 2611 insertions(+), 420 deletions(-) create mode 100644 Shared/Flyover/Sources/FlyoverWebThumbnail.swift create mode 100644 Shared/Flyover/Tests/FlyoverWebThumbnailTests.swift create mode 100644 Shared/SnapshotKit/Tests/SnapshotTraitsTests.swift create mode 100644 Tools/Tests/flyover_test_support.py create mode 100644 Tools/Tests/test_flyover_manifest.py create mode 100644 Tools/flyover_manifest.py diff --git a/Shared/Flyover/AGENTS.md b/Shared/Flyover/AGENTS.md index bad29dc97..a457dd34b 100644 --- a/Shared/Flyover/AGENTS.md +++ b/Shared/Flyover/AGENTS.md @@ -30,7 +30,8 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, format - **Type erase only at the heterogeneous content/control registry boundary.** - **Validate every stable screen and variant identifier before capture.** Use generated ordinals for image paths. - **Serve only a validated generated artifact.** Bind to loopback unless the user selects LAN access. -- **Load every visible web screenshot.** Apply the residency limits only to offscreen preload candidates. +- **Load a thumbnail for every visible web screen.** Reserve full-resolution captures for the inspector and raw PNG link. +- **Apply web residency limits only to offscreen preload candidates.** - **Preserve snapshot-backed capture intent.** Reject mixed sizing matrices unless the app supplies an explicit export policy. - **Fail full-content export when sizing does not converge.** Never publish a viewport fallback. diff --git a/Shared/Flyover/README.md b/Shared/Flyover/README.md index a09b9ed1b..575a628f8 100644 --- a/Shared/Flyover/README.md +++ b/Shared/Flyover/README.md @@ -137,11 +137,17 @@ A generated source scan would add build ordering and cache invalidation complexi ## 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 +native PNG captures and card-size thumbnails. It also writes `manifest.json` +and `manifest.js`. The exporter derives each thumbnail from its hosted native +capture. It does not render the SwiftUI content again. The web shell reads `manifest.js`, so the atlas works from `file://` and any static host. The browser changes images and navigation state. It does not run SwiftUI or serialize `FlyoverControl` actions. +A thumbnail uses at most 1,024 pixels on its longest axis. A full-content +thumbnail shows the top device viewport. The inspector and raw PNG link use the +complete capture. + The exporter validates the complete plan before its first capture. The host provides one stable string for each typed screen ID and one capture closure. Stable IDs must be nonempty and unique. Variant IDs must also be nonempty and @@ -162,7 +168,8 @@ The built-in IDs are: The first profile is the initial web selection. An empty profile list becomes `phone-light` followed by `phone-dark`. Fixed Flyover viewports keep their size -while profile traits still apply. +while profile traits still apply. Tablet and landscape profiles also apply an +explicit interface idiom and size classes to adaptive content. Run Where's exporter from the repository root: @@ -175,7 +182,10 @@ Run Where's exporter from the repository root: 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. +successful atlas unchanged. Before replacement, the command validates all +schema fields, references, image mappings, and generated PNG files. A repeated +in-repository export excludes its prior generated directory from dirty-build +metadata. A Git status error stops the export. ### Preview the export @@ -197,6 +207,8 @@ Use `--lan` to open the preview to other devices on the local network: The command prints one URL for each network address that it finds. The other device must be able to reach this computer. A macOS firewall prompt can appear. +The server pins the validated directory for its lifetime. It does not follow a +symbolic link that replaces an allowed file or directory after startup. WARNING: LAN preview has no authentication or TLS. Any device that can reach the computer can view the native screenshots. Stop and restart the preview @@ -209,19 +221,20 @@ All site URLs are relative. The manifest compatibility boundary is `schemaVersion: 1`. It contains the application and build identity, profiles, precomputed canvas geometry, groups, screens, routes, and image metadata. It contains no local source or account -paths. Full-content sizing uses SnapshotKitTesting limits and convergence -rules. A sizing failure stops the export; it never substitutes a viewport -image. +paths. New image records include the optional thumbnail path and pixel size. +Older schema-1 artifacts remain readable. The web shell uses the full capture +when thumbnail metadata is absent. Full-content sizing uses SnapshotKitTesting +limits and convergence rules. A sizing error stops the export. The exporter +never substitutes a viewport image. The website opens the first catalog group in canvas mode. A floating control dock keeps the canvas visible. The group panel and overview map move between groups without recalculating the graph. The canvas keeps its position when a state, profile, or panel changes. -Canvas and list views give an active image source to every visible screenshot. -The list can also preload nearby screenshots. This preload targets six active -images and 24 million pixels. -Visible screenshots override both targets. +Canvas and list views give an active thumbnail source to every visible screen. +The list can also preload nearby thumbnails. This preload targets six active +images and 24 million thumbnail pixels. Visible screens override both targets. The inspector removes these sources while it shows one full-resolution capture. Point to or focus a card to emphasize its connected routes. The site dims diff --git a/Shared/Flyover/Sources/FlyoverCaptureProfile.swift b/Shared/Flyover/Sources/FlyoverCaptureProfile.swift index 465e86a1d..c29322284 100644 --- a/Shared/Flyover/Sources/FlyoverCaptureProfile.swift +++ b/Shared/Flyover/Sources/FlyoverCaptureProfile.swift @@ -128,6 +128,7 @@ contrast: contrast, layoutDirection: layoutDirection, legibilityWeight: legibilityWeight, + layoutTraits: layoutTraits, device: frame, snapshotType: snapshotType, ) @@ -150,6 +151,16 @@ self == .phoneDark ? .dark : .light } + private var layoutTraits: SnapshotConfiguration.LayoutTraits { + switch self { + case .tabletLight: .tabletPortrait + case .phoneLandscape: .phoneLandscape + case .phoneLight, .phoneDark, .phoneSmall, .phoneXXXL, + .phoneAX3, .phoneContrast, .phoneRTL, .phoneBold, + .phoneVoiceOver: .phonePortrait + } + } + private var dynamicType: DynamicTypeSize { switch self { case .phoneSmall: .small diff --git a/Shared/Flyover/Sources/FlyoverExportError.swift b/Shared/Flyover/Sources/FlyoverExportError.swift index 9c45800dd..e456aadcf 100644 --- a/Shared/Flyover/Sources/FlyoverExportError.swift +++ b/Shared/Flyover/Sources/FlyoverExportError.swift @@ -10,6 +10,7 @@ case emptyVariantIdentifier(screen: String) case duplicateVariantIdentifier(screen: String, variant: String) case mixedSizingPolicy(screen: String, variant: String, extents: [String]) + case measurementHookRequiresMeasuredSizing(screen: String, variant: String) case missingManifestGeometry(kind: String, identifier: String) case unknownProfile(String) case captureFailed( @@ -42,6 +43,8 @@ "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 .measurementHookRequiresMeasuredSizing(screen, variant): + "The export policy for \(screen) / \(variant) declares onReadyToMeasure, but viewport sizing has no measured-content phase." case let .missingManifestGeometry(kind, identifier): "The Flyover layout has no \(kind) geometry for \(identifier)." case let .unknownProfile(identifier): diff --git a/Shared/Flyover/Sources/FlyoverWebExporter.swift b/Shared/Flyover/Sources/FlyoverWebExporter.swift index 938018fae..3d2155ce7 100644 --- a/Shared/Flyover/Sources/FlyoverWebExporter.swift +++ b/Shared/Flyover/Sources/FlyoverWebExporter.swift @@ -1,5 +1,6 @@ #if DEBUG import Foundation + import SnapshotKit import SwiftUI /// Converts a typed Flyover catalog into a static manifest and PNG set. @@ -123,11 +124,45 @@ } let imageURL = directory.appending(path: relativePath) + let thumbnailRelativePath = String( + format: "images/screen-%04d/variant-%04d/%@-thumbnail.png", + prepared.screenOrdinal, + variantIndex + 1, + profile.rawValue, + ) + let thumbnail: FlyoverWebThumbnail + do { + thumbnail = try await FlyoverWebThumbnail.make( + from: captured.pngData, + pointSize: captured.pointSize, + viewportPointSize: configuration.thumbnailViewportPointSize, + ) + } catch is CancellationError { + throw CancellationError() + } catch { + throw FlyoverExportError.captureFailed( + group: prepared.groupTitle, + screen: prepared.screen.title, + variant: variant.title, + profile: profile.rawValue, + phase: "thumbnail generation", + reason: error.localizedDescription, + ) + } + try Task.checkCancellation() + let thumbnailURL = directory.appending(path: thumbnailRelativePath) do { try fileManager.createDirectory( at: imageURL.deletingLastPathComponent(), withIntermediateDirectories: true, ) + } catch { + throw FlyoverExportError.outputWriteFailed( + path: imageURL.deletingLastPathComponent().path, + reason: error.localizedDescription, + ) + } + do { try captured.pngData.write(to: imageURL, options: .atomic) } catch { throw FlyoverExportError.outputWriteFailed( @@ -135,6 +170,14 @@ reason: error.localizedDescription, ) } + do { + try thumbnail.pngData.write(to: thumbnailURL, options: .atomic) + } catch { + throw FlyoverExportError.outputWriteFailed( + path: thumbnailURL.path, + reason: error.localizedDescription, + ) + } let key = VariantKey( screenID: prepared.stableID, @@ -146,10 +189,13 @@ variantID: variant.id.rawValue, profileID: profile.rawValue, relativePath: relativePath, + thumbnailRelativePath: thumbnailRelativePath, pointWidth: Double(captured.pointSize.width), pointHeight: Double(captured.pointSize.height), pixelWidth: Int(captured.pixelSize.width.rounded()), pixelHeight: Int(captured.pixelSize.height.rounded()), + thumbnailPixelWidth: Int(thumbnail.pixelSize.width.rounded()), + thumbnailPixelHeight: Int(thumbnail.pixelSize.height.rounded()), scale: Double(captured.scale), captureExtent: policy.captureExtent.rawValue, )) @@ -169,9 +215,10 @@ ) try write(manifest: manifest, to: directory) let actualImageCount = try pngCount(in: imagesDirectory) - guard actualImageCount == images.count else { + let expectedImageCount = images.count * 2 + guard actualImageCount == expectedImageCount else { throw FlyoverExportError.assetCountMismatch( - expected: images.count, + expected: expectedImageCount, actual: actualImageCount, ) } @@ -247,9 +294,9 @@ _ variant: FlyoverVariant, screen: String, ) throws -> FlyoverExportPolicy { - switch variant.exportPolicyResolution { + let policy = switch variant.exportPolicyResolution { case let .policy(policy): - return policy + policy case let .mixed(extents): throw FlyoverExportError.mixedSizingPolicy( screen: screen, @@ -257,6 +304,13 @@ extents: extents.map(\.rawValue), ) } + guard policy.captureExtent != .viewport || policy.onReadyToMeasure == nil else { + throw FlyoverExportError.measurementHookRequiresMeasuredSizing( + screen: screen, + variant: variant.id.rawValue, + ) + } + return policy } private func prepareRoutes( @@ -529,6 +583,19 @@ } } + extension SnapshotConfiguration { + fileprivate var thumbnailViewportPointSize: CGSize? { + switch device.size { + case .fixed, .intrinsic: + nil + case let .fullContent(width, minimumHeight): + minimumHeight.map { CGSize(width: width, height: $0) } + case let .fullContent2D(minimumSize): + minimumSize + } + } + } + extension FlyoverCaptureProfile { fileprivate var manifestValue: FlyoverWebManifest.Profile { FlyoverWebManifest.Profile( diff --git a/Shared/Flyover/Sources/FlyoverWebManifest.swift b/Shared/Flyover/Sources/FlyoverWebManifest.swift index da0cf4ec5..912da65f5 100644 --- a/Shared/Flyover/Sources/FlyoverWebManifest.swift +++ b/Shared/Flyover/Sources/FlyoverWebManifest.swift @@ -89,10 +89,13 @@ public let variantID: String public let profileID: String public let relativePath: String + public let thumbnailRelativePath: String? public let pointWidth: Double public let pointHeight: Double public let pixelWidth: Int public let pixelHeight: Int + public let thumbnailPixelWidth: Int? + public let thumbnailPixelHeight: Int? public let scale: Double public let captureExtent: String } diff --git a/Shared/Flyover/Sources/FlyoverWebThumbnail.swift b/Shared/Flyover/Sources/FlyoverWebThumbnail.swift new file mode 100644 index 000000000..92b742d92 --- /dev/null +++ b/Shared/Flyover/Sources/FlyoverWebThumbnail.swift @@ -0,0 +1,128 @@ +#if DEBUG + import CoreGraphics + import Foundation + import ImageIO + + /// Builds a card-sized PNG from one hosted full-resolution capture. + struct FlyoverWebThumbnail { + static let maximumPixelDimension = 1024 + + let pngData: Data + let pixelSize: CGSize + + @concurrent + static func make( + from pngData: Data, + pointSize: CGSize, + viewportPointSize: CGSize?, + ) async throws -> Self { + try Task.checkCancellation() + guard let source = CGImageSourceCreateWithData(pngData as CFData, nil), + let sourceImage = CGImageSourceCreateImageAtIndex(source, 0, nil) + else { + throw FlyoverWebThumbnailError.invalidPNG + } + + let croppedImage = try crop( + sourceImage, + pointSize: pointSize, + viewportPointSize: viewportPointSize, + ) + let longestDimension = max(croppedImage.width, croppedImage.height) + let scale = min( + 1, + CGFloat(maximumPixelDimension) / CGFloat(max(longestDimension, 1)), + ) + let width = max(1, Int((CGFloat(croppedImage.width) * scale).rounded())) + let height = max(1, Int((CGFloat(croppedImage.height) * scale).rounded())) + + guard let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue, + ) + else { + throw FlyoverWebThumbnailError.couldNotCreateBitmap + } + context.interpolationQuality = .high + context.draw(croppedImage, in: CGRect(x: 0, y: 0, width: width, height: height)) + try Task.checkCancellation() + + guard let thumbnailImage = context.makeImage() else { + throw FlyoverWebThumbnailError.couldNotCreateBitmap + } + let output = NSMutableData() + guard let destination = CGImageDestinationCreateWithData( + output, + "public.png" as CFString, + 1, + nil, + ) else { + throw FlyoverWebThumbnailError.couldNotCreatePNG + } + CGImageDestinationAddImage(destination, thumbnailImage, nil) + guard CGImageDestinationFinalize(destination) else { + throw FlyoverWebThumbnailError.couldNotCreatePNG + } + return FlyoverWebThumbnail( + pngData: output as Data, + pixelSize: CGSize(width: width, height: height), + ) + } + + private static func crop( + _ image: CGImage, + pointSize: CGSize, + viewportPointSize: CGSize?, + ) throws -> CGImage { + guard let viewportPointSize else { + return image + } + guard pointSize.width > 0, pointSize.height > 0 else { + throw FlyoverWebThumbnailError.invalidPointSize + } + let width = min( + CGFloat(image.width), + viewportPointSize.width * CGFloat(image.width) / pointSize.width, + ) + let height = min( + CGFloat(image.height), + viewportPointSize.height * CGFloat(image.height) / pointSize.height, + ) + guard width > 0, height > 0, + let cropped = image.cropping(to: CGRect(x: 0, y: 0, width: width, height: height)) + else { + throw FlyoverWebThumbnailError.couldNotCrop + } + return cropped + } + } + + enum FlyoverWebThumbnailError: Error, Equatable, LocalizedError { + case invalidPNG + case invalidPointSize + case couldNotCrop + case couldNotCreateBitmap + case couldNotCreatePNG + + var errorDescription: String? { + switch self { + case .invalidPNG: + "The hosted capture is not a valid PNG." + case .invalidPointSize: + "The hosted capture has an invalid point size." + case .couldNotCrop: + "The capture viewport could not be cropped." + case .couldNotCreateBitmap: + "The thumbnail bitmap could not be created." + case .couldNotCreatePNG: + "The thumbnail PNG could not be encoded." + } + } + } +#endif diff --git a/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift b/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift index 3bdae5668..ad749c00b 100644 --- a/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift +++ b/Shared/Flyover/Tests/FlyoverCaptureProfileTests.swift @@ -1,5 +1,7 @@ #if DEBUG + import CoreGraphics @testable import Flyover + import SnapshotKit import Testing struct FlyoverCaptureProfileTests { @@ -26,5 +28,35 @@ try FlyoverCaptureProfile.parse(["system"]) } } + + @Test func deviceProfilesDeclareTruthfulAdaptiveLayoutTraits() { + let phone = FlyoverCaptureProfile.phoneLight.configuration( + viewport: .device, + captureExtent: .viewport, + ) + let tablet = FlyoverCaptureProfile.tabletLight.configuration( + viewport: .device, + captureExtent: .viewport, + ) + let landscape = FlyoverCaptureProfile.phoneLandscape.configuration( + viewport: .device, + captureExtent: .viewport, + ) + + #expect(phone.layoutTraits == .phonePortrait) + #expect(tablet.layoutTraits == .tabletPortrait) + #expect(landscape.layoutTraits == .phoneLandscape) + } + + @Test func fixedViewportRetainsItsSizeWhileProfileLayoutTraitsApply() { + let fixedSize = CGSize(width: 320, height: 180) + let configuration = FlyoverCaptureProfile.tabletLight.configuration( + viewport: .fixed(fixedSize), + captureExtent: .viewport, + ) + + #expect(configuration.device.size == .fixed(fixedSize)) + #expect(configuration.layoutTraits == .tabletPortrait) + } } #endif diff --git a/Shared/Flyover/Tests/FlyoverWebExporterTests.swift b/Shared/Flyover/Tests/FlyoverWebExporterTests.swift index 9d0376993..4e9df4137 100644 --- a/Shared/Flyover/Tests/FlyoverWebExporterTests.swift +++ b/Shared/Flyover/Tests/FlyoverWebExporterTests.swift @@ -18,6 +18,8 @@ title: "Test App", screenIdentifier: \FlyoverTestScreen.rawValue, ) + let pngData = try #require(Data(base64Encoded: + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=")) let summary = try await exporter.export( to: directory, @@ -25,7 +27,7 @@ build: build, ) { request in FlyoverCapturedImage( - pngData: Data([0x89, 0x50, 0x4E, 0x47]), + pngData: pngData, pointSize: request.configuration.device.testPointSize, pixelSize: CGSize(width: 1206, height: 2622), scale: 3, @@ -53,7 +55,16 @@ && FileManager.default.fileExists( atPath: directory.appending(path: image.relativePath).path, ) + && image.thumbnailRelativePath?.hasSuffix("-thumbnail.png") == true + && image.thumbnailRelativePath.map { path in + FileManager.default.fileExists( + atPath: directory.appending(path: path).path, + ) + } == true + && image.thumbnailPixelWidth == 1 + && image.thumbnailPixelHeight == 1 }) + #expect(try pngCount(in: directory) == 16) let script = try String( contentsOf: directory.appending(path: "manifest.js"), encoding: .utf8, @@ -121,6 +132,73 @@ #expect(captureCount == 0) } + @Test func rejectsAViewportMeasurementHookBeforeAnyEarlierVariantCaptures() async throws { + let invalidPolicy = FlyoverExportPolicy( + captureExtent: .viewport, + measurementReadiness: .sameAsCapture, + settle: .settled, + onReadyToMeasure: {}, + onReadyToSnapshot: nil, + ) + let screen = FlyoverScreen( + id: FlyoverTestScreen.root, + title: "Root", + variants: [ + FlyoverVariant( + id: FlyoverVariantID("valid"), + title: "Valid", + ) { EmptyView() }, + FlyoverVariant( + id: FlyoverVariantID("invalid"), + title: "Invalid", + exportPolicy: invalidPolicy, + ) { EmptyView() }, + ], + ) + 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 the invalid measurement hook to fail preflight.") + } catch let error as FlyoverExportError { + #expect(error == .measurementHookRequiresMeasuredSizing( + screen: "root", + variant: "invalid", + )) + } 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) } @@ -197,6 +275,56 @@ #expect(try pngCount(in: directory) == 0) } + @Test func reportsInvalidCapturedPNGAsAThumbnailGenerationFailure() async throws { + let directory = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directory) } + let catalog = FlyoverCatalog(groups: [ + FlyoverGroup( + id: FlyoverGroupID("main"), + title: "Main", + root: .root, + screens: [makeFlyoverTestScreen(.root, title: "Root")], + ), + ]) + let exporter = FlyoverWebExporter( + catalog: catalog, + applicationID: "test", + title: "Test", + screenIdentifier: \FlyoverTestScreen.rawValue, + ) + + do { + _ = try await exporter.export( + to: directory, + profiles: [.phoneLight], + build: build, + ) { _ in + FlyoverCapturedImage( + pngData: Data([1]), + pointSize: CGSize(width: 1, height: 1), + pixelSize: CGSize(width: 1, height: 1), + scale: 1, + ) + } + Issue.record("Expected thumbnail generation to reject the invalid PNG.") + } catch let error as FlyoverExportError { + #expect(error == .captureFailed( + group: "Main", + screen: "Root", + variant: "Default", + profile: "phone-light", + phase: "thumbnail generation", + reason: FlyoverWebThumbnailError.invalidPNG.localizedDescription, + )) + } catch { + Issue.record("Unexpected error: \(error)") + } + #expect(try pngCount(in: directory) == 0) + #expect(FileManager.default.fileExists( + atPath: directory.appending(path: "manifest.json").path, + ) == false) + } + private var build: FlyoverExportBuild { FlyoverExportBuild( commit: "abc123", diff --git a/Shared/Flyover/Tests/FlyoverWebThumbnailTests.swift b/Shared/Flyover/Tests/FlyoverWebThumbnailTests.swift new file mode 100644 index 000000000..c0bc17bf6 --- /dev/null +++ b/Shared/Flyover/Tests/FlyoverWebThumbnailTests.swift @@ -0,0 +1,126 @@ +#if DEBUG + import CoreGraphics + @testable import Flyover + import Foundation + import ImageIO + import Testing + + struct FlyoverWebThumbnailTests { + @Test func cropsFullContentToItsTopViewport() async throws { + let source = try makePNG( + width: 2, + height: 4, + rowColors: [ + RGBA(red: 255, green: 0, blue: 0, alpha: 255), + RGBA(red: 0, green: 255, blue: 0, alpha: 255), + RGBA(red: 0, green: 0, blue: 255, alpha: 255), + RGBA(red: 255, green: 255, blue: 0, alpha: 255), + ], + ) + + let thumbnail = try await FlyoverWebThumbnail.make( + from: source, + pointSize: CGSize(width: 2, height: 4), + viewportPointSize: CGSize(width: 2, height: 2), + ) + + #expect(thumbnail.pixelSize == CGSize(width: 2, height: 2)) + let image = try decode(thumbnail.pngData) + #expect(try pixel(in: image, x: 0, y: 0) == [255, 0, 0, 255]) + #expect(try pixel(in: image, x: 0, y: 1) == [0, 255, 0, 255]) + } + + @Test func capsTheLongestPixelDimension() async throws { + let source = try makePNG( + width: FlyoverWebThumbnail.maximumPixelDimension + 1, + height: 1, + rowColors: [RGBA(red: 42, green: 84, blue: 126, alpha: 255)], + ) + + let thumbnail = try await FlyoverWebThumbnail.make( + from: source, + pointSize: CGSize( + width: FlyoverWebThumbnail.maximumPixelDimension + 1, + height: 1, + ), + viewportPointSize: nil, + ) + + #expect(thumbnail.pixelSize.width == CGFloat(FlyoverWebThumbnail.maximumPixelDimension)) + #expect(thumbnail.pixelSize.height == 1) + } + + @Test func rejectsInvalidPNGData() async { + await #expect(throws: FlyoverWebThumbnailError.invalidPNG) { + try await FlyoverWebThumbnail.make( + from: Data("not a png".utf8), + pointSize: CGSize(width: 1, height: 1), + viewportPointSize: nil, + ) + } + } + + private func makePNG( + width: Int, + height: Int, + rowColors: [RGBA], + ) throws -> Data { + let colors = rowColors.count == 1 + ? Array(repeating: rowColors[0], count: height) + : rowColors + #expect(colors.count == height) + var bytes: [UInt8] = [] + bytes.reserveCapacity(width * height * 4) + for color in colors { + for _ in 0 ..< width { + bytes.append(contentsOf: [color.red, color.green, color.blue, color.alpha]) + } + } + let provider = try #require(CGDataProvider(data: Data(bytes) as CFData)) + let colorSpace = try #require(CGColorSpace(name: CGColorSpace.sRGB)) + let image = try #require(CGImage( + width: width, + height: height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent, + )) + let output = NSMutableData() + let destination = try #require(CGImageDestinationCreateWithData( + output, + "public.png" as CFString, + 1, + nil, + )) + CGImageDestinationAddImage(destination, image, nil) + try #require(CGImageDestinationFinalize(destination)) + return output as Data + } + + private func decode(_ data: Data) throws -> CGImage { + let source = try #require(CGImageSourceCreateWithData(data as CFData, nil)) + return try #require(CGImageSourceCreateImageAtIndex(source, 0, nil)) + } + + private func pixel(in image: CGImage, x: Int, y: Int) throws -> [UInt8] { + let provider = try #require(image.dataProvider) + let data = try #require(provider.data) + let bytes = CFDataGetBytePtr(data) + let offset = y * image.bytesPerRow + x * 4 + return (0 ..< 4).map { bytes?[offset + $0] ?? 0 } + } + + private struct RGBA { + let red: UInt8 + let green: UInt8 + let blue: UInt8 + let alpha: UInt8 + } + } +#endif diff --git a/Shared/Flyover/Tools/Tests/flyover_test.sh b/Shared/Flyover/Tools/Tests/flyover_test.sh index 81bae1cca..ee557f198 100755 --- a/Shared/Flyover/Tools/Tests/flyover_test.sh +++ b/Shared/Flyover/Tools/Tests/flyover_test.sh @@ -44,14 +44,76 @@ for index, profile in enumerate(profiles, 1): manifest = { 'schemaVersion': 1, 'application': {'id': 'where', 'title': 'Where'}, - 'build': {'dirty': os.environ.get('FLYOVER_EXPORT_DIRTY') == 'true'}, - 'profiles': [{'id': profile} for profile in profiles], - 'canvas': {}, - 'groups': [], - 'screens': [{'id': 'screen', 'variants': [{'id': 'default', 'imagesByProfile': paths}]}], + 'build': { + 'commit': os.environ['FLYOVER_EXPORT_COMMIT'], + 'dirty': os.environ.get('FLYOVER_EXPORT_DIRTY') == 'true', + 'branch': os.environ.get('FLYOVER_EXPORT_BRANCH') or None, + 'generatedAt': os.environ['FLYOVER_EXPORT_GENERATED_AT'], + 'xcodeVersion': os.environ['FLYOVER_EXPORT_XCODE_VERSION'], + 'simulatorDevice': os.environ['FLYOVER_EXPORT_SIMULATOR_DEVICE'], + 'simulatorOS': os.environ['FLYOVER_EXPORT_SIMULATOR_OS'], + }, + 'profiles': [{ + 'id': profile, + 'title': profile, + 'device': 'phone', + 'orientation': 'portrait', + 'colorScheme': 'dark' if profile == 'phone-dark' else 'light', + 'dynamicType': 'large', + 'contrast': 'standard', + 'layoutDirection': 'left-to-right', + 'legibilityWeight': 'regular', + 'snapshotType': 'standard', + } for profile in profiles], + 'canvas': { + 'size': {'width': 500, 'height': 700}, + 'initialFitSize': {'width': 500, 'height': 700}, + 'groupFrames': [{ + 'id': 'group', + 'frame': {'x': 0, 'y': 0, 'width': 500, 'height': 700}, + }], + 'depthBandFrames': [{ + 'groupID': 'group', + 'kind': 'route', + 'depth': 0, + 'frame': {'x': 20, 'y': 20, 'width': 460, 'height': 660}, + }], + 'screenFrames': [{ + 'id': 'screen', + 'frame': {'x': 50, 'y': 50, 'width': 300, 'height': 650}, + }], + 'connectors': [], + }, + 'groups': [{ + 'id': 'group', + 'title': 'Group', + 'order': 0, + 'rootScreenID': 'screen', + 'screenIDs': ['screen'], + }], + 'screens': [{ + 'id': 'screen', + 'title': 'Screen', + 'groupID': 'group', + 'groupOrder': 0, + 'screenOrder': 0, + 'viewport': {'kind': 'device'}, + 'navigationContainer': 'stack', + 'frame': {'x': 50, 'y': 50, 'width': 300, 'height': 650}, + 'variants': [{ + 'id': 'default', + 'title': 'Default', + 'captureExtent': 'viewport', + 'imagesByProfile': paths, + }], + 'incomingRouteIDs': [], + 'outgoingRouteIDs': [], + }], 'routes': [], 'images': images, } +if os.environ.get('FLYOVER_INVALID_MANIFEST') == 'missing-application': + del manifest['application'] 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') @@ -63,6 +125,13 @@ fi if [ -n "${FLYOVER_RACE_SYMLINK_DESTINATION:-}" ]; then ln -s "$FLYOVER_RACE_SYMLINK_DESTINATION.missing" "$FLYOVER_RACE_SYMLINK_DESTINATION" fi +if [ -n "${FLYOVER_STALE_BACKUP_DESTINATION:-}" ]; then + stale_parent="$(dirname "$FLYOVER_STALE_BACKUP_DESTINATION")" + stale_name="$(basename "$FLYOVER_STALE_BACKUP_DESTINATION")" + stale="$stale_parent/.flyover-previous.$stale_name.$PPID" + mkdir -p "$stale" + printf '%s\n' retained >"$stale/retained" +fi RUNNER chmod +x "$SUCCESS_RUNNER" @@ -299,6 +368,39 @@ try: ).read() if image != b'PNG': raise SystemExit('preview served unexpected image data') + + outside_file = pathlib.Path(directory).parent / 'outside-preview-file' + outside_file.write_bytes(b'SECRET') + image_path = ( + pathlib.Path(directory) + / 'images/screen-0001/variant-0001/phone-dark.png' + ) + image_path.unlink() + image_path.symlink_to(outside_file) + try: + urllib.request.urlopen( + base_url + 'images/screen-0001/variant-0001/phone-dark.png', + timeout=5, + ) + raise SystemExit('preview followed a replacement image symbolic link') + except urllib.error.HTTPError as error: + if error.code != 404: + raise + + original_root = pathlib.Path(directory) + moved_root = original_root.with_name(original_root.name + '-moved') + replacement_root = original_root.with_name(original_root.name + '-replacement') + original_root.rename(moved_root) + replacement_root.mkdir() + (replacement_root / 'manifest.json').write_bytes(b'SECRET') + original_root.symlink_to(replacement_root, target_is_directory=True) + pinned_manifest = urllib.request.urlopen( + base_url + 'manifest.json', + timeout=5, + ).read() + if b'"schemaVersion": 1' not in pinned_manifest or pinned_manifest == b'SECRET': + raise SystemExit('preview changed roots after server startup') + try: urllib.request.urlopen(base_url + 'assets/', timeout=5) raise SystemExit('preview exposed a directory listing') @@ -383,6 +485,7 @@ CLEAN_REPO="$TEMP/clean-repo" mkdir -p "$CLEAN_REPO/Shared/Flyover/Web/assets" mkdir -p "$CLEAN_REPO/Tools" cp "$ROOT/flyover" "$CLEAN_REPO/flyover" +cp "$ROOT/Tools/flyover_manifest.py" "$CLEAN_REPO/Tools/flyover_manifest.py" cp "$ROOT/Tools/flyover_preview.py" "$CLEAN_REPO/Tools/flyover_preview.py" cp "$ROOT/Shared/Flyover/Web/index.html" "$CLEAN_REPO/Shared/Flyover/Web/index.html" cp "$ROOT/Shared/Flyover/Web/assets/app.js" "$CLEAN_REPO/Shared/Flyover/Web/assets/app.js" @@ -394,6 +497,8 @@ git -C "$CLEAN_REPO" -c user.name=Flyover -c user.email=flyover@example.com \ -c commit.gpgsign=false commit -qm fixture FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ "$CLEAN_REPO/flyover" export --output "$CLEAN_REPO/site" >/dev/null +FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$CLEAN_REPO/flyover" export --output "$CLEAN_REPO/site" >/dev/null python3 - "$CLEAN_REPO/site/manifest.json" <<'PY' import json, pathlib, sys dirty = json.loads(pathlib.Path(sys.argv[1]).read_text())['build']['dirty'] @@ -401,6 +506,45 @@ if dirty: raise SystemExit('staging a clean in-repository export marked the source dirty') PY +FAILING_GIT_BIN="$TEMP/failing-git-bin" +mkdir -p "$FAILING_GIT_BIN" +cat >"$FAILING_GIT_BIN/git" <<'GIT' +#!/bin/bash +for argument in "$@"; do + if [ "$argument" = status ]; then + exit 71 + fi +done +exec "$FLYOVER_REAL_GIT" "$@" +GIT +chmod +x "$FAILING_GIT_BIN/git" +expect_failure env PATH="$FAILING_GIT_BIN:$PATH" FLYOVER_REAL_GIT="$(command -v git)" \ + FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + "$CLEAN_REPO/flyover" export --output "$CLEAN_REPO/status-failure" +grep -q "could not read the Git working-tree status" "$TEMP/stderr" \ + || fail "a Git status failure was treated as a clean source tree" + +printf '%s\n' retained-before-invalid >"$OUTPUT/retained-before-invalid" +expect_failure env FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" \ + FLYOVER_XCODE_VERSION_OVERRIDE=Tests FLYOVER_INVALID_MANIFEST=missing-application \ + "$ROOT/flyover" export --output "$OUTPUT" +grep -q "manifest.application is not an object" "$TEMP/stderr" \ + || fail "an incomplete browser manifest did not report its missing section" +[ -f "$OUTPUT/retained-before-invalid" ] \ + || fail "an incomplete browser manifest replaced the last successful output" + +FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ + FLYOVER_STALE_BACKUP_DESTINATION="$OUTPUT" \ + "$ROOT/flyover" export --output "$OUTPUT" >/dev/null +STALE_BACKUP="$(find "$TEMP" -maxdepth 1 -type d \ + -name '.flyover-previous.output.*' -print -quit)" +[ -n "$STALE_BACKUP" ] && [ -f "$STALE_BACKUP/retained" ] \ + || fail "replacement removed a backup directory that it did not create" +rm -rf "$STALE_BACKUP" +if find "$TEMP" -maxdepth 1 -name '.flyover-replacement.*' | grep -q .; then + fail "a successful export left its replacement directory" +fi + printf '%s\n' old >"$OUTPUT/old-file" FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ "$ROOT/flyover" export --output "$OUTPUT" >/dev/null @@ -461,11 +605,18 @@ for selector in ( }) require_properties('#app img', {'-webkit-user-drag': 'none'}) -factory_start = javascript.index(' function screenImage(screen, className = "", eager = false) {') +factory_start = javascript.index( + ' function screenImage(screen, className = "", eager = false, fullResolution = false) {' +) factory_end = javascript.index('\n\n function captureViewportSize(screen) {', factory_start) factory = javascript[factory_start:factory_end] if 'image.draggable = false;' not in factory: raise SystemExit('screen images must disable native dragging') +if 'fullResolution ? imagePath(screen) : thumbnailPath(screen)' not in factory: + raise SystemExit('atlas images must use thumbnails while allowing full-resolution inspection') +if 'const fullImage = screenImage(screen, "", true, true);' not in javascript: + raise SystemExit('the inspector must request the full-resolution image') +require_properties('.bottom-dock', {'justify-content': 'safe center'}) for fragment in ( 'class="error selectable-text"', @@ -487,28 +638,48 @@ import textwrap source = pathlib.Path(sys.argv[1]).read_text() settings_start_marker = ' const targetResidentImageCount = ' settings_end_marker = '\n let inspectorResizeObserver' +image_path_start_marker = ' function screenVariant(screen) {' +image_path_end_marker = '\n\n function connectedRoutes(screen) {' +image_factory_start_marker = ' function screenImage(screen, className = "", eager = false, fullResolution = false) {' +image_factory_end_marker = '\n\n function captureViewportSize(screen) {' start_marker = ' function residentScreenIDs(candidates) {' end_marker = '\n\n function installCanvasPinch(viewport) {' -fit_start_marker = ' function fitFrame(frame, behavior = "smooth") {' +fit_start_marker = ' function setZoom(next) {' fit_end_marker = '\n\n function fitAll(behavior = "smooth") {' +fit_first_start_marker = ' function fitFirstGroup() {' +fit_first_end_marker = '\n\n let canvasNavigationFrame' settings_start = source.index(settings_start_marker) settings_end = source.index(settings_end_marker, settings_start) +image_path_start = source.index(image_path_start_marker) +image_path_end = source.index(image_path_end_marker, image_path_start) +image_factory_start = source.index(image_factory_start_marker) +image_factory_end = source.index(image_factory_end_marker, image_factory_start) start = source.index(start_marker) end = source.index(end_marker, start) fit_start = source.index(fit_start_marker) fit_end = source.index(fit_end_marker, fit_start) +fit_first_start = source.index(fit_first_start_marker) +fit_first_end = source.index(fit_first_end_marker, fit_first_start) settings = textwrap.dedent(source[settings_start:settings_end]) +image_paths = textwrap.dedent(source[image_path_start:image_path_end]) +image_factory = textwrap.dedent(source[image_factory_start:image_factory_end]) residency = textwrap.dedent(source[start:end]) -fit_frame = textwrap.dedent(source[fit_start:fit_end]) -test = ''' -%s -%s -%s - +fit_functions = textwrap.dedent(source[fit_start:fit_end]) +fit_first_group = textwrap.dedent(source[fit_first_start:fit_first_end]) +test = '\n'.join(( + settings, + image_paths, + image_factory, + residency, + fit_functions, + fit_first_group, + r''' let document; let manifest; let state; let screenByID; +let selectedVariants; +let imageByKey; const window = { matchMedia: () => ({ matches: true }), }; @@ -518,15 +689,22 @@ function requestAnimationFrame(callback) { return 1; } -function matchesFilters() { - return true; +function element(tag, className) { + return { + tag, + className, + dataset: {}, + getAttribute(name) { return this[name] || null; }, + removeAttribute(name) { delete this[name]; }, + }; } -function imageMetadata(screen) { - return screen.imageMetadata; +function matchesFilters() { + return true; } function applyZoom() {} +function captureCanvasPosition() {} function candidate(id, isVisible, imagePixels) { return { screen: { id }, isVisible, imagePixels }; @@ -543,6 +721,49 @@ function expectIDs(actual, expected, message) { } } +const imageScreen = { + id: 'screen', + title: 'Screen', + variants: [{ + id: 'default', + title: 'Default', + captureExtent: 'viewport', + imagesByProfile: { 'phone-light': 'images/full.png' }, + }], +}; +selectedVariants = new Map([['screen', 'default']]); +state = { profile: 'phone-light' }; +imageByKey = new Map([[ + imageKey('screen', 'default', 'phone-light'), + { relativePath: 'images/full.png', thumbnailRelativePath: 'images/thumbnail.png' }, +]]); +const cardImage = screenImage(imageScreen); +expect(cardImage.dataset.src === 'images/thumbnail.png', 'cards must use the generated thumbnail'); +const inspectorImage = screenImage(imageScreen, '', true, true); +expect(inspectorImage.src === 'images/full.png', 'the inspector must use the full-resolution PNG'); +imageByKey.get(imageKey('screen', 'default', 'phone-light')).thumbnailRelativePath = null; +expect( + screenImage(imageScreen).dataset.src === 'images/full.png', + 'older manifests without thumbnails must fall back to the full-resolution PNG', +); +imageMetadata = screen => screen.imageMetadata; +expect( + screenImagePixels({ + imageMetadata: { + pixelWidth: 2_500, + pixelHeight: 8_000, + thumbnailRelativePath: 'images/thumbnail.png', + thumbnailPixelWidth: 470, + thumbnailPixelHeight: 1_024, + }, + }) === 481_280, + 'residency budgets must use thumbnail dimensions', +); +expect( + screenImagePixels({ imageMetadata: { pixelWidth: 2_500, pixelHeight: 8_000 } }) === 20_000_000, + 'older manifests must budget their full-resolution fallback', +); + const visible = Array.from( { length: 20 }, (_, index) => candidate('visible-' + index, true, 5_000_000), @@ -677,7 +898,39 @@ expect( residencyRefreshCount === 1, 'fitting the canvas must refresh image residency after changing zoom', ); -''' % (settings, residency, fit_frame) + +setZoom(-1); +expect(state.zoom === minimumManualZoom, 'manual zoom must retain its 10% floor'); +setZoom(9); +expect(state.zoom === maximumZoom, 'manual zoom must retain its 150% ceiling'); + +for (const width of [320, 390]) { + fitViewport.clientWidth = width; + fitViewport.clientHeight = 700; + state.zoom = 1; + fitFrame({ x: 0, y: 0, width: 8_260, height: 4_000 }, 'auto'); + const availableWidth = width - 64; + expect( + 8_260 * state.zoom <= availableWidth + 0.001, + 'Fit All must fit the canvas at ' + width + 'px', + ); + const fittedZoom = state.zoom; + setZoom(state.zoom - 0.1); + expect( + state.zoom === fittedZoom, + 'zooming out from a sub-10% fit must not increase zoom', + ); + + manifest = { canvas: { initialFitSize: { width: 2_500 } } }; + updateCanvasNavigation = () => {}; + fitFirstGroup(); + expect( + 2_500 * state.zoom <= width - 32 + 0.001, + 'the initial group must fit at ' + width + 'px', + ); +} +''', +)) pathlib.Path(sys.argv[2]).write_text(test) PY "$JSC" "$TEMP/residency-test.js" \ diff --git a/Shared/Flyover/Web/assets/app.js b/Shared/Flyover/Web/assets/app.js index 6f4fbc4a3..d9bfae957 100644 --- a/Shared/Flyover/Web/assets/app.js +++ b/Shared/Flyover/Web/assets/app.js @@ -20,6 +20,8 @@ const defaultProfile = manifest.profiles[0]?.id; const targetResidentImageCount = 6; const targetResidentPixelCount = 24_000_000; + const minimumManualZoom = 0.1; + const maximumZoom = 1.5; let inspectorResizeObserver = null; const selectedVariants = new Map(manifest.screens.map(screen => [screen.id, screen.variants[0]?.id])); const state = { @@ -123,6 +125,10 @@ return imageByKey.get(imageKey(screen.id, variant?.id, state.profile)); } + function thumbnailPath(screen) { + return imageMetadata(screen)?.thumbnailRelativePath || imagePath(screen); + } + function connectedRoutes(screen) { return [...screen.incomingRouteIDs, ...screen.outgoingRouteIDs] .map(id => routeByID.get(id)) @@ -409,12 +415,12 @@ return links; } - function screenImage(screen, className = "", eager = false) { + function screenImage(screen, className = "", eager = false, fullResolution = false) { const image = element("img", className); image.draggable = false; image.loading = eager ? "eager" : "lazy"; image.decoding = "async"; - const source = imagePath(screen); + const source = fullResolution ? imagePath(screen) : thumbnailPath(screen); if (eager) image.src = source; else image.dataset.src = source; image.dataset.captureExtent = screenVariant(screen)?.captureExtent || "viewport"; @@ -761,7 +767,7 @@ device.setAttribute("role", "region"); device.setAttribute("aria-label", screen.title + " scrollable full-content capture"); } - const fullImage = screenImage(screen, "", true); + const fullImage = screenImage(screen, "", true, true); if (metadata) { const viewportSize = captureViewportSize(screen); device.style.setProperty("--point-width", metadata.pointWidth + "px"); @@ -1478,7 +1484,11 @@ function screenImagePixels(screen) { const metadata = imageMetadata(screen); - return metadata ? metadata.pixelWidth * metadata.pixelHeight : 0; + if (!metadata) return 0; + const usesThumbnail = Boolean(metadata.thumbnailRelativePath); + const width = usesThumbnail ? (metadata.thumbnailPixelWidth || metadata.pixelWidth) : metadata.pixelWidth; + const height = usesThumbnail ? (metadata.thumbnailPixelHeight || metadata.pixelHeight) : metadata.pixelHeight; + return width * height; } function updateCanvasImageResidency() { @@ -1582,6 +1592,7 @@ gesture = { distance: touchDistance(event.touches), zoom: state.zoom, + minimumZoom: Math.min(minimumManualZoom, state.zoom), canvasX: (viewport.scrollLeft + midpoint.x - bounds.left) / state.zoom, canvasY: (viewport.scrollTop + midpoint.y - bounds.top) / state.zoom, }; @@ -1591,7 +1602,7 @@ event.preventDefault(); const midpoint = touchMidpoint(event.touches); const bounds = viewport.getBoundingClientRect(); - const nextZoom = Math.max(0.1, Math.min(1.5, + const nextZoom = Math.max(gesture.minimumZoom, Math.min(maximumZoom, gesture.zoom * touchDistance(event.touches) / Math.max(gesture.distance, 1))); state.zoom = nextZoom; applyZoom(); @@ -1661,7 +1672,8 @@ 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)); + const minimumZoom = Math.min(minimumManualZoom, oldZoom); + state.zoom = Math.max(minimumZoom, Math.min(maximumZoom, next)); applyZoom(); if (viewport && center) { viewport.scrollTo({ @@ -1679,7 +1691,7 @@ const padding = Math.min(Math.max(viewport.clientWidth * 0.055, 32), 80); 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)); + state.zoom = Math.min(maximumZoom, horizontal, vertical); applyZoom(); const left = (frame.x + frame.width / 2) * state.zoom - viewport.clientWidth / 2; const top = (frame.y + frame.height / 2) * state.zoom - viewport.clientHeight / 2; @@ -1708,7 +1720,7 @@ if (!initialWidth) return; const framingInset = 16; const horizontal = Math.max(viewport.clientWidth - framingInset * 2, 1) / initialWidth; - state.zoom = Math.max(0.15, Math.min(1, horizontal)); + state.zoom = Math.min(1, horizontal); applyZoom(); viewport.scrollTo({ left: 0, top: 0, behavior: "auto" }); state.canvas.scrollLeft = 0; diff --git a/Shared/Flyover/Web/assets/styles.css b/Shared/Flyover/Web/assets/styles.css index f000968f8..89bf5d7ba 100644 --- a/Shared/Flyover/Web/assets/styles.css +++ b/Shared/Flyover/Web/assets/styles.css @@ -1950,7 +1950,8 @@ a.inspector-action { left: 10px; max-width: none; min-height: 56px; - justify-content: center; + justify-content: flex-start; + justify-content: safe center; gap: 2px; overflow-x: auto; padding: 6px; diff --git a/Shared/SnapshotKit/AGENTS.md b/Shared/SnapshotKit/AGENTS.md index ceda95f75..797d911b7 100644 --- a/Shared/SnapshotKit/AGENTS.md +++ b/Shared/SnapshotKit/AGENTS.md @@ -15,6 +15,7 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. - **Non-default values include `dark`, `xxxl`, `contrast`, `rtl`, `bold`, `accessibility`, and `iPad`.** The light/standard/default baseline stays empty. - **Reference-image filenames depend on this.** Treat omission rules as a wire format. - **When you add an axis, give it a default that is omitted.** That is how `layoutDirection`/`legibilityWeight` landed. +- **Treat explicit layout traits as non-default.** Their idiom and size classes must add one stable identifier token. - **Filter `.accessibility` configs out of previews.** `snapshotPreviews` drops them. - **Stuff keeps AccessibilitySnapshot's annotation renderer in the test-only `SnapshotKitTesting` product.** It does not link into shipping UI modules. - **Accessibility configs only render as snapshot tests.** Do not "fix" previews to include them without a preview-only dependency boundary. diff --git a/Shared/SnapshotKit/README.md b/Shared/SnapshotKit/README.md index aff3e1a5a..fcc59b09f 100644 --- a/Shared/SnapshotKit/README.md +++ b/Shared/SnapshotKit/README.md @@ -13,27 +13,33 @@ capture + comparison pipeline lives in the sibling ## What's in the box -- **`SnapshotConfiguration`** — one rendering variant: color scheme, Dynamic - Type size, contrast, layout direction (`rtl` token), legibility weight (bold - text, `bold` token), a device `Frame`, and a `snapshotType` (`.standard` or - `.accessibility`). `Hashable`, with an `identifier` (built from - `identifierParts`) that **omits default axes** so common cases stay terse. +- **`SnapshotConfiguration`** — one rendering variant. It specifies appearance + traits, device layout traits, a device `Frame`, and a `snapshotType`. + Appearance traits include color scheme, Dynamic Type size, contrast, layout + direction, and legibility weight. This `Hashable` value has an `identifier` + that **omits default axes**, so common cases stay terse. The `snapshotType` + is `.standard` or `.accessibility`. Frames come in four sizing strategies: fixed device viewports (`.iPhone`, `.iPad`), the intrinsic `.component` frame, `.fullContent(name:width:)`, and `.fullContent2D(name:minimumSize:)`. The ordinary full-content frame has a fixed width and a height measured from the settled content. Use the explicit two-axis frame for spatial canvases that scroll in both dimensions. Full-width scrolling descendants drive the measured height while preserving surrounding - navigation, tab, sheet, search, and toolbar chrome. An bounded + navigation, tab, sheet, search, and toolbar chrome. A bounded or greedy production container that cannot converge must expose and snapshot its shared scrolling child directly, without snapshot-only layout behavior. The iPhone/iPad full-content presets retain their normal viewport height as a minimum and - grow when content is taller. custom full-content frames shrink-wrap unless + grow when content is taller. Custom full-content frames shrink-wrap unless given a minimum. A frame also carries `safeAreaInsets` (default zero, keeping - images device-independent). the `.iPhoneNotched` preset simulates real device + images device-independent). The `.iPhoneNotched` preset simulates real device chrome (Dynamic Island top 47pt, home-indicator bottom 34pt) for cases that must prove layout under it. +- **`SnapshotConfiguration.LayoutTraits`** — an explicit interface idiom and + size-class set for adaptive content. The standard presets cover phone + portrait, phone landscape, and tablet portrait. Each set adds a stable + identifier token. If this value is `nil`, the capture inherits the host + simulator traits. - **`combinations(...)` + presets** (`.componentDefaults`, `.screenDefaults`, `.fullContentScreenDefaults`) — expand a terse declaration into the full matrix. @@ -80,10 +86,9 @@ capture + comparison pipeline lives in the sibling again before capture. The preview cutsheet ignores the hook (only the test pipeline can re-settle around it). - **`snapshotTraits(_:)`** — applies a configuration's traits to a view for the - preview cutsheet (color scheme, Dynamic Type, layout direction, legibility - weight, and an increased-contrast trait override), so previews and test - captures stay in lockstep. Simulated frame insets are capture-only — a - preview can't fake safe areas. + preview cutsheet. This includes explicit device layout traits. Thus, previews + and test captures stay in lockstep. Simulated frame insets are capture-only. + A preview cannot simulate safe areas. - **`\.isCapturingSnapshot`** — an environment flag that is `true` while `SnapshotKitTesting` captures the view (and in the preview cutsheet, which mirrors the tests). A view may read it **only** to render a deterministic diff --git a/Shared/SnapshotKit/Sources/SnapshotConfiguration+Combinations.swift b/Shared/SnapshotKit/Sources/SnapshotConfiguration+Combinations.swift index 36df018a7..5b816dce4 100644 --- a/Shared/SnapshotKit/Sources/SnapshotConfiguration+Combinations.swift +++ b/Shared/SnapshotKit/Sources/SnapshotConfiguration+Combinations.swift @@ -5,7 +5,7 @@ extension SnapshotConfiguration { /// configurations. An empty axis falls back to that axis's default singleton /// (light color scheme, `.large` Dynamic Type, standard contrast, the /// component frame, a standard capture), so a caller varies only the axes it - /// cares about. + /// cares about. An empty layout-traits axis inherits the host traits. public static func combinations( devices: [Frame] = [], colorSchemes: [ColorScheme] = [], @@ -13,6 +13,7 @@ extension SnapshotConfiguration { contrasts: [ColorSchemeContrast] = [], layoutDirections: [LayoutDirection] = [], legibilityWeights: [LegibilityWeight] = [], + layoutTraits requestedLayoutTraits: [LayoutTraits] = [], snapshotTypes: [SnapshotType] = [], ) -> [SnapshotConfiguration] { let devices = devices.isEmpty ? [.component] : devices @@ -21,6 +22,9 @@ extension SnapshotConfiguration { let contrasts = contrasts.isEmpty ? [.standard] : contrasts let layoutDirections = layoutDirections.isEmpty ? [.leftToRight] : layoutDirections let legibilityWeights = legibilityWeights.isEmpty ? [.regular] : legibilityWeights + let layoutTraits: [LayoutTraits?] = requestedLayoutTraits.isEmpty + ? [nil] + : requestedLayoutTraits.map(Optional.some) let snapshotTypes = snapshotTypes.isEmpty ? [.standard] : snapshotTypes var result: [SnapshotConfiguration] = [] @@ -30,18 +34,21 @@ extension SnapshotConfiguration { for contrast in contrasts { for layoutDirection in layoutDirections { for legibilityWeight in legibilityWeights { - for snapshotType in snapshotTypes { - result.append( - SnapshotConfiguration( - colorScheme: colorScheme, - dynamicType: dynamicType, - contrast: contrast, - layoutDirection: layoutDirection, - legibilityWeight: legibilityWeight, - device: device, - snapshotType: snapshotType, - ), - ) + for layoutTraits in layoutTraits { + for snapshotType in snapshotTypes { + result.append( + SnapshotConfiguration( + colorScheme: colorScheme, + dynamicType: dynamicType, + contrast: contrast, + layoutDirection: layoutDirection, + legibilityWeight: legibilityWeight, + layoutTraits: layoutTraits, + device: device, + snapshotType: snapshotType, + ), + ) + } } } } diff --git a/Shared/SnapshotKit/Sources/SnapshotConfiguration+Traits.swift b/Shared/SnapshotKit/Sources/SnapshotConfiguration+Traits.swift index e9d141a9c..b73ad9a0d 100644 --- a/Shared/SnapshotKit/Sources/SnapshotConfiguration+Traits.swift +++ b/Shared/SnapshotKit/Sources/SnapshotConfiguration+Traits.swift @@ -3,11 +3,12 @@ import UIKit extension SnapshotConfiguration { /// A `UITraitCollection` expressing this configuration's appearance axes — - /// interface style, content size category, contrast, layout direction, and - /// legibility weight. Used both by the preview cutsheet's trait override and - /// by the test runner to configure the capture, so the two stay in lockstep. + /// interface style, content size category, contrast, layout direction, + /// legibility weight, and any explicit device-adaptive traits. Used both by + /// the preview cutsheet's trait override and by the test runner to configure + /// the capture, so the two stay in lockstep. public var uiTraitCollection: UITraitCollection { - UITraitCollection(traitsFrom: [ + var traits = [ UITraitCollection(userInterfaceStyle: colorScheme == .dark ? .dark : .light), UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory(dynamicType)), UITraitCollection(accessibilityContrast: contrast == .increased ? .high : .normal), @@ -17,7 +18,33 @@ extension SnapshotConfiguration { UITraitCollection( legibilityWeight: legibilityWeight == .bold ? .bold : .regular, ), - ]) + ] + if let layoutTraits { + traits.append(contentsOf: [ + UITraitCollection(userInterfaceIdiom: layoutTraits.interfaceIdiom.uiValue), + UITraitCollection(horizontalSizeClass: layoutTraits.horizontalSizeClass.uiValue), + UITraitCollection(verticalSizeClass: layoutTraits.verticalSizeClass.uiValue), + ]) + } + return UITraitCollection(traitsFrom: traits) + } +} + +extension SnapshotConfiguration.LayoutTraits.InterfaceIdiom { + fileprivate var uiValue: UIUserInterfaceIdiom { + switch self { + case .phone: .phone + case .tablet: .pad + } + } +} + +extension SnapshotConfiguration.LayoutTraits.SizeClass { + fileprivate var uiValue: UIUserInterfaceSizeClass { + switch self { + case .compact: .compact + case .regular: .regular + } } } diff --git a/Shared/SnapshotKit/Sources/SnapshotConfiguration.swift b/Shared/SnapshotKit/Sources/SnapshotConfiguration.swift index cf448767b..b623185e6 100644 --- a/Shared/SnapshotKit/Sources/SnapshotConfiguration.swift +++ b/Shared/SnapshotKit/Sources/SnapshotConfiguration.swift @@ -7,10 +7,9 @@ import SwiftUI /// /// `Hashable` so it can key a matrix, and it vends an ``identifier`` (from /// ``identifierParts``) that names the reference image. The identifier **omits -/// default axes** — only a non-default color scheme, Dynamic Type size, contrast, -/// snapshot type, or a named device shows up — so the common (light / large / -/// standard) baseline stays terse. Treat the omission rules as a wire format: -/// changing them renames every reference image on disk. +/// default axes**. A named device, explicit layout traits, or a non-default +/// appearance axis adds a token. Thus, the common baseline stays terse. Treat +/// the omission rules as a wire format. A change renames reference images. public struct SnapshotConfiguration: Hashable, Sendable { /// The color scheme (light/dark) to render in. public var colorScheme: ColorScheme @@ -22,6 +21,9 @@ public struct SnapshotConfiguration: Hashable, Sendable { public var layoutDirection: LayoutDirection /// The legibility weight (regular / bold text) to render with. public var legibilityWeight: LegibilityWeight + /// Optional device-adaptive traits. `nil` inherits the host simulator's + /// idiom and size classes. + public var layoutTraits: LayoutTraits? /// The frame (size + name) to render into. public var device: Frame /// Whether this is a plain image or a VoiceOver-annotated accessibility image. @@ -36,6 +38,7 @@ public struct SnapshotConfiguration: Hashable, Sendable { contrast: ColorSchemeContrast = .standard, layoutDirection: LayoutDirection = .leftToRight, legibilityWeight: LegibilityWeight = .regular, + layoutTraits: LayoutTraits? = nil, device: Frame = .component, snapshotType: SnapshotType = .standard, name: String? = nil, @@ -45,6 +48,7 @@ public struct SnapshotConfiguration: Hashable, Sendable { self.contrast = contrast self.layoutDirection = layoutDirection self.legibilityWeight = legibilityWeight + self.layoutTraits = layoutTraits self.device = device self.snapshotType = snapshotType self.name = name @@ -56,6 +60,7 @@ public struct SnapshotConfiguration: Hashable, Sendable { var parts: [String] = [] if let name, !name.isEmpty { parts.append(name) } if !device.name.isEmpty { parts.append(device.name) } + if let layoutTraits { parts.append(layoutTraits.snapshotToken) } if colorScheme == .dark { parts.append("dark") } if dynamicType != .large { parts.append(dynamicType.snapshotToken) } if contrast == .increased { parts.append("contrast") } @@ -74,6 +79,56 @@ public struct SnapshotConfiguration: Hashable, Sendable { } extension SnapshotConfiguration { + /// A deterministic device idiom and size-class combination for adaptive + /// content. Omit it when the capture should inherit the host simulator. + public struct LayoutTraits: Hashable, Sendable { + public enum InterfaceIdiom: Hashable, Sendable { + case phone + case tablet + } + + public enum SizeClass: Hashable, Sendable { + case compact + case regular + } + + public var interfaceIdiom: InterfaceIdiom + public var horizontalSizeClass: SizeClass + public var verticalSizeClass: SizeClass + + public init( + interfaceIdiom: InterfaceIdiom, + horizontalSizeClass: SizeClass, + verticalSizeClass: SizeClass, + ) { + self.interfaceIdiom = interfaceIdiom + self.horizontalSizeClass = horizontalSizeClass + self.verticalSizeClass = verticalSizeClass + } + + public static let phonePortrait = LayoutTraits( + interfaceIdiom: .phone, + horizontalSizeClass: .compact, + verticalSizeClass: .regular, + ) + + public static let phoneLandscape = LayoutTraits( + interfaceIdiom: .phone, + horizontalSizeClass: .compact, + verticalSizeClass: .compact, + ) + + public static let tabletPortrait = LayoutTraits( + interfaceIdiom: .tablet, + horizontalSizeClass: .regular, + verticalSizeClass: .regular, + ) + + fileprivate var snapshotToken: String { + "\(interfaceIdiom.snapshotToken)-\(horizontalSizeClass.snapshotToken)-\(verticalSizeClass.snapshotToken)" + } + } + /// Whether this configuration is a plain image or a VoiceOver-annotated one. public enum SnapshotType: Hashable, Sendable { case standard @@ -227,6 +282,24 @@ extension SnapshotConfiguration { } } +extension SnapshotConfiguration.LayoutTraits.InterfaceIdiom { + fileprivate var snapshotToken: String { + switch self { + case .phone: "phone" + case .tablet: "tablet" + } + } +} + +extension SnapshotConfiguration.LayoutTraits.SizeClass { + fileprivate var snapshotToken: String { + switch self { + case .compact: "compact" + case .regular: "regular" + } + } +} + extension DynamicTypeSize { /// A short, stable token for identifiers. Only non-`.large` values ever reach /// a filename (the default is omitted), but the full map keeps tokens unique. diff --git a/Shared/SnapshotKit/Sources/SnapshotTraits.swift b/Shared/SnapshotKit/Sources/SnapshotTraits.swift index 2d1e12124..e2c1633fa 100644 --- a/Shared/SnapshotKit/Sources/SnapshotTraits.swift +++ b/Shared/SnapshotKit/Sources/SnapshotTraits.swift @@ -6,39 +6,62 @@ extension View { /// preview renders the way its snapshot will be captured. /// /// Color scheme, Dynamic Type, layout direction, and legibility weight go - /// through the SwiftUI environment; increased contrast has no SwiftUI setter, - /// so that variant is hosted once through a UIKit trait override (which the - /// hosting controller bridges back into the content's `colorSchemeContrast`). + /// through the SwiftUI environment. Increased contrast and device-adaptive + /// traits use a UIKit trait override, which the hosting controller bridges + /// back into the SwiftUI content. @ViewBuilder public func snapshotTraits(_ configuration: SnapshotConfiguration) -> some View { let base = environment(\.colorScheme, configuration.colorScheme) .dynamicTypeSize(configuration.dynamicType) .environment(\.layoutDirection, configuration.layoutDirection) .environment(\.legibilityWeight, configuration.legibilityWeight) - if configuration.contrast == .increased { - ContrastOverrideHost { base } + if configuration.contrast == .increased || configuration.layoutTraits != nil { + SnapshotTraitOverrideHost(configuration: configuration) { base } } else { base } } } -/// Hosts content with an increased-contrast trait override — the only appearance -/// axis SwiftUI can't set directly. Sized to its content so it lays out inline in -/// the preview cutsheet. -private struct ContrastOverrideHost: UIViewControllerRepresentable { +/// Hosts content with UIKit-only trait overrides. Sized to its content so it +/// lays out inline in the preview cutsheet. +private struct SnapshotTraitOverrideHost: UIViewControllerRepresentable { + let configuration: SnapshotConfiguration @ViewBuilder var content: () -> Content func makeUIViewController(context _: Context) -> UIHostingController { let host = UIHostingController(rootView: content()) host.view.backgroundColor = .clear host.sizingOptions = [.intrinsicContentSize] - host.traitOverrides.accessibilityContrast = .high + applyTraits(to: host) return host } func updateUIViewController(_ host: UIHostingController, context _: Context) { host.rootView = content() - host.traitOverrides.accessibilityContrast = .high + applyTraits(to: host) + } + + private func applyTraits(to host: UIHostingController) { + applySnapshotTraitOverrides(configuration, to: host) + } +} + +/// Updates the UIKit-only traits and removes device overrides that no longer apply. +@MainActor +func applySnapshotTraitOverrides( + _ configuration: SnapshotConfiguration, + to host: UIViewController, +) { + let traits = configuration.uiTraitCollection + host.traitOverrides.accessibilityContrast = traits.accessibilityContrast + if configuration.layoutTraits != nil { + host.traitOverrides.userInterfaceIdiom = traits.userInterfaceIdiom + host.traitOverrides.horizontalSizeClass = traits.horizontalSizeClass + host.traitOverrides.verticalSizeClass = traits.verticalSizeClass + } else { + host.traitOverrides.remove(UITraitUserInterfaceIdiom.self) + host.traitOverrides.remove(UITraitHorizontalSizeClass.self) + host.traitOverrides.remove(UITraitVerticalSizeClass.self) } } diff --git a/Shared/SnapshotKit/Tests/SnapshotConfigurationTests.swift b/Shared/SnapshotKit/Tests/SnapshotConfigurationTests.swift index 00b605990..39aa706d0 100644 --- a/Shared/SnapshotKit/Tests/SnapshotConfigurationTests.swift +++ b/Shared/SnapshotKit/Tests/SnapshotConfigurationTests.swift @@ -27,6 +27,16 @@ struct SnapshotConfigurationTests { #expect(configs.count == 8) } + @Test func combinationsMultiplyAdaptiveLayoutTraits() { + let configs = SnapshotConfiguration.combinations( + devices: [.iPhone, .iPad], + layoutTraits: [.phonePortrait, .tabletPortrait], + ) + + #expect(configs.count == 4) + #expect(Set(configs.compactMap(\.layoutTraits)) == [.phonePortrait, .tabletPortrait]) + } + @Test func componentDefaultsAreAdditiveNotCartesian() { // baseline + dark + accessibility type size + increased contrast + a11y capture #expect([SnapshotConfiguration].componentDefaults.count == 5) @@ -95,6 +105,22 @@ struct SnapshotConfigurationTests { #expect(SnapshotConfiguration(device: .iPhoneNotched).identifierParts == ["iPhoneNotched"]) } + @Test func adaptiveLayoutTraitsHaveStableDistinctIdentifierTokens() { + let phonePortrait = SnapshotConfiguration(layoutTraits: .phonePortrait) + let phoneLandscape = SnapshotConfiguration(layoutTraits: .phoneLandscape) + let tabletPortrait = SnapshotConfiguration(layoutTraits: .tabletPortrait) + + #expect(phonePortrait.identifierParts == ["phone-compact-regular"]) + #expect(phoneLandscape.identifierParts == ["phone-compact-compact"]) + #expect(tabletPortrait.identifierParts == ["tablet-regular-regular"]) + #expect(Set([ + phonePortrait.identifier, + phoneLandscape.identifier, + tabletPortrait.identifier, + ]) + .count == 3) + } + @Test func identifierOrdersAndJoinsPartsWithNameFirst() { let config = SnapshotConfiguration( colorScheme: .dark, diff --git a/Shared/SnapshotKit/Tests/SnapshotTraitsTests.swift b/Shared/SnapshotKit/Tests/SnapshotTraitsTests.swift new file mode 100644 index 000000000..ec5abbaef --- /dev/null +++ b/Shared/SnapshotKit/Tests/SnapshotTraitsTests.swift @@ -0,0 +1,36 @@ +@testable import SnapshotKit +import SwiftUI +import Testing +import UIKit + +@MainActor +struct SnapshotTraitsTests { + @Test func removesDeviceOverridesWhenAReusedHostReturnsToInheritedTraits() { + let host = UIHostingController(rootView: EmptyView()) + applySnapshotTraitOverrides( + SnapshotConfiguration( + contrast: .increased, + layoutTraits: .tabletPortrait, + ), + to: host, + ) + + #expect(host.traitCollection.accessibilityContrast == .high) + #expect(host.traitCollection.userInterfaceIdiom == .pad) + #expect(host.traitCollection.horizontalSizeClass == .regular) + #expect(host.traitCollection.verticalSizeClass == .regular) + #expect(host.traitOverrides.contains(UITraitUserInterfaceIdiom.self)) + #expect(host.traitOverrides.contains(UITraitHorizontalSizeClass.self)) + #expect(host.traitOverrides.contains(UITraitVerticalSizeClass.self)) + + applySnapshotTraitOverrides( + SnapshotConfiguration(contrast: .increased), + to: host, + ) + + #expect(host.traitCollection.accessibilityContrast == .high) + #expect(host.traitOverrides.contains(UITraitUserInterfaceIdiom.self) == false) + #expect(host.traitOverrides.contains(UITraitHorizontalSizeClass.self) == false) + #expect(host.traitOverrides.contains(UITraitVerticalSizeClass.self) == false) + } +} diff --git a/Shared/SnapshotKitTesting/README.md b/Shared/SnapshotKitTesting/README.md index 340509a30..b76c927a7 100644 --- a/Shared/SnapshotKitTesting/README.md +++ b/Shared/SnapshotKitTesting/README.md @@ -114,10 +114,11 @@ 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. +including explicit interface idiom and size classes. It then does 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 error throws. The caller must not publish +a partial artifact. ## Recording diff --git a/Shared/SnapshotKitTesting/Sources/SnapshotHostingController.swift b/Shared/SnapshotKitTesting/Sources/SnapshotHostingController.swift index eed7e0797..d70948e3c 100644 --- a/Shared/SnapshotKitTesting/Sources/SnapshotHostingController.swift +++ b/Shared/SnapshotKitTesting/Sources/SnapshotHostingController.swift @@ -27,6 +27,11 @@ func makeHostingController( hostingController.traitOverrides.accessibilityContrast = traits.accessibilityContrast hostingController.traitOverrides.layoutDirection = traits.layoutDirection hostingController.traitOverrides.legibilityWeight = traits.legibilityWeight + if configuration.layoutTraits != nil { + hostingController.traitOverrides.userInterfaceIdiom = traits.userInterfaceIdiom + hostingController.traitOverrides.horizontalSizeClass = traits.horizontalSizeClass + hostingController.traitOverrides.verticalSizeClass = traits.verticalSizeClass + } switch configuration.device.size { case let .fixed(size): diff --git a/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift b/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift index 74858e356..0399727cd 100644 --- a/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift +++ b/Shared/SnapshotKitTesting/Tests/SnapshotPNGTests.swift @@ -1,4 +1,4 @@ -import SnapshotKitTesting +@_spi(Testing) import SnapshotKitTesting import SwiftUI import Testing import UIKit @@ -31,6 +31,30 @@ struct SnapshotPNGTests { #expect(png.pixelSize.height == png.pointSize.height * png.scale) } + @Test func appliesTabletLayoutTraitsToUIKitAndSwiftUI() async throws { + try await expectAdaptiveLayoutTraits( + .tabletPortrait, + expected: AdaptiveTraitExpectation( + interfaceIdiom: .pad, + horizontalSizeClass: .regular, + verticalSizeClass: .regular, + ), + captureName: "png-api-tablet-traits-probe", + ) + } + + @Test func appliesPhoneLandscapeLayoutTraitsToUIKitAndSwiftUI() async throws { + try await expectAdaptiveLayoutTraits( + .phoneLandscape, + expected: AdaptiveTraitExpectation( + interfaceIdiom: .phone, + horizontalSizeClass: .compact, + verticalSizeClass: .compact, + ), + captureName: "png-api-phone-landscape-traits-probe", + ) + } + @Test func runsReadinessHooksThroughTheSharedPipeline() async throws { var measurementHookRan = false var finalHookRan = false @@ -229,6 +253,116 @@ struct SnapshotPNGTests { } } +@MainActor +private func expectAdaptiveLayoutTraits( + _ layoutTraits: SnapshotConfiguration.LayoutTraits, + expected: AdaptiveTraitExpectation, + captureName: String, +) async throws { + let configuration = SnapshotConfiguration( + layoutTraits: layoutTraits, + device: SnapshotConfiguration.Frame( + name: captureName, + size: .fixed(CGSize(width: 100, height: 60)), + ), + ) + let png = try await captureSnapshotPNG( + of: AdaptiveTraitProbe(expected: expected), + configuration: configuration, + named: captureName, + sizing: .fixed, + safeAreaInsets: .zero, + measurementReadiness: .sameAsCapture, + onReadyToMeasure: nil, + settle: .immediate, + onReadyToSnapshot: nil, + ) + let image = try #require(UIImage(data: png.data, scale: png.scale)) + let swiftUITraits = image.probePixel(atUnitPoint: CGPoint(x: 0.25, y: 0.5)) + let uiKitTraits = image.probePixel(atUnitPoint: CGPoint(x: 0.75, y: 0.5)) + + #expect(swiftUITraits.green > 0.5) + #expect(swiftUITraits.red < 0.5) + #expect(uiKitTraits.green > 0.5) + #expect(uiKitTraits.red < 0.5) +} + +private struct AdaptiveTraitExpectation { + let interfaceIdiom: UIUserInterfaceIdiom + let horizontalSizeClass: UIUserInterfaceSizeClass + let verticalSizeClass: UIUserInterfaceSizeClass + + var swiftUIHorizontalSizeClass: UserInterfaceSizeClass { + switch horizontalSizeClass { + case .compact: .compact + case .regular: .regular + case .unspecified: .compact + @unknown default: .compact + } + } + + var swiftUIVerticalSizeClass: UserInterfaceSizeClass { + switch verticalSizeClass { + case .compact: .compact + case .regular: .regular + case .unspecified: .compact + @unknown default: .compact + } + } +} + +private struct AdaptiveTraitProbe: View { + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass + + let expected: AdaptiveTraitExpectation + + var body: some View { + HStack(spacing: 0) { + let matches = horizontalSizeClass == expected.swiftUIHorizontalSizeClass + && verticalSizeClass == expected.swiftUIVerticalSizeClass + (matches ? Color.green : Color.red) + UIKitAdaptiveTraitProbe(expected: expected) + } + } +} + +private struct UIKitAdaptiveTraitProbe: UIViewRepresentable { + let expected: AdaptiveTraitExpectation + + func makeUIView(context _: Context) -> UIKitAdaptiveTraitProbeView { + UIKitAdaptiveTraitProbeView(expected: expected) + } + + func updateUIView(_ view: UIKitAdaptiveTraitProbeView, context _: Context) { + view.expected = expected + view.setNeedsLayout() + } +} + +@MainActor +private final class UIKitAdaptiveTraitProbeView: UIView { + var expected: AdaptiveTraitExpectation + + init(expected: AdaptiveTraitExpectation) { + self.expected = expected + super.init(frame: .zero) + } + + @available(*, unavailable) + required init?(coder _: NSCoder) { + fatalError("init(coder:) is unavailable") + } + + override func layoutSubviews() { + super.layoutSubviews() + let matches = traitCollection.userInterfaceIdiom == expected.interfaceIdiom + && traitCollection.horizontalSizeClass == expected.horizontalSizeClass + && traitCollection.verticalSizeClass == expected.verticalSizeClass + backgroundColor = matches ? .green : .red + } +} + @MainActor private final class QueuedCaptureCancellationProbe { var firstCaptureStarted = false diff --git a/Tools/README.md b/Tools/README.md index eec9b260c..e46c82653 100644 --- a/Tools/README.md +++ b/Tools/README.md @@ -13,8 +13,9 @@ result reporting without requiring Java in its tests. The Xcode-facing root commands keep process and simulator orchestration in shell; `xcode_results.py` shares xcresult traversal, `snapshot_reports.py` shares capture reports, and the command-specific Python modules retain each command's distinct policy. -`flyover_preview.py` validates generated atlases and serves their allowlisted -files from a local HTTP server. +`flyover_manifest.py` validates generated atlases and builds their file +allowlists. `flyover_preview.py` pins and serves those files from a local HTTP +server. Filesystem-heavy Ruby generators are require-safe so their behavior can be exercised against temporary repositories. `SimulatorRegistry` validates exact runtime ownership from captured `simctl` JSON and persists private claims diff --git a/Tools/Tests/flyover_test_support.py b/Tools/Tests/flyover_test_support.py new file mode 100644 index 000000000..574fce3b1 --- /dev/null +++ b/Tools/Tests/flyover_test_support.py @@ -0,0 +1,121 @@ +"""Shared fixtures for Flyover manifest and preview tool tests.""" + +import json +from pathlib import Path +from typing import Any + + +def write_manifest(root: Path, manifest: object) -> None: + data = json.dumps(manifest) + (root / "manifest.json").write_text(data) + (root / "manifest.js").write_text("window.FLYOVER_MANIFEST = " + data + ";\n") + + +def create_flyover_artifact(root: Path) -> Path: + (root / "assets").mkdir(parents=True) + image = root / "images/screen-0001/variant-0001/phone-light.png" + image.parent.mkdir(parents=True) + image.write_bytes(b"PNG") + (root / ".flyover-generated").write_text("schemaVersion=1\n") + (root / "index.html").write_text("") + (root / "assets/app.js").write_text("") + (root / "assets/styles.css").write_text("") + write_manifest(root, flyover_manifest_fixture()) + return root + + +def flyover_manifest_fixture() -> dict[str, Any]: + image_path = "images/screen-0001/variant-0001/phone-light.png" + screen_frame = {"x": 50, "y": 50, "width": 300, "height": 650} + return { + "schemaVersion": 1, + "application": {"id": "where", "title": "Where"}, + "build": { + "commit": "abc123", + "dirty": False, + "generatedAt": "2026-09-07T12:00:00Z", + "xcodeVersion": "Xcode 27.0", + "simulatorDevice": "iPhone 17", + "simulatorOS": "27.0", + }, + "profiles": [ + { + "id": "phone-light", + "title": "Phone Light", + "device": "phone", + "orientation": "portrait", + "colorScheme": "light", + "dynamicType": "large", + "contrast": "standard", + "layoutDirection": "left-to-right", + "legibilityWeight": "regular", + "snapshotType": "standard", + } + ], + "canvas": { + "size": {"width": 500, "height": 700}, + "initialFitSize": {"width": 500, "height": 700}, + "groupFrames": [ + { + "id": "group", + "frame": {"x": 0, "y": 0, "width": 500, "height": 700}, + } + ], + "depthBandFrames": [ + { + "groupID": "group", + "kind": "route", + "depth": 0, + "frame": {"x": 20, "y": 20, "width": 460, "height": 660}, + } + ], + "screenFrames": [{"id": "screen", "frame": screen_frame}], + "connectors": [], + }, + "groups": [ + { + "id": "group", + "title": "Group", + "order": 0, + "rootScreenID": "screen", + "screenIDs": ["screen"], + } + ], + "screens": [ + { + "id": "screen", + "title": "Screen", + "groupID": "group", + "groupOrder": 0, + "screenOrder": 0, + "viewport": {"kind": "device"}, + "navigationContainer": "stack", + "frame": screen_frame, + "variants": [ + { + "id": "default", + "title": "Default", + "captureExtent": "viewport", + "imagesByProfile": {"phone-light": image_path}, + } + ], + "incomingRouteIDs": [], + "outgoingRouteIDs": [], + } + ], + "routes": [], + "images": [ + { + "screenID": "screen", + "variantID": "default", + "profileID": "phone-light", + "relativePath": image_path, + "pointWidth": 402, + "pointHeight": 874, + "pixelWidth": 1206, + "pixelHeight": 2622, + "scale": 3, + "captureExtent": "viewport", + } + ], + } diff --git a/Tools/Tests/test_flyover_manifest.py b/Tools/Tests/test_flyover_manifest.py new file mode 100644 index 000000000..e2a13f78e --- /dev/null +++ b/Tools/Tests/test_flyover_manifest.py @@ -0,0 +1,242 @@ +import importlib.util +import json +import sys +import tempfile +import unittest +from pathlib import Path + +try: + from Tools.Tests.flyover_test_support import ( + create_flyover_artifact, + write_manifest, + ) +except ModuleNotFoundError as error: + if error.name != "Tools": + raise + from flyover_test_support import create_flyover_artifact, write_manifest + + +MODULE_PATH = Path(__file__).resolve().parents[1] / "flyover_manifest.py" +SPEC = importlib.util.spec_from_file_location("flyover_manifest", MODULE_PATH) +flyover_manifest = importlib.util.module_from_spec(SPEC) +assert SPEC.loader +sys.modules[SPEC.name] = flyover_manifest +SPEC.loader.exec_module(flyover_manifest) + + +class FlyoverManifestTests(unittest.TestCase): + def test_validates_generated_artifact_and_builds_allowlist(self): + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + + artifact = flyover_manifest.validate_artifact(root) + + self.assertEqual(artifact.root, root) + self.assertEqual( + artifact.allowed_paths, + frozenset( + { + "index.html", + "manifest.json", + "manifest.js", + "assets/app.js", + "assets/styles.css", + "images/screen-0001/variant-0001/phone-light.png", + } + ), + ) + + def test_rejects_invalid_marker_and_symbolic_links(self): + with tempfile.TemporaryDirectory() as temporary: + temporary_root = Path(temporary) + root = create_flyover_artifact(temporary_root / "atlas") + (root / ".flyover-generated").write_text("schemaVersion=2\n") + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "generated marker is unsupported", + ): + flyover_manifest.validate_artifact(root) + + (root / ".flyover-generated").write_bytes(b"\xff") + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "could not read", + ): + flyover_manifest.validate_artifact(root) + + (root / ".flyover-generated").write_text("schemaVersion=1\n") + (root / "leak").symlink_to(temporary_root / "outside") + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "contains a symbolic link", + ): + flyover_manifest.validate_artifact(root) + + def test_rejects_unsafe_missing_duplicate_and_extra_images(self): + scenarios = ( + ("../outside.png", "unsafe image path"), + ("/outside.png", "unsafe image path"), + ("images/missing.png", "manifest image is missing"), + ) + for relative_path, message in scenarios: + with self.subTest(relative_path=relative_path): + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["images"][0]["relativePath"] = relative_path + if relative_path == "images/missing.png": + manifest["screens"][0]["variants"][0]["imagesByProfile"][ + "phone-light" + ] = relative_path + write_manifest(root, manifest) + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + message, + ): + flyover_manifest.validate_artifact(root) + + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + manifest_path = root / "manifest.json" + manifest = json.loads(manifest_path.read_text()) + manifest["images"].append(dict(manifest["images"][0])) + write_manifest(root, manifest) + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "duplicate screen, variant, and profile record", + ): + flyover_manifest.validate_artifact(root) + + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + (root / "images/extra.png").write_bytes(b"PNG") + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "image files do not match", + ): + flyover_manifest.validate_artifact(root) + + def test_rejects_non_object_boolean_schema_and_mismatched_script(self): + scenarios = ( + ([], "not a JSON object"), + ({"schemaVersion": True, "images": []}, "schemaVersion 1"), + ) + for manifest, message in scenarios: + with self.subTest(manifest=manifest): + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + write_manifest(root, manifest) + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + message, + ): + flyover_manifest.validate_artifact(root) + + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + (root / "manifest.js").write_text("window.FLYOVER_MANIFEST = {};\n") + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "manifest.js does not match manifest.json", + ): + flyover_manifest.validate_artifact(root) + + def test_rejects_incomplete_and_internally_inconsistent_manifests(self): + scenarios = ( + ( + lambda manifest: manifest.pop("application"), + "manifest.application is not an object", + ), + ( + lambda manifest: manifest["build"].pop("generatedAt"), + "manifest.build is missing generatedAt", + ), + ( + lambda manifest: manifest["canvas"].pop("screenFrames"), + "manifest.canvas is missing screenFrames", + ), + ( + lambda manifest: manifest["groups"][0].update( + { + "rootScreenID": "missing-screen", + "screenIDs": ["missing-screen"], + } + ), + "references an unknown screen", + ), + ( + lambda manifest: manifest["images"][0].update( + {"profileID": "missing-profile"} + ), + "references an unknown profile", + ), + ( + lambda manifest: manifest.update({"images": []}), + "manifest.images is empty", + ), + ) + for mutate, message in scenarios: + with self.subTest(message=message): + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + manifest = json.loads((root / "manifest.json").read_text()) + mutate(manifest) + write_manifest(root, manifest) + + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + message, + ): + flyover_manifest.validate_artifact(root) + + def test_accepts_optional_complete_thumbnail_metadata(self): + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + thumbnail_relative = ( + "images/screen-0001/variant-0001/phone-light-thumbnail.png" + ) + thumbnail = root / thumbnail_relative + thumbnail.write_bytes(b"THUMBNAIL") + manifest = json.loads((root / "manifest.json").read_text()) + manifest["images"][0].update( + { + "thumbnailRelativePath": thumbnail_relative, + "thumbnailPixelWidth": 300, + "thumbnailPixelHeight": 650, + } + ) + write_manifest(root, manifest) + + artifact = flyover_manifest.validate_artifact(root) + + self.assertIn(thumbnail_relative, artifact.allowed_paths) + + def test_rejects_incomplete_or_undeclared_thumbnail_assets(self): + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + manifest = json.loads((root / "manifest.json").read_text()) + manifest["images"][0]["thumbnailRelativePath"] = ( + "images/screen-0001/variant-0001/phone-light-thumbnail.png" + ) + write_manifest(root, manifest) + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "incomplete thumbnail metadata", + ): + flyover_manifest.validate_artifact(root) + + with tempfile.TemporaryDirectory() as temporary: + root = create_flyover_artifact(Path(temporary)) + (root / "images/screen-0001/variant-0001/extra-thumbnail.png").write_bytes( + b"THUMBNAIL" + ) + with self.assertRaisesRegex( + flyover_manifest.FlyoverArtifactError, + "image files do not match", + ): + flyover_manifest.validate_artifact(root) + + +if __name__ == "__main__": + unittest.main() diff --git a/Tools/Tests/test_flyover_preview.py b/Tools/Tests/test_flyover_preview.py index 7540cbe6d..d258f6c96 100644 --- a/Tools/Tests/test_flyover_preview.py +++ b/Tools/Tests/test_flyover_preview.py @@ -1,6 +1,5 @@ import importlib.util import io -import json import signal import sys import tempfile @@ -8,6 +7,13 @@ import unittest from pathlib import Path +try: + from Tools.Tests.flyover_test_support import create_flyover_artifact +except ModuleNotFoundError as error: + if error.name != "Tools": + raise + from flyover_test_support import create_flyover_artifact + MODULE_PATH = Path(__file__).resolve().parents[1] / "flyover_preview.py" SPEC = importlib.util.spec_from_file_location("flyover_preview", MODULE_PATH) @@ -18,149 +24,42 @@ class FlyoverPreviewTests(unittest.TestCase): - def write_manifest(self, root: Path, manifest: object) -> None: - data = json.dumps(manifest) - (root / "manifest.json").write_text(data) - (root / "manifest.js").write_text( - "window.FLYOVER_MANIFEST = " + data + ";\n" - ) - - def fixture(self, root: Path) -> Path: - (root / "assets").mkdir(parents=True) - image = root / "images/screen-0001/variant-0001/phone-light.png" - image.parent.mkdir(parents=True) - image.write_bytes(b"PNG") - (root / ".flyover-generated").write_text("schemaVersion=1\n") - (root / "index.html").write_text("") - (root / "assets/app.js").write_text("") - (root / "assets/styles.css").write_text("") - self.write_manifest( - root, - { - "schemaVersion": 1, - "images": [ - { - "relativePath": ( - "images/screen-0001/variant-0001/phone-light.png" - ) - } - ], - }, - ) - return root + fixture = staticmethod(create_flyover_artifact) - def test_validates_generated_artifact_and_builds_allowlist(self): + def test_opening_an_allowlisted_file_does_not_follow_a_replacement_symlink(self): with tempfile.TemporaryDirectory() as temporary: - root = self.fixture(Path(temporary)) - + temporary_root = Path(temporary) + root = self.fixture(temporary_root / "atlas") artifact = flyover_preview.validate_artifact(root) - - self.assertEqual(artifact.root, root) - self.assertEqual( - artifact.allowed_paths, - frozenset( - { - "index.html", - "manifest.json", - "manifest.js", - "assets/app.js", - "assets/styles.css", + image = root / "images/screen-0001/variant-0001/phone-light.png" + outside = temporary_root / "outside" + outside.write_bytes(b"SECRET") + + with flyover_preview._open_artifact_root(artifact) as descriptor: + image.unlink() + image.symlink_to(outside) + with self.assertRaises(OSError): + flyover_preview._open_file_beneath( + descriptor, "images/screen-0001/variant-0001/phone-light.png", - } - ), - ) + ) - def test_rejects_invalid_marker_and_symbolic_links(self): + def test_server_root_descriptor_stays_on_the_validated_directory(self): with tempfile.TemporaryDirectory() as temporary: temporary_root = Path(temporary) root = self.fixture(temporary_root / "atlas") - (root / ".flyover-generated").write_text("schemaVersion=2\n") - with self.assertRaisesRegex( - flyover_preview.FlyoverArtifactError, - "generated marker is unsupported", - ): - flyover_preview.validate_artifact(root) - - (root / ".flyover-generated").write_bytes(b"\xff") - with self.assertRaisesRegex( - flyover_preview.FlyoverArtifactError, - "could not read", - ): - flyover_preview.validate_artifact(root) - - (root / ".flyover-generated").write_text("schemaVersion=1\n") - (root / "leak").symlink_to(temporary_root / "outside") - with self.assertRaisesRegex( - flyover_preview.FlyoverArtifactError, - "contains a symbolic link", - ): - flyover_preview.validate_artifact(root) - - def test_rejects_unsafe_missing_duplicate_and_extra_images(self): - scenarios = ( - ("../outside.png", "unsafe image path"), - ("/outside.png", "unsafe image path"), - ("images/missing.png", "manifest image is missing"), - ) - for relative_path, message in scenarios: - with self.subTest(relative_path=relative_path): - with tempfile.TemporaryDirectory() as temporary: - root = self.fixture(Path(temporary)) - manifest_path = root / "manifest.json" - manifest = json.loads(manifest_path.read_text()) - manifest["images"][0]["relativePath"] = relative_path - self.write_manifest(root, manifest) - with self.assertRaisesRegex( - flyover_preview.FlyoverArtifactError, - message, - ): - flyover_preview.validate_artifact(root) - - with tempfile.TemporaryDirectory() as temporary: - root = self.fixture(Path(temporary)) - manifest_path = root / "manifest.json" - manifest = json.loads(manifest_path.read_text()) - manifest["images"].append(dict(manifest["images"][0])) - self.write_manifest(root, manifest) - with self.assertRaisesRegex( - flyover_preview.FlyoverArtifactError, - "duplicate image path", - ): - flyover_preview.validate_artifact(root) - - with tempfile.TemporaryDirectory() as temporary: - root = self.fixture(Path(temporary)) - (root / "images/extra.png").write_bytes(b"PNG") - with self.assertRaisesRegex( - flyover_preview.FlyoverArtifactError, - "image files do not match", - ): - flyover_preview.validate_artifact(root) - - def test_rejects_non_object_boolean_schema_and_mismatched_script(self): - scenarios = ( - ([], "not a JSON object"), - ({"schemaVersion": True, "images": []}, "schemaVersion 1"), - ) - for manifest, message in scenarios: - with self.subTest(manifest=manifest): - with tempfile.TemporaryDirectory() as temporary: - root = self.fixture(Path(temporary)) - self.write_manifest(root, manifest) - with self.assertRaisesRegex( - flyover_preview.FlyoverArtifactError, - message, - ): - flyover_preview.validate_artifact(root) - - with tempfile.TemporaryDirectory() as temporary: - root = self.fixture(Path(temporary)) - (root / "manifest.js").write_text("window.FLYOVER_MANIFEST = {};\n") - with self.assertRaisesRegex( - flyover_preview.FlyoverArtifactError, - "manifest.js does not match manifest.json", - ): - flyover_preview.validate_artifact(root) + artifact = flyover_preview.validate_artifact(root) + moved = temporary_root / "validated-atlas" + + with flyover_preview._open_artifact_root(artifact) as descriptor: + root.rename(moved) + root.mkdir() + (root / "manifest.json").write_bytes(b"SECRET") + with flyover_preview._open_file_beneath( + descriptor, + "manifest.json", + ) as manifest_file: + self.assertIn(b'"schemaVersion": 1', manifest_file.read()) def test_filters_and_sorts_usable_ipv4_addresses(self): self.assertEqual( diff --git a/Tools/flyover_manifest.py b/Tools/flyover_manifest.py new file mode 100644 index 000000000..1ea253813 --- /dev/null +++ b/Tools/flyover_manifest.py @@ -0,0 +1,838 @@ +"""Schema and filesystem validation for generated Flyover atlases.""" + +from __future__ import annotations + +import json +import math +import os +import stat +from dataclasses import dataclass +from pathlib import Path, PurePosixPath + + +MARKER_CONTENT = "schemaVersion=1" +REQUIRED_FILES = ( + "index.html", + "manifest.json", + "manifest.js", + "assets/app.js", + "assets/styles.css", +) + + +class FlyoverArtifactError(ValueError): + """A generated atlas is incomplete or unsafe to serve.""" + + +@dataclass(frozen=True) +class FlyoverArtifact: + root: Path + allowed_paths: frozenset[str] + root_device: int + root_inode: int + + +def validate_artifact( + directory: Path, + *, + require_marker: bool = True, +) -> FlyoverArtifact: + """Validate a generated atlas and return its HTTP allowlist.""" + root = directory + try: + root_status = os.lstat(root) + except FileNotFoundError: + raise FlyoverArtifactError( + f"no generated atlas exists at {root}. Run ./flyover export first." + ) from None + except OSError as error: + raise FlyoverArtifactError(f"could not inspect the atlas path {root}: {error}") from error + if stat.S_ISLNK(root_status.st_mode): + raise FlyoverArtifactError(f"the atlas directory is a symbolic link: {root}") + if not stat.S_ISDIR(root_status.st_mode): + raise FlyoverArtifactError(f"the atlas path is not a directory: {root}") + + symbolic_link = next((path for path in root.rglob("*") if path.is_symlink()), None) + if symbolic_link is not None: + relative = symbolic_link.relative_to(root) + raise FlyoverArtifactError(f"the atlas contains a symbolic link: {relative}") + + marker = root / ".flyover-generated" + if require_marker: + if not marker.is_file(): + raise FlyoverArtifactError( + f"the directory is not a generated Flyover atlas: {root}" + ) + try: + marker_content = marker.read_text(encoding="utf-8").strip() + except (OSError, UnicodeError) as error: + raise FlyoverArtifactError(f"could not read {marker}: {error}") from error + if marker_content != MARKER_CONTENT: + raise FlyoverArtifactError( + f"the generated marker is unsupported: {marker_content or 'empty'}" + ) + + for relative in REQUIRED_FILES: + path = root / relative + if not path.is_file(): + raise FlyoverArtifactError(f"the generated atlas is missing {relative}") + + manifest_path = root / "manifest.json" + try: + manifest_data = manifest_path.read_bytes() + manifest = json.loads(manifest_data) + except (OSError, UnicodeError, json.JSONDecodeError) as error: + raise FlyoverArtifactError(f"could not read manifest.json: {error}") from error + if not isinstance(manifest, dict): + raise FlyoverArtifactError("manifest.json is not a JSON object") + if ( + type(manifest.get("schemaVersion")) is not int + or manifest["schemaVersion"] != 1 + ): + raise FlyoverArtifactError("manifest.json does not use schemaVersion 1") + _validate_manifest(manifest) + + manifest_script_path = root / "manifest.js" + try: + manifest_script = manifest_script_path.read_bytes() + except OSError as error: + raise FlyoverArtifactError(f"could not read manifest.js: {error}") from error + expected_script = b"window.FLYOVER_MANIFEST = " + manifest_data + b";\n" + if manifest_script != expected_script: + raise FlyoverArtifactError("manifest.js does not match manifest.json") + + images = manifest.get("images") + if not isinstance(images, list): + raise FlyoverArtifactError("manifest.json has no image list") + + asset_paths: list[str] = [] + for image in images: + if not isinstance(image, dict): + raise FlyoverArtifactError("manifest.json contains an invalid image record") + relative_value = image.get("relativePath") + if not isinstance(relative_value, str): + raise FlyoverArtifactError("manifest.json contains an invalid image path") + relative = _safe_image_path(relative_value) + path = root.joinpath(*relative.parts) + if not path.is_file(): + raise FlyoverArtifactError(f"the manifest image is missing: {relative}") + asset_paths.append(relative.as_posix()) + + if "thumbnailRelativePath" in image: + thumbnail_value = image["thumbnailRelativePath"] + assert isinstance(thumbnail_value, str) + thumbnail = _safe_image_path(thumbnail_value) + thumbnail_path = root.joinpath(*thumbnail.parts) + if not thumbnail_path.is_file(): + raise FlyoverArtifactError( + f"the manifest thumbnail is missing: {thumbnail}" + ) + asset_paths.append(thumbnail.as_posix()) + + if len(set(asset_paths)) != len(asset_paths): + raise FlyoverArtifactError("manifest.json contains a duplicate image or thumbnail path") + + images_directory = root / "images" + if images_directory.is_dir(): + actual_paths = { + path.relative_to(root).as_posix() + for path in images_directory.rglob("*.png") + if path.is_file() + } + else: + actual_paths = set() + declared_paths = set(asset_paths) + if actual_paths != declared_paths: + raise FlyoverArtifactError( + "the image files do not match the manifest " + f"({len(declared_paths)} declared, {len(actual_paths)} found)" + ) + + return FlyoverArtifact( + root=root, + allowed_paths=frozenset((*REQUIRED_FILES, *asset_paths)), + root_device=root_status.st_dev, + root_inode=root_status.st_ino, + ) + + +def _validate_manifest(manifest: dict[str, object]) -> None: + """Validate all schema-1 data that the browser reads.""" + application = _required_object( + manifest.get("application"), + "manifest.application", + ("id", "title"), + ) + _string(application["id"], "manifest.application.id", nonempty=True) + _string(application["title"], "manifest.application.title", nonempty=True) + + build = _required_object( + manifest.get("build"), + "manifest.build", + ( + "commit", + "dirty", + "generatedAt", + "xcodeVersion", + "simulatorDevice", + "simulatorOS", + ), + ) + for field in ( + "commit", + "generatedAt", + "xcodeVersion", + "simulatorDevice", + "simulatorOS", + ): + _string(build[field], f"manifest.build.{field}", nonempty=True) + if type(build["dirty"]) is not bool: + raise FlyoverArtifactError("manifest.build.dirty is not a Boolean") + branch = build.get("branch") + if branch is not None: + _string(branch, "manifest.build.branch") + + profiles = _required_list(manifest.get("profiles"), "manifest.profiles", nonempty=True) + profile_by_id = _identified_records( + profiles, + "manifest.profiles", + ( + "id", + "title", + "device", + "orientation", + "colorScheme", + "dynamicType", + "contrast", + "layoutDirection", + "legibilityWeight", + "snapshotType", + ), + ) + profile_values = { + "device": {"phone", "tablet"}, + "orientation": {"portrait", "landscape"}, + "colorScheme": {"light", "dark"}, + "dynamicType": {"small", "large", "xxxl", "accessibility3"}, + "contrast": {"standard", "increased"}, + "layoutDirection": {"left-to-right", "right-to-left"}, + "legibilityWeight": {"regular", "bold"}, + "snapshotType": {"standard", "accessibility"}, + } + for index, profile in enumerate(profiles): + assert isinstance(profile, dict) + _string(profile["title"], f"manifest.profiles[{index}].title", nonempty=True) + for field, accepted in profile_values.items(): + value = _string(profile[field], f"manifest.profiles[{index}].{field}") + if value not in accepted: + raise FlyoverArtifactError( + f"manifest.profiles[{index}].{field} has an unsupported value: {value}" + ) + + groups = _required_list(manifest.get("groups"), "manifest.groups", nonempty=True) + group_by_id = _identified_records( + groups, + "manifest.groups", + ("id", "title", "order", "rootScreenID", "screenIDs"), + ) + for index, group in enumerate(groups): + assert isinstance(group, dict) + _string(group["title"], f"manifest.groups[{index}].title", nonempty=True) + if _integer(group["order"], f"manifest.groups[{index}].order") != index: + raise FlyoverArtifactError("manifest.groups are not in stable order") + _string( + group["rootScreenID"], + f"manifest.groups[{index}].rootScreenID", + nonempty=True, + ) + screen_ids = _string_list( + group["screenIDs"], + f"manifest.groups[{index}].screenIDs", + nonempty=True, + ) + if len(set(screen_ids)) != len(screen_ids): + raise FlyoverArtifactError( + f"manifest.groups[{index}].screenIDs contains a duplicate identifier" + ) + + screens = _required_list(manifest.get("screens"), "manifest.screens", nonempty=True) + screen_by_id = _identified_records( + screens, + "manifest.screens", + ( + "id", + "title", + "groupID", + "groupOrder", + "screenOrder", + "viewport", + "navigationContainer", + "frame", + "variants", + "incomingRouteIDs", + "outgoingRouteIDs", + ), + ) + variant_by_key: dict[tuple[str, str], dict[str, object]] = {} + expected_screen_ids: list[str] = [] + for group_index, group in enumerate(groups): + assert isinstance(group, dict) + group_id = str(group["id"]) + group_screen_ids = _string_list( + group["screenIDs"], + f"manifest.groups[{group_index}].screenIDs", + nonempty=True, + ) + root_screen_id = str(group["rootScreenID"]) + if root_screen_id not in group_screen_ids: + raise FlyoverArtifactError( + f"manifest.groups[{group_index}].rootScreenID is not in its screenIDs" + ) + expected_screen_ids.extend(group_screen_ids) + for screen_index, screen_id in enumerate(group_screen_ids): + screen = screen_by_id.get(screen_id) + if screen is None: + raise FlyoverArtifactError( + f"manifest.groups[{group_index}].screenIDs references an unknown screen: " + f"{screen_id}" + ) + if screen["groupID"] != group_id: + raise FlyoverArtifactError( + f"manifest.screens[{screen_id}].groupID does not match its group" + ) + if screen["groupOrder"] != group_index or screen["screenOrder"] != screen_index: + raise FlyoverArtifactError( + f"manifest.screens[{screen_id}] has inconsistent ordering" + ) + if len(set(expected_screen_ids)) != len(expected_screen_ids): + raise FlyoverArtifactError("manifest group screen lists contain a duplicate screen") + if expected_screen_ids != list(screen_by_id): + raise FlyoverArtifactError("manifest group screen lists do not match manifest.screens") + + for index, screen in enumerate(screens): + assert isinstance(screen, dict) + screen_id = str(screen["id"]) + _string(screen["title"], f"manifest.screens[{index}].title", nonempty=True) + group_id = _string( + screen["groupID"], + f"manifest.screens[{index}].groupID", + nonempty=True, + ) + if group_id not in group_by_id: + raise FlyoverArtifactError( + f"manifest.screens[{index}].groupID references an unknown group: {group_id}" + ) + _integer(screen["groupOrder"], f"manifest.screens[{index}].groupOrder") + _integer(screen["screenOrder"], f"manifest.screens[{index}].screenOrder") + _validate_viewport(screen["viewport"], f"manifest.screens[{index}].viewport") + navigation = _string( + screen["navigationContainer"], + f"manifest.screens[{index}].navigationContainer", + ) + if navigation not in {"stack", "none"}: + raise FlyoverArtifactError( + f"manifest.screens[{index}].navigationContainer has an unsupported value: " + f"{navigation}" + ) + _validate_rect(screen["frame"], f"manifest.screens[{index}].frame") + _string_list( + screen["incomingRouteIDs"], + f"manifest.screens[{index}].incomingRouteIDs", + ) + _string_list( + screen["outgoingRouteIDs"], + f"manifest.screens[{index}].outgoingRouteIDs", + ) + + variants = _required_list( + screen["variants"], + f"manifest.screens[{index}].variants", + nonempty=True, + ) + local_ids: set[str] = set() + for variant_index, value in enumerate(variants): + variant = _required_object( + value, + f"manifest.screens[{index}].variants[{variant_index}]", + ("id", "title", "captureExtent", "imagesByProfile"), + ) + variant_id = _string( + variant["id"], + f"manifest.screens[{index}].variants[{variant_index}].id", + nonempty=True, + ) + if variant_id in local_ids: + raise FlyoverArtifactError( + f"manifest.screens[{index}].variants contains a duplicate identifier: " + f"{variant_id}" + ) + local_ids.add(variant_id) + _string( + variant["title"], + f"manifest.screens[{index}].variants[{variant_index}].title", + nonempty=True, + ) + extent = _capture_extent( + variant["captureExtent"], + f"manifest.screens[{index}].variants[{variant_index}].captureExtent", + ) + images_by_profile = _required_object( + variant["imagesByProfile"], + f"manifest.screens[{index}].variants[{variant_index}].imagesByProfile", + tuple(profile_by_id), + ) + if set(images_by_profile) != set(profile_by_id): + raise FlyoverArtifactError( + f"manifest.screens[{index}].variants[{variant_index}].imagesByProfile " + "does not match manifest.profiles" + ) + for profile_id, relative_path in images_by_profile.items(): + _safe_image_path( + _string( + relative_path, + f"manifest.screens[{index}].variants[{variant_index}]" + f".imagesByProfile[{profile_id}]", + nonempty=True, + ) + ) + variant_by_key[(screen_id, variant_id)] = variant + + routes = _required_list(manifest.get("routes"), "manifest.routes") + route_by_id = _identified_records( + routes, + "manifest.routes", + ("id", "sourceScreenID", "destinationScreenID", "kind", "geometry"), + ) + for index, route in enumerate(routes): + assert isinstance(route, dict) + for field in ("sourceScreenID", "destinationScreenID"): + screen_id = _string( + route[field], + f"manifest.routes[{index}].{field}", + nonempty=True, + ) + if screen_id not in screen_by_id: + raise FlyoverArtifactError( + f"manifest.routes[{index}].{field} references an unknown screen: " + f"{screen_id}" + ) + kind = _string(route["kind"], f"manifest.routes[{index}].kind") + if kind not in {"push", "modal"}: + raise FlyoverArtifactError( + f"manifest.routes[{index}].kind has an unsupported value: {kind}" + ) + label = route.get("label") + if label is not None: + _string(label, f"manifest.routes[{index}].label") + _validate_geometry(route["geometry"], f"manifest.routes[{index}].geometry") + + for index, screen in enumerate(screens): + assert isinstance(screen, dict) + screen_id = str(screen["id"]) + expected_incoming = [ + str(route["id"]) + for route in routes + if isinstance(route, dict) and route["destinationScreenID"] == screen_id + ] + expected_outgoing = [ + str(route["id"]) + for route in routes + if isinstance(route, dict) and route["sourceScreenID"] == screen_id + ] + if screen["incomingRouteIDs"] != expected_incoming: + raise FlyoverArtifactError( + f"manifest.screens[{index}].incomingRouteIDs does not match manifest.routes" + ) + if screen["outgoingRouteIDs"] != expected_outgoing: + raise FlyoverArtifactError( + f"manifest.screens[{index}].outgoingRouteIDs does not match manifest.routes" + ) + + _validate_canvas(manifest.get("canvas"), group_by_id, screen_by_id, route_by_id) + _validate_images(manifest.get("images"), profile_by_id, screen_by_id, variant_by_key) + + +def _validate_canvas( + value: object, + group_by_id: dict[str, dict[str, object]], + screen_by_id: dict[str, dict[str, object]], + route_by_id: dict[str, dict[str, object]], +) -> None: + canvas = _required_object( + value, + "manifest.canvas", + ( + "size", + "initialFitSize", + "groupFrames", + "depthBandFrames", + "screenFrames", + "connectors", + ), + ) + _validate_size(canvas["size"], "manifest.canvas.size") + _validate_size(canvas["initialFitSize"], "manifest.canvas.initialFitSize") + group_frames = _identified_records( + _required_list(canvas["groupFrames"], "manifest.canvas.groupFrames"), + "manifest.canvas.groupFrames", + ("id", "frame"), + ) + screen_frames = _identified_records( + _required_list(canvas["screenFrames"], "manifest.canvas.screenFrames"), + "manifest.canvas.screenFrames", + ("id", "frame"), + ) + if set(group_frames) != set(group_by_id): + raise FlyoverArtifactError("manifest.canvas.groupFrames does not match manifest.groups") + if set(screen_frames) != set(screen_by_id): + raise FlyoverArtifactError("manifest.canvas.screenFrames does not match manifest.screens") + for identifier, record in group_frames.items(): + _validate_rect(record["frame"], f"manifest.canvas.groupFrames[{identifier}].frame") + for identifier, record in screen_frames.items(): + frame = _validate_rect( + record["frame"], + f"manifest.canvas.screenFrames[{identifier}].frame", + ) + if frame != screen_by_id[identifier]["frame"]: + raise FlyoverArtifactError( + f"manifest.canvas.screenFrames[{identifier}] does not match its screen frame" + ) + + depth_bands = _required_list( + canvas["depthBandFrames"], + "manifest.canvas.depthBandFrames", + ) + for index, value in enumerate(depth_bands): + band = _required_object( + value, + f"manifest.canvas.depthBandFrames[{index}]", + ("groupID", "kind", "frame"), + ) + group_id = _string( + band["groupID"], + f"manifest.canvas.depthBandFrames[{index}].groupID", + nonempty=True, + ) + if group_id not in group_by_id: + raise FlyoverArtifactError( + f"manifest.canvas.depthBandFrames[{index}].groupID references an unknown group" + ) + kind = _string(band["kind"], f"manifest.canvas.depthBandFrames[{index}].kind") + depth = band.get("depth") + if kind == "route": + if _integer(depth, f"manifest.canvas.depthBandFrames[{index}].depth") < 0: + raise FlyoverArtifactError( + f"manifest.canvas.depthBandFrames[{index}].depth is negative" + ) + elif kind == "unlinked": + if depth is not None: + raise FlyoverArtifactError( + f"manifest.canvas.depthBandFrames[{index}].depth must be null" + ) + else: + raise FlyoverArtifactError( + f"manifest.canvas.depthBandFrames[{index}].kind has an unsupported value: " + f"{kind}" + ) + _validate_rect(band["frame"], f"manifest.canvas.depthBandFrames[{index}].frame") + + connectors = _required_list(canvas["connectors"], "manifest.canvas.connectors") + connector_by_route: dict[str, dict[str, object]] = {} + for index, value in enumerate(connectors): + connector = _required_object( + value, + f"manifest.canvas.connectors[{index}]", + ("routeID", "geometry"), + ) + route_id = _string( + connector["routeID"], + f"manifest.canvas.connectors[{index}].routeID", + nonempty=True, + ) + if route_id in connector_by_route: + raise FlyoverArtifactError( + f"manifest.canvas.connectors contains a duplicate routeID: {route_id}" + ) + connector_by_route[route_id] = connector + _validate_geometry( + connector["geometry"], + f"manifest.canvas.connectors[{index}].geometry", + ) + if set(connector_by_route) != set(route_by_id): + raise FlyoverArtifactError("manifest.canvas.connectors does not match manifest.routes") + for route_id, connector in connector_by_route.items(): + if connector["geometry"] != route_by_id[route_id]["geometry"]: + raise FlyoverArtifactError( + f"manifest.canvas.connectors[{route_id}] does not match its route geometry" + ) + + +def _validate_images( + value: object, + profile_by_id: dict[str, dict[str, object]], + screen_by_id: dict[str, dict[str, object]], + variant_by_key: dict[tuple[str, str], dict[str, object]], +) -> None: + images = _required_list(value, "manifest.images", nonempty=True) + image_by_key: dict[tuple[str, str, str], dict[str, object]] = {} + paths: set[str] = set() + for index, value in enumerate(images): + image = _required_object( + value, + f"manifest.images[{index}]", + ( + "screenID", + "variantID", + "profileID", + "relativePath", + "pointWidth", + "pointHeight", + "pixelWidth", + "pixelHeight", + "scale", + "captureExtent", + ), + ) + thumbnail_fields = ( + "thumbnailRelativePath", + "thumbnailPixelWidth", + "thumbnailPixelHeight", + ) + present_thumbnail_fields = [field for field in thumbnail_fields if field in image] + if present_thumbnail_fields and len(present_thumbnail_fields) != len(thumbnail_fields): + raise FlyoverArtifactError( + f"manifest.images[{index}] has incomplete thumbnail metadata" + ) + screen_id = _string(image["screenID"], f"manifest.images[{index}].screenID", nonempty=True) + variant_id = _string( + image["variantID"], + f"manifest.images[{index}].variantID", + nonempty=True, + ) + profile_id = _string( + image["profileID"], + f"manifest.images[{index}].profileID", + nonempty=True, + ) + if screen_id not in screen_by_id: + raise FlyoverArtifactError( + f"manifest.images[{index}].screenID references an unknown screen: {screen_id}" + ) + variant = variant_by_key.get((screen_id, variant_id)) + if variant is None: + raise FlyoverArtifactError( + f"manifest.images[{index}].variantID references an unknown variant: {variant_id}" + ) + if profile_id not in profile_by_id: + raise FlyoverArtifactError( + f"manifest.images[{index}].profileID references an unknown profile: {profile_id}" + ) + key = (screen_id, variant_id, profile_id) + if key in image_by_key: + raise FlyoverArtifactError( + "manifest.images contains a duplicate screen, variant, and profile record" + ) + image_by_key[key] = image + relative_path = _safe_image_path( + _string( + image["relativePath"], + f"manifest.images[{index}].relativePath", + nonempty=True, + ) + ).as_posix() + if relative_path in paths: + raise FlyoverArtifactError("manifest.json contains a duplicate image path") + paths.add(relative_path) + if present_thumbnail_fields: + thumbnail_path = _safe_image_path( + _string( + image["thumbnailRelativePath"], + f"manifest.images[{index}].thumbnailRelativePath", + nonempty=True, + ) + ).as_posix() + if thumbnail_path in paths: + raise FlyoverArtifactError( + "manifest.json contains a duplicate image or thumbnail path" + ) + paths.add(thumbnail_path) + for field in ("thumbnailPixelWidth", "thumbnailPixelHeight"): + if _integer(image[field], f"manifest.images[{index}].{field}") <= 0: + raise FlyoverArtifactError( + f"manifest.images[{index}].{field} is not positive" + ) + expected_path = variant["imagesByProfile"][profile_id] + if relative_path != expected_path: + raise FlyoverArtifactError( + f"manifest.images[{index}].relativePath does not match imagesByProfile" + ) + extent = _capture_extent( + image["captureExtent"], + f"manifest.images[{index}].captureExtent", + ) + if extent != variant["captureExtent"]: + raise FlyoverArtifactError( + f"manifest.images[{index}].captureExtent does not match its variant" + ) + for field in ("pointWidth", "pointHeight", "scale"): + if _number(image[field], f"manifest.images[{index}].{field}") <= 0: + raise FlyoverArtifactError(f"manifest.images[{index}].{field} is not positive") + for field in ("pixelWidth", "pixelHeight"): + if _integer(image[field], f"manifest.images[{index}].{field}") <= 0: + raise FlyoverArtifactError(f"manifest.images[{index}].{field} is not positive") + + expected_keys = { + (screen_id, variant_id, profile_id) + for screen_id, variant_id in variant_by_key + for profile_id in profile_by_id + } + if set(image_by_key) != expected_keys: + raise FlyoverArtifactError( + "manifest.images does not contain exactly one image for each state and profile" + ) + + +def _required_object( + value: object, + path: str, + fields: tuple[str, ...], +) -> dict[str, object]: + if not isinstance(value, dict): + raise FlyoverArtifactError(f"{path} is not an object") + missing = next((field for field in fields if field not in value), None) + if missing is not None: + raise FlyoverArtifactError(f"{path} is missing {missing}") + return value + + +def _required_list(value: object, path: str, *, nonempty: bool = False) -> list[object]: + if not isinstance(value, list): + raise FlyoverArtifactError(f"{path} is not an array") + if nonempty and not value: + raise FlyoverArtifactError(f"{path} is empty") + return value + + +def _identified_records( + values: list[object], + path: str, + fields: tuple[str, ...], +) -> dict[str, dict[str, object]]: + records: dict[str, dict[str, object]] = {} + for index, value in enumerate(values): + record = _required_object(value, f"{path}[{index}]", fields) + identifier = _string(record["id"], f"{path}[{index}].id", nonempty=True) + if identifier in records: + raise FlyoverArtifactError(f"{path} contains a duplicate identifier: {identifier}") + records[identifier] = record + return records + + +def _string(value: object, path: str, *, nonempty: bool = False) -> str: + if not isinstance(value, str): + raise FlyoverArtifactError(f"{path} is not a string") + if nonempty and not value: + raise FlyoverArtifactError(f"{path} is empty") + return value + + +def _string_list(value: object, path: str, *, nonempty: bool = False) -> list[str]: + values = _required_list(value, path, nonempty=nonempty) + return [_string(item, f"{path}[{index}]", nonempty=True) for index, item in enumerate(values)] + + +def _integer(value: object, path: str) -> int: + if type(value) is not int: + raise FlyoverArtifactError(f"{path} is not an integer") + return value + + +def _number(value: object, path: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise FlyoverArtifactError(f"{path} is not a number") + result = float(value) + if not math.isfinite(result): + raise FlyoverArtifactError(f"{path} is not finite") + return result + + +def _validate_size(value: object, path: str) -> dict[str, object]: + size = _required_object(value, path, ("width", "height")) + for field in ("width", "height"): + if _number(size[field], f"{path}.{field}") <= 0: + raise FlyoverArtifactError(f"{path}.{field} is not positive") + return size + + +def _validate_rect(value: object, path: str) -> dict[str, object]: + rect = _required_object(value, path, ("x", "y", "width", "height")) + _number(rect["x"], f"{path}.x") + _number(rect["y"], f"{path}.y") + for field in ("width", "height"): + if _number(rect[field], f"{path}.{field}") <= 0: + raise FlyoverArtifactError(f"{path}.{field} is not positive") + return rect + + +def _validate_point(value: object, path: str) -> None: + point = _required_object(value, path, ("x", "y")) + _number(point["x"], f"{path}.x") + _number(point["y"], f"{path}.y") + + +def _validate_geometry(value: object, path: str) -> None: + geometry = _required_object( + value, + path, + ( + "start", + "end", + "firstControl", + "secondControl", + "firstArrowPoint", + "secondArrowPoint", + ), + ) + for field in ( + "start", + "end", + "firstControl", + "secondControl", + "firstArrowPoint", + "secondArrowPoint", + ): + _validate_point(geometry[field], f"{path}.{field}") + + +def _validate_viewport(value: object, path: str) -> None: + viewport = _required_object(value, path, ("kind",)) + kind = _string(viewport["kind"], f"{path}.kind") + fixed_size = viewport.get("fixedSize") + if kind == "device": + if fixed_size is not None: + raise FlyoverArtifactError(f"{path}.fixedSize must be null for a device viewport") + elif kind == "fixed": + _validate_size(fixed_size, f"{path}.fixedSize") + else: + raise FlyoverArtifactError(f"{path}.kind has an unsupported value: {kind}") + + +def _capture_extent(value: object, path: str) -> str: + extent = _string(value, path) + if extent not in {"viewport", "intrinsic", "fullContent", "fullContent2D"}: + raise FlyoverArtifactError(f"{path} has an unsupported value: {extent}") + return extent + + +def _safe_image_path(value: str) -> PurePosixPath: + relative = PurePosixPath(value) + if ( + not value + or "\\" in value + or relative.is_absolute() + or ".." in relative.parts + or relative.parts[:1] != ("images",) + or relative.suffix.lower() != ".png" + ): + raise FlyoverArtifactError(f"the manifest contains an unsafe image path: {value}") + return relative diff --git a/Tools/flyover_preview.py b/Tools/flyover_preview.py index 88c90c738..39972209e 100644 --- a/Tools/flyover_preview.py +++ b/Tools/flyover_preview.py @@ -1,161 +1,36 @@ -"""Validation and local HTTP serving for generated Flyover atlases.""" +"""Local HTTP serving for validated Flyover atlases.""" from __future__ import annotations import argparse import ipaddress -import json +import os import signal import socket +import stat import sys import threading from contextlib import contextmanager -from dataclasses import dataclass from functools import partial from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path, PurePosixPath -from typing import Callable, Iterable, Iterator, TextIO +from typing import BinaryIO, Callable, Iterable, Iterator, Optional, TextIO from urllib.parse import unquote, urlsplit - -MARKER_CONTENT = "schemaVersion=1" -REQUIRED_FILES = ( - "index.html", - "manifest.json", - "manifest.js", - "assets/app.js", - "assets/styles.css", -) - - -class FlyoverArtifactError(ValueError): - """A generated atlas is incomplete or unsafe to serve.""" - - -@dataclass(frozen=True) -class FlyoverArtifact: - root: Path - allowed_paths: frozenset[str] - - -def validate_artifact( - directory: Path, - *, - require_marker: bool = True, -) -> FlyoverArtifact: - """Validate a generated atlas and return its HTTP allowlist.""" - root = directory - if root.is_symlink(): - raise FlyoverArtifactError(f"the atlas directory is a symbolic link: {root}") - if not root.exists(): - raise FlyoverArtifactError( - f"no generated atlas exists at {root}. Run ./flyover export first." - ) - if not root.is_dir(): - raise FlyoverArtifactError(f"the atlas path is not a directory: {root}") - - symbolic_link = next((path for path in root.rglob("*") if path.is_symlink()), None) - if symbolic_link is not None: - relative = symbolic_link.relative_to(root) - raise FlyoverArtifactError(f"the atlas contains a symbolic link: {relative}") - - marker = root / ".flyover-generated" - if require_marker: - if not marker.is_file(): - raise FlyoverArtifactError( - f"the directory is not a generated Flyover atlas: {root}" - ) - try: - marker_content = marker.read_text(encoding="utf-8").strip() - except (OSError, UnicodeError) as error: - raise FlyoverArtifactError(f"could not read {marker}: {error}") from error - if marker_content != MARKER_CONTENT: - raise FlyoverArtifactError( - f"the generated marker is unsupported: {marker_content or 'empty'}" - ) - - for relative in REQUIRED_FILES: - path = root / relative - if not path.is_file(): - raise FlyoverArtifactError(f"the generated atlas is missing {relative}") - - manifest_path = root / "manifest.json" - try: - manifest_data = manifest_path.read_bytes() - manifest = json.loads(manifest_data) - except (OSError, UnicodeError, json.JSONDecodeError) as error: - raise FlyoverArtifactError(f"could not read manifest.json: {error}") from error - if not isinstance(manifest, dict): - raise FlyoverArtifactError("manifest.json is not a JSON object") - if ( - type(manifest.get("schemaVersion")) is not int - or manifest["schemaVersion"] != 1 - ): - raise FlyoverArtifactError("manifest.json does not use schemaVersion 1") - - manifest_script_path = root / "manifest.js" - try: - manifest_script = manifest_script_path.read_bytes() - except OSError as error: - raise FlyoverArtifactError(f"could not read manifest.js: {error}") from error - expected_script = b"window.FLYOVER_MANIFEST = " + manifest_data + b";\n" - if manifest_script != expected_script: - raise FlyoverArtifactError("manifest.js does not match manifest.json") - - images = manifest.get("images") - if not isinstance(images, list): - raise FlyoverArtifactError("manifest.json has no image list") - - image_paths: list[str] = [] - for image in images: - if not isinstance(image, dict): - raise FlyoverArtifactError("manifest.json contains an invalid image record") - relative_value = image.get("relativePath") - if not isinstance(relative_value, str): - raise FlyoverArtifactError("manifest.json contains an invalid image path") - relative = _safe_image_path(relative_value) - path = root.joinpath(*relative.parts) - if not path.is_file(): - raise FlyoverArtifactError(f"the manifest image is missing: {relative}") - image_paths.append(relative.as_posix()) - - if len(set(image_paths)) != len(image_paths): - raise FlyoverArtifactError("manifest.json contains a duplicate image path") - - images_directory = root / "images" - if images_directory.is_dir(): - actual_paths = { - path.relative_to(root).as_posix() - for path in images_directory.rglob("*.png") - if path.is_file() - } - else: - actual_paths = set() - declared_paths = set(image_paths) - if actual_paths != declared_paths: - raise FlyoverArtifactError( - "the image files do not match the manifest " - f"({len(declared_paths)} declared, {len(actual_paths)} found)" - ) - - return FlyoverArtifact( - root=root, - allowed_paths=frozenset((*REQUIRED_FILES, *image_paths)), +try: + from Tools.flyover_manifest import ( + FlyoverArtifact, + FlyoverArtifactError, + validate_artifact, + ) +except ModuleNotFoundError as error: + if error.name != "Tools": + raise + from flyover_manifest import ( + FlyoverArtifact, + FlyoverArtifactError, + validate_artifact, ) - - -def _safe_image_path(value: str) -> PurePosixPath: - relative = PurePosixPath(value) - if ( - not value - or "\\" in value - or relative.is_absolute() - or ".." in relative.parts - or relative.parts[:1] != ("images",) - or relative.suffix.lower() != ".png" - ): - raise FlyoverArtifactError(f"the manifest contains an unsafe image path: {value}") - return relative def usable_ipv4_addresses(candidates: Iterable[str]) -> tuple[str, ...]: @@ -205,24 +80,39 @@ class FlyoverRequestHandler(SimpleHTTPRequestHandler): def __init__( self, *args: object, - directory: str, + root_descriptor: int, allowed_paths: frozenset[str], **kwargs: object, ) -> None: + self.root_descriptor = os.dup(root_descriptor) self.allowed_paths = allowed_paths - super().__init__(*args, directory=directory, **kwargs) + try: + super().__init__(*args, directory="/", **kwargs) + finally: + os.close(self.root_descriptor) - def do_GET(self) -> None: # noqa: N802 - stdlib handler API - if not self._request_is_allowed(): + def send_head(self) -> Optional[BinaryIO]: + relative = self._allowed_relative_path() + if relative is None: self.send_error(404) - return - super().do_GET() - - def do_HEAD(self) -> None: # noqa: N802 - stdlib handler API - if not self._request_is_allowed(): + return None + try: + file = _open_file_beneath(self.root_descriptor, relative) + except OSError: self.send_error(404) - return - super().do_HEAD() + return None + + try: + status = os.fstat(file.fileno()) + self.send_response(200) + self.send_header("Content-type", self.guess_type(relative)) + self.send_header("Content-Length", str(status.st_size)) + self.send_header("Last-Modified", self.date_time_string(status.st_mtime)) + self.end_headers() + return file + except BaseException: + file.close() + raise def list_directory(self, path: str) -> None: self.send_error(404) @@ -231,23 +121,72 @@ def list_directory(self, path: str) -> None: def log_message(self, message: str, *args: object) -> None: """Keep untrusted HTTP request data out of the terminal.""" - def _request_is_allowed(self) -> bool: + def _allowed_relative_path(self) -> Optional[str]: try: target = urlsplit(self.path) except ValueError: - return False + return None if target.scheme or target.netloc or target.fragment: - return False + return None raw_path = unquote(target.path) if raw_path == "/": - return "index.html" in self.allowed_paths + return "index.html" if "index.html" in self.allowed_paths else None if not raw_path.startswith("/") or raw_path.endswith("/"): - return False + return None segments = raw_path[1:].split("/") if any(segment in ("", ".", "..") for segment in segments): - return False + return None relative = "/".join(segments) - return relative in self.allowed_paths + return relative if relative in self.allowed_paths else None + + +def _open_file_beneath(root_descriptor: int, relative: str) -> BinaryIO: + """Open one allowlisted regular file without following symbolic links.""" + flags = os.O_RDONLY | os.O_NOFOLLOW + directory_flags = flags | os.O_DIRECTORY + current_descriptor = os.dup(root_descriptor) + file_descriptor: Optional[int] = None + try: + parts = PurePosixPath(relative).parts + for part in parts[:-1]: + next_descriptor = os.open( + part, + directory_flags, + dir_fd=current_descriptor, + ) + os.close(current_descriptor) + current_descriptor = next_descriptor + file_descriptor = os.open(parts[-1], flags, dir_fd=current_descriptor) + if not stat.S_ISREG(os.fstat(file_descriptor).st_mode): + raise OSError("the request target is not a regular file") + file = os.fdopen(file_descriptor, "rb") + file_descriptor = None + return file + finally: + if file_descriptor is not None: + os.close(file_descriptor) + os.close(current_descriptor) + + +@contextmanager +def _open_artifact_root(artifact: FlyoverArtifact) -> Iterator[int]: + """Pin the validated atlas directory for the server lifetime.""" + try: + descriptor = os.open( + artifact.root, + os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, + ) + except OSError as error: + raise FlyoverArtifactError( + f"could not open the validated atlas directory {artifact.root}: {error}" + ) from error + try: + status = os.fstat(descriptor) + if (status.st_dev, status.st_ino) != (artifact.root_device, artifact.root_inode): + raise FlyoverArtifactError("the atlas directory changed during server startup") + yield descriptor + finally: + os.close(descriptor) ServerFactory = Callable[..., ThreadingHTTPServer] @@ -308,33 +247,34 @@ def serve( ) -> None: artifact = validate_artifact(directory) host = "0.0.0.0" if lan else "127.0.0.1" - handler = partial( - FlyoverRequestHandler, - directory=str(artifact.root), - allowed_paths=artifact.allowed_paths, - ) - try: - server = server_factory((host, port), handler) - except OSError as error: - raise FlyoverArtifactError( - f"could not start the preview server on {host}:{port}: {error}" - ) from error - - with server, _serve_until_interrupted(server): - selected_port = int(server.server_address[1]) - print(f"Flyover preview: {artifact.root}", file=output) - print(f"Local: http://127.0.0.1:{selected_port}/", file=output) - if lan: - addresses = address_provider() - for address in addresses: - print(f"Network: http://{address}:{selected_port}/", file=output) - if not addresses: - print( - "Network: bound to all interfaces, but no LAN address was found.", - file=output, - ) - print("Warning: LAN preview has no authentication or TLS.", file=output) - print("Press Ctrl-C to stop.", file=output, flush=True) + with _open_artifact_root(artifact) as root_descriptor: + handler = partial( + FlyoverRequestHandler, + root_descriptor=root_descriptor, + allowed_paths=artifact.allowed_paths, + ) + try: + server = server_factory((host, port), handler) + except OSError as error: + raise FlyoverArtifactError( + f"could not start the preview server on {host}:{port}: {error}" + ) from error + + with server, _serve_until_interrupted(server): + selected_port = int(server.server_address[1]) + print(f"Flyover preview: {artifact.root}", file=output) + print(f"Local: http://127.0.0.1:{selected_port}/", file=output) + if lan: + addresses = address_provider() + for address in addresses: + print(f"Network: http://{address}:{selected_port}/", file=output) + if not addresses: + print( + "Network: bound to all interfaces, but no LAN address was found.", + file=output, + ) + print("Warning: LAN preview has no authentication or TLS.", file=output) + print("Press Ctrl-C to stop.", file=output, flush=True) def _parser() -> argparse.ArgumentParser: diff --git a/flyover b/flyover index 897f9709a..2cf22b585 100755 --- a/flyover +++ b/flyover @@ -8,6 +8,7 @@ OUTPUT="$DEFAULT_OUTPUT" PROFILES=() STAGING="" BACKUP="" +BACKUP_CONTAINER="" DESTINATION="" COMMAND="" PREVIEW_LAN=false @@ -161,6 +162,12 @@ cleanup() { fi if [ -n "$BACKUP" ] && [ -e "$BACKUP" ] && [ -n "$DESTINATION" ] && [ ! -e "$DESTINATION" ]; then mv "$BACKUP" "$DESTINATION" + BACKUP="" + fi + if [ -n "$BACKUP_CONTAINER" ] && [ -d "$BACKUP_CONTAINER" ]; then + if ! rmdir "$BACKUP_CONTAINER" 2>/dev/null; then + echo "flyover: warning: could not remove temporary replacement directory '$BACKUP_CONTAINER'" >&2 + fi fi } trap cleanup EXIT INT TERM @@ -269,7 +276,28 @@ paths_are_same "$DESTINATION" "$WORKSPACE_PATH" \ validate_destination COMMIT="$(git -C "$ROOT" rev-parse HEAD 2>/dev/null || echo unknown)" -if [ -n "$(git -C "$ROOT" status --porcelain 2>/dev/null || true)" ]; then +GIT_STATUS_ARGUMENTS=(status --porcelain --untracked-files=all -- .) +if [ -e "$DESTINATION" ] || [ -L "$DESTINATION" ]; then + DESTINATION_EXCLUSION="$(python3 - "$ROOT" "$DESTINATION" <<'PY' +import pathlib, sys +root, destination = map(pathlib.Path, sys.argv[1:]) +try: + relative = destination.relative_to(root) +except ValueError: + pass +else: + if relative.parts: + print(":(exclude,top,literal)" + relative.as_posix()) +PY +)" + if [ -n "$DESTINATION_EXCLUSION" ]; then + GIT_STATUS_ARGUMENTS+=("$DESTINATION_EXCLUSION") + fi +fi +if ! GIT_STATUS="$(git -C "$ROOT" "${GIT_STATUS_ARGUMENTS[@]}" 2>/dev/null)"; then + error "could not read the Git working-tree status" +fi +if [ -n "$GIT_STATUS" ]; then DIRTY=true else DIRTY=false @@ -313,7 +341,9 @@ find "$STAGING" -type f -exec chmod 0644 {} + validate_destination if [ -e "$DESTINATION" ] || [ -L "$DESTINATION" ]; then - BACKUP="$PARENT/.flyover-previous.$(basename "$DESTINATION").$$" + BACKUP_CONTAINER="$(mktemp -d "$PARENT/.flyover-replacement.XXXXXX")" + chmod 0700 "$BACKUP_CONTAINER" + BACKUP="$BACKUP_CONTAINER/previous" mv "$DESTINATION" "$BACKUP" fi mv "$STAGING" "$DESTINATION" @@ -322,6 +352,10 @@ if [ -n "$BACKUP" ]; then rm -rf "$BACKUP" BACKUP="" fi +if [ -n "$BACKUP_CONTAINER" ]; then + rmdir "$BACKUP_CONTAINER" + BACKUP_CONTAINER="" +fi ELAPSED_SECONDS=$((SECONDS - EXPORT_STARTED_AT)) python3 - "$DESTINATION" "$ELAPSED_SECONDS" <<'PY' From b8ad552c9a7db1c83035ffeb4793c5050fd2b65a Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Mon, 7 Sep 2026 21:22:38 -0700 Subject: [PATCH 15/15] Keep Flyover tooling from dirtying source --- Shared/Flyover/Tools/Tests/flyover_test.sh | 24 ++++++++++++++++++++-- flyover | 6 ++++-- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/Shared/Flyover/Tools/Tests/flyover_test.sh b/Shared/Flyover/Tools/Tests/flyover_test.sh index ee557f198..3528b03ce 100755 --- a/Shared/Flyover/Tools/Tests/flyover_test.sh +++ b/Shared/Flyover/Tools/Tests/flyover_test.sh @@ -259,15 +259,18 @@ if bad_directories or bad_files: PY PREVIEW_ARGUMENTS="$TEMP/preview-arguments" +PREVIEW_ENVIRONMENT="$TEMP/preview-environment" PREVIEW_RUNNER="$TEMP/preview-runner" cat >"$PREVIEW_RUNNER" <<'RUNNER' #!/bin/bash set -euo pipefail printf '%s\n' "$@" >"$FLYOVER_PREVIEW_ARGUMENTS" +printf '%s\n' "${PYTHONDONTWRITEBYTECODE-}" >"$FLYOVER_PREVIEW_ENVIRONMENT" RUNNER chmod +x "$PREVIEW_RUNNER" FLYOVER_PREVIEW_PYTHON="$PREVIEW_RUNNER" \ FLYOVER_PREVIEW_ARGUMENTS="$PREVIEW_ARGUMENTS" \ + FLYOVER_PREVIEW_ENVIRONMENT="$PREVIEW_ENVIRONMENT" \ "$ROOT/flyover" preview --output "$OUTPUT" --lan --port 8080 python3 - "$PREVIEW_ARGUMENTS" "$ROOT" "$OUTPUT" <<'PY' import pathlib, sys @@ -288,6 +291,7 @@ PY cd "$CALLER" FLYOVER_PREVIEW_PYTHON="$PREVIEW_RUNNER" \ FLYOVER_PREVIEW_ARGUMENTS="$PREVIEW_ARGUMENTS" \ + FLYOVER_PREVIEW_ENVIRONMENT="$PREVIEW_ENVIRONMENT" \ "$ROOT/flyover" preview --port 0 ) python3 - "$PREVIEW_ARGUMENTS" "$ROOT" "$CALLER/.build/flyover/where" <<'PY' @@ -303,6 +307,8 @@ expected = [ if arguments != expected: raise SystemExit(f'default preview arguments are wrong: {arguments}') PY +[ "$(< "$PREVIEW_ENVIRONMENT")" = 1 ] \ + || fail "preview Python can write bytecode into the source tree" expect_failure "$ROOT/flyover" preview --output "$UNMARKED" grep -q "not a generated Flyover atlas" "$TEMP/stderr" \ @@ -481,6 +487,16 @@ expect_failure "$ROOT/flyover" preview --output "$SYMLINK_PREVIEW" grep -q "contains a symbolic link" "$TEMP/stderr" \ || fail "preview accepted a symbolic link" +STANDARD_CACHE_PYTHON_BIN="$TEMP/standard-cache-python-bin" +mkdir -p "$STANDARD_CACHE_PYTHON_BIN" +cat >"$STANDARD_CACHE_PYTHON_BIN/python3" <<'PYTHON' +#!/bin/bash +set -euo pipefail +exec "$FLYOVER_REAL_PYTHON" -X pycache_prefix= "$@" +PYTHON +chmod +x "$STANDARD_CACHE_PYTHON_BIN/python3" +REAL_PYTHON="$(command -v python3)" + CLEAN_REPO="$TEMP/clean-repo" mkdir -p "$CLEAN_REPO/Shared/Flyover/Web/assets" mkdir -p "$CLEAN_REPO/Tools" @@ -495,9 +511,11 @@ git -C "$CLEAN_REPO" init -q git -C "$CLEAN_REPO" add . git -C "$CLEAN_REPO" -c user.name=Flyover -c user.email=flyover@example.com \ -c commit.gpgsign=false commit -qm fixture -FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ +PATH="$STANDARD_CACHE_PYTHON_BIN:$PATH" FLYOVER_REAL_PYTHON="$REAL_PYTHON" \ + FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ "$CLEAN_REPO/flyover" export --output "$CLEAN_REPO/site" >/dev/null -FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ +PATH="$STANDARD_CACHE_PYTHON_BIN:$PATH" FLYOVER_REAL_PYTHON="$REAL_PYTHON" \ + FLYOVER_CAPTURE_RUNNER="$SUCCESS_RUNNER" FLYOVER_XCODE_VERSION_OVERRIDE=Tests \ "$CLEAN_REPO/flyover" export --output "$CLEAN_REPO/site" >/dev/null python3 - "$CLEAN_REPO/site/manifest.json" <<'PY' import json, pathlib, sys @@ -505,6 +523,8 @@ dirty = json.loads(pathlib.Path(sys.argv[1]).read_text())['build']['dirty'] if dirty: raise SystemExit('staging a clean in-repository export marked the source dirty') PY +[ ! -e "$CLEAN_REPO/Tools/__pycache__" ] \ + || fail "Flyover Python tooling wrote bytecode into the source tree" FAILING_GIT_BIN="$TEMP/failing-git-bin" mkdir -p "$FAILING_GIT_BIN" diff --git a/flyover b/flyover index 2cf22b585..68f907b12 100755 --- a/flyover +++ b/flyover @@ -241,7 +241,8 @@ if [ "$COMMAND" = preview ]; then if [ "$PREVIEW_LAN" = true ]; then PREVIEW_ARGUMENTS+=(--lan) fi - exec "${FLYOVER_PREVIEW_PYTHON:-python3}" "${PREVIEW_ARGUMENTS[@]}" + exec env PYTHONDONTWRITEBYTECODE=1 \ + "${FLYOVER_PREVIEW_PYTHON:-python3}" "${PREVIEW_ARGUMENTS[@]}" fi while [ "$#" -gt 0 ]; do @@ -333,7 +334,8 @@ env \ "$CAPTURE_RUNNER" --only \ 'WhereUISnapshotTests/WhereFlyoverWebExportTests/exportsRequestedAtlas()' -python3 "$ROOT/Tools/flyover_preview.py" validate "$STAGING" --without-marker +env PYTHONDONTWRITEBYTECODE=1 \ + python3 "$ROOT/Tools/flyover_preview.py" validate "$STAGING" --without-marker printf '%s\n' 'schemaVersion=1' >"$STAGING/.flyover-generated" find "$STAGING" -type d -exec chmod 0755 {} +