Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
f205942
Simplify directory trees in AGENTS.md and README.md
kyleve Mar 31, 2026
cbad878
Remove now un-nedded placeholder files
kyleve Mar 31, 2026
39e93ee
BTraitsObserver: protocol-driven unified trait observation
kyleve Mar 31, 2026
c5ab221
Move trait type registration into BTraits
kyleve Mar 31, 2026
f4b306c
Move Hashable conformance to the main type to avoid spurious IDE error
kyleve Mar 31, 2026
94d5f74
Lazy BRootViewController setup and Swift Testing show() helper
kyleve Mar 31, 2026
488fb2f
Minor updates after Cursor: Reorder BRootViewController creation, mov…
kyleve Mar 31, 2026
ef54e3d
Reorganize traits into Traits/ subdirectory and add BTraitOverridesVi…
kyleve Apr 1, 2026
aeb7c14
Simplify BStylesheets by removing Config wrapper and using EquatableI…
kyleve Apr 1, 2026
7f0a2f0
UIKit trait bridging, SwiftUI integration, and BTraitOverridesViewCon…
kyleve Apr 2, 2026
5d65a28
Merge origin/main; resolve docs; minimal directory trees
kyleve Apr 5, 2026
16553c9
Fix context propagation, stylesheet init, and observer idempotency
kyleve Apr 5, 2026
c0f198c
Address PR review: encapsulation, tests, BMode.from, restore xctest
kyleve Apr 5, 2026
2f557ec
Hide BStylesheets.themes; sync via updateThemes from BContext
kyleve Apr 5, 2026
9d75433
Use Swift package access for BStylesheets sync APIs (SE-0386)
kyleve Apr 5, 2026
282404f
Adopt root Package.swift; Tuist for apps and xctest bundles
kyleve Apr 5, 2026
d9ae4ff
PR review: document SPM test deps, clarify trait observer docs, stren…
kyleve Apr 6, 2026
d596ab1
Simplify SeqTrait observer test harness (single NotificationCenter)
kyleve Apr 6, 2026
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
49 changes: 31 additions & 18 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,35 @@
# AGENTS.md — Repository Shape

Broadway is a SwiftUI iOS + Mac Catalyst design system managed by **Tuist**. The Xcode project is not checked in — it is generated from `Project.swift`.
Broadway is a SwiftUI iOS + Mac Catalyst design system: **Swift Package Manager** supplies the shared libraries (`Package.swift`), and **Tuist** generates the Xcode workspace from `Project.swift` for the catalog app, test host, and unit test bundles that link those packages.

## Directory Layout

```
BroadwayCatalog/ # Catalog app (Sources/, Resources/, Tests/)
BroadwayUI/ # Reusable UI component framework (Sources/, Tests/)
BroadwayCore/ # Foundational utilities framework (Sources/, Tests/)
BroadwayTestHost/ # Minimal test host app (Sources/)
BroadwayTesting/ # Shared test utilities framework (Sources/)
Project.swift # Tuist project manifest
Tuist.swift # Tuist global configuration
ide # Dev script (installs hooks, runs tuist generate)
swiftformat # Run SwiftFormat (--lint to check only)
sync-agents # Generate CLAUDE.md + .claude/skills from AGENTS.md
/
├── .githooks/ # Git hooks (pre-commit SwiftFormat lint)
├── BroadwayCatalog/ # Catalog app (Sources/, Resources/, Tests/)
├── BroadwayUI/ # UI framework (Sources/, Tests/)
├── BroadwayCore/ # Core framework (Sources/, Tests/)
├── BroadwayTestHost/ # Minimal test host app (Sources/)
├── BroadwayTesting/ # Shared test utilities (Sources/)
├── Plans/ # Archived implementation plans
├── Package.swift # SPM: BroadwayCore, BroadwayUI, BroadwayTesting libraries
├── Project.swift # Tuist: apps, test host, and xctest targets using the local package
├── Tuist.swift # Tuist global configuration
├── ide # Dev script (installs hooks, runs tuist generate)
├── swiftformat # Run SwiftFormat (--lint to check only)
└── sync-agents # Generate CLAUDE.md + .claude/skills from AGENTS.md
```

