--- 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. 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 Origin-check failure mode for at least one user submission; HTMX routes through the `X-CSRF-Token` header on body's `hx-headers` which is the same path every other form uses successfully (see [`status/operator-ux.md`](../status/operator-ux.md) "Composite expansion landed decisions"). **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:** handler sets `HX-Redirect: /path` header (HTMX intercepts and does a full-page navigation). Do *not* call `http.Redirect` — HTMX will swap the redirect response body into the target. --- ## 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. **Anti-pattern:** rendering `
` inside the response body of a mutation. That pattern pre-dates `showSuccessToast` and got mixed into the codebase before the toast primitive was wired. Migrate to toast on next touch. --- ## 4. Error feedback `internal/embeds/static/error-handler.js` is the **single funnel** for HTTP error feedback. It listens for `htmx:beforeSwap`, intercepts: - 403 (CSRF expired) → red toast + preserve user input (no swap) - 4xx with body → allow swap (server returns rendered error context) + backup toast - 5xx → red toast + preserve user input (no swap) - network failure → red toast + preserve user input **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 + form partial with `FieldErrors` populated (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). --- ## 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`). **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 | | `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. --- ## 6. Server-side validation **The rule:** every form that mutates revalidates server-side and renders field-level errors. HTML5 `required` / `type=` stays as belt-and-suspenders (good UX for the happy path) but isn't trusted as the validation contract. **Infrastructure** (to be built in M7e Slice C): - `internal/web/formerrors.go` — `FieldErrors map[string]string` keyed by form input name - A form-field template partial that renders `is-invalid` + `
` from the map - Handler pattern: validate → on fail, return 422 with the form partial + populated `FieldErrors` **Field-level error rendering:** ```html
{{ with index .FieldErrors `reason` }}
{{ . }}
{{ end }}
``` Keep messages concrete and actionable: "Reason is required" 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. **Single exception:** `internal/embeds/static/grant-toggle.js` — a toggle switch for grant active/inactive that flips immediately and reconciles on server response. Justified because the toggle is high-frequency and the misfire cost is low (server reconciles). New optimistic updates should not be added without explicit precedent + writeup. --- ## 8. Mutation status codes | Outcome | Status | Body | |---------|--------|------| | Success | 200 | partial HTML for the swap target (plus `HX-Trigger: showSuccessToast` header) | | Success + redirect | 200 (HTMX) | empty body + `HX-Redirect: /path` header | | Validation failure | 422 | form partial with `FieldErrors` populated | | 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:** `error-handler.js` lets 4xx-with-body swap (so the operator sees the form re-rendered with field errors) while 403/5xx do not (the original input must be preserved). 422 is the validation-failure signal that opts into the swap path. --- ## 9. Anti-patterns | Don't | Do instead | |-------|------------| | `` | `hx-post` (CSRF Origin-check is brittle on native POST) | | `
` in a mutation response | `HX-Trigger: {"showSuccessToast": "..."}` header | | 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 `