portal-mojo 0.1.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 (369) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +55 -0
  3. package/docs/DetailView.md +173 -0
  4. package/docs/ModelTable.md +128 -0
  5. package/docs/README.md +98 -0
  6. package/docs/admin-assistant.md +72 -0
  7. package/docs/admin-bouncer.md +120 -0
  8. package/docs/admin-cloudwatch.md +38 -0
  9. package/docs/admin-credentials.md +116 -0
  10. package/docs/admin-dashboard.md +15 -0
  11. package/docs/admin-devices-geoip.md +284 -0
  12. package/docs/admin-dns-certificates.md +122 -0
  13. package/docs/admin-dns-records.md +65 -0
  14. package/docs/admin-dns-registrar.md +33 -0
  15. package/docs/admin-dns.md +80 -0
  16. package/docs/admin-identity-users.md +103 -0
  17. package/docs/admin-incidents.md +71 -0
  18. package/docs/admin-jobs.md +426 -0
  19. package/docs/admin-members.md +112 -0
  20. package/docs/admin-messaging.md +56 -0
  21. package/docs/admin-metrics-explorer.md +63 -0
  22. package/docs/admin-monitoring.md +99 -0
  23. package/docs/admin-network-security.md +393 -0
  24. package/docs/admin-phonehub.md +27 -0
  25. package/docs/admin-push.md +36 -0
  26. package/docs/admin-rules.md +87 -0
  27. package/docs/admin-security-tickets.md +40 -0
  28. package/docs/admin-settings.md +78 -0
  29. package/docs/admin-shortlinks.md +32 -0
  30. package/docs/admin-storage.md +141 -0
  31. package/docs/auth-pages.md +212 -0
  32. package/docs/auth.md +58 -0
  33. package/docs/calendar.md +122 -0
  34. package/docs/charts.md +303 -0
  35. package/docs/client.md +206 -0
  36. package/docs/collection-multiselect.md +117 -0
  37. package/docs/collection-select.md +132 -0
  38. package/docs/combobox.md +105 -0
  39. package/docs/dataview.md +226 -0
  40. package/docs/datepicker.md +151 -0
  41. package/docs/daterange.md +170 -0
  42. package/docs/datetimepicker.md +249 -0
  43. package/docs/defineModel.md +72 -0
  44. package/docs/detail-primitives.md +174 -0
  45. package/docs/feedback.md +85 -0
  46. package/docs/file-upload-ux.md +146 -0
  47. package/docs/forms.md +435 -0
  48. package/docs/grouping-and-fmt.md +98 -0
  49. package/docs/idioms.md +112 -0
  50. package/docs/image-editor.md +101 -0
  51. package/docs/loading.md +242 -0
  52. package/docs/location-address.md +76 -0
  53. package/docs/markdown.md +203 -0
  54. package/docs/menus-and-access.md +89 -0
  55. package/docs/multiselect-dropdown.md +153 -0
  56. package/docs/params.md +73 -0
  57. package/docs/popover.md +130 -0
  58. package/docs/realtime.md +61 -0
  59. package/docs/record-feed.md +238 -0
  60. package/docs/right-panel.md +42 -0
  61. package/docs/tabs-and-form-wizard.md +60 -0
  62. package/docs/taginput.md +120 -0
  63. package/docs/timepicker.md +173 -0
  64. package/docs/timezone-select.md +147 -0
  65. package/docs/worldmap.md +302 -0
  66. package/package.json +55 -0
  67. package/src/admin/assistant/AssistantFeed.tsx +29 -0
  68. package/src/admin/assistant/AssistantPanel.tsx +65 -0
  69. package/src/admin/assistant/api.ts +17 -0
  70. package/src/admin/assistant/data.ts +63 -0
  71. package/src/admin/assistant/index.ts +21 -0
  72. package/src/admin/assistant/launchers.tsx +33 -0
  73. package/src/admin/assistant/pages.tsx +78 -0
  74. package/src/admin/assistant/streaming.ts +238 -0
  75. package/src/admin/assistant/types.ts +30 -0
  76. package/src/admin/bouncer/devices.tsx +230 -0
  77. package/src/admin/bouncer/index.ts +34 -0
  78. package/src/admin/bouncer/models.ts +199 -0
  79. package/src/admin/bouncer/signals.tsx +182 -0
  80. package/src/admin/bouncer/signatures.tsx +220 -0
  81. package/src/admin/cloudwatch/CloudWatchChart.tsx +61 -0
  82. package/src/admin/cloudwatch/CloudWatchDashboardPage.tsx +52 -0
  83. package/src/admin/cloudwatch/CloudWatchResourceDetail.tsx +33 -0
  84. package/src/admin/cloudwatch/data.ts +163 -0
  85. package/src/admin/cloudwatch/index.ts +16 -0
  86. package/src/admin/credentials/group-api-keys.tsx +433 -0
  87. package/src/admin/credentials/index.ts +4 -0
  88. package/src/admin/credentials/models.ts +244 -0
  89. package/src/admin/credentials/secret-dialog.tsx +77 -0
  90. package/src/admin/credentials/webhook-subscriptions.tsx +423 -0
  91. package/src/admin/dashboard/AdminDashboardPage.tsx +101 -0
  92. package/src/admin/dashboard/data.ts +60 -0
  93. package/src/admin/dashboard/index.ts +2 -0
  94. package/src/admin/dns/CertificateDetail.tsx +115 -0
  95. package/src/admin/dns/CertificateLifecyclePoller.tsx +141 -0
  96. package/src/admin/dns/CertificateRequestDialog.tsx +103 -0
  97. package/src/admin/dns/CertificatesPage.tsx +85 -0
  98. package/src/admin/dns/DnsRecordEditor.tsx +110 -0
  99. package/src/admin/dns/DnsRecordsPage.tsx +36 -0
  100. package/src/admin/dns/DnsRecordsPanel.tsx +71 -0
  101. package/src/admin/dns/DomainCertificatesSection.tsx +40 -0
  102. package/src/admin/dns/DomainDetail.tsx +41 -0
  103. package/src/admin/dns/DomainOnboardingDialog.tsx +27 -0
  104. package/src/admin/dns/DomainPurchaseDetail.tsx +11 -0
  105. package/src/admin/dns/DomainPurchaseWizard.tsx +112 -0
  106. package/src/admin/dns/DomainPurchasesPage.tsx +16 -0
  107. package/src/admin/dns/DomainsPage.tsx +27 -0
  108. package/src/admin/dns/ProviderCredentialsPage.tsx +304 -0
  109. package/src/admin/dns/RegistrantContactPage.tsx +42 -0
  110. package/src/admin/dns/api.ts +515 -0
  111. package/src/admin/dns/certificate-data.ts +221 -0
  112. package/src/admin/dns/data.ts +32 -0
  113. package/src/admin/dns/dns-data.ts +266 -0
  114. package/src/admin/dns/dns-integration.ts +24 -0
  115. package/src/admin/dns/domain-links.ts +37 -0
  116. package/src/admin/dns/index.ts +86 -0
  117. package/src/admin/dns/models.ts +418 -0
  118. package/src/admin/dns/purchase-data.ts +67 -0
  119. package/src/admin/dns/registrant-data.ts +52 -0
  120. package/src/admin/identity/index.ts +5 -0
  121. package/src/admin/identity/members/GroupMembersPanel.tsx +146 -0
  122. package/src/admin/identity/members/MemberDetail.tsx +226 -0
  123. package/src/admin/identity/members/MembersPage.tsx +144 -0
  124. package/src/admin/identity/members/index.ts +24 -0
  125. package/src/admin/identity/members/member-flows.tsx +132 -0
  126. package/src/admin/identity/members/models.ts +204 -0
  127. package/src/admin/identity/users/UserDetail.tsx +257 -0
  128. package/src/admin/identity/users/UsersPage.tsx +163 -0
  129. package/src/admin/identity/users/index.ts +27 -0
  130. package/src/admin/identity/users/models.ts +232 -0
  131. package/src/admin/identity/users/sections/ApiKeysSection.tsx +119 -0
  132. package/src/admin/identity/users/sections/AuditSection.tsx +190 -0
  133. package/src/admin/identity/users/sections/DevicesSection.tsx +140 -0
  134. package/src/admin/identity/users/sections/GroupsSection.tsx +64 -0
  135. package/src/admin/identity/users/sections/LoginsSection.tsx +106 -0
  136. package/src/admin/identity/users/sections/MetadataPanel.tsx +36 -0
  137. package/src/admin/identity/users/sections/NotificationsSection.tsx +117 -0
  138. package/src/admin/identity/users/sections/OAuthSection.tsx +72 -0
  139. package/src/admin/identity/users/sections/OverviewSection.tsx +109 -0
  140. package/src/admin/identity/users/sections/PermissionsSection.tsx +35 -0
  141. package/src/admin/identity/users/sections/PersonalSection.tsx +73 -0
  142. package/src/admin/identity/users/sections/ProfileSection.tsx +175 -0
  143. package/src/admin/identity/users/sections/SecuritySection.tsx +123 -0
  144. package/src/admin/identity/users/sections/actions.tsx +752 -0
  145. package/src/admin/identity/users/sections/permission-catalog.ts +138 -0
  146. package/src/admin/identity/users/sections/queries.ts +74 -0
  147. package/src/admin/identity/users/sections/shared.tsx +299 -0
  148. package/src/admin/incidents/EventDetail.tsx +43 -0
  149. package/src/admin/incidents/EventsPage.tsx +52 -0
  150. package/src/admin/incidents/IncidentDetail.tsx +68 -0
  151. package/src/admin/incidents/IncidentsPage.tsx +102 -0
  152. package/src/admin/incidents/forensics.tsx +73 -0
  153. package/src/admin/incidents/index.ts +16 -0
  154. package/src/admin/incidents/models.ts +113 -0
  155. package/src/admin/incidents/sanitize.ts +71 -0
  156. package/src/admin/index.ts +383 -0
  157. package/src/admin/jobs/JobDashboardPage.tsx +111 -0
  158. package/src/admin/jobs/JobDetail.tsx +721 -0
  159. package/src/admin/jobs/JobRunnersPage.tsx +216 -0
  160. package/src/admin/jobs/JobsTablePage.tsx +122 -0
  161. package/src/admin/jobs/RunnerDetail.tsx +576 -0
  162. package/src/admin/jobs/ScheduledTaskDetail.tsx +421 -0
  163. package/src/admin/jobs/ScheduledTasksPage.tsx +167 -0
  164. package/src/admin/jobs/columns.tsx +297 -0
  165. package/src/admin/jobs/control.ts +488 -0
  166. package/src/admin/jobs/index.ts +85 -0
  167. package/src/admin/jobs/models.ts +599 -0
  168. package/src/admin/jobs/queries.ts +272 -0
  169. package/src/admin/jobs/scheduled-task-form.tsx +493 -0
  170. package/src/admin/jobs/sections/JobChannelsPanel.tsx +142 -0
  171. package/src/admin/jobs/sections/JobOperationsSection.tsx +423 -0
  172. package/src/admin/jobs/sections/JobRunnersStrip.tsx +82 -0
  173. package/src/admin/jobs/sections/JobThroughputSection.tsx +118 -0
  174. package/src/admin/messaging/EmailDomainWizard.tsx +24 -0
  175. package/src/admin/messaging/EmailDomainsPage.tsx +17 -0
  176. package/src/admin/messaging/EmailTemplatesPage.tsx +13 -0
  177. package/src/admin/messaging/MailboxesPage.tsx +15 -0
  178. package/src/admin/messaging/PublicMessagesPage.tsx +13 -0
  179. package/src/admin/messaging/SandboxedEmailPreview.tsx +23 -0
  180. package/src/admin/messaging/SentMessagesPage.tsx +11 -0
  181. package/src/admin/messaging/api.ts +17 -0
  182. package/src/admin/messaging/data.ts +10 -0
  183. package/src/admin/messaging/index.ts +23 -0
  184. package/src/admin/messaging/models.ts +76 -0
  185. package/src/admin/messaging/push/PushPage.tsx +44 -0
  186. package/src/admin/messaging/push/api.ts +17 -0
  187. package/src/admin/messaging/push/index.ts +9 -0
  188. package/src/admin/messaging/push/models.ts +43 -0
  189. package/src/admin/messaging/sanitize.ts +44 -0
  190. package/src/admin/monitoring/LogInspector.tsx +160 -0
  191. package/src/admin/monitoring/LogsPage.tsx +135 -0
  192. package/src/admin/monitoring/MetricsExplorerPage.tsx +360 -0
  193. package/src/admin/monitoring/MetricsPermissionsPage.tsx +166 -0
  194. package/src/admin/monitoring/MetricsSourcePicker.tsx +136 -0
  195. package/src/admin/monitoring/index.ts +55 -0
  196. package/src/admin/monitoring/metrics-explorer-client.ts +229 -0
  197. package/src/admin/monitoring/metrics-explorer-data.ts +158 -0
  198. package/src/admin/monitoring/models.ts +155 -0
  199. package/src/admin/network/BlockedIPsPage.tsx +194 -0
  200. package/src/admin/network/BlockedIpDetail.tsx +262 -0
  201. package/src/admin/network/FirewallLogPage.tsx +115 -0
  202. package/src/admin/network/IPSetDetail.tsx +280 -0
  203. package/src/admin/network/IPSetEditor.tsx +191 -0
  204. package/src/admin/network/IPSetsPage.tsx +177 -0
  205. package/src/admin/network/geofence/BlocksTab.tsx +289 -0
  206. package/src/admin/network/geofence/ExemptionsTab.tsx +349 -0
  207. package/src/admin/network/geofence/GeofencingPage.tsx +119 -0
  208. package/src/admin/network/geofence/PostureHeader.tsx +184 -0
  209. package/src/admin/network/geofence/RuleEditor.tsx +176 -0
  210. package/src/admin/network/geofence/RulesTab.tsx +254 -0
  211. package/src/admin/network/geofence/SimulatorTab.tsx +281 -0
  212. package/src/admin/network/geofence/geofence-data.ts +674 -0
  213. package/src/admin/network/index.ts +68 -0
  214. package/src/admin/network/models.ts +607 -0
  215. package/src/admin/phonehub/PhoneHubPage.tsx +57 -0
  216. package/src/admin/phonehub/api.ts +47 -0
  217. package/src/admin/phonehub/data.ts +6 -0
  218. package/src/admin/phonehub/index.ts +10 -0
  219. package/src/admin/phonehub/models.ts +68 -0
  220. package/src/admin/rules/HandlerChainBuilder.tsx +51 -0
  221. package/src/admin/rules/RuleSetDetailPage.tsx +33 -0
  222. package/src/admin/rules/RuleSetsPage.tsx +33 -0
  223. package/src/admin/rules/editors.tsx +54 -0
  224. package/src/admin/rules/handler-dsl.ts +213 -0
  225. package/src/admin/rules/index.ts +13 -0
  226. package/src/admin/rules/models.ts +112 -0
  227. package/src/admin/security/devices/LoginEventDetail.tsx +123 -0
  228. package/src/admin/security/devices/LoginLocationMap.tsx +321 -0
  229. package/src/admin/security/devices/LoginLocationsPage.tsx +162 -0
  230. package/src/admin/security/devices/UserDeviceDetail.tsx +546 -0
  231. package/src/admin/security/devices/UserDeviceLocationDetail.tsx +272 -0
  232. package/src/admin/security/devices/UserDevicesPage.tsx +84 -0
  233. package/src/admin/security/devices/index.ts +40 -0
  234. package/src/admin/security/devices/models.ts +470 -0
  235. package/src/admin/security/geoip/GeoIpCachePage.tsx +135 -0
  236. package/src/admin/security/geoip/GeoIpDossier.tsx +804 -0
  237. package/src/admin/security/geoip/geoip-forms.ts +102 -0
  238. package/src/admin/security/geoip/index.ts +27 -0
  239. package/src/admin/security/geoip/models.ts +397 -0
  240. package/src/admin/security/index.ts +31 -0
  241. package/src/admin/security/models.ts +137 -0
  242. package/src/admin/security/tickets.tsx +562 -0
  243. package/src/admin/security-permissions.ts +5 -0
  244. package/src/admin/settings/SettingDetail.tsx +57 -0
  245. package/src/admin/settings/SettingEditor.tsx +157 -0
  246. package/src/admin/settings/SettingsPage.tsx +72 -0
  247. package/src/admin/settings/index.ts +19 -0
  248. package/src/admin/settings/model.ts +143 -0
  249. package/src/admin/shortlinks/ShortlinkDetail.tsx +55 -0
  250. package/src/admin/shortlinks/ShortlinkHistoryPage.tsx +18 -0
  251. package/src/admin/shortlinks/ShortlinksPage.tsx +59 -0
  252. package/src/admin/shortlinks/index.ts +18 -0
  253. package/src/admin/shortlinks/models.ts +246 -0
  254. package/src/admin/storage/BackendsPage.tsx +43 -0
  255. package/src/admin/storage/BucketDetail.tsx +77 -0
  256. package/src/admin/storage/BucketsPage.tsx +64 -0
  257. package/src/admin/storage/FileManagerDetail.tsx +36 -0
  258. package/src/admin/storage/FilePreview.tsx +54 -0
  259. package/src/admin/storage/FileUploadSurface.tsx +250 -0
  260. package/src/admin/storage/FileView.tsx +109 -0
  261. package/src/admin/storage/FilesPage.tsx +53 -0
  262. package/src/admin/storage/file-renditions.ts +99 -0
  263. package/src/admin/storage/index.ts +31 -0
  264. package/src/admin/storage/models.ts +585 -0
  265. package/src/admin/storage/storage-dialogs.tsx +80 -0
  266. package/src/charts/CircularProgress.tsx +281 -0
  267. package/src/charts/KPIStrip.tsx +162 -0
  268. package/src/charts/KPITile.tsx +125 -0
  269. package/src/charts/MetricsChart.tsx +459 -0
  270. package/src/charts/MetricsMiniWidget.tsx +588 -0
  271. package/src/charts/MiniChart.tsx +283 -0
  272. package/src/charts/PieChart.tsx +354 -0
  273. package/src/charts/SeriesChart.tsx +378 -0
  274. package/src/charts/chart-dialogs.tsx +149 -0
  275. package/src/charts/exportChart.ts +144 -0
  276. package/src/charts/index.ts +28 -0
  277. package/src/charts/pie-math.ts +127 -0
  278. package/src/charts/stats.ts +265 -0
  279. package/src/charts/worldmap/WorldMap.tsx +649 -0
  280. package/src/charts/worldmap/countryCentroids.ts +304 -0
  281. package/src/charts/worldmap/geo.ts +293 -0
  282. package/src/charts/worldmap/use-world-land.ts +47 -0
  283. package/src/charts/worldmap/world-land.ts +18 -0
  284. package/src/charts/worldmap/worldmap-data.ts +285 -0
  285. package/src/client/auth.ts +737 -0
  286. package/src/client/client.ts +268 -0
  287. package/src/client/duid.ts +31 -0
  288. package/src/client/errors.ts +33 -0
  289. package/src/client/group-context.ts +28 -0
  290. package/src/client/group.tsx +126 -0
  291. package/src/client/hooks.ts +50 -0
  292. package/src/client/index.ts +37 -0
  293. package/src/client/jwt.ts +75 -0
  294. package/src/client/location.ts +285 -0
  295. package/src/client/lookups.ts +41 -0
  296. package/src/client/markdown-fallback.tsx +115 -0
  297. package/src/client/markdown-parse.ts +386 -0
  298. package/src/client/markdown-sanitize.ts +217 -0
  299. package/src/client/markdown.ts +122 -0
  300. package/src/client/me.ts +188 -0
  301. package/src/client/mock.ts +9984 -0
  302. package/src/client/model.ts +227 -0
  303. package/src/client/params.ts +292 -0
  304. package/src/client/realtime-mock.ts +221 -0
  305. package/src/client/realtime.ts +561 -0
  306. package/src/client/record-feed.ts +329 -0
  307. package/src/client/safe-export.ts +71 -0
  308. package/src/client/types.ts +278 -0
  309. package/src/client/upload.ts +421 -0
  310. package/src/ui/AddressField.tsx +273 -0
  311. package/src/ui/AttachmentQueue.tsx +92 -0
  312. package/src/ui/CollectionMultiSelect.tsx +622 -0
  313. package/src/ui/CollectionSelect.tsx +517 -0
  314. package/src/ui/ComboBox.tsx +336 -0
  315. package/src/ui/DetailView.tsx +438 -0
  316. package/src/ui/FileDrop.tsx +194 -0
  317. package/src/ui/FileField.tsx +369 -0
  318. package/src/ui/FilterBar.tsx +269 -0
  319. package/src/ui/FormFields.tsx +77 -0
  320. package/src/ui/FormView.tsx +360 -0
  321. package/src/ui/FormWizard.tsx +336 -0
  322. package/src/ui/GroupSwitcher.tsx +202 -0
  323. package/src/ui/Guarded.tsx +16 -0
  324. package/src/ui/MarkdownView.tsx +89 -0
  325. package/src/ui/ModelTable.tsx +848 -0
  326. package/src/ui/MultiSelectDropdown.tsx +390 -0
  327. package/src/ui/Popover.tsx +204 -0
  328. package/src/ui/RecordFeed.tsx +627 -0
  329. package/src/ui/RequiresGroup.tsx +32 -0
  330. package/src/ui/RightPanel.tsx +107 -0
  331. package/src/ui/SidebarNav.tsx +330 -0
  332. package/src/ui/Tabs.tsx +161 -0
  333. package/src/ui/TagInput.tsx +381 -0
  334. package/src/ui/ThemeProvider.tsx +52 -0
  335. package/src/ui/UploadQueue.tsx +603 -0
  336. package/src/ui/armed-button.tsx +91 -0
  337. package/src/ui/date/Calendar.tsx +498 -0
  338. package/src/ui/date/DatePicker.tsx +289 -0
  339. package/src/ui/date/DateRangePicker.tsx +280 -0
  340. package/src/ui/date/DateTimePicker.tsx +587 -0
  341. package/src/ui/date/PresetRail.tsx +165 -0
  342. package/src/ui/date/TimePicker.tsx +624 -0
  343. package/src/ui/date/TimezoneSelect.tsx +267 -0
  344. package/src/ui/date/fns.ts +679 -0
  345. package/src/ui/detail/DataView.tsx +692 -0
  346. package/src/ui/detail/FlowStrip.tsx +80 -0
  347. package/src/ui/detail/KnownFieldsCard.tsx +121 -0
  348. package/src/ui/detail/MetadataSection.tsx +219 -0
  349. package/src/ui/detail/StackTraceView.tsx +150 -0
  350. package/src/ui/detail/StatusPanel.tsx +73 -0
  351. package/src/ui/detail/Timeline.tsx +52 -0
  352. package/src/ui/detail/index.ts +12 -0
  353. package/src/ui/field-registry.tsx +428 -0
  354. package/src/ui/field-wire.ts +322 -0
  355. package/src/ui/form-autosave.ts +583 -0
  356. package/src/ui/format.ts +444 -0
  357. package/src/ui/grouping.ts +150 -0
  358. package/src/ui/image-editor/ImageEditor.tsx +484 -0
  359. package/src/ui/image-editor/canvas.ts +254 -0
  360. package/src/ui/image-editor/index.ts +3 -0
  361. package/src/ui/image-editor/math.ts +416 -0
  362. package/src/ui/index.ts +58 -0
  363. package/src/ui/loading.tsx +604 -0
  364. package/src/ui/menu-registry.ts +213 -0
  365. package/src/ui/modal.tsx +297 -0
  366. package/src/ui/password.tsx +302 -0
  367. package/src/ui/schema-form-core.tsx +315 -0
  368. package/src/ui/toast.tsx +219 -0
  369. package/src/ui/ui.tsx +45 -0