Key root files: `Package.swift` (library products), `Project.swift` (Tuist manifest), `Tuist.swift` (Tuist config), `.mise.toml` (tool versions), `.swiftformat` (style config), `ide` and `swiftformat` (dev scripts).

## Build System

- **Tuist 4+** generates the Xcode project from `Project.swift`. The `.xcodeproj` and `Derived/` are git-ignored.
- **Swift Package Manager** (`Package.swift`) defines the **BroadwayCore**, **BroadwayUI**, and **BroadwayTesting** libraries (iOS 26 + Mac Catalyst). Library sources live under each module’s `Sources/` tree as today.
- **Tuist 4+** generates the Xcode workspace from `Project.swift`, wires a **local package** dependency (`.relativeToRoot(".")`), and defines **BroadwayCatalog**, **BroadwayTestHost**, **BroadwayCoreTests**, **BroadwayUITests**, and **BroadwayCatalogTests**. The `.xcodeproj` / `.xcworkspace` and `Derived/` are git-ignored.
- Tuist and SwiftFormat are version-pinned via **mise** in `.mise.toml`. Run `mise install` to install them.
- Run `./ide` to generate the project (or `./ide -i` to run `mise exec -- tuist install` first).
- Run `mise exec -- tuist test` to execute all tests, or `mise exec -- tuist test <SchemeName>` for a specific target.
- Run `mise exec -- tuist test` to execute all tests (scheme **Broadway-Workspace**), or `mise exec -- tuist test <SchemeName>` for a subset. Example schemes: **BroadwayCoreTests**, **BroadwayUITests**, **BroadwayCatalog**. There is no longer a generated **BroadwayCore** scheme (the core library is built as part of the Swift package).

## Formatting

Expand All @@ -41,11 +48,16 @@ sync-agents # Generate CLAUDE.md + .claude/skills from AGENTS.md
## Dependency Graph

```
BroadwayCatalog (app) --> BroadwayUI (framework) --> BroadwayCore (framework)
BroadwayTestHost (app) --> BroadwayUI --> BroadwayCore
BroadwayTesting (framework) --> BroadwayCore
Package.swift:
BroadwayUI (library) --> BroadwayCore (library)
BroadwayTesting (library) --> BroadwayCore (library)

All framework test targets use BroadwayTestHost and depend on BroadwayTesting.
Project.swift (Tuist):
BroadwayCatalog (app) --> package product BroadwayUI
BroadwayCatalogTests --> BroadwayCatalog, package BroadwayTesting
BroadwayCoreTests --> package BroadwayCore, package BroadwayTesting, BroadwayTestHost
BroadwayUITests --> package BroadwayUI, package BroadwayTesting, BroadwayTestHost
BroadwayTestHost (app) --> (no framework deps)
```

## Key Conventions
Expand All @@ -54,4 +66,5 @@ All framework test targets use BroadwayTestHost and depend on BroadwayTesting.
- **Swift Testing** (`import Testing`) is used for unit tests, not XCTest.
- Source files use `<Target>/Sources/**` globs; test files use `<Target>/Tests/**`.
- Info.plist is auto-generated by Tuist via `infoPlist: .extendingDefault(with:)`.
- New targets or dependencies: edit `Project.swift`.
- New **library** code or package products: edit `Package.swift` (and folder layout under each module).
- New **app**, **test host**, or **xctest bundle** targets: edit `Project.swift`.
8 changes: 1 addition & 7 deletions BroadwayCatalog/Tests/BroadwayCatalogTests.swift
Original file line number Diff line number Diff line change
@@ -1,10 +1,4 @@
@testable import BroadwayCatalog
import Testing

