portal-mojo 0.2.8 → 0.2.9

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 (149) hide show
  1. package/README.md +59 -11
  2. package/docs/DetailView.md +3 -2
  3. package/docs/README.md +6 -2
  4. package/docs/account.md +4 -3
  5. package/docs/admin-bouncer.md +3 -3
  6. package/docs/admin-credentials.md +4 -3
  7. package/docs/admin-devices-geoip.md +1 -1
  8. package/docs/admin-dns-certificates.md +2 -2
  9. package/docs/admin-dns-records.md +1 -1
  10. package/docs/admin-edge.md +210 -0
  11. package/docs/admin-identity-directories-sources.json +2 -2
  12. package/docs/admin-identity-directories.md +9 -0
  13. package/docs/admin-identity-users.md +2 -2
  14. package/docs/admin-jobs.md +4 -4
  15. package/docs/admin-media-rendering.md +142 -0
  16. package/docs/admin-modals.md +7 -0
  17. package/docs/admin-monitoring.md +4 -4
  18. package/docs/admin-network-security.md +2 -2
  19. package/docs/admin-phonehub.md +4 -1
  20. package/docs/admin-storage.md +5 -2
  21. package/docs/calendar.md +1 -1
  22. package/docs/client.md +159 -26
  23. package/docs/collection-multiselect.md +2 -2
  24. package/docs/combobox.md +2 -2
  25. package/docs/dataview.md +1 -1
  26. package/docs/datepicker.md +3 -3
  27. package/docs/daterange.md +1 -1
  28. package/docs/datetimepicker.md +1 -1
  29. package/docs/defineModel.md +40 -7
  30. package/docs/detail-primitives.md +1 -1
  31. package/docs/feedback.md +9 -8
  32. package/docs/file-upload-ux.md +2 -2
  33. package/docs/idioms.md +1 -1
  34. package/docs/loading.md +2 -2
  35. package/docs/location-address.md +2 -1
  36. package/docs/markdown.md +3 -3
  37. package/docs/menus-and-access.md +21 -1
  38. package/docs/multiselect-dropdown.md +1 -1
  39. package/docs/popover.md +1 -1
  40. package/docs/record-feed.md +2 -2
  41. package/docs/taginput.md +2 -1
  42. package/docs/timepicker.md +1 -1
  43. package/docs/timezone-select.md +4 -3
  44. package/docs/user-menu.md +4 -3
  45. package/docs/worldmap.md +2 -2
  46. package/package.json +3 -2
  47. package/src/account/AccountModal.tsx +3 -2
  48. package/src/account/index.ts +2 -2
  49. package/src/admin/core/index.ts +1 -1
  50. package/src/admin/dns/models.ts +1 -1
  51. package/src/admin/domains/infrastructure.ts +12 -2
  52. package/src/admin/domains/security.ts +3 -1
  53. package/src/admin/edge/BlocklistPage.tsx +110 -0
  54. package/src/admin/edge/UpstreamsPage.tsx +113 -0
  55. package/src/admin/edge/VhostDetail.tsx +166 -0
  56. package/src/admin/edge/VhostWizard.tsx +273 -0
  57. package/src/admin/edge/VhostsPage.tsx +47 -0
  58. package/src/admin/edge/api.ts +56 -0
  59. package/src/admin/edge/index.ts +15 -0
  60. package/src/admin/edge/models.ts +395 -0
  61. package/src/admin/edge/permissions.ts +13 -0
  62. package/src/admin/identity/users/PersonalApiKeysPage.tsx +10 -0
  63. package/src/admin/identity/users/sections/actions.tsx +3 -3
  64. package/src/admin/index.ts +1 -0
  65. package/src/admin/phonehub/api.ts +5 -3
  66. package/src/admin/public/infrastructure.ts +1 -0
  67. package/src/admin/registry.ts +2 -1
  68. package/src/admin/storage/MediaRenderingPage.tsx +258 -0
  69. package/src/admin/storage/file-renditions.ts +12 -0
  70. package/src/admin/storage/index.ts +2 -0
  71. package/src/admin/storage/rendition-settings.ts +264 -0
  72. package/src/charts/exportChart.ts +1 -1
  73. package/src/client/action-result.ts +23 -61
  74. package/src/client/active-group.ts +9 -3
  75. package/src/client/auth.ts +18 -4
  76. package/src/client/client.ts +49 -10
  77. package/src/client/errors.ts +57 -2
  78. package/src/client/group-context.ts +2 -0
  79. package/src/client/group.tsx +112 -20
  80. package/src/client/hooks.ts +7 -3
  81. package/src/client/mock.ts +958 -14
  82. package/src/client/model.ts +15 -3
  83. package/src/client/renditions-mock.ts +212 -0
  84. package/src/client/scoped.ts +19 -5
  85. package/src/styles/components/account.css +304 -0
  86. package/src/styles/components/admin-assistant.css +28 -0
  87. package/src/styles/components/admin-bouncer.css +53 -0
  88. package/src/styles/components/admin-cloudwatch.css +23 -0
  89. package/src/styles/components/admin-dashboard.css +16 -0
  90. package/src/styles/components/admin-devices.css +260 -0
  91. package/src/styles/components/admin-dns-certificates.css +28 -0
  92. package/src/styles/components/admin-dns-registrar.css +1 -0
  93. package/src/styles/components/admin-dns.css +43 -0
  94. package/src/styles/components/admin-edge.css +1 -0
  95. package/src/styles/components/admin-incidents.css +14 -0
  96. package/src/styles/components/admin-jobs.css +237 -0
  97. package/src/styles/components/admin-media-rendering.css +38 -0
  98. package/src/styles/components/admin-messaging.css +3 -0
  99. package/src/styles/components/admin-monitoring.css +152 -0
  100. package/src/styles/components/admin-network.css +406 -0
  101. package/src/styles/components/admin-phonehub.css +61 -0
  102. package/src/styles/components/admin-push.css +30 -0
  103. package/src/styles/components/admin-rules.css +23 -0
  104. package/src/styles/components/admin-security.css +37 -0
  105. package/src/styles/components/admin-shortlinks.css +8 -0
  106. package/src/styles/components/admin-signin.css +64 -0
  107. package/src/styles/components/admin-storage.css +4 -0
  108. package/src/styles/components/auth.css +162 -0
  109. package/src/styles/components/calendar.css +228 -0
  110. package/src/styles/components/charts-c2.css +249 -0
  111. package/src/styles/components/collection-multiselect.css +114 -0
  112. package/src/styles/components/collection-select.css +63 -0
  113. package/src/styles/components/combobox.css +42 -0
  114. package/src/styles/components/dataview.css +139 -0
  115. package/src/styles/components/datepicker.css +106 -0
  116. package/src/styles/components/daterange.css +119 -0
  117. package/src/styles/components/datetimepicker.css +230 -0
  118. package/src/styles/components/detail-primitives.css +246 -0
  119. package/src/styles/components/detailview.css +69 -0
  120. package/src/styles/components/drawer.css +111 -0
  121. package/src/styles/components/file-upload.css +112 -0
  122. package/src/styles/components/form-wizard.css +11 -0
  123. package/src/styles/components/formview.css +74 -0
  124. package/src/styles/components/group-admin.css +302 -0
  125. package/src/styles/components/guardrail.css +29 -0
  126. package/src/styles/components/idioms.css +84 -0
  127. package/src/styles/components/image-editor.css +80 -0
  128. package/src/styles/components/loading.css +200 -0
  129. package/src/styles/components/location-address.css +32 -0
  130. package/src/styles/components/markdown.css +192 -0
  131. package/src/styles/components/multiselect-dropdown.css +92 -0
  132. package/src/styles/components/popover.css +35 -0
  133. package/src/styles/components/record-feed.css +161 -0
  134. package/src/styles/components/right-panel.css +37 -0
  135. package/src/styles/components/tabs.css +26 -0
  136. package/src/styles/components/taginput.css +132 -0
  137. package/src/styles/components/timepicker.css +282 -0
  138. package/src/styles/components/user-admin.css +189 -0
  139. package/src/styles/components/user-menu.css +98 -0
  140. package/src/styles/components/worldmap.css +97 -0
  141. package/src/styles/core.css +807 -0
  142. package/src/styles/index.css +69 -0
  143. package/src/styles/required-tokens.json +32 -0
  144. package/src/ui/FormWizard.tsx +9 -3
  145. package/src/ui/ThemeProvider.tsx +1 -1
  146. package/src/ui/detail/index.ts +2 -2
  147. package/src/ui/index.ts +3 -2
  148. package/src/ui/loading.tsx +1 -1
  149. package/src/ui/toast.tsx +1 -1
