npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 edit

Primitives (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-expanded on something that navigates away announces a state the user never observes; and making the label expand-only would silently change what href means 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. children holds NavChildItem, which is NavItem minus children, 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. page caps 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.
  • prose moves when the font does. A webfont arriving after first paint reflows the column, because ch is measured in the face that actually rendered. That is the tier doing its job rather than a bug; page and wide are lengths and never move.
  • The cap is the border box, so padded spends its padding inside it. measure="page" with the default padding is 80rem of box holding 78rem of content, which is the same arithmetic as the mx-auto max-w-4xl px-6 idiom it replaces. An app running padded={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 ::before inside the scroller, which is subtly wrong three ways: at z-index: auto a 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: -1 trades 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/body stay 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: local makes 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 at dir=rtl, at a 24px root size, and under forced-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 / `