diff --git a/Shared/Broadway/BroadwayCore/AGENTS.md b/Shared/Broadway/BroadwayCore/AGENTS.md index f1e0c1007..af2d5db61 100644 --- a/Shared/Broadway/BroadwayCore/AGENTS.md +++ b/Shared/Broadway/BroadwayCore/AGENTS.md @@ -21,6 +21,8 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](. - Select `BModeVarying` values with the slice's explicit mode. Keep product theme definitions in consumer modules. +- **Keep both size classes registered in `BTraits.system`.** Expose only compact and regular values. Normalize unavailable UIKit values to compact; do not infer size classes from device identity or measured bounds. + ## Testing Run `BroadwayCoreTests` in `StuffTestHost` (`./test BroadwayCoreTests`). diff --git a/Shared/Broadway/BroadwayCore/README.md b/Shared/Broadway/BroadwayCore/README.md index 136904a3e..698066730 100644 --- a/Shared/Broadway/BroadwayCore/README.md +++ b/Shared/Broadway/BroadwayCore/README.md @@ -26,6 +26,10 @@ let opacity = BModeVarying(light: 0.14, dark: 0.3) let resolvedOpacity = opacity[context.traits.mode] ``` +`BHorizontalSizeClass` and `BVerticalSizeClass` expose only compact and regular values. +Both default to compact. Their UIKit initializers map unspecified and unknown values to compact, so styling consumers need only two cases. +`BTraits.system` registers both for live UIKit observation. Scoped overrides use the matching properties on `BTraits.Overrides`. + ## How it works `BContext` holds a `BStylesheets` cache that is rebuilt whenever `baseTraits`, `traitOverrides`, or `themes` change (via `didSet`). diff --git a/Shared/Broadway/BroadwayCore/Sources/Traits/BTraits.swift b/Shared/Broadway/BroadwayCore/Sources/Traits/BTraits.swift index df2502bda..c6d52f2aa 100644 --- a/Shared/Broadway/BroadwayCore/Sources/Traits/BTraits.swift +++ b/Shared/Broadway/BroadwayCore/Sources/Traits/BTraits.swift @@ -27,6 +27,8 @@ public struct BTraits: Equatable, Hashable, @unchecked Sendable { traits.register(BAccessibility.self) traits.register(BMode.self) traits.register(BContentSizeCategory.self) + traits.register(BHorizontalSizeClass.self) + traits.register(BVerticalSizeClass.self) return traits } diff --git a/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BHorizontalSizeClass.swift b/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BHorizontalSizeClass.swift new file mode 100644 index 000000000..45185f39f --- /dev/null +++ b/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BHorizontalSizeClass.swift @@ -0,0 +1,50 @@ +import UIKit + +/// The horizontal space available to a component. Unavailable system values use compact styling. +public enum BHorizontalSizeClass: Hashable, Sendable { + case compact + case regular + + public init(_ sizeClass: UIUserInterfaceSizeClass) { + self = switch sizeClass { + case .unspecified: .compact + case .compact: .compact + case .regular: .regular + @unknown default: .compact + } + } +} + +extension BHorizontalSizeClass: BTraitsValue { + public static let defaultValue: Self = .compact + + @MainActor public static func currentValue(from viewController: UIViewController) -> Self { + Self(viewController.traitCollection.horizontalSizeClass) + } + + @MainActor public static func makeObserver( + with viewController: UIViewController, + onChange: @MainActor @escaping @Sendable (Self) -> Void, + ) -> UIViewControllerTraitObserver { + UIViewControllerTraitObserver( + observing: [UITraitHorizontalSizeClass.self], + on: viewController, + ) { controller in + onChange(currentValue(from: controller)) + } + } +} + +extension BTraits { + public var horizontalSizeClass: BHorizontalSizeClass { + get { self[BHorizontalSizeClass.self] } + set { self[BHorizontalSizeClass.self] = newValue } + } +} + +extension BTraits.Overrides { + public var horizontalSizeClass: BHorizontalSizeClass? { + get { self[BHorizontalSizeClass.self] } + set { self[BHorizontalSizeClass.self] = newValue } + } +} diff --git a/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BVerticalSizeClass.swift b/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BVerticalSizeClass.swift new file mode 100644 index 000000000..073846d0d --- /dev/null +++ b/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BVerticalSizeClass.swift @@ -0,0 +1,50 @@ +import UIKit + +/// The vertical space available to a component. Unavailable system values use compact styling. +public enum BVerticalSizeClass: Hashable, Sendable { + case compact + case regular + + public init(_ sizeClass: UIUserInterfaceSizeClass) { + self = switch sizeClass { + case .unspecified: .compact + case .compact: .compact + case .regular: .regular + @unknown default: .compact + } + } +} + +extension BVerticalSizeClass: BTraitsValue { + public static let defaultValue: Self = .compact + + @MainActor public static func currentValue(from viewController: UIViewController) -> Self { + Self(viewController.traitCollection.verticalSizeClass) + } + + @MainActor public static func makeObserver( + with viewController: UIViewController, + onChange: @MainActor @escaping @Sendable (Self) -> Void, + ) -> UIViewControllerTraitObserver { + UIViewControllerTraitObserver( + observing: [UITraitVerticalSizeClass.self], + on: viewController, + ) { controller in + onChange(currentValue(from: controller)) + } + } +} + +extension BTraits { + public var verticalSizeClass: BVerticalSizeClass { + get { self[BVerticalSizeClass.self] } + set { self[BVerticalSizeClass.self] = newValue } + } +} + +extension BTraits.Overrides { + public var verticalSizeClass: BVerticalSizeClass? { + get { self[BVerticalSizeClass.self] } + set { self[BVerticalSizeClass.self] = newValue } + } +} diff --git a/Shared/Broadway/BroadwayCore/Tests/BHorizontalSizeClassTests.swift b/Shared/Broadway/BroadwayCore/Tests/BHorizontalSizeClassTests.swift new file mode 100644 index 000000000..b07c09a09 --- /dev/null +++ b/Shared/Broadway/BroadwayCore/Tests/BHorizontalSizeClassTests.swift @@ -0,0 +1,32 @@ +@testable import BroadwayCore +import TestHostSupport +import Testing +import UIKit + +@MainActor +struct BHorizontalSizeClassTests { + @Test func mapsUIKitValuesAndDefaultsToCompact() { + #expect(BHorizontalSizeClass(.unspecified) == .compact) + #expect(BHorizontalSizeClass(.compact) == .compact) + #expect(BHorizontalSizeClass(.regular) == .regular) + #expect(BTraits().horizontalSizeClass == .compact) + #expect(BTraits.system.horizontalSizeClass == .compact) + } + + @Test func observesLiveChangesAndScopedOverrides() throws { + let host = UIViewController() + host.traitOverrides.horizontalSizeClass = .compact + try show(host) { controller in + let observer = BTraitsObserver(traits: .system, from: controller, onChange: { _ in }) + observer.start() + defer { observer.stop() } + #expect(observer.traits.horizontalSizeClass == .compact) + controller.traitOverrides.horizontalSizeClass = .regular + try waitFor { observer.traits.horizontalSizeClass == .regular } + var overrides = BTraits.Overrides() + overrides.horizontalSizeClass = .compact + #expect(observer.traits.merging(with: overrides).horizontalSizeClass == .compact) + #expect(observer.traits.horizontalSizeClass == .regular) + } + } +} diff --git a/Shared/Broadway/BroadwayCore/Tests/BVerticalSizeClassTests.swift b/Shared/Broadway/BroadwayCore/Tests/BVerticalSizeClassTests.swift new file mode 100644 index 000000000..565cdfabf --- /dev/null +++ b/Shared/Broadway/BroadwayCore/Tests/BVerticalSizeClassTests.swift @@ -0,0 +1,32 @@ +@testable import BroadwayCore +import TestHostSupport +import Testing +import UIKit + +@MainActor +struct BVerticalSizeClassTests { + @Test func mapsUIKitValuesAndDefaultsToCompact() { + #expect(BVerticalSizeClass(.unspecified) == .compact) + #expect(BVerticalSizeClass(.compact) == .compact) + #expect(BVerticalSizeClass(.regular) == .regular) + #expect(BTraits().verticalSizeClass == .compact) + #expect(BTraits.system.verticalSizeClass == .compact) + } + + @Test func observesLiveChangesAndScopedOverrides() throws { + let host = UIViewController() + host.traitOverrides.verticalSizeClass = .compact + try show(host) { controller in + let observer = BTraitsObserver(traits: .system, from: controller, onChange: { _ in }) + observer.start() + defer { observer.stop() } + #expect(observer.traits.verticalSizeClass == .compact) + controller.traitOverrides.verticalSizeClass = .regular + try waitFor { observer.traits.verticalSizeClass == .regular } + var overrides = BTraits.Overrides() + overrides.verticalSizeClass = .compact + #expect(observer.traits.merging(with: overrides).verticalSizeClass == .compact) + #expect(observer.traits.verticalSizeClass == .regular) + } + } +} diff --git a/Shared/Broadway/BroadwayUI/AGENTS.md b/Shared/Broadway/BroadwayUI/AGENTS.md index 0c9efb30d..e00675afa 100644 --- a/Shared/Broadway/BroadwayUI/AGENTS.md +++ b/Shared/Broadway/BroadwayUI/AGENTS.md @@ -9,9 +9,10 @@ Read the root [`AGENTS.md`](../../../AGENTS.md) and the group [`../AGENTS.md`](. - **Keep shared components here only.** Put app-specific views in BroadwayCatalog. - **Defer `BRootViewController` setup** until the controller enters a valid hierarchy (`viewIsAppearing`). Before then, `context` is `nil`. - **The controller publishes context to descendants through `traitOverrides.bContext`.** -- **Do not add `BTraitsObserver` to `BRootView`.** SwiftUI re-evaluates `body` on color-scheme and Dynamic Type changes. +- **Do not add `BTraitsObserver` to `BRootView`.** SwiftUI re-evaluates `body` on color-scheme, Dynamic Type, and size-class 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. +- **Map unavailable SwiftUI size classes to compact on both axes.** Keep this consistent with BroadwayCore's UIKit mapping. - **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. diff --git a/Shared/Broadway/BroadwayUI/README.md b/Shared/Broadway/BroadwayUI/README.md index 7b2972508..500fa6e8d 100644 --- a/Shared/Broadway/BroadwayUI/README.md +++ b/Shared/Broadway/BroadwayUI/README.md @@ -5,7 +5,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)`, `\.dynamicTypeSize`, both size classes, 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`). @@ -30,6 +30,12 @@ WindowGroup { } ``` +The root observes horizontal and vertical size classes through SwiftUI. +Missing or unknown size classes resolve to compact on both axes, matching the UIKit bridge. +Later explicit values update the context normally. +`bHorizontalSizeClass(_:)` and `bVerticalSizeClass(_:)` scope Broadway layout policies to a subtree. +These overrides affect stylesheets; native container traits remain under system control. + ## Install Local SPM library declared in the root [`Package.swift`](../../../Package.swift) (depends on BroadwayCore): `.package(product: "BroadwayUI")`. diff --git a/Shared/Broadway/BroadwayUI/Sources/BRootView.swift b/Shared/Broadway/BroadwayUI/Sources/BRootView.swift index 41f5146d3..0960b7607 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BRootView.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BRootView.swift @@ -23,6 +23,8 @@ public struct BRootView: View { @Environment(\.colorScheme) private var colorScheme @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @Environment(\.verticalSizeClass) private var verticalSizeClass @State private var accessibility: BAccessibility = .current() public init(themes: BThemes = .init(), @ViewBuilder content: () -> Content) { @@ -35,6 +37,8 @@ public struct BRootView: View { .environment(\.bContext, BRootContext.make( colorScheme: colorScheme, dynamicTypeSize: dynamicTypeSize, + horizontalSizeClass: horizontalSizeClass, + verticalSizeClass: verticalSizeClass, accessibility: accessibility, themes: themes, )) @@ -65,6 +69,8 @@ enum BRootContext { static func make( colorScheme: ColorScheme, dynamicTypeSize: DynamicTypeSize, + horizontalSizeClass: UserInterfaceSizeClass?, + verticalSizeClass: UserInterfaceSizeClass?, accessibility: BAccessibility, themes: BThemes, ) -> BContext { @@ -79,6 +85,8 @@ enum BRootContext { // there instead when a token needs to follow a scoped scheme override. traits.mode = BMode(colorScheme) traits.contentSizeCategory = BContentSizeCategory(dynamicTypeSize) + traits.horizontalSizeClass = BHorizontalSizeClass(horizontalSizeClass) + traits.verticalSizeClass = BVerticalSizeClass(verticalSizeClass) traits.accessibility = accessibility return BContext(traits: traits, themes: themes) } diff --git a/Shared/Broadway/BroadwayUI/Sources/BTraitOverrides+SwiftUI.swift b/Shared/Broadway/BroadwayUI/Sources/BTraitOverrides+SwiftUI.swift index c554aaae4..402a6bfa3 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BTraitOverrides+SwiftUI.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BTraitOverrides+SwiftUI.swift @@ -27,4 +27,14 @@ extension View { public func bContentSizeCategory(_ category: BContentSizeCategory) -> some View { bTraitOverrides { _, overrides in overrides.contentSizeCategory = category } } + + /// Overrides the horizontal layout trait for this subtree's stylesheets. + public func bHorizontalSizeClass(_ sizeClass: BHorizontalSizeClass) -> some View { + bTraitOverrides { _, overrides in overrides.horizontalSizeClass = sizeClass } + } + + /// Overrides the vertical layout trait for this subtree's stylesheets. + public func bVerticalSizeClass(_ sizeClass: BVerticalSizeClass) -> some View { + bTraitOverrides { _, overrides in overrides.verticalSizeClass = sizeClass } + } } diff --git a/Shared/Broadway/BroadwayUI/Sources/BTraitValues+SwiftUI.swift b/Shared/Broadway/BroadwayUI/Sources/BTraitValues+SwiftUI.swift index 1e9e188ec..07eeebb0e 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BTraitValues+SwiftUI.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BTraitValues+SwiftUI.swift @@ -41,3 +41,27 @@ extension BContentSizeCategory { } } } + +extension BHorizontalSizeClass { + /// Maps SwiftUI's size class, using compact styling when the value is unavailable. + public init(_ sizeClass: UserInterfaceSizeClass?) { + self = switch sizeClass { + case .compact: .compact + case .regular: .regular + case nil: .compact + @unknown default: .compact + } + } +} + +extension BVerticalSizeClass { + /// Maps SwiftUI's size class, using compact styling when the value is unavailable. + public init(_ sizeClass: UserInterfaceSizeClass?) { + self = switch sizeClass { + case .compact: .compact + case .regular: .regular + case nil: .compact + @unknown default: .compact + } + } +} diff --git a/Shared/Broadway/BroadwayUI/Tests/BRootViewTests.swift b/Shared/Broadway/BroadwayUI/Tests/BRootViewTests.swift index 24a5f5886..ba199cee6 100644 --- a/Shared/Broadway/BroadwayUI/Tests/BRootViewTests.swift +++ b/Shared/Broadway/BroadwayUI/Tests/BRootViewTests.swift @@ -20,11 +20,15 @@ struct BRootContextTests { let context = BRootContext.make( colorScheme: .dark, dynamicTypeSize: .accessibility3, + horizontalSizeClass: .regular, + verticalSizeClass: .compact, accessibility: BAccessibility(isVoiceOverRunning: true), themes: themes, ) #expect(context.traits.mode == .dark) + #expect(context.traits.horizontalSizeClass == .regular) + #expect(context.traits.verticalSizeClass == .compact) #expect(context.traits.contentSizeCategory == .accessibilityExtraLarge) #expect(context.traits.accessibility == BAccessibility(isVoiceOverRunning: true)) #expect(context.themes[Marker.self] == .b) @@ -33,6 +37,22 @@ struct BRootContextTests { @MainActor struct BRootViewHostingTests { + @Test("A mounted root follows repeated size-class changes without replacing content") + func followsResizing() throws { + let box = ContextBox() + let host = UIHostingController(rootView: BRootView { ContextProbe(box: box) }) + try show(host) { controller in + for sizeClass in [UIUserInterfaceSizeClass.compact, .regular, .compact] { + controller.traitOverrides.horizontalSizeClass = sizeClass + controller.traitOverrides.verticalSizeClass = .regular + try waitFor { + box.value?.traits.horizontalSizeClass == BHorizontalSizeClass(sizeClass) + && box.value?.traits.verticalSizeClass == .regular + } + } + } + } + @Test("Root injects its themes into the descendant environment") func injectsThemes() throws { var themes = BThemes() @@ -63,6 +83,6 @@ private struct ContextProbe: View { @Environment(\.bContext) private var context var body: some View { - Color.clear.onAppear { box.value = context } + Color.clear.onChange(of: context, initial: true) { _, value in box.value = value } } } diff --git a/Shared/Broadway/BroadwayUI/Tests/BTraitValues+SwiftUITests.swift b/Shared/Broadway/BroadwayUI/Tests/BTraitValues+SwiftUITests.swift index a6159ecbd..7df6319b2 100644 --- a/Shared/Broadway/BroadwayUI/Tests/BTraitValues+SwiftUITests.swift +++ b/Shared/Broadway/BroadwayUI/Tests/BTraitValues+SwiftUITests.swift @@ -28,3 +28,14 @@ struct BContentSizeCategorySwiftUIMappingTests { #expect(BContentSizeCategory(.accessibility5) == .accessibilityExtraExtraExtraLarge) } } + +struct BSizeClassSwiftUIMappingTests { + @Test func mapsSizeClassesAndDefaultsToCompact() { + #expect(BHorizontalSizeClass(UserInterfaceSizeClass.compact) == .compact) + #expect(BHorizontalSizeClass(UserInterfaceSizeClass.regular) == .regular) + #expect(BHorizontalSizeClass(nil) == .compact) + #expect(BVerticalSizeClass(UserInterfaceSizeClass.compact) == .compact) + #expect(BVerticalSizeClass(UserInterfaceSizeClass.regular) == .regular) + #expect(BVerticalSizeClass(nil) == .compact) + } +}