@krizaka/ui 3.30.0 → 3.32.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 (394) hide show
  1. package/README.md +4 -8
  2. package/dist/action-bar.js +3 -3
  3. package/dist/action-bar.js.map +1 -1
  4. package/dist/argument-card.d.ts +1 -1
  5. package/dist/argument-card.js +2 -2
  6. package/dist/argument-card.js.map +1 -1
  7. package/dist/blocks.css +19 -60
  8. package/dist/{button-BmCrLCq7.d.ts → button-Cm9pFH3P.d.ts} +10 -9
  9. package/dist/button.d.ts +11 -10
  10. package/dist/button.js +1 -1
  11. package/dist/captcha.js +1 -1
  12. package/dist/card-grid.d.ts +5 -12
  13. package/dist/card-grid.js +4 -12
  14. package/dist/card-grid.js.map +1 -1
  15. package/dist/card.d.ts +7 -7
  16. package/dist/card.js +2 -2
  17. package/dist/card.js.map +1 -1
  18. package/dist/{chunk-GYKQ52HX.js → chunk-2AJ4NJUF.js} +2 -2
  19. package/dist/{chunk-MQPC3P4T.js → chunk-2DGLWTPS.js} +3 -3
  20. package/dist/chunk-2DGLWTPS.js.map +1 -0
  21. package/dist/{chunk-DZU6RMYD.js → chunk-3DHNN5V7.js} +15 -9
  22. package/dist/chunk-3DHNN5V7.js.map +1 -0
  23. package/dist/{chunk-TFJDIOFK.js → chunk-4E5IUZHR.js} +3 -3
  24. package/dist/chunk-4E5IUZHR.js.map +1 -0
  25. package/dist/{chunk-HAQWKORZ.js → chunk-4JO5GSSD.js} +1 -37
  26. package/dist/chunk-4JO5GSSD.js.map +1 -0
  27. package/dist/{chunk-APNIVNIX.js → chunk-IBDTAMPF.js} +3 -4
  28. package/dist/chunk-IBDTAMPF.js.map +1 -0
  29. package/dist/{chunk-LYOPPBMC.js → chunk-KIBICUZK.js} +2 -2
  30. package/dist/{chunk-HBZPJ3EP.js → chunk-NJFCSVPT.js} +2 -2
  31. package/dist/{chunk-KM6VAKIE.js → chunk-RE5WJ4QP.js} +2 -2
  32. package/dist/chunk-RE5WJ4QP.js.map +1 -0
  33. package/dist/{chunk-CHOJUCVE.js → chunk-SJS6RJIT.js} +15 -9
  34. package/dist/chunk-SJS6RJIT.js.map +1 -0
  35. package/dist/{chunk-6ZYRUOTA.js → chunk-UV4HBVH7.js} +15 -9
  36. package/dist/chunk-UV4HBVH7.js.map +1 -0
  37. package/dist/command.js +2 -2
  38. package/dist/confirm-button.d.ts +1 -1
  39. package/dist/confirm-button.js +1 -1
  40. package/dist/continue-prompt.js +14 -8
  41. package/dist/continue-prompt.js.map +1 -1
  42. package/dist/data-table.js +2 -2
  43. package/dist/dialog.js +2 -2
  44. package/dist/engine-flow.d.ts +25 -2
  45. package/dist/engine-flow.js +27 -6
  46. package/dist/engine-flow.js.map +1 -1
  47. package/dist/force-update-screen.js +2 -2
  48. package/dist/force-update-screen.js.map +1 -1
  49. package/dist/genui.js +3 -3
  50. package/dist/glow-card.d.ts +5 -4
  51. package/dist/glow-card.js +2 -8
  52. package/dist/glow-card.js.map +1 -1
  53. package/dist/image-cropper.js +1 -1
  54. package/dist/index.js +4 -4
  55. package/dist/index.js.map +1 -1
  56. package/dist/iso.d.ts +1 -1
  57. package/dist/iso.js.map +1 -1
  58. package/dist/landscape.js +4 -4
  59. package/dist/landscape.js.map +1 -1
  60. package/dist/link-card.d.ts +3 -6
  61. package/dist/link-card.js +1 -1
  62. package/dist/maintenance-screen.js +1 -1
  63. package/dist/map.d.ts +50 -5
  64. package/dist/map.js +202 -66
  65. package/dist/map.js.map +1 -1
  66. package/dist/match-dialog.js +1 -1
  67. package/dist/media-gallery.js +1 -1
  68. package/dist/media-gallery.js.map +1 -1
  69. package/dist/media-grid.js +2 -2
  70. package/dist/motion.css +52 -122
  71. package/dist/motion.d.ts +2 -2
  72. package/dist/motion.js +1 -1
  73. package/dist/native/map.js +64 -64
  74. package/dist/native/map.js.map +1 -1
  75. package/dist/native.js +30 -30
  76. package/dist/native.js.map +1 -1
  77. package/dist/nav-bar.js +2 -2
  78. package/dist/pointer-light.d.ts +6 -9
  79. package/dist/pointer-light.js +9 -5
  80. package/dist/pointer-light.js.map +1 -1
  81. package/dist/radio-group.js +2 -2
  82. package/dist/radio-group.js.map +1 -1
  83. package/dist/service-card.js +1 -1
  84. package/dist/service-card.js.map +1 -1
  85. package/dist/site-footer.js +2 -2
  86. package/dist/slider.js +1 -1
  87. package/dist/slider.js.map +1 -1
  88. package/dist/story.css +5 -12
  89. package/dist/theme.d.ts +1 -1
  90. package/dist/theme.js +1 -1
  91. package/dist/update-prompt.js +2 -2
  92. package/dist/update-prompt.js.map +1 -1
  93. package/dist/vertical-video.js +1 -1
  94. package/dist/video-player.js +2 -2
  95. package/dist/video-player.js.map +1 -1
  96. package/package.json +8 -8
  97. package/registry/action-bar.json +1 -1
  98. package/registry/app-shell.json +4 -4
  99. package/registry/argument-card.json +7 -16
  100. package/registry/auth-layout.json +4 -4
  101. package/registry/button.json +20 -20
  102. package/registry/card-grid.json +21 -24
  103. package/registry/card.json +7 -7
  104. package/registry/component-page.json +2 -2
  105. package/registry/confirm-button.json +6 -6
  106. package/registry/contact-page.json +2 -2
  107. package/registry/cover-hero.json +3 -3
  108. package/registry/dashboard-layout.json +2 -2
  109. package/registry/detail-layout.json +4 -4
  110. package/registry/docs-hub-page.json +2 -2
  111. package/registry/empty-state.json +1 -1
  112. package/registry/engine-flow.json +74 -3
  113. package/registry/examples/app-shell/admin-console.js +1 -1
  114. package/registry/examples/app-shell/admin-console.tsx +1 -1
  115. package/registry/examples/app-shell/basic.js +1 -1
  116. package/registry/examples/app-shell/basic.tsx +1 -1
  117. package/registry/examples/app-shell/desktop.js +1 -1
  118. package/registry/examples/app-shell/desktop.tsx +1 -1
  119. package/registry/examples/app-shell/orochia-studio.js +1 -1
  120. package/registry/examples/app-shell/orochia-studio.tsx +1 -1
  121. package/registry/examples/argument-card/hover-quiet.js +1 -1
  122. package/registry/examples/argument-card/hover-quiet.tsx +1 -1
  123. package/registry/examples/auth-layout/onboarding.js +2 -2
  124. package/registry/examples/auth-layout/onboarding.tsx +2 -2
  125. package/registry/examples/auth-layout/sign-in.js +2 -2
  126. package/registry/examples/auth-layout/sign-in.tsx +2 -2
  127. package/registry/examples/auth-layout/verify.js +1 -1
  128. package/registry/examples/auth-layout/verify.tsx +1 -1
  129. package/registry/examples/avatar/native/presence.dark.png +0 -0
  130. package/registry/examples/avatar/native/presence.light.png +0 -0
  131. package/registry/examples/badge/native/tones.dark.png +0 -0
  132. package/registry/examples/badge/native/tones.light.png +0 -0
  133. package/registry/examples/button/native/disabled.dark.png +0 -0
  134. package/registry/examples/button/native/disabled.light.png +0 -0
  135. package/registry/examples/button/native/gradient.dark.png +0 -0
  136. package/registry/examples/button/native/gradient.light.png +0 -0
  137. package/registry/examples/button/native/icon-only.dark.png +0 -0
  138. package/registry/examples/button/native/icon-only.light.png +0 -0
  139. package/registry/examples/button/native/icon.dark.png +0 -0
  140. package/registry/examples/button/native/icon.light.png +0 -0
  141. package/registry/examples/button/native/loading.dark.png +0 -0
  142. package/registry/examples/button/native/loading.light.png +0 -0
  143. package/registry/examples/button/native/primary.dark.png +0 -0
  144. package/registry/examples/button/native/primary.light.png +0 -0
  145. package/registry/examples/button/native/sizes.dark.png +0 -0
  146. package/registry/examples/button/native/sizes.light.png +0 -0
  147. package/registry/examples/button/signature.js +2 -2
  148. package/registry/examples/button/signature.tsx +4 -4
  149. package/registry/examples/card/interactive-link.tsx +1 -1
  150. package/registry/examples/card/native/body.dark.png +0 -0
  151. package/registry/examples/card/native/body.light.png +0 -0
  152. package/registry/examples/card/native/default.dark.png +0 -0
  153. package/registry/examples/card/native/default.light.png +0 -0
  154. package/registry/examples/card/native/elevated.dark.png +0 -0
  155. package/registry/examples/card/native/elevated.light.png +0 -0
  156. package/registry/examples/card-grid/hover-quiet.js +1 -1
  157. package/registry/examples/card-grid/hover-quiet.tsx +1 -1
  158. package/registry/examples/chat/native/assistant.dark.png +0 -0
  159. package/registry/examples/chat/native/assistant.light.png +0 -0
  160. package/registry/examples/chat/native/conversation.dark.png +0 -0
  161. package/registry/examples/chat/native/conversation.light.png +0 -0
  162. package/registry/examples/chat/native/inbox.dark.png +0 -0
  163. package/registry/examples/chat/native/inbox.light.png +0 -0
  164. package/registry/examples/checkbox/native/terms.dark.png +0 -0
  165. package/registry/examples/checkbox/native/terms.light.png +0 -0
  166. package/registry/examples/chip/native/multiple.dark.png +0 -0
  167. package/registry/examples/chip/native/multiple.light.png +0 -0
  168. package/registry/examples/chip/native/single.dark.png +0 -0
  169. package/registry/examples/chip/native/single.light.png +0 -0
  170. package/registry/examples/chip/native/toggle.dark.png +0 -0
  171. package/registry/examples/chip/native/toggle.light.png +0 -0
  172. package/registry/examples/component-page/button.js +2 -2
  173. package/registry/examples/component-page/button.tsx +2 -2
  174. package/registry/examples/contact-page/team.js +1 -1
  175. package/registry/examples/contact-page/team.tsx +1 -1
  176. package/registry/examples/cover-hero/plain.js +1 -1
  177. package/registry/examples/cover-hero/plain.tsx +1 -1
  178. package/registry/examples/cover-hero/shop.js +1 -1
  179. package/registry/examples/cover-hero/shop.tsx +1 -1
  180. package/registry/examples/dashboard-layout/orazaka-eric.js +2 -2
  181. package/registry/examples/dashboard-layout/orazaka-eric.tsx +2 -2
  182. package/registry/examples/date-picker/native/calendar.dark.png +0 -0
  183. package/registry/examples/date-picker/native/calendar.light.png +0 -0
  184. package/registry/examples/date-picker/native/day-strip.dark.png +0 -0
  185. package/registry/examples/date-picker/native/day-strip.light.png +0 -0
  186. package/registry/examples/date-picker/native/month-year.dark.png +0 -0
  187. package/registry/examples/date-picker/native/month-year.light.png +0 -0
  188. package/registry/examples/date-picker/native/time-slots.dark.png +0 -0
  189. package/registry/examples/date-picker/native/time-slots.light.png +0 -0
  190. package/registry/examples/description-list/native/job-details.dark.png +0 -0
  191. package/registry/examples/description-list/native/job-details.light.png +0 -0
  192. package/registry/examples/detail-layout/checkout.js +1 -1
  193. package/registry/examples/detail-layout/checkout.tsx +1 -1
  194. package/registry/examples/detail-layout/job.js +1 -1
  195. package/registry/examples/detail-layout/job.tsx +1 -1
  196. package/registry/examples/detail-layout/video.js +2 -2
  197. package/registry/examples/detail-layout/video.tsx +2 -2
  198. package/registry/examples/dialog/native/sheet.dark.png +0 -0
  199. package/registry/examples/dialog/native/sheet.light.png +0 -0
  200. package/registry/examples/docs-hub-page/krizaka.js +1 -1
  201. package/registry/examples/docs-hub-page/krizaka.tsx +1 -1
  202. package/registry/examples/empty-state/library.js +1 -1
  203. package/registry/examples/empty-state/library.tsx +1 -1
  204. package/registry/examples/empty-state/native/default.dark.png +0 -0
  205. package/registry/examples/empty-state/native/default.light.png +0 -0
  206. package/registry/examples/fab/native/extended.dark.png +0 -0
  207. package/registry/examples/fab/native/extended.light.png +0 -0
  208. package/registry/examples/force-update-screen/native/default.dark.png +0 -0
  209. package/registry/examples/force-update-screen/native/default.light.png +0 -0
  210. package/registry/examples/form/native/amount.dark.png +0 -0
  211. package/registry/examples/form/native/amount.light.png +0 -0
  212. package/registry/examples/form/native/audience.dark.png +0 -0
  213. package/registry/examples/form/native/audience.light.png +0 -0
  214. package/registry/examples/form/native/profile.dark.png +0 -0
  215. package/registry/examples/form/native/profile.light.png +0 -0
  216. package/registry/examples/form/native/settings.dark.png +0 -0
  217. package/registry/examples/form/native/settings.light.png +0 -0
  218. package/registry/examples/form/native/sign-up.dark.png +0 -0
  219. package/registry/examples/form/native/sign-up.light.png +0 -0
  220. package/registry/examples/form/native/upload.dark.png +0 -0
  221. package/registry/examples/form/native/upload.light.png +0 -0
  222. package/registry/examples/glow-card/spotlight.tsx +1 -1
  223. package/registry/examples/goal-gauge/native/default.dark.png +0 -0
  224. package/registry/examples/goal-gauge/native/default.light.png +0 -0
  225. package/registry/examples/landing-page/krizaka.js +3 -3
  226. package/registry/examples/landing-page/krizaka.tsx +3 -3
  227. package/registry/examples/link-card/hover-quiet.js +1 -1
  228. package/registry/examples/link-card/hover-quiet.tsx +1 -1
  229. package/registry/examples/loader/native/block.dark.png +0 -0
  230. package/registry/examples/loader/native/block.light.png +0 -0
  231. package/registry/examples/map/native/location-picker.dark.png +0 -0
  232. package/registry/examples/map/native/location-picker.light.png +0 -0
  233. package/registry/examples/map/route.js +36 -0
  234. package/registry/examples/map/route.tsx +35 -0
  235. package/registry/examples/marketing-layout/pricing.js +1 -1
  236. package/registry/examples/marketing-layout/pricing.tsx +1 -1
  237. package/registry/examples/marketing-layout/product-page.js +1 -1
  238. package/registry/examples/marketing-layout/product-page.tsx +2 -2
  239. package/registry/examples/mascot-state/native/default.dark.png +0 -0
  240. package/registry/examples/mascot-state/native/default.light.png +0 -0
  241. package/registry/examples/mascots/native/crane.dark.png +0 -0
  242. package/registry/examples/mascots/native/crane.light.png +0 -0
  243. package/registry/examples/mascots/native/default.dark.png +0 -0
  244. package/registry/examples/mascots/native/default.light.png +0 -0
  245. package/registry/examples/mascots/native/lovebird.dark.png +0 -0
  246. package/registry/examples/mascots/native/lovebird.light.png +0 -0
  247. package/registry/examples/mascots/native/phoenix.dark.png +0 -0
  248. package/registry/examples/mascots/native/phoenix.light.png +0 -0
  249. package/registry/examples/match-dialog/native/its-a-match.dark.png +0 -0
  250. package/registry/examples/match-dialog/native/its-a-match.light.png +0 -0
  251. package/registry/examples/media-capture/native/photo.dark.png +0 -0
  252. package/registry/examples/media-capture/native/photo.light.png +0 -0
  253. package/registry/examples/meta-row/native/video.dark.png +0 -0
  254. package/registry/examples/meta-row/native/video.light.png +0 -0
  255. package/registry/examples/meter/native/credits.dark.png +0 -0
  256. package/registry/examples/meter/native/credits.light.png +0 -0
  257. package/registry/examples/meter/native/split-ring.dark.png +0 -0
  258. package/registry/examples/meter/native/split-ring.light.png +0 -0
  259. package/registry/examples/nav-bar/family-product.js +1 -1
  260. package/registry/examples/nav-bar/family-product.tsx +1 -1
  261. package/registry/examples/onboarding/native/first-launch.dark.png +0 -0
  262. package/registry/examples/onboarding/native/first-launch.light.png +0 -0
  263. package/registry/examples/permission-prompt/native/blocked.dark.png +0 -0
  264. package/registry/examples/permission-prompt/native/blocked.light.png +0 -0
  265. package/registry/examples/permission-prompt/native/camera.dark.png +0 -0
  266. package/registry/examples/permission-prompt/native/camera.light.png +0 -0
  267. package/registry/examples/photo-grid/native/profile-photos.dark.png +0 -0
  268. package/registry/examples/photo-grid/native/profile-photos.light.png +0 -0
  269. package/registry/examples/pricing-page/credits.js +3 -3
  270. package/registry/examples/pricing-page/credits.tsx +3 -3
  271. package/registry/examples/product-page/orazaka.js +2 -2
  272. package/registry/examples/product-page/orazaka.tsx +2 -2
  273. package/registry/examples/profile-card/native/default.dark.png +0 -0
  274. package/registry/examples/profile-card/native/default.light.png +0 -0
  275. package/registry/examples/progress/native/bar.dark.png +0 -0
  276. package/registry/examples/progress/native/bar.light.png +0 -0
  277. package/registry/examples/progress/native/indeterminate.dark.png +0 -0
  278. package/registry/examples/progress/native/indeterminate.light.png +0 -0
  279. package/registry/examples/progress/native/ring.dark.png +0 -0
  280. package/registry/examples/progress/native/ring.light.png +0 -0
  281. package/registry/examples/radio-group/native/buttons.dark.png +0 -0
  282. package/registry/examples/radio-group/native/buttons.light.png +0 -0
  283. package/registry/examples/radio-group/native/cards.dark.png +0 -0
  284. package/registry/examples/radio-group/native/cards.light.png +0 -0
  285. package/registry/examples/radio-group/native/list.dark.png +0 -0
  286. package/registry/examples/radio-group/native/list.light.png +0 -0
  287. package/registry/examples/screen/native/auth.dark.png +0 -0
  288. package/registry/examples/screen/native/auth.light.png +0 -0
  289. package/registry/examples/screen/native/chat.dark.png +0 -0
  290. package/registry/examples/screen/native/chat.light.png +0 -0
  291. package/registry/examples/screen/native/profile.dark.png +0 -0
  292. package/registry/examples/screen/native/profile.light.png +0 -0
  293. package/registry/examples/screen/native/settings.dark.png +0 -0
  294. package/registry/examples/screen/native/settings.light.png +0 -0
  295. package/registry/examples/search-field/native/cancel.dark.png +0 -0
  296. package/registry/examples/search-field/native/cancel.light.png +0 -0
  297. package/registry/examples/settings-layout/account.js +1 -1
  298. package/registry/examples/settings-layout/account.tsx +1 -1
  299. package/registry/examples/settings-layout/native/account.dark.png +0 -0
  300. package/registry/examples/settings-layout/native/account.light.png +0 -0
  301. package/registry/examples/settings-layout/sections.js +1 -1
  302. package/registry/examples/settings-layout/sections.tsx +1 -1
  303. package/registry/examples/showcase/native/slides.dark.png +0 -0
  304. package/registry/examples/showcase/native/slides.light.png +0 -0
  305. package/registry/examples/slider/native/default.dark.png +0 -0
  306. package/registry/examples/slider/native/default.light.png +0 -0
  307. package/registry/examples/slider/native/range.dark.png +0 -0
  308. package/registry/examples/slider/native/range.light.png +0 -0
  309. package/registry/examples/slider/native/stepped.dark.png +0 -0
  310. package/registry/examples/slider/native/stepped.light.png +0 -0
  311. package/registry/examples/slider/native/vertical.dark.png +0 -0
  312. package/registry/examples/slider/native/vertical.light.png +0 -0
  313. package/registry/examples/spinner/native/sizes.dark.png +0 -0
  314. package/registry/examples/spinner/native/sizes.light.png +0 -0
  315. package/registry/examples/split-view/messages.js +1 -1
  316. package/registry/examples/split-view/messages.tsx +1 -1
  317. package/registry/examples/status-page/building.js +1 -1
  318. package/registry/examples/status-page/building.tsx +1 -1
  319. package/registry/examples/step-progress/native/sign-up.dark.png +0 -0
  320. package/registry/examples/step-progress/native/sign-up.light.png +0 -0
  321. package/registry/examples/stepper/native/run.dark.png +0 -0
  322. package/registry/examples/stepper/native/run.light.png +0 -0
  323. package/registry/examples/sticky-action/phone.js +1 -1
  324. package/registry/examples/sticky-action/phone.tsx +1 -1
  325. package/registry/examples/switch/native/default.dark.png +0 -0
  326. package/registry/examples/switch/native/default.light.png +0 -0
  327. package/registry/examples/switch/native/settings.dark.png +0 -0
  328. package/registry/examples/switch/native/settings.light.png +0 -0
  329. package/registry/examples/tab-bar/native/main-sections.dark.png +0 -0
  330. package/registry/examples/tab-bar/native/main-sections.light.png +0 -0
  331. package/registry/examples/tabs/native/sections.dark.png +0 -0
  332. package/registry/examples/tabs/native/sections.light.png +0 -0
  333. package/registry/examples/tag-selector/native/interests.dark.png +0 -0
  334. package/registry/examples/tag-selector/native/interests.light.png +0 -0
  335. package/registry/examples/toast/native/snackbar.dark.png +0 -0
  336. package/registry/examples/toast/native/snackbar.light.png +0 -0
  337. package/registry/examples/toast/native/tones.dark.png +0 -0
  338. package/registry/examples/toast/native/tones.light.png +0 -0
  339. package/registry/examples/txt/native/tones.dark.png +0 -0
  340. package/registry/examples/txt/native/tones.light.png +0 -0
  341. package/registry/examples/update-prompt/native/default.dark.png +0 -0
  342. package/registry/examples/update-prompt/native/default.light.png +0 -0
  343. package/registry/examples/verified-badge/native/verified.dark.png +0 -0
  344. package/registry/examples/verified-badge/native/verified.light.png +0 -0
  345. package/registry/examples/vertical-video/native/rail.dark.png +0 -0
  346. package/registry/examples/vertical-video/native/rail.light.png +0 -0
  347. package/registry/examples/waveform/native/card.dark.png +0 -0
  348. package/registry/examples/waveform/native/card.light.png +0 -0
  349. package/registry/force-update-screen.json +1 -1
  350. package/registry/form.json +6 -6
  351. package/registry/glow-card.json +19 -19
  352. package/registry/index.json +20 -21
  353. package/registry/iso.json +7 -7
  354. package/registry/landing-page.json +2 -2
  355. package/registry/landscape.json +1 -1
  356. package/registry/link-card.json +15 -24
  357. package/registry/map.json +235 -6
  358. package/registry/marketing-layout.json +3 -3
  359. package/registry/media-gallery.json +1 -1
  360. package/registry/nav-bar.json +3 -3
  361. package/registry/pointer-light.json +17 -22
  362. package/registry/pricing-page.json +2 -2
  363. package/registry/product-page.json +2 -2
  364. package/registry/progress.json +2 -2
  365. package/registry/radio-group.json +1 -1
  366. package/registry/rail-nav.json +2 -2
  367. package/registry/service-card.json +1 -1
  368. package/registry/settings-layout.json +3 -3
  369. package/registry/site-footer.json +2 -2
  370. package/registry/slider.json +1 -1
  371. package/registry/split-view.json +2 -2
  372. package/registry/status-page.json +1 -1
  373. package/registry/sticky-action.json +2 -2
  374. package/registry/update-prompt.json +1 -1
  375. package/registry/video-player.json +1 -1
  376. package/dist/chunk-6ZYRUOTA.js.map +0 -1
  377. package/dist/chunk-AB5D3KBG.js +0 -37
  378. package/dist/chunk-AB5D3KBG.js.map +0 -1
  379. package/dist/chunk-APNIVNIX.js.map +0 -1
  380. package/dist/chunk-CHOJUCVE.js.map +0 -1
  381. package/dist/chunk-DZU6RMYD.js.map +0 -1
  382. package/dist/chunk-HAQWKORZ.js.map +0 -1
  383. package/dist/chunk-KM6VAKIE.js.map +0 -1
  384. package/dist/chunk-MQPC3P4T.js.map +0 -1
  385. package/dist/chunk-TFJDIOFK.js.map +0 -1
  386. package/registry/examples/argument-card/hover-light.js +0 -17
  387. package/registry/examples/argument-card/hover-light.tsx +0 -19
  388. package/registry/examples/link-card/hover-light.d.ts +0 -4
  389. package/registry/examples/link-card/hover-light.js +0 -18
  390. package/registry/examples/link-card/hover-light.tsx +0 -20
  391. /package/dist/{chunk-GYKQ52HX.js.map → chunk-2AJ4NJUF.js.map} +0 -0
  392. /package/dist/{chunk-LYOPPBMC.js.map → chunk-KIBICUZK.js.map} +0 -0
  393. /package/dist/{chunk-HBZPJ3EP.js.map → chunk-NJFCSVPT.js.map} +0 -0
  394. /package/registry/examples/{argument-card/hover-light.d.ts → map/route.d.ts} +0 -0
