From 329a96344f7e7b0c9afae80b6c17f7ad189bdbb3 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 14 Oct 2026 05:43:33 -0700 Subject: [PATCH 1/2] Add live layout size classes to Broadway Register typed horizontal and vertical traits, map SwiftUI root inputs, and expose scoped stylesheet overrides. Verify repeated trait changes in a mounted host. Validation: BroadwayCoreTests (118 tests), BroadwayUITests (20 tests), and architecture checks passed on pinned Xcode 27.0. --- Shared/Broadway/BroadwayCore/AGENTS.md | 2 + Shared/Broadway/BroadwayCore/README.md | 3 ++ .../BroadwayCore/Sources/Traits/BTraits.swift | 2 + .../Traits/Values/BHorizontalSizeClass.swift | 51 +++++++++++++++++++ .../Traits/Values/BVerticalSizeClass.swift | 51 +++++++++++++++++++ .../Tests/BHorizontalSizeClassTests.swift | 30 +++++++++++ .../Tests/BVerticalSizeClassTests.swift | 30 +++++++++++ Shared/Broadway/BroadwayUI/AGENTS.md | 2 +- Shared/Broadway/BroadwayUI/README.md | 6 ++- .../BroadwayUI/Sources/BRootView.swift | 8 +++ .../Sources/BTraitOverrides+SwiftUI.swift | 10 ++++ .../Sources/BTraitValues+SwiftUI.swift | 24 +++++++++ .../BroadwayUI/Tests/BRootViewTests.swift | 22 +++++++- .../Tests/BTraitValues+SwiftUITests.swift | 11 ++++ 14 files changed, 249 insertions(+), 3 deletions(-) create mode 100644 Shared/Broadway/BroadwayCore/Sources/Traits/Values/BHorizontalSizeClass.swift create mode 100644 Shared/Broadway/BroadwayCore/Sources/Traits/Values/BVerticalSizeClass.swift create mode 100644 Shared/Broadway/BroadwayCore/Tests/BHorizontalSizeClassTests.swift create mode 100644 Shared/Broadway/BroadwayCore/Tests/BVerticalSizeClassTests.swift diff --git a/Shared/Broadway/BroadwayCore/AGENTS.md b/Shared/Broadway/BroadwayCore/AGENTS.md index f1e0c1007..8cd09e26b 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`.** Preserve unspecified values; do not infer them from device identity. + ## Testing Run `BroadwayCoreTests` in `StuffTestHost` (`./test BroadwayCoreTests`). diff --git a/Shared/Broadway/BroadwayCore/README.md b/Shared/Broadway/BroadwayCore/README.md index 136904a3e..714b4c514 100644 --- a/Shared/Broadway/BroadwayCore/README.md +++ b/Shared/Broadway/BroadwayCore/README.md @@ -26,6 +26,9 @@ let opacity = BModeVarying(light: 0.14, dark: 0.3) let resolvedOpacity = opacity[context.traits.mode] ``` +`BHorizontalSizeClass` and `BVerticalSizeClass` preserve compact, regular, and unspecified values. +`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..eb9a4f4f8 --- /dev/null +++ b/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BHorizontalSizeClass.swift @@ -0,0 +1,51 @@ +import UIKit + +/// The horizontal space available to a component. Unspecified preserves an unknown system value. +public enum BHorizontalSizeClass: Hashable, Sendable { + case unspecified + case compact + case regular + + public init(_ sizeClass: UIUserInterfaceSizeClass) { + self = switch sizeClass { + case .unspecified: .unspecified + case .compact: .compact + case .regular: .regular + @unknown default: .unspecified + } + } +} + +extension BHorizontalSizeClass: BTraitsValue { + public static let defaultValue: Self = .unspecified + + @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..cc4ed72a3 --- /dev/null +++ b/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BVerticalSizeClass.swift @@ -0,0 +1,51 @@ +import UIKit + +/// The vertical space available to a component. Unspecified preserves an unknown system value. +public enum BVerticalSizeClass: Hashable, Sendable { + case unspecified + case compact + case regular + + public init(_ sizeClass: UIUserInterfaceSizeClass) { + self = switch sizeClass { + case .unspecified: .unspecified + case .compact: .compact + case .regular: .regular + @unknown default: .unspecified + } + } +} + +extension BVerticalSizeClass: BTraitsValue { + public static let defaultValue: Self = .unspecified + + @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..8669fdacc --- /dev/null +++ b/Shared/Broadway/BroadwayCore/Tests/BHorizontalSizeClassTests.swift @@ -0,0 +1,30 @@ +@testable import BroadwayCore +import TestHostSupport +import Testing +import UIKit + +@MainActor +struct BHorizontalSizeClassTests { + @Test func mapsUIKitValuesAndPreservesUnspecified() { + #expect(BHorizontalSizeClass(.unspecified) == .unspecified) + #expect(BHorizontalSizeClass(.compact) == .compact) + #expect(BHorizontalSizeClass(.regular) == .regular) + } + + @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..fbd4e635f --- /dev/null +++ b/Shared/Broadway/BroadwayCore/Tests/BVerticalSizeClassTests.swift @@ -0,0 +1,30 @@ +@testable import BroadwayCore +import TestHostSupport +import Testing +import UIKit + +@MainActor +struct BVerticalSizeClassTests { + @Test func mapsUIKitValuesAndPreservesUnspecified() { + #expect(BVerticalSizeClass(.unspecified) == .unspecified) + #expect(BVerticalSizeClass(.compact) == .compact) + #expect(BVerticalSizeClass(.regular) == .regular) + } + + @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..db922c0b6 100644 --- a/Shared/Broadway/BroadwayUI/AGENTS.md +++ b/Shared/Broadway/BroadwayUI/AGENTS.md @@ -9,7 +9,7 @@ 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. - **Make `\.bContext` prefer a synchronous SwiftUI value, and mirror to UIKit.** diff --git a/Shared/Broadway/BroadwayUI/README.md b/Shared/Broadway/BroadwayUI/README.md index 7b2972508..b653e168b 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,10 @@ WindowGroup { } ``` +The root observes horizontal and vertical size classes through SwiftUI. +`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..653ac79bc 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 optional size class without assuming a device or orientation. + public init(_ sizeClass: UserInterfaceSizeClass?) { + self = switch sizeClass { + case .compact: .compact + case .regular: .regular + case nil: .unspecified + @unknown default: .unspecified + } + } +} + +extension BVerticalSizeClass { + /// Maps SwiftUI's optional size class without assuming a device or orientation. + public init(_ sizeClass: UserInterfaceSizeClass?) { + self = switch sizeClass { + case .compact: .compact + case .regular: .regular + case nil: .unspecified + @unknown default: .unspecified + } + } +} 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..442f74a11 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 mapsSizeClasses() { + #expect(BHorizontalSizeClass(UserInterfaceSizeClass.compact) == .compact) + #expect(BHorizontalSizeClass(UserInterfaceSizeClass.regular) == .regular) + #expect(BHorizontalSizeClass(nil) == .unspecified) + #expect(BVerticalSizeClass(UserInterfaceSizeClass.compact) == .compact) + #expect(BVerticalSizeClass(UserInterfaceSizeClass.regular) == .regular) + #expect(BVerticalSizeClass(nil) == .unspecified) + } +} From e69865228b5ebb1fae71b5ff8c18e7968f3c5994 Mon Sep 17 00:00:00 2001 From: Kyle Van Essen Date: Wed, 11 Nov 2026 11:55:34 -0500 Subject: [PATCH 2/2] fix(Broadway): normalize unavailable size classes to compact Expose only compact and regular on both axes. Normalize unspecified UIKit values and missing SwiftUI values at the bridge, and keep defaults, live observation, and scoped overrides consistent. Validation: 141 Broadway tests passed from clean artifacts; architecture checks and SwiftFormat passed. Shared-cache attempts failed on stale LifecycleKit deployment metadata; isolated build and execution passed. --- Shared/Broadway/BroadwayCore/AGENTS.md | 2 +- Shared/Broadway/BroadwayCore/README.md | 3 ++- .../Sources/Traits/Values/BHorizontalSizeClass.swift | 9 ++++----- .../Sources/Traits/Values/BVerticalSizeClass.swift | 9 ++++----- .../Tests/BHorizontalSizeClassTests.swift | 6 ++++-- .../BroadwayCore/Tests/BVerticalSizeClassTests.swift | 6 ++++-- Shared/Broadway/BroadwayUI/AGENTS.md | 1 + Shared/Broadway/BroadwayUI/README.md | 2 ++ .../BroadwayUI/Sources/BTraitValues+SwiftUI.swift | 12 ++++++------ .../BroadwayUI/Tests/BTraitValues+SwiftUITests.swift | 6 +++--- 10 files changed, 31 insertions(+), 25 deletions(-) diff --git a/Shared/Broadway/BroadwayCore/AGENTS.md b/Shared/Broadway/BroadwayCore/AGENTS.md index 8cd09e26b..af2d5db61 100644 --- a/Shared/Broadway/BroadwayCore/AGENTS.md +++ b/Shared/Broadway/BroadwayCore/AGENTS.md @@ -21,7 +21,7 @@ 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`.** Preserve unspecified values; do not infer them from device identity. +- **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 diff --git a/Shared/Broadway/BroadwayCore/README.md b/Shared/Broadway/BroadwayCore/README.md index 714b4c514..698066730 100644 --- a/Shared/Broadway/BroadwayCore/README.md +++ b/Shared/Broadway/BroadwayCore/README.md @@ -26,7 +26,8 @@ let opacity = BModeVarying(light: 0.14, dark: 0.3) let resolvedOpacity = opacity[context.traits.mode] ``` -`BHorizontalSizeClass` and `BVerticalSizeClass` preserve compact, regular, and unspecified values. +`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 diff --git a/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BHorizontalSizeClass.swift b/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BHorizontalSizeClass.swift index eb9a4f4f8..45185f39f 100644 --- a/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BHorizontalSizeClass.swift +++ b/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BHorizontalSizeClass.swift @@ -1,23 +1,22 @@ import UIKit -/// The horizontal space available to a component. Unspecified preserves an unknown system value. +/// The horizontal space available to a component. Unavailable system values use compact styling. public enum BHorizontalSizeClass: Hashable, Sendable { - case unspecified case compact case regular public init(_ sizeClass: UIUserInterfaceSizeClass) { self = switch sizeClass { - case .unspecified: .unspecified + case .unspecified: .compact case .compact: .compact case .regular: .regular - @unknown default: .unspecified + @unknown default: .compact } } } extension BHorizontalSizeClass: BTraitsValue { - public static let defaultValue: Self = .unspecified + public static let defaultValue: Self = .compact @MainActor public static func currentValue(from viewController: UIViewController) -> Self { Self(viewController.traitCollection.horizontalSizeClass) diff --git a/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BVerticalSizeClass.swift b/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BVerticalSizeClass.swift index cc4ed72a3..073846d0d 100644 --- a/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BVerticalSizeClass.swift +++ b/Shared/Broadway/BroadwayCore/Sources/Traits/Values/BVerticalSizeClass.swift @@ -1,23 +1,22 @@ import UIKit -/// The vertical space available to a component. Unspecified preserves an unknown system value. +/// The vertical space available to a component. Unavailable system values use compact styling. public enum BVerticalSizeClass: Hashable, Sendable { - case unspecified case compact case regular public init(_ sizeClass: UIUserInterfaceSizeClass) { self = switch sizeClass { - case .unspecified: .unspecified + case .unspecified: .compact case .compact: .compact case .regular: .regular - @unknown default: .unspecified + @unknown default: .compact } } } extension BVerticalSizeClass: BTraitsValue { - public static let defaultValue: Self = .unspecified + public static let defaultValue: Self = .compact @MainActor public static func currentValue(from viewController: UIViewController) -> Self { Self(viewController.traitCollection.verticalSizeClass) diff --git a/Shared/Broadway/BroadwayCore/Tests/BHorizontalSizeClassTests.swift b/Shared/Broadway/BroadwayCore/Tests/BHorizontalSizeClassTests.swift index 8669fdacc..b07c09a09 100644 --- a/Shared/Broadway/BroadwayCore/Tests/BHorizontalSizeClassTests.swift +++ b/Shared/Broadway/BroadwayCore/Tests/BHorizontalSizeClassTests.swift @@ -5,10 +5,12 @@ import UIKit @MainActor struct BHorizontalSizeClassTests { - @Test func mapsUIKitValuesAndPreservesUnspecified() { - #expect(BHorizontalSizeClass(.unspecified) == .unspecified) + @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 { diff --git a/Shared/Broadway/BroadwayCore/Tests/BVerticalSizeClassTests.swift b/Shared/Broadway/BroadwayCore/Tests/BVerticalSizeClassTests.swift index fbd4e635f..565cdfabf 100644 --- a/Shared/Broadway/BroadwayCore/Tests/BVerticalSizeClassTests.swift +++ b/Shared/Broadway/BroadwayCore/Tests/BVerticalSizeClassTests.swift @@ -5,10 +5,12 @@ import UIKit @MainActor struct BVerticalSizeClassTests { - @Test func mapsUIKitValuesAndPreservesUnspecified() { - #expect(BVerticalSizeClass(.unspecified) == .unspecified) + @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 { diff --git a/Shared/Broadway/BroadwayUI/AGENTS.md b/Shared/Broadway/BroadwayUI/AGENTS.md index db922c0b6..e00675afa 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, 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 b653e168b..500fa6e8d 100644 --- a/Shared/Broadway/BroadwayUI/README.md +++ b/Shared/Broadway/BroadwayUI/README.md @@ -31,6 +31,8 @@ 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. diff --git a/Shared/Broadway/BroadwayUI/Sources/BTraitValues+SwiftUI.swift b/Shared/Broadway/BroadwayUI/Sources/BTraitValues+SwiftUI.swift index 653ac79bc..07eeebb0e 100644 --- a/Shared/Broadway/BroadwayUI/Sources/BTraitValues+SwiftUI.swift +++ b/Shared/Broadway/BroadwayUI/Sources/BTraitValues+SwiftUI.swift @@ -43,25 +43,25 @@ extension BContentSizeCategory { } extension BHorizontalSizeClass { - /// Maps SwiftUI's optional size class without assuming a device or orientation. + /// 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: .unspecified - @unknown default: .unspecified + case nil: .compact + @unknown default: .compact } } } extension BVerticalSizeClass { - /// Maps SwiftUI's optional size class without assuming a device or orientation. + /// 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: .unspecified - @unknown default: .unspecified + case nil: .compact + @unknown default: .compact } } } diff --git a/Shared/Broadway/BroadwayUI/Tests/BTraitValues+SwiftUITests.swift b/Shared/Broadway/BroadwayUI/Tests/BTraitValues+SwiftUITests.swift index 442f74a11..7df6319b2 100644 --- a/Shared/Broadway/BroadwayUI/Tests/BTraitValues+SwiftUITests.swift +++ b/Shared/Broadway/BroadwayUI/Tests/BTraitValues+SwiftUITests.swift @@ -30,12 +30,12 @@ struct BContentSizeCategorySwiftUIMappingTests { } struct BSizeClassSwiftUIMappingTests { - @Test func mapsSizeClasses() { + @Test func mapsSizeClassesAndDefaultsToCompact() { #expect(BHorizontalSizeClass(UserInterfaceSizeClass.compact) == .compact) #expect(BHorizontalSizeClass(UserInterfaceSizeClass.regular) == .regular) - #expect(BHorizontalSizeClass(nil) == .unspecified) + #expect(BHorizontalSizeClass(nil) == .compact) #expect(BVerticalSizeClass(UserInterfaceSizeClass.compact) == .compact) #expect(BVerticalSizeClass(UserInterfaceSizeClass.regular) == .regular) - #expect(BVerticalSizeClass(nil) == .unspecified) + #expect(BVerticalSizeClass(nil) == .compact) } }