---
title: "8. Theming and tests"
image: "https://adonia.pages.dev/og.png"
version: "next"
---

> Documentation Index
> Fetch the complete documentation index at: https://adonia.pages.dev/llms.txt
> Use this file to discover all available pages before exploring further.

# 8. Theming and tests

Two things left: make the panel look like your product, and prove it works.

## Brand and accent

The panel's own declaration carries the name and accent:

```ts no-check
export default Panel.make('admin')
  .path('/admin')
  .guard('web')
  .login({ model: () => import('#models/user') })
  .brand({ name: 'Blog Admin' })
  .resources(registry)
```

`accent` is expanded at runtime into an eleven-step ramp
(`--adonia-accent-50` … `--adonia-accent-950`) by a pure function that runs
identically on server and client, so the server-rendered HTML already carries
the finished ramp and hydration never flashes. Named Tailwind colours, hex,
`rgb()` and `oklch()` are all accepted; anything unparseable falls back to the
default accent rather than blanking the admin.

The ramp keeps your hue and chroma but imposes a fixed lightness scale — which
is what guarantees `accent-50` is always a tint you can put text on and
`accent-600` is always a surface `accent-50` reads against, whatever colour you
picked.

## Tokens

Every visual decision reads a `--adonia-*` custom property; no component
hard-codes a colour, radius or spacing step. Override them anywhere in your own
CSS, or from the client entry:

```ts no-check
// inertia/adonia.ts
import { adonia } from '@adonia/ui'

export default adonia({
  theme: {
darkMode: 'class',
cssVariables: {
  '--adonia-radius': '10px',
  '--adonia-density': '0.28rem',
  '--adonia-font-family': '"Inter var", system-ui, sans-serif',
},
  },
})
```

The spacing scale is `calc(var(--adonia-density) * n)`, so that one density line
takes the whole panel from comfortable to compact. `darkMode` picks a strategy:
`'system'` (the default, pure CSS), `'class'` (a user toggle persisted to
`localStorage`, exposed through `useColorScheme()`), or `'off'`.

Two slots let you add chrome without ejecting anything:

```ts no-check
adonia({
  registry: (r) => {
r.slot('topbar.right', ThemeToggle)
r.slot('sidebar.footer', StorageMeter)
  },
})
```

If a page needs more than that, `node ace adonia:eject resource_index` swaps the
thin re-export for the full source and you own it from then on. Field- and
cell-level customisation is a smaller hammer: `r.field('app/map-point', MapPoint)`
registers a component for a descriptor type, and `node ace adonia:field` writes
both halves for you.

Finally, the Tailwind preset maps the tokens onto theme keys so your own markup
can use them:

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

Utilities land as `bg-adonia-surface`, `text-adonia-text-muted`,
`rounded-adonia`, `p-adonia-4`. Every value is a `var()` reference, so changing
the accent or the colour scheme repaints without regenerating a class.

## Run the panel's tests

```sh
cd examples/blog-admin
node ace.js test
```

```text
Tests  82 passed (82)
```

The suite that matters for everything you just built is
`tests/functional/adonia_tutorial.spec.ts` — one group per chapter of this
tutorial, driven through the real HTTP stack:

| Group | Asserts |
|---|---|
| the panel and its navigation | brand, nav order and grouping, every resource serves its index |
| schema, validation, `inputErrorsBag` | grid root, flat state keys, per-field error bag, relation existence |
| the table | declared columns and filters, materialized relation cells, filter/search/sort behaviour |
| authorization | per-row `can` divergence, and a 403 on the write that ignores it |
| relations | picker targets and options endpoint, pivot diff on create and edit |
| the image field | node rules and `urls.upload`, a rejected upload, a promoted one |
| reactivity | the `reactive` block, server re-derivation, prune-and-require mirroring |

Write yours the same way — through the panel, not around it. For the parts that
do not need a server, `@adonia/core/testing` compiles a resource in-process:

```ts no-check
import { compileFor, validateWith, assertFillOmits } from '@adonia/core/testing'

const descriptor = compileFor(resource, 'create')
const { errors } = await validateWith(resource, 'create', { title: '' })
await assertFillOmits(resource, 'edit', { status: 'draft', publishedAt: '…' }, ['publishedAt'])
```

Those run in milliseconds and are the right place to pin a validation rule or a
mirroring invariant; keep the HTTP suite for the wiring. See
[testing](/guide/testing) for the whole harness, and
[CI](/ci) for the pipeline that runs both.

## Where to go next

- [Resources](/guide/resources) — every static and hook the class supports.
- [Fields reference](/reference/fields) — a page per field type.
- [Filters and search](/guide/filters-and-search) — custom filters, search
  drivers, cursor pagination.
- [Error model](/error-model) and [observability](/observability)
  — what a failure looks like and what it emits.
- [Protocol v1](/protocol/v1) — the wire format, if you are building a
  custom field or a second client.

Actions and widgets render through the same restrained chrome and token set.
Use `topbar.right` and `sidebar.footer` for app-specific additions. Continue with
[tenancy](/guide/tenancy) and [nested resources](/guide/nested-resources) for
tenant switching and parent-scoped navigation.

Source: https://adonia.pages.dev/guide/tutorial/theming/index.mdx
