Skip to content

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:

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:

// 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:

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:

// 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

cd examples/blog-admin
node ace.js test
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:

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 for the whole harness, and CI for the pipeline that runs both.

Where to go next

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 and nested resources for tenant switching and parent-scoped navigation.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close