portal-mojo 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (208) hide show
  1. package/README.md +42 -1
  2. package/docs/DetailView.md +24 -3
  3. package/docs/ModelTable.md +44 -0
  4. package/docs/README.md +1 -0
  5. package/docs/admin-assistant.md +14 -7
  6. package/docs/charts.md +7 -0
  7. package/docs/client.md +109 -0
  8. package/docs/dataview.md +6 -3
  9. package/docs/defineModel.md +49 -2
  10. package/docs/grouping-and-fmt.md +17 -1
  11. package/docs/image-editor.md +13 -0
  12. package/docs/menus-and-access.md +57 -0
  13. package/docs/params.md +12 -0
  14. package/docs/personas.md +212 -0
  15. package/docs/realtime.md +44 -2
  16. package/docs/route-error.md +64 -0
  17. package/package.json +15 -2
  18. package/src/admin/assistant/AssistantLauncher.tsx +23 -0
  19. package/src/admin/assistant/AssistantPanel.tsx +3 -2
  20. package/src/admin/assistant/api.ts +1 -1
  21. package/src/admin/assistant/index.ts +1 -11
  22. package/src/admin/assistant/launchers.tsx +5 -11
  23. package/src/admin/assistant/pages.tsx +1 -1
  24. package/src/admin/assistant/permissions.ts +1 -0
  25. package/src/admin/assistant/streaming.ts +69 -41
  26. package/src/admin/bouncer/index.ts +1 -28
  27. package/src/admin/bouncer/models.ts +1 -1
  28. package/src/admin/bouncer/signatures.tsx +1 -1
  29. package/src/admin/cloudwatch/CloudWatchChart.tsx +1 -1
  30. package/src/admin/cloudwatch/data.ts +1 -1
  31. package/src/admin/cloudwatch/index.ts +1 -11
  32. package/src/admin/core/index.ts +230 -0
  33. package/src/admin/credentials/group-api-keys.tsx +1 -1
  34. package/src/admin/credentials/models.ts +1 -1
  35. package/src/admin/credentials/webhook-subscriptions.tsx +1 -1
  36. package/src/admin/dashboard/AdminDashboardPage.tsx +9 -18
  37. package/src/admin/dashboard/data.ts +1 -1
  38. package/src/admin/dashboard/index.ts +1 -0
  39. package/src/admin/dns/CertificateDetail.tsx +1 -1
  40. package/src/admin/dns/CertificateLifecyclePoller.tsx +1 -1
  41. package/src/admin/dns/CertificatesPage.tsx +1 -1
  42. package/src/admin/dns/DnsRecordsPanel.tsx +1 -1
  43. package/src/admin/dns/DomainCertificatesSection.tsx +1 -1
  44. package/src/admin/dns/DomainDetail.tsx +1 -1
  45. package/src/admin/dns/DomainOnboardingDialog.tsx +1 -1
  46. package/src/admin/dns/DomainPurchasesPage.tsx +1 -1
  47. package/src/admin/dns/ProviderCredentialsPage.tsx +1 -1
  48. package/src/admin/dns/RegistrantContactPage.tsx +1 -1
  49. package/src/admin/dns/api.ts +1 -1
  50. package/src/admin/dns/index.ts +2 -59
  51. package/src/admin/dns/models.ts +1 -1
  52. package/src/admin/dns/setup.ts +27 -0
  53. package/src/admin/domains/assistant.ts +13 -0
  54. package/src/admin/domains/communications.ts +35 -0
  55. package/src/admin/domains/identity.ts +24 -0
  56. package/src/admin/domains/infrastructure.ts +24 -0
  57. package/src/admin/domains/observability.ts +28 -0
  58. package/src/admin/domains/operations.ts +18 -0
  59. package/src/admin/domains/security.ts +55 -0
  60. package/src/admin/identity/members/GroupMembersPanel.tsx +1 -1
  61. package/src/admin/identity/members/MemberDetail.tsx +1 -1
  62. package/src/admin/identity/members/MembersPage.tsx +1 -1
  63. package/src/admin/identity/members/index.ts +1 -17
  64. package/src/admin/identity/members/member-flows.tsx +1 -1
  65. package/src/admin/identity/members/models.ts +1 -1
  66. package/src/admin/identity/users/UserDetail.tsx +1 -1
  67. package/src/admin/identity/users/UsersPage.tsx +1 -1
  68. package/src/admin/identity/users/index.ts +1 -16
  69. package/src/admin/identity/users/models.ts +1 -1
  70. package/src/admin/identity/users/sections/LoginsSection.tsx +1 -1
  71. package/src/admin/identity/users/sections/NotificationsSection.tsx +1 -1
  72. package/src/admin/identity/users/sections/PersonalSection.tsx +1 -1
  73. package/src/admin/identity/users/sections/actions.tsx +15 -6
  74. package/src/admin/identity/users/sections/permission-catalog.ts +1 -1
  75. package/src/admin/identity/users/sections/queries.ts +1 -1
  76. package/src/admin/identity/users/sections/shared.tsx +1 -1
  77. package/src/admin/incidents/EventsPage.tsx +1 -1
  78. package/src/admin/incidents/IncidentDetail.tsx +1 -1
  79. package/src/admin/incidents/IncidentsPage.tsx +1 -1
  80. package/src/admin/incidents/index.ts +1 -8
  81. package/src/admin/incidents/models.ts +1 -1
  82. package/src/admin/index.ts +5 -364
  83. package/src/admin/jobs/JobDetail.tsx +5 -3
  84. package/src/admin/jobs/JobsTablePage.tsx +1 -1
  85. package/src/admin/jobs/RunnerDetail.tsx +2 -2
  86. package/src/admin/jobs/ScheduledTaskDetail.tsx +1 -1
  87. package/src/admin/jobs/ScheduledTasksPage.tsx +1 -1
  88. package/src/admin/jobs/columns.tsx +1 -1
  89. package/src/admin/jobs/control.ts +1 -1
  90. package/src/admin/jobs/index.ts +1 -39
  91. package/src/admin/jobs/models.ts +1 -1
  92. package/src/admin/jobs/queries.ts +1 -1
  93. package/src/admin/jobs/sections/JobChannelsPanel.tsx +1 -1
  94. package/src/admin/jobs/sections/JobOperationsSection.tsx +1 -1
  95. package/src/admin/messaging/EmailDomainWizard.tsx +1 -1
  96. package/src/admin/messaging/PublicMessagesPage.tsx +1 -1
  97. package/src/admin/messaging/api.ts +1 -1
  98. package/src/admin/messaging/data.ts +1 -1
  99. package/src/admin/messaging/index.ts +1 -9
  100. package/src/admin/messaging/models.ts +1 -1
  101. package/src/admin/messaging/push/PushPage.tsx +1 -1
  102. package/src/admin/messaging/push/api.ts +1 -1
  103. package/src/admin/messaging/push/index.ts +1 -4
  104. package/src/admin/messaging/push/models.ts +1 -1
  105. package/src/admin/monitoring/MetricsExplorerPage.tsx +1 -1
  106. package/src/admin/monitoring/MetricsSourcePicker.tsx +1 -1
  107. package/src/admin/monitoring/index.ts +1 -41
  108. package/src/admin/monitoring/metrics-explorer-client.ts +2 -2
  109. package/src/admin/monitoring/models.ts +1 -1
  110. package/src/admin/network/BlockedIPsPage.tsx +10 -7
  111. package/src/admin/network/BlockedIpDetail.tsx +1 -1
  112. package/src/admin/network/FirewallLogPage.tsx +3 -1
  113. package/src/admin/network/IPSetDetail.tsx +1 -1
  114. package/src/admin/network/IPSetsPage.tsx +1 -1
  115. package/src/admin/network/geofence/BlocksTab.tsx +1 -1
  116. package/src/admin/network/geofence/ExemptionsTab.tsx +1 -1
  117. package/src/admin/network/geofence/GeofencingPage.tsx +1 -1
  118. package/src/admin/network/geofence/RulesTab.tsx +1 -1
  119. package/src/admin/network/geofence/geofence-data.ts +3 -21
  120. package/src/admin/network/geofence/permissions.ts +13 -0
  121. package/src/admin/network/index.ts +1 -41
  122. package/src/admin/network/models.ts +3 -9
  123. package/src/admin/network/permissions.ts +8 -0
  124. package/src/admin/phonehub/PhoneHubPage.tsx +3 -3
  125. package/src/admin/phonehub/api.ts +4 -1
  126. package/src/admin/phonehub/data.ts +1 -1
  127. package/src/admin/phonehub/index.ts +1 -4
  128. package/src/admin/phonehub/models.ts +1 -1
  129. package/src/admin/public/assistant-launcher.ts +1 -0
  130. package/src/admin/public/assistant.ts +2 -0
  131. package/src/admin/public/communications.ts +4 -0
  132. package/src/admin/public/identity.ts +3 -0
  133. package/src/admin/public/infrastructure.ts +4 -0
  134. package/src/admin/public/observability.ts +4 -0
  135. package/src/admin/public/operations.ts +3 -0
  136. package/src/admin/public/security.ts +7 -0
  137. package/src/admin/registry.ts +40 -0
  138. package/src/admin/rules/RuleSetDetailPage.tsx +1 -1
  139. package/src/admin/rules/RuleSetsPage.tsx +1 -1
  140. package/src/admin/rules/index.ts +1 -6
  141. package/src/admin/rules/models.ts +1 -1
  142. package/src/admin/security/devices/LoginEventDetail.tsx +1 -1
  143. package/src/admin/security/devices/LoginLocationsPage.tsx +1 -1
  144. package/src/admin/security/devices/UserDeviceDetail.tsx +1 -1
  145. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +1 -1
  146. package/src/admin/security/devices/index.ts +1 -22
  147. package/src/admin/security/devices/models.ts +1 -1
  148. package/src/admin/security/geoip/GeoIpCachePage.tsx +1 -1
  149. package/src/admin/security/geoip/GeoIpDossier.tsx +1 -1
  150. package/src/admin/security/geoip/geoip-forms.ts +1 -1
  151. package/src/admin/security/geoip/index.ts +1 -17
  152. package/src/admin/security/geoip/models.ts +1 -1
  153. package/src/admin/security/index.ts +1 -23
  154. package/src/admin/security/models.ts +1 -1
  155. package/src/admin/security/tickets.tsx +1 -1
  156. package/src/admin/settings/SettingDetail.tsx +1 -1
  157. package/src/admin/settings/SettingEditor.tsx +1 -1
  158. package/src/admin/settings/index.ts +1 -13
  159. package/src/admin/settings/model.ts +1 -1
  160. package/src/admin/shortlinks/index.ts +1 -12
  161. package/src/admin/shortlinks/models.ts +1 -1
  162. package/src/admin/storage/BackendsPage.tsx +1 -1
  163. package/src/admin/storage/FileManagerDetail.tsx +1 -1
  164. package/src/admin/storage/FileView.tsx +1 -1
  165. package/src/admin/storage/FilesPage.tsx +1 -1
  166. package/src/admin/storage/index.ts +1 -18
  167. package/src/admin/storage/models.ts +9 -4
  168. package/src/admin/storage/permissions.ts +3 -0
  169. package/src/admin/storage/storage-dialogs.tsx +1 -1
  170. package/src/client/action-result.ts +95 -0
  171. package/src/client/active-group.ts +29 -0
  172. package/src/client/client.ts +15 -1
  173. package/src/client/endpoint-scope.ts +79 -0
  174. package/src/client/group.tsx +9 -0
  175. package/src/client/index.ts +5 -0
  176. package/src/client/mock.ts +17 -12
  177. package/src/client/model.ts +26 -2
  178. package/src/client/params.ts +16 -4
  179. package/src/client/persist.ts +54 -0
  180. package/src/client/realtime-mock.ts +17 -13
  181. package/src/client/realtime.ts +18 -4
  182. package/src/client/runtime.ts +21 -0
  183. package/src/client/scoped.ts +93 -0
  184. package/src/client/upload.ts +6 -3
  185. package/src/personas/PersonaProvider.tsx +151 -0
  186. package/src/personas/audit.ts +73 -0
  187. package/src/personas/index.ts +13 -0
  188. package/src/personas/presets.ts +72 -0
  189. package/src/personas/registry.ts +111 -0
  190. package/src/personas/sections.tsx +202 -0
  191. package/src/ui/DetailView.tsx +30 -6
  192. package/src/ui/ModelTable.tsx +75 -9
  193. package/src/ui/RouteError.tsx +73 -0
  194. package/src/ui/SidebarNav.tsx +36 -5
  195. package/src/ui/active-persona.ts +34 -0
  196. package/src/ui/detail/DataView.tsx +10 -4
  197. package/src/ui/detail/KnownFieldsCard.tsx +13 -1
  198. package/src/ui/fixed-params.ts +58 -0
  199. package/src/ui/format.ts +36 -11
  200. package/src/ui/image-editor/ImageEditor.tsx +21 -22
  201. package/src/ui/image-editor/canvas.ts +82 -11
  202. package/src/ui/image-editor/math.ts +78 -0
  203. package/src/ui/index.ts +3 -0
  204. package/src/ui/menu-registry.ts +128 -9
  205. package/src/ui/route-error.ts +60 -0
  206. package/src/ui/safe-node.tsx +88 -0
  207. package/src/ui/shell.ts +13 -0
  208. package/src/ui/warn-once.ts +12 -0