@@ -62,6 +62,7 @@ the inventory using `node scripts/verify-admin-modals.mjs --write-inventory`.
62
62
  | Storage/shortlinks | Bucket, FileManager, File, shortlink; owner/backend/test/CORS/upload/rendition/share | FileManager/shortlink/share active state; no File disable promise; bucket empty only |
63
63
  | Email/support | Domain, mailbox, sent message, template, contact; credentials/onboarding/audit/reconcile/test/template/status | Preserve evidence; separate mailbox directions and support status |
64
64
  | Phone/Push | Number, SMS, config, device, delivery, template; lookup/test/config editors | Config/template lifecycle; number/SMS/device/delivery evidence retained |
65
+ | Edge | Vhost with nested routes; shape-first wizard, route editor, upstream declare/retire, blocklist entry editor | Vhost and upstream enabled state; upstreams retire and are never deleted; vhost, route and blocklist deletion are retained operations |
65
66
  | Assistant/settings | Conversation, Skill, Setting; feed continuation, scoped memory, typed settings editor | Owner-only continuation; Skill active selection; memory is scoped editing |
66
67
 
67
68
  ## Resource lifecycle matrix
@@ -84,6 +85,9 @@ the inventory using `node scripts/verify-admin-modals.mjs --write-inventory`.
84
85
  | Push config / template | Existing active selection with separate manage/test grants |
85
86
  | Assistant conversation | Retain history; no arbitrary REST save or invented archive |
86
87
  | Assistant Skill | Existing `is_active` save requires `sys.view_admin`; authoritative reload after mutation |
88
+ | Edge vhost | Enable/disable from the header; Edit and Delete in the header menu. Disabling keeps the row and is how a replacement is staged |
89
+ | Edge upstream | `is_enabled` only. Retire (platform administrators) disables and keeps the row; server `CAN_DELETE=False` |
90
+ | Edge blocklist entry | Mode is the lifecycle: `off` parks an entry without losing it. Deletion is a retained operation |
87
91
  | Other logs/devices/history | Read-only by default, even where low-level REST supports DELETE |
88
92
 
89
93
  The public low-level exported delete APIs remain compatible. Built-in controls
@@ -101,6 +105,9 @@ conversation archival require backend work before new lifecycle UI is added.
101
105
  | DNS record-set removal | DNS manage gate, provider capability and record coordinator. Preview all values, apex/service impact and exact name/type; stale-state preflight and reconcile failures; no domain deletion |
102
106
  | Empty bucket | `sys.manage_aws OR sys.files`, then fresh auth; header to focused armed confirmation and exact case-sensitive bucket name. Removes objects, versions, markers and multipart uploads; retains bucket. Display partial/unknown outcomes and always refresh |
103
107
  | Jobs retention purge | `sys.manage_jobs OR sys.jobs`; dry-run estimate bound to normalized days/status plus generation. Editing/replacing preview resets arming. Ignore stale/unmounted responses; lock inputs/dismissal during execution. Report actual cutoff and cascade-inclusive counts; rejection requires another preview |
108
+ | Edge vhost deletion | `sys.manage_dns OR sys.security`; house vhosts are platform administrators only. Header menu to a focused confirmation naming the address. Stops serving the name and deletes its routes with it; the confirmation points at Disable as the reversible choice |
109
+ | Edge route removal | Same grant, inside the vhost's Routes content with an armed control naming the prefix. Requests under the prefix stop being proxied. A quiet path left without a covering route makes the vhost's next save fail, and the server's message is shown |
110
+ | Edge blocklist entry removal | `sys.manage_security OR sys.security`; armed control in the entry's editor. The entry leaves every node within about ten minutes. `off` is the reversible choice |
104
111
  | Queue/consumer cleanup | Jobs manage grant, exact channel/scope and armed confirmation; queue clear sends `confirm:yes` only after consent and may cancel pending database jobs. Report partial outcomes |
105
112
 
106
113
  Other non-row removals retain their scoped editor semantics: rate-limit override
@@ -93,7 +93,7 @@ current contract.
93
93
  ## Styling and showcase
94
94
 
95
95
  Monitoring components use semantic `monitoring-*` classes and application
