# Architecture
How the Urbicon UI monorepo is put together: which packages exist, how a component gets
from a design token to rendered markup, and which decisions are load-bearing.
**New here?** Read §1 for the map, then §2 for the one path that runs through everything
else. §3–§5 are reference — come back to them when you need them.
Deeper references this document links into rather than duplicating:
[COMPONENT-FAMILIES.md](COMPONENT-FAMILIES.md) (the taxonomy that drives ARIA, tier and
border decisions) · [COMPONENT-API-CONVENTIONS.md](COMPONENT-API-CONVENTIONS.md) (props and
callbacks) · [ComponentStructureStandard.md](ComponentStructureStandard.md) (file layout) ·
[DECISIONS.md](DECISIONS.md) (conscious trade-offs).
---
## 1 · The monorepo at a glance
Urbicon UI is a vertically integrated platform: UI primitives, a data table, auth, i18n,
documentation tooling and an AI-native developer surface, all under one version and with
**zero runtime dependencies** in every package that can reach a consumer's bundle.
### The package map
Every arrow is a `peerDependency`, not a runtime dependency — every package that can reach a
consumer's bundle has a `dependencies` field that is literally `{}`. Consumers install what
they use; the workspace wires the same edges as `workspace:*` devDependencies for local
development. Two dev-time tools sit outside the rule, because nothing about them ships into
an app: `docs-gen` (`glob`, plus `design-engine` and `shared-types`) and the `urbicon` CLI in
`design` (`design-engine`, `design-content`, `i18n`).
```mermaid
graph TD
ST[shared-types TS types only]
I18N[i18n runes-based l10n]
BL[blocks primitives + components]
TB[table data table]
AU[auth sessions, passkeys, push]
SKU[sveltekit-utils]
DOCS[docs doc-site UI kit]
DG[docs-gen AST doc generator]
DE[design-engine linter, manifest, rubric]
DC[design-content knowledge bundle]
DES[design urbicon CLI]
I18N --> BL
BL --> TB
BL --> AU
I18N --> AU
BL --> DOCS
TB --> DOCS
ST --> DOCS
ST --> DG
DE --> DG
DE --> DES
DC --> DES
classDef found fill:#e8f0fe,stroke:#5b7fb9,color:#1a3a5c
classDef lib fill:#fff3e0,stroke:#c9884a,color:#5c3a10
classDef tool fill:#f0f0f0,stroke:#999,color:#333
class ST,I18N found
class BL,TB,AU,SKU,DOCS lib
class DG,DE,DC,DES tool
```
### What lives where
| Package | Does | Start reading at |
| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------- |
| `shared-types` | Docs-tooling types (playground, docs-config, navigation) for `docs` and `docs-gen`, no runtime code | `src/index.ts` |
| `i18n` | Runes-based localization + translation audit | `src/lib/i18n/registry.svelte.ts` |
| `blocks` | 39 primitives + 27 components, the token system, the `tv()` engine | `src/lib/index.ts` |
| `table` | Data table: sorting, filtering, grouping, selection, keyboard nav, virtualization, remote mode | `src/lib/stores/TableStore.svelte.ts` |
| `auth` | JWT sessions, refresh rotation, passkeys, Web Push, notifications | `src/lib/server/index.ts` |
| `sveltekit-utils` | SvelteKit helpers (`createCronRunner`, URL-state runes). No arrow in the map: nothing peer-depends on it, and its table-view types are a deliberate mirror guarded by a parity test rather than an import | `src/lib/index.ts` |
| `docs` | Reusable documentation UI components | `src/lib/index.ts` |
| `docs-gen` | AST-based documentation generator (CLI) | `src/cli/index.ts` |
| `design-engine` | Zero-dep design linter, manifest parser, rubric | `src/linter/index.ts` |
| `design-content` | Versioned design-knowledge bundle (`content/` is a build artifact) | `src/content-loader.ts` |
| `design` | The `urbicon` CLI — the consumer-facing surface | `src/cli/index.ts` |
Applications live in `apps/`: [`apps/docs`](../apps/docs/README.md) is the documentation
site. End-to-end suites live in `e2e/`.
### Build order
`bun run build:ts` (and `build:packages`, since 2026-08-12) runs
`scripts/build-packages.ts`, which derives **layered build order from the
`workspace:*` edges** between the packages — each layer builds in parallel,
the next starts only when the previous finished. A flat
`--filter='./packages/*'` sweep is not equivalent: it races the topology, and
a package that types against a not-yet-built neighbour (docs against blocks'
`VariantProps`, table against sveltekit-utils, …) gets its inferred exports
silently emitted as `any` — exit code 0, declaration file present, every
variant prop gone (measured 2026-07-31 and again 2026-08-12; details in the
script header).
```mermaid
graph LR
A["layer 1 shared-types, …"] --> B["layer 2 i18n, design-engine, …"]
B --> C["layer 3 blocks, …"] --> D["layer 4 docs, table, auth, …"]
D --> E["docs:gen:ci api.ts, catalogs, llms-full"]
E --> F["apps/* build"]
```
(The concrete layers are derived, not hand-written — run the script to see
the current assignment.)
Two things bite in a fresh worktree:
- Run `bunx --bun svelte-kit sync` in `blocks` and `apps/docs` before `svelte-check` or
`dev`.
- `apps/docs` needs both the built packages **and** a `docs:gen:all` run — every page
imports a generated, git-ignored `./api.ts`.
---
## 2 · From token to markup
This is the single path every visible component in the library follows. Understanding it
once explains how all of them are styled.
```mermaid
graph LR
F["foundation.css OKLCH palette, spacing, radii, z-index"]
S["semantic.css surface-*, text-*, border-* light-dark()"]
I["interaction.css hover/focus, durations, easings, shadows"]
T["@theme Tailwind utilities"]
V["*.variants.ts tv() config"]
R["resolveSlotClasses override cascade"]
D["rendered element"]
F --> S --> I --> T --> V --> R --> D
```
### The three token layers
All in `packages/blocks/src/lib/style/`:
| Layer | File | Purpose |
| ----------- | ----------------- | --------------------------------------------------------------------------------------------------------------------- |
| Foundation | `foundation.css` | OKLCH colour palette, spacing, radii, z-index scale, breakpoints |
| Semantic | `semantic.css` | Context-aware tokens (`surface-*`, `text-*`, `border-*`) with automatic dark mode via the CSS `light-dark()` function |
| Interaction | `interaction.css` | Hover/focus/active states, duration tokens, easing tokens, shadow tokens |
Foundation defines raw values. Semantic maps them to UI purposes and handles light/dark
switching. Interaction defines motion and visual feedback.
All tokens are registered in Tailwind 4 `@theme` blocks, so they generate utility classes
automatically (`bg-surface-base`, `text-text-primary`). Dark mode needs **no `dark:`
overrides anywhere** — `light-dark()` follows `color-scheme`. Tailwind 4 specifics:
[TailwindCaveats.md](TailwindCaveats.md).
The surface ladder (`base` / `quiet` / `subtle` / `elevated` / `overlay`) and what each
container variant means is documented in the shipped
[variant contract](../packages/blocks/docs/VARIANT-CONTRACT.md).
### The tier system
> **Canon.** This section is the one description of the tier mechanism; the border-token half
> of the contract is [VARIANT-CONTRACT § 7](../packages/blocks/docs/VARIANT-CONTRACT.md#7--border-tokens-hairline-vs-subtle).
> Other docs link here instead of restating the rules.
Radius semantics follow a three-tier model. A component picks the tier whose _semantics_
match its purpose, never a fixed pixel value — so a brand can re-tune a tier's physical
radius without touching component code.
| Tier | Token | Purpose | Default geometry |
| --------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------- |
| `commit` | `--radius-commit` | Action surfaces — buttons, menu triggers, toolbar items, toggle tracks. Declares identity ("press me"). | Pill (`9999px`) |
| `modify` | `--radius-modify` | Tap surfaces — inputs, selects, checkboxes, tabs. Reads as editable, not as a commit-decision. | Soft (`var(--radius-sm)`) |
| `contain` | `--radius-contain` | Architectural surfaces — cards, dialogs, drawers, alerts, popovers, tooltips. Reads as a frame holding content. (`Sidebar` sits outside: a full-height panel flush to the viewport edge has no corner to turn.) | Subtle (`var(--radius-xs)`) |
**Two axes share the name `tier`, deliberately — same vocabulary, different values.** The
**interactive tier** (`commit | modify`) is the one that cascades, on Action, Form and
Navigation primitives. The **container tier** (`contain | bridge`) is `Card`'s alone and
does **not** read the context: a Toolbar's `commit` must not reshape a Card inside it. Every
other container is `contain` by construction and exposes no prop.
**Defaults by family:** Action `commit` · Form `modify` · Navigation per component
(SegmentGroup `commit`, Tab `modify`, Stepper `commit`) · Container `contain`.
Feedback/Ambient (Toast, Spinner, Progress, Skeleton) and Identity (Avatar) are **not**
tier-aware: a Toast pops over the page chrome and must keep its identity even where the host
page is themed `tier="modify"`, and a Spinner is a circular affordance read at a glance, which
flattening would defeat. `Badge` is the lone Feedback exception — it exposes `tier` and reads
the context, because a Badge inside a `` does want to flatten. A family
default is not the same as reading the context, and the two come apart: `Slider`,
`FormField`, `Breadcrumb` and every Container including `Card` sit on a fixed tier without
consulting it. The live roster is whatever calls `getTierContext()` — grep it in
`packages/blocks/src/lib` rather than trusting a list here. Full taxonomy:
[COMPONENT-FAMILIES.md](COMPONENT-FAMILIES.md).
**One shape sits outside the tiers: `--radius-control`, the radio indicator.** It
defaults to the same pill as `commit` but is not tied to it, because the circle is the
only thing distinguishing a radio from a checkbox — both are otherwise the same small box
with the same label, and unlike a button's pill the circle is convention with meaning
(Material, HIG and Carbon all hold it). While the two shared a token, a brand taking this
section's own invitation to flatten `--radius-commit` squared its radios into things that
read as checkboxes; all four docs liveries needed a provider override to undo it, including
the one that only softens the tier to 2px. Set `--radius-control` if you want square radios
too — that is now a decision rather than a side effect.
The **checkbox** deliberately still follows the tier. It looks like the mirror case and is
not: a squared radio is damage a theme inflicts on a control it was not aiming at, whereas
a pill-shaped checkbox is what a consumer asked for by writing `tier="commit"` — the
status-chip look for checklists.
**Context propagation.** The tier context is a context, not a component: a wrapping container
calls `setTierContext()` for all its descendants and a tier-aware primitive reads it with
`getTierContext()` (both exported from `#lib/utils`, defined in `utils/tier-context.ts`).
`Toolbar` and `ButtonGroup` are the two that set it today, and they differ: `ButtonGroup`
both sets and reads, while **`Toolbar` only sets**. Its own surface is `rounded-contain` and
its `tier` prop (default `modify`) is a propagation value — it dresses the controls inside
the strip, never the strip.
```svelte
```
```ts
const tierCtx = getTierContext();
const effectiveTier = $derived(tier ?? tierCtx?.tier ?? 'commit'); // or 'modify'
```
Per-instance `tier` beats context; context beats the family default.
**The bridge token.** `--radius-bridge` is the _middle_ rung — 6px by default, living in
`foundation.css` — for the two cases where `contain` is too hard and `commit` too soft:
1. **Adjacency.** A floating panel anchored to a pill (`commit`) trigger sits between the pill
edge and the `contain`-tier surface beneath — the `Menu` panel is the canonical case.
2. **Optical size.** Radius scales with the area it turns: 2px on a 600px Card reads as a
precise edge, the same 2px on a ~200px tile reads as a plain rectangle. A small tinted
surface is _content_, not architecture — the `ChatMessage` bubble, `Textarea` at
`tier="commit"` (a pill would be absurd on a multi-line field), and `Card tier="bridge"`,
which is how a consumer says "this tile is too small for the container radius" without
hand-setting a `rounded-*` class and splitting the contain family.
Anything that genuinely _is_ a panel, dialog or container stays on `contain`.
### The tv() variant engine
> **Canon.** The fold order below is described once, here.
> [ComponentStructureStandard.md](ComponentStructureStandard.md) links to it for the
> authoring rules that follow from it.
All variant logic runs through a **custom `tv()` engine**
(`packages/blocks/src/lib/utils/variants.ts` — `wc -l` it before you read it — a
zero-dependency replacement for `tailwind-variants`). Each component has a
`*.variants.ts` defining slots, variants, sizes, intents, compound variants and defaults.
**The conflict resolver.** The pipeline is an ordered list of sources —
`slot-base → each variant axis in declaration order → each matching compoundVariant in
array order → call-site class` — folded sequentially: every later source **strips**
conflicting Tailwind utilities from everything before it. The call-site `class` is not one
source but as many as the array has top-level elements, so the shape the components already
write — `{ class: [slotClasses?.base, className] }` — is the override ladder's last two
rungs and resolves as one.
So `slotClasses={{ box: 'rounded-full' }}` deterministically defeats a base `rounded-sm`,
and an active-state compound's `bg-neutral` defeats an outlined variant's `bg-transparent`.
**Axis and compound order are therefore semantic**: declare the axis that must win a shared
bucket later. The house axis order that follows from it, and the `variantProps` rules that
decide which axes a component may be addressed on, are in
[COMPONENT-API-CONVENTIONS.md § variantProps and the house axis order](COMPONENT-API-CONVENTIONS.md#variantprops-and-the-house-axis-order).
The resolver works on **bucket equality** — two classes conflict only if they normalize to
the same key (`bg-color`, `border-t-width`, `text-size`, `opacity`, …). Modifier prefixes
(`hover:`, `focus-visible:`, `md:`, `!`, negative sign) are part of the bucket key, so they
isolate naturally: `hover:bg-red` and `bg-blue` coexist. The modifier/base split is
bracket-aware, and arbitrary properties bucket per CSS property name (`[gap:inherit]` shares
the `gap` bucket; `[--spinner-speed:1s]` conflicts only with the same custom property).
Within a _single_ class string nothing is stripped, so intentional pairings like
`rounded-md rounded-t-none` survive.
**Directional shorthand dominance** (a subset of `twMerge`'s map): a later shorthand strips
the longhands it fully covers — `p-0` defeats an earlier `px-4`/`pl-10`, `rounded-none`
defeats `rounded-t-*`, `inset-0` defeats `top-*`, `size-8` defeats `w-*`/`h-*`. The reverse
never strips: a later `pl-2` _refines_ an earlier `p-4`.
**Fail-loud configs.** Slot-map keys are compile-checked against declared slots (a
`wrapeer` typo is a type error), and every config is validated once at module init —
unknown slot keys, unknown compound axes, undeclared `defaultVariants`, `base` + `slots`
together all **throw** with precise messages rather than degrading silently. Every resolver
exposes its config as `.config`, which is what `bun run variants:lint` replays over the
pairwise variant matrix to find dead tokens. The same run asks the Tailwind compiler which
properties each class declares and compares that against `BUCKET_PATTERNS`, in two passes
over two populations. A family the table has **no** pattern for — where both classes would
survive and the stylesheet's emit order would decide again — is asked about the classes the
library ships, so a gap in a family no component writes stays invisible until one does. A
bucket holding classes that write **different** properties is asked about Tailwind's whole
class list as well, because such a bucket strips something for nothing in every consumer
bundle whether or not this library ever writes it. Neither population subsumes the other:
`getClassList()` enumerates no arbitrary value and no variant-prefixed form, so
`stroke-[2px]` and `active:scale-95` are visible only in the shipped half.
Coverage limits and why they are deliberate: [DECISIONS.md](DECISIONS.md#the-tv-engine-is-narrower-than-tailwind-variants).
### The override cascade
> **Canon.** The cascade, the ladder, `slotClasses`, `preset`, `overrides`, theme `props` and
> `unstyled` are described once — here. [COMPONENT-API-CONVENTIONS.md](COMPONENT-API-CONVENTIONS.md) carries
> the API-surface half (which props a component must offer, how they are named and typed) and
> links back for the mechanism.
Consumers restyle components through one ordered chain, conflict-resolved per Tailwind
bucket so a later source always wins — the instance `class` prop included, which reaches
`tv()` as a source of its own rather than glued to the resolved `slotClasses` string:
```mermaid
graph LR
A["defaults .slotClasses"] --> B["defaults .overrides[match]"]
B --> C["preset .slotClasses"]
C --> D["preset .overrides[match]"]
D --> E["instance .slotClasses"]
E --> F["instance .class"]
```
**Which rung to reach for.** Take the narrowest one that solves the problem — the narrower
rungs preserve more of the system's behaviour (dark mode, the hover/active cascade, focus
rings):
1. **`class`** — restyle one element (the one that component's `class` prop names, usually
the root slot) on one instance.
2. **`slotClasses.`** — restyle an inner element on one instance.
3. **`preset` / `BlocksProvider` defaults** — an app-wide look for a component type: `props`
where the look is one the library ships as a variant, size or intent, `slotClasses` for the
rest.
4. **`overrides`** — style only one variant / intent / state; prop-conditional, which is
what unconditional `slotClasses` cannot express.
5. **`unstyled` + `slotClasses`** — strip every default and rebuild the look.
**That numbering is blast radius, not cascade strength, and the two run opposite ways.**
Rung 1 changes the least and also **wins** the most: the instance `class` is the last source
the fold sees, so `class="py-4"` strips a `slotClasses={{ base: 'py-8' }}`, which in turn
strips a provider default. Reaching higher up the list buys reach, never precedence.
`slotClasses` (unconditional) and `presets` (opt-in, named) are registered on
`BlocksProvider`; a preset is consumed via the `preset` prop on any component:
```svelte
```
**Theme `props` decide the variant before the cascade dresses it.** A provider entry and a preset
carry a `props` key beside their classes, and it sets the component's style props where the
instance sets none: instance prop → the active preset's `props` → `defaults..props` → the
component's own default. Everything already ranked above that own default stays above the theme —
a `TierContext` from a Toolbar or ButtonGroup, a ButtonGroup's variant on its Buttons, Badge's
neutral `tag` intent — and a theme never decides what is announced: Alert paints a theme's `intent`,
from `defaults` or from a preset alike (a `preset="error"` written at the call site included), but
takes `role="alert"` from the instance `intent` alone. Which props a theme may set at all — style yes, behaviour and state no — and
the classification behind it are
[COMPONENT-API-CONVENTIONS.md § Theme props](COMPONENT-API-CONVENTIONS.md#theme-props-style-in-the-theme-behaviour-at-the-call-site).
```svelte
```
Nothing about the class cascade changes: it runs against the props the component resolved, so an
`overrides` rule keyed on `{ variant: 'outlined' }` fires on every instance a theme made outlined
(pinned in `provider/theme-props.svelte.test.ts`). That is also why a theme default beats repainting
a variant through `overrides`: an `outlined` repainted into an underline means something else in
that theme than everywhere else, and an agent asking for `outlined` gets an underline.
Each component reads its theme under its own name, where its own default is written
(`variant = theme.variant ?? 'outlined'`, after `preset` in the destructuring), so a value it
forwards to a component it renders is that component's instance value. Two shapes follow. A
composite with a default of its own sets the forwarded prop, and the inner name does not reach it:
DatePicker's field takes `DatePicker.props.inputVariant`, not `Input.props.variant`. A composite
without one forwards `undefined` where its name sets nothing, and the inner component resolves
under its own name — NumberInput's field takes `NumberInput`'s entry, then `Input`'s, the same order
the two names' classes fold in. Either way a value is resolved once. An agent-rendered component
gets the theme the same way, which is what presets could not do (an agent never writes `preset`):
the A2UI renderers leave a style prop the payload does not name `undefined` rather than writing a
fallback of their own, which would read as an instance value.
What holds the two halves together. The type derives each component's admissible keys and values
from its own props (`ThemePropsOf` in `provider/component-slots.ts`, the same barrel query the slot
map uses), so `Card: { props: { size } }` — a key Card does not declare — does not compile. Whether
the component _reads_ a key it declares is not a type question; `provider/theme-props-sweep.svelte.test.ts` mounts
every component of the cascade registry with every value the checker says `ThemePropsOf` admits, and
requires the theme mount to render what the call-site mount renders, plus the preset rung and the
instance-beats-theme order. Its population comes from the type, so a style prop a component
declares and does not read fails it. A style prop that also sets an attribute only the call site
may decide — Alert's `intent` and its `role` — fails it too until it is an entry in
`CALL_SITE_ATTRIBUTES` (`provider/__fixtures__/theme-call-site.ts`); the sweep then compares the rest
of the markup and requires the theme mount to carry that attribute as a mount with no value does. `provider/theme-props-ssr.test.ts` repeats the first route on
the server, where a style prop destructured ahead of `preset` throws. A value that paints only
behind another prop, a real child or an opened panel is mounted with it (the sweep's `PROBES`, each
stale once the prop shows without it). What neither reaches: a name outside the package barrel —
`Table`, held by its own tests in `@urbicon-ui/table`, and a consumer's wrapper, typed open as their
slots are — and a config typed `Record`, which checks nothing; a
development build reports a `props` key that is no style prop at all, once per component.
**Conditional `overrides`** target a specific variant/intent/state — what unconditional
`slotClasses` cannot express. Each entry is a `compoundVariant`-shaped matcher; on a match
its classes join the cascade and the resolver strips the library's conflicting class:
```svelte
```
Entries match the component's **effective variant props** (via `matchesCompound`), so it
is irrelevant whether `border-2` lives in a `variant` or a `compoundVariant`. Effective
means: every axis the component's own condition object **names**, at its value, or at the
config's `defaultVariants` value where it named the axis but wrote `undefined`. `string`
matches by equality, `string[]` as "one of"; multiple matching entries merge additively.
Naming is the boundary, and it is deliberately narrower than `tv()`'s own fold. `tv()` may
fill in an axis the object never mentioned, because it styles one slot call at a time; an
`overrides` rule is resolved **once per component** and applied to every slot, so an axis
the object does not carry is one the component cannot speak for. Which keys a component
therefore puts in its `variantProps`, and why an axis shared with a sibling or handed to a
single slot call stays out, is the authoring rule in
[COMPONENT-API-CONVENTIONS.md § variantProps and the house axis order](COMPONENT-API-CONVENTIONS.md#variantprops-and-the-house-axis-order).
The keys are variant **axis** names. Those are the component's public prop names wherever
it has a prop for the axis, and internal where the axis is computed rather than received
(`hasRightIcon`, `messageType`, `open`, Card's `interactive`). Both sides of a boolean axis
are addressable: `{ disabled: false }` fires on every component that names a `disabled`
axis, while a component that dims through the `disabled:` CSS variant rather than a variant
branch has no such axis and cannot be addressed on it at all.
Components resolve through the shared
`resolveSlotClasses(config, name, preset, activeProps, instanceSlotClasses, variantConfig)`,
each feeding its active `variantProps` plus its own `tv()` config — the config is what
turns the raw bag into the effective one, so `overrides` applies library-wide and means the
same thing everywhere. Not every component reaches it, and the exceptions are shapes rather
than a list: a **wrapper** calls it indirectly — it hands its name down and the component it
wraps makes the call (below); `CalendarHeader` and `FormField` read their slot classes from
elsewhere (the Calendar context, and their own prop) yet still declare a `slotClasses` prop,
so an entry under either name type-checks, narrows, and arrives nowhere; and
`PaginationItem` has no provider name of its own — it declares no `slotClasses`, and the
`preset` it does declare resolves under `Button`, for its button form only, because the
`href` form dresses its own anchor with `buttonVariants()` and resolves no cascade. Routes
A–C of `provider/provider-cascade.svelte.test.ts` pin what that leaves unaddressable. Which names exist is derived from the components themselves in
`provider/component-slots.ts`, never listed here.
The name is not always the component's own. A **compound part** — `CalendarHeader`,
`MenuItem`, `CalendarDay` — renders only inside another component and is addressed under
_that_ component's name: `defaults.Calendar.slotClasses` reaches CalendarHeader's header,
nav and title. A **wrapper** carries both names at once instead, and resolves neither
itself: it hands its name, `preset` and instance `slotClasses` down the context
(`provider/wrapper-cascade.ts`), and the component it wraps resolves that cascade under the
wrapper's name against its **own** `variantProps` — which is where the axes are, since
`tier` (off a context), `messageType` and the coerced `error` are not knowable above it at
all, and Select's `open` not in time — it is `$bindable`, so a wrapper may hold the value,
but the wrapper's own `$derived` runs before the child ever writes to it. So
`defaults.NumberInput` reaches its own stepper _and_ that field, while
`defaults.Input` reaches the field alone — both measured, and the fold order with them, by
`provider/wrapper-cascade-order.svelte.test.ts`. The `preset` travels too; resolving it
under the wrapper's name is the point, because forwarded as the inner component's own
`preset` it would style every text field (#355). Only the resolved record goes back the
other way, for a wrapper that has slots of its own: NumberInput's stepper renders as Input's
`rightIcon`, so Input has resolved by the time that snippet runs. An entry written under a
name nothing resolves as matches no lookup and is never read, with nothing reported — the
same silence a misspelt component name buys.
**Writing your own wrapper** takes the same two steps minus that context, which is internal
to the package: destructure `preset` and `slotClasses` out of the rest spread, then hand the
inner component
`slotClasses={resolveSlotClasses(config, 'YourWrapper', preset, writtenProps, slotClasses, innerVariants.config)}`
and no `preset`. `writtenProps` names the axes your wrapper can speak for — not `label`,
which the inner condition object never carries either — and `resolveSlotClasses` answers the
ones it left out from the same `innerVariants.config`. Most of what the internal path buys
the library's four wrappers is still yours to write, because a derived axis is not a
hidden one: `error: !!error` and `messageType: error ? 'error' : 'helper'` are computed from a
prop you hold, and `getTierContext()` is exported, so
`tier: tierProp ?? getTierContext()?.tier ?? 'modify'` reads the same context `Input` reads.
`DatePicker` is the worked example in this package. Two axes stay out of reach: Select's
`open` is the component's own runtime state, and `hasRightIcon` diverges in one direction —
`clearable` with a value renders a clear button, which makes the axis `true` on a field whose
wrapper passed no `rightIcon` (measured), while the wrapper's `!!rightIcon` says `false`. For
those two, put the rule under the inner component's name. Theme `props` under your wrapper's name
are read the way the library reads its own:
`const theme = useThemeProps('YourWrapper', () => preset)`, then
`variant = theme.variant` in the destructuring, after `preset`, forwarded to the inner component —
which resolves its own name for whatever you leave `undefined`.
The chain's last two rungs are the call site's array (above), so a library class placed in it
belongs **before** the consumer's rungs (`Button`'s `pressCueClass`), never after. The
`unstyled` branch has no `tv()` to fold through and folds the same list with
`resolveClassChain` instead. The two are separate expressions per slot, so their agreement is
a thing the sweep asserts (route G) rather than a thing the shape guarantees: a one-line edit
to either branch parts them, and only route G notices.
An element with **state slots** carries a library default _and_ a consumer entry on each,
and all of them share that one array. The order is every library state first, then the
consumer's base slot, then the consumer's states in the same order the library's are in —
never the two halves of one state folded together, which puts a library class after a
consumer rung again. So a `slotClasses.option` beats `Combobox`'s active highlight the way
it beats the base slot, and a consumer who wants the highlight back addresses
`optionActive`; and because both halves are written the same way round, `optionSelected`
outranks `optionActive` for the consumer exactly as it does for the library. Route D reports
the fold only where the state holds on the **first** element the neighbouring slot lands on —
measured, and pinned by a purpose-built fixture in the sweep, because `slotElements` keeps
that first element and reads no other. Combobox's `optionSelected` holds there; its
`optionActive` is never entered by any mount the sweep makes, by keyboard or by pointer. What
route D cannot reach is held by mounted tests instead.
`unstyled` travels the same way — by prop, never by context. A component passes it to the
blocks components it renders itself (DatePicker → Input/Popover/Calendar, ChatMessage →
Avatar/Alert/Tooltip, …), so a half-stripped widget is not a state one instance can be in.
It deliberately does **not** reach components the consumer hands in as `children` or a
snippet: that would be action at a distance from a prop written on the wrapper.
`` is the tool for a whole subtree.
Which components that covers is measured rather than listed: route H of
`provider/provider-cascade.svelte.test.ts` mounts a component twice and requires the instance
flag to remove exactly what the provider flag removes. It answers for the markup that mount
renders and no more — measured, it is blind to a child that appears only in a state the mount
does not reach: `AvatarGroup`'s avatars (no `items`), `CalendarHeader`'s overlays and
`FileUpload`'s progress bar (both closed). That is why the roster is _also_ derived from the
source rather than read off the sweep, and why those three are listed as `ROUTE_H_BLIND` in
the sweep itself — a mount fixture that reaches the child takes a component off that list, as
`Guide`'s tour fixture did.
**What `unstyled` does not remove — stated once, here.** It drops the `tv()` pass, not every
class. Two kinds of thing survive it, and a stylesheet styling a stripped tree targets them:
1. **The component's `blocks-*` hooks.** `Button` writes `blocks-button` + `blocks-intent-*`,
`Avatar` writes `blocks-avatar` + `blocks-intent-*`, `PaginationItem`'s anchor writes
`blocks-intent-*` alone. Most of the library carries none, and those components really do
reduce to the classes the consumer wrote — `Input` and `Combobox` among them.
2. **The press-cue custom property** `[--blocks-press-scale:1]`, but only while the mint is
off — `isMintOff` is true for `undefined`, `''` and `'none'`. It therefore depends on a
default that differs per component: `Button`'s `mint` is `'scale'`, so the property is
absent unless a consumer turns the mint off; `PaginationItem`'s is `'none'`, so it is
present.
Both measured cases come to two surviving classes, for different reasons — `Button` two hooks
and no press cue, `PaginationItem`'s anchor one hook plus the press cue (the sweep's C row:
2 of that anchor's 39 root classes). Do not carry a count into a prop's JSDoc; name the hooks
and link here. The structural plumbing of an embedded core survives as well —
`CoreIconButton` takes no `unstyled` prop at all.
Key files: `provider/BlocksProvider.svelte`, `provider/blocks-context.ts`,
`provider/theme-props.ts`, `utils/variants.ts`. Which props a component must expose for all of this, and how they are
named and typed: [COMPONENT-API-CONVENTIONS.md § Styling props](COMPONENT-API-CONVENTIONS.md#styling-props-class-unstyled-slotclasses-preset).
### The internal core layer
> **Canon.** The core layer is described once, here. ComponentStructureStandard and
> COMPONENT-API-CONVENTIONS each keep one sentence and a link.
Public components never import each other for trivial embedded controls — that made every
Badge ship Button's full variants matrix and every Button ship the whole Spinner (measured:
Badge −29 %, Dialog/Drawer −30 % gz after the extraction). Shared behaviour lives in
`src/lib/internal/core/` instead — four cores today:
- **`CoreSpinner`** — the spinner arc (geometry shared with the public `Spinner` via
`spinner-geometry.ts`) plus spin animation. No role/aria: the embedding context carries
the semantics, and a `role="status"` here would nest live regions inside Toast.
- **`CoreIconButton`** — a behaviour-only native `