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,85 @@
1
+ # feedback — awaitable modals + toasts
2
+
3
+ ```ts
4
+ import { modal, formModal, toast, ModalHost, ToastHost } from 'portal-mojo/ui';
5
+ ```
6
+
7
+ Mount `<ModalHost />` and `<ToastHost />` once in the app shell. Modals are
8
+ native `<dialog>` — stacking, backdrop and Escape come free; every opener
9
+ is a Promise that resolves when its dialog closes.
10
+
11
+ ## modal
12
+
13
+ | Call | Resolves |
14
+ |---|---|
15
+ | `modal.confirm({title, message, confirmText, danger?})` | `boolean` |
16
+ | `modal.open<T>((close) => <JSX/>)` | `T \| null` — generic surface; call `close(value)` |
17
+ | `modal.detail((close) => <DetailView …/>)` | detail-sized shell for record views |
18
+ | `modal.drawer<T>({title, content\|render, …})` | `T \| null` — right slide-over (below) |
19
+ | `formModal({title, fields, initial?, submitText?, intro?})` | `FormData \| null` (see forms.md) |
20
+
21
+ Patterns:
22
+ - Danger confirms: `danger: true` styles the confirm button destructively.
23
+ - Batch prepare: open a `formModal` INSIDE a flow to collect once-per-batch
24
+ input; resolve null to cancel the whole thing.
25
+ - Stacked dialogs are fine (disable-reason form over a detail modal).
26
+
27
+ ## modal.drawer
28
+
29
+ ```ts
30
+ const picked = await modal.drawer<string>({
31
+ eyebrow: 'Source IP', title: '198.51.100.24',
32
+ meta: [{ icon: 'bi-clock', text: 'Last 30 days' }, '412 events'],
33
+ width: 'wide',
34
+ render: (close) => <button onClick={() => close('approved')}>Approve</button>,
35
+ });
36
+ ```
37
+
38
+ Right-anchored slide-over on the SAME `<dialog showModal>` substrate as the
39
+ rest of the manager — backdrop, Escape and stacking are native, so a drawer
40
+ opened from an open modal simply takes the top layer. Header shape (eyebrow ·
41
+ title · meta) is web-mojo's `Modal.drawer()` carried over.
42
+
43
+ | Option | Meaning |
44
+ |---|---|
45
+ | `title` / `eyebrow` / `meta` | Header: big line, uppercase tag above it, subtitle row (`string` or `{icon, text}`) |
46
+ | `content` | Static body ReactNode — ignored when `render` is given |
47
+ | `render(close)` | Body that owns the result; `close(value)` resolves the await |
48
+ | `width` | `480px` default · `'wide'` → `720px` · a number → px (min 280). Always clamped to the viewport (`min(w, 100%)`); full-bleed under 560px |
49
+ | `dismissable` | Default `true`. Governs Escape + backdrop + the header X together — no half-states |
50
+
51
+ - **Resolution:** `close(value)` → `value`; Escape / backdrop / X → `null`.
52
+ The promise settles AFTER the slide-out (~200ms), so `await` then toast
53
+ reads in the right order. First close wins; later ones are ignored.
54
+ - **Exit animation:** close adds `.mojo-drawer-closing`, the manager waits out
55
+ the keyframes, THEN drops the item (unmount calls `dialog.close()`). Under
56
+ `prefers-reduced-motion: reduce` the wait is 0 and the keyframes are off.
57
+ - **`dismissable: false` requires a `render`** that can close the drawer —
58
+ otherwise there is no exit at all, so it falls back to dismissable with a
59
+ `console.warn` (house rule: degenerate input → default + warn).
60
+ - An unknown `width` warns and falls back to 480px.
61
+ - Styles: `apps/portal/src/theme/drawer.css` (`.mojo-drawer`, `.drawer-panel`,
62
+ `.drawer-head/-eyebrow/-title/-meta`, `.drawer-body`) — tokens only, both
63
+ themes. `DRAWER_EXIT_MS` in `modal.tsx` must match the closing keyframes.
64
+
65
+ **Boundary — this drawer is TRANSIENT.** It is a modal, top-layer, awaitable
66
+ surface for drill-downs and one-off flows: it blocks the page, it resolves,
67
+ it is gone. The persistent shell right-panel slot (tickets / assistant) is a
68
+ different thing — a non-modal layout region that lives alongside content and
69
+ holds state across navigation. Do not build that with `modal.drawer`, and do
70
+ not grow `modal.drawer` toward it.
71
+
72
+ ## toast
73
+
74
+ `toast.success(msg)` / `.error(msg)` / `.info(msg)` / `.warning(msg)` —
75
+ bottom-right cards, max 5, 3.5s. Conventions:
76
+ - Server failures: toast the server's message verbatim
77
+ (`err instanceof Error ? err.message : 'Fallback'`).
78
+ - Batch partial results use `warning` (`"Disable: 3 succeeded, 2 failed"`).
79
+ - `response: 'payload'` actions toast the SERVER's payload message
80
+ (`outcome.body.message`), not client copy.
81
+
82
+ ## Pitfall
83
+
84
+ An open `<dialog>` pauses ModelTable's autoRefresh by design — don't build
85
+ alternative modal systems that bypass `<dialog>` or that interlock breaks.
@@ -0,0 +1,146 @@
1
+ # File upload UX
2
+
3
+ ```tsx
4
+ import {
5
+ AttachmentQueue, FileDropZone, FilePicker, UploadQueue, useUploadQueue,
6
+ validateFileSelection,
7
+ } from 'portal-mojo/ui';
8
+ ```
9
+
10
+ The UI layer composes the imperative transport documented in
11
+ [`client.md`](client.md). It does not replace the server contract: browser
12
+ validation is convenience only, and every accepted file still passes through
13
+ django-mojo's initiate → byte transfer → authoritative reconciliation flow.
14
+
15
+ Live examples: portal → Develop → Components → **File upload**. The Showcase
16
+ transport and progress fixture live only in its demo module; package consumers
17
+ use the real client by default.
18
+
19
+ ## Selection controls
20
+
21
+ ```tsx
22
+ const selected = ({ accepted, rejected }: FileSelectionResult) => {
23
+ queue.add(accepted);
24
+ rejected.forEach((item) => toast.warning(item.message));
25
+ };
26
+
27
+ <FileDropZone
28
+ accept={['image/*', '.pdf']}
29
+ maxFileSize={10_000_000}
30
+ maxFiles={4}
31
+ onSelection={selected}
32
+ />
33
+
34
+ <FilePicker accept=".csv,text/csv" multiple onSelection={selected}>
35
+ Import CSV
36
+ </FilePicker>
37
+ ```
38
+
39
+ `validateFileSelection(files, rules)` is pure. It returns ordered `accepted`
40
+ and `rejected` arrays and never uploads. `accept` supports exact MIME types,
41
+ MIME wildcards, and extensions. `maxFileSize` is bytes; `maxFiles` applies to
42
+ one picker/drop gesture.
43
+
44
+ Both controls are native buttons. The hidden input is a sibling, so the DOM
45
+ does not nest interactive controls. The picker clears its input after every
46
+ change, allowing the same file to be chosen twice in separate gestures.
47
+ `FileDropZone` also installs a reference-counted window guard while mounted:
48
+ file drags cannot navigate the tab when dropped outside the zone, while text,
49
+ links, and other non-file drags retain normal browser behavior.
50
+
51
+ `useFileDrop(options)` exposes the same behavior for a custom native control:
52
+
53
+ ```tsx
54
+ const { isDragActive, dropProps } = useFileDrop({ onSelection: selected });
55
+ return <button type="button" {...dropProps}>{isDragActive ? 'Drop' : 'Attach'}</button>;
56
+ ```
57
+
58
+ ## Queue
59
+
60
+ ```tsx
61
+ const queue = useUploadQueue({
62
+ consumerKey: 'ticket-attachments',
63
+ destination: { use: 'support', groupId },
64
+ async onComplete(file, { signal }) {
65
+ await attachFileToTicket(file.id, { signal });
66
+ },
67
+ });
68
+
69
+ <FileDropZone onSelection={({ accepted }) => queue.add(accepted)} />
70
+ <UploadQueue queue={queue} />
71
+ ```
72
+
73
+ Defaults are concurrency **3** and capacity **6**. Files enter a FIFO and the
74
+ transport task is not created until a slot opens; selecting six files never
75
+ starts six initiate requests. The queue belongs to the mounted component—no
76
+ local/session persistence and no TanStack Query cache. StrictMode's probe
77
+ unmount is tolerated, but a real unmount aborts tasks and callbacks, removes
78
+ progress cards, unsubscribes observers, and drops retained `File` references.
79
+ An authenticated queue also disposes immediately if its auth UID changes or
80
+ the session signs out.
81
+
82
+ The queue deduplicates on consumer + normalized destination + file metadata.
83
+ Use a stable `consumerKey` for the owning form/view. `destination` accepts
84
+ `fileManagerId`, `groupId`, and the django-mojo `use` selector. Two consumers
85
+ or two destinations may intentionally queue the same local file.
86
+
87
+ The public snapshot is stable between transitions and deliberately inert. It
88
+ contains safe filename/type/size, byte counts, status, sanitized failures, and
89
+ safe completed File references. It never contains browser `File` objects,
90
+ transport tasks, callbacks, capabilities, abort controllers, toast handles,
91
+ raw errors, or auth data.
92
+
93
+ `AttachmentQueue` is the shared reference-only composition used by record
94
+ notes and Assistant chat. It adds picker/drop UI to the same queue and emits
95
+ only completed `{id,filename,content_type,category}` references after checking
96
+ the authoritative lifecycle result has a positive manager id and the exact
97
+ expected group. It never emits browser Files, deletes a File, or turns a failed
98
+ or uncertain upload into an attachable reference.
99
+
100
+ Admin Files composes this primitive in `FileUploadSurface`: Add File and a
101
+ drag-only whole-page overlay share an explicit policy-backed destination modal,
102
+ while the queue remains mounted outside it. The surface fabricates neither
103
+ progress nor reconciliation timers.
104
+
105
+ ## Outcome truth and actions
106
+
107
+ - **Cancel** is a request. An active item becomes `cancelling`; it becomes
108
+ `uncertain` if django-mojo cannot prove the remote state. The UI never calls
109
+ an initiated transfer “cancelled” merely because `AbortController` fired.
110
+ - **Retry** explicitly replays a retryable failed/id-less-uncertain task.
111
+ - **Recover** appears only when the task has a known File id and invokes the
112
+ transport's reconciliation-only recovery—no duplicate byte transfer.
113
+ - **Finalizing** begins after upload truth is `completed` and while
114
+ `onComplete` runs. It is non-cancellable and releases the transfer slot.
115
+ - A rejected `onComplete` is `completed-warning`, not upload failure. “Retry
116
+ follow-up” reruns only the callback using the already-completed safe File
117
+ reference; it never replays the bytes.
118
+ - Remove is immediate for queued/settled items. Removing an active item first
119
+ requests cancellation and only forgets it after transport truth settles.
120
+
121
+ Every active attempt owns exactly one `toast.progress` card. Its displayed
122
+ percentage changes only when real byte events cross an integer percentage.
123
+ Reconciliation/callback work uses the additive non-cancellable `finalizing`
124
+ state, then settles to success, failure, or completed-with-warning.
125
+
126
+ ## Headless use and verification
127
+
128
+ `createUploadQueue()` exposes the same external-store controller for code that
129
+ does not need the hook. `UploadQueue` is presentational and accepts any
130
+ controller with the documented snapshot/actions, which also keeps Showcase
131
+ fixtures out of production transport code.
132
+
133
+ Run the focused executable contract:
134
+
135
+ ```bash
136
+ npm run verify:upload-ux
137
+ npm run verify:form-uploads
138
+ npm run verify:record-attachments
139
+ ```
140
+
141
+ It covers pure validation, lazy concurrency, capacity, stable/safe snapshots,
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
145
+ attach states, owner-response reconciliation, orphan retention, safe previews,
146
+ avatar #1488 semantics, and cache-boundary sanitization.
package/docs/forms.md ADDED
@@ -0,0 +1,435 @@
1
+ # forms — the Field language, SchemaForm, formModal, FormView (autosave)
2
+
3
+ ```ts
4
+ import {
5
+ SchemaForm, SchemaSelect, formModal,
6
+ FormView, FileField, ImageField, registerFormTabs, getFormTabs,
7
+ type Field, type FormData, type FormTab,
8
+ } from 'portal-mojo/ui';
9
+ import { useFormAutosave, resolveShowWhen } from 'portal-mojo/ui'; // the machine, standalone
10
+ // Field/FormData/ModelForm/FieldValues/ShowWhen TYPES also export from
11
+ // 'portal-mojo/client' — model definitions carry form configs as plain data.
12
+ ```
13
+
14
+ Two surfaces over ONE field language:
15
+
16
+ - **SchemaForm / formModal** — submit-style (create dialogs, action forms).
17
+ - **FormView** — the inline-autosave surface bound to a server row via
18
+ `defineModel`. **No save buttons**; edits save themselves.
19
+
20
+ ## The `Field` language (data, not JSX)
21
+
22
+ ```ts
23
+ interface Field {
24
+ name: string; // wire key; DOTTED names read/write nested dicts
25
+ type: FieldType; // 6 builtins + every registry type (below) + (string & {})
26
+ label: string;
27
+ placeholder?: string;
28
+ required?: boolean; // SchemaForm submit validation
29
+ help?: string;
30
+ columns?: 6 | 12; // grid halves / full row
31
+ options?: FieldOption[]; // select/multiselect/combo: {value, label, description?, disabled?}
32
+ showWhen?: ShowWhen; // conditional visibility (both surfaces)
33
+ schema?: ZodType; // per-field zod validation (FormView commits)
34
+ disabled?: boolean; // registry types
35
+ accept?: string | string[]; maxFileSize?: number; // file/image selection UX
36
+ uploadDestination?: { fileManagerId?; groupId?; use? };
37
+ onUploadOrphan?: (fileId: number) => void;
38
+ // …plus the registry-type props (precision, outputFormat, presets,
39
+ // model/endpoint, timezone, maxTags, …) — see "Field-type registry".
40
+ }
41
+ type FormData = Record<string, FieldValue>; // SchemaForm values
42
+ type FieldValue = string | number | boolean | null | Array<string | number>;
43
+ type FieldValues = Record<string, FieldValue>; // FormView values
44
+ type ShowWhen = { field; value; negate? } | ((values: FieldValues) => boolean);
45
+ ```
46
+
47
+ **Builtins** — rendered inline by both surfaces, byte-for-byte the pre-B4
48
+ behavior: `text` `email` `tel` `select` `switch` `textarea`. Every other
49
+ type resolves through the **field-type registry** (below).
50
+
51
+ `ModelForm = { title, fields, submitText? }` — the shape `defineModel`
52
+ carries (`UserModel.forms.create`) and `formModal` renders.
53
+
54
+ ### showWhen (both surfaces, one pipeline: `resolveShowWhen`)
55
+
56
+ Declarative rule = web-mojo FormBuilder semantics: the controlling field's
57
+ current value is `String()`-coerced and matched against `value`
58
+ (scalar or list); `negate` flips. Or pass a predicate over the live values.
59
+ **Hidden fields don't submit/save, and their pending edits + errors clear.**
60
+
61
+ ### Per-field zod (`schema`)
62
+
63
+ Validated at COMMIT against the display value (string for text-ish types,
64
+ boolean for switch). A failing parse **blocks the save of that field only** —
65
+ the first issue's message shows in the field's error slot; other fields keep
66
+ saving. The server stays authoritative: values zod allows can still be
67
+ rejected server-side (and then revert — below).
68
+
69
+ ## SchemaForm
70
+
71
+ `<SchemaForm fields initial? submitText? onSubmit onCancel? />` — fully
72
+ controlled; validates required + email format on visible fields; async
73
+ `onSubmit` gets a busy state and surfaces thrown errors as the form alert.
74
+ showWhen-hidden fields don't render, don't validate, and are stripped from
75
+ the submitted payload.
76
+
77
+ When file/image fields are present, submit, cancel, wizard steps/tabs, and
78
+ modal dismissal lock while bytes are active. `onSubmit`/`onFinish` must return
79
+ the authoritative saved owner row so the field can compare its returned
80
+ relation to the requested id/null. A rejection or mismatch keeps the completed
81
+ candidate available for attachment-only retry; it never replays bytes.
82
+
83
+ **Select semantics (the rule-4 fix):** display always equals state. While
84
+ the value is `''` (unset) the select shows a real disabled placeholder; an
85
+ initial value not among the options warns once and shows the placeholder.
86
+ `SchemaSelect` is exported — FormView renders selects through the same
87
+ component. One select implementation, one value pipeline.
88
+
89
+ ## formModal
90
+
91
+ ```ts
92
+ const data = await formModal({ ...UserModel.forms.create!, initial?, intro? });
93
+ if (!data) return; // cancelled → null
94
+ ```
95
+
96
+ ## FormView — inline autosave
97
+
98
+ ```tsx
99
+ <FormView
100
+ model={UserModel} // Pick<ModelDef, 'useSave'> — the batch POST
101
+ row={user} // current server row (snapshot + revert target)
102
+ fields={PROFILE_FIELDS} // flat fields, and/or:
103
+ tabs="user.permissions" // registry name — or inline FormTab[]
104
+ debounceMs={300} // batch window (default 300)
105
+ savedFlashMs={1500} // saved-check duration (default 1500)
106
+ onSaved={({ changes, fields, row }) => …}
107
+ onSaveError={({ changes, fields, error }) => …}
108
+ onPendingChange={(uploading) => setDetailDismissLocked(uploading)}
109
+ />
110
+ ```
111
+
112
+ ### The autosave lifecycle (the machine: `useFormAutosave`)
113
+
114
+ 1. **Commit, never keystroke.** Text/textarea update the draft per keystroke
115
+ (dirty dot) but COMMIT on blur — text also on Enter. Selects and switches
116
+ commit on change. This is by construction: web-mojo's per-keystroke
117
+ `input` autosave saved partial text (the ComboBox "Amer" leak).
118
+ 2. **300ms batch.** Each accepted commit (re)arms one window; commits inside
119
+ it coalesce. The batch POSTs **one save with only the changed fields**
120
+ (change-detection against the server snapshot: trimmed-string compare,
121
+ strict boolean for switches — `42` vs `'42'` and `null` vs `''` are not
122
+ changes).
123
+ 3. **Serialized.** While a POST is in flight, further commits queue the next
124
+ batch; completion re-arms the window. Never two saves interleaved for one
125
+ form. (web-mojo's `isSaving` guard silently dropped the queue — fixed.)
126
+ 4. **Indicators** ride the label row: dirty dot → spinner (queued + in
127
+ flight) → saved check (~1.5s) → idle. An **error pins** on the field with
128
+ the zod/server message until that field's next successful save.
129
+ 5. **Revert on fail.** A rejected batch restores every batch field to the
130
+ **server snapshot** and toasts the server's message verbatim — a failed
131
+ value never sticks (`Model.save` resolve-on-failure heritage ends here;
132
+ `useSave` REJECTS).
133
+ 6. **Reconcile.** The success row (and any `row` prop change — refetch,
134
+ another form's save) becomes the new snapshot; untouched fields follow it.
135
+ Server normalization is visible: save `555 010 0142`, the field comes back
136
+ `+15550100142`.
137
+
138
+ ### `file` / `image` relations
139
+
140
+ Both registry bindings use the exported controlled `FileField`/`ImageField`
141
+ over the one shared `UploadQueue` + client transport. Their public value is
142
+ only a positive numeric File id or `null`; expanded relation objects normalize
143
+ to their positive numeric id before generic string coercion, while booleans,
144
+ numeric strings, non-positive numbers, and ambiguous objects normalize to
145
+ null and are never posted as File ids.
146
+
147
+ The explicit UI states are keep, clear, replacement-in-progress,
148
+ replacement-failed, completed-awaiting-attach, and attach-failed. The stored
149
+ relation remains the visible/revert baseline until upload completion and an
150
+ authoritative owner save both succeed. FormView receives the exact sent File
151
+ value plus saved row from its autosave mutation and reconciles only that File
152
+ field: a result for an older candidate is ignored, while an exact returned
153
+ id/null means attached; reject or mismatch restores the prior server value but
154
+ retains the completed candidate for “Retry attachment.” Remove reports the
155
+ candidate once through `onUploadOrphan`; Clear posts null. Neither action
156
+ deletes File.
157
+
158
+ ImageField previews a newly selected image with a component-local object URL
159
+ and revokes it on replace/settle/unmount. Stored preview URLs are resolved by
160
+ an imperative mounted File detail read, scheme-checked, rendered with
161
+ `referrerPolicy="no-referrer"`, and never cached, persisted, logged, or copied
162
+ into errors. Queue work is generation-guarded, cancelled on unmount/auth loss,
163
+ and an active task is cancelled before removal.
164
+
165
+ Image editing is opt-in. Direct use passes `edit` to `ImageField`; schema data
166
+ passes `imageEdit` on an `image` field. The value is `true` for defaults or the
167
+ `imageEditorModal` options (`startMode`, `modes`, `crop`, `maxHistory`, and
168
+ labels). `requireEdit` (schema: `imageEditRequired`) removes the Use original
169
+ escape hatch while still retaining the selection for retry/discard. The editor opens before upload initiation. Cancel and decode/encode
170
+ failure retain the selected original locally with Edit/Use original/Discard
171
+ choices; no File row, relation id, or owner save exists yet. Save converts the
172
+ explicit PNG Blob into the exact File fed to the existing queue. Editing joins
173
+ transfer in `onPendingChange`, so owning forms and dialogs keep their existing
174
+ dismiss/submit lock.
175
+
176
+ ```ts
177
+ {
178
+ name: 'avatar', type: 'image', label: 'Avatar',
179
+ imageEdit: {
180
+ startMode: 'crop',
181
+ crop: { aspectRatio: 1, cropAndScale: { width: 200, height: 200 } },
182
+ },
183
+ imageEditRequired: true,
184
+ }
185
+ ```
186
+
187
+ All machine state lives in one reducer (draft, server snapshot, per-field
188
+ status, pending batch, in-flight flag); the two timers only dispatch.
189
+
190
+ ### Dotted names — the nested-value wire contract
191
+
192
+ `name: 'permissions.manage_users'` reads `row.permissions.manage_users`
193
+ (loose truthy: `true` or `1`) and saves as a **partial dict**:
194
+ `{permissions: {manage_users: true}}`. django-mojo **merges** dict bodies
195
+ into JSONFields (`rest.py on_rest_update_jsonfield` → `objict.merge_dicts`)
196
+ — never send the whole dict, never fear clobbering sibling keys. The mock
197
+ mirrors the merge exactly.
198
+
199
+ ### Permission tabsets — a registry, not mutated arrays
200
+
201
+ ```ts
202
+ registerFormTabs('user.permissions', [
203
+ { key: 'standard', label: 'Standard', fields: [...] },
204
+ { key: 'users', label: 'Users', permissions: ['users', 'manage_users'], fields: [...] },
205
+ ]);
206
+ ```
207
+
208
+ - Registering the same name again **appends new keys / replaces same keys**
209
+ (an app adds its tab beside the framework's — web-mojo
210
+ `registerPermissions` semantics without `arr.length = 0; arr.push(…)`).
211
+ - `getFormTabs(name)` returns a snapshot copy; mounted FormViews re-render
212
+ on registration (subscription).
213
+ - Tab `permissions` resolve **fail-closed like `<Guarded>`** against `me` +
214
+ the active-group member (`hasPermission`): hidden while loading, while
215
+ anonymous, and on failure. The active tab self-heals to the first visible.
216
+ - A tab whose field holds a pinned error shows a red dot.
217
+
218
+ The machine spans ALL tabs' fields — a batch may carry fields from several
219
+ tabs; switching tabs loses nothing.
220
+
221
+ ## Field-type registry (board #1278)
222
+
223
+ ```ts
224
+ import {
225
+ registerFieldType, resolveFieldRenderer, registeredFieldTypes,
226
+ warnUnknownFieldType, // the shared unknown-type warn
227
+ fieldToWire, wireToField, // the epoch boundary (field-wire)
228
+ emptyFieldValue, fieldPrecision,
229
+ type RegistryFieldProps, type FieldTypeRenderer,
230
+ } from 'portal-mojo/ui';
231
+ ```
232
+
233
+ `ui/field-registry` maps every non-builtin `Field.type` string to a renderer
234
+ that binds the Field def + the form's controlled value/commit pipeline to
235
+ the real component — web-mojo's `INPUT_TYPES`/`createInput` +
236
+ FormBuilder's field-type switch, carried as a registry (also the FormPlugins
237
+ replacement: apps extend with `registerFieldType`). **Both surfaces consult
238
+ it**: SchemaForm and FormView render any registered type from the same
239
+ schema, with the same commit semantics (every registry component fires on
240
+ COMMIT, so each change is one autosave gesture).
241
+
242
+ ### The registry API
243
+
244
+ ```ts
245
+ // Renderers are function components — they may hold hooks/state.
246
+ type FieldTypeRenderer = ComponentType<RegistryFieldProps>;
247
+ interface RegistryFieldProps {
248
+ field: Field;
249
+ value: FieldValue; // form-state value (WIRE-shaped — below)
250
+ invalid?: boolean; // paint the error border (text is the surface's)
251
+ disabled?: boolean;
252
+ commit: (value: FieldValue) => void; // THE change pipeline, once per commit
253
+ commitPatch: (patch: FieldValues) => void; // one declared-field transaction
254
+ }
255
+ registerFieldType('rating', RatingField); // add / replace (HMR-safe)
256
+ registerFieldType(['a', 'b'], SharedField); // aliases share one renderer
257
+ resolveFieldRenderer('rating'); // renderer | null
258
+ registeredFieldTypes(); // names, registration order
259
+ ```
260
+
261
+ Names match **exactly** (no case folding): a typo'd type must fall through
262
+ to the loud fallback, not silently half-match.
263
+
264
+ **Unknown-type rule** (house rule 4): a type with no renderer renders a
265
+ **text input** and `console.warn`s ONCE per (field, type) — never "render
266
+ nothing". Both surfaces enforce it (`warnUnknownFieldType` carries the
267
+ shared warn-once set).
268
+
269
+ ### The value-shape table
270
+
271
+ **State holds the WIRE shape.** The registry renderer converts at its own
272
+ edge — render through `wireToField(field, value)`, commit through
273
+ `fieldToWire(field, value)` — so SchemaForm's submit payload and FormView's
274
+ autosave batches carry wire-ready values without either surface knowing
275
+ dates exist. This is the ONE conversion point.
276
+
277
+ | `Field.type` (aliases) | Component | Control (what the picker holds) | State = wire (what the save body carries) | Empty |
278
+ |---|---|---|---|---|
279
+ | `tag`, `tags` | TagInput | chip list | **CSV string** `'a,b'` (django-mojo models split it) | `''` |
280
+ | `multiselect` | MultiSelectDropdown | checked options | **array** of option values | `[]` |
281
+ | `collection` | CollectionSelect | one picked row | the row's **id** (`string \| number`); `null` clears | `null` |
282
+ | `collectionmultiselect`, `collection-multiselect` | CollectionMultiSelect | checked rows | **array of ids** | `[]` |
283
+ | `combo`, `combobox`, `autocomplete` | ComboBox | committed option/text | the committed value (`string \| number`; free text iff `allowCustom`) | `''` |
284
+ | `address` | AddressField | private draft + provider suggestions | raw committed address, or one atomic mapped details patch | `''` |
285
+ | `datepicker`, `monthpicker`, `yearpicker` | DatePicker | canonical `YYYY-MM-DD` / `YYYY-MM` / `YYYY` | the shape it read (see below); **epoch seconds** at UTC midnight by default; `outputFormat` overrides | `null` |
286
+ | `daterange`, `monthrange`, `yearrange` | DateRangePicker | canonical `[start, end]` pair | as above, per element — **`[startEpoch, endEpoch]`** by default | `null` |
287
+ | `timepicker` | TimePicker (+ the REAL TimezoneSelect in its slot) | HH:MM (+ zone) | serialized **string** — `'iso'` (default) `'14:30-07:00'`, `'iana'` `'14:30 America/…'`; times are never epochs | `''` |
288
+ | `datetimepicker` | DateTimePicker | `'YYYY-MM-DD HH:MM'` local | the shape it read; **epoch seconds** (exact instant) by default, ISO when the column spoke ISO | `null` |
289
+ | `timezone` | TimezoneSelect | IANA zone | the IANA **string** | `''` (picker displays the local zone; `resolveTimezone` computes that effective default when a form must post it) |
290
+
291
+ Alias precision mapping (web-mojo `PRECISION_ALIASES`): `monthpicker`/
292
+ `monthrange` → `'month'`, `yearpicker`/`yearrange` → `'year'`; an
293
+ **explicit `Field.precision` wins** over the alias (`createInput` parity).
294
+
295
+ Registry-type Field props (all optional, additive): `precision`,
296
+ `outputFormat`, `displayFormat`, `min`/`max`, `presets`, `months`,
297
+ `separator` (tag CSV joiner / daterange display), `timeFormat`, `step`,
298
+ `timezone`/`timezones`, `model`/`endpoint`, `labelField`/`valueField`,
299
+ `maxItems`, `emptyFetch`, `debounceMs`, `requiresActiveGroup`,
300
+ `defaultParams`, `enableSearch`, `maxTags`, `allowDuplicates`,
301
+ `allowCustom`, `showDescription`, `maxSuggestions`, `addressFields`,
302
+ `country`, `minChars`, `disabled`. Component
303
+ knobs a Field doesn't carry (locale, firstDay, disabledDates, …) are
304
+ reachable only by using the component directly.
305
+
306
+ ### The epoch-seconds save boundary (`ui/field-wire`)
307
+
308
+ The measured django-mojo contract (serializer.py:380-389 +
309
+ rest.py:1888-1892 / `dates.parse_datetime`): **DateTimeField serializes OUT
310
+ as epoch seconds** and the save path parses epochs back in; DateField
311
+ serializes OUT as `'YYYY-MM-DD'` but its save path ALSO accepts epochs
312
+ (naive/UTC-parsed — a UTC-midnight epoch lands on the same day). Hence:
313
+
314
+ - `wireToField(field, raw)` — accepts **every** shape django-mojo emits
315
+ (epoch seconds, epoch **milliseconds**, either of those as numeric
316
+ strings, `'YYYY-MM-DD'`, full ISO, `Date`), so rows mixing `last_login`
317
+ epochs and `dob` strings flow through one path. Detection is
318
+ `date/fns detectTemporal`; unparseable stored values warn once and read
319
+ as empty.
320
+ - `fieldToWire(field, control)` — **answers in the shape the field last
321
+ read**. A column that arrived as `'YYYY-MM-DD'` saves back as
322
+ `'YYYY-MM-DD'`; one that arrived as ISO saves ISO; one that arrived as
323
+ epoch milliseconds saves milliseconds; everything else (and any field
324
+ never read — a fresh record) saves **epoch seconds**, the DateTimeField
325
+ contract. Canonical strings become UTC midnight for date-only
326
+ precisions; datetimes honor an explicit offset/IANA zone, else the
327
+ browser's local wall time.
328
+
329
+ This auto-detection exists because the wire shape is per-COLUMN, not
330
+ global: emitting epochs for everything silently rewrote a DateField's
331
+ type on every save and turned ISO metadata values into numbers.
332
+ `Field.outputFormat` (`'epoch' | 'date' | 'iso'`) remains the explicit
333
+ override and always wins — set it when a column's shape must not be
334
+ inferred. The remembered shape is keyed by the Field **object**, so
335
+ module-level schema constants (the normal case) carry it correctly and
336
+ two forms sharing a field name never collide.
337
+ - **`dr_field/dr_start/dr_end` filter params are NOT this boundary** — they
338
+ stay `YYYY-MM-DD` strings and belong to FilterBar/params (rest.py parses
339
+ dr_* itself). Untouched.
340
+
341
+ Every other type passes through both functions unchanged — its control
342
+ shape IS its wire shape, per the table.
343
+
344
+ ### Kitchen sink
345
+
346
+ The playground's **Field registry kitchen sink** section renders EVERY
347
+ registered type (all aliases) from one schema on both surfaces — FormView
348
+ autosave against the mock user (saves land in `metadata.*`, live
349
+ control-vs-wire panel per field) and SchemaForm submit (exact payload
350
+ shown) — plus the explicit-precision override, `outputFormat:'date'`, the
351
+ #1273 seam, and the deliberate unknown type.
352
+ (`apps/portal/src/pages/components/demos-kitchen-sink.tsx`)
353
+
354
+ ## Pitfalls
355
+
356
+ `SchemaForm` and `FormWizard` share one controlled submit-state/rendering core. SchemaForm intentionally keeps its original lazy initialization and required/email-only submit contract; FormWizard adds cross-section validation, focus, reset, and roster reconciliation. `FormView` does **not** use that submit store: it retains the one autosave reducer across flat fields and all tabs. See [tabs-and-form-wizard.md](tabs-and-form-wizard.md).
357
+
358
+ - Never uncontrolled inputs, never a second value pipeline — the
359
+ buttongroup/checklistdropdown bug class stays impossible only while this
360
+ holds.
361
+ - Phone fields: django-mojo runs `set_phone_number` on save and wants E.164
362
+ (`+15555550142`); pretty/short formats REJECT with
363
+ `Invalid phone number: <value>` — surface it verbatim (FormView does).
364
+ The mock enforces the same rule.
365
+ - Keep the client `schema` **looser or equal** to the server: client zod is
366
+ UX, the reject/revert path is the safety net — don't try to out-validate
367
+ the backend.
368
+ - FormView needs a LOADED row — gate on `useOne`'s `isPending` first.
369
+ - The rich field set (collection pickers, tags, dates, …) lives in the
370
+ field-type registry above — extend THIS language via `registerFieldType`;
371
+ don't fork it. New renderers must fire on COMMIT only and keep state
372
+ wire-shaped (convert in the renderer, like the date bindings do).
373
+ - `commitPatch` is for one provider-driven gesture, such as address details.
374
+ Both form surfaces filter it to declared field names. FormView validates
375
+ and queues the whole accepted patch as one batch, so server failure rolls
376
+ every member back together.
377
+ - `daterange` here is ONE field name whose value is a `[start, end]` pair —
378
+ NOT web-mojo's `startName`/`endName` twin wire keys. A model with two
379
+ separate date COLUMNS should use two `datepicker` fields.
380
+ - `timepicker`'s `outputFormat:'object'` (component-level) is not a form
381
+ shape — form state is JSON-scalar/array; the registry always serializes
382
+ to the string forms.
383
+
384
+ ## Simple-types parity audit
385
+
386
+ Every `case` in web-mojo `FormBuilder.js`'s field-type switch (the ~45-type
387
+ FieldTypes list), vs this package. **Gaps are recorded, not built** — the
388
+ orchestrator files follow-ups.
389
+
390
+ | FormBuilder case | SchemaForm/FormView | Registry | Status / note |
391
+ |---|---|---|---|
392
+ | `text` | builtin | — | OK |
393
+ | `email` | builtin (+ format validation) | — | OK |
394
+ | `password` | — | — | **GAP** — C3 auth pages own password UX (strength/generator, board #1259) |
395
+ | `number` | — | — | **GAP** — obvious next builtin (min/max/step props already exist on Field) |
396
+ | `tel` | builtin | — | OK |
397
+ | `url` | — | — | **GAP** — trivial builtin (text + `type="url"`) |
398
+ | `search` | — | — | **GAP** as a FORM field; the live-search UX exists as the params-store search (ModelTable) |
399
+ | `hex` | — | — | **GAP** — pattern-validated text; zod `schema` covers it today |
400
+ | `textarea` | builtin | — | OK |
401
+ | `htmlpreview` | — | — | **GAP** — sweep verdict: PATTERN (iframe preview), rebuild small when needed |
402
+ | `json` | — | — | **GAP** — textarea + zod covers today; DataView/JSON viewer (#1303) is the read side |
403
+ | `select` | builtin (SchemaSelect) | — | OK (placeholder + unknown-value warn beat source) |
404
+ | `multiselect` | — | MultiSelectDropdown | OK |
405
+ | `checkbox` | — | — | **GAP** — portal has `switch` only; plain-checkbox look absent (use `switch`) |
406
+ | `toggle` / `switch` | builtin `switch` | — | OK |
407
+ | `radio` | — | — | **GAP** — use `select`; radio-group component unfiled |
408
+ | `date` (native input) | — | — | superseded by `datepicker` (registry) — native input not carried |
409
+ | `datetime` (native) | — | — | superseded by `datetimepicker` |
410
+ | `time` (native) | — | — | superseded by `timepicker` |
411
+ | `file` | — | FileField | OK — positive File id/null; shared 3-stage queue; authoritative owner attach |
412
+ | `image` | — | ImageField | OK — FileField foundation + safe local/stored image preview |
413
+ | `color` | — | — | **GAP** — native `<input type=color>` wrapper unfiled |
414
+ | `range` | — | — | **GAP** — native `<input type=range>` wrapper unfiled |
415
+ | `hidden` | — | — | N/A by design — controlled forms post state, not hidden DOM inputs |
416
+ | `button` | — | — | N/A by design — SchemaForm owns submit/cancel; FormView has no buttons |
417
+ | `divider` | — | — | **GAP** — layout-only field (FormWizard/Tabs #1305 is the structure story) |
418
+ | `html` | — | — | N/A by design — trusted-HTML slots became ReactNode (architecture rule 6) |
419
+ | `heading` / `header` | — | — | **GAP** — layout-only field, same #1305 story |
420
+ | `tag` / `tags` | — | TagInput | OK (CSV wire shape) |
421
+ | `collection` | — | CollectionSelect | OK |
422
+ | `collectionmultiselect` / `collection-multiselect` | — | CollectionMultiSelect | OK |
423
+ | `datepicker` | — | DatePicker | OK (epoch wire; `outputFormat:'date'` opt-out) |
424
+ | `monthpicker` | — | DatePicker | OK (alias → precision `month`; explicit `precision` wins) |
425
+ | `yearpicker` | — | DatePicker | OK (alias → precision `year`) |
426
+ | `daterange` | — | DateRangePicker | OK — deviation: ONE name, `[start, end]` value; no `startName`/`endName` twin keys (use two datepickers for split columns) |
427
+ | `monthrange` | — | DateRangePicker | OK (alias → precision `month`) |
428
+ | `yearrange` | — | DateRangePicker | OK (alias → precision `year`) |
429
+ | `timepicker` | — | TimePicker | OK (real TimezoneSelect in the slot; string wire) |
430
+ | `datetimepicker` | — | seam → DateTimePicker | OK once #1273 merges (alias + epoch boundary live now; interim text input) |
431
+ | `checklistdropdown` | — | — | folded into `multiselect` (sweep: ONE implementation) — alias deliberately NOT registered |
432
+ | `buttongroup` | — | — | **GAP** — segmented control; source was the split-pipeline bug family (do-not-recreate), needs a fresh build |
433
+ | `combo` / `combobox` / `autocomplete` | — | ComboBox | OK (ComboInput feature spec, commit-only) |
434
+ | `address` | — | AddressField | OK (private session token, details → atomic declared-field patch) |
435
+ | `tabset` | FormView `tabs` prop | — | OK — tabsets are FORM STRUCTURE here (registry-of-tabs + permissions), not a field type |