struct BroadwayCatalogTests {
@Test("App launches with ContentView")
func contentViewExists() {
let view = ContentView()
#expect(view != nil)
}
}
struct BroadwayCatalogTests {}
4 changes: 2 additions & 2 deletions BroadwayCore/Sources/BContext+UITraits.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import UIKit

/// Bridges ``BContext`` into UIKit's trait system so that it propagates
/// automatically through the view-controller and view hierarchy.
struct BContextTrait: UITraitDefinition {
static let defaultValue = BContext()
public struct BContextTrait: UITraitDefinition {
public static let defaultValue = BContext()
}

extension UITraitCollection {
Expand Down
36 changes: 29 additions & 7 deletions BroadwayCore/Sources/BContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,26 +12,48 @@ import Foundation
/// ``BStylesheets`` cache. Updating `traits` or `themes` replaces the
/// stylesheet cache so subsequent lookups produce fresh instances.
public struct BContext: Equatable, Sendable {
public init(traits: BTraits = .init(), themes: BThemes = .init()) {
self.traits = traits
public init(
traits: BTraits,
overrides: BTraits.Overrides = .init(),
themes: BThemes = .init(),
) {
baseTraits = traits
traitOverrides = overrides
self.themes = themes
stylesheets = BStylesheets(config: .init(traits: traits, themes: themes))
stylesheets = BStylesheets(
traits: baseTraits.merging(with: traitOverrides),
themes: themes,
)
}

init() {
self.init(traits: BTraits())
}

public var traitOverrides: BTraits.Overrides {
didSet {
stylesheets.updateTraits(traits)
}
}

/// The current trait values (accessibility, size class, etc.).
public var traits: BTraits {
baseTraits.merging(with: traitOverrides)
}

/// The current trait values (accessibility, size class, etc.).
public var baseTraits: BTraits {
didSet {
stylesheets.traits = traits
stylesheets.updateTraits(traits)
}
}

/// The current theme values.
@CopyOnWrite public var themes: BThemes {
didSet {
stylesheets.themes = themes
stylesheets.updateThemes(themes)
}
}

/// Lazily-populated stylesheet cache, scoped to the current traits and themes.
@CopyOnWrite public private(set) var stylesheets: BStylesheets
@EquatableIgnored @CopyOnWrite public private(set) var stylesheets: BStylesheets
}
50 changes: 24 additions & 26 deletions BroadwayCore/Sources/BStylesheets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,31 +12,25 @@ import Foundation
/// first access and cached for subsequent lookups with the same key.
public struct BStylesheets: Equatable, @unchecked Sendable {
/// The current trait values (accessibility, size class, etc.).
public var traits: BTraits {
get { config.traits }
set { config.traits = newValue }
}
private var traits: BTraits

/// The current theme values.
public var themes: BThemes {
get { config.themes }
set { config.themes = newValue }
}

/// The inputs that determine stylesheet identity. Two `BStylesheets`
/// values are equal when their configurations match, regardless of
/// how much of the cache has been lazily populated.
var config: Config
private var themes: BThemes

struct Config: Equatable {
var traits: BTraits
var themes: BThemes
init(traits: BTraits, themes: BThemes) {
self.traits = traits
self.themes = themes
}

// MARK: Equatable
/// Keeps the resolver’s trait key in sync with ``BContext/traits``.
/// `package` matches the monorepo `-package-name Broadway` set in Tuist (SE-0386).
package mutating func updateTraits(_ newTraits: BTraits) {
traits = newTraits
}

public static func == (lhs: Self, rhs: Self) -> Bool {
lhs.config == rhs.config
/// Keeps the resolver’s theme key in sync with ``BContext/themes``.
package mutating func updateThemes(_ newThemes: BThemes) {
themes = newThemes
}

// MARK: Lookup
Expand All @@ -47,26 +41,30 @@ public struct BStylesheets: Equatable, @unchecked Sendable {
/// a dependency cycle, or ``StylesheetError/creationFailed(type:underlying:)``
/// if the stylesheet's initializer throws.
public func get<Stylesheet: BStylesheet>(_: Stylesheet.Type) throws(StylesheetError) -> Stylesheet {
let key = Key(stylesheet: .init(Stylesheet.self), traits: traits, themes: themes)
let key = Key(
stylesheet: .init(Stylesheet.self),
traits: traits,
themes: themes,
)

guard let value = cache[key], let value = value.base as? Stylesheet else {
let id = TypeIdentifier(Stylesheet.self)

let creating = _creating._unsafeUnderlyingValue
let creating = _creating.wrappedValue._unsafeUnderlyingValue

if let cycleStart = creating.firstIndex(of: id) {
let path = creating[cycleStart...].map(\.debugDescription) + [id.debugDescription]
throw .cyclicDependency(path: path)
}

_creating._unsafeUnderlyingValue.append(id)
defer { _creating._unsafeUnderlyingValue.removeLast() }
_creating.wrappedValue._unsafeUnderlyingValue.append(id)
defer { _creating.wrappedValue._unsafeUnderlyingValue.removeLast() }

let context = SlicingContext(themes: themes, stylesheets: self)

do {
let new = try Stylesheet(context: context)
_cache._unsafeUnderlyingValue[key] = AnyEquatable(new)
_cache.wrappedValue._unsafeUnderlyingValue[key] = AnyEquatable(new)
return new
} catch let error as StylesheetError {
throw error
Expand All @@ -88,9 +86,9 @@ public struct BStylesheets: Equatable, @unchecked Sendable {
// MARK: Cache

// TODO: Eventually we should clear this out on memory warnings for sheets not accessed within 10(?) min
@CopyOnWrite private var cache: [Key: AnyEquatable] = [:]
@EquatableIgnored @CopyOnWrite private var cache: [Key: AnyEquatable] = [:]

@CopyOnWrite private var creating: [TypeIdentifier] = []
@EquatableIgnored @CopyOnWrite private var creating: [TypeIdentifier] = []

/// Cache key combining the stylesheet type with the traits and themes
/// that were active at creation time.
Expand Down
60 changes: 0 additions & 60 deletions BroadwayCore/Sources/BTraits.swift

This file was deleted.

4 changes: 3 additions & 1 deletion BroadwayCore/Sources/EquatableIgnored.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import Foundation
/// }
/// ```
/// In this example, `cache` will never affect the equality of two `Example` instances.
@propertyWrapper public struct EquatableIgnored<Value>: Equatable {
@propertyWrapper public struct EquatableIgnored<Value>: Equatable, Hashable, @unchecked Sendable {
public var wrappedValue: Value

public init(wrappedValue: Value) {
Expand All @@ -30,4 +30,6 @@ import Foundation
public static func == (_: Self, _: Self) -> Bool {
true
}

public func hash(into _: inout Hasher) {}
}
46 changes: 46 additions & 0 deletions BroadwayCore/Sources/Traits/BTraits+Overrides.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
//
// BTraits+Overrides.swift
// BroadwayCore
//
// Created by Kyle Van Essen on 3/31/26.
//

import Foundation

extension BTraits {
/// Allows overriding traits with specific values.
public struct Overrides: Equatable, Hashable, @unchecked Sendable {
public init() {}

// MARK: Subscript

/// Gets or sets the trait for the given type. Returns `nil` if no value has been set.
public subscript<Value: BTraitsValue>(_: Value.Type) -> Value? {
get {
let id = TypeIdentifier(Value.self)

guard let value = storage[id], let value = value.base as? Value else {
return nil
}

return value
}

set {
let id = TypeIdentifier(Value.self)

if let newValue {
storage[id] = AnyHashable(newValue)
} else {
storage[id] = nil
}
}
}

@CopyOnWrite private var storage: [TypeIdentifier: AnyHashable] = [:]

var values: [TypeIdentifier: AnyHashable] {
storage
}
}
}
Loading
Loading