package/README.md CHANGED
@@ -13,12 +13,53 @@ Live demos for everything: run `npm run dev:showcase` → **Develop → Componen
13
13
 
14
14
  ## Subpath surfaces
15
15
 
16
+ The `client/runtime`, `ui/shell`, `admin/registry`, and Assistant launcher
17
+ subpaths are stable, narrow package boundaries for first-party portal shells
18
+ and advanced hosts. They intentionally expose only the runtime pieces needed
19
+ at application startup; use the broader compatibility surfaces when those
20
+ additional exports are required.
21
+
16
22
  | Subpath | Surface |
17
23
  |---|---|
18
24
  | `portal-mojo/client` | Typed django-mojo protocol layer: envelope unwrap at exactly one boundary (a failed save **rejects**), `start`/`size` paging, `'-field'` sort, Django lookups, the URL-synced `useTableParams` store (single source of truth for table state), TanStack Query hooks. Auth client: password / magic-link / passkey login, forgot/reset, cross-origin handoff (`?auth_code=` scrub-before-network), single-flight refresh + pre-request gate (synthetic-401 reject, `/api/token/refresh` recursion guard), `X-Mojo-UID` device header, `getAuthSnapshot`/`subscribeAuth` for React. Auth-challenged realtime transport: `RealtimeProvider`/`RealtimeClient`, refcounted topics, typed event projection, and a deterministic mock. Boot auth with `initAuth()`. The in-memory HTTP mock transport lives here too — it is the wire contract's executable spec and evolves in lockstep with the client (any seeded active user logs in with email + `"mojo"`). |
