10 KiB
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
Componentprotocol from theComponentspackage.
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
Componentprotocol,AnyComponent,ComponentsRegistry,ComponentFactory. - MUST contain: bundle management, registration interfaces, and the component-list model/view.
- MUST include a
components.jsonconfiguration 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
Componentprotocol from theComponentspackage. - Registration happens in a per-package
*ComponentsRegistration.swift. - Register external components through the shared registry.
The Component protocol
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.
@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<ComponentType>(_ 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.
public struct ComponentFactory: Sendable {
public let kind: ComponentKind
private let _makeRenderable: @MainActor () -> AnyComponent
public init<C: Component>(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.
public final class ComponentsRegistry {
public typealias ComponentDecoder =
@Sendable (KeyedDecodingContainer<Container.CodingKeys>) 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.
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().
{
"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
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.
@_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
AppComponentswithout the coreComponentspackage. - Adding UI dependencies (theme, font) to
Components(it MUST have zero dependencies). - Adding UI dependencies to
SharedComponents(hot reload only). - Importing the broad
AllComponentsaggregator inside production features. - Over-specific package naming. Keep app-specific components in
AppComponents, not a narrowly named package.
Checklist
Componentspackage exists with zero dependencies, created firstComponentscontains the protocol,AnyComponent, registry, factory, list typesComponentsincludescomponents.json, processed with.process()SharedComponentsdepends ONLY on hot-reload + file-watch toolingAppComponentsdepends on Components + theme + fontAppComponentsholds production components onlyAllComponents, if present, used only in preview/demo apps- All components conform to the
Componentprotocol - 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