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
2 changes: 2 additions & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,8 @@ let package = Package(
name: "BroadwayUI",
dependencies: [
.target(name: "BroadwayCore"),
.target(name: "ViewDocumentCore"),
.target(name: "ViewDocumentUI"),
],
path: "Shared/Broadway/BroadwayUI/Sources",
),
Expand Down
4 changes: 3 additions & 1 deletion Shared/Broadway/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
8 changes: 8 additions & 0 deletions Shared/Broadway/BroadwayCore/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,11 @@ 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<Value>` 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.
2 changes: 2 additions & 0 deletions Shared/Broadway/BroadwayCore/Sources/BModeVarying.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,5 @@ public struct BModeVarying<Value> {
extension BModeVarying: Equatable where Value: Equatable {}
extension BModeVarying: Hashable where Value: Hashable {}
extension BModeVarying: Sendable where Value: Sendable {}

extension BModeVarying: Codable where Value: Codable {}
12 changes: 12 additions & 0 deletions Shared/Broadway/BroadwayCore/Sources/BThemeValue.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/// A portable authored value, either constant or selected by the explicit slice mode.
public enum BThemeValue<Value: Codable & Hashable & Sendable>: Codable, Hashable, Sendable {
case constant(Value)
case modes(BModeVarying<Value>)

public func resolve(mode: BMode) -> Value {
switch self {
case let .constant(value): value
case let .modes(values): values[mode]
}
}
}
32 changes: 32 additions & 0 deletions Shared/Broadway/BroadwayCore/Sources/CopyOnWrite.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Value: Decodable>(
_: CopyOnWrite<Value?>.Type,
forKey key: Key,
) throws -> CopyOnWrite<Value?> {
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
Expand Down
3 changes: 2 additions & 1 deletion Shared/Broadway/BroadwayCore/Sources/Traits/BTraits.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions Shared/Broadway/BroadwayCore/Tests/BThemeValueTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import BroadwayCore
import Foundation
import Testing

struct BThemeValueTests {
@Test func roundTripsAndResolvesExplicitMode() throws {
let values: [BThemeValue<Double>] = [.constant(0), .modes(.init(light: 1, dark: 2))]
let decoded = try JSONDecoder().decode(
[BThemeValue<Double>].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)
}
}
45 changes: 45 additions & 0 deletions Shared/Broadway/BroadwayCore/Tests/CopyOnWriteTests.swift
Original file line number Diff line number Diff line change
@@ -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<Date>.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<Data>.self, from: encoder.encode(data))
.wrappedValue == data)
}

// MARK: - Initialization

@Test("Stores the initial value")
Expand Down
8 changes: 7 additions & 1 deletion Shared/Broadway/BroadwayUI/AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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.
Expand All @@ -21,3 +22,8 @@ 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.
- 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.
29 changes: 28 additions & 1 deletion Shared/Broadway/BroadwayUI/README.md
Original file line number Diff line number Diff line change
@@ -1,11 +1,28 @@
# 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.
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.

`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

- **`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`).

Expand Down Expand Up @@ -34,3 +51,13 @@ 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.

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.
124 changes: 124 additions & 0 deletions Shared/Broadway/BroadwayUI/Sources/BArtworkLayer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import SwiftUI
import ViewDocumentCore

/// Hosts prepared document or native artwork in paint bounds that never change the host's size.
public struct BArtworkLayer<SurfaceShape: InsettableShape, Content: View>: 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.
@Environment(\.layoutDirection) private var layoutDirection

public init(
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()
}

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,
maximumOutsets: maximumOutsets,
)
let frame = geometry.frame
let bounds = geometry.paintBounds
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,
)
.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<SurfaceShape: InsettableShape>: 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
Loading
Loading