Skip to content

Theming and panel chrome

Customize panel color, density, logos, and application-owned CSS.

The token system, accent ramp, dark mode, Tailwind preset, and panel chrome follow protocol v1 §1 (the adonia envelope). Everything lives in packages/ui: tokens in src/theme/tokens.ts and styles.css, the ramp in src/theme/accent_ramp.ts, chrome components in src/components/chrome/, and the Tailwind preset in src/tailwind/preset.ts (published as @adonia/ui/tailwind).

Tokens

Every visual decision reads a --adonia-* custom property; no component hard-codes a colour, radius, or spacing step. TOKENS in src/theme/tokens.ts is the machine-readable manifest — name, family, description, light default, optional dark override — and styles.css ships the same defaults. tests/theme.test.ts fails if the two drift, so edit them together.

Family Tokens
Colour surface, surface-muted, border, text, text-muted, danger, warning, success
Accent accent (alias of accent-600), accent-50accent-950
Radius radius, radius-sm, radius-lg (the last two derive from radius)
Font font-family, font-family-mono, font-size-xsfont-size-2xl
Density density, space-1space-6 (each calc(var(--adonia-density) * n))

Because the spacing scale is a multiple of one unit, a panel switches from comfortable to compact by changing --adonia-density alone.

Accent ramp

A panel declares one accent — brand.accent in the envelope, or theme.accent client-side. buildAccentRamp(accent) expands it into the eleven steps at runtime: pure, dependency-free, identical on server and client, so renderToString emits the finished ramp and hydration never flashes.

import { buildAccentRamp } from '@adonia/ui'

buildAccentRamp('violet')['600'] // 'oklch(54.1% 0.247 293)'

Accepted notations: a named accent ('violet', the Tailwind families), hex, rgb(), oklch(). Anything unparseable degrades to the default accent — a typo must not blank an admin.

The ramp keeps the accent’s hue and chroma but imposes a fixed, monotonically decreasing lightness scale. That is deliberate: contrast relationships between steps then hold no matter how light or dark the brand colour is, so accent-50 is always a tint you can put text on and accent-600 is always a solid surface that accent-50 reads against.

Dark mode

theme.darkMode picks a strategy:

Value Behaviour
'system' (default) The OS preference, resolved by a prefers-color-scheme block in styles.css. No JavaScript.
'class' The user chooses. ThemeProvider scopes the effective choice to the panel and its owned portal root; useColorScheme() persists it to localStorage under adonia.color-scheme. A leased .dark compatibility signal on <html> never removes a host-owned class.
'off' Light only.

Both dark blocks are scoped to the provider’s [data-adonia-color-scheme="…"] attribute. The manual block also keys on data-adonia-effective-scheme, so a host app’s own .dark class cannot repaint a light panel. Each AdoniaProvider owns a [data-adonia-portal] container with the same theme variables, scheme, and direction as its panel; host Base UI portals are not selected or restyled.

import { useColorScheme } from '@adonia/ui'

function ThemeToggle() {
  const { scheme, toggle } = useColorScheme()
  return <button onClick={toggle}>{scheme === 'dark' ? 'Light' : 'Dark'}</button>
}

useColorScheme is SSR-safe: it reads through useSyncExternalStore with a server snapshot, and the module never touches window at the top level. Storage that throws (opaque origins, private mode) degrades to a session-only choice.

Panel chrome

PanelShell composes the chrome around a page from the §1 envelope:

import { PanelShell } from '@adonia/ui'
import type { AdoniaEnvelope } from '@adonia/ui'

export default function PostsIndex(props: AdoniaEnvelope) {
  return (
    <PanelShell envelope={props} breadcrumbs={[{ label: 'Posts' }]}>
      {/* page content */}
    </PanelShell>
  )
}
  • Sidebar — brand link to panel.urls.dashboard, a navigation landmark labelled “Main” holding NavTree, and the sidebar.footer slot.
  • NavTree — renders NavNode[]: groups become labelled nested lists, items become links. The active item carries aria-current="page".
  • TopbarBreadcrumbs, the topbar.right slot, then the user menu (a disclosure button with a menu, closed by Escape).
  • Toasteradonia.flash.success as role="status", adonia.flash.error as role="alert", each dismissible.
  • A skip link precedes the sidebar and <main> is focusable, so keyboard users are not walked through the nav on every page.

The shell has no authorization logic. A resource the user may not reach is absent from panel.navigation (protocol §2 rule 3), so nothing renders for it — there is no “hidden” state to get wrong on the client. A protocolVersion other than 1 throws, per protocol §1.

Slots are filled through the registry:

import { adonia } from '@adonia/ui'

export default adonia({
  registry: (r) => {
    r.slot('topbar.right', ThemeToggle)
    r.slot('sidebar.footer', StorageMeter)
  },
})

Tailwind preset

// tailwind.config.ts
import adonia from '@adonia/ui/tailwind'
export default { presets: [adonia] }

The preset maps tokens onto theme keys and nothing else — no content globs, no plugins, no darkMode setting. Adonia’s own files are scanned through the mandatory @source '../../node_modules/@adonia/ui'; directive, so utilities land as bg-adonia-surface, text-adonia-text-muted, bg-adonia-accent-600, rounded-adonia, p-adonia-4, text-adonia-sm, font-adonia. Every value is a var() reference, so changing the accent or the colour scheme repaints without regenerating a single class.

Out of scope here

Action modals, widgets, lenses, and tenant switching are Phase 3+; the topbar.right / sidebar.footer slots are where they will attach.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close