Files
member-console/docs/operator-ux-conventions.md
T
cgalo5758 16a15560c8 Add reconciled tier removal flow
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.
2026-07-12 21:09:43 -05:00

252 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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) §23
- 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 `<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`](../status/operator-ux.md) "Composite expansion landed decisions").
**The triad** every form sets:
```html
<form hx-post="/partials/operator/<capability>/<action>"
hx-target="#<swap-target-id>"
hx-swap="innerHTML">
```
- `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": "<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`](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:
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` `<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`):
```html
<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]string` keyed 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:**
```html
<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 on `pool_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 with `delivery_state ∈ {live, superseded, inactive}`. Render the column. Gate destructive actions on `live`. 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-info` without `text-`, native `method="POST"`, etc. M7f.1 delivered `member-console lint` covering 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`](design-system.md) — primitive definitions (modal, toast, badges, error handler)
- [`operator-ia.md`](operator-ia.md) — IA contract that forms/actions must not break
- [`operator-ux-research.md`](operator-ux-research.md) — original pain-point inventory that motivated these conventions
- [`openspec/specs/operator-panel-navigation/spec.md`](../openspec/specs/operator-panel-navigation/spec.md) — the spec these conventions implement