Riftix Console Bridge

Components

The console component kit, page recipes, loading, empty and error states.

About the primitive layer

The reference apps use shadcn/ui — the console on Radix primitives, filavirtual/apps/admin on Base UI. Both generate from the same token set, which is why they look identical despite different primitive libraries.

None of that is required. What is required is the behaviour described on this page and the token conformance described in Foundations. A Svelte app with hand-written components is conformant; a React app that installs stock shadcn without the tokens is not.

If you do use shadcn, two notes from the reference implementations: generate against the vendored token file rather than a fresh theme, and expect to compress the default sizes — stock shadcn's 36px control height is one step taller than this system's 32px.

The console kit

These are the components every console app needs. They encode decisions, not just markup, which is why they are worth specifying rather than leaving to each app. The reference implementations live in apps/console/src/components/ and are copied verbatim into apps — treat them as shared code to keep in sync, not as a starting point to evolve locally.

Pending button

Rule. A button that enters a pending state must not change width.

Swapping "Save" for "Saving…" relayouts the button and often the row around it, right at the moment the user is watching to see whether their click registered. Stack both labels in the same grid cell so the button is sized by the wider of the two, and crossfade between them:

<button class="…">
  <span class="grid place-items-center">
    <span class="col-start-1 row-start-1 inline-flex items-center gap-1.5 transition-opacity duration-150">
      Save
    </span>
    <span class="col-start-1 row-start-1 inline-flex items-center gap-1.5 opacity-0 transition-opacity duration-150">
      <svg class="size-3.5 animate-spin">…</svg> Saving…
    </span>
  </span>
</button>

Toggle opacity-0 between the two spans, and mirror it with aria-hidden so assistive technology announces one label, not both. The button is disabled while pending.

Settings section

Rule. One card per request. Save is disabled until something changed.

A settings page with a single Save button at the bottom makes the user guess which fields a failure applies to, and forces one request to carry unrelated changes. One card per concern maps each save to one API call and one error.

┌──────────────────────────────────────────┐
│ Identity                                 │  title
│ How this project is named and addressed. │  description
├──────────────────────────────────────────┤
│ [ Name           ]                       │
│ [ Slug           ]                       │  content
├──────────────────────────────────────────┤
│ Slug must be lowercase.        [ Save ]  │  hint · save
└──────────────────────────────────────────┘

The section is deliberately not a <form> element: section content may embed its own action forms (an invite row, a delete button), and forms cannot nest.

Track edits as an overlay on live data rather than copying the entity into local state: keep a map of changed keys, derive dirty from whether that map is empty, and send only the changed keys. Background refetches then stay visible in untouched fields instead of being masked by a stale snapshot, and a partial save does not have to reconcile a whole object.

Confirm dialog

Rule. A confirmation stays open if the action fails.

If the dialog closes optimistically and the request then throws, the user gets an error toast about something that is no longer on screen, with no way to retry except finding the row again. Keep the dialog mounted until the action resolves successfully.

Destructive confirmations name the object in the title (Remove backup.acme.com?) and use the destructive variant for the confirm button only — the cancel button stays neutral and is the default focus.

Embedded apps should route confirmations through the host modal rather than rendering their own, so the dialog is centered on the console viewport rather than inside the iframe.

Status badge

One mapping for the whole domain vocabulary. See Status colors.

Humanize labels at the badge, not at the call site: no_show renders as "No-show", everything else replaces underscores with spaces. One place to fix a label is worth the indirection.

Error and loading states

// A failed read: the message, and nothing pretending to be data.
ErrorAlert   → destructive alert, title "Could not load", body = the message
RowsSkeleton → n × full-width skeleton bars at control height, gap-2

Empty state

Always the same composition, so an empty page is recognisable as empty rather than broken:

     ▣           icon in a muted square
  No projects yet
  A project groups the experiences your guests
  join. Create one to get started.
     [ + New project ]
  • Title names what is missing.
  • Description is one sentence on what the object is and why you would make one — this is the only place many users learn the domain vocabulary.
  • Action is the primary creation affordance. Omit it when there is nothing the user can do from here (a filtered log with no matches).
  • Vary the copy by filter scope: "No projects yet" and "No active projects" are different situations and one of them has an obvious next step.

Empty states use a dashed border to read as a placeholder rather than a card.

Loading

Rule. Loading is a layout-matched skeleton, never a centered spinner.

From the console's own skeleton module:

Every route renders a skeleton that mirrors its real layout (same containers, same table columns), so a loading page reads as the page it's about to become — never a blank screen or a centered spinner.

A skeleton that mirrors the page prevents the layout shift when data lands, and tells the user what is coming. A centered spinner tells them only that something is happening, and it looks identical on every route.

