From 9d2d2f94bef4d827ca3cd015e32de20f065e1c29 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 29 Oct 2026 19:31:21 -0400 Subject: [PATCH 1/5] feat(ViewDocument): render validated portable read-only view documents --- .bumper/RULES.md | 6 + .bumper/Tests/WhereArchitectureTests.swift | 24 +++ AGENTS.md | 4 + BumperBowling.swift | 18 ++ Package.swift | 8 + Project.swift | 28 +++ Shared/ViewDocument/AGENTS.md | 12 ++ Shared/ViewDocument/README.md | 40 ++++ .../ViewDocument/ViewDocumentCore/AGENTS.md | 5 + .../ViewDocument/ViewDocumentCore/README.md | 3 + .../Sources/ValidatedViewDocument.swift | 62 ++++++ .../Sources/ViewAlignment.swift | 6 + .../ViewAnimationDescriptor+Sampling.swift | 53 ++++++ .../Sources/ViewAnimationDescriptor.swift | 74 ++++++++ .../Sources/ViewAssetID.swift | 9 + .../Sources/ViewColorDescriptor.swift | 11 ++ .../Sources/ViewDimension.swift | 7 + .../Sources/ViewDocument.swift | 62 ++++++ .../Sources/ViewDocumentLimits.swift | 18 ++ .../Sources/ViewDocumentResources.swift | 12 ++ .../Sources/ViewDocumentValidationError.swift | 10 + .../Sources/ViewDocumentValidator.swift | 177 ++++++++++++++++++ .../Sources/ViewFontDescriptor.swift | 34 ++++ .../Sources/ViewModifierDescriptor.swift | 22 +++ .../ViewDocumentCore/Sources/ViewPaint.swift | 24 +++ .../Sources/ViewShapeDescriptor.swift | 8 + .../Sources/ViewSymbolID.swift | 9 + .../Tests/ValidatedViewDocumentTests.swift | 124 ++++++++++++ ...iewAnimationDescriptor+SamplingTests.swift | 52 +++++ .../Tests/ViewDocumentTestSupport.swift | 18 ++ .../Tests/ViewDocumentTests.swift | 33 ++++ .../Tests/ViewDocumentValidatorTests.swift | 74 ++++++++ Shared/ViewDocument/ViewDocumentUI/AGENTS.md | 5 + Shared/ViewDocument/ViewDocumentUI/README.md | 3 + .../ViewDocumentViewSnapshotTests.swift | 85 +++++++++ ...andaloneContent.ReadOnlyContent_iPhone.png | 3 + ...t.ReadOnlyContent_iPhone_accessibility.png | 3 + ...oneContent.ReadOnlyContent_iPhone_dark.png | 3 + ...dOnlyContent_iPhone_dark_accessibility.png | 3 + .../Sources/PreparedViewDocument.swift | 15 ++ .../Sources/ViewDocumentAnimatedView.swift | 57 ++++++ .../Sources/ViewDocumentAppearance.swift | 138 ++++++++++++++ .../Sources/ViewDocumentAssets.swift | 17 ++ .../Sources/ViewDocumentGroupView.swift | 35 ++++ .../Sources/ViewDocumentNodeView.swift | 52 +++++ .../Sources/ViewDocumentShape.swift | 16 ++ .../Sources/ViewDocumentView.swift | 100 ++++++++++ .../ViewModifierDescriptor+Rendering.swift | 38 ++++ .../Tests/ViewDocumentAppearanceTests.swift | 28 +++ .../Tests/ViewDocumentViewTests.swift | 56 ++++++ 50 files changed, 1704 insertions(+) create mode 100644 Shared/ViewDocument/AGENTS.md create mode 100644 Shared/ViewDocument/README.md create mode 100644 Shared/ViewDocument/ViewDocumentCore/AGENTS.md create mode 100644 Shared/ViewDocument/ViewDocumentCore/README.md create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewAlignment.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor+Sampling.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewAssetID.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewColorDescriptor.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewDimension.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentLimits.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentResources.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidationError.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewFontDescriptor.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewModifierDescriptor.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewPaint.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewShapeDescriptor.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewSymbolID.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Tests/ValidatedViewDocumentTests.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Tests/ViewAnimationDescriptor+SamplingTests.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTestSupport.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTests.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/AGENTS.md create mode 100644 Shared/ViewDocument/ViewDocumentUI/README.md create mode 100644 Shared/ViewDocument/ViewDocumentUI/SnapshotTests/ViewDocumentViewSnapshotTests.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone.png create mode 100644 Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_accessibility.png create mode 100644 Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark.png create mode 100644 Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark_accessibility.png create mode 100644 Shared/ViewDocument/ViewDocumentUI/Sources/PreparedViewDocument.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAnimatedView.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAppearance.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAssets.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentGroupView.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentNodeView.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentShape.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentView.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/Sources/ViewModifierDescriptor+Rendering.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentAppearanceTests.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentViewTests.swift diff --git a/.bumper/RULES.md b/.bumper/RULES.md index 712db69d7..07a79a581 100644 --- a/.bumper/RULES.md +++ b/.bumper/RULES.md @@ -179,3 +179,9 @@ directory. Delete or reshape the rule if the repository deliberately adopts a different logging vocabulary layout. Bumper's standard `singleNominalSpelling` shaper expresses the invariant; no custom syntax rule is needed. + +## ViewDocument boundaries + +ViewDocumentCore uses Foundation only. ViewDocumentUI depends on Core and system UI frameworks. +Both reject Broadway, Where, and SnapshotKit imports. The `ViewDocument rejects consumer dependencies` mutation test covers these boundaries. +Asset providers supply prepared images. The import rules do not inspect image contents or enforce resource limits; document validation owns those checks. diff --git a/.bumper/Tests/WhereArchitectureTests.swift b/.bumper/Tests/WhereArchitectureTests.swift index 389cafd2d..15c5a29f6 100644 --- a/.bumper/Tests/WhereArchitectureTests.swift +++ b/.bumper/Tests/WhereArchitectureTests.swift @@ -201,3 +201,27 @@ func `Where consumers can use InstallerKit`() throws { )) #expect(report.violations.isEmpty) } + +@Test +func `ViewDocument rejects consumer dependencies`() throws { + for component in [WhereComponent.viewDocumentCore, .viewDocumentUI] { + for source in [ + "import WhereUI", + "import BroadwayUI", + "import BroadwayCore", + "import SnapshotKit", + ] { + let report = try bumper.evaluate(RepositoryInput( + architecture: bumper.architecture, + files: [SourceInput( + path: component == .viewDocumentCore ? + "Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift" : + "Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentView.swift", + component: ComponentID(component.rawValue), + source: source, + )], + )) + #expect(!report.violations.isEmpty) + } + } +} diff --git a/AGENTS.md b/AGENTS.md index b4c70a518..9d8375332 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -209,6 +209,10 @@ Keep the review history in the PR and link the rule to its guard or source. ## Targets +- ViewDocumentCore and ViewDocumentUI provide the independent document renderer. + Keep SnapshotKit in its test bundle; production rendering receives static-motion policy by injection. + See [ViewDocument](Shared/ViewDocument/AGENTS.md). + - For the current list of library products, apps, extensions, and test bundles, read [`Package.swift`](Package.swift) and [`Project.swift`](Project.swift). Each module's own `README.md` / diff --git a/BumperBowling.swift b/BumperBowling.swift index 23672898b..dc5f3d490 100644 --- a/BumperBowling.swift +++ b/BumperBowling.swift @@ -1,6 +1,8 @@ import BumperBowlingCore enum WhereComponent: String, ComponentKey { + case viewDocumentCore + case viewDocumentUI case periscope case loggingTests case installerKit @@ -17,6 +19,8 @@ enum WhereComponent: String, ComponentKey { let bumper = BumperProject { Included { + "Shared/ViewDocument/ViewDocumentCore/Sources" + "Shared/ViewDocument/ViewDocumentUI/Sources" "Shared/Periscope/PeriscopeCore/Sources" "Shared/Periscope/PeriscopeMacros/Sources" "Shared/Periscope/PeriscopeTools/Sources" @@ -51,6 +55,20 @@ let bumper = BumperProject { } Architecture(WhereComponent.self) { + Component(.viewDocumentCore) { + Owns("Shared/ViewDocument/ViewDocumentCore/Sources") + Modules("ViewDocumentCore") + Applies(.whereFoundationLayer) + DoesNotUse("BroadwayCore", "BroadwayUI", "SnapshotKit", "SnapshotKitTesting") + } + Component(.viewDocumentUI) { + Owns("Shared/ViewDocument/ViewDocumentUI/Sources") + Modules("ViewDocumentUI") + MayDependOn(.viewDocumentCore) + Applies(.wherePresentationLayer) + DoesNotUse("BroadwayCore", "BroadwayUI", "SnapshotKit", "SnapshotKitTesting") + } + Component(.periscope) { Owns("Shared/Periscope/PeriscopeCore/Sources") Owns("Shared/Periscope/PeriscopeMacros/Sources") diff --git a/Package.swift b/Package.swift index c25d4df0c..6d2afd1f8 100644 --- a/Package.swift +++ b/Package.swift @@ -10,6 +10,8 @@ let package = Package( .macOS(.v26), ], products: [ + .library(name: "ViewDocumentCore", targets: ["ViewDocumentCore"]), + .library(name: "ViewDocumentUI", targets: ["ViewDocumentUI"]), .library(name: "InstallerKit", targets: ["InstallerKit"]), .library(name: "InstallerKitUI", targets: ["InstallerKitUI"]), .library(name: "CreditKit", targets: ["CreditKit"]), @@ -47,6 +49,12 @@ let package = Package( .package(url: "https://github.com/swiftlang/swift-syntax", exact: "603.0.2"), ], targets: [ + .target(name: "ViewDocumentCore", path: "Shared/ViewDocument/ViewDocumentCore/Sources"), + .target( + name: "ViewDocumentUI", + dependencies: [.target(name: "ViewDocumentCore")], + path: "Shared/ViewDocument/ViewDocumentUI/Sources", + ), .macro( name: "PeriscopeMacros", dependencies: [ diff --git a/Project.swift b/Project.swift index 47a2f838b..82546c9b5 100644 --- a/Project.swift +++ b/Project.swift @@ -851,6 +851,26 @@ let project = Project( .package(product: "TestHostSupport"), ], ), + unitTests( + name: "ViewDocumentUISnapshotTests", + bundleIdSuffix: "viewdocument.ui.snapshots", + productDependency: "ViewDocumentUI", + sources: ["Shared/ViewDocument/ViewDocumentUI/SnapshotTests/**"], + extraPackageProducts: ["SnapshotKitTesting"], + environmentVariables: snapshotEnvironment, + ), + unitTests( + name: "ViewDocumentCoreTests", + bundleIdSuffix: "viewdocument.core", + productDependency: "ViewDocumentCore", + sources: ["Shared/ViewDocument/ViewDocumentCore/Tests/**"], + ), + unitTests( + name: "ViewDocumentUITests", + bundleIdSuffix: "viewdocument.ui", + productDependency: "ViewDocumentUI", + sources: ["Shared/ViewDocument/ViewDocumentUI/Tests/**"], + ), unitTests( name: "BroadwayCoreTests", bundleIdSuffix: "broadway.core", @@ -912,6 +932,8 @@ let project = Project( "WhereUITests", "WhereIntentsTests", "BroadwayCatalog", + "ViewDocumentCoreTests", + "ViewDocumentUITests", "BroadwayCoreTests", "BroadwayUITests", "BroadwayCatalogTests", @@ -937,6 +959,8 @@ let project = Project( "WhereTests", "WhereUITests", "WhereIntentsTests", + "ViewDocumentCoreTests", + "ViewDocumentUITests", "BroadwayCoreTests", "BroadwayUITests", "BroadwayCatalogTests", @@ -975,6 +999,7 @@ let project = Project( name: "StuffSnapshotTests", shared: true, buildAction: .buildAction(targets: [ + "ViewDocumentUISnapshotTests", "WhereUISnapshotTests", "ToolsSnapshotTests", "InstallerKitUISnapshotTests", @@ -984,6 +1009,7 @@ let project = Project( ]), testAction: .targets( [ + "ViewDocumentUISnapshotTests", "WhereUISnapshotTests", "ToolsSnapshotTests", "InstallerKitUISnapshotTests", @@ -995,6 +1021,8 @@ let project = Project( ), ), testScheme(name: "WhereIntentsTests"), + testScheme(name: "ViewDocumentCoreTests"), + testScheme(name: "ViewDocumentUITests"), testScheme(name: "BroadwayCoreTests"), testScheme(name: "BroadwayUITests"), testScheme(name: "BroadwayCatalogTests"), diff --git a/Shared/ViewDocument/AGENTS.md b/Shared/ViewDocument/AGENTS.md new file mode 100644 index 000000000..9b2e37843 --- /dev/null +++ b/Shared/ViewDocument/AGENTS.md @@ -0,0 +1,12 @@ +# ViewDocument + +ViewDocument renders a finite, read-only document vocabulary. See [README.md](README.md) and the repository [rules](../../AGENTS.md). + +- Keep Core Foundation-only. Keep UI dependent only on Core and system frameworks. +- Never import Broadway, Where, or SnapshotKit into production targets. +- Validate before rendering. Keep validated documents immutable and reject missing assets before view construction. +- Keep modifier order stable. Host registries own images and approved symbols; the renderer never resolves URLs or file paths. +- Inject appearance, activity, and static-motion policy. Do not create animation tasks that outlive the view. +- Keep field and case names stable within a schema version. Reject unsupported schema versions. + +Run `./test ViewDocumentCoreTests` and `./test ViewDocumentUITests` for unit coverage. diff --git a/Shared/ViewDocument/README.md b/Shared/ViewDocument/README.md new file mode 100644 index 000000000..42578a0e6 --- /dev/null +++ b/Shared/ViewDocument/README.md @@ -0,0 +1,40 @@ +# ViewDocument + +ViewDocument converts a versioned document into read-only SwiftUI content. It supports text, images, shapes, finite groups, ordered modifiers, and transform animations. + +`ViewDocumentCore` owns Codable values and validation. `ViewDocumentUI` renders them. Neither module requires Broadway or Where. + +## Render a document + +1. Prepare images and approved symbols with `ViewDocumentAssets`. +2. Decode with `ValidatedViewDocument.decode(_:resources:limits:)`. +3. Construct `ViewDocumentView` with that document, its assets, appearance values, and rendering policy. + +Decode and validate outside `body`. The renderer initializer throws if its assets differ from the validated resource requirements. +For trait-driven rendering, construct `PreparedViewDocument` once. Pass it to the nonthrowing renderer initializer as appearance inputs change. + +The schema version is 1. Codable enums use Swift's synthesized associated-value representation. Node identities and asset keys encode as strings through their RawRepresentable Codable conformance. + +```json +{ + "schemaVersion": 1, + "root": { + "id": "greeting", + "content": { "text": { "_0": "Hello" } }, + "modifiers": [], + "accessibility": { "automatic": {} } + } +} +``` + +`ViewDocument.Content` and `ViewModifierDescriptor` define the complete vocabulary. Arrays of modifiers apply in source order. Documents contain no executable expressions, actions, bindings, or loops. + +General content preserves text accessibility and supports image labels and grouping. Decoration policy hides the tree from accessibility. All document content is noninteractive. + +Hosts supply semantic background colors and dimension scales. Fonts preserve semantic Dynamic Type behavior. An omitted font weight preserves the system weight of that text style. Use fixed sizes only for intentional fixed artwork. + +Assets are prepared SwiftUI images keyed by typed identities. Hosts load packaged PNG/JPEG assets and register approved symbols before rendering. Documents cannot access paths or URLs. + +Animations sample elapsed seconds. Curves use linear, quadratic ease-in/out, or cosine ease-in-out interpolation. Autoreversing repetition counts complete forward-and-back cycles. Each animation has a separate static pose. Inactive views render that pose and restart on activation. Finite animations stop requesting frames after their final cycle. + +Validation checks versions, ranges, resource availability, identity uniqueness, and complexity. Standard limits are 1 MiB, depth 16, 256 nodes, and 32 animated nodes. Validation errors carry a document path. Unknown enum operations fail decoding; additive object fields follow Codable's forward-compatible decoding behavior. diff --git a/Shared/ViewDocument/ViewDocumentCore/AGENTS.md b/Shared/ViewDocument/ViewDocumentCore/AGENTS.md new file mode 100644 index 000000000..26883eb1d --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/AGENTS.md @@ -0,0 +1,5 @@ +# ViewDocumentCore + +ViewDocumentCore follows the [group contract](../AGENTS.md) and [repository rules](../../../AGENTS.md). See [README.md](README.md). + +Keep source tests one-to-one. Run `./test ViewDocumentCoreTests`. diff --git a/Shared/ViewDocument/ViewDocumentCore/README.md b/Shared/ViewDocument/ViewDocumentCore/README.md new file mode 100644 index 000000000..6d74b4946 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/README.md @@ -0,0 +1,3 @@ +# ViewDocumentCore + +See the [ViewDocument guide](../README.md) for composition, validation, and the wire format. This target is declared in the root Package.swift. diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift new file mode 100644 index 000000000..198961d57 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Immutable proof that the document satisfies the vocabulary and resource contract. +public struct ValidatedViewDocument: Hashable, Sendable { + public let document: ViewDocument + public let resources: ViewDocumentResources + + public init( + document: ViewDocument, + resources: ViewDocumentResources, + limits: ViewDocumentLimits, + ) throws { + guard limits.bytes > 0, limits.depth > 0, limits.nodes > 0, limits.animatedNodes >= 0 else { + throw ViewDocumentValidationError(path: "$", reason: "Invalid validation limits") + } + guard document.schemaVersion == 1 else { + throw ViewDocumentValidationError( + path: "$.schemaVersion", + reason: "Unsupported schema version", + ) + } + var validator = ViewDocumentValidator(resources: resources, limits: limits) + try validator.visit(document.root, path: "$.root", depth: 1) + guard try JSONEncoder().encode(document).count <= limits.bytes else { + throw ViewDocumentValidationError(path: "$", reason: "Document exceeds byte limit") + } + self.document = document + self.resources = .init( + images: validator.referencedImages, + symbols: validator.referencedSymbols, + ) + } + + public static func decode( + _ data: Data, + resources: ViewDocumentResources, + limits: ViewDocumentLimits, + ) throws -> Self { + guard data.count <= limits.bytes else { + throw ViewDocumentValidationError(path: "$", reason: "Document exceeds byte limit") + } + do { + let document = try JSONDecoder().decode(ViewDocument.self, from: data) + return try Self(document: document, resources: resources, limits: limits) + } catch let error as DecodingError { + let context: DecodingError.Context + switch error { + case let .dataCorrupted(value), let .keyNotFound(_, value), let .typeMismatch( + _, + value, + ), let .valueNotFound(_, value): + context = value + @unknown default: + throw error + } + throw ViewDocumentValidationError( + path: "$" + context.codingPath.map { "." + $0.stringValue }.joined(), + reason: context.debugDescription, + ) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAlignment.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAlignment.swift new file mode 100644 index 000000000..de8522bfc --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAlignment.swift @@ -0,0 +1,6 @@ +import Foundation + +public enum ViewAlignment: String, Codable, Hashable, Sendable { + case topLeading, top, topTrailing, leading, center, trailing, bottomLeading, bottom, + bottomTrailing +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor+Sampling.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor+Sampling.swift new file mode 100644 index 000000000..6b094fa75 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor+Sampling.swift @@ -0,0 +1,53 @@ +import Foundation + +extension ViewAnimationDescriptor { + /// Finite playback stops requesting frames after its last complete cycle. + public func isComplete(at elapsed: Double) -> Bool { + switch repetition { + case .once: elapsed >= delay + duration + case let .count(count, autoreverses): + elapsed >= delay + duration * Double(count) * (autoreverses ? 2 : 1) + case .forever: false + } + } + + /// Samples elapsed seconds without wall-clock dependencies or retained animation tasks. + public func pose(at elapsed: Double) -> Pose { + guard elapsed >= delay else { return from } + let time = max(0, elapsed - delay) / duration + let reverses: Bool + let cycles: Double + switch repetition { + case .once: reverses = false; cycles = 1 + case let .count(count, autoreverses): reverses = autoreverses; cycles = Double(count) + case let .forever(autoreverses): reverses = autoreverses; cycles = .infinity + } + let legs = reverses ? 2.0 : 1.0 + let progress: Double + if time >= cycles * legs { + progress = reverses ? 0 : 1 + } else { + let leg = floor(time) + let fraction = time - leg + progress = reverses && leg + .truncatingRemainder(dividingBy: 2) == 1 ? 1 - fraction : fraction + } + let eased: Double = switch curve { + case .linear: progress + case .easeIn: progress * progress + case .easeOut: 1 - (1 - progress) * (1 - progress) + case .easeInOut: (1 - cos(.pi * progress)) / 2 + } + func mix(_ start: Double, _ end: Double) -> Double { + start + (end - start) * eased + } + return Pose( + opacity: mix(from.opacity, to.opacity), + offsetX: mix(from.offsetX, to.offsetX), + offsetY: mix(from.offsetY, to.offsetY), + scaleX: mix(from.scaleX, to.scaleX), + scaleY: mix(from.scaleY, to.scaleY), + rotation: mix(from.rotation, to.rotation), + ) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor.swift new file mode 100644 index 000000000..7032b5997 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAnimationDescriptor.swift @@ -0,0 +1,74 @@ +import Foundation + +/// A bounded transform vocabulary with an explicit accessible, deterministic pose. +public struct ViewAnimationDescriptor: Codable, Hashable, Sendable { + public var from: Pose + public var to: Pose + public var staticPose: Pose + public var duration: Double + public var delay: Double + public var curve: Curve + public var repetition: Repetition + + public init( + from: Pose, + to: Pose, + staticPose: Pose, + duration: Double, + delay: Double, + curve: Curve, + repetition: Repetition, + ) { + self.from = from + self.to = to + self.staticPose = staticPose + self.duration = duration + self.delay = delay + self.curve = curve + self.repetition = repetition + } + + public struct Pose: Codable, Hashable, Sendable { + public var opacity: Double + public var offsetX: Double + public var offsetY: Double + public var scaleX: Double + public var scaleY: Double + public var rotation: Double + + public init( + opacity: Double, + offsetX: Double, + offsetY: Double, + scaleX: Double, + scaleY: Double, + rotation: Double, + ) { + self.opacity = opacity + self.offsetX = offsetX + self.offsetY = offsetY + self.scaleX = scaleX + self.scaleY = scaleY + self.rotation = rotation + } + + public static let identity = Self( + opacity: 1, + offsetX: 0, + offsetY: 0, + scaleX: 1, + scaleY: 1, + rotation: 0, + ) + } + + public enum Curve: String, Codable, Hashable, Sendable { + case linear, easeIn, easeOut, easeInOut + } + + public enum Repetition: Codable, Hashable, Sendable { + case once + case count(Int, autoreverses: Bool) + case forever(autoreverses: Bool) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAssetID.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAssetID.swift new file mode 100644 index 000000000..960857f8c --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewAssetID.swift @@ -0,0 +1,9 @@ +import Foundation + +/// A host-supplied image identity, never a URL or filesystem path. +public struct ViewAssetID: RawRepresentable, Codable, Hashable, Sendable { + public var rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewColorDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewColorDescriptor.swift new file mode 100644 index 000000000..5b68a81ce --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewColorDescriptor.swift @@ -0,0 +1,11 @@ +import Foundation + +/// Portable colors. Explicit components use the sRGB color space. +public enum ViewColorDescriptor: Codable, Hashable, Sendable { + case rgba(red: Double, green: Double, blue: Double, alpha: Double) + case semantic(Role) + + public enum Role: String, Codable, Hashable, Sendable { + case primary, secondary, accent, background + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDimension.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDimension.swift new file mode 100644 index 000000000..50b0bc05d --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDimension.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Points, optionally scaled relative to a semantic font by the host. +public enum ViewDimension: Codable, Hashable, Sendable { + case points(Double) + case scaled(points: Double, relativeTo: ViewFontDescriptor.TextStyle) +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift new file mode 100644 index 000000000..1209a97ea --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift @@ -0,0 +1,62 @@ +import Foundation + +/// A finite, read-only view tree. Validation is required before rendering. +public struct ViewDocument: Codable, Hashable, Sendable { + public var schemaVersion: Int + public var root: Node + + public init(schemaVersion: Int, root: Node) { + self.schemaVersion = schemaVersion + self.root = root + } + + public struct Node: Codable, Hashable, Sendable, Identifiable { + public struct ID: RawRepresentable, Codable, Hashable, Sendable { + public var rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } + } + + public var id: ID + public var content: Content + public var modifiers: [ViewModifierDescriptor] + public var animation: ViewAnimationDescriptor? + public var accessibility: Accessibility + + public init( + id: ID, + content: Content, + modifiers: [ViewModifierDescriptor], + animation: ViewAnimationDescriptor?, + accessibility: Accessibility, + ) { + self.id = id + self.content = content + self.modifiers = modifiers + self.animation = animation + self.accessibility = accessibility + } + } + + public indirect enum Content: Codable, Hashable, Sendable { + case text(String) + case image(ViewAssetID) + case symbol(ViewSymbolID) + case shape(ViewShapeDescriptor) + case spacer(minimum: Double) + case group(axis: Axis, alignment: ViewAlignment, spacing: Double, children: [Node]) + } + + public enum Axis: String, Codable, Hashable, Sendable { + case horizontal, vertical, overlay + } + + public enum Accessibility: Codable, Hashable, Sendable { + case automatic + case hidden + case label(String) + case combine + case contain + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentLimits.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentLimits.swift new file mode 100644 index 000000000..7c688c091 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentLimits.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Limits are explicit at validation boundaries. Production callers normally use `standard`. +public struct ViewDocumentLimits: Hashable, Sendable { + public var bytes: Int + public var depth: Int + public var nodes: Int + public var animatedNodes: Int + + public init(bytes: Int, depth: Int, nodes: Int, animatedNodes: Int) { + self.bytes = bytes + self.depth = depth + self.nodes = nodes + self.animatedNodes = animatedNodes + } + + public static let standard = Self(bytes: 1_048_576, depth: 16, nodes: 256, animatedNodes: 32) +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentResources.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentResources.swift new file mode 100644 index 000000000..cff3d19c5 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentResources.swift @@ -0,0 +1,12 @@ +import Foundation + +/// Asset availability is checked before a document reaches the renderer. +public struct ViewDocumentResources: Hashable, Sendable { + public let images: Set + public let symbols: Set + + public init(images: Set, symbols: Set) { + self.images = images + self.symbols = symbols + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidationError.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidationError.swift new file mode 100644 index 000000000..7b86f2ae9 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidationError.swift @@ -0,0 +1,10 @@ +import Foundation + +/// Validation fails atomically and reports a path within the authored document. +public struct ViewDocumentValidationError: Error, Equatable, Sendable, LocalizedError { + public let path: String + public let reason: String + public var errorDescription: String? { + "\(path): \(reason)" + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift new file mode 100644 index 000000000..db2ff8c96 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift @@ -0,0 +1,177 @@ +import Foundation + +struct ViewDocumentValidator { + let resources: ViewDocumentResources + let limits: ViewDocumentLimits + var identities: Set = [] + var animations = 0 + var referencedImages: Set = [] + var referencedSymbols: Set = [] + + mutating func visit(_ node: ViewDocument.Node, path: String, depth: Int) throws { + try require(depth <= limits.depth, path, "Tree exceeds depth limit") + try require(!node.id.rawValue.isEmpty, path + ".id", "Empty node identity") + try require(identities.insert(node.id).inserted, path + ".id", "Duplicate node identity") + try require(identities.count <= limits.nodes, path, "Tree exceeds node limit") + switch node.content { + case .text: break + case let .image(asset): + try require( + resources.images.contains(asset), + path, + "Missing image asset", + ) + referencedImages.insert(asset) + case let .symbol(symbol): + try require( + resources.symbols.contains(symbol), + path, + "Missing registered symbol", + ) + referencedSymbols.insert(symbol) + case let .shape(value): try shape(value, path) + case let .spacer(minimum): try number(minimum, path, range: 0 ... 100_000) + case let .group(_, _, spacing, children): + try number(spacing, path + ".spacing", range: 0 ... 100_000) + for (index, child) in children.enumerated() { + try visit(child, path: path + ".children[\(index)]", depth: depth + 1) + } + } + for (index, modifier) in node.modifiers.enumerated() { + try validate(modifier, path + ".modifiers[\(index)]") + } + if let animation = node.animation { + animations += 1 + try require(animations <= limits.animatedNodes, path, "Tree exceeds animation limit") + try pose(animation.from, path) + try pose(animation.to, path) + try pose(animation.staticPose, path) + try number(animation.duration, path + ".animation.duration", range: 0.1 ... 3600) + try number(animation.delay, path + ".animation.delay", range: 0 ... 3600) + switch animation.repetition { + case .once, .forever: break + case let .count(count, _): try require( + (1 ... 10000).contains(count), + path, + "Invalid repetition count", + ) + } + } + } + + func validate(_ modifier: ViewModifierDescriptor, _ path: String) throws { + switch modifier { + case let .font(font): + switch font.size { + case .semantic: break + case let .fixed(size): try number(size, path, range: 1 ... 1000) + } + case let .foreground(value): try paint(value, path) + case let .frame(width, height, _): + if let width { try dimension(width, path) } + if let height { try dimension(height, path) } + case let .padding(value): try dimension(value, path) + case let .offset(x, y): try number(x, path); try number(y, path) + case let .scale(x, y): try number(x, path, range: -100 ... 100); try number( + y, + path, + range: -100 ... 100, + ) + case let .rotation(degrees): try number(degrees, path) + case let .opacity(value): try number(value, path, range: 0 ... 1) + case let .clip(value): try shape(value, path) + case let .background(value, fill): try shape(value, path); try paint(fill, path) + case let .border(value, fill, width): + try shape(value, path); try paint(fill, path); try number( + width, + path, + range: 0 ... 1000, + ) + case let .shadow(value, radius, x, y): + try color(value, path); try number(radius, path, range: 0 ... 1000); try number( + x, + path, + ); try number(y, path) + case .blend: break + } + } + + func pose(_ pose: ViewAnimationDescriptor.Pose, _ path: String) throws { + try number(pose.opacity, path, range: 0 ... 1) + try number(pose.offsetX, path); try number(pose.offsetY, path) + try number(pose.scaleX, path, range: -100 ... 100); try number( + pose.scaleY, + path, + range: -100 ... 100, + ) + try number(pose.rotation, path) + } + + func dimension(_ dimension: ViewDimension, _ path: String) throws { + switch dimension { + case let .points(value), let .scaled(value, _): try number( + value, + path, + range: 0 ... 100_000, + ) + } + } + + func shape(_ shape: ViewShapeDescriptor, _ path: String) throws { + switch shape { + case .rectangle, .circle, .ellipse: break + case let .roundedRectangle(radius): try number(radius, path, range: 0 ... 100_000) + } + } + + func paint(_ paint: ViewPaint, _ path: String) throws { + switch paint { + case let .color(value): try color(value, path) + case let .linearGradient(stops, start, end): + try require( + (2 ... 64).contains(stops.count), + path, + "A gradient requires 2 to 64 stops", + ) + for point in [start, end] { + try number(point.x, path, range: 0 ... 1); try number( + point.y, + path, + range: 0 ... 1, + ) + } + var previous = 0.0 + for stop in stops { + try color(stop.color, path) + try number(stop.location, path, range: previous ... 1) + previous = stop.location + } + } + } + + func color(_ color: ViewColorDescriptor, _ path: String) throws { + switch color { + case .semantic: break + case let .rgba(red, green, blue, alpha): + for value in [red, green, blue, alpha] { + try number(value, path, range: 0 ... 1) + } + } + } + + func number( + _ value: Double, + _ path: String, + range: ClosedRange = -100_000 ... 100_000, + ) throws { + try require( + value.isFinite && range.contains(value), + path, + "Numeric value is outside the supported range", + ) + } + + func require(_ condition: Bool, _ path: String, _ reason: String) throws { + guard condition else { throw ViewDocumentValidationError(path: path, reason: reason) } + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewFontDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewFontDescriptor.swift new file mode 100644 index 000000000..b46d228b4 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewFontDescriptor.swift @@ -0,0 +1,34 @@ +import Foundation + +/// System typography preserves semantic Dynamic Type scaling. +public struct ViewFontDescriptor: Codable, Hashable, Sendable { + public var size: Size + /// Nil preserves the semantic text style's system weight. + public var weight: Weight? + /// Nil preserves the semantic font's inherited design. + public var design: Design? + + public init(size: Size, weight: Weight?, design: Design?) { + self.size = size + self.weight = weight + self.design = design + } + + public enum Size: Codable, Hashable, Sendable { + case semantic(TextStyle) + case fixed(Double) + } + + public enum TextStyle: String, CaseIterable, Codable, Hashable, Sendable { + case largeTitle, title, title2, title3, headline, subheadline, body, callout, footnote, + caption, caption2 + } + + public enum Weight: String, Codable, Hashable, Sendable { + case ultraLight, thin, light, regular, medium, semibold, bold, heavy, black + } + + public enum Design: String, Codable, Hashable, Sendable { + case standard, rounded, serif, monospaced + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewModifierDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewModifierDescriptor.swift new file mode 100644 index 000000000..e74032b57 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewModifierDescriptor.swift @@ -0,0 +1,22 @@ +import Foundation + +/// Ordered operations; a modifier applies to the result of all preceding operations. +public enum ViewModifierDescriptor: Codable, Hashable, Sendable { + case font(ViewFontDescriptor) + case foreground(ViewPaint) + case frame(width: ViewDimension?, height: ViewDimension?, alignment: ViewAlignment) + case padding(ViewDimension) + case offset(x: Double, y: Double) + case scale(x: Double, y: Double) + case rotation(degrees: Double) + case opacity(Double) + case clip(ViewShapeDescriptor) + case background(shape: ViewShapeDescriptor, paint: ViewPaint) + case border(shape: ViewShapeDescriptor, paint: ViewPaint, width: Double) + case shadow(color: ViewColorDescriptor, radius: Double, x: Double, y: Double) + case blend(Blend) + + public enum Blend: String, Codable, Hashable, Sendable { + case normal, multiply, screen, overlay, darken, lighten, plusLighter + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewPaint.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewPaint.swift new file mode 100644 index 000000000..56a1f9397 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewPaint.swift @@ -0,0 +1,24 @@ +import Foundation + +/// A solid color or linear gradient in normalized local coordinates. +public enum ViewPaint: Codable, Hashable, Sendable { + case color(ViewColorDescriptor) + case linearGradient(stops: [Stop], start: Point, end: Point) + + public struct Stop: Codable, Hashable, Sendable { + public var color: ViewColorDescriptor + public var location: Double + public init(color: ViewColorDescriptor, location: Double) { + self.color = color + self.location = location + } + } + + public struct Point: Codable, Hashable, Sendable { + public var x: Double + public var y: Double + public init(x: Double, y: Double) { + self.x = x; self.y = y + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewShapeDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewShapeDescriptor.swift new file mode 100644 index 000000000..4b1fe9bd8 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewShapeDescriptor.swift @@ -0,0 +1,8 @@ +import Foundation + +public enum ViewShapeDescriptor: Codable, Hashable, Sendable { + case rectangle + case roundedRectangle(radius: Double) + case circle + case ellipse +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewSymbolID.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewSymbolID.swift new file mode 100644 index 000000000..a0d9472a4 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewSymbolID.swift @@ -0,0 +1,9 @@ +import Foundation + +/// A key in the host's approved symbol registry, not a system symbol name. +public struct ViewSymbolID: RawRepresentable, Codable, Hashable, Sendable { + public var rawValue: String + public init(rawValue: String) { + self.rawValue = rawValue + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ValidatedViewDocumentTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ValidatedViewDocumentTests.swift new file mode 100644 index 000000000..20ce7e586 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ValidatedViewDocumentTests.swift @@ -0,0 +1,124 @@ +import Foundation +import Testing +import ViewDocumentCore + +struct ValidatedViewDocumentTests { + @Test func retainsOnlyReferencedResources() throws { + let used = ViewAssetID(rawValue: "used") + let unused = ViewAssetID(rawValue: "unused") + let result = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode("image", content: .image(used))), + resources: .init(images: [used, unused], symbols: []), + limits: .standard, + ) + #expect(result.resources.images == [used]) + } + + @Test func rejectsDuplicateIdentities() throws { + let child = documentNode("same", content: .text("One")) + let root = documentNode( + "root", + content: .group( + axis: .vertical, + alignment: .leading, + spacing: 2, + children: [child, child], + ), + ) + let error = #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.path.contains("children[1]") == true) + } + + @Test func rejectsUnavailableAssets() { + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode( + "image", + content: .image(.init(rawValue: "pumpkin")), + )), + resources: noDocumentResources, + limits: .standard, + ) + } + } + + @Test(arguments: [Double.nan, .infinity, -0.1, 1.1]) + func rejectsInvalidOpacity(_ opacity: Double) { + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode( + "shape", + content: .shape(.circle), + modifiers: [.opacity(opacity)], + )), + resources: noDocumentResources, + limits: .standard, + ) + } + } + + @Test func enforcesDepthAndNodeLimits() { + let root = documentNode( + "root", + content: .group( + axis: .overlay, + alignment: .center, + spacing: 0, + children: [documentNode("child", content: .text("Hello"))], + ), + ) + for limits in [ + ViewDocumentLimits(bytes: 10000, depth: 1, nodes: 10, animatedNodes: 0), + .init(bytes: 10000, depth: 10, nodes: 1, animatedNodes: 0), + ] { + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: limits, + ) + } + } + } + + @Test func rejectsVersionAndOversizedInput() { + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 2, root: documentNode( + "root", + content: .text("Hello"), + )), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument.decode( + Data(repeating: 32, count: 1024), + resources: noDocumentResources, + limits: .init(bytes: 100, depth: 16, nodes: 256, animatedNodes: 32), + ) + } + } + + @Test func changedContentHasDifferentIdentity() throws { + let first = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode("same", content: .text("First"))), + resources: noDocumentResources, + limits: .standard, + ) + let second = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode("same", content: .text("Second"))), + resources: noDocumentResources, + limits: .standard, + ) + #expect(first != second) + #expect(Set([first, second]).count == 2) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewAnimationDescriptor+SamplingTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewAnimationDescriptor+SamplingTests.swift new file mode 100644 index 000000000..dad251ca2 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewAnimationDescriptor+SamplingTests.swift @@ -0,0 +1,52 @@ +import Testing +import ViewDocumentCore + +struct ViewAnimationDescriptorSamplingTests { + @Test func delayEndpointsAndAutoreverseAreDeterministic() { + let end = ViewAnimationDescriptor.Pose( + opacity: 0.5, + offsetX: 10, + offsetY: 20, + scaleX: 2, + scaleY: 3, + rotation: 90, + ) + let animation = ViewAnimationDescriptor( + from: .identity, + to: end, + staticPose: .identity, + duration: 2, + delay: 1, + curve: .linear, + repetition: .count(1, autoreverses: true), + ) + #expect(animation.pose(at: 0) == .identity) + #expect(animation.pose(at: 1) == .identity) + #expect(animation.pose(at: 2).offsetX == 5) + #expect(animation.pose(at: 3) == end) + #expect(animation.pose(at: 4).offsetX == 5) + #expect(!animation.isComplete(at: 4.99)) + #expect(animation.isComplete(at: 5)) + #expect(animation.pose(at: 5) == .identity) + #expect(animation.pose(at: 100) == .identity) + } + + @Test func onceHoldsFinalPoseAndForeverRestarts() { + var end = ViewAnimationDescriptor.Pose.identity + end.rotation = 90 + var animation = ViewAnimationDescriptor( + from: .identity, + to: end, + staticPose: .identity, + duration: 1, + delay: 0, + curve: .linear, + repetition: .once, + ) + #expect(animation.isComplete(at: 2)) + #expect(animation.pose(at: 2) == end) + animation.repetition = .forever(autoreverses: false) + #expect(!animation.isComplete(at: 100_000)) + #expect(animation.pose(at: 2.5).rotation == 45) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTestSupport.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTestSupport.swift new file mode 100644 index 000000000..6a9092c9d --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTestSupport.swift @@ -0,0 +1,18 @@ +import ViewDocumentCore + +func documentNode( + _ name: String, + content: ViewDocument.Content, + modifiers: [ViewModifierDescriptor] = [], + animation: ViewAnimationDescriptor? = nil, +) -> ViewDocument.Node { + .init( + id: .init(rawValue: name), + content: content, + modifiers: modifiers, + animation: animation, + accessibility: .automatic, + ) +} + +let noDocumentResources = ViewDocumentResources(images: [], symbols: []) diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTests.swift new file mode 100644 index 000000000..7e8c1d61c --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentTests.swift @@ -0,0 +1,33 @@ +import Foundation +import Testing +import ViewDocumentCore + +struct ViewDocumentTests { + @Test func preservesOrderedModifiersAndTypedIdentity() throws { + let document = ViewDocument( + schemaVersion: 1, + root: documentNode("label", content: .text("Hello"), modifiers: [ + .padding(.points(8)), + .opacity(0.5), + .rotation(degrees: 12), + ]), + ) + let data = try JSONEncoder().encode(document) + let decoded = try JSONDecoder().decode(ViewDocument.self, from: data) + #expect(decoded == document) + #expect(decoded.root.modifiers.count == 3) + } + + @Test func literalJSONIsAnIndependentInput() throws { + let json = Data( + #"{"schemaVersion":1,"root":{"id":"greeting","content":{"text":{"_0":"Hello"}},"modifiers":[],"accessibility":{"automatic":{}}}}"# + .utf8, + ) + let result = try ValidatedViewDocument.decode( + json, + resources: noDocumentResources, + limits: .standard, + ) + #expect(result.document.root.content == .text("Hello")) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift new file mode 100644 index 000000000..65097bde9 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift @@ -0,0 +1,74 @@ +import Testing +import ViewDocumentCore + +struct ViewDocumentValidatorTests { + @Test func rejectsUnknownOperationsWithTheirPath() { + let json = #"{"schemaVersion":1,"root":{"id":"bad","content":{"button":{}},"modifiers":[],"accessibility":{"automatic":{}}}}"# + let error = #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument.decode( + .init(json.utf8), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.path.contains("content") == true) + } + + @Test func rejectsUnorderedGradientStops() { + let paint = ViewPaint.linearGradient( + stops: [ + .init(color: .semantic(.primary), location: 0.8), + .init(color: .semantic(.secondary), location: 0.2), + ], + start: .init(x: 0, y: 0), + end: .init(x: 1, y: 1), + ) + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode( + "root", + content: .shape(.circle), + modifiers: [.foreground(paint)], + )), + resources: noDocumentResources, + limits: .standard, + ) + } + } + + @Test func enforcesAnimationBudgetAndDuration() { + var animation = ViewAnimationDescriptor( + from: .identity, + to: .identity, + staticPose: .identity, + duration: 1, + delay: 0, + curve: .linear, + repetition: .forever(autoreverses: true), + ) + let limits = ViewDocumentLimits(bytes: 10000, depth: 16, nodes: 256, animatedNodes: 0) + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode( + "root", + content: .shape(.circle), + animation: animation, + )), + resources: noDocumentResources, + limits: limits, + ) + } + animation.duration = 0 + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: documentNode( + "root", + content: .shape(.circle), + animation: animation, + )), + resources: noDocumentResources, + limits: .standard, + ) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/AGENTS.md b/Shared/ViewDocument/ViewDocumentUI/AGENTS.md new file mode 100644 index 000000000..e4ad179c3 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/AGENTS.md @@ -0,0 +1,5 @@ +# ViewDocumentUI + +ViewDocumentUI follows the [group contract](../AGENTS.md) and [repository rules](../../../AGENTS.md). See [README.md](README.md). + +Keep source tests one-to-one. Run `./test ViewDocumentUITests`. diff --git a/Shared/ViewDocument/ViewDocumentUI/README.md b/Shared/ViewDocument/ViewDocumentUI/README.md new file mode 100644 index 000000000..9fdb78621 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/README.md @@ -0,0 +1,3 @@ +# ViewDocumentUI + +See the [ViewDocument guide](../README.md) for composition, validation, and the wire format. This target is declared in the root Package.swift. diff --git a/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/ViewDocumentViewSnapshotTests.swift b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/ViewDocumentViewSnapshotTests.swift new file mode 100644 index 000000000..188200a0f --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/ViewDocumentViewSnapshotTests.swift @@ -0,0 +1,85 @@ +import SnapshotKit +import SnapshotKitTesting +import SwiftUI +import Testing +import ViewDocumentCore +import ViewDocumentUI + +@MainActor +struct ViewDocumentViewSnapshotTests { + @Test func standaloneContent() async throws { + let root = ViewDocument.Node( + id: .init(rawValue: "root"), + content: .group(axis: .vertical, alignment: .center, spacing: 20, children: [ + .init( + id: .init(rawValue: "title"), + content: .text("A document rendered with SwiftUI"), + modifiers: [.font(.init( + size: .semantic(.title2), + weight: .bold, + design: .rounded, + ))], + animation: nil, + accessibility: .automatic, + ), + .init( + id: .init(rawValue: "art"), + content: .shape(.roundedRectangle(radius: 18)), + modifiers: [ + .foreground(.linearGradient( + stops: [.init( + color: .rgba(red: 1, green: 0.4, blue: 0.1, alpha: 1), + location: 0, + ), .init( + color: .rgba(red: 0.6, green: 0.1, blue: 0.3, alpha: 1), + location: 1, + )], + start: .init(x: 0, y: 0), + end: .init(x: 1, y: 1), + )), + .frame(width: .points(160), height: .points(100), alignment: .center), + .rotation(degrees: 8), + ], + animation: nil, + accessibility: .label("Orange and red artwork"), + ), + .init( + id: .init(rawValue: "caption"), + content: .text("Finite layouts. Ordered modifiers. Accessible content."), + modifiers: [.font(.init( + size: .semantic(.body), + weight: .regular, + design: .standard, + ))], + animation: nil, + accessibility: .automatic, + ), + ]), + modifiers: [.padding(.points(24))], + animation: nil, + accessibility: .contain, + ) + let assets = ViewDocumentAssets(images: [:], symbols: [:]) + let document = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: assets.resources, + limits: .standard, + ) + let view = try ViewDocumentView( + document: document, + assets: assets, + appearance: .init(background: .white, dimensionScales: [:]), + policy: .init(purpose: .content, isActive: true, motionIsStatic: true), + ) + await assertSnapshots( + of: view.padding().frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(uiColor: .systemBackground)), + named: "ReadOnlyContent", + configurations: SnapshotConfiguration.combinations( + devices: [.iPhone], + colorSchemes: [.light, .dark], + snapshotTypes: [.standard, .accessibility], + ), + ) + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone.png b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone.png new file mode 100644 index 000000000..1f2bff78e --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c633e1f3faefce6ff52f49ed74d5129cc9dcce74a915e6341e78618fa767af4d +size 323845 diff --git a/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_accessibility.png b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_accessibility.png new file mode 100644 index 000000000..e99dec937 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a041b9ab2240a2ebe7c00633c2e01b839158e1d6c8c8e73744abfc0eea9c6fae +size 317089 diff --git a/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark.png b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark.png new file mode 100644 index 000000000..1aa5a6b54 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd9ff8309965bdad62a41dd3fa8ac52ecbfae51510a070ca7a62b31b3a4cd22f +size 329905 diff --git a/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark_accessibility.png b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark_accessibility.png new file mode 100644 index 000000000..3fdf0b8ea --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/__Snapshots__/ViewDocumentViewSnapshotTests/standaloneContent.ReadOnlyContent_iPhone_dark_accessibility.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:728ab32cca37f80be763668abddafd57138fd60593c865437cb735843da4f51b +size 320002 diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/PreparedViewDocument.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/PreparedViewDocument.swift new file mode 100644 index 000000000..06a5ce5d1 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/PreparedViewDocument.swift @@ -0,0 +1,15 @@ +import ViewDocumentCore + +/// Pairs immutable validated content with its resources before entering a view body. +public struct PreparedViewDocument { + public let document: ValidatedViewDocument + let assets: ViewDocumentAssets + + public init(document: ValidatedViewDocument, assets: ViewDocumentAssets) throws { + guard document.resources.images.isSubset(of: assets.resources.images), + document.resources.symbols.isSubset(of: assets.resources.symbols) + else { throw ViewDocumentView.ResourceError.mismatchedAssets } + self.document = document + self.assets = assets + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAnimatedView.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAnimatedView.swift new file mode 100644 index 000000000..0be9c60e7 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAnimatedView.swift @@ -0,0 +1,57 @@ +import SwiftUI +import ViewDocumentCore + +/// Visibility owns playback. Finite animations stop the timeline at their final pose. +struct ViewDocumentAnimatedView: View { + let animation: ViewAnimationDescriptor + let policy: ViewDocumentView.Policy + let content: AnyView + @State private var playback = Playback.waiting + + private enum Playback { + case waiting + case running(since: Date) + case holding(ViewAnimationDescriptor.Pose) + + var isPaused: Bool { + switch self { + case .waiting, .holding: true + case .running: false + } + } + } + + var body: some View { + if policy.motionIsStatic || !policy.isActive { + posed(animation.staticPose) + } else { + TimelineView(.animation( + minimumInterval: 1 / 30, + paused: playback.isPaused, + )) { context in + let elapsed: Double = if case let .running(epoch) = playback { + context.date.timeIntervalSince(epoch) + } else { 0 } + let pose: ViewAnimationDescriptor.Pose = switch playback { + case .waiting: animation.from + case .running: animation.pose(at: elapsed) + case let .holding(value): value + } + posed(pose) + .onChange(of: animation.isComplete(at: elapsed)) { _, complete in + if complete { playback = .holding(pose) } + } + } + .onAppear { playback = .running(since: Date()) } + .onDisappear { playback = .waiting } + } + } + + private func posed(_ pose: ViewAnimationDescriptor.Pose) -> some View { + content + .opacity(pose.opacity) + .scaleEffect(x: pose.scaleX, y: pose.scaleY) + .rotationEffect(.degrees(pose.rotation)) + .offset(x: pose.offsetX, y: pose.offsetY) + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAppearance.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAppearance.swift new file mode 100644 index 000000000..f4c0e7746 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAppearance.swift @@ -0,0 +1,138 @@ +import SwiftUI +import ViewDocumentCore + +/// Host-resolved appearance inputs. Resolution does not consult ambient UIKit traits. +public struct ViewDocumentAppearance { + public var background: Color + public var dimensionScales: [ViewFontDescriptor.TextStyle: Double] + + public init(background: Color, dimensionScales: [ViewFontDescriptor.TextStyle: Double]) { + self.background = background + self.dimensionScales = dimensionScales + } + + public func color(_ value: ViewColorDescriptor) -> Color { + switch value { + case let .rgba(red, green, blue, alpha): Color( + .sRGB, + red: red, + green: green, + blue: blue, + opacity: alpha, + ) + case let .semantic(role): + switch role { + case .primary: .primary + case .secondary: .secondary + case .accent: .accentColor + case .background: background + } + } + } + + public func paint(_ value: ViewPaint) -> AnyShapeStyle { + switch value { + case let .color(value): AnyShapeStyle(color(value)) + case let .linearGradient(stops, start, end): + AnyShapeStyle(LinearGradient( + stops: stops.map { .init(color: color($0.color), location: $0.location) }, + startPoint: UnitPoint(x: start.x, y: start.y), + endPoint: UnitPoint(x: end.x, y: end.y), + )) + } + } + + public func dimension(_ value: ViewDimension) -> CGFloat { + switch value { + case let .points(points): points + case let .scaled(points, style): points * (dimensionScales[style] ?? 1) + } + } +} + +extension ViewFontDescriptor { + public var font: Font { + let base: Font = switch size { + case let .semantic(style): .system(style.textStyle, design: design?.fontDesign) + case let .fixed(points): + if let design { .system(size: points, design: design.fontDesign) } + else { .system(size: points) } + } + return weight.map { base.weight($0.fontWeight) } ?? base + } +} + +extension ViewFontDescriptor.TextStyle { + public var textStyle: Font.TextStyle { + switch self { + case .largeTitle: .largeTitle + case .title: .title + case .title2: .title2 + case .title3: .title3 + case .headline: .headline + case .subheadline: .subheadline + case .body: .body + case .callout: .callout + case .footnote: .footnote + case .caption: .caption + case .caption2: .caption2 + } + } +} + +extension ViewFontDescriptor.Weight { + public var fontWeight: Font.Weight { + switch self { + case .ultraLight: .ultraLight + case .thin: .thin + case .light: .light + case .regular: .regular + case .medium: .medium + case .semibold: .semibold + case .bold: .bold + case .heavy: .heavy + case .black: .black + } + } +} + +extension ViewFontDescriptor.Design { + public var fontDesign: Font.Design { + switch self { + case .standard: .default + case .rounded: .rounded + case .serif: .serif + case .monospaced: .monospaced + } + } +} + +extension ViewAlignment { + public var alignment: Alignment { + switch self { + case .topLeading: .topLeading + case .top: .top + case .topTrailing: .topTrailing + case .leading: .leading + case .center: .center + case .trailing: .trailing + case .bottomLeading: .bottomLeading + case .bottom: .bottom + case .bottomTrailing: .bottomTrailing + } + } +} + +extension ViewModifierDescriptor.Blend { + public var blendMode: BlendMode { + switch self { + case .normal: .normal + case .multiply: .multiply + case .screen: .screen + case .overlay: .overlay + case .darken: .darken + case .lighten: .lighten + case .plusLighter: .plusLighter + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAssets.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAssets.swift new file mode 100644 index 000000000..4be483667 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAssets.swift @@ -0,0 +1,17 @@ +import SwiftUI +import ViewDocumentCore + +/// Prepared images and approved symbols. No file or network work occurs during rendering. +public struct ViewDocumentAssets { + let images: [ViewAssetID: Image] + let symbols: [ViewSymbolID: Image] + + public init(images: [ViewAssetID: Image], symbols: [ViewSymbolID: Image]) { + self.images = images + self.symbols = symbols + } + + public var resources: ViewDocumentResources { + .init(images: Set(images.keys), symbols: Set(symbols.keys)) + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentGroupView.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentGroupView.swift new file mode 100644 index 000000000..c74a81d8d --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentGroupView.swift @@ -0,0 +1,35 @@ +import SwiftUI +import ViewDocumentCore + +struct ViewDocumentGroupView: View { + let axis: ViewDocument.Axis + let alignment: ViewAlignment + let spacing: Double + let children: [ViewDocument.Node] + let assets: ViewDocumentAssets + let appearance: ViewDocumentAppearance + let policy: ViewDocumentView.Policy + + var body: some View { + switch axis { + case .horizontal: + HStack(alignment: alignment.alignment.vertical, spacing: spacing) { + nodes + } + case .vertical: + VStack(alignment: alignment.alignment.horizontal, spacing: spacing) { + nodes + } + case .overlay: + ZStack(alignment: alignment.alignment) { + nodes + } + } + } + + private var nodes: some View { + ForEach(children) { node in + ViewDocumentNodeView(node: node, assets: assets, appearance: appearance, policy: policy) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentNodeView.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentNodeView.swift new file mode 100644 index 000000000..b1c5e1a3c --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentNodeView.swift @@ -0,0 +1,52 @@ +import SwiftUI +import ViewDocumentCore + +/// Type erasure is confined to the recursive document interpreter. +struct ViewDocumentNodeView: View { + let node: ViewDocument.Node + let assets: ViewDocumentAssets + let appearance: ViewDocumentAppearance + let policy: ViewDocumentView.Policy + + var body: some View { + let styled = node.modifiers.reduce(content()) { view, modifier in + modifier.applying(to: view, appearance: appearance) + } + let accessible = accessibility(styled) + if let animation = node.animation { + ViewDocumentAnimatedView(animation: animation, policy: policy, content: accessible) + } else { + accessible + } + } + + private func content() -> AnyView { + switch node.content { + case let .text(text): AnyView(Text(verbatim: text)) + case let .image(asset): AnyView(assets.images[asset]!.resizable().scaledToFit()) + case let .symbol(symbol): AnyView(assets.symbols[symbol]!) + case let .shape(shape): AnyView(ViewDocumentShape(descriptor: shape)) + case let .spacer(minimum): AnyView(Spacer(minLength: minimum)) + case let .group(axis, alignment, spacing, children): + AnyView(ViewDocumentGroupView( + axis: axis, + alignment: alignment, + spacing: spacing, + children: children, + assets: assets, + appearance: appearance, + policy: policy, + )) + } + } + + private func accessibility(_ content: AnyView) -> AnyView { + switch node.accessibility { + case .automatic: content + case .hidden: AnyView(content.accessibilityHidden(true)) + case let .label(label): AnyView(content.accessibilityLabel(Text(verbatim: label))) + case .combine: AnyView(content.accessibilityElement(children: .combine)) + case .contain: AnyView(content.accessibilityElement(children: .contain)) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentShape.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentShape.swift new file mode 100644 index 000000000..312c7cfef --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentShape.swift @@ -0,0 +1,16 @@ +import SwiftUI +import ViewDocumentCore + +struct ViewDocumentShape: Shape { + let descriptor: ViewShapeDescriptor + + func path(in rect: CGRect) -> Path { + switch descriptor { + case .rectangle: Rectangle().path(in: rect) + case let .roundedRectangle(radius): RoundedRectangle(cornerRadius: radius) + .path(in: rect) + case .circle: Circle().path(in: rect) + case .ellipse: Ellipse().path(in: rect) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentView.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentView.swift new file mode 100644 index 000000000..da439e80c --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentView.swift @@ -0,0 +1,100 @@ +import SwiftUI +import ViewDocumentCore + +/// Renders validated read-only content. Host policy controls motion and decorative use. +public struct ViewDocumentView: View { + private let document: ValidatedViewDocument + private let assets: ViewDocumentAssets + private let appearance: ViewDocumentAppearance + private let policy: Policy + + public struct Policy: Equatable, Sendable { + public var purpose: Purpose + public var isActive: Bool + public var motionIsStatic: Bool + + public init(purpose: Purpose, isActive: Bool, motionIsStatic: Bool) { + self.purpose = purpose + self.isActive = isActive + self.motionIsStatic = motionIsStatic + } + + public enum Purpose: Sendable { + case content, decoration + } + } + + public init( + document: ValidatedViewDocument, + assets: ViewDocumentAssets, + appearance: ViewDocumentAppearance, + policy: Policy, + ) throws { + let prepared = try PreparedViewDocument(document: document, assets: assets) + self.init(prepared: prepared, appearance: appearance, policy: policy) + } + + public init( + prepared: PreparedViewDocument, + appearance: ViewDocumentAppearance, + policy: Policy, + ) { + let document = prepared.document + let assets = prepared.assets + self.document = document + self.assets = assets + self.appearance = appearance + self.policy = policy + } + + public enum ResourceError: Error { + case mismatchedAssets + } + + public var body: some View { + ViewDocumentNodeView( + node: document.document.root, + assets: assets, + appearance: appearance, + policy: policy, + ) + .id(document.document) + .accessibilityHidden(policy.purpose == .decoration) + .allowsHitTesting(false) + } +} + +#if DEBUG + #Preview("Read-only document") { ViewDocumentPreview.make() } + + @MainActor + private enum ViewDocumentPreview { + static func make() -> ViewDocumentView { + do { + let assets = ViewDocumentAssets(images: [:], symbols: [:]) + let document = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: .init( + id: .init(rawValue: "greeting"), + content: .text("Hello from ViewDocument"), + modifiers: [ + .font(.init(size: .semantic(.title), weight: .bold, design: .rounded)), + .padding(.points(24)), + ], + animation: nil, + accessibility: .automatic, + )), + resources: assets.resources, + limits: .standard, + ) + return try ViewDocumentView( + document: document, + assets: assets, + appearance: .init(background: .white, dimensionScales: [:]), + policy: .init(purpose: .content, isActive: true, motionIsStatic: true), + ) + } catch { + preconditionFailure("Invalid built-in preview: \(error)") + } + } + } +#endif diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewModifierDescriptor+Rendering.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewModifierDescriptor+Rendering.swift new file mode 100644 index 000000000..e4d4c9b9a --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewModifierDescriptor+Rendering.swift @@ -0,0 +1,38 @@ +import SwiftUI +import ViewDocumentCore + +extension ViewModifierDescriptor { + func applying(to view: AnyView, appearance: ViewDocumentAppearance) -> AnyView { + switch self { + case let .font(font): AnyView(view.font(font.font)) + case let .foreground(paint): AnyView(view.foregroundStyle(appearance.paint(paint))) + case let .frame(width, height, alignment): + AnyView(view.frame( + width: width.map(appearance.dimension), + height: height.map(appearance.dimension), + alignment: alignment.alignment, + )) + case let .padding(value): AnyView(view.padding(appearance.dimension(value))) + case let .offset(x, y): AnyView(view.offset(x: x, y: y)) + case let .scale(x, y): AnyView(view.scaleEffect(x: x, y: y)) + case let .rotation(degrees): AnyView(view.rotationEffect(.degrees(degrees))) + case let .opacity(value): AnyView(view.opacity(value)) + case let .clip(shape): AnyView(view.clipShape(ViewDocumentShape(descriptor: shape))) + case let .background(shape, paint): + AnyView(view + .background(ViewDocumentShape(descriptor: shape).fill(appearance.paint(paint)))) + case let .border(shape, paint, width): + AnyView(view.overlay(ViewDocumentShape(descriptor: shape).stroke( + appearance.paint(paint), + lineWidth: width, + ))) + case let .shadow(color, radius, x, y): AnyView(view.shadow( + color: appearance.color(color), + radius: radius, + x: x, + y: y, + )) + case let .blend(blend): AnyView(view.blendMode(blend.blendMode)) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentAppearanceTests.swift b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentAppearanceTests.swift new file mode 100644 index 000000000..37441a1dd --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentAppearanceTests.swift @@ -0,0 +1,28 @@ +import SwiftUI +import Testing +import ViewDocumentCore +import ViewDocumentUI + +struct ViewDocumentAppearanceTests { + @Test func preservesSystemWeightUnlessExplicitlyOverridden() { + let semantic = ViewFontDescriptor( + size: .semantic(.headline), + weight: nil, + design: nil, + ) + #expect(semantic.font == Font.headline) + let regular = ViewFontDescriptor( + size: .semantic(.headline), + weight: .regular, + design: nil, + ) + #expect(regular.font == Font.headline.weight(.regular)) + #expect(regular.font != semantic.font) + } + + @Test func scalesOnlyAuthoredSemanticDimensions() { + let appearance = ViewDocumentAppearance(background: .white, dimensionScales: [.body: 2]) + #expect(appearance.dimension(.points(12)) == 12) + #expect(appearance.dimension(.scaled(points: 12, relativeTo: .body)) == 24) + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentViewTests.swift b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentViewTests.swift new file mode 100644 index 000000000..75fafa265 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentViewTests.swift @@ -0,0 +1,56 @@ +import SwiftUI +import Testing +import ViewDocumentCore +import ViewDocumentUI + +@MainActor +struct ViewDocumentViewTests { + @Test func rejectsAssetsThatDifferFromValidation() throws { + let asset = ViewAssetID(rawValue: "artwork") + let document = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: .init( + id: .init(rawValue: "root"), + content: .image(asset), + modifiers: [], + animation: nil, + accessibility: .label("Artwork"), + )), + resources: .init(images: [asset], symbols: []), + limits: .standard, + ) + #expect(throws: ViewDocumentView.ResourceError.self) { + try ViewDocumentView( + document: document, + assets: .init(images: [:], symbols: [:]), + appearance: .init(background: .white, dimensionScales: [:]), + policy: .init(purpose: .content, isActive: true, motionIsStatic: true), + ) + } + } + + @Test func rendersStandaloneDocumentWithOrderedPadding() throws { + let document = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: .init( + id: .init(rawValue: "root"), + content: .shape(.rectangle), + modifiers: [ + .frame(width: .points(20), height: .points(10), alignment: .center), + .padding(.points(5)), + ], + animation: nil, + accessibility: .hidden, + )), + resources: .init(images: [], symbols: []), + limits: .standard, + ) + let view = try ViewDocumentView( + document: document, + assets: .init(images: [:], symbols: [:]), + appearance: .init(background: .white, dimensionScales: [:]), + policy: .init(purpose: .content, isActive: true, motionIsStatic: true), + ) + let renderer = ImageRenderer(content: view) + let image = try #require(renderer.uiImage) + #expect(image.size == CGSize(width: 30, height: 20)) + } +} From 1eb7433ef7f78536c0750364355a1775399bd15d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 29 Oct 2026 20:17:40 -0400 Subject: [PATCH 2/5] fix(ViewDocument): bound modifier nesting and aggregate rendering work --- .../ViewDocument/ViewDocumentCore/AGENTS.md | 2 + .../ViewDocument/ViewDocumentCore/README.md | 2 + .../Sources/ValidatedViewDocument.swift | 4 +- .../Sources/ViewDocumentLimits.swift | 22 +++++++- .../Sources/ViewDocumentValidator.swift | 12 +++++ .../Tests/ValidatedViewDocumentTests.swift | 27 ++++++++-- .../Tests/ViewDocumentValidatorTests.swift | 51 ++++++++++++++++++- 7 files changed, 113 insertions(+), 7 deletions(-) diff --git a/Shared/ViewDocument/ViewDocumentCore/AGENTS.md b/Shared/ViewDocument/ViewDocumentCore/AGENTS.md index 26883eb1d..33456ee15 100644 --- a/Shared/ViewDocument/ViewDocumentCore/AGENTS.md +++ b/Shared/ViewDocument/ViewDocumentCore/AGENTS.md @@ -3,3 +3,5 @@ ViewDocumentCore follows the [group contract](../AGENTS.md) and [repository rules](../../../AGENTS.md). See [README.md](README.md). Keep source tests one-to-one. Run `./test ViewDocumentCoreTests`. + +Validate modifier nesting and aggregate modifier counts alongside tree depth before rendering wire input. diff --git a/Shared/ViewDocument/ViewDocumentCore/README.md b/Shared/ViewDocument/ViewDocumentCore/README.md index 6d74b4946..a17a4cd2b 100644 --- a/Shared/ViewDocument/ViewDocumentCore/README.md +++ b/Shared/ViewDocument/ViewDocumentCore/README.md @@ -1,3 +1,5 @@ # ViewDocumentCore See the [ViewDocument guide](../README.md) for composition, validation, and the wire format. This target is declared in the root Package.swift. + +Validation also bounds modifier nesting and total modifier work. Standard limits allow 32 modifiers per node and 1,024 per document. diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift index 198961d57..63bda7dc4 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift @@ -10,7 +10,9 @@ public struct ValidatedViewDocument: Hashable, Sendable { resources: ViewDocumentResources, limits: ViewDocumentLimits, ) throws { - guard limits.bytes > 0, limits.depth > 0, limits.nodes > 0, limits.animatedNodes >= 0 else { + guard limits.bytes > 0, limits.depth > 0, limits.nodes > 0, limits.animatedNodes >= 0, + limits.modifiersPerNode >= 0, limits.modifiers >= 0 + else { throw ViewDocumentValidationError(path: "$", reason: "Invalid validation limits") } guard document.schemaVersion == 1 else { diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentLimits.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentLimits.swift index 7c688c091..02b5013df 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentLimits.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentLimits.swift @@ -6,13 +6,31 @@ public struct ViewDocumentLimits: Hashable, Sendable { public var depth: Int public var nodes: Int public var animatedNodes: Int + public var modifiersPerNode: Int + public var modifiers: Int - public init(bytes: Int, depth: Int, nodes: Int, animatedNodes: Int) { + public init( + bytes: Int, + depth: Int, + nodes: Int, + animatedNodes: Int, + modifiersPerNode: Int, + modifiers: Int, + ) { self.bytes = bytes self.depth = depth self.nodes = nodes self.animatedNodes = animatedNodes + self.modifiersPerNode = modifiersPerNode + self.modifiers = modifiers } - public static let standard = Self(bytes: 1_048_576, depth: 16, nodes: 256, animatedNodes: 32) + public static let standard = Self( + bytes: 1_048_576, + depth: 16, + nodes: 256, + animatedNodes: 32, + modifiersPerNode: 32, + modifiers: 1024, + ) } diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift index db2ff8c96..78aa72931 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift @@ -5,6 +5,7 @@ struct ViewDocumentValidator { let limits: ViewDocumentLimits var identities: Set = [] var animations = 0 + var modifiers = 0 var referencedImages: Set = [] var referencedSymbols: Set = [] @@ -13,6 +14,17 @@ struct ViewDocumentValidator { try require(!node.id.rawValue.isEmpty, path + ".id", "Empty node identity") try require(identities.insert(node.id).inserted, path + ".id", "Duplicate node identity") try require(identities.count <= limits.nodes, path, "Tree exceeds node limit") + try require( + node.modifiers.count <= limits.modifiersPerNode, + path + ".modifiers", + "Node exceeds modifier limit", + ) + try require( + node.modifiers.count <= limits.modifiers - modifiers, + path + ".modifiers", + "Tree exceeds modifier limit", + ) + modifiers += node.modifiers.count switch node.content { case .text: break case let .image(asset): diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ValidatedViewDocumentTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ValidatedViewDocumentTests.swift index 20ce7e586..eb9338466 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Tests/ValidatedViewDocumentTests.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ValidatedViewDocumentTests.swift @@ -74,8 +74,22 @@ struct ValidatedViewDocumentTests { ), ) for limits in [ - ViewDocumentLimits(bytes: 10000, depth: 1, nodes: 10, animatedNodes: 0), - .init(bytes: 10000, depth: 10, nodes: 1, animatedNodes: 0), + ViewDocumentLimits( + bytes: 10000, + depth: 1, + nodes: 10, + animatedNodes: 0, + modifiersPerNode: 32, + modifiers: 1024, + ), + .init( + bytes: 10000, + depth: 10, + nodes: 1, + animatedNodes: 0, + modifiersPerNode: 32, + modifiers: 1024, + ), ] { #expect(throws: ViewDocumentValidationError.self) { try ValidatedViewDocument( @@ -102,7 +116,14 @@ struct ValidatedViewDocumentTests { try ValidatedViewDocument.decode( Data(repeating: 32, count: 1024), resources: noDocumentResources, - limits: .init(bytes: 100, depth: 16, nodes: 256, animatedNodes: 32), + limits: .init( + bytes: 100, + depth: 16, + nodes: 256, + animatedNodes: 32, + modifiersPerNode: 32, + modifiers: 1024, + ), ) } } diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift index 65097bde9..5c52ca972 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift @@ -2,6 +2,48 @@ import Testing import ViewDocumentCore struct ViewDocumentValidatorTests { + @Test func boundsModifierNestingAndTotalWork() throws { + var root = documentNode( + "root", + content: .text(""), + modifiers: Array(repeating: .opacity(1), count: 32), + ) + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + root.modifiers.append(.opacity(1)) + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + } + let child = documentNode("child", content: .text(""), modifiers: [.opacity(1)]) + root = documentNode( + "root", + content: .group(axis: .overlay, alignment: .center, spacing: 0, children: [child]), + modifiers: [.opacity(1)], + ) + var limits = ViewDocumentLimits.standard + limits.modifiers = 2 + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: limits, + ) + limits.modifiers = 1 + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: limits, + ) + } + } + @Test func rejectsUnknownOperationsWithTheirPath() { let json = #"{"schemaVersion":1,"root":{"id":"bad","content":{"button":{}},"modifiers":[],"accessibility":{"automatic":{}}}}"# let error = #expect(throws: ViewDocumentValidationError.self) { @@ -46,7 +88,14 @@ struct ViewDocumentValidatorTests { curve: .linear, repetition: .forever(autoreverses: true), ) - let limits = ViewDocumentLimits(bytes: 10000, depth: 16, nodes: 256, animatedNodes: 0) + let limits = ViewDocumentLimits( + bytes: 10000, + depth: 16, + nodes: 256, + animatedNodes: 0, + modifiersPerNode: 32, + modifiers: 1024, + ) #expect(throws: ViewDocumentValidationError.self) { try ValidatedViewDocument( document: .init(schemaVersion: 1, root: documentNode( From 3701fd3973759ae69de3a1c544a7beca1b43703e Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 29 Oct 2026 21:08:11 -0400 Subject: [PATCH 3/5] Bound compounded ViewDocument transforms and geometry Reject cumulative scale and authored lengths that can produce blank native rendering even when each modifier is valid. Include ancestor transforms, animation poses, padding, frames, group spacing, and effect extents. Strengthen the dependency mutation tests with exact diagnostics and valid neighboring imports. Document the conservative input-budget contract. Validation: full ./test --all --review passed, including 21 Core tests, four renderer tests, and all 29 architecture tests plus lint. --- .bumper/Tests/WhereArchitectureTests.swift | 26 ++- Shared/ViewDocument/README.md | 2 + .../ViewDocument/ViewDocumentCore/AGENTS.md | 2 +- .../ViewDocument/ViewDocumentCore/README.md | 4 + .../Sources/ValidatedViewDocument.swift | 2 +- .../Sources/ViewDocumentValidator.swift | 138 ++++++++++++--- .../Tests/ViewDocumentValidatorTests.swift | 159 ++++++++++++++++++ 7 files changed, 301 insertions(+), 32 deletions(-) diff --git a/.bumper/Tests/WhereArchitectureTests.swift b/.bumper/Tests/WhereArchitectureTests.swift index 15c5a29f6..207ddb39d 100644 --- a/.bumper/Tests/WhereArchitectureTests.swift +++ b/.bumper/Tests/WhereArchitectureTests.swift @@ -205,23 +205,41 @@ func `Where consumers can use InstallerKit`() throws { @Test func `ViewDocument rejects consumer dependencies`() throws { for component in [WhereComponent.viewDocumentCore, .viewDocumentUI] { + let path: RelativeFilePath = component == .viewDocumentCore ? + "Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift" : + "Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentView.swift" + let allowed = try bumper.evaluate(RepositoryInput( + architecture: bumper.architecture, + files: [SourceInput( + path: path, + component: ComponentID(component.rawValue), + source: component == .viewDocumentCore ? "import Foundation" : + "import Foundation\nimport SwiftUI\nimport UIKit\nimport ViewDocumentCore", + )], + )) + #expect(allowed.violations.isEmpty) for source in [ "import WhereUI", "import BroadwayUI", "import BroadwayCore", "import SnapshotKit", + "import SnapshotKitTesting", ] { let report = try bumper.evaluate(RepositoryInput( architecture: bumper.architecture, files: [SourceInput( - path: component == .viewDocumentCore ? - "Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift" : - "Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentView.swift", + path: path, component: ComponentID(component.rawValue), source: source, )], )) - #expect(!report.violations.isEmpty) + let violation = try #require(report.violations.first) + #expect(report.violations.count == 1) + let expected: RuleID = source == "import WhereUI" ? .componentBoundary : + .forbiddenImport + #expect(violation.rule.id == expected) + #expect(violation.rule.severity == .error) + #expect(violation.path == path) } } } diff --git a/Shared/ViewDocument/README.md b/Shared/ViewDocument/README.md index 42578a0e6..1cc9c914d 100644 --- a/Shared/ViewDocument/README.md +++ b/Shared/ViewDocument/README.md @@ -38,3 +38,5 @@ Assets are prepared SwiftUI images keyed by typed identities. Hosts load package Animations sample elapsed seconds. Curves use linear, quadratic ease-in/out, or cosine ease-in-out interpolation. Autoreversing repetition counts complete forward-and-back cycles. Each animation has a separate static pose. Inactive views render that pose and restart on activation. Finite animations stop requesting frames after their final cycle. Validation checks versions, ranges, resource availability, identity uniqueness, and complexity. Standard limits are 1 MiB, depth 16, 256 nodes, and 32 animated nodes. Validation errors carry a document path. Unknown enum operations fail decoding; additive object fields follow Codable's forward-compatible decoding behavior. + +Validation also bounds cumulative scale amplification across modifiers, ancestor nodes, and animation poses. Individually valid scales can otherwise combine into transforms that native rendering cannot display. diff --git a/Shared/ViewDocument/ViewDocumentCore/AGENTS.md b/Shared/ViewDocument/ViewDocumentCore/AGENTS.md index 33456ee15..ec6633657 100644 --- a/Shared/ViewDocument/ViewDocumentCore/AGENTS.md +++ b/Shared/ViewDocument/ViewDocumentCore/AGENTS.md @@ -4,4 +4,4 @@ ViewDocumentCore follows the [group contract](../AGENTS.md) and [repository rule Keep source tests one-to-one. Run `./test ViewDocumentCoreTests`. -Validate modifier nesting and aggregate modifier counts alongside tree depth before rendering wire input. +Validate modifier nesting, aggregate modifier counts, cumulative scale amplification, and amplified authored geometry before rendering wire input. Include every animation pose in the scale budget. Keep authored geometry costs separate from host layout measurement. diff --git a/Shared/ViewDocument/ViewDocumentCore/README.md b/Shared/ViewDocument/ViewDocumentCore/README.md index a17a4cd2b..6b7337c84 100644 --- a/Shared/ViewDocument/ViewDocumentCore/README.md +++ b/Shared/ViewDocument/ViewDocumentCore/README.md @@ -3,3 +3,7 @@ See the [ViewDocument guide](../README.md) for composition, validation, and the wire format. This target is declared in the root Package.swift. Validation also bounds modifier nesting and total modifier work. Standard limits allow 32 modifiers per node and 1,024 per document. + +Scale amplification is limited to 1,000 along each tree path, including all animation poses. Smaller scales do not restore this conservative budget. Sibling branches have separate scale budgets. + +Authored frame, padding, spacing, offset, and effect lengths share a 100-million-point budget after amplification. This rejects combinations that exceed native rendering precision. It is a conservative input budget, not a measurement of the final SwiftUI layout. Hosts still own measured layout and font metrics. diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift index 63bda7dc4..7d7604f3a 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ValidatedViewDocument.swift @@ -22,7 +22,7 @@ public struct ValidatedViewDocument: Hashable, Sendable { ) } var validator = ViewDocumentValidator(resources: resources, limits: limits) - try validator.visit(document.root, path: "$.root", depth: 1) + try validator.visit(document.root, path: "$.root", depth: 1, inheritedScaleAmplification: 1) guard try JSONEncoder().encode(document).count <= limits.bytes else { throw ViewDocumentValidationError(path: "$", reason: "Document exceeds byte limit") } diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift index 78aa72931..8a4e668e8 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift @@ -1,15 +1,25 @@ import Foundation struct ViewDocumentValidator { + // Bound compounded transforms and authored lengths before native rendering loses precision. + // Scale-down operations cannot refund these conservative budgets. + private static let maximumScaleAmplification = 1000.0 + private static let maximumAmplifiedGeometry = 100_000_000.0 let resources: ViewDocumentResources let limits: ViewDocumentLimits var identities: Set = [] var animations = 0 var modifiers = 0 + var amplifiedGeometry = 0.0 var referencedImages: Set = [] var referencedSymbols: Set = [] - mutating func visit(_ node: ViewDocument.Node, path: String, depth: Int) throws { + mutating func visit( + _ node: ViewDocument.Node, + path: String, + depth: Int, + inheritedScaleAmplification: Double, + ) throws { try require(depth <= limits.depth, path, "Tree exceeds depth limit") try require(!node.id.rawValue.isEmpty, path + ".id", "Empty node identity") try require(identities.insert(node.id).inserted, path + ".id", "Duplicate node identity") @@ -25,32 +35,17 @@ struct ViewDocumentValidator { "Tree exceeds modifier limit", ) modifiers += node.modifiers.count - switch node.content { - case .text: break - case let .image(asset): - try require( - resources.images.contains(asset), - path, - "Missing image asset", - ) - referencedImages.insert(asset) - case let .symbol(symbol): - try require( - resources.symbols.contains(symbol), - path, - "Missing registered symbol", - ) - referencedSymbols.insert(symbol) - case let .shape(value): try shape(value, path) - case let .spacer(minimum): try number(minimum, path, range: 0 ... 100_000) - case let .group(_, _, spacing, children): - try number(spacing, path + ".spacing", range: 0 ... 100_000) - for (index, child) in children.enumerated() { - try visit(child, path: path + ".children[\(index)]", depth: depth + 1) - } - } + var scaleAmplification = inheritedScaleAmplification for (index, modifier) in node.modifiers.enumerated() { - try validate(modifier, path + ".modifiers[\(index)]") + let modifierPath = path + ".modifiers[\(index)]" + try validate(modifier, modifierPath) + if case let .scale(x, y) = modifier { + scaleAmplification = try amplify( + scaleAmplification, + by: max(abs(x), abs(y)), + path: modifierPath, + ) + } } if let animation = node.animation { animations += 1 @@ -58,6 +53,20 @@ struct ViewDocumentValidator { try pose(animation.from, path) try pose(animation.to, path) try pose(animation.staticPose, path) + let animationScale = [animation.from, animation.to, animation.staticPose] + .reduce(1.0) { max($0, abs($1.scaleX), abs($1.scaleY)) } + scaleAmplification = try amplify( + scaleAmplification, + by: animationScale, + path: path + ".animation", + ) + let offset = [animation.from, animation.to, animation.staticPose] + .reduce(0.0) { max($0, abs($1.offsetX), abs($1.offsetY)) } + try includeGeometry( + offset, + amplification: scaleAmplification, + path: path + ".animation", + ) try number(animation.duration, path + ".animation.duration", range: 0.1 ... 3600) try number(animation.delay, path + ".animation.delay", range: 0 ... 3600) switch animation.repetition { @@ -69,6 +78,83 @@ struct ViewDocumentValidator { ) } } + for (index, modifier) in node.modifiers.enumerated() { + try includeGeometry( + geometryCost(modifier), + amplification: scaleAmplification, + path: path + ".modifiers[\(index)]", + ) + } + switch node.content { + case .text: break + case let .image(asset): + try require(resources.images.contains(asset), path, "Missing image asset") + referencedImages.insert(asset) + case let .symbol(symbol): + try require(resources.symbols.contains(symbol), path, "Missing registered symbol") + referencedSymbols.insert(symbol) + case let .shape(value): try shape(value, path) + case let .spacer(minimum): + try number(minimum, path, range: 0 ... 100_000) + try includeGeometry(minimum, amplification: scaleAmplification, path: path) + case let .group(_, _, spacing, children): + try number(spacing, path + ".spacing", range: 0 ... 100_000) + try includeGeometry( + spacing * Double(max(0, children.count - 1)), + amplification: scaleAmplification, + path: path + ".spacing", + ) + for (index, child) in children.enumerated() { + try visit( + child, + path: path + ".children[\(index)]", + depth: depth + 1, + inheritedScaleAmplification: scaleAmplification, + ) + } + } + } + + private func amplify(_ inherited: Double, by scale: Double, path: String) throws -> Double { + let amplified = inherited * max(1, scale) + try require( + amplified <= Self.maximumScaleAmplification, + path, + "Combined scale exceeds the supported amplification", + ) + return amplified + } + + private mutating func includeGeometry( + _ points: Double, + amplification: Double, + path: String, + ) throws { + let cost = points * amplification + try require( + cost <= Self.maximumAmplifiedGeometry - amplifiedGeometry, + path, + "Combined authored geometry exceeds the rendering budget", + ) + amplifiedGeometry += cost + } + + /// Count authored extents conservatively; the host still owns measured layout and font metrics. + private func geometryCost(_ modifier: ViewModifierDescriptor) -> Double { + func points(_ dimension: ViewDimension?) -> Double { + switch dimension { + case let .points(value), let .scaled(value, _): value + case nil: 0 + } + } + return switch modifier { + case let .frame(width, height, _): max(points(width), points(height)) + case let .padding(value): 2 * points(value) + case let .offset(x, y): max(abs(x), abs(y)) + case let .shadow(_, radius, x, y): 2 * radius + max(abs(x), abs(y)) + case let .border(_, _, width): 2 * width + case .font, .foreground, .scale, .rotation, .opacity, .clip, .background, .blend: 0 + } } func validate(_ modifier: ViewModifierDescriptor, _ path: String) throws { diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift index 5c52ca972..535d438bb 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewDocumentValidatorTests.swift @@ -1,7 +1,166 @@ +import Foundation import Testing import ViewDocumentCore struct ViewDocumentValidatorTests { + @Test func amplifiedPaddingCannotExceedTheGeometryBudget() throws { + var modifiers: [ViewModifierDescriptor] = [ + .frame(width: .points(100_000), height: .points(100_000), alignment: .center), + ] + modifiers += Array(repeating: .padding(.points(100_000)), count: 5) + modifiers += [ + .background(shape: .rectangle, paint: .color(.semantic(.primary))), + .scale(x: 100, y: 100), + .scale(x: 10, y: 10), + ] + let document = ViewDocument( + schemaVersion: 1, + root: documentNode("padded", content: .shape(.rectangle), modifiers: modifiers), + ) + let error = #expect(throws: ViewDocumentValidationError.self) { + _ = try ValidatedViewDocument.decode( + JSONEncoder().encode(document), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.reason == "Combined authored geometry exceeds the rendering budget") + } + + @Test func amplifiedChildFramesShareTheGeometryBudget() throws { + let children = (0 ..< 20).map { index in + documentNode("child-\(index)", content: .shape(.rectangle), modifiers: [ + .frame(width: .points(100_000), height: .points(100_000), alignment: .center), + ]) + } + var root = documentNode( + "root", + content: .group(axis: .horizontal, alignment: .center, spacing: 0, children: children), + ) + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + root.modifiers = [.scale(x: 100, y: 100), .scale(x: 10, y: 10)] + let error = #expect(throws: ViewDocumentValidationError.self) { + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.path == "$.root.children[1].modifiers[0]") + } + + @Test func rejectsRepeatedScalesBeforeNativeRendering() throws { + // Five individually valid scales make a clipped 2-point shape disappear in SwiftUI. + let document = ViewDocument(schemaVersion: 1, root: documentNode( + "shape", + content: .shape(.rectangle), + modifiers: [.frame(width: .points(2), height: .points(2), alignment: .center)] + + Array(repeating: .scale(x: 100, y: 100), count: 5), + )) + let error = #expect(throws: ViewDocumentValidationError.self) { + _ = try ValidatedViewDocument( + document: document, + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.reason == "Combined scale exceeds the supported amplification") + #expect(throws: ViewDocumentValidationError.self) { + _ = try ValidatedViewDocument.decode( + JSONEncoder().encode(document), + resources: noDocumentResources, + limits: .standard, + ) + } + } + + @Test func scaleBudgetFollowsAncestorsAndDoesNotCombineSiblings() throws { + let first = documentNode( + "first", + content: .shape(.circle), + modifiers: [.scale(x: 100, y: 100)], + ) + let second = documentNode( + "second", + content: .shape(.circle), + modifiers: [.scale(x: 100, y: 100)], + ) + var root = documentNode( + "root", + content: .group( + axis: .overlay, + alignment: .center, + spacing: 0, + children: [first, second], + ), + ) + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + root.modifiers = [.scale(x: 100, y: 100)] + let error = #expect(throws: ViewDocumentValidationError.self) { + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.path == "$.root.children[0].modifiers[0]") + } + + @Test func everyAnimationPoseContributesToScaleBudget() throws { + let poses: [WritableKeyPath] = [ + \.from, + \.to, + \.staticPose, + ] + for pose in poses { + var animation = ViewAnimationDescriptor( + from: .identity, + to: .identity, + staticPose: .identity, + duration: 1, + delay: 0, + curve: .linear, + repetition: .once, + ) + animation[keyPath: pose].scaleX = 100 + let root = documentNode( + "root", + content: .shape(.circle), + modifiers: [.scale(x: 20, y: 20)], + animation: animation, + ) + let error = #expect(throws: ViewDocumentValidationError.self) { + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + } + #expect(error?.path == "$.root.animation") + } + } + + @Test func moderateScaleAndScaleDownRemainValid() throws { + let root = documentNode("root", content: .shape(.circle), modifiers: [ + .scale(x: 10, y: -10), + .scale(x: 0.1, y: 0.1), + .scale(x: 10, y: 10), + ]) + _ = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: root), + resources: noDocumentResources, + limits: .standard, + ) + } + @Test func boundsModifierNestingAndTotalWork() throws { var root = documentNode( "root", From bf24a67bac90ecf8af2b1a1a65ecdfbbaa8e5681 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 29 Oct 2026 21:45:25 -0400 Subject: [PATCH 4/5] Add bounded artwork placement and raster sizing to ViewDocument Keep artwork paint geometry separate from host layout. Add fit, clipped fill, and native tiling with resource and density validation. Split the existing image gate into light/dark and extended entry points without changing reference identities. --- Shared/ViewDocument/README.md | 10 ++ .../ViewDocument/ViewDocumentCore/AGENTS.md | 2 + .../ViewDocument/ViewDocumentCore/README.md | 3 + .../Sources/ViewArtworkPlacement.swift | 151 ++++++++++++++++++ .../Sources/ViewDocument.swift | 1 + .../Sources/ViewDocumentValidator.swift | 11 ++ .../Sources/ViewImageDescriptor.swift | 19 +++ .../Tests/ViewArtworkPlacementTests.swift | 102 ++++++++++++ .../Tests/ViewImageDescriptorTests.swift | 45 ++++++ Shared/ViewDocument/ViewDocumentUI/README.md | 3 + .../ViewDocumentViewSnapshotTests.swift | 15 +- .../Sources/ViewDocumentImage.swift | 22 +++ .../Sources/ViewDocumentNodeView.swift | 4 + .../Tests/ViewDocumentImageTests.swift | 70 ++++++++ 14 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewArtworkPlacement.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Sources/ViewImageDescriptor.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Tests/ViewArtworkPlacementTests.swift create mode 100644 Shared/ViewDocument/ViewDocumentCore/Tests/ViewImageDescriptorTests.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentImage.swift create mode 100644 Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentImageTests.swift diff --git a/Shared/ViewDocument/README.md b/Shared/ViewDocument/README.md index 1cc9c914d..6f9e44909 100644 --- a/Shared/ViewDocument/README.md +++ b/Shared/ViewDocument/README.md @@ -35,6 +35,16 @@ Hosts supply semantic background colors and dimension scales. Fonts preserve sem Assets are prepared SwiftUI images keyed by typed identities. Hosts load packaged PNG/JPEG assets and register approved symbols before rendering. Documents cannot access paths or URLs. +Use `raster(ViewImageDescriptor)` for fit, clipped fill, or native image tiling. The original `image` operation retains its fit behavior. +Tile scale is bounded to prevent excessive texture density. Tiling does not repeat document nodes. + +`ViewArtworkPlacement` describes a host-relative frame and separate paint bounds. Hosts validate placement before rendering. +Sizes have finite point caps. Fixed artwork shrinks uniformly when the host becomes narrower, including its offset. +Leading and trailing anchors, offsets, and outsets follow layout direction. +Interior artwork cannot declare outsets. Exterior artwork can extend up to 128 points per edge. +The host must clip all transforms to the declared paint bounds and keep those bounds outside layout, hit testing, and accessibility. +The host owns surface composition, edge protection, reserved gaps, and viewport clipping. + Animations sample elapsed seconds. Curves use linear, quadratic ease-in/out, or cosine ease-in-out interpolation. Autoreversing repetition counts complete forward-and-back cycles. Each animation has a separate static pose. Inactive views render that pose and restart on activation. Finite animations stop requesting frames after their final cycle. Validation checks versions, ranges, resource availability, identity uniqueness, and complexity. Standard limits are 1 MiB, depth 16, 256 nodes, and 32 animated nodes. Validation errors carry a document path. Unknown enum operations fail decoding; additive object fields follow Codable's forward-compatible decoding behavior. diff --git a/Shared/ViewDocument/ViewDocumentCore/AGENTS.md b/Shared/ViewDocument/ViewDocumentCore/AGENTS.md index ec6633657..4bed823a2 100644 --- a/Shared/ViewDocument/ViewDocumentCore/AGENTS.md +++ b/Shared/ViewDocument/ViewDocumentCore/AGENTS.md @@ -5,3 +5,5 @@ ViewDocumentCore follows the [group contract](../AGENTS.md) and [repository rule Keep source tests one-to-one. Run `./test ViewDocumentCoreTests`. Validate modifier nesting, aggregate modifier counts, cumulative scale amplification, and amplified authored geometry before rendering wire input. Include every animation pose in the scale budget. Keep authored geometry costs separate from host layout measurement. + +Validate artwork placement before hosting it. Keep paint outsets finite and separate from layout bounds. Hosts must clip transformed artwork to those paint bounds. diff --git a/Shared/ViewDocument/ViewDocumentCore/README.md b/Shared/ViewDocument/ViewDocumentCore/README.md index 6b7337c84..29800b560 100644 --- a/Shared/ViewDocument/ViewDocumentCore/README.md +++ b/Shared/ViewDocument/ViewDocumentCore/README.md @@ -7,3 +7,6 @@ Validation also bounds modifier nesting and total modifier work. Standard limits Scale amplification is limited to 1,000 along each tree path, including all animation poses. Smaller scales do not restore this conservative budget. Sibling branches have separate scale budgets. Authored frame, padding, spacing, offset, and effect lengths share a 100-million-point budget after amplification. This rejects combinations that exceed native rendering precision. It is a conservative input budget, not a measurement of the final SwiftUI layout. Hosts still own measured layout and font metrics. + +`ViewArtworkPlacement` validates host-relative artwork independently of the view tree. Call `validate()` before accepting it and `geometry(in:rightToLeft:)` during layout. +Its `Extent` and `Frame` values use portable point coordinates. The host converts them to its rendering framework and enforces paint clipping. diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewArtworkPlacement.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewArtworkPlacement.swift new file mode 100644 index 000000000..2e44c0346 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewArtworkPlacement.swift @@ -0,0 +1,151 @@ +import Foundation + +/// Bounded artwork geometry, independent of the host's layout and interaction bounds. +public struct ViewArtworkPlacement: Codable, Hashable, Sendable { + public var anchor: ViewAlignment + public var size: Size + public var offset: Offset + public var plane: Plane + public var outsets: Outsets + + public init(anchor: ViewAlignment, size: Size, offset: Offset, plane: Plane, outsets: Outsets) { + self.anchor = anchor + self.size = size + self.offset = offset + self.plane = plane + self.outsets = outsets + } + + public enum Plane: String, Codable, Hashable, Sendable { + case interior, exterior + } + + /// Relative sizes are capped in points. Fixed sizes shrink uniformly for narrow hosts. + public enum Size: Codable, Hashable, Sendable { + case fixed(width: Double, height: Double) + case relative(width: Double, height: Double, maximumWidth: Double, maximumHeight: Double) + } + + public struct Offset: Codable, Hashable, Sendable { + public var x: Double + public var y: Double + public init(x: Double, y: Double) { + self.x = x; self.y = y + } + + public static let zero = Self(x: 0, y: 0) + } + + public struct Outsets: Codable, Hashable, Sendable { + public var top: Double + public var leading: Double + public var bottom: Double + public var trailing: Double + public init(top: Double, leading: Double, bottom: Double, trailing: Double) { + self.top = top; self.leading = leading; self.bottom = bottom; self.trailing = trailing + } + + public static let zero = Self(top: 0, leading: 0, bottom: 0, trailing: 0) + } + + public func validate() throws { + func number(_ value: Double, _ field: String, _ range: ClosedRange) throws { + guard value.isFinite, range.contains(value) else { + throw ViewDocumentValidationError( + path: "$.placement." + field, + reason: "Invalid artwork geometry", + ) + } + } + switch size { + case let .fixed(width, height): + try number(width, "width", 1 ... 2048) + try number(height, "height", 1 ... 2048) + case let .relative(width, height, maximumWidth, maximumHeight): + try number(width, "width", 0.001 ... 2) + try number(height, "height", 0.001 ... 2) + try number(maximumWidth, "maximumWidth", 1 ... 2048) + try number(maximumHeight, "maximumHeight", 1 ... 2048) + } + try number(offset.x, "offset.x", -2048 ... 2048) + try number(offset.y, "offset.y", -2048 ... 2048) + for value in [outsets.top, outsets.leading, outsets.bottom, outsets.trailing] { + try number(value, "outsets", 0 ... 128) + } + if plane == .interior, outsets != .zero { + throw ViewDocumentValidationError( + path: "$.placement.outsets", + reason: "Interior artwork cannot overflow", + ) + } + } + + /// The renderer must clip to paintBounds; transforms cannot enlarge that contract. + public func geometry(in host: Extent, rightToLeft: Bool) -> Geometry { + let width = host.width.isFinite ? max(0, Double(host.width)) : 0 + let height = host.height.isFinite ? max(0, Double(host.height)) : 0 + let proposed = switch size { + case let .fixed(width, height): Extent(width: width, height: height) + case let .relative(x, y, maximumWidth, maximumHeight): + Extent(width: min(width * x, maximumWidth), height: min(height * y, maximumHeight)) + } + let scale = proposed.width > 0 ? min(1, width / Double(proposed.width)) : 0 + let artworkWidth = Double(proposed.width) * scale + let artworkHeight = Double(proposed.height) * scale + let horizontal: Double = switch anchor { + case .topLeading, .leading, .bottomLeading: rightToLeft ? 1 : 0 + case .top, .center, .bottom: 0.5 + case .topTrailing, .trailing, .bottomTrailing: rightToLeft ? 0 : 1 + } + let vertical: Double = switch anchor { + case .topLeading, .top, .topTrailing: 0 + case .leading, .center, .trailing: 0.5 + case .bottomLeading, .bottom, .bottomTrailing: 1 + } + let left = rightToLeft ? outsets.trailing : outsets.leading + let right = rightToLeft ? outsets.leading : outsets.trailing + let x = (width - artworkWidth) * horizontal + offset.x * scale * (rightToLeft ? -1 : 1) + let y = (height - artworkHeight) * vertical + offset.y * scale + return Geometry( + frame: Frame( + x: x, + y: y, + width: artworkWidth, + height: artworkHeight, + ), + paintBounds: Frame( + x: -left, + y: -outsets.top, + width: width + left + right, + height: height + outsets.top + outsets.bottom, + ), + ) + } + + public struct Extent: Equatable, Sendable { + public let width: Double + public let height: Double + public init(width: Double, height: Double) { + self.width = width; self.height = height + } + } + + public struct Frame: Equatable, Sendable { + public let x: Double + public let y: Double + public let width: Double + public let height: Double + public var size: Extent { + .init(width: width, height: height) + } + + public init(x: Double, y: Double, width: Double, height: Double) { + self.x = x; self.y = y; self.width = width; self.height = height + } + } + + public struct Geometry: Equatable, Sendable { + public let frame: Frame + public let paintBounds: Frame + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift index 1209a97ea..22660d1d3 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocument.swift @@ -42,6 +42,7 @@ public struct ViewDocument: Codable, Hashable, Sendable { public indirect enum Content: Codable, Hashable, Sendable { case text(String) case image(ViewAssetID) + case raster(ViewImageDescriptor) case symbol(ViewSymbolID) case shape(ViewShapeDescriptor) case spacer(minimum: Double) diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift index 8a4e668e8..bdd500dd5 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift @@ -90,6 +90,17 @@ struct ViewDocumentValidator { case let .image(asset): try require(resources.images.contains(asset), path, "Missing image asset") referencedImages.insert(asset) + case let .raster(image): + try require(resources.images.contains(image.asset), path, "Missing image asset") + referencedImages.insert(image.asset) + switch image.sizing { + case .fit, .fill: break + case let .tile(scale): try number( + scale, + path + ".sizing.scale", + range: 0.1 ... 10, + ) + } case let .symbol(symbol): try require(resources.symbols.contains(symbol), path, "Missing registered symbol") referencedSymbols.insert(symbol) diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewImageDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewImageDescriptor.swift new file mode 100644 index 000000000..c3a9ee650 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewImageDescriptor.swift @@ -0,0 +1,19 @@ +import Foundation + +/// An approved image and its behavior within the proposed layout bounds. +public struct ViewImageDescriptor: Codable, Hashable, Sendable { + public var asset: ViewAssetID + public var sizing: Sizing + + public init(asset: ViewAssetID, sizing: Sizing) { + self.asset = asset + self.sizing = sizing + } + + public enum Sizing: Codable, Hashable, Sendable { + case fit + case fill + /// Scale relative to the image's intrinsic point size. Tiles never create document nodes. + case tile(scale: Double) + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewArtworkPlacementTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewArtworkPlacementTests.swift new file mode 100644 index 000000000..1d325f198 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewArtworkPlacementTests.swift @@ -0,0 +1,102 @@ +import Foundation +import Testing +import ViewDocumentCore + +struct ViewArtworkPlacementTests { + @Test func separatesOverflowFromHostLayoutAndMirrorsDirection() throws { + let placement = ViewArtworkPlacement( + anchor: .bottomTrailing, + size: .fixed(width: 80, height: 60), + offset: .init(x: 12, y: 24), + plane: .exterior, + outsets: .init(top: 0, leading: 4, bottom: 24, trailing: 12), + ) + try placement.validate() + let host = ViewArtworkPlacement.Extent(width: 300, height: 160) + let leftToRight = placement.geometry(in: host, rightToLeft: false) + let rightToLeft = placement.geometry(in: host, rightToLeft: true) + #expect(leftToRight.frame == ViewArtworkPlacement.Frame( + x: 232, + y: 124, + width: 80, + height: 60, + )) + #expect(leftToRight.paintBounds == ViewArtworkPlacement.Frame( + x: -4, + y: 0, + width: 316, + height: 184, + )) + #expect(rightToLeft.frame == ViewArtworkPlacement.Frame( + x: -12, + y: 124, + width: 80, + height: 60, + )) + #expect(rightToLeft.paintBounds == ViewArtworkPlacement.Frame( + x: -12, + y: 0, + width: 316, + height: 184, + )) + } + + @Test func narrowHostsScaleArtworkAndOffsetTogether() throws { + let placement = ViewArtworkPlacement( + anchor: .bottom, + size: .fixed(width: 200, height: 100), + offset: .init(x: 0, y: 20), + plane: .exterior, + outsets: .init(top: 0, leading: 0, bottom: 20, trailing: 0), + ) + try placement.validate() + #expect(placement.geometry( + in: ViewArtworkPlacement.Extent(width: 100, height: 80), + rightToLeft: false, + ).frame + == ViewArtworkPlacement.Frame(x: 0, y: 40, width: 100, height: 50)) + } + + @Test func relativeSizingHasFinitePointCaps() throws { + let placement = ViewArtworkPlacement( + anchor: .center, + size: .relative(width: 1, height: 0.5, maximumWidth: 400, maximumHeight: 300), + offset: .zero, + plane: .interior, + outsets: .zero, + ) + try placement.validate() + let geometry = placement.geometry( + in: ViewArtworkPlacement.Extent(width: 2000, height: 2000), + rightToLeft: false, + ) + #expect(geometry.frame.size == ViewArtworkPlacement.Extent(width: 400, height: 300)) + #expect(try JSONDecoder().decode( + ViewArtworkPlacement.self, + from: JSONEncoder().encode(placement), + ) == placement) + } + + @Test(arguments: [Double.nan, .infinity, -1, 129]) + func rejectsUnboundedOutsets(value: Double) { + let placement = ViewArtworkPlacement( + anchor: .center, + size: .fixed(width: 80, height: 60), + offset: .zero, + plane: .exterior, + outsets: .init(top: value, leading: 0, bottom: 0, trailing: 0), + ) + #expect(throws: ViewDocumentValidationError.self) { try placement.validate() } + } + + @Test func interiorCannotClaimOverflow() { + let placement = ViewArtworkPlacement( + anchor: .center, + size: .fixed(width: 80, height: 60), + offset: .zero, + plane: .interior, + outsets: .init(top: 1, leading: 0, bottom: 0, trailing: 0), + ) + #expect(throws: ViewDocumentValidationError.self) { try placement.validate() } + } +} diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewImageDescriptorTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewImageDescriptorTests.swift new file mode 100644 index 000000000..a728e2344 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewImageDescriptorTests.swift @@ -0,0 +1,45 @@ +import Foundation +import Testing +import ViewDocumentCore + +struct ViewImageDescriptorTests { + @Test(arguments: [ViewImageDescriptor.Sizing.fit, .fill, .tile(scale: 0.5)]) + func checksResourcesAndRoundTrips(sizing: ViewImageDescriptor.Sizing) throws { + let asset = ViewAssetID(rawValue: "illustration") + let document = ViewDocument(schemaVersion: 1, root: documentNode( + "art", + content: .raster(.init(asset: asset, sizing: sizing)), + )) + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: document, + resources: noDocumentResources, + limits: .standard, + ) + } + let resources = ViewDocumentResources(images: [asset], symbols: []) + let decoded = try ValidatedViewDocument.decode( + JSONEncoder().encode(document), + resources: resources, + limits: .standard, + ) + #expect(decoded.document == document) + #expect(decoded.resources.images == [asset]) + } + + @Test(arguments: [Double.nan, .infinity, 0, 0.01, 11]) + func rejectsExtremeTileDensity(scale: Double) { + let asset = ViewAssetID(rawValue: "illustration") + let document = ViewDocument(schemaVersion: 1, root: documentNode( + "art", + content: .raster(.init(asset: asset, sizing: .tile(scale: scale))), + )) + #expect(throws: ViewDocumentValidationError.self) { + try ValidatedViewDocument( + document: document, + resources: .init(images: [asset], symbols: []), + limits: .standard, + ) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/README.md b/Shared/ViewDocument/ViewDocumentUI/README.md index 9fdb78621..86e64e501 100644 --- a/Shared/ViewDocument/ViewDocumentUI/README.md +++ b/Shared/ViewDocument/ViewDocumentUI/README.md @@ -1,3 +1,6 @@ # ViewDocumentUI See the [ViewDocument guide](../README.md) for composition, validation, and the wire format. This target is declared in the root Package.swift. + +Raster images support fit, clipped fill, and tiling. Hosts provide decoded assets; rendering never performs resource loading. +Select `standaloneContentLightDark()` for the fast image gate. `standaloneContentExtended()` retains accessibility captures with the original reference names. diff --git a/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/ViewDocumentViewSnapshotTests.swift b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/ViewDocumentViewSnapshotTests.swift index 188200a0f..13195cc18 100644 --- a/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/ViewDocumentViewSnapshotTests.swift +++ b/Shared/ViewDocument/ViewDocumentUI/SnapshotTests/ViewDocumentViewSnapshotTests.swift @@ -7,7 +7,17 @@ import ViewDocumentUI @MainActor struct ViewDocumentViewSnapshotTests { - @Test func standaloneContent() async throws { + @Test func standaloneContentLightDark() async throws { + try await standaloneContent(snapshotTypes: [.standard]) + } + + @Test func standaloneContentExtended() async throws { + try await standaloneContent(snapshotTypes: [.accessibility]) + } + + private func standaloneContent(snapshotTypes: [SnapshotConfiguration + .SnapshotType]) async throws + { let root = ViewDocument.Node( id: .init(rawValue: "root"), content: .group(axis: .vertical, alignment: .center, spacing: 20, children: [ @@ -78,8 +88,9 @@ struct ViewDocumentViewSnapshotTests { configurations: SnapshotConfiguration.combinations( devices: [.iPhone], colorSchemes: [.light, .dark], - snapshotTypes: [.standard, .accessibility], + snapshotTypes: snapshotTypes, ), + testName: "standaloneContent()", ) } } diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentImage.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentImage.swift new file mode 100644 index 000000000..c926772f0 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentImage.swift @@ -0,0 +1,22 @@ +import SwiftUI +import ViewDocumentCore + +/// Fill clips at the proposed image bounds; tiling is one native paint operation. +struct ViewDocumentImage: View { + let image: Image + let sizing: ViewImageDescriptor.Sizing + + var body: some View { + switch sizing { + case .fit: image.resizable().scaledToFit() + case .fill: + GeometryReader { geometry in + image.resizable().scaledToFill() + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + } + case let .tile(scale): + Rectangle().fill(ImagePaint(image: image, scale: scale)) + } + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentNodeView.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentNodeView.swift index b1c5e1a3c..3aa56210a 100644 --- a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentNodeView.swift +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentNodeView.swift @@ -24,6 +24,10 @@ struct ViewDocumentNodeView: View { switch node.content { case let .text(text): AnyView(Text(verbatim: text)) case let .image(asset): AnyView(assets.images[asset]!.resizable().scaledToFit()) + case let .raster(descriptor): AnyView(ViewDocumentImage( + image: assets.images[descriptor.asset]!, + sizing: descriptor.sizing, + )) case let .symbol(symbol): AnyView(assets.symbols[symbol]!) case let .shape(shape): AnyView(ViewDocumentShape(descriptor: shape)) case let .spacer(minimum): AnyView(Spacer(minLength: minimum)) diff --git a/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentImageTests.swift b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentImageTests.swift new file mode 100644 index 000000000..31a836cf6 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentImageTests.swift @@ -0,0 +1,70 @@ +import SwiftUI +import Testing +import ViewDocumentCore +@testable import ViewDocumentUI + +@MainActor +struct ViewDocumentImageTests { + @Test(arguments: [ViewImageDescriptor.Sizing.fit, .fill, .tile(scale: 0.5)]) + func imageSizingPreservesHostBounds(sizing: ViewImageDescriptor.Sizing) throws { + let source = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 20)).image { context in + UIColor.orange.setFill() + context.fill(CGRect(x: 0, y: 0, width: 40, height: 20)) + } + let renderer = ImageRenderer(content: ViewDocumentImage( + image: Image(uiImage: source), + sizing: sizing, + ) + .frame(width: 80, height: 80)) + let image = try #require(renderer.uiImage) + #expect(image.size == CGSize(width: 80, height: 80)) + } + + @Test func imageModesHaveDistinctPixelsAndFillStaysClipped() throws { + let format = UIGraphicsImageRendererFormat() + format.scale = 1 + let source = UIGraphicsImageRenderer(size: CGSize(width: 40, height: 20), format: format) + .image { context in + UIColor.red.setFill() + context.fill(CGRect(x: 0, y: 0, width: 20, height: 20)) + UIColor.blue.setFill() + context.fill(CGRect(x: 20, y: 0, width: 20, height: 20)) + } + func pixels(_ sizing: ViewImageDescriptor.Sizing) throws -> [UInt8] { + let view = ViewDocumentImage(image: Image(uiImage: source), sizing: sizing) + .frame(width: 80, height: 80).padding(10) + let renderer = ImageRenderer(content: view) + renderer.scale = 1 + let image = try #require(renderer.cgImage) + var bytes = [UInt8](repeating: 0, count: 100 * 100 * 4) + try bytes.withUnsafeMutableBytes { buffer in + let context = try #require(CGContext( + data: buffer.baseAddress, + width: 100, + height: 100, + bitsPerComponent: 8, + bytesPerRow: 400, + space: CGColorSpaceCreateDeviceRGB(), + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue, + )) + context.draw(image, in: CGRect(x: 0, y: 0, width: 100, height: 100)) + } + return bytes + } + let fit = try pixels(.fit) + let fill = try pixels(.fill) + let tile = try pixels(.tile(scale: 1)) + func channel(_ pixels: [UInt8], x: Int, y: Int, channel: Int) -> UInt8 { + pixels[(y * 100 + x) * 4 + channel] + } + // Top inset inside the host: fit is transparent, fill occupies the full height. + #expect(channel(fit, x: 20, y: 20, channel: 3) == 0) + #expect(channel(fill, x: 20, y: 20, channel: 3) == 255) + // The second tile starts red again; the cropped fill remains blue on the right. + #expect(channel(tile, x: 60, y: 50, channel: 0) == 255) + #expect(channel(fill, x: 60, y: 50, channel: 2) == 255) + // Cropped source pixels never escape into the surrounding padding. + #expect(channel(fill, x: 5, y: 50, channel: 3) == 0) + #expect(channel(fill, x: 95, y: 50, channel: 3) == 0) + } +} From 59dc0d2dd94f8b578cfd9f1ef4ab42bc39bfbeec Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 30 Oct 2026 01:28:53 -0400 Subject: [PATCH 5/5] Preserve native paint semantics and bound artwork placement Keep portable colors symbolic, including opacity and native palette roles. Supply background elevation through the host appearance contract and bound host-relative artwork geometry before rendering. Validation: ViewDocumentCore 31 tests and ViewDocumentUI 9 tests passed in the full integration run. Standalone light/dark and accessibility captures, formatting, and architecture checks passed. --- Shared/ViewDocument/README.md | 5 ++ .../ViewDocument/ViewDocumentCore/AGENTS.md | 3 + .../ViewDocument/ViewDocumentCore/README.md | 2 + .../Sources/ViewArtworkPlacement.swift | 31 ++++++++-- .../Sources/ViewColorDescriptor.swift | 9 ++- .../Sources/ViewDocumentValidator.swift | 10 +++- .../Tests/ViewArtworkPlacementTests.swift | 28 +++++++++ .../Tests/ViewColorDescriptorTests.swift | 44 ++++++++++++++ .../Sources/ViewDocumentAppearance.swift | 58 ++++++++++++++++++- .../Tests/ViewDocumentAppearanceTests.swift | 37 ++++++++++++ 10 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 Shared/ViewDocument/ViewDocumentCore/Tests/ViewColorDescriptorTests.swift diff --git a/Shared/ViewDocument/README.md b/Shared/ViewDocument/README.md index 6f9e44909..febdcef5a 100644 --- a/Shared/ViewDocument/README.md +++ b/Shared/ViewDocument/README.md @@ -33,6 +33,10 @@ General content preserves text accessibility and supports image labels and group Hosts supply semantic background colors and dimension scales. Fonts preserve semantic Dynamic Type behavior. An omitted font weight preserves the system weight of that text style. Use fixed sizes only for intentional fixed artwork. +Native palette references retain SwiftUI's light, dark, and Increased Contrast colors. Use them for system color meanings instead of captured RGB samples. +Hosts can inject separate background levels through `ViewDocumentAppearance.Backgrounds`. +The single-background initializer uses its supplied color for every level. + Assets are prepared SwiftUI images keyed by typed identities. Hosts load packaged PNG/JPEG assets and register approved symbols before rendering. Documents cannot access paths or URLs. Use `raster(ViewImageDescriptor)` for fit, clipped fill, or native image tiling. The original `image` operation retains its fit behavior. @@ -41,6 +45,7 @@ Tile scale is bounded to prevent excessive texture density. Tiling does not repe `ViewArtworkPlacement` describes a host-relative frame and separate paint bounds. Hosts validate placement before rendering. Sizes have finite point caps. Fixed artwork shrinks uniformly when the host becomes narrower, including its offset. Leading and trailing anchors, offsets, and outsets follow layout direction. +A host can supply smaller maximum outsets. Geometry then scales artwork, offsets, and paint bounds together to fit those reserved gaps. Interior artwork cannot declare outsets. Exterior artwork can extend up to 128 points per edge. The host must clip all transforms to the declared paint bounds and keep those bounds outside layout, hit testing, and accessibility. The host owns surface composition, edge protection, reserved gaps, and viewport clipping. diff --git a/Shared/ViewDocument/ViewDocumentCore/AGENTS.md b/Shared/ViewDocument/ViewDocumentCore/AGENTS.md index 4bed823a2..8644c8e02 100644 --- a/Shared/ViewDocument/ViewDocumentCore/AGENTS.md +++ b/Shared/ViewDocument/ViewDocumentCore/AGENTS.md @@ -7,3 +7,6 @@ Keep source tests one-to-one. Run `./test ViewDocumentCoreTests`. Validate modifier nesting, aggregate modifier counts, cumulative scale amplification, and amplified authored geometry before rendering wire input. Include every animation pose in the scale budget. Keep authored geometry costs separate from host layout measurement. Validate artwork placement before hosting it. Keep paint outsets finite and separate from layout bounds. Hosts must clip transformed artwork to those paint bounds. + +Keep semantic opacity and color blends finite and depth-bounded. Do not resolve them to captured RGB values. Guard: `ViewColorDescriptorTests`. +Keep native palette colors symbolic and background levels host-supplied. Guard: `ViewDocumentAppearanceTests` in ViewDocumentUI. diff --git a/Shared/ViewDocument/ViewDocumentCore/README.md b/Shared/ViewDocument/ViewDocumentCore/README.md index 29800b560..f2794fc5d 100644 --- a/Shared/ViewDocument/ViewDocumentCore/README.md +++ b/Shared/ViewDocument/ViewDocumentCore/README.md @@ -10,3 +10,5 @@ Authored frame, padding, spacing, offset, and effect lengths share a 100-million `ViewArtworkPlacement` validates host-relative artwork independently of the view tree. Call `validate()` before accepting it and `geometry(in:rightToLeft:)` during layout. Its `Extent` and `Frame` values use portable point coordinates. The host converts them to its rendering framework and enforces paint clipping. + +Portable colors include bounded opacity and perceptual blends. Semantic colors remain semantic through these operations. Color composition has a maximum depth of eight. diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewArtworkPlacement.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewArtworkPlacement.swift index 2e44c0346..383b65839 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewArtworkPlacement.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewArtworkPlacement.swift @@ -82,6 +82,15 @@ public struct ViewArtworkPlacement: Codable, Hashable, Sendable { /// The renderer must clip to paintBounds; transforms cannot enlarge that contract. public func geometry(in host: Extent, rightToLeft: Bool) -> Geometry { + geometry(in: host, rightToLeft: rightToLeft, maximumOutsets: nil) + } + + /// The host can reduce its reserved gutters without reducing native content width. + public func geometry( + in host: Extent, + rightToLeft: Bool, + maximumOutsets: Outsets?, + ) -> Geometry { let width = host.width.isFinite ? max(0, Double(host.width)) : 0 let height = host.height.isFinite ? max(0, Double(host.height)) : 0 let proposed = switch size { @@ -89,7 +98,19 @@ public struct ViewArtworkPlacement: Codable, Hashable, Sendable { case let .relative(x, y, maximumWidth, maximumHeight): Extent(width: min(width * x, maximumWidth), height: min(height * y, maximumHeight)) } - let scale = proposed.width > 0 ? min(1, width / Double(proposed.width)) : 0 + var allowanceScale = 1.0 + if let maximumOutsets { + func constrain(_ requested: Double, _ available: Double) { + guard requested > 0 else { return } + let available = available.isFinite ? max(0, available) : 0 + allowanceScale = min(allowanceScale, available / requested) + } + constrain(outsets.top, maximumOutsets.top) + constrain(outsets.leading, maximumOutsets.leading) + constrain(outsets.bottom, maximumOutsets.bottom) + constrain(outsets.trailing, maximumOutsets.trailing) + } + let scale = proposed.width > 0 ? min(allowanceScale, width / Double(proposed.width)) : 0 let artworkWidth = Double(proposed.width) * scale let artworkHeight = Double(proposed.height) * scale let horizontal: Double = switch anchor { @@ -102,8 +123,8 @@ public struct ViewArtworkPlacement: Codable, Hashable, Sendable { case .leading, .center, .trailing: 0.5 case .bottomLeading, .bottom, .bottomTrailing: 1 } - let left = rightToLeft ? outsets.trailing : outsets.leading - let right = rightToLeft ? outsets.leading : outsets.trailing + let left = (rightToLeft ? outsets.trailing : outsets.leading) * allowanceScale + let right = (rightToLeft ? outsets.leading : outsets.trailing) * allowanceScale let x = (width - artworkWidth) * horizontal + offset.x * scale * (rightToLeft ? -1 : 1) let y = (height - artworkHeight) * vertical + offset.y * scale return Geometry( @@ -115,9 +136,9 @@ public struct ViewArtworkPlacement: Codable, Hashable, Sendable { ), paintBounds: Frame( x: -left, - y: -outsets.top, + y: -outsets.top * allowanceScale, width: width + left + right, - height: height + outsets.top + outsets.bottom, + height: height + (outsets.top + outsets.bottom) * allowanceScale, ), ) } diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewColorDescriptor.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewColorDescriptor.swift index 5b68a81ce..7287f978a 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewColorDescriptor.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewColorDescriptor.swift @@ -1,11 +1,18 @@ import Foundation /// Portable colors. Explicit components use the sRGB color space. -public enum ViewColorDescriptor: Codable, Hashable, Sendable { +public indirect enum ViewColorDescriptor: Codable, Hashable, Sendable { case rgba(red: Double, green: Double, blue: Double, alpha: Double) case semantic(Role) + case opacity(ViewColorDescriptor, Double) + /// A bounded perceptual blend of two portable colors. + case mix(ViewColorDescriptor, ViewColorDescriptor, fraction: Double) public enum Role: String, Codable, Hashable, Sendable { case primary, secondary, accent, background + case secondaryBackground, groupedBackground, secondaryGroupedBackground + /// Native palette colors retain appearance and accessibility adaptation. + case red, orange, yellow, green, mint, teal, cyan, blue, indigo, purple, pink, brown, gray + case black, white, clear } } diff --git a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift index bdd500dd5..67edb8eef 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Sources/ViewDocumentValidator.swift @@ -258,8 +258,16 @@ struct ViewDocumentValidator { } } - func color(_ color: ViewColorDescriptor, _ path: String) throws { + func color(_ color: ViewColorDescriptor, _ path: String, depth: Int = 0) throws { + try require(depth <= 8, path, "Color composition exceeds depth limit") switch color { + case let .opacity(value, alpha): + try self.color(value, path, depth: depth + 1) + try number(alpha, path, range: 0 ... 1) + case let .mix(first, second, fraction): + try self.color(first, path, depth: depth + 1) + try self.color(second, path, depth: depth + 1) + try number(fraction, path, range: 0 ... 1) case .semantic: break case let .rgba(red, green, blue, alpha): for value in [red, green, blue, alpha] { diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewArtworkPlacementTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewArtworkPlacementTests.swift index 1d325f198..3b5e89755 100644 --- a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewArtworkPlacementTests.swift +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewArtworkPlacementTests.swift @@ -3,6 +3,34 @@ import Testing import ViewDocumentCore struct ViewArtworkPlacementTests { + @Test func hostGuttersShrinkArtworkBeforeNativeContent() throws { + let placement = ViewArtworkPlacement( + anchor: .bottomTrailing, + size: .fixed(width: 100, height: 80), + offset: .init(x: 40, y: 20), + plane: .exterior, + outsets: .init(top: 0, leading: 0, bottom: 20, trailing: 40), + ) + try placement.validate() + let geometry = placement.geometry( + in: .init(width: 300, height: 100), + rightToLeft: false, + maximumOutsets: .init(top: 0, leading: 0, bottom: 20, trailing: 10), + ) + #expect(geometry.frame.width == 25) + #expect(geometry.frame.height == 20) + #expect(geometry.frame.x == 285) + #expect(geometry.paintBounds.width == 310) + #expect(geometry.paintBounds.height == 105) + let hidden = placement.geometry( + in: .init(width: 300, height: 100), + rightToLeft: true, + maximumOutsets: .zero, + ) + #expect(hidden.frame.width == 0) + #expect(hidden.paintBounds.width == 300) + } + @Test func separatesOverflowFromHostLayoutAndMirrorsDirection() throws { let placement = ViewArtworkPlacement( anchor: .bottomTrailing, diff --git a/Shared/ViewDocument/ViewDocumentCore/Tests/ViewColorDescriptorTests.swift b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewColorDescriptorTests.swift new file mode 100644 index 000000000..d3e542676 --- /dev/null +++ b/Shared/ViewDocument/ViewDocumentCore/Tests/ViewColorDescriptorTests.swift @@ -0,0 +1,44 @@ +import Foundation +import Testing +import ViewDocumentCore + +struct ViewColorDescriptorTests { + @Test func semanticCompositionsRoundTrip() throws { + let color = ViewColorDescriptor.mix( + .semantic(.primary), + .opacity(.semantic(.accent), 0.7), + fraction: 0.25, + ) + #expect(try JSONDecoder().decode( + ViewColorDescriptor.self, + from: JSONEncoder().encode(color), + ) == color) + _ = try validate(color) + } + + @Test func rejectsUnboundedCompositionsAndInvalidFractions() throws { + var deep = ViewColorDescriptor.semantic(.primary) + for _ in 0 ..< 9 { + deep = .opacity(deep, 0.5) + } + for color in [ + deep, + .opacity(.semantic(.primary), .infinity), + .mix(.semantic(.primary), .semantic(.accent), fraction: -0.1), + ] { + #expect(throws: ViewDocumentValidationError.self) { try validate(color) } + } + } + + private func validate(_ color: ViewColorDescriptor) throws -> ValidatedViewDocument { + try .init( + document: .init(schemaVersion: 1, root: documentNode( + "color", + content: .shape(.rectangle), + modifiers: [.foreground(.color(color))], + )), + resources: .init(images: [], symbols: []), + limits: .standard, + ) + } +} diff --git a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAppearance.swift b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAppearance.swift index f4c0e7746..6c519599d 100644 --- a/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAppearance.swift +++ b/Shared/ViewDocument/ViewDocumentUI/Sources/ViewDocumentAppearance.swift @@ -3,16 +3,51 @@ import ViewDocumentCore /// Host-resolved appearance inputs. Resolution does not consult ambient UIKit traits. public struct ViewDocumentAppearance { - public var background: Color + /// Platform background levels supplied by the host. No platform color API is required here. + public struct Backgrounds: Equatable, Sendable { + public var primary: Color + public var secondary: Color + public var grouped: Color + public var secondaryGrouped: Color + + public init(primary: Color, secondary: Color, grouped: Color, secondaryGrouped: Color) { + self.primary = primary + self.secondary = secondary + self.grouped = grouped + self.secondaryGrouped = secondaryGrouped + } + } + + public var backgrounds: Backgrounds + public var background: Color { + get { backgrounds.primary } + set { backgrounds.primary = newValue } + } + public var dimensionScales: [ViewFontDescriptor.TextStyle: Double] public init(background: Color, dimensionScales: [ViewFontDescriptor.TextStyle: Double]) { - self.background = background + self.init(backgrounds: .init( + primary: background, + secondary: background, + grouped: background, + secondaryGrouped: background, + ), dimensionScales: dimensionScales) + } + + public init(backgrounds: Backgrounds, dimensionScales: [ViewFontDescriptor.TextStyle: Double]) { + self.backgrounds = backgrounds self.dimensionScales = dimensionScales } public func color(_ value: ViewColorDescriptor) -> Color { switch value { + case let .opacity(value, alpha): color(value).opacity(alpha) + case let .mix(first, second, fraction): color(first).mix( + with: color(second), + by: fraction, + in: .perceptual, + ) case let .rgba(red, green, blue, alpha): Color( .sRGB, red: red, @@ -26,6 +61,25 @@ public struct ViewDocumentAppearance { case .secondary: .secondary case .accent: .accentColor case .background: background + case .secondaryBackground: backgrounds.secondary + case .groupedBackground: backgrounds.grouped + case .secondaryGroupedBackground: backgrounds.secondaryGrouped + case .red: .red + case .orange: .orange + case .yellow: .yellow + case .green: .green + case .mint: .mint + case .teal: .teal + case .cyan: .cyan + case .blue: .blue + case .indigo: .indigo + case .purple: .purple + case .pink: .pink + case .brown: .brown + case .gray: .gray + case .black: .black + case .white: .white + case .clear: .clear } } } diff --git a/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentAppearanceTests.swift b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentAppearanceTests.swift index 37441a1dd..f84cd2979 100644 --- a/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentAppearanceTests.swift +++ b/Shared/ViewDocument/ViewDocumentUI/Tests/ViewDocumentAppearanceTests.swift @@ -4,6 +4,43 @@ import ViewDocumentCore import ViewDocumentUI struct ViewDocumentAppearanceTests { + @Test func backgroundLevelsUseHostInputsAndTheLegacyInitializerHasOneFallback() { + let appearance = ViewDocumentAppearance(backgrounds: .init( + primary: .red, + secondary: .green, + grouped: .blue, + secondaryGrouped: .orange, + ), dimensionScales: [:]) + #expect(appearance.color(.semantic(.secondaryBackground)) == Color.green) + #expect(appearance.color(.semantic(.groupedBackground)) == Color.blue) + #expect(appearance.color(.semantic(.secondaryGroupedBackground)) == Color.orange) + let fallback = ViewDocumentAppearance(background: .purple, dimensionScales: [:]) + #expect(fallback.color(.semantic(.secondaryGroupedBackground)) == Color.purple) + } + + @Test func decodedNativePaletteRetainsSystemAdaptation() throws { + let decoded = try JSONDecoder().decode( + ViewColorDescriptor.self, + from: Data(#"{"opacity":{"_0":{"semantic":{"_0":"red"}},"_1":0.15}}"#.utf8), + ) + let appearance = ViewDocumentAppearance(background: .white, dimensionScales: [:]) + #expect(appearance.color(decoded) == Color.red.opacity(0.15)) + #expect(appearance.color(.semantic(.orange)) == Color.orange) + } + + @Test func preservesSemanticColorCompositions() { + let appearance = ViewDocumentAppearance(background: .white, dimensionScales: [:]) + #expect(appearance.color(.opacity(.semantic(.primary), 0.12)) == Color.primary + .opacity(0.12)) + #expect(appearance + .color(.mix(.semantic(.primary), .semantic(.accent), fraction: 0.25)) == Color.primary + .mix( + with: .accentColor, + by: 0.25, + in: .perceptual, + )) + } + @Test func preservesSystemWeightUnlessExplicitlyOverridden() { let semantic = ViewFontDescriptor( size: .semantic(.headline),