@@ -0,0 +1,61 @@
1
+ # Realtime transport
2
+
3
+ Import `RealtimeClient`, `RealtimeProvider`, `useRealtime`,
4
+ `useRealtimeStatus`, `useRealtimeEvent`, `useRealtimeTopic`, and
5
+ `defineRealtimeEvent` from `portal-mojo/client`.
6
+
7
+ Mount one `RealtimeProvider` inside the app's auth root. It owns the connection;
8
+ consumer permissions own topic subscriptions. Disabling the provider or losing
9
+ the auth token closes the socket. A permission loss in one consumer must not
10
+ disable this shared provider.
11
+
12
+ ## Wire contract
13
+
14
+ - URL is derived from the configured API origin as exactly `/ws/realtime/`
15
+ with `ws:`/`wss:`. Credentials are never placed in the URL, lifecycle state,
16
+ logs, caches, mock observations, or storage beyond the existing auth store.
17
+ - An open socket is not ready. The client waits for `auth_required`, then sends
18
+ `{type:'authenticate',token,prefix:'bearer'}` and becomes ready only after
19
+ `auth_success`. An auth error closes and suspends the connection until the
20
+ token value changes.
21
+ - Heartbeat is a `ping` every 20 seconds with a 10-second `pong` deadline.
22
+ Reconnect uses capped jittered backoff. Pre-accept close code 4429 enforces at
23
+ least 60 seconds, and focus/visibility cannot bypass that deadline.
24
+ - Desired topics are refcounted and replay after authentication. Operations are
25
+ serialized because acknowledgements carry only the topic and denial is a
26
+ generic uncorrelated `error`. Denial yields topic status `denied`; it does not
27
+ reconnect the socket. The server's automatic `user:<id>` topic is reported
28
+ separately and is never explicitly subscribed by the client. That automatic
29
+ topic is a delivery scope, not a presence feed.
30
+ - Framework messages, one wrapped `{type:'message',data,topic?,timestamp?}`
31
+ layer, and direct application events share one dispatch boundary. Malformed
32
+ frames drop. No generic Query invalidation is performed.
33
+
34
+ The transport does not invent application events. Current django-mojo has no
35
+ canonical record-created/updated/deleted stream and no canonical presence
36
+ stream, so neither record cache invalidation nor online status can be derived
37
+ from this client. `defineRealtimeEvent` and `useRealtimeTopic` are useful only
38
+ when a backend producer defines and authorizes that exact event/topic contract;
39
+ Assistant streaming is the package's current concrete application consumer.
40
+
41
+ ```tsx
42
+ // Hypothetical: this works only if the consuming backend publishes this
43
+ // application-specific event and authorizes the `orders` topic.
44
+ const orderChanged = defineRealtimeEvent('order_changed', (value) => {
45
+ if (!value || typeof value !== 'object') return null;
46
+ const id = (value as {id?: unknown}).id;
47
+ return typeof id === 'number' ? {id} : null;
48
+ });
49
+
50
+ function OrdersLive() {
51
+ const topic = useRealtimeTopic('orders');
52
+ useRealtimeEvent(orderChanged, ({data}) => console.log(data.id));
53
+ return <span>{topic}</span>;
54
+ }
55
+ ```
56
+
57
+ `createRealtimeMock()` is an adjacent deterministic in-memory server for demos
58
+ and behavioral verification. Its injected factory replaces no global
59
+ `WebSocket`; controls cover delayed challenge, auth failure left open, 4429,
60
+ topic denial, missing pong, wrapped/direct events, forced disconnect, and
61
+ multiple connections. Observations contain only bounded derived metadata.
@@ -0,0 +1,238 @@
1
+ # RecordFeed — record notes, assistant replies, and system history
2
+
3
+ ```tsx
4
+ import {
5
+ createTicketNoteAdapter,
6
+ createIncidentHistoryAdapter,
7
+ normalizeRecordFeedItem,
8
+ recordFeedQueryKey,
9
+ type RecordFeedAdapter,
10
+ type RecordFeedItem,
11
+ type RecordFeedPage,
12
+ } from 'portal-mojo/client';
13
+ import { RecordFeed } from 'portal-mojo/ui';
14
+ ```
15
+
16
+ `RecordFeed` is the shared display and cache substrate for ticket comments,
17
+ incident history, assistant-authored notes, status changes, and plain system
18
+ activity. It has two deliberately separate ownership modes:
19
+
20
+ - **Adapter mode** owns a TanStack list query, POST mutation, and exact
21
+ optimistic cache lifecycle.
22
+ - **Controlled mode** accepts `items` + `onSend`; a future streaming assistant
23
+ can own its own state without pretending a stream is a django-mojo list.
24
+
25
+ Adapter mode supports one optional completed File reference. Controlled mode
26
+ keeps its existing `items`/`onSend(text)` contract and has no attachment policy;
27
+ specialized owners may place the shared `AttachmentQueue` in `composerAddon`.
28
+
29
+ ## Adapter mode
30
+
31
+ ```tsx
32
+ const adapter = useMemo(
33
+ () => createTicketNoteAdapter(ticket.id, { groupId: ticket.group.id }),
34
+ [ticket.id, ticket.group.id],
35
+ );
36
+
37
+ <RecordFeed
38
+ adapter={adapter}
39
+ currentUser={{ id: me.id, name: me.display_name ?? me.username }}
40
+ currentUserId={me.id}
41
+ variant="compact"
42
+ attachmentUpload={{
43
+ destination: { groupId: ticket.group.id, use: 'uploads' },
44
+ expectedGroupId: ticket.group.id,
45
+ }}
46
+ />
47
+ ```
48
+
49
+ Use `createIncidentHistoryAdapter(incidentId, {groupId})` for incident
50
+ history. Memoize the adapter for ordinary React hygiene; its query key is
51
+ structural and remains stable either way.
52
+
53
+ ### Wire contract
54
+
55
+ Both adapters request only the latest window:
56
+
57
+ ```text
58
+ GET /api/incident/ticket/note
59
+ GET /api/incident/incident/history
60
+
61
+ parent=<record id>
62
+ group=<optional active group id>
63
+ graph=default
64
+ sort=-created
65
+ start=0
66
+ size=100
67
+ ```
68
+
69
+ django-mojo returns newest-first. The adapter normalizes those rows and
70
+ reverses the returned window for chronological display. Its result is:
71
+
72
+ ```ts
73
+ interface RecordFeedPage {
74
+ items: RecordFeedItem[]; // chronological
75
+ count: number; // full server count
76
+ hasEarlier: boolean; // count > returned rows
77
+ }
78
+ ```
79
+
80
+ POST uses the same collection endpoint and `{parent, group, note, media?}` when the
81
+ adapter has a group (`group` is the bare ForeignKey primary key django-mojo's
82
+ generic REST saver consumes). Incident posts also carry `kind: 'comment'`.
83
+ `media` is a single positive completed File id and never replaces required note
84
+ text. Failed saves reject at the shared client boundary.
85
+
86
+ Every incoming `media` value is positively rebuilt to exactly
87
+ `{id, filename, content_type, category}` before domain sanitization,
88
+ normalization, optimistic cache replacement, or rendering. Unknown fields,
89
+ URLs, tokens, manager/provider details, transfer state, and browser `File`
90
+ objects cannot enter RecordFeed state.
91
+
92
+ The attachment queue has capacity/concurrency one. It is record-local,
93
+ deduplicated, and exposes real progress/cancel/retry/recover/remove actions.
94
+ Only a completed reference may be sent. Removal detaches queue state; it never
95
+ deletes the File. Permission loss, logout, or any parent/group/destination
96
+ change cancels and disposes the old queue.
97
+
98
+ Grouped records use the authoritative parent group and immutable
99
+ `{groupId, use:'uploads'}` initiation selectors. Groupless records send no
100
+ selector. Parent payloads currently expose no exact FileManager upload
101
+ capability, so the server resolves its authorized manager; the client then
102
+ requires the completed lifecycle response to carry a positive manager id and
103
+ the exact expected group before enabling association. A future parent contract
104
+ may expose an explicit manager/readiness capability without changing the safe
105
+ File-reference shape.
106
+
107
+ The cache key is always:
108
+
109
+ ```ts
110
+ ['record-feed', 'ticket-note' | 'incident-history', parentId, groupId ?? null]
111
+ ```
112
+
113
+ That final group slot is not cosmetic. Two group contexts viewing the same
114
+ numeric record ID must never share a cache entry. Changing any adapter key
115
+ slot also remounts adapter mode so its composer draft, mutation/error state,
116
+ scroll position, and bottom-pin refs cannot leak into the next record.
117
+
118
+ ## Author/type normalization
119
+
120
+ `normalizeRecordFeedItem(raw)` applies one strict precedence:
121
+
122
+ 1. A **structured status change** with `metadata.type='status_change'` (or a
123
+ `metadata.status_change` object) and a structured old/new value becomes
124
+ `kind:'status'`.
125
+ 2. `[LLM Agent]`, `kind:'handler:llm'`, or an explicit LLM handler/origin
126
+ becomes `kind:'assistant'` — even if a modern row also embeds a user.
127
+ 3. Legacy null-user action/context records become assistant notes.
128
+ 4. Any remaining row with a user becomes `kind:'comment'`.
129
+ 5. Everything else becomes `kind:'system'`.
130
+
131
+ Two negative rules matter:
132
+
133
+ - A null user alone **never means assistant**. Most automated history has no
134
+ user and remains System.
135
+ - Prose is never regexed into a status transition. If old/new status is not
136
+ structured, the row follows the remaining precedence.
137
+
138
+ `metadata` is normalized to a plain object. `raw` remains wire-shaped, but it
139
+ may be domain-sanitized. Sensitive domains pass `sanitizeRow` and
140
+ `sanitizeText`; the unsanitized failure-restoration draft remains only in a
141
+ component-local ref and never enters MutationCache.
142
+
143
+ ## Exact optimistic lifecycle
144
+
145
+ Adapter mode permits one submit at a time and performs these operations on
146
+ the adapter's exact structural key:
147
+
148
+ 1. Trim the draft; ignore empty/duplicate pending submits.
149
+ 2. Clear the visible draft and cancel the exact in-flight query.
150
+ 3. Snapshot the exact `RecordFeedPage`.
151
+ 4. Append a client-only pending comment with a unique temporary ID.
152
+ 5. On success, replace that exact temporary row with the normalized server
153
+ row. The composer stays clear and regains focus.
154
+ 6. On a deterministic failure, restore the snapshot. If no cache existed, remove the exact
155
+ query instead of manufacturing stale state. Restore the exact submitted
156
+ text, focus the composer, and surface the mutation error in `role=alert`.
157
+ 7. On a lost/ambiguous response, retain the draft and completed candidate,
158
+ mark delivery uncertain, and block blind replay while an authoritative
159
+ refetch looks for a newly-created exact text/media row. A snapshot id can
160
+ never satisfy reconciliation. If refetch also fails, changing the draft or
161
+ detaching the candidate is the explicit escape.
162
+ 8. In all cases, invalidate only that exact record/group key.
163
+
164
+ The input is disabled during the request, so no second draft can be lost or
165
+ silently overwritten by failure restoration.
166
+
167
+ ## Controlled mode
168
+
169
+ ```tsx
170
+ <RecordFeed
171
+ items={messages}
172
+ onSend={sendMessage}
173
+ isSending={streamStarting}
174
+ pending={streaming ? <Thinking /> : null}
175
+ error={streamError?.message}
176
+ variant="bubbles"
177
+ />
178
+ ```
179
+
180
+ The parent owns item append/replacement/streaming. `RecordFeed` still owns the
181
+ composer draft: it clears before awaiting `onSend`, restores the exact text on
182
+ rejection, focuses after either outcome, prevents concurrent submits, and
183
+ surfaces local rejection through the same alert region.
184
+
185
+ ## Props
186
+
187
+ | Prop | Default | Meaning |
188
+ |---|---|---|
189
+ | `adapter` | — | Selects adapter mode. Mutually exclusive with `items/onSend`. |
190
+ | `items`, `onSend` | — | Select controlled mode. |
191
+ | `variant` | `'compact'` | `'compact'` admin activity or `'bubbles'` conversation layout. |
192
+ | `currentUserId` | — | Marks only matching **comments** as the current user's/right-side bubble. Assistant rows never become “mine.” |
193
+ | `currentUser` | `{id:currentUserId,name:'You'}` | Author stamped onto adapter-mode optimistic rows. |
194
+ | `renderAddon(item)` | — | ReactNode slot below a comment/assistant body for domain cards/actions. |
195
+ | `pending` | — | ReactNode after the list for thinking/streaming state. |
196
+ | `showInput` | `true` | Hide the composer for read-only history. |
197
+ | `disabled` | `false` | Disable sends while retaining history. |
198
+ | `attachmentUpload` | — | Adapter-only immutable destination and expected parent group; enables the singular queue. |
199
+ | `composerAddon` | — | Generic controlled-owner slot; RecordFeed itself assigns no attachment semantics. |
200
+ | `submitBlocked` | `false` | Blocks text submission while a specialized composer owner is not ready. |
201
+ | `placeholder` | `'Add a note…'` | Composer placeholder. |
202
+ | `sendLabel` | `'Send'` | Visible/accessibility send label. |
203
+ | `emptyLabel` | `'No activity yet.'` | Empty-state text. |
204
+ | `ariaLabel` | `'Record activity'` | Feed region label. |
205
+
206
+ ## Rendering, keyboard, and scrolling
207
+
208
+ - Comment and assistant bodies use existing `<MarkdownView renderer="client">`.
209
+ A feed never fans out one `/api/docit/render` request per row and never
210
+ creates another HTML path.
211
+ - Status and system events are React nodes/plain text, not markdown.
212
+ - The scroll viewport is `role=log` with a semantic ordered list and polite
213
+ addition/text announcements. Loading, pending, and failures have explicit
214
+ status/alert semantics.
215
+ - Enter sends; Shift+Enter inserts a newline. Composition Enter is ignored via
216
+ the native `isComposing` flag, so CJK/IME commits cannot accidentally send.
217
+ - The textarea grows to 144 px, then scrolls internally.
218
+ - The feed pins to bottom only on initial load, the viewer's own send, or an
219
+ update received while already within 48 px of bottom. Reading older rows is
220
+ never interrupted by background/streaming updates.
221
+
222
+ ## Styling
223
+
224
+ Import `theme/record-feed.css` after the token declarations (the portal and
225
+ showcase theme aggregators already do). It uses only the mission-control
226
+ tokens and supports both `data-theme` values. Override the default bounded
227
+ height with `--record-feed-height` on a wrapper/component class.
228
+
229
+ ## Pitfalls
230
+
231
+ - Do not use an endpoint-only query key; parent and group scope are required.
232
+ - Do not fetch ascending and take the first 100 — that returns the oldest
233
+ window. Fetch descending, take 100, then reverse the returned window.
234
+ - Do not read attachment data from `raw.media`; use the positively rebuilt
235
+ `item.attachment` reference.
236
+ - Do not classify every null-user row as assistant or parse status prose.
237
+ - Do not use server MarkdownView per row. Feeds use the secure React client
238
+ renderer; richer domain UI belongs in `renderAddon` as ReactNode.
@@ -0,0 +1,42 @@
1
+ # RightPanel — persistent complementary shell content
2
+
3
+ ```tsx
4
+ import { RightPanelProvider, RightPanelSlot, useRightPanel } from 'portal-mojo/ui';
5
+ ```
6
+
7
+ RightPanel is an explicit exception for product experiences that need
8
+ persistent, complementary shell context while the main route changes. It is
9
+ not the default Admin record-detail presentation: Admin table records use
10
+ `modal.detail(...)` unless the product specifically asks for a right panel.
11
+
12
+ Mount one provider above the opted-in application shell and one slot beside
13
+ the main route content. Open content with a stable key, visible title, render
14
+ callback, and the exact launcher element:
15
+
16
+ ```tsx
17
+ panel.open({
18
+ key: `map-layer:${layer.id}`,
19
+ title: layer.name,
20
+ render: ({ close }) => <MapLayerInspector id={layer.id} close={close} />,
21
+ }, event.currentTarget);
22
+ ```
23
+
24
+ The provider owns one session-only descriptor. Opening a different key replaces
25
+ it atomically; opening the same key is idempotent. Closing is idempotent and
26
+ restores focus when the captured launcher is still connected.
27
+
28
+ `RightPanelSlot` renders a non-modal `aside[role=complementary]` with a visible
29
+ close control and labelled heading. It moves focus into that control on open.
30
+ Escape closes the panel only when no native `dialog[open]` exists, so
31
+ `ModalHost` keeps ownership of its top-layer keyboard interaction. There is no
32
+ focus trap or backdrop: use `modal.drawer()` when the interaction must be
33
+ modal, awaitable, and transient.
34
+
35
+ Do not add RightPanel to the global Admin shell merely to inspect a selected
36
+ row. That changes the application layout and interaction model for every Admin
37
+ domain; such a choice requires an explicit product decision.
38
+
39
+ Panel selection never enters the hash or query string. Route navigation may
40
+ replace the main `<Outlet>` while provider state stays mounted, and table params
41
+ can never leak a `panel` key into Django filters. The supplied app styles use a
42
+ third grid column on wide layouts and a fixed non-modal aside on narrow ones.
@@ -0,0 +1,60 @@
1
+ # Tabs and FormWizard
2
+
3
+ Import both from `portal-mojo/ui`:
4
+
5
+ ```tsx
6
+ import { Tabs, FormWizard, formWizardModal } from 'portal-mojo/ui';
7
+ ```
8
+
9
+ ## Tabs
10
+
11
+ `Tabs` accepts `items: { key, label, panel, disabled?, ariaLabel? }[]`, optional controlled `activeKey`/`onActiveKeyChange`, uncontrolled `defaultActiveKey`, `variant`, `ariaLabel`, and `className`. Labels and panels are `ReactNode`.
12
+
13
+ The default variant is `underline-all`. Canonical variants are `minimal`, `traditional`, `underline`, `underline-all`, `pills`, `pills-solid`, `segmented`, and `btn-group`; `buttongroup` and `btngroup` alias `btn-group`. Unknown variants warn once and use the default.
14
+
15
+ In controlled mode, the caller owns the requested key. If it is missing or disabled, Tabs paints the first enabled key and notifies the caller once per requested-key/roster transition. Uncontrolled selection heals internally. Empty and duplicate keys are dropped with a warning; disabled tabs are never selected.
16
+
17
+ The tablist uses automatic activation: Left/Right wraps and skips disabled tabs; Home/End selects the first/last enabled tab. Stable `tab`/`tabpanel` IDs carry `aria-controls` and `aria-labelledby`, with one roving `tabIndex=0`. Every panel shell remains mounted, while only the active panel content mounts. Narrow tab lists scroll horizontally.
18
+
19
+ ```tsx
20
+ <Tabs
21
+ variant="segmented"
22
+ defaultActiveKey="profile"
23
+ items={[
24
+ { key: 'profile', label: 'Profile', panel: <Profile /> },
25
+ { key: 'audit', label: 'Audit', panel: <Audit />, disabled: !canAudit },
26
+ ]}
27
+ />
28
+ ```
29
+
30
+ ## FormWizard
31
+
32
+ Sections have `{ key, label, fields, description?, optional?, content?, eligible?, onNext?, terminal? }`. `content` may be a node or a render function over the shared draft/busy state. `eligible(data)` removes a step without destroying its draft. `onNext(data)` is an async single-flight transition after section validation. `terminal` renders an actionless receipt/status step. Section keys must be non-empty and unique, and field names must be globally unique. Later duplicates are warned and dropped.
33
+
34
+ Core props are `sections`, `initial`, `resetKey`, `mode: 'wizard' | 'tabs'`, `tabVariant`, button labels, `validateAllOnFinish`, `onStepChange`, `onCancel`, and `onFinish(data)`. Wizard mode has ordered Back/Next/Finish flow. Back does not validate; Next validates visible fields on the active step. Tabs mode provides non-linear navigation and one Save action. Finish validates all visible sections by default, opens the first invalid section, and focuses its first error. Set `validateAllOnFinish={false}` only when active-section validation is deliberately sufficient; the emitted payload still includes all visible fields.
35
+
36
+ Required/email checks run before an optional `Field.schema`. Hidden fields neither validate nor submit, but their draft remains available if shown again. `onFinish` is async and single-flight: navigation and actions disable while pending; rejection remains visible and retryable. A new `initial` object does not overwrite drafts. Change `resetKey` to intentionally reseed values/errors/active section. Roster changes preserve surviving values, seed additions, retire removals, and heal selection; pending finish defers reconciliation.
37
+
38
+ ```tsx
39
+ const save = User.useSave();
40
+ <FormWizard
41
+ mode="wizard"
42
+ sections={sections}
43
+ initial={row}
44
+ resetKey={row.id}
45
+ onFinish={(data) => save.mutateAsync({ id: row.id, changes: data })}
46
+ />
47
+ ```
48
+
49
+ `formWizardModal(options)` adds `title` and optional `size`, and returns the submitted `FormData` or `null` for idle cancellation. Its required `onFinish` runs before success closes. Next/Back/tabs/Cancel/Escape/backdrop are all locked during async transitions or finish. Reset/unmount generations make late completions stale, so they cannot advance, close, or rewrite a newer roster.
50
+
51
+ ```tsx
52
+ const result = await formWizardModal({
53
+ title: 'Create member',
54
+ sections,
55
+ mode: 'wizard',
56
+ onFinish: (data) => create.mutateAsync(data),
57
+ });
58
+ ```
59
+
60
+ `SchemaForm` shares the submit-state/rendering core but deliberately retains its lazy one-time initialization, flat payload, and required/email-only validation. `FormView` remains a separate autosave reducer; it uses Tabs only for presentation so permission filtering and one cross-tab autosave batch stay intact.
@@ -0,0 +1,120 @@
1
+ # TagInput — chip tags over a CSV string
2
+
3
+ ```ts
4
+ import { TagInput } from 'portal-mojo/ui';
5
+ ```
6
+
7
+ Free-text tags as removable chips with a full keyboard flow. Ported from
8
+ web-mojo's `TagInputView` (`src/core/forms/inputs/TagInput.js`).
9
+
10
+ **The value is a CSV string, both directions.** django-mojo models store the
11
+ joined string and split it server-side, so `onChange` hands back the string
12
+ FIRST and the array second. A parent that persists the array instead has
13
+ changed the wire shape.
14
+
15
+ ```tsx
16
+ const [csv, setCsv] = useState(record.tags ?? ''); // "prod,us-east-1,pci"
17
+
18
+ <TagInput
19
+ name="tags"
20
+ value={csv}
21
+ onChange={(next) => setCsv(next)}
22
+ maxTags={10}
23
+ />
24
+ ```
25
+
26
+ ## API
27
+
28
+ | Prop | Default | Notes |
29
+ |---|---|---|
30
+ | `name` | — | also renders a hidden input carrying the CSV, for native form posts |
31
+ | `value` | `''` | CSV string **or** array; both parse to the same tags |
32
+ | `onChange` | — | `(csv: string, tags: string[]) => void` — fires on commit/removal only |
33
+ | `placeholder` | `'Add tags...'` | |
34
+ | `maxTags` | `50` | caps interactive adds; the counter's denominator |
35
+ | `allowDuplicates` | `false` | duplicate check is CASE-SENSITIVE (`Design` ≠ `design`) |
36
+ | `separator` | `','` | exactly one character; joins the CSV, splits input, and commits on keypress |
37
+ | `trimTags` | `true` | |
38
+ | `minLength` / `maxLength` | `1` / `50` | per tag |
39
+ | `disabled` | `false` | field stays, every add/removal refused |
40
+ | `readonly` | `false` | chips only — no input, no remove icons |
41
+ | `className` | — | appended to the root |
42
+
43
+ ## Keyboard + commit routes
44
+
45
+ | Key | In the text field | On a focused chip |
46
+ |---|---|---|
47
+ | `Enter` | commit (preventDefault; an empty field still submits an enclosing form) | — |
48
+ | `Tab` | commit **and move focus on** | — |
49
+ | separator (`,`) | commit (always preventDefault) | — |
50
+ | `Backspace` | empty field → drop the last chip | remove it, focus the previous chip (or the field) |
51
+ | `Delete` | — | same as Backspace |
52
+ | `←` | empty field → focus the last chip | chip 0 → field, else previous chip |
53
+ | `→` | empty field → focus chip 0 | last chip → field, else next chip |
54
+ | `Escape` | clear the draft text | — |
55
+
56
+ Two more commit routes that are not keydowns:
57
+
58
+ - **input-change**: text ending in the separator or a newline commits. This is
59
+ what catches pastes and soft keyboards, which never fire a usable keydown.
60
+ - **blur**: a pending draft commits rather than vanishing (the house commit set
61
+ is select/Enter/blur).
62
+
63
+ Every route funnels through one `commitText`, which splits on the separator and
64
+ on newlines first — so pasting `a,b,c,` yields three tags, never one tag
65
+ containing commas that would re-split on the next load.
66
+
67
+ ## Invariants
68
+
69
+ 1. **No committed tag contains the separator.** Enforced at commit AND on parse
70
+ (array entries are split too). This is what keeps the CSV round-trip exact.
71
+ 2. **`separator` must be one character** — anything else falls back to `,` with
72
+ a `console.warn` (the house rule for unknown option values; an empty
73
+ separator would split every tag into characters).
74
+ 3. **Controlled.** Tags derive from `value` on every render — the chips can
75
+ never show something the parent's state doesn't hold. With no `onChange` (and
76
+ not `readonly`/`disabled`) the component warns once at mount instead of
77
+ silently swallowing adds.
78
+ 4. **Parsing `value` does NOT apply `minLength`/`maxLength`/`maxTags`.** Those
79
+ cap what a user may add; silently dropping stored values would lose backend
80
+ data on the next save. A record with 7 tags under `maxTags={5}` honestly
81
+ reads `7/5 tags` and refuses further adds.
82
+ 5. **Rejected adds are never silent** — a transient inline error (~3s,
83
+ `role="status"`) says why: `Tag "x" already exists`,
84
+ `Maximum N tags allowed`, or the length messages.
85
+
86
+ ## Deliberate deviations from web-mojo
87
+
88
+ | web-mojo | here | why |
89
+ |---|---|---|
90
+ | `Tab` calls `preventDefault` | Tab commits, focus still moves | the source trapped focus in the field for as long as it held text |
91
+ | commits on `,` even when `separator` is something else | commits on the separator key | otherwise a `\|`-separated field could never hold a comma |
92
+ | length violations rejected silently | inline error | "silent failure as policy" is on the do-not-recreate list |
93
+ | `Delete` promised by the chip's aria-label, only Backspace wired | both work | the label was lying |
94
+ | `Escape` also blurs | clears the draft only | Escape inside a modal still reaches the dialog once there is nothing to clear |
95
+ | wrapper `role="combobox" aria-expanded="false"` | `role="group"` | there is no popup; a permanently-false `aria-expanded` misleads AT |
96
+ | `escapeHtml()` on every tag before HTML-string render | plain JSX | React escapes; the "caller must escape" contract class is gone |
97
+ | `console.log` in `focusTag` | dropped | |
98
+ | `tag:added` / `tag:removed` / `tags:cleared` events | `onChange` only | it already carries the full next list |
99
+
100
+ ## Styling
101
+
102
+ Classes are styled by the consuming app (`apps/portal/src/theme/taginput.css`,
103
+ tokens only, light + dark): `.tag-input`, `.tag-input-wrap` (wears the `.input`
104
+ field look, accent ring on `:focus-within`), `.tag-chip` / `.tag-chip-text` /
105
+ `.tag-chip-x`, `.tag-input-field`, `.tag-input-foot` / `.tag-input-count` /
106
+ `.tag-input-error`, plus `.tag-input-disabled` / `.tag-input-readonly` on the
107
+ root. Chips show a focus ring on pointer focus too — they are only focusable to
108
+ drive the arrow/Backspace flow, and Backspace must never delete a chip whose
109
+ focus is invisible.
110
+
111
+ ## Pitfalls
112
+
113
+ - Don't store the `tags` array and rebuild the string with a different
114
+ separator on save — pass through the CSV the component emits.
115
+ - Clicking the wrapper's padding focuses the field without blurring it
116
+ (mousedown default is swallowed); don't add a competing focus handler.
117
+ - The remove `×` is `aria-hidden` on purpose: it duplicates the chip's own
118
+ keyboard removal, and nesting an interactive element inside `role="button"`
119
+ is invalid ARIA.
120
+ - Live demo: portal → **Develop → Components → TagInput**.