Skip to content

Review the admin surface: right components, stable layout, and tools to work with it - #633

Merged
MattA-Official merged 5 commits into
unified/mainfrom
unified/feat/admin-forms
Aug 31, 2026
Merged

Review the admin surface: right components, stable layout, and tools to work with it#633
MattA-Official merged 5 commits into
unified/mainfrom
unified/feat/admin-forms

Conversation

@MattA-Official

@MattA-Official MattA-Official commented Aug 30, 2026

Copy link
Copy Markdown
Member

Five admin screens were built one after another, each copying the last, and the copying carried the
mistakes forward. This is the review of all of them against the Nuxt UI component matrix, and the
changes it produced. K-123 and K-124, decision 0032.

The components were not so much wrong as unchosen: where a specific input existed, we reached
for UInput. A person was entered by pasting a 32-character hex id, in four places. Six dates
rendered as mm/dd/yyyy, American order, in a British theatre's admin. Fifty settings were fifty
full-width text boxes with no way to find one.

What changed

A person is chosen, never typed. PersonPicker.vue searches name, address and student number,
debounced, shows name over address, and replaces all four id boxes. It never serves a cached
result, because the person behind a search term may since have been renamed or erased.

Every input is the component for its job. DateField.vue is the one place that converts
between the YYYY-MM-DD the API speaks and the calendar value the input takes, British order, with
a calendar popover on the trailing button. Numbers are steppers, money is entered in pounds and
stored in pence (0004), lists are tags inputs.

One schema validates the request and drives the form. The Zod bodies moved to shared/utils/,
so a server refusal lands on the field it concerns rather than as an alert behind the modal.

Filters sit behind one button. AdminToolbar.vue gives every list the same shape: a
fixed-width search, a "Filters (n)" popover, an actions slot, and what is filtered shown as
removable chips. The rows no longer resize when a select's value changes, or wrap at 1400px. The
shape is lifted from newtheatre/lumina's members index, which also leaves room for the bulk
actions that are not in this slice.

The audit trail reads. It showed user:b4dd5014cd... where a name belongs and raw JSON clipped
off the right edge; it now resolves the name, renders a diff as a diff, and turns an epoch into the
date it is (expiresAt: 31 Jul 2027, which is the committee year end, visible at last).

Settings are findable. A search box over the fifty keys, the workshop groups as tabs carrying
their counts, and the right input per key. Save stays per key, because one save is one audited
change (J-104).

Confirmations are toasts, alerts are only for something the reader must act on, and every table
has an empty state.

The developer tools

Working on an admin screen locally started with registering an account, fetching a token out of
.data/mail, enrolling an authenticator and running grant-admin.ts. /dev replaces that: a
persona per role plus a plain member, a guest and a tombstone, one click to be any of them, the
local mailbox, and the permissions the current session resolves to.

It signs in without a password, so the guarantee that matters is that it is not in a build at all.
A guard inside a file still ships the file, so nuxt.config leaves the page and its routes out of
the bundle instead, and a test greps the built .output to prove it. That test earned its place
immediately
: Nuxt's own ignore covers the app but Nitro scans server/ separately, so the first
build shipped the dev API routes. Both are excluded now, and the check passes against a build made
the way CI makes one.

bun run shots is the harness used for this review, checked in. It seeds realistic data, signs in,
and writes a picture of every admin screen and modal at 1400 and 900 into a gitignored directory.
It gates nothing and CI never runs it.

Conventions, not review habits

tests/unit/admin-conventions.test.ts fails on a raw type="date", a bare account-id input, a
filter row outside the toolbar, a table with no empty state, a screen that confirms without a
toast, and a count written as "account(s)". Each was proved by introducing one and watching it
fail. Decision 0032 says which component is for which job.

Checks

Lint, both typechecks, all five checkers, bun run test (234 pass), bun run test:e2e (212 pass,
run twice), and a cold build, twice, with the dev-surface test run against each output.

One earlier e2e run reported 2 failures. It began seconds after I force-killed a dev server that a
leaked process was holding the port with, and two full runs since have been green; I discarded its
output before reading it, so I cannot name what failed. Recorded here rather than buried. The leak
itself is fixed: bun run shots now exits explicitly, because the spawned dev server kept the loop
alive and the run sat there holding the port.

Not in this slice

Bulk actions, deliberately: the toolbar leaves room for the selection bar, and it is its own story.
The public site, which shells.test.ts already holds to the design language. K-101's accessibility
baseline and K-102's phone-first screens, which need a device to judge rather than a screenshot.

Reviewing this

Run bun run shots and look at .shots/. The plan was judged against the pictures rather than the
diff, and it should be reviewed the same way.

Five admin screens were built one after another, each copying the last, and
the copying carried the mistakes forward. Thirteen screenshots of every screen
and modal at two widths, checked against the Nuxt UI component matrix, showed
the same thing everywhere: the components are not wrong so much as unchosen.
Where a specific one existed for the job, we reached for UInput.