package/registry/map.json CHANGED
@@ -38,6 +38,7 @@
38
38
  "bestPractices": [
39
39
  "Give every map a `label` and every marker a `label`: they are read as the list of places in place of the map.",
40
40
  "Always pass a `fallback` (`StaticMap` with the same centre and places): it shows while the engine loads and for good without WebGL.",
41
+ "Motion on a map: `Marker drop={index * 90}` drops pins in turn, `Route` draws its line once (and a `courier` makes one trip, `repeatAfter` repeats it while the map is on screen), `Pulse` beats around a hot spot. The drawing is finite — nothing runs afterwards — and everything is in place at once under reduced motion.",
41
42
  "Ask for the position only on a press (`LocationPicker`'s Near me), never on load; every refusal has its words.",
42
43
  "Keep the attribution visible (OpenStreetMap's licence, the ODbL, asks for it): the components draw it.",
43
44
  "Install the engine only where a map is shown: `maplibre-gl` (web) or `@maplibre/maplibre-react-native` (Expo) — optional peers."
@@ -71,7 +72,7 @@
71
72
  ],
72
73
  "web": {
73
74
  "entry": "@krizaka/ui/map",
74
- "import": "import { Map, Marker, Cluster, UserPosition, RadiusCircle, LocationPicker, StaticMap, mapStyle, distanceMeters, formatDistance } from \"@krizaka/ui/map\";",
75
+ "import": "import { Map, Marker, Cluster, UserPosition, RadiusCircle, Route, Pulse, LocationPicker, StaticMap, mapStyle, distanceMeters, formatDistance, pathLength, pointAlong, sliceLine } from \"@krizaka/ui/map\";",
75
76
  "props": [
76
77
  {
77
78
  "component": "MAP_GLYPHS",
@@ -366,6 +367,13 @@
366
367
  "description": "What it draws instead of the accent dot (an avatar, an icon).",
367
368
  "required": false
368
369
  },
370
+ {
371
+ "name": "drop",
372
+ "type": "number",
373
+ "default": null,
374
+ "description": "Drops onto its place once, after this many milliseconds (stagger several: `index * 90`). Still under reduced motion.",
375
+ "required": false
376
+ },
369
377
  {
370
378
  "name": "onPress",
371
379
  "type": "(() => void)",
@@ -389,6 +397,40 @@
389
397
  }
390
398
  ]
391
399
  },
400
+ {
401
+ "component": "Pulse",
402
+ "description": "A heat pulse: rings that swell and fade around a place (a hot spot, a demand peak). Still rings under reduced motion.",
403
+ "props": [
404
+ {
405
+ "name": "label",
406
+ "type": "string",
407
+ "default": null,
408
+ "description": "Its words (\"High demand in Mile End\"), read in the list of places.",
409
+ "required": true
410
+ },
411
+ {
412
+ "name": "position",
413
+ "type": "LngLat",
414
+ "default": null,
415
+ "description": "Where it beats.",
416
+ "required": true
417
+ },
418
+ {
419
+ "name": "size",
420
+ "type": "number",
421
+ "default": "56",
422
+ "description": "The rings' reach in pixels. Default 56.",
423
+ "required": false
424
+ },
425
+ {
426
+ "name": "tone",
427
+ "type": "\"accent\" | \"soft\"",
428
+ "default": "accent",
429
+ "description": "`accent` (a zone that matters) or `soft`. Default `accent`.",
430
+ "required": false
431
+ }
432
+ ]
433
+ },
392
434
  {
393
435
  "component": "RadiusCircle",
394
436
  "description": "A circle on the ground: a delivery zone, a search radius — filled with the accent, its edge drawn.",
@@ -416,6 +458,75 @@
416
458
  }
417
459
  ]
418
460
  },
461
+ {
462
+ "component": "Route",
463
+ "description": "A route on the map: a line that draws itself, and a courier that travels it. Under reduced motion the line is whole and the courier rests at the end.",
464
+ "props": [
465
+ {
466
+ "name": "label",
467
+ "type": "string",
468
+ "default": null,
469
+ "description": "Its words (\"Delivery from the studio to Mile End, 2.4 km\"), read in the list of places.",
470
+ "required": true
471
+ },
472
+ {
473
+ "name": "path",
474
+ "type": "readonly LngLat[]",
475
+ "default": null,
476
+ "description": "The path, as positions in order (a polyline: a delivery, a walk, a flight).",
477
+ "required": true
478
+ },
479
+ {
480
+ "name": "courier",
481
+ "type": "ReactNode",
482
+ "default": null,
483
+ "description": "A dot that travels the route once drawn (`true`, or your own content: an avatar, an icon).",
484
+ "required": false
485
+ },
486
+ {
487
+ "name": "draw",
488
+ "type": "boolean",
489
+ "default": "true",
490
+ "description": "The line draws itself once, from its start, when the map is on screen; `false` shows it whole. Default `true`.",
491
+ "required": false
492
+ },
493
+ {
494
+ "name": "duration",
495
+ "type": "number",
496
+ "default": "1400",
497
+ "description": "How long the drawing lasts, in milliseconds. Default 1400.",
498
+ "required": false
499
+ },
500
+ {
501
+ "name": "repeatAfter",
502
+ "type": "number",
503
+ "default": null,
504
+ "description": "Start the trips again after a pause (milliseconds), only while the map is on screen. Without it the courier makes one trip.",
505
+ "required": false
506
+ },
507
+ {
508
+ "name": "replayKey",
509
+ "type": "string | number",
510
+ "default": null,
511
+ "description": "Changing it replays the drawing and the trip.",
512
+ "required": false
513
+ },
514
+ {
515
+ "name": "tone",
516
+ "type": "\"accent\" | \"soft\"",
517
+ "default": "accent",
518
+ "description": "`accent` or `soft`. Default `accent`.",
519
+ "required": false
520
+ },
521
+ {
522
+ "name": "tripDuration",
523
+ "type": "number",
524
+ "default": "3200",
525
+ "description": "How long a trip takes, in milliseconds. Default 3200.",
526
+ "required": false
527
+ }
528
+ ]
529
+ },
419
530
  {
420
531
  "component": "UserPosition",
421
532
  "description": "The person's position: a dot that pulses (still under reduced motion), its accuracy as a soft disc.",
@@ -522,6 +633,14 @@
522
633
  "module": "@krizaka/ui/registry/examples/map/location-picker",
523
634
  "code": "\"use client\";\n\nimport { type LngLat, LocationPicker, StaticMap } from \"@krizaka/ui/map\";\nimport * as React from \"react\";\n\n// A meeting point picked by moving the map under the pin: its distance from the person, a 1-km zone, \"Near me\" on request.\nexport default function MapLocationPicker() {\n const [point, setPoint] = React.useState<LngLat>([-73.5673, 45.5017]);\n return (\n <div className=\"w-full max-w-xl\">\n <LocationPicker\n label=\"Meeting point\"\n locale=\"en-CA\"\n value={point}\n onValueChange={setPoint}\n origin={[-73.5794, 45.5048]}\n radius={1000}\n fallback={<StaticMap label=\"Meeting point\" center={point} zoom={14} places={[{ position: point, label: \"Meeting point\" }]} />}\n />\n </div>\n );\n}\n"
524
635
  },
636
+ {
637
+ "name": "route",
638
+ "title": "Route",
639
+ "description": "A delivery: pins that drop, a route that draws itself, a courier that travels it, a demand pulse — still under reduced motion.",
640
+ "path": "examples/map/route.tsx",
641
+ "module": "@krizaka/ui/registry/examples/map/route",
642
+ "code": "\"use client\";\n\nimport { Button } from \"@krizaka/ui/button\";\nimport { Map, Marker, Pulse, Route, StaticMap } from \"@krizaka/ui/map\";\nimport * as React from \"react\";\n\nconst STUDIO = [-73.6005, 45.5229] as const;\nconst HOME = [-73.5695, 45.5268] as const;\nconst PATH = [STUDIO, [-73.5935, 45.5232], [-73.5862, 45.5159], [-73.5753, 45.5171], HOME] as const;\n\n// A delivery in Montréal: the pins drop (staggered), the route draws itself, a courier makes the trip, a demand pulse\n// beats at the destination. Under reduced motion everything is in place at once, the courier at the end.\nexport default function MapRoute() {\n const [run, setRun] = React.useState(0);\n return (\n <div className=\"flex w-full max-w-xl flex-col gap-3\">\n <Map\n key={run}\n label=\"A delivery from the studio to Parc La Fontaine\"\n center={[-73.585, 45.521]}\n zoom={13.2}\n className=\"h-80 w-full\"\n fallback={<StaticMap label=\"A delivery from the studio to Parc La Fontaine\" center={[-73.585, 45.521]} zoom={13.2} places={[{ position: STUDIO, label: \"Studio\" }, { position: HOME, label: \"Parc La Fontaine\" }]} />}\n >\n <Route path={PATH} label=\"Delivery from the studio to Parc La Fontaine, 3 km\" courier tripDuration={3600} />\n <Marker position={STUDIO} label=\"Studio\" drop={0} />\n <Marker position={HOME} label=\"Parc La Fontaine\" drop={260} />\n <Pulse position={HOME} label=\"Orders are picking up around Parc La Fontaine\" size={64} />\n </Map>\n <Button variant=\"secondary\" size=\"sm\" onClick={() => setRun((n) => n + 1)}>\n Replay\n </Button>\n </div>\n );\n}\n"
643
+ },
525
644
  {
526
645
  "name": "static",
527
646
  "title": "Static map",
@@ -901,11 +1020,11 @@
901
1020
  },
