Skip to content

Admin screens

A screen is an Astro component rendered inside the admin shell at /admin/x/<plugin>/<id>. Use one when a plugin needs a view that is not a form over content: a report, a queue, a connection test.

src/plugins/queue/Panel.astro
---
import { env } from 'cloudflare:workers';
import Stack from '@ouncepage/core/ui/Stack.astro';
import Row from '@ouncepage/core/ui/Row.astro';
import Spacer from '@ouncepage/core/ui/Spacer.astro';
import Heading from '@ouncepage/core/ui/Heading.astro';
import Pill from '@ouncepage/core/ui/Pill.astro';
import Empty from '@ouncepage/core/ui/Empty.astro';
const { results } = await env.DB
.prepare('SELECT email, state, created_at FROM queue ORDER BY created_at DESC LIMIT 50')
.all<{ email: string; state: string; created_at: string }>();
const rows = results ?? [];
---
<Stack gap="xl" class="card p-5">
<Heading level={2} size={4}>Queued sends</Heading>
{rows.length === 0 ? (
<Empty>Nothing is queued.</Empty>
) : (
<Stack as="ul" gap="md">
{rows.map((row) => (
<Row as="li" gap="lg" wrap class="card px-5 py-3.5">
<span class="truncate">{row.email}</span>
<Spacer />
<Pill tone={row.state === 'sent' ? 'live' : 'neutral'}>{row.state}</Pill>
<span>{row.created_at}</span>
</Row>
))}
</Stack>
)}
</Stack>
src/plugins/queue/index.ts
import type { Plugin } from '@ouncepage/core';
import Panel from './Panel.astro';
export function queue(): Plugin {
return {
name: 'queue',
title: 'Send queue',
nav: [
{
id: 'panel',
label: 'Send queue',
icon: 'activity',
permission: 'activity.view',
},
],
screens: {
panel: Panel,
},
};
}

The nav entry’s id and the screen’s key must match. The engine pairs them by building the href /admin/x/<plugin>/<id> and looking the screen up at <plugin>/<id>. A mismatch means a sidebar link that redirects to /admin.

A screen key may also hold a ScreenEntry instead of a component, which is how a screen states its own permission and heading:

screens: {
panel: { component: Panel, label: 'Send queue', permission: 'activity.view' },
}

Ounce resolves each screen’s permission once, at registration: the screen’s own permission if it has one, otherwise the matching nav entry’s. A screen with neither is reachable by any signed-in editor, so declare it on the screen when the screen is the thing you are guarding.

Key Type Meaning
id string Matches a key in screens
label string Sidebar text
icon string One of the engine’s icon names. Defaults to settings
href string Override the generated href. Only for linking elsewhere
permission Permission Hides the entry, and refuses the route, for roles that lack it. A ScreenEntry may override it

The engine ships 218 icons. Every one is rendered beside its name on the design system’s Icons topic, at /admin/design-system/icons, and the same names are the keys of the ICONS record exported from @ouncepage/core/ui/icons, so a screen written in TypeScript can be typed against them.

An unknown name renders the missing glyph, a plain circle, rather than an error or an empty <svg>. A typo shows up on the screen instead of leaving a gap you have to notice.

Plugin entries are appended after the built-in ones, in plugin order. There is no way to place one above Pages.

src/plugins/queue/Panel.astro
---
import { requires } from '@ouncepage/core/guard';
const denied = requires(Astro.locals.editor, 'activity.view');
if (denied) return denied;
---

requires returns a 403 Response when the role lacks the permission and null otherwise. Return it, do not throw it. This repeats what the nav entry already declares, which is the point: the screen stays right if it is ever reached another way.

Guard the POST separately. A screen whose GET is safe for editors but whose POST is not needs the check inside the if (request.method === 'POST') branch as well, because the two are different permissions:

---
const who = Astro.locals.editor;
if (Astro.request.method === 'POST') {
const denied = requires(who, 'plugins.manage');
if (denied) return denied;
// ...
}
---

