Skip to content

Admin UI kit

The shared component vocabulary for every /admin/* surface. Before this kit, each admin page redeclared its own .btn, .card, table, .toast, input, and .empty styles — .btn alone was duplicated (and quietly drifting) across 17 pages, and the primary create action was labelled three different ways. The kit consolidates those into one set of components so an admin learns the interface once. It is the admin (dense, utilitarian) register of the design brief; the public register is separate.

Everything styles exclusively through the --ns-* token contract (see theming) — no literal colours/fonts/radii, enforced by npm run check:tokens.

Where it lives

  • Components: src/lib/admin/ui/*.svelte, re-exported from src/lib/admin/ui/index.ts. Import from $lib/admin/ui.
  • Base stylesheet: src/lib/admin/ui/admin-ui.css, imported once by src/routes/admin/+layout.svelte. It styles the markup pages author directly and that can't be componentised: native form controls, DataTable cells, and the .hint / .empty / .sr-only helpers. Its selectors are scoped under .admin (the layout root) at specificity (0,1,0) via :where(), so it supplies defaults where a page has no styling of its own and never fights a page's scoped rules — un-migrated pages don't regress.

The /admin sidebar (src/routes/admin/+layout.svelte) renders the section registry (src/lib/admin/sections.ts) grouped, not as a flat list (#314). Each AdminSection carries a group (AdminGroupKey), and ADMIN_GROUPS fixes the render order and headings:

GroupHeadingSections
catalogCatalogSystems, Attributes, Factors, Tiers
guided-selectionGuided selectionTracks, Right-sizing rules
content-mediaContent & mediaMedia, Corpus
integrationsIntegrationsDrupal sync, Eloqua
systemSystemThemes, Audit

ADMIN_SECTIONS is declared in this grouped order, so sectionsFor(caps) is already render-ordered and groupedSectionsFor(caps) buckets the visible sections into their groups — dropping any group emptied by capability filtering so no orphan heading renders. Labels are kept terse because the group heading supplies the context (e.g. Factors, not Selection factors; Media, not Media library).

Conventions:

  • Group headings render as small-caps / muted <h2>s using --ns-color-text-faint (tokens only — no literal colours).
  • The active nav link carries aria-current="page" (plus the .active class), so the current section is conveyed to assistive tech, not by colour alone.

Components

ComponentUse forKey props
Buttonevery action controlvariant = primary | default | danger | ghost; size = sm | md; href (renders <a>) or type; disabled
Carda surface panel / form sectiontitle, muted (retired/secondary surface), actions snippet
PageHeaderthe page title rowtitle, sub (count/summary line), actions snippet
DataTablea list tablerows, row snippet (<td>s, receives the item); header via either head snippet (<th>s) or opt-in columns (SortColumn[], adds per-column sorting); caption (screen-reader name), empty text
Fielda form fieldlabel, hint, error (inline validation), wide (span a 2-col grid); the control is the child
Flashsave/action feedbackkind = success | error | info
EmptyStatean inviting empty listmessage, action snippet (the create CTA)
ConfirmButtona destructive actionaction, fields (hidden inputs), label, message, title, confirmLabel, variant
Badgea status pilltone = neutral | accent | success | warning | danger
CategoryGroupsa category-grouped listgroups ([category, items][] from groupByCategory), section snippet (one group's body), defaultOpen, forceOpen
GroupedSelecta <select> whose options group under <optgroup> category headings (#1607) — every attribute picker (media tagging, rule conditions); options with no category data render flat, so non-attribute axes reuse it safelyoptions ({ value, label, category? }[], pre-filtered by the caller), order (managed category names in display order), placeholder (empty-value option; disabled when required), value (bindable), plus any <select> attribute (name, required, disabled, aria-label)
CategorySelectpick an attribute's category from the managed set (#340)categories (managed names, in set order), value (bindable selected name; '' = Uncategorized), name (form field, default category), action (create action, default ?/createCategory)
Tabssplit a long detail page into URL-addressable sections (#468)tabs (TabDef[] = { id, label }[]; first is default), panel snippet (receives the tab id), idBase (unique per page, for aria ids), label (tablist accessible name), param (URL query key, default tab)
ReorderableListadmin-arrange a set into a persisted order (#471) — categories (#471) and factors (#470) share ititems ({ id }[] in persisted order), row snippet (one item's read-only display), action (persist action, e.g. ?/reorder), name (hidden field of comma-joined ids, default order), ariaLabel, saveLabel, label (per-item accessible name for the ↑/↓ buttons); mode = form (default, owns its <form> + Save) | controlled (form-less — the order rides on the caller's formId, for rows that carry their own forms); align = center (default) | start (rows that can grow tall, e.g. a Disclosure); trailing snippet
SaveBarthe save-actions row of an Overview edit form (#725) — pairs with createDirtyTrackerdirty (from a tracker; gates the submit + the live "● Unsaved changes" cue), label (submit text, default "Save changes"), extra snippet (trailing controls)
EditPanelthe inline reveal container behind every "New …" / "Edit …" in-place editor (#1522)open (host-owned), label (accessible name for the revealed region), variant = editor (default — dashed muted chrome, scroll-into-view and focus-into-panel) | disclosure (no chrome, no focus steal; the body half of Disclosure)
Disclosurecollapse a long list of rich rows behind a toggle (#1867) — the factor Options tabopen + onToggle (host-owned, so several rows can be open at once), id (aria id base), label (name for the disclosed region), summary snippet (header content inside the trigger), actions snippet (header controls outside it), children = the body
DisclosureTriggerthe toggle button ALONE (#1866) — for hosts whose panel can't share a wrapper with its trigger, e.g. a table row whose panel spans a following <tr colspan>. Disclosure uses it internally, so both shapes share one aria implementationopen, panelId (the aria-controls target — must exist in both states), onToggle, children = the trigger's visible content
BlockedReferenceDialogthe shared "action blocked — still referenced" modal (#725, F-27) — the hard-block case (remove references first; nothing changed). Attributes (#131) + categories share it; a force-able destructive confirm (media) stays a confirm control, not thisblocked (the raw form-action result, or null; its identity gates + re-triggers the modal), heading, intro, groups (BlockedRefGroup[] — each { label, items: { href?, label, via?, mono? }[] }), dismissLabel

DataTable

Owns the <table>/<thead>/<tbody> chrome and the empty fallback; the cells are authored by the caller so any column shape works. Cell styling lives in admin-ui.css (.admin table.data …) because those cells render in the caller's scope. Cell helper classes: name (bold), mono (id/code column), num (right-aligned tabular figures), row-actions (right-aligned trailing action cluster; auto-spaces its children and renders inline <form>s inline).

svelte
<DataTable rows={data.active} caption="Active systems" empty="No systems yet.">
	{#snippet head()}
		<th class="name">Name</th><th>Key</th><th class="row-actions">Actions</th>
	{/snippet}
	{#snippet row(sys)}
		<td class="name">{sys.name}</td>
		<td class="mono">{sys.id}</td>
		<td class="row-actions">
			<Button size="sm" href={`/admin/systems/${sys.id}`}>Edit</Button>
			<ConfirmButton
				size="sm"
				action="?/retire"
				fields={{ id: sys.id }}
				label="Retire"
				message={`“${sys.name}” will be hidden from the live selector…`}
			/>
		</td>
	{/snippet}
</DataTable>

Sortable columns (opt-in, #316)

Pass columns (a SortColumn[], exported from $lib/admin/ui) instead of a head snippet and DataTable renders the header itself, adding per-column sorting. A column with a get accessor is sortable by default: its header becomes a keyboard-operable <button> that toggles ascending → descending, and the <th> exposes aria-sort (ascending / descending on the active column, none on the other sortable ones). Set sortable: false for a presentational column (e.g. Actions). Sorting is client-side (fine at admin-list sizes) and stable, so the server default order shows through until a header is activated; the pure sort logic lives in table-sort.ts and is unit-tested.

svelte
const columns: SortColumn<Sys>[] = [
	{ label: 'Name', get: (s) => s.name, class: 'name' },
	{ label: 'Tier', get: (s) => s.budgetTier },
	{ label: 'Actions', sortable: false, class: 'row-actions' }
];
<DataTable rows={data.active} {columns} caption="Active systems">
	{#snippet row(sys)}…{/snippet}   <!-- column order must match the cells -->
</DataTable>

Column order must match the row cells. The active attributes list is category-grouped (CategoryGroups, the unified attribute-by-category display — #317) rather than a flat table, so it is intentionally not column-sortable.

CategoryGroups

The one shared presentation for an attribute-by-category list, so the attributes list (/admin/attributes) and the system-edit screen (/admin/systems/[id]) group and order categories identically instead of drifting into two accordions. It owns the collapsible <details> panel chrome — heading, count pill, and expand/collapse — while the caller authors each group's body via the section snippet, so a group can hold a DataTable/<table> (attributes) or inline ValueEditors (system-edit) with the same wrapper.

Feed it groupByCategory(items, UNCATEGORIZED, order) (exported from $lib/admin/ui): a pure bucketer whose heading order follows the managed category set (#340, AC6 — the single ordering source). Pass order — the managed category names in sortOrder sequence (from listCategories) — and headings sort by their rank in it; a name absent from the set (or an omitted order) falls back to alphabetical (case-insensitive); the null/blank bucket (shared UNCATEGORIZED = "Uncategorized") is always pinned last. Items within a category keep their incoming order, and both screens load via listAttributes (name-sorted), so within a category reads A→Z too — one deterministic order in both places. Empty categories never emit a heading.

Because backfilled categories start at sort_order = 0, the effective order is alphabetical until an admin reorders the set — so this is a source change (the set now drives ordering), not a visible reshuffle.

Open behaviour:

  • defaultOpen — the resting state of each panel (system-edit passes defaultOpen so every group starts open; the attributes list omits it, so groups start collapsed).
  • forceOpen — while true, every panel shows open and toggle events are not recorded, so the attributes quick-find can open matching drawers additively and clearing the query restores exactly the manual open/closed set.
svelte
// data.categoryOrder is listCategories(db).map(c => c.name) from the loader
const groups = $derived(groupByCategory(data.active, UNCATEGORIZED, data.categoryOrder));
<CategoryGroups {groups} forceOpen={q !== ''}>
	{#snippet section(attrs)}
		<table class="data">…{/snippet}   <!-- one group's body -->
</CategoryGroups>

CategorySelect

The category picker for the attribute create/edit forms (#340, AC2). An attribute assigns its category by selecting from the managed set — no free-typing an arbitrary string — with an inline "+ New category" affordance that adds a category to the set and selects it without leaving the form. It replaced the old free-text <input list=datalist>.

The inline create calls the page's ?/createCategory action programmatically (fetch + deserialize) rather than submitting the surrounding attribute form, so the rest of the half-filled form is preserved and there is no navigation; the action returns { created: { id, name } }. The submitted category name resolves to attributes.category_id in the write layer, so a picked or inline-created category lands on the FK — the single read source.

svelte
<Field label="Category">
	<CategorySelect categories={data.categories} bind:value={selectedCategory} />
</Field>

List default order

Every admin list has an explicit, documented default order (no reliance on DB insertion order):

ListServer helperDefault order
SystemslistSystemsname (alphabetical; displayOrder is 0 everywhere)
AttributeslistAttributesname (alphabetical) — and alphabetical within each category
CategorieslistCategoriessortOrder, then name — alphabetical until an admin arranges the set (category ordering, #471)
Factors (admin)listFactorsAdmindisplayOrder, then name — admin-arrangeable (factor ordering, #470)
Factors (guided flow)listFactorsdisplayOrder, then name — the default /select order; new tracks seed from it
Tierstier ladderordinal (cheapest → dearest)
Auditaudit queryrecency (newest first)

Attributes are flat catalogs with no meaningful curated sequence, so they default to alphabetical by name; their category headings are ordered by the managed category set (groupByCategory's order arg from listCategories, sort_order then name — currently alphabetical since every backfilled category is sort_order = 0), Uncategorized last. Factors keep displayOrder — they have a deliberate guided-flow sequence. Sortable column headers let an operator re-sort a table on top without changing the default.

ConfirmButton

The whole control is a POST form: the trigger opens a native <dialog> (focus-trapped, Escape-dismissable, focus returned on close — all from showModal()), and the dialog's confirm button submits the form. Use it for anything that removes data or changes a live surface. Before the kit only 2 of ~20 pages confirmed at all.

Tabs

The one shared tablist for /admin/*/[id] detail pages that have outgrown a single scroll (#468) — before this, the system detail page buried its media below the full attribute list. Each major section becomes a tab, so it's one click, not a long scroll. Feed it an ordered tabs list (TabDef[], exported from $lib/admin/ui) and a panel snippet that renders one section per tab id; every panel is rendered and the inactive ones are hidden (so an in-progress form survives a tab switch). Pure tab logic lives in tabs.ts and is unit-tested (tabs.spec.ts) — the table-sort.ts / category-groups.ts convention.

svelte
const tabs: TabDef[] = [
	{ id: 'overview', label: 'Overview' },
	{ id: 'attributes', label: 'Attributes' },
	{ id: 'media', label: 'Media' }
];
<Tabs {tabs} idBase="sysdetail" label="System sections">
	{#snippet panel(id)}
		{#if id === 'overview'}<Card title="Core fields">…</Card>
		{:else if id === 'attributes'}…
		{:else if id === 'media'}<MediaManager … />{/if}
	{/snippet}
</Tabs>

URL-addressable (deep-linkable, SSR-resolved). The active tab lives in a ?tab=<id> query param, so a specific tab is shareable and back-button friendly. The active tab is resolved from the URL, so the server renders the right panel — a deep-linked ?tab=media shows Media with no JS, and an unknown/missing value falls back to the first tab. Selecting a tab is a shallow route (replaceState) — no data reload, no history spam.

Keyboard/ARIA. A proper WAI-ARIA tablist: roving tabindex, arrow / Home / End navigation with automatic activation (panels are preloaded, so moving focus selects). aria-selected, aria-controls, and aria-labelledby wire tabs to panels.

EditPanel vs Disclosure

Two halves of one idea, deliberately kept separate (#1867). EditPanel (#1522) is the reveal container: it owns the muted panel and the reveal a11y (scroll-into-view, focus-into-panel) that hand-rolled editors kept forgetting. It is not a toggle — it has no trigger and no aria-expanded, because its hosts already have a "New …" / "Edit" button elsewhere on the row.

Disclosure is that missing toggle: a real <button> with aria-expanded / aria-controls, wrapping an EditPanel body in its disclosure variant (no editor chrome, and focus stays on the trigger — the canonical ARIA disclosure pattern, so a second Enter collapses what the first opened).

Pick by intent:

You haveUse
A separate button that reveals a create/edit form in placeEditPanel (default editor variant)
A long list of rich rows the operator needs to collapse and compareDisclosure

Never write page-local collapse CSS or a second reveal component beside these, and never nest interactive controls inside the trigger — row actions (Edit, a ConfirmButton) go in the actions snippet, which renders as the trigger's sibling.

In a table, reach for DisclosureTrigger instead. Disclosure renders its own head/body wrappers, which cannot straddle two <tr>s — and a table's panel row usually must (<tr><td colspan=3>). The attribute Values tab (#1866) puts DisclosureTrigger in the name <td> and owns the panel <tr> itself, passing the same panelId. Keep the panel row in the DOM in both states so aria-controls resolves, and render its content only when open — that is what makes a collapsed panel genuinely unreachable rather than merely hidden.

svelte
<Disclosure
	id={`option-${opt.id}`}
	label={`Option ${opt.label} details`}
	open={isExpanded(opt.id)}
	onToggle={() => toggleOption(opt.id)}
>
	{#snippet summary()}<span class="option-label">{opt.label}</span>{/snippet}
	{#snippet actions()}<Button onclick={…}>Edit</Button>{/snippet}
	<!-- the heavy body: education, media, mappings, editors -->
</Disclosure>

Open state is host-owned, so multi-open is the default — expanding one row never collapses another. Don't hand-roll that toggle: the kit exports the pure helpers beside the component (the tabs.ts / reorder.ts convention), because a three-line toggle written from scratch is exactly how a single-open accordion appears by accident.

HelperUse
toggleExpanded(open, id)Flip one row. Returns a new array, so a $state host re-renders.
isExpanded(open, id)Membership test for the open prop.
ensureExpanded(open, id)Open a row without closing anything — for "acting on a row implies revealing it".
svelte
let expandedOptions = $state<string[]>([]);
const optionOpen = (id: string) => isExpanded(expandedOptions, id);
const toggleOption = (id: string) => { expandedOptions = toggleExpanded(expandedOptions, id); };
// Edit opens an editor that lives INSIDE the body, so a collapsed row must expand
// too — otherwise the button appears to do nothing.
const editOption = (id: string) => {
	editingOption = editingOption === id ? null : id;
	if (editingOption === id) expandedOptions = ensureExpanded(expandedOptions, id);
};

Conventions

These make the interface predictable across sections. Follow them when adding or migrating a page.

Action labels

  • "New <Entity>" — opens a create form (the primary PageHeader action).
  • "Create <Entity>" — submits that create form.
  • "Add <thing>" — adds a child row within a form (a factor option, a rule condition).
  • "Save changes" — commits an edit on a detail page.
  • "Retire" / "Restore" — reversible soft-delete of a catalogue entity; data is preserved. Route "Retire" through ConfirmButton.
  • "Remove" — detaches a child from its parent (an attribute value, a factor from a track).
  • "Delete" — reserved for a genuine hard delete; always ConfirmButton.

Feedback — one Flash per result, kind="success" for a completed write, kind="error" for a failure. (Replaces the old toast ok / toast bad / toast err trio.)

Empty states — a plain DataTable empty="…" when the list is simply empty; EmptyState with an action when emptiness should invite creation.

StatusBadge for retired/draft/published/role markers; the label always names the state so meaning never rides on colour alone.

Detail-page layout — a /admin/*/[id] page opens with DetailHeader (breadcrumb + title + status Badges + action cluster). When the page has more than ~2 stacked sections and reads as a long scroll, split the sections into a Tabs tablist (first tab = the core "Overview"/"Details" form; the entity's gallery is its own Media tab). Keep page-level chrome above the tablist — the DetailHeader, Flash result banners, and any status/caution banner should show on whichever tab is active. Per-child media managers stay inside their owning tab; only the entity-level MediaManager is the Media tab. Short detail pages (e.g. tracks/[id], two brief sections) stay a single scroll — tabs would add chrome without benefit. Tabbed today: systems/[id], attributes/[id], right-sizing/[id], factors/[id].

Create-form layout (one pattern, #724) — a rich entity is created on its own breadcrumbed /admin/*/new route, not inline. The screen opens with DetailHeader (Admin › <Section> › New <entity>, no status badges yet), and the form carries a primary "Create <Entity>" submit plus a Cancel control that returns to the section list. The form body is either a Card wrapping the fields directly (attributes/new, factors/new, right-sizing/new) or a shared form component reused by the edit page (tracks/newTrackForm, experiments/newExperimentForm); either way the header, the "Create"/"Cancel" action pair, and the breadcrumb are identical. Any one-line "born as a draft" guidance sits in a muted .lede line under the header (or a Card p.hint), not in a PageHeader sub. Prefer this route over an inline create so the create and edit surfaces read as one flow and every form has a back-out affordance.

The one intentional exception is systems: a system is created from a two-field (Name + Key) quick-create that expands inline on the list page, because a system is born minimal and all of its substance is authored on its rich, tabbed edit page. That trivial quick-create is a deliberate lightweight case — it is not the rich-form pattern, so it does not move to /new. Any create form beyond a couple of fields uses the breadcrumbed /new route above.

Migration status

All /admin surfaces are migrated. systems landed first as the reference implementation; the remaining 20 pages + the TrackForm / ValueEditor / MediaManager components followed in one sweep (net ~2,100 lines of bespoke CSS removed). Every page now composes the kit; the only scoped CSS that remains is genuinely page-specific layout (see gaps below). Contract preservation was verified per file (form actions, input name= attrs, hidden inputs) plus svelte-check and SSR 200 across all routes.

Known kit gaps (follow-up candidates)

The sweep surfaced UI the kit doesn't yet model; these pages keep bespoke markup

  • CSS for now, and are the shortlist for extending the kit:
  • DataTable per-row hooks — no way to put a class/attribute on a <tr> (for dimming disabled/retired rows) or to emit a second colspanned sub-row (the attributes enum table's expandable education row). Those tables stay bespoke but reuse the global table.data cell styling.
  • Filter/search bar — the audit and attributes quick-find GET filter rows are hand-laid-out (the inputs themselves are styled by admin-ui.css).
  • List-the-blockers dialogConfirmButton is a yes/no guard; the attributes blocked-reference dialog (#131) lists what references an item and stays a bespoke <dialog>.
  • Interactive card / link-tileCard is a non-interactive <section>; the /admin dashboard's clickable section grid is bespoke.
  • Badge spacing — no built-in inline gap, so pages that place a badge next to a link add a local margin rule.