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,173 @@
1
+ # TimePicker
2
+
3
+ The stepper time input, ported from web-mojo `forms/inputs/TimePicker.js`
4
+ (648 lines) — the source's locked **variant B**: per unit a column of
5
+ ▲ / value input / ▼ / label, with direct numeric typing on the value. Built
6
+ on the shared [`Popover`](popover.md) and the `parseTime`/`formatTime`/
7
+ `compareTime`/`ianaOffset` half of [`dateFns`](calendar.md). No wheels (the
8
+ source deferred those to a future mobile mode), no library.
9
+
10
+ ```ts
11
+ import { TimePicker } from 'portal-mojo/ui';
12
+ import type { TimeValue, TimeChangeEvent, TimeOutputFormat } from 'portal-mojo/ui';
13
+ ```
14
+
15
+ ## Value shapes (the contract)
16
+
17
+ Storage is **always canonical 24h `HH:MM` internally** — `format` only
18
+ decides what the panel and trigger render. `outputFormat` decides what
19
+ leaves:
20
+
21
+ | `outputFormat` | with a zone | without a zone | empty |
22
+ |---|---|---|---|
23
+ | `'iso'` (default) | `'14:30-07:00'` | `'14:30'` | `''` |
24
+ | `'iana'` (legacy) | `'14:30 America/Los_Angeles'` | `'14:30'` | `''` |
25
+ | `'object'` | `{time: '14:30', timezone: 'America/Los_Angeles'}` | `'14:30'` (string!) | `null` |
26
+
27
+ `'object'` only produces an object when the zone picker is enabled —
28
+ otherwise it degrades to the `'iso'` string, exactly as the source did.
29
+
30
+ **Everything above parses back in**, plus two more shapes the source
31
+ accepted (`_parseInitial`, ported line for line):
32
+
33
+ - 12h text — `'2:30 PM'` (an AM/PM tail belongs to the time, not the zone)
34
+ - ISO offsets in every spelling — `'14:30Z'` → zone `+00:00`,
35
+ `'14:30+0530'` → `+05:30`, `'14:30-07:00'` → `-07:00`
36
+ - IANA tails — `'14:30 America/New_York'`
37
+ - bare `'14:30'` / `'9:05'`
38
+
39
+ Invalid text parses to `null` (empty picker) — `parseTime` never throws.
40
+
41
+ ## API
42
+
43
+ | Prop | Notes |
44
+ |---|---|
45
+ | `value` | `string \| {time, timezone} \| null` — see above. Omit entirely for an uncontrolled picker. |
46
+ | `onChange` | `({value, formatted, oldValue})` — fires on **commit only**, and only when the serialized string actually changed |
47
+ | `format` | `'24h'` (default) \| `'12h'`. Unknown values warn + fall back |
48
+ | `step` | minute stepping increment, ≥1 (default 1). Hours always step by 1 |
49
+ | `min` / `max` | time strings (`"09:00"`, `"5:30 pm"` — `parseTime` parses them); clamped on **every** commit path |
50
+ | `placeholder` | default `'HH:MM'` / `'h:mm AM/PM'` |
51
+ | `disabled` / `readOnly` / `required` / `invalid` | trigger states; `required` and `readOnly` also suppress the clear ✕ |
52
+ | `inline` | render the panel in place — no trigger, no popover |
53
+ | `showFooter` | `false` → the embeddable footerless panel (DateTimePicker's time column) |
54
+ | `timezone` | `true` enables the zone picker; a `string[]` enables it **and is the zone list** |
55
+ | `timezones` | zone list when `timezone` is `true` (default: Intl's full list) |
56
+ | `outputFormat` | `'iso'` \| `'iana'` \| `'object'`. Unknown values warn + fall back |
57
+ | `renderTimezoneSelect` | the zone-picker slot — see below |
58
+ | `name` | renders a hidden input carrying the serialized string (native-form use) |
59
+ | `className` | appended to the root |
60
+
61
+ `TimeChangeEvent.oldValue` is always the **previous canonical serialized
62
+ string** (`''` when empty), never the object form — it is the diffing key,
63
+ not a mirror of `value`.
64
+
65
+ ## The timezone seam
66
+
67
+ `TimePicker` owns every bit of the zone's value and serialization logic but
68
+ renders the picker itself through a slot:
69
+
70
+ ```tsx
71
+ <TimePicker
72
+ timezone
73
+ renderTimezoneSelect={({ value, onChange, timezones, disabled }) => (
74
+ <TimezoneSelect value={value} onChange={onChange} timezones={timezones} disabled={disabled} />
75
+ )}
76
+ />
77
+ ```
78
+
79
+ Without the prop it falls back to a plain token-styled `<select>` built from
80
+ `Intl.supportedValuesOf('timeZone')` (curated ~50-zone list + a `console.warn`
81
+ when Intl can't supply one), so the feature is complete standalone. Swapping
82
+ in the real `TimezoneSelect` changes nothing about the value pipeline — the
83
+ slot only renders. A zone the list doesn't contain still renders as its own
84
+ option (never "render nothing") with a warn.
85
+
86
+ ## Behavior invariants (from source, keep them true)
87
+
88
+ - **Commit-only pipeline.** Typing into a column writes a *draft*, which can
89
+ never be read as the value. `onChange` fires on: stepper click, ArrowUp/
90
+ Down, Enter (which commits by blurring), blur, AM/PM, Now, Set-on-empty,
91
+ clear, zone pick. Re-committing the same value is silent.
92
+ - **Column typing.** Digits only (the keydown blocks the rest; ⌘/^/⌥
93
+ shortcuts pass through — a fix over source, which ate ⌘A/⌘V). Focus
94
+ selects the whole field; `maxLength` is 2; a blur clamps — 12h hours
95
+ 1–12, 24h hours 0–23, minutes 0–59 — and an empty field reverts to the
96
+ displayed value.
97
+ - **12h hour math** is the fiddly part: **12 AM = 0**, **12 PM = 12**, and a
98
+ typed hour maps through the *current* AM/PM state (PM + `3` → `15:00`).
99
+ AM/PM on an empty picker seeds `00:00` / `12:00`.
100
+ - **Minute stepping honors `step`** via total-minutes math and wraps across
101
+ midnight in BOTH directions (`23:45` +15 → `00:00`; `00:00` −15 →
102
+ `23:45`). Hours wrap 0–23. Typing is not constrained to `step`.
103
+ - **`min`/`max` clamp is a snap, not a block** — the stepper still moves, it
104
+ just lands on the bound.
105
+ - **Now** takes the local wall clock; **Set** fills `00:00` if the picker is
106
+ empty and closes the popover.
107
+ - The trigger is bi-clock icon + formatted text + a clear ✕ that appears on
108
+ hover/focus (suppressed by `required`/`disabled`/`readOnly`). Clicking it
109
+ on an empty picker is a no-op.
110
+
111
+ ## Deviations from source (deliberate)
112
+
113
+ 1. **A zone change emits.** web-mojo updated its hidden input but never fired
114
+ `change` on a zone pick (`_syncOutputs()` called with no `oldStored`).
115
+ `onChange` is the only channel here — staying silent would strand the
116
+ owner's value.
117
+ 2. **`Now` and the AM/PM-on-empty seed are clamped** like every other commit
118
+ path. The source skipped `_clampToBounds` on both, so `Now` outside
119
+ business hours produced an out-of-bounds value.
120
+ 3. **An already-offset zone passes through** `'iso'` serialization
121
+ (`'14:30-07:00'` re-saves as `'14:30-07:00'`). `ianaOffset('-07:00')`
122
+ returns `null`, so the source silently dropped the offset on re-save.
123
+ 4. **The clear ✕ is a sibling of the trigger**, not nested inside it (the
124
+ source produced a `<button>` inside a `<button>`). The wrapper carries the
125
+ hover reveal, so the pointer sitting on the ✕ keeps it visible.
126
+ 5. `disabled`/`readOnly` are enforced at the commit boundary, so a locked
127
+ `inline` panel is inert (the source only guarded the popover, which a
128
+ locked picker could never open anyway).
129
+ 6. The dead `autoApply` option (destructured, never read in source) is not
130
+ carried. There is no `hasError()` stub either — pass `invalid` instead.
131
+
132
+ ## State ownership (read this before wiring a form)
133
+
134
+ The picker holds its **own committed state**, seeded from `value` and
135
+ re-seeded whenever the `value` prop changes to something that is not an echo
136
+ of its own last emit — the same shape [`Calendar`](calendar.md) uses. Write
137
+ `event.value` straight back into `value` and the two stay in lockstep; omit
138
+ `value` entirely and it behaves as an uncontrolled picker.
139
+
140
+ Why not derive purely from props: `'iso'` output carries only the **offset**,
141
+ so re-parsing our own echo would silently degrade
142
+ `'America/Los_Angeles'` → `'-07:00'`. The zone therefore lives in component
143
+ state, and the echo guard is what protects it. Corollary: **a rejected write
144
+ does not revert the panel** — if you need to refuse a value, write the value
145
+ you *do* want (a second prop change re-seeds).
146
+
147
+ One more source-parity quirk: enabling `timezone` seeds the browser's local
148
+ zone (`Intl.DateTimeFormat().resolvedOptions().timeZone`, `'UTC'` on failure)
149
+ **without emitting**. The owner learns the zone on the first real commit.
150
+
151
+ ## Styling
152
+
153
+ `apps/portal/src/theme/timepicker.css` — `mojo-time-*` anatomy only
154
+ (`trigger` / `trigger-text` / `trigger-clear` / `popover-inner` /
155
+ `picker-inline` / `stepper*` / `ampm*` / `tz-host` / `foot`), tokens only,
156
+ both themes. The trigger wears the house `.input` look so it lines up in a
157
+ form grid; the popover shell (`.mojo-popover`) supplies background, border
158
+ and shadow, and `.mojo-time-popover-inner` brings the padding
159
+ (`.has-tz` widens it to 300px for IANA labels).
160
+
161
+ ## Pitfalls
162
+
163
+ - Don't read the column inputs to get the value — they may hold an
164
+ uncommitted draft. `onChange`/`value` is the only value channel.
165
+ - `'object'` output is `null` when empty, `''` for every other
166
+ `outputFormat`. Check for both if you switch formats at runtime.
167
+ - `min`/`max` are wall-clock times, not datetimes: a range that crosses
168
+ midnight (`22:00`–`02:00`) is not expressible — `compareTime` is linear.
169
+ - `ianaOffset` is evaluated at **serialization time against `new Date()`**,
170
+ so a DST-straddling value serializes with today's offset. Store the IANA
171
+ name (`'iana'` / `'object'`) when the date matters.
172
+ - django-mojo datetimes are epoch **seconds** — that is a DateTimePicker
173
+ concern; this component only speaks wall-clock time strings.
@@ -0,0 +1,147 @@
1
+ # TimezoneSelect — the IANA zone picker
2
+
3
+ ```ts
4
+ import {
5
+ TimezoneSelect, localTimezone, resolveTimezone, FALLBACK_TIMEZONES,
6
+ type TimezoneChangeEvent, type TimezoneSelectProps,
7
+ } from 'portal-mojo/ui';
8
+ ```
9
+
10
+ A searchable timezone field: the house [`ComboBox`](combobox.md) with
11
+ `allowCustom: false`, options built from the engine's IANA zone list, each
12
+ label annotated with the zone's **current** UTC offset. Ported from
13
+ web-mojo `src/core/forms/inputs/TimezoneSelect.js`. Used standalone and
14
+ embedded by the time pickers (#1269 TimePicker, DateTimePicker).
15
+
16
+ ## API
17
+
18
+ ```ts
19
+ <TimezoneSelect
20
+ value={zone} // committed IANA string | null
21
+ onChange={({value, oldValue}) => …} // COMMIT only, real changes only
22
+ timezones={['UTC', 'Europe/London']} // fixed list overrides the engine
23
+ defaultToLocal // default true — empty displays the local zone
24
+ name="timezone" // renders a hidden input (native form posts)
25
+ placeholder disabled required maxSuggestions id className
26
+ />
27
+
28
+ interface TimezoneChangeEvent { value: string; oldValue: string | null }
29
+
30
+ localTimezone(): string // 'UTC' when the engine can't say
31
+ resolveTimezone(value, { timezones?, defaultToLocal? }) // → the EFFECTIVE zone (getFormValue)
32
+ FALLBACK_TIMEZONES: readonly string[] // the curated 48
33
+ ```
34
+
35
+ `maxSuggestions` defaults to the **whole list** (source parity — web-mojo's
36
+ combobox scrolled every zone and never capped); pass a number to trim.
37
+
38
+ ## The zone list
39
+
40
+ 1. `timezones` prop, in the order given, when it is a non-empty array.
41
+ 2. else `Intl.supportedValuesOf('timeZone')` — ~418 zones, resolved once per
42
+ session and cached. `UTC` is prepended when absent: V8 omits it, and a
43
+ django-mojo portal whose picker cannot choose UTC is broken (a deliberate
44
+ one-line deviation from source; the curated list leads with UTC too).
45
+ 3. else `FALLBACK_TIMEZONES` (the curated 48, verbatim from web-mojo) with
46
+ ONE module-level `console.warn` — once per page load, not per instance.
47
+
48
+ **The committed zone is always in the list**, prepended when the resolved
49
+ list lacks it. This is not a nicety — V8 reports ICU-canonical ids
50
+ (`Asia/Calcutta`, `Europe/Kiev`, `Asia/Saigon`, `America/Godthab`,
51
+ `Asia/Rangoon`, `America/Buenos_Aires`) while Python/`zoneinfo` — and
52
+ therefore django-mojo — stores the modern IANA primaries (`Asia/Kolkata`,
53
+ `Europe/Kyiv`, …). Without the prepend, a stored `Asia/Kolkata` rendered
54
+ unlabelled and could not be re-selected: the user had to change the zone to
55
+ save the record. Engines accept the aliases as *input*, so those entries get
56
+ a proper offset label.
57
+
58
+ ## Labels: offset annotation
59
+
60
+ `America/New_York (UTC−05:00)` — `dateFns.ianaOffset` does the
61
+ `formatToParts` / `timeZoneName: 'shortOffset'` work (`GMT`/`UTC` → `+00:00`,
62
+ hours zero-padded, minutes defaulted), and the sign is rewritten to a
63
+ **Unicode minus** (U+2212) for negatives. A zone the engine rejects labels as
64
+ its bare name — never nothing.
65
+
66
+ **Labels recompute on open.** web-mojo built them once at mount, so a
67
+ long-lived picker kept showing pre-DST offsets. Here every gesture that can
68
+ open the list (focus, pointer-down, ArrowDown on the wrapper) stamps a new
69
+ reference instant, keyed into the label `useMemo`; the stamp coalesces to one
70
+ update per second, because a single open is several DOM events and a second
71
+ is far finer than any DST transition. Full relabel of ~420 zones is ~10ms,
72
+ paid on open only.
73
+
74
+ ## Values: commit-only, and the local default
75
+
76
+ `onChange` fires on a **commit** — clicking an option, Enter on the
77
+ highlighted one, or blurring onto an exact zone name — and only when the zone
78
+ actually changed. `allowCustom` is `false`, so typed filter text is a draft
79
+ that reverts: it can never become a value. This is the bug that mattered —
80
+ web-mojo's ComboBox emitted per keystroke, so fragments like `"Amer"` reached
81
+ the picker's value and FormView's 300ms autosave saved them.
82
+
83
+ The field is **fully controlled** (one pipeline, no internal value state).
84
+ The default is a *display* resolution, not a commit:
85
+
86
+ ```
87
+ effective = value || (defaultToLocal ? defaultZone : null)
88
+ defaultZone = localTimezone(), except: a caller-supplied `timezones` list
89
+ that lacks the local zone falls back to its FIRST entry
90
+ ```
91
+
92
+ - `getFormValue` semantics = `resolveTimezone(value, {timezones, defaultToLocal})`
93
+ → a plain IANA string, or `null` (never the offset label). The `name` prop's
94
+ hidden input posts exactly that.
95
+ - An empty `value` therefore *displays* the local zone while the owner's state
96
+ is still `null`. That is deliberate: emitting a commit on mount would mark a
97
+ form dirty and trip inline autosave for a zone the user never chose. Seed
98
+ state with `localTimezone()` when you need state and display identical from
99
+ the first paint.
100
+ - `oldValue` is the previously **displayed** (effective) zone, so a default
101
+ that was never committed still reports honestly.
102
+
103
+ ## Embedding (TimePicker / DateTimePicker)
104
+
105
+ The mount seam is `{ value, onChange, timezones?, disabled?, name?, placeholder? }`
106
+ — exactly what web-mojo's TimePicker/DateTimePicker passed
107
+ (`timezone: true | string[]` collapses to `timezones` at the call site; a
108
+ `timezone` prop of `true` means "show the picker", the array form means "show
109
+ it with this fixed list"). Hosts render the offset-annotated field inline
110
+ under their time strip.
111
+
112
+ Until the field-type registry (#1278) wires this in, the time-picker port may
113
+ be rendering a plain `<select>` fallback in that slot; swapping it for
114
+ `<TimezoneSelect>` is a drop-in — value in, value out, same IANA string.
115
+
116
+ ## Invariants / fixed web-mojo bugs (do not reintroduce)
117
+
118
+ - **Commit-only value** — inherited by construction from ComboBox plus
119
+ `allowCustom: false`; there is no per-keystroke path to `onChange`.
120
+ - **Offsets are recomputed, not frozen at mount** (DST correctness).
121
+ - **One warn, once** — the no-Intl fallback warns at module level, not per
122
+ instance and never per render.
123
+ - The value is always a plain IANA string; the offset lives only in the
124
+ label. Never post or store the label.
125
+
126
+ ## Pitfalls
127
+
128
+ - `Intl.supportedValuesOf('timeZone')` does **not** contain `GMT`, and (in
129
+ V8) not `UTC` either — hence the prepend. Other aliases (`US/Eastern`,
130
+ `Etc/GMT+5`) are not browsable either: they work as a *value* (prepended,
131
+ labelled) but a user cannot discover them. Pass `timezones` if a deployment
132
+ needs them listed.
133
+ - A zone the engine cannot resolve **at all** (`ianaOffset` → null, e.g. the
134
+ fallback list's bogus `America/Honolulu`) warns once per instance/value and
135
+ is displayed verbatim, unlabelled — house rule 4's signal that stored data
136
+ is wrong. An alias the engine *accepts* never warns.
137
+ - `FALLBACK_TIMEZONES` is verbatim from source, warts included:
138
+ `America/Honolulu` is not a real IANA zone (`Pacific/Honolulu` is, and is
139
+ also in the list). It labels bare instead of throwing. The list is only
140
+ reachable on engines without `Intl.supportedValuesOf`.
141
+ - An empty `timezones={[]}` means "no override" (source parity), not "no
142
+ zones".
143
+ - No stylesheet ships: the wrapper `.tz-select` is an unstyled hook and every
144
+ pixel comes from `apps/portal/src/theme/combobox.css`, which consuming apps
145
+ must include.
146
+ - The dropdown is ComboBox's locally-positioned list — inside
147
+ `overflow:hidden` containers it clips (MERGE-WIRE: Popover primitive #1271).
@@ -0,0 +1,302 @@
1
+ # WorldMap — dependency-free geo map
2
+
3
+ ```ts
4
+ import {
5
+ WorldMap,
6
+ // country data
7
+ COUNTRY_CENTROIDS, COUNTRY_OPTIONS, getCountryCentroid, countryName,
8
+ // binding helpers
9
+ countrySeriesToMarkers, countryCodeFromSlug, scaleMarkerSize,
10
+ toneColor, landRings, MAP_TONES,
11
+ // basemap geometry — lazily loaded; see "The `land` seam"
12
+ useWorldLand,
13
+ // projection math
14
+ equirectangular, fitViewToPoints, clampView, splitAntimeridian,
15
+ geoArcPath, graticule, isFinitePoint,
16
+ DEFAULT_BOUNDS, DEFAULT_VIEW, MIN_ZOOM, MAX_ZOOM,
17
+ } from 'portal-mojo/charts';
18
+ ```
19
+
20
+ One map for every "where in the world did this happen" surface: login
21
+ locations (#1291), geofence policy (#1287), the dashboard geography panel
22
+ (#1292). Demos: Develop → Components → Display → **WorldMap** and **WorldMap
23
+ routes**.
24
+
25
+ ## Why this is a REBUILD, not a port
26
+
27
+ web-mojo drew all of its maps through `src/extensions/map/MapLibreView.js`,
28
+ which injects `https://unpkg.com/maplibre-gl@4.7.1` as a runtime `<script>`
29
+ (maplibre is not in its `package.json` at all) and takes basemap tiles and
30
+ glyphs from `demotiles.maplibre.org` — MapLibre's explicitly-demo server.
31
+ Porting that verbatim would make every admin deployment fetch a third-party
32
+ CDN and a demo tile server at page load, which is unacceptable for the
33
+ IP-restricted / separate-origin admin deployments independently of the
34
+ no-library rule. The rebuild covers exactly the MapLibre surface the
35
+ consumers used and nothing more.
36
+
37
+ ## Invariants
38
+
39
+ 1. **Presentational only.** `WorldMap` fetches NOTHING — no tiles, no CDN, no
40
+ `fetch`, no `useQuery`. Consumers own their TanStack queries and pass
41
+ `markers` / `routes` / `status` / `loading`. `verify-worldmap.mjs` asserts
42
+ the absence of every network affordance in the source.
43
+ 2. **Tokens, never hexes.** Every color is a `var(--…)` string resolved by
44
+ the browser, so a theme flip needs no re-render.
45
+ 3. **ReactNode tooltips, never HTML strings.** No `dangerouslySetInnerHTML`.
46
+ Both web-mojo consumers concatenated popup HTML out of unescaped server
47
+ values (`entry.region`, `ev.ip_address`, `ev.user.display_name`);
48
+ `label` + `detail?: ReactNode` + `renderTooltip` replace that.
49
+ 4. **Coordinate validity is `Number.isFinite`.** `{lat: 0, lng: 0}` is real
50
+ data and plots. The source's `if (!lng || !lat) return`
51
+ (`MapLibreView.js:223`) dropped the equator and the prime meridian.
52
+ 5. **Off-bounds points are skipped and COUNTED**, surfaced in the footer as
53
+ "N points outside the map view". Clamping to the frame edge would lie
54
+ about where the event happened; dropping them silently is the
55
+ silent-failure anti-pattern on the do-not-recreate list.
56
+ 6. **Unknown values fall back WITH a `console.warn`** — an unknown tone
57
+ becomes `accent`, an unsupported land geometry is skipped, non-array
58
+ `markers`/`routes` render the empty state.
59
+
60
+ ## `<WorldMap>`
61
+
62
+ ```tsx
63
+ <WorldMap<CountryTotal>
64
+ markers={markers} // WorldMapMarker<T>[] — full replace
65
+ routes={routes} // WorldMapRoute[]
66
+ land={null} // injectable geometry; see below
67
+ height={320}
68
+ bounds={DEFAULT_BOUNDS}
69
+ view={view} onViewChange={setView} // controlled…
70
+ defaultView={DEFAULT_VIEW} // …or uncontrolled
71
+ fit="none" // 'none' | 'markers'
72
+ interactive // wheel zoom + drag pan
73
+ showGraticule showLegend showTooltip
74
+ legend={[{ key: 'Failed', label: 'Failed', tone: 'bad' }]}
75
+ renderTooltip={(m) => <>…</>}
76
+ onMarkerHover={(m) => …} onMarkerClick={(m) => …} onMarkerDoubleClick={(m) => …}
77
+ animateRoutes viewTransition={600}
78
+ loading={query.isPending}
79
+ status={`${n} logins plotted`}
80
+ emptyText="No locations to plot"
81
+ />
82
+ ```
83
+
84
+ | prop | type | default | notes |
85
+ |---|---|---|---|
86
+ | `markers` | `readonly WorldMapMarker<T>[]` | `[]` | Full replace, like `updateMarkers` |
87
+ | `routes` | `readonly WorldMapRoute[]` | `[]` | Origin→destination arcs |
88
+ | `land` | `WorldMapLand \| null` | `null` | Basemap geometry. Feed it `useWorldLand()`; `null` draws the graticule fallback |
89
+ | `height` | `number` | `320` | Width is measured (ResizeObserver) |
90
+ | `bounds` | `GeoBounds` | `{west:-180,east:180,south:-58,north:84}` | The geographic crop |
91
+ | `view` / `defaultView` | `WorldMapView` | `DEFAULT_VIEW` | Controlled vs uncontrolled |
92
+ | `onViewChange` | `(view) => void` | — | Fires for gestures, `fit`, and resets |
93
+ | `fit` | `'none' \| 'markers'` | `'none'` | `'none'` = the source's `autoFitBounds: false` |
94
+ | `interactive` | `boolean` | `true` | Wheel zoom + drag pan; markers stay live either way |
95
+ | `showGraticule` / `showLegend` / `showTooltip` | `boolean` | `true` | |
96
+ | `legend` | `readonly WorldMapLegendItem[]` | derived | Falls back to the markers' `legendKey`s |
97
+ | `renderTooltip` | `(m) => ReactNode` | — | Replaces the whole tooltip body |
98
+ | `onMarkerHover/Click/DoubleClick` | `(m) => void` | — | See the click-deferral note |
99
+ | `animateRoutes` | `boolean` | `true` | `prefers-reduced-motion` still wins |
100
+ | `viewTransition` | `number` | `600` | ms; the `flyTo({duration: 600})` equivalent |
101
+ | `loading` / `status` / `emptyText` / `className` | | | `status` is the source's `setStatus()` |
102
+
103
+ ### `WorldMapMarker<T>`
104
+
105
+ `{ id?, lat, lng, size?, tone?, intensity?, icon?, label?, value?, detail?,
106
+ legendKey?, data? }`
107
+
108
+ `data` is the typed payload every handler receives — the reason drill-down
109
+ does not need the `_countryCode` / `_isRegion` underscore fields web-mojo
110
+ smuggled through the marker object. `icon` is a bootstrap-icons class drawn
111
+ inside the dot (it rides an HTML overlay layer; an icon class cannot be put
112
+ into SVG `<text>` without knowing its codepoint).
113
+
114
+ ### `WorldMapRoute`
115
+
116
+ `{ id?, from, to, intensity?, tone?, label?, legendKey? }`
117
+
118
+ Paint follows the source ramps verbatim (`MetricsCountryMapView.js:232-260`):
119
+ width `1.75 → 6`, opacity `0.45 → 0.95`, color teal→amber — now
120
+ `toneColor('scale', intensity)`.
121
+
122
+ ### Tones
123
+
124
+ | tone | token | used for |
125
+ |---|---|---|
126
+ | `ok` | `--ok` | successful logins |
127
+ | `bad` | `--bad` | failed logins |
128
+ | `warn` | `--warn` | suspicious / MFA-required |
129
+ | `info` | `--info` | informational |
130
+ | `accent` | `--accent` | route origin, default |
131
+ | `mute` | `--mute` | neutral / unclassified |
132
+ | `scale` | `color-mix(--ok → --warn)` | the value ramp |
133
+
134
+ **There is deliberately no login-tone helper here.** One shipped briefly
135
+ (`loginEventTone` / `LOGIN_EVENT_TONES`, ported from
136
+ `LoginLocationMapView.js:31-41`) and was **removed 2026-08-06**: it keyed off a
137
+ login "event type", and django-mojo's `UserLoginEvent` has no such field, so
138
+ every real row fell through to grey — which is exactly why web-mojo's login map
139
+ rendered entirely grey for years. Tone login rows with `loginRiskTone(row)` from
140
+ `portal-mojo/admin` (`admin/security/devices`): danger on `is_new_country`,
141
+ warning on `is_new_region`, otherwise success. `verify-worldmap.mjs` asserts the
142
+ old names stay gone.
143
+
144
+ ## Projection contract
145
+
146
+ `equirectangular({width, height, bounds, view})` → `{project, invert, scale,
147
+ contains, size, bounds, view}`.
148
+
149
+ - **Equirectangular (plate carrée)**: `lat`/`lng` map linearly to `y`/`x`, so
150
+ `invert` is exact — that is what makes a graticule-only map honest.
151
+ - **`zoom` is a fit multiple, not a tile level.** At `zoom: 1` the whole
152
+ `bounds` box fits the frame; the scale is `min(width/spanLng,
153
+ height/spanLat)` in BOTH axes, so a mismatched container letterboxes rather
154
+ than stretching. Non-uniform fitting would turn markers into ellipses.
155
+ Range: `MIN_ZOOM` 1 … `MAX_ZOOM` 12.
156
+ - **`invert`** exists for zoom-toward-the-cursor and for any consumer that
157
+ needs "what is under this pixel".
158
+ - **`DEFAULT_BOUNDS`** crops to `south: -58, north: 84` — the framing web-mojo
159
+ approximated with `center [10, 20], zoom 1.3`. Antarctica (lat −77) is
160
+ outside it on purpose; widen `bounds` if a consumer needs it.
161
+ - `fitViewToPoints(points, {size, bounds, padding = 50, maxZoom, fallback})`
162
+ replaces `fitBounds()`. Zero points returns `fallback`; ONE point recenters
163
+ but KEEPS the current zoom (a single-point bbox has zero span).
164
+ - `clampView(view, bounds, size)` clamps zoom and pans so the map can never
165
+ leave the frame.
166
+ - `splitAntimeridian(from, to)` returns 2 segments when the short path wraps
167
+ past ±180°, 1 otherwise — without it a Reston→Tokyo arc runs backwards
168
+ across the entire map.
169
+ - `geoArcPath(projection, from, to, curvature = 0.22)` is a quadratic bezier
170
+ whose control point is perpendicular to the chord. **Named `geoArcPath`,
171
+ not `arcPath`,** because `charts/pie-math` already exports `arcPath`.
172
+
173
+ ## `countrySeriesToMarkers` and the two metrics key shapes
174
+
175
+ ```ts
176
+ const markers = countrySeriesToMarkers(response.data, {
177
+ maxCountries: 12, tone: 'scale', metricLabel: 'events',
178
+ });
179
+ ```
180
+
181
+ Two key shapes exist in the wild and ONE rule covers both — uppercase the
182
+ last `:`-separated segment, accept it only if it is 2 characters AND a known
183
+ country:
184
+
185
+ | shape | source | example |
186
+ |---|---|---|
187
+ | bare ISO2 | `MetricsCountryMapView.js:107-118` | `US` |
188
+ | namespaced slug | `GeographyPanel.js:232-243` | `incident_events:country:US`, `firewall:blocks:country:US`, `bouncer:blocks:country:US` |
189
+
190
+ `api_calls` and `ZZ` therefore both return `null`, and rows summing to zero
191
+ are dropped. Results are sorted descending, cut to `maxCountries`, joined to
192
+ `COUNTRY_CENTROIDS`, and sized by `scaleMarkerSize` (default range
193
+ `[18, 42]` = the source's `18 + intensity × 24`; `LoginLocationMapView` used
194
+ `× 26`, i.e. `[18, 44]`).
195
+
196
+ ## The `land` seam — SETTLED 2026-08-06
197
+
198
+ Ian's call: **embed the outlines, no library.** `WORLD_LAND` in
199
+ `charts/worldmap/world-land.ts` is Natural Earth 1:110m Admin 0 Countries —
200
+ **public domain**, no attribution required — decoded from the `world-atlas`
201
+ TopoJSON build, rounded to 1 decimal and stripped of sub-visible islets.
202
+ 173 countries, 10,443 positions, ~143 KB raw / **~40 KB gzipped**.
203
+
204
+ It is generated **once and checked in**. There is no runtime dependency, no
205
+ CDN fetch and no tile server — which is the whole point: web-mojo pulled
206
+ `maplibre-gl` from unpkg at page load and drew on MapLibre's *demo* tile
207
+ server, so its admin map could not work on an IP-restricted or offline
208
+ deployment. Ours can.
209
+
210
+ **Reach it through `useWorldLand()` — never a static import.**
211
+
212
+ ```ts
213
+ import { WorldMap, useWorldLand } from 'portal-mojo/charts';
214
+
215
+ const land = useWorldLand(); // or useWorldLand(hasCoords) to skip the fetch
216
+ <WorldMap land={land} markers={markers} />
217
+ ```
218
+
219
+ `world-land.ts` is deliberately **not re-exported from the charts barrel**, and
220
+ the hook reaches it with a dynamic `import()`. That is what makes Rollup emit it
221
+ as its own chunk. Measured on `apps/portal`: static export put the coordinates
222
+ in the main bundle (1,472 kB / 426 kB gzipped); behind the hook the main bundle
223
+ is 1,328 kB / 386 kB and `world-land-*.js` is a separate 144 kB / **39.8 kB
224
+ gzipped** chunk fetched only when a map first renders. `verify-admin-devices`
225
+ asserts the barrel never re-exports it.
226
+
227
+ The hook caches at module scope, so later maps in a session get the geometry on
228
+ first render and concurrent mounts share one request. It returns `null` until
229
+ resolved — not a special case, since a null `land` is just the graticule
230
+ fallback, so the map is fully readable before the coastlines arrive. A failed
231
+ chunk fetch warns and leaves the fallback rather than blanking the map.
232
+
233
+ Surfaces using it today: `LoginLocationMap` and the GeoIP dossier's mini-map.
234
+
235
+ Rendering correctly **without** it remains a hard requirement — pass
236
+ `land={null}` (the default) and the ocean + graticule fallback carries the
237
+ markers, legend, tooltips and status line on its own. Both showcase demos keep
238
+ a toggle so the no-geometry case stays visible.
239
+
240
+ Regenerating (only if you want different precision or a different source):
241
+ decode the TopoJSON, de-quantize with its `transform`, round, and drop outer
242
+ rings under 0.5 square degrees. The file header records the exact parameters.
243
+ Do not hand-edit it.
244
+
245
+ Precision note: 1:110m is a *coarse* source by design. At the map's rendered
246
+ scale one pixel is roughly 0.36° of longitude, so 0.1° rounding is sub-pixel;
247
+ at the maximum 12× zoom the coarseness of the source itself shows before the
248
+ rounding does. If you ever need street-level fidelity, that is a different
249
+ dataset and a different conversation — not a rounding tweak.
250
+
251
+ `land` accepts a `FeatureCollection`, a bare feature array, or a single
252
+ `Polygon`/`MultiPolygon`. Unsupported geometry types are skipped with a
253
+ `console.warn` and the rest still draw (`landRings` is the exported
254
+ normalizer). A ring whose consecutive vertices jump more than 180° of
255
+ longitude BREAKS into a new subpath instead of smearing a band across the
256
+ map.
257
+
258
+ ## Pitfalls
259
+
260
+ - **Double-click is drill-down, so canvas double-click does NOT zoom.** With
261
+ both `onMarkerClick` and `onMarkerDoubleClick` wired, a single click is
262
+ deferred `250ms` and cancelled by a double-click (in MapLibre the marker
263
+ handler `stopPropagation`'d against `doubleClickZoom`). Double-clicking the
264
+ canvas resets to `defaultView`.
265
+ - **Off-bounds markers do not render.** Check the footer count before
266
+ concluding the data is missing; widen `bounds` to bring them in.
267
+ - **The centroid keys were deduped.** The source table has 249 rows but 244
268
+ distinct ISO2 keys (`BQ`×3, `ES`×2, `TF`×3), and JS last-write-wins made
269
+ **`ES` resolve to "Canarias" rather than Spain** — so web-mojo's geofence
270
+ picker listed a Spanish island group where Spain belonged, and Spanish
271
+ metrics plotted in the Canaries. Duplicate keys are also a hard TS error
272
+ (TS1117). Here the primary territory keeps the key (`BQ`→Bonaire,
273
+ `ES`→Spain, `TF`→French Southern Territories) and the 5 dropped rows are
274
+ recorded with their coordinates in the module header.
275
+ - **`LoginLocationMapView`'s docstring says `size=500`; the code sends
276
+ `size: 1000`** (`:208`). Consumers should follow the code.
277
+ - **`map.resize()` has no equivalent** — `ResizeObserver` covers the
278
+ tab-activation and drawer-opening cases that forced it. A container at
279
+ width 0 defers rendering until the first non-zero measurement.
280
+ - **`pitch` / `bearing` are dropped.** Cosmetic 3D tilt with no consumer
281
+ semantics; a fake-perspective SVG map would be dishonest.
282
+ - **Route animation is an ADDITION.** web-mojo's routes were static
283
+ LineStrings; only color/width/opacity interpolated on intensity. It is
284
+ therefore opt-out (`animateRoutes={false}`) and lives inside
285
+ `@media (prefers-reduced-motion: no-preference)`.
286
+ - **`fit="markers"` pushes through the same channel as a gesture** — it sets
287
+ internal state when uncontrolled and calls `onViewChange` when controlled.
288
+ A controlled consumer must write that value back for the fit to stick.
289
+
290
+ ## Verification
291
+
292
+ ```bash
293
+ node scripts/verify-worldmap.mjs # or: npm run verify:worldmap
294
+ ```
295
+
296
+ Headless assertions over the projection (corner mapping, `project`∘`invert`
297
+ round-trip, clamping, fitting, antimeridian splitting), the centroid table
298
+ (244 unique keys, coordinate ranges, `ES` = Spain), the binding helpers
299
+ (slug parsing, sizing, sorting/top-N/drops), tone fallbacks and their warns,
300
+ the land normalizer, and the source invariants above (no network, no map
301
+ library, no HTML-string tooltips, tokens-only CSS, and the two theme copies
302
+ staying byte-identical).