@poodle64/ui
v2026.9.17
Published
Household shared component layer: shadcn-svelte primitives (bits-ui) plus the composed page chrome every app builds its routes from, restyled by each app's @poodle64/design-tokens alias layer. One fix reaches every app.
Readme
@poodle64/ui
Household shared shadcn-svelte component primitives (bits-ui), extracted from
the estate's most conformant consuming app's frontend — the best-looking,
most battle-tested implementation of each primitive — plus alert/popover/
progress/tabs from the first app that migrated onto this package (WP-51
Lane WP) and needed them.
bits-ui is a required peerDependency: components share compound-component
context across the package, so a duplicated bits-ui instance is a real
functional bug (mismatched types at best). mode-watcher and svelte-sonner
are optional peers (peerDependenciesMeta), needed only if the consuming app
uses @poodle64/ui/sonner (a single dark-mode store for mode-watcher, one
toast queue for svelte-sonner's toast() + Toaster pair — a duplicated
instance there means a Toaster that never sees the app's own toast()
calls). Declare whichever peers you use directly in your own package.json:
pnpm auto-installs missing peers, but an explicit dependency is what lets
Renovate track the version and pnpm ls show it.
Every app previously vendored its own copy of these primitives and restyled them
through its @poodle64/design-tokens alias layer. That let apps differ by palette,
but a fix (an accessibility bug, a focus-trap issue) had to be applied once per app.
This package is the same restyling mechanism: components consume shadcn's standard
CSS variable names (bg-primary, text-foreground, --radius, …), and both the
component code and the mapping those names resolve through now live once, here.
Apps differ by palette and nothing else. Override --ds-color-* and the whole
shadcn surface follows; a consuming app writes no alias layer of its own.
What is here
src/lib/
utils.ts cn() (clsx + tailwind-merge) and the shared TS helper types
format.ts the Australian value formatters: money, dates, percentages,
numbers — en-AU, AUD, Australia/Brisbane
telemetry.ts browser telemetry: one initBrowserTelemetry() call, one Faro
instance, one session id (see below)
styles.css the component stylesheet: scale keys, .ds-edge, .ds-chip/.ds-dot,
the dialogue-section divider rule
components/ui/ one directory per component: the shadcn-svelte primitives
(bits-ui behaviour + shadcn markup/variants) and the composed
page-chrome components built on top of them
components/feedback/ ReportWidget: the fixed bottom-right "report a problem"
trigger and dialogue every app mounts (see below)
dist/ generated by `pnpm build` (@sveltejs/package); never editPrimitives (26 — battle-tested implementations pulled from whichever app had
them first, not an invented "ideal" list): alert, alert-dialog, avatar,
badge, button, card, checkbox, command, data-table, dialog,
dropdown-menu, input, input-group, label, password-input, popover,
progress, select, separator, skeleton, sonner, switch, table,
tabs, textarea, tooltip.
avatar is root, image and fallback only; the group and badge variants in the
upstream registry had no consumer in any surveyed app. It is here because
AppShell's identity slot needs one, and a consumer filling a slot the shell
itself defines should not have to keep a private copy of a primitive to do it.
Not yet included: sheet — no app's ui/ has vendored it yet. Add it here
(pnpm dlx shadcn-svelte@latest add <name> inside packages/ui, or hand-port from
a sibling app once one adopts it) the first time a converging app actually needs it;
do not invent it speculatively.
Deliberately not here: chart and form. Two apps keep local copies of each,
and both stay local for reasons that are about coupling rather than effort.
form in the upstream registry is a Formsnap wrapper over Superforms. Adding it
would make a form library a peer of the whole design system and decide, for every
app, how validation and submission work — which is an application-architecture
choice, not a design one. An app that uses Superforms already gets its markup from
input, label, input-group and button here; what it keeps locally is the
binding layer, which is where it belongs.
chart is a LayerChart wrapper, and it needs the chart-1…chart-5 colour keys
this stylesheet deliberately does not register — chart hue is the one part of the
shadcn surface the estate genuinely disagrees on, and registering it here would
claim a key no app can then override without a stylesheet-order coin toss (see
the one-owner-per-key note below). It becomes a candidate the day two apps want
the same chart palette; until then a shared chart component would be a shared
disagreement.
Composed components (24). Primitives are not what makes an app look like an app — the page chrome is. These are the cross-cutting surfaces every route composes from, so a household app gets its layout language from the package rather than rebuilding it:
| Import | What it is |
| ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| page-header | The only page-title pattern: optional breadcrumbs and icon snippets, eyebrow, an optional title, one clamped subtitle, an info tooltip, a meta row and an actions slot. Omit title for a header that is a breadcrumb bar. |
| panel | The generic titled card, and the settings section: optional icon, a clamped one-line subtitle or a wrapping description, trailing actions, a body that can opt out of padding, an optional footer strip for Save/Cancel, and tone="destructive" for a danger zone. There is no SettingsSection; see The settings destination. |
| detail-panel | The entity-detail surface: header with icon/eyebrow/title/StatusBadge/close, scrollable body, footer of actions. |
| settings-shell | The settings destination: a grouped section list of NavItem rows beside the section you are reading, both scrolling independently from md, stacked below it. Render it inside AppShell with padded={false}. |
| context-column | The persistent right-hand column: a standing StatList plus an optional detail that flows in on select. Below xl (1280px), where the column has nowhere to sit, the same content opens from a floating trigger instead of disappearing. |
| app-dialog | The dialogue frame: titled header, scrollable body, footer action bar, five sizes (xs…xl), and an onOpenChange for the dismissals the caller did not drive. |
| dialog-section | One section of a dialogue body; adjacent sections are divided automatically. |
| stat-card | A single metric that earns its space (label, value, unit, sub, status dot, and valueTone to colour the figure itself). |
| stat-list | A route's low-context integers as a label→value list. Zero-aware: muted keeps a healthy zero quiet. |
| arc-gauge | A radial capacity/percentage ring for a single 0–100 metric, in a footprint too compact for a stat-card. |
| bar-row | A labelled horizontal bar with a trailing tabular value, for a ranked list (usage, rank, token burn). |
| scorecard | A compact 0/1/2 dot-row health strip for several independent checks read at a glance. |
| sparkline | An inline multi-series area+line trend for a row or card with room for a trend but not a full chart. |
| status / status-badge | The fixed five-state vocabulary (success \| warning \| error \| info \| neutral) and the one state chip, with pulse for a state still in motion and class for placement. status-badge alone also accepts 'primary', a brand-emphasis extension outside the shared vocabulary — stat-card, stat-list and data-table-toolbar never see it. |
| empty-state / error-state / loading-state | The shared blank, error and loading surfaces. Never hand-roll one. |
| info-tip | One tooltip pattern: a small info trigger, or wrap an existing affordance as children. |
| data-table-toolbar | Search field plus filter-chip groups for a TanStack table. Owns no state; fires callbacks. |
| schema-form | The renderer for a config object the server described: a JSON Schema plus a JSON Forms UI Schema in, a form out. Anything it cannot dispatch renders flagged, never blank; see Server-described forms below. |
| data-table-tanstack | The TanStack-backed table: global search, column filters, master-detail row select, opt-in bulk selection, responsive column hiding, a first-class empty branch. |
| library-browse | The faceted catalogue index: search, facet rail, active filter chips, the document table and a pager, all over plain props (LibraryDocument[], LibraryFacet[]). The page fetches, maps and routes; the component renders. Library data is fixed components because its shape is stable; configuration is schema-form because its shape changes (#30). |
| collection-detail | One collection's surface: identity (detail-panel), an at-a-glance stat-list, and the documents it holds, with actions and children slots for the app-specific rest. |
| document-detail | One document's surface: identity fields, locations, tags and collection memberships, each section present exactly when its data is. Membership links are the app's own via collectionHref. |
| search-results | A ranked retrieval answer: title, the matched passage with accent-tinted highlights, source chip, mapped state and a mono relevance figure per hit; plus the before-any-search and matched-nothing empties. |
The application shell (app-shell, command-palette). Page chrome is not
what makes an app feel like an app either — the shell is. Five household
frontends were surveyed before this API was settled, and between them they had
built five shells: a rail-plus-drawer, an eleven-file collapsible sidebar tree
under its own top bar, and three header-only bars that each answered the mobile
question differently. They agreed on almost nothing structurally while trying to
be the same thing.
There is now exactly one shell shape, no prop to choose a different one.
Operator ruling, 31/07/2026: every household app renders the same composition,
a permanent side rail on md+ (an overlay drawer below it) carrying primary
navigation, brand and the collapse toggle, plus a real top navbar (search,
leading/trailing slots, theme toggle, identity), always both together, with
content that is always full-width. Apps do not choose variants, content modes,
or spacing.
Everything the apps otherwise differed on turned out to be a slot, not a variant. The package therefore imports no app store, no app route and no app brand:
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import AppShell, { type NavGroup } from '@poodle64/ui/app-shell';
import CommandPalette from '@poodle64/ui/command-palette';
import { nav } from '$lib/config/navigation'; // typed as NavGroup[]
let paletteOpen = $state(false);
</script>
<AppShell {nav} brandTitle="Console" currentPath={page.url.pathname}
onSearch={() => (paletteOpen = true)}>
{@render children()}
</AppShell>
<CommandPalette bind:open={paletteOpen} {nav} onNavigate={goto} />That is the whole minimum. Everything below is optional.
| Prop | Purpose |
| ------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
| nav | Bare NavItems, NavGroups, or a mix. Consecutive bare items collapse into one run; an emptied group renders nothing. |
| currentPath | Active state, and closing the mobile nav on navigation. |
| collapsible, collapsed | An icon-only rail collapse toggle whose state binds out so an app can persist it. collapsible defaults to true. |
| brand / brandTitle + brandMark / homeHref | Full control of the lockup, or the wordmark-plus-mark shorthand. |
| identity | The signed-in surface. Rendered once, at the end of the top bar. |
| context, actions | Leading and trailing top-bar slots: a store/tenant switcher, app-level action buttons. |
| banner | Full-width region under the bar: reconnect notices, trial warnings. |
| onSearch, searchLabel, searchShortcut | Provide onSearch to render the search affordance at all. |
| themeToggle, onToggleTheme | Defaults to mode-watcher. Set themeToggle={false} when the app puts theming inside its own user menu. |
| measure | How wide the page body may get, from a named scale. Defaults to full (no cap). |
| texture | The house atmosphere on the content region. Defaults to grid; none is the opt-out. |
| padded, mainClass | Padding, and extra classes on the scrolling content container. |
The bar also names where you are — the active nav label, read off nav and
currentPath — and carries the page's scope controls. A page registers them
with ShellControls; they render after the location, inline from xl and on
a second row of the bar below that:
<script lang="ts">
import { ShellControls } from '@poodle64/ui/app-shell';
import { Segmented } from '@poodle64/ui/segmented';
let year = $state('FY26');
</script>
<ShellControls>
<Segmented bind:value={year} options={YEARS} label="Financial year" size="sm" />
</ShellControls>A scope control changes the view of the page you are on and never
navigates; sub-routes are NavItem.children. The rail's right edge is a drag
handle (200–420px, arrows nudge, Home resets, a drag below 170px collapses);
the width persists in localStorage as ds-shell-rail-width.
Nested navigation
A section with its own navigation puts it on the item, as children, and the
rail discloses it in place:
{
label: 'Education',
href: '/education',
icon: BookOpen,
children: [
{ label: 'Open architecture', href: '/education/open-architecture' },
{ label: 'Autonomy', href: '/education/autonomy' }
]
}That is the whole API. An item with no children renders and behaves exactly as
it did before the field existed.
It exists because the alternative was two left-hand columns: an app whose
sections have inner navigation had nowhere to put it in the rail, so it put
modules in nav and the current section's pages in a sidebar slot. The other
way out (modules along the top bar, rail for the current module) was rejected on
mobile: it leaves two navigation surfaces that both need collapsing and both want
the same hamburger. One nested tree collapses to one drawer.
Since 2026.8.11 it is the only way in: the sidebar slot is gone. What it
produced was the shell shape differing per app, and in one app per MODULE — two
of its sections rendered their pages beside the rail and the rest rendered them
inside it. Operator ruling, 21/08/2026: no app supports an additional sidebar.
The behaviour, and why:
- The parent stays a destination. Its label navigates; a separate chevron
expands. Folding both into the link cannot be made honest, since
aria-expandedon something that navigates away announces a state the user never observes; and making the label expand-only would silently change whathrefmeans the day an app adds children to an item that already had one. - A group holding the current page is open, on first paint, with nothing clicked. A rail that will not show you where you are is the defect this feature exists to remove.
- A deliberate toggle wins, and keeps winning for the life of the shell, which in a SvelteKit layout is the session. It overrides the path default rather than replacing it, so an untouched group still opens as you walk into it while a group you made a decision about keeps your decision. Nothing is written to storage: a shared package writing to a fixed key would collide with the app's own, and with itself on a page rendering two navs.
- One level, enforced by the type.
childrenholdsNavChildItem, which isNavItemminuschildren, so a second nesting is a compile error where the nav is authored. The rail is 15.5rem and every level costs an indent; by depth three the label has less room than the chevron. - A collapsed rail renders no tree. 3.5rem of icons cannot hold one, and a hover flyout would be a second interaction surface with its own positioning, touch and focus story. The parent stays an icon-only link to its own page; the way back is the Collapse control the user just used.
- The drawer renders the same tree. It is the same element as the rail, and it is never collapsed.
What this does not absorb, and deliberately: a children list is flat and one
deep, so a section whose own navigation is itself grouped under sub-headings, or
whose rows disclose a third level, does not lift into the rail whole. The rail is
not the place to fix that. At 15.5rem a third level leaves roughly 128px for the
label, which is about fifteen characters. A section that deep keeps its deepest
level on its own page, where there is width for it. A column that is not
NavItem-shaped at all — a document tree, a table of contents, a filter panel —
belongs in the page too, as a sibling of the article it serves, and not in the
shell: it is part of that page's own reading surface, it wants that page's
breakpoints, and no other route should be paying rail width for it.
NavItem / NavGroup are exported so an app types its own config against them.
They carry no notion of who may see an item: two surveyed apps gate
navigation on admin or per-module permission, and both do it with their own auth
store. Apps filter before they pass (nav.filter((i) => !i.adminOnly || user.isAdmin)),
and that is what keeps this package free of the auth coupling that made the best
shell in the estate unliftable in the first place.
currentPath is a prop rather than a $app/state import for the same reason:
this package is built with svelte-package and has no SvelteKit runtime, so
importing it would make SvelteKit a hard peer and make the shell untestable
outside a running app.
CommandPalette ships beside the shell because it carried the identical
coupling to a hardcoded navigation module; leaving it behind would have stranded
the shell's search affordance. It binds ⌘K / Ctrl-K itself (shortcut={false} to
opt out) and takes a children snippet for app-specific command groups beneath
the navigation group.
The shell paints its chrome from --ds-shell-chrome, registered as the shell
colour key. It is deliberately not sidebar: those keys stay the app's to
define, and one-owner-per-key is what keeps an override from depending on
stylesheet order. An app that already has a chrome hue points it there in one
line, :root { --ds-shell-chrome: var(--sidebar); }, and --ds-shell-rail-width
retunes the rail.
Chrome ink follows the same pattern, through --ds-shell-chrome-foreground
and --ds-shell-chrome-muted-foreground. Both default to the page's own
foreground tokens, so nothing moves until an app asks; set them to invert the
whole chrome — rail, bar, every chrome control and every nav row — against the
palette:
:root {
--ds-shell-chrome: var(--ds-color-primary);
--ds-shell-chrome-foreground: var(--ds-color-primary-foreground);
--ds-shell-chrome-muted-foreground: color-mix(
in oklch,
var(--ds-color-primary-foreground) 70%,
transparent
);
}AppNav used outside the chrome (a navigation list inside a page) keeps the
page's ink, so inverting the rail does not drag it along.
The settings destination
Settings is the one destination with a second column, and it takes one shape in
every app. SettingsShell is that shape: a grouped section list beside the
section you are reading, both panes scrolling independently from md.
<script lang="ts">
import { page } from '$app/state';
import AppShell from '@poodle64/ui/app-shell';
import type { NavSource } from '@poodle64/ui/app-shell';
import SettingsShell from '@poodle64/ui/settings-shell';
import Users from '@lucide/svelte/icons/users';
import Gavel from '@lucide/svelte/icons/gavel';
// Grouped by WHOSE setting it is: the person's, then the workspace's. It is
// the only split a reader can predict: "yours" changes what you see, "this
// company" changes what everyone sees. A group with no items renders nothing,
// so an app with no personal sections yet can leave it in the list.
const sections: NavSource = [
{ heading: 'Yours', items: [] },
{
heading: 'This company',
items: [
{ label: 'Users', href: '/settings/users', icon: Users },
{ label: 'Delegation limits', href: '/settings/delegation-limits', icon: Gavel }
]
}
];
</script>
<!-- padded={false}: the list's rule and tint have to reach the content area's
edges, and SettingsShell pads its own content pane to the same rhythm. -->
<AppShell {nav} currentPath={page.url.pathname} settingsHref="/settings" padded={false}>
<SettingsShell {sections} currentPath={page.url.pathname}>
{@render children()}
</SettingsShell>
</AppShell>| Prop | Purpose |
| ------------- | ------------------------------------------------------------------------------ |
| sections | Groups of NavItem, bare items, or a mix: the rail's own vocabulary. |
| currentPath | Which row is the page. Prefix-matched on the segment boundary, as the rail is. |
| listLabel | The list's accessible name. Defaults to Settings sections. |
A row at the settings ROOT (/settings) needs exact: true, or its prefix match
keeps it lit on every section beneath it alongside the section's own row.
Three things are settled here and are not props:
Rows are icon plus label, with nothing beneath. A description long enough to be worth reading does not fit a 240px column; every one of them truncated to an ellipsis and told the reader less than the label already did.
There is no "Settings" heading above the list. The bar already names the section you are on, so a heading here is the same word twice.
Below md the list stacks above the content and the page scrolls as one. A
horizontally scrollable strip of the same rows was the alternative and loses on
the requirement it was meant to serve: it fits about two and a half rows at
375px, so the rest are behind a sideways swipe with no scrollbar advertising
them — a swipe then a tap, where a stack is a scroll the reader is already
doing. It also has to drop the group labels for width.
Density works here as it does in the rail, because the list is the rail's
list: the controls inside a section ride the --ds-control-* ramp and shrink
under data-ds-density="compact", while a section row keeps AppNav's own
36px whatever the density. A row that shrank here and not in the rail would be
the drift, not the fix.
The sections themselves are Panels
There is no SettingsSection, and deliberately so: it would have been Panel
with a different name (same surface, same header, same title), and the estate
would then hold two answers to "a titled section of a route", which is the drift
this package exists to end. What settings actually needed was three props, and
all three serve any route:
<Panel title="Delegation limits"
description="Who can approve a bill, and up to what value.">
<!-- controls -->
{#snippet footer()}
<Button variant="ghost" size="sm">Cancel</Button>
<Button size="sm">Save</Button>
{/snippet}
</Panel>
<Panel title="Delete this workspace" tone="destructive" icon={Trash2}
description="Every project, document and filing decision goes with it.">
<Button variant="destructive" size="sm">Delete</Button>
</Panel>description wraps; subtitle is the one-line qualifier beside the title and
truncates. Carry one or the other. A page writes a run of Panels and no
wrapper: the pane stacks them on the package's section rhythm itself.
The content measure
How wide the page body may get, chosen once in the layout from a scale of four:
<AppShell {nav} currentPath={page.url.pathname} measure="page">
{@render children()}
</AppShell>| Value | Width | Reach for it when the page is |
| ------- | -------- | -------------------------------------------------------------------- |
| prose | 72ch | long-form running text: documentation, an article, a policy, a guide |
| page | 80rem | an everyday page: a form, a detail view, settings, a wizard |
| wide | 120rem | an index or a dashboard: card grids, tables, charts, board columns |
| full | no cap | a canvas that should use the whole panel: a map, a diagram, a board |
full is the default, so a shell that does not mention measure renders exactly
as it did before the prop existed — verified, not assumed: the content region's
markup and geometry were diffed against the pre-change build across three
surfaces at three viewports and are identical on every field (harness/drive.md).
Adoption is deliberate, one app at a time.
It exists because "content is always full-width" did not remove the width
decision, it pushed it into every page. Surveyed at 2560px, one consumer had six
distinct caps across nine routes — each a hand-written mx-auto max-w-* at the
top of a +page.svelte, none wrong on its own, no two agreeing — using between
15% and 79% of the width available. Across the estate that is 42 hand-rolled caps
in 34 page files, spanning eight different max-w-* values. A scale with four
names cannot drift like that.
Why prose is a tier at all. Because a shared measure that let running text
span a large display would be worse than what it replaces, and worse everywhere
at once. Measured in Chromium at 2560px: the same paragraph runs to 311
characters per line uncapped, against an accepted band of 45–90 for continuous
text. prose puts it at 80. It is stated in ch rather than rem deliberately —
a reading measure is a count of characters, not a length, so it has to track
whatever face and size the app actually set.
Every tier is a CSS custom property, so an app retunes one without waiting on a release of this package:
:root {
--ds-shell-measure-prose: 66ch;
--ds-shell-measure-page: 72rem;
--ds-shell-measure-wide: 100rem;
}Two things worth knowing before you set it:
- The cap is a ceiling, never a floor.
pagecaps at 80rem; a 1440px laptop has less than that available, so nothing changes there. A measure never narrows a window that was already narrower than the tier. prosemoves when the font does. A webfont arriving after first paint reflows the column, becausechis measured in the face that actually rendered. That is the tier doing its job rather than a bug;pageandwideare lengths and never move.- The cap is the border box, so
paddedspends its padding inside it.measure="page"with the default padding is 80rem of box holding 78rem of content, which is the same arithmetic as themx-auto max-w-4xl px-6idiom it replaces. An app runningpadded={false}and its own page padding gets the cap flush, which is usually what it wanted.
Set it in the layout, not the page. A page reaching for measure is the habit
this replaces; a route group that genuinely differs (a docs section inside an
app of dashboards) gets its own layout, which is where a shared decision belongs.
A block inside the page: .ds-measure
A route legitimately set to wide — a dashboard, a table, a card grid — often
also carries a paragraph of explanatory prose, and that prose inherits the wide
measure. Cap the block, not the route:
<p class="ds-measure" data-measure="prose">Explanatory running text…</p>Same attribute and same custom properties as the shell's own measure, so the
block retunes when the scale does. That is the point of it: the alternative an
app reaches for is a local max-w-prose or max-w-[72ch], a number typed once
that never hears about a retune — four apps had written ten of them between
them and not one matched this package's own 72ch.
.ds-measure caps and nothing else. .ds-shell-measure, which the shell puts
on its own content box, is that plus margin-inline: auto — right for a content
box, wrong for a block within a page, where it indented a set of left-anchored
paragraphs ~207px away from their own label. Both halves are gated in
harness/drive.mjs at 2560px on a wide route: the cap binds, the block stays
on the page's own left edge, and it follows a retune of
--ds-shell-measure-prose.
The content texture
The house atmosphere — a faint dot-grid floor with a soft accent vignette in the top-right — painted once, by the shell, on the region that scrolls:
<AppShell {nav} currentPath={page.url.pathname}>
{@render children()}
</AppShell>| Value | What it paints | Reach for it when |
| ------ | ------------------------------------------------------- | -------------------------------------------------- |
| grid | a dot-grid floor plus one accent vignette in the corner | the default — the house "instrument-grade" surface |
| none | nothing at all | an app arguing a deliberate exception |
grid is the default since 2026.8.8, so a shell that does not mention
texture wears the house atmosphere. It shipped none-by-default in 2026.8.4
and the estate's answer to opt-in was measured a fortnight later: five of nine
stamped apps wore it, three of them through a hand-rolled *-dotgrid class in
their own app.css under three names. An opt-in house style measures who
remembered, not what the house looks like.
texture="none" is the complete opt-out and renders the region exactly as it
was before the feature existed — gated, not assumed:
node harness/additivity.mjs ui-v2026.8.3
# 15 surface/viewport pairs, 105 compared fields (including the screenshot hash)
# IDENTICAL on every field and every pixel against an explicit texture="none".harness/additivity.mjs builds the package at any base ref in a throwaway git
worktree, renders the five surfaces an existing consumer already has at 2560px,
1440px and 360px, and diffs the markup, both attribute sets, the computed box and
background properties, the geometry and the rendered pixels.
Why it lives on the shell rather than being a class an app applies. Because
that is the entire defect it fixes. Two apps had built this same picture
separately, under two names, in two app.css files, and one of them shipped it
as a helper class routes opt into, which reached four routes out of about
fifteen. Whether a given page wore the house atmosphere therefore came down to
which pages someone had happened to touch, and no amount of care at the call site
fixes that: a texture an app has to remember to apply is not a texture, it is a
coin toss with a stylesheet. On the shell there is one place to say it and no
per-route decision left to get wrong.
It is one texture rather than a menu. The two apps wanted the same picture and differed only on values: how dark the dots are, how the vignette is tinted, how far apart the dots sit. Those are custom properties, retuned in one declaration without waiting on a release of this package:
:root {
--ds-shell-texture-grid-ink: oklch(0.68 0.02 250 / 0.5);
--ds-shell-texture-vignette-ink: color-mix(in oklch, var(--primary) 8%, transparent);
--ds-shell-texture-grid-pitch: 24px;
--ds-shell-texture-vignette-height: 40rem;
--ds-shell-texture-vignette-at: 15% -10%;
}The defaults derive from the app's own --foreground and --primary, so the
grid inverts between light and dark with no per-mode override and the vignette
carries the app's brand rather than a colour this package picked. A plain wash
instead of a grid is the same declaration with --ds-shell-texture-grid-ink: transparent.
Three things worth knowing before you set it:
- It is a background, not a layer. Both apps that built this first reached for
an absolutely positioned
::beforeinside the scroller, which is subtly wrong three ways: atz-index: autoa positioned box paints above non-positioned content, so the atmosphere tints the page instead of sitting under it (invisible only because it is faint);z-index: -1trades that for sinking behind an ancestor's background; and it has to be excused from hit-testing by hand. A background cannot be hit-tested, always paints beneath every descendant, adds no box to the flex column and opens no stacking context. Proved rather than claimed: an opaque card photographs byte-identically with the texture on and off, while the floor beside it does not. - It does not print. A 30px dot grid prints as banding and a vignette as a
corner smudge, so on paper the texture is suppressed and the content region goes
white. That lives here rather than in each app's print rules; it is the same
copy both apps had already written for themselves.
html/bodystay the app's to decide. - It travels with the content. A scroll container's background defaults to
being pinned to its own box, which would leave the dots hanging motionless while
the page slid over them;
background-attachment: localmakes it the floor the page sits on. Driven by photographing bare floor either side of a half-pitch scroll, with the frozen behaviour forced on as a control. - The corner is a knob because a gradient position is physical. There is no
logical form of
at 85%, so an RTL app wanting the glow at the reading-start corner sets--ds-shell-texture-vignette-at. Driven atdir=rtl, at a 24px root size, and underforced-colors: active; none of those move the texture on their own.
Set it in the layout, once. That is the whole point of the prop.
The page header: icon, meta, and what eyebrow is for
<PageHeader title="Rivers Family Trust" subtitle="Deed of variation">
{#snippet icon()}<FileText />{/snippet}
{#snippet meta()}
<span>Opened 12/03/2026</span>
<StatusBadge status="success" label="Active" />
{/snippet}
{#snippet actions()}<Button>Edit</Button>{/snippet}
</PageHeader>icon takes the glyph alone; the tinted square around it — its size, its
radius, its alignment against the title — belongs to this component. That split
is the whole reason the slot exists rather than each app placing its own icon:
two apps had built the square and picked two sizes for it. meta is a wrapped
row of facts under the title, in muted small text.
Both are additive. A header that names neither renders exactly as it did before, asserted rather than assumed.
They were promoted on duplication, not on request: three apps had hand-rolled the meta row and two the icon square, and one of them had reimplemented this entire component locally to get them, across 38 of its 49 page headers. One app wanting something does not earn a shared slot; three apps having already built it does.
eyebrow is an exception, not a slot to fill
eyebrow renders a small uppercase kicker above the title, and a kicker above a
heading is one of the more reliable visual tells of generated UI. In an app with
a persistent nav rail it usually restates the section the rail already
highlights, and it costs vertical space above every title in the app.
It is kept because it is genuinely load-bearing in a few places, not because it
is a good default — one audited app passed it on all 17 of its page headers,
including a home surface whose kicker read "AIR 6015 · CASPO Workbench" directly
above a title reading "Workbench". Before reaching for it, check whether the
fact belongs in subtitle (a short line), info (an explanation), meta (a
fact about the page) or breadcrumbs (where you are). If one of those fits, use
it.
The active nav row: primary is a fill, never an ink
The active row is marked with a 12% --ds-color-primary tint, a primary edge
bar on the rail, a weight step to 500, and aria-current="page".
Its label is painted in the chrome's own foreground, not in the brand colour.
That is deliberate. --ds-color-primary is the one token every app overrides,
and the only constraint stated where an app picks it is that it clear AA against
its own -foreground pair: the fill case. Painting it as ink on the chrome
would impose a second, stricter requirement that nothing states and that
constrains a brand hue far more tightly. Measured in Chromium, a warm amber that
is 7.69:1 as a fill was 1.90:1 as an active nav label, and a saturated blue
that is 5:1 as a fill was 2.87:1. Both palettes were entirely sanctioned. So
the shell stops asking a colour it does not control to be legible text, and no
app is ever told to repaint its brand to make the shared shell readable.
An app whose primary genuinely is legible as ink on its chrome can put it back, and owns the contrast in doing so:
:root {
--ds-nav-ink-active: var(--ds-color-primary);
}Check that at ≥4.5:1 against --ds-color-surface-1 (the chrome), not against
--ds-color-background; they are different surfaces, and the chrome is the
darker of the two. harness/drive.mjs gates the default path across three
palettes in both themes on every run.
.ds-prose: HTML the app did not author
Rendered markdown, an extracted document body, a rich-text field — content whose tags the app did not write and cannot style at the call site:
<div class="ds-prose ds-measure" data-measure="prose">
{@html sanitised}
</div>Headings take the display face at sizes relative to the body copy around them
(an <h1> inside a document is not the page's <h1>), lists keep the markers
the preflight reset strips, links take the accent, code takes the mono face, and
a table wider than the measure scrolls in its own box rather than taking the
page sideways — gated at 360px, and driven red by removing its overflow-x,
which pushes 901px into the content region.
The face carries no measure. A reading width and a reading face are two
decisions, so compose it with .ds-measure
as above.
The package styles this content; it does not render it. Sanitising untrusted HTML is a security boundary, and a package cannot see the inputs the boundary is protecting — the app owns the markdown-to-HTML seam and hands the result in.
It is here because three consumers had each built one, by three different mechanisms — a hand-written token-based block, the Tailwind typography plugin, and a second hand-written block one of them had already duplicated inside itself. Typography degrades worse than most things when it is re-derived per app.
Depth: the ladder and one hairline, not an elevation scale
A raised surface reads as raised from the surface ladder plus a 1px inner
highlight, applied by .ds-edge — which DetailPanel, AppDialog, Panel,
StatCard, StatList, EmptyState, ErrorState and DataTableTanstack
already carry, so most apps never write it. There is one knob:
:root {
/* The drop shadow under .ds-edge. Neutral translucent black in both modes —
a shadow is an absence of light, not a palette colour. */
--ds-shadow-sm: 0 1px 2px oklch(0 0 0 / 0.12);
}There is deliberately no sm/md/lg elevation scale, and that is a
decision rather than a gap. A hairline border under a wide soft shadow is a
recognised generated-UI tell, and a named ladder of shadows invites exactly it —
so depth is declared once per surface, by the rung it sits on, and the edge does
the rest.
Measured across nine consumers before deciding: two declared their own
shadows, not the "every app" the case for a scale assumed. One of the two
already points --ds-shadow-sm at its own value, which is the supported path
working as intended. The other had rebuilt .ds-edge's exact formula under a
different local name, which is a discoverability problem this section exists to
fix, not an argument for more tokens.
Motion is the same answer for a stronger reason: no consumer declares a
duration or easing scale. What three of them do share, verbatim, is a
prefers-reduced-motion block — an accessibility guard rather than a scale, and
a better candidate for sharing than any easing curve.
Making a surface interactive: hover:bg-accent
A clickable card, row or tile takes the ordinary shadcn treatment, and it works:
<Card class="hover:bg-accent/50 cursor-pointer">…</Card>bg-accent is a 12% tint of --ds-color-primary, the same fill the active
nav row wears — so a hover reads as the same language as a selection, and it
follows an app's own accent with no per-app CSS. Reach for it rather than
writing a local hover:border-primary, which is what four apps had each
arrived at separately.
It has not always worked, and the way it failed is worth keeping. Until
2026.8.17, --color-accent and --color-card both resolved to
--ds-color-surface-2 — the same rung — so hover:bg-accent/50 on a card
mixed a colour at 50% over a ground identical to it and could not move a pixel.
Every app that made a card clickable shipped a control with no hover
affordance, and nothing caught it: it compiled, type-checked, passed the
component tests, and satisfied every contrast check on text. It was wrong only
when a human moved a mouse.
So the gate for it is a value comparison in a real browser rather than a
structural one — harness/drive.mjs drives a pointer onto a card in both
colour schemes, composites the resting and hovered fills, and asserts they
differ, that the difference is large enough to see, and that the hover fill
moves when the app retunes --ds-color-primary. That last check is the one
that matters: the first two passed against the broken build.
Depth is not the affordance. The household design language separates surfaces
with the ladder and a hairline edge, not with a drop shadow that grows on
hover — see packages/design-tokens/README.md.
Measuring the content region
About overflow, not width — for how wide the body is allowed to get, see The content measure above.
<main> is the scrolling content region and carries
data-slot="app-shell-content". A consuming app's "no horizontal overflow" check
must measure that element, not the document:
const region = page.locator('[data-slot="app-shell-content"]');
expect(await region.evaluate((el) => el.scrollWidth - el.clientWidth)).toBeLessThanOrEqual(0);overflow-y: auto makes overflow-x compute to auto too, so a wide child's
excess ends up in this box and never reaches
document.documentElement.scrollWidth — the number nearly every app's overflow
test reads, and one that cannot move. A page can scroll sideways on a phone with
that suite green throughout.
The shell constrains its own boxes, but a child wider than the region has to
carry its own scroller: @poodle64/ui/table does (data-slot="table-container",
and containerClass if you need to cap its height for a sticky header), and a
<pre> or an unbreakable string needs one from the page.
The table is the TanStack-shaped pair the household actually runs, not a
rows/columns config API. The page owns the Table instance (built with
createSvelteTable from @poodle64/ui/data-table) and passes it in; the
component owns how it looks and how a row is picked. For a small static table
that does not earn a table instance, @poodle64/ui/table also exports the
TH_CLASS / TD_CLASS / TH_HIDDEN_UNTIL_XL constants for the raw-<table>
idiom.
A column's meta.class reaches both its <th> and its <td>; meta.headClass
/ meta.cellClass reach one cell only, additive on top of class (the
cell-specific slot wins on a conflicting Tailwind utility). Reach for the split
whenever a class must differ between the two — a truncating column needs
max-w-0 on the <td> to ellipsis instead of pushing every other column out,
but the same class on the <th> collapses the heading over its neighbour:
{ accessorKey: 'filename', header: 'Document', meta: { class: 'w-full', cellClass: 'max-w-0' } }Hand-written forms
The shadcn-svelte Formsnap wrapper set, over sveltekit-superforms:
<script lang="ts">
import * as Form from '@poodle64/ui/form';
import { Input } from '@poodle64/ui/input';
</script>
<Form.Field {form} name="title">
<Form.Control>
{#snippet children({ props })}
<Form.Label>Title</Form.Label>
<Input {...props} bind:value={$formData.title} />
{/snippet}
</Form.Control>
<Form.Description>What the record is called.</Form.Description>
<Form.FieldErrors />
</Form.Field>
<Form.Button>Save</Form.Button>Field, Control, Label, Description, FieldErrors, Fieldset, Legend,
ElementField and Button, each also exported under a Form-prefixed alias
(FormField, FormLabel, …) for a flat import.
formsnap and sveltekit-superforms are optional peer dependencies. An app
that renders no form installs neither and the other 54 components are
unaffected; an app that does already has both, since these wrappers are useless
without them.
This is the sibling of Server-described forms below,
and the two answer different questions: reach for these when the app knows the
fields at build time, and for SchemaForm when the shape arrives at runtime.
Why it lives here. Three apps had vendored the same nine files. The diff
between two of them was quote style; between those and the third, which package
the shared cn and Label were imported from. Nothing had diverged — but every
one of them owned its own copy of the ARIA wiring, so a fix to how an error is
announced, or to the aria-describedby chain, landed once per app or not at
all.
That is what the tests assert, rather than the markup: the label resolves for
to the control's generated id, aria-describedby reaches both the description
and the error node, an errored field flips aria-invalid and marks the label
data-fs-error, and Form.Button is type="submit" without the call site
saying so. Each was driven red before being kept. A test on the class strings
would have passed against all three copies while any one of them quietly stopped
pointing at its own error node.
Consumers drop their local form/ directory at their next frontend change set
— there is no forced sweep, and their own check-ui-drift.mjs will name it.
Server-described forms
<SchemaForm> renders a config object whose shape arrives at runtime. It is the
estate's one mechanism for that, and this README is where its contract lives.
Two standard documents go in. A JSON Schema says what the value is; a JSON
Forms UI Schema says how it is laid out — a tree of layouts, Controls
addressed by JSON Pointer scope, and declarative rule: { effect, condition }
for conditional visibility. Both are published standards, which is the point:
the renderer is replaceable without a server changing anything.
<script lang="ts">
import SchemaForm from '@poodle64/ui/schema-form';
let { schema, uischema } = $props(); // fetched by the app, not by this package
let value = $state({});
</script>
<SchemaForm {schema} {uischema} {value} onChange={(next) => (value = next)} />| Prop | Purpose |
| ---------- | --------------------------------------------------------------------------------------------------------------------------------------- |
| schema | The JSON Schema. Scopes resolve against it, $ref included, and it drives validation. |
| uischema | The JSON Forms UI Schema. Omit it and one is generated from the schema, the only mode in which no field can be missing from the layout. |
| value | The current value. The component is controlled and never mutates what it is given. |
| onChange | (next, { path, value }). next is a fresh object with every untouched branch structurally intact. |
| disabled | Disables every control. |
| idPrefix | Prefix for generated element ids, when two forms share a page. |
It knows nothing about any app
No HTTP client, no fetch, no endpoint string, no service name, no
app-specific type. Consumers fetch the two documents and map the result
themselves. That invariant is load-bearing: it is what lets the engine behind
the schema be swapped later without touching a consumer, and what stopped three
apps' renderers from being three different components.
An unrecognised control renders LOUDLY
This is the non-negotiable rule. The three hand-rolled renderers this
replaces failed silently: a widget: "dropdown" hint rendered no input at all,
three whole top-level config groups never rendered because their names were
absent from a hardcoded GROUP_ORDER, and nested hints were inert. Every one
of those made a field VANISH, and a form with a field missing looks exactly like
a form. They shipped that way for months.
So nothing here renders as nothing. Eight failure shapes each produce a
visibly-flagged block carrying data-schema-form-unknown and a
data-unknown-reason, which names what could not be done, quotes the pointer so
the fix is a copy-paste, shows the value that would otherwise have been lost,
and keeps it editable wherever a text box cannot destroy structure:
| data-unknown-reason | Raised when |
| --------------------- | --------------------------------------------------------------------------------- |
| unknown-widget | options.format / options.widget names a widget this package does not ship. |
| unknown-element | A UI schema element type outside the vocabulary below. |
| unresolved-scope | A Control's scope resolves to nothing in the JSON Schema. |
| missing-scope | A Control carries no scope at all. |
| no-options | A select or radio over a subschema that declares no enum or oneOf. |
| object-control | A Control points at an object, which needs a layout rather than one control. |
| unsupported-array | An array whose items are not primitives, so tags cannot represent it. |
| not-in-layout | The JSON Schema describes a property no Control anywhere in the layout addresses. |
A primitive value stays editable; an object or an array is shown read-only,
because a text box over structured data is a data-loss affordance rather than a
fallback. An unrecognised hint is never quietly swapped for the widget it
probably meant — dropdown almost certainly meant select, and guessing would
restore the field while hiding the fact that the two documents disagree.
not-in-layout is reported once per unaddressed subtree, at the subtree, so a
forgotten group is one loud entry rather than forty. There is no prop to silence
any of this. The way to stop a field being flagged is to put a Control for it in
the UI schema.
src/test/schema-form-loud-unknown.test.ts pins all eight reasons, and
harness/drive.mjs proves in a real engine that each flag has real size and a
resolved, visible warning border. "Renders loudly" is a claim about paint, and
a fallback styled into invisibility would satisfy every jsdom assertion while
reproducing the original defect exactly.
The widget dispatch table
An explicit hint wins; otherwise the widget is derived from the subschema.
options.format is the JSON Forms spelling and options.widget is accepted
alongside it, so a server migrating off a home-grown hint vocabulary does not
have to change both documents at once. Any hint outside this table is
unknown-widget.
| Widget | Chosen when | Rendered by |
| ---------- | ----------------------------------------------- | --------------------------------- |
| text | string (the default) | input |
| textarea | options.multi: true, or the hint | textarea |
| password | format: "password", or the hint | input[type=password] |
| date | format: "date" | input[type=date] |
| time | format: "time" | input[type=time] |
| datetime | format: "date-time" | input[type=datetime-local] |
| number | number / integer | input[type=number] |
| slider | options.slider: true on a number, or the hint | composed here (native range) |
| select | enum, or oneOf: [{ const, title }] | select |
| radio | the hint, on the same closed value sets | composed here (native radio) |
| switch | boolean (the default) | switch |
| checkbox | the hint, on a boolean | checkbox |
| tags | array of string / number / integer | composed here (badge + input) |
Three of those — slider, radio and tags — had no primitive in this package
and are composed inside schema-form/widgets/. They are deliberately not
top-level exports: their only caller is the renderer, and a widget promoted to
the catalogue before a second consumer wants it is a shape nobody agreed to.
The layout vocabulary
VerticalLayout, HorizontalLayout, Group (a Panel), Categorization /
Category (a Tabs set), Control and Label. Anything else is
unknown-element.
rule is evaluated on every element, not only on Controls, so a rule on a Group
takes the whole group with it — which is what an author writing one means.
SHOW / HIDE decide whether the element renders at all; ENABLE / DISABLE
and options.readonly disable the control instead.
The engine
@jsonforms/core is used headless: JSON Pointer scope resolution, rule
evaluation and the Ajv instance, nothing else. The renderers are this package's
own and dispatch to this package's own widgets. There is no official Svelte
renderer for JSON Forms and the community one carries no external validation, so
neither is used.
JSON Forms was chosen over RJSF and @sjsf/form on one deciding fact: the
upstream config models carry 36 conditional-visibility rules, and JSON Forms'
rule model maps onto them 1:1, while RJSF has no standard conditional-
visibility directive. @jsonforms/[email protected] was verified working headless
under Svelte 5 in this package before it was adopted, on 21/08/2026 — scope
resolution through $ref, SHOW/HIDE/DISABLE evaluation, and
Generate.uiSchema all exercised live. It is the package's only runtime
dependency beyond what was already here.
Consuming the package
Published to public npm under the @poodle64 scope, same as @poodle64/design-tokens
— no registry config, no .npmrc, and no auth token needed to install.
pnpm add @poodle64/ui @poodle64/design-tokens<script lang="ts">
import { Button } from '@poodle64/ui/button';
import * as Dialog from '@poodle64/ui/dialog';
import PageHeader from '@poodle64/ui/page-header';
import StatusBadge from '@poodle64/ui/status-badge';
import type { Status } from '@poodle64/ui/status';
</script>Composed components export both a default and a named binding, so either import
style works. stat-list also exports its StatItem type, and
data-table-toolbar its ChipGroup / ChipSpec types.
Each component is its own subpath export (@poodle64/ui/<name>), matching
shadcn-svelte's own convention; a flat barrel would collide on the generic names
(Root, Content, Trigger) that most primitives share.
CardTitle takes an optional level. It renders a <div> by default, as
upstream shadcn does, because a card title is not always a heading. Where it IS
the heading for that card's content — the common case on a dashboard — pass the
level and it becomes a real <h1>–<h6>:
<Card.Title level={2}>Estate summary</Card.Title>Nothing else changes: the class list is identical either way, so level moves
the document outline and not a pixel. Worth knowing before a migration, because
the default is silent — an app replacing its own <h3> card titles with this
component loses every heading below its page <h1> with no error, no lint hit
and no visual difference.
Two lines in the app's app.css, after the token imports:
@import '@poodle64/design-tokens/tokens.tw.css';
@import '@poodle64/design-tokens/tokens.css';
@import '@poodle64/ui/styles.css'; /* required: theme registration + component styles */
@source '../node_modules/@poodle64/ui/dist'; /* Tailwind content scan */@poodle64/ui/styles.css is required, not optional. It carries two things.
First, the shadcn semantic surface: --card, --popover, --muted, --accent,
--secondary, --input and their -foreground pairs, each mapped to a --ds-*
token and registered with Tailwind so bg-card, bg-muted, border-input and
friends actually generate CSS. Declaring those names as plain custom properties
is not enough: the variable exists, Tailwind still does not know it is a colour,
and the utility compiles to no rule at all. That is a silent failure with no
build error, no lint hit and no failing test, which is exactly how it went
unnoticed through a full app migration (#3).
Second, what the variable layer does not cover: the text-display / text-body /
text-stat / tracking-eyebrow scale keys, the .ds-edge card treatment, the
.ds-dialog-section divider rule, the .ds-chip / `
