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.
252 lines
17 KiB
Markdown
252 lines
17 KiB
Markdown
---
|
||
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 `<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
|