From 1076685f83a935ea1d3fda1ef9c5b0b9a86f64d1 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 29 Oct 2026 19:35:30 -0400 Subject: [PATCH 1/3] feat(Broadway): consume portable theme values and document decorations --- Package.swift | 2 + Shared/Broadway/AGENTS.md | 4 +- Shared/Broadway/BroadwayCore/README.md | 4 + .../BroadwayCore/Sources/BModeVarying.swift | 2 + .../BroadwayCore/Sources/BThemeValue.swift | 12 ++ .../BroadwayCore/Tests/BThemeValueTests.swift | 17 +++ Shared/Broadway/BroadwayUI/AGENTS.md | 4 +- Shared/Broadway/BroadwayUI/README.md | 6 + .../Sources/BViewDocumentLayer.swift | 121 ++++++++++++++++++ .../Sources/BViewDocumentStylesheet.swift | 69 ++++++++++ .../Tests/BViewDocumentLayerTests.swift | 74 +++++++++++ .../Tests/BViewDocumentStylesheetTests.swift | 36 ++++++ Shared/Broadway/README.md | 6 + 13 files changed, 355 insertions(+), 2 deletions(-) create mode 100644 Shared/Broadway/BroadwayCore/Sources/BThemeValue.swift create mode 100644 Shared/Broadway/BroadwayCore/Tests/BThemeValueTests.swift create mode 100644 Shared/Broadway/BroadwayUI/Sources/BViewDocumentLayer.swift create mode 100644 Shared/Broadway/BroadwayUI/Sources/BViewDocumentStylesheet.swift create mode 100644 Shared/Broadway/BroadwayUI/Tests/BViewDocumentLayerTests.swift create mode 100644 Shared/Broadway/BroadwayUI/Tests/BViewDocumentStylesheetTests.swift diff --git a/Package.swift b/Package.swift index 6d2afd1f8..b5b9c1cbd 100644 --- a/Package.swift +++ b/Package.swift @@ -257,6 +257,8 @@ let package = Package( name: "BroadwayUI", dependencies: [ .target(name: "BroadwayCore"), + .target(name: "ViewDocumentCore"), + .target(name: "ViewDocumentUI"), ], path: "Shared/Broadway/BroadwayUI/Sources", ), diff --git a/Shared/Broadway/AGENTS.md b/Shared/Broadway/AGENTS.md index 61c3827f5..fe2168ad1 100644 --- a/Shared/Broadway/AGENTS.md +++ b/Shared/Broadway/AGENTS.md @@ -7,7 +7,7 @@ Read the root [`AGENTS.md`](../../AGENTS.md) first. That file owns build, format ## Modules & dependencies - **BroadwayCore** — foundation types (Foundation and UIKit). No sibling deps. -- **BroadwayUI** — components (SwiftUI and UIKit). Depends on BroadwayCore. +- **BroadwayUI** — components (SwiftUI and UIKit). Depends on BroadwayCore, ViewDocumentCore, and ViewDocumentUI. - **BroadwayCatalog** — showcase app. Depends on BroadwayUI and SFSafeSymbols. UIKit hosting helpers for Broadway's hosted test bundles live in the shared [`TestHostSupport`](../TestHostSupport) module, not in a Broadway module. @@ -21,3 +21,5 @@ Libraries live in [`Package.swift`](../../Package.swift). The app and hosted tes ## Testing Run `./test BroadwayCoreTests`, `./test BroadwayUITests`, or `./test BroadwayCatalogTests`. The Catalog bundle is currently hosted by the **BroadwayCatalog** app itself. That deviates from the shared-host convention. Track it in [`TODOs.md`](TODOs.md). Use 1:1 test files per the root rules. + +- Document decorations use validated, prepared content. Resolve scales and accessibility from the Broadway slice; inject host activity and snapshot motion policy. Keep networking and product attachment names in consumers. diff --git a/Shared/Broadway/BroadwayCore/README.md b/Shared/Broadway/BroadwayCore/README.md index 136904a3e..a46232153 100644 --- a/Shared/Broadway/BroadwayCore/README.md +++ b/Shared/Broadway/BroadwayCore/README.md @@ -37,3 +37,7 @@ The cache is `@EquatableIgnored`, so two contexts compare equal on their inputs, Local SPM library declared in the root [`Package.swift`](../../../Package.swift). Depend on it with `.package(product: "BroadwayCore")`. Run tests with `./test BroadwayCoreTests`. + +## Portable theme values + +`BModeVarying` supports Codable when its value does. `BThemeValue` encodes either a constant or paired light/dark values. Call `resolve(mode:)` with the stylesheet slice's mode. Product modules own theme schemas and fallback rules. diff --git a/Shared/Broadway/BroadwayCore/Sources/BModeVarying.swift b/Shared/Broadway/BroadwayCore/Sources/BModeVarying.swift index 0672d736f..148e9c4cc 100644 --- a/Shared/Broadway/BroadwayCore/Sources/BModeVarying.swift +++ b/Shared/Broadway/BroadwayCore/Sources/BModeVarying.swift @@ -19,3 +19,5 @@ public struct BModeVarying { extension BModeVarying: Equatable where Value: Equatable {} extension BModeVarying: Hashable where Value: Hashable {} extension BModeVarying: Sendable where Value: Sendable {} + +extension BModeVarying: Codable where Value: Codable {} diff --git a/Shared/Broadway/BroadwayCore/Sources/BThemeValue.swift b/Shared/Broadway/BroadwayCore/Sources/BThemeValue.swift new file mode 100644 index 000000000..607b02531 --- /dev/null +++ b/Shared/Broadway/BroadwayCore/Sources/BThemeValue.swift @@ -0,0 +1,12 @@ +/// A portable authored value, either constant or selected by the explicit slice mode. +public enum BThemeValue: Codable, Hashable, Sendable { + case constant(Value) + case modes(BModeVarying) + + public func resolve(mode: BMode) -> Value { + switch self { + case let .constant(value): value + case let .modes(values): values[mode] + } + } +} diff --git a/Shared/Broadway/BroadwayCore/Tests/BThemeValueTests.swift b/Shared/Broadway/BroadwayCore/Tests/BThemeValueTests.swift new file mode 100644 index 000000000..e339249b6 --- /dev/null +++ b/Shared/Broadway/BroadwayCore/Tests/BThemeValueTests.swift @@ -0,0 +1,17 @@ +import BroadwayCore +import Foundation +import Testing + +struct BThemeValueTests { + @Test func roundTripsAndResolvesExplicitMode() throws { + let values: [BThemeValue] = [.constant(0), .modes(.init(light: 1, dark: 2))] + let decoded = try JSONDecoder().decode( + [BThemeValue].self, + from: JSONEncoder().encode(values), + ) + #expect(decoded == values) + #expect(decoded[0].resolve(mode: .dark) == 0) + #expect(decoded[1].resolve(mode: .light) == 1) + #expect(decoded[1].resolve(mode: .dark) == 2) + } +} diff --git a/Shared/Broadway/BroadwayUI/AGENTS.md b/Shared/Broadway/BroadwayUI/AGENTS.md index 0c9efb30d..4bc7301cb 100644 --- a/Shared/Broadway/BroadwayUI/AGENTS.md +++ b/Shared/Broadway/BroadwayUI/AGENTS.md @@ -1,6 +1,6 @@ # BroadwayUI – Module Shape -BroadwayUI provides UIKit and SwiftUI components that own and propagate a `BContext` down the view hierarchy. Key types: `BRootViewController` (UIKit root container and trait observation), `BRootView` / `.broadwayRoot(themes:)` (SwiftUI-native root), and `BTraitOverridesViewController` (scoped overrides). It depends on **BroadwayCore**. See [`README.md`](README.md). +BroadwayUI provides UIKit and SwiftUI components that own and propagate a `BContext` down the view hierarchy. Key types: `BRootViewController` (UIKit root container and trait observation), `BRootView` / `.broadwayRoot(themes:)` (SwiftUI-native root), and `BTraitOverridesViewController` (scoped overrides). It depends on **BroadwayCore**, **ViewDocumentCore**, and **ViewDocumentUI**. See [`README.md`](README.md). Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](../AGENTS.md) first. @@ -21,3 +21,5 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](. ## Testing Run `BroadwayUITests` in `StuffTestHost`, linking `TestHostSupport` (`./test BroadwayUITests`). + +- Document decorations use validated, prepared content. Resolve scales and accessibility from the Broadway slice; inject host activity and snapshot motion policy. Keep networking and product attachment names in consumers. diff --git a/Shared/Broadway/BroadwayUI/README.md b/Shared/Broadway/BroadwayUI/README.md index 7b2972508..e06e60e1f 100644 --- a/Shared/Broadway/BroadwayUI/README.md +++ b/Shared/Broadway/BroadwayUI/README.md @@ -34,3 +34,9 @@ WindowGroup { Local SPM library declared in the root [`Package.swift`](../../../Package.swift) (depends on BroadwayCore): `.package(product: "BroadwayUI")`. Run tests with `./test BroadwayUITests`. + +## Portable documents + +BroadwayUI consumes the independent ViewDocument libraries. Prepare and validate a document before rendering it. Attach it with `bViewDocumentBackground` or `bViewDocumentOverlay`, passing host activity and static-motion policy. These decorations stay within the host bounds and do not receive input or accessibility focus. + +`BViewDocumentStylesheet` resolves semantic dimension scales from the current content-size category. Reduce Motion selects the document's static pose. Reduce Transparency and Increased Contrast hide decorations. The renderer does not load assets or fetch documents. diff --git a/Shared/Broadway/BroadwayUI/Sources/BViewDocumentLayer.swift b/Shared/Broadway/BroadwayUI/Sources/BViewDocumentLayer.swift new file mode 100644 index 000000000..9252c3614 --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Sources/BViewDocumentLayer.swift @@ -0,0 +1,121 @@ +import BroadwayCore +import SwiftUI +import ViewDocumentCore +import ViewDocumentUI + +/// A decoration resolves through the nearest Broadway context and stays inside its host bounds. +public struct BViewDocumentLayer: View { + let document: PreparedViewDocument + let isActive: Bool + let motionIsStatic: Bool + @Environment(\.bViewDocumentStylesheet) private var sheet + + public init(document: PreparedViewDocument, isActive: Bool, motionIsStatic: Bool) { + self.document = document + self.isActive = isActive + self.motionIsStatic = motionIsStatic + } + + public var body: some View { + if sheet.showsDecoration { + ViewDocumentView( + prepared: document, + appearance: sheet.appearance, + policy: .init( + purpose: .decoration, + isActive: isActive, + motionIsStatic: motionIsStatic || sheet.motionIsStatic, + ), + ) + } + } +} + +extension View { + /// Adds a bounded background without changing the host's layout or hit area. + public func bViewDocumentBackground( + _ document: PreparedViewDocument, + isActive: Bool, + motionIsStatic: Bool, + ) -> some View { + background { + GeometryReader { geometry in + BViewDocumentLayer( + document: document, + isActive: isActive, + motionIsStatic: motionIsStatic, + ) + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + + /// Adds a bounded overlay without changing the host's layout or hit area. + public func bViewDocumentOverlay( + _ document: PreparedViewDocument, + isActive: Bool, + motionIsStatic: Bool, + ) -> some View { + overlay { + GeometryReader { geometry in + BViewDocumentLayer( + document: document, + isActive: isActive, + motionIsStatic: motionIsStatic, + ) + .frame(width: geometry.size.width, height: geometry.size.height) + .clipped() + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } +} + +extension EnvironmentValues { + public var bViewDocumentStylesheet: BViewDocumentStylesheet { + bContext.stylesheet(BViewDocumentStylesheet.self, fallback: .default) + } +} + +#if DEBUG + #Preview("Broadway document decoration") { BViewDocumentPreview.make() } + + @MainActor + private enum BViewDocumentPreview { + static func make() -> some View { + do { + let validated = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: .init( + id: .init(rawValue: "artwork"), + content: .shape(.circle), + modifiers: [ + .foreground(.color(.semantic(.accent))), + .frame( + width: .scaled(points: 40, relativeTo: .body), + height: .scaled(points: 40, relativeTo: .body), + alignment: .center, + ), + ], + animation: nil, + accessibility: .hidden, + )), + resources: .init(images: [], symbols: []), + limits: .standard, + ) + let prepared = try PreparedViewDocument( + document: validated, + assets: .init(images: [:], symbols: [:]), + ) + return Color.clear.frame(width: 200, height: 120) + .bViewDocumentBackground(prepared, isActive: true, motionIsStatic: true) + .broadwayRoot() + } catch { + preconditionFailure("Invalid Broadway preview: \(error)") + } + } + } +#endif diff --git a/Shared/Broadway/BroadwayUI/Sources/BViewDocumentStylesheet.swift b/Shared/Broadway/BroadwayUI/Sources/BViewDocumentStylesheet.swift new file mode 100644 index 000000000..5af8c8143 --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Sources/BViewDocumentStylesheet.swift @@ -0,0 +1,69 @@ +import BroadwayCore +import SwiftUI +import UIKit +import ViewDocumentCore +import ViewDocumentUI + +/// Resolves document dimensions and decoration policy once per Broadway slice. +public struct BViewDocumentStylesheet: BStylesheet, Sendable { + public let background: Color + public let dimensionScales: [ViewFontDescriptor.TextStyle: Double] + public let motionIsStatic: Bool + public let showsDecoration: Bool + + public init(context: SlicingContext) { + self.init(traits: context.traits) + } + + public init(traits: BTraits) { + let uiTraits = UITraitCollection(traitsFrom: [ + UITraitCollection(userInterfaceStyle: traits.mode == .dark ? .dark : .light), + UITraitCollection(accessibilityContrast: traits.accessibility + .isDarkerSystemColorsEnabled ? .high : .normal), + ]) + background = Color(uiColor: UIColor.systemBackground.resolvedColor(with: uiTraits)) + var scales: [ViewFontDescriptor.TextStyle: Double] = [:] + for style in ViewFontDescriptor.TextStyle.allCases { + scales[style] = Double(BScaledDimension.value( + 1, + relativeTo: style.uiTextStyle, + category: traits.contentSizeCategory, + )) + } + dimensionScales = scales + motionIsStatic = traits.accessibility.isReduceMotionEnabled + showsDecoration = !traits.accessibility.isReduceTransparencyEnabled && !traits.accessibility + .isDarkerSystemColorsEnabled + } + + public static let `default` = Self() + + private init() { + background = .white + dimensionScales = [:] + motionIsStatic = true + showsDecoration = false + } + + public var appearance: ViewDocumentAppearance { + .init(background: background, dimensionScales: dimensionScales) + } +} + +extension ViewFontDescriptor.TextStyle { + fileprivate var uiTextStyle: UIFont.TextStyle { + switch self { + case .largeTitle: .largeTitle + case .title: .title1 + case .title2: .title2 + case .title3: .title3 + case .headline: .headline + case .subheadline: .subheadline + case .body: .body + case .callout: .callout + case .footnote: .footnote + case .caption: .caption1 + case .caption2: .caption2 + } + } +} diff --git a/Shared/Broadway/BroadwayUI/Tests/BViewDocumentLayerTests.swift b/Shared/Broadway/BroadwayUI/Tests/BViewDocumentLayerTests.swift new file mode 100644 index 000000000..175b65666 --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Tests/BViewDocumentLayerTests.swift @@ -0,0 +1,74 @@ +import BroadwayCore +import BroadwayUI +import SwiftUI +import Testing +import ViewDocumentCore +import ViewDocumentUI + +@MainActor +struct BViewDocumentLayerTests { + @Test func contentChangeWithSameNodeIdentityInvalidatesSlice() { + var context = BContext(traits: .system) + let first = context.stylesheet( + DocumentSheet.self, + fallback: .init(document: DocumentTheme.defaultValue.document), + ) + var changed = first.document + changed.root.content = .text("Updated") + context.themes[DocumentTheme.self] = .init(document: changed) + let second = context.stylesheet(DocumentSheet.self, fallback: first) + #expect(second.document.root.id == first.document.root.id) + #expect(second.document.root.content == .text("Updated")) + #expect(second != first) + } + + @Test func decorationsCannotExpandHostLayout() throws { + let validated = try ValidatedViewDocument( + document: .init(schemaVersion: 1, root: .init( + id: .init(rawValue: "oversized"), + content: .shape(.rectangle), + modifiers: [.frame( + width: .points(1000), + height: .points(1000), + alignment: .center, + )], + animation: nil, + accessibility: .label("Must stay decorative"), + )), + resources: .init(images: [], symbols: []), + limits: .standard, + ) + let document = try PreparedViewDocument( + document: validated, + assets: .init(images: [:], symbols: [:]), + ) + let view = Color.white.frame(width: 40, height: 30) + .bViewDocumentBackground(document, isActive: true, motionIsStatic: true) + .bViewDocumentOverlay(document, isActive: true, motionIsStatic: true) + .broadwayRoot() + let image = try #require(ImageRenderer(content: view).uiImage) + #expect(image.size == CGSize(width: 40, height: 30)) + } +} + +private struct DocumentTheme: BTheme { + var document: ViewDocument + static let defaultValue = Self(document: .init(schemaVersion: 1, root: .init( + id: .init(rawValue: "stable"), + content: .text("Original"), + modifiers: [], + animation: nil, + accessibility: .automatic, + ))) +} + +private struct DocumentSheet: BStylesheet { + let document: ViewDocument + init(document: ViewDocument) { + self.document = document + } + + init(context: SlicingContext) { + document = context.themes[DocumentTheme.self].document + } +} diff --git a/Shared/Broadway/BroadwayUI/Tests/BViewDocumentStylesheetTests.swift b/Shared/Broadway/BroadwayUI/Tests/BViewDocumentStylesheetTests.swift new file mode 100644 index 000000000..06607fdc9 --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Tests/BViewDocumentStylesheetTests.swift @@ -0,0 +1,36 @@ +import BroadwayCore +import BroadwayUI +import Testing +import ViewDocumentCore + +@MainActor +struct BViewDocumentStylesheetTests { + @Test func resolvesAccessibilityAndExplicitScaling() { + var traits = BTraits.system + traits.contentSizeCategory = .large + let standard = BViewDocumentStylesheet(traits: traits) + #expect(standard.showsDecoration) + #expect(!standard.motionIsStatic) + traits.contentSizeCategory = .accessibilityExtraExtraExtraLarge + traits.accessibility = .init(isReduceMotionEnabled: true) + let accessible = BViewDocumentStylesheet(traits: traits) + #expect(accessible.motionIsStatic) + #expect(accessible.dimensionScales[.body, default: 0] > standard.dimensionScales[ + .body, + default: 0, + ]) + traits.accessibility = .init(isReduceTransparencyEnabled: true) + #expect(!BViewDocumentStylesheet(traits: traits).showsDecoration) + traits.accessibility = .init(isDarkerSystemColorsEnabled: true) + #expect(!BViewDocumentStylesheet(traits: traits).showsDecoration) + } + + @Test func traitChangeResolvesNewCachedSlice() { + var context = BContext(traits: .system) + let initial = context.stylesheet(BViewDocumentStylesheet.self, fallback: .default) + context.traitOverrides.accessibility = .init(isReduceMotionEnabled: true) + let changed = context.stylesheet(BViewDocumentStylesheet.self, fallback: .default) + #expect(!initial.motionIsStatic) + #expect(changed.motionIsStatic) + } +} diff --git a/Shared/Broadway/README.md b/Shared/Broadway/README.md index 8d5289dc5..fba98bd64 100644 --- a/Shared/Broadway/README.md +++ b/Shared/Broadway/README.md @@ -19,3 +19,9 @@ UIKit hosting helpers (`show`, `waitFor`, …) for Broadway's hosted Swift Testi Libraries are declared in the root [`Package.swift`](../../Package.swift). The Catalog app and hosted test bundles are in [`Project.swift`](../../Project.swift) (bundle IDs `com.stuff.broadway.*`). Run `./test BroadwayCoreTests`, `./test BroadwayUITests`, or `./test BroadwayCatalogTests`. + +## Portable documents + +BroadwayUI consumes the independent ViewDocument libraries. Prepare and validate a document before rendering it. Attach it with `bViewDocumentBackground` or `bViewDocumentOverlay`, passing host activity and static-motion policy. These decorations stay within the host bounds and do not receive input or accessibility focus. + +`BViewDocumentStylesheet` resolves semantic dimension scales from the current content-size category. Reduce Motion selects the document's static pose. Reduce Transparency and Increased Contrast hide decorations. The renderer does not load assets or fetch documents. From 1e509c854cf3b84cdb6d6d57d9a5e850d2b532bc Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Thu, 29 Oct 2026 21:53:07 -0400 Subject: [PATCH 2/3] Compose Broadway surfaces with bounded exterior artwork Separate native content layout from artwork paint bounds. Keep the material and zoom source inside the exterior layer, protect native content with an inset contour, and preserve essential surfaces under accessibility simplification. --- Shared/Broadway/BroadwayUI/AGENTS.md | 3 + Shared/Broadway/BroadwayUI/README.md | 15 +++ .../BroadwayUI/Sources/BArtworkLayer.swift | 118 ++++++++++++++++++ .../Sources/BArtworkPlacement.swift | 16 +++ .../BroadwayUI/Sources/BSurface.swift | 70 +++++++++++ .../Sources/BViewDocumentLayer.swift | 11 +- .../Sources/BViewDocumentStylesheet.swift | 7 ++ .../BroadwayUI/Tests/BArtworkLayerTests.swift | 63 ++++++++++ .../Tests/BArtworkPlacementTests.swift | 22 ++++ .../BroadwayUI/Tests/BSurfaceTests.swift | 22 ++++ .../Tests/BViewDocumentStylesheetTests.swift | 3 + 11 files changed, 348 insertions(+), 2 deletions(-) create mode 100644 Shared/Broadway/BroadwayUI/Sources/BArtworkLayer.swift create mode 100644 Shared/Broadway/BroadwayUI/Sources/BArtworkPlacement.swift create mode 100644 Shared/Broadway/BroadwayUI/Sources/BSurface.swift create mode 100644 Shared/Broadway/BroadwayUI/Tests/BArtworkLayerTests.swift create mode 100644 Shared/Broadway/BroadwayUI/Tests/BArtworkPlacementTests.swift create mode 100644 Shared/Broadway/BroadwayUI/Tests/BSurfaceTests.swift diff --git a/Shared/Broadway/BroadwayUI/AGENTS.md b/Shared/Broadway/BroadwayUI/AGENTS.md index 4bc7301cb..d4b581620 100644 --- a/Shared/Broadway/BroadwayUI/AGENTS.md +++ b/Shared/Broadway/BroadwayUI/AGENTS.md @@ -23,3 +23,6 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](. Run `BroadwayUITests` in `StuffTestHost`, linking `TestHostSupport` (`./test BroadwayUITests`). - Document decorations use validated, prepared content. Resolve scales and accessibility from the Broadway slice; inject host activity and snapshot motion policy. Keep networking and product attachment names in consumers. +- Compose native content with `BSurface`. Its finish owns the inner material or transition source; exterior artwork stays outside that finish. +- Host artwork through validated `BArtworkPlacement` and `BArtworkLayer`. Keep paint bounds separate from layout and interaction. The parent reserves gaps and retains viewport clipping. +- Essential layers remain visible under accessibility simplification. Exterior artwork may paint only in its bounded edge band and exterior zone; native content remains protected. diff --git a/Shared/Broadway/BroadwayUI/README.md b/Shared/Broadway/BroadwayUI/README.md index e06e60e1f..191873a0e 100644 --- a/Shared/Broadway/BroadwayUI/README.md +++ b/Shared/Broadway/BroadwayUI/README.md @@ -1,5 +1,20 @@ # BroadwayUI +## Composed surfaces + +`BSurface` orders the base, interior artwork, native content, highlights, and exterior artwork. +Only native content determines layout. Supply a `finish` modifier for material treatment or the inner zoom-transition source. +The exterior layer follows that modifier and does not become part of its clipping or transition source. + +Validate a `ViewArtworkPlacement` with `BArtworkPlacement` before constructing `BArtworkLayer`. +The layer hosts either a prepared document or native artwork. It clips all transforms to the declared paint bounds. +Exterior artwork also excludes the content center. Its edge band is at most 16 points and shrinks for small hosts. +The containing layout must reserve gaps and gutters. The layer does not disable scroll viewport clipping. + +`BSurfaceLayerRole.essential` preserves required surface content when accessibility settings simplify ornament. +`BViewDocumentLayer` accepts that role and always retains the Reduce Motion policy. +Both artwork paths remain hidden from accessibility and ignore hit testing. + UIKit + SwiftUI components that carry a `BContext` (from BroadwayCore) through the view hierarchy. ## Public API diff --git a/Shared/Broadway/BroadwayUI/Sources/BArtworkLayer.swift b/Shared/Broadway/BroadwayUI/Sources/BArtworkLayer.swift new file mode 100644 index 000000000..c627309b0 --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Sources/BArtworkLayer.swift @@ -0,0 +1,118 @@ +import SwiftUI +import ViewDocumentCore + +/// Hosts prepared document or native artwork in paint bounds that never change the host's size. +public struct BArtworkLayer: View { + private let placement: BArtworkPlacement + private let shape: SurfaceShape + private let role: BSurfaceLayerRole + private let content: Content + @Environment(\.bViewDocumentStylesheet) private var sheet + /// Layout direction is local container geometry and can differ beneath the Broadway root. + @Environment(\.layoutDirection) private var layoutDirection + + public init( + placement: BArtworkPlacement, + shape: SurfaceShape, + role: BSurfaceLayerRole, + @ViewBuilder content: () -> Content, + ) { + self.placement = placement + self.shape = shape + self.role = role + self.content = content() + } + + public var body: some View { + if sheet.shows(role) { + GeometryReader { host in + let geometry = placement.value.geometry( + in: .init(width: host.size.width, height: host.size.height), + rightToLeft: layoutDirection == .rightToLeft, + ) + let frame = geometry.frame + let bounds = geometry.paintBounds + content + .frame(width: frame.width, height: frame.height) + .position( + x: frame.x + frame.width / 2 - bounds.x, + y: frame.y + frame.height / 2 - bounds.y, + ) + .frame(width: bounds.width, height: bounds.height) + .mask { + BArtworkClip( + shape: shape, + host: host.size, + paintBounds: bounds, + plane: placement.value.plane, + ) + .fill(style: FillStyle(eoFill: true)) + } + .offset(x: bounds.x, y: bounds.y) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } +} + +/// Foreground paint is restricted to a narrow edge band and the declared exterior bounds. +struct BArtworkClip: Shape { + let shape: SurfaceShape + let host: CGSize + let paintBounds: ViewArtworkPlacement.Frame + let plane: ViewArtworkPlacement.Plane + + func path(in rect: CGRect) -> Path { + let surface = CGRect( + x: -paintBounds.x, + y: -paintBounds.y, + width: host.width, + height: host.height, + ) + switch plane { + case .interior: return shape.path(in: surface) + case .exterior: + // Keep a protected center even when a host is smaller than the ordinary edge band. + let edge = min(16, min(host.width, host.height) / 4) + var path = Path(rect) + path.addPath(shape.inset(by: edge).path(in: surface)) + return path + } + } +} + +#if DEBUG + #Preview("Bounded exterior artwork") { BArtworkPreview.make() } + + @MainActor + private enum BArtworkPreview { + static func make() -> some View { + do { + let placement = try BArtworkPlacement(.init( + anchor: .bottomTrailing, + size: .fixed(width: 48, height: 48), + offset: .init(x: 8, y: 24), + plane: .exterior, + outsets: .init(top: 0, leading: 0, bottom: 24, trailing: 8), + )) + return Text("Native card").padding(40).background( + .orange.opacity(0.15), + in: .rect(cornerRadius: 20), + ) + .overlay { + BArtworkLayer( + placement: placement, + shape: RoundedRectangle(cornerRadius: 20), + role: .ornament, + ) { + Circle().fill(.orange) + } + } + .padding(40).broadwayRoot() + } catch { + preconditionFailure("Invalid artwork preview: \(error)") + } + } + } +#endif diff --git a/Shared/Broadway/BroadwayUI/Sources/BArtworkPlacement.swift b/Shared/Broadway/BroadwayUI/Sources/BArtworkPlacement.swift new file mode 100644 index 000000000..f9774b2f6 --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Sources/BArtworkPlacement.swift @@ -0,0 +1,16 @@ +import ViewDocumentCore + +/// Immutable proof that a host's artwork geometry passed the portable bounds checks. +public struct BArtworkPlacement: Hashable, Sendable { + public let value: ViewArtworkPlacement + + public init(_ value: ViewArtworkPlacement) throws { + try value.validate() + self.value = value + } +} + +/// Essential surface content remains visible when accessibility settings simplify ornament. +public enum BSurfaceLayerRole: String, Codable, Hashable, Sendable { + case essential, ornament +} diff --git a/Shared/Broadway/BroadwayUI/Sources/BSurface.swift b/Shared/Broadway/BroadwayUI/Sources/BSurface.swift new file mode 100644 index 000000000..ccd98c532 --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Sources/BSurface.swift @@ -0,0 +1,70 @@ +import SwiftUI + +/// Composes a surface without letting artwork participate in content layout or interaction. +public struct BSurface< + SurfaceShape: Shape, + Base: View, + Interior: View, + Content: View, + Highlights: View, + Exterior: View, + Finish: ViewModifier, +>: View { + private let shape: SurfaceShape + private let base: Base + private let interior: Interior + private let content: Content + private let highlights: Highlights + private let exterior: Exterior + private let finish: Finish + + /// Finish applies to the inner surface only. It can own material treatment or a zoom source. + public init( + shape: SurfaceShape, + finish: Finish, + @ViewBuilder base: () -> Base, + @ViewBuilder interior: () -> Interior, + @ViewBuilder content: () -> Content, + @ViewBuilder highlights: () -> Highlights, + @ViewBuilder exterior: () -> Exterior, + ) { + self.shape = shape + self.base = base() + self.interior = interior() + self.content = content() + self.highlights = highlights() + self.exterior = exterior() + self.finish = finish + } + + public var body: some View { + content + .background { + ZStack { base; interior } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + .clipShape(shape) + .overlay { highlights.allowsHitTesting(false).accessibilityHidden(true) } + .contentShape(shape) + .modifier(finish) + .overlay { exterior.allowsHitTesting(false).accessibilityHidden(true) } + } +} + +#if DEBUG + #Preview("Surface composition") { + BSurface(shape: RoundedRectangle(cornerRadius: 20), finish: EmptyModifier()) { + Color.orange.opacity(0.12) + } interior: { + Circle().fill(.orange.opacity(0.15)).padding(10) + } content: { + Text("Native content").font(.headline).padding(40) + } highlights: { + RoundedRectangle(cornerRadius: 20).stroke(.orange, lineWidth: 1) + } exterior: { + EmptyView() + } + .padding(40) + } +#endif diff --git a/Shared/Broadway/BroadwayUI/Sources/BViewDocumentLayer.swift b/Shared/Broadway/BroadwayUI/Sources/BViewDocumentLayer.swift index 9252c3614..7fa64aec6 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BViewDocumentLayer.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BViewDocumentLayer.swift @@ -8,16 +8,23 @@ public struct BViewDocumentLayer: View { let document: PreparedViewDocument let isActive: Bool let motionIsStatic: Bool + let role: BSurfaceLayerRole @Environment(\.bViewDocumentStylesheet) private var sheet - public init(document: PreparedViewDocument, isActive: Bool, motionIsStatic: Bool) { + public init( + document: PreparedViewDocument, + isActive: Bool, + motionIsStatic: Bool, + role: BSurfaceLayerRole = .ornament, + ) { self.document = document self.isActive = isActive self.motionIsStatic = motionIsStatic + self.role = role } public var body: some View { - if sheet.showsDecoration { + if sheet.shows(role) { ViewDocumentView( prepared: document, appearance: sheet.appearance, diff --git a/Shared/Broadway/BroadwayUI/Sources/BViewDocumentStylesheet.swift b/Shared/Broadway/BroadwayUI/Sources/BViewDocumentStylesheet.swift index 5af8c8143..f6435c7e3 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BViewDocumentStylesheet.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BViewDocumentStylesheet.swift @@ -48,6 +48,13 @@ public struct BViewDocumentStylesheet: BStylesheet, Sendable { public var appearance: ViewDocumentAppearance { .init(background: background, dimensionScales: dimensionScales) } + + public func shows(_ role: BSurfaceLayerRole) -> Bool { + switch role { + case .essential: true + case .ornament: showsDecoration + } + } } extension ViewFontDescriptor.TextStyle { diff --git a/Shared/Broadway/BroadwayUI/Tests/BArtworkLayerTests.swift b/Shared/Broadway/BroadwayUI/Tests/BArtworkLayerTests.swift new file mode 100644 index 000000000..6aeba4e2f --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Tests/BArtworkLayerTests.swift @@ -0,0 +1,63 @@ +@testable import BroadwayUI +import SwiftUI +import Testing +import ViewDocumentCore + +@MainActor +struct BArtworkLayerTests { + @Test func exteriorMaskProtectsNativeContentAndStopsAtPaintBounds() { + let clip = BArtworkClip( + shape: Rectangle(), + host: CGSize(width: 100, height: 80), + paintBounds: .init(x: -10, y: -10, width: 120, height: 100), + plane: .exterior, + ) + let path = clip.path(in: CGRect(x: 0, y: 0, width: 120, height: 100)) + #expect(!path.contains(CGPoint(x: 60, y: 50), eoFill: true)) + #expect(path.contains(CGPoint(x: 12, y: 50), eoFill: true)) + #expect(path.contains(CGPoint(x: 5, y: 50), eoFill: true)) + #expect(!path.contains(CGPoint(x: -1, y: 50), eoFill: true)) + #expect(!path.contains(CGPoint(x: 121, y: 50), eoFill: true)) + } + + @Test func interiorMaskFollowsSurfaceCorners() { + let clip = BArtworkClip( + shape: RoundedRectangle(cornerRadius: 20), + host: CGSize(width: 100, height: 80), + paintBounds: .init(x: 0, y: 0, width: 100, height: 80), + plane: .interior, + ) + let path = clip.path(in: CGRect(x: 0, y: 0, width: 100, height: 80)) + #expect(!path.contains(CGPoint(x: 1, y: 1))) + #expect(path.contains(CGPoint(x: 50, y: 40))) + } + + @Test func exteriorBandInsetsTheRoundedContourWithoutRescalingIt() { + let clip = BArtworkClip( + shape: RoundedRectangle(cornerRadius: 20), + host: CGSize(width: 100, height: 80), + paintBounds: .init(x: 0, y: 0, width: 100, height: 80), + plane: .exterior, + ) + let path = clip.path(in: CGRect(x: 0, y: 0, width: 100, height: 80)) + #expect(!path.contains(CGPoint(x: 20, y: 20), eoFill: true)) + #expect(path.contains(CGPoint(x: 10, y: 20), eoFill: true)) + } + + @Test func overflowDoesNotChangeLayoutSize() throws { + let placement = try BArtworkPlacement(.init( + anchor: .bottomTrailing, + size: .fixed(width: 100, height: 100), + offset: .init(x: 20, y: 20), + plane: .exterior, + outsets: .init(top: 0, leading: 0, bottom: 20, trailing: 20), + )) + let view = Color.white.frame(width: 80, height: 60).overlay { + BArtworkLayer(placement: placement, shape: Rectangle(), role: .essential) { + Color.orange.scaleEffect(100) + } + } + let image = try #require(ImageRenderer(content: view).uiImage) + #expect(image.size == CGSize(width: 80, height: 60)) + } +} diff --git a/Shared/Broadway/BroadwayUI/Tests/BArtworkPlacementTests.swift b/Shared/Broadway/BroadwayUI/Tests/BArtworkPlacementTests.swift new file mode 100644 index 000000000..9901556fd --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Tests/BArtworkPlacementTests.swift @@ -0,0 +1,22 @@ +import BroadwayUI +import Testing +import ViewDocumentCore + +struct BArtworkPlacementTests { + @Test func rejectsUnboundedPaintBeforeHosting() { + #expect(throws: ViewDocumentValidationError.self) { + try BArtworkPlacement(.init( + anchor: .center, + size: .fixed(width: 80, height: 80), + offset: .zero, + plane: .exterior, + outsets: .init( + top: .infinity, + leading: 0, + bottom: 0, + trailing: 0, + ), + )) + } + } +} diff --git a/Shared/Broadway/BroadwayUI/Tests/BSurfaceTests.swift b/Shared/Broadway/BroadwayUI/Tests/BSurfaceTests.swift new file mode 100644 index 000000000..eba80907d --- /dev/null +++ b/Shared/Broadway/BroadwayUI/Tests/BSurfaceTests.swift @@ -0,0 +1,22 @@ +import BroadwayUI +import SwiftUI +import Testing + +@MainActor +struct BSurfaceTests { + @Test func onlyNativeContentDeterminesLayout() throws { + let view = BSurface(shape: Rectangle(), finish: EmptyModifier()) { + Color.white + } interior: { + Color.red.frame(width: 1000, height: 1000) + } content: { + Color.clear.frame(width: 80, height: 60) + } highlights: { + Rectangle().stroke(.black, lineWidth: 1) + } exterior: { + Color.blue.frame(width: 1000, height: 1000) + } + let image = try #require(ImageRenderer(content: view).uiImage) + #expect(image.size == CGSize(width: 80, height: 60)) + } +} diff --git a/Shared/Broadway/BroadwayUI/Tests/BViewDocumentStylesheetTests.swift b/Shared/Broadway/BroadwayUI/Tests/BViewDocumentStylesheetTests.swift index 06607fdc9..9f5f2e275 100644 --- a/Shared/Broadway/BroadwayUI/Tests/BViewDocumentStylesheetTests.swift +++ b/Shared/Broadway/BroadwayUI/Tests/BViewDocumentStylesheetTests.swift @@ -21,8 +21,11 @@ struct BViewDocumentStylesheetTests { ]) traits.accessibility = .init(isReduceTransparencyEnabled: true) #expect(!BViewDocumentStylesheet(traits: traits).showsDecoration) + #expect(BViewDocumentStylesheet(traits: traits).shows(.essential)) + #expect(!BViewDocumentStylesheet(traits: traits).shows(.ornament)) traits.accessibility = .init(isDarkerSystemColorsEnabled: true) #expect(!BViewDocumentStylesheet(traits: traits).showsDecoration) + #expect(BViewDocumentStylesheet(traits: traits).shows(.essential)) } @Test func traitChangeResolvesNewCachedSlice() { From 11e44739b26d4d8c7f3a43b981570b79e804016d Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Fri, 30 Oct 2026 01:34:43 -0400 Subject: [PATCH 3/3] Preserve surface bounds and adapt document rendering to native traits Keep exterior artwork outside the surface finish and native focus shape. Resolve portable background providers with pinned mode and contrast while retaining host elevation. Propagate effective SwiftUI Increased Contrast without weakening the system setting. Add transparent Codable support for copy-on-write appearance trees. Seven calendar and forecast contrast references accompany the root trait correction. The Where-specific Elsewhere outline remains on the next slice. Validation: BroadwayCore 120 tests and BroadwayUI 31 tests passed in the full integration run. Consumer interaction and affected snapshot checks, formatting, and architecture checks passed. --- Shared/Broadway/BroadwayCore/README.md | 4 ++ .../BroadwayCore/Sources/CopyOnWrite.swift | 32 +++++++++ .../BroadwayCore/Sources/Traits/BTraits.swift | 3 +- .../BroadwayCore/Tests/CopyOnWriteTests.swift | 45 +++++++++++++ Shared/Broadway/BroadwayUI/AGENTS.md | 3 +- Shared/Broadway/BroadwayUI/README.md | 8 ++- .../BroadwayUI/Sources/BArtworkLayer.swift | 38 ++++++----- .../BroadwayUI/Sources/BRootView.swift | 9 ++- .../BroadwayUI/Sources/BSurface.swift | 3 + .../Sources/BViewDocumentStylesheet.swift | 66 +++++++++++++++++-- .../BroadwayUI/Tests/BRootViewTests.swift | 21 ++++++ .../Tests/BViewDocumentStylesheetTests.swift | 29 ++++++++ ...calendarContent.WithData_iPad_contrast.png | 4 +- ...lendarContent.WithData_iPhone_contrast.png | 4 +- ...ndarContent.YearOverview_iPad_contrast.png | 4 +- ...arContent.YearOverview_iPhone_contrast.png | 4 +- .../componentThemes.Expanded_contrast.png | 4 +- .../componentThemes.Collapsed_contrast.png | 4 +- ...mponentThemes.SummaryExpanded_contrast.png | 4 +- 19 files changed, 248 insertions(+), 41 deletions(-) diff --git a/Shared/Broadway/BroadwayCore/README.md b/Shared/Broadway/BroadwayCore/README.md index a46232153..9863eeff4 100644 --- a/Shared/Broadway/BroadwayCore/README.md +++ b/Shared/Broadway/BroadwayCore/README.md @@ -41,3 +41,7 @@ Run tests with `./test BroadwayCoreTests`. ## Portable theme values `BModeVarying` supports Codable when its value does. `BThemeValue` encodes either a constant or paired light/dark values. Call `resolve(mode:)` with the stylesheet slice's mode. Product modules own theme schemas and fallback rules. + +`BTraits()` supplies deterministic defaults without live observers. Use it for document validation and off-tree defaults; use `BTraits.system` at a live root. + +`CopyOnWrite` encodes its wrapped value directly. Optional wrapped properties retain missing-key and null decoding, and omit nil values when encoding. diff --git a/Shared/Broadway/BroadwayCore/Sources/CopyOnWrite.swift b/Shared/Broadway/BroadwayCore/Sources/CopyOnWrite.swift index fb2729578..2a2714770 100644 --- a/Shared/Broadway/BroadwayCore/Sources/CopyOnWrite.swift +++ b/Shared/Broadway/BroadwayCore/Sources/CopyOnWrite.swift @@ -85,6 +85,38 @@ extension CopyOnWrite: Equatable where Value: Equatable {} extension CopyOnWrite: Hashable where Value: Hashable {} extension CopyOnWrite: @unchecked Sendable where Value: Sendable {} +extension CopyOnWrite: Decodable where Value: Decodable { + public init(from decoder: any Decoder) throws { + try self.init(wrappedValue: decoder.singleValueContainer().decode(Value.self)) + } +} + +extension CopyOnWrite: Encodable where Value: Encodable { + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(wrappedValue) + } +} + +extension KeyedDecodingContainer { + /// Preserve optional-field semantics when synthesis sees the non-optional wrapper. + public func decode( + _: CopyOnWrite.Type, + forKey key: Key, + ) throws -> CopyOnWrite { + try .init(wrappedValue: decodeIfPresent(Value.self, forKey: key)) + } +} + +extension KeyedEncodingContainer { + public mutating func encode( + _ value: CopyOnWrite<(some Encodable)?>, + forKey key: Key, + ) throws { + try encodeIfPresent(value.wrappedValue, forKey: key) + } +} + extension CopyOnWrite.Box: Equatable where Value: Equatable { static func == (lhs: CopyOnWrite.Box, rhs: CopyOnWrite.Box) -> Bool { lhs === rhs || lhs.value == rhs.value diff --git a/Shared/Broadway/BroadwayCore/Sources/Traits/BTraits.swift b/Shared/Broadway/BroadwayCore/Sources/Traits/BTraits.swift index df2502bda..d706430b6 100644 --- a/Shared/Broadway/BroadwayCore/Sources/Traits/BTraits.swift +++ b/Shared/Broadway/BroadwayCore/Sources/Traits/BTraits.swift @@ -18,7 +18,8 @@ import UIKit /// Accessing a type that hasn't been explicitly set returns /// its ``BTraitsValue/defaultValue``. public struct BTraits: Equatable, Hashable, @unchecked Sendable { - init() {} + /// Deterministic defaults without live trait observers. Roots use `system`. + public init() {} /// A traits container pre-registered with all built-in system trait types. @MainActor public static var system: BTraits { diff --git a/Shared/Broadway/BroadwayCore/Tests/CopyOnWriteTests.swift b/Shared/Broadway/BroadwayCore/Tests/CopyOnWriteTests.swift index c405bb27e..6a57e826c 100644 --- a/Shared/Broadway/BroadwayCore/Tests/CopyOnWriteTests.swift +++ b/Shared/Broadway/BroadwayCore/Tests/CopyOnWriteTests.swift @@ -1,7 +1,52 @@ +import Foundation import Testing @_spi(CopyOnWrite) @testable import BroadwayCore struct CopyOnWriteTests { + private struct CodableModel: Codable, Equatable { + @CopyOnWrite var values: [Int] + @CopyOnWrite var optional: String? + } + + @Test func codablePreservesTheWrappedWireFormat() throws { + let decoder = JSONDecoder() + let absent = try decoder.decode(CodableModel.self, from: Data(#"{"values":[1,2]}"#.utf8)) + let null = try decoder.decode( + CodableModel.self, + from: Data(#"{"values":[1,2],"optional":null}"#.utf8), + ) + #expect(absent == null) + let encoded = try JSONEncoder().encode(absent) + let object = try #require(JSONSerialization.jsonObject(with: encoded) as? [String: Any]) + #expect(object["values"] as? [Int] == [1, 2]) + #expect(object["optional"] == nil) + var changed = absent + changed.values.append(3) + changed.optional = "present" + #expect(absent.values == [1, 2]) + #expect(absent.optional == nil) + #expect(try decoder + .decode(CodableModel.self, from: JSONEncoder().encode(changed)) == changed) + #expect(throws: DecodingError.self) { + try decoder.decode(CodableModel.self, from: Data(#"{"values":[],"optional":42}"#.utf8)) + } + } + + @Test func codableHonorsEncoderAndDecoderStrategies() throws { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let date = Date(timeIntervalSince1970: 0) + #expect(try encoder.encode(CopyOnWrite(wrappedValue: date)) == encoder.encode(date)) + #expect(try decoder.decode(CopyOnWrite.self, from: encoder.encode(date)) + .wrappedValue == date) + let data = Data([1, 2, 3]) + #expect(try encoder.encode(CopyOnWrite(wrappedValue: data)) == encoder.encode(data)) + #expect(try decoder.decode(CopyOnWrite.self, from: encoder.encode(data)) + .wrappedValue == data) + } + // MARK: - Initialization @Test("Stores the initial value") diff --git a/Shared/Broadway/BroadwayUI/AGENTS.md b/Shared/Broadway/BroadwayUI/AGENTS.md index d4b581620..a07fc0e1c 100644 --- a/Shared/Broadway/BroadwayUI/AGENTS.md +++ b/Shared/Broadway/BroadwayUI/AGENTS.md @@ -12,6 +12,7 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](. - **Do not add `BTraitsObserver` to `BRootView`.** SwiftUI re-evaluates `body` on color-scheme and Dynamic Type changes. - **A `.task` mirrors `BAccessibility.changes()` into state.** Both rebuild the injected `BContext`. - **Context-building lives in `BRootContext.make(...)`.** That makes the trait mapping testable without a host. +- Merge effective SwiftUI contrast into the root accessibility slice. A local override must not weaken the system setting. Guard: `BRootViewTests`. - **Make `\.bContext` prefer a synchronous SwiftUI value, and mirror to UIKit.** - **`BContext+SwiftUI` stores a SwiftUI-set context in a pure-SwiftUI `EnvironmentKey`.** That covers `BRootView`, `broadwayRoot`, and `bTraitOverrides`. - **Read it synchronously.** Do not round-trip through `UITraitCollection`. Do not accept first-frame lag. @@ -23,6 +24,6 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](. Run `BroadwayUITests` in `StuffTestHost`, linking `TestHostSupport` (`./test BroadwayUITests`). - Document decorations use validated, prepared content. Resolve scales and accessibility from the Broadway slice; inject host activity and snapshot motion policy. Keep networking and product attachment names in consumers. -- Compose native content with `BSurface`. Its finish owns the inner material or transition source; exterior artwork stays outside that finish. +- Compose native content with `BSurface`. Its finish owns the inner material or transition source; exterior artwork stays outside that finish. Preserve its explicit accessibility content shape after exterior composition. - Host artwork through validated `BArtworkPlacement` and `BArtworkLayer`. Keep paint bounds separate from layout and interaction. The parent reserves gaps and retains viewport clipping. - Essential layers remain visible under accessibility simplification. Exterior artwork may paint only in its bounded edge band and exterior zone; native content remains protected. diff --git a/Shared/Broadway/BroadwayUI/README.md b/Shared/Broadway/BroadwayUI/README.md index 191873a0e..efc60ba10 100644 --- a/Shared/Broadway/BroadwayUI/README.md +++ b/Shared/Broadway/BroadwayUI/README.md @@ -5,9 +5,11 @@ `BSurface` orders the base, interior artwork, native content, highlights, and exterior artwork. Only native content determines layout. Supply a `finish` modifier for material treatment or the inner zoom-transition source. The exterior layer follows that modifier and does not become part of its clipping or transition source. +An explicit accessibility content shape keeps combined focus bounds on the native surface. Validate a `ViewArtworkPlacement` with `BArtworkPlacement` before constructing `BArtworkLayer`. The layer hosts either a prepared document or native artwork. It clips all transforms to the declared paint bounds. +Pass `maximumOutsets` when the host reserves smaller gutters. The layer scales artwork to fit without changing native content or interaction bounds. Exterior artwork also excludes the content center. Its edge band is at most 16 points and shrinks for small hosts. The containing layout must reserve gaps and gutters. The layer does not disable scroll viewport clipping. @@ -20,7 +22,7 @@ UIKit + SwiftUI components that carry a `BContext` (from BroadwayCore) through t ## Public API - **`BRootViewController`** — a container view controller that owns the root `BContext`, observes system trait changes via `BTraitsObserver`, and republishes the context to descendants through `traitOverrides`. The designated initializer wraps any `UIViewController`. A convenience initializer hosts SwiftUI content directly. -- **`BRootView` / `.broadwayRoot(themes:)`** — the SwiftUI-native root. Seeds a root `BContext` from the live system traits (`@Environment(\.colorScheme)`, `\.dynamicTypeSize`, and `BAccessibility.changes()`) plus the given `themes`, and injects it so descendants read `@Environment(\.bContext)`. Unlike the UIKit root it takes `themes`, letting an app seed palette/typography at the root. +- **`BRootView` / `.broadwayRoot(themes:)`** — the SwiftUI-native root. Seeds a root `BContext` from the live system traits (`@Environment(\.colorScheme)`, `\.colorSchemeContrast`, `\.dynamicTypeSize`, and `BAccessibility.changes()`) plus the given `themes`, and injects it so descendants read `@Environment(\.bContext)`. Unlike the UIKit root it takes `themes`, letting an app seed palette/typography at the root. - **`BTraitOverridesViewController`** — scopes trait overrides to a subtree while preserving inherited base traits and themes. - **SwiftUI bridges** — `BContext+SwiftUI` resolves `@Environment(\.bContext)`, preferring a synchronous pure-SwiftUI value (set by `BRootView` / `broadwayRoot` / `bTraitOverrides`, and mirrored into UIKit traits so it also reaches nested UIKit) and falling back to the UIKit trait-bridged value when none is set. Plus `BTraitOverrides+SwiftUI` and the `BMode` / `BContentSizeCategory` initializers from `ColorScheme` / `DynamicTypeSize` (`BTraitValues+SwiftUI`). @@ -55,3 +57,7 @@ Run tests with `./test BroadwayUITests`. BroadwayUI consumes the independent ViewDocument libraries. Prepare and validate a document before rendering it. Attach it with `bViewDocumentBackground` or `bViewDocumentOverlay`, passing host activity and static-motion policy. These decorations stay within the host bounds and do not receive input or accessibility focus. `BViewDocumentStylesheet` resolves semantic dimension scales from the current content-size category. Reduce Motion selects the document's static pose. Reduce Transparency and Increased Contrast hide decorations. The renderer does not load assets or fetch documents. + +The adapter supplies native background levels resolved from explicit mode and contrast traits. +Background elevation remains dynamic so sheets and popovers retain native raised surfaces. +The SwiftUI root includes effective contrast overrides without weakening the system's Increased Contrast setting. diff --git a/Shared/Broadway/BroadwayUI/Sources/BArtworkLayer.swift b/Shared/Broadway/BroadwayUI/Sources/BArtworkLayer.swift index c627309b0..2733b8d08 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BArtworkLayer.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BArtworkLayer.swift @@ -6,6 +6,7 @@ public struct BArtworkLayer: View private let placement: BArtworkPlacement private let shape: SurfaceShape private let role: BSurfaceLayerRole + private let maximumOutsets: ViewArtworkPlacement.Outsets? private let content: Content @Environment(\.bViewDocumentStylesheet) private var sheet /// Layout direction is local container geometry and can differ beneath the Broadway root. @@ -15,11 +16,13 @@ public struct BArtworkLayer: View placement: BArtworkPlacement, shape: SurfaceShape, role: BSurfaceLayerRole, + maximumOutsets: ViewArtworkPlacement.Outsets? = nil, @ViewBuilder content: () -> Content, ) { self.placement = placement self.shape = shape self.role = role + self.maximumOutsets = maximumOutsets self.content = content() } @@ -29,26 +32,29 @@ public struct BArtworkLayer: View let geometry = placement.value.geometry( in: .init(width: host.size.width, height: host.size.height), rightToLeft: layoutDirection == .rightToLeft, + maximumOutsets: maximumOutsets, ) let frame = geometry.frame let bounds = geometry.paintBounds - content - .frame(width: frame.width, height: frame.height) - .position( - x: frame.x + frame.width / 2 - bounds.x, - y: frame.y + frame.height / 2 - bounds.y, - ) - .frame(width: bounds.width, height: bounds.height) - .mask { - BArtworkClip( - shape: shape, - host: host.size, - paintBounds: bounds, - plane: placement.value.plane, + if frame.width > 0, frame.height > 0 { + content + .frame(width: frame.width, height: frame.height) + .position( + x: frame.x + frame.width / 2 - bounds.x, + y: frame.y + frame.height / 2 - bounds.y, ) - .fill(style: FillStyle(eoFill: true)) - } - .offset(x: bounds.x, y: bounds.y) + .frame(width: bounds.width, height: bounds.height) + .mask { + BArtworkClip( + shape: shape, + host: host.size, + paintBounds: bounds, + plane: placement.value.plane, + ) + .fill(style: FillStyle(eoFill: true)) + } + .offset(x: bounds.x, y: bounds.y) + } } .allowsHitTesting(false) .accessibilityHidden(true) diff --git a/Shared/Broadway/BroadwayUI/Sources/BRootView.swift b/Shared/Broadway/BroadwayUI/Sources/BRootView.swift index 41f5146d3..c6a729a1c 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BRootView.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BRootView.swift @@ -22,6 +22,7 @@ public struct BRootView: View { private let content: Content @Environment(\.colorScheme) private var colorScheme + @Environment(\.colorSchemeContrast) private var contrast @Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var accessibility: BAccessibility = .current() @@ -34,6 +35,7 @@ public struct BRootView: View { content .environment(\.bContext, BRootContext.make( colorScheme: colorScheme, + contrast: contrast, dynamicTypeSize: dynamicTypeSize, accessibility: accessibility, themes: themes, @@ -64,6 +66,7 @@ enum BRootContext { @MainActor static func make( colorScheme: ColorScheme, + contrast: ColorSchemeContrast, dynamicTypeSize: DynamicTypeSize, accessibility: BAccessibility, themes: BThemes, @@ -79,7 +82,11 @@ enum BRootContext { // there instead when a token needs to follow a scoped scheme override. traits.mode = BMode(colorScheme) traits.contentSizeCategory = BContentSizeCategory(dynamicTypeSize) - traits.accessibility = accessibility + var effectiveAccessibility = accessibility + effectiveAccessibility.isDarkerSystemColorsEnabled = accessibility + .isDarkerSystemColorsEnabled + || contrast == .increased + traits.accessibility = effectiveAccessibility return BContext(traits: traits, themes: themes) } } diff --git a/Shared/Broadway/BroadwayUI/Sources/BSurface.swift b/Shared/Broadway/BroadwayUI/Sources/BSurface.swift index ccd98c532..c32dd28be 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BSurface.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BSurface.swift @@ -49,6 +49,9 @@ public struct BSurface< .contentShape(shape) .modifier(finish) .overlay { exterior.allowsHitTesting(false).accessibilityHidden(true) } + // SwiftUI can include hidden overlay paint in a combined element's frame. + // Keep its focus contour on the native surface after exterior composition. + .contentShape(.accessibility, shape) } } diff --git a/Shared/Broadway/BroadwayUI/Sources/BViewDocumentStylesheet.swift b/Shared/Broadway/BroadwayUI/Sources/BViewDocumentStylesheet.swift index f6435c7e3..16c666d8f 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BViewDocumentStylesheet.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BViewDocumentStylesheet.swift @@ -7,6 +7,7 @@ import ViewDocumentUI /// Resolves document dimensions and decoration policy once per Broadway slice. public struct BViewDocumentStylesheet: BStylesheet, Sendable { public let background: Color + public let backgrounds: ViewDocumentAppearance.Backgrounds public let dimensionScales: [ViewFontDescriptor.TextStyle: Double] public let motionIsStatic: Bool public let showsDecoration: Bool @@ -16,12 +17,13 @@ public struct BViewDocumentStylesheet: BStylesheet, Sendable { } public init(traits: BTraits) { - let uiTraits = UITraitCollection(traitsFrom: [ - UITraitCollection(userInterfaceStyle: traits.mode == .dark ? .dark : .light), - UITraitCollection(accessibilityContrast: traits.accessibility - .isDarkerSystemColorsEnabled ? .high : .normal), - ]) - background = Color(uiColor: UIColor.systemBackground.resolvedColor(with: uiTraits)) + switch (traits.mode, traits.accessibility.isDarkerSystemColorsEnabled) { + case (.light, false): backgrounds = Self.lightBackgrounds + case (.dark, false): backgrounds = Self.darkBackgrounds + case (.light, true): backgrounds = Self.lightContrastBackgrounds + case (.dark, true): backgrounds = Self.darkContrastBackgrounds + } + background = backgrounds.primary var scales: [ViewFontDescriptor.TextStyle: Double] = [:] for style in ViewFontDescriptor.TextStyle.allCases { scales[style] = Double(BScaledDimension.value( @@ -40,13 +42,63 @@ public struct BViewDocumentStylesheet: BStylesheet, Sendable { private init() { background = .white + backgrounds = .init( + primary: .white, + secondary: .white, + grouped: .white, + secondaryGrouped: .white, + ) dimensionScales = [:] motionIsStatic = true showsDecoration = false } + // Cache the dynamic providers so equal trait slices keep equal Color identities. + private static let lightBackgrounds = makeBackgrounds(isDark: false, increasedContrast: false) + private static let darkBackgrounds = makeBackgrounds(isDark: true, increasedContrast: false) + private static let lightContrastBackgrounds = makeBackgrounds( + isDark: false, + increasedContrast: true, + ) + private static let darkContrastBackgrounds = makeBackgrounds( + isDark: true, + increasedContrast: true, + ) + + private enum BackgroundLevel { case primary, secondary, grouped, secondaryGrouped } + + private static func makeBackgrounds( + isDark: Bool, + increasedContrast: Bool, + ) -> ViewDocumentAppearance.Backgrounds { + func color(_ level: BackgroundLevel) -> Color { + Color(uiColor: UIColor { hostTraits in + // Mode and contrast belong to Broadway. Elevation belongs to the actual + // sheet/popover host and must remain dynamic until the color is drawn. + let traits = UITraitCollection(traitsFrom: [ + hostTraits, + UITraitCollection(userInterfaceStyle: isDark ? .dark : .light), + UITraitCollection(accessibilityContrast: increasedContrast ? .high : .normal), + ]) + let native: UIColor = switch level { + case .primary: .systemBackground + case .secondary: .secondarySystemBackground + case .grouped: .systemGroupedBackground + case .secondaryGrouped: .secondarySystemGroupedBackground + } + return native.resolvedColor(with: traits) + }) + } + return .init( + primary: color(.primary), + secondary: color(.secondary), + grouped: color(.grouped), + secondaryGrouped: color(.secondaryGrouped), + ) + } + public var appearance: ViewDocumentAppearance { - .init(background: background, dimensionScales: dimensionScales) + .init(backgrounds: backgrounds, dimensionScales: dimensionScales) } public func shows(_ role: BSurfaceLayerRole) -> Bool { diff --git a/Shared/Broadway/BroadwayUI/Tests/BRootViewTests.swift b/Shared/Broadway/BroadwayUI/Tests/BRootViewTests.swift index 24a5f5886..8ac00d1d6 100644 --- a/Shared/Broadway/BroadwayUI/Tests/BRootViewTests.swift +++ b/Shared/Broadway/BroadwayUI/Tests/BRootViewTests.swift @@ -12,6 +12,17 @@ private enum Marker: String, BTheme { @MainActor struct BRootContextTests { + @Test func anEnvironmentOverrideCannotWeakenSystemContrast() { + let context = BRootContext.make( + colorScheme: .light, + contrast: .standard, + dynamicTypeSize: .large, + accessibility: .init(isDarkerSystemColorsEnabled: true), + themes: .init(), + ) + #expect(context.traits.accessibility.isDarkerSystemColorsEnabled) + } + @Test("make maps SwiftUI system inputs into the context traits and themes") func mapsSystemInputs() { var themes = BThemes() @@ -19,6 +30,7 @@ struct BRootContextTests { let context = BRootContext.make( colorScheme: .dark, + contrast: .standard, dynamicTypeSize: .accessibility3, accessibility: BAccessibility(isVoiceOverRunning: true), themes: themes, @@ -33,6 +45,15 @@ struct BRootContextTests { @MainActor struct BRootViewHostingTests { + @Test func effectiveContrastReachesTheRootStylesheet() throws { + let box = ContextBox() + let host = UIHostingController(rootView: BRootView { ContextProbe(box: box) }) + host.traitOverrides.accessibilityContrast = .high + try show(host) { _ in + try waitFor { box.value?.traits.accessibility.isDarkerSystemColorsEnabled == true } + } + } + @Test("Root injects its themes into the descendant environment") func injectsThemes() throws { var themes = BThemes() diff --git a/Shared/Broadway/BroadwayUI/Tests/BViewDocumentStylesheetTests.swift b/Shared/Broadway/BroadwayUI/Tests/BViewDocumentStylesheetTests.swift index 9f5f2e275..2baa0ca08 100644 --- a/Shared/Broadway/BroadwayUI/Tests/BViewDocumentStylesheetTests.swift +++ b/Shared/Broadway/BroadwayUI/Tests/BViewDocumentStylesheetTests.swift @@ -1,10 +1,39 @@ import BroadwayCore import BroadwayUI +import SwiftUI import Testing +import UIKit import ViewDocumentCore @MainActor struct BViewDocumentStylesheetTests { + @Test(arguments: [UIUserInterfaceLevel.base, .elevated]) + func backgroundLevelsPinModeAndContrastButRetainHostElevation(level: UIUserInterfaceLevel) { + var traits = BTraits() + traits.mode = .dark + traits.accessibility = .init(isDarkerSystemColorsEnabled: true) + let appearance = BViewDocumentStylesheet(traits: traits).appearance + let host = UITraitCollection(traitsFrom: [ + UITraitCollection(userInterfaceStyle: .light), + UITraitCollection(accessibilityContrast: .normal), + UITraitCollection(userInterfaceLevel: level), + ]) + let expected = UITraitCollection(traitsFrom: [ + UITraitCollection(userInterfaceStyle: .dark), + UITraitCollection(accessibilityContrast: .high), + UITraitCollection(userInterfaceLevel: level), + ]) + #expect(UIColor(appearance.color(.semantic(.background))).resolvedColor(with: host) + == UIColor.systemBackground.resolvedColor(with: expected)) + #expect(UIColor(appearance.color(.semantic(.secondaryBackground))).resolvedColor(with: host) + == UIColor.secondarySystemBackground.resolvedColor(with: expected)) + #expect(UIColor(appearance.color(.semantic(.secondaryGroupedBackground))) + .resolvedColor(with: host) + == UIColor.secondarySystemGroupedBackground.resolvedColor(with: expected)) + #expect(appearance.background == BViewDocumentStylesheet(traits: traits).appearance + .background) + } + @Test func resolvesAccessibilityAndExplicitScaling() { var traits = BTraits.system traits.contentSizeCategory = .large diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.WithData_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.WithData_iPad_contrast.png index d420d5263..7ee7b9056 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.WithData_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.WithData_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b50313c53053e275bd139f5d05699ebe0a5d13cc309036ebdc243c6d39b47a60 -size 3622838 +oid sha256:b0aae6a1a9919d40d8bc6a43e6e0c4a9e6590126003801f2f4e559913a54f04a +size 3984209 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.WithData_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.WithData_iPhone_contrast.png index 693c477e6..406d53897 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.WithData_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.WithData_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f4069506f65fc227e39103173a44d195be2e07d48437e25b315b62f38d588956 -size 2108164 +oid sha256:7df4938662a7e82958d01ac6ca77d56eea75ef16fe61ec8eb95bf838425662b1 +size 2289161 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.YearOverview_iPad_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.YearOverview_iPad_contrast.png index 54f105909..34d8aff7f 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.YearOverview_iPad_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.YearOverview_iPad_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:657bab045358cf07cfe8f1b1407b939c50001c7b8f7ce34bdc75c13d165969f0 -size 2655544 +oid sha256:a0f515c7ed0aaa37247bccf10814199c649f78de50e788b7699ea1ea9b247438 +size 2971996 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.YearOverview_iPhone_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.YearOverview_iPhone_contrast.png index 847efd6bb..d5d074f36 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.YearOverview_iPhone_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/CalendarContentViewSnapshotTests/other/calendarContent.YearOverview_iPhone_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cbf989908ab77e870bdb4ba3f87be646aefbd10260eba54d8fc8c75fd34c49ba -size 1458440 +oid sha256:8a70623b8a1183efce9e004dc4229619314a1dd160cdd82c8965059ab8067327 +size 1616923 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationForecastPanelSnapshotTests/themes/other/componentThemes.Expanded_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationForecastPanelSnapshotTests/themes/other/componentThemes.Expanded_contrast.png index 8be7001b7..374a5e4f6 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationForecastPanelSnapshotTests/themes/other/componentThemes.Expanded_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationForecastPanelSnapshotTests/themes/other/componentThemes.Expanded_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ac79feaf20d819a639b3c277d767e6a7ade377ad3b61c8ef711f994f9168c658 -size 2751837 +oid sha256:c90f8366df42ccb86d9432dec81cae4b47a9c03495f914b7d2dbee9e5133bf29 +size 3092228 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationForecastSummarySnapshotTests/themes/other/componentThemes.Collapsed_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationForecastSummarySnapshotTests/themes/other/componentThemes.Collapsed_contrast.png index a8983ae3b..60fd532d3 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationForecastSummarySnapshotTests/themes/other/componentThemes.Collapsed_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationForecastSummarySnapshotTests/themes/other/componentThemes.Collapsed_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:790c62ea2a76ab64256548c21acd6a86ac8311e35d83f159240b1ebbf3346a7c -size 462810 +oid sha256:e23e068a4e97aeac0adf6b874102c871dc8e33a87c466fded938c811fbd5dc5e +size 471099 diff --git a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationForecastSummarySnapshotTests/themes/other/componentThemes.SummaryExpanded_contrast.png b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationForecastSummarySnapshotTests/themes/other/componentThemes.SummaryExpanded_contrast.png index 73369b7a7..a072822d5 100644 --- a/Where/WhereUI/SnapshotTests/__Snapshots__/LocationForecastSummarySnapshotTests/themes/other/componentThemes.SummaryExpanded_contrast.png +++ b/Where/WhereUI/SnapshotTests/__Snapshots__/LocationForecastSummarySnapshotTests/themes/other/componentThemes.SummaryExpanded_contrast.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26ac4601433a37ac80042a48efb39f2885e9c0faccf645db2a540fae9790084e -size 1901895 +oid sha256:651559b96a31eccd1e94cac1025ebeb9b48e5126883df1af7cca44bbdd59570d +size 1923005