Preview affected orgs by position source and require keep or migrate for default-sourced positions. Commit deletion, renumbering, and holder reconciliation atomically while preserving other-source delivery.
17 KiB
title, audience, summary
| title | audience | summary | |
|---|---|---|---|
| Operator UX Conventions |
|
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; 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§2–3 - IA / routes / breadcrumbs — see
operator-ia.mdand theoperator-panel-navigationspec - 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 <form method="POST">. 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 "Composite expansion landed decisions").
The triad every form sets:
<form hx-post="/partials/operator/<capability>/<action>"
hx-target="#<swap-target-id>"
hx-swap="innerHTML">
hx-post/hx-put/hx-delete— never use baremethod="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—innerHTMLfor content-area swaps;outerHTMLonly 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": "<message>"} response header |
A mutation handler returns success |
<div class="alert alert-info"> 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 §3 "Success feedback"). The toast driver is internal/embeds/static/success-toast.js.
Standard message framing:
| Action | Toast message |
|---|---|
| Created | <Resource> created |
| Updated | Changes saved |
| Deleted | <Resource> deleted |
| Revoked | Grant revoked |
| Backfilled | Backfill complete (<N> orgs processed) |
Keep it short. Operators read toasts in <1s of glance time — every extra word is friction.
Anti-pattern: rendering <div class="alert alert-success"> 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:
- Constraint violation → translate with
web.FieldErrorsFromDB(err, web.ConstraintMessages{…})(internal/web/dberrors.go) and route the result through the page's 422 +FieldErrorspath (§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. - Everything else (
ok == false) →slog.Errorthe 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 <details> 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):
<button type="button"
data-bs-toggle="modal"
data-bs-target="#confirmActionModal"
data-action-url="/partials/operator/<resource>/<id>"
data-action-method="delete"
data-action-target="#<swap-target-id>"
data-action-title="Delete plan ladder"
data-action-body="Delete '<resource-name>'? This cannot be undone."
data-action-confirm-label="Delete ladder"
data-action-style="danger">
Delete
</button>
| 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]stringkeyed by form input name- A form-field template partial that renders
is-invalid+<div class="invalid-feedback">from the map - Handler pattern: validate → on fail, return 422 with the form partial + populated
FieldErrors
Field-level error rendering:
<div class="mb-2">
<label class="form-label" for="reason">Reason</label>
<input type="text" id="reason" name="reason"
class="form-control {{ if index .FieldErrors `reason` }}is-invalid{{ end }}"
value="{{ .Form.Reason }}"
aria-invalid="{{ if index .FieldErrors `reason` }}true{{ else }}false{{ end }}"
required>
{{ with index .FieldErrors `reason` }}
<div class="invalid-feedback">{{ . }}</div>
{{ end }}
</div>
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 |
|---|---|
<form method="POST" action="..."> |
hx-post (CSRF Origin-check is brittle on native POST) |
<div class="alert alert-success"> 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 |
<span class="badge bg-info"> |
<span class="badge text-bg-info"> (bare bg-* fails WCAG AA contrast against Bootstrap defaults) |
Inline <script> in templates |
Put JS in internal/embeds/static/ and reference via <script defer src> (CSP script-src 'self' blocks inline) |
http.Redirect(w, r, ...) from an HTMX handler |
w.Header().Set("HX-Redirect", "/path") |
Validation in the client only (HTML5 required alone) |
Server-side FieldErrors + the field partial; keep required as belt-and-suspenders |
for _, g := range grants { if g.Status != "active" { continue } … } to build an "active grants" list |
Use a query that joins to active provisions (ListDeliveringGrantsByOrgID / ListGrantsWithDeliveryByOrgID) — see §9a |
hx-get="/operator/foo/{{ .ID }}" — interpolated URL string in any hx-* or data-action-url attribute |
hx-get="{{ routeURL "/operator/foo/{id}" .ID }}" — member-console lint cross-checks the pattern against mux.HandleFunc registrations. Helper is web.RouteURL in internal/web/route_url.go; register on each template FuncMap as "routeURL": web.RouteURL. |
"Failed to save: " + err.Error() in a render/toast/http.Error call |
web.FieldErrorsFromDB → 422 + FieldErrors for constraint violations; slog.Error + generic message otherwise (§4; enforced by the raw-error-render lint rule) |
9a. Grant lifecycle ≠ delivery state
core.grants.status is a lifecycle field with three values — active, expired, revoked — and it only changes through explicit revocation or time-based expiry. status='active' means "this commitment has never been formally retracted"; it does not mean "this grant is currently delivering entitlements to a pool."
Operational delivery lives one layer down, on pool_provisions.status and pool_provision_ladders.status — and the schema's GiST exclusion constraint guarantees at most one active provision per (pool, ladder). A grant whose provision was ended by a later conferral (supersession, extend-as-replace, or end_conferral from another flow) stays grants.status='active' (audit ledger), but it stops delivering.
These two facts get confused in code that filters grants by status='active' and treats the result as "what this org is getting right now." The result is a UI lie: 5+ rows shown as "active" for a product that the data model guarantees is delivered by exactly one of them.
Rule. Any operator or member surface that answers "what is this org/pool getting right now?" MUST use a query that joins to pool_provisions (and pool_provision_ladders when ladder-scoped):
- Members (Sources panel, Plans page, anything user-facing): use
ListDeliveringGrantsByOrgID— INNER JOIN onpool_provisions.status='active'. Returns only live grants. Audit history is noise here. - Operators (per-org composite, audit-shaped views): use
ListGrantsWithDeliveryByOrgID— LEFT JOIN, returns every grant tagged withdelivery_state ∈ {live, superseded, inactive}. Render the column. Gate destructive actions onlive. History is the point.
Don't filter on grants.status for the "currently active" question. The status field answers a different question. If you're tempted to write a third such query, name it after what it actually returns (ListGrantsCurrentlyDeliveringTo…, not ListActiveGrants…) so the trap doesn't reappear.
10. Future
- Cascade-preview pattern — for catalog edits at criticality ≥4 (product edit, entitlement-set edit, tier reorder), surface "this change will affect: N products, M orgs, K members" before confirmation. Deferred to M11 (audit log & observability; renumbered from M10 on 2026-07-03) because the projected-change set is audit-log adjacent.
- Convention-drift guard — automated lint that flags
bg-infowithouttext-, nativemethod="POST", etc. M7f.1 deliveredmember-console lintcovering dead routes, dead swap targets, stale?tab=, and interpolated URL literals; additional §9 rules can be added there as static-detectable patterns emerge. - Real-time validation — on-blur server validation pings vs. on-submit. Not pursued unless operator feedback demands it.
See also
design-system.md— primitive definitions (modal, toast, badges, error handler)operator-ia.md— IA contract that forms/actions must not breakoperator-ux-research.md— original pain-point inventory that motivated these conventionsopenspec/specs/operator-panel-navigation/spec.md— the spec these conventions implement