902
1021
  {
903
1022
  "path": "map/geo.ts",
904
- "content": "/// <reference types=\"geojson\" />\n\n// Plain geometry for the maps, web and native (no DOM, no React): distances, circles, the Web Mercator projection.\n\n/** A position: longitude, latitude (GeoJSON's order, MapLibre's `LngLat`). */\nexport type LngLat = readonly [lng: number, lat: number];\n\nconst R = 6_371_008.8;\nconst rad = (d: number) => (d * Math.PI) / 180;\n\n/** The great-circle distance between two positions, in metres (haversine). */\nexport function distanceMeters(a: LngLat, b: LngLat): number {\n const dLat = rad(b[1] - a[1]);\n const dLng = rad(b[0] - a[0]);\n const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a[1])) * Math.cos(rad(b[1])) * Math.sin(dLng / 2) ** 2;\n return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));\n}\n\n/** A circle of `radius` metres around `center` as a GeoJSON polygon (`steps` points): a delivery zone, a search radius. */\nexport function circlePolygon(center: LngLat, radius: number, steps = 64): GeoJSON.Feature<GeoJSON.Polygon> {\n const [lng, lat] = center;\n const d = radius / R;\n const ring: [number, number][] = [];\n for (let i = 0; i <= steps; i++) {\n const brg = (2 * Math.PI * i) / steps;\n const lat2 = Math.asin(Math.sin(rad(lat)) * Math.cos(d) + Math.cos(rad(lat)) * Math.sin(d) * Math.cos(brg));\n const lng2 = rad(lng) + Math.atan2(Math.sin(brg) * Math.sin(d) * Math.cos(rad(lat)), Math.cos(d) - Math.sin(rad(lat)) * Math.sin(lat2));\n ring.push([(lng2 * 180) / Math.PI, (lat2 * 180) / Math.PI]);\n }\n return { type: \"Feature\", properties: {}, geometry: { type: \"Polygon\", coordinates: [ring] } };\n}\n\n/** The Web Mercator pixel of a position at a zoom (256-px tiles): what a static map draws without an engine. */\nexport function project([lng, lat]: LngLat, zoom: number): [number, number] {\n const scale = 256 * 2 ** zoom;\n const s = Math.sin(rad(Math.max(-85.0511, Math.min(85.0511, lat))));\n return [((lng + 180) / 360) * scale, (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * scale];\n}\n\n/** A distance written the locale's way: metres under 1 km, kilometres (one decimal under 10) above. */\nexport function formatDistance(meters: number, locale: string): string {\n if (meters < 1000) return new Intl.NumberFormat(locale, { style: \"unit\", unit: \"meter\", maximumFractionDigits: 0 }).format(Math.round(meters / 10) * 10);\n return new Intl.NumberFormat(locale, { style: \"unit\", unit: \"kilometer\", maximumFractionDigits: meters < 10_000 ? 1 : 0 }).format(meters / 1000);\n}\n"
1023
+ "content": "/// <reference types=\"geojson\" />\n\n// Plain geometry for the maps, web and native (no DOM, no React): distances, circles, the Web Mercator projection.\n\n/** A position: longitude, latitude (GeoJSON's order, MapLibre's `LngLat`). */\nexport type LngLat = readonly [lng: number, lat: number];\n\nconst R = 6_371_008.8;\nconst rad = (d: number) => (d * Math.PI) / 180;\n\n/** The great-circle distance between two positions, in metres (haversine). */\nexport function distanceMeters(a: LngLat, b: LngLat): number {\n const dLat = rad(b[1] - a[1]);\n const dLng = rad(b[0] - a[0]);\n const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a[1])) * Math.cos(rad(b[1])) * Math.sin(dLng / 2) ** 2;\n return 2 * R * Math.asin(Math.min(1, Math.sqrt(h)));\n}\n\n/** A circle of `radius` metres around `center` as a GeoJSON polygon (`steps` points): a delivery zone, a search radius. */\nexport function circlePolygon(center: LngLat, radius: number, steps = 64): GeoJSON.Feature<GeoJSON.Polygon> {\n const [lng, lat] = center;\n const d = radius / R;\n const ring: [number, number][] = [];\n for (let i = 0; i <= steps; i++) {\n const brg = (2 * Math.PI * i) / steps;\n const lat2 = Math.asin(Math.sin(rad(lat)) * Math.cos(d) + Math.cos(rad(lat)) * Math.sin(d) * Math.cos(brg));\n const lng2 = rad(lng) + Math.atan2(Math.sin(brg) * Math.sin(d) * Math.cos(rad(lat)), Math.cos(d) - Math.sin(rad(lat)) * Math.sin(lat2));\n ring.push([(lng2 * 180) / Math.PI, (lat2 * 180) / Math.PI]);\n }\n return { type: \"Feature\", properties: {}, geometry: { type: \"Polygon\", coordinates: [ring] } };\n}\n\n/** The Web Mercator pixel of a position at a zoom (256-px tiles): what a static map draws without an engine. */\nexport function project([lng, lat]: LngLat, zoom: number): [number, number] {\n const scale = 256 * 2 ** zoom;\n const s = Math.sin(rad(Math.max(-85.0511, Math.min(85.0511, lat))));\n return [((lng + 180) / 360) * scale, (0.5 - Math.log((1 + s) / (1 - s)) / (4 * Math.PI)) * scale];\n}\n\n/** A distance written the locale's way: metres under 1 km, kilometres (one decimal under 10) above. */\nexport function formatDistance(meters: number, locale: string): string {\n if (meters < 1000) return new Intl.NumberFormat(locale, { style: \"unit\", unit: \"meter\", maximumFractionDigits: 0 }).format(Math.round(meters / 10) * 10);\n return new Intl.NumberFormat(locale, { style: \"unit\", unit: \"kilometer\", maximumFractionDigits: meters < 10_000 ? 1 : 0 }).format(meters / 1000);\n}\n\n/** The length of a path of positions, in metres (the sum of its great-circle legs). */\nexport function pathLength(path: readonly LngLat[]): number {\n let total = 0;\n for (let i = 1; i < path.length; i++) total += distanceMeters(path[i - 1]!, path[i]!);\n return total;\n}\n\n/** The position at `fraction` (0–1, clamped) of the path's length, with the heading of the leg it is on (degrees clockwise from north). */\nexport function pointAlong(path: readonly LngLat[], fraction: number): { position: LngLat; heading: number } {\n const first = path[0] ?? [0, 0];\n if (path.length < 2) return { position: first, heading: 0 };\n const target = Math.max(0, Math.min(1, fraction)) * pathLength(path);\n let walked = 0;\n for (let i = 1; i < path.length; i++) {\n const a = path[i - 1]!;\n const b = path[i]!;\n const leg = distanceMeters(a, b);\n if (walked + leg >= target || i === path.length - 1) {\n const t = leg === 0 ? 0 : Math.max(0, Math.min(1, (target - walked) / leg));\n const heading = (Math.atan2(b[0] - a[0], b[1] - a[1]) * 180) / Math.PI;\n return { position: [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t], heading: (heading + 360) % 360 };\n }\n walked += leg;\n }\n return { position: first, heading: 0 };\n}\n\n/** The first `fraction` (0–1) of the path, as a GeoJSON line: what a route has drawn so far. */\nexport function sliceLine(path: readonly LngLat[], fraction: number): GeoJSON.Feature<GeoJSON.LineString> {\n const f = Math.max(0, Math.min(1, fraction));\n const total = pathLength(path);\n const coordinates: [number, number][] = [];\n if (path.length) coordinates.push([...path[0]!] as [number, number]);\n let walked = 0;\n for (let i = 1; i < path.length && f > 0; i++) {\n const a = path[i - 1]!;\n const b = path[i]!;\n const leg = distanceMeters(a, b);\n if (walked + leg <= f * total) coordinates.push([...b] as [number, number]);\n else {\n const t = leg === 0 ? 0 : (f * total - walked) / leg;\n coordinates.push([a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]);\n break;\n }\n walked += leg;\n }\n if (coordinates.length === 1) coordinates.push([...coordinates[0]!]);\n return { type: \"Feature\", properties: {}, geometry: { type: \"LineString\", coordinates } };\n}\n"
905
1024
  },
906
1025
  {
907
1026
  "path": "map/index.ts",
908
- "content": "/**\n * @krizaka/ui/map — maps on MapLibre GL JS (open source) over Protomaps tiles on the Krizaka CDN, styled from the\n * tokens: `Map`, `Marker`, `Cluster`, `UserPosition`, `RadiusCircle`, `LocationPicker`, `StaticMap` (the fallback), the\n * style (`mapStyle`, `mapPalette`) and the geometry (`distanceMeters`, `circlePolygon`, `formatDistance`). maplibre-gl\n * and pmtiles are optional peers, loaded when a map is shown. Client (\"use client\").\n */\nexport { MAP_ATTRIBUTION, MAP_GLYPHS, MAP_TILES, mapAssets, setMapAssetsBase } from \"./assets\";\nexport { circlePolygon, distanceMeters, formatDistance, type LngLat, project } from \"./geo\";\nexport { LocationPicker, locationPicker, type LocationPickerLabels, locationPickerLabels, type LocationPickerProps } from \"./location-picker\";\nexport {\n Cluster,\n type ClusterPoint,\n type ClusterProps,\n configureMaps,\n Map,\n map,\n type MapLabels,\n mapLabels,\n type MapProps,\n Marker,\n type MarkerProps,\n RadiusCircle,\n type RadiusCircleProps,\n useMap,\n UserPosition,\n type UserPositionProps,\n} from \"./map\";\nexport { StaticMap, staticMap, type StaticMapPlace, type StaticMapProps } from \"./static-map\";\nexport { type MapBrand, mapFlavor, mapPalette, type MapRegion, mapStyle, type MapStyleOptions, mix, REGION_FROM_ZOOM } from \"./style\";\n"
1027
+ "content": "/**\n * @krizaka/ui/map — maps on MapLibre GL JS (open source) over Protomaps tiles on the Krizaka CDN, styled from the\n * tokens: `Map`, `Marker`, `Cluster`, `UserPosition`, `RadiusCircle`, `LocationPicker`, `StaticMap` (the fallback), the\n * style (`mapStyle`, `mapPalette`) and the geometry (`distanceMeters`, `circlePolygon`, `formatDistance`). maplibre-gl\n * and pmtiles are optional peers, loaded when a map is shown. Client (\"use client\").\n */\nexport { MAP_ATTRIBUTION, MAP_GLYPHS, MAP_TILES, mapAssets, setMapAssetsBase } from \"./assets\";\nexport { circlePolygon, distanceMeters, formatDistance, type LngLat, pathLength, pointAlong, project, sliceLine } from \"./geo\";\nexport { LocationPicker, locationPicker, type LocationPickerLabels, locationPickerLabels, type LocationPickerProps } from \"./location-picker\";\nexport {\n Cluster,\n type ClusterPoint,\n type ClusterProps,\n configureMaps,\n Map,\n map,\n type MapLabels,\n mapLabels,\n type MapProps,\n Marker,\n type MarkerProps,\n Pulse,\n type PulseProps,\n RadiusCircle,\n type RadiusCircleProps,\n Route,\n type RouteProps,\n useMap,\n UserPosition,\n type UserPositionProps,\n} from \"./map\";\nexport { StaticMap, staticMap, type StaticMapPlace, type StaticMapProps } from \"./static-map\";\nexport { type MapBrand, mapFlavor, mapPalette, type MapRegion, mapStyle, type MapStyleOptions, mix, REGION_FROM_ZOOM } from \"./style\";\n"
909
1028
  },
910
1029
  {
911
1030
  "path": "map/location-picker.tsx",
@@ -913,11 +1032,11 @@
913
1032
  },