96
- tokens only. A consuming app must include `admin-monitoring.css`. This repo's
97
- portal and showcase theme entries do so. The showcase's Admin Monitoring demo
98
- contains static request, response, JSON, and HTML-looking plain-text cases for
99
- both-theme verification without mutating a backend.
96
+ tokens only. `admin-monitoring.css` ships in `portal-mojo/styles.css`. The
97
+ showcase's Admin Monitoring demo contains static request, response, JSON, and
98
+ HTML-looking plain-text cases for both-theme verification without mutating a
99
+ backend.
@@ -33,8 +33,8 @@ Perimeter control: block an abusive IP right now, whitelist a customer's NAT,
33
33
  maintain country and datacenter CIDR sets synced to the firewall fleet, set
34
34
  geographic policy — and prove the policy does what you think with a built-in
35
35
  simulator before it bites real traffic. Demo: showcase → Admin → **Network
36
- security**. Styles: `apps/{portal,showcase}/src/theme/admin-network.css`
37
- (byte-identical).
36
+ security**. Styles:
37
+ `packages/portal-mojo/src/styles/components/admin-network.css`.
38
38
 
39
39
  ## Surfaces
40
40
 
@@ -13,7 +13,10 @@ Import `PhoneHubPage`, the three individual pages, models, permission clauses, a
13
13
  - `PhoneConfigModel` reads sanitized scalar/default-graph rows. Saves and `test_connection` use imperative, cache-free calls. The editor follows web-mojo's provider-conditional layout: selecting Twilio, AWS SNS, or Mojo Remote shows and submits only that provider's connection fields. Mojo Remote uses `https://api.mojoverify.com` when the saved URL is unset or the editor is switched to Mojo; an explicitly configured custom URL is preserved. Secret inputs are DOM refs, not React or Query state: blank means untouched and is omitted; replacement must be non-empty; clear sends `null` only after a separate confirmation. Switching providers resets pending clears, and hidden provider credentials remain untouched. Responses are allowlisted before cache reconciliation. This surface edits existing provider credentials; it does not provision Django user/group API keys.
14
14
 
15
15
  Connection tests read the provider's `success` verdict, not the outer REST
16
- `status`. A failed or missing verdict is an error; `test_mode` is a warning
16
+ `status`. The real wire answers the test flat (the action dict verbatim, as
17
+ the mock now does too), so the call passes `refusal: 'return'`: a failed
18
+ verdict is an answer for inline display, not a rejected request. A failed or
19
+ missing verdict is an error; `test_mode` is a warning
17
20
  that the provider was not contacted. Results persist inside the detail modal
18
21
  on both sections, alongside a toast. Scope is labeled in the header and
19
22
  Connection section. Editing is disabled while a connection test is pending, so an old response