25
+ | `portal-mojo/client/runtime` | Stable, narrow client boundary for first-party application runtime code. It omits eager mock-test controls; the mock transport itself loads on the first mock request. |
19
26
  | `portal-mojo/ui` | Mission-control UI: `ModelTable`, `FilterBar`/`FilterPills`, `SchemaForm`/`formModal`, `DetailView`, `RecordFeed`, `AttachmentQueue`/`UploadQueue`, `ImageEditor`/`imageEditorModal`, awaitable native-`<dialog>` `modal`, `toast`, `ThemeProvider`, `Guarded` (permission slot), `GroupSwitcher` (searchable tree selector), `RequiresGroup`, the sidebar engine (`registerMenus`/`setDefaultMenu` registry + `SidebarNav` — static or searchable accordion with a compact icon rail; global and group scopes are explicit), `Badge`/`MetricCard`/`Spark`, `fmt` formatter namespace. |
27
+ | `portal-mojo/ui/shell` | Stable, narrow boundary for application-shell primitives: theme, guards, menus/sidebar, right panel, modal/toast hosts, password helpers, and formatters. |
20
28
  | `portal-mojo/charts` | Dependency-free SVG charts: `SeriesChart` (line/bar/area, stacked bars, legend toggle, crosshair tooltip) and `MetricsChart` (granularity/range/type control bar for `/api/metrics/fetch`). |
21
- | `portal-mojo/admin` | Global Admin **section bundles**, dual-mount: the standalone back-office portal mounts all of them at root; a product portal embeds them under a global "System" area. `AdminSection` contract + `adminSectionsMenu()` (sections → domain-grouped sidebar menu, mount-point-relative, with section + route permission clauses). Admin scope masks active-group membership even when embedded. Reusable domains include credentials, monitoring, security operations, settings, Bouncer, Members, and the Assistant control plane with narrow realtime streaming plus authoritative REST fallback/reconciliation. |
29
+ | `portal-mojo/admin/core` | Side-effect-light Admin contracts and host: `AdminSection`, the discriminated `AdminRoute` XOR, `adminSectionRoutes`, `adminSectionsMenu`, and `AdminLazyPage`. |
30
+ | `portal-mojo/admin/registry` | Stable, narrow built-in `ADMIN_SECTIONS` roster for first-party hosts, without broad page barrels. It installs the optional Email→DNS adapter synchronously so communications routes work before DNS navigation. |
31
+ | `portal-mojo/admin/assistant/launcher` | Stable, narrow shell launcher boundary without the Assistant page/data barrel. |
32
+ | `portal-mojo/admin/{identity,security,observability,operations,infrastructure,communications,assistant}` | Narrow domain APIs and registry contributions. Built-in routes use leaf-module `loadComponent` loaders; importing `infrastructure` synchronously installs the optional Email→DNS adapter. |
33
+ | `portal-mojo/admin` | Compatibility aggregate containing every historical named export and the stable `ADMIN_SECTIONS` roster. It intentionally installs the DNS adapter and Rule field registration synchronously. New code should prefer `admin/core` plus the domains it consumes. |
34
+
35
+ ### Admin route contract
36
+
37
+ Routes are an explicit XOR. A host-owned page stays synchronous; a packaged
38
+ page supplies a separately named loader. The host never guesses from whether a
39
+ value is callable.
40
+
41
+ ```tsx
42
+ const extension: AdminSection = {
43
+ id: 'billing', title: 'Billing', icon: 'bi-receipt',
44
+ permissions: ['sys.billing'],
45
+ routes: [{ path: '', component: BillingHome }],
46
+ };
47
+
48
+ const packaged: AdminSection = {
49
+ id: 'audit', title: 'Audit', icon: 'bi-journal-text',
50
+ permissions: ['sys.view_logs'],
51
+ routes: [{
52
+ path: '',
53
+ loadComponent: () => import('./AuditPage').then(({ AuditPage }) => ({ default: AuditPage })),
54
+ }],
55
+ };
56
+ ```
57
+
58
+ Lazy loading, chunk-error recovery, and retry render inside section and route
59
+ permission guards, so a denied route never invokes its loader. Routes stay
60
+ mount-relative: pass no mount for `#/…`, or `{mount: '/system'}` for the same
61
+ sections under `#/system/…`. Root denial and first-visible fallback semantics
62
+ are identical in both forms.
22
63
 
23
64
  ## What the consuming app must provide
24
65
 
