Skip to content

Fix the UX issues the new test suite found - #596

Draft
sandhose wants to merge 13 commits into
quenting/a11y-fixesfrom
quenting/ux-fixes
Draft

Fix the UX issues the new test suite found#596
sandhose wants to merge 13 commits into
quenting/a11y-fixesfrom
quenting/ux-fixes

Conversation

@sandhose

Copy link
Copy Markdown
Member

The quick fixes from the UX pass over the test suite (the larger items are in the follow-up backlog):

  • MAS API errors render as prose instead of raw JSON, opaque failures as "failed with status code N", and an empty error body no longer renders "{}"
  • cancelling OIDC consent says "Sign-in was cancelled"; login on a non-secure origin explains itself instead of leaving a silently dead button
  • the admin's browser stops phoning hosts whose result is never rendered (GitHub on non-ESS, the adminbot probe off-Pro, a destination's well-known before the destination is known to exist) — and the mocks now enforce it under strict MSW
  • the duplicated token status badges are extracted with one palette (grey for normal ends of life, red only for the unexplainable), and the expiry check runs ahead of the server-computed flag
  • destructive actions confirm first (allowlist remove, token revoke), mutation failures surface inside the dialog where a screen reader can perceive them, and the one-time token secret can no longer be dismissed by accident
  • legibility: rooms stop passing IDs off as aliases, the destination drawer says Never/None instead of em dashes, the user drawer leads with the name, the supervision launch button and SBG alerts stop misleading, the sidebar's registration-tokens filter shows as a chip, and the application drawer links to its users

108 non-screenshot / 132 container tests at the tip, with three new specs for the confirm dialogs. Four screenshot baselines regenerate across this and the a11y PR, each in the commit that changes that page's pixels.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 24, 2026

Copy link
Copy Markdown

Deploying admin-beta-element-dev with  Cloudflare Pages  Cloudflare Pages

Latest commit: 38f77a7
Status: ✅  Deploy successful!
Preview URL: https://da79e010.admin-beta-element-dev.pages.dev
Branch Preview URL: https://quenting-ux-fixes.admin-beta-element-dev.pages.dev

View logs

@sandhose
sandhose force-pushed the quenting/ux-fixes branch 2 times, most recently from 4abba5b to 81d039e Compare August 24, 2026 09:27
@sandhose
sandhose force-pushed the quenting/ux-fixes branch 2 times, most recently from 7a3d256 to 9af3b28 Compare August 25, 2026 08:39
sandhose and others added 13 commits August 25, 2026 12:26
A failing MAS request threw whatever the generated client decoded. A
structured error body came out as an object, so the error screen printed
the raw JSON with the message buried in it; a body that is not a MAS error
came out as the raw upstream text, with no hint of what went wrong; and an
empty body became the literal "{}".

The MAS client now throws `MasApiError` with the titles the service listed,
falls back to the HTTP status error when the body carries no usable list,
and the error screen replaces an empty stringified body with a sentence
saying the server gave no details. That fallback attaches the body as a
cause, which the error screen renders as a sibling paragraph rather than
nesting one inside another.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Declining consent at the authorization server sent the browser back with
`error=access_denied`, which the callback rethrew as the provider's raw
`error_description` — a technical sentence for something the user chose to
do. On the login page, a browser outside a secure context withholds Web
Crypto, so building the PKCE challenge throws, the redirect never happens
and "Get started" looks broken with nothing said. A failure to start the
flow passed silently either way.

The callback throws a localized error, saying the sign-in was cancelled for
`access_denied` — keeping the server's description on the non-localized
message, where the error screen still exposes it — and quoting that
description otherwise. The login form checks `isSecureContext` up front,
disables the submit button and explains that the page has to be loaded over
https, and a failed start is now logged and reported under the field, until
the server name is edited. Neither of those marks the field itself invalid:
the server name is not what is wrong.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three loaders fired requests the page then had no use for. The dashboard
asked api.github.com for the latest ESS release on every deployment, even
where the tile that shows it never renders — and the component asked again,
unconditionally, undoing any gate the loader put on it. /supervision probed
the adminbot endpoint before knowing the edition, where it 404s outside Pro.
The federation destination drawer prefetched the destination's own
well-known in parallel with looking the destination up, so any string in
the URL made the admin's browser fetch that host.

Each of the three now waits for the answer that decides whether the request
is worth making: the ESS version before the GitHub release, in the loader
and in the component, the edition before the adminbot config, and the
destination itself before its well-known. The mocks pin the first two gates:
the GitHub release is handled on the ESS deployments only and the adminbot
config on ESS Pro only, so a run against a deployment which should not make
those calls fails on the unhandled request.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both token list pages and both token drawers carried their own copy of a
status badge, and the copies had already drifted: the registration one
short-circuited on the server-computed `valid`, so a token that expired
while the page was open kept reading "Active" until the server recomputed
the flag, and it painted revoked, expired and used-up tokens red — the
colour that should mean something went wrong, not that a token reached a
normal end of life.

The badges move to src/ui/token-status-badge.tsx, one per token kind. The
attribute checks run before the server flag, so the first render after an
expiry tells the truth without waiting on the server, and grey covers the
ends of life with red kept for a token the server rejects for a reason none
of the attributes explain. An expiry the browser cannot parse counts as
past rather than as still active. The registration list's "Valid Until"
column becomes "Expires at" to match the drawer, and the personal token
columns drop their title casing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Removing a federation allowlist entry and revoking either kind of token
happened on the first click, with no confirmation, even though the first
stops the server federating with a whole domain and the second breaks
everything using the token immediately. The allowlist trigger was an
icon-only button whose label lived in a tooltip, so it had no accessible
name. When either token mutation failed nothing was said at all: the
revoke, unrevoke and edit handlers had no `onError`, so the request just
sank.

All three destructive actions now open a confirmation dialog naming what
they affect, and the allowlist trigger carries an `aria-label`. A failure
raised while a dialog is open is rendered inside it, through
`Dialog.ErrorAlert`: an open dialog marks the app root `aria-hidden`, so a
toast fired from there reaches nobody using a screen reader. The dialog
stays open on the failure, which is what makes the alert the feedback. The
allowlist remove already reported its failure as a toast, and that toast
moves into the dialog with the rest.

Successes are reported by toast: the personal token keeps the one it had,
and revoking or editing a registration token gains one. Unrevoke has no
dialog of its own, so its failure stays a toast too.

The specs cover cancelling both revoke dialogs, and a failing revoke
endpoint showing the error with the token untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A newly created or regenerated personal token shows its secret exactly
once. The dialog holding it closed on Escape, on a click outside and on the
corner close button, so the only copy of the token could be lost by a
stray keypress — and the button that closed it read "Cancel" or "Close",
as if the token had not already been created.

`Dialog.Root` takes a `dismissible` prop that turns off Escape, outside
clicks and the close button, and both token dialogs set it while the secret
is on screen. The remaining button becomes a primary "Done", closing
through the dialog's own open state: on a phone or tablet, where the dialog
renders as a vaul drawer, a non-dismissible drawer swallows `Dialog.Close`
too, which would leave no way out at all.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two buttons are disabled and need to explain why: Regenerate on a personal
token you do not own, and Edit properties on a revoked registration token.
Compound buttons are `aria-disabled` rather than natively disabled, so a
tooltip on one does open — but Regenerate passed its explanation as
`label`, which renamed the button to it, and Edit properties had no tooltip
at all: on a revoked token it was a bare disabled dialog trigger, so the
dialog could never be opened and nothing said so.

The Regenerate tooltip becomes a `description`, leaving the button its own
name, and the revoked branch of the edit modal returns the disabled button
inside a new tooltip saying what to do about it, instead of a dialog nobody
can reach.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Alias column fell back to the room ID when a room had no canonical
alias, presenting an identifier as something it is not and duplicating the
Room ID field in the drawer. The column headed "Type" showed Public,
Restricted or Private, which is the join rule, not a room type. Member
counts were interpolated raw in the list while the drawer formatted its
own, so the same number appeared two ways. The drawer's creator was a bare
Matrix ID with nothing to follow.

The alias cell renders an em dash when there is none, the column is renamed
Visibility, and both member counts go through the locale's number
formatting. A creator from this server links to it in the user list,
searching for the username — the filter matches that, not the full Matrix
ID. A creator from another server stays plain text, and a room created
under room version 11 or later, which reports no creator at all, gets the
em dash.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A destination that has never been retried has `retry_last_ts` and
`retry_interval` of 0, which the drawer rendered as an em dash in both
fields — the same glyph it uses for a value it does not have, so "never
retried" and "unknown" looked alike. The last successful stream ordering
was interpolated raw, so a seven-digit number came out ungrouped. The
drawer also repeated the destination name as its first data item, right
under the heading that already says it.

The two retry fields read "Never" and "None" — no backoff is the healthy
state, so the interval avoids wording that sounds like a fault under a
green badge — the stream ordering goes through `Data.NumericValue`, and the
duplicated Destination item is gone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The user drawer led with the mxid in a bold paragraph and put the display
name underneath in smaller text, so the identifier outranked the name and
neither was a heading. The Status badge sat directly inside the data item,
next to its term but outside any definition: `Data.Grid` and its children
are divs carrying `list`, `listitem`, `term` and `definition` roles, so a
value outside `Data.Value` leaves the term with nothing paired to it.
"Locked At" was the only title-cased label in the drawer.

The display name becomes the drawer's h3, falling back to the mxid when
there is none, with the mxid below it. The badge moves inside `Data.Value`
and the label reads "Locked at".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Five strings said something other than what was true. The /supervision
launch button reused the generic "Sign in" label, which reads as signing
in to the console you are already in. Closing the supervision popup before
it finished raised a critical alert blaming the user for closing it "too
quickly". The disabled-feature alert told an ESS Pro admin the feature is
part of their subscription without saying it is off on this deployment.
The Secure Border Gateway alert pitched SBG as an ESS Pro feature to
admins who already have ESS Pro. And the dashboard's rooms total carried
the uptime tile's translator description.

Each now says what it means: the button names the interface it opens, the
closed-popup alert is informational and offers to try again, both
deployment alerts point at the configuration — with the SBG description
carrying only the action, since its title already says the feature is off
— and the description matches its message.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The sidebar entry for Registration tokens links to `?revoked=false`, but
the page's filter definition had no entry for that key, so the list arrived
already filtered with nothing on screen saying so and no way to widen it
short of editing the URL.

`revoked: false` joins the filter definition as "Not revoked", which gives
it a chip, a menu item and the usual "Clear". A test walks the sidebar link
and checks the chip appears and Clear drops it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The application details drawer already links to the devices list filtered
to that application, but there was no way to get from it to the people
using it — the users list accepts exactly that filter, and only a
hand-written URL reached it.

The drawer gains a "View users on this application" button next to the
existing devices one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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