A save that only reports itself inline is easy to miss, because a redirect puts the message at the top of a page the editor may not be looking at. The engine renders a toast region in the admin shell and gives you two ways into it.

From the server, emit a <Toast> anywhere in your screen. It renders a <template>, which the shell hoists into the region on load:

src/plugins/queue/Panel.astro
---
import Toast from '@ouncepage/core/ui/Toast.astro';
const sent = Astro.url.searchParams.has('sent');
---
{sent && <Toast>Queued. The send starts within a minute.</Toast>}
{failed.length > 0 && <Toast tone="danger">{`${failed.length} could not be queued.`}</Toast>}

From the browser, call toast():

import { toast } from '@ouncepage/core/toast.js';
toast('Connection tested.');
toast('That token was rejected.', 'danger');

tone is good or danger. A good toast clears itself after five seconds, a danger one after nine, and either can be dismissed. Under prefers-reduced-motion it appears without the slide.

Every asynchronous interaction in the admin follows one contract, and attempt is it. It disables the control that started the work, marks the region it will change as busy, raises the danger toast if it fails, and gives everything back whatever happens:

import { attempt, json } from '@ouncepage/core/attempt.js';
const { ok, value, message } = await attempt(
async () => json(await fetch('/admin/x/queue/send', { method: 'POST', body })),
{ control: button, region: panel, failure: 'That could not be queued.' },
);
if (!ok) {
status.textContent = message;
return;
}

json and text turn a non-2xx response into an error carrying the server’s own message when it sent one. message is that message, or your failure sentence when the network simply gave out, so Failed to fetch never reaches a person. A request cancelled on purpose comes back with aborted and says nothing.

The region you pass gets aria-busy for the duration. Whatever announces the result needs aria-live="polite" on it, or a screen reader hears nothing at all.

Flash messages are read with flashed(Astro), which is true only for a GET carrying the parameter. Reading the query string directly means a failed POST back to ?saved=1 still reports a save that did not happen.

interface Editor {
email: string;
id: string;
role: Role; // the role in effect, which may be a preview
actualRole: Role; // the real role
viewingAs: Role | null;
}

Check against role, never actualRole. A developer previewing the admin as a viewer should see exactly what a viewer sees. Ounce only ever lets someone preview a role below their own, so role is never an escalation.

The shell is already rendered around your component: sidebar, heading, breadcrumbs, save bar. Render the content only.

Use the engine’s own components and tokens so a screen does not look foreign. The palette is gray, red, green and amber; nothing else compiles.

Colour is named by role, not by step. bg-surface, bg-sunken and bg-muted for surfaces; text-ink, text-ink-label, text-ink-body and text-ink-muted for text from darkest to lightest; text-ink-faint for an icon beside its own label; border-line-soft, border-line and border-line-firm for edges. They are CSS variables underneath, so bg-surface/80 works, and a theme that redefines them reaches your screen without your changing a line.

A neutral step is not an option: ounce-audit fails bg-gray-100, text-gray-500, bg-white and bg-black in your source, because each one pins a colour a theme cannot move. Red, green and amber keep their steps, because a tone pair carries a meaning rather than a surface.

One rule follows from how the utilities are generated: a role beats a palette colour in the cascade, so a shared class string must not set a text colour that a call site overrides. Put the colour at the call site, or make it a prop.

You should not have to write CSS, or learn which of Tailwind’s numbers this admin uses. Five components lay a screen out, and every gap is a name:

Component What it does
Stack Down the page. as="ul" when the right element is a list
Row Across. align, justify, wrap
Grid As many columns as fit. columns is narrow, medium or wide
Spacer Pushes whatever follows it to the far end of a Row
Divider The line between two things, horizontal or vertical

gap takes none, xs, sm, md, lg, xl, 2xl or 3xl, and md is the one you reach for most. There is no number to get wrong: a gap this admin does not use is not a name you can pass.