@@ -8,8 +8,11 @@ import {
8
8
  ```
9
9
 
10
10
  `STORAGE_ADMIN_SECTION` contributes global/no-group Infrastructure routes at
11
- `storage/buckets`, `storage/backends`, and `storage/files` (or the same paths
12
- under an embedded `/system` mount). There are no record routes: buckets,
11
+ `storage/buckets`, `storage/backends`, `storage/files` and `storage/rendering`
12
+ (or the same paths under an embedded `/system` mount). `storage/rendering` is
13
+ the Media rendering editor — see [admin-media-rendering.md](admin-media-rendering.md);
14
+ it carries the Settings gate (`sys.manage_settings | sys.groups`), which the
15
+ section audience therefore includes. There are no record routes: buckets,
13
16
  backends, nested files, and files use `modal.detail`.
14
17
 
15
18
  ## Permissions
package/docs/calendar.md CHANGED
@@ -101,7 +101,7 @@ Parse fns return `null` on invalid input, never throw; format fns return
101
101
 
102
102
  ## Styling
103
103
 
104
- `apps/portal/src/theme/calendar.css` — engine anatomy only
104
+ `packages/portal-mojo/src/styles/components/calendar.css` — engine anatomy only
105
105
  (`mojo-calendar-*`: pane/head/nav/grid/weekday/cell + state classes), all
106
106
  colors via theme tokens; component tokens `--mojo-cal-anchor-fill`,
107
107
  `--mojo-cal-range-fill`, `--mojo-cal-range-fill-hover` (accent tints:
package/docs/client.md CHANGED
@@ -34,6 +34,19 @@ status, semantic `error_code`, and structured safe failure evidence. Nothing out
34
34
  parses envelopes; `mojoCall(path, {method, params, body})` is the typed
35
35
  escape hatch for protocol modules and returns the unwrapped `Envelope`.
36
36
 
37
+ **Replacement tokens.** An envelope may carry `tokens: {access_token,
38
+ refresh_token}` beside `data`. django-mojo sends it when the request ended every
39
+ other session of the caller's own account — today a save that changed the
40
+ caller's own password (for example an admin using "Set password" on their own
41
+ row). The tokens the request was sent with are already dead, so the boundary
42
+ stores the new pair through the auth hook `tokensReplaced`, in the storage the
43
+ session already lives in. Callers do nothing; the realtime socket reconnects
44
+ with the new token. No `tokens` means no change. A token refresh that was in
45
+ flight when the session was replaced is discarded when it answers, success or
46
+ 401: it was for the old session, and it neither overwrites the new pair nor
47
+ reports it unauthorized. The same guard keeps a late refresh from signing a
48
+ user back in after a sign-out.
49
+
37
50
  `FetchOpts.beforeSend` is an optional synchronous final validity check, invoked
38
51
  after the auth gate refreshes credentials and before headers/network activity.
39
52
  The packaged app uses it to recheck its source-generation binding at the cookie
@@ -45,6 +58,19 @@ shape. Modern envelopes use numeric top-level `code` plus a string semantic
45
58
  envelopes always reject. Structured `data` can contain deliberately safe
46
59
  recovery evidence, but callers must still avoid logging it indiscriminately.
47
60
 
61
+ A handler's **flat refusal inside an HTTP 200** — a top-level
62
+ `success:false`, as a model action answers — rejects too, on every call
63
+ through the boundary (`mojoCall`, `mojoList`, `mojoGet`, `mojoSave`,
64
+ `mojoDelete`, `mojoMetrics`, `mojoScopedCall`). It throws
65
+ **`ActionRefusedError`**, a `MojoError` with `status: 200`, `message` = the
66
+ server's `error` (or `message`) text, `errorCode` = the string refusal
67
+ `code`, and `data`/`result.payload` = the full reply; a raw call's label is
68
+ its request path. A wrapped `{status:true, data:{success:false}}` is **data**
69
+ here — a successful POST can legitimately carry it. A call whose flat
70
+ `success:false` is a real answer (a connection test) passes
71
+ **`refusal: 'return'`** and reads the reply itself with `readActionResult`.
72
+ See [Upgrading to 0.3](#upgrading-to-03).
73
+
48
74
  ## Functions
49
75
 
50
76
  | Fn | Wire | Returns |
@@ -62,6 +88,11 @@ desc; filters are Django lookups (`field`, `field__in=a,b`,
62
88
  triple is `dr_field/dr_start/dr_end`; datetimes serialize as epoch seconds;
63
89
  unknown params are silently ignored by the server.
64
90
 
91
+ `mojoGet`, `mojoSave` and `mojoDelete` take an optional last argument,
92
+ `{ unscoped?: boolean }` — the record-route opt-out from a registered
93
+ endpoint scope (see "Record routes vs custom views" below). `defineModel`
94
+ passes it for you.
95
+
65
96
  The executable mock coerces comparisons narrowly: both operands must be
66
97
  fully numeric for numeric comparison; temporal comparison only recognizes
67
98
  canonical `YYYY[-MM[-DD]]` or full ISO datetime shapes; everything else is
@@ -77,15 +108,21 @@ answer 440. Ordinary requests and real transports are unaffected.
77
108
 
78
109
  django-mojo POST_SAVE_ACTION handlers can refuse **inside an HTTP 200**, in
79
110
  two wire shapes — flat `{success:false, code, error}` or wrapped
80
- `{status:true, data:{success:false, …}}` — which the unwrap boundary
81
- deliberately passes through (envelope `status:false` is a different failure
82
- and already rejects). Never hand-sniff these shapes; there is one reader:
111
+ `{status:true, data:{success:false, …}}`. The unwrap boundary rejects the
112
+ flat shape for every call (see the envelope section above); the wrapped shape
113
+ can be ordinary data on a successful POST, so only the action layer, which
114
+ knows it posted an action, reads it as a refusal. `mojoAction` and
115
+ `useAction` pass `refusal: 'return'` to unwrap and read the reply
116
+ themselves, so their error names the action rather than the path (envelope
117
+ `status:false` is a different failure and already rejects). Never
118
+ hand-sniff these shapes; there is one reader:
83
119
 
84
120
  - `readActionResult(body)` → `{ok, code, error, payload}`. `payload` merges
85
121
  the envelope with the action dict (action fields win), so one-shot
86
122
  secrets/counters read from one place whichever direction they rode.
87
123
  Payload `status:false` (the `revoke_sessions` spelling) also refuses;
88
- bodies with no flag resolve `ok: true`.
124
+ bodies with no flag resolve `ok: true`. A refusal with no string `error`
125
+ takes its text from a string `message`; numeric codes are ignored.
89
126
  - `mojoAction(endpoint, id, action, payload?)` — the raw-path primitive:
90
127
  POSTs `{[action]: payload ?? true}` to `endpoint/<id>` under
91
128
  `withFreshAuth`, normalizes the reply, and **REJECTS with
@@ -98,15 +135,18 @@ normalizer + cache maintenance; see defineModel.md "Action refusals" for
98
135
  `refusal: 'reject' | 'return'`); **raw one-off action POST →
99
136
  `mojoAction`**; **never** parse `success`/`status` off an action body at a
100
137
  call site. Diagnostics whose flag is a result datum (connection testers)
101
- either declare `refusal:'return'` on the model or stay as bespoke raw
102
- readers with a comment.
138
+ either declare `refusal:'return'` on the model or, posted raw, pass
139
+ `refusal: 'return'` to `mojoCall` and read the verdict with a comment.
103
140
 
104
141
  ## Endpoint scoping — the middle tier
105
142
 
106
- The model layer scopes reliably; everything hand-rolled with `useQuery` +
107
- `mojoCall` historically forgot the scope param — the largest bug class in the
108
- first consumer audit (eight files, every one a raw read or verb missing its
109
- `group`). The middle tier makes the raw path as safe as the blessed one:
143
+ The model layer needs no scope on record routes — `<endpoint>/<id>` gets,
144
+ saves, deletes and actions send none, because the server binds the row's own
145
+ group (see "Record routes vs custom views" below). Everything hand-rolled
146
+ with `useQuery` + `mojoCall` historically forgot the scope param — the largest
147
+ bug class in the first consumer audit (eight files, every one a raw read or
148
+ verb missing its `group`). The middle tier makes the raw path as safe as the
149
+ blessed one:
110
150
  apps *declare* their scoped endpoint families once, and every helper —
111
151
  plus a dev tripwire in the transport itself — enforces the declaration.
112
152
 
@@ -130,13 +170,62 @@ query, below); `required: false` families only inject when a group is active.
130
170
 
131
171
  ### Where the group id comes from
132
172
 
133
- `GroupProvider` **owns** the active group — context stays the React-side
134
- source of truth — and mirrors its *resolved* group into a module-level
135
- signal (`setActiveGroupSignal` / `getActiveGroupId`) so plain functions can
136
- read it. Do not read `localStorage.active_group_id` yourself instead: the
137
- URL `?group=` param BEATS the stored id (group.tsx resolution order), and
138
- only the provider computes that resolution — the signal always reflects it.
139
- An explicit `group` option on any helper beats the signal.
173
+ `GroupProvider` **owns** the active group. Three rules decide which group a
174
+ request carries:
175
+
176
+ 1. **Hooks read the group context.** `useScopedQuery`, `useCan` and
177
+ `CollectionSelect` take the group from `GroupContext` at render time.
178
+ Under a mounted `GroupProvider`, `useScopedQuery` uses the context and
179
+ nothing else: a null context group means *no group*, never "whatever the
180
+ signal still holds". Only with no provider mounted at all does it fall
181
+ back to the signal.
182
+ 2. **Plain functions read the signal.** `mojoScopedCall` and `mojoRpc` have
183
+ no React context, so the provider mirrors its *resolved* group into a
184
+ module-level signal (`setActiveGroupSignal` / `getActiveGroupId`). It
185
+ writes the signal in the **layout phase** of the commit, before any child
186
+ query starts its fetch, so the first request after a group switch already
187
+ carries the new group.
188
+ 3. **An explicit `group` id wins everywhere**, on hooks and plain functions
189
+ alike. `group: null` differs: on `useScopedQuery` it means "unscoped by
190
+ choice"; on `mojoScopedCall` and `mojoRpc` a null `group` is treated as
191
+ not passed and the signal is used.
192
+
193
+ Do not read `localStorage.active_group_id` yourself: the URL `?group=` param
194
+ BEATS the stored id (group.tsx resolution order), and only the provider
195
+ computes that resolution.
196
+
197
+ **Recommended default**
198
+
199
+ - Reads: `useScopedQuery`.
200
+ - Writes and other calls from event handlers: `mojoRpc` / `mojoScopedCall`.
201
+ - A query you write by hand with `useQuery`: put the group id in its key
202
+ (`['things', group?.id]`). The package cannot key a hand-rolled query for
203
+ you, and a key without the group serves one group's rows to the next.
204
+
205
+ **While the group is resolving.** At boot, or on a switch the cache was not
206
+ seeded for (`?group=`, or the fallback after a failed load), the provider
207
+ reports `loading` with a null group. `useScopedQuery` holds a registered
208
+ query, required or optional, **disabled** for that window, so nothing goes
209
+ out under the previous group or unscoped. A query given an explicit `group`
210
+ option is not held: it has nothing to wait for. A plain `mojoScopedCall` in the
211
+ same window sees an empty signal: a required family throws in dev and an
212
+ optional one goes out unscoped, exactly as at boot. Gate on `loading` or
213
+ render under `RequiresGroup`.
214
+
215
+ **Upgrading.** A blanket "invalidate everything when the group changes" is
216
+ no longer needed for `useScopedQuery`, nor for hand-rolled queries whose key
217
+ carries the group id.
218
+
219
+ **Caveats**
220
+
221
+ - A child's own `useLayoutEffect` runs before the provider's and still sees
222
+ the previous group in the signal. Use a hook, or pass `group` explicitly.
223
+ - A fetch started during render (`useSuspenseQuery`, `fetchOptimistic`) runs
224
+ before the commit, so the signal is not yet current. Pass `group`
225
+ explicitly.
226
+ - Signed out with a stored group, the provider is not `loading`, so an
227
+ optional family can fire once unscoped and again after sign-in. Scoped
228
+ product routes sit behind `RequireAuth`; nothing is held for this case.
140
229
 
141
230
  ### `useScopedQuery<T>(path, params?, {read?, group?, ...queryOpts})`
142
231
 
@@ -146,7 +235,8 @@ request params, and the group id rides the query key —
146
235
  cached rows, while invalidation by `[path]` prefix keeps working. Keep that
147
236
  key discipline if you write a raw scoped query by hand.
148
237
 
149
- When a REQUIRED scope has no active group, the query renders **disabled** —
238
+ While the provider is still resolving its group, a registered query with no
239
+ explicit `group` is held disabled (see above). When a REQUIRED scope has no active group, the query renders **disabled** —
150
240
  and a disabled query has *neither data nor error*. Do not draw an empty box
151
241
  or a spinner that never resolves: state the wait honestly at the call site
152
242
  ("select a brand to load …").
@@ -161,14 +251,21 @@ or a spinner that never resolves: state the wait honestly at the call site
161
251
  `withFreshAuth` with the scope injected; resolves `env.data as T`.
162
252
  Action-shaped `{key: payload}` saves belong to `mojoAction`, not here.
163
253
 
164
- ### `mojoAction` body injection
254
+ ### `mojoAction` sends no scope
255
+
256
+ `mojoAction(endpoint, id, action, payload?)` posts exactly
257
+ `{[action]: payload}` to `<endpoint>/<id>` and never adds a scope key, on any
258
+ family, with or without an active group. Two reasons:
259
+
260
+ - A REST record route needs no group. django-mojo authorizes the request
261
+ against the **row's own** group, whatever the caller sends.
262
+ - A `group` in the body is not only a selector there: the save handler
263
+ writes it as the row's `group` field, and its presence turns the action
264
+ into a full field save. An action on a record read outside the active
265
+ group could move that record to the active group.
165
266
 
166
- `mojoAction(endpoint, id, action, payload?)` consults the same registry:
167
- for a scope-registered family with an active group, the scope key is
168
- injected into the action POST body alongside `{[action]: payload}` — the
169
- wallet-verbs class: actions the backend refuses for brand-scoped operators
170
- without `group`. With nothing registered (the base admin) the injection is
171
- inert.
267
+ The call is declared `unscoped: true`, so it passes the dev tripwire on a
268
+ registered-REQUIRED family. Earlier versions injected the scope here.
172
269
 
173
270
  ### The dev tripwire — and `unscoped: true`
174
271
 
@@ -182,6 +279,25 @@ for the rare genuinely-global call to a scoped family, mark it
182
279
  skip the check (`import.meta.env.DEV` only); it is a development tripwire,
183
280
  not a runtime gate.
184
281
 
282
+ ### Record routes vs custom views
283
+
284
+ Two kinds of URL end in an id, and only the caller knows which it has:
285
+
286
+ - **REST record routes** — the model's own `<endpoint>/<id>` (get, save,
287
+ delete, POST_SAVE_ACTIONS). The server binds the row's group; send no
288
+ scope. `defineModel`'s record hooks, the generic `useModel` and
289
+ `useSaveModel` (with an id) and `mojoAction` already declare these
290
+ `unscoped: true`. A raw call to one does the same:
291
+ `mojoGet(endpoint, id, { unscoped: true })`.
292
+ - **Custom views on a record path** — `requests/<id>/resend`, a download by
293
+ id, any view the backend gates with a group permission decorator. These
294
+ read the group from the request and **do** need it: keep using `mojoRpc` /
295
+ `mojoScopedCall` / `useScopedQuery`, which inject it.
296
+
297
+ No path rule can tell the two apart, so the tripwire does not try. A
298
+ collection create (`POST <endpoint>`) is not a record route: the group on
299
+ the request decides where the new row lands, so pass it in the body.
300
+
185
301
  ## Mock admin contracts
186
302
 
187
303
  The mock carries measured GroupView/admin transports, not UI-shaped
@@ -313,7 +429,7 @@ confirm `completed` before the task returns success. Completed scalar
313
429
  ## `mojoQueryDefaults()`
314
430
 
315
431
  Spread into the app's `QueryClient` defaults. Provides: no retry on 4xx
316
- `MojoError`s (deterministic failures), `networkMode: 'always'` under the
432
+ `MojoError`s or `ActionRefusedError` (deterministic failures), `networkMode: 'always'` under the
317
433
  mock ('online' live), and `refetchOnWindowFocus: false` (freshness comes
318
434
  from explicit refresh + opt-in autoRefresh — focus storms read as phantom
319
435
  fetches).
@@ -325,3 +441,20 @@ fetches).
325
441
  the semantic wire code, and `data` preserves safe structured evidence.
326
442
  - `AuthRequiredError` — thrown by the pre-request gate when a request needs
327
443
  a session that doesn't exist (synthetic 401; no network was touched).
444
+
445
+ ## Upgrading to 0.3
446
+
447
+ 0.3.0 is a breaking minor release: a raw call through the unwrap boundary
448
+ now **rejects** a flat HTTP-200 refusal (top-level `success:false`) with
449
+ `ActionRefusedError` instead of resolving it.
450
+
451
+ - Find raw `mojoCall` / `mojoScopedCall` sites that read a top-level
452
+ `.success`. Those refusal checks are now dead code: delete them. Their
453
+ `catch` receives `ActionRefusedError`, which is a `MojoError`, so existing
454
+ `instanceof MojoError` handling keeps working.
455
+ - A call whose flat `success:false` is a real answer, such as a connection
456
+ test, adds `refusal: 'return'` and keeps reading the reply.
457
+ - Reads of a wrapped `data.success` are unaffected.
458
+ - `err.errorCode` carries the refusal code and `err.data` the full reply, so
459
+ the server's reason is no longer lost.
460
+ - `defineModel` actions and `mojoAction` behave as before.
@@ -113,5 +113,5 @@ Sorting is the server's default unless `defaultParams` carries `sort`.
113
113
  a permanently empty picker in global context.
114
114
  - The dropdown's trigger/footer classes (`.multiselect-trigger`,
115
115
  `.multiselect-footer`, `.multiselect-done`) live in
116
- `multiselect-dropdown.css` — consuming apps must import it alongside
117
- `collection-multiselect.css` (the reference `theme.css` imports both).
116
+ `multiselect-dropdown.css` — `portal-mojo/styles.css` imports it alongside
117
+ `collection-multiselect.css`.
package/docs/combobox.md CHANGED
@@ -101,5 +101,5 @@ strings.
101
101
  to the label, duplicating every row — not carried).
102
102
  - The dropdown is mounted in the shared top-layer Popover, so overflow
103
103
  containers and native dialogs do not clip it.
104
- - Styles live in `apps/portal/src/theme/combobox.css` (tokens only, both
105
- themes); consuming apps must include it.
104
+ - Styles live in `packages/portal-mojo/src/styles/components/combobox.css` (tokens only, both
105
+ themes) and ship in `portal-mojo/styles.css`.
package/docs/dataview.md CHANGED
@@ -11,7 +11,7 @@ Point it at any record and get a sensible detail grid. Field **names** and
11
11
  **values** together pick the renderer, nested objects become nested grids,
12
12
  and anything that isn't a scalar lands in a JSON block with copy and
13
13
  collapse. Port of web-mojo `src/core/views/data/DataView.js` (1,153 lines).
14
- Styles: `apps/portal/src/theme/dataview.css`. Demo: **Develop → Components →
14
+ Styles: `packages/portal-mojo/src/styles/components/dataview.css`. Demo: **Develop → Components →
15
15
  DataView**.
16
16
 
17
17
  ## DataView vs KnownFieldsCard
@@ -144,8 +144,8 @@ lean on the per-precision default.
144
144
 
145
145
  ## Styling
146
146
 
147
- `apps/portal/src/theme/datepicker.css` — trigger, clear ✕, popover content
148
- box, inline host. The grid itself is `theme/calendar.css` and the floating
149
- surface is `theme/popover.css`; this file adds nothing they already cover.
147
+ `packages/portal-mojo/src/styles/components/datepicker.css` — trigger, clear ✕, popover content
148
+ box, inline host. The grid itself is `components/calendar.css` and the floating
149
+ surface is `components/popover.css`; this file adds nothing they already cover.
150
150
  Trigger metrics follow the house control (`.input`): `--surface2` fill,
151
151
  `--line` border, 8px radius, accent focus ring. Tokens only, both themes.
package/docs/daterange.md CHANGED
@@ -164,7 +164,7 @@ carry them when the registry lands:
164
164
  `inline` is fluid to its host. Two panes + rail ≈ 850px — don't mount
165
165
  two-pane popovers inside narrow scroll containers, and keep dialogs on
166
166
  `months={1}`.
167
- - Styling lives in `apps/portal/src/theme/daterange.css` (trigger
167
+ - Styling lives in `packages/portal-mojo/src/styles/components/daterange.css` (trigger
168
168
  `.mojo-daterange-*`, rail `.mojo-calendar-preset*`), tokens only, both
169
169
  themes; the calendar grid ships in `calendar.css`, the popover shell in
170
170
  `popover.css`.
@@ -222,7 +222,7 @@ not `new Date(epochSeconds)` without `× 1000`.
222
222
 
223
223
  ## Styling
224
224
 
225
- `apps/portal/src/theme/datetimepicker.css` — layout glue only
225
+ `packages/portal-mojo/src/styles/components/datetimepicker.css` — layout glue only
226
226
  (`mojo-datetime-*`: `trigger` / `trigger-text` / `trigger-clear` /
227
227
  `popover-inner` / `row` / `cal-col` / `time-col` / `time-head` / `tz-row` /
228
228
  `tz-label` / `tz-host` / `foot`), tokens only, both themes. The grid, the
@@ -28,11 +28,11 @@ export const UserModel = defineModel<User>({
28
28
  | Member | Behavior |
29
29
  |---|---|
30
30
  | `useList(params?, {enabled?})` | `useQuery` list under key `[endpoint, params]` with `keepPreviousData`; `enabled:false` suppresses permission-hidden background reads |
31
- | `useOne(id \| null)` | one record, key `[endpoint, 'one', id]`; disabled while `id` is null |
32
- | `useSave()` | mutation `{id, changes}`; `id: null` creates. Handles one fresh-auth 440/retry, then writes the returned row into the one-record cache and invalidates `[endpoint]`. REJECTS on failure |
33
- | `useDelete()` | mutation `{id}`; removes the one-record cache entry + invalidates |
34
- | `useAction(name)` | mutation for ONE declared POST_SAVE_ACTION (below), with one fresh-auth 440/retry. THROWS at render time for an undeclared name |
35
- | `fetchOne(queryClient, id)` | imperative fetch through the same cache key — dedupes with mounted `useOne`s (prefetch pattern) |
31
+ | `useOne(id \| null)` | one record, key `[endpoint, 'one', id]`; disabled while `id` is null. Sends no group scope |
32
+ | `useSave()` | mutation `{id, changes}`; `id: null` creates. Handles one fresh-auth 440/retry, then writes the returned row into the one-record cache and invalidates `[endpoint]`. REJECTS on failure. With an id it sends no group scope; a create sends only your `changes` |
33
+ | `useDelete()` | mutation `{id}`; removes the one-record cache entry + invalidates. Sends no group scope |
34
+ | `useAction(name)` | mutation for ONE declared POST_SAVE_ACTION (below), with one fresh-auth 440/retry. THROWS at render time for an undeclared name. The body is exactly `{[key]: payload}`; sends no group scope |
35
+ | `fetchOne(queryClient, id)` | imperative fetch through the same cache key — dedupes with mounted `useOne`s (prefetch pattern). Sends no group scope |
36
36
  | `invalidate(queryClient)` | invalidate everything under `[endpoint]` |
37
37
  | `keys.root / keys.list(params) / keys.one(id)` | key builders for targeted cache surgery |
38
38
 
@@ -45,6 +45,35 @@ only on final success. Delete remains unwrapped, as do direct `mojoCall`
45
45
  mutations; callers opt those in only when the endpoint is both sensitive and
46
46
  safe to retry once.
47
47
 
48
+ ## Group scope
49
+
50
+ In a group portal an app registers its scoped endpoint families
51
+ (`registerEndpointScope`, [client.md](client.md)). The model layer treats
52
+ its two kinds of call differently:
53
+
54
+ - **Record calls send no group.** `useOne`, `fetchOne`, `useSave` with an
55
+ id, `useDelete` and `useAction` all hit `<endpoint>/<id>`. django-mojo
56
+ authorizes those against the row's own group, so a client group adds
57
+ nothing — and a `group` in a save or action body is written to the row as
58
+ its `group` field. The hooks declare these calls `unscoped: true`, so a
59
+ registered-REQUIRED family never blocks them in development.
60
+ - **A create sends only your `changes`.** `useSave` with `id: null` posts to
61
+ the collection, where the request's group decides where the row lands.
62
+ Put the group in `changes` yourself; on a REQUIRED family the dev tripwire
63
+ stops a create that carries none.
64
+
65
+ List reads (`useList`) are scoped only by the params you pass.
66
+
67
+ **Upgrading.** Record calls used to be stopped by the dev tripwire on a
68
+ REQUIRED family, and `mojoAction` used to inject the group. If your portal
69
+ grew a local wrapper to get past that — one that adds the group to an
70
+ action or save on a record, or refuses to send when the active group
71
+ changed since the record was read — delete it and call `M.useAction(verb)`
72
+ (or `mojoAction`) directly. Remove any hand-added `group` from record-path
73
+ bodies and params: it is at best ignored and at worst rewrites the row's
74
+ group. Custom views on a record path (`<id>/resend`) are not model calls and
75
+ keep their scope through `mojoRpc`.
76
+
48
77
  ## POST_SAVE_ACTIONS
49
78
 
50
79
  Backend contract (`mojo/models/rest.py on_rest_save`): an action is a key
@@ -75,8 +104,12 @@ reaches the client in one of two wire shapes:
75
104
  ```
76
105
 
77
106
  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:
107
+ rejects at the unwrap boundary (`MojoError`). Since 0.3 unwrap also rejects
108
+ the flat shape for raw calls; `useAction` opts out of that check
109
+ (`refusal: 'return'` on its own request) so the error names the action and
110
+ honours the model's declared mode. No behaviour change for models. The
111
+ action-refusal layer sits above the boundary and is handled by `useAction`
112
+ automatically:
80
113
 
81
114
  - **`refusal: 'reject'` (default)** — a `success:false` (or payload
82
115
  `status:false`) reply REJECTS with **`ActionRefusedError`**, so a refusal
@@ -10,7 +10,7 @@ Six small pieces admin record pages are composed from, ported from
10
10
  web-mojo's `src/core/views/data/*` primitives plus its admin metadata
11
11
  section. They are presentational and stateless (except `MetadataSection`,
12
12
  which writes) — drop them inside a `DetailView` section, a `panel`, or a
13
- `modal.detail`. Styles: `apps/portal/src/theme/detail-primitives.css`.
13
+ `modal.detail`. Styles: `packages/portal-mojo/src/styles/components/detail-primitives.css`.
14
14
 
15
15
  **All three trusted-HTML slots are gone.** web-mojo rendered
16
16
  `StatusPanel.meta`, `FlowStrip.value/hint` and `Timeline.detail` as
package/docs/feedback.md CHANGED
@@ -73,10 +73,10 @@ disable, member `manage_group` / `manage_members` grants (through
73
73
  which of the watched keys changed (null/'' normalised), so a save confirms
74
74
  ONLY the fields that break things and passes silently otherwise.
75
75
 
76
- Styles: `apps/portal/src/theme/guardrail.css` (`.guardrail`,
76
+ Styles: `packages/portal-mojo/src/styles/components/guardrail.css` (`.guardrail`,
77
77
  `.guardrail-effect/-why/-why-head/-undo/-type`, `.guardrail-icon`) over
78
- `--bad/--bad-soft` (danger) and `--warn/--warn-soft` (`danger: false`). The
79
- package ships no CSS — a consuming app carries that block (README).
78
+ `--bad/--bad-soft` (danger) and `--warn/--warn-soft` (`danger: false`). It
79
+ ships in `portal-mojo/styles.css`; the app declares the tokens (README).
80
80
 
81
81
  ## modal.drawer
82
82
 
@@ -112,7 +112,7 @@ title · meta) is web-mojo's `Modal.drawer()` carried over.
112
112
  otherwise there is no exit at all, so it falls back to dismissable with a
113
113
  `console.warn` (house rule: degenerate input → default + warn).
114
114
  - An unknown `width` warns and falls back to 480px.
115
- - Styles: `apps/portal/src/theme/drawer.css` (`.mojo-drawer`, `.drawer-panel`,
115
+ - Styles: `packages/portal-mojo/src/styles/components/drawer.css` (`.mojo-drawer`, `.drawer-panel`,
116
116
  `.drawer-head/-eyebrow/-title/-meta`, `.drawer-body`) — tokens only, both
117
117
  themes. `DRAWER_EXIT_MS` in `modal.tsx` must match the closing keyframes.
118
118
 
@@ -139,10 +139,11 @@ and drawers), then returns to the page when the last modal closes. Modal-local
139
139
  ancestry keeps Undo and progress Cancel usable despite native dialog inertness.
140
140
  Moving the host preserves card state and timers and does not focus the toast.
141
141
  Mount it once; do not add per-dialog toast hosts or solve this with `z-index`.
142
- Custom app themes must include the updated toast CSS from
143
- `apps/portal/src/theme.css`: reset the popover's UA inset/margin/border/padding
144
- and background, hide closed popovers, and keep only the cards pointer-active.
145
- Updating the TypeScript package alone does not update a consumer's own theme.
142
+ The toast CSS ships in `portal-mojo/styles.css`
143
+ (`packages/portal-mojo/src/styles/core.css`): it resets the popover's UA
144
+ inset/margin/border/padding and background, hides closed popovers, and keeps
145
+ only the cards pointer-active. An app that still carries a copied theme keeps
146
+ its old toast rules until it deletes the copy.
146
147
  The showcase's **Toasts inside modal** demo covers errors, Undo, progress,
147
148
  and nested opening/closing. `node scripts/verify-toast-modal.mjs` checks the
148
149
  mounted lifecycle; native visibility and interaction require browser checks.
@@ -140,7 +140,7 @@ npm run verify:record-attachments
140
140
 
141
141
  It covers pure validation, lazy concurrency, capacity, stable/safe snapshots,
142
142
  consumer/destination dedupe, real progress updates, callback-only retry,
143
- truthful cancellation, known-id recovery, and byte-identical Portal/Showcase
144
- token CSS. The form contract adds strict relation normalization, explicit
143
+ truthful cancellation, known-id recovery, and the package stylesheet importing
144
+ the upload CSS. The form contract adds strict relation normalization, explicit
145
145
  attach states, owner-response reconciliation, orphan retention, safe previews,
146
146
  avatar #1488 semantics, and cache-boundary sanitization.
package/docs/idioms.md CHANGED
@@ -47,7 +47,7 @@ Behavior contract (dom.js `armedButton` parity):
47
47
  live trigger). Fail-safe cost is one extra click, never an unintended fire.
48
48
  - Clicks `stopPropagation()` — safe inside clickable rows / expand rows.
49
49
 
50
- Styling: `apps/portal/src/theme/idioms.css` — `.armed-btn` (rest) /
50
+ Styling: `packages/portal-mojo/src/styles/components/idioms.css` — `.armed-btn` (rest) /
51
51
  `.armed-btn.is-armed` (armed, pulsing; static under
52
52
  `prefers-reduced-motion`). Tokens only, both themes; the dark theme swaps
53
53
  the armed text color for contrast against the light --bad.
package/docs/loading.md CHANGED
@@ -12,7 +12,7 @@ import {
12
12
  The "we're fetching / working" family. It **complements the skeleton
13
13
  silhouette**, which stays the default for anything whose shape is already
14
14
  known (see the decision table below). Live demo: portal → Develop →
15
- Components → **Loaders**. Styling: `apps/portal/src/theme/loading.css` —
15
+ Components → **Loaders**. Styling: `packages/portal-mojo/src/styles/components/loading.css` —
16
16
  tokens only, both themes.
17
17
 
18
18
  Ported from web-mojo `src/core/views/feedback/BusyIndicator.js` (its only
@@ -210,7 +210,7 @@ clicked away mid-save, they keep where they went.
210
210
  restores focus to the element the inert switch blurred (see above). Its
211
211
  veil is `pointer-events: none`: `inert` already stopped the clicks, and a
212
212
  veil that swallowed them would break text selection after release.
213
- - **Reduced motion:** the spinner does not spin. `theme.css` kills every
213
+ - **Reduced motion:** the spinner does not spin. `core.css` kills every
214
214
  animation with a universal `!important` rule — right for a skeleton
215
215
  (shimmer stops), wrong for a spinner (the ring would freeze mid-rotation
216
216
  with one colored quarter and read as a stuck control). `loading.css`
@@ -73,4 +73,5 @@ field in that batch back to its shared server snapshot.
73
73
 
74
74
  The suggestion list uses the shared `Popover`, which is the sole owner of
75
75
  outside-mousedown and Escape dismissal. Styles live in
76
- `theme/location-address.css` and use design tokens in both themes.
76
+ `src/styles/components/location-address.css` (shipped in
77
+ `portal-mojo/styles.css`) and use design tokens in both themes.
package/docs/markdown.md CHANGED
@@ -24,8 +24,8 @@ tab or a cold first frame still shows the document.
24
24
  | `debounceMs` | `0` | Wait this long after `source` stops changing before calling the server. Set it for text that grows token by token (a streaming assistant reply) so one reply is one request. |
25
25
  | `className` | — | Extra classes on the wrapper. `md` is always present. |
26
26
 
27
- Renders one `<div class="md">`. Styling lives in the consuming app —
28
- `apps/portal/src/theme/markdown.css`, tokens only, both themes; both render
27
+ Renders one `<div class="md">`. Styling ships in `portal-mojo/styles.css` —
28
+ `packages/portal-mojo/src/styles/components/markdown.css`, tokens only, both themes; both render
29
29
  paths emit the same element set, so one stylesheet dresses both.
30
30
 
31
31
  ### No loading jump, by construction
@@ -175,7 +175,7 @@ that is what lets a cache hit paint on the first frame.
175
175
  | `client/markdown-sanitize.ts` | the allowlist sanitizer — the security boundary |
176
176
  | `client/markdown-parse.ts` | markdown → block AST, plus the HTML emitter the **mock** uses |
177
177
  | `client/markdown-fallback.tsx` | AST → React elements (the fallback path) |
178
- | `apps/portal/src/theme/markdown.css` | `.md` styling, tokens only, both themes |
178
+ | `packages/portal-mojo/src/styles/components/markdown.css` | `.md` styling, tokens only, both themes |
179
179
 
180
180
  ## Fallback coverage
181
181