@@ -8,7 +8,7 @@ import {
8
8
  ```
9
9
 
10
10
  The record-detail surface: identity header (avatar, title, subtitle,
11
- chips, optional active switch, kebab menu, close) over a left rail of
11
+ chips, optional active switch, kebab menu, optional close) over a left rail of
12
12
  sections with side-label dividers. Open it inside `modal.detail(...)` for
13
13
  the standard row-click experience. Ported from web-mojo `SideNavView.js`
14
14
  (sections, gating, badges) + `ContextMenu.js` (the kebab).
@@ -31,7 +31,7 @@ of the toolkit it must render inside the app's `QueryClientProvider`
31
31
  title={...} subtitle={u.email}
32
32
  chips={[{ icon: 'bi-patch-check-fill', text: 'Email', tone: 'success' }]}
33
33
  active={{ value: u.is_active, onChange: (next) => ... }}
34
- onClose={onClose}
34
+ onClose={onClose} // optional — omit for page hosts (no ✕ renders)
35
35
  badges={{ sessions: openSessions }}
36
36
  menuContext={u}
37
37
  contextMenu={[
@@ -54,6 +54,27 @@ of the toolkit it must render inside the app's `QueryClientProvider`
54
54
  `{divider: 'Label'}` side-labels.
55
55
  - `initialSection` picks the starting rail entry.
56
56
 
57
+ ## Page embedding — `onClose` is optional
58
+
59
+ The ✕ is the component's only modal coupling (no Escape handling, no focus
60
+ trap — those belong to the host dialog). The affordance follows the callback:
61
+
62
+ - **Omit `onClose`** for a routed-page/embedded host — no close affordance
63
+ renders and no CSS overrides are needed.
64
+ - **Pass a history-back handler** when a routed record page should keep the
65
+ ✕ as "back" (`onClose={() => navigate(-1)}` — linkable record routes read
66
+ well this way).
67
+ - **Modal hosts MUST pass it.** Inside `modal.detail` this ✕ is the modal's
68
+ only visible dismiss — the envelope has no header X of its own, only
69
+ Escape/backdrop — and TypeScript no longer enforces the prop. Omitting it
70
+ in a modal yields an Escape-only dialog.
71
+
72
+ Sizing is the consuming app's concern: `.detail` carries
73
+ `min-height`/`max-height: 82vh` in the app `theme.css` (base at
74
+ `apps/portal/src/theme.css`) — page hosts relax it there. This is distinct
75
+ from the C1 chrome (`.rail-badge`, `.dv-menu*`, `.dv-keep`) that lives in
76
+ `theme/detailview.css` (see Pitfalls).
77
+
57
78
  ## Permission-gated sections (fail-closed)
58
79
 
59
80
  `Section.permissions` is a permission name or an ANY-of array, resolved
@@ -90,7 +111,7 @@ follows. There is no imperative handle.
90
111
  ## Header kebab menu
91
112
 
92
113
  `contextMenu?: Array<{ label, icon?, permissions?, when?, onSelect, danger? } | { divider: true }>`
93
- renders a three-dot button in the header gutter (before the close X) that
114
+ renders a three-dot button in the header gutter (before the close X, when one renders) that
94
115
  opens a `Popover` menu (`placement: bottom-end`, top-layer — works inside
95
116
  `modal.detail`). Filtering is `ContextMenu.visibleItems` parity:
96
117
 
@@ -45,6 +45,50 @@ drives the sort param. `render` is the ONE cell prop (no formatter/template
45
45
  aliases). `hideable: false` locks a column in the chooser (identity
46
46
  columns).
47
47
 
48
+ **Cells never crash the page.** django-mojo graphs expand FK fields
49
+ per-graph, so a cell value can flip from `"GC"` to `{id, code, name, …}`
50
+ without a frontend change. The default (no-`render`) leg degrades an object
51
+ to `fmt.code(value)` or a visible `[object]` marker plus one `console.warn`
52
+ naming the column. A custom `render` runs inside `RenderGuard` — a per-cell
53
+ error boundary — so a callback that throws on the new shape, or returns an
54
+ element with the raw object nested inside, degrades to a dim `[render
55
+ error]` marker instead of white-screening. `rowExpand` and
56
+ `groupHeaderLabel` are guarded the same way. Render FK-ish fields through
57
+ `fmt.code()` (see grouping-and-fmt.md) so the cell shows the code instead
58
+ of a marker.
59
+
60
+ ## fixedParams — locked scope
61
+
62
+ `fixedParams?: Params | null` is table IDENTITY, not table state: tenant /
63
+ group / graph scope merged into every wire request (list, export, exporter
64
+ callback) AFTER filter state and model normalization. It never renders a
65
+ pill, survives Clear all, is excluded from view persistence and the URL —
66
+ a bookmarked `?group=9` or a persisted view saved before the page was
67
+ scope-migrated is silently dropped rather than resurrected as a removable
68
+ filter.
69
+
70
+ - **Scope belongs in `fixedParams`; user-editable filter DEFAULTS belong
71
+ in `defaultParams`.** A tenant key in `defaultParams` is one click from
72
+ an un-scoped table — the bug class this prop closes. Passing the same
73
+ key in both warns once (the `defaultParams` copy is dead).
74
+ - **The merge runs after `normalizeListParams`** — positive-projection
75
+ normalizers would otherwise silently strip an un-allowlisted scope key
76
+ and the request would go out unscoped. Consequence: `fixedParams.graph`
77
+ overrides a normalizer-pinned graph (that's the documented
78
+ `graph: 'full'` use).
79
+ - **`null` means "scope pending"**: the table renders its skeleton and
80
+ fetches nothing until scope resolves — an async-scoped table must never
81
+ fire (and cache) an unscoped first request. `undefined` means the table
82
+ is unscoped by design and fetches normally.
83
+ - **Reserved keys can't be locked** (`start`/`size`/`page`/`sort`/
84
+ `search`) — they'd fight the pager/sort/search UI; ignored with one
85
+ console.warn.
86
+ - **A scope flip is a filter change to the user**: changing `fixedParams`
87
+ resets to page 1 and clears row selection (a batch action must never
88
+ fire against ids selected under the previous scope).
89
+ - **Presets must not carry fixed keys** — their writes are scrubbed from
90
+ filter state, so such a preset never matches as active.
91
+
48
92
  ## Filters (see also forms.md dialog types)
49
93
 
50
94
  `FilterDef` types: `text` (`__icontains`), `select`, `multiselect`
package/docs/README.md CHANGED
@@ -62,6 +62,7 @@ everything: run the showcase (`npm run dev:showcase`) → **Develop → Componen
62
62
  | [collection-multiselect.md](collection-multiselect.md) | Server-backed multi-pick dropdown (panel variant available) — model binding, 400ms search, select/deselect-all, shift-click ranges |
63
63
  | [detail-primitives.md](detail-primitives.md) | StatusPanel, FlowStrip, Timeline, KnownFieldsCard, MetadataSection, StackTraceView — the detail-page pack |
64
64
  | [dataview.md](dataview.md) | Auto-inferring key/value grid + safe JSON viewer — schema or inference, nested records |
65
+ | [route-error.md](route-error.md) | `RouteError`/`RouteErrorCard` — the route-level last-resort error card, the error-handling ladder, two-level `errorElement` wiring |
65
66
  | [markdown.md](markdown.md) | MarkdownView — server render via docit, allowlist sanitizer trust model, client fallback |
66
67
  | [record-feed.md](record-feed.md) | RecordFeed — ticket/incident adapters, chronological latest-window normalization, optimistic sends, controlled mode |
67
68
  | [right-panel.md](right-panel.md) | Explicit opt-in persistent shell slot — provider state, focus/Escape semantics, route preservation, responsive layout |
@@ -9,20 +9,22 @@ Import from `portal-mojo/admin`.
9
9
  - An owned, text-only conversation uses realtime only when the shared transport
10
10
  is authenticated and the caller locally has `sys.view_admin`, matching the
11
11
  WebSocket handler's narrower server check. The client sends exactly
12
- `{type:'assistant_message',message,conversation_id?}`. A caller with only
12
+ `{type:'assistant_message',message,request_id,conversation_id?}`, with a fresh
13
+ canonical UUID per turn. A caller with only
13
14
  `sys.assistant`, any send with attachments, an unavailable socket, and a
14
15
  foreign inspect-only conversation stay on REST. Messages are never
15
16
  auto-resubmitted across paths.
16
17
  - `POST /api/assistant` sends `{message, conversation_id?, attachments?}`.
17
18
  `attachments` is omitted for text-only sends or contains 1–5 unique positive
18
19
  ids from completed authoritative queue references.
19
- - Realtime positively projects only `assistant_thinking`, `assistant_text`,
20
+ - Realtime accepts these Assistant event shapes only as exact direct frames;
21
+ topic-wrapped or broadcast Assistant-shaped traffic is ignored. It positively
22
+ projects only `assistant_thinking`, `assistant_text`,
20
23
  `assistant_tool_call`, `assistant_plan`, `assistant_plan_update`,
21
- `assistant_response`, and `assistant_error`, whether they arrive directly or
22
- through the transport's one wrapper boundary. Thinking, text, tool, plan,
23
- update, and response events require a positive `conversation_id` and are
24
- correlated to the active turn. `assistant_error` alone may omit it; such an
25
- uncorrelated server error terminates the active turn. Tool state retains
24
+ `assistant_response`, and `assistant_error`. Thinking, text, tool, plan,
25
+ update, response, and error events require the exact `request_id` sent for
26
+ the active turn. Non-error events also require a positive `conversation_id`;
27
+ an error may omit only the conversation id. Tool state retains
26
28
  bounded name/status/count only; raw input and the terminal `tool_calls_made`
27
29
  list never enter UI state, caches, logs, or mock observations. Plans omit
28
30
  tool inputs. Terminal duplication is checked only with the backend's
@@ -37,6 +39,11 @@ Import from `portal-mojo/admin`.
37
39
  reconnect; locally observed permission loss immediately clears this
38
40
  consumer's transient stream/subscriptions without disabling the shared
39
41
  provider.
42
+ - Direct-source binding and the echoed `request_id` jointly isolate concurrent
43
+ turns, including turns in the same conversation. Missing or mismatched IDs
44
+ are ignored; if no correlated acknowledgement arrives within ten seconds,
45
+ the outcome is marked unknown, the list is refreshed, and the send is never
46
+ retried automatically.
40
47
  - `POST /api/assistant/context` sends only `{model, pk}`. The returned conversation id is immediately fetched from `/api/assistant/conversation/<id>?graph=detail`; the client never synthesizes or reposts context text.
41
48
  - Conversation and Skill lists/details/deletes are imperative and component-local. They do not use `defineModel`, Query cache, `ModelTable`, a `RecordFeed` adapter, persistence, or exports.
42
49
  - A foreign conversation visible to an administrator is inspect-only. Only `conversation.user.id === me.id` enables continuation.
package/docs/charts.md CHANGED
@@ -58,6 +58,13 @@ daterange triple — `/api/metrics/fetch` ignores it silently. web-mojo sent
58
58
  />
59
59
  ```
60
60
 
61
+ > **Money in charts:** metric values reach `valueFormatter` (and the KPITile
62
+ > `formatter`) as plain numbers with no unit semantics. If a series carries
63
+ > money, keep it in integer minor units end-to-end and write
64
+ > `(n) => fmt.currency(n)` — never pre-convert to dollars (renders 100×
65
+ > small). Avg/Median stats and "nice" Y-ticks pass DERIVED, possibly
66
+ > fractional cents; `fmt.currency` rounds them for display.
67
+
61
68
  Legend entries toggle series; hover shows the crosshair index tooltip;
62
69
  ticks are "nice"-rounded; container width is measured (no viewBox
63
70
  smearing).
package/docs/client.md CHANGED
@@ -61,6 +61,115 @@ Mock-only contract controls are exported in development: call
61
61
  `armMockReauth(method, path)` to make one authenticated method+path match
62
62
  answer 440. Ordinary requests and real transports are unaffected.
63
63
 
64
+ ## Actions — `mojoAction` / `readActionResult`
65
+
66
+ django-mojo POST_SAVE_ACTION handlers can refuse **inside an HTTP 200**, in
67
+ two wire shapes — flat `{success:false, code, error}` or wrapped
68
+ `{status:true, data:{success:false, …}}` — which the unwrap boundary
69
+ deliberately passes through (envelope `status:false` is a different failure
70
+ and already rejects). Never hand-sniff these shapes; there is one reader:
71
+
72
+ - `readActionResult(body)` → `{ok, code, error, payload}`. `payload` merges
73
+ the envelope with the action dict (action fields win), so one-shot
74
+ secrets/counters read from one place whichever direction they rode.
75
+ Payload `status:false` (the `revoke_sessions` spelling) also refuses;
76
+ bodies with no flag resolve `ok: true`.
77
+ - `mojoAction(endpoint, id, action, payload?)` — the raw-path primitive:
78
+ POSTs `{[action]: payload ?? true}` to `endpoint/<id>` under
79
+ `withFreshAuth`, normalizes the reply, and **REJECTS with
80
+ `ActionRefusedError`** (a `MojoError` with `status: 200`, `errorCode` =
81
+ the refusal code, `message` = the server's text) on an inside-the-200
82
+ refusal. Returns the `ActionResult` on success.
83
+
84
+ Which to use: **model-bound → `defineModel(...).useAction`** (same
85
+ normalizer + cache maintenance; see defineModel.md "Action refusals" for
86
+ `refusal: 'reject' | 'return'`); **raw one-off action POST →
87
+ `mojoAction`**; **never** parse `success`/`status` off an action body at a
88
+ call site. Diagnostics whose flag is a result datum (connection testers)
89
+ either declare `refusal:'return'` on the model or stay as bespoke raw
90
+ readers with a comment.
91
+
92
+ ## Endpoint scoping — the middle tier
93
+
94
+ The model layer scopes reliably; everything hand-rolled with `useQuery` +
95
+ `mojoCall` historically forgot the scope param — the largest bug class in the
96
+ first consumer audit (eight files, every one a raw read or verb missing its
97
+ `group`). The middle tier makes the raw path as safe as the blessed one:
98
+ apps *declare* their scoped endpoint families once, and every helper —
99
+ plus a dev tripwire in the transport itself — enforces the declaration.
100
+
101
+ ### `registerEndpointScope(prefix | prefix[], {key?, required?, format?})`
102
+
103
+ App-side, at boot (before the first fetch). Longest registered prefix wins;
104
+ re-registering a prefix replaces it. `key` defaults to `'group'`, `required`
105
+ defaults to `true`; `format` maps the group id to the wire value for
106
+ spellings like `account=group-<pk>`:
107
+
108
+ ```ts
109
+ registerEndpointScope('/api/wallet/', { key: 'group' });
110
+ registerEndpointScope('/api/mojoverify/', { key: 'group' });
111
+ registerEndpointScope('/api/store/order', { key: 'gid' });
112
+ registerEndpointScope('/api/billing/', { key: 'account', format: (id) => `group-${id}` });
113
+ ```
114
+
115
+ The base admin portal registers none — it is deliberately no-group. A
116
+ `required` scope that cannot be resolved fails loudly (throw or disabled
117
+ query, below); `required: false` families only inject when a group is active.
118
+
119
+ ### Where the group id comes from
120
+
121
+ `GroupProvider` **owns** the active group — context stays the React-side
122
+ source of truth — and mirrors its *resolved* group into a module-level
123
+ signal (`setActiveGroupSignal` / `getActiveGroupId`) so plain functions can
124
+ read it. Do not read `localStorage.active_group_id` yourself instead: the
125
+ URL `?group=` param BEATS the stored id (group.tsx resolution order), and
126
+ only the provider computes that resolution — the signal always reflects it.
127
+ An explicit `group` option on any helper beats the signal.
128
+
129
+ ### `useScopedQuery<T>(path, params?, {read?, group?, ...queryOpts})`
130
+
131
+ `useQuery` over a scoped GET. The registered spelling is injected into the
132
+ request params, and the group id rides the query key —
133
+ `[path, params, gid]` — so a brand switch can never serve another brand's
134
+ cached rows, while invalidation by `[path]` prefix keeps working. Keep that
135
+ key discipline if you write a raw scoped query by hand.
136
+
137
+ When a REQUIRED scope has no active group, the query renders **disabled** —
138
+ and a disabled query has *neither data nor error*. Do not draw an empty box
139
+ or a spinner that never resolves: state the wait honestly at the call site
140
+ ("select a brand to load …").
141
+
142
+ ### `mojoScopedCall` / `mojoRpc`
143
+
144
+ - `mojoScopedCall(path, opts)` — `mojoCall` with the registered scope
145
+ injected: into `params` for parameter-only requests, into the `body` when
146
+ a body is present. Throws in dev when a REQUIRED scope cannot be resolved
147
+ — an unscoped write to a scoped family must never leave the machine.
148
+ - `mojoRpc<T>(path, body, opts?)` — the scoped RPC verb: POST `body` under
149
+ `withFreshAuth` with the scope injected; resolves `env.data as T`.
150
+ Action-shaped `{key: payload}` saves belong to `mojoAction`, not here.
151
+
152
+ ### `mojoAction` body injection
153
+
154
+ `mojoAction(endpoint, id, action, payload?)` consults the same registry:
155
+ for a scope-registered family with an active group, the scope key is
156
+ injected into the action POST body alongside `{[action]: payload}` — the
157
+ wallet-verbs class: actions the backend refuses for brand-scoped operators
158
+ without `group`. With nothing registered (the base admin) the injection is
159
+ inert.
160
+
161
+ ### The dev tripwire — and `unscoped: true`
162
+
163
+ `assertScoped` runs inside the unwrap boundary on **every** dev request: a
164
+ registered-REQUIRED path whose params *and* body both lack the scope key
165
+ throws before the request leaves the machine, naming the registration and
166
+ the fix. So even a raw `mojoCall` that bypasses every helper still fails at
167
+ the desk, not in production data. Passing the scope explicitly satisfies it;
168
+ for the rare genuinely-global call to a scoped family, mark it
169
+ `{ unscoped: true }` — an explicit, greppable opt-out. Production builds
170
+ skip the check (`import.meta.env.DEV` only); it is a development tripwire,
171
+ not a runtime gate.
172
+
64
173
  ## Mock admin contracts
65
174
 
66
175
  The mock carries measured GroupView/admin transports, not UI-shaped
package/docs/dataview.md CHANGED
@@ -126,7 +126,7 @@ grids stops being readable.
126
126
  | `email` | `<a href="mailto:…">` |
127
127
  | `url` / `file` | `<a target="_blank" rel="noopener noreferrer">` + external-link icon |
128
128
  | `phone` | `<a href="tel:…">` around `fmt.phone` (digits + `+` only in the href) |
129
- | `currency` | `fmt.currency`; `EUR`/`GBP` from the key, else `USD`. **Integer → cents, fractional → major units** |
129
+ | `currency` | `fmt.currency`; `EUR`/`GBP` from the key, else `USD`. **Integer (or integer-string) → cents; fractional (number or string) → major units** |
130
130
  | `filesize` | `fmt.filesize` (decimal KB/MB/GB) |
131
131
  | `percent` | `fmt.percent`; **`\|v\| ≤ 1` is scaled ×100**, anything larger is already a percentage |
132
132
  | `number` | `count`/`total`/`followers`/`views` → `fmt.compact` at ≥1000 else `fmt.number`; `score`/`rating` → `fmt.number(v, 1)` when fractional; `version` / the word `id` → verbatim; else `fmt.number` |
@@ -182,8 +182,11 @@ Truncated text keeps the full value on a `title` attribute.
182
182
 
183
183
  - **Money is CENTS when it's an integer.** django-mojo stores minor units,
184
184
  so `plan_amount: 129900` → `$1,299.00` while `overage_price: 4.75` →
185
- `$4.75`. A record that breaks that convention needs an explicit
186
- `format`.
185
+ `$4.75` — and wire decimal STRINGS classify the same way, so
186
+ `overage_price: '4.75'` (the DecimalField shape) is also `$4.75`. A
187
+ whole-dollar decimal string (`'12.00'`) parses to an integer and reads as
188
+ cents (`$0.12`) — inherently ambiguous; a record that breaks the
189
+ convention needs an explicit `format`.
187
190
  - **Percent auto-scales below 1.** `error_rate: 0.0182` → `2%`;
188
191
  `error_rate: 12` → `12%`. A ratio that legitimately exceeds 1 needs an
189
192
  explicit `format`.
@@ -55,16 +55,63 @@ action-specific payload. **Which one is DECLARED, never sniffed:**
55
55
 
56
56
  - `response: 'row'` (default) → `ActionOutcome.row` is the refreshed record
57
57
  (written through to the cache).
58
- - `response: 'payload'` → `row` is null; read `outcome.body` (e.g.
59
- `body.message` for `revoke_sessions` — toast the server's text).
58
+ - `response: 'payload'` → `row` is null; read `outcome.result.payload`
59
+ (e.g. `result.payload.message` for `revoke_sessions` — toast the
60
+ server's text). `outcome.body` remains the raw unwrapped envelope.
60
61
 
61
62
  Invoke: `await disableAction.mutateAsync({ id, payload: { reason: 'abuse' } })`.
62
63
  `payload` defaults to `{}`; handlers treat the value as a dict. Errors
63
64
  (e.g. `"reason must be one of: abuse, admin"`) REJECT with the server's
64
65
  message.
65
66
 
67
+ ## Action refusals
68
+
69
+ django-mojo action handlers can refuse **inside an HTTP 200**. The reply
70
+ reaches the client in one of two wire shapes:
71
+
72
+ ```jsonc
73
+ {"success": false, "code": "ALREADY_DISABLED", "error": "…"} // flat (JsonResponse verbatim)
74
+ {"status": true, "data": {"success": false, "code": "…", "error": "…"}} // wrapped (save-and-respond)
75
+ ```
76
+
77
+ This is NOT the envelope failure: envelope-level `status:false` already
78
+ rejects at the unwrap boundary (`MojoError`). The action-refusal layer sits
79
+ above it and is handled by `useAction` automatically:
80
+
81
+ - **`refusal: 'reject'` (default)** — a `success:false` (or payload
82
+ `status:false`) reply REJECTS with **`ActionRefusedError`**, so a refusal
83
+ can never read as done. The error extends `MojoError` with `status: 200`
84
+ (the HTTP layer succeeded — the HANDLER said no), `message` = the server's
85
+ `error` text, `errorCode` = the semantic refusal `code`, and
86
+ `result`/`data` = the merged payload (may hold evidence such as the
87
+ current row state). Call sites need only their ordinary
88
+ `catch (err) { toast.error(err.message) }`.
89
+ - **`refusal: 'return'`** — declare this when the flag is a DIAGNOSTIC
90
+ RESULT, not a refusal (e.g. a connection test answering
91
+ `{success, key_count}`). The mutation resolves either way; read
92
+ `outcome.result` (`{ok, code, error, payload}`). Declared, never sniffed.
93
+
94
+ `ActionOutcome.result` is the normalized reply for every action:
95
+ `result.payload` merges the envelope with the action dict (action fields
96
+ win), so one-shot secrets and counters read from one place regardless of
97
+ which wire shape arrived. For `refusal:'reject'` actions `result.ok` is
98
+ always `true` on the resolved path — a refusal already rejected.
99
+
100
+ Oddball: `User.revoke_sessions` spells the flag `status` inside its payload
101
+ (`{status: true, message}` on success). Only `status === false` refuses, so
102
+ its truthy success value — and payloads with no flag at all — resolve
103
+ normally.
104
+
66
105
  ## Pitfalls
67
106
 
107
+ **FK fields are graph-shaped.** The backend controls graph definitions, so
108
+ an FK field your UI reads as a scalar (`row.currency === "GC"`) can start
109
+ arriving expanded (`{id, code, name, …}`) — or as the bare pk — when a
110
+ graph changes. Render such fields through `fmt.code(value)` or a `render`
111
+ callback that handles all three shapes; never hand the raw field to JSX.
112
+ The shared primitives degrade instead of crashing (`safeNode`/`RenderGuard`),
113
+ but app JSX outside them is unguarded.
114
+
68
115
  - Don't build a second store around these hooks — pages read query state.
69
116
  - The action-name union isn't typed; an undeclared name throws in dev the
70
117
  first time the hook renders (fail loud, not a 404 later).
@@ -54,7 +54,7 @@ the type checker and was the source's silent-failure vector) and the
54
54
  | Fn | Notes |
55
55
  |---|---|
56
56
  | `fmt.number(v, decimals=0, fallback='—')` | thousands separators: `1234567` → `1,234,567`. *Deviation:* source defaulted to 2 decimals; portal columns are counts |
57
- | `fmt.currency(v, code='USD', {unit, decimals, fallback})` | `129900` → `$1,299.00`. **Input is CENTS by default** — django-mojo stores money as integer minor units (`cents_to_currency`) so it never touches a float; pass `{unit:'major'}` for a dollars value or a zero-decimal currency (JPY). *Deviation:* arg 2 is an ISO **code**, not a symbol — the source's own callers passed `currency("EUR")` and got `EUR1,299.00`. An unknown code warns once and falls back to USD |
57
+ | `fmt.currency(v, code='USD', {unit, decimals, fallback})` | `129900` → `$1,299.00`. **Input is CENTS by default** — django-mojo stores money as integer minor units (`cents_to_currency`) so it never touches a float; pass `{unit:'major'}` for a dollars value or a zero-decimal currency (JPY). *Deviation:* arg 2 is an ISO **code**, not a symbol — the source's own callers passed `currency("EUR")` and got `EUR1,299.00`. An unknown code warns once and falls back to USD. **End-to-end rule: ALL money crosses the wire as integer minor units — format at the edge, never pre-convert.** Troubleshooting: amounts 100× small = you passed dollars; 100× large = you passed cents with `unit:'major'` |
58
58
  | `fmt.percent(v, decimals=0, multiply=true, fallback='—')` | `0.856` → `86%`; `multiply=false` when the value is already a percentage |
59
59
  | `fmt.compact(v, decimals=1, fallback='—')` | `1234` → `1.2K`, `3400000` → `3.4M`. Uppercase K/M/B (source casing); caps at B, so `1e12` reads `1000.0B`. Below 1,000 the raw number passes through unseparated |
60
60
  | `fmt.filesize(v, binary=false, decimals=1, fallback='—')` | `1536` → `1.5 KB`; `binary` switches to 1024/`KiB`. Whole bytes stay integral (`999 B`); caps at TB. Negatives degrade readably (`-500 B`) |
@@ -69,6 +69,7 @@ the type checker and was the source's silent-failure vector) and the
69
69
  | `fmt.truncateFront(v, length=8, prefix='...', fallback='—')` | keeps only the TAIL: `...2eZvKY` |
70
70
  | `fmt.mask(v, char='*', showLast=4, fallback='—')` | all-but-last-4: `************1111`. Values no longer than `showLast` return intact. *Fixes a source bug:* `showLast:0` masked the string and re-appended it whole (`slice(-0)` is `slice(0)`) |
71
71
  | `fmt.slug(v, separator='-')` | **fallback is `''`**, not `—` (a slug of nothing is nothing). *Deviations:* accents fold to ASCII (`Café` → `cafe`, matching django-mojo's `strip_accents`) instead of being dropped (`caf`); the separator is regex-escaped — the source interpolated it raw, so `slug(v,'.')` misbehaved and `slug(v,'')` threw on an empty quantifier |
72
+ | `fmt.code(v, fallback='')` | **FK-safe display for coded wire fields.** django-mojo graphs expand FK fields per-graph — the same field is `"GC"` on one graph, `{id, code, name, …}` on another, and the bare pk when the graph omits it. `code()` renders every shape: string → itself, expanded row → `.code` ?? `.name`, pk number → `"3"`. **Fallback is `''`** (like `slug`) so it composes into template strings; cells that want `—` pass it explicitly. An object with no usable code/name warns once and returns the fallback. *Deviation from the wmx-admin-v2 `codeOf` source:* a pk number renders as its digits, not `''` — an unexpanded FK is data |
72
73
  | `fmt.phone(v, fallback='—')` | **DISPLAY ONLY.** E.164 (`+15555550142`) stays the wire shape — django-mojo REJECTS pretty formats on save, so never round-trip this into a field. US 10/11-digit → `(555) 555-0142` / `+1 (555) 555-0142`; anything else (a real international number) is returned trimmed and unchanged. *Deviation:* the source stripped the leading `+`, turning valid E.164 into digit soup. No `<a href="tel:">` — links are components |
73
74
 
74
75
  ### Duration + booleans
@@ -85,6 +86,21 @@ the type checker and was the source's silent-failure vector) and the
85
86
  | `fmt.initials(name)` | 1–2 letters; null-safe (`'?'` for empty — live rows carry `display_name: null`) |
86
87
  | `fmt.inferTone(value)` | status text → Badge tone ('active'→success, 'banned'→danger, booleans, …) |
87
88
 
89
+ ### FK fields change shape per graph — render them through `fmt.code`
90
+
91
+ A raw object rendered as a React child crashes the page (`Objects are not
92
+ valid as a React child`), and django-mojo makes that a live hazard: the
93
+ backend controls graph definitions, so a field your column renders as a
94
+ string can start arriving as an expanded row object without any frontend
95
+ change. wmx-admin-v2 white-screened a production table exactly this way.
96
+ Any FK-ish field (`currency`, `group`, `owner`, …) rendered in JSX goes
97
+ through `fmt.code(value)` — or a `render` callback that handles both
98
+ shapes. The shared primitives (ModelTable cells, DetailView sections and
99
+ badges, the card formatter hooks) degrade to a readable value plus one
100
+ `console.warn` instead of crashing (see `safeNode`/`RenderGuard` in the
101
+ ModelTable doc), but app-composed JSX outside those paths is on its own —
102
+ use the formatter.
103
+
88
104
  Formatters NEVER throw on bad input — they degrade (rule: a formatter is
89
105
  the last thing allowed to take down a row). Every one is null/undefined-safe,
90
106
  parses strictly (`"12 items"` is NOT 12 — it degrades, unlike the source's
@@ -64,6 +64,19 @@ These helpers operate on logical pixels and do not mount UI, upload Files,
64
64
  persist state, or initiate a browser download. `decodeImageSource` is the one
65
65
  exception that performs the URL fetch described above.
66
66
 
67
+ ## Browser-work limits
68
+
69
+ The public low-level constants make the editor's availability boundary explicit:
70
+ `MAX_IMAGE_SOURCE_BYTES` is 25,000,000 bytes, `MAX_IMAGE_DIMENSION` is 8,192
71
+ pixels, `MAX_IMAGE_PIXELS` is 16,000,000 pixels per decoded/intermediate/final
72
+ surface, and `MAX_IMAGE_EDITOR_OPERATIONS` is 20. Every operation output is
73
+ checked before raster allocation. Initial transforms and filters must remain
74
+ inside the same ranges as the controls (scale 0.1–5; brightness, contrast, and
75
+ saturation 0–200; hue 0–360; blur 0–10; grayscale and sepia 0–100). URL bodies
76
+ are streamed to the byte ceiling, and source replacement or unmount aborts the
77
+ fetch and `<img>` fallback where the browser permits; `createImageBitmap` itself
78
+ is not abortable, so a stale result is closed immediately on resolution.
79
+
67
80
  ## Composition and history
68
81
 
69
82
  Operations are immutable and chronological. Transform creates a logical
@@ -58,6 +58,63 @@ Admin section bundles contribute through `adminSectionsMenu`. Use
58
58
  experience. Routes remain mount-relative; multiple embedded root sections
59
59
  share one `/system` landing instead of emitting duplicate index routes.
60
60
 
61
+ Import the route/menu machinery from `portal-mojo/admin/core`, then import only
62
+ the registry domains the shell installs. The stable domains are `identity`,
63
+ `security`, `observability`, `operations`, `infrastructure`, `communications`,
64
+ and `assistant`. `portal-mojo/admin` remains the compatibility aggregate and
65
+ retains every historical export, but deliberately performs the legacy DNS and
66
+ Rule-field setup at import time.
67
+
68
+ Built-in routes use `loadComponent`; external sections may use `component`.
69
+ These properties are mutually exclusive. Loading and retryable error states
70
+ live inside both permission guards, so access denial performs no page import.
71
+ The infrastructure entry installs the optional managed-DNS adapter
72
+ synchronously, which makes Email-first navigation safe even before any DNS page
73
+ chunk has loaded. `admin/core` performs neither DNS nor field registration.
74
+
75
+ ### Flat sections (`presentation: 'flat'`)
76
+
77
+ A section whose children should render as FLAT top-level sidebar rows under
78
+ labeled group dividers — no accordion (the persona-lens shape: a Settings
79
+ lens whose sidebar reads Brand / Access / Activity as divider groups):
80
+
81
+ ```ts
82
+ {
83
+ id: 'settings', label: 'Settings', icon: 'bi-gear', route: '/settings',
84
+ presentation: 'flat', group: 'Brand', // labels the hoisted home row
85
+ children: [
86
+ { label: 'Brand profile', route: '/settings/brand', group: 'Brand' },
87
+ { label: 'Members', route: '/settings/members', group: 'Access' },
88
+ // a nested child stays an accordion among the flat rows
89
+ { label: 'Reports', group: 'Access', children: [/* … */] },
90
+ ],
91
+ }
92
+ ```
93
+
94
+ `SidebarNav` applies the exported pure `flattenMenuItems(items, ctx)`
95
+ automatically; flag-less menus pass through by identity. Semantics:
96
+
97
+ - **Divider on group CHANGE** (run semantics) — a repeated non-consecutive
98
+ label is two runs. A registry divider immediately followed by the first
99
+ group divider collapses (later wins).
100
+ - **The section's `route` hoists first** as its home row, `exact`-matched so
101
+ it never prefix-lights over deep routes. The label stays the section's own
102
+ (no auto-"Overview" — name it what you mean).
103
+ - **Nested children hoist as accordions** and RESET the run, so the next
104
+ flat row re-emits its divider.
105
+ - **Icons fall back to the section's**; a child's `group` label joins its
106
+ search `keywords`.
107
+ - **Gates compose section ∧ child** — a child's own `permissions` never
108
+ bypass the section gate (stricter than the wmx source, deliberately: it
109
+ matches the admin nav's clause idiom). An invisible flat parent drops its
110
+ whole section.
111
+ - **Flat is presentation only.** Route resolution (`resolveActiveMenu`)
112
+ walks the original tree, so active-menu selection cannot disagree with
113
+ what renders. Rail density is an authoring concern — nest a long group.
114
+
115
+ The personas layer's `sectionMenuEntries` still pre-flattens its own menus;
116
+ converging it onto flat sections is a named follow-up.
117
+
61
118
  ## Permissions (A2) — client gates, server authority
62
119
 
63
120
  `Me.permissions` is a dict; truthy `true` OR `1` grants. Semantics
package/docs/params.md CHANGED
@@ -34,6 +34,18 @@ unless `defaults.sort`), `page` (1-based), `size`.
34
34
  Every write goes through the URL (`replace`), so table state deep-links and
35
35
  survives reloads by construction.
36
36
 
37
+ ## `excludeKeys` — the fixedParams scrub
38
+
39
+ `useTableParams(defaults, defaultFilters, excludeKeys?)`: keys in the set
40
+ never become filter state — dropped from the URL-derived filters memo, the
41
+ `defaultFilters` seed, and `applySaved`'s persisted filters. ModelTable
42
+ passes its `fixedParams` keys here so locked scope cannot re-enter as a
43
+ removable pill via a bookmark or a stale persisted view. This does not
44
+ bend the store's rule: the store remains the single source of truth for
45
+ table STATE (what the user did); `fixedParams` is table IDENTITY (what
46
+ the table is) and deliberately lives outside it, merged onto the wire
47
+ after normalization. Pass a render-stable Set — it is a memo dependency.
48
+
37
49
  ## `registerNonFilterParams(...keys)` — params the PAGE owns
38
50
 
39
51
  Everything in the query string that isn't `search/sort/page/size` is treated