A person was entered by pasting a thirty-two character identifier, in four
places: awarding a fellowship, recording a membership, and both halves of a
manual audit entry. No search, no name, no way to tell you had the right
person. PersonPicker replaces all four: an autocomplete searching name,
address and student number, debounced, showing the address or number under
each name. It reads the account directory, which already pages and allow-lists
its columns, and the directory search gains the student number because that is
how the committee finds somebody (0031).

Six date fields rendered as mm/dd/yyyy, in American order, in a British
theatre's admin. DateField is the one place that converts between the
YYYY-MM-DD strings the API speaks and the calendar value the input takes, and
the one place that says the dates here are London's.

No form validated on the client, so a refusal appeared as a red alert behind
the modal it concerned. The Zod objects the endpoints validate now live in
shared/ and drive the forms, so one definition cannot drift from the other and
a refusal lands on the field it is about.

Nothing used a toast, though UApp was already in place, so every confirmation
stayed on the page until dismissed. Confirmations are toasts now; an alert is
for something the reader has to act on.

The conventions are decision 0032 and a test that fails when a screen departs
from them, the way the design language is a test rather than a review habit
(0021). It ships with the three checks this change satisfies; the rest arrive
with the tables and the settings page. Stories K-123 and K-124 are the home
for the work.

Two things the browser suite found, both real. The picker cached results by
search term, which would have offered somebody since renamed or erased; it
never caches now. And the suite searched by first name, which syntheticPerson
draws from a list of thirty-five, so the picker could choose a different person
with the same one: it searches by address.
@MattA-Official
MattA-Official force-pushed the unified/feat/admin-forms branch from 1e25c6a to d31e79d Compare August 30, 2026 15:55
The audit toolbar wrapped onto a second line at 1400 pixels, the people
filter grew and shrank as its value changed, and every search box truncated
its own placeholder. All three are the same cause: a bare flex row whose
children carry min-w and flex-1, so the row is only ever as stable as its
contents.

AdminToolbar is the shape lumina already uses: a search of fixed width, every
filter behind one button labelled with how many are set, and the actions to
the right. The row cannot resize because nothing in it is sized by its value.
What is filtered is shown underneath as chips that come off one at a time,
which is also the only way somebody discovers a filter they forgot was on.
People, members, fellows and the audit trail all use it.

The audit table printed user: and a thirty-two character id where a name
belongs. It joins the subject the way it already joined the actor, so an
entry reads "Role granted, Priya Nair"; an entry about something that is not
a person still shows what it says. Its detail column was raw JSON clipped
mid-value off the right edge, and now reads as what changed: a diff shows
"value: 2000 → 2500", and anything else shows its own keys.

Every table has an empty state, because until now one with nothing in it
showed nothing at all.

The date field gained the calendar popover from the Nuxt UI example, so a date
can be picked as well as typed.

Two more conventions join the test: every list uses the toolbar, and every
table says what would be there. That is five of the six; the number input
arrives with the settings page.

One slip fixed: a debug line I added while diagnosing the harness was left in
and shipped in the previous commit.
The settings screen was fifty full-width text boxes, a number in each and no way to find
one. Every key now gets the input for what it holds: a switch for a flag, a stepper for a
number, a currency input for anything in pence (entered in pounds, stored as pence, 0004),
a tags input for a list. A search box sits over the lot, and the workshop groups became
tabs carrying their counts, so one group is on screen at a time.

Counting things as "4 membership(s)" was in six places. `plural()` is the one place that
bothers now, and a test fails on any screen that goes back to the brackets.

Two smaller things the screenshots caught: the audit trail printed `expiresAt: 1817074799`
where a date belongs, and the sidebar footer wrapped the account name and Sign out onto
four lines at 210px.
Working on an admin screen locally started with registering an account, fetching a token
out of .data/mail, enrolling an authenticator and running grant-admin.ts. `/dev` replaces
that: a persona per role plus a plain member, a guest and a tombstone, one click to be any
of them, the local mailbox, and the permissions the current session actually resolves to.

This signs in without a password, so the guarantee that matters is that it is not in a
build at all. A guard inside a file still ships the file, so nuxt.config leaves the page
and its routes out of the bundle instead. The test that proves it earned its place
immediately: Nuxt's own `ignore` covers the app but Nitro scans server/ separately, so the
first build shipped the dev API. Both are excluded now and the built output is checked.

The tombstone persona could not be found after seeding, because anonymisation rewrites the
address it was seeded under, so every run made another one. The seeder now remembers which
account each persona became in .data/personas.json, next to the database it belongs to.

`bun run shots` is the harness used to review all of this, checked in: it seeds realistic
data, signs in, and writes a picture of every admin screen and modal at two widths into a
gitignored directory. It gates nothing and CI never runs it.
The server runs under node, so Bun.write threw where it was called and the map was never
written: every persona read back as "not seeded" even though seeding had made them. The
mailbox reader in the same file already used node:fs, which is the pattern to follow.

The map now sits in the hub directory beside the database it describes, so a run against a
throwaway one does not read a map written for the developer's own.
@MattA-Official MattA-Official changed the title Choose the component, once, for admin forms Review the admin surface: right components, stable layout, and tools to work with it Aug 30, 2026
@MattA-Official
MattA-Official merged commit 47b6a64 into unified/main Aug 31, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant