--- title: "Operator UX Conventions" audience: [developer] summary: "The convention layer atop the design-system primitives: when and how to apply HTMX request shapes, feedback, confirmation modals, and validation when building operator forms and actions." --- # Operator UX conventions This is the **convention layer** that sits on top of the design-system primitives. It says *when* and *how* to use each primitive — not what they look like. Primitive definitions live in [`design-system.md`](design-system.md); this doc decides which one a new form or action picks up. It exists because each operator tab had grown its own confirmation, error, and success patterns — the pain point M7a's research named — and M7e replaced them with one vocabulary. Status: M7e deliverable. Cited by 7f's audit as the convention-drift baseline. --- ## 1. Scope & non-goals **In scope:** - HTMX request shape on mutation surfaces (forms, destructive actions) - Success and error feedback patterns - Confirmation modal coverage rules - Server-side validation rendering - HTTP status-code discipline for mutation handlers **Out of scope** (covered elsewhere): - Component primitives (modal markup, toast markup, badge styles) — see [`design-system.md`](design-system.md) §2–3 - IA / routes / breadcrumbs — see [`operator-ia.md`](operator-ia.md) and the `operator-panel-navigation` spec - A11y audit (keyboard nav, ARIA correctness, contrast checks) — M7f - Spacing, typography, color tokens — `design-system.md` §1 --- ## 2. HTMX request shape Every mutation form on the operator surface uses HTMX, not native `
`. The native path hit a brittle gorilla-CSRF failure mode: gorilla checks the `Origin` header against its trusted origins, so a submission that arrives with `Origin: null` is rejected — which is what the operator lookup form did for at least one user, and what CDP-driven form clicks do reliably. HTMX routes through the `X-CSRF-Token` header on body's `hx-headers` instead, which is the same path every other form uses successfully. **The triad** every form sets: ```html ``` - `hx-post` / `hx-put` / `hx-delete` — never use bare `method="POST"` - `hx-target` — CSS selector for the element to swap (default page-shell target is `#operator-main`; in-page partials use their own container ID) - `hx-swap` — `innerHTML` for content-area swaps; `outerHTML` only when replacing the wrapper itself (rare) **For redirects:** a mutation that lands somewhere new (a create page landing on the record, a settings save returning to a page) answers through one shared helper, `redirectToRecord` (`internal/server/redirect.go`, design D9): an htmx submit gets `HX-Redirect` to the destination and a 200 with an empty body; a native submit gets a `303` to the same destination directly. Both carry `?flash=` with the toast key (§3, §6 below). A bare `303` is never the answer to an htmx submit: htmx follows it itself and swaps the destination's full page into the form's own target, nesting the operator shell inside itself. `HX-Redirect` on a refusal is still wrong (§9); a refusal is `422` with the form re-rendered, never a redirect of either kind. --- ## 3. Success feedback **Rule:** mutation success fires a toast. Page-load durable notices use a banner. | Pattern | When | |---------|------| | `HX-Trigger: {"showSuccessToast": ""}` response header | A mutation handler returns success | | `
` rendered in the response body | The page itself is reporting a durable in-progress state (e.g., "Backfill in progress") | The toast container lives outside the swap target so it survives `hx-boost` navigations and partial swaps (see [`design-system.md`](design-system.md) §3 "Success feedback"). The toast driver is `internal/embeds/static/success-toast.js`. **Standard message framing:** | Action | Toast message | |--------|---------------| | Created | ` created` | | Updated | `Changes saved` | | Deleted | ` deleted` | | Revoked | `Grant revoked` | | Backfilled | `Backfill complete ( orgs processed)` | Keep it short. Operators read toasts in <1s of glance time — every extra word is friction. **A toast reports the outcome of the work, not that the handler reached its end.** A handler that iterates rows states how many succeeded and how many failed; a run in which every row failed answers with `fireErrorToast`, because a green toast beside a failure banner leaves the operator with two contradictory readings of one mass mutation. **Retired:** rendering `
` inside the response body of a mutation. That pattern pre-dated `showSuccessToast`; the forms library's outcome contract (`form-library`, `form-conventions`; `docs/design-system.md` §8) names the two mechanisms above as the only ones a mutation answers with, and the two remaining alert-in-body successes (org types, workspaces) were converted to the toast when their forms moved onto the library. --- ## 4. Error feedback `internal/embeds/static/error-handler.js` is the **single funnel** for HTTP error feedback. Under htmx 4 every response swaps by default; suppression is declared once in each page's `htmx-config` meta tag (`"noSwap": [204, 304, 403, "5xx"]`), not scripted. The funnel listens for `htmx:response:error` and `htmx:error`: - 422 → silent (the body is the re-rendered form and swaps inline; see §6) - 403 (CSRF expired) → red toast; the swap is suppressed by `noSwap`, so user input is preserved - other 4xx with body → the body swaps (server returns rendered error context) + backup toast - 5xx → red toast; suppressed by `noSwap`, user input preserved - network failure / timeout → red toast **Handler responsibility:** return proper HTTP status codes. Never return a 200 with an `.alert-danger` banner for a failure — the error handler keys off status, not body content. | Failure type | Status to return | |--------------|------------------| | Validation failure | 422; the declared form re-rendered in submission mode (see §6) | | Auth failure | 401 / 403 | | Resource not found | 404 | | Conflict (e.g., concurrent edit) | 409 | | Server bug | 500 (let the error handler show a generic toast; details go to logs) | **Database write failures never render `err.Error()`.** Raw driver text ("duplicate key value violates unique constraint … SQLSTATE 23505") is not operator-facing copy — it leaks schema internals and reads as a crash. The contract for a failed write: 1. **Constraint violation** → translate with `web.FieldErrorsFromDB(err, web.ConstraintMessages{…})` (`internal/web/dberrors.go`) and route the result through the page's 422 + `FieldErrors` path (§6/§8). Name the constraints the form can plausibly hit in a map next to the handler — constraint names live beside the form they belong to, not in a global registry — each mapped to the form field to flag and a friendly, actionable message. Violations without a named entry fall back to a per-SQLSTATE-class message on the form-level `""` key. Surfaces without FieldErrors machinery (member partials, formless actions like revoke/delete buttons) render the translated message through their existing banner/toast shape instead — friendly text, never driver text. 2. **Everything else** (`ok == false`) → `slog.Error` the real error and render a *generic* message ("Failed to update the product. Details are in the server logs."). The operator can't act on driver text; the log line is where the details belong. `member-console lint` enforces this with the `raw-error-render` rule: any line under `internal/server` that passes `err.Error()` into a `render*` helper, `fireErrorToast`/`fireSuccessToast`, or `http.Error` is flagged (lines containing `slog.` are exempt; `_test.go` files are skipped). **A collection whose backing query failed renders an error state, never the empty state.** The empty state asserts that no rows exist, so falling back to it after a failed load tells the person their data is gone when it is only unreadable — and it takes away the actions those rows carried. A handler that loads a list renders the failure and leaves the rows it already holds in place. --- ## 5. Confirmation modals **The rule:** require the modal for actions that (a) delete data, or (b) revoke user-visible access via the composite path (the revoke handler: decree + `end_conferral`). The same modal and contract serve the member surface (plan cancel, domain release and cancel-verification, FedWiki archive and delete); `hx-confirm` is banned everywhere and lint-caught. **Not required** for: grant issuance, grant extend, create, update, save. These are forward-only — mistakes are revocable. Adding friction to high-frequency operator actions doesn't earn its weight. **Special case, retired:** the org-type backfill's confirmation modal is gone with the action itself. High blast-radius mass mutations now follow the org-type default-change pattern instead: the selection immediately renders a **server-side read-only preview** (classified affected-population counts, named where a disposition is required), and the commit button lives inside that preview — the operator cannot reach the mutation without having seen its projection. Prefer this preview-then-commit shape over a modal for any future mass mutation: a modal interrupts with a question; the preview answers it. Tier drag-to-reorder and tier removal follow the same shape: the action renders the preview and the commit lives inside it — pending state is server-rendered, never browser-only, so no swap can silently discard it. Affected-population lists inside previews render **count-first**: the bucket line carries only the count and one sentence of consequence; the org names live behind a `show all N` `
` disclosure (`.org-disclosure-list` — one org per row, scrollable), so a hundred-org preview reads as one line until the operator asks for names. **Trigger contract** (from `internal/embeds/static/confirm-action-modal.js`): ```html ``` | Attribute | Required | Default | Notes | |-----------|----------|---------|-------| | `data-action-url` | yes | — | endpoint hit on confirm | | `data-action-method` | yes | `post` | `post` or `delete` | | `data-action-target` | yes | — | HTMX swap target; `closest ` resolves against the trigger element | | `data-action-swap` | no | `innerHTML` | HTMX swap style | | `data-action-title` | no | `Confirm` | modal heading | | `data-action-body` | no | empty | body copy; name the resource being affected | | `data-action-confirm-label` | no | `Confirm` | submit-button label | | `data-action-style` | no | `danger` | `danger` / `primary` / `warning` | | `data-action-fields` | no | — | JSON map of hidden form fields | **Failed requests leave the modal open** so the operator sees the error in the swap target and can retry. Successful requests close the modal automatically. **Body copy convention:** name the affected resource. "Delete 'Standard tier'?" beats "Are you sure you want to delete this?" by a wide margin — operators routinely manage many similarly-named entities. State the effect the primitive actually has: when confirmation or preview copy and the handler disagree, the disagreement is a finding against the copy rather than a wording preference, and it is settled by correcting the copy or by changing the primitive, never by leaving the two apart. --- ## 6. Server-side validation **The rule:** every form that mutates is a declared `forms.FormSpec` (spec `form-library`, `form-conventions`; `docs/design-system.md` §8), parsed through `spec.Parse(r)` (never `r.FormValue`), and revalidated server-side regardless of what the browser already checked. This section used to describe a hand-built `FieldErrors` map and a form-field partial written per form; the forms library replaced both, and this doc no longer prescribes either. The ban on `r.FormValue` holds for a handler reading a request outside the library too: `r.FormValue` merges the query string into the posted body, so a URL parameter can shadow a submitted field, and it returns only the first of a repeated field's values. Read `r.PostForm` instead. **The outcome contract** (design D9, restated here; the full statement is `docs/design-system.md` §8 "The outcome contract"): - A refusal for any reason, field-level or not, answers **422** with the same declaration re-rendered in submission mode: every submitted value carried back (checkbox and radio state included), each field's error under its control, a refusal that names no field in the form-level slot every declared form renders, and `autofocus` on the first errored control. - Every mutation form carries `novalidate`, so a cleared required field's submission always reaches the server, and the refusal a person sees is the server's own message under the control, not the browser's transient bubble. The constraint attributes (`required`, `maxlength`, `min`, `max`, `pattern`) stay on the control regardless, generated from the same declaration `Parse` reads, so the browser's rule and the server's rule cannot differ. - A handler adds its own errors by field name (uniqueness, a domain rule) or at the form level, on the `forms.Errors` value `Parse` returns, and builds the domain object only once that value is empty. **A constraint-violation refusal from the database** still translates through `web.FieldErrorsFromDB` (§4 above); its result is copied onto the same `forms.Errors` value (`applyWebErrors` in the packages that need it) so a DB-constraint refusal renders through the identical 422 path as a field-validation one, never a second mechanism. Keep messages concrete and actionable: "Enter a name." not "Invalid input." --- ## 7. Optimistic updates **Rule:** don't. Every mutation waits for server confirmation before the UI updates. HTMX's request-response cycle is the contract. **No exception currently in the codebase.** `grant-toggle.js`, the one prior exception (a grant active/inactive switch that flipped immediately and reconciled on server response), is deleted; grant issuance and extension are declared forms now (`operator.enrollment.grant.issue`, `.extend`) and wait for the server like every other mutation (design D15). A future optimistic update should not be added without explicit precedent and a writeup. --- ## 8. Mutation status codes | Outcome | Status | Body | |---------|--------|------| | Success, stays | 200 | the region's re-render (plus `HX-Trigger: showSuccessToast` header) | | Success, navigates | 200 (htmx) or 303 (native) | `redirectToRecord` (`internal/server/redirect.go`): `HX-Redirect` + empty body for an htmx submit, `Location` for a native one, both to `?flash=` on the landing page, rendered as a success toast there (design D9) | | Validation failure | 422 | the same declared form re-rendered in submission mode, every field's error under its control | | CSRF expired | 403 | `error-handler.js` handles the toast | | Not found | 404 | error-handler funnel | | Conflict | 409 | error-handler funnel | | Server error | 500 | error-handler funnel | **The 422 distinction matters:** 422 swaps natively under htmx 4 (the operator sees the form re-rendered with field errors) while 403/5xx sit in the declared `noSwap` list (the original input must be preserved). 422 is the validation-failure signal; nothing in JavaScript steers the swap. --- ## 9. Anti-patterns | Don't | Do instead | |-------|------------| | `` | `hx-post` (CSRF Origin-check is brittle on native POST) | | `
` in a mutation response (retired, design D9) | `HX-Trigger: {"showSuccessToast": "..."}` header for a mutation that stays, or `redirectToRecord`'s `HX-Redirect`/`303` pair for one that navigates | | `HX-Redirect` on a refusal (retired, design D9; integration settings did this before this change) | `422` with the same declared form re-rendered in submission mode | | Return 200 + `.alert-danger` for a failure | Return proper status code (422 / 4xx / 5xx); let `error-handler.js` funnel it | | `hx-confirm="Are you sure?"` | The confirm-action-modal contract | | `` | `` (bare `bg-*` fails WCAG AA contrast against Bootstrap defaults) | | Inline `