Both from the final review, and the first one overturns a call I got wrong.
The aria-hidden sweep that makes aria-modal true also swallowed the toasts.
ToastProvider renders inside the app root, and every modal-raised confirmation
is toasted *before* its dialog closes — "Saved …", "Deleted …", "Removed …" —
so each one was inserted into a hidden subtree and never announced. Un-hiding
a live region afterwards does not replay what it missed. The toast layer is
portalled to the body carrying the dialog-layer attribute, which exempts it
from the sweep, and sits above the dialog: a toast explaining why a dialog's
action failed is no use behind it.
EmptyState's action was narrower than the call site it replaced. The old
first-workflow button carried loading={isPending}; the new one carried
nothing, so a double click created two workflows. The action is a union now —
a link takes no pending state, a handler takes loading and disabled.
Keys, secrets, audit, steps and workflows each carried the same
loading/error/empty ternary with its own copy of the spinner and its own
wording for a failed fetch — five of them had drifted to five different
sentences for "the request did not come back". They go through AsyncBoundary
now, which means a skeleton in place of a spinner, a retry button on failure,
and backend messages passed through friendlyMessage rather than printed raw.
Steps gets the filtered-empty state the fleet just got: "no steps match that
filter" is not "no steps yet", and only one of them should offer to create the
first one.
Two more from review:
Modal's effect ran before `mounted` flipped, so a dialog rendered already open
found null refs and took no focus at all. It depends on `mounted` now.
aria-modal was a claim with no mechanism behind it — portalled to the body,
the app tree is a sibling of the dialog and a screen reader's virtual cursor
still browsed the page underneath. The body's other children are marked
aria-hidden while any dialog is open, refcounted alongside the scroll lock.
This does mean a toast raised while a dialog is open is not announced, which
is the correct trade for a modal: ConfirmDialog shows its own errors inline.
Two of these were real defects in the previous two commits.
friendlyMessage discarded exactly the messages it claimed to keep: the
"is this a bare reason phrase" test was a shape regex, and "Default steps
cannot be edited" has the same shape as "Not Found". It is an exact-match set
of reason phrases now.
Modal depended on onKeyDown, which is rebuilt whenever onClose changes
identity — and onClose is an inline arrow at every call site, so any parent
re-render (a 30s poll, a mutation flipping to pending) tore the effect down
and rebuilt it: cleanup restored focus to the trigger, setup then moved it to
the top of the dialog, mid-typing. onClose is held in a ref and the effect is
keyed on `open` alone.
Also in Modal: initial focus takes the first control in the body rather than
the panel, since the header comes first in DOM order and every dialog was
opening on its own dismiss button; the Tab trap pulls focus back when it has
escaped the panel entirely rather than only handling the two ends; the scroll
lock is refcounted, because per-instance save/restore released the page when
an outer dialog unmounted under an open inner one; and the whole thing is
portalled to the body so a nested confirm is not clipped by its parent's
overflow box.
The fleet's filtered-empty state keyed on the search alone, so a tag filter
matching nothing told a customer with a full fleet to add their first server.
Remaining: pending mutation errors are reset when a confirm dialog closes, so
one member's failure no longer greets the next; ConfirmDialog clears typed
confirmation when the target changes, not only when it reopens; deleting a
workflow closes its dialogs before navigating rather than carrying a scroll
lock onto the next page; toasts split into a polite and an assertive region,
since one polite wrapper demotes the role="alert" children inside it; and a
custom skeleton gets a live "Loading" beside it, having been aria-hidden with
nothing else to announce.
The fleet list had a tag filter and nothing else: no search, no sort, and an
unbounded list. Searching hostname/address/OS and sorting by hostname, status
or last seen are all client-side, since the browser already holds the fleet
the page just fetched. Sorting by status orders by how much attention each
state wants rather than alphabetically, which is the only reason to sort by it.
The filtered count is shown beside the total so a search does not read as the
fleet having shrunk, and "no results" is a distinct empty state from "no
servers", with a way back out of the search.
refetchIntervalInBackground defaults to false on the query client. Polling
pages kept refetching in a hidden tab — the fleet list pulls inventory blobs
every 30s — so a console left open in a background tab polled until its
session expired. It belongs in the defaults because the argument is identical
on every polling page.
Four correctness/accessibility defects and the destructive-action flow.
- Button: the loading spinner carried xmlns="http://www.w3.instance/2000/svg",
a find/replace of "org" that landed inside a URL. Button also grows an href
form, because <Link><Button> nested a button inside an anchor at nineteen
call sites: invalid markup, two tab stops, and Enter firing only the anchor.
- Fleet status was four meanings carried by hue with the distinction living in
a title attribute, which touch never shows and screen readers need not
announce. It now carries a text label and an accessible name, which is the
one rule the design system states outright.
- Modal had no focus management at all: no trap, no initial focus, no restore,
no scroll lock, no aria-labelledby. Dialogs nest (a confirm over an edit), so
a stack decides which panel owns Escape and Tab.
- Seven destructive actions went through window.confirm(). ConfirmDialog
replaces them and can say what is about to happen; deleting a secret group,
a shared base step or a workflow now requires typing the name, since those
have no undo and a wide blast radius. adminsite keeps its own inline idiom
rather than importing a dialog system it does not have.
Adds Toast, AsyncBoundary/EmptyState/ErrorState/TableSkeleton and
friendlyMessage, replacing per-page loading ternaries and raw
(error as Error).message text. Wired here only where a call site was already
being edited; the remaining pages follow.
Every other page under app/(app) opens with `p-4 sm:p-6 lg:p-8` and the
layout adds none of its own, so this page alone sat flush against the shell
edge. Its h1 was text-xl where every other page is text-2xl.
The findings list was a stack of separately bordered cards; it is now rows
inside one Card, separated by border-border-soft, matching the monitors and
workflows lists. Loading is the shared spinner rather than a line of text,
the error is the shared danger strip, and the empty state uses the same
proportions as the monitors one.
Fleet board grouped by CVE, a per-server section on server detail, and
alert rules beside the channels they consume.
The server detail page has no tab pattern despite the plan saying to
follow one, so this adds a section in the existing vertical stack.
Three states are kept visually distinct because they are identical if
handled carelessly and only one is good news: never reported, no advisory
feed for the distribution, and scanned-and-clean. Database freshness sits
with the findings rather than in settings for the same reason.
server/internal/services/settings.go SaveSettings takes alerts and
email as required (non-pointer) values and writes them unconditionally
- absent fields would blank stored settings, not just leave them
alone. onLocalLoginChange was building its payload from the stale
loaded settings object instead of the in-progress form state
(thresholdMinutes/logRetentionDays) that handleSubmit uses, so editing
the offline threshold and then flipping the toggle silently reverted
the edit. Both paths now submit the same in-progress values.
Three root causes behind five reported overflows:
- Td below sm is a flex row, and a flex item's default min-width:auto is
its min-content width. A fingerprint or target URL is one unbreakable
token, so the value could not shrink and spilled out of the card. The
value now sits in a min-w-0 break-words wrapper that is display:contents
above sm, leaving the desktop table untouched. Fixes /keys, /monitors
and the installed-keys table on /servers/[id] in one place.
- The ESO read URL and the secrets group endpoint are unbreakable tokens
in prose with the default overflow-wrap:normal. Both paragraphs now
break-words.
- Three page headers (servers/[id], keys/[id], monitors/[id]) held an
unwrapped button row. They stack below sm and the clusters wrap. These
used items-start, which is why the earlier responsive sweep — matching
items-center — skipped all three; only one was reported.
Verified: tsc clean, next build clean, and the compiled stylesheet confirms
max-sm:min-w-0 / max-sm:break-words land in the below-640px query and
sm:contents in the above-640px one.
Implements spec 7 tasks 2-10 on top of the six-plan payload from task 1.
Admin: plans re-keyed on (deployment, tier); new catalogue collection holds
every Paddle price ID (one row per priceable component); new entitlements
collection holds desired beside granted. admin/internal/catalogue owns both
folds — entitlement to licence limits, and entitlement to Paddle line items —
so the base allowance is subtracted in exactly one place. licensing.Issue now
snapshots the instance's granted entitlement, never desired. Free is enforced
per account AND deployment. Staff endpoints for plans, catalogue and
entitlements; Free self-hosted can be claimed and renewed on its annual term;
the reaper stays cloud-only.
Server: enforces the monitor cap, audit-log retention (daily sweep, skips
Unlimited and lapsed instances), and gates the OIDC callback. Unset limits are
filled from the seed plan at the single decode site so old blobs never read as
zero.
Frontends: adminsite gains a catalogue price-ID editor, six-plan allowance
screen, and a catalogue-driven PlanConfigurator mounted on the staff instance
page. web shows monitors, audit retention and support level on the licence page.
Docs: CLAUDE.md, spec index and plan 5 preamble updated.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>