@humanforest/nuxt-layer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (324) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +208 -0
  3. package/app.config.ts +10 -0
  4. package/assets/icons/forest/bike-asterisk.svg +21 -0
  5. package/assets/icons/forest/bike-down.svg +19 -0
  6. package/assets/icons/forest/bike-off.svg +20 -0
  7. package/assets/icons/forest/bike-up.svg +19 -0
  8. package/assets/icons/forest/bike-x.svg +19 -0
  9. package/assets/icons/forest/bike.svg +18 -0
  10. package/assets/icons/forest/mark-fill.svg +6 -0
  11. package/assets/icons/forest/mark.svg +6 -0
  12. package/assets/icons/forest/parking.svg +3 -0
  13. package/assets/icons/forest/star-fill.svg +4 -0
  14. package/assets/icons/forest/star.svg +4 -0
  15. package/assets/icons/forest/traffic-light-caution.svg +8 -0
  16. package/assets/icons/forest/traffic-light-go.svg +8 -0
  17. package/assets/icons/forest/traffic-light-lit.svg +8 -0
  18. package/assets/icons/forest/traffic-light-stop.svg +8 -0
  19. package/assets/icons/forest/traffic-light.svg +8 -0
  20. package/nuxt.config.ts +81 -0
  21. package/package.json +35 -0
  22. package/packages/charts/src/FBoroughShape.vue +52 -0
  23. package/packages/charts/src/FCalendarGrid.vue +332 -0
  24. package/packages/charts/src/FCellLegend.vue +54 -0
  25. package/packages/charts/src/FChartFrame.vue +201 -0
  26. package/packages/charts/src/FChartFrameSingle.vue +91 -0
  27. package/packages/charts/src/FChartLegend.vue +26 -0
  28. package/packages/charts/src/FDistributionBar.vue +224 -0
  29. package/packages/charts/src/FDottedMap.vue +335 -0
  30. package/packages/charts/src/FLondonMap.vue +399 -0
  31. package/packages/charts/src/FSparkline.vue +198 -0
  32. package/packages/charts/src/FStatusTrack.vue +151 -0
  33. package/packages/charts/src/cellSize.ts +46 -0
  34. package/packages/charts/src/distribution.ts +99 -0
  35. package/packages/charts/src/engine.ts +15 -0
  36. package/packages/charts/src/forestTooltip.ts +110 -0
  37. package/packages/charts/src/fromCategories.ts +43 -0
  38. package/packages/charts/src/index.ts +87 -0
  39. package/packages/charts/src/londonAreas.ts +75 -0
  40. package/packages/charts/src/londonGrid.ts +67 -0
  41. package/packages/charts/src/motionDuration.ts +49 -0
  42. package/packages/charts/src/presets.ts +143 -0
  43. package/packages/charts/src/resolveVar.ts +21 -0
  44. package/packages/charts/src/snapTooltip.ts +154 -0
  45. package/packages/charts/src/tooltipStandIn.ts +58 -0
  46. package/packages/charts/src/useCellScale.ts +155 -0
  47. package/packages/charts/src/useChartPalette.ts +61 -0
  48. package/packages/charts/src/useChartRepaintKey.ts +22 -0
  49. package/packages/charts/src/useThemeVersion.ts +17 -0
  50. package/packages/frames/src/FBrowserFrame.vue +53 -0
  51. package/packages/frames/src/FDeviceFrame.vue +242 -0
  52. package/packages/frames/src/FFitFrame.vue +83 -0
  53. package/packages/frames/src/art/iphone-11.svg +124 -0
  54. package/packages/frames/src/art/iphone-12.svg +601 -0
  55. package/packages/frames/src/art/iphone-13-pro.svg +820 -0
  56. package/packages/frames/src/art/iphone-13.svg +849 -0
  57. package/packages/frames/src/art/iphone-14.svg +681 -0
  58. package/packages/frames/src/art/iphone-15-pro.svg +633 -0
  59. package/packages/frames/src/art/iphone-15.svg +713 -0
  60. package/packages/frames/src/art/iphone-16-plus.svg +685 -0
  61. package/packages/frames/src/art/iphone-16-pro-max.svg +611 -0
  62. package/packages/frames/src/art/iphone-16-pro.svg +631 -0
  63. package/packages/frames/src/art/iphone-16.svg +713 -0
  64. package/packages/frames/src/art/iphone-17-pro-max.svg +355 -0
  65. package/packages/frames/src/art/iphone-17.svg +305 -0
  66. package/packages/frames/src/art/iphone-air.svg +389 -0
  67. package/packages/frames/src/art/iphone-se-3.svg +473 -0
  68. package/packages/frames/src/art/iphone-x.svg +652 -0
  69. package/packages/frames/src/art/pixel-10-pro-xl.svg +32 -0
  70. package/packages/frames/src/art/pixel-10-pro.svg +32 -0
  71. package/packages/frames/src/art/pixel-10.svg +32 -0
  72. package/packages/frames/src/art/pixel-8-pro.svg +208 -0
  73. package/packages/frames/src/art/pixel-8.svg +208 -0
  74. package/packages/frames/src/art/pixel-8a.svg +8 -0
  75. package/packages/frames/src/art/pixel-9-pro-xl.svg +32 -0
  76. package/packages/frames/src/art/pixel-9-pro.svg +34 -0
  77. package/packages/frames/src/art/pixel-9a.svg +25 -0
  78. package/packages/frames/src/art.ts +29 -0
  79. package/packages/frames/src/devices.ts +337 -0
  80. package/packages/frames/src/index.ts +15 -0
  81. package/packages/tokens/geo/london-boroughs.json +1 -0
  82. package/packages/tokens/geo/london-dots-coarse.json +1 -0
  83. package/packages/tokens/geo/london-dots-fine.json +1 -0
  84. package/packages/tokens/geo/london-dots-medium.json +1 -0
  85. package/packages/tokens/geo/london-dots-ultra.json +1 -0
  86. package/packages/tokens/geo/london-wards.json +1 -0
  87. package/packages/tokens/glyphs/glyphs.json +1 -0
  88. package/packages/tokens/logos/f-rot.svg +4 -0
  89. package/packages/tokens/logos/f.svg +4 -0
  90. package/packages/tokens/logos/favicon-c2w.svg +9 -0
  91. package/packages/tokens/logos/favicon-console.svg +9 -0
  92. package/packages/tokens/logos/favicon-ds.svg +9 -0
  93. package/packages/tokens/logos/favicon-fleet.svg +9 -0
  94. package/packages/tokens/logos/favicon-flex.svg +9 -0
  95. package/packages/tokens/logos/favicon-plus.svg +9 -0
  96. package/packages/tokens/logos/favicon-radar.svg +9 -0
  97. package/packages/tokens/logos/favicon-tower.svg +9 -0
  98. package/packages/tokens/logos/lockup-horizontal.svg +17 -0
  99. package/packages/tokens/logos/lockup-vertical.svg +17 -0
  100. package/packages/tokens/logos/mark-mono.svg +4 -0
  101. package/packages/tokens/logos/mark.svg +7 -0
  102. package/packages/tokens/logos/wordmark.svg +9 -0
  103. package/packages/tokens/src/colourEngine.ts +604 -0
  104. package/packages/tokens/src/glyphs.ts +127 -0
  105. package/packages/tokens/src/logo.ts +50 -0
  106. package/packages/tokens/src/subbrands.ts +44 -0
  107. package/packages/tokens/src/sublogo.ts +80 -0
  108. package/packages/ui/icons/forest/map/forest-place-count.svg +21 -0
  109. package/packages/ui/icons/forest/map/forest-place-marker.svg +17 -0
  110. package/packages/ui/icons/forest/map/forest-vehicle-marker.svg +73 -0
  111. package/packages/ui/icons/forest/map/manifest.json +373 -0
  112. package/packages/ui/icons/forest/map/place-count-manifest.json +83 -0
  113. package/packages/ui/icons/forest/map/place-manifest.json +74 -0
  114. package/packages/ui/src/address/address.ts +125 -0
  115. package/packages/ui/src/address/autofill.ts +78 -0
  116. package/packages/ui/src/address/geocode.ts +55 -0
  117. package/packages/ui/src/address/index.ts +31 -0
  118. package/packages/ui/src/address/validate.ts +91 -0
  119. package/packages/ui/src/canvas/FCanvasCard.vue +97 -0
  120. package/packages/ui/src/canvas/FCanvasControl.vue +121 -0
  121. package/packages/ui/src/canvas/FCanvasControlGroup.vue +100 -0
  122. package/packages/ui/src/canvas/FCanvasFlyout.vue +216 -0
  123. package/packages/ui/src/canvas/FCanvasHud.vue +402 -0
  124. package/packages/ui/src/canvas/canvasSurface.ts +39 -0
  125. package/packages/ui/src/canvas/index.ts +17 -0
  126. package/packages/ui/src/contexts/ForestContext.vue +53 -0
  127. package/packages/ui/src/contexts/contexts.ts +153 -0
  128. package/packages/ui/src/contexts/index.ts +23 -0
  129. package/packages/ui/src/contexts/marketing-theme.ts +214 -0
  130. package/packages/ui/src/contexts/mobile-theme.ts +402 -0
  131. package/packages/ui/src/filter/FFilterBar.vue +72 -0
  132. package/packages/ui/src/filter/FFilterChip.vue +362 -0
  133. package/packages/ui/src/filter/FFilterClauses.vue +104 -0
  134. package/packages/ui/src/filter/FFilterTrigger.vue +183 -0
  135. package/packages/ui/src/filter/filter.theme.ts +27 -0
  136. package/packages/ui/src/filter/filterClause.ts +265 -0
  137. package/packages/ui/src/filter/filterPredicate.ts +134 -0
  138. package/packages/ui/src/filter/filterSchema.ts +82 -0
  139. package/packages/ui/src/filter/index.ts +36 -0
  140. package/packages/ui/src/forest-preset.ts +68 -0
  141. package/packages/ui/src/icons.ts +79 -0
  142. package/packages/ui/src/index.ts +17 -0
  143. package/packages/ui/src/kpi/FKpi.vue +253 -0
  144. package/packages/ui/src/kpi/FKpiGroup.vue +161 -0
  145. package/packages/ui/src/kpi/index.ts +20 -0
  146. package/packages/ui/src/kpi/kpi.theme.ts +168 -0
  147. package/packages/ui/src/kpi/kpiDelta.ts +82 -0
  148. package/packages/ui/src/kpi/kpiFormat.ts +72 -0
  149. package/packages/ui/src/kpi/kpiGroupState.ts +63 -0
  150. package/packages/ui/src/logo/FLogo.vue +52 -0
  151. package/packages/ui/src/logo/FSubLogo.vue +214 -0
  152. package/packages/ui/src/logo/index.ts +8 -0
  153. package/packages/ui/src/map/FMap.vue +355 -0
  154. package/packages/ui/src/map/FMapCompass.vue +154 -0
  155. package/packages/ui/src/map/FMapCoordinates.vue +134 -0
  156. package/packages/ui/src/map/FMapDrawTools.vue +116 -0
  157. package/packages/ui/src/map/FMapFullscreen.vue +92 -0
  158. package/packages/ui/src/map/FMapGeolocate.vue +208 -0
  159. package/packages/ui/src/map/FMapLayers.vue +75 -0
  160. package/packages/ui/src/map/FMapLegend.vue +45 -0
  161. package/packages/ui/src/map/FMapPopup.vue +129 -0
  162. package/packages/ui/src/map/FMapRamp.vue +69 -0
  163. package/packages/ui/src/map/FMapRegion.vue +77 -0
  164. package/packages/ui/src/map/FMapScale.vue +107 -0
  165. package/packages/ui/src/map/FMapSearch.vue +362 -0
  166. package/packages/ui/src/map/FMapTilt.vue +87 -0
  167. package/packages/ui/src/map/FMapTools.vue +61 -0
  168. package/packages/ui/src/map/FMapZoom.vue +81 -0
  169. package/packages/ui/src/map/FMarker.vue +218 -0
  170. package/packages/ui/src/map/FTrackScrubber.vue +94 -0
  171. package/packages/ui/src/map/FVehicleLegend.vue +72 -0
  172. package/packages/ui/src/map/FVehicleMark.vue +166 -0
  173. package/packages/ui/src/map/bin.ts +196 -0
  174. package/packages/ui/src/map/coordinates.ts +83 -0
  175. package/packages/ui/src/map/density.ts +401 -0
  176. package/packages/ui/src/map/draw.ts +356 -0
  177. package/packages/ui/src/map/ease.ts +67 -0
  178. package/packages/ui/src/map/fullscreen.ts +59 -0
  179. package/packages/ui/src/map/heat.ts +160 -0
  180. package/packages/ui/src/map/index.ts +351 -0
  181. package/packages/ui/src/map/liveMarkers.ts +60 -0
  182. package/packages/ui/src/map/map.css +116 -0
  183. package/packages/ui/src/map/mapColor.ts +31 -0
  184. package/packages/ui/src/map/mapContext.ts +12 -0
  185. package/packages/ui/src/map/mapLayers.ts +55 -0
  186. package/packages/ui/src/map/mapLegend.ts +70 -0
  187. package/packages/ui/src/map/mapSearch.theme.ts +79 -0
  188. package/packages/ui/src/map/mapSearch.ts +279 -0
  189. package/packages/ui/src/map/mapUrl.ts +117 -0
  190. package/packages/ui/src/map/markerAsset.ts +3 -0
  191. package/packages/ui/src/map/padding.ts +39 -0
  192. package/packages/ui/src/map/place.ts +192 -0
  193. package/packages/ui/src/map/ramp.ts +95 -0
  194. package/packages/ui/src/map/regionSelect.ts +163 -0
  195. package/packages/ui/src/map/route.ts +167 -0
  196. package/packages/ui/src/map/scale.ts +329 -0
  197. package/packages/ui/src/map/scaleBar.ts +67 -0
  198. package/packages/ui/src/map/selectionModel.ts +50 -0
  199. package/packages/ui/src/map/sources/coordinates.ts +58 -0
  200. package/packages/ui/src/map/sources/mapboxPlaces.ts +71 -0
  201. package/packages/ui/src/map/style.ts +14 -0
  202. package/packages/ui/src/map/track.ts +279 -0
  203. package/packages/ui/src/map/useFeatureSelect.ts +230 -0
  204. package/packages/ui/src/map/useLiveMarkers.ts +84 -0
  205. package/packages/ui/src/map/useMap.ts +150 -0
  206. package/packages/ui/src/map/useMapDraw.ts +776 -0
  207. package/packages/ui/src/map/useMapOverlay.ts +421 -0
  208. package/packages/ui/src/map/useMapTableSelection.ts +101 -0
  209. package/packages/ui/src/map/useMapUrlState.ts +155 -0
  210. package/packages/ui/src/map/useMarkerSource.ts +163 -0
  211. package/packages/ui/src/map/useRegionSelect.ts +309 -0
  212. package/packages/ui/src/map/useTokenMode.ts +61 -0
  213. package/packages/ui/src/map/useTrackPlayback.ts +131 -0
  214. package/packages/ui/src/map/useVehicleFilter.ts +51 -0
  215. package/packages/ui/src/map/useVisibleFeatures.ts +124 -0
  216. package/packages/ui/src/map/vehicle.ts +807 -0
  217. package/packages/ui/src/map/vehicleFilter.ts +284 -0
  218. package/packages/ui/src/map/vehicleLegend.ts +155 -0
  219. package/packages/ui/src/map/visibleFeatures.ts +192 -0
  220. package/packages/ui/src/map/zone.ts +291 -0
  221. package/packages/ui/src/shell/FAppNavbar.vue +68 -0
  222. package/packages/ui/src/shell/FAppShell.vue +163 -0
  223. package/packages/ui/src/shell/FAppSidebar.vue +215 -0
  224. package/packages/ui/src/shell/FPageToolbar.vue +126 -0
  225. package/packages/ui/src/shell/FScrollShadow.vue +43 -0
  226. package/packages/ui/src/shell/FSidePanel.vue +258 -0
  227. package/packages/ui/src/shell/FToolbarActions.vue +32 -0
  228. package/packages/ui/src/shell/index.ts +33 -0
  229. package/packages/ui/src/shell/nav.ts +37 -0
  230. package/packages/ui/src/shell/pageToolbar.ts +69 -0
  231. package/packages/ui/src/shell/shell.ts +74 -0
  232. package/packages/ui/src/shell/sidePanel.ts +115 -0
  233. package/packages/ui/src/themes/accordion.ts +36 -0
  234. package/packages/ui/src/themes/alert.ts +16 -0
  235. package/packages/ui/src/themes/auth-form.ts +19 -0
  236. package/packages/ui/src/themes/avatar-group.ts +9 -0
  237. package/packages/ui/src/themes/avatar.ts +18 -0
  238. package/packages/ui/src/themes/badge.ts +51 -0
  239. package/packages/ui/src/themes/banner.ts +21 -0
  240. package/packages/ui/src/themes/breadcrumb.ts +10 -0
  241. package/packages/ui/src/themes/button.mechanics.ts +191 -0
  242. package/packages/ui/src/themes/button.ts +136 -0
  243. package/packages/ui/src/themes/calendar.ts +29 -0
  244. package/packages/ui/src/themes/card.ts +67 -0
  245. package/packages/ui/src/themes/carousel.ts +41 -0
  246. package/packages/ui/src/themes/checkbox-group.ts +20 -0
  247. package/packages/ui/src/themes/checkbox.ts +47 -0
  248. package/packages/ui/src/themes/chip.ts +50 -0
  249. package/packages/ui/src/themes/collapsible.ts +26 -0
  250. package/packages/ui/src/themes/color-picker.ts +13 -0
  251. package/packages/ui/src/themes/command-palette.ts +37 -0
  252. package/packages/ui/src/themes/container.ts +14 -0
  253. package/packages/ui/src/themes/context-menu.ts +38 -0
  254. package/packages/ui/src/themes/dashboard-group.ts +11 -0
  255. package/packages/ui/src/themes/dashboard-navbar.ts +10 -0
  256. package/packages/ui/src/themes/dashboard-panel.ts +18 -0
  257. package/packages/ui/src/themes/dashboard-resize-handle.ts +35 -0
  258. package/packages/ui/src/themes/dashboard-sidebar-collapse.ts +7 -0
  259. package/packages/ui/src/themes/dashboard-sidebar-toggle.ts +7 -0
  260. package/packages/ui/src/themes/dashboard-sidebar.ts +58 -0
  261. package/packages/ui/src/themes/dashboard-toolbar.ts +15 -0
  262. package/packages/ui/src/themes/drawer.ts +45 -0
  263. package/packages/ui/src/themes/dropdown-menu.ts +38 -0
  264. package/packages/ui/src/themes/empty.ts +43 -0
  265. package/packages/ui/src/themes/field-group.ts +52 -0
  266. package/packages/ui/src/themes/file-upload.ts +38 -0
  267. package/packages/ui/src/themes/footer-columns.ts +13 -0
  268. package/packages/ui/src/themes/footer.ts +9 -0
  269. package/packages/ui/src/themes/form-field.ts +25 -0
  270. package/packages/ui/src/themes/form.ts +22 -0
  271. package/packages/ui/src/themes/header.ts +12 -0
  272. package/packages/ui/src/themes/icon.ts +61 -0
  273. package/packages/ui/src/themes/index.ts +99 -0
  274. package/packages/ui/src/themes/input-date.ts +73 -0
  275. package/packages/ui/src/themes/input-menu.ts +66 -0
  276. package/packages/ui/src/themes/input-number.ts +58 -0
  277. package/packages/ui/src/themes/input-rating.ts +25 -0
  278. package/packages/ui/src/themes/input-tags.ts +63 -0
  279. package/packages/ui/src/themes/input-time.ts +70 -0
  280. package/packages/ui/src/themes/input.ts +148 -0
  281. package/packages/ui/src/themes/kbd.ts +27 -0
  282. package/packages/ui/src/themes/link.ts +27 -0
  283. package/packages/ui/src/themes/listbox.ts +48 -0
  284. package/packages/ui/src/themes/main.ts +6 -0
  285. package/packages/ui/src/themes/marquee.ts +45 -0
  286. package/packages/ui/src/themes/modal.ts +46 -0
  287. package/packages/ui/src/themes/navigation-menu.ts +80 -0
  288. package/packages/ui/src/themes/page-anchors.ts +7 -0
  289. package/packages/ui/src/themes/page-aside.ts +7 -0
  290. package/packages/ui/src/themes/page-card.ts +22 -0
  291. package/packages/ui/src/themes/page-cta.ts +18 -0
  292. package/packages/ui/src/themes/page-feature.ts +13 -0
  293. package/packages/ui/src/themes/page-header.ts +16 -0
  294. package/packages/ui/src/themes/page-hero.ts +15 -0
  295. package/packages/ui/src/themes/page-links.ts +12 -0
  296. package/packages/ui/src/themes/page-section.ts +14 -0
  297. package/packages/ui/src/themes/page.ts +7 -0
  298. package/packages/ui/src/themes/pagination.ts +17 -0
  299. package/packages/ui/src/themes/pin-input.ts +73 -0
  300. package/packages/ui/src/themes/popover.ts +30 -0
  301. package/packages/ui/src/themes/progress.ts +29 -0
  302. package/packages/ui/src/themes/prose.ts +45 -0
  303. package/packages/ui/src/themes/radio-group.ts +39 -0
  304. package/packages/ui/src/themes/scroll-area.ts +23 -0
  305. package/packages/ui/src/themes/select-menu.ts +71 -0
  306. package/packages/ui/src/themes/select.ts +72 -0
  307. package/packages/ui/src/themes/separator.ts +28 -0
  308. package/packages/ui/src/themes/sidebar.ts +127 -0
  309. package/packages/ui/src/themes/skeleton.ts +15 -0
  310. package/packages/ui/src/themes/slideover.ts +87 -0
  311. package/packages/ui/src/themes/slider.ts +48 -0
  312. package/packages/ui/src/themes/stepper.ts +41 -0
  313. package/packages/ui/src/themes/switch.ts +63 -0
  314. package/packages/ui/src/themes/table.ts +34 -0
  315. package/packages/ui/src/themes/tabs.ts +74 -0
  316. package/packages/ui/src/themes/textarea.ts +77 -0
  317. package/packages/ui/src/themes/timeline.ts +25 -0
  318. package/packages/ui/src/themes/toast.ts +29 -0
  319. package/packages/ui/src/themes/toaster.ts +66 -0
  320. package/packages/ui/src/themes/tooltip.ts +31 -0
  321. package/packages/ui/src/themes/tree.ts +41 -0
  322. package/packages/ui/src/themes/user.ts +28 -0
  323. package/styles.cdn.gen.css +2047 -0
  324. package/theme.gen.json +1 -0
