Imported from isamercan/ThemeKit (
.claude/skills/themekit-authoring/SKILL.md). Install upstream withnpx skills add isamercan/ThemeKit --skill themekit-authoring. Copyright stays with the author (MIT).
Authoring ThemeKit components
ThemeKit is a brand-neutral, token-driven SwiftUI component library. Components
are stateless value-type views — no ViewModels, no networking, no backend DTOs.
Every color, radius, spacing, and type style resolves at runtime from the active
Theme, so one theme change re-skins the whole library. Your job when adding a
component is to make it compose, theme, localize, and mirror (RTL) for free.
This adapts generic SwiftUI guidance to this codebase. Where generic advice says "use MVVM / @StateObject / async-await / semantic asset colors," that is app advice and does not apply to leaf components here. The parts that do apply — view composition, ViewModifiers, custom styles,
@State/@Bindingfor local interaction, accessibility, Dynamic Type, previews — are baked in below.
Copy-pasteable reference implementations (atom, molecule, style-driven organism)
live in references/patterns.md. Read it before writing a
new component — start from a template, don't reinvent the shape.
The 6 house rules (non-negotiable)
- Stateless & data-driven. A component takes value types / provider closures,
never a backend schema. Local UI-only state (
@State private var chosen) is fine; app state is not. NoObservableObject, noTask, no network. - Brand-neutral & generic. No coupling to any real app (ets/Voyage/etc.), no domain data model. Offer generic overrides for anything a brand might tweak.
- Init = content; modifiers = appearance. Required content, bindings and
actions go in
init. Every variant, size, flag, color and callback is a chainable modifier using copy-on-write. Nosize:/variant:/isEnabled:init args. - Token-fed, always. No raw
Color, no magicCGFloat. Colors → theme token keys orSemanticColor; radius →Theme.RadiusRole; spacing →Theme.SpacingKey; type →.textStyle(_:). This applies to the body and to modifier signatures. - Native modifiers for native concepts. Size →
.controlSize(.small), disabled →.disabled(_:). Do not inventsize:/isEnabled:parameters. - Accessible, localized, RTL-safe by construction (sections below).
The copy-on-write modifier pattern
House style for every configurable component: content + action in init, all
appearance in a public extension of chainable modifiers, each mutating a copy
through one copy(_:) point.
public init(_ text: String, action: (() -> Void)? = nil) { … } // content + action only
public extension Badge {
func badgeStyle(_ s: BadgeStyle) -> Self { copy { $0.style = s } }
func size(_ s: BadgeSize) -> Self { copy { $0.size = s } }
private func copy(_ mutate: (inout Self) -> Void) -> Self { var c = self; mutate(&c); return c }
}
Reads left-to-right: Badge("Sale").badgeStyle(.error).variant(.solid).size(.small).
If a raw-value escape hatch must exist, @available(*, deprecated,…) it toward
the token path instead of promoting it. Full annotated struct → references/patterns.md §1.
Slots (optional content areas)
Required content is a generic @ViewBuilder init parameter (type-preserved).
Optional slots are always copy-on-write modifiers — never extra init
overloads — storing the internal SlotContent helper
(Sources/ThemeKit/Extensions/SlotContent.swift): type-erased, nil = "use the
built-in". SlotContent is a View, so store it and render it directly:
private var customHeader: SlotContent? // nil → built-in header
func header<H: View>(@ViewBuilder _ header: () -> H) -> Self {
copy { $0.customHeader = SlotContent(header) }
}
// body:
if let customHeader { customHeader } else { titleHeader }
No sending is needed — the slot closure is non-escaping and evaluated
immediately during the parent's body construction (the style-erasure inits need
sending only because they store an escaping closure in the environment).
Canonical slot vocabulary — a component uses these names or none:
| Slot name | Meaning | Precedent |
|---|---|---|
.header { } |
replaces the built-in title header | Card |
.footer { } |
bottom-aligned accessory area | Card |
.leading { } / .trailing { } |
before/after the main content (RTL-safe by name) | Chip, Badge, InlineText; PriceTag (.leading) |
.label { } |
replaces a control's built-in text label | ThemeButton (title only), RadioButton |
.indicator { } |
replaces a state glyph (spinner, chevron, thumb) | Spinner; planned Accordion |
.loadingIndicator { } |
replaces a control's loading spinner — where .indicator would re-resolve the corner overlay View.indicator(_:content:) |
ThemeButton |
.emptyContent { } |
shown when a collection component has no items | planned ChipGroup |
Slot content must render correctly with zero configuration — it inherits
textStyle and the surrounding chrome's foreground token from the environment
(as Card's header slot does). TextInput.addons(before:after:) keeps its domain
name (an input-group concept, not a generic slot).
Slot-type stability (the .id rule): AnyView diffs by the wrapped
concrete type. A slot re-erased on every parent render keeps @State and
transitions as long as it wraps the same type — but an if/else of two
different view types directly in a slot loses branch identity (cross-fade
instead of insert/remove). Wrap alternating slot content in .id(_:) to keep
insert/remove semantics.
Token vocabulary (use these, never literals)
- Read the theme:
@Environment(\.theme) private var theme. - Text:
theme.text(.textPrimary | .textSecondary | .textTertiary | .textDisabled | .textHero) - Surfaces:
theme.background(.bgBase | .bgWhite | .bgSecondaryLight | .bgHero | …) - Borders / foreground:
theme.border(.borderPrimary | .borderHero | …),theme.foreground(.fgHero | .systemcolorsFgSuccess | …) - Semantic palette:
SemanticColor(.primary .accent .neutral .info .success .warning .error+ brand hues), each with.base .hover .active .soft .solid .border .onSolidand a 50–900 ladder. Pair withFillVariant(.soft .solid .outline .ghost). - Radius by role:
Theme.RadiusRole.box.value(cards),.field.value(buttons/inputs/chips),.selector.value(badges/checkboxes). Size ramp:Theme.RadiusKey.base.value. - Spacing:
Theme.SpacingKey.xs|sm|md|base|lg|xl.value. - Type:
.textStyle(.headingSm | .bodyBase400 | .labelSm600 | .overline400 | …)— also gives Dynamic Type for free; never hardcode.font(.system(size:))for text.
Genuine dimensions with no semantic token (a fixed 22×22 logo frame, an aspect
ratio, a chart height) stay raw CGFloat — as fixed constants inside the view,
not as arbitrary knobs exposed in a modifier signature.
Decompose: atom → molecule → organism
When a component grows past one screenful, split it. The public component is the organism, built from smaller pieces:
- Atom — smallest reusable unit (
Badge,PriceTag,Icon,SeatCell). - Molecule — a few atoms with light logic (
FlightRoute,SeatLegend). - Organism — the shipped component (
FlightListItem,SeatMap,Card).
Keep sub-views private in the same file unless independently useful. Models + any
generic palette (e.g. SeatPalette) go in a <Component>Models.swift.
When does a component earn a <Component>Models.swift? Only when its data has one
of these shapes (the D2 test, docs/ADR-0005-data-intake-taxonomy.md) — never merely
because it's "data-rich":
- collection/graph — a list/grid/graph of repeating records you can't flatten into
modifier args (
Seatrows,ChartPointseries); - shared across a family — one record read by an atom + molecule + organism (
SeatbySeatCell/SeatLegend/SeatMap); - a provider-closure return type —
seat: (id,row,col) -> SeatInfo.
Richness ≠ aggregation. A 20-field component (e.g. HotelResultCard) whose fields
are independent optional scalars stays on init + chainable modifiers — do NOT bundle
them into a <Component>Data bag. That would break the fluent/additive API, freeze
default strings against a live language switch, and reintroduce the "backend DTO" rule 1
forbids. Field shape decides, not field count. Strings never live in a Model
either — default copy stays String(themeKit:) (a Model holds only the consumer's own
content: a hotel name, a series label).
Style-driven API (organisms with multiple archetypes)
When one component needs several fundamentally different layouts (as FlightListItem
does with 9 styles), do not add a variant enum with a giant switch in the
body. Use the style protocol + configuration pattern: a Configuration struct of
typed data + captured locale/flags/callbacks, a …Style protocol with
makeBody(configuration:), one struct per archetype (thin wrapper over a private
…Chrome view), static accessors via where Self ==, and type-erasure + an
EnvironmentKey + a func …Style(_:) view modifier so a list sets it once. Share
cross-style building blocks as private sub-views. Full skeleton → references/patterns.md §3.
Chrome styles (consumer-owned paint, ADR-0009)
A second reason for a style protocol: a host design system must own the chrome (its own tokens, text styles, icon font) while ThemeKit keeps behaviour, content, slots, accessibility, RTL and state. These ship with exactly one implementation, the stock look — no speculative presets (ADR-F5 still governs presets).
Shipped: ChipStyle, ButtonChromeStyle (ThemeButton), BadgeChromeStyle,
CountBadgeStyle, IconTileStyle, PriceTagStyle, RadioButtonChromeStyle,
SkeletonStyle, DividerStyle, CalloutChromeStyle, InlineTextStyle,
TooltipStyle (.tooltip(…); its arrow is the public TooltipArrowShape),
TitleStyle, SegmentedTabBarChromeStyle (one tab; the style owns the
selection indicator and gets the bar's matchedGeometryEffect namespace),
ButtonDockChromeStyle (.buttonDock { }; the modifier keeps the
safeAreaInset pinning and hands over the measured bottom safe-area inset) and
SheetHeaderStyle (SheetHeader's whole layout, one hook outside BarStyle).
The uniform shape — copy it from any of those files:
- Name:
<Component>Stylewhen free, else<Component>ChromeStyle(a 1.x enum already ownsThemeButtonStyle,BadgeStyle,CalloutStyle,RadioButtonStyle;SegmentedTabBarStyletoo). Also take…ChromeStylewhen the "component" is aViewmodifier, not a type (.buttonDock { }→ButtonDockChromeStyle): the short name would describe a type that doesn't exist and is what a future preset enum for that modifier would want. public struct <Name>Configuration—public letfields, internal memberwise init. Raw strings (not styledText), un-fonted/uncolouredAnyViewcontent, resolved state (isEnabled,isPressed, …), the modifier axes as set, and motion already resolved (isMotionEnabled/animation/isAnimated;PriceTag'sanimatesValuestill ignoresmicroAnimations, as in 1.4.0) — a style never readsmicroAnimationsor Reduce Motion. Stock SF Symbol shorthands may arrive pre-sized; say so on the field and offer the symbol name or a slot.public struct Default<Name>: <Name>, Sendable(public init()) +static var defaultviawhere Self == Default<Name>; it must draw the built-in body's exact pixels (add a pixel-parity test with an in-loop control that must fail), including what the built-in path's ownButtonStyledraws (press dim, disabled fade) and tinting only what the built-in path tints.- Internal
Any<Name>eraser withlet isDefault: Bool; theEnvironmentKey'sdefaultValueis the only eraser built withisDefault: true, so an explicit.defaultgoes throughmakeBody. (Documented exception:RadioButtonChromeStyletreats an explicit.defaultas the built-in path, whose.plainbutton supplies the disabled fade and press feedback.) - Name the slots you add from the vocabulary above — but never ship a member slot
that re-resolves an existing 1.x call; rename instead. A member beats a generic
Viewmodifier with defaulted parameters in overload resolution, and the API digester can't see it:ThemeButton.indicator { }would have turnedThemeButton(…).indicator { Badge("3") }(the corner overlayView.indicator(_:content:)) into a loading slot, so the slot is.loadingIndicator { }. Greppublic extension Viewfor the name first. func <lowerName><S: <Name>>(_ style: sending S) -> some ViewonView.- Component body:
if style.isDefault { <unchanged body> } else { <behaviour wrapper around style.makeBody(configuration:)> }— existing snapshots stay identical. Keep taps, haptics, focus and the accessibility label/value/traits in the wrapper; for a liveisPressed, hand the style to an internalButtonStylebridge. - Deprecated raw overrides (
Color) travel as internal configuration fields for the default style only. - A style that draws one item of a collection (
SegmentedTabBarChromeStyle) takes that item's content and state plus the container's axes, and the container keeps the row around the items — and hands over the geometry namespace + id its sliding indicator needs (matchedGeometryEffect), because the style, not the component, draws the indicator on that path. - A style for a
Viewmodifier keeps whatever makes the modifier a modifier (buttonDock's bottomsafeAreaInset) outsidemakeBody, and hands the style anything the chrome needs that only ThemeKit can measure — the dock passes the bottom safe-area inset (a zero-sizeGeometryReaderprobe on the style path only, so the built-in body stays byte-identical) so a style can pad for the home indicator without reading geometry. Never add a configuration field a style cannot use. - A style that sits outside an existing style hook (
SheetHeaderStyleoverBarStyle) says so on the protocol, and itsDefault…routes back through the inner hook, so.defaultcomposes with it instead of overriding it. - Adding an argument to an existing function is an overload, not a parameter.
swift package diagnose-api-breaking-changesreports an inserted defaulted parameter ashas been renamed/has parameter N type change; ship a second overload beside the untouched signature that forwards withnil(bottomSheet(…, contentPadding:),SheetPresenter.present(…, contentPadding:)). - Document on the protocol which ThemeKit compositions the environment style reaches,
and resolve colours in the style from
@Environment(\.theme)(neverTheme.shared).
Accessibility
- Label every non-text control:
.accessibilityLabel(...); give togglers a state-aware label (isExpanded ? "Collapse…" : "Expand…"). - Expose a stable test id where the convention exists:
.a11yID("..."). - Rely on
.textStyle(_:)for Dynamic Type; don't cap text with fixed heights that clip. - Use SF Symbols (
Image(systemName:)/ theIconatom) for iconography.
Localization & RTL
- English only, generic strings. Never Turkish, never "ets"/"etstur" in copy or
placeholders. Wrap user-facing text:
String(localized: "Nonstop", bundle: .module). - Format with the captured locale, not the device default — take a
localeinto the configuration and use.formatted(….locale(locale))for dates/numbers so injected locales and RTL demos render correctly. - Build for RTL by construction: compose from
HStack/VStack(they mirror automatically) rather than absolutePath/GeometryReadergeometry. When you must draw aPath(sparkline, dashed line), add.flipsForRightToLeftLayoutDirection(true).
Previews & verification
- Ship a
#Previewthat exercises every variant — iterate the enums withForEach(BadgeStyle.allCases, …), and show a light + a themed/dark case. - Add or extend the matching Demo/Gallery entry, then verify it live by deep-link
instead of tapping through the UI:
then screenshot. Snapshot / a11y / RTL harness exists — keep new components in it.xcrun simctl launch <bundle> -startTab 0 -openDemo "<Component Name>"
Naming & Swift hygiene
PascalCasetypes,camelCasemembers; boolean props readis…/has…/should….guardfor early return; never force-unwrap — a style must render sensibly when optional data is absent.publiconly what callers need; keep chrome/sub-views/shapesprivate.
Anti-patterns (don't → do)
- ❌
Color(hex:)/.foregroundStyle(.blue)in a component → ✅theme.text(.textPrimary)/SemanticColor. - ❌
func badge(color: Color)modifier → ✅ token key /SemanticColor/ style-enum param. - ❌
cornerRadius: 12→ ✅Theme.RadiusRole.box.value. ❌padding(16)→ ✅Theme.SpacingKey.md.value. - ❌
Badge("x", size: .small, isEnabled: false)→ ✅Badge("x").size(.small).disabled(true). - ❌ A
variantenum + 200-lineswitchinbody→ ✅ the style-protocol pattern. - ❌ ViewModel /
Task/ network / hardcoded JSON in a component → ✅ value types + provider closures. - ❌ Re-implementing an existing atom (divider, price, chip) → ✅ compose the library's own.
Before opening a PR for a component
- Content/actions in
init; all appearance via chainable copy-on-write modifiers. - Zero raw
Color/magic numbers in body and modifier signatures. - Reads theme from
@Environment(\.theme); re-skins under a preset/dark/brand change. - Decomposed if large; public surface is the organism.
- a11y labels + Dynamic Type via
textStyle; strings localized & English-only. - Mirrors under RTL (or
Paths flipped); dates/numbers use the captured locale. -
#Previewcovers every variant; Demo/Gallery entry added and verified by deep-link.