# Component System Architecture Rules Component-system rules for the planned PureYAML native editor. The editor's UI is built from reusable, registry-driven components. This file consolidates the component protocol, the package hierarchy, the registry, and the one-component-per-file discipline. ## One component per file - Each UI component lives in its own component package, NEVER in feature packages or screens. - ONE component per file; the file name equals the component name. - Every component conforms to the `Component` protocol from the `Components` package. ## Core rules ### Rule 1: Three-package hierarchy Use this strict layering: ``` Components (core infrastructure - ZERO dependencies) ↓ SharedComponents (hot reload - depends on hot-reload + file-watch tools only) ↓ AppComponents (app-specific - depends on Components + theme + font packages) ↓ AllComponents (optional aggregator for previews - depends on Components + AppComponents) ``` ### Rule 2: Components package foundation Create the `Components` package FIRST with the core infrastructure: - MUST contain: the `Component` protocol, `AnyComponent`, `ComponentsRegistry`, `ComponentFactory`. - MUST contain: bundle management, registration interfaces, and the component-list model/view. - MUST include a `components.json` configuration file, processed with `.process()`. - MUST have ZERO dependencies (foundation layer). ### Rule 3: SharedComponents dependencies Depend ONLY on hot-reload tooling: - A hot-reload injection library and a file-watching library. - NEVER depend on UI packages (theme, font). Mixing them in breaks the hot-reload layer's independence from the UI foundation. ### Rule 4: AppComponents dependencies Depend on the foundation packages: - `Components` (core system) - The theme package (colors, styles) - The font package (typography) ### Rule 5: Component registration Register components via the registry: - All components conform to the `Component` protocol from the `Components` package. - Registration happens in a per-package `*ComponentsRegistration.swift`. - Register external components through the shared registry. ## The Component protocol ```swift import Foundation import SwiftUI public protocol ComponentData: Hashable, Decodable, Sendable {} public typealias ComponentKind = String public protocol Component: Sendable { associatedtype Data: ComponentData associatedtype ViewBody: View var data: Data { get } /// Build the SwiftUI view for this component. /// Runs on the main actor because SwiftUI view construction /// and state are main-actor isolated. @MainActor func make() -> ViewBody static var kind: ComponentKind { get } init(data: Data) } public extension Component { /// Auto-generates kind from the type name (e.g. "TilePreviewComponent" → "tilepreview"). @inline(__always) static var kind: ComponentKind { let fullName = String(reflecting: Self.self) let parts = fullName.split(separator: ".") let typePath = parts.dropFirst().joined(separator: ".") return typePath .replacingOccurrences(of: "Component", with: "") .lowercased() } /// Register this component in the registry. static func register(in registry: ComponentsRegistry) { let componentType = Self.self let dataType = Self.Data.self registry.decoders[kind] = { decoder in let data = try decoder.decode(dataType, forKey: .payload) return ComponentFactory(data: data, type: componentType) } } } ``` ### AnyComponent (type-erased wrapper) `AnyComponent` is a type-erased wrapper struct, not a protocol. Concrete components conform to `Component`; `AnyComponent` wraps any of them for storage and rendering. ```swift @MainActor public struct AnyComponent: Identifiable { private let component: any Component public var id: UUID = .init() public let kind: ComponentKind private let _make: () -> AnyView public init(_ component: ComponentType) where ComponentType: Component { self.component = component kind = ComponentType.kind _make = { AnyView(component.make()) } } public var contentView: AnyView { _make() } } ``` ### ComponentFactory (Sendable bridge) `ComponentFactory` is `Sendable` so component data can be decoded off the main thread, then rendered on the main actor. ```swift public struct ComponentFactory: Sendable { public let kind: ComponentKind private let _makeRenderable: @MainActor () -> AnyComponent public init(data: C.Data, type: C.Type) { kind = C.kind _makeRenderable = { let instance = C(data: data) return AnyComponent(instance) } } @MainActor public func makeRenderable() -> AnyComponent { _makeRenderable() } } ``` ### ComponentsRegistry The registry maps a component `kind` to a decoder that produces a `ComponentFactory`. JSON describing a component carries a `kind` plus a `payload`, and the registry decodes the right concrete data type. ```swift public final class ComponentsRegistry { public typealias ComponentDecoder = @Sendable (KeyedDecodingContainer) throws -> ComponentFactory public var decoders: [ComponentKind: ComponentDecoder] = [:] public struct Container: Decodable { public enum CodingKeys: CodingKey { case kind, payload } public struct ComponentNotFound: Error { public var kind: ComponentKind } public struct DecodersNotFound: Error {} public static let decodersKey = CodingUserInfoKey(rawValue: "ComponentDecoders")! public let factory: ComponentFactory public init(from decoder: Decoder) throws { guard let decoders = decoder.userInfo[Self.decodersKey] as? [ComponentKind: ComponentDecoder] else { throw DecodersNotFound() } let container = try decoder.container(keyedBy: CodingKeys.self) let kind = try container.decode(ComponentKind.self, forKey: .kind) guard let decodeForKind = decoders[kind] else { throw ComponentNotFound(kind: kind) } factory = try decodeForKind(container) } } public init() {} /// Decode JSON into ComponentFactories (safe off the main thread). public func decodeFactories(from data: Data) -> [ComponentFactory] { do { let decoder = JSONDecoder() decoder.userInfo[Container.decodersKey] = decoders let containers = try decoder.decode([Container].self, from: data) return containers.map(\.factory) } catch { print("Unable to parse \(error)") return [] } } } ``` ## Concrete component example One component per file. Define the data struct, store it, implement `make()`, and provide a preview. ```swift import Components import SwiftUI public struct TilePreviewComponent: Component { public struct Data: ComponentData { public var title: String public var subtitle: String public var isActive: Bool = true public init(title: String, subtitle: String, isActive: Bool = true) { self.title = title self.subtitle = subtitle self.isActive = isActive } } public var data: Data public init(data: Data) { self.data = data } public func make() -> some View { TilePreviewContent(data: data) } } // Separate view struct for the actual UI. struct TilePreviewContent: View { var data: TilePreviewComponent.Data @Environment(\.appTheme) private var theme var body: some View { // card implementation using theme.colors and the font package EmptyView() } } #Preview { TilePreviewComponent(data: .init( title: "Hero Tile", subtitle: "Top of the canvas" )).make() } ``` ## components.json Describes the components and sample payloads used by previews and the component gallery. Lives in the `Components` package, processed with `.process()`. ```json { "components": [ { "kind": "tile-preview", "category": "Display", "description": "Displays a tile preview with status", "payload": { "title": "Hero Tile", "subtitle": "Top of the canvas", "isActive": true } } ] } ``` ## Registration ```swift import Components /// Register a package's components in the registry. public func registerTileComponents(in registry: ComponentsRegistry) { TilePreviewComponent.register(in: registry) // additional components... } // In app initialization: let registry = ComponentsRegistry() registerTileComponents(in: registry) ``` ## AllComponents aggregator An optional umbrella package that re-exports `Components` and `AppComponents` for convenient imports. ```swift @_exported import Components @_exported import AppComponents ``` - USE only in preview/demo apps and the component gallery. - DO NOT use in production features or main app targets; be explicit with imports there. ## Common mistakes - Creating `AppComponents` without the core `Components` package. - Adding UI dependencies (theme, font) to `Components` (it MUST have zero dependencies). - Adding UI dependencies to `SharedComponents` (hot reload only). - Importing the broad `AllComponents` aggregator inside production features. - Over-specific package naming. Keep app-specific components in `AppComponents`, not a narrowly named package. ## Checklist - [ ] `Components` package exists with zero dependencies, created first - [ ] `Components` contains the protocol, `AnyComponent`, registry, factory, list types - [ ] `Components` includes `components.json`, processed with `.process()` - [ ] `SharedComponents` depends ONLY on hot-reload + file-watch tooling - [ ] `AppComponents` depends on Components + theme + font - [ ] `AppComponents` holds production components only - [ ] `AllComponents`, if present, used only in preview/demo apps - [ ] All components conform to the `Component` protocol - [ ] ONE component per file; file name equals component name - [ ] Registration goes through the shared registry - [ ] Resources use `.process()` not `.copy()` - [ ] The component layer follows the strict hierarchy