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-50 … accent-950 |
| Radius | radius, radius-sm, radius-lg (the last two derive from radius) |
| Font | font-family, font-family-mono, font-size-xs … font-size-2xl |
| Density | density, space-1 … space-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 topanel.urls.dashboard, anavigationlandmark labelled “Main” holdingNavTree, and thesidebar.footerslot.NavTree— rendersNavNode[]: groups become labelled nested lists, items become links. The active item carriesaria-current="page".Topbar—Breadcrumbs, thetopbar.rightslot, then the user menu (a disclosure button with amenu, closed by Escape).Toaster—adonia.flash.successasrole="status",adonia.flash.errorasrole="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.