# Changelog

## Unreleased

### Admin · GroupAuthConfigSection — edit `registration.extra_fields`

- The Group **Auth Config → Registration** tab now has an **Extra fields** tag input that edits `registration.extra_fields` — per-group non-canonical signup fields (e.g. `promo`, `ref`, `tracking`). It serializes to `[{name}]` (django-mojo humanizes the label and defaults `required: false`), loads the group's own override else the inherited value, and is included in the Save diff only when changed (clearing an inherited list sends `[]`). Names are sanitized client-side to the server's identifier rule and canonical-field collisions are dropped, so the saved config always passes server validation. Tests in `test/unit/GroupAuthConfigSection.test.js`.
- `GroupAuthConfigSection` now uses a trailing `export default` (matching `DetailView`/`JobDetailsView`) so the test harness's module loader can load the class for behavioral tests.

### Forms · Fixed double-escaped form field text (labels, values, placeholders, …)

- **`FormBuilder` HTML-escaped field text twice.** Every render method pre-escaped `label` / `placeholder` / `help` / `tooltip` / `error` (and the field `value` / `fieldValue`) with `escapeHtml()` and *then* rendered them through Mustache `{{…}}` (which escapes again), so e.g. a label `Verification & Compliance` rendered as the literal `Verification &amp; Compliance`, and a value `Tom & Jerry` would submit as `Tom &amp; Jerry`. Most visible on app-registered permission toggles (labels often contain `&`), but it affected text/email/number inputs, select, textarea, json, color, range, file, image, radio, checkbox, and switch fields.
- **Fix:** the raw text is now handed to Mustache, which escapes it exactly once (and is attribute-safe — it escapes quotes). Pre-escaping is kept only where it's still needed: manually-built HTML strings (`attrs`, `optionsHTML`, the `header`/legend) injected via `{{{…}}}`. Regression tests in `test/unit/FormBuilder.escaping.test.js` cover switch, checkbox, input (label/placeholder/value), select, and tooltip text.

### Build · Clean up admin build warnings

- **Fixed a malformed comment in `admin.css`** (`.runner-detailsPrint Styles */` — a stray `*/` with no opening `/*`) that produced an esbuild `Unexpected "/"` CSS-minify warning and could drop the following runner/task-details rules.
- **Removed IncidentView's dynamic imports of TicketView / UserView / User.** They're all eagerly bundled, so the dynamic imports gained no code-splitting and tripped Vite "dynamically imported … but also statically imported" warnings; the `TicketView` one was also a module cycle (`TicketView` statically imports `IncidentView`). IncidentView now opens those views through the registered `Ticket.VIEW_CLASS` / `User.VIEW_CLASS` (the same pattern UserView/MemberView use), removing the cross-imports and the cycle. No behavior change.

### Admin · GroupView / MemberView polish

- **GroupView: no more "Delete Group".** Groups are deactivated, never deleted — the destructive context-menu item and its handler were removed.
- **GroupView: Auth Config moved to a "Configure Auth" modal.** The 3-tab auth-config editor was too heavy for the side-nav; it now opens from a `Configure Auth` context-menu action in a `ModalView` (the section keeps its own Save). The "Auth Config" side-nav section is gone.
- **GroupView: Members section reworked.** Now a compact `ListView` (card rows, same pattern as API Keys / Webhooks) instead of a wide table: a **search** box, a **Status** filter (defaults to Active, hides disabled members), and a per-row **Active/Disabled** badge. The row label falls back display_name → email → username (users often have no display name).
- **GroupView: "Add Existing User" is now a simple form.** Replaced the raw-ID `SimpleSearchView` list with a `Modal.form` containing a `collection` field bound to `UserList` (`labelField: 'email'`) — the same type-ahead picker as the Group "Parent" field.
- **MemberView: trimmed the header.** Removed the odd "Edit role" and "Remove" header buttons; deactivation goes through the existing active toggle. "Remove from group" remains in the kebab as the deliberate, low-prominence path.
- **Auth Config: split the crowded Theme tab.** Tabs are now `Base / Login / Registration / Advanced`. **Base** leads with **Layout** then branding/links through Terms URL; **Advanced** holds the rarely-touched plumbing (API base, Success redirect, Custom CSS URL, Custom CSS). Presentation-only — field names/paths and save logic are unchanged.

### Admin · UserView splits permissions into "Sys Perms" / "App Perms" and surfaces app-registered ones