914
1033
  {
915
1034
  "path": "map/map.tsx",
916
- "content": "// Client. A map of the Krizaka family on MapLibre GL JS (open source) over Protomaps tiles on our CDN — no API key, no\n// metered service. MapLibre and PMTiles are optional peers, loaded when a map is shown (a dynamic import: a page without\n// a map never pays for them). The style comes from the tokens (`mapStyle`), in the page's scheme, and follows it.\n//\n// What every map does: the canvas is decorative for assistive technologies — the map is a region named by `label`, its\n// places a list read in order (the text alternative, `MapPlaces`), each marker a button when it does something; the\n// attribution of OpenStreetMap (ODbL) and Protomaps is always shown; under reduced motion the camera jumps, never flies;\n// without WebGL (or while the engine loads) `fallback` stands in its place.\n/// <reference types=\"geojson\" />\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\n\nimport { usePrefersReducedMotion } from \"../media\";\nimport { tv } from \"../tv\";\nimport { MAP_ATTRIBUTION, setMapAssetsBase } from \"./assets\";\nimport { circlePolygon, type LngLat } from \"./geo\";\nimport { type MapBrand, mapPalette, type MapRegion, mapStyle } from \"./style\";\n\ntype MapLibre = typeof import(\"maplibre-gl\");\ntype MapInstance = import(\"maplibre-gl\").Map;\n\nexport const map = tv({\n slots: {\n root: \"relative isolate overflow-hidden rounded-2xl border border-border-default bg-surface-2 shadow-raised\",\n canvas: \"absolute inset-0 [&_.maplibregl-canvas]:outline-hidden\",\n fallback: \"absolute inset-0\",\n controls: \"absolute right-3 top-3 z-10 flex flex-col overflow-hidden rounded-xl border border-border-default bg-surface-1 shadow-floating\",\n control:\n \"inline-flex h-10 w-10 items-center justify-center text-lg font-semibold text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg \" +\n \"focus-visible:outline-hidden focus-visible:focus-ring-inset disabled:opacity-40 [&+&]:border-t [&+&]:border-border-subtle\",\n attribution:\n \"absolute bottom-1.5 right-1.5 z-10 rounded-md bg-surface-1/85 px-1.5 py-0.5 text-[10px] text-fg-secondary backdrop-blur-sm [&_a]:underline-offset-2 hover:[&_a]:underline\",\n places: \"sr-only\",\n marker:\n \"group relative flex -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border-2 border-on-accent bg-accent shadow-floating outline-hidden \" +\n \"transition-transform hover:scale-110 focus-visible:focus-ring motion-reduce:transition-none motion-reduce:hover:scale-100 data-[selected]:scale-125\",\n markerLabel:\n \"pointer-events-none absolute bottom-full mb-2 whitespace-nowrap rounded-lg border border-border-default bg-surface-1 px-2 py-1 text-xs font-semibold text-fg shadow-floating\",\n },\n});\n\ntype Registry = { add: (id: string, text: string) => void; remove: (id: string) => void };\ntype Ctx = { map: MapInstance | null; lib: MapLibre | null; ready: boolean; reduce: boolean; places: Registry };\nconst MapContext = React.createContext<Ctx | null>(null);\n\n/** The map a child is drawn on (`Marker`, `RadiusCircle`, `Cluster`…): the MapLibre instance once it is ready. */\nexport function useMap(): Ctx {\n const ctx = React.useContext(MapContext);\n if (!ctx) throw new Error(\"useMap: inside a <Map> only\");\n return ctx;\n}\n\n/** The scheme of the page (`html.light`), followed as it changes. */\nfunction usePageScheme(): \"dark\" | \"light\" {\n const read = () => (typeof document !== \"undefined\" && document.documentElement.classList.contains(\"light\") ? \"light\" : \"dark\");\n const [scheme, setScheme] = React.useState<\"dark\" | \"light\">(read);\n React.useEffect(() => {\n const observer = new MutationObserver(() => setScheme(read()));\n observer.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n return () => observer.disconnect();\n }, []);\n return scheme;\n}\n\nlet protocolAdded = false;\nlet workerUrl: string | null = null;\n\n/**\n * Where MapLibre's worker comes from. A bundler (Next.js, Vite) renames MapLibre's module and leaves its worker behind:\n * by default the worker of the very version the app installed is imported from jsDelivr (free, open source, no key)\n * through a module worker of the page's own origin. An app with a strict CSP serves it itself —\n * `configureMaps({ workerUrl: \"/maplibre-gl-worker.mjs\" })` (with `maplibre-gl-shared.mjs` beside it), once, before a map.\n * `assetsBase` reads the tiles and glyphs through a same-origin proxy of the CDN (`setMapAssetsBase`).\n */\nexport function configureMaps(options: { workerUrl?: string; assetsBase?: string }) {\n if (options.workerUrl) workerUrl = options.workerUrl;\n if (options.assetsBase) setMapAssetsBase(options.assetsBase);\n}\n\n/** MapLibre, its worker and the PMTiles protocol, loaded once for the page. */\nasync function loadEngine(): Promise<MapLibre> {\n const [lib, pmtiles] = await Promise.all([import(\"maplibre-gl\"), import(\"pmtiles\")]);\n const ml = ((lib as unknown as { default?: MapLibre }).default ?? lib) as MapLibre;\n if (!protocolAdded) {\n protocolAdded = true;\n const remote = `https://cdn.jsdelivr.net/npm/maplibre-gl@${ml.getVersion()}/dist/maplibre-gl-worker.mjs`;\n ml.setWorkerUrl(workerUrl ?? URL.createObjectURL(new Blob([`import ${JSON.stringify(remote)};`], { type: \"text/javascript\" })));\n ml.addProtocol(\"pmtiles\", new pmtiles.Protocol().tile);\n }\n return ml;\n}\n\nconst hasWebGL = () => {\n try {\n const canvas = document.createElement(\"canvas\");\n return Boolean(canvas.getContext(\"webgl2\") ?? canvas.getContext(\"webgl\"));\n } catch {\n return false;\n }\n};\n\nexport type MapLabels = {\n /** The zoom-in button (\"Zoom in\"). */\n zoomIn: string;\n /** The zoom-out button (\"Zoom out\"). */\n zoomOut: string;\n /** Before the list of places read in place of the map (\"Places on the map\"). */\n places: string;\n};\n\nexport const mapLabels: MapLabels = { zoomIn: \"Zoom in\", zoomOut: \"Zoom out\", places: \"Places on the map\" };\n\nexport type MapProps = {\n /** The map's accessible name (\"Pick-up points near you\"), passed translated. */\n label: string;\n /** Where the map looks at first: longitude, latitude. */\n center: LngLat;\n /** The zoom at first (0 the world, 12 a city, 16 a street). Default 12. */\n zoom?: number;\n /** The camera follows `center` and `zoom` when they change (flies, or jumps under reduced motion). */\n controlled?: boolean;\n /** The scheme. Default: the page's (`html.light`), followed. */\n scheme?: \"dark\" | \"light\";\n /** The product whose accent the map carries. Default `krizaka`. */\n brand?: MapBrand;\n /** The language of the labels (`fr`, `en`). Default `en`. */\n lang?: string;\n /** The detailed region (PMTiles) drawn from zoom 6.5. Default: our Montréal–Québec extract (to zoom 14). */\n region?: MapRegion | null;\n /** The world's low zooms (PMTiles). Default: our world extract (to zoom 6). */\n world?: MapRegion | null;\n /** The zoom buttons (default) — the keyboard also zooms with + and − on the focused map. */\n controls?: boolean;\n /** Moves with the hand (default); `false` makes a still picture of a place. */\n interactive?: boolean;\n /** Shown in place of the map while its engine loads, and for good without WebGL: a `StaticMap`, a picture. */\n fallback?: React.ReactNode;\n /** Called when the camera rests: its centre and zoom. */\n onMoveEnd?: (view: { center: LngLat; zoom: number }) => void;\n /** Called with the position pressed on the map (not on a marker). */\n onPress?: (position: LngLat) => void;\n /** Markers, circles, clusters, the user's position — and anything drawn over the map. */\n children?: React.ReactNode;\n /** The words, passed translated; English by default. */\n labels?: Partial<MapLabels>;\n /** Classes merged on the root (its height: `h-80`, `aspect-video`). */\n className?: string;\n};\n\n/** A map: MapLibre over our Protomaps tiles, styled from the tokens, with its places as a list for screen readers. */\nexport function Map({\n label,\n center,\n zoom = 12,\n controlled,\n scheme: schemeProp,\n brand,\n lang,\n region,\n world,\n controls = true,\n interactive = true,\n fallback,\n onMoveEnd,\n onPress,\n children,\n labels: given,\n className,\n}: MapProps) {\n const labels = { ...mapLabels, ...given };\n const s = map();\n const pageScheme = usePageScheme();\n const scheme = schemeProp ?? pageScheme;\n const reduce = usePrefersReducedMotion();\n const host = React.useRef<HTMLDivElement>(null);\n const [instance, setInstance] = React.useState<MapInstance | null>(null);\n const [lib, setLib] = React.useState<MapLibre | null>(null);\n const [ready, setReady] = React.useState(false);\n const [failed, setFailed] = React.useState(false);\n const [places, setPlaces] = React.useState<Record<string, string>>({});\n const registry = React.useMemo<Registry>(\n () => ({\n add: (id, text) => setPlaces((p) => (p[id] === text ? p : { ...p, [id]: text })),\n remove: (id) =>\n setPlaces((p) => {\n const { [id]: _gone, ...rest } = p;\n return rest;\n }),\n }),\n [],\n );\n const latest = React.useRef({ onMoveEnd, onPress });\n React.useEffect(() => {\n latest.current = { onMoveEnd, onPress };\n });\n const styleOf = React.useCallback((sc: \"dark\" | \"light\") => mapStyle({ scheme: sc, brand, lang, ...(region !== undefined ? { region } : {}), ...(world !== undefined ? { world } : {}) }), [brand, lang, region, world]);\n\n // The engine and the map, once (the style follows the scheme below).\n React.useEffect(() => {\n if (!host.current) return;\n let alive = true;\n let created: MapInstance | null = null;\n (hasWebGL() ? loadEngine() : Promise.reject(new Error(\"no WebGL\")))\n .then((ml) => {\n if (!alive || !host.current) return;\n created = new ml.Map({\n container: host.current,\n style: styleOf(scheme) as never,\n center: [...center] as [number, number],\n zoom,\n interactive,\n attributionControl: false,\n fadeDuration: reduce ? 0 : 300,\n cooperativeGestures: false,\n });\n created.getCanvas().setAttribute(\"aria-hidden\", \"true\");\n created.getCanvas().setAttribute(\"tabindex\", \"-1\");\n created.on(\"load\", () => alive && setReady(true));\n // The basemap cannot be read (offline, a blocked request): the fallback comes back rather than an empty map.\n created.on(\"error\", (e) => {\n const source = (e as { sourceId?: string }).sourceId;\n if (alive && (source === \"world\" || source === \"region\")) setFailed(true);\n });\n created.on(\"moveend\", () => {\n const c = created!.getCenter();\n latest.current.onMoveEnd?.({ center: [c.lng, c.lat], zoom: created!.getZoom() });\n });\n created.on(\"click\", (e) => {\n if ((e.originalEvent.target as Element | null)?.closest?.(\"[data-kz-marker]\")) return;\n latest.current.onPress?.([e.lngLat.lng, e.lngLat.lat]);\n });\n setLib(ml);\n setInstance(created);\n })\n .catch(() => alive && setFailed(true));\n return () => {\n alive = false;\n created?.remove();\n };\n // Created once: the props that change afterwards are followed by the effects below.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // The scheme (and the brand, the language) change the style, not the map.\n const first = React.useRef(true);\n React.useEffect(() => {\n if (!instance) return;\n if (first.current) {\n first.current = false;\n return;\n }\n instance.setStyle(styleOf(scheme) as never);\n }, [instance, scheme, styleOf]);\n\n // A controlled camera: it flies (or jumps) to the new view.\n React.useEffect(() => {\n if (!instance || !controlled) return;\n const view = { center: [...center] as [number, number], zoom };\n if (reduce) instance.jumpTo(view);\n else instance.flyTo({ ...view, speed: 1.4 });\n }, [instance, controlled, center[0], center[1], zoom, reduce]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const zoomBy = (by: number) => instance && (reduce ? instance.setZoom(instance.getZoom() + by) : instance.zoomTo(instance.getZoom() + by, { duration: 250 }));\n const ctx = React.useMemo<Ctx>(() => ({ map: instance, lib, ready, reduce, places: registry }), [instance, lib, ready, reduce, registry]);\n const listed = Object.entries(places);\n return (\n <MapContext.Provider value={ctx}>\n <section\n aria-label={label}\n className={s.root({ className })}\n data-ready={ready ? \"\" : undefined}\n tabIndex={interactive ? 0 : undefined}\n onKeyDown={(e) => {\n if (e.target !== e.currentTarget || !instance) return;\n if (e.key === \"+\" || e.key === \"=\") zoomBy(1);\n else if (e.key === \"-\") zoomBy(-1);\n else if (e.key.startsWith(\"Arrow\")) {\n e.preventDefault();\n const step = 80;\n const by: Record<string, [number, number]> = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0] };\n instance.panBy(by[e.key]!, { duration: reduce ? 0 : 200 });\n }\n }}\n >\n {!ready || failed ? <div className={s.fallback()}>{fallback}</div> : null}\n <div ref={host} className={s.canvas()} style={{ position: \"absolute\", inset: 0, visibility: failed ? \"hidden\" : undefined }} />\n {controls && interactive && !failed ? (\n <div className={s.controls()}>\n <button type=\"button\" className={s.control()} aria-label={labels.zoomIn} disabled={!instance} onClick={() => zoomBy(1)}>\n +\n </button>\n <button type=\"button\" className={s.control()} aria-label={labels.zoomOut} disabled={!instance} onClick={() => zoomBy(-1)}>\n −\n </button>\n </div>\n ) : null}\n <p className={s.attribution()}>\n <a href={MAP_ATTRIBUTION.protomapsUrl} target=\"_blank\" rel=\"noreferrer\">\n {MAP_ATTRIBUTION.protomaps}\n </a>\n {\" · \"}\n <a href={MAP_ATTRIBUTION.osmUrl} target=\"_blank\" rel=\"noreferrer\">\n {MAP_ATTRIBUTION.osm}\n </a>\n </p>\n {listed.length ? (\n <div className={s.places()}>\n <p>{labels.places}</p>\n <ul>\n {listed.map(([id, text]) => (\n <li key={id}>{text}</li>\n ))}\n </ul>\n </div>\n ) : null}\n {children}\n </section>\n </MapContext.Provider>\n );\n}\n\nexport type MarkerProps = {\n /** Where it stands. */\n position: LngLat;\n /** Its words: the place's name, read in the list of places and as the button's name (\"Café Olimpico, 350 m\"). */\n label: string;\n /** Pressing it (details in a panel, a sheet): the marker becomes a button. */\n onPress?: () => void;\n /** The chosen one: larger, its label shown above it. */\n selected?: boolean;\n /** Its size in pixels. Default 18. */\n size?: number;\n /** What it draws instead of the accent dot (an avatar, an icon). */\n children?: React.ReactNode;\n};\n\nconst noSubscription = () => () => undefined;\n\n/** A place on the map: an accent dot (or your content) that is a button when it does something. */\nexport function Marker({ position, label, onPress, selected, size = 18, children }: MarkerProps) {\n const { map: m, lib, places } = useMap();\n const s = map();\n const id = React.useId();\n // The marker's host element exists only in a browser: the server and the hydration pass render nothing for it, then the\n // client swaps in the element — `useSyncExternalStore`'s server snapshot, so hydration never sees a difference.\n const host = React.useRef<HTMLElement | null>(null);\n const el = React.useSyncExternalStore(\n noSubscription,\n () => (host.current ??= document.createElement(\"div\")),\n () => null,\n );\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n React.useEffect(() => {\n if (!m || !lib || !el) return;\n el.setAttribute(\"data-kz-marker\", \"\");\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...position] as [number, number]).addTo(m);\n return () => void marker.remove();\n }, [m, lib, el, position[0], position[1]]); // eslint-disable-line react-hooks/exhaustive-deps\n if (!el) return null;\n const Tag = onPress ? \"button\" : \"span\";\n return createPortal(\n <Tag\n type={onPress ? \"button\" : undefined}\n aria-label={onPress ? label : undefined}\n aria-hidden={onPress ? undefined : true}\n aria-pressed={onPress && selected !== undefined ? selected : undefined}\n data-selected={selected ? \"\" : undefined}\n className={s.marker()}\n style={children ? undefined : { width: size, height: size }}\n onClick={onPress}\n >\n {children}\n {selected ? <span className={s.markerLabel()}>{label}</span> : null}\n </Tag>,\n el,\n );\n}\n\nexport type UserPositionProps = {\n /** Where the person is. */\n position: LngLat;\n /** Its words (\"You are here\"), read in the list of places. */\n label: string;\n /** The accuracy in metres: a soft disc around the dot. */\n accuracy?: number;\n};\n\n/** The person's position: a dot that pulses (still under reduced motion), its accuracy as a soft disc. */\nexport function UserPosition({ position, label, accuracy }: UserPositionProps) {\n const { map: m, lib, places } = useMap();\n const id = React.useId();\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n React.useEffect(() => {\n if (!m || !lib) return;\n const el = document.createElement(\"div\");\n el.className = \"kz-map-user\";\n el.setAttribute(\"aria-hidden\", \"true\");\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...position] as [number, number]).addTo(m);\n return () => void marker.remove();\n }, [m, lib, position[0], position[1]]); // eslint-disable-line react-hooks/exhaustive-deps\n return accuracy ? <RadiusCircle center={position} radius={accuracy} tone=\"soft\" /> : null;\n}\n\n/** Adds a GeoJSON source and its layers while the map has a style (again after a change of scheme). */\nfunction useGeoLayer(id: string, data: GeoJSON.GeoJSON, layers: (palette: ReturnType<typeof mapPalette>) => object[], source: object = {}) {\n const { map: m, ready } = useMap();\n const dataRef = React.useRef(data);\n React.useEffect(() => {\n dataRef.current = data;\n const src = m?.getSource(id) as { setData?: (d: GeoJSON.GeoJSON) => void } | undefined;\n src?.setData?.(data);\n }, [m, id, data]);\n React.useEffect(() => {\n if (!m || !ready) return;\n const add = () => {\n if (m.getSource(id)) return;\n const scheme = document.documentElement.classList.contains(\"light\") ? \"light\" : \"dark\";\n m.addSource(id, { type: \"geojson\", data: dataRef.current, ...source } as never);\n for (const layer of layers(mapPalette(scheme))) m.addLayer(layer as never);\n };\n add();\n m.on(\"styledata\", add);\n return () => {\n m.off(\"styledata\", add);\n if (!m.getStyle()) return;\n for (const layer of m.getStyle().layers.filter((l) => \"source\" in l && l.source === id)) m.removeLayer(layer.id);\n if (m.getSource(id)) m.removeSource(id);\n };\n }, [m, ready, id]); // eslint-disable-line react-hooks/exhaustive-deps\n}\n\nexport type RadiusCircleProps = {\n /** Its centre. */\n center: LngLat;\n /** Its radius in metres. */\n radius: number;\n /** `accent` (a zone that matters: a delivery area, a search radius) or `soft` (an accuracy). */\n tone?: \"accent\" | \"soft\";\n};\n\n/** A circle on the ground: a delivery zone, a search radius — filled with the accent, its edge drawn. */\nexport function RadiusCircle({ center, radius, tone = \"accent\" }: RadiusCircleProps) {\n const id = `kz-circle-${React.useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n const data = React.useMemo(() => circlePolygon(center, radius), [center[0], center[1], radius]); // eslint-disable-line react-hooks/exhaustive-deps\n useGeoLayer(id, data, (c) => [\n { id: `${id}-fill`, type: \"fill\", source: id, paint: { \"fill-color\": c.accent, \"fill-opacity\": tone === \"soft\" ? 0.1 : 0.14 } },\n { id: `${id}-line`, type: \"line\", source: id, paint: { \"line-color\": c.accent, \"line-width\": tone === \"soft\" ? 1 : 2, \"line-opacity\": tone === \"soft\" ? 0.4 : 0.9, ...(tone === \"accent\" ? { \"line-dasharray\": [2, 1.5] } : {}) } },\n ]);\n return null;\n}\n\nexport type ClusterPoint = { id: string; position: LngLat; label: string };\n\nexport type ClusterProps = {\n /** The points: many of them (creators, pick-up points, events). */\n points: readonly ClusterPoint[];\n /** Pressing a single point. A group zooms in to open itself. */\n onPointPress?: (point: ClusterPoint) => void;\n /** The radius, in pixels, within which points group. Default 48. */\n radius?: number;\n};\n\n/** Many points grouped while they are close: a disc with the count, opened by zooming in; each point is listed for screen readers. */\nexport function Cluster({ points, onPointPress, radius = 48 }: ClusterProps) {\n const { map: m, ready, reduce, places } = useMap();\n const id = `kz-cluster-${React.useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n const data = React.useMemo<GeoJSON.FeatureCollection>(\n () => ({ type: \"FeatureCollection\", features: points.map((p) => ({ type: \"Feature\", properties: { id: p.id, label: p.label }, geometry: { type: \"Point\", coordinates: [...p.position] } })) }),\n [points],\n );\n React.useEffect(() => {\n for (const p of points) places.add(`${id}-${p.id}`, p.label);\n return () => points.forEach((p) => places.remove(`${id}-${p.id}`));\n }, [points, places, id]);\n useGeoLayer(\n id,\n data,\n (c) => [\n { id: `${id}-groups`, type: \"circle\", source: id, filter: [\"has\", \"point_count\"], paint: { \"circle-color\": c.accent, \"circle-opacity\": 0.92, \"circle-radius\": [\"step\", [\"get\", \"point_count\"], 16, 10, 20, 50, 26], \"circle-stroke-width\": 3, \"circle-stroke-color\": c.halo } },\n { id: `${id}-count`, type: \"symbol\", source: id, filter: [\"has\", \"point_count\"], layout: { \"text-field\": [\"get\", \"point_count_abbreviated\"], \"text-font\": [\"Noto Sans Medium\"], \"text-size\": 13 }, paint: { \"text-color\": c.onAccent } },\n { id: `${id}-points`, type: \"circle\", source: id, filter: [\"!\", [\"has\", \"point_count\"]], paint: { \"circle-color\": c.accent, \"circle-radius\": 7, \"circle-stroke-width\": 2.5, \"circle-stroke-color\": c.halo } },\n ],\n { cluster: true, clusterRadius: radius, clusterMaxZoom: 15 },\n );\n React.useEffect(() => {\n if (!m || !ready) return;\n const openGroup = async (e: { features?: { properties: Record<string, unknown>; geometry: GeoJSON.Geometry }[] }) => {\n const f = e.features?.[0];\n if (!f) return;\n const src = m.getSource(id) as unknown as { getClusterExpansionZoom: (id: number) => Promise<number> };\n const z = await src.getClusterExpansionZoom(f.properties.cluster_id as number);\n const center = (f.geometry as GeoJSON.Point).coordinates as [number, number];\n if (reduce) m.jumpTo({ center, zoom: z });\n else m.easeTo({ center, zoom: z });\n };\n const pressPoint = (e: { features?: { properties: Record<string, unknown> }[] }) => {\n const pid = e.features?.[0]?.properties.id;\n const point = points.find((p) => p.id === pid);\n if (point) onPointPress?.(point);\n };\n m.on(\"click\", `${id}-groups`, openGroup as never);\n m.on(\"click\", `${id}-points`, pressPoint as never);\n return () => {\n m.off(\"click\", `${id}-groups`, openGroup as never);\n m.off(\"click\", `${id}-points`, pressPoint as never);\n };\n }, [m, ready, id, points, onPointPress, reduce]);\n return null;\n}\n"
1035
+ "content": "// Client. A map of the Krizaka family on MapLibre GL JS (open source) over Protomaps tiles on our CDN — no API key, no\n// metered service. MapLibre and PMTiles are optional peers, loaded when a map is shown (a dynamic import: a page without\n// a map never pays for them). The style comes from the tokens (`mapStyle`), in the page's scheme, and follows it.\n//\n// What every map does: the canvas is decorative for assistive technologies — the map is a region named by `label`, its\n// places a list read in order (the text alternative, `MapPlaces`), each marker a button when it does something; the\n// attribution of OpenStreetMap (ODbL) and Protomaps is always shown; under reduced motion the camera jumps, never flies;\n// without WebGL (or while the engine loads) `fallback` stands in its place.\n/// <reference types=\"geojson\" />\nimport * as React from \"react\";\nimport { createPortal } from \"react-dom\";\n\nimport { usePrefersReducedMotion } from \"../media\";\nimport { tv } from \"../tv\";\nimport { MAP_ATTRIBUTION, setMapAssetsBase } from \"./assets\";\nimport { circlePolygon, type LngLat, pointAlong, sliceLine } from \"./geo\";\nimport { type MapBrand, mapPalette, type MapRegion, mapStyle } from \"./style\";\n\ntype MapLibre = typeof import(\"maplibre-gl\");\ntype MapInstance = import(\"maplibre-gl\").Map;\n\nexport const map = tv({\n slots: {\n root: \"relative isolate overflow-hidden rounded-2xl border border-border-default bg-surface-2 shadow-raised\",\n canvas: \"absolute inset-0 [&_.maplibregl-canvas]:outline-hidden\",\n fallback: \"absolute inset-0\",\n controls: \"absolute right-3 top-3 z-10 flex flex-col overflow-hidden rounded-xl border border-border-default bg-surface-1 shadow-floating\",\n control:\n \"inline-flex h-10 w-10 items-center justify-center text-lg font-semibold text-fg-secondary transition-colors hover:bg-surface-3 hover:text-fg \" +\n \"focus-visible:outline-hidden focus-visible:focus-ring-inset disabled:opacity-40 [&+&]:border-t [&+&]:border-border-subtle\",\n attribution:\n \"absolute bottom-1.5 right-1.5 z-10 rounded-md bg-surface-1/85 px-1.5 py-0.5 text-[10px] text-fg-secondary backdrop-blur-sm [&_a]:underline-offset-2 hover:[&_a]:underline\",\n places: \"sr-only\",\n marker:\n \"group relative flex -translate-y-1/2 cursor-pointer items-center justify-center rounded-full border-2 border-on-accent bg-accent shadow-floating outline-hidden \" +\n \"transition-transform hover:scale-110 focus-visible:focus-ring motion-reduce:transition-none motion-reduce:hover:scale-100 data-[selected]:scale-125\",\n markerLabel:\n \"pointer-events-none absolute bottom-full mb-2 whitespace-nowrap rounded-lg border border-border-default bg-surface-1 px-2 py-1 text-xs font-semibold text-fg shadow-floating\",\n },\n});\n\ntype Registry = { add: (id: string, text: string) => void; remove: (id: string) => void };\ntype Ctx = { map: MapInstance | null; lib: MapLibre | null; ready: boolean; reduce: boolean; places: Registry };\nconst MapContext = React.createContext<Ctx | null>(null);\n\n/** The map a child is drawn on (`Marker`, `RadiusCircle`, `Cluster`…): the MapLibre instance once it is ready. */\nexport function useMap(): Ctx {\n const ctx = React.useContext(MapContext);\n if (!ctx) throw new Error(\"useMap: inside a <Map> only\");\n return ctx;\n}\n\n/** The scheme of the page (`html.light`), followed as it changes. */\nfunction usePageScheme(): \"dark\" | \"light\" {\n const read = () => (typeof document !== \"undefined\" && document.documentElement.classList.contains(\"light\") ? \"light\" : \"dark\");\n const [scheme, setScheme] = React.useState<\"dark\" | \"light\">(read);\n React.useEffect(() => {\n const observer = new MutationObserver(() => setScheme(read()));\n observer.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n return () => observer.disconnect();\n }, []);\n return scheme;\n}\n\nlet protocolAdded = false;\nlet workerUrl: string | null = null;\n\n/**\n * Where MapLibre's worker comes from. A bundler (Next.js, Vite) renames MapLibre's module and leaves its worker behind:\n * by default the worker of the very version the app installed is imported from jsDelivr (free, open source, no key)\n * through a module worker of the page's own origin. An app with a strict CSP serves it itself —\n * `configureMaps({ workerUrl: \"/maplibre-gl-worker.mjs\" })` (with `maplibre-gl-shared.mjs` beside it), once, before a map.\n * `assetsBase` reads the tiles and glyphs through a same-origin proxy of the CDN (`setMapAssetsBase`).\n */\nexport function configureMaps(options: { workerUrl?: string; assetsBase?: string }) {\n if (options.workerUrl) workerUrl = options.workerUrl;\n if (options.assetsBase) setMapAssetsBase(options.assetsBase);\n}\n\n/** MapLibre, its worker and the PMTiles protocol, loaded once for the page. */\nasync function loadEngine(): Promise<MapLibre> {\n const [lib, pmtiles] = await Promise.all([import(\"maplibre-gl\"), import(\"pmtiles\")]);\n const ml = ((lib as unknown as { default?: MapLibre }).default ?? lib) as MapLibre;\n if (!protocolAdded) {\n protocolAdded = true;\n const remote = `https://cdn.jsdelivr.net/npm/maplibre-gl@${ml.getVersion()}/dist/maplibre-gl-worker.mjs`;\n ml.setWorkerUrl(workerUrl ?? URL.createObjectURL(new Blob([`import ${JSON.stringify(remote)};`], { type: \"text/javascript\" })));\n ml.addProtocol(\"pmtiles\", new pmtiles.Protocol().tile);\n }\n return ml;\n}\n\nconst hasWebGL = () => {\n try {\n const canvas = document.createElement(\"canvas\");\n return Boolean(canvas.getContext(\"webgl2\") ?? canvas.getContext(\"webgl\"));\n } catch {\n return false;\n }\n};\n\nexport type MapLabels = {\n /** The zoom-in button (\"Zoom in\"). */\n zoomIn: string;\n /** The zoom-out button (\"Zoom out\"). */\n zoomOut: string;\n /** Before the list of places read in place of the map (\"Places on the map\"). */\n places: string;\n};\n\nexport const mapLabels: MapLabels = { zoomIn: \"Zoom in\", zoomOut: \"Zoom out\", places: \"Places on the map\" };\n\nexport type MapProps = {\n /** The map's accessible name (\"Pick-up points near you\"), passed translated. */\n label: string;\n /** Where the map looks at first: longitude, latitude. */\n center: LngLat;\n /** The zoom at first (0 the world, 12 a city, 16 a street). Default 12. */\n zoom?: number;\n /** The camera follows `center` and `zoom` when they change (flies, or jumps under reduced motion). */\n controlled?: boolean;\n /** The scheme. Default: the page's (`html.light`), followed. */\n scheme?: \"dark\" | \"light\";\n /** The product whose accent the map carries. Default `krizaka`. */\n brand?: MapBrand;\n /** The language of the labels (`fr`, `en`). Default `en`. */\n lang?: string;\n /** The detailed region (PMTiles) drawn from zoom 6.5. Default: our Montréal–Québec extract (to zoom 14). */\n region?: MapRegion | null;\n /** The world's low zooms (PMTiles). Default: our world extract (to zoom 6). */\n world?: MapRegion | null;\n /** The zoom buttons (default) — the keyboard also zooms with + and − on the focused map. */\n controls?: boolean;\n /** Moves with the hand (default); `false` makes a still picture of a place. */\n interactive?: boolean;\n /** Shown in place of the map while its engine loads, and for good without WebGL: a `StaticMap`, a picture. */\n fallback?: React.ReactNode;\n /** Called when the camera rests: its centre and zoom. */\n onMoveEnd?: (view: { center: LngLat; zoom: number }) => void;\n /** Called with the position pressed on the map (not on a marker). */\n onPress?: (position: LngLat) => void;\n /** Markers, circles, clusters, the user's position — and anything drawn over the map. */\n children?: React.ReactNode;\n /** The words, passed translated; English by default. */\n labels?: Partial<MapLabels>;\n /** Classes merged on the root (its height: `h-80`, `aspect-video`). */\n className?: string;\n};\n\n/** A map: MapLibre over our Protomaps tiles, styled from the tokens, with its places as a list for screen readers. */\nexport function Map({\n label,\n center,\n zoom = 12,\n controlled,\n scheme: schemeProp,\n brand,\n lang,\n region,\n world,\n controls = true,\n interactive = true,\n fallback,\n onMoveEnd,\n onPress,\n children,\n labels: given,\n className,\n}: MapProps) {\n const labels = { ...mapLabels, ...given };\n const s = map();\n const pageScheme = usePageScheme();\n const scheme = schemeProp ?? pageScheme;\n const reduce = usePrefersReducedMotion();\n const host = React.useRef<HTMLDivElement>(null);\n const [instance, setInstance] = React.useState<MapInstance | null>(null);\n const [lib, setLib] = React.useState<MapLibre | null>(null);\n const [ready, setReady] = React.useState(false);\n const [failed, setFailed] = React.useState(false);\n const [places, setPlaces] = React.useState<Record<string, string>>({});\n const registry = React.useMemo<Registry>(\n () => ({\n add: (id, text) => setPlaces((p) => (p[id] === text ? p : { ...p, [id]: text })),\n remove: (id) =>\n setPlaces((p) => {\n const { [id]: _gone, ...rest } = p;\n return rest;\n }),\n }),\n [],\n );\n const latest = React.useRef({ onMoveEnd, onPress });\n React.useEffect(() => {\n latest.current = { onMoveEnd, onPress };\n });\n const styleOf = React.useCallback((sc: \"dark\" | \"light\") => mapStyle({ scheme: sc, brand, lang, ...(region !== undefined ? { region } : {}), ...(world !== undefined ? { world } : {}) }), [brand, lang, region, world]);\n\n // The engine and the map, once (the style follows the scheme below).\n React.useEffect(() => {\n if (!host.current) return;\n let alive = true;\n let created: MapInstance | null = null;\n (hasWebGL() ? loadEngine() : Promise.reject(new Error(\"no WebGL\")))\n .then((ml) => {\n if (!alive || !host.current) return;\n created = new ml.Map({\n container: host.current,\n style: styleOf(scheme) as never,\n center: [...center] as [number, number],\n zoom,\n interactive,\n attributionControl: false,\n fadeDuration: reduce ? 0 : 300,\n cooperativeGestures: false,\n });\n created.getCanvas().setAttribute(\"aria-hidden\", \"true\");\n created.getCanvas().setAttribute(\"tabindex\", \"-1\");\n created.on(\"load\", () => alive && setReady(true));\n // The basemap cannot be read (offline, a blocked request): the fallback comes back rather than an empty map.\n created.on(\"error\", (e) => {\n const source = (e as { sourceId?: string }).sourceId;\n if (alive && (source === \"world\" || source === \"region\")) setFailed(true);\n });\n created.on(\"moveend\", () => {\n const c = created!.getCenter();\n latest.current.onMoveEnd?.({ center: [c.lng, c.lat], zoom: created!.getZoom() });\n });\n created.on(\"click\", (e) => {\n if ((e.originalEvent.target as Element | null)?.closest?.(\"[data-kz-marker]\")) return;\n latest.current.onPress?.([e.lngLat.lng, e.lngLat.lat]);\n });\n setLib(ml);\n setInstance(created);\n })\n .catch(() => alive && setFailed(true));\n return () => {\n alive = false;\n created?.remove();\n };\n // Created once: the props that change afterwards are followed by the effects below.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, []);\n\n // The scheme (and the brand, the language) change the style, not the map.\n const first = React.useRef(true);\n React.useEffect(() => {\n if (!instance) return;\n if (first.current) {\n first.current = false;\n return;\n }\n instance.setStyle(styleOf(scheme) as never);\n }, [instance, scheme, styleOf]);\n\n // A controlled camera: it flies (or jumps) to the new view.\n React.useEffect(() => {\n if (!instance || !controlled) return;\n const view = { center: [...center] as [number, number], zoom };\n if (reduce) instance.jumpTo(view);\n else instance.flyTo({ ...view, speed: 1.4 });\n }, [instance, controlled, center[0], center[1], zoom, reduce]); // eslint-disable-line react-hooks/exhaustive-deps\n\n const zoomBy = (by: number) => instance && (reduce ? instance.setZoom(instance.getZoom() + by) : instance.zoomTo(instance.getZoom() + by, { duration: 250 }));\n const ctx = React.useMemo<Ctx>(() => ({ map: instance, lib, ready, reduce, places: registry }), [instance, lib, ready, reduce, registry]);\n const listed = Object.entries(places);\n return (\n <MapContext.Provider value={ctx}>\n <section\n aria-label={label}\n className={s.root({ className })}\n data-ready={ready ? \"\" : undefined}\n tabIndex={interactive ? 0 : undefined}\n onKeyDown={(e) => {\n if (e.target !== e.currentTarget || !instance) return;\n if (e.key === \"+\" || e.key === \"=\") zoomBy(1);\n else if (e.key === \"-\") zoomBy(-1);\n else if (e.key.startsWith(\"Arrow\")) {\n e.preventDefault();\n const step = 80;\n const by: Record<string, [number, number]> = { ArrowUp: [0, -step], ArrowDown: [0, step], ArrowLeft: [-step, 0], ArrowRight: [step, 0] };\n instance.panBy(by[e.key]!, { duration: reduce ? 0 : 200 });\n }\n }}\n >\n {!ready || failed ? <div className={s.fallback()}>{fallback}</div> : null}\n <div ref={host} className={s.canvas()} style={{ position: \"absolute\", inset: 0, visibility: failed ? \"hidden\" : undefined }} />\n {controls && interactive && !failed ? (\n <div className={s.controls()}>\n <button type=\"button\" className={s.control()} aria-label={labels.zoomIn} disabled={!instance} onClick={() => zoomBy(1)}>\n +\n </button>\n <button type=\"button\" className={s.control()} aria-label={labels.zoomOut} disabled={!instance} onClick={() => zoomBy(-1)}>\n −\n </button>\n </div>\n ) : null}\n <p className={s.attribution()}>\n <a href={MAP_ATTRIBUTION.protomapsUrl} target=\"_blank\" rel=\"noreferrer\">\n {MAP_ATTRIBUTION.protomaps}\n </a>\n {\" · \"}\n <a href={MAP_ATTRIBUTION.osmUrl} target=\"_blank\" rel=\"noreferrer\">\n {MAP_ATTRIBUTION.osm}\n </a>\n </p>\n {listed.length ? (\n <div className={s.places()}>\n <p>{labels.places}</p>\n <ul>\n {listed.map(([id, text]) => (\n <li key={id}>{text}</li>\n ))}\n </ul>\n </div>\n ) : null}\n {children}\n </section>\n </MapContext.Provider>\n );\n}\n\nexport type MarkerProps = {\n /** Where it stands. */\n position: LngLat;\n /** Its words: the place's name, read in the list of places and as the button's name (\"Café Olimpico, 350 m\"). */\n label: string;\n /** Pressing it (details in a panel, a sheet): the marker becomes a button. */\n onPress?: () => void;\n /** The chosen one: larger, its label shown above it. */\n selected?: boolean;\n /** Its size in pixels. Default 18. */\n size?: number;\n /** What it draws instead of the accent dot (an avatar, an icon). */\n children?: React.ReactNode;\n /** Drops onto its place once, after this many milliseconds (stagger several: `index * 90`). Still under reduced motion. */\n drop?: number;\n};\n\nconst noSubscription = () => () => undefined;\n\n/** A place on the map: an accent dot (or your content) that is a button when it does something. */\nexport function Marker({ position, label, onPress, selected, size = 18, children, drop }: MarkerProps) {\n const { map: m, lib, places } = useMap();\n const s = map();\n const id = React.useId();\n // The marker's host element exists only in a browser: the server and the hydration pass render nothing for it, then the\n // client swaps in the element — `useSyncExternalStore`'s server snapshot, so hydration never sees a difference.\n const host = React.useRef<HTMLElement | null>(null);\n const el = React.useSyncExternalStore(\n noSubscription,\n () => (host.current ??= document.createElement(\"div\")),\n () => null,\n );\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n React.useEffect(() => {\n if (!m || !lib || !el) return;\n el.setAttribute(\"data-kz-marker\", \"\");\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...position] as [number, number]).addTo(m);\n return () => void marker.remove();\n }, [m, lib, el, position[0], position[1]]); // eslint-disable-line react-hooks/exhaustive-deps\n if (!el) return null;\n const Tag = onPress ? \"button\" : \"span\";\n return createPortal(\n <Tag\n type={onPress ? \"button\" : undefined}\n aria-label={onPress ? label : undefined}\n aria-hidden={onPress ? undefined : true}\n aria-pressed={onPress && selected !== undefined ? selected : undefined}\n data-selected={selected ? \"\" : undefined}\n data-drop={drop === undefined ? undefined : \"\"}\n className={s.marker()}\n style={{ ...(children ? null : { width: size, height: size }), ...(drop === undefined ? null : ({ \"--kz-map-drop-delay\": `${drop}ms` } as React.CSSProperties)) }}\n onClick={onPress}\n >\n {children}\n {selected ? <span className={s.markerLabel()}>{label}</span> : null}\n </Tag>,\n el,\n );\n}\n\nexport type UserPositionProps = {\n /** Where the person is. */\n position: LngLat;\n /** Its words (\"You are here\"), read in the list of places. */\n label: string;\n /** The accuracy in metres: a soft disc around the dot. */\n accuracy?: number;\n};\n\n/** The person's position: a dot that pulses (still under reduced motion), its accuracy as a soft disc. */\nexport function UserPosition({ position, label, accuracy }: UserPositionProps) {\n const { map: m, lib, places } = useMap();\n const id = React.useId();\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n React.useEffect(() => {\n if (!m || !lib) return;\n const el = document.createElement(\"div\");\n el.className = \"kz-map-user\";\n el.setAttribute(\"aria-hidden\", \"true\");\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...position] as [number, number]).addTo(m);\n return () => void marker.remove();\n }, [m, lib, position[0], position[1]]); // eslint-disable-line react-hooks/exhaustive-deps\n return accuracy ? <RadiusCircle center={position} radius={accuracy} tone=\"soft\" /> : null;\n}\n\nexport type PulseProps = {\n /** Where it beats. */\n position: LngLat;\n /** Its words (\"High demand in Mile End\"), read in the list of places. */\n label: string;\n /** The rings' reach in pixels. Default 56. */\n size?: number;\n /** `accent` (a zone that matters) or `soft`. Default `accent`. */\n tone?: \"accent\" | \"soft\";\n};\n\n/** A heat pulse: rings that swell and fade around a place (a hot spot, a demand peak). Still rings under reduced motion. */\nexport function Pulse({ position, label, size = 56, tone = \"accent\" }: PulseProps) {\n const { map: m, lib, places } = useMap();\n const id = React.useId();\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n React.useEffect(() => {\n if (!m || !lib) return;\n const el = document.createElement(\"div\");\n el.className = \"kz-map-pulse\";\n el.setAttribute(\"data-tone\", tone);\n el.setAttribute(\"aria-hidden\", \"true\");\n el.style.setProperty(\"--kz-map-pulse-size\", `${size}px`);\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...position] as [number, number]).addTo(m);\n return () => void marker.remove();\n }, [m, lib, tone, size, position[0], position[1]]); // eslint-disable-line react-hooks/exhaustive-deps\n return null;\n}\n\nexport type RouteProps = {\n /** The path, as positions in order (a polyline: a delivery, a walk, a flight). */\n path: readonly LngLat[];\n /** Its words (\"Delivery from the studio to Mile End, 2.4 km\"), read in the list of places. */\n label: string;\n /** The line draws itself once, from its start, when the map is on screen; `false` shows it whole. Default `true`. */\n draw?: boolean;\n /** How long the drawing lasts, in milliseconds. Default 1400. */\n duration?: number;\n /** A dot that travels the route once drawn (`true`, or your own content: an avatar, an icon). */\n courier?: boolean | React.ReactNode;\n /** How long a trip takes, in milliseconds. Default 3200. */\n tripDuration?: number;\n /** Start the trips again after a pause (milliseconds), only while the map is on screen. Without it the courier makes one trip. */\n repeatAfter?: number;\n /** Changing it replays the drawing and the trip. */\n replayKey?: string | number;\n /** `accent` or `soft`. Default `accent`. */\n tone?: \"accent\" | \"soft\";\n};\n\ntype RouteSource = { setData?: (d: GeoJSON.GeoJSON) => void };\n\n/** A route on the map: a line that draws itself, and a courier that travels it. Under reduced motion the line is whole and the courier rests at the end. */\nexport function Route({ path, label, draw = true, duration = 1400, courier, tripDuration = 3200, repeatAfter, replayKey, tone = \"accent\" }: RouteProps) {\n const { map: m, lib, ready, reduce, places } = useMap();\n const id = `kz-route-${React.useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n const animated = draw && !reduce;\n // The drawing is done for this `replayKey` (a new key draws again); without animation the line is whole at once.\n const token = replayKey ?? 0;\n const [doneFor, setDoneFor] = React.useState<string | number | null>(null);\n const drawn = !animated || doneFor === token;\n const whole = React.useMemo(() => sliceLine(path, 1), [path]);\n const start = React.useMemo(() => sliceLine(path, 0), [path]);\n React.useEffect(() => {\n places.add(id, label);\n return () => places.remove(id);\n }, [id, label, places]);\n useGeoLayer(id, drawn ? whole : start, (c) => [\n { id: `${id}-casing`, type: \"line\", source: id, layout: { \"line-cap\": \"round\", \"line-join\": \"round\" }, paint: { \"line-color\": c.accent, \"line-width\": 9, \"line-opacity\": tone === \"soft\" ? 0.08 : 0.16, \"line-blur\": 3 } },\n { id: `${id}-line`, type: \"line\", source: id, layout: { \"line-cap\": \"round\", \"line-join\": \"round\" }, paint: { \"line-color\": c.accent, \"line-width\": tone === \"soft\" ? 2 : 3.5, \"line-opacity\": tone === \"soft\" ? 0.6 : 1 } },\n ]);\n // Is the map on screen? The drawing waits for it, and a repeating trip does not start while it is not.\n const [visible, setVisible] = React.useState(() => typeof IntersectionObserver === \"undefined\");\n React.useEffect(() => {\n const el = m?.getContainer?.();\n if (!el || typeof IntersectionObserver === \"undefined\") return;\n const io = new IntersectionObserver(([entry]) => setVisible(!!entry?.isIntersecting), { threshold: 0.25 });\n io.observe(el);\n return () => io.disconnect();\n }, [m]);\n // The drawing: a finite animation, one frame per paint for `duration` ms — no loop, nothing runs afterwards.\n React.useEffect(() => {\n if (!m || !ready || !animated || drawn || !visible) return;\n const source = m.getSource(id) as RouteSource | undefined;\n let frame = 0;\n const began = performance.now();\n const step = (now: number) => {\n const t = Math.min(1, (now - began) / Math.max(1, duration));\n source?.setData?.(sliceLine(path, 1 - (1 - t) ** 3));\n if (t < 1) frame = requestAnimationFrame(step);\n else setDoneFor(token);\n };\n frame = requestAnimationFrame(step);\n return () => cancelAnimationFrame(frame);\n }, [m, ready, animated, drawn, visible, id, path, duration, token]);\n // The courier: one trip once the line is drawn (finite frames), again after `repeatAfter` while on screen.\n const [trip, setTrip] = React.useState(0);\n React.useEffect(() => {\n if (!courier || !m || !lib || !ready || !drawn) return;\n const el = document.createElement(\"div\");\n el.className = \"kz-map-courier\";\n el.setAttribute(\"aria-hidden\", \"true\");\n const marker = new lib.Marker({ element: el, anchor: \"center\" }).setLngLat([...(path[reduce ? path.length - 1 : 0] ?? [0, 0])] as [number, number]).addTo(m);\n let frame = 0;\n let timer: ReturnType<typeof setTimeout> | undefined;\n if (!reduce && visible) {\n const began = performance.now();\n const step = (now: number) => {\n const t = Math.min(1, (now - began) / Math.max(1, tripDuration));\n const eased = t < 0.5 ? 2 * t * t : 1 - (-2 * t + 2) ** 2 / 2;\n marker.setLngLat([...pointAlong(path, eased).position] as [number, number]);\n if (t < 1) frame = requestAnimationFrame(step);\n else if (repeatAfter !== undefined) timer = setTimeout(() => setTrip((n) => n + 1), repeatAfter);\n };\n frame = requestAnimationFrame(step);\n } else if (!reduce) marker.setLngLat([...(path[path.length - 1] ?? [0, 0])] as [number, number]);\n return () => {\n cancelAnimationFrame(frame);\n clearTimeout(timer);\n marker.remove();\n };\n }, [courier, m, lib, ready, drawn, visible, reduce, path, tripDuration, repeatAfter, trip, token]);\n return null;\n}\n\n/** Adds a GeoJSON source and its layers while the map has a style (again after a change of scheme). */\nfunction useGeoLayer(id: string, data: GeoJSON.GeoJSON, layers: (palette: ReturnType<typeof mapPalette>) => object[], source: object = {}) {\n const { map: m, ready } = useMap();\n const dataRef = React.useRef(data);\n React.useEffect(() => {\n dataRef.current = data;\n const src = m?.getSource(id) as { setData?: (d: GeoJSON.GeoJSON) => void } | undefined;\n src?.setData?.(data);\n }, [m, id, data]);\n React.useEffect(() => {\n if (!m || !ready) return;\n const add = () => {\n if (m.getSource(id)) return;\n const scheme = document.documentElement.classList.contains(\"light\") ? \"light\" : \"dark\";\n m.addSource(id, { type: \"geojson\", data: dataRef.current, ...source } as never);\n for (const layer of layers(mapPalette(scheme))) m.addLayer(layer as never);\n };\n add();\n m.on(\"styledata\", add);\n return () => {\n m.off(\"styledata\", add);\n if (!m.getStyle()) return;\n for (const layer of m.getStyle().layers.filter((l) => \"source\" in l && l.source === id)) m.removeLayer(layer.id);\n if (m.getSource(id)) m.removeSource(id);\n };\n }, [m, ready, id]); // eslint-disable-line react-hooks/exhaustive-deps\n}\n\nexport type RadiusCircleProps = {\n /** Its centre. */\n center: LngLat;\n /** Its radius in metres. */\n radius: number;\n /** `accent` (a zone that matters: a delivery area, a search radius) or `soft` (an accuracy). */\n tone?: \"accent\" | \"soft\";\n};\n\n/** A circle on the ground: a delivery zone, a search radius — filled with the accent, its edge drawn. */\nexport function RadiusCircle({ center, radius, tone = \"accent\" }: RadiusCircleProps) {\n const id = `kz-circle-${React.useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n const data = React.useMemo(() => circlePolygon(center, radius), [center[0], center[1], radius]); // eslint-disable-line react-hooks/exhaustive-deps\n useGeoLayer(id, data, (c) => [\n { id: `${id}-fill`, type: \"fill\", source: id, paint: { \"fill-color\": c.accent, \"fill-opacity\": tone === \"soft\" ? 0.1 : 0.14 } },\n { id: `${id}-line`, type: \"line\", source: id, paint: { \"line-color\": c.accent, \"line-width\": tone === \"soft\" ? 1 : 2, \"line-opacity\": tone === \"soft\" ? 0.4 : 0.9, ...(tone === \"accent\" ? { \"line-dasharray\": [2, 1.5] } : {}) } },\n ]);\n return null;\n}\n\nexport type ClusterPoint = { id: string; position: LngLat; label: string };\n\nexport type ClusterProps = {\n /** The points: many of them (creators, pick-up points, events). */\n points: readonly ClusterPoint[];\n /** Pressing a single point. A group zooms in to open itself. */\n onPointPress?: (point: ClusterPoint) => void;\n /** The radius, in pixels, within which points group. Default 48. */\n radius?: number;\n};\n\n/** Many points grouped while they are close: a disc with the count, opened by zooming in; each point is listed for screen readers. */\nexport function Cluster({ points, onPointPress, radius = 48 }: ClusterProps) {\n const { map: m, ready, reduce, places } = useMap();\n const id = `kz-cluster-${React.useId().replace(/[^a-zA-Z0-9]/g, \"\")}`;\n const data = React.useMemo<GeoJSON.FeatureCollection>(\n () => ({ type: \"FeatureCollection\", features: points.map((p) => ({ type: \"Feature\", properties: { id: p.id, label: p.label }, geometry: { type: \"Point\", coordinates: [...p.position] } })) }),\n [points],\n );\n React.useEffect(() => {\n for (const p of points) places.add(`${id}-${p.id}`, p.label);\n return () => points.forEach((p) => places.remove(`${id}-${p.id}`));\n }, [points, places, id]);\n useGeoLayer(\n id,\n data,\n (c) => [\n { id: `${id}-groups`, type: \"circle\", source: id, filter: [\"has\", \"point_count\"], paint: { \"circle-color\": c.accent, \"circle-opacity\": 0.92, \"circle-radius\": [\"step\", [\"get\", \"point_count\"], 16, 10, 20, 50, 26], \"circle-stroke-width\": 3, \"circle-stroke-color\": c.halo } },\n { id: `${id}-count`, type: \"symbol\", source: id, filter: [\"has\", \"point_count\"], layout: { \"text-field\": [\"get\", \"point_count_abbreviated\"], \"text-font\": [\"Noto Sans Medium\"], \"text-size\": 13 }, paint: { \"text-color\": c.onAccent } },\n { id: `${id}-points`, type: \"circle\", source: id, filter: [\"!\", [\"has\", \"point_count\"]], paint: { \"circle-color\": c.accent, \"circle-radius\": 7, \"circle-stroke-width\": 2.5, \"circle-stroke-color\": c.halo } },\n ],\n { cluster: true, clusterRadius: radius, clusterMaxZoom: 15 },\n );\n React.useEffect(() => {\n if (!m || !ready) return;\n const openGroup = async (e: { features?: { properties: Record<string, unknown>; geometry: GeoJSON.Geometry }[] }) => {\n const f = e.features?.[0];\n if (!f) return;\n const src = m.getSource(id) as unknown as { getClusterExpansionZoom: (id: number) => Promise<number> };\n const z = await src.getClusterExpansionZoom(f.properties.cluster_id as number);\n const center = (f.geometry as GeoJSON.Point).coordinates as [number, number];\n if (reduce) m.jumpTo({ center, zoom: z });\n else m.easeTo({ center, zoom: z });\n };\n const pressPoint = (e: { features?: { properties: Record<string, unknown> }[] }) => {\n const pid = e.features?.[0]?.properties.id;\n const point = points.find((p) => p.id === pid);\n if (point) onPointPress?.(point);\n };\n m.on(\"click\", `${id}-groups`, openGroup as never);\n m.on(\"click\", `${id}-points`, pressPoint as never);\n return () => {\n m.off(\"click\", `${id}-groups`, openGroup as never);\n m.off(\"click\", `${id}-points`, pressPoint as never);\n };\n }, [m, ready, id, points, onPointPress, reduce]);\n return null;\n}\n"
917
1036
  },