Practically:

  • Lists and sections → skeleton rows at the real row height.
  • Tables → a skeleton table with the same columns; pass column widths so the bars line up with the real header.
  • Dense dashboards → mirror the real grid, including the tile count.
  • Buttons and inline actions → the pending-button pattern, not a skeleton.
  • First paint of an embedded app → nothing. The host already covers the iframe with an overlay and spinner until the handshake completes; your own full-screen loader would be a second wait stacked on the first.

Spinners are for in-place pending states inside a control, and they run at 0.6s, not the default 1s.

Page recipes

List page

PageHeader  ─ title, description, primary action
body        ─ exactly one of: skeleton │ error │ empty │ data
dialogs     ─ rendered unconditionally, controlled by open state

Resolve the four states in one place, in this order, and assign to a single variable that the page renders once:

let body;
if (query.isPending)        body = skeleton;
else if (query.isError)     body = errorAlert;
else if (data.length === 0) body = empty;
else                        body = table;

The ordering matters: an error must win over an empty state, or a failed request renders as "no results" and the user never learns it failed.

Render create dialogs at the bottom of the page, always mounted, driven by an open flag — not conditionally inside the action button, where the exit animation cannot play.

Detail page

PageHeader  ─ eyebrow (parent object) · title with inline status badge · actions
alert       ─ only when the object is in a degraded state, with a link to the fix
stat strip  ─ <dl> hairline grid, 2 columns → 4 at md
sections    ─ PageSection per related collection

Use a description list for stats — it is the correct semantic and it gives screen readers the label/value pairing for free.

Settings page

Stack settings sections. If there are more than about four, split them across tabs — and give every tab a real URL so a specific setting can be linked to. Derive the active tab from the pathname rather than from tab component state, so the URL is the single source of truth.

Dialogs and sheets

UseFor
Dialog (centered)Create flows, short confirmations, previews
Sheet (side panel, max-w-md)Editors and read-only drill-ins with more than a few fields

Rule. Mount a sheet or dialog body only while open, and key it by the record being edited. Otherwise the previous record's values leak into the next opening, and a form that was dirty when dismissed reopens dirty.

Sheets carry their own scroll container (min-h-0 flex-1 overflow-y-auto) for the same reason pages do.

Tables

Plain semantic <table> markup. A headless table library buys column virtualization, sorting and grouping — none of which the reference console pages need, and all of which cost markup you then have to make accessible again.

CellConvention
PrimaryA link, hover:underline, often two lines: name in font-medium above a muted secondary identifier
Numeric / datetext-muted-foreground tabular-nums
StatusStatus badge
Actions<th class="w-0"> with a visually-hidden "Actions" label; right-aligned ghost buttons at xs

Rows get hover:bg-muted/50. Keep the row highlighted while its action menu is open, so the menu stays visually attached to its row.

If a whole row opens something, put the click handler on the row and a real focusable element in the first cell — a cursor-pointer row is not reachable by keyboard on its own.

Wrap tables in the standard surface:

<div class="bg-card ring-foreground/10 ring-1">
  <table>…</table>
</div>

Forms

Rule. Validation is server-authoritative. Field errors render inline; everything else becomes a toast.

Client-side validation duplicates rules that the server must enforce anyway, and the two drift. Use client-side checks only to disable a submit button on obviously incomplete input — never as the source of the error message.

Map each server error code to the field it belongs to, in one table per app:

const FIELD_BY_CODE = {
  invalid_slug: "slug",
  slug_taken: "slug",
  invalid_timezone: "timezone",
};

Errors with a field render under that field. Errors without one render at the bottom of the field group. Transport and unexpected failures become a toast — they are not the user's input problem.

Markup for an invalid field:

<div data-invalid="true">
  <label for="project-name">Name</label>
  <input id="project-name" aria-invalid="true" aria-describedby="project-name-error" />
  <p id="project-name-error">Name is required.</p>
</div>

aria-invalid is what the styling keys off, so the visual and assistive signals can never disagree.

Accessibility

  • Focus rings are a 1px ring in --ring at 50%, on :focus-visible only. Never remove an outline without replacing it. Set a global fallback outline so an element that slips past a component still shows focus.
  • Accessible names on every icon-only control. Include the object: Actions for Main queue, not Actions.
  • Decorative icons beside a visible label are hidden from assistive tech.
  • Live regions for anything that updates without user action — a polling board's freshness indicator, a countdown.
  • Landmarks: real <header>, <main>, <section>, <nav> elements, and <dl> for stat pairs.
  • Keyboard shortcuts need to be discoverable. Cmd/Ctrl + B toggling the sidebar is invisible unless the trigger's tooltip says so.
  • Do not lock zoom. width=device-width, initial-scale=1 and nothing more.

On this page