<Stack gap="xl">
<Row justify="between">
<Heading level={2} size={4}>Send queue</Heading>
<Button variant="primary">Send now</Button>
</Row>
<Grid columns="medium" gap="lg">…</Grid>
</Stack>
Need Reach for
A button, a link that acts as one, or a file-input label ui/Button.astro
A bordered surface: a card, a tile, a dense row ui/Panel.astro
Rows of values to compare ui/Table.astro with ui/Cell.astro
A person, or a plugin, as a face ui/Avatar.astro
A hint on an icon-only control ui/Tooltip.astro
A date, a count or a file size date, dateTime, ago, count, bytes from @ouncepage/core/format
A heading ui/Heading.astro
Body, label, help or eyebrow text TEXT from @ouncepage/core/ui/type
A card the card class
A status word ui/Pill.astro
An explanation, or a summary of what failed ui/Notice.astro
Nothing to show yet ui/Empty.astro
A field input, label and help from @ouncepage/core/fields/ui
An icon ui/Icon.astro
Work in flight ui/Spinner.astro or ui/ProgressBar.astro

Button takes a variant (primary, ghost, quiet, danger, dangerGhost, quietDanger) and a size (md, sm), and renders an anchor when given href, a label when given as="label", and a button otherwise:

---
import Button from '@ouncepage/core/ui/Button.astro';
---
<Button variant="primary" type="submit">Send now</Button>
<Button href="/admin/x/queue/panel" size="sm" icon="refresh">Refresh</Button>
<Button label="Remove" icon="trash" variant="quietDanger" size="sm" />

Do not write a button’s classes by hand, and do not call buttonClass yourself: the engine reserves it for Button and for the Menu trigger, and its own test suite fails anything else that reaches for it. md is the size that lines up beside an input; sm is for a dense row. Passing label instead of children makes an icon-only button, and the label becomes its accessible name.

Two more scales are closed the same way as the palette and the gaps. Stacking is seven named layers (z-base, z-raised, z-sidebar, z-bar, z-menu, z-dock, z-toast), so a number like z-30 is not a class; and the admin has three breakpoints (sm: 640px, lg: 1024px where the sidebar arrives, and xl: 1280px), so md: and 2xl: generate nothing.

Every one of these is documented at /admin/design-system, which renders the real component beside the rule it follows. Its index lists every shared component with a status, so you can tell what is safe to depend on: stable keeps its props until a major version, experimental can change them in a minor one. The Focus topic states what every key does per component, and the Voice topic states how the admin writes, which matters if your screen puts words on the page. The screen needs the design.view permission, which only the developer role holds.

The engine ships two command line checks. ounce-audit src reads your source and fails a hand-written button, an off-palette colour, a gap the scale does not generate or a container laid out by hand:

{ "scripts": { "check": "astro check && ounce-audit src" } }

ounce-layout checks the rendered page instead. It drives a browser over the DevTools protocol, records the box of every element on each screen at two widths, and diffs that against a baseline in your repo, which makes a refactor verifiable rather than hopeful. Put a .ounce-layout.json beside your package.json:

{
"origin": "http://localhost:4321",
"browser": "http://localhost:9333",
"baseline": "layout",
"viewports": [
{ "name": "desktop", "width": 1280, "height": 900 },
{ "name": "mobile", "width": 390, "height": 844 }
],
"screens": [{ "id": "queue", "path": "/admin/x/queue" }]
}

Start your site’s dev server and a browser listening on that port, then ounce-layout --update to record and ounce-layout to check. A screen that scrolls sideways fails on its own, whatever the baseline says. --twice captures each screen twice and reports what does not repeat, which is how you find out that a screen is not worth baselining. Content that comes from the clock or from a remote feed never repeats: mark that element data-volatile and it is left out of the capture.

  • Live outside /admin/x/<plugin>/. There is no route injection for plugins.
  • Replace or reorder a built-in admin page.
  • Skip the shell. Every screen renders inside it.