918
1037
  {
919
1038
  "path": "map/meta.ts",
920
- "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Map\",\n summary:\n \"Maps that cost nothing per view — MapLibre (open source) over Protomaps tiles on the Krizaka CDN, painted from the tokens in dark or light and in the product's accent — with markers, clusters, the person's position, a radius and a location picker.\",\n status: \"beta\",\n category: \"data-display\",\n platforms: \"both\",\n parity: { desktop: \"web\" },\n whenToUse: [\n \"To show where things are: pick-up points, creators near you, an event's venue.\",\n \"`Cluster` for many points (dozens to thousands): they group while close and open by zooming in.\",\n \"`LocationPicker` to choose a place: a meeting point, a delivery address, the centre of a search with its radius.\",\n \"`StaticMap` where a live map is too much (a card, a list, an e-mail) and as the `fallback` of every `Map`.\",\n ],\n whenNotToUse: [\n { when: \"For an address typed or searched: a text field (geocoding is a server's job — Nominatim or Photon self-hosted later), the map only confirms it.\", use: \"field\" },\n { when: \"For figures by country or region (a choropleth): a chart.\", use: \"stat\" },\n ],\n bestPractices: [\n \"Give every map a `label` and every marker a `label`: they are read as the list of places in place of the map.\",\n \"Always pass a `fallback` (`StaticMap` with the same centre and places): it shows while the engine loads and for good without WebGL.\",\n \"Ask for the position only on a press (`LocationPicker`'s Near me), never on load; every refusal has its words.\",\n \"Keep the attribution visible (OpenStreetMap's licence, the ODbL, asks for it): the components draw it.\",\n \"Install the engine only where a map is shown: `maplibre-gl` (web) or `@maplibre/maplibre-react-native` (Expo) — optional peers.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Tab\", action: \"The map (a region named by `label`), then its buttons: zoom, the markers that do something, Near me.\" },\n { keys: \"Arrow keys\", action: \"Pan the focused map.\" },\n { keys: \"+ / −\", action: \"Zoom the focused map in or out.\" },\n ],\n notes: [\n \"The canvas is hidden from assistive technologies: the places are read as a list (each `Marker`, `Cluster` point and the person's position by its `label`).\",\n \"A marker with `onPress` is a button named by its label (`aria-pressed` when it can be chosen); the chosen one shows its name above it.\",\n \"Under reduced motion the camera jumps instead of flying and the position's pulse stands still.\",\n \"Colours come from the tokens: the water is the accent softened, roads and labels the text roles — readable in both schemes.\",\n ],\n },\n related: [\"dialog\", \"search-field\", \"chip\"],\n web: {\n imports: [\"Map\", \"Marker\", \"Cluster\", \"UserPosition\", \"RadiusCircle\", \"LocationPicker\", \"StaticMap\", \"mapStyle\", \"distanceMeters\", \"formatDistance\"],\n examples: [\n { name: \"places\", title: \"Places\", description: \"Pick-up points in Montréal: markers are buttons, the chosen one shows its name.\" },\n { name: \"cluster\", title: \"Cluster\", description: \"Sixty creators grouped while they are close; a group opens by zooming in.\" },\n { name: \"location-picker\", title: \"Location picker\", description: \"A meeting point under the pin, its distance from the person, a 1-km zone and Near me.\" },\n { name: \"static\", title: \"Static map\", description: \"The places on the map's paper, without an engine: a card, a list item, a fallback.\" },\n ],\n },\n native: {\n entry: \"@krizaka/ui/native/map\",\n imports: [\"Map\", \"Marker\", \"Cluster\", \"UserPosition\", \"RadiusCircle\", \"LocationPicker\"],\n differences: [\n \"MapLibre Native (`@maplibre/maplibre-react-native`, an optional peer, with its Expo config plugin): the same style and tiles as the web — it reads `pmtiles://` itself.\",\n \"A marker's details open in a `Sheet` (`@krizaka/ui/native`); markers and Near me play a haptic.\",\n \"Near me asks the system's permission through MapLibre's `LocationManager` when pressed; the app declares the location usage in its config.\",\n \"The map's name lists its places for VoiceOver and TalkBack; the screenshots of these stories are drawn by a stand-in (the markers placed, not the tiles).\",\n ],\n examples: [\n { name: \"places\", title: \"Places\", description: \"Pick-up points: a marker pressed opens its details in a bottom sheet.\" },\n { name: \"location-picker\", title: \"Location picker\", description: \"A meeting point under the pin; Near me on request.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
1039
+ "content": "import type { ComponentMeta } from \"../meta\";\n\nexport const meta = {\n title: \"Map\",\n summary:\n \"Maps that cost nothing per view — MapLibre (open source) over Protomaps tiles on the Krizaka CDN, painted from the tokens in dark or light and in the product's accent — with markers, clusters, the person's position, a radius and a location picker.\",\n status: \"beta\",\n category: \"data-display\",\n platforms: \"both\",\n parity: { desktop: \"web\" },\n whenToUse: [\n \"To show where things are: pick-up points, creators near you, an event's venue.\",\n \"`Cluster` for many points (dozens to thousands): they group while close and open by zooming in.\",\n \"`LocationPicker` to choose a place: a meeting point, a delivery address, the centre of a search with its radius.\",\n \"`StaticMap` where a live map is too much (a card, a list, an e-mail) and as the `fallback` of every `Map`.\",\n ],\n whenNotToUse: [\n { when: \"For an address typed or searched: a text field (geocoding is a server's job — Nominatim or Photon self-hosted later), the map only confirms it.\", use: \"field\" },\n { when: \"For figures by country or region (a choropleth): a chart.\", use: \"stat\" },\n ],\n bestPractices: [\n \"Give every map a `label` and every marker a `label`: they are read as the list of places in place of the map.\",\n \"Always pass a `fallback` (`StaticMap` with the same centre and places): it shows while the engine loads and for good without WebGL.\",\n \"Motion on a map: `Marker drop={index * 90}` drops pins in turn, `Route` draws its line once (and a `courier` makes one trip, `repeatAfter` repeats it while the map is on screen), `Pulse` beats around a hot spot. The drawing is finite — nothing runs afterwards — and everything is in place at once under reduced motion.\",\n \"Ask for the position only on a press (`LocationPicker`'s Near me), never on load; every refusal has its words.\",\n \"Keep the attribution visible (OpenStreetMap's licence, the ODbL, asks for it): the components draw it.\",\n \"Install the engine only where a map is shown: `maplibre-gl` (web) or `@maplibre/maplibre-react-native` (Expo) — optional peers.\",\n ],\n accessibility: {\n keyboard: [\n { keys: \"Tab\", action: \"The map (a region named by `label`), then its buttons: zoom, the markers that do something, Near me.\" },\n { keys: \"Arrow keys\", action: \"Pan the focused map.\" },\n { keys: \"+ / −\", action: \"Zoom the focused map in or out.\" },\n ],\n notes: [\n \"The canvas is hidden from assistive technologies: the places are read as a list (each `Marker`, `Cluster` point and the person's position by its `label`).\",\n \"A marker with `onPress` is a button named by its label (`aria-pressed` when it can be chosen); the chosen one shows its name above it.\",\n \"Under reduced motion the camera jumps instead of flying and the position's pulse stands still.\",\n \"Colours come from the tokens: the water is the accent softened, roads and labels the text roles — readable in both schemes.\",\n ],\n },\n related: [\"dialog\", \"search-field\", \"chip\"],\n web: {\n imports: [\"Map\", \"Marker\", \"Cluster\", \"UserPosition\", \"RadiusCircle\", \"Route\", \"Pulse\", \"LocationPicker\", \"StaticMap\", \"mapStyle\", \"distanceMeters\", \"formatDistance\", \"pathLength\", \"pointAlong\", \"sliceLine\"],\n examples: [\n { name: \"places\", title: \"Places\", description: \"Pick-up points in Montréal: markers are buttons, the chosen one shows its name.\" },\n { name: \"cluster\", title: \"Cluster\", description: \"Sixty creators grouped while they are close; a group opens by zooming in.\" },\n { name: \"location-picker\", title: \"Location picker\", description: \"A meeting point under the pin, its distance from the person, a 1-km zone and Near me.\" },\n { name: \"route\", title: \"Route\", description: \"A delivery: pins that drop, a route that draws itself, a courier that travels it, a demand pulse — still under reduced motion.\" },\n { name: \"static\", title: \"Static map\", description: \"The places on the map's paper, without an engine: a card, a list item, a fallback.\" },\n ],\n },\n native: {\n entry: \"@krizaka/ui/native/map\",\n imports: [\"Map\", \"Marker\", \"Cluster\", \"UserPosition\", \"RadiusCircle\", \"LocationPicker\"],\n differences: [\n \"MapLibre Native (`@maplibre/maplibre-react-native`, an optional peer, with its Expo config plugin): the same style and tiles as the web — it reads `pmtiles://` itself.\",\n \"A marker's details open in a `Sheet` (`@krizaka/ui/native`); markers and Near me play a haptic.\",\n \"Near me asks the system's permission through MapLibre's `LocationManager` when pressed; the app declares the location usage in its config.\",\n \"The map's name lists its places for VoiceOver and TalkBack; the screenshots of these stories are drawn by a stand-in (the markers placed, not the tiles).\",\n ],\n examples: [\n { name: \"places\", title: \"Places\", description: \"Pick-up points: a marker pressed opens its details in a bottom sheet.\" },\n { name: \"location-picker\", title: \"Location picker\", description: \"A meeting point under the pin; Near me on request.\" },\n ],\n },\n} satisfies ComponentMeta;\n"
921
1040
  },