- **Fixed: `UserView` → Permissions dropped app-level permissions.** The section re-derived its own tabset from the framework *source* arrays, so permissions registered via `User.registerPermissions({ categories, granularPermissions })` (the `APP_*` arrays) never appeared in the user detail view — even though they rendered in the user table's "Edit Permissions" modal. The two editors disagreed about which permissions existed.
- **Two clean side-nav sections instead of one cluttered one.** `UserView` now shows **Sys Perms** (framework permissions — System category + granular domains, one row of tabs) and **App Perms** (app-registered permissions — Categories + Permissions; shown only when an app registered any). Each is one autosaving `FormView` over a single tabset, consuming the new section-aligned live caches `User.SYSTEM_PERMISSION_FIELDS` / `User.APP_PERMISSION_FIELDS` (maintained by `rebuildPermissions()`), so app permissions appear automatically and the detail view can't drift from the table modal.
- **New `User.SYSTEM_PERMISSION_FIELDS` / `User.APP_PERMISSION_FIELDS` caches.** Additive — the existing `CATEGORY_PERMISSION_FIELDS` / `GRANULAR_PERMISSION_FIELDS` (with their "App" tabs) and the flat `PERMISSION_FIELDS` are unchanged. `registerPermissions({ categories, granularPermissions })` flow into App Perms; `granularTabs` still add a domain tab to the framework granular set (Sys Perms).
- **`MemberView` permissions are now a clean tabset, group-perms only.** The section dropped its read-only "System permissions" panel (system/User-record perms are edited from the user's detail view) and now renders a single tabset (one row): **Standard** (framework group perms) + an **App** tab and/or one tab per registered app domain. `Member.registerPermissions({ permissions, tabs })` gained a `tabs: [{ label, permissions }]` form for **multiple** app permission tabs; new `Member.APP_PERMISSION_TABS` source and `Member.PERMISSION_TABSET` cache back it. `Member.registerPermissions` still also accepts a flat `permissions` array (→ a single "App" tab).

### Admin · Group `kind` is now editable + the three Group forms share one field source

- **Fixed: editing a Group's kind was limited to a static select.** `kind` rendered as a plain `<select>` in every Group form (create / edit / detailed) and in the inline "Edit Kind" pencil, so an admin could only pick from the hardcoded option list and never type a value the backend supports. `kind` is now a `combo` (editable autocomplete) everywhere — it still suggests the canonical `Group.GroupKinds`, but accepts any typed value. The save path is unchanged (writes the same string).
- **One source of truth for Group form fields.** `GroupForms.create` / `edit` / `detailed` (`src/core/models/Group.js`) previously redeclared their fields independently and had drifted — the same `kind` bug had to be patched in three places, the `detailed` timezone select offered 5 zones while the inline pencil offered 14, and `short_name` / `auth_domain` existed *only* as inline editors, in no form at all. All three forms now compose from a single `groupFields()` factory (fresh field objects per form, shared option arrays), so a field's type/label/options are defined once and can't drift.
- **One universal editor, used everywhere.** The editor is a 5-tab `tabset` (Profile / Identity / Localization / Branding / Avatar) covering every editable field, including the previously orphaned `short_name` and `auth_domain`; the avatar uploader sits in its own trailing tab since it's secondary. Both the detail-page **Edit Group** button *and* the groups-table row **Edit** now open this same form — `Group.EDIT_FORM` is an alias of the detailed config (retitled "Edit Group"), so the table edit no longer shows the old flat 7-field form. `Group.FORM_DIALOG_CONFIG = { size: 'lg' }` gives the row-edit modal room for the tabs.
- **Inline "Edit Timezone" pencil** now uses the shared `TimezoneOptions` list (exported from `Group.js`) instead of its own divergent copy. New named exports: `GroupKindOptions`, `TimezoneOptions`, `EodHourOptions`.

### Admin · Storage Backend detail view + Fix CORS fix

- **Fixed: "Fix CORS" did nothing.** The Storage Backends context menu has always carried a `fix-cors` item, but `onActionFixCors` was never implemented — clicking it was a no-op. It now issues `model.save({ fix_cors: 1 })` (mirroring `check_cors` / `test_connection`) and shows the result report.
- **New `FileManagerView`** — `system/filemanagers` rows now open a proper `DetailView` (flat header with icon, chips, active toggle, and context menu, followed by a `SideNavView` rail). The page previously had a row-level context menu and no detail view — inconsistent with every other admin TablePage. New file `src/extensions/admin/storage/FileManagerView.js`; `FileManagerTablePage` is now a thin list (`clickAction: 'view'`).
- **Two rail sections**: **Overview** (a `DataView` of the backend's configuration) and **Files** (a context-scoped `TableView` — see below). All per-record actions live on the header context menu: Edit, Edit Credentials, Edit Owners, Clone Backend, Test Connection, Check CORS, Fix CORS, Delete. The header's `activeField` toggle replaces the old activate/deactivate flow. Credentials remain write-only (never displayed).
- **Files-in-this-backend list.** The Files section is a `TableView` of the files stored in that backend — filtered by the `file_manager` FK with `hideActivePillNames: ['file_manager']` so the scoping can't be cleared. The rail shows a live file-count badge. Previously there was no way to browse files by storage backend. Rows open `FileView`.

### Admin · Group Auth Config editor (`GroupView` → Auth Config)

`GroupView` gains an **Auth Config** side-nav section (under the **Detail** divider, before **Metadata**, gated by the `sys.groups` / `sys.manage_groups` permission) for editing a group's `metadata.auth_config` — the structured config that drives the django-mojo–hosted login, registration, and passkey pages. Previously this was only editable as raw JSON through the generic Metadata section.

- **`GroupAuthConfigSection`** (`src/extensions/admin/account/groups/GroupAuthConfigSection.js`) is a `View` that embeds one `FormView` with a 3-tab `tabset`:
  - **Theme** — text inputs for `app_title`, `logo_url`, `favicon_url`, `hero_image_url`, `hero_headline`, `hero_subheadline`, `back_to_website_url`, `terms_url`, `api_base`, `success_redirect`, `custom_css_url`; a `layout` select; a `custom_css` textarea.
  - **Login** — a multiselect for `login.methods` (password / sms / passkey / magic / google / apple).
  - **Registration** — an `enabled` toggle, `passkey_prompt` / `identity_field` selects, a `min_age` number, a `methods` multiselect, and a fixed 6-row grid for `registration.fields` (include / required / verify per canonical field; `password` may be omitted for passwordless registration but is always required when included).
- **Inherit-aware.** Each field shows the group's own override if set, otherwise the resolved/inherited value fetched once from `GET /api/auth/config?group_uuid=<uuid>`; text fields show the resolved value as placeholder.
- **Save.** A Save button writes only the fields changed from the loaded baseline as a nested `{ metadata: { auth_config: {…} } }` via `model.save()`, so django deep-merges the `metadata` JSONField (sibling keys like timezone, domain, portal survive) and untouched fields keep inheriting. Save is explicit — not autosave — because the server applies cross-field validation (`registration.fields` must include email or phone; `login.methods` must be non-empty); those rules are also checked client-side so the admin gets an inline message instead of a raw 400. An inline status line reports save progress.

### Forms · Component fields now initialize inside a `tabset`

`FormView.getFormFieldConfig()` — used by the component initializers for `multiselect`, `combobox`, `collection`, `collectionmultiselect`, and the date/time pickers — recursed into `group` fields but not into `tabset` tabs. A component field placed inside a tab was never upgraded from FormBuilder's plain-HTML fallback (e.g. a `multiselect` stayed a native `<select multiple>`). `getFormFieldConfig()` now also searches `tabset` tabs, matching `findFieldConfig()` and `FormBuilder`'s own field lookup.

### Core · Detail Views can size their own row-view modal via `ViewClass.DIALOG_OPTIONS`

Dense detail Views (`UserView`, `GroupView`, …) opened as a row-view dialog were stuck at `Modal.dialog()`'s default `size: 'lg'`. The only way to widen them was to repeat `viewDialogOptions: { size: 'xl' }` in every TablePage / TableView / ListView that opened the View — easy to forget, and the View itself (which actually knows how much room it needs) had no say.

- **New static `DIALOG_OPTIONS` on the View class.** `ListView._onRowView()` and `TablePage.showItemDialog()` now spread `ViewClass.DIALOG_OPTIONS` onto `Modal.dialog()` whenever they open a `VIEW_CLASS` / `itemView`. A detail View declares its preferred modal presentation once — `UserView.DIALOG_OPTIONS = { size: 'fullscreen' }` — and every surface that opens it picks it up automatically. `DIALOG_OPTIONS` accepts any `Modal.dialog()` option (`size`, `centered`, `scrollable`, `noBodyPadding`, …); supported sizes are `'sm'`, `'lg'`, `'xl'`, `'xxl'`, `'fullscreen'`.
- **Precedence (later wins):** built-in defaults (`size: 'lg'`) → `Model.FORM_DIALOG_CONFIG` → `ViewClass.DIALOG_OPTIONS` → page/instance `viewDialogOptions`. The page-level escape hatch still wins, so a page can override the View's preferred size when it genuinely needs to. Existing pages are unaffected — a View with no `DIALOG_OPTIONS` keeps the previous behavior.
- **Adopted on the two heaviest admin inspectors.** `UserView.DIALOG_OPTIONS = { size: 'fullscreen' }` (side-nav + ~10 sections of embedded tables) and `GroupView.DIALOG_OPTIONS = { size: 'xl' }`, registered next to their `VIEW_CLASS` assignments. The `viewDialogOptions` in `UserTablePage` / `GroupTablePage` / `MemberTablePage` set no `size`, so the new sizes apply cleanly without touching those pages.
- **Docs.** New "Sizing the dialog from the View class" subsections in `docs/web-mojo/components/TableView.md` and `docs/web-mojo/components/ListView.md`, plus a `ViewClass.DIALOG_OPTIONS` row in the TablePage "Where to register the static" table.

### Admin · GroupTablePage exposes a UUID column + filter

- **New `uuid` column** in `GroupTablePage` (monospace, muted, `xl`-only visibility so it doesn't crowd the table on smaller screens — matches the `created` / `last_activity` cluster). Falls back to `—` for groups without a UUID.
- **New "Filter by UUID" text filter** wired inline on the column, so it appears in the toolbar's *Add Filter* dropdown and sends `uuid=<value>` to `/api/group`.

### Admin · Webhook Subscriptions management UI (per-group section + standalone TablePage)

Operators previously had no UI for the `account.WebhookSubscription` rows that landed on django-mojo last cycle — provisioning a webhook receiver meant hand-curling `POST /api/group/webhook_subscriptions`, and there was nowhere to fetch the per-group signing secret a consumer needs to verify deliveries. This adds the missing surfaces, mirroring the existing ApiKey two-surface shape. Per `planning/done/admin-webhook-subscriptions.md`.

- **New `WebhookSubscription` model** in `src/core/models/WebhookSubscription.js` — endpoint `/api/group/webhook_subscriptions`, with `WebhookSubscriptionList`, `WebhookSubscriptionForms.{create,edit}`, and a `normalizePayload(formData)` helper that converts the form's comma-separated `events` string into the JSON array the server expects. Both admin surfaces share the helper so the string→array conversion lives in exactly one place. Prototype `toggleActive()` flips `is_active` and PUTs the change.
- **Per-group Webhooks section inside `GroupView`** under the **Access** divider directly after **API Keys** (icon `bi-broadcast`, `manage_group` permission gate). The section is a `WebhookSection` composite that stacks a small `WebhookSecretPanel` above a `ListView` of `WebhookSubscriptionListItem` cards. Each subscription row shows the URL (mono, truncated), event chips, status badge, inline active toggle, and a trash button — same card pattern as the recent ApiKey reshape (no table chrome for a typical 1–10 rows per group). Create / inline-delete / inline-toggle / row-click-to-edit all wired through GroupView so we own the confirm copy, toast feedback, and refetch.
- **Webhook signing secret panel**. Two actions: **Reveal** posts to `POST /api/group/webhook_secret` with an empty body; **Rotate** posts `{rotate: true}` after a destructive `Modal.confirm`. Both open a dismissal-protected reveal dialog (`backdrop: 'static'`, `keyboard: false`) with a monospaced `user-select-all` block themed via Bootstrap surface tokens and an inline `data-action="copy-to-clipboard"` button (handled by the inherited `View.onActionCopyToClipboard` — no custom JS). The panel does NOT auto-fetch on mount: the backend auto-mints on first POST, so a render-time call would silently create a secret the operator never asked for. Metadata (`created_at`, `last_rotated_at`) populates the panel only after a successful reveal or rotate.
- **Standalone `WebhookSubscriptionTablePage`** at route `system/webhook-subscriptions`, registered under **System → Webhook Subscriptions** in the admin sidebar (`manage_groups` / `manage_group`). Columns: id · url (truncated mono) · events (badge formatter, `visibility: 'lg'`) · group · status · created. The Add flow calls `WebhookSubscriptionForms.normalizePayload` before `model.save(payload)` so the events transform behaves identically across both surfaces.
- **`WebhookSubscriptionView`** detail modal — header with `bi-broadcast` icon, full URL, status, and a ContextMenu with Edit / Activate-Deactivate / Delete. `WebhookSubscription.VIEW_CLASS = WebhookSubscriptionView` registered at file load so `clickAction: 'view'` on both the per-group ListView and the standalone TablePage resolve the modal class automatically.
- **Reuses `TagInput` (`type: 'tags'`)** for the events chip input — no new framework primitives. Helper text on the field calls out that names are free-form strings published by the emitting service, with examples (`invoice.paid`, `verification.completed`).
- **Tests.** `test/unit/WebhookSubscription.test.js` source-text-pins the endpoint, form field shapes, and the `toggleActive` body — then extracts `normalizePayload` from the live source via brace-walking and runs it against six cases (comma split, trim/empty drop, array passthrough, missing field, no-mutate, null input). `test/unit/WebhookSubscriptionTablePage.test.js` pins the page identity, column order, the normalize-before-save call in `onActionAdd`, the admin nav registration, and the GroupView Webhooks section ordering + the no-auto-fetch-secret invariant + the static-backdrop reveal dialog + the destructive rotate confirm. GroupView Modal-pipeline integration tests aren't feasible in the current harness (called out in the planning doc).
- **Docs.** New `## WebhookSubscription & WebhookSubscriptionList` section in `docs/web-mojo/models/BuiltinModels.md` (after the ApiKey section), and a `## Webhook Subscriptions` section in `docs/web-mojo/extensions/Admin.md` describing both surfaces, the no-auto-mint secret panel behaviour, and the events normalization contract. `WebhookSubscriptionTablePage` / `WebhookSubscriptionView` added to the `Importing Individual Pages & Views` example imports.

### Forms · `columns: { xs, sm, md, lg, xl, xxl }` now actually renders responsive Bootstrap classes

- **Field-level responsive `columns` is wired up end-to-end.** `FormBuilder.buildFieldHTML` now recognises the object form documented in `forms/FieldTypes.md`, `forms/README.md`, and `pages/FormPage.md` — `columns: { xs: 12, md: 4 }` emits `class="col-12 col-md-4"` (and similarly for `sm`/`lg`/`xl`/`xxl`). Previously the object stringified to `col-[object Object]`, which Bootstrap silently ignored, so every responsive field collapsed to full-width and stacked. The integer form (`columns: 6` → `col-6`) is unchanged; an empty object falls back to `col-12`; `field.class` extras are still appended.
- **No new public API.** The object syntax was already documented and partially implemented (groups via `buildGroupHTML` already accepted it). This change brings field-level rendering in line with the docs.
- **New unit test:** `test/unit/FormBuilder.columns.test.js` pins both the legacy integer behaviour and the responsive object output (including the empty-object fallback and `field.class` interaction).

### Admin · Phone Config page (Twilio / AWS / Mojo) + downstream API-key provisioning

- **New `system/phonehub/config` page** registered under System → Phone Hub → **Config** (icon `bi-sliders`), gated on `manage_phone_config` / `manage_groups`. Per-row Provider / Active / Test Mode badges and column filters; default sort `-modified`; default filter `is_active=true`. Per `planning/done/admin-phone-config-mojo-provider.md`.
- **Read-only `PhoneConfigView` detail + three-dots context menu** (mirrors `ApiKeyView`): header with provider / active / test badges, then Configuration / provider-settings / Metadata sections. All mutations live on the context menu — Edit, Test connection, Provision API key, Delete — rather than a button bar.
- **Single combined create/edit form driven by `showWhen`.** Both the table Add flow and the context-menu Edit open one form whose `provider` select reveals only the matching credential block: **Twilio** (`twilio_from_number`, `twilio_account_sid`, `twilio_auth_token`), **AWS SNS** (`aws_region`, `aws_sender_id`, `aws_access_key_id`, `aws_secret_access_key`), or **Mojo Remote** (`mojo_remote_url`, `mojo_api_key`). Hidden showWhen fields are auto-stripped from `getFormData()`, so switching providers and saving never bleeds the prior provider's typed-but-unsaved secret to the server. `PhoneConfig.FORM_DIALOG_CONFIG` opens the dialog at `lg` width.
- **Secrets are write-only.** Password inputs render with a `••••••••` placeholder; blank-on-save is stripped client-side via `PhoneConfig.SECRET_FIELDS` so an empty input never overwrites a stored credential. Backend response graphs already exclude the encrypted blob, so GET → form-prefill never round-trips a value.
- **Test connection** posts `{test_connection: 1}` through `Model.save` (same endpoint) and surfaces the result inline in the detail view — green banner with `message` on success, red banner with the friendly-mapped error on failure.
- **Provision downstream API key flow** (Mojo provider rows only, gated on `manage_groups` / `manage_group`). Tailored one-purpose dialog with fixed permissions `{send_sms: true, comms: true}`; raw token revealed once in a dismissal-protected alert with copy-to-clipboard, then never shown again. Reuses the existing `ApiKey` model and its `resp.data.data.token` one-time pattern.
- **Cross-link from SMS table.** Provider chip on `system/phonehub/sms` is now value-mapped (twilio=info, aws=warning, mojo=primary) and Mojo rows render as a clickable anchor that navigates to `system/phonehub/config?provider=mojo&group=<sms.group.id>`. New `error_code` column with friendly labels for `timeout` / `http_<status>` / `remote_error` / `remote_failed` / `config_error` / credential codes.
- **No new framework primitives.** Pure admin-extension wiring around the existing `Model`, `Collection`, `FormView` (showWhen), `TablePage`, `ContextMenu`, and `Modal.show/alert/form` surfaces. New files: `src/extensions/admin/messaging/sms/PhoneConfigTablePage.js`, `src/extensions/admin/messaging/sms/PhoneConfigView.js`. `PhoneConfig` model + `PhoneConfigForms` live alongside the existing PhoneNumber / SMS wrappers in `src/extensions/admin/models/Phonehub.js`.

### Admin · GroupView Identity section exposes UUID (view + copy + edit + generate)

- **UUID is now a first-class identifier in `GroupView`.** The Identity section gains a dedicated **UUID** flat-row directly under the existing **ID** row. Both rows now carry a copy-to-clipboard button (built from the framework's `clipboard` pipe formatter — same `data-clipboard` carrier + `View.onActionCopyToClipboard` handler that powers every other clipboard control). Per `planning/done/groupview-uuid-not-exposed.md`.
- **Per-row edit + Generate flow.** A pencil button opens `Modal.prompt` to free-text edit the UUID (mirrors `onActionEditName` / `onActionEditDomain`). When the UUID is empty, a Generate button (`bi-shuffle`) appears that produces a fresh `crypto.randomUUID().replace(/-/g, '')` value — matching the backend's `uuid.uuid4().hex` shape (32 lowercase hex chars, no hyphens) — confirms via `Modal.confirm`, then saves. Falls back to `crypto.getRandomValues(new Uint8Array(16))` + hex-stringify when `crypto.randomUUID` is unavailable; toasts an error if neither primitive exists.
- **Edit Group modal also gains UUID.** `GroupForms.edit` and `GroupForms.detailed` both now include a `uuid` text field (between `name` and `kind`), so the context-menu **Edit Group** dialog can change UUID alongside the rest of the profile. `GroupForms.create` is intentionally unchanged — the backend lazy-inits via `get_uuid()` on first read.
- **No new copy/clipboard code.** Uses the built-in `dataFormatter.clipboard()` pipe (icon-only mode) and the inherited `View.onActionCopyToClipboard` handler. Theme-safe by construction (Bootstrap tokens only).

### Admin · GroupView API Keys section — modern ListView card layout + Create flow fix

- **Section reshaped from TableView to ListView** with a custom `ApiKeyListItem` (extends `ListViewItem`) — for a typical group's 1–10 keys the table chrome (column headers, sort dropdowns, pagination footer) was heavier than the data warranted. Each row is now a card-style layout: key icon + name + status badge + inline permission chips + "Last used X · Created Y" muted line + prominent inline trash button.
- **Inline Delete** via `data-action="delete"` in the item template plus an explicit `onItemDelete` override on the section (not the framework's generic `_onRowDelete` chain) — `_deleteApiKey` owns the confirm copy ("Delete API key X? This cannot be undone — any service using it will lose access immediately."), the toast feedback, and the refetch. `event.stopPropagation()` in the inherited `ListViewItem.onActionDelete` keeps the row's click-to-view from firing alongside.
- **Permissions render as inline chips** computed from a getter on the item view — replaces the previous broken `permissions|keys|badge` column pipe that rendered the literal strings `null` / `badge`.
- **Last used** shown as a relative timestamp ("10 minutes ago", "7 days ago") with a `never` fallback — the operational signal admins actually need when auditing dormant keys.
- **Create API Key form no longer prompts for Group ID** when opened from inside `GroupView`. The current group is used automatically — typo / wrong-group risk eliminated. The form is now Name + Permissions (JSON) only.
- **One-time token is now revealed** in a dismissal-protected dialog (`backdrop: 'static'`, `keyboard: false`) so the operator cannot lose the secret by stray-clicking the backdrop or pressing Esc. The dialog includes: a success line, a warning banner ("Save this token now — it will not be shown again"), a monospace `user-select-all` token block themed via Bootstrap surface tokens (light + dark from day one), an **inline copy icon** at the right edge of the token block (matches the framework's `clipboard` DataFormatter pattern — uses `data-action="copy-to-clipboard"` + the inherited `View.onActionCopyToClipboard` handler, no custom flash code needed), a permissions preview (badges of granted keys, with a "no permissions granted" fallback for empty / invalid JSON), and a copy-as-password footnote. Previously the framework's generic add path discarded `resp.data.data.token`, leaving the newly-created key unusable.
- **Newest-first default sort** (`sort: '-created'`) so the just-created key appears at the top.
- **Informative empty state** ("No API keys yet. Click 'Create Key' to add one.").
- **Explicit `itemView: ApiKeyView`** wiring on the section, removing the prior reliance on a side-effect `VIEW_CLASS` import from `ApiKeyTablePage`. Per `planning/done/groupview-create-api-key-flow-broken.md`.
- The standalone `ApiKeyTablePage` flow is unchanged — its `Group ID` field still appears (no context to infer from there) and its existing token alert still works.

### Framework · ListView / TableView `rowStripe:` (severity-coded left-edge color)

- **New `rowStripe:` constructor option on ListView**, inherited by TableView and forwarded by TablePage. Per-row callback `(model) => 'danger' | 'warning' | 'success' | 'info' | 'primary' | 'secondary' | <custom-class> | null` paints a 4px theme-aware left-edge stripe on each row. Bootstrap variant tokens resolve via `--bs-<token>` CSS variables, so light + dark themes track automatically; any other non-empty string is treated as a consumer-defined class name and passes through verbatim. Per `planning/done/framework-listview-severity-stripe.md`.
- **Auto re-eval on model change.** The stripe re-applies on every row render — and because `View` already binds `model:change → render()` in the base class, a `model.set()` that flips the callback's input drives a stripe refresh with zero extra wiring. Throwing callbacks are logged and treated as no-stripe so a bad row can never break the render.
- **`refreshStripes()` method** for external-state callbacks (stripe depends on a parent filter / threshold / cutoff). No-op when no `rowStripe` is configured.
- **Rendering mechanics.** ListView items (`<div>`) use `border-left: 4px solid`; TableView rows (`<tr>`) use `box-shadow: inset 4px 0 0` on `td:first-child` (because `<tr>` border-left doesn't render under Bootstrap's `border-collapse: separate`, and `border-left` on the cell would shift column widths). When `selectable: true`, the checkbox `<td>` is `td:first-child` — the stripe lands on it, which is the intended leftmost-edge placement.
- **Tests.** New `test/unit/ListView.rowStripe.test.js` (15 cases — token mapping, null/undefined/throw handling, six-token parameterized table, custom-class passthrough, auto re-eval on `model.set()`, `refreshStripes()`). New `test/unit/TableView.rowStripe.test.js` (5 cases — verifies `<tr>` class application via the inheritance path). Extended `test/unit/TablePage.option-forwarding.test.js` with `rowStripe` forwarding regression.
- **Docs.** New "Row stripe (severity-coded left-edge color)" section in `docs/web-mojo/components/ListView.md` (API surface, callback contract, auto re-eval, `refreshStripes()`, custom-class example, rendering mechanics, constructor options table). Cross-link added to `docs/web-mojo/components/TableView.md`.

### Admin TablePage · phone-first column breakpoints

Follow-up to the previous TablePage UX sweep, shifted to account for the admin shell's persistent sidebar. Every admin TablePage now declares its `visibility:` breakpoints one notch tighter (`md`→`lg`, `lg`→`xl`, `xl`→`xxl`) than the desktop-first heuristic the prior sweep used — because the sidebar shaves ~250-300px off the usable table width at lower nav levels, so a "viewport ≥768px" check doesn't mean the table itself has 768px to render in. Pure column-option edits; no row-template rewrites, no framework changes. Per `planning/done/admin-tablepages-mobile-breakpoint-pass.md`.

- **22 admin TablePages shifted.** Every `visibility:` tag in `src/extensions/admin/` moved one notch up — the 13 covered by this request's matrix (`UserDevice`, `Incident`, `Event`, `Log`, `IPSet`, `EmailMailbox`, `PhoneNumber`, `BouncerSignal`, `BouncerDevice`, `PushDelivery`, `PushDevice`, `ShortLink`, `ShortLinkClick`) plus the 9 other admin tables that already had tags from the prior sweep and suffered the same sidebar-cutoff problem (`EmailDomain`, `Group`, `Member`, `User`, `UserDeviceLocation`, `SentMessage`, `SMS`, `FileManager`, `File`, `RuleSet`). Effective rule: a `lg` tag now hides whenever the table panel is narrower than ~992px — which on the admin shell is roughly "phone-or-narrow-tablet OR sidebar-open-on-laptop".
- **Tests.** New `test/unit/admin-tablepages-mobile-breakpoints.test.js` — 25 source-level regex assertions at the shifted values, one per `(file, key, breakpoint)` triple from the matrix. Mirrors the existing `admin-tablepages-bugfixes.test.js` pattern; locks the regression so a future refactor can't silently drop the tags.
- **Docs.** No changes — `docs/web-mojo/components/TableView.md` already documents the `visibility:` option. The sidebar-cutoff rationale is a per-shell judgment, not a framework invariant.

### ListView · three new grouping helpers (`groupByField`, `groupByRecency`, `groupByBoolean`)

- **`groupByField(fieldOrAccessor, { labels, fallback, format })`** — categorical bucketing on the raw value at `fieldOrAccessor`, coerced to a string for deterministic equality. `labels` map wins over the optional `format` transform; `fallback` provides a bucket key for `null` / `undefined` / `''` raw values (otherwise they drop into the ungrouped tail). Covers status / severity / category / role / environment / tier feeds. Per `planning/done/listview-grouping-helpers.md`.
- **`groupByRecency(fieldOrAccessor)`** — six fixed buckets (Today / Yesterday / This week / This month / Earlier this year / Older) relative to local "now". V1 is opinionated — no opts. Bucket keys are sort-ordered (`'recency-0-today'` …) so descending-by-date sort renders buckets in natural reading order.
- **`groupByBoolean(fieldOrAccessor, { trueLabel = 'Yes', falseLabel = 'No' })`** — binary on/off split. Includes a string-false carve-out (`'false'` / `'0'` / `'no'` / `'off'`, case-insensitive, trimmed) so JSON-string booleans from backends bucket correctly without manual coercion. `null` / `undefined` / `''` raw values drop into the ungrouped tail.
- All three share the existing `resolveAccessor` / `toDate` / `isoDayKey` private internals in `src/core/views/list/grouping.js` and return `{ groupBy, groupHeaderLabel }` for spread into the ListView constructor (same shape as `groupByDay`). Inherited by TableView via the existing grouping plumbing. 23 new unit tests in `test/unit/ListView.test.js`.

### Admin GroupView + MemberView · spec alignment (Phase 5)

- **New `chip.action` field on `DetailView` chips.** A chip with `action: 'kebab-name'` renders as a click-through `<button>` (visually identical to the static `<span class="badge">` variant); click events flow through the parent view's standard `onActionKebabName` handler. Chips without `action` keep the static-span markup — no behaviour change for existing consumers. Per `planning/done/admin-users-spec-alignment.md`.
- **UserView header gets a click-through org chip.** When a user has an `org`, the header shows a `bi-buildings` badge bound to `onActionViewOrg` — opens the org's GroupView via `Modal.detail`. Mirror of `MemberView.onActionViewGroup`.
- **GroupView Identity section surfaces `auth_domain` and `short_name`** alongside the existing `domain` / `timezone` / `eod_hour` / `portal` / `email_template` rows. Two new pencil handlers (`onActionEditAuthDomain`, `onActionEditShortName`) following the existing `_saveField({metadata: {...}})` pattern. Per spec line 282.
- **GroupView uses kind-aware copy** in modal titles, kebab labels, and confirmations. New `_kindNoun()` helper falls back to "Group" when `kind` is unset. Example: a `kind: 'org'` group shows "Edit Organization", "Add Sub-Organization", "Delete Organization".
- **GroupView adopts Phase 4's admin-tier gating** with two perm tiers matching the backend's tightening: `['groups', 'manage_groups']` for routine edits (`edit-group`, `invite-member`, `add-child-group`); strict `['manage_groups']` only for destructive `state-toggle` / `delete-group` (per spec line 214). Header `is_active` toggle hidden for callers without `manage_groups`. Filtering routed through `ModalView.filterContextMenuItems` via per-item `permissions: [...]`.
- **MemberView Permissions splits into two panels.** "Group permissions" (editable, autosave — same FormView as today) and "System permissions" (read-only display of `member.user.permissions`). Edits to system perms still belong on the User record; surfacing them read-only here answers "what does this user have system-wide" without leaving MemberView. Per spec line 270.

### Admin UserView · admin-tier gating + throttle badge (Phase 4)

- **Admin-tier gate** (`users` / `manage_users` / `is_superuser`) now hides destructive affordances from non-admin viewers. Affected: header `is_active` toggle (disable/reactivate), kebab items (`edit-user`, `change-avatar`, `clear-avatar`, `change-password`, `clear-rate-limit`, `revoke-all-sessions`), Permissions sidebar entry (via `permissions: [...]`), and the Security section's `Set Password` / `MFA Requirement` / `Revoke All Sessions` rows. Email-keyed actions (`reset-password`, `send-magic-link`) stay visible to any caller — backend trusts the email recipient. Per `planning/done/admin-users-spec-alignment.md`.
- **Throttle badge in UserView header.** Fire-and-forget `GET /api/auth/manage/throttle?user_id=N&key=login` on view open; red "Login locked Xs" chip appears whenever `retry_after_seconds > 0`. Independent of `is_active` (a user can be active AND login-locked, or disabled AND not throttled).
- **New "Clear Rate Limit" kebab action** wires `POST /api/auth/manage/clear_rate_limit` (admin-tier). Re-fetches throttle state on success so the chip clears immediately.
- **New "Change Password" kebab item** — same flow as the Security section's "Set Password" row. Both now bubble to a single canonical `UserView.onActionChangePassword` handler.
- **`AdminSecuritySection` dedup.** `onActionSetPassword` and `onActionRevokeAllSessions` removed from the section — events bubble to UserView. `data-action="set-password"` renamed to `change-password` to match the canonical handler name. Net delete ~30 lines.

### Admin UserView · identity cards (Phase 3)

- **UserView Profile section now renders Username / Email / Phone / Password as managed `.admin-security-item` cards.** Display Name keeps its existing pencil row under the "Personal" eyebrow; the four identity cards live under a new "Identity" eyebrow. Per `planning/done/admin-users-spec-alignment.md`.
- **Admin-tier gating via single `isAdminCaller` getter** on `UserProfileSection` — reads `app.activeUser.is_superuser` or `hasPermission(['users','manage_users'])`. Admin-tier callers see direct-edit pencils + force-verify icon-buttons + Set/Clear-phone buttons; non-admin viewers see read-only rows with a "Send magic-login link" affordance pointing to user self-service.
- **No auth/change endpoints.** Admins direct-edit Username / Email / Phone via `POST /api/user/<id>` field writes — backend was relaxed (django-mojo `2e5ebcc`+) so `users` / `manage_users` callers can write these without superuser. `/api/auth/{email,phone,username}/change/*` stay reserved for user-driven self-service flows that verify channel ownership via OTP/link.
- **New `onActionSetPhone` and `onActionRemovePhone`** on UserView for phone-no-value and phone-clear cases.
- **AdminSecuritySection cleanup.** Removed the broken "Send Email Verification" row and handler — `/api/auth/email/verify` is JWT-scoped (self-only) and an admin clicking it operated on the admin's own pending state rather than the target user's. Admins now route the user through "Send Magic Login Link" instead so the user verifies their own email after logging in. Deduplicated the `onActionSendPasswordReset` and `onActionSendMagicLink` handlers — they now bubble to UserView's existing `onActionResetPassword` / `onActionSendMagicLink` (renamed the section's `data-action="send-password-reset"` to `reset-password` to match). "Set Password" affordance kept (operationally required, backend-audited); switched the body field name from `password` to `new_password` per the django-mojo relaxation note.

### Admin extension · TablePage UX sweep

Cross-cutting cleanup of every `TablePage` under `src/extensions/admin/`
— bug fixes, new-feature adoption, KISS standardization. Per
`planning/requests/admin-tablepages-ux-sweep.md`. Six small commits.

- **New `TablePage.batchAction({ field, value, destroy, handler, label, message, confirm })` helper.** Encapsulates the confirm → save/destroy → toast → refresh pattern shared by every admin batch handler. Three modes: save-by-field-and-value (status transitions), `destroy: true` (deletes), and a custom `handler(model)` callback (nested fields, special saves). Always uses `Promise.allSettled` so one failed item doesn't abort the rest; toast tier degrades success → warning → error based on settled outcomes. Returns the count of successful operations.
- **14 hand-rolled `onActionBatch*` methods collapsed to one-liners** — `IncidentTablePage` (5 of 6 — Merge stays custom), `RuleSetTablePage` (3), `BotSignatureTablePage` (3), `BlockedIPsTablePage` (2), `IPSetTablePage` (5), `PublicMessageTablePage` (1), `AssistantConversationTablePage` (1, destroy mode). Net: ~241 lines of boilerplate removed.
- **Silent bugs fixed.** `tableOptions: { actions: [...] }` was the wrong slot — `tableOptions` is HTML-table styling (striped/bordered/size) and `actions` placed there was silently dropped by TableView. Lifted across `PushDeliveryTablePage`, `PushDeviceTablePage`, `PushTemplateTablePage`, `MetricsPermissionsTablePage`. `format: 'boolean'` typo (correct property is `formatter:`) corrected in `PushConfigTablePage`, `PushTemplateTablePage`, `PushDeviceTablePage`. `MetricsPermissionsTablePage` had `showAdd: true` with no `formCreate` → click-Add threw; set to `showAdd: false` until/unless an Add flow is wired. `LogTablePage` declared 4 batch actions with no handler methods (selecting + clicking did nothing); the entire `batchActions` block + `selectable: true` removed — logs are an immutable audit feed. `PhoneNumberTablePage` and `GeoLocatedIPTablePage` reached into `tableView._onRowView()` (private API) after their lookup-and-show flow; replaced with the public `this.showItemDialog(model)`. `EventTablePage` and `SentMessageTablePage` had stray `selectable: true` with no batch handlers — same immutable-feed cleanup.
- **`itemView:` → `itemViewClass:` standardization.** Both work today (`TablePage.js` aliases them), but mixed usage was confusing. Audited and converted across `GeoLocatedIPTablePage`, `PhoneNumberTablePage`, `SMSTablePage`.
- **`Model.{ADD_FORM, EDIT_FORM, VIEW_CLASS}` adopted across ~30 admin pages.** Pages now declare `Collection`, `columns`, and toolbar flags; the Add / Edit / View dialogs auto-resolve from the model class. Pattern: form statics on the model file (next to existing `RuleSet` precedent in `Incident.js`), VIEW_CLASS at the top of the consuming TablePage file (avoids core→ext deps on view classes). Reference impls: `ApiKeyTablePage`, `SettingTablePage`, `ScheduledTaskTablePage`. New / completed registrations on: `Incident.js` (`Incident`, `IncidentEvent`), `Tickets.js` (`Ticket`), `Email.js` (`EmailDomain`, `Mailbox`, `EmailTemplate`), `Push.js` (`PushConfig`, `PushTemplate`), `Bouncer.js` (`BouncerSignature`), `AWS.js` (`S3Bucket`), `Files.js` (`FileManager`, `File`). ~35 inline `formCreate:` / `formEdit:` / `itemViewClass:` lines dropped from page constructors.
- **`dayRangeFilter: true` adopted across 18 time-series tables** — `Log`, `Event`, `Incident`, `BouncerSignal`, `FirewallLog`, `SentMessage`, `PushDelivery` (created), `PushDevice` and `UserDevice` (last_seen 30d), `BlockedIPs` (blocked_at 7d), `BotSignature`, `ShortLinkClick`, `ShortLink`, `AssistantConversation` (modified 7d), `SMS`, `BouncerDevice` (last_seen 30d), `PublicMessage`, `EmailTemplate`, plus the embedded login-events table in `UserDeviceLocationTablePage`. The toolbar segment writes `${field}__gte`; complements existing column `daterange` filters (which write start + end).
- **`groupByDay('created')` adopted on chronological audit feeds** — `Log`, `Event`, `BouncerSignal`, `FirewallLog`, `SentMessage`, `ShortLinkClick`, `PushDelivery`, plus the embedded login-events table. Day-grouped headers with `'Today'` / `'Yesterday'` / `'May 5'` / `'May 5, 2025'` labels.
- **`boolean` filter type replaces hand-rolled `select` with `[true, false]`** — `Group.is_active`, `IPSet.is_enabled`, `BotSignature.is_active`, `RuleSet.is_active`, `ScheduledTask.enabled`, `GeoLocatedIP.is_blocked` / `is_vpn` / `is_tor`. Keeps wire format as the string `'true'` / `'false'` so URL round-trip works; `trueLabel` / `falseLabel` overrides used where the domain wants different copy ("Enabled" / "Disabled" instead of "True" / "False").
- **Responsive `visibility:` breakpoints** added to 14 wide tables — non-essential columns hide at `md` / `lg` / `xl` so mobile views stay scannable. Affected: `UserDevice`, `IPSet`, `Event`, `EmailDomain`, `EmailMailbox`, `BouncerDevice`, `PhoneNumber`, `Member`, `SMS`, `SentMessage`, `PushDelivery`, `PushDevice`, `FileManager`, `File`, `UserDeviceLocation`.
- **`searchPlaceholder` hints** on every `searchable: true` table — guides users toward indexed fields (`'Search title, message, or ID'` etc.) instead of the generic `'Search...'`.
- **`footer_total` + `align: 'right'`** added to numeric columns where useful — `BouncerDevice` (`event_count`, `block_count`), `ShortLink` (`hit_count`).
- **Immutable audit feeds drop `selectable: true` and `batchActions`.** Documented as a principle: logs, signals, deliveries, sent messages, click trails, login events, and similar history tables get `view` (and optionally `export`) and nothing else. Bulk-deleting or bulk-modifying audit history is dangerous, rarely a real need, and when it is needed it belongs in a backend retention policy — not a row checkbox. Applied to `Log`, `Event`, `SentMessage`.
- **Tests.** New `test/unit/TablePage.batchAction.test.js` (8 cases: empty selection, confirm-cancel, save mode, destroy mode, handler mode, partial failure, all-failed, `confirm: false`, custom message). New `test/unit/admin-tablepages-bugfixes.test.js` (24 cases — source-level regression assertions for every silent bug locked in by commit 2). New `test/unit/admin-model-statics.test.js` (67 cases — registration-shape assertions for every migrated model + "no inline duplication" lockdown). Total: 1163 tests pass.
- **`TablePage` registered with the simple-module-loader** (`test/utils/simple-module-loader.js`) so prototype-method tests like `batchAction.call(stub, …)` don't need to instantiate a full Page with all its dependencies.
- **Out of scope, deferred to a follow-up request.** Hand-rolled modal flows in `UserTablePage` (password / permissions / invite), `EmailDomainTablePage` (onboard / audit / reconcile), `EmailMailboxTablePage` (send-email / send-template-email collapse), `FileManagerTablePage` (6 context-menu actions), `ShortLinkTablePage` (metadata flatten/unflatten), `IPSetTablePage` (country-code transform), and `GeoIP` / `PhoneNumber` lookup forms. All keep their existing `onAdd:` / `onItemEdit:` / context-menu handlers; moving the form configs onto model statics is a focused review pass for a separate request.
- **Docs.** New "Canonical pattern: model statics", "When to use `dayRangeFilter` vs. column `daterange` filter", "Embedding a TableView in a non-TablePage", and `batchAction()` API sections in `docs/web-mojo/pages/TablePage.md`.

### DetailView audit round 2 — header polish + new framework primitives

- **New `iconHtml` slot on `DetailHeaderView`.** Trusted-HTML option (string or `(model) => string`) replacing the Bootstrap icon — for avatar `<img>`, custom badges, click-to-change affordances. New `.dh-icon-image` CSS modifier strips the tinted background so the slot becomes a 44×44 frame with `overflow: hidden`. Returning falsy falls through to the standard `icon` + `iconTone` path, so consumers (UserView's avatar) can wire the slot conditionally and keep the toned-circle placeholder when there's no avatar URL.
- **New `titleAffix` slot on `DetailHeaderView`.** Trusted-HTML option rendered inline next to the title text — for copy buttons, edit pencils, link-out icons. Wraps title in a flex `.dh-name-row` container with `.dh-title-affix`. Default ghost-icon-button styling via `.dh-name-action` class. ShortLinkView uses it to put the clipboard next to the URL it copies.
- **New `chip.tooltip` field on chip configs.** Pass a string or `(model) => string` and the badge gains `data-bs-toggle="tooltip" title="…"`. Bootstrap auto-init wires the hover (header view already has `enableTooltips: true`). IncidentView uses it on the threat-flag chips (TOR / VPN / Proxy / Datacenter / Cloud / Mobile / Known attacker / Blocked / Whitelisted) so each chip carries a one-line description.
- **Mobile reflow rewritten** at the Bootstrap `sm` breakpoint (575.98px). The previous rule dropped `.dh-actions` to `position: static` and stacked the entire cluster (toggle + meta + ⋮ + ✕) below the title — disconnected from where users expect modal chrome. New rule keeps `.dh-actions` absolute top-right with a tighter offset, hides muted secondary aux content (relative-time meta line, "Online"/"Offline" label, inactivity warnings, the active-toggle's text label), and trims the `dh-group-sep` divider's footprint. Active toggle and ⋮ ✕ stay visible; full info still reachable inside the modal sections.
- **`dh-group-sep` divider tightened** universally — margin reduced from `0.45rem + 0.35rem` to `0.2rem + 0.1rem`, height 18px → 16px, opacity 0.6 → 0.5. Saves ~0.5rem of right-gutter width on every DetailView header.
- **Public exports added.** `DetailView`, `DetailHeaderView`, `MetricCard`, `StatusPanel`, `Timeline`, `KnownFieldsCard`, `FlowStrip` are now exported from the package main (`web-mojo`). Previously only available via internal `@core/views/data/...` paths, which downstream consumers couldn't reach. The new `components/detail-view` example imports them via the public surface.
- **New canonical example** at `components/detail-view` (in the example portal) demonstrates the full envelope: toned icon (`iconToneFn`), title + subtitle, three chips with mixed `tooltip`s, `titleAffix` slot, `auxFn` two-row toggle+meta layout, empty `actions: []` gutter, `contextMenu` for long-tail items, three sections (Overview KPIs + flat-rows / Details / Activity). Opens via `Modal.detail()` so the locked envelope (no body padding, no footer, lg width) lands automatically.
- **New view: `LoginEventView`.** Detail view for an individual `LoginEvent`. Sections: Overview (KPIs: Result / Source / Country / When + Identity flat-rows), Source (geolocation + threat flags), Audit (LogList scoped to source IP, day-grouped via `groupByDay('created')`). `LoginEvent.VIEW_CLASS = LoginEventView` is wired so UserView's Logins ListView opens it on row click. Also exported from the admin extension's main index.
- **`FileView` migrated to extend `DetailView`.** Previously hand-built header + SideNavView + ContextMenu trio; now lands on the canonical envelope. Adds a `FileOverviewSection` (Snapshot KPIs: file size / type / status / uploaded; Identity flat-rows incl. Public URL with clipboard formatter) so file modals lead with the same Overview shape every other DetailView consumer uses, with Preview / Details / Renditions / Shares / Metadata as dedicated sections one click away.
- **UserView audit feed `.user-audit-row` redesign + Logins `.user-login-row` timeline.** Three Audit-tab ListViews and the Locations · Logins ListView replace `.user-feed-row` flat-strip rendering with a 3-column flex layout (leading tonal icon column, primary/secondary stack, right-aligned relative time) for audit feeds, and a CSS-only vertical-rail timeline with tone-coded dots for logins. New `levelIcon` and `loginTone` formatters complement the existing `levelTone`. All four lists use `groupByDay('created')` for "Today / Yesterday / May 4" headers.
- **GroupView Identity row-level edits, UserView Profile per-row pencils.** Both views drop block-edit modals in favor of per-row pencil → `Modal.prompt` / `Modal.form` flows for each editable field — same shape as the round-1 `AdminPersonalSection` reference.
- **Pattern enforcement.** Header right gutters across UserView / GroupView / IncidentView / GeoIPView / ShortLinkView now follow a uniform pattern: `actions: []`, primary actions on `StatusPanel` or section affordances, long-tail in the context menu. The right gutter holds at most: aux block (toggle + meta) → ⋮ → ✕. All seven `clickAction:'view'` ListViews in UserView gained `viewDialogOptions: { header: false, noBodyPadding: true, buttons: [] }` so row-click modals match the locked envelope; FileTablePage same fix.

### ListView / TableView · `dayRangeFilter` toolbar helper

- **New `dayRangeFilter` option on `ListView`** — opt-in. Pass `dayRangeFilter: true` and ListView mounts a `1d / 7d / 30d / 90d` `SegmentControl` in the toolbar AND auto-applies the selected range as a `${field}__gte` filter on the collection. Same flow as the existing `filterable` machinery: writes to `collection.params`, resets `start = 0`, refetches. Boolean form uses defaults `{ field: 'created', value: '7d' }`; object form `{ field, value, options, ariaLabel }` merges over those defaults. TableView inherits the option unchanged.
- **`range:change` event** — fires on the ListView with `{ field, value, previous, params }` whenever the user clicks a different segment, plus a `params-changed` emit (mirrors `list:search`, `list:sort`, filter pills). Initial mount-time seed does NOT emit, matching the existing `defaultQuery` seeding pattern.
- **Programmatic API** — `listView.getRange()`, `listView.setRange(value, { silent })`, plus the underlying SegmentControl is exposed as `listView.dayRangeControl` for callers that need it.
- **Side-by-side composition with `toolbarRight`.** Both can coexist; day-range mounts to the left, `toolbarRight` to the right, in the existing right-aligned toolbar group via a new `data-container="toolbar-day-range"` slot.
- **Escape hatches** — values that don't match `/^\d+d$/` (e.g. `'all'`, `'ytd'`) wire the segment but don't write a `__gte` param. The `range:change` event still fires (with `params: {}`) so callers can customize.
- **`RuleSetView` migration** — the manual SegmentControl wiring in `src/extensions/admin/incidents/RuleSetView.js` (~15 lines: SegmentControl construction, `_applyRange` translation, `toolbarRight` plumbing, `rangeValue` tracking) collapses to `dayRangeFilter: { value: this.rangeValue }` plus a single `range:change` listener for the eyebrow label.
- **Tests:** new `describe('ListView (dayRangeFilter)')` block in `test/unit/ListView.test.js` covering boolean / object forms, mount-time seed, custom field, refetch + `start = 0` on change, `range:change` payload, no-emit-on-seed, `getRange` / `setRange` (incl. `silent: true`), unknown-value rejection, side-by-side ordering with `toolbarRight`, `_isToolbarEnabled` truthiness, and the non-`\d+d` escape-hatch path.
- **Docs:** new `dayRangeFilter` row in the ListView toolbar options table + a "Day-range filter" subsection under Toolbar / Search / Filters in `docs/web-mojo/components/ListView.md`. TableView inherits without a separate doc section.

### ListView / TableView · grouped rows (synthetic group headers between items)

- **New `groupBy` primitive on `ListView`** — opt-in. Pass either a function `(model) => key` or a string field-name shorthand and ListView interleaves a synthetic header row before the first item of each new group. Headers extend `View` directly (not `ListViewItem`) so they cannot fire `item:click` / `row:click` / `clickAction: 'view'` — non-interactive by construction.
- **`groupHeaderTemplate` + `groupHeaderLabel`** complete the three-layer label pipeline: `model → groupBy(model) → rawKey → groupHeaderLabel(rawKey) → displayKey → {{key}} in groupHeaderTemplate → DOM`. Header template also receives `{{model.*}}` for the trigger model (the first model of the group).
- **TableView inherits grouping** via the same options. The default header markup is overridden to a full-width `<tr class="list-group-header-row"><th colspan="N" class="list-group-header-cell">{{key}}</th></tr>` so the header sits in the table grid; consumer overrides should write cell-only inner content (`<th colspan="…">…</th>`), parallel to how `TableRow` consumes inner `<td>` markup.
- **Pagination counts items only** — page-size 5 still means 5 items per page regardless of header count. Filter / search / sort interactions all just work — `_onCollectionReset` rebuilds against the new model order and the resolver runs again.
- **`groupByDay` helper** — `import { groupByDay } from '@core/views/list/grouping.js'`. Spread it onto a ListView (`...groupByDay('created')`) and you get day-bucketed headers (`'Today'` / `'Yesterday'` / `'May 5'` / `'May 5, 2025'`). Stable `YYYY-MM-DD` bucket keys make equality deterministic across input formats (epoch / ISO / `Date`). Accepts a field-name string or an accessor function. Additional helpers (`groupByField`, `groupByMonth`, `groupByYear`, `groupByLetter`) tracked in `planning/requests/listview-grouping-helpers.md`.
- **New file: `src/core/views/list/ListGroupHeaderView.js`** — small View subclass for header rows. New file: `src/core/views/list/grouping.js` — exports `groupByDay` plus a default helper bag for downstream consumers that prefer that import shape.
- **Four built-in visual styles**, opt-in via `groupHeaderStyle: 'banner' | 'mark' | 'band' | 'rule'`. Default is `'banner'` — a neutral full-width `--bs-tertiary-bg` band with the label centered. Other options: `'mark'` (small accent square + bold label + directional fading hairline), `'band'` (same neutral band as `'banner'` but label left-aligned), `'rule'` (editorial fieldset-legend, label centered between symmetric hairlines). Each maps to a CSS modifier class on the header view's outer element (`.list-group-header--<style>` for the list shape; `.list-group-header-row--<style>` for the table shape) — overridable in your own stylesheet. Unknown values fall back silently to `'banner'`. The `'mark'` style additionally exposes `--list-group-header-accent` (default `var(--bs-primary)`) so consumers can retint a single header per-instance for severity tones (`style="--list-group-header-accent: var(--bs-success)"`). All Bootstrap tokens — light + dark from a single block.
- **Tests:** new `describe('ListView (grouped)')` and `describe('groupByDay helper')` blocks in `test/unit/ListView.test.js` covering function-form `groupBy`, string-form `groupBy`, label formatting, falsy-resolver "ungrouped tail", header / itemView separation, click-routing exclusion, collection-reset re-segmentation, DOM interleave order, non-grouped consumer no-op, and the full `groupByDay` matrix (today / yesterday / current-year / prior-year / format-input variants). New `describe('TableView (grouped)')` block verifies the default `<tr class="list-group-header-row"><th colspan="N">…</th></tr>` shape.
- **Docs:** new "Grouped rows" section in `docs/web-mojo/components/ListView.md` (full pipeline + helper + naming caveat) and a cross-reference + TableView shape note in `docs/web-mojo/components/TableView.md`.

### ListView / TableView fixes following the toolbar refactor

- **Boolean filters render as a True/False select.** `type: 'boolean'` (and `'switch'` / `'toggle'`) used to fall through to a text input in the edit dialog because FormBuilder doesn't have a `boolean` field type. Now mapped to a select with explicit `{ value: 'true', text: 'True' }` / `{ value: 'false', text: 'False' }` options. Caller can override labels via `trueLabel` / `falseLabel` and pre-select with `defaultValue`. Wire format is the string `'true'` / `'false'` so URL round-trip works.
- **Filter pill text is readable.** A previous commit changed `.badge.bg-primary` to a subtle (light) background, which made the pill's `text-white` link button and `btn-close-white` invisible. Replaced with a `.list-filter-pill-text` class + plain `.btn-close`; CSS makes the link button inherit the pill's `--bs-primary-text-emphasis` foreground in both themes.
- **Filter pill click no longer opens two modals.** ListView's `onActionEditFilter` runs the full edit-modal flow itself AND emit'd `filter:edit`, which TablePage was listening for and handling by opening another `Modal.form`. Removed the redundant emit from ListView and the corresponding listener from TablePage.
- **`Model.collection` back-reference.** Model never stored the `collection` ref that Collection.add was already passing in. Added `this.collection = options.collection || null` in the Model constructor, plus matching adoption in `Collection.add` (existing instances now get `model.collection = this`) and detachment in `Collection.remove` (`removedModel.collection = null`). Unblocks the in-memory CRUD pattern: `class FooModel extends Model { async destroy() { if (this.collection) this.collection.remove(this); ... } }`.
- **New `ListViewLifecycleExample`.** Exercises the full Add / View / Edit / Delete flow on a plain ListView using `BookModel.ADD_FORM` / `EDIT_FORM` / `DELETE_TEMPLATE` statics, with click-to-view via `clickAction: 'view'` and inline action buttons via `data-action="edit"|"delete"` in the item template. Mocks `save()` / `destroy()` locally so the example runs without a backend.

### ListView model lifecycle (view / edit / delete / add) hoisted from TableView

- **Full model lifecycle is now on `ListView`.** `clickAction` (`'view' | 'edit' | 'select' | 'none' | function`), `onItemView` / `onItemEdit` / `onItemDelete` / `onAdd` / `onRowClick` callback overrides, `itemView`, `addForm`, `editForm`, `deleteTemplate`, `formDialogConfig`, `viewDialogOptions`, `fetchOnView`, `showAdd`, `showExport`, `exportOptions`, `exportSource` — all the model-dialog scaffolding TableView has, on plain ListView. ListView defaults `clickAction: 'none'` so existing usage is unchanged; opt in by setting `clickAction` plus the appropriate form/itemView config.
- **`data-action="view" | "edit" | "delete"` in item templates** now route to the standard view / edit / delete dialog flow — drop the buttons into a card template and they work without per-page wiring. Same vocabulary TableView's TableRow uses.
- **Add and Export buttons** render in the ListView toolbar when `showAdd` / `showExport` are set (gated off by default; TableView keeps its default-on behavior).
- **TableView is leaner** — its `_createItemView` now calls `super._wireItemViewListeners()` for the standard select/click/view/edit/delete event wiring, and only adds the table-specific cell:edit/save/cancel + batch panel binding. Its `buildActionButtonsTemplate` override calls super and inserts only the Fullscreen button. `getModelClass`, `getModelName`, `getItemViewClass`, `getAddFormConfig`, `getEditFormConfig`, `getFormDialogConfig`, `renderTemplateString`, `_onRowView`, `_onRowEdit`, `_onRowDelete`, `_onRowClick`, `onActionAdd` (full implementation), `onActionExport` (full implementation) all moved up to ListView. `Mustache`, `Modal`, `FormView` imports dropped from TableView.
- **`table:add` / `table:export` events preserved** for backwards compat — TableView keeps thin `onActionAdd` / `onActionExport` overrides that emit the event before delegating to super.

### ListView toolbar / filters / pagination upgrade

- **`ListView` now hosts the toolbar / filter / pagination machinery** that previously lived only on `TableView`. New opt-in options: `searchable`, `filterable`, `filters`, `hideActivePills`, `hideActivePillNames`, `paginated`, `paginationMode` (`'pages' | 'more' | 'none'`), `pageSize`, `sortOptions`, `title`, `eyebrow`, `showRefresh`, `toolbarButtons`, `toolbarRight`, `persistSelection`. All flags default to off — a `new ListView({ collection, itemTemplate })` with no toolbar options renders exactly as before.
- **New `paginationMode: 'more'`** — renders a "Show more" load-more button below the list and appends the next page in place via the new `Collection.fetchMore()` helper. Default for visual ListView (the convention for cards / feeds). Hides automatically when `collection.length() >= meta.count`. `persistSelection` defaults to `true` in this mode so selections survive appends.
- **New `paginationMode: 'pages'`** — same numbered pagination + page-size selector that TableView has, available on plain ListView for hunt-by-page lists.
- **New `sortOptions` toolbar dropdown** — a list-style "Sort by" dropdown driven by `[{ key, label, dir }, ...]`. Sets `collection.params.sort`. Coexists with TableView's column-header sort dropdowns.
- **`TableView` behavior is unchanged.** It now extends ListView's toolbar primitives but explicitly preserves all its existing defaults: `searchable: true`, `filterable: true`, `paginated: true`, `paginationMode: 'pages'`, `persistSelection: false`, plus the table-specific Add/Export/Fullscreen buttons and column-aware sort. All existing TableView usage keeps working as before.
- **`Collection.fetchMore({ pageDelta = 1 })`** added — advances `start` by `pageDelta * size` and calls `fetch({ reset: false })` to append rather than replace. Emits `fetch:more` in addition to the standard `fetch:start` / `fetch:end` lifecycle.
- **Fixed: `Collection.fetch({ reset: false })` per-call opt-out** — previously the `if (this.options.reset || additionalParams.reset !== false)` condition short-circuited to `true` whenever `this.options.reset` was true (the default), making the per-call override dead code. Per-call `reset: false` now correctly suppresses the reset, falling back to `this.options.reset` only when the caller hasn't decided.
- **Tests:** new `test/unit/ListView.test.js` (15 cases covering default-no-toolbar render, search, filterable + active pills, numbered pagination, show-more append + auto-hide, persistSelection true/false, sortOptions dropdown). Extended `test/unit/Collection.test.js` with 6 cases for `fetchMore()` (advance by size, emit fetch:more, guard past meta.count, REST-disabled, pageDelta multipage, plus a regression for the per-call `reset: false` fix).
- **Examples:** `examples/portal/examples/components/ListView/ListViewToolbarExample.js` (search + filter pills + sort + visual cards) and `ListViewPaginatedExample.js` (numbered pagination on a plain visual list). Registered in the topic taxonomy.
- **Docs:** `docs/web-mojo/components/ListView.md` expanded with Toolbar / Search / Filters / Pagination & Show More / Constructor Options sections. `docs/web-mojo/components/TableView.md` notes that toolbar config now lives on ListView (no behavior change). `docs/web-mojo/core/Collection.md` documents `fetchMore()`. `docs/web-mojo/forms/SearchFilterForms.md` updated to point at ListView's built-in toolbar for simple cases.
- **CSS:** new `src/core/css/list-view.css` with the show-more row and list-wrapper styles, imported alongside `table.css`. Theme-token-based — dark theme is automatic.

### RunnerDetailsView (Wave 3 C3) — StatusPanel hero, ActiveJobsList, flat sections

- **`@core/StatusPanel` hero in Overview** — replaces the local `RunnerStatusPanel` class. Tone tracks runner health (`alive` + `last_heartbeat`): healthy runs `success`, slow / stale heartbeat (≥30s / ≥120s) runs `warning`, no heartbeat or `alive: false` runs `danger`. Headline reads "Up 3d 14h · heartbeat 4s ago" / "Stale heartbeat · last heartbeat 12m ago" / "Down". Meta line shows the runner's served channels and lifetime processed / failed counts. Actions (Ping / Drain / Shutdown) are conditional on health and bubble through the standard MOJO action pipeline.
- **Four flat KPIs** — Uptime / Jobs processed / Failure rate / Active jobs. The heartbeat sparkline and the System Resources / Active Jobs preview cards from the previous Overview are dropped per the rethink plan: `/api/jobs/runners` already answers "is this runner alive" and the inline cards duplicated content from the dedicated System and Active Jobs sections.
- **NEW Channels section** — Mustache template + flat-row layout, one row per channel with a per-channel "active" badge driven by the live `ActiveJobsList`. No card wrappers.
- **Active Jobs section** — replaced the hand-built `<table>` + ad-hoc `/api/jobs/job?runner_id=…` fetch with a `TableView` over the new `ActiveJobsList` (`@ext/admin/models/Job`, Wave 2 B2). Columns: Job (id + func), Channel, Started, Attempt. Click-to-view opens the JobDetailsView modal.
- **Job History section** — already a `TableView`; columns updated to use `formatter: 'relative'` / `formatter: 'duration'` instead of hand-rolled cells.
- **Logs section** — replaced the per-job multi-fetch + hand-built lines with a single `TableView` over `JobLogList` filtered by `?runner_id=`. Standard CRUD per `.claude/rules/api.md` — no special admin endpoints.
- **NEW Actions section** — flat-row layout (Ping / Drain / Restart / Shutdown / Export + Broadcast form). No card stacking. Drain and Restart wire the UX surface for now and toast "backend integration pending" when invoked, matching how Drain was already gated.
- **System section** — converted from `_buildTemplate()` string concat to Mustache + getter properties. OS / CPU / Memory / Disk / Network blocks render as flat-row groups; the raw sysinfo blob lives inside a `KnownFieldsCard` (`@core/views/data/KnownFieldsCard`) for the collapsible raw view. No `.detail-field-card` wrappers.
- **Header** — `auxFn` produces a state-aware right-gutter readout (`Up 3d · 0.40% failure` / `Stale heartbeat 12m` / `Down`). Header `actions[]` is empty — Ping / Drain / Shutdown live on the StatusPanel; Ping / Broadcast / Drain / Restart / Shutdown / Export populate the context menu. The legacy `runner-pulse-dot` DOM patching is dropped (the StatusPanel + auxFn carry the alive signal).
- **Tests:** `test/unit/RunnerDetailsView.test.js` adds twelve regression cases — StatusPanel mounts as instance, tone flips for healthy / down / stale heartbeats, the three TableView sections + ActiveJobsList wiring, the 9-section side-nav config, auxFn readouts for healthy and down runners, header `actions: []`, Channels / System section structural getters.
- **Loader:** `test/utils/simple-module-loader.js` registers `RunnerDetailsView` and adds the `JobRunnerModelsStub` import-rewrite rule (mirroring the existing `JobModelsStub`).

### Wave 3 C8: ShortLinkView sweep — flat sections + Mustache + DataFormatter + KnownFieldsCard

- **`src/extensions/admin/shortlinks/ShortLinkView.js`** rewritten end-to-end against the Wave 1 framework primitives and Wave 3 design language:
  - **Mustache + DataFormatter throughout.** Every section view (`ShortLinkOverviewSection`, `ShortLinkConfigurationSection`, `ShortLinkOgSection`, `ShortLinkMetadataSection`) now uses Mustache string templates with `{{model.field}}`, `{{model.expires_at|datetime}}`, `{{#flag|bool}}…{{/}}` blocks plus view-level getters for derived values. The hand-rolled `_buildTemplate()` methods and local `escapeHtml()` helper are gone.
  - **Overview KPIs** are four `MetricCard`s at default size (no `metric-card-lg`): Hits · 30d, Hits · 7d, Today, Top country. The `summarizeClicks(...)` helper rolls up the shared 30-day clicks collection.
  - **Slack/iMessage preview** is now a borderless tinted region (`.sl-preview` family in `admin.css`) inside Overview — NOT wrapped in `.card`. Falls back to gradient placeholder + Bootstrap-Icons link glyph when og:* fields unset.
  - **Hand-rolled SVG sparkline + 30-day stats card removed** — replaced by the four MetricCards + the dedicated Metrics section's `MetricsChart`.
  - **Configuration / OG / Social sections** drop `.detail-field-card` wrappers in favor of `.detail-section-eyebrow` + `.detail-flat-row` family.
  - **Metadata section** wraps `KnownFieldsCard` — raw JSON dump replaced with promoted-keys grid + collapsible raw `<details>`.
  - **Header `auxFn`** replaces synthetic `_subtitle` with state-aware right-gutter readout. **No `viewDialogOptions` size override** (inherits `Modal.detail()`'s default `'lg'`).
- **`src/extensions/admin/css/admin.css`** — appended `ShortLinkView · Slack / iMessage preview tile` block with `.sl-preview*` rules using Bootstrap tokens (theme-neutral).
- **Tests:** `test/unit/ShortLinkView.test.js` adds 7 regression cases — KPIs at default size, no `.card` wrapper on preview, KnownFieldsCard mount on Metadata, sectionsConfig shape, no `size: 'xl'`, auxFn renders, `track_clicks`-aware empty message.
- **Loader:** registers `ShortLinkView` + `@core/models/ShortLink` and `@ext/charts/index` import paths for stubbing.

### Wave 3 C4: DeviceView sweep — grow 2→5 sections, threat chips, KnownFieldsCard

- **`src/extensions/admin/account/devices/DeviceView.js`** rewritten end-to-end against the Wave 1 framework primitives and the JobDetailsView/GeoIPView canonical pattern:
  - **2 → 5 sections.** Overview now leads with four `MetricCard` KPIs (Sessions / Locations / Days active / Last login) followed by a `Timeline` of threat-signal events (trust / VPN / Tor / proxy / geo / DUID) so the operator's first question — "is this device safe?" — has a single answer block. Hardware (full `device_info` dump), Locations (existing `TableView`), Sessions (NEW — placeholder until the backend records sessions), and Metadata (uses `KnownFieldsCard`) round out the section list.
  - **Mustache + DataFormatter throughout.** Every section view is now a Mustache string template binding `{{model.field}}`, `{{model.field|datetime}}`, `{{model.field|relative}}`, `{{#flag|bool}}…{{/flag|bool}}`, with view-level getters for derived flags. The previous `template = () => this._buildTemplate()` string concatenation, the per-section `_fieldCard()` factory, and the per-section `escapeHtml()` calls along the template path are gone.
  - **`@core` primitives in use.** Overview KPIs are four `MetricCard`s at the default size (no `metric-card-lg`); the Overview's threat-signals feed is a `Timeline`. Metadata section uses `KnownFieldsCard` — the raw `metadata` blob plus a small set of audit fields are promoted to the 2-col grid, with the raw record JSON in the framework's collapsible `<details>` block.
  - **Hardware section flat-row redesign.** The five `.detail-field-card` blocks (Browser / OS / Hardware / Display & environment / Identification) collapse into five `.detail-section-eyebrow` subsections of `.detail-flat-row`s.
  - **Header chips: Trusted / Blocked / VPN / Tor / Proxy / Cloud** with `when:` callbacks — only render when truthy.
  - **Header `auxFn`** replaces the synthetic-`_subtitle` round-trip with a structured presence-dot + main-state + muted-relative readout. Trusted HTML; every model field is escaped via `MOJOUtils.escapeHtml(...)` before composition.
  - **No `p-3` / `p-4`** utility classes on section className strings; layout owns padding. **No `viewDialogOptions` size override** (inherits `Modal.detail()`'s default `'lg'`).
- No public API changes. Lint clean on the touched file.

### Wave 3 C2: IncidentView sweep — StatusPanel + Timeline + KnownFieldsCard, RelatedIncidentsList table

- **`src/extensions/admin/incidents/IncidentView.js`** rewritten end-to-end against the Wave 1 framework primitives. Eight section classes that previously declared `template = () => this._buildTemplate()` and concatenated `<li>` / `<div>` strings via the local `_escapeHtml` helper are now Mustache string templates with DataFormatter pipes plus getter properties for derived values. The local `_escapeHtml` shim is gone — every trusted-HTML slot (StatusPanel `meta`, Timeline `detail`, header `auxFn`, action-button bars, ruleset-data DataView field templates) escapes via `MOJOUtils.escapeHtml`.
- **`IncidentStatusPanel` deleted** — the local hero-panel class is replaced with `StatusPanel` from `@core/views/data/StatusPanel.js`. All five options (`tone`, `state`, `headline`, `meta`, `actions`) are function-valued so the panel re-resolves against the current model on every render. Tone selection is unchanged (active high-pri = danger, active low-pri = warning, resolved/closed = success, paused/ignored = info). Resolve / Assign / Re-open buttons hang off the panel's `actions` list and bubble `action:resolve` / `action:assign` / `action:reopen` to the Overview section, which forwards to the DetailView's handlers.
- **`IncidentResponseCard` rewritten** — the hand-built `<ol class="detail-timeline">` is gone. The card now mounts a `Timeline` from `@core/views/data/Timeline.js` with a `(model) => items[]` resolver that maps the incident's `metadata.handler_chain`, `blocked_ip`, `ticket_id`, and `llm_analysis` blocks to the `{ tone, headline, detail, when }` shape. Detail strings escape model-controlled fields before interpolation (per Timeline's security note).
- **`IncidentTriggerCard` rewritten as Mustache + getters** — eight rows (Rule / Category / Scope / Source IP / Targeted user / Hostname / Events) bind to view-level getter properties (`hasRule`, `ruleLabel`, `hasSourceIp`, `targetUser`, etc.). No more inline `escapeHtml()` along the template path. Card flags missing context with a single muted "No trigger context recorded." line instead of an empty `<li>`.
- **`IncidentSourceSection` rewritten** — Mustache template against view-level getters (`geoLine`, `ispLine`, `hasGeoData`, `hasRiskScore`); the four `DataView` blocks (Network / Threat / Threat flags / Block status) are unchanged but mount via labelled `data-container` slots. Trusted-HTML `badgesHtml` and `actionsHtml` getters compose the threat-level badge + flag chips and the contextual button row from sanitized inputs only (booleans + escaped strings + URL-encoded coordinates).
- **`IncidentRequestSection` rewritten** — eight HTTP fields (Method / Status / Host / Path / URL / Protocol / Query string / User agent) are flat-row Mustache rows driven by view-level getters; the dedicated Headers and Body blocks below render `<pre class="detail-payload-block">` blocks with auto-escaped content. The legacy `.detail-field-card` wrappers and inline `<style>` whitespace overrides are dropped — the framework's `.detail-payload-block` primitive owns the layout.
- **`IncidentStackTraceSection` simplified** — drops the `_escapeHtml` fallback; the `StackTraceView` from `@core` handles formatting, with a Mustache `{{stackTrace}}` fallback view for the rare case where `StackTraceView` fails to construct.
- **`RuleEngineSection` modernised** — already idiomatic Mustache; the inline button-group sub-header switched to the framework's `.detail-section-eyebrow` + slot pattern (Edit / Open / New rule pencils right-aligned), and the inner DataView no longer carries a `'rounded bg-body-tertiary p-3'` className override (layout owns padding).
- **`IncidentTicketsSection` modernised** — bespoke `<h6>` + button-group header swapped for `.detail-section-eyebrow` with a right-pinned "Create Ticket" affordance.
- **`RelatedIncidentsSection` rebound to `RelatedIncidentsList`** — the section now constructs the related collection via `new RelatedIncidentsList({ sourceIp, ruleSet, group, hostname, category, status })` instead of an `IncidentList` with hand-spread params. Per the Wave 2 B2 contract, **no `user` filter** is passed (Incident has no user FK; `group` already scopes to a user's primary group). `hideActivePillNames` covers every filter the collection might apply.
- **`IncidentHistorySection` (new wrapper)** — replaces the bare `ChatView` adapter mount with a labelled `.detail-section-eyebrow` shell so the History section visually matches the rest of the side-nav layout. The existing `IncidentHistoryAdapter` is unchanged.
- **`IncidentMetadataSection` (replaces `IncidentDetailSection`)** — the legacy bordered raw `<pre class="bg-body-tertiary border rounded">{{model.metadata|json}}</pre>` block is gone. The section now mounts a `KnownFieldsCard` from `@core/views/data/KnownFieldsCard.js` with twenty known JSON keys (source_ip, hostname, user_agent, http_url, http_method, http_status, country_code, region, city, request_path, user, component, component_id, error_class, error_message, rule_id, risk_score, action, trigger, do_not_delete) — each promoted to a labelled flat row, with the raw blob in the framework's collapsible `<details>` element below. The eyebrow exposes a single `.detail-section-action` pencil that flips between "Add metadata" / "Edit JSON" based on emptiness. The action handler still bubbles through the same `action:edit-metadata` event the parent `IncidentView` wires.
- **Header `auxFn`** — replaces the synthetic `model.attributes._subtitle` round-trip with the standard `(model) => htmlString` slot from Wave 1 A1. Renders a presence-style dot + main label + muted relative line ("New", "In flight 4m", "Resolved 1h ago"). Tone resolves through the existing `getHeaderIconStyle()` map. Header `actions[]` stays empty — primary state actions live on the StatusPanel; long-tail (Re-run handler / Snooze / Edit / Change priority / Protect / Create Ticket / Merge / Ask AI / LLM Analyze / Delete) on the context menu.
- **Sections list** — `Overview / Events / [Investigation] / Source / Request / Stack Trace / [Response] / Rule Engine / Tickets / History / [Related] / Related / Metadata`. The conditional Source / Request / Stack Trace sections appear only when the incident metadata supports them. `Detail` was renamed to `Metadata` to match the parent migration plan §6.
- **`viewDialogOptions`** — verified clean; the call site (`IncidentTablePage.js`) carries `{ header: false, noBodyPadding: true, buttons: [] }` only, so the modal inherits `Modal.detail()`'s default `'lg'` size. No `size: 'xl'` regression.

### Wave 3 C2: EventView sweep — Mustache + DataFormatter, KnownFieldsCard for metadata tab

- **`src/extensions/admin/incidents/EventView.js`** — header template uses Mustache + DataFormatter pipes (`{{model.title|default('System Event')}}`, `{{model.category|capitalize|default('—')}}`, `{{{model.created|epoch|datetime}}}`). The handcrafted `getIconForEvent(level)` helper is renamed `_iconForLevel` and moved to module scope. The `text-muted` class drift is corrected to the framework's `.text-secondary` token so dark theme works without bespoke overrides.
- **Metadata tab swap** — the previous tab content was a hardcoded `bg-light p-3 border rounded` `<pre>` block of raw JSON. The tab now mounts a `KnownFieldsCard` (Wave 1 A5) with nine known event-metadata keys (source_ip / hostname / user_agent / http_url / http_method / http_status / request_path / error_class / error_message). The known-keys grid + collapsible raw `<details>` block is the same primitive used in `IncidentView.Metadata` so the two views read consistently.
- **Overview DataView** — drops the bespoke `'p-3'` className override (layout owns padding) and adds a `Created` row with `formatter: 'epoch|datetime'` so the timestamp displays consistently with the rest of the framework.

### Wave 3 C1: UserView sweep — flat sections, TabView for Audit/Permissions/Devices, TableView for ApiKeys, presence auxFn

- **`src/extensions/admin/account/users/UserView.js`** rewritten end-to-end against the Wave 1 framework primitives. The file moves from `template = () => this._buildTemplate()` string concatenation in five separate section classes plus four hand-rolled time/escape helpers down to Mustache templates with DataFormatter pipes throughout, plus computed `getter` properties on each view for the values the templates bind to.
- **Helpers retired** — `epochToMs`, `formatRelative`, `formatDate`, `formatDateTime`, and the local `escapeHtml` are gone. `escapeHtml` now resolves to `MOJOUtils.escapeHtml` and only appears inside trusted-HTML slots (`Timeline.detail`, `auxFn`, `Modal.confirm` messages, the OAuth/passkey dialog row HTML). Time formatting is delegated to `dataFormatter.apply(value, ['relative' | 'datetime' | 'date'])` so dark-theme renderings stay consistent. `isOnline()` is retained — used by header chips + the new presence dot in `auxFn`. `_toMs(value)` survives as a tiny private sort key for the activity timeline only.
- **`UserOverviewSection`** — `_buildTemplate()` is gone. The section is a single Mustache string template with `{{model.field|formatter}}` pipes for the identity card and three computed getters (`hasEmail`, `hasPhone`, `accountType`). The four KPI cards (Devices / Last login / Active sessions / Groups) are `MetricCard` instances at the default size (no `metric-card-lg`). The hand-rolled `<ol class="detail-timeline">` Recent-activity card is replaced with the `@core` `Timeline` primitive (Wave 1 A3) — `items` is a function of the shared collections so the feed re-resolves on every `render()` and picks up rows added after the first `fetch:success` event. Trusted-HTML detail strings escape every model-controlled field before interpolation.
- **`UserProfileSection`** — three `_buildSubsection()`-built blocks collapse into one Mustache template with `.detail-section-eyebrow` + `.detail-flat-row` subsections (Personal / Account / Linked accounts). Inline edit pencils use `.detail-section-action` (the framework hover-brighten pattern). Force-verify / unverify buttons render conditionally inside `.detail-flat-row-action` slots and bubble through the same `action:*` events the parent `UserView` already wires.
- **`UserPermissionsSection`** — replaces the `btn-group` "Common / Advanced / Effective" toggle with a `TabView` of three `PermissionsTabBody` child views (one per mode). Each tab body owns its own filter input + render pipeline and emits `toggle-perm` / `filter-perms` action events. The `effective` tab still disables the switches and shows the inherited-from-category note. Inheritance lookup uses `User.GRANULAR_TO_CATEGORY` directly — no behavioral change.
- **`UserDevicesSection`** — single unified `TableView` replaces the hand-rolled `<table>` with the All / Browser / Push `btn-group`. Per the parent plan, the kind selector lives as `toolbarButtons` on the table (with a Refresh button). Rows are a synthetic in-memory array rebuilt from the two shared collections (`UserDeviceList`, `PushDeviceList`); the `kind` column uses a per-row Mustache `template` with `bi-bell` for push devices and the dynamic `deviceIcon` for browsers. `Last seen` uses the `relative` formatter; `Identifier` uses `truncate_middle(20)`.
- **`UserAuditSection`** — the hand-built incidents/activity/object-changes list with its custom search box, paginator, and source `btn-group` is **deleted entirely**. Replaced with a `TabView` of three `TableView`s (Activity / Incidents / Object changes). Each table is wired to its own existing collection with server-side `?q=` search via `searchable: true`, gets `searchPlaceholder` and `paginated: true`, and uses `onTabActivated` to fire its first fetch lazily so the user only pays for the data they ask to see. The Object changes tab carries `permissions: 'view_logs'`.
- **`UserApiKeysSection`** — the hand-built card list and inline "Generate Key" button are gone. Replaced with a `TableView` whose collection is the synthetic decorated array returned by `/api/account/api_keys?user=<id>`. **Generate Key** lives in `toolbarButtons`; **Revoke** is wired through `actions: ['delete']` + `onItemDelete: rowModel => this._revokeKey(rowModel)`. **Copy Token** stays on the inline post-generation banner (a small Mustache child view) which only renders when `generatedToken` is truthy. Status / Created / Expires / Last used / Allowed IPs are dedicated columns with DataFormatter pipes.
- **`UserView` header — `auxFn`** for online/offline + last-active relative. Replaces the legacy "Online" chip + the implicit `last_seen` text on the subtitle with a state-aware right-gutter readout: presence dot (`dh-aux-dot-success` when online, `dh-aux-dot-secondary` when offline) + main label (`Online` / `Offline` / `No activity`) + muted relative readout (`active 4m ago` / `last active 2d ago`). Trusted HTML — model fields escaped before interpolation.
- **OAuth + Passkey dialogs** — the two `Modal.dialog` body views (Linked accounts, Passkeys) drop their inline `style="border-bottom: …"` rules and now use the framework `.detail-flat-row` family (label slot for the icon, value slot for provider/email + meta, action slot for buttons). The behavior — provider chips, last-used timestamps, edit/delete affordances — is unchanged.
- **CSS** — `src/extensions/admin/css/admin.css` gains a tiny **UserView** block (12 lines) with two layout-only rules for the permissions filter input width + the API-key token banner's word-break behaviour. Both are theme-neutral so dark theme inherits automatically. No new component-specific dark overrides needed.
- **No `viewDialogOptions` size override** — UserView inherits `Modal.detail()`'s default `'lg'`. **No `metric-card-lg`** anywhere. **No `p-3` / `p-4` / `mt-3` / `mt-4`** in section className strings; layout owns padding. **No inline `<style>` blocks** in any view template. **No `_buildTemplate()` string concat** in any section view.
- **No public API changes.** `UserView.VIEW_CLASS`, `User.VIEW_CLASS`, `User.MODEL_REF` are unchanged. `showTab(name)` legacy compatibility shim retained. The four verification action events keep their existing names (`action:force-verify-email`, `action:unverify-email`, `action:force-verify-phone`, `action:unverify-phone`).

### Wave 3 C6: GroupView sweep — flat sections, hierarchy tree, Timeline audit

- **`src/extensions/admin/account/groups/GroupView.js`** rewritten end-to-end against the Wave 1 framework primitives:
  - **Mustache + DataFormatter throughout.** Every section view (`GroupOverviewSection`, `GroupIdentitySection`, `GroupPermissionsSection`, `GroupHierarchyTree`, the new `GroupAuditTimelineSection`) is a Mustache string template against `this.model` and a small set of getter properties (`hasKind`, `kindLabel`, `hasParent`, `parentName`, `hasTimezone`, `eodHourLabel`, `permissionRows`, `selfLine`, `childLines`, etc.). DataFormatter pipes (`epoch|datetime`, `epoch|relative`, `default:'—'`) handle every date / relative readout. Module-local `formatRelative` / `formatDate` / `escapeHtml` helpers are gone — `escapeHtml` now resolves to `MOJOUtils.escapeHtml` and is only used in the trusted-HTML slots (`Timeline.detail`, `auxFn`, `Modal.confirm` messages, the hierarchy-tree's decorative ASCII rows).
  - **Overview redesign** — KPI grid (Members / Sub-Groups / API Keys / Last activity) now uses four `MetricCard`s at the default size (no `metric-card-lg`). The two card-wrapped panels ("Identity" + "Hierarchy") are gone; Overview is a flat stack of `.detail-section-eyebrow` + `.detail-flat-row` subsections ("This group", "Hierarchy", "Recent activity"). The Hierarchy mini-tree is a small DOM-only render with a Mustache template and trusted-HTML getters (`selfLine`, `childLines`) — caller-controlled fields (sub-group names / ids) are escaped before interpolation. A new "Recent activity" `Timeline` (`@core/views/data/Timeline.js`) surfaces the last 5 audit entries from the shared `LogList` collection.
  - **Identity section** drops the three nested `.detail-field-card` blocks for three labeled `.detail-section-eyebrow` subsections (Profile / Settings / Dates) of flat rows. Settings rows are conditional via `{{#hasTimezone|bool}}` etc., with a "No settings configured" fallback when the metadata blob carries none of the known keys.
  - **Permissions section** drops the `.detail-perm-group` wrapper (deprecated since Wave 1) for a flat-row layout under a single `.detail-section-eyebrow`. The permission grid renders via Mustache iteration over a `permissionRows` getter (`[{ key, enabled }]`); the "no permissions" empty state matches the section-eyebrow rhythm.
  - **Audit section** is now a `Timeline` (per Phase E C6 spec) — replaces the prior `LogList`-backed `TableView`. Built as `GroupAuditTimelineSection`, the timeline re-resolves items from the shared audit collection on every fetch:success and tones rows by log level (`error` / `critical` → `danger`, `warning` → `warning`, `info` → `info`). The Audit sidebar badge tracks the same collection. Permission gate (`view_logs`) is preserved.
  - **Header `auxFn`** — adds a small right-gutter readout next to the Active toggle: "Last activity · 4m ago" when `last_activity` is set, otherwise the populated member count. Trusted HTML; model fields escaped before interpolation.
  - **Helpers retired** — module-local `formatRelative`, `formatDate`, `escapeHtml` deleted. `iconForKind` and `kindLabel` retained (still drive the header icon + chips).
  - **Sidebar layout unchanged** — same 11 entries (Overview / Identity / [Membership] / Members / Sub-Groups / [Access] / API Keys / Permissions / [Activity] / Events / Audit / [Detail] / Metadata).
  - **No `p-3` / `p-4` utility classes** on section className strings; layout owns padding. **No `viewDialogOptions` size override** — every inner-table modal (Members / Sub-Groups / API Keys) inherits `Modal.detail()`'s default `'lg'`. Confirmed.
- **`src/extensions/admin/css/admin.css`** — adds a small `.group-hierarchy-tree` block (line-height 1.7 on the inner rows) so the decorative └─ / ├─ rule chars render with breathing room. Theme-neutral; uses `var(--bs-secondary-color)` for the surrounding muted text.

### Wave 2 B4: JobDetailsView precedent — adopt `@core` primitives

- **`JobStatusPanel` deleted** — replaced with `StatusPanel` from `@core/views/data/StatusPanel.js`. The lifecycle narratives (Scheduled / Running / Completed / Failed / Cancelled / Expired / Pending) are now resolved via function-valued constructor options (`tone`, `state`, `headline`, `meta`, `actions`) so the panel re-renders against the current model state. `meta` interpolates `<code>` / `<strong>` / `<br>` fragments — every model field is escaped with `MOJOUtils.escapeHtml(...)` before composition (the trusted-HTML contract). Retry / Cancel buttons hang off the panel's `actions` list and bubble `action:retry` / `action:cancel` to the Overview section, which forwards to the DetailView's handlers.
- **`JobLifecycleCard` simplified** — the hand-built `<ol>` is gone. The card now mounts a `Timeline` from `@core/views/data/Timeline.js` with a `(model) => items[]` resolver mapping `recent_events` to the `{ tone, headline, detail, when }` shape via a new `mapJobEventToTimelineItem(ev, useRelativeWhen)` helper. Detail strings escape model-controlled fields before interpolation (`runner_id`, free-text `details`).
- **`JobExecutionCard` rewritten as Mustache + DataFormatter** — the eight rows (Function / Channel / Runner / Created / Started / Finished / Scheduled / Expires) are now `<div class="detail-flat-row">` markup driven by Mustache with DataFormatter pipes (`{{model.created|datetime}}`, `{{model.func|default:'—'}}`, `{{#hasRunner|bool}}…{{/hasRunner|bool}}`). Computed values (`hasRunner`, `hasStarted`, `isScheduled`, `runAtRelative`, `hasError`) are getters on the view; the template binds to them directly. The error block uses `<pre class="detail-error-block">{{model.last_error}}</pre>` (auto-escaped). No more inline `escapeHtml()` calls in the section template path.
- **`JobPayloadSection` simplified** — drops the inline `<pre>` style attribute and Bootstrap utility chain in favor of `.detail-section-eyebrow` + `.detail-payload-block` primitives. (CSS for `.detail-payload-block` lives in `core.css` and uses Bootstrap tokens — dark theme works automatically.)
- **NEW Retry History section** — uses the existing `JobEventList` filtered by `?event=retry&ordering=-at`, rendered as a `Timeline` (events are inherently a timeline shape, not a table). Side-nav badge shows the row count after fetch. Falls back to `recent_events` filtered to retries when the dedicated collection hasn't populated yet.
- **NEW Similar Jobs section** — uses `SimilarJobsList` from `@ext/admin/models/Job.js` (added in Wave 2 B2). `TableView` with `clickAction: 'view'` opens nested `JobDetailsView` modals. Side-nav badge shows the count of recent runs.
- **Header `auxFn`** — replaces the `_subtitle` synthetic-field round-trip with the standard `(model) => htmlString` slot from Wave 1 A1. Renders a state-aware right-gutter readout (presence dot + main label + muted relative time): "Running on runner-7 · started 4m ago", "Failed · 12m ago", "Scheduled · runs in 2h", etc. Dot tone resolves through the same status-tone map as the StatusPanel hero. Header `actions[]` stays empty — primary actions live on the StatusPanel; long-tail (Refresh, Retry, Cancel duplicates) on the context menu.
- **Module-local time helpers** retained — `formatDateTime`, `formatRelative`, `relativeFuture`, `epochToMs` still drive the StatusPanel narrative resolvers and `auxFn` (those compose trusted HTML directly, outside the Mustache-pipe path). DataFormatter pipes are used everywhere a template binds a model field.
- **Tests:** `test/unit/JobDetailsView.test.js` adds six regression cases — Overview's `data-container="job-status"` slot mounts a `StatusPanel` instance, the Lifecycle card's child is a `Timeline` with one item per `recent_events` entry, the view registers the 8-entry `sectionsConfig` (Overview / Payload / [Activity] / Events / Logs / Retry History / [Related] / Similar) when `func` is known, omits Similar when `func` is null, the header `auxFn` renders a state-aware readout for running status, and header `actions[]` stays empty so the StatusPanel owns Retry / Cancel.

### Wave 3 C5: GeoIPView sweep — flat sections, embedded map, threat chips

- **`src/extensions/admin/account/devices/GeoIPView.js`** rewritten end-to-end against the Wave 1 framework primitives:
  - **8 → 7 sections.** The standalone Map section is gone; the map now embeds inline inside Overview (lazy-mounted via `MapView` in `onAfterMount` once the section is in the DOM). Events + Logs collapse into a single **Activity** section that wraps a `TabView` of two `TableView`s (per Phase E briefing).
  - **Mustache + DataFormatter throughout.** Every section view now uses Mustache string templates with `{{model.field}}`, `{{field|default:'—'}}`, `{{field|datetime}}`, `{{{model.is_*|yesnoicon}}}`, `{{#flag|bool}}…{{/flag|bool}}`. Module-local `formatRelative` / `formatDateTime` / `yesNo` helpers and every per-section `_buildTemplate()` have been deleted (the Overview section keeps two trusted-HTML helpers stashed on the model for the StatusPanel `meta` slot, where the framework hands callers a trusted-HTML slot for `<strong>`/`<code>` interpolation).
  - **`@core` primitives in use.** Overview hero is `StatusPanel` (function-valued `tone` / `state` / `headline` / `meta` / `actions` resolvers driven off the model). Overview KPIs are four `MetricCard`s at the default size (no `metric-card-lg`). Metadata section uses `KnownFieldsCard` — the per-section raw `<pre>` JSON dump is gone; raw record JSON now lives in the framework's collapsible `<details>` block.
  - **Risk & Reputation flat-row redesign.** The card-with-header summary block + bordered audit list collapse into a single labeled-section + `.detail-flat-row` family. Only flags that actually fired render rows (with tone-coded badges); the empty state shows "No reputation flags fired." instead of a wall of "no signal" rows.
  - **Threat flags promoted to header chips.** `is_vpn`, `is_tor`, `is_proxy`, `is_cloud`, `is_datacenter`, `is_blocked`, `is_whitelisted` plus `risk_score` and the threat-level severity now render as `when:`-gated header chips so the body of the Risk section can shrink. The header chip variants tone-code by severity (danger for Tor, warning for VPN/proxy/datacenter, info for cloud, success for whitelisted).
  - **Network section** drops the three nested `.detail-field-card` blocks for three labeled `.detail-section-eyebrow` subsections (Identity / Carrier · ASN · ISP / Hosting flags) of flat rows.
  - **Block & Whitelist section** drops both card wrappers and the duplicated header buttons — the eyebrow now exposes a single `.detail-section-action` pencil that flips between Block/Unblock and Whitelist/Unwhitelist based on the current state.
  - **Activity** section badges combine event + log counts into a single sidebar count (replacing the two previous per-section counts that competed for sidebar space).
  - **No `p-3` / `p-4` utility classes** on section className strings; layout owns padding. **No `viewDialogOptions` size override** (inherits `Modal.detail()`'s default `'lg'`).

### Wave 3 C7 — MemberView sweep — flat sections + Mustache + DataFormatter

- **`src/extensions/admin/account/users/MemberView.js`** — applied the Wave 3 design language:
  - **MemberOverviewSection** rewritten as a Mustache string template against `this.model` and a small set of getter properties (`userDisplayName`, `userEmail`, `groupName`, `roleLabel`, `hasRole`, `hasCreated`, `invitedBy`, `permsCount`, `isActive`). Replaces `template = () => this._buildTemplate()` plus its `escapeHtml()` string concatenation. DataFormatter pipes (`epoch|datetime`, `epoch|relative`) handle every date/relative read-out.
  - **Flat-row layout** — dropped the two `.card` wrappers in Overview ("This membership" + "Recent activity"). Both sub-sections now use `.detail-section-eyebrow` + `.detail-flat-row` family. Vertical rhythm (2rem gap above subsection eyebrows after content) is owned by the framework CSS.
  - **MetricCard children** — the four KPI cards (Role / Status / Joined / Perms granted) are now `MetricCard` instances added via `addChild()`, not hand-built `metric-card` divs. Default size (no `metric-card-lg`).
  - **Recent-activity timeline** — `_renderActivity()` and its hand-rolled `<ol class="detail-timeline">` markup deleted. Replaced with the `@core` `Timeline` primitive (Wave 1 A3) configured with `items` as a function of the shared `LogList`, `limit: 5`, and a tone-mapped log-level lookup. Trusted-HTML `detail` slot is escaped via `MOJOUtils.escapeHtml(...)` per Timeline's security note.
  - **MemberPermissionsSection** — eyebrow markup switched from the legacy `.section-eyebrow` / `.section-title` pair to the standard `.detail-section-eyebrow`. The inner `FormView` is unchanged.
  - **Audit section** — already a `TableView`; verified `clickAction` is omitted (Log rows don't have a meaningful detail view) and no `viewDialogOptions` with `size: 'xl'` is passed. Stays as is.
  - **Helpers** — `epochToMs`, `formatRelative`, `formatDate` deleted. `countTruthy` retained (used by header chip and Overview KPI). `_refreshComputedFields` switched to `dataFormatter.apply(value, ['epoch', 'relative'])` for the subtitle's "joined …" segment.
- No public API changes. All 667 unit tests still pass; lint clean.

### Admin user sections — drop inline `<style>` blocks, switch to framework primitives

- **AdminPersonalSection** / **AdminProfileSection** / **AdminConnectedSection** / **AdminSecuritySection** — deleted the inline `<style>` blocks (every block had hardcoded light-theme hex literals: `#adb5bd`, `#f0f0f0`, `#6c757d`, `#212529`, `#0d6efd`, `#d1e7dd`, etc., with **no** `[data-bs-theme="dark"]` overrides). Templates now use the framework's `.detail-section-eyebrow` + `.detail-flat-row*` primitives (Wave 1 A5) for the labeled-row layout, and Bootstrap utility classes (`text-bg-success`, `text-bg-warning`, `text-bg-light border`, `text-secondary fst-italic`) for badges and "not set" placeholders.
- **AdminConnectedSection / AdminSecuritySection structural CSS** moved to `admin.css` under `AdminConnectedSection`, `AdminSecuritySection`, "passkey list", and "recovery codes" headers. All values use Bootstrap tokens (`--bs-tertiary-bg`, `--bs-border-color`, `--bs-body-color`, `--bs-secondary-color`, `--bs-primary`, etc.) so dark theme works automatically per `.claude/rules/theming.md`.
- The two inner `Modal.dialog` views in `AdminSecuritySection` (passkey list, recovery codes) likewise drop their inline `<style>` blocks — moved to `admin.css` under namespaced `.admin-passkey-*` and `.admin-recovery-*` classes.
- Per-section bespoke classes (`.aps-*`, `.ap-*`, `.ac-*`, `.as-*`, `.pk-*`, `.rc-*`) are now retired in favor of either framework primitives or short namespaced classes in admin.css. No new component-specific dark overrides needed — every value resolves through Bootstrap tokens.

### Admin models — new section-driven Collections

- **Added** `SimilarJobsList` (`@ext/admin/models/Job.js`) — thin `JobList` subclass with default `params: { ordering: '-created' }` plus optional `func` constructor arg. Backs JobDetailsView's "Similar Jobs" section. Endpoint: `GET /api/jobs/job?func=<name>&ordering=-created`.
- **Added** `ActiveJobsList` (`@ext/admin/models/Job.js`) — thin `JobList` subclass defaulting to `?status=running`, with optional `runnerId` constructor arg. Backs RunnerDetailsView's "Active Jobs" section. Endpoint: `GET /api/jobs/job?runner_id=<id>&status=running`.
- **Added** `RelatedIncidentsList` (`@ext/admin/models/Incident.js`) — thin `IncidentList` subclass accepting any subset of `sourceIp`, `ruleSet`, `group`, `hostname`, `category`, `status` constructor args; emits the matching `?source_ip=`, `?rule_set=`, etc. filters. Backs IncidentView's "Related Incidents" section. Note: Incident has no `user` FK — only `group` — and the constructor reflects that.
- **Note:** `IncidentEventList` (already at `/api/incident/event`), `IncidentHistoryList` (already at `/api/incident/incident/history`), and `ShortLinkClickList` (already at `/api/shortlink/history`) already cover the remaining new-section data needs. No new collection classes for those — consumer views just call `fetch({ incident: id })` etc. on the existing collections.
- **No backend HTTP API changes.** Every endpoint and filter referenced is verified to exist.



- **Added:** `src/core/views/data/KnownFieldsCard.js` — "promote known JSON keys, keep the raw blob accessible" pattern. Promotes selected keys to a 2-column label/value grid (using the `.detail-flat-row` family) with the raw JSON in a collapsible `<details>` block underneath. Constructor: `data` (object OR `(model) => object`), `knownKeys` (array OR `(model) => array`), `rawCollapsed`, `rawLabel`, `showRaw`, `emptyText`. Each known-key spec is `{ key, label, formatter?, hideEmpty? }`. `formatter` may be a `DataFormatter` pipe name (string) or a `(value, key, data) => htmlString` function — both treat output as trusted HTML. Dotted-path keys (`os.family`, `user_agent.major`) work for nested objects. Missing values render the muted "—" placeholder unless `hideEmpty: true` is set.
- **Moved:** the `.detail-section`, `.detail-section-eyebrow`, `.detail-section-action`, `.detail-flat-row*` rules from `admin.css` into `core.css`. They're framework-wide primitives — `KnownFieldsCard` uses them, and Wave 3's per-view sweeps will use them across admin views.
- **Added** `KnownFieldsCard` CSS (`.detail-known-fields-card`, `.detail-known-fields-grid`, `.detail-known-fields-raw*`) to core.css. Raw block uses native `<details>` for accessibility — no JS, no listeners.
- **Tests:** `test/unit/KnownFieldsCard.test.js` adds twelve cases — known-key rendering, missing "—" placeholder, `hideEmpty`, function formatter, dotted-path lookup, raw block default-collapsed and explicit-open, `showRaw: false`, empty-data fallback, function-valued data + knownKeys re-resolving, nested-object JSON fragment, escape policy.
- **Docs:** new `docs/web-mojo/components/KnownFieldsCard.md` covers quick-start, full options, the spec shape, and three common patterns (incident metadata, device info, IP intel).

### FlowStrip — new `@core` primitive

- **Added:** `src/core/views/data/FlowStrip.js` — horizontal "STEP 1 → STEP 2 → STEP 3" flow primitive (extracted from `RuleSetTriggeringSection`'s Match → Bundle → Threshold → Re-trigger layout). Each step is `{ num, title, value, hint, empty?, action?, actionIcon?, actionData? }`. `value` and `hint` are trusted HTML (for `<code>` / `<strong>` interpolation); `num` and `title` are escaped. `empty: true` renders the muted-italic `.flow-strip-empty` style for "Fires immediately" / "No bundling" sentinels. Optional pencil action with arbitrary `data-*` attributes for routing edit forms to a specific tab. Steps may be a static array OR `(model) => array`. `setSteps(steps)` replaces the source and re-renders. CSS variable `--flow-strip-cols` overrides the default 4-column layout per instance.
- **Tests:** `test/unit/FlowStrip.test.js` adds nine smoke cases — render shape, missing-hint omission, num fallback, empty modifier, action button + `data-*` attrs, function-valued steps re-resolving, escape policy, empty-array no-op, `setSteps`.
- **Docs:** new `docs/web-mojo/components/FlowStrip.md` covers quick-start, state-driven flows, full step shape, the RuleSet triggering example, and column-count overrides.
- **Migration note:** the legacy `.rs-flow*` CSS in `admin.css` still backs `RuleSetView`'s current implementation. `RuleSetView` will switch to `FlowStrip` in a later wave; the old CSS will be removed at that point. New code should use `FlowStrip` directly.

### Timeline — new `@core` primitive

- **Added:** `src/core/views/data/Timeline.js` — vertical event-feed primitive (the `<ol>` with hairline connector and tone-colored dots used for incident history, job lifecycle events, recent-activity overviews, audit trails). Constructor: `items` (array OR `(model) => array`), `emptyText`, `limit`, `model`. Each item is `{ headline, detail?, when?, tone? }`. Falsy entries are filtered. Function-valued `items` re-resolve on every `render()` so the feed reflects the latest model state. Empty list renders a single `.detail-timeline-empty` placeholder so the rail still draws. `setItems(items)` replaces the source and re-renders.
- **Moved:** the `.detail-timeline*` and `.tone-*::before` CSS rules from `src/extensions/admin/css/admin.css` into `src/core/css/core.css`. Added a `tone-secondary` variant matching StatusPanel's palette and a `.detail-timeline-empty` rule for the empty-state fallback.
- **Tests:** `test/unit/Timeline.test.js` adds eight smoke cases — `<ol>` root, per-item tone class, optional detail/when omission, empty-state fallback, function-valued items resolving and re-resolving, the `limit` option, HTML-escaping of headline/when, and `setItems`.
- **Docs:** new `docs/web-mojo/components/Timeline.md` covers quick-start, options, item shape, common patterns (Job Lifecycle card, Recent activity), and the trusted-HTML rules for `detail`.

### StatusPanel — new `@core` primitive

- **Added:** `src/core/views/data/StatusPanel.js` — hero "current state" panel for record-detail views (the dot+state read-out / headline / meta line / action buttons that opens the Overview section in JobDetailsView, IncidentView, RunnerDetailsView, etc.). Constructor options `tone`, `state`, `headline`, `meta`, `icon`, `actions[]` each accept either a static value **or** `(model) => value` so the panel re-resolves on model state changes. Action buttons render with `data-action="<action>"` and dispatch via the standard MOJO action pipeline — handlers live on whichever ancestor reacts. Tones (`primary`, `success`, `info`, `warning`, `danger`, `secondary`) tint background + border via Bootstrap CSS variables; dark theme works automatically.
- **Moved:** the `.detail-status-panel` / `.detail-status-headline` / `.detail-status-state` / `.detail-status-line` / `.detail-status-meta` / `.detail-status-actions` / `.tone-*` CSS rules from `src/extensions/admin/css/admin.css` into `src/core/css/core.css` (the JS class is `@core`-level, so the styles must travel with it). Added `.detail-status-icon` for the optional Bootstrap-Icons-driven state icon, and a `tone-secondary` variant for inactive/cancelled records.
- **Tests:** `test/unit/StatusPanel.test.js` adds six smoke cases — render shape, dot vs icon, action row, function-valued options re-resolving, empty actions omission, HTML escaping of state / headline / labels.
- **Docs:** new `docs/web-mojo/components/StatusPanel.md` covering quick start, function-valued options, all constructor options, tones, and common patterns.

### DetailView — `auxFn` right-gutter slot + flat-row primitives

- **Added — `header.auxFn(model) -> htmlString`:** new optional slot on `DetailHeaderView` for inline state read-outs that don't fit the chip / badge model — presence dots, "Last seen 4m ago" lines, attempt counters, etc. Renders left of the active switch in the right-side action cluster. Returning falsy omits the wrapper. The output is **trusted HTML** (caller is in source code, not user input). Re-renders along with the rest of the header on `model.set(...)`. Framework ships `.dh-aux-presence`, `.dh-aux-dot` (`.is-online` modifier), and `.dh-aux-meta` defaults in `core.css`.
- **Added — flat-row primitives in `admin.css`:** `.detail-section`, `.detail-section-eyebrow`, `.detail-section-action`, `.detail-flat-row`, `.detail-flat-row-label`, `.detail-flat-row-value`, `.detail-flat-row-action`. The minimalist "labeled section eyebrow + flat field rows" pattern that section views in admin DetailView subclasses should default to going forward. Replaces stacked `.detail-field-card` blocks.
- **Deprecated:** `.detail-field-card`, `.detail-field-card-header`, `.detail-field-card-body`. Existing call-sites will be migrated off as part of the DetailView migration rethink (see `planning/requests/detailview-migration-rethink.md`). Don't add new uses.
- **Tests:** `test/unit/DetailView.test.js` adds three `auxFn` cases — wrapper present when truthy, omitted when falsy, re-rendered after `model.set`.

### Modal.detail() — default size flipped from `'xl'` to `'lg'`

- **Behavior change:** `Modal.detail(view)` now defaults to `size: 'lg'` instead of `size: 'xl'`. The previous width was too generous for typical record-detail content and ran wide of the reference layout. Pass `Modal.detail(view, { size: 'xl' })` (or `'xxl'`) when content genuinely needs more room — dense charts, multi-column dashboards, etc. RuleSetView and other in-tree DetailView callers fit comfortably at `'lg'`.

### EventDelegate — fix async double-dispatch across nested Views

- **Fixed:** when a click landed on a `[data-action]` element inside a nested View hierarchy, ancestor delegates could double-dispatch the same action — the inner delegate's post-`await` `event.stopPropagation()` was a no-op because the browser had already finished bubbling. Now each delegate publishes its in-flight dispatch on `event._mojoDispatch` synchronously at handler entry; ancestor delegates await it before their own `shouldHandle` check, so an inner truthy `onAction*` / `handleAction*` reliably stops the parent. Sync handlers benefit from the same fix (they raced too, just over a shorter window).
- **Contract preserved:** `onAction*` returning falsy still delegates the event up to ancestor Views; `onPassThruAction*` still never consumes; `handleAction*` still always consumes. No public API changes — the documented behavior just now works for async handlers as it always claimed to.
- **Tests:** `test/unit/EventDelegate.test.js` adds a `nested delegate isolation` block covering sync/async truthy consume, falsy delegate-up, `handleAction*`, `onPassThruAction*`, parent-only, and three-level nesting.

### RuleSetView — full redesign + supporting framework primitives

- **Redesigned:** `src/extensions/admin/incidents/RuleSetView.js` replaces the 2-tab `TabView` (Configuration / Rules) with a header card + `SideNavView`. Sections in operator-priority order: **Overview** (4 KPI cards + summary panels), **Conditions** (rule conditions table), **Triggering** (Match → Bundle → Threshold → Re-trigger as a 4-step visual flow with friendly empty-state copy in place of `—`), **Handler** (parsed handler chain rendered as icon cards with tone accents), **Agent Prompt** (new, see below), **Incidents** (`IncidentList` filtered by `rule_set` with a 7d/30d/90d range picker), **Metadata** (known fields + raw JSON, hidden when empty).
- **Added — `metadata.agent_prompt`:** new editable LLM agent prompt persisted on the RuleSet's metadata. The Agent Prompt section in `RuleSetView` shows a contextual hint based on whether `llm://` is in the handler chain. Saved via partial dotted-path `model.save({ 'metadata.agent_prompt': value })` (backend auto-merges JSONFields).
- **Form updates** in `src/extensions/admin/models/Incident.js`: `RuleSetForms.create` and `RuleSetForms.edit` gain an "Agent" tab with a `metadata.agent_prompt` textarea. The Thresholds tab is restructured from a cramped 3-across `columns: 4` row into a numbered step layout — each threshold field is full-width with its own inline label.
- **Header card** surfaces `metadata.reasoning` as the subtitle, an `assistant_proposed` indicator, an inline Active toggle (saves immediately, reverts on error), and a context menu including new actions `edit-agent-prompt` and `view-incidents`.

### SideNavView — badge support + dark-theme migration

- **Added:** Section configs accept an optional `badge` field — `number`, `string`, or `{ text, variant }`. Variants: `'muted'` (default), `'primary'`, `'success'`, `'warning'`, `'danger'`. The active section's `muted` badge automatically inverts to white-on-primary so it stays readable. Falsy values render no badge.
- **Added:** new instance method `sideNav.setBadge(key, value)` updates a section's badge dynamically without re-rendering the whole nav. Critical for live counts (Incidents, Conditions) populated after the section fetches.
- **Fixed (long-standing):** the inline `<style>` block was hardcoded light-theme hex literals (`#f8f9fc`, `#0d6efd`, etc.) with zero `[data-bs-theme="dark"]` overrides — the doc had claimed dark support that didn't exist. Migrated to Bootstrap tokens (`var(--bs-tertiary-bg)`, `var(--bs-body-color)`, `var(--bs-secondary-bg)`, `var(--bs-border-color)`, `var(--bs-primary)`) and added the missing dark-mode rules clustered at the bottom of the style block per `.claude/rules/theming.md`. Existing callers see no behavior change in light mode; dark-mode rendering now matches the documented behavior.

### SegmentControl — new component

- **Added:** `src/core/views/navigation/SegmentControl.js` — a small horizontal pill-button group bound to a single value. Constructor accepts `options: [{ value, label, icon? }, …]`, `value`, `size: 'sm'|'md'`, `ariaLabel`. Emits `change` with `{ value, previous }` on selection. Public API: `getValue()`, `setValue(value, { silent })`. Themed via Bootstrap `btn-primary` + `btn-outline-secondary` so dark-mode is automatic. Smoke tests in `test/unit/SegmentControl.test.js`.

### MetricCard — new component

- **Added:** `src/core/views/data/MetricCard.js` — at-a-glance KPI card (label / big value / optional icon / optional hint / optional tone left-border accent). Constructor accepts `label`, `value`, `icon`, `tone: 'default'|'success'|'warning'|'danger'|'info'|'primary'`, `hint`, `action`. When `action` is set the root renders as a `<button data-action="…">` so clicks flow through the standard MOJO action pipeline. Public API: `setValue(value)`, `setHint(hint)`. Themed via `var(--bs-tertiary-bg)`, `var(--bs-border-color)`, and `var(--bs-{tone})` so dark-mode is automatic. Smoke tests in `test/unit/MetricCard.test.js`.

### MOJOUtils — security: harden dot-path lookup against prototype-chain keys

- **Hardened:** `MOJOUtils.getNestedValue` and `DataWrapper.getContextValue` now return `undefined` for any path segment matching `__proto__`, `constructor`, or `prototype` (at every depth). The no-dot fast path no longer auto-invokes `Object.prototype` builtins (`toString`, `valueOf`, `hasOwnProperty`, `propertyIsEnumerable`, `isPrototypeOf`, `toLocaleString`) — calls are skipped when the function is reference-equal to the inherited builtin. The depth-≥1 inherited-method invocation branch was removed; nested inherited functions (e.g. `{{a.b.toString}}`) now resolve to `undefined` instead of auto-calling.
- **Robustness:** the walker uses `Object.prototype.hasOwnProperty.call(...)` so payloads with a shadowed `hasOwnProperty` field (e.g. `{ hasOwnProperty: 1, name: 'A' }` from an API) no longer break.
- **Custom methods unaffected:** view methods like `getStatus()` defined on a class subclass — own functions on a context literal — and user-overridden `toString` (different function reference than the builtin) all continue to auto-invoke at the top level. Existing `MOJOUtils.test.js` and `View-get.test.js` cases pass unchanged.
- **Follow-up landed:** the residual surface in `Mustache.Context.lookup` is now also closed (see entry below).

### Mustache — security: close residual prototype surface in `Context.lookup`

- **Hardened:** `Context.lookup` now blocks `__proto__` / `constructor` / `prototype` segments at the entry of the function (covering both the dot-prefix and non-prefix branches in one check). The dot-prefix single-segment fallback and the post-loop function-invocation now skip `Object.prototype` builtins via reference equality (`value === Object.prototype[lastSegment]`), so `{{toString}}` / `{{valueOf}}` / `{{hasOwnProperty}}` no longer auto-invoke and leak `"[object Object]"`-style data. The post-loop wraps the function call in `try`/`catch` that swallows **only** `TypeError: Class constructor X cannot be invoked without 'new'` — legitimate view-method exceptions still propagate to the caller (matches the `View-get.test.js:181` contract).
- **Before / after:** `Mustache.render('|{{__proto__}}|{{toString}}|', { name: 'A' })` was `'|[object Object]|[object Object]|'`, is now `'|||'`. `Mustache.render('|{{constructor}}|', { name: 'A' })` previously crashed with `TypeError: Class constructor DataWrapper cannot be invoked without 'new'`, now resolves to `'||'`. Pipes and pipe-stripped paths handled: `{{constructor|upper}}` is rejected the same as `{{constructor}}`.
- **Custom methods unaffected:** user-overridden `toString` (different function reference than `Object.prototype.toString`), class-defined view methods like `getStatus()`, and own functions on a context literal continue to auto-invoke. Eleven new test cases in `test/unit/MOJOUtils.getNestedValue.test.js` and `test/unit/Mustache-dot-prefix.test.js` lock in the safe behavior.

### Mustache — fix: dot-prefixed multi-segment paths inside iteration

- **Fixed:** `{{.foo.bar}}` (dot-prefix with two or more dotted segments) inside `{{#items}} ... {{/items}}` now resolves to the current iteration item's nested property. Previously the dot-prefix lookup branch did a single-key access on the joined name (`view['foo.bar']`), which always returned `undefined` for plain-object iteration items, so templates like `{{#merchants}}{{.group.name}}{{/merchants}}` rendered empty cells. The single-segment form `{{.rank}}` and the bare form `{{group.name}}` were already correct; only the dot-prefixed multi-segment form was broken. The fix delegates nested walks in the dot-prefix branch to the existing `MOJOUtils.getNestedValue` helper while keeping the walk scoped strictly to the current view (no parent-chain climb), so the leading-dot semantic is preserved.

### MetricsMiniChartWidget — fix: `{{now_value}}` showed yesterday's value when `trendOffset > 0`

- **Fixed:** `{{now_value}}` in the subtitle template now always reads from the latest bucket, independent of `trendOffset`. Previously it was shifted back by `trendOffset` buckets — so a widget with `trendOffset: 1` and a subtitle like `'{{now_value}} Today'` rendered yesterday's value next to the static "Today" label. The chart's tooltip on the rightmost bar showed the correct (today's) value, exposing the mismatch.
- **Behavior change for callers using `trendOffset > 0`:** `{{now_value}}` now jumps from "N back" to the latest bucket. Migration: callers who genuinely want the offset-shifted windowed sum should switch to `{{lastValue}}` (already documented; already respects `trendOffset` and `trendRange`). Callers using the default `trendOffset: 0` see no change.
- **Unchanged:** `trendOffset` still shifts the trending comparison window (`lastValue`, `prevValue`, `trendingPercent`) — that's its remaining purpose, and the original use case (skip an incomplete current bucket in trending math) still works.

### Charts — `apiParams` passthrough on metrics-aware components

- **Added:** `MetricsChart` and `MetricsMiniChart` accept an `apiParams: object` constructor option — a passthrough map for arbitrary `/api/metrics/fetch` query params the framework doesn't yet promote to first-class options. `MetricsMiniChartWidget` forwards the option through `chartOptions` to its inner mini chart.
- **Added:** `MetricsChart.setApiParams(next)` and `MetricsMiniChart.setApiParams(next)` runtime setters that replace (not merge) the map and refetch. Callers wanting a merge do `chart.setApiParams({ ...chart.apiParams, key: value })` explicitly.
- **Added:** `MetricsChart.getStats()` now reports `apiParams` (defensive copy).
- **Precedence:** `apiParams` is spread *first* into `buildApiParams`; hardcoded options (`granularity`, `account`, `slugs`, `category`, `dateStart`/`End`, `withDelta`, `childKind`, `breakdown`) overwrite anything that overlaps. The `_` cache-buster always wins. Empty / omitted `apiParams` produces query strings byte-identical to today — no impact on existing callers. Use `apiParams` as a base layer for forward-compatible / experimental keys, not as an override surface.
- **Note:** `apiParams` is purely a query-string mechanic. Constructor options with non-URL side effects (e.g. `withDelta: true` switches the default endpoint to `/api/metrics/series`) require the first-class option.
- **Trust boundary:** `apiParams` values land directly in the URL — treat as developer-controlled (same convention as `title:`). Never pipe user input through it without sanitizing at the call site.

### Charts — group fan-out (parent rollup + per-child breakdown)

- **Added:** `MetricsChart` accepts `childKind: string` and `breakdown: boolean` constructor options that drive Modes 2 and 3 of `/api/metrics/fetch`. With `childKind` set on a `account: 'group-<id>'` chart, the backend sums the metric across all active descendants of that kind (Mode 2 — same response shape as Mode 1). Add `breakdown: true` and the backend returns one series per child group plus a `groups` map (name → child id) for drill-in (Mode 3 — single slug only).
- **Added:** `MetricsChart.setChildKind(kind)` and `setBreakdown(flag)` runtime setters that refetch (mirroring `setGranularity` / `setMetrics`). `getStats()` now reports `childKind` and `breakdown`.
- **Added:** the `metrics:data-loaded` event payload now includes a `groups` field — `null` in Modes 1 and 2, populated in Mode 3. The map is also cached as `this._lastGroups`. In breakdown mode the chart uses raw response keys (e.g. `Downtown` / `Downtown#15` for collisions) verbatim — no slug-style title-casing.
- **Added:** `MetricsMiniChart` and `MetricsMiniChartWidget` accept the same `childKind` option for Mode 2 rollups. Mode 3 (`breakdown`) is intentionally not supported on the mini variant — sparklines are single-series; for a per-child breakdown use a row of mini charts or a `KPIStrip`.
- **Docs:** new "Group fan-out" subsection in `docs/web-mojo/extensions/Charts.md`; option entry added to `docs/web-mojo/extensions/MetricsMiniChartWidget.md`. The `MetricsChartExample` portal page now demonstrates Mode 2 and Mode 3 with stubbed data so the patterns are interactive without a backend.

### TableView — `fetchOnView` auto-refreshes model before detail dialog

- **Added:** `fetchOnView` option (default `true`). When enabled, TableView calls `model.fetch()` before opening the view dialog, ensuring the detail view always has the latest server data. Set to `false` to use the row data as-is. Skipped when a custom `onItemView` handler is provided.

### Ticket panel — app-shell slide-over with action blocks

- **Added:** `registerTicketPanel(app)` — new registration helper that attaches `app.openTicketPanel(modelOrId)` and `app.closeTicketPanel()` to the running app instance. The panel (`TicketPanelView`) mounts as a flex child of `.portal-layout` (like `AssistantPanelView`) and persists across page navigation. `openTicketPanel` accepts either a Ticket model instance OR an id; the table passes the model directly so the panel and table share the same instance and updates propagate without a re-fetch.
- **Changed:** `TicketTablePage` now delegates to `app.openTicketPanel(model)` instead of managing the panel internally. This moves the panel to the app-shell level so it survives route changes. The table also got mockup-style styling — colored status pills, severity-colored priority chips, category dot+label, monospace IDs, single hairline borders — via custom function formatters and scoped CSS in `buildTemplate()`.
- **Changed:** `TicketPanelView` panel UI cleaned up — the AI-enable toggle was removed from the panel header and moved to the ticket edit form (`TicketForms`). The kebab `⋯` menu item changed from "Close Ticket" (which prompted a status change to `closed`) to "Close Window" (which dismisses the panel). The panel no longer shows a standalone close button in the header. The kebab menu now also includes "Ask AI" which opens an Assistant chat scoped to the current ticket.
- **Added:** `TicketPanelView` — 460 px slide-over detail view. Supports switching between tickets without closing the panel. Header has inline-editable status / priority / category / assignee / group dropdowns (each picks unique action ids — `pick-0`, `pick-1`, ... — so `ContextMenu`'s `find()`-by-action correctly resolves the clicked item). Saves go through `_saveAndSync()` which `save()` + `fetch()`-es the model and refreshes notes (so backend-side `metadata.type === 'status_change'` notes appear immediately).
- **Added:** Description chip in the panel header. Tickets with a description show "Description" — click to view rendered markdown with `Edit` / `Close` buttons. Tickets without a description show "Add description" — click jumps straight to edit. The edit modal is a large textarea with markdown shortcuts (`Cmd/Ctrl+B` bold, `Cmd/Ctrl+I` italic, `Shift+Enter` continues lists, ``` ``` ``` opens a code fence, bracket auto-pairing). Description was removed from `TicketForms.edit.fields` since it now has its own editor.
- **Added:** `ActionCardView` — renders LLM agent action blocks from ticket notes inline directly under each note. Approval-type blocks show Approve / Deny buttons; resolved blocks render a compact one-line chip (label + Approved/Denied badge + chevron) that expands per-note on click to show the full card with reference links; context-type blocks show clickable model-reference links. Emits `action:respond` when the user responds; `TicketPanelView` writes the response back as a new note via `TicketNoteAdapter.addActionResponse`.
- **Changed:** `TicketNoteAdapter.transform` now also detects `metadata.type === 'status_change'` and renders the note as a muted system-event row with colored badges for `old_status` → `new_status`. System-event content skips the markdown-render pass so the badge HTML isn't escaped. `ChatMessageView`'s system-event template uses `{{{message.content}}}` (unescaped) so pre-rendered HTML lands intact.
- **Added:** `TicketNoteAdapter.addActionResponse(actionNote, action)` — convenience method that posts an approve/deny response note with the correct metadata shape.

### TableRow — `editable + formatter` cells now work

- **Changed:** `TableRow.buildCellTemplate` adds the `cell-content` class to the wrapper span when a column is `editable: true` — even when a `formatter` (string or function) or `template` is set. Previously these branches skipped the wrapper, so `enterEditMode()`'s `querySelector('.cell-content')` came back empty and silently no-op'd. Tables can now combine custom rendering (e.g. status pills) with inline editing.

### ChatMessageView — cleaner re-renders

- **Fixed:** the attachments container is cleared and prior `FilePreviewView` children are removed before re-rendering, so files no longer multiply on each re-render of a message.
- **Changed:** the `system_event` body uses `{{{message.content}}}` (unescaped) so callers can inject pre-rendered HTML (e.g. status-change badges).

### WebApp — MODEL_REF registry (`registerModelRef` / `getModelByRef`)

- **Added:** `app.registerModelRef(ref, ModelClass)` and `app.getModelByRef(ref)` — a registry that maps backend dotted-type strings (e.g. `'incident.Incident'`) to frontend Model classes.
- **Added:** `MODEL_REF` static property convention on Model classes — the string that identifies the class in the backend type system (analogous to `VIEW_CLASS`).
- **Changed:** `registerAdminPages` now calls `app.registerModelRef` for `Incident`, `IncidentEvent`, `RuleSet`, `Ticket`, and `GeoLocatedIP` automatically. Consumer apps do not need extra wiring for these types.

### Sidebar & TopNav — `iconHtml` field on nav items

- **Added:** `iconHtml` field on Sidebar and TopNav item configs. When set, the raw HTML string is rendered (triple-brace, unescaped) in place of the `icon` Bootstrap Icon. This allows custom SVG images or other HTML in sidebar/topbar item icons. `icon` remains the preferred option for Bootstrap Icons.

### Admin Assistant — context reference blocks in chat messages

- **Added:** `AssistantMessageView` now renders `block.type === 'context'` blocks as clickable model-reference chips inline in chat messages. Each reference in the block's `references` array renders as a compact chip with the model label and instance display name. If the referenced model is registered via `app.registerModelRef` and declares a `VIEW_CLASS`, clicking the chip opens the detail dialog (`Modal.showModel`). Unregistered or unknown model types render as plain text. All user-controlled fields are HTML-escaped; the `pk` value is validated against `/^\d+$/` before use to prevent XSS.

### Admin Assistant — renamed to "Mojo"

- **Changed:** The AI assistant is now displayed as "Mojo" throughout the admin extension — `AssistantPanelView`, `AssistantView`, `AssistantContextChat`, `AssistantConversationView`, `ChatMessageView`, and `AssistantMemoryPage` all use "Mojo" as the author name and panel title. The permission label in `User.CATEGORY_PERMISSIONS` changed from "AI Assistant" to "Mojo". The `assistant` permission key is unchanged.
- **Changed:** Assistant avatar in `ChatMessageView` and the welcome icon in `AssistantPanelView` / `AssistantView` changed from the `bi-robot` Bootstrap Icon to the Mojo logo image.

### MetricsChart / MetricsMiniChartWidget — granularity in stats header

- The stats modal now shows the granularity and bucket count above the
  table (e.g. "Hourly · 24 points"). Makes it clear what window the
  stats are computed over without having to look back at the chart.

### MetricsChart — collapsible secondary toolbar

- **Changed:** the secondary toolbar (gear, type switch, stats, data, refresh)
  now collapses behind a kebab `⋯` trigger and slides into view on hover or
  focus-within of the cluster. The granularity toggle stays visible since
  it's the primary control. Reduces visual clutter when the chart isn't
  being interacted with.
- The kebab uses `btn-link` styling (transparent border / background) so
  it reads as a quiet trigger rather than a button.
- Pure CSS — `max-width` + `opacity` transition, no JS. Touch-friendly:
  tapping the kebab focuses it, `:focus-within` reveals the cluster, and
  the cluster stays visible while focus remains inside (e.g. while a
  modal opened from a cluster button is interacted with).

### MetricsChart / MetricsMiniChartWidget — stats modal + data table modal

- **Added:** stats modal — click the `bi-info-circle` toolbar button to
  open a modal showing `Latest / Min / Max / Avg / Median / Sum / Count`
  over the chart's currently-loaded data. `MetricsChart` shows one row
  per dataset; `MetricsMiniChartWidget` shows a single set since it's
  one series.
- **Added:** data table modal — click the `bi-table` toolbar button to
  open a modal with the chart's labels and values as a sortable table,
  plus a "Download CSV" button. Filename is `<title-slug>-<YYYY-MM-DD>.csv`.
- Both opt-out via `showStats: false` / `showDataTable: false`. Default
  on; auto-suppressed in `MetricsChart`'s `compactHeader` mode.
- `MetricsChart` now caches the most recent processed payload
  (`{labels, datasets}`) on `_lastChartData` so the stats and data-table
  modals can read it without reaching into the SeriesChart child's
  private state.

### MetricsChart — inline granularity toggle

- **Changed:** granularity selection moved out of the gear dropdown into
  an inline Yahoo-style toggle (`MIN HR DAY WK MO`) in the chart header.
  Quiet styling — text-only buttons with subtle hover/active background,
  selected option in `--bs-body-color` and bold weight. One click instead
  of two.
- **Responsive:** below 560px container width (CSS container query), the
  inline toggle automatically swaps to a compact native `<select>` styled
  to match the toolbar's `btn-sm` height (31px). Same granularity values,
  same action wiring; the two surfaces stay in sync. Zero JS — the
  breakpoint is container-aware (not viewport-aware), so charts in narrow
  dashboard columns get the dropdown even on a wide screen.
- The gear menu now contains only the Date Range section (quick ranges +
  Custom Range dialog). It's auto-suppressed when no items remain.
- **Added:** `inlineGranularity` option (default `true`). Pass `false`
  to revert to the old gear-menu-only flow.
- **Added:** `shortLabel` field on `granularityOptions`. Defaults:
  `minutes → MIN`, `hours → HR`, `days → DAY`, `weeks → WK`, `months → MO`.
  Override per option to customize.
- `compactHeader` mode disables the inline toggle automatically (it was
  already suppressing the rest of the toolbar).

### MetricsChart / SeriesChart — refresh button + x-axis label fallback

- **Added:** `MetricsChart` now renders a refresh button in its header
  toolbar by default, matching the convention from `MetricsMiniChartWidget`.
  Opt-out with `showRefresh: false`. Suppressed automatically in
  `compactHeader` mode.
- **Fixed:** `SeriesChart` x-axis labels disappeared when the backend
  returned pre-formatted strings (e.g. `"16:00"`) and `xLabelFormat`
  was set to a `time:`/`date:` pipe. The DataFormatter pipe couldn't
  parse the already-formatted string, returned empty, and every tick
  rendered blank. `_formatXLabel` now falls back to the raw label when
  the formatter produces an empty result. This unblocks the
  `MetricsChart` defaults for `granularity: 'hours'` and `'minutes'`.

### MiniChart — bar charts baseline at zero

- **Fixed:** bar charts now baseline at zero so minimum-value bars are
  always visible. Previously a series like `[3,3,4,3,4]` rendered every
  value-3 bar with `height=0` because the auto-calculated bounds used the
  data minimum as the baseline. Negative-only and mixed-sign series are
  now also handled correctly (bars hang from / grow up from a zero line).
- **Fixed:** when every value is zero, the chart renders a thin dashed
  baseline at the chart bottom in the chart color (low opacity) instead of
  rendering nothing, so the card communicates "alive, just zero" rather
  than looking broken. Suppressed when any of `minValue`/`maxValue`/
  `softMin`/`softMax` is set.
- **Fixed:** out-of-range bars (caller-supplied `maxValue` smaller than
  `dataMax`, etc.) are clamped to the drawable area instead of producing
  negative or off-canvas heights.
- **Added:** `softMin` / `softMax` options on `MiniChart` (and pass-through
  on `MetricsMiniChartWidget`). Soft bounds: bars normalize to the soft
  target, but the bounds expand if data exceeds it. Distinct from
  `minValue`/`maxValue`, which are hard crops. Bar charts only.
- Caller-supplied `minValue` continues to behave as a hard crop —
  callers who explicitly cropped to a non-zero floor still get that.
- No changes to line charts, animation, tooltips, or x-axis.

### TimePicker / DateTimePicker — completes the picker rebuild

- New `timepicker` field type — HH:MM stepper with hour and minute
  columns, `▲`/`▼` buttons, and direct numeric typing on the value.
  Supports `format: '24h' | '12h'` (AM/PM toggle in 12h mode),
  `step` (minute increment, e.g. `15`), and `min`/`max` clamping.
- New `datetimepicker` field type — Calendar on the left, time
  stepper on the right, optional IANA timezone stacked full-width
  below in one popover. Single field type with `timezone: false |
  true` toggle (locked mockup variant A — single field type, the
  TZ slot is part of the picker, not a separate field).
- Optional IANA timezone selector built on the existing `ComboBox`,
  populated from `Intl.supportedValuesOf('timeZone')` with a curated
  ~50-zone fallback. Default value is the user's local zone via
  `Intl.DateTimeFormat().resolvedOptions().timeZone`. Inner field
  name defaults to `'timezone'` so it lines up with backend
  expectations.
- Time is **always stored as 24h canonical `'HH:MM'`** regardless of
  display format. With timezone, the default storage is ISO-style
  `'HH:MM±HH:MM'` (e.g. `'14:30-07:00'`); legacy `'HH:MM IANA/Zone'`
  is opt-in via `outputFormat: 'iana'`; `outputFormat: 'object'`
  yields `{ time, timezone }`.
- DateTime defaults to **ISO 8601** — `'2026-05-04T14:30:00'` without
  timezone, `'2026-05-04T14:30:00-07:00'` with timezone. Backends
  expecting JSON / Postgres-style timestamps handle this directly.
  `outputFormat: 'iana'` falls back to `'YYYY-MM-DD HH:MM IANA/Zone'`,
  and `outputFormat: 'object'` yields `{ date, time, timezone? }`.
- New `ianaOffset(zone, refDate)` helper resolves an IANA zone to its
  current `±HH:MM` offset (DST-aware via `Intl.DateTimeFormat`).
- `parseDateTime` now also accepts ISO 8601 strings with offset
  (`'2026-05-04T14:30:00-07:00'`, `'…Z'`, `'…+05:30'`) and round-trips
  the offset back through the picker.
- New utilities in `dateFns.js`: `parseTime`, `formatTime`,
  `compareTime`, `addMinutes`, `parseDateTime`, `formatDateTime`,
  `formatDateTimeForDisplay`.
- Reuses `CalendarPopover` so the popover portal-mounts to
  `document.body` and escapes clipping containers (modals,
  overflow:hidden tables).
- Bootstrap-tokened theming. Light + dark from day one.
- Docs: new `docs/web-mojo/forms/inputs/TimePicker.md` and
  `DateTimePicker.md`. Field-type tables and basic-types pointer
  notes refreshed.
- Examples: new `TimePicker` and `DateTimePicker` example pages, and
  the `DateTimeSuite` showcase grew two new cards covering the new
  components.

### DatePicker / DateRangePicker — in-house Calendar engine

- Replaced the prior Easepick-based pickers with an in-house `Calendar`
  view that supports day, month, and year precision via a single
  `precision` option. Same engine, three precisions — drill-down zoom
  is the precision system (header click → month grid → year grid).
- New field-type aliases: `monthpicker`, `yearpicker`, `monthrange`,
  `yearrange`. Existing `datepicker` and `daterange` configs keep
  working unchanged with `precision` defaulting to `'day'`.
- DateRangePicker — best-in-class range selection: continuous range
  fill, inward chevron anchors on start/end day cells, hover preview
  between anchor and cursor, and **cross-page anchor persistence**
  (click start in May, page to June, click end — no anchor loss).
  Backwards selection auto-swaps start/end. Two-month side-by-side
  default at day precision.
- Optional Stripe-style **preset sidebar** via `presets: 'default'`
  with sensible defaults per precision (Today, Last 7 / 30 / 90 days,
  This month, YTD, Last 12 months, etc.) or a custom array.
- No runtime CDN dependency, no native HTML5 fallback branch,
  uniform behavior across all modern browsers.
- Bootstrap-tokened theming. Light + dark themes from day one — the
  calendar reads `[data-bs-theme]` from the document root.
- New low-level utilities: `src/core/utils/dateFns.js` (parse / format
  / compare / span counts at all three precisions; not a general-
  purpose date library).

### TableView — Column `align` property

- New `align` property on column definitions: `'left'`, `'center'`,
  or `'right'` (with `'start'`/`'end'` aliases). Applied to the
  header `<th>`, body `<td>`, and footer total cell in lockstep so
  the column reads as a single visual unit.
- Footer cells now default to **left** alignment (previously hard-
  coded right). Set `align: 'right'` on numeric `footer_total`
  columns to restore the old right-aligned look.

### Charts — `MetricsMiniChartWidget.setAccount(account)` (and `MetricsMiniChart.setAccount`)

- New `setAccount(account)` method on both `MetricsMiniChartWidget`
  and the inner `MetricsMiniChart`. Mutates the account context AND
  triggers a refetch in one call — callers no longer have to
  remember to call `refresh()` after assigning a new account.
  Returns the underlying fetch promise so
  `await widget.setAccount(...)` works.
- Aligns with the existing `setGranularity` / `setDateRange` /
  `setMetrics` shape on `MetricsMiniChart`.
- **Bug fix:** `MetricsMiniChartWidget.refresh()` and
  `onActionRefreshChart()` previously gated account propagation on
  `this.account`, which is never set (the constructor stores it as
  `this.chartOptions.account`). They now read from
  `this.chartOptions.account`, so manual refreshes correctly carry
  the most recent account assignment.

### CSS — UserProfile extension: dark theme coverage + consolidated stylesheet

- Every view in the `user-profile` extension (`UserProfileView`, the
  12 `Profile*Section` views, and `PasskeySetupView`) now renders
  correctly under `[data-bs-theme="dark"]`. Previously the dialog
  rendered against hardcoded white surfaces (~99 hex declarations
  across 14 files, zero dark overrides) — the extension predated the
  framework's dark-theme cleanup.
- **New stylesheet:** `src/extensions/user-profile/css/user-profile.css`
  consolidates the 12 inline `<style>` blocks previously emitted from
  each view's `getTemplate()`. Light-theme values are byte-identical
  to before. A `[data-bs-theme="dark"]` cluster at the bottom adds
  dark companions following the chat.css / portal.css / admin.css
  convention. Auto-imported from `src/extensions/user-profile/index.js`.
- **Inline `style="…"` attributes** that paint surfaces (icon tints,
  hero gradient circles, helper paragraphs, the TOTP secret-key code
  block, the QR border, the Sessions/Devices "label: value" detail
  rows) are now class-based so the same surfaces can carry light +
  dark variants without `!important`. New shared utility classes:
  `.up-hero-circle-primary`, `.up-hero-circle-success`,
  `.up-help-text`, `.up-help-text-bottom`, `.up-secret-code`,
  `.up-qr-image`, `.up-qr-hint`, `.up-detail-row`, `.up-detail-label`,
  `.up-detail-value`, `.ps-icon-purple`, `.ps-icon-danger`,
  `.po-danger-zone`.
- **Selector names preserved verbatim** — apps that override the
  extension's CSS continue to work. No public API changes.
- **Light theme is byte-identical.** No template-structure changes,
  no JS behavior changes.
- **Known-deferred:** the `--bs-{warning,success,danger,info,primary}-bg-subtle`
  tokens are still leaking light-mode values into dark theme via
  `core.css`'s unscoped base `:root` block (separate issue). Status
  pills (`*-badge-warn` / `*-badge-ok` / `pak-warning` / `rc-warning`
  / `pak-result` etc.) in this fix use hand-tuned `rgba()` literals
  rather than reaching for those subtle tokens. Once the leak is
  fixed, those literals are good candidates for a follow-up cleanup.

### Feature — TopNav: `theme: 'auto'` follows the global dark/light preference live

- New value `'auto'` for `TopNav`'s `theme` and `shadow` constructor
  options. With `theme: 'auto'`, the navbar resolves to `'light'` or
  `'dark'` at construction time by reading `<html data-bs-theme>`
  (default `'light'` if unset), then installs a `MutationObserver` on
  `<html>` so subsequent `data-bs-theme` flips swap the
  `navbar-light` / `navbar-dark` / `topnav-light` / `topnav-dark`
  class tokens live. `shadow: 'auto'` follows the same rule for
  `topnav-shadow-light` / `topnav-shadow-dark`.
- The class swap is surgical — only the framework-managed theme
  tokens are removed/added, so any consumer-supplied classes on the
  navbar element are preserved.
- The observer is disconnected automatically in `onBeforeDestroy()`.
- `'clean'` and `'gradient'` remain static themes — `'auto'` only
  resolves to `'light'` or `'dark'`. Apps that want a clean-style
  topbar that follows dark mode should build it with custom CSS.
- **Migration:** apps that were syncing the topbar theme by hand
  (reading `<html data-bs-theme>` at boot + installing their own
  `MutationObserver` to swap `navbar-light`/`navbar-dark` etc.) can
  drop that helper and pass `theme: 'auto'` / `shadow: 'auto'`
  instead.

### Behavior — PortalApp: collapsed theme menu into a single `Theme settings` entry

- The auto-injected topbar usermenu used to add **three rows** (Theme:
  Light / Theme: Dark / Theme: System) and re-render the topbar on
  every preference change to keep the active mark in sync. It's now a
  **single row** — `Theme settings` (icon `bi-palette`, action
  `theme-settings`) — that opens a small dialog with the three radios
  wired to `app.setTheme()`. The dialog is the same shape consumers
  were already building by hand (see the previous example portal's
  `openDisplaySettings()` helper).
- **New public method:** `app.showThemeSettings()` returns the
  underlying `Modal.dialog` promise. Wired to the auto-injected
  menu item, but also callable from any consumer hook (sidebar gear
  icon, slash command, etc.).
- **Backward compat:** the legacy `theme-light` / `theme-dark` /
  `theme-system` `portal:action` cases still work — apps that wired
  their own buttons to those actions don't need to change. The
  framework's auto-injected menu just no longer emits them.
- **Opt-out unchanged:** set `topbar.themeToggle: false` to skip the
  auto-inject entirely.
- **Removed:** the private `_refreshThemeToggleActiveState()` helper
  and the `theme:changed` topbar re-render listener (a single item
  has no active mark to track).
- The example portal (`examples/portal/app.js`) was updated to remove
  its own `Settings` userMenu row and `bi-sliders` topbar gear, both
  of which duplicated the framework's auto-injected entry.

### Feature — Extensible `User` permission registry

Two related fixes that make the `User` permission registry behave correctly when apps extend it. Both bugs had the same root cause: framework-only state computed once at module load with no recompute path, so app-level mutations after import had no effect on the UI or the gate-checker.

**1. `User.registerCategoryMap()` — app categories implicitly satisfy app granular gates**

The user-permission gate already falls back from a granular permission to its parent *category* via `User.GRANULAR_TO_CATEGORY` — so a user holding `permissions.security` automatically satisfies a page gated `permissions: ["view_security"]`. But `GRANULAR_TO_CATEGORY` was built once from the framework-only `User.CATEGORY_GRANULAR_MAP` with no extension point, so app categories registered through `User.APP_CATEGORY_PERMISSIONS` could never satisfy app granular gates. Apps had to list both names in every gate array.

- **New** `User.registerCategoryMap(map)` — apps register their own category → granular relationships. Pass an object of the same shape as `CATEGORY_GRANULAR_MAP`:
  ```js
  User.registerCategoryMap({ app_cat: ['view_app_thing', 'manage_app_thing'] });
  ```
  Merges into `User.CATEGORY_GRANULAR_MAP` (extending existing categories without dropping prior entries) and triggers `rebuildPermissions()`. After registration, a user with `permissions.app_cat === true` passes a gate of `permissions: ["view_app_thing"]` automatically.
- No change to `User.hasPermission` / `_hasPermission` — they already consult `GRANULAR_TO_CATEGORY`.
- `Member.hasPermission` still does literal-only matching (no category fallback) — pre-existing, separate.

**2. `User.rebuildPermissions()` — UI picks up extended permission tabs and "App" tabset**

`User.PERMISSIONS`, `User.PERMISSION_FIELDS`, `User.CATEGORY_PERMISSION_FIELDS`, and `User.GRANULAR_PERMISSION_FIELDS` were computed by IIFEs at module-load time and never re-read their source arrays. Apps documented to extend `User.GRANULAR_PERMISSION_TABS.push(...)` / `User.APP_CATEGORY_PERMISSIONS.push(...)` / `User.CATEGORY_GRANULAR_MAP.x = [...]` after import saw no change in the rendered "Permissions" or "Adv Permissions" forms — the cached field arrays were already frozen.

- **New** `User.rebuildPermissions()` — recomputes every cached structure from the live source arrays. Idempotent. Apps call it once after their registry edits:
  ```js
  User.GRANULAR_PERMISSION_TABS.push({
      label: 'Custom',
      permissions: [
          { name: 'view_app_thing',   label: 'View App Thing' },
          { name: 'manage_app_thing', label: 'Manage App Thing' }
      ]
  });
  User.APP_CATEGORY_PERMISSIONS.push({ name: 'app_cat', label: 'App Category' });
  User.CATEGORY_GRANULAR_MAP.app_cat = ['view_app_thing', 'manage_app_thing'];

  User.rebuildPermissions();
  ```
- **Mutates caches in place** so existing references stay live. `UserForms.permissions.fields` (which captures `User.PERMISSION_FIELDS` at module-load) reflects post-rebuild updates without re-import.
- Replaces the previous IIFE-built initial state — there's now a single `User.rebuildPermissions()` call at the bottom of `User.js` instead. Initial behavior is identical to before.
- `User.registerCategoryMap()` calls `rebuildPermissions()` automatically; apps mutating `CATEGORY_GRANULAR_MAP` directly should call it themselves.

**3. `User.registerPermissions()` — atomic one-shot extension API**

Higher-level wrapper for apps that want to declare every extension in a single call rather than push to four separate arrays:

```js
User.registerPermissions({
    categories:           [{ name: 'app_cat', label: 'App Category' }],
    granularPermissions:  [{ name: 'app_perm', label: 'App Perm' }],
    granularTabs:         [{
        label: 'Custom',
        permissions: [{ name: 'view_app_thing', label: 'View App Thing' }]
    }],
    categoryGranularMap:  { app_cat: ['view_app_thing'] }
});
```

All four keys are optional. Arrays append, the map merges + dedupes, then `rebuildPermissions()` runs once. Equivalent to the imperative pattern but with no chance of forgetting the rebuild.

**4. `User._permSwitch` — exposed field builder**

The internal `_permSwitch(p) → { name: 'permissions.<p.name>', type: 'switch', ... }` helper is now `User._permSwitch` so apps building custom permission forms can use it directly and stay aligned with the framework's switch-field shape (no copy-paste drift if the shape evolves).

### Feature — `Member` permission registry parity

`Member.PERMISSIONS` had no extension point and was a literal source array — apps wanting custom member permissions had to redefine it themselves, breaking on every framework update that added new entries. Mirroring the `User` treatment:

- **New** `Member.BASE_PERMISSIONS` — the framework-defined list (renamed from the old `Member.PERMISSIONS` source).
- **New** `Member.APP_PERMISSIONS` — empty array; apps push their own entries here.
- **New** `Member.rebuildPermissions()` — recomputes `Member.PERMISSIONS` and `Member.PERMISSION_FIELDS` from `BASE_PERMISSIONS + APP_PERMISSIONS`. Idempotent. Mutates caches in place so cached references (e.g. forms holding `Member.PERMISSION_FIELDS`) stay current.
- `Member.PERMISSIONS` and `Member.PERMISSION_FIELDS` are now live caches; reading them works exactly as before. Any code that *wrote* directly to `Member.PERMISSIONS` should switch to `APP_PERMISSIONS` (or `BASE_PERMISSIONS` for framework-level edits).
- `Member.hasPermission` is unchanged — still does literal-only matching with no category fallback (Member has no category concept; this matches existing behavior).

**Tests / loader**

- New `test/unit/User.test.js` (17 tests) covers: granular→category fallback, `registerCategoryMap` merge semantics + array-form gates + superuser bypass, `rebuildPermissions` picking up extended granular tabs, the "App" tabset appearing when `APP_CATEGORY_PERMISSIONS` is non-empty, `CATEGORY_GRANULAR_MAP` updates flowing through to `GRANULAR_TO_CATEGORY`, idempotency, in-place mutation preserving held references, and `registerPermissions` atomic registration end-to-end (including the gate-check round-trip).
- New `test/unit/Member.test.js` (6 tests) covers: initial `BASE_PERMISSIONS` exposure through the live cache, switch-field shape, `APP_PERMISSIONS` pickup, in-place mutation invariant, idempotency, and the literal-matching contract for `Member.hasPermission`.
- `test/utils/simple-module-loader.js` gained `User` and `Member` entries with fallback returns, and now handles aliased named imports (`import { X as Y } from ...`) and unresolved relative named imports (declaring locals as `undefined` so module-load doesn't ReferenceError when names appear only in metadata literals).

### Feature — Examples: landing page, legacy removal, automated example tests

- **`examples/index.html` (new)** — visiting `http://localhost:3000/examples/` is no longer a blank page. A static landing card-grid links to the **Examples Portal** (canonical demos) and the standalone **Auth** login flow. No JS, no module imports — works even if the framework build is broken.
- **`examples/legacy/` removed** — the previous portal and one-off HTML demos (frozen on 2026-04-25) are deleted from the working tree. Git history preserves blame; there's nothing to port from. References in `examples/portal/README.md` are gone; references in `planning/done/*.md` and historical CHANGELOG entries are intentionally left as-is (historical record).
- **Static import-symbol check (`test/build/examples-imports.test.js`)** — runs in `npm test` and `npm run test:build`. For every `*Example.js` under `examples/portal/examples/`, it parses the `import` statements and verifies each named symbol from `'web-mojo'` / `'web-mojo/<sub>'` is actually exported by the corresponding source-tree entry. Catches "this example imports a symbol that no longer exists" without booting a browser.
- **Headless smoke runner (`scripts/test-examples-smoke.js`, `npm run test:examples`)** — opt-in, NOT wired into the default test run. Boots Vite on an ephemeral port, launches Chromium via Playwright, visits every registry route, and fails on `pageerror` or unhandled rejections. One-time setup: `npx playwright install chromium`. `playwright` is now a `devDependency`.

### Feature — Modal chrome: stripe + outline icon + tint (typed alerts only)

Pivoted away from the eyebrow band entirely. The 28px colored slab + uppercase tracking-letterspaced label was a 2015-2018 dev-tool aesthetic that aged poorly; modern reference apps (Linear, Stripe, Notion, Vercel, Apple HIG, Material 3) use minimal default chrome and reserve type signaling for typed alerts only.

**New design — see [`planning/mockups/modals/10-stripe-icon-tint.html`](planning/mockups/modals/10-stripe-icon-tint.html):**

- **Default modals** (`Modal.dialog`, `Modal.show`, `Modal.confirm`, `Modal.prompt`, `Modal.form`, `Modal.modelForm`, `Modal.showModel*`) — stock Bootstrap 5 cards. No stripe, no tint, no extra chrome. Header/footer dividers were already removed.
- **Typed alerts only** (`Modal.alert` with `type: 'info' | 'success' | 'warning' | 'error'`) get three layered cues, all driven by `--mojo-current-accent`:
  1. **4px top accent stripe** in the type color (Stripe-style; hugs the card's inner radius).
  2. **Outline leading icon** — `bi-info-circle` / `bi-check-circle` / `bi-exclamation-triangle` / `bi-x-circle` — sitting next to the title in the header. Color follows the type accent.
  3. **Soft full-card tint** — 5% type color in light mode, 10% in dark, applied as a top-to-bottom gradient.
- New `Modal.alert` option: `icon: 'bi-...'` to override the default icon, or `icon: null` to suppress it.
- Type-colored primary button preserved (red band → red OK button, etc.).

**Removed plumbing (was unreleased — no migration required):**

- `ModalView` constructor option `eyebrow` (string / `false` / `null`) — gone.
- `Modal.setEyebrowEnabled(boolean)` / `Modal.isEyebrowEnabled()` static helpers — gone.
- CSS classes `modal-bandless` and `mojo-no-eyebrow` — gone.
- Internal helpers `Modal._eyebrowStyle`, `Modal._resolveEyebrow`, `Modal._suppressDuplicateTitle` — gone.
- `eyebrow` parameter on `Modal.dialog` / `Modal.alert` / `Modal.confirm` / `Modal.prompt` / `Modal.form` / `Modal.modelForm` / `Modal.show` / `Modal.showModelView` — gone.
- CSS variables `--mojo-eyebrow`, `--mojo-current-eyebrow-fg`, `--mojo-current-tint` — gone (the band's `::before` content var, the eyebrow text color, and the separate tint var).
- The 28px band-clearance padding rules for `.modal-header` and `.modal-body:first-child` — gone (no band to clear).

`Modal.drawer` keeps its own `eyebrow` option — that's a separate concept (a small uppercase label inside the drawer's custom header markup, unrelated to the band).

### CSS — Sidebar group selector: clean stock card

- `Sidebar.showGroupSearchDialog()` no longer passes an `eyebrow` to its `ModalView`. The selector renders as a clean Bootstrap card with the search header, group rows, and footer count — same as before, just without the band.

### CSS — User profile dialog: drop the hardcoded blue gradient strip

- `UserProfileView` had its own `.up-accent` element painting a blue 4px linear gradient at the top of the dialog (independent of the framework's modal chrome). Removed — the user profile now reads as a clean default modal, consistent with the new design system.

### Feature — Examples portal: simplified display settings

- The Display settings dialog drops the "Show eyebrow band" toggle (no eyebrow to toggle anymore). Theme picker (Light / Dark / System) is unchanged. The `examples-portal:eyebrow` localStorage key is no longer read or written.

### Mockups — `planning/mockups/modals/`

- **08-stripe-minimal.html** — 4px stripe only, no icon, no tint.
- **09-stripe-and-icon.html** — 4px stripe + leading icon, no tint.
- **10-stripe-icon-tint.html** — full pattern (stripe + outline icon + tint). **Implemented.**
- **11-icon-only.html** — Apple HIG / Material 3 icon-badge alternative.
- Each mockup demos the same five scenarios side-by-side in light + dark: a custom-body modal (Alice Adams), a default confirm, and the four typed alerts.

### Feature — Modal eyebrow band: redesign + global controls

- `ModalView` now accepts an `eyebrow` constructor option directly:
  - `eyebrow: 'TEXT'` sets the band's label via the `--mojo-eyebrow` CSS
    custom property (no need to drop down to inline `style`).
  - `eyebrow: false` / `eyebrow: null` adds `modal-bandless` to the
    modal root, suppressing the colored slab entirely.
  - `eyebrow: undefined` keeps the default behavior (band visible,
    label whatever the helper supplied).
- New global toggles on `Modal`:
  - `Modal.setEyebrowEnabled(boolean)` — adds/removes the
    `mojo-no-eyebrow` class on `<html>`. SSR-safe (no-op when
    `document` is undefined).
  - `Modal.isEyebrowEnabled()` — reads the current state.
  - The CSS-only path is still equivalent — set `class="mojo-no-eyebrow"`
    on `<html>` or `<body>` directly if you prefer.
- Per-modal `eyebrow` and `modal-bandless` overrides still win when
  the global toggle is on, so individual dialogs can opt back in.
- Bug fix in `Modal.confirm()`: clicking **Confirm** previously
  resolved to `false` because the inner `onAction` returned literal
  `true`, which `_renderAndAwait` interpreted as "use the button's
  default action string" and substituted `'confirm'`. Final equality
  check `result === true` then always failed. Removed the wrapper and
  compare against the action string directly. Cancel/dismiss still
  resolves to `false`.

### CSS — Modal chrome: stock Bootstrap 5 look, custom eyebrow only

- Stripped the custom `.modal-content` overrides (14px radius, custom
  border, custom shadow stack) and the bespoke `.modal-header` /
  `.modal-footer` / `.modal-body` padding rules. The card now reads as
  a stock Bootstrap 5 modal driven by `--bs-modal-*` tokens (border
  radius, padding, border, header/footer dividers).
- The eyebrow band's top corners use
  `var(--bs-modal-inner-border-radius)` so they always track Bootstrap's
  card radius — no hardcoded `14px` left.
- Removed `border-bottom` on `.modal-header` and `border-top` on
  `.modal-footer`. The eyebrow band already separates header from body,
  and the footer's button cluster anchors itself.
- Default eyebrow is now neutral and theme-aware:
  `--mojo-current-accent: var(--bs-secondary-bg)` and
  `--mojo-current-eyebrow-fg: var(--bs-secondary-color)`. Quiet gray
  band in light mode, dark slab in dark mode.
- Typed alerts (`.modal-alert.modal-alert-{success|warning|error}`)
  override `--mojo-current-accent`, `--mojo-current-tint`, and
  `--mojo-current-eyebrow-fg` at higher specificity, so they keep their
  vivid colored bands. Untyped alerts (`Modal.alert` without a `type`)
  fall back to `--mojo-dialog-accent`.
- Default modals now use a flat `--bs-modal-bg` surface — no background
  tint. Earlier iterations tinted the whole card or just the top ~96px,
  but a body view with its own opaque chrome (e.g. a custom user-detail
  view) inevitably exposed the gradient as a thin colored strip between
  the band and the view's surface. The eyebrow band already carries the
  structural signal; the card itself stays clean.
- Typed alerts (`.modal-alert.*`) keep the full-height tint — their
  bodies are short text and benefit from full-surface brand presence.
  `--mojo-current-tint` is set per type to drive the gradient.
- Eyebrow text color uses `var(--bs-emphasis-color)` (high-contrast)
  by default rather than `var(--bs-secondary-color)` (muted) so the
  uppercase label stays legible on the neutral band in both themes.
  Typed alerts continue to render white text on their colored bands.
- New `.modal-body.modal-body-flush:last-child` rule: edge-to-edge
  bodies (e.g. `noBodyPadding: true` with no footer) clip their
  bottom corners to `var(--bs-modal-inner-border-radius)` so content
  doesn't square off against the rounded card. `overflow: hidden` is
  scoped to the body — descendant popovers from `.modal-content`
  (dropdowns, MultiSelectDropdown, ContextMenu) still escape the card.
- Eyebrow-disabled modes (`.modal-bandless` and the global
  `.mojo-no-eyebrow`) now reset the close X back to Bootstrap's
  default flex-positioned button: `position: static`, `1em` size,
  cleared filter (with `[data-bs-theme="dark"]` restoring the white
  filter via `var(--bs-btn-close-white-filter)`). The band-anchored
  white close X is now scoped to `.modal.modal-alert` only.
- Dark-mode modal box-shadow: Bootstrap ships none by default, so the
  card vanished against the dark backdrop. Added a soft white inner
  halo (`rgba(255, 255, 255, 0.08)`) plus a deep drop shadow so the
  card edge reads and the elevation still feels lifted.

### Fix — `ModalView.buildBody`: `noBodyPadding` consistency

- The `bodyView` branch was emitting `px-0 pt-4 pb-3` (Bootstrap
  utilities, all `!important`), while the string branch emitted `p-0`.
  The View-branch values clobbered the band-clearance CSS rules and
  the bottom-padding default, leaving headerless body-views stuck
  under the eyebrow band and a wide empty gap above the bottom edge.
  Both branches now emit a single `modal-body-flush` class, with all
  spacing/clipping handled by CSS (band-aware top reserve, edge-to-edge
  sides, rounded bottom corners).

### Fix — `SimpleSearchView`: theme-aware sticky header & footer

- Replaced the hardcoded `bg-light` Bootstrap utility (which paints
  `#f8f9fa` regardless of theme) with `bg-body-tertiary` on the
  sticky search header, the empty-state footer, and the result-count
  footer. Dark mode no longer flashes white slabs at the top and
  bottom of the search view.

### CSS — TopNav responsive collapse

- When `nav.navbar.navbar-expand-lg` collapses below `992px` and the
  hamburger is opened, `.navbar-collapse > .navbar-nav` items now lay
  out as a horizontal row instead of Bootstrap's default vertical
  stack: `flex-direction: row`, right-justified for `.ms-auto`,
  tighter `nav-link` padding, and dropdowns kept `position: absolute`
  so menus float instead of expanding inline. Reads as a clean
  horizontal continuation of the topbar instead of full-width links
  cascading down the screen.

### Feature — Examples portal: display settings panel

- New "Display settings" right-item in the topbar (sliders icon)
  opens a small modal exposing:
  - **Theme** — Light / Dark / System radio group wired to
    `app.setTheme()` (the existing `ThemeManager`).
  - **Modal chrome** — `Show eyebrow band` toggle wired to
    `Modal.setEyebrowEnabled()`. Choice is persisted to
    `localStorage` under `examples-portal:eyebrow` and restored at
    app boot before any modal renders.

### Docs — Modal & ModalView

- `docs/web-mojo/components/ModalView.md` — added `eyebrow` to the
  options table, clarified `title` vs `eyebrow` vs `header`
  distinctions, and added a **Hero Band & Eyebrow** section with
  examples.
- `docs/web-mojo/components/Modal.md` — added a **Hero Band & Eyebrow**
  section covering per-modal override, empty-text, and full
  suppression patterns; documented the global `Modal.setEyebrowEnabled`
  helper and the `mojo-no-eyebrow` CSS class; noted that the close
  button reverts to Bootstrap's default automatically when the band
  is suppressed.

### Feature — Cross-origin auth handoff

- `TokenManager.handleAuthCodeFromURL(app)` and `TokenManager.exchangeAuthCode(app, code)` redeem a `?auth_code=<32-hex>` URL param against `POST /api/auth/exchange` on bootstrap. The URL is scrubbed via `history.replaceState` before the network call (security bullet from the django-mojo review) and concurrent callers share one in-flight POST via the same single-flight pattern as `refreshToken()`.
- `PortalApp.checkAuthStatus()` calls `handleAuthCodeFromURL` before deciding the user is unauthenticated, so portals deployed on a different origin from the auth server boot directly into the authenticated state — no `/login` bounce, no countdown.
- Parity helpers added to the standalone auth surfaces: `MojoAuth.handleAuthCodeFromURL()` / `MojoAuth.exchangeAuthCode(code)` in `src/extensions/mojo-auth/mojo-auth.js`, and `auth.handleAuthCodeFromURL()` / `auth.exchangeAuthCode(code)` on the object returned by `createAuthClient` in `web-mojo/auth`.
- New event: `auth:exchange:failed` with `{ error }` payload. Existing `auth:login` is now also emitted by `TokenManager` on a successful exchange. Same-origin auth flows are unchanged — when no `?auth_code=` is present, `handleAuthCodeFromURL` is a synchronous no-op with no network call and no event.

### CSS — Admin assistant panel: dark theme coverage

- The Admin extension's AI Assistant panel (`AssistantPanelView` + the
  modal-fullscreen `AssistantView`) now honors `[data-bs-theme="dark"]`.
  Previously the panel header, empty-state hero, suggestion chips,
  composer input, history rail, conversation search, and thinking
  indicator all rendered against hardcoded `#fff` / `#f7f7f8` surfaces —
  loud against the framework's dark portal page (and especially loud
  against the new `#0a0d11` mission-control palette). Each surface now
  picks up `--bs-body-bg` / `--bs-tertiary-bg` / `--bs-secondary-bg`
  under the dark theme. Hover/active states on conversation rows and
  panel header buttons swap their `rgba(0, 0, 0, ...)` tints for the
  matching `rgba(255, 255, 255, ...)` values. Light theme is unchanged;
  no `!important`.

### Feature — Map: disable scroll/zoom interaction at construction time

- New constructor options on `MapView`, `MapLibreView`, and (via
  `mapOptions`) `MetricsCountryMapView`:
  - `interactive` (default `true`) — master switch; `false` freezes all
    user interaction (pan, zoom, keyboard, rotate).
  - `scrollZoom`, `dragPan`, `doubleClickZoom`, `keyboard`, `touchZoom`
    (all default `true`) — granular per-handler toggles.
- Cross-cutting names are translated per backend: `scrollZoom` →
  Leaflet `scrollWheelZoom`, `dragPan` → Leaflet `dragging`, `touchZoom`
  → MapLibre `touchZoomRotate`. Both views accept the same wrapper API.
- Defaults preserve today's fully-interactive behavior; existing call
  sites are unchanged.
- `showZoomControl` / `showNavigationControl` remain independent — UI
  buttons can still be shown on a non-interactive map.
- Programmatic camera changes (`setView()`, `setZoom()`, `flyTo()`,
  `setPitch()`, `setBearing()`) are unaffected; the flags only gate
  user input.
- Portal example pages (`extensions/map-view`, `extensions/map-libre-view`)
  show both modes side-by-side.

### CSS — Dark theme: deeper mission-control surface as default

- The framework's `[data-bs-theme="dark"]` palette now uses the deep
  near-black surfaces previously scoped to `SecurityDashboardPage`:
  page `#0a0d11`, card/tertiary `#11161d`, secondary `#161b22`, border
  `#1f2630`, emphasis text `#e6ecf3`, muted text `#8a96a6`. Every
  dark-mode page in consuming apps will look noticeably deeper and
  more contrasty on upgrade.
- **Light theme is unchanged.**
- **Opt out:** apps that want the previous Bootstrap defaults can
  override `--bs-body-bg`, `--bs-tertiary-bg`, etc. in their own CSS
  under `[data-bs-theme="dark"]` — the framework block uses no
  `!important`, so any later/higher-specificity rule wins.
- **Removed:** the `SecurityDashboardPage`-scoped overrides in
  `charts.css` (`.portal-layout:has(.security-dashboard-page)`,
  `.security-dashboard-page`, and the defensive `.page-container` /
  `.portal-content` fall-throughs). The dashboard now inherits the
  global palette seamlessly — no visual change.
- **Removed:** the `KPITile` `[data-bs-theme="dark"]` `--mojo-kpi-tile-*`
  variable block. The tile component already falls back through
  `--bs-tertiary-bg` / `--bs-emphasis-color` / `--bs-secondary-color` /
  `--bs-border-color`, which now match the original dashboard values
  1:1, so the dedicated overrides became redundant. The
  delta-badge and hover tints are kept since they don't map to a
  Bootstrap token.
- **`--mojo-sidebar-dark-bg`** now drops to `#0d1117` under
  `[data-bs-theme="dark"]` so `topnav-dark` and `sidebar-dark` sit
  one tier above the page (`#0a0d11`) and one tier below cards
  (`#11161d`) — portal chrome reads as a band rather than a raised
  tile. The light-mode default (`#343a40`) is unchanged.
- **`[data-bs-theme="dark"] .sidebar-light`** now uses
  `var(--bs-secondary-bg)` instead of a hardcoded `#2a2f36`, so the
  rail stays one step above the page automatically and tracks any
  future palette tweak.
- **Topbar default unchanged:** `--mojo-topnav-bg` still resolves to
  `var(--bs-primary)` (brand-blue topbar) when no `topnav-*` class is
  set. Consumers who want a deep mission-control topbar should use
  `topnav-dark`, which now picks up the new `#0d1117`.

### Changed — Admin sidebar Security menu restructured

- **Security Dashboard** is now a top-level sidebar item, placed directly
  below the system **Dashboard** (route `?page=system/incident-dashboard`,
  icon `bi-shield-check`).
- The single 12-child **Security** group has been split into three smaller
  groups: **System Security** (Tickets, Incidents, Events, Rules),
  **Network Security** (IPs, IP Sets, Blocked, Firewall Log), and
  **Bouncer** (Signals, Devices, Bots).
- Labels were tightened: `Rule Engine` → `Rules`, `GeoIP` → `IPs`,
  `Blocked IPs` → `Blocked`, `Bouncer Signals` → `Signals`,
  `Bouncer Devices` → `Devices`, `Bot Signatures` → `Bots`.
- Routes and per-item permissions are unchanged. Pure menu-config edit in
  `src/admin.js`; no page registrations or framework APIs changed.

### Feature — SeriesChart axis label visibility

- New `showXLabels` / `showYLabels` options (default `true`) hide the X /
  Y text labels independently. Gridlines (`showGrid`) are unaffected.
- When labels are hidden, the plot area grows into the freed padding
  (`padBottom` 24→8 with `showXLabels: false`, `padLeft` 40→8 with
  `showYLabels: false`). The X-label auto-rotation extra-padding path is
  skipped when X labels are hidden.
- Plumbed through `MetricsChart` so dashboard panels can hide axis text
  for compact tile-style displays.

### Behavior — SeriesChart hover-dim is now opt-in

- New `highlightOnHover` option on SeriesChart (default `false`). Hovering
  a bar or dot no longer dims the other series — the dim effect was
  visually noisy on stacked-bar charts and distracting in dashboard
  contexts.
- Pass `highlightOnHover: true` to restore the earlier always-on behavior.
- Plumbed through `MetricsChart`.

### Behavior — SeriesChart legend default is now top-left

- New `legendJustify: 'start' | 'center' | 'end'` option (default
  `'start'`). Combined with the existing `legendPosition: 'top'` default,
  the SeriesChart legend now sits **top-left** instead of top-center.
- `legendJustify` maps to CSS `justify-content` for both the horizontal
  flex (top/bottom legends) and the column flex (left/right legends).
- Invalid values fall back to `'start'` with a `console.warn`.
- To restore the prior top-center look, pass `legendJustify: 'center'`.
- Plumbed through `MetricsChart` (and via that, every dashboard chart
  built on the metrics fetch path).

### Fixed — Modal: descendant dropdowns/popovers no longer clipped at the card edge

- `.modal-content` had `overflow: hidden` (added with the hero-band redesign in
  `ff27795`) which clipped any absolutely-positioned descendant — `MultiSelectDropdown`,
  `ComboBox`, `CollectionMultiSelect`, plain Bootstrap `.dropdown-menu`, and any
  context menu rendered inside a modal body.
- The hero band's `::before` pseudo-element already declares its own matching
  `border-radius: 14px 14px 0 0`, so the ancestor clip was unnecessary for the
  rounded chrome. Removing `overflow: hidden` restores Bootstrap's default
  modal behavior — popovers can escape the card edge.
- No JS or component changes; the fix is a single CSS-rule removal in
  `src/core/css/core.css`.

### Feature — Security Dashboard rebuild + new framework primitives

- **`SecurityDashboardPage`** replaces the older tabbed
  `IncidentDashboardPage` with a single scrolling mission-control page.
  Route stays `system/incident-dashboard`. Seven sections answer the
  one question a sysadmin actually asks: *what should I be doing right
  now?*
  - **Pulse** — 8 KPI tiles via one batched
    `/api/metrics/series?with_delta=true` + parallel REST counts. Tiles
    track NEW incidents (untriaged), not OPEN (already claimed).
  - **Needs Attention** — list of priority>=8, status=new incidents.
    Click row opens the existing `IncidentView` modal. Hover-revealed
    inline resolve/pause actions for users with `manage_security`.
  - **Threat Composition** — single 30-day stacked bar chart that
    condenses `incident_events` / `firewall:blocks` / `bouncer:blocks`
    / `auth:failures` into one view. 7D / 30D / 90D toggle.
  - **Geography** — `MetricsCountryMapView` with slug-family selector
    (Events / Incidents / Firewall / Logins).
  - **Distributions** — three cards: status donut, priority bucket bars,
    bouncer funnel (assessments → monitors → blocks).
  - **Top Sources** — top IPs + top categories (last 7d). Tries
    server-side `group_by` first; falls back to client-side aggregation
    of recent 500 events when unsupported, with a fallback note in the
    card subtitle.
  - **Auth Failures** — uses the new `auth:failures` aggregate slug
    directly (no client-side composition); 4 sub-tiles for password
    resets / TOTP failures / sessions revoked / accounts deactivated.
  - **System Health** — single `/api/incident/health/summary` call,
    one row per discovered category, color dot from `level`, click row
    drills into the linked incident.
  - Sections 3-7 use **lazy mount** so they don't fetch until scrolled
    into view.
  - Refresh tiers via `Page.scheduleRefresh`: 60s for pulse +
    needs-attention; 5min for everything else; manual refresh button
    fires all tiers.
  - Drill-downs use `Modal.drawer` for day / country / status-filter /
    priority-bucket / IP / category / auth sub-tile clicks.

- **New framework primitives** (charts):
  - **`KPITile`** (`web-mojo/charts`) — compact presentation-only tile:
    label, big tabular value, color-coded delta badge, embedded
    `MiniChart` sparkline. Renders pre-fetched data via constructor or
    `setData()`. Click emits `tile:click`. Sits between `MiniChart`
    (sparkline only) and `MetricsMiniChartWidget` (rich self-fetching
    card). Delta rendering rules:
    - `deltaPct` present → "+12%" / "−8%"
    - `deltaPct` omitted (prev=0) + `delta` present → "+4" absolute
    - both null → no badge
    - never renders `Infinity%`
    - `severity` (critical/high/warn/info/good) adds left-stripe accent
    - `tone` ('bad' or 'good') decides whether rising = red or green
  - **`KPIStrip`** (`web-mojo/charts`) — orchestrator for N `KPITile`s.
    Single batched `/api/metrics/series?with_delta=true` call populates
    all metric tiles, parallel REST count calls populate tiles defined
    with `rest:` config, and one batched `/api/metrics/fetch` populates
    sparklines for all metric tiles.

- **Extensions to existing components:**
  - **`PieChart`** — new `centerLabel` and `centerSubLabel` options
    render text in the donut center (when `cutout > 0`). Accept either
    a static string or a function called with `({ total, segments })`.
  - **`MetricsChart`** — new `withDelta` flag passes through to the
    series endpoint; new `compactHeader` mode hides the gear menu and
    shrinks the range toggle for use inside dashboard panels.
  - **`Modal.drawer({ eyebrow, title, meta, view })`** — standardised
    drill-down modal header (eyebrow tag, title, meta row of icon-
    prefixed spans). Accepts a `View` instance OR raw HTML body.
  - **`Page.scheduleRefresh(handler, intervalMs, { tier, immediate })`**
    — registers a recurring handler that auto-clears in `onExit`.
    Replaces the `setInterval`/`clearInterval` boilerplate in every
    dashboard. `runScheduledRefreshes(tier?)` fires all (or one tier).
  - **`View.addChild(child, { lazyMount: true })`** — defers the
    child's render until its container scrolls into viewport via
    `IntersectionObserver`. Container gets a 1px placeholder min-height
    so the observer can detect 0-content placeholders. Disconnects on
    destroy. Falls back to immediate render when IO isn't available.

- **Examples portal:**
  - New `KPIStripExample` at `extensions/charts/kpi-strip` —
    demonstrates standalone `KPITile`s (delta rules) and `KPIStrip`
    (batched fetch).
  - `PieChartExample` updated to show the new doughnut center label.

### Feature — App-level theme management

- **`WebApp` now owns the user's light/dark theme.** New public API:
  - `app.setTheme('light' | 'dark' | 'system')` — persists the preference,
    applies `data-bs-theme` to `<html>`, emits `'theme:changed'` on
    `app.events` with `{ theme, resolved }`.
  - `app.getTheme()` — returns the stored preference.
  - `app.getResolvedTheme()` — returns the currently applied
    `'light' | 'dark'` (resolves `'system'` via `prefers-color-scheme`).
- **Default preference is `'system'`** — first-time visitors automatically
  get the theme that matches their OS. The `prefers-color-scheme` media
  listener tracks OS theme changes live while the preference is `'system'`.
- **Storage:** the preference is persisted to `localStorage` under
  `${appName}:theme` (mirrors the existing PortalApp sidebar-state
  pattern). All reads/writes are wrapped in try/catch — private mode and
  disabled storage degrade gracefully.
- **No flash:** the manager runs in the WebApp constructor so
  `data-bs-theme` is set before the first view renders.
- **PortalApp auto-injects a topbar theme toggle** into the usermenu:
  Light / Dark / System items with `bi-sun`, `bi-moon-stars`,
  `bi-circle-half` icons. The currently selected option is marked
  active. Opt out with `topbar.themeToggle: false`.
- **TopNav dropdown items now honor an `active: true` flag** — the
  template renders `class="dropdown-item active"` for selected items
  (used by the new theme toggle and available to any caller).
- **`examples/portal/app.js` simplified** — the manual `theme-light` /
  `theme-dark` action handlers are gone; the framework toggle handles
  them.
- **New module:** `src/core/utils/ThemeManager.js`.

### CSS — Dark-theme coverage for sidebar treatments, SideNavView, ChatView, TimelineView

- **`sidebar-light` under `data-bs-theme="dark"`** now renders against a
  softer dark surface (`#2a2f36`) instead of bright white. Hover, active,
  group-header, and muted-text selectors all adapt to the dark palette.
  Treatment classes remain independent of the global theme — devs can
  still mix `sidebar-light` / `sidebar-dark` with either.
- **`sidebar-dark` under `data-bs-theme="dark"`** got a sanity-pass hover
  override so the active state remains distinguishable from the base.
- **`SideNavView`** now has dark-theme overrides in `portal.css` covering
  the rail bg, active accent, hover, group-label, and dropdown-collapse
  mode. Base inline styles in the component template are unchanged.
- **`ChatView` (`chat.css`)** picks up dark-theme rules for the
  container, message bubbles (left), input area, attachment states,
  file-attachment overlay, and the WebKit scrollbar. Bubble `right`
  keeps `--bs-primary` from the base rule (theme-aware).
- **`TimelineView`** ships its own `src/extensions/timeline/timeline.css`
  for the first time — class-based base styles for the connector line,
  marker, dot, content card, and meta surfaces, plus `data-bs-theme="dark"`
  overrides where Bootstrap tokens aren't enough on their own.
  Auto-imported from `TimelineView.js`.

### Refactor — In-`src/` callers migrated from Dialog.* to Modal.* / ModalView

- **All in-`src/` callers** migrated from the deprecated `Dialog.*` API
  to the canonical `Modal.*` (static API) / `ModalView` (instance class)
  surface. 60 files touched across `src/core/`, `src/extensions/admin/*`,
  `src/extensions/lightbox/*`, `src/extensions/charts/*`,
  `src/extensions/map/*`, `src/extensions/user-profile/*`.
  - **Pure fire-and-forget `new Dialog({...})` sites** (7) collapsed to
    one-line `Modal.show(view, { size, header, title })` calls.
  - **Instance-handle `new Dialog({...})` sites** (11) now use
    `new ModalView({...})` — same instance API (`on('action:*')`,
    `setLoading`, `element`, `hide()`, `destroy()`) since `Dialog`
    already re-exported `ModalView` under the hood.
  - **`Dialog.show*()` static calls** mechanically renamed: `showDialog
    → dialog`, `showForm → form`, `showModelForm → modelForm`, `showData
    → data`, `showCode → code`, `showModelView → showModelView`,
    `updateModelImage → updateModelImage`, `showBusy/hideBusy` (alias
    preserved on `Modal.*`), `alert/confirm/prompt` (identical signatures).
  - **`WebApp.showLoading/hideLoading/showModelView/showModelForm/showForm/
    showDialog/showAlert`** internal lazy imports now resolve `Modal.js`
    instead of `Dialog.js`.
- **Pre-existing bug fixed**: `JobHealthView.onActionSystemSettings()`
  called `Dialog.showAlert(...)` — `showAlert` was never wired on the
  shim. The System Settings button now resolves through `Modal.alert`.
- **`Model.showError()`** also migrated from a (broken, unimported)
  `Dialog.alert(...)` global reference to a dynamic `Modal.alert`
  import, matching the lazy-import pattern WebApp uses.
- **Public surface unchanged**: the `Dialog.js` shim and the public
  `Dialog` re-exports in `src/index.js` / `src/lite/index.js` remain in
  place for downstream consumers. Their removal is a separate breaking
  change PR.

### Refactor — Dialog.js split into ModalView + Modal + focused helpers

- **`Dialog.js` (1,987 lines) split** into focused modules in
  `src/core/views/feedback/`:
  - **`ModalView.js`** — the underlying `View` class. Owns Bootstrap 5
    modal mechanics (lifecycle, sizing, z-index stacking, header/body/
    footer composition, button rendering, context menu).
  - **`Modal.js`** — canonical static API: `dialog`, `show`, `showModel`,
    `showModelView`, `alert`, `confirm`, `prompt`, `form`, `modelForm`,
    `data`, `code`, `htmlPreview`, `updateModelImage`, `loading`. A new
    `_renderAndAwait` helper consolidates ~300 lines of duplicated
    render/show/resolve/destroy code.
  - **`BusyIndicator.js`** — singleton frosted-glass loading overlay.
  - **`CodeViewer.js`** — Prism-highlighted code block view + statics.
  - **`HtmlPreview.js`** — sandboxed iframe preview view.
  - **`Dialog.js`** — thin compatibility shim. Default-exports
    `ModalView`; every legacy static (`Dialog.alert`, `Dialog.showForm`,
    `Dialog.showBusy`, …) is a one-line forward to the matching
    `Modal.*` method. Existing `new Dialog({...})` and `Dialog.show*()`
    callers continue to work unchanged.
- **Busy-indicator overlays consolidated**. The legacy dark
  `mojo-busy-indicator` is gone; only the modern frosted-card
  `mojo-loading-overlay` remains. `Modal.loading()` / `Modal.showBusy()`
  / `Dialog.showBusy()` all route through the same singleton.
- **`ModalView` is now a public export** (`src/index.js`,
  `src/lite/index.js`) — use it directly when you need a long-lived
  modal handle (streaming `setContent`, external event wiring,
  subclassing). Most callers should still prefer the static `Modal.*`
  API.
- **No consumer change required.** The 24 `new Dialog({...})` and
  `Dialog.show*()` sites already in `src/` continue to work via the
  shim. A separate request (`planning/requests/migrate-legacy-dialog-callers.md`)
  tracks the eventual sweep.
- New docs: `docs/web-mojo/components/ModalView.md`. Updated:
  `components/Modal.md`, `components/Dialog.md` (now a deprecation
  notice + migration table), `README.md`, `docs/agent/architecture.md`.

### Improved — SeriesChart axis labels (nice numbers, formats, rotation)

- **Y-axis ticks** now snap to clean `1/2/5 × 10ⁿ` values via the Heckbert
  nice-number algorithm. The "API Metrics" chart and similar `MetricsChart`
  consumers now show `0, 25, 50, 75, 100` instead of `0, 28.77, 57.54,
  86.31, 115.08, 143.85`. `gridLines` becomes a target count; the algorithm
  picks the closest clean fit.
- **X-axis labels auto-rotate** `-45°` when they would overlap their slots.
  The chart's bottom padding expands automatically. No configuration
  required; rotation kicks in when labels collide.
- **`MetricsChart` defaults `xLabelFormat`** based on `granularity`:
  `minutes`/`hours` → `date:'HH:mm'` (`17:00`), `days`/`weeks` → `date:'MMM
  D'` (`Apr 26`), `months` → `date:'MMM YYYY'` (`Apr 2026`). Caller-supplied
  `tooltip.x` still wins. The default is re-applied when `setGranularity()`
  is called.
- Truncation cap raised from 10 → 24 chars (rotation handles long labels;
  truncation is the fallback for pathological cases like UUIDs).
- `_formatAxisValue` adds a `B` (billion) branch and step-aware decimal
  precision so very small or very large nice-tick ranges read cleanly.

### Docs — Phase 3 of taxonomy realignment (undocumented public exports)

- New doc pages in `docs/web-mojo/`:
  - `core/Router.md` — `Router` class: `?page=` URL handling, `navigate`, route patterns, `route:changed` / `route:notfound` events.
  - `components/ProgressView.md` — file-upload progress UI; `updateProgress`, `markCompleted`, etc.
  - `components/SimpleSearchView.md` — searchable list bound to a `Collection`; emits `item:selected`.
  - `utils/MustacheFormatter.md` — lower-level template renderer behind `View`; `registerFormatter` for custom pipes.
  - `mixins/FileDropMixin.md` — `applyFileDropMixin(ViewClass)` + `enableFileDrop({…})` + `onFileDrop(files, …)`.
- **Breaking**: `DataWrapper` named export removed from `src/index.js`. Triage found zero consumers (no `src/`, no `examples/`, no `test/` references). The class itself remains in `src/core/utils/MOJOUtils.js`; only the public re-export is gone.
- README + AGENT cross-links updated to surface the new pages.

### Added — Assistant: `assistant_text` event + chart-option passthrough

- **New `assistant_text` WS event** is now handled in `AssistantView`,
  `AssistantPanelView`, and `AssistantContextChat`. When the model writes
  prose alongside tool calls in the same turn, that intermediate text now
  renders as an assistant bubble before the tool-call status indicators.
  `assistant_response` remains the terminal signal that clears the thinking
  indicator and re-enables input. Conversations that don't emit
  `assistant_text` are unchanged.
- **`AssistantContextChat` gains a small `_adoptConversationId` helper** so
  all three assistant views handle new-conversation events uniformly. The
  adapter remains the canonical owner of `conversationId`.
- **Chart blocks now forward new `SeriesChart` / `PieChart` options** from
  the LLM into the chart constructor via a strict snake_case → camelCase
  allowlist. New block-level fields: `stacked`, `grouped`, `crosshair_tracking`,
  `cutout`, `show_labels`, `show_percentages`, `colors`, `show_legend`,
  `legend_position`. New per-series fields: `color`, `fill`, `smoothing`.
  Existing minimal chart blocks render identically.
- Stale doc comment in `AssistantMessageView._renderChartBlock` corrected
  (`MiniPieChart`/`MiniSeriesChart` → `PieChart`/`SeriesChart`).

### Docs — Phase 2 of taxonomy realignment

- New doc pages in `docs/web-mojo/`:
  - `extensions/Auth.md` — `mountAuth` + `createAuthClient` (`web-mojo/auth`).
  - `extensions/UserProfile.md` — `UserProfileView`, `PasskeySetupView`, the 11 section views (`web-mojo/user-profile`).
  - `extensions/DocIt.md` — `DocItApp` and the four documentation pages (`web-mojo/docit`).
  - `services/TokenManager.md` — JWT lifecycle, single-flight refresh, the auth gate.
  - `utils/DjangoLookups.md` — `field__lookup` syntax, `LOOKUPS` map, `parseFilterKey`, `formatFilterDisplay`.
  - `utils/ConsoleSilencer.md` — log-level filtering, URL/`localStorage` runtime overrides.
- `src/extensions/auth/README.md` deleted (the new `extensions/Auth.md` is canonical).
- `src/extensions/mojo-auth/mojo-auth.js` gets a `LEGACY shim` JSDoc header. No package entry, no internal callers; new code uses `web-mojo/auth`. File is kept for downstream apps still linking it directly.
- `docs/web-mojo/README.md`, `docs/web-mojo/AGENT.md`, and root `AGENT.md` cross-links updated to surface the new pages.
- `planning/notes/taxonomy-audit.md` notes that `src/core/utils/TemplateResolver.js` is orphaned (zero consumers, not exported) — tracked for a future cleanup pass.

### Bug fixes — typed alerts now actually render their type

- `Dialog.alert(message, title, options)` (and `Modal.alert(...)`) silently
  dropped the second and third arguments — every typed alert rendered as
  `info` regardless of the `type` option. The signature is now correctly
  honored: `Modal.alert('Saved!', 'Done', { type: 'success' })` produces a
  success-styled alert. Object-form (`Modal.alert({ message, title, type })`)
  and single-string form (`Modal.alert('hi')`) continue to work unchanged.
- `WebApp.showError / showSuccess / showInfo / showWarning` were broken in
  the same way and rendered identically. They now produce visually distinct
  typed alerts and route through `Modal.alert` directly.
- `WebApp.confirm` also routes through `Modal.confirm` for consistency.

### API direction — Modal is the canonical modal/dialog surface

- `Modal.alert / Modal.confirm / Modal.prompt` are now the canonical
  implementations. `Dialog.alert / Dialog.confirm / Dialog.prompt` have been
  rewritten as thin pass-throughs that delegate to Modal — all existing
  `Dialog.*` callers continue to work unchanged, but new code should call
  `Modal.*` directly.
- `Dialog` itself (the underlying View class) is unchanged: the constructor,
  `Dialog.showDialog / showForm / showModelForm / showCode / showHtmlPreview`,
  z-index management, and `Dialog.showBusy / hideBusy` continue to live there.
  Only the three top-level helpers moved.
- `docs/web-mojo/components/Modal.md` is now the canonical doc; Dialog.md
  retains its deprecation banner with pass-through notes under each helper.

### UI / CSS — refreshed dialog chrome and typed-alert accents

- All dialogs share a refreshed chrome: rounded corners (14px), soft
  drop-shadow, gradient header tint, and a small offset circular close
  button anchored to the top-right corner.
- Typed alerts (`Modal.alert(... { type })`) now get a 6px colored hero
  band across the top of the modal card and a subtle tinted card background,
  so each type is visually distinct without relying on an icon alone. Color
  tokens: success=`#198754`, error=`#dc3545`, warning=`#ffc107`;
  info/default uses `--mojo-dialog-accent` (see below).
- New CSS variable `--mojo-dialog-accent`, defined at `:root` and defaulting
  to `var(--bs-primary)`. Drives the header gradient tint and the info-typed
  alert accent. Override at `:root` (or any scope) to set a custom brand
  color without touching `--bs-primary`.
- Dark-mode rules added under `prefers-color-scheme: dark`, mirroring the
  shape of the existing toast.css dark-mode block.
- Internal styling hook: typed alerts add `modal-alert modal-alert-{type}`
  to the modal root for downstream apps that want to override the look.

### Breaking — Admin models moved to a separate package entry

- 14 admin-coupled `Model` / `Collection` classes have moved out of `src/core/models/`
  into `src/extensions/admin/models/`. The affected models: `AWS`, `Assistant`,
  `Bouncer`, `Email`, `Incident`, `IPSet`, `Job`, `JobRunner`, `LoginEvent`,
  `PublicMessage`, `Push`, `Phonehub`, `ScheduledTask`, `Tickets`.
- 7 of those (`AWS`, `Email`, `Incident`, `Job`, `JobRunner`, `Push`, `Tickets`)
  were previously re-exported from the main `web-mojo` entry. They are no longer.
  **Migration**: switch to the new `web-mojo/admin-models` entry.
  ```js
  // before
  import { Job, JobList, Incident } from 'web-mojo';
  // after
  import { Job, JobList, Incident } from 'web-mojo/admin-models';
  ```
- New package entry `web-mojo/admin-models` ships the 14 admin models as **data
  only** — no DOM, Bootstrap, or template deps. Use this entry from a Node
  script, an API client, or any non-portal UI. The `web-mojo/admin` entry
  remains the way to get the admin **pages** (sidebar, dashboards, table pages).
- `Log` and `ShortLink` stay in `src/core/models/` because they have legitimate
  non-admin consumers (`FileView`'s share-link feature, `user-profile`'s
  activity section). The audit's "admin-only" classification was overzealous on
  those two; their import paths and main-entry export are unchanged.
- `docs/web-mojo/models/BuiltinModels.md` now covers only the 10 still-core
  models. Admin models documented in `docs/web-mojo/extensions/Admin.md`.
- 73 internal `@core/models/<X>.js` import statements rewritten to
  `@ext/admin/models/<X>.js` across the 58 admin files that consume them.

### Examples Portal — area-mismatch realignment

- `TabView` moved from `extensions/` → `components/` (source has always been at
  `src/core/views/navigation/TabView.js`). Routes change:
  `?page=extensions/tab-view` → `?page=components/tab-view`. Doc moves to
  `docs/web-mojo/components/TabView.md`.
- `TablePage` doc moved from `components/` → `pages/` (source is at
  `src/core/pages/TablePage.js`). Doc path:
  `docs/web-mojo/pages/TablePage.md`. Example folder unchanged (already at
  `examples/portal/examples/pages/TablePage/`).
- `FileUpload` moved from `extensions/` → `services/` (source is at
  `src/core/services/FileUpload.js`). Routes change:
  `?page=extensions/file-upload` → `?page=services/file-upload`. Doc moves to
  `docs/web-mojo/services/FileUpload.md`.
- `docs/web-mojo/extensions/metricsminichartwidget.md` renamed to
  `MetricsMiniChartWidget.md` to match sibling-doc casing.
- New `FormBuilder` example at
  `examples/portal/examples/forms/FormBuilder/FormBuilderExample.js`. Demos
  `buildFormHTML()` and `buildFieldsHTML()`. `FormBuilder` is now exported
  from the main `web-mojo` entry (was previously only available via
  `@core/forms/FormBuilder.js`).
- Dead `src/core/views/map/MapView.js` duplicate removed (canonical version
  is `src/extensions/map/MapView.js`, exported via `web-mojo/map`).
- `docs/web-mojo/forms/FORMS_DOCUMENTATION_PLAN.md` (an internal planning
  doc that snuck into published docs) moved to `planning/notes/`.

### Examples Portal — hub-and-spoke navigation

- Replaced the single 75-item sidebar with a hub menu plus four topic
  sub-sidebars: **Architecture**, **Components**, **Forms**, **Extensions**.
  The hub pins a curated **Start Here** path (View, Templates, Model, Page,
  WebApp). Each topic sub-sidebar ends with a "Back to Examples" item that
  returns to the hub, mirroring the existing admin "Exit Admin" pattern.
- Component variants (Dialog form / context-menu / custom-body, TableView
  batch-actions / custom-row / server-collection, …) now collapse under their
  parent in the sidebar instead of rendering as siblings. Routes are unchanged.
- `examples.registry.json` now exposes a `topics` tree (each topic → groups →
  items with optional one-level children), and every page record carries
  `topic` and `group` fields. The legacy `menu` array is kept for one cycle.
- `docs/web-mojo/examples.md` is regenerated under H2/H3 topic/group
  headings and now correctly links each variant row to its own source file.
- Sidebar widened from the framework default 250px to 300px in this portal
  (set via `--mojo-sidebar-width` CSS variable; framework default unchanged).
- No framework changes — `Sidebar` already supported multiple registered
  menus, route-driven menu switching, and per-item `handler` callbacks.

### Breaking — Charts extension rebuilt on native SVG

- **Chart.js dependency removed.** `BaseChart` previously injected
  `https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js` at runtime;
  that fetch no longer happens. `chart.js` was never in `package.json` and
  remains absent. ~2,400 LOC of source removed: `BaseChart.js` (1,329),
  the old Chart.js-backed `SeriesChart.js` (533), and the old `PieChart.js` (567).
- **`SeriesChart` rewritten as a native SVG component** (promoted from
  `MiniSeriesChart`). Multi-dataset line/bar/area, click-to-toggle legend,
  hover-isolated highlighting, animated `setData` updates (`animate: false`
  to opt out), built-in 10-color palette + golden-angle HSL fallback, and
  per-series `color` overrides.
- **Bar charts default to stacked.** `chartType: 'bar'` is stacked unless you
  pass `stacked: false` (or the alias `grouped: true`). `stacked: 'auto'` is
  the default and resolves to `true` for bar, `false` for line/area.
- **`PieChart` rewritten as a native SVG component** (promoted from
  `MiniPieChart`). Adds slice-edge labels, `chart:click` drill-down, animated
  slice tweens, and an optional `endpoint:` shim that auto-fetches via
  `app.rest.GET` in `onInit`.
- **`MetricsChart` rewritten on top of native `SeriesChart`.** Public API
  preserved: `endpoint`, `account`, `granularity`, `slugs`, `category`,
  `dateStart`/`dateEnd`, `defaultDateRange`, `quickRanges`, `availableMetrics`,
  `maxDatasets`, `groupRemainingLabel`, `chartType`, `title` (HTML), `height`,
  `yAxis`, `tooltip`, `showDateRange`, `showGranularity`. Methods unchanged:
  `fetchData`, `refresh`, `setGranularity`, `setDateRange`, `setMetrics`,
  `getStats`. Admin call sites (`AdminDashboardPage`, `CloudWatchChart`,
  `ShortLinkView`, `PushDashboardPage`) need no changes.
- **PNG export moved out of charts into a standalone helper.**
  `MetricsChart.export(format)` removed. Use:
  `import { exportChartPng } from 'web-mojo/charts'; exportChartPng(chart);`
  Works on any view containing an `<svg>`.
- **`MiniSeriesChart` and `MiniPieChart` exports removed.** The dynamic imports
  in `AssistantMessageView` were updated to `SeriesChart`/`PieChart`. If any
  downstream code imported `MiniSeriesChart`/`MiniPieChart` directly, switch
  to `SeriesChart`/`PieChart`.
- **Removed at the chart level**: WebSocket integration, `autoRefresh`,
  `setEndpoint`, `setWebSocketUrl`, theme toggle, `chartOptions` passthrough,
  `dataTransform`. Pages own those concerns.
- New examples portal entries: `SeriesChartExample.js`, `PieChartExample.js`.
  `ChartsExample.js` continues to demo `MiniChart` (the dedicated single-series
  sparkline — kept as-is).
- `src/charts.js` bumped from 2.1.0 → 3.0.0.

### Added — Charts: floating crosshair tooltip on line charts

- **`SeriesChart` gains optional `crosshairTracking` mode** for line/area
  charts. With `crosshairTracking: true`, a transparent rect overlays the
  plot area; on `mousemove` the chart snaps to the nearest column and shows
  a vertical crosshair, a per-dataset ghost dot, and the existing multi-row
  tooltip. Off by default — bar charts ignore the flag.
- **Bootstrap-theme-aware** — the crosshair line uses
  `var(--bs-secondary-color)` via `currentColor` and auto-adapts under
  `data-bs-theme="dark"`. Pass `crosshairColor` for an explicit override
  (accepts CSS color strings or `var(--…)` references) and `crosshairWidth`
  for a thicker line.
- **`chart:click` semantics** — in tracking mode, click emits the column
  for the first visible dataset (matches Chart.js `mode: 'index'`). Per-
  dataset clicks remain available with `crosshairTracking: false`.
- New examples-portal demo card under
  `examples/portal/examples/extensions/Charts/SeriesChartExample.js`.

### Changed
- **Examples directory rewritten** — The previous `examples/portal/` (37 pages, 17 templates, ~13k LOC) and all standalone HTML demos have been moved to `examples/legacy/` (with git history preserved). The new `examples/portal/` is a single canonical [`PortalWebApp`](docs/web-mojo/core/PortalWebApp.md) shell whose taxonomy mirrors `docs/web-mojo/`. Each documented component has a folder under `examples/portal/examples/<area>/<Component>/` with a single-file canonical-and-demo `<Component>Example.js` (≤150 LOC, inline template, imports only from `web-mojo`) and an `example.json` manifest. The portal sidebar and route registration are generated by `examples/portal/scripts/build-registry.js` from those manifests, so adding an example never touches `app.js`. Coverage: **59 examples across 8 areas** (`core`, `pages`, `services`, `components`, `extensions`, `forms`, `forms/inputs`, `models`).
- **`docs/web-mojo/examples.md`** — Generated index of every example file, written by the registry generator. Linked from `docs/web-mojo/README.md`.
- **Per-doc cross-links** — Each component doc under `docs/web-mojo/` now ends with an `## Examples` section listing the runnable example file(s) for that component. Sections are managed by `examples/portal/scripts/cross-link-docs.js` and bracketed with `<!-- examples:cross-link begin/end -->` markers so reruns are idempotent.
- **`vite.config.js`** — Added `web-mojo/timeline` and `web-mojo/models` aliases so per-extension package imports resolve in dev.

### Added
- **`examples/auth/`** — Fresh standalone login flow built on `FormView` + `Rest`, posts to `/login` and redirects to `/examples/portal/` on success. Replaces the old multi-page auth example (now under `examples/legacy/auth/`).
- **`docs/web-mojo/components/ContextMenu.md`**, **`docs/web-mojo/forms/MultiStepWizard.md`**, **`docs/web-mojo/forms/SearchFilterForms.md`** — Three new doc pages covering components and patterns the legacy portal demonstrated but the docs hadn't.

### Changed (legacy)
- **FileView consolidated** — The three overlapping file components (`src/core/views/data/FileView.js` legacy, `src/extensions/admin/storage/FileView.js` admin, and the small `FilePreviewView` chat card) have been reduced to one canonical `FileView` at `src/core/views/data/FileView.js`, exported from both `web-mojo` and `web-mojo/admin`. The new component uses a `SideNavView` layout (Preview / Details / Renditions / Metadata) and drives its Preview section from the backend `category` field (`image`, `video`, `audio`, `pdf`, `document`, `spreadsheet`, `presentation`, `archive`, `other`) — each category gets a purpose-built preview (inline `<video>`/`<audio>`, lightbox gallery, PDF viewer, or download-focused card). `LightboxGallery` and `PDFViewer` are accessed optionally via `window.MOJO.plugins.*` and fall back to `window.open` when the lightbox extension isn't loaded. Metadata section is hidden when empty. `FilePreviewView` (chat attachment card) is unchanged.
- **FileView handles async renditions** — The backend now generates renditions asynchronously: `upload_status` flips to `completed` immediately while thumbnails/transcodes run on a background worker. FileView now shows a "Renditions are being generated" placeholder in the Renditions section (with a manual Refresh button) when `File.isRenditionsProcessing()` returns true, and kicks off an automatic background poll (`model.fetch()` every 5s, up to 5 minutes) that stops as soon as the renditions map populates. The poll is cancelled in `onBeforeDestroy`. Preview and Renditions sections listen for `change` on the model and re-render in place as the new URLs arrive, so video posters and rendition rows appear without a manual refresh.
- **`File` model helpers** — Added `getCategory()` (with content_type fallback), `hasRenditions()`, `isRenditionsProcessing()`, `getRenditions()`, `getBestImageRendition()`, `getThumbnailUrl()`, and `regenerateRenditions(roles?)` to the `File` model.

### Added
- **"Regenerate Previews" action** — New `regenerate-renditions` ContextMenu item in `FileView`. Confirms, then POSTs `{ action: 'regenerate_renditions' }` to `/api/fileman/file/<id>` via `File.regenerateRenditions(roles?)` and (re)starts the rendition poll. Matches the new backend endpoint described in django-mojo fileman docs.

### Breaking
- **FileView constructor** — Removed options `file` (URL string input), `size: 'xs'..'xl'`, `showActions`, `showMetadata`, `showRenditions`, and the `updateFile()` method. Pass a `File` model via `model`, or raw data via `data` (wrapped internally). The legacy options described a different component that no longer exists.

### Removed
- **`src/extensions/admin/storage/FileView.js`** — Deleted. Logic moved into the canonical core `FileView`. `src/admin.js` still exports `FileView` for backward compatibility — it now re-exports the core component.

### Added
- **PublicMessage admin (Contact Messages)** — New `PublicMessage` model + `PublicMessageList` collection wired to `/api/account/public_message`, plus `PublicMessageTablePage` and `PublicMessageView` for reviewing visitor-submitted contact and support messages (bouncer-gated `/contact` submissions). Table supports filtering by status/kind, batch "Mark Closed", and row-click detail view. Detail view renders submitter info, generic key/value metadata (friendly labels for known keys like `company`, `category`, `severity`, UTM tags; humanized fallback for unknown keys), full message body (auto-escaped), and a one-click status toggle via `model.save({ status })`. Registered at `system/messaging/public-messages` with `view_support`/`support`/`security` permissions. Sidebar "Email" block renamed to "Messaging" with a new "Contact Messages" child entry.
- **PortalWebApp** — New opinionated base class extending `PortalApp` with auth-gated lifecycle, automatic WebSocket setup, and clean events. Auth is checked before the router starts; if it fails, a configurable countdown redirect is shown. WebSocket connects automatically after auth using `WebSocketClient.deriveURL()`. New events: `user:ready`, `user:logout`, `ws:ready`, `ws:lost`, `ws:reconnecting`. Config-driven: `auth: { loginUrl }` (default `/login`), `ws: true/false` (default `true`). Overridable `onAuthFailed(error)` hook. Exported from `src/index.js`.
- **Admin Assistant** — Fullscreen modal chat interface for LLM-powered admin queries. Triggered via `registerAssistant(app)` which adds a `bi-robot` icon to the topbar (requires `view_admin` permission). Two-panel layout: conversation list (left, REST-backed) + real-time chat area (right, WebSocket). Supports structured response blocks rendered inline: `table` blocks as `TableView`, `chart` blocks as `SeriesChart`/`PieChart`, and `stat` blocks as Bootstrap stat cards.
- **AssistantView** — New view exported from `web-mojo/admin`. Manages WebSocket subscriptions for `assistant_thinking`, `assistant_tool_call`, `assistant_response`, and `assistant_error` events. Unsubscribes on destroy to prevent leaks.
- **AssistantConversation & AssistantConversationList** — New models exported from `web-mojo/models`. Endpoint `/api/assistant/conversation`. Conversation history loaded via REST; messages sent via WebSocket.
- **IPSetTablePage & IPSetView** — New admin pages/views for managing kernel-level IP blocking sets (country blocks, AbuseIPDB feeds, datacenter ranges, custom CIDR lists). Route: `system/security/ipsets`. Registered automatically by `registerSystemPages()`.
- **IP Sets menu entry** — Added "IP Sets" (`bi-shield-shaded`) to the Security section of the admin sidebar, requiring `view_security` permission.
- **ChatView: `showThinking(text?)`** — Appends an animated bouncing-dots thinking indicator to the messages area. Subsequent calls update the text without adding a second indicator.
- **ChatView: `hideThinking()`** — Removes the thinking indicator.
- **ChatView: `setInputEnabled(enabled)`** — Enables or disables the chat textarea and send button.
- **ChatView: `messageViewClass` option** — Constructor option (default `ChatMessageView`) allowing consumers to supply a custom message view class.
- **ChatView: `showFileInput` option** — Constructor option (default `true`). When `false`, hides the file drop zone and disables the `FileDropMixin` in `ChatInputView`.
- **ChatInputView: `setEnabled(enabled)`** — Disables or re-enables the textarea and send button. Distinct from `setBusy()` (which shows a spinner).
- **ChatMessageView: `role` support** — Applies `message-assistant` or `message-user` CSS class based on `message.role`. Assistant messages display a `bi-robot` icon/avatar instead of user initials.
- **ChatMessageView: `blocks` container** — Renders a `data-container="blocks-{id}"` slot after message text for attaching block child views (used by `AssistantMessageView`).
- **ChatMessageView: `tool_calls` display** — If `message.tool_calls` is present, renders a collapsible Bootstrap collapse section showing tool names as badges.
- **FormBuilder: `showWhen` field option** — Conditionally shows/hides a field based on another field's value. Hidden fields are excluded from form submission data and their `required` attributes are suppressed during validation.
- **IncidentView RuleEngine: OSSEC smart rule creation** — When creating a new RuleSet from an OSSEC incident that carries a `rule_id` in its metadata, a matching rule condition (`field_name=rule_id`, `comparator==`, `value_type=int`) is auto-created and linked. Toast confirms whether auto-creation succeeded or fell back to manual.
- **IncidentView RuleEngine: "Create New Rule" button** — New `create-rule-from-incident` action button added alongside "View Full Details" in the RuleEngine section header.
- **IncidentView events table: compact two-line columns** — Date column now shows datetime + category badge stacked; Source column now shows hostname + IP stacked. Standalone Category and Host columns removed to reduce horizontal clutter.
- **RuleSet form: `Delete on Resolution` toggle** — New switch field (`metadata.delete_on_resolution`) added to both the create and edit RuleSet forms. When enabled, incidents produced by this rule are permanently deleted (cascade to events and history) when resolved or closed.
- **RuleSetTablePage: Auto-Delete column** — New `Auto-Delete` column (`metadata.delete_on_resolution`, `yesnoicon` formatter) shows at a glance which rules cascade-delete incidents on resolution.
- **IncidentView: Protect / Unprotect quick actions** — `QuickActionsBar` now shows a `Protect` button (outline) or a `Protected` button (warning/filled) based on `metadata.do_not_delete`. Clicking either saves the flag and emits `incident:updated` to refresh the view.
- **IncidentView: Protect / Remove Protection context menu items** — The incident context menu now includes "Protect from Deletion" or "Remove Protection" depending on current state, wired to `onActionProtectIncident` / `onActionRemoveProtection`.
- **IncidentView header: Protected badge** — A `bg-warning` Bootstrap badge with a shield icon is shown alongside the category badge when `metadata.do_not_delete` is set on the incident.
- **IncidentView RuleEngine: auto-delete warning** — When the linked RuleSet has `delete_on_resolution` enabled and the incident is not protected, a warning alert is shown. If the incident is also protected, an info alert notes that auto-delete is enabled but overridden.
- **IncidentTablePage: batch Protect action** — "Protect" added to the batch-action bar. Confirms via dialog, then saves `metadata.do_not_delete: true` on all selected incidents and refreshes the table.
- **IncidentView: HTTP Request tab** — A new "HTTP Request" tab (`bi-globe2`) is conditionally shown when the incident's metadata includes `http_method` or `http_path`. Displays method, status code, host, path, URL, protocol, query string, and user agent via `DataView`.
- **IncidentView: IP Intelligence tab** — A new "IP Intel" tab (`bi-shield-lock`) is conditionally shown when the incident carries `ip_info`. The tab is divided into four `DataView` subsections: Network (IP address, subnet, ISP, ASN, connection type), Threat Assessment (level, risk score, is_threat, is_suspicious), Threat Flags (TOR, VPN, proxy, datacenter, mobile, cloud, known attacker/abuser), and Block Status (blocked/whitelisted state, reason, timestamps).
- **IncidentView overview: server/timezone info** — If the incident metadata contains `server` or `timezone`, a combined info line is shown beneath the GeoIP summary card in the Overview tab.

- **AI Assistant context chat for TicketView and IncidentView** — Both `TicketView` and `IncidentView` now have an "Ask AI" button that opens a single-conversation assistant chat scoped to the specific model instance. On first open, `POST /api/assistant/context` is called with `{ model, pk }` to create a conversation; the returned `conversation_id` is persisted to `metadata.assistant_conversation_id` so subsequent opens resume the same thread. The chat renders in an `xl` Dialog (not fullscreen) so the underlying view remains visible. Supports real-time streaming via WebSocket with the same event flow as `AssistantView` (`assistant_thinking`, `assistant_tool_call`, `assistant_response`, `assistant_error`), structured response blocks (table, chart, stat) via `AssistantMessageView`, and markdown rendering. Falls back to `POST /api/assistant` when WebSocket is unavailable. `IncidentView` also exposes "Ask AI" in the context menu.
- **AssistantContextChat module** (`src/extensions/admin/assistant/AssistantContextChat.js`) — New self-contained module exporting `AssistantContextAdapter`, `AssistantContextChat`, and the `openAssistantChat(view, modelName)` helper. Any view with a `this.model` that has an `id` and `metadata` can call `openAssistantChat(this, 'app.ModelName')` to launch a context-scoped assistant chat with one line of code.
- **User permissions: AI Assistant category** — Added `assistant` permission category (`view_admin`-gated) to `User.CATEGORY_PERMISSIONS`.
- **Assistant `file` block type** — `AssistantMessageView` now renders `file` blocks as downloadable inline attachment cards. Each card shows a format-aware Bootstrap Icon (`csv`, `xlsx`, `pdf`, `json`, or generic), the filename, and optional metadata (file size, row count, expiry). Clicking the card triggers a browser download. URL scheme validation rejects any URL that does not begin with `https?://` or `/`, preventing `javascript:` XSS. Supported block fields: `filename` (required), `url` (required), `format`, `size`, `row_count`, `expires_in`.
- **Sidebar: auto group settings footer link** — Group-kind menus now automatically append a "Settings" nav link at the bottom of the footer for users with `manage_groups` or `manage_group` permission. Clicking the link opens a `GroupView` dialog for the active group. No configuration is required; the link is invisible to users without the relevant permission.
- **AssistantPanelView** — New chat-only sidebar panel for the Admin Assistant. Shows a compact header bar (hamburger history toggle, conversation title, new-conversation button, close button), welcome screen with quick-start suggestions, auto-resizing textarea, and connection status indicator. A hamburger toggle switches between chat and a conversation history list (`AssistantConversationListView` with search and pagination). Emits `panel:close` when the close button is clicked. Supports full WebSocket streaming (thinking, tool call, response, error, plan, plan_update events) with the same patterns as `AssistantView`. Falls back to `POST /api/assistant` when WebSocket is unavailable.
- **AssistantConversationListView: search and pagination** — The conversation list now includes a debounced search input (300 ms) at the top. Typing filters via `collection.params.search` and re-fetches. A "Load more" button appears at the bottom when `collection.hasMore` is true and appends the next page without clearing existing items. Both additions work in the fullscreen modal's left sidebar and in the panel history toggle.
- **registerAssistant(): responsive display mode** — The topbar assistant button now auto-selects display mode based on viewport width: `>= 1000px` opens a right sidebar panel (`AssistantPanelView`); `< 1000px` opens the existing fullscreen modal. Clicking the button while the sidebar is open closes it. If the viewport drops below 1000 px while the sidebar is open, a debounced resize listener closes the sidebar and opens the fullscreen modal (conversation ID preserved via `app._assistantConversationId`).
- **Shortlinks admin** — New `manage_shortlinks`-gated admin section for managing django-mojo shortlinks. Registered automatically by `registerSystemPages(app)`. Includes two pages (`ShortLinkTablePage` at `system/shortlinks/links` for full CRUD + detail modal with Details / Preview / Metadata / Click History / Metrics tabs, and `ShortLinkClickTablePage` at `system/shortlinks/clicks` for read-only global click history) and one composable view (`ShortLinkView`). All three are exported from `web-mojo/admin`. OG/Twitter metadata is exposed as flat form fields and collapsed into the API's `metadata` map on save; when no OG fields are provided, `metadata` is omitted so the backend auto-scrape runs.
- **ShortLink & ShortLinkClick models** — New models exported from `web-mojo/models`. `ShortLink` and `ShortLinkList` use endpoint `/api/shortlink/link`; `ShortLinkClick` and `ShortLinkClickList` use `/api/shortlink/history` (read-only). `ShortLinkForms` ships `create` and `edit` configs. Three metadata helpers are also exported: `flattenShortLinkMetadata` (metadata object → flat form fields), `buildShortLinkMetadata` (flat form fields → colon-keyed metadata object), and `extractShortLinkPayload` (strips OG/Twitter fields from form data and folds them into `metadata` before a REST save).

### Fixed
- **LoginLocationMapView: `_fetchSummary()` wrong endpoint for per-user summaries** — Fixed a bug where `_fetchSummary()` with a `userId` set would request `/api/account/logins/user` (non-existent endpoint) with a `user_id=` param instead of the correct `/api/account/logins/summary` endpoint. The method now always calls `/api/account/logins/summary` and passes the user ID as a `user=` query parameter, consistent with how global (non-user-scoped) summaries are fetched.
- **Rest + TokenManager: expired JWTs no longer leak into outgoing requests** — `PortalApp` (and its subclasses `PortalWebApp`, `DocItApp`) now install a pre-request auth gate that refreshes an expired access token before the call goes out. Concurrent callers share a single `POST /api/token/refresh` (single-flight). If the refresh token is also expired/invalid, the call returns `{ success: false, status: 401, reason: 'unauthorized' }` without hitting the network and `auth:unauthorized` is emitted. URLs under `/api/token/` bypass the gate to prevent refresh recursion. `rest.download()` / `rest.downloadBlob()` / raw-XHR `rest.upload()` still bypass interceptors and are not yet covered.
- **Admin AssistantConversationView: wrong author and missing post-processing** — User messages now show the actual conversation user's name and avatar instead of "You" (the admin). Internal tool calls are collapsed and legacy block formats are parsed, matching the user-facing `AssistantView` rendering.
- **AssistantView: stuck "Waiting for response" state** — Fixed a bug where `AssistantView` would remain locked in the waiting state after a WebSocket reconnection, requiring a page reload to recover.
- **ListView: no empty-state flash on initial load** — `setCollection()` now sets `loading = true` immediately when the collection is REST-enabled and has never been fetched, preventing the "No data available" message from appearing briefly before the first fetch completes.
- **GeoIPSummaryCard: eliminated redundant API call** — The card now accepts an `ipInfo` option. When `ip_info` is already present on the incident graph response, the card uses it directly and skips the `GeoLocatedIP.lookup()` call. Falls back to the API lookup when `ip_info` is absent.
- **GeoIPView block/unblock/whitelist actions** — Converted from ad hoc `rest.POST` calls to `model.save()` with action payloads; added optional chaining on `toast` calls to avoid errors in non-portal contexts.
- **IncidentView RuleEngine: `rule_set` field name** — Fixed stale field reference (`ruleset` → `rule_set`) when reading and saving the linked rule set on an incident. Handles both plain ID and nested object responses.
- **IncidentView: detailed graph fetch** — `IncidentView.onInit()` now fetches the incident with `graph=detailed` so nested relations (e.g. `rule_set`) are available before child sections render.

## [Previous]
- **User Profile Extension** (`web-mojo/user-profile`) — Moved all user profile views from `src/core/views/user/` into a standalone extension at `src/extensions/user-profile/`. Available as `import { ... } from 'web-mojo/user-profile'` or via `@ext/user-profile/index.js` internally.
- **ProfilePersonalSection** — Editable first/last name, display name, DOB (with verified/unverified badge), timezone, and address (stored in `user.metadata`)
- **ProfileConnectedSection** — Lists OAuth provider connections (Google, GitHub, Microsoft, etc.) with unlink capability and lockout guard
- **ProfileSecurityEventsSection** — TableView of auth events (logins, failed attempts, password changes) with color-coded severity badges and custom `SecurityEventRow`
- **ProfileNotificationsSection** — Per-kind, per-channel toggle grid for notification preferences (in-app, email, push)
- **ProfileApiKeysSection** — Generate, list, copy, and delete personal API keys with IP restriction and expiration options; token shown once with copy-to-clipboard
- **Recovery Codes** in ProfileSecuritySection — View masked codes, regenerate with TOTP verification, copy-all support
- **Revoke All Sessions** in ProfileSecuritySection — Password-confirmed session revocation with automatic token refresh
- **Passkey model centralization** — `Passkey.register(friendlyName)` and `Passkey.suggestName()` static methods on the Passkey model, shared by both `PasskeySetupView` and `ProfileSecuritySection`
- **Rich passkey dialogs** — Passkey registration uses polished dialogs for name input (with auto-suggested device name), success confirmation, and error display instead of toasts
- **UserProfileView nav** updated to 11 sections across 3 groups: Profile, Personal, Security, Connected | Sessions, Devices, Security Events | Notifications, API Keys, Groups, Permissions

### Changed
- **ProfileSessionsSection** — Rewritten with TableView (paginated, size 10) and custom `SessionRow` with rich two-line column templates: browser + device on top, location + IP + threat flags below
- **ProfileDevicesSection** — Rewritten with TableView (paginated, size 10) and custom `DeviceRow` with rich two-line column templates: device name + model on top, browser + OS + IP below
- **ProfileOverviewSection** — Removed personal fields (moved to Personal section), removed username edit (read-only), added account deactivation, relaxed phone number format placeholder
- **PortalApp** — Dynamic imports updated from `@core/views/user/index.js` to `@ext/user-profile/index.js`; removed duplicate `onActionChangePassword` handler that caused double dialog
- `src/core/views/user/index.js` now re-exports from extension for backward compatibility (marked `@deprecated`)

### Fixed
- **Passkey registration flow** — Name is now collected before the WebAuthn API call (was previously asking after OS biometric prompt)
- **Passkey REST calls** — Added `dataOnly: true` to prevent double-wrapped response (`resp.data.data`) causing "Failed to start" errors
- **Double password dialog** — Removed duplicate `onActionChangePassword` from `UserProfileView` that conflicted with `ProfileSecuritySection`'s handler
- **Phone number format** — Changed placeholder from E.164 format (`+14155550123`) to friendly format (`(415) 555-0123`) since backend normalizes
- **MetricsChart gear dropdown** — Chart type toggle now returns `true` from action handlers for EventDelegate auto-close; chart type moved back to SeriesChart's built-in switcher
