Review the admin surface: right components, stable layout, and tools to work with it - #633
Merged
Merged
Conversation
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
force-pushed
the
unified/feat/admin-forms
branch
from
August 30, 2026 15:55
1e25c6a to
d31e79d
Compare
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 datesrendered as
mm/dd/yyyy, American order, in a British theatre's admin. Fifty settings were fiftyfull-width text boxes with no way to find one.
What changed
A person is chosen, never typed.
PersonPicker.vuesearches 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.vueis the one place that convertsbetween the
YYYY-MM-DDthe API speaks and the calendar value the input takes, British order, witha 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.vuegives every list the same shape: afixed-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 bulkactions that are not in this slice.
The audit trail reads. It showed
user:b4dd5014cd...where a name belongs and raw JSON clippedoff 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 runninggrant-admin.ts./devreplaces that: apersona 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.configleaves the page and its routes out ofthe bundle instead, and a test greps the built
.outputto prove it. That test earned its placeimmediately: Nuxt's own
ignorecovers the app but Nitro scansserver/separately, so the firstbuild shipped the dev API routes. Both are excluded now, and the check passes against a build made
the way CI makes one.
bun run shotsis 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.tsfails on a rawtype="date", a bare account-id input, afilter 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 shotsnow exits explicitly, because the spawned dev server kept the loopalive 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.tsalready holds to the design language. K-101's accessibilitybaseline and K-102's phone-first screens, which need a device to judge rather than a screenshot.
Reviewing this
Run
bun run shotsand look at.shots/. The plan was judged against the pictures rather than thediff, and it should be reviewed the same way.