922
1041
  {
923
1042
  "path": "map/static-map.tsx",
@@ -941,7 +1060,7 @@
941
1060
  }
942
1061
  ],
943
1062
  "dependencies": [
944
- "@krizaka/tokens@^3.30.0",
1063
+ "@krizaka/tokens@^3.32.0",
945
1064
  "@protomaps/basemaps@5.7.2",
946
1065
  "tailwind-variants@^3.3.1"
947
1066
  ],
@@ -1246,6 +1365,13 @@
1246
1365
  "description": "What it draws instead of the accent dot (an avatar, an icon).",
1247
1366
  "required": false
1248
1367
  },
1368
+ {
1369
+ "name": "drop",
1370
+ "type": "number",
1371
+ "default": null,
1372
+ "description": "Drops onto its place once, after this many milliseconds (stagger several: `index * 90`). Still under reduced motion.",
1373
+ "required": false
1374
+ },
1249
1375
  {
1250
1376
  "name": "onPress",
1251
1377
  "type": "(() => void)",
@@ -1269,6 +1395,40 @@
1269
1395
  }
1270
1396
  ]
1271
1397
  },
1398
+ {
1399
+ "component": "Pulse",
1400
+ "description": "A heat pulse: rings that swell and fade around a place (a hot spot, a demand peak). Still rings under reduced motion.",
1401
+ "props": [
1402
+ {
1403
+ "name": "label",
1404
+ "type": "string",
1405
+ "default": null,
1406
+ "description": "Its words (\"High demand in Mile End\"), read in the list of places.",
1407
+ "required": true
1408
+ },
1409
+ {
1410
+ "name": "position",
1411
+ "type": "LngLat",
1412
+ "default": null,
1413
+ "description": "Where it beats.",
1414
+ "required": true
1415
+ },
1416
+ {
1417
+ "name": "size",
1418
+ "type": "number",
1419
+ "default": "56",
1420
+ "description": "The rings' reach in pixels. Default 56.",
1421
+ "required": false
1422
+ },
1423
+ {
1424
+ "name": "tone",
1425
+ "type": "\"accent\" | \"soft\"",
1426
+ "default": "accent",
1427
+ "description": "`accent` (a zone that matters) or `soft`. Default `accent`.",
1428
+ "required": false
1429
+ }
1430
+ ]
1431
+ },
1272
1432
  {
1273
1433
  "component": "RadiusCircle",
1274
1434
  "description": "A circle on the ground: a delivery zone, a search radius — filled with the accent, its edge drawn.",
@@ -1296,6 +1456,75 @@
1296
1456
  }