@@ -0,0 +1,216 @@
1
+ <script setup lang="ts">
2
+ // One canvas control that opens into a cluster of them, in place.
3
+ //
4
+ // <FCanvasFlyout icon="i-lucide-lasso" label="Selection tools" :items="tools" @select="pick" />
5
+ //
6
+ // ★ WHY IT EXISTS. A rail is a narrow column shared with the zoom pair, the compass and the rest, and
7
+ // four controls stacked is 144px of a 480px map before anything else gets its share. This is one
8
+ // control at rest, and the cluster exists only while a reader is choosing from it — the only moment it
9
+ // says anything.
10
+ //
11
+ // ★ IT OPENS ACROSS THE RAIL, NOT ALONG IT. `orientation` is the axis the cluster opens ALONG, and it
12
+ // defaults to horizontal precisely because rails are vertical: a group that grew downward would push
13
+ // every control below it along, every time someone glanced at the tools. Opening across spends space
14
+ // the canvas has and the rail does not.
15
+ //
16
+ // ★ AND IT IS AN OVERLAY, NOT A RESIZE. The wrapper only ever occupies the trigger; the cluster is
17
+ // absolutely positioned from it. A rail hugs its widest child (`fit-content`), so a cluster that grew
18
+ // in flow would widen the whole column and drag its neighbours sideways — measured, before this was
19
+ // positioned out of flow.
20
+ //
21
+ // ★ NOT A POPOVER. It grows contiguously from the trigger and shares one surface with it, so it reads
22
+ // as one object opening rather than a panel arriving beside it: no anchor to position, nothing to
23
+ // teleport, no second pane of glass. <FMapSearch> takes the same view for the same reason.
24
+ import { computed, ref, watch } from 'vue';
25
+ import FCanvasControl from './FCanvasControl.vue';
26
+
27
+ /** One member of the cluster. `value` is what comes back on select. */
28
+ export interface FlyoutItem {
29
+ value: string;
30
+ icon: string;
31
+ label: string;
32
+ /** lit — for a cluster of modes, the one currently held */
33
+ pressed?: boolean;
34
+ }
35
+
36
+ import FCanvasControlGroup from './FCanvasControlGroup.vue';
37
+
38
+ const open = defineModel<boolean>('open', { default: false });
39
+
40
+ const emit = defineEmits<{ select: [value: string] }>();
41
+
42
+ const props = withDefaults(
43
+ defineProps<{
44
+ /**
45
+ * The trigger's icon at rest.
46
+ *
47
+ * ★ Deliberately not derived. A flyout of MODES usually wants the active one here, so a reader can
48
+ * see what is in their hand without opening anything; a flyout of one-shot ACTIONS wants a fixed
49
+ * icon, because there is no state to report. The component cannot tell which it is in, so it does
50
+ * not try.
51
+ */
52
+ icon: string;
53
+ label: string;
54
+ /** Lit at rest — for a mode flyout, whether the mode it names is active. */
55
+ pressed?: boolean;
56
+ /** The axis the cluster opens along. Horizontal suits a vertical rail, and vice versa. */
57
+ orientation?: 'vertical' | 'horizontal';
58
+ /** Which end the trigger is pinned to, so the cluster opens INWARD rather than off the canvas. */
59
+ side?: 'start' | 'end';
60
+ /**
61
+ * The controls the cluster holds.
62
+ *
63
+ * ★ Items rather than a slot, and the motion is why. A cluster's members enter and leave, and
64
+ * <TransitionGroup> can only drive vnodes it created — hand it slot content and the classes never
65
+ * land on anything (measured: members appeared with the button's own transition and no enter
66
+ * class at all). Owning the members buys working motion; the cost is that a member is an icon
67
+ * control rather than arbitrary markup, which is all a cluster of chrome ever is.
68
+ */
69
+ items?: FlyoutItem[];
70
+ /** Icon for the dismiss the trigger becomes while open. */
71
+ closeIcon?: string;
72
+ /** Accessible name for that dismiss. */
73
+ closeLabel?: string;
74
+ }>(),
75
+ {
76
+ items: () => [],
77
+ pressed: undefined,
78
+ orientation: 'horizontal',
79
+ side: 'end',
80
+ closeIcon: 'i-lucide-x',
81
+ closeLabel: 'Close',
82
+ },
83
+ );
84
+
85
+ const root = ref<HTMLElement | null>(null);
86
+ const close = () => (open.value = false);
87
+
88
+ // Picking closes: a cluster of modes has said everything it has to say once one is chosen.
89
+ function choose(value: string) {
90
+ emit('select', value);
91
+ close();
92
+ }
93
+
94
+ /**
95
+ * Where a member starts from: just inside the trigger, so the cluster reads as unfolding OUT of it
96
+ * rather than materialising beside it.
97
+ *
98
+ * ★ `translate-*`, and the transition names `translate` — not `transform`. In Tailwind v4 the
99
+ * translate utilities set the `translate` property, so a `transition-[opacity,transform]` would
100
+ * animate the fade alone and snap the movement.
101
+ */
102
+ const from = computed(() => {
103
+ const d = '0.5rem';
104
+ if (props.orientation === 'horizontal')
105
+ return { '--forest-flyout-x': props.side === 'end' ? d : `-${d}`, '--forest-flyout-y': '0' };
106
+ return { '--forest-flyout-x': '0', '--forest-flyout-y': props.side === 'end' ? d : `-${d}` };
107
+ });
108
+
109
+ /**
110
+ * ★ A CSS ANIMATION, NOT <TransitionGroup>. Measured: with the members inside a TransitionGroup —
111
+ * both as slot content and rendered here — the enter classes never landed on them at all; every
112
+ * member showed the button's own `transition-property` and no opacity or translate of its own. An
113
+ * animation runs on mount by construction, which is exactly when a member appears, and nothing has
114
+ * to be wired correctly for it to fire.
115
+ */
116
+
117
+ // ★ Focus alone cannot dismiss this. A map canvas is not focusable, so pressing on it may move focus
118
+ // nowhere at all, and the cluster would sit open over the very thing the reader reached for. Watching
119
+ // pointerdown catches the press wherever it lands.
120
+ watch(open, (isOpen, _was, onCleanup) => {
121
+ if (!isOpen) return;
122
+ const away = (e: PointerEvent) => {
123
+ if (!root.value?.contains(e.target as Node)) close();
124
+ };
125
+ // Capture, so a handler that stops propagation cannot leave the cluster open behind its own panel.
126
+ document.addEventListener('pointerdown', away, true);
127
+ onCleanup(() => document.removeEventListener('pointerdown', away, true));
128
+ });
129
+
130
+ // The keyboard still moves focus properly, so this covers tabbing away.
131
+ function onFocusOut() {
132
+ requestAnimationFrame(() => {
133
+ if (!open.value) return;
134
+ if (root.value?.contains(document.activeElement)) return;
135
+ close();
136
+ });
137
+ }
138
+ </script>
139
+
140
+ <style>
141
+ /* One keyframe serves all four placements; the flyout sets the axis it enters from. */
142
+ @keyframes forest-flyout-in {
143
+ from {
144
+ opacity: 0;
145
+ translate: var(--forest-flyout-x, 0) var(--forest-flyout-y, 0);
146
+ }
147
+ }
148
+ .forest-flyout-in {
149
+ /* No var() fallbacks. They had already DRIFTED: 150ms is Tailwind's stock default, not
150
+ --motion-fast's 140ms, and the bare `ease-out` keyword is the weak built-in the doctrine bans
151
+ outright — so the "safety net" quietly contradicted the ladder it was netting. The tokens ship
152
+ with the layer now (packages/tokens/motion/motion.css), so an absent value means a broken
153
+ install, which should look broken rather than silently animate off-doctrine. */
154
+ animation: forest-flyout-in var(--motion-fast) var(--motion-ease-out) both;
155
+ }
156
+ /* The global floor covers transitions; an animation has to be said separately. */
157
+ @media (prefers-reduced-motion: reduce) {
158
+ .forest-flyout-in {
159
+ animation: none;
160
+ }
161
+ }
162
+ </style>
163
+
164
+ <template>
165
+ <div
166
+ ref="root"
167
+ class="pointer-events-auto relative"
168
+ :style="from"
169
+ :class="orientation === 'horizontal' ? 'h-(--forest-canvas-control)' : 'w-(--forest-canvas-control)'"
170
+ @keydown.escape="close"
171
+ @focusout="onFocusOut"
172
+ >
173
+ <FCanvasControlGroup
174
+ :orientation="orientation"
175
+ class="absolute"
176
+ :class="
177
+ orientation === 'horizontal'
178
+ ? ['top-0', side === 'end' ? 'end-0' : 'start-0']
179
+ : ['start-0', side === 'end' ? 'bottom-0' : 'top-0']
180
+ "
181
+ >
182
+ <!-- Order follows `side` so the TRIGGER stays put and the cluster appears beside it. Pinned at
183
+ the end, members render first and the trigger last — otherwise opening would walk the
184
+ trigger off the edge it is anchored to. -->
185
+ <FCanvasControl
186
+ v-for="item in open && side === 'end' ? items : []"
187
+ :key="item.value"
188
+ :icon="item.icon"
189
+ :label="item.label"
190
+ :pressed="item.pressed"
191
+ class="forest-flyout-in"
192
+ @click="choose(item.value)"
193
+ />
194
+
195
+ <!-- Closed it is the thing itself; open it is the way out. A dismiss rather than a member,
196
+ because a member that also closed the cluster would be indistinguishable from one that
197
+ does not. -->
198
+ <FCanvasControl
199
+ :icon="open ? closeIcon : icon"
200
+ :label="open ? closeLabel : label"
201
+ :pressed="open ? undefined : pressed"
202
+ @click="open = !open"
203
+ />
204
+
205
+ <FCanvasControl
206
+ v-for="item in open && side === 'start' ? items : []"
207
+ :key="item.value"
208
+ :icon="item.icon"
209
+ :label="item.label"
210
+ :pressed="item.pressed"
211
+ class="forest-flyout-in"
212
+ @click="choose(item.value)"
213
+ />
214
+ </FCanvasControlGroup>
215
+ </div>
216
+ </template>
@@ -0,0 +1,402 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ import { CANVAS_CARD_MAX, CANVAS_RAIL_BOTTOM_MAX, type CanvasSurface } from './canvasSurface';
4
+ // The canvas HUD — chrome OVER a full-bleed canvas (a map, a camera) instead of around content.
5
+ //
6
+ // ★ NOT A SHELL, and the distinction is the point. A shell DISPLACES: a sidebar takes its space and the
7
+ // content is simply smaller. A HUD OCCLUDES: the canvas keeps the whole surface and the chrome floats over
8
+ // it, which is why this component's budget is measured in occlusion and reachability — figures that mean
9
+ // nothing for a sidebar. It also composes WITH a shell rather than replacing one: on a console this sits
10
+ // inside the app shell's released panel, and on a phone it stands alone.
11
+ // See /layout/canvas.
12
+ //
13
+ // ★ THE CHROME IS A FRAME OF AREAS, NOT A SET OF CORNERS.
14
+ // Corner slots size themselves to whatever you put in them, which makes the layout a consequence of
15
+ // the content instead of a decision. Areas are the other way round: the HUD declares where chrome
16
+ // may live and how much room it has, and content fills them. That is what makes two screens built by
17
+ // two people line up, and it is what a Flutter implementation can match.
18
+ //
19
+ // Eight areas, as a STACK of rows — hub, top band, bottom band, dock:
20
+ //
21
+ // ┌───────────────────────────────────────┐
22
+ // │ hub │ a full-width row, contents centred. It centres on the
23
+ // ├──────┬─────────────┬────────┬─────────┤ CANVAS because it SPANS it.
24
+ // │ rail │ card-top │ │ rail │ TOP BAND — its own grid. Rails hug their control and
25
+ // │ -top │ │ │ -top │ cap; the card takes its own track; the middle is slack.
26
+ // │ start│ │ │ end │ Aligned to the band's TOP edge.
27
+ // ├──────┴─────────────┴────────┴─────────┤
28
+ // │ canvas visible │ NO element here at all — nothing to swallow a drag.
29
+ // ├──────┬─────────────┬────────┬─────────┤
30
+ // │ rail │ card-bottom │ │ rail │ BOTTOM BAND — its own grid, aligned to the BOTTOM edge,
31
+ // │ -bot │ │ │ -bot │ so it grows upward from the dock.
32
+ // │ start│ │ │ end │
33
+ // ├───────────────────────────────────────┤
34
+ // │ dock │ PHONE: its own full-width row, rails on its top edge.
35
+ // └───────────────────────────────────────┘ CONSOLE: joins the band's row, spanning it.
36
+ //
37
+ // ★ ONE GRID PER BAND, NOT ONE FOR THE HUD. A single grid means one set of columns for the whole
38
+ // layer, and that couples the bands to each other: an empty top rail cannot free its column because
39
+ // the BOTTOM rail is holding it open, and a card in each band is pinned to the same track whatever its
40
+ // band wants. Both were real — a card stayed indented by a rail that was not there. Bands sized
41
+ // independently have none of it, and nothing outside a band can move what is inside it.
42
+ //
43
+ // ★ THE HUB AND THE DOCK SPAN, WHICH IS WHY THEY ARE CENTRED. An area centred in a middle TRACK is
44
+ // centred on whatever the side tracks left over — and those are content-sized, so two cards of
45
+ // different lengths put it off centre. Measured: 11px with both sides filled, before anything is
46
+ // empty. Spanning the row makes it centre on the canvas by construction rather than by arithmetic.
47
+ //
48
+ // ★ RAILS HUG. The track is `fit-content(--forest-canvas-rail-max)`, so a 36px control leaves 36px and
49
+ // the hub keeps the rest. A fixed track left the control floating in dead space with the card beside it
50
+ // indented by space nothing occupied. The cap still earns its keep: an oversized child clamps there
51
+ // instead of starving the hub.
52
+ //
53
+ // ★ ONE CARD PER BAND. The slot names the BAND (`card-top`, `card-bottom`) and a prop names the side,
54
+ // so a band holding two cards is unrepresentable rather than merely discouraged. Two side by side need
55
+ // ~424px before the hub gets anything, which a 390pt phone does not have — measured at 79px each,
56
+ // against 164px for one.
57
+ //
58
+ // ★ AN UNUSED AREA RENDERS NOTHING — `v-if`, never an empty div. An empty div still carries the area's
59
+ // padding and holds its track open, so the neighbour never gets the width back.
60
+ //
61
+ // canvas the default slot. Full-bleed, reaches the physical edge, runs under the status bar.
62
+ // hub a full-width row: filter or mode chips, a search pill, a status line.
63
+ // rails four control stacks, hugging their content up to the rail cap.
64
+ // cards one per band, on the side its prop names — at every width, because the card hugs its
65
+ // content rather than stretching. Capped at the card measure on a console; on a phone its
66
+ // AREA spans between the rails, and a card that wants all of that says `w-full` itself.
67
+ // dock the primary action, or a stacked pair. Its row is `auto`, so it grows with its
68
+ // content and the bottom rails rise to meet it.
69
+ // drawer NOT here: that is UDrawer, portalled, and it owns its own scrim and drag. It does not
70
+ // need the dock hidden — a bottom drawer occupies the bottom of the screen, so it covers
71
+ // the dock on its own. The convention worth following is that the primary action rides the
72
+ // DRAWER's foot once one is open; the HUD does not enforce it, because a drawer opened as
73
+ // a peek over a still-relevant dock is a legitimate arrangement too.
74
+ // takeover NOT here, and not a slot. A takeover REPLACES the canvas, so it is `v-if` on this component
75
+ // or a route of its own — never an element inside it. A slot could only ever COVER the canvas,
76
+ // leaving the map mounted with its GL context live and its tiles loading behind an opaque box.
77
+ // A CAMERA earns a takeover; nothing else does. It replaces the canvas with a full-screen
78
+ // capture frame of its own.
79
+ //
80
+ // The grid does the arranging: top rails align to the top of their row, bottom rails to the BOTTOM of
81
+ // theirs, so they sit directly above the dock and move with it. That dependency is the reason this is
82
+ // one component rather than six absolutely-positioned boxes.
83
+ //
84
+ // Two rules it enforces structurally rather than by memo:
85
+ //
86
+ // 1. POINTER-EVENTS. The layer is `pointer-events-none`, the area wrappers STAY none, and only the
87
+ // children placed inside them opt back in (`[&>*]:pointer-events-auto`). That distinction is
88
+ // load-bearing: an area is a zone that spans real estate, so a wrapper that opted in itself would
89
+ // swallow every drag across its whole column — the defining bug of hand-rolled canvas chrome, and
90
+ // invisible until someone tries to pan the map behind a transparent box. The canvas column holds
91
+ // no element at all.
92
+ //
93
+ // 2. SAFE AREA IS CHROME'S, NEVER THE CANVAS'S. The canvas bleeds under the notch by definition;
94
+ // only chrome is inset. The inset reads `var(--forest-safe-*, env(safe-area-inset-*))` so a device
95
+ // mock can override with measured numbers while a real device falls through to `env()`.
96
+ const props = defineProps<{
97
+ /** Element the canvas renders as — `section` where the HUD is a page's main region. */
98
+ as?: string;
99
+ /**
100
+ * Which arrangement to draw. `auto` — the default — reflows on the width of its OWN container,
101
+ * so one instance is correct on a phone browser and on a console without the caller doing anything.
102
+ *
103
+ * `phone` and `web` PIN it, overriding the query in both directions. That is for a specimen that has to
104
+ * draw a given surface regardless of how wide it happens to be rendered (a diagram, a device mock) — not
105
+ * for production, where the point is that it adapts.
106
+ *
107
+ * The reflow itself is CSS: see `.forest-hud` / `.forest-hud-band` in forest.css.
108
+ */
109
+ surface?: CanvasSurface;
110
+ /**
111
+ * Ceiling on a rail's width, overriding the surface's own. A rail HUGS its control — the track is
112
+ * `fit-content(this)` — so a 36px button leaves 36px and the hub keeps the rest. The cap still
113
+ * matters: an oversized child clamps here instead of starving the hub.
114
+ */
115
+ railMax?: string;
116
+ /** Ceiling on a card area's width, overriding the surface's own. */
117
+ card?: string;
118
+ /**
119
+ * Which side of its band each card takes. Both default to `start`.
120
+ *
121
+ * ★ It means the same thing at every width. On a phone the card AREA spans between the rails, but the
122
+ * card inside hugs its content, so there is a side to take — a narrow card sits at the start or the
123
+ * end of that span. Only a card that asks for `w-full` has no side left to choose.
124
+ */
125
+ cardTop?: 'start' | 'end';
126
+ cardBottom?: 'start' | 'end';
127
+ /**
128
+ * Ceiling on how tall a bottom rail may grow, as a share of the HUD.
129
+ *
130
+ * Expressed in `cqh` because that is the only unit that means what it says here: `%` on a grid item
131
+ * resolves against its grid area, not the screen, and `dvh` would measure the browser viewport — wrong
132
+ * inside a device mock. The chrome layer is declared a size container so `cqh` resolves against the
133
+ * HUD's own safe region.
134
+ */
135
+ railBottomMax?: string;
136
+ /**
137
+ * Ceiling on how tall a card may grow, as a share of the HUD. Half the canvas per band.
138
+ *
139
+ * A card is the one area whose content has no natural stopping point — a rail holds two buttons, a card
140
+ * holds however many bays are in view — so it is the one that has to be told. Uncapped it pushes the
141
+ * dock out of the layer entirely: measured on a phone, a tall card moved the primary action 1582px past
142
+ * the bottom edge, where `overflow-hidden` clipped it away.
143
+ */
144
+ cardMax?: string;
145
+ }>();
146
+
147
+ const el = computed(() => props.as ?? 'div');
148
+ const surface = computed(() => props.surface ?? 'auto');
149
+ const railMax = computed(() => props.railMax);
150
+ const card = computed(() => props.card);
151
+ // Defaulted here rather than with withDefaults so the class name can never interpolate `undefined`.
152
+ const cardTop = computed(() => props.cardTop ?? 'start');
153
+ const cardBottom = computed(() => props.cardBottom ?? 'start');
154
+ const railBottomMax = computed(() => props.railBottomMax ?? CANVAS_RAIL_BOTTOM_MAX);
155
+ const cardMax = computed(() => props.cardMax ?? CANVAS_CARD_MAX);
156
+
157
+ // ★ THE DOCK CENTRES ON THE CANVAS, not on whatever the rails leave over — and in the wide arrangement the
158
+ // band's side columns are declared symmetrically for exactly that reason, whether or not a rail is there to
159
+ // fill them. Asymmetric columns would push the primary action off centre by half a rail whenever one corner
160
+ // held a panel and the other did not. Reserving both costs nothing: they are transparent and pointer-none.
161
+ // This used to be JS, and had to stop being JS — a `computed` reading `$slots` caches its first answer,
162
+ // because `$slots` is not reactive, so a reserved column outlived the rail that justified it. Grid columns
163
+ // have no such failure mode.
164
+ //
165
+ // The hub does NOT balance, deliberately: it sits BETWEEN two cards as a peer in a band, so centring it in
166
+ // the space they leave is right. The dock is a single anchored action on the surface.
167
+
168
+ // ★ The safe inset ONLY. The gutter is padding INSIDE each area, not a gap between them — see the
169
+ // note on the grid below.
170
+ // Every area shares this: pointer-transparent wrapper, children opt in, and the gutter as INTERNAL
171
+ // padding.
172
+ //
173
+ // ★ Height is NOT here. Two `height` utilities at equal specificity are resolved by Tailwind's
174
+ // stylesheet order, not by the order they are listed on the element — so a shared `h-full` cannot be
175
+ // overridden per area. Each area states its own height.
176
+ // ★ HALF the gutter here, and the layer pads by the other half on top of the safe inset. Content is
177
+ // still one gutter from the screen edge, but two things in NEIGHBOURING areas are one gutter apart
178
+ // rather than two — a full gutter on each side of a shared edge read as dead space.
179
+ const AREA_BASE = 'pointer-events-none flex flex-col gap-2 p-[calc(var(--forest-chrome-gutter)/2)]';
180
+ // ★ `shrink-0` ON A RAIL'S CHILDREN, and it is a correctness rule rather than a spacing one.
181
+ //
182
+ // A bottom rail carries the same 50cqh ceiling a card does, but it has no scroll wrapper — that is the
183
+ // CARD constant below, which pins its children `shrink-0` and scrolls the overflow. Without the same
184
+ // pin here, a rail child taller than half the canvas is simply flex-shrunk to fit, and because every
185
+ // <FCanvasCard> is `overflow-hidden`, whatever no longer fits DISAPPEARS: measured on a five-row
186
+ // legend, the last row was gone with no scrollbar, no clipping edge and nothing in the DOM to say so —
187
+ // the element reported all five children while painting four.
188
+ //
189
+ // Pinned, the same child overflows its rail instead. That spills over the canvas, which is not pretty,
190
+ // but it is visible the moment it happens and it is fixable by whoever sees it. Silent loss is neither.
191
+ const AREA = `${AREA_BASE} [&>*]:shrink-0 [&>*]:pointer-events-auto`;
192
+ // ★ The hub and the dock use AREA_BASE, WITHOUT the grant. Their child is the reading wrapper, which is
193
+ // `w-full` up to 46rem — granting it pointers made a transparent 736px box that takes clicks either side
194
+ // of a 72px chip row. The grant cannot be undone on the wrapper either: `pointer-events-none` there and
195
+ // `[&>*]:pointer-events-auto` here are equal specificity, so Tailwind's stylesheet order decides it and
196
+ // the element loses. The wrapper passes the grant to ITS children instead.
197
+
198
+ // The safe inset per side. The gutter is padding INSIDE each area, not part of this.
199
+ //
200
+ // ★ The bottom also clears --forest-canvas-basemap-strip, which is a LICENCE constraint rather than
201
+ // spacing: Mapbox requires its wordmark (bottom-left) and its attribution (bottom-right) to stay
202
+ // visible, so the whole bottom edge of a map canvas belongs to the basemap. Reserving it here means
203
+ // compliance does not depend on whoever fills the areas remembering it. Defaults to 0, so a camera or
204
+ // board canvas pays nothing for a rule that does not apply to it.
205
+ // ★ THE HUB AND THE DOCK ARE CENTRED AND CONTENT-SIZED, with the reading measure as their ceiling.
206
+ //
207
+ // These are the only two areas that span the canvas, so they are the only two that can stretch a control
208
+ // cluster across the whole surface. On a phone that is right — a dock IS a full-width CTA across the thumb
209
+ // zone. On a 1360px console it is wrong: a button bar 1358px wide reads as a toolbar for the window rather
210
+ // than as actions for the canvas.
211
+ //
212
+ // The fix is NOT a width. It is `items-center` on the area plus this wrapper, which together make the
213
+ // child's own declaration decide:
214
+ //
215
+ // <UButton block> → w-full, so it fills, up to the reading measure. The phone dock.
216
+ // <UButton> → content width, centred. The desktop dock.
217
+ //
218
+ // That is already the Nuxt UI idiom for "fill your container", so the HUD adds no second mechanism and
219
+ // the same markup is correct on both surfaces. `max-w` is the ceiling for the `block` case only — it stops
220
+ // a filling child at the same measure the well's reading rung uses, and never binds on a phone, where the
221
+ // screen is narrower than the measure.
222
+ //
223
+ // The AREA still tiles the frame either way: this constrains its contents, so the grid and the overlay are
224
+ // untouched.
225
+ // `flex flex-col items-center` and not just `mx-auto`: the wrapper centres ITSELF in the area, which
226
+ // leaves a content-width child sitting against the wrapper's start edge rather than in the middle of the
227
+ // canvas. A `block` child is w-full and fills regardless, so this costs the filling case nothing. `gap-2`
228
+ // is for the stacked pair the dock is allowed to grow into.
229
+ // ★ POINTER-TRANSPARENT, and its children opt back in. The area grants `[&>*]:pointer-events-auto`,
230
+ // which lands on THIS wrapper — and it is `w-full` up to the reading measure, so it was a transparent
231
+ // 736px box taking clicks either side of a 72px chip row. Measured on a console: the hub and the dock
232
+ // each claimed 736×36 of canvas that looks empty and does not pan.
233
+ const READING =
234
+ 'mx-auto flex w-full max-w-(--forest-well-reading) flex-col items-center gap-2 [&>*]:pointer-events-auto';
235
+
236
+ // A card scrolls at its ceiling rather than spilling past it, so the area caps the BOX and this scrolls
237
+ // the CONTENT. It has to be a wrapper: the area is pointer-transparent and a pointer-none box cannot be
238
+ // scrolled directly, whereas a wheel or a drag landing on the card itself chains up to this one.
239
+ // ★ Pointer-transparent regardless of its width, and the grant lands on its children — which is where the
240
+ // card actually is. A wrapper that took clicks itself would own every pixel of its box, including whatever
241
+ // sits beside a card narrower than it.
242
+ // ★ IT HUGS ITS CONTENT — no `w-full`. The AREA still spans columns 2/5 below 60rem, because a third-width
243
+ // track plus two rails wants ~424px and a phone has not got it; what does not follow is that the CARD must
244
+ // stretch to fill that span. Stretching it made two things wrong at once: `cardTop`/`cardBottom` became
245
+ // inert on a phone, since aligning a full-width box does nothing, and every author who wanted a card the
246
+ // size of its content wrote around it — `w-64` appears on three of the five shipped call sites. Hugging
247
+ // makes the side prop mean the same thing at every width, and anything that genuinely wants the whole
248
+ // measure (a phone sheet) says `w-full` on its own card, where it reads as a decision.
249
+ // ★ `[&>*]:shrink-0` is what makes it scroll rather than crush. A card carries `overflow`, which gives it
250
+ // an automatic minimum size of 0 — so as a flex item it shrank to the ceiling and clipped its own content,
251
+ // leaving the scroller with nothing to scroll. Measured: a 2000px card became 367px with the excess simply
252
+ // gone. Holding children at their natural height puts the overflow where the scrollbar is.
253
+ const CARD = 'flex min-h-0 flex-col gap-2 overflow-y-auto [&>*]:shrink-0 [&>*]:pointer-events-auto';
254
+
255
+ // ★ The side insets also clear whatever an open <FSidePanel> declared on the container they share
256
+ // (--forest-canvas-inset-start/end, 0 when none is open). A panel OCCLUDES the canvas, so a rail on
257
+ // that edge would otherwise sit underneath it — visible chrome that cannot be clicked, and no z-index
258
+ // resolves it because the panel is deliberately on top. Adding it to the safe inset rather than
259
+ // replacing it means a panel and a device notch compose instead of one erasing the other.
260
+ //
261
+ // ★ PHYSICAL sides, matching the safe-area insets they are added to (`env(safe-area-inset-*)` is
262
+ // physical — a notch does not move under RTL). FSidePanel maps its logical `side` to the physical
263
+ // property when it writes it; an RTL surface would need that mapping to become direction-aware, which
264
+ // nothing here ships today.
265
+ const inset = (side: 'top' | 'right' | 'bottom' | 'left') => {
266
+ const safe = `var(--forest-safe-${side}, env(safe-area-inset-${side}, 0px))`;
267
+ // ★ Plus HALF the gutter. Each area carries the other half, so content sits one gutter from the
268
+ // edge while neighbours across a shared edge stay one gutter apart rather than two. The safe inset
269
+ // is a MINIMUM, not a target, so adding to it is legitimate.
270
+ const half = 'calc(var(--forest-chrome-gutter) / 2)';
271
+ if (side === 'bottom')
272
+ return `calc(${safe} + var(--forest-canvas-basemap-strip, 0px) + ${half})`;
273
+ if (side === 'top') return `calc(${safe} + ${half})`;
274
+ return `calc(${safe} + var(--forest-canvas-inset-${side}, 0px) + ${half})`;
275
+ };
276
+ </script>
277
+
278
+ <template>
279
+ <!-- ★ The named query container the reflow keys on, and it is the ROOT rather than the viewport: on a
280
+ console this HUD is the interior of an inset panel, narrower than the window and narrower again
281
+ when the sidebar expands. `inline-size` because only the width is queried; the chrome layer inside
282
+ declares `size` separately, for the rails' `cqh` ceiling. -->
283
+ <!-- ★ `data-forest-canvas` is a HANDLE, not a styling hook: it is how a control finds the whole
284
+ canvas — chrome and all — from the thing it is chrome for. <FMapFullscreen> walks up from the
285
+ map's own container to it, because fullscreening the map element alone leaves every control
286
+ behind. Nothing in this file styles off it, and nothing should. -->
287
+ <component
288
+ :is="el"
289
+ data-forest-canvas
290
+ class="relative isolate h-full w-full overflow-hidden"
291
+ style="container: forest-canvas / inline-size"
292
+ >
293
+ <!-- CANVAS — z-0, edge to edge, untouched by safe area -->
294
+ <div class="absolute inset-0 z-0">
295
+ <slot />
296
+ </div>
297
+
298
+ <!-- CHROME — one layer, pointer-transparent; areas opt in.
299
+ A STACK, not a grid: hub row, top band, bottom band, and each band brings its own columns. -->
300
+ <div
301
+ class="forest-hud pointer-events-none absolute inset-0 z-10 flex flex-col"
302
+ :data-surface="surface"
303
+ :style="{
304
+ containerType: 'size',
305
+ ...(railMax ? { '--forest-canvas-rail-max': railMax } : {}),
306
+ ...(card ? { '--forest-canvas-card': card } : {}),
307
+ '--forest-canvas-card-max': cardMax,
308
+ paddingTop: inset('top'),
309
+ paddingRight: inset('right'),
310
+ paddingBottom: inset('bottom'),
311
+ paddingLeft: inset('left'),
312
+ transitionProperty: 'padding',
313
+ transitionDuration: `var(${'--forest-canvas-inset-duration'}, var(--motion-slow))`,
314
+ transitionTimingFunction: 'var(--motion-ease-out)',
315
+ }"
316
+ >
317
+ <!-- HUB — a full-width row of the stack, so it centres on the CANVAS by construction rather than
318
+ on whatever the rails leave over. It was a middle track between two rails, which put it off
319
+ centre the moment the two sides held different content — and starved it to nothing on a
320
+ phone, where two card-width rails leave ~16px between them. -->
321
+ <div v-if="$slots.hub" data-area="hub" :class="[AREA_BASE, 'items-center']">
322
+ <div :class="READING">
323
+ <slot name="hub" />
324
+ </div>
325
+ </div>
326
+
327
+ <!-- TOP BAND -->
328
+ <div v-if="$slots['rail-top-start'] || $slots['card-top'] || $slots['rail-top-end']" class="forest-hud-band">
329
+ <div
330
+ v-if="$slots['rail-top-start']"
331
+ data-area="rail-top-start"
332
+ :class="[AREA, 'items-start justify-start']"
333
+ style="grid-column: 1 / 2; grid-row: 1"
334
+ >
335
+ <slot name="rail-top-start" />
336
+ </div>
337
+ <div
338
+ v-if="$slots['card-top']"
339
+ data-area="card-top"
340
+ :class="[AREA_BASE, 'forest-hud-card min-h-0', `forest-hud-card--${cardTop}`, cardTop === 'end' ? 'items-end' : 'items-start', 'justify-start']"
341
+ :style="{ gridRow: '1', maxHeight: '100%' }"
342
+ >
343
+ <div :class="CARD">
344
+ <slot name="card-top" />
345
+ </div>
346
+ </div>
347
+ <div
348
+ v-if="$slots['rail-top-end']"
349
+ data-area="rail-top-end"
350
+ :class="[AREA, 'items-end justify-start']"
351
+ style="grid-column: 5 / 6; grid-row: 1"
352
+ >
353
+ <slot name="rail-top-end" />
354
+ </div>
355
+ </div>
356
+
357
+ <!-- ★ NO SPACER ELEMENT between the bands. The canvas region holds nothing at all, so there is no
358
+ box to swallow a drag across it. `mt-auto` on the bottom band does the pushing. -->
359
+ <div class="forest-hud-band forest-hud-band--dock mt-auto">
360
+ <div
361
+ v-if="$slots['rail-bottom-start']"
362
+ data-area="rail-bottom-start"
363
+ :class="[AREA, 'items-start justify-end']"
364
+ :style="{ gridColumn: '1 / 2', gridRow: '1', maxHeight: railBottomMax }"
365
+ >
366
+ <slot name="rail-bottom-start" />
367
+ </div>
368
+ <div
369
+ v-if="$slots['card-bottom']"
370
+ data-area="card-bottom"
371
+ :class="[AREA_BASE, 'forest-hud-card min-h-0', `forest-hud-card--${cardBottom}`, cardBottom === 'end' ? 'items-end' : 'items-start', 'justify-end']"
372
+ :style="{ gridRow: '1', maxHeight: '100%' }"
373
+ >
374
+ <div :class="CARD">
375
+ <slot name="card-bottom" />
376
+ </div>
377
+ </div>
378
+ <div
379
+ v-if="$slots['rail-bottom-end']"
380
+ data-area="rail-bottom-end"
381
+ :class="[AREA, 'items-end justify-end']"
382
+ :style="{ gridColumn: '5 / 6', gridRow: '1', maxHeight: railBottomMax }"
383
+ >
384
+ <slot name="rail-bottom-end" />
385
+ </div>
386
+
387
+ <!-- The dock spans its row, so it centres on the canvas. Phone: its own row under the band.
388
+ Console: the band's single row, between the rails. Which one is CSS, so `auto` reflows. -->
389
+ <div
390
+ v-if="$slots.dock"
391
+ data-area="dock"
392
+ :class="[AREA_BASE, 'forest-hud-dock min-w-0 items-center justify-end']"
393
+ >
394
+ <div :class="READING">
395
+ <slot name="dock" />
396
+ </div>
397
+ </div>
398
+ </div>
399
+ </div>
400
+
401
+ </component>
402
+ </template>
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The canvas HUD's surface vocabulary.
3
+ *
4
+ * ★ THE MEASURES ARE NOT HERE, and deliberately so. `railTop` and `railBottom` live once, as the
5
+ * `--forest-canvas-rail-*` custom properties in styles/forest.css, where a container query reflows them
6
+ * between the two surfaces. Anything that needs to state a number reads that property back:
7
+ *
8
+ * · the HUD applies them as `var(--forest-canvas-rail-top)`, never as a JS value;
9
+ * · a diagram sizes its rail chips from the same property, so it cannot draw a cap the HUD is not
10
+ * applying;
11
+ * · documentation reads them off a hidden probe (apps/docs useCanvasMeasures), so prose prints what the
12
+ * HUD resolved.
13
+ *
14
+ * A TS mirror of those values used to live here for documentation to quote. It was one copy too many: this
15
+ * page had already shipped a caption claiming 18rem while the component passed 17. Numbers belong wherever
16
+ * they are used, and everything else asks.
17
+ */
18
+ export type CanvasSurface = 'auto' | 'phone' | 'web';
19
+
20
+ /**
21
+ * Ceiling on how tall a bottom rail may grow, as a share of the HUD. Same on both surfaces, applied as an
22
+ * inline `max-height`, so unlike the rail widths it has no CSS counterpart to drift from.
23
+ *
24
+ * `cqh` because it is the only unit that means what it says here: `%` on a grid item resolves against its
25
+ * grid area, and `dvh` would measure the browser viewport — wrong inside a device mock. The HUD's chrome
26
+ * layer is declared a size container so this resolves against the HUD's own safe region.
27
+ */
28
+ export const CANVAS_RAIL_BOTTOM_MAX = '50cqh';
29
+
30
+ /**
31
+ * Ceiling on how tall a card may grow, as a share of the HUD — half the canvas per band, so a card in each
32
+ * band still leaves the map its middle. Same unit and the same reasoning as the rail ceiling above.
33
+ *
34
+ * ★ Applied to the band's ROW — `minmax(0, cap)` — and not as a `max-height` on the card. A max-height on a
35
+ * grid item clamps only what it paints: the auto row still sizes to the item's content, so a 2000px card
36
+ * drew at its 383px ceiling inside a 2116px band and pushed the dock out of the layer regardless. On the
37
+ * track it binds, and the card takes `max-height: 100%` of the row it is given.
38
+ */
39
+ export const CANVAS_CARD_MAX = '50cqh';