# 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 ``` **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 `