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.
Example
Section titled “Example”---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>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.
AdminNavEntry
Section titled “AdminNavEntry”| 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.
Permissions
Section titled “Permissions”---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; // ...}---Telling the editor what happened
Section titled “Telling the editor what happened”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:
---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.
Anything that fetches
Section titled “Anything that fetches”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.
Astro.locals
Section titled “Astro.locals”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.
Styling
Section titled “Styling”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.
Checking a screen still looks right
Section titled “Checking a screen still looks right”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.
Limits
Section titled “Limits”- 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.