1297
1457
  ]
1298
1458
  },
1459
+ {
1460
+ "component": "Route",
1461
+ "description": "A route on the map: a line that draws itself, and a courier that travels it. Under reduced motion the line is whole and the courier rests at the end.",
1462
+ "props": [
1463
+ {
1464
+ "name": "label",
1465
+ "type": "string",
1466
+ "default": null,
1467
+ "description": "Its words (\"Delivery from the studio to Mile End, 2.4 km\"), read in the list of places.",
1468
+ "required": true
1469
+ },
1470
+ {
1471
+ "name": "path",
1472
+ "type": "readonly LngLat[]",
1473
+ "default": null,
1474
+ "description": "The path, as positions in order (a polyline: a delivery, a walk, a flight).",
1475
+ "required": true
1476
+ },
1477
+ {
1478
+ "name": "courier",
1479
+ "type": "ReactNode",
1480
+ "default": null,
1481
+ "description": "A dot that travels the route once drawn (`true`, or your own content: an avatar, an icon).",
1482
+ "required": false
1483
+ },
1484
+ {
1485
+ "name": "draw",
1486
+ "type": "boolean",
1487
+ "default": "true",
1488
+ "description": "The line draws itself once, from its start, when the map is on screen; `false` shows it whole. Default `true`.",
1489
+ "required": false
1490
+ },
1491
+ {
1492
+ "name": "duration",
1493
+ "type": "number",
1494
+ "default": "1400",
1495
+ "description": "How long the drawing lasts, in milliseconds. Default 1400.",
1496
+ "required": false
1497
+ },
1498
+ {
1499
+ "name": "repeatAfter",
1500
+ "type": "number",
1501
+ "default": null,
1502
+ "description": "Start the trips again after a pause (milliseconds), only while the map is on screen. Without it the courier makes one trip.",
1503
+ "required": false
1504
+ },
1505
+ {
1506
+ "name": "replayKey",
1507
+ "type": "string | number",
1508
+ "default": null,
1509
+ "description": "Changing it replays the drawing and the trip.",
1510
+ "required": false
1511
+ },
1512
+ {
1513
+ "name": "tone",
1514
+ "type": "\"accent\" | \"soft\"",
1515
+ "default": "accent",
1516
+ "description": "`accent` or `soft`. Default `accent`.",
1517
+ "required": false
1518
+ },
1519
+ {
1520
+ "name": "tripDuration",
1521
+ "type": "number",
1522
+ "default": "3200",
1523
+ "description": "How long a trip takes, in milliseconds. Default 3200.",
1524
+ "required": false
1525
+ }
1526
+ ]
1527
+ },
1299
1528
  {
1300
1529
  "component": "UserPosition",
1301
1530
  "description": "The person's position: a dot that pulses (still under reduced motion), its accuracy as a soft disc.",
@@ -99,7 +99,7 @@
99
99
  "why": "A visitor decides whether a product is for them: the promise first, the proof and the call after.",
100
100
  "path": "examples/marketing-layout/product-page.tsx",
101
101
  "module": "@krizaka/ui/registry/examples/marketing-layout/product-page",
102
- "code": "import { ProductLogo } from \"@krizaka/ui\";\nimport { ActionBar } from \"@krizaka/ui/action-bar\";\nimport { ArgumentCard } from \"@krizaka/ui/argument-card\";\nimport { Button } from \"@krizaka/ui/button\";\nimport { CardGrid } from \"@krizaka/ui/card-grid\";\nimport { CtaPanel } from \"@krizaka/ui/cta-panel\";\nimport { DecisionList } from \"@krizaka/ui/decision-list\";\nimport { MarketingLayout } from \"@krizaka/ui/marketing-layout\";\nimport { MascotQuote } from \"@krizaka/ui/mascot-quote\";\nimport { Mascot } from \"@krizaka/ui/mascots\";\nimport { NavBar } from \"@krizaka/ui/nav-bar\";\nimport { Facts, Section } from \"@krizaka/ui/page\";\nimport { PageHero } from \"@krizaka/ui/page-hero\";\n\nconst WHY = [\n { id: \"local\", title: \"Local inference\", hook: \"Elsewhere, every prompt crosses the ocean.\", bird: \"falcon\" as const },\n { id: \"paid\", title: \"Paid once\", hook: \"Elsewhere, you pay per token, forever.\", bird: \"flamingo\" as const },\n { id: \"asked\", title: \"Agents that ask\", hook: \"Elsewhere, an agent acts first and apologises later.\", bird: \"pigeon\" as const },\n];\nconst DECISIONS = [\n { id: \"local\", chose: \"Local models by default.\", refused: \"A hosted API as the default path.\", because: \"A prompt that never leaves cannot leak.\", cost: \"You need a GPU, or patience on a CPU.\" },\n { id: \"telemetry\", chose: \"Zero telemetry.\", refused: \"Anonymous usage statistics.\", because: \"Anonymous rarely stays anonymous.\", cost: \"You tell us what breaks.\" },\n];\nconst labels = { chose: \"We chose\", refused: \"We refused\", because: \"Because\", cost: \"What it costs you\" };\n\n// TEMPLATE — a product page (Orazaka): the hero with its facts and its main action (cut, shimmer), the reasons as compact\n// argument cards (the bird in the corner), the decisions with their cost, the owl's promise, the closing call. On a\n// phone the hero's bird goes above the title, the cards stack, the decisions' three parts stack under the choice.\nexport default function MarketingLayoutProductPage() {\n return (\n <div className=\"brand-orazaka\">\n <MarketingLayout\n skipLabel=\"Skip to content\"\n header={\n <NavBar.Root label=\"Main\" compactAfter={false} className=\"max-w-(--kz-page-max,72rem)\">\n <NavBar.Brand href=\"#home\"><ProductLogo id=\"orazaka\" size={22} animated={false} /> Orazaka</NavBar.Brand>\n <NavBar.Items>\n <NavBar.Link href=\"#why\" active>Why</NavBar.Link>\n <NavBar.Link href=\"#decisions\">Decisions</NavBar.Link>\n </NavBar.Items>\n </NavBar.Root>\n }\n >\n <PageHero\n eyebrow=\"Sovereign AI · open source\"\n title=\"The AI that never leaves home\"\n lead=\"Chat, image, voice and video on your own server — one governed pipeline, nothing sent anywhere.\"\n aside={<Mascot bird=\"owl\" mood=\"welcome\" size={112} />}\n >\n <Facts items={[{ label: \"Apache-2.0\", tone: \"ok\" }, { label: \"Law 25 · GDPR\" }]} />\n <Button variant=\"primary\" size=\"lg\" shape=\"cut\" shimmer>Install in three commands</Button>\n </PageHero>\n <Section id=\"why\" eyebrow=\"Why\" title=\"Three traps it removes\">\n <CardGrid min={260} fit=\"fit\" reveal={false}>\n {WHY.map((w) => (\n <ArgumentCard\n key={w.id}\n id={`why-${w.id}`}\n title={w.title}\n mark={<Mascot bird={w.bird} size={40} animated={false} />}\n hook={{ label: \"The trap\", text: w.hook }}\n more={{ href: `#${w.id}`, label: \"Details\" }}\n />\n ))}\n </CardGrid>\n </Section>\n <Section id=\"decisions\" eyebrow=\"Decisions\" title=\"What we chose, and what it costs you\" divider>\n <DecisionList decisions={DECISIONS} labels={labels} reveal={false} />\n <div className=\"mt-8\">\n <MascotQuote variant=\"callout\" bird=\"owl\" label=\"The owl's promise\">\n Every personal record stays in Québec, and you can read who touched it.\n </MascotQuote>\n </div>\n </Section>\n <Section spacing=\"sm\" aria-labelledby=\"product-cta\">\n <CtaPanel id=\"product-cta\" title=\"Ready to run it at home?\" body=\"Three commands, then your first private answer.\">\n <ActionBar primaryFirst size=\"lg\" label=\"Get started\" actions={[{ href: \"#install\", label: \"Install\" }, { href: \"#contact\", label: \"Talk to us\" }]} />\n </CtaPanel>\n </Section>\n </MarketingLayout>\n </div>\n );\n}\n"
102
+ "code": "import { ProductLogo } from \"@krizaka/ui\";\nimport { ActionBar } from \"@krizaka/ui/action-bar\";\nimport { ArgumentCard } from \"@krizaka/ui/argument-card\";\nimport { Button } from \"@krizaka/ui/button\";\nimport { CardGrid } from \"@krizaka/ui/card-grid\";\nimport { CtaPanel } from \"@krizaka/ui/cta-panel\";\nimport { DecisionList } from \"@krizaka/ui/decision-list\";\nimport { MarketingLayout } from \"@krizaka/ui/marketing-layout\";\nimport { MascotQuote } from \"@krizaka/ui/mascot-quote\";\nimport { Mascot } from \"@krizaka/ui/mascots\";\nimport { NavBar } from \"@krizaka/ui/nav-bar\";\nimport { Facts, Section } from \"@krizaka/ui/page\";\nimport { PageHero } from \"@krizaka/ui/page-hero\";\n\nconst WHY = [\n { id: \"local\", title: \"Local inference\", hook: \"Elsewhere, every prompt crosses the ocean.\", bird: \"falcon\" as const },\n { id: \"paid\", title: \"Paid once\", hook: \"Elsewhere, you pay per token, forever.\", bird: \"flamingo\" as const },\n { id: \"asked\", title: \"Agents that ask\", hook: \"Elsewhere, an agent acts first and apologises later.\", bird: \"pigeon\" as const },\n];\nconst DECISIONS = [\n { id: \"local\", chose: \"Local models by default.\", refused: \"A hosted API as the default path.\", because: \"A prompt that never leaves cannot leak.\", cost: \"You need a GPU, or patience on a CPU.\" },\n { id: \"telemetry\", chose: \"Zero telemetry.\", refused: \"Anonymous usage statistics.\", because: \"Anonymous rarely stays anonymous.\", cost: \"You tell us what breaks.\" },\n];\nconst labels = { chose: \"We chose\", refused: \"We refused\", because: \"Because\", cost: \"What it costs you\" };\n\n// TEMPLATE — a product page (Orazaka): the hero with its facts and its main action, the reasons as compact\n// argument cards (the bird in the corner), the decisions with their cost, the owl's promise, the closing call. On a\n// phone the hero's bird goes above the title, the cards stack, the decisions' three parts stack under the choice.\nexport default function MarketingLayoutProductPage() {\n return (\n <div className=\"brand-orazaka\">\n <MarketingLayout\n skipLabel=\"Skip to content\"\n header={\n <NavBar.Root label=\"Main\" compactAfter={false} className=\"max-w-(--kz-page-max,72rem)\">\n <NavBar.Brand href=\"#home\"><ProductLogo id=\"orazaka\" size={22} animated={false} /> Orazaka</NavBar.Brand>\n <NavBar.Items>\n <NavBar.Link href=\"#why\" active>Why</NavBar.Link>\n <NavBar.Link href=\"#decisions\">Decisions</NavBar.Link>\n </NavBar.Items>\n </NavBar.Root>\n }\n >\n <PageHero\n eyebrow=\"Sovereign AI · open source\"\n title=\"The AI that never leaves home\"\n lead=\"Chat, image, voice and video on your own server — one governed pipeline, nothing sent anywhere.\"\n aside={<Mascot bird=\"owl\" mood=\"welcome\" size={112} />}\n >\n <Facts items={[{ label: \"Apache-2.0\", tone: \"ok\" }, { label: \"Law 25 · GDPR\" }]} />\n <Button variant=\"primary\" size=\"lg\">Install in three commands</Button>\n </PageHero>\n <Section id=\"why\" eyebrow=\"Why\" title=\"Three traps it removes\">\n <CardGrid min={260} fit=\"fit\" reveal={false}>\n {WHY.map((w) => (\n <ArgumentCard\n key={w.id}\n id={`why-${w.id}`}\n title={w.title}\n mark={<Mascot bird={w.bird} size={40} animated={false} />}\n hook={{ label: \"The trap\", text: w.hook }}\n more={{ href: `#${w.id}`, label: \"Details\" }}\n />\n ))}\n </CardGrid>\n </Section>\n <Section id=\"decisions\" eyebrow=\"Decisions\" title=\"What we chose, and what it costs you\" divider>\n <DecisionList decisions={DECISIONS} labels={labels} reveal={false} />\n <div className=\"mt-8\">\n <MascotQuote variant=\"callout\" bird=\"owl\" label=\"The owl's promise\">\n Every personal record stays in Québec, and you can read who touched it.\n </MascotQuote>\n </div>\n </Section>\n <Section spacing=\"sm\" aria-labelledby=\"product-cta\">\n <CtaPanel id=\"product-cta\" title=\"Ready to run it at home?\" body=\"Three commands, then your first private answer.\">\n <ActionBar primaryFirst size=\"lg\" label=\"Get started\" actions={[{ href: \"#install\", label: \"Install\" }, { href: \"#contact\", label: \"Talk to us\" }]} />\n </CtaPanel>\n </Section>\n </MarketingLayout>\n </div>\n );\n}\n"
103
103
  },
