Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .bumper/RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
42 changes: 42 additions & 0 deletions .bumper/Tests/WhereArchitectureTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -201,3 +201,45 @@ func `Where consumers can use InstallerKit`() throws {
))
#expect(report.violations.isEmpty)
}

@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: path,
component: ComponentID(component.rawValue),
source: source,
)],
))
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)
}
}
}
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` /
Expand Down
18 changes: 18 additions & 0 deletions BumperBowling.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import BumperBowlingCore

enum WhereComponent: String, ComponentKey {
case viewDocumentCore
case viewDocumentUI
case periscope
case loggingTests
case installerKit
Expand All @@ -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"
Expand Down Expand Up @@ -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")
Expand Down
8 changes: 8 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]),
Expand Down Expand Up @@ -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: [
Expand Down
28 changes: 28 additions & 0 deletions Project.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -912,6 +932,8 @@ let project = Project(
"WhereUITests",
"WhereIntentsTests",
"BroadwayCatalog",
"ViewDocumentCoreTests",
"ViewDocumentUITests",
"BroadwayCoreTests",
"BroadwayUITests",
"BroadwayCatalogTests",
Expand All @@ -937,6 +959,8 @@ let project = Project(
"WhereTests",
"WhereUITests",
"WhereIntentsTests",
"ViewDocumentCoreTests",
"ViewDocumentUITests",
"BroadwayCoreTests",
"BroadwayUITests",
"BroadwayCatalogTests",
Expand Down Expand Up @@ -975,6 +999,7 @@ let project = Project(
name: "StuffSnapshotTests",
shared: true,
buildAction: .buildAction(targets: [
"ViewDocumentUISnapshotTests",
"WhereUISnapshotTests",
"ToolsSnapshotTests",
"InstallerKitUISnapshotTests",
Expand All @@ -984,6 +1009,7 @@ let project = Project(
]),
testAction: .targets(
[
"ViewDocumentUISnapshotTests",
"WhereUISnapshotTests",
"ToolsSnapshotTests",
"InstallerKitUISnapshotTests",
Expand All @@ -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"),
Expand Down
12 changes: 12 additions & 0 deletions Shared/ViewDocument/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
57 changes: 57 additions & 0 deletions Shared/ViewDocument/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# 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.

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

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.
12 changes: 12 additions & 0 deletions Shared/ViewDocument/ViewDocumentCore/AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# 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`.

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.
14 changes: 14 additions & 0 deletions Shared/ViewDocument/ViewDocumentCore/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# 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.

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.

Portable colors include bounded opacity and perceptual blends. Semantic colors remain semantic through these operations. Color composition has a maximum depth of eight.
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
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,
limits.modifiersPerNode >= 0, limits.modifiers >= 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, inheritedScaleAmplification: 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,
)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import Foundation

public enum ViewAlignment: String, Codable, Hashable, Sendable {
case topLeading, top, topTrailing, leading, center, trailing, bottomLeading, bottom,
bottomTrailing
}
Loading
Loading