104
104
  {
105
105
  "name": "pricing",
@@ -110,7 +110,7 @@
110
110
  "why": "A buyer compares plans and sees what a credit buys before deciding to commit.",
111
111
  "path": "examples/marketing-layout/pricing.tsx",
112
112
  "module": "@krizaka/ui/registry/examples/marketing-layout/pricing",
113
- "code": "import { Badge } from \"@krizaka/ui/badge\";\nimport { Button } from \"@krizaka/ui/button\";\nimport { GlowCard } from \"@krizaka/ui/glow-card\";\nimport { Meter } from \"@krizaka/ui/meter\";\nimport { Section } from \"@krizaka/ui/page\";\nimport { PageHero } from \"@krizaka/ui/page-hero\";\n\nconst PLANS = [\n { id: \"community\", name: \"Community\", price: \"Free\", unit: \"self-hosted\", points: [\"Every feature\", \"Your hardware\", \"Community support\"] },\n { id: \"workspace\", name: \"Workspace\", price: \"€29\", unit: \"per month · 12,000 credits\", points: [\"Packs & Studios\", \"Shared workspaces\", \"Email support\"], featured: true },\n { id: \"sovereign\", name: \"Sovereign\", price: \"Custom\", unit: \"on your servers\", points: [\"Installation with you\", \"Law 25 review\", \"A named engineer\"] },\n];\n\n// TEMPLATE — pricing and credits: the plans side by side (the featured one lit by its turning ring), what a credit buys,\n// and the credits left for a signed-in visitor. On a phone the plans stack, the featured one first in the reading order\n// it already has; every action is the cut button, the featured one primary.\nexport default function MarketingLayoutPricing() {\n return (\n <div className=\"w-full\">\n <PageHero eyebrow=\"Pricing\" title=\"Pay once for what was done\" lead=\"Credits buy answers, images and minutes of video — never a seat you do not use.\" align=\"center\" backdrop=\"plain\" />\n <Section aria-label=\"Plans\" spacing=\"sm\">\n <ul className=\"grid gap-4 md:grid-cols-3\">\n {PLANS.map((p) => (\n <li key={p.id} className=\"flex\">\n <GlowCard alive={p.featured} className=\"flex w-full flex-col gap-4\">\n <div className=\"flex items-center justify-between gap-2\">\n <h2 className=\"font-display text-lg font-bold text-fg\">{p.name}</h2>\n {p.featured && <Badge tone=\"accent\">Most chosen</Badge>}\n </div>\n <p>\n <span className=\"font-display text-4xl font-extrabold text-fg\">{p.price}</span>\n <span className=\"mt-1 block text-sm text-fg-secondary\">{p.unit}</span>\n </p>\n <ul className=\"flex flex-col gap-2 text-sm text-fg-secondary\">\n {p.points.map((x) => (\n <li key={x}>— {x}</li>\n ))}\n </ul>\n <Button variant={p.featured ? \"primary\" : \"secondary\"} shape=\"cut\" className=\"mt-auto w-full\">\n Choose {p.name}\n </Button>\n </GlowCard>\n </li>\n ))}\n </ul>\n </Section>\n <Section id=\"credits\" title=\"Your credits\" headingSize=\"sm\" spacing=\"sm\" divider>\n <div className=\"max-w-md\">\n <Meter label=\"Credits this month\" value={3800} max={12000} valueText=\"3,800 of 12,000 used\" thresholds={{ low: 0.7, high: 0.9 }} showLabel />\n <p className=\"mt-3 text-sm text-fg-secondary\">One credit ≈ one chat answer · 4 credits an image · 30 a minute of video.</p>\n </div>\n </Section>\n </div>\n );\n}\n"
113
+ "code": "import { Badge } from \"@krizaka/ui/badge\";\nimport { Button } from \"@krizaka/ui/button\";\nimport { GlowCard } from \"@krizaka/ui/glow-card\";\nimport { Meter } from \"@krizaka/ui/meter\";\nimport { Section } from \"@krizaka/ui/page\";\nimport { PageHero } from \"@krizaka/ui/page-hero\";\n\nconst PLANS = [\n { id: \"community\", name: \"Community\", price: \"Free\", unit: \"self-hosted\", points: [\"Every feature\", \"Your hardware\", \"Community support\"] },\n { id: \"workspace\", name: \"Workspace\", price: \"€29\", unit: \"per month · 12,000 credits\", points: [\"Packs & Studios\", \"Shared workspaces\", \"Email support\"], featured: true },\n { id: \"sovereign\", name: \"Sovereign\", price: \"Custom\", unit: \"on your servers\", points: [\"Installation with you\", \"Law 25 review\", \"A named engineer\"] },\n];\n\n// TEMPLATE — pricing and credits: the plans side by side (the featured one lit by its turning ring), what a credit buys,\n// and the credits left for a signed-in visitor. On a phone the plans stack, the featured one first in the reading order\n// it already has; every action is the cut button, the featured one primary.\nexport default function MarketingLayoutPricing() {\n return (\n <div className=\"w-full\">\n <PageHero eyebrow=\"Pricing\" title=\"Pay once for what was done\" lead=\"Credits buy answers, images and minutes of video — never a seat you do not use.\" align=\"center\" backdrop=\"plain\" />\n <Section aria-label=\"Plans\" spacing=\"sm\">\n <ul className=\"grid gap-4 md:grid-cols-3\">\n {PLANS.map((p) => (\n <li key={p.id} className=\"flex\">\n <GlowCard alive={p.featured} className=\"flex w-full flex-col gap-4\">\n <div className=\"flex items-center justify-between gap-2\">\n <h2 className=\"font-display text-lg font-bold text-fg\">{p.name}</h2>\n {p.featured && <Badge tone=\"accent\">Most chosen</Badge>}\n </div>\n <p>\n <span className=\"font-display text-4xl font-extrabold text-fg\">{p.price}</span>\n <span className=\"mt-1 block text-sm text-fg-secondary\">{p.unit}</span>\n </p>\n <ul className=\"flex flex-col gap-2 text-sm text-fg-secondary\">\n {p.points.map((x) => (\n <li key={x}>— {x}</li>\n ))}\n </ul>\n <Button variant={p.featured ? \"primary\" : \"secondary\"} className=\"mt-auto w-full\">\n Choose {p.name}\n </Button>\n </GlowCard>\n </li>\n ))}\n </ul>\n </Section>\n <Section id=\"credits\" title=\"Your credits\" headingSize=\"sm\" spacing=\"sm\" divider>\n <div className=\"max-w-md\">\n <Meter label=\"Credits this month\" value={3800} max={12000} valueText=\"3,800 of 12,000 used\" thresholds={{ low: 0.7, high: 0.9 }} showLabel />\n <p className=\"mt-3 text-sm text-fg-secondary\">One credit ≈ one chat answer · 4 credits an image · 30 a minute of video.</p>\n </div>\n </Section>\n </div>\n );\n}\n"
114
114
  },
115
115
  {
116
116
  "name": "docs-hub",
@@ -160,7 +160,7 @@
160
160
  ],
161
161
  "demo": {
162
162
  "path": "examples/marketing-layout/product-page.tsx",
163
- "content": "import { ProductLogo } from \"@krizaka/ui\";\nimport { ActionBar } from \"@krizaka/ui/action-bar\";\nimport { ArgumentCard } from \"@krizaka/ui/argument-card\";\nimport { Button } from \"@krizaka/ui/button\";\nimport { CardGrid } from \"@krizaka/ui/card-grid\";\nimport { CtaPanel } from \"@krizaka/ui/cta-panel\";\nimport { DecisionList } from \"@krizaka/ui/decision-list\";\nimport { MarketingLayout } from \"@krizaka/ui/marketing-layout\";\nimport { MascotQuote } from \"@krizaka/ui/mascot-quote\";\nimport { Mascot } from \"@krizaka/ui/mascots\";\nimport { NavBar } from \"@krizaka/ui/nav-bar\";\nimport { Facts, Section } from \"@krizaka/ui/page\";\nimport { PageHero } from \"@krizaka/ui/page-hero\";\n\nconst WHY = [\n { id: \"local\", title: \"Local inference\", hook: \"Elsewhere, every prompt crosses the ocean.\", bird: \"falcon\" as const },\n { id: \"paid\", title: \"Paid once\", hook: \"Elsewhere, you pay per token, forever.\", bird: \"flamingo\" as const },\n { id: \"asked\", title: \"Agents that ask\", hook: \"Elsewhere, an agent acts first and apologises later.\", bird: \"pigeon\" as const },\n];\nconst DECISIONS = [\n { id: \"local\", chose: \"Local models by default.\", refused: \"A hosted API as the default path.\", because: \"A prompt that never leaves cannot leak.\", cost: \"You need a GPU, or patience on a CPU.\" },\n { id: \"telemetry\", chose: \"Zero telemetry.\", refused: \"Anonymous usage statistics.\", because: \"Anonymous rarely stays anonymous.\", cost: \"You tell us what breaks.\" },\n];\nconst labels = { chose: \"We chose\", refused: \"We refused\", because: \"Because\", cost: \"What it costs you\" };\n\n// TEMPLATE — a product page (Orazaka): the hero with its facts and its main action (cut, shimmer), the reasons as compact\n// argument cards (the bird in the corner), the decisions with their cost, the owl's promise, the closing call. On a\n// phone the hero's bird goes above the title, the cards stack, the decisions' three parts stack under the choice.\nexport default function MarketingLayoutProductPage() {\n return (\n <div className=\"brand-orazaka\">\n <MarketingLayout\n skipLabel=\"Skip to content\"\n header={\n <NavBar.Root label=\"Main\" compactAfter={false} className=\"max-w-(--kz-page-max,72rem)\">\n <NavBar.Brand href=\"#home\"><ProductLogo id=\"orazaka\" size={22} animated={false} /> Orazaka</NavBar.Brand>\n <NavBar.Items>\n <NavBar.Link href=\"#why\" active>Why</NavBar.Link>\n <NavBar.Link href=\"#decisions\">Decisions</NavBar.Link>\n </NavBar.Items>\n </NavBar.Root>\n }\n >\n <PageHero\n eyebrow=\"Sovereign AI · open source\"\n title=\"The AI that never leaves home\"\n lead=\"Chat, image, voice and video on your own server — one governed pipeline, nothing sent anywhere.\"\n aside={<Mascot bird=\"owl\" mood=\"welcome\" size={112} />}\n >\n <Facts items={[{ label: \"Apache-2.0\", tone: \"ok\" }, { label: \"Law 25 · GDPR\" }]} />\n <Button variant=\"primary\" size=\"lg\" shape=\"cut\" shimmer>Install in three commands</Button>\n </PageHero>\n <Section id=\"why\" eyebrow=\"Why\" title=\"Three traps it removes\">\n <CardGrid min={260} fit=\"fit\" reveal={false}>\n {WHY.map((w) => (\n <ArgumentCard\n key={w.id}\n id={`why-${w.id}`}\n title={w.title}\n mark={<Mascot bird={w.bird} size={40} animated={false} />}\n hook={{ label: \"The trap\", text: w.hook }}\n more={{ href: `#${w.id}`, label: \"Details\" }}\n />\n ))}\n </CardGrid>\n </Section>\n <Section id=\"decisions\" eyebrow=\"Decisions\" title=\"What we chose, and what it costs you\" divider>\n <DecisionList decisions={DECISIONS} labels={labels} reveal={false} />\n <div className=\"mt-8\">\n <MascotQuote variant=\"callout\" bird=\"owl\" label=\"The owl's promise\">\n Every personal record stays in Québec, and you can read who touched it.\n </MascotQuote>\n </div>\n </Section>\n <Section spacing=\"sm\" aria-labelledby=\"product-cta\">\n <CtaPanel id=\"product-cta\" title=\"Ready to run it at home?\" body=\"Three commands, then your first private answer.\">\n <ActionBar primaryFirst size=\"lg\" label=\"Get started\" actions={[{ href: \"#install\", label: \"Install\" }, { href: \"#contact\", label: \"Talk to us\" }]} />\n </CtaPanel>\n </Section>\n </MarketingLayout>\n </div>\n );\n}\n"
163
+ "content": "import { ProductLogo } from \"@krizaka/ui\";\nimport { ActionBar } from \"@krizaka/ui/action-bar\";\nimport { ArgumentCard } from \"@krizaka/ui/argument-card\";\nimport { Button } from \"@krizaka/ui/button\";\nimport { CardGrid } from \"@krizaka/ui/card-grid\";\nimport { CtaPanel } from \"@krizaka/ui/cta-panel\";\nimport { DecisionList } from \"@krizaka/ui/decision-list\";\nimport { MarketingLayout } from \"@krizaka/ui/marketing-layout\";\nimport { MascotQuote } from \"@krizaka/ui/mascot-quote\";\nimport { Mascot } from \"@krizaka/ui/mascots\";\nimport { NavBar } from \"@krizaka/ui/nav-bar\";\nimport { Facts, Section } from \"@krizaka/ui/page\";\nimport { PageHero } from \"@krizaka/ui/page-hero\";\n\nconst WHY = [\n { id: \"local\", title: \"Local inference\", hook: \"Elsewhere, every prompt crosses the ocean.\", bird: \"falcon\" as const },\n { id: \"paid\", title: \"Paid once\", hook: \"Elsewhere, you pay per token, forever.\", bird: \"flamingo\" as const },\n { id: \"asked\", title: \"Agents that ask\", hook: \"Elsewhere, an agent acts first and apologises later.\", bird: \"pigeon\" as const },\n];\nconst DECISIONS = [\n { id: \"local\", chose: \"Local models by default.\", refused: \"A hosted API as the default path.\", because: \"A prompt that never leaves cannot leak.\", cost: \"You need a GPU, or patience on a CPU.\" },\n { id: \"telemetry\", chose: \"Zero telemetry.\", refused: \"Anonymous usage statistics.\", because: \"Anonymous rarely stays anonymous.\", cost: \"You tell us what breaks.\" },\n];\nconst labels = { chose: \"We chose\", refused: \"We refused\", because: \"Because\", cost: \"What it costs you\" };\n\n// TEMPLATE — a product page (Orazaka): the hero with its facts and its main action, the reasons as compact\n// argument cards (the bird in the corner), the decisions with their cost, the owl's promise, the closing call. On a\n// phone the hero's bird goes above the title, the cards stack, the decisions' three parts stack under the choice.\nexport default function MarketingLayoutProductPage() {\n return (\n <div className=\"brand-orazaka\">\n <MarketingLayout\n skipLabel=\"Skip to content\"\n header={\n <NavBar.Root label=\"Main\" compactAfter={false} className=\"max-w-(--kz-page-max,72rem)\">\n <NavBar.Brand href=\"#home\"><ProductLogo id=\"orazaka\" size={22} animated={false} /> Orazaka</NavBar.Brand>\n <NavBar.Items>\n <NavBar.Link href=\"#why\" active>Why</NavBar.Link>\n <NavBar.Link href=\"#decisions\">Decisions</NavBar.Link>\n </NavBar.Items>\n </NavBar.Root>\n }\n >\n <PageHero\n eyebrow=\"Sovereign AI · open source\"\n title=\"The AI that never leaves home\"\n lead=\"Chat, image, voice and video on your own server — one governed pipeline, nothing sent anywhere.\"\n aside={<Mascot bird=\"owl\" mood=\"welcome\" size={112} />}\n >\n <Facts items={[{ label: \"Apache-2.0\", tone: \"ok\" }, { label: \"Law 25 · GDPR\" }]} />\n <Button variant=\"primary\" size=\"lg\">Install in three commands</Button>\n </PageHero>\n <Section id=\"why\" eyebrow=\"Why\" title=\"Three traps it removes\">\n <CardGrid min={260} fit=\"fit\" reveal={false}>\n {WHY.map((w) => (\n <ArgumentCard\n key={w.id}\n id={`why-${w.id}`}\n title={w.title}\n mark={<Mascot bird={w.bird} size={40} animated={false} />}\n hook={{ label: \"The trap\", text: w.hook }}\n more={{ href: `#${w.id}`, label: \"Details\" }}\n />\n ))}\n </CardGrid>\n </Section>\n <Section id=\"decisions\" eyebrow=\"Decisions\" title=\"What we chose, and what it costs you\" divider>\n <DecisionList decisions={DECISIONS} labels={labels} reveal={false} />\n <div className=\"mt-8\">\n <MascotQuote variant=\"callout\" bird=\"owl\" label=\"The owl's promise\">\n Every personal record stays in Québec, and you can read who touched it.\n </MascotQuote>\n </div>\n </Section>\n <Section spacing=\"sm\" aria-labelledby=\"product-cta\">\n <CtaPanel id=\"product-cta\" title=\"Ready to run it at home?\" body=\"Three commands, then your first private answer.\">\n <ActionBar primaryFirst size=\"lg\" label=\"Get started\" actions={[{ href: \"#install\", label: \"Install\" }, { href: \"#contact\", label: \"Talk to us\" }]} />\n </CtaPanel>\n </Section>\n </MarketingLayout>\n </div>\n );\n}\n"
164
164
  },
165
165
  "props": [
166
166
  {