@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,215 @@
1
+ <script setup lang="ts">
2
+ // The app shell's sidebar — the brand mark, the nav, and the collapsed icon rail.
3
+ //
4
+ // <FAppSidebar :groups="nav" sub-brand="OPS" v-model:open="open" v-model:collapsed="collapsed" />
5
+ //
6
+ // The app supplies the LIST; everything else here is the same in every Forest app. Two of those are
7
+ // pairings that break silently if a sidebar is composed by hand:
8
+ //
9
+ // · The scrollbar is hidden (themes/dashboard-sidebar.ts), so <FScrollShadow> is what tells a reader
10
+ // the nav runs past the fold. Without it a long list clips with no signal at all.
11
+ // · Slot props, NOT the models: the mobile slideover re-renders these slots with collapsed=false, so
12
+ // reading `collapsed` here would leak the desktop rail's icon-only rows into the phone menu.
13
+ import { computed } from 'vue';
14
+ import { F, WORDMARK_H, WORDMARK_W } from '@humanforest/tokens/logo';
15
+ import { setWord, runInk } from '@humanforest/tokens/glyphs';
16
+ import { keycapGeometry, SUBLOGO_MOHR_SIZE } from '@humanforest/tokens/sublogo';
17
+ import { FSubLogo } from '../logo';
18
+ import FScrollShadow from './FScrollShadow.vue';
19
+ import type { FNavGroup, FNavItem } from './nav';
20
+
21
+ // ── The lockup and the room it owns ──
22
+ // The brand's spacing module is the f from the wordmark: one f of clear space on every side, with no
23
+ // type or edge inside that zone. One f is 0.982 × the rendered height, so the expanded lockup sits at
24
+ // 24px in a block of 24 + 2×23.6 ≈ 71px.
25
+ //
26
+ // ★ THE BLOCK IS NOT A HEADER STRIP. UDashboardSidebar's header recipe is `h-(--ui-header-height)`,
27
+ // and the largest lockup that keeps one f inside 48px is 16px, at which the wordmark reads as a
28
+ // caption rather than a mark. So the block takes `h-auto` with the clear space as padding, and the
29
+ // navbar beside it keeps that height — the rail reads as a brand block above the nav, not one strip.
30
+ //
31
+ // ★ THE MOBILE MENU CANNOT READ THE CUSTOM PROPERTIES. They are declared by `:style` on the sidebar
32
+ // root, which is the DESKTOP column; the slideover is a sibling <Menu> teleported to <body>, so the
33
+ // root is not an ancestor of it and `p-(--forest-rail-clear)` there resolves to nothing — the header
34
+ // collapsed to 33px with the lockup 4px off the panel edge. The recipe's classes DO reach both, so the
35
+ // padding splits on the breakpoint the two states already split on: the column is `hidden lg:flex` and
36
+ // the menu content is `lg:hidden`, so `p-6` dresses the menu and `lg:p-(…)` the column. 24px is half a
37
+ // pixel off one f at this size, which is as close as a spacing step gets to the module.
38
+ //
39
+ // The size is a HEIGHT, so the clear space holds whatever the lockup says; the width follows the lead
40
+ // and the sub-brand together, and the mark does not shrink. `forest [CONSOLE]` measures 247px in a
41
+ // 256px default column, so a lead and a word both long enough will run past the expanded rail.
42
+ const LOCKUP_SIZE = 24;
43
+ const oneF = (size: number) => size * (F.h / WORDMARK_H);
44
+ const CLEAR = `${oneF(LOCKUP_SIZE).toFixed(2)}px`;
45
+ // The block the brand owns, in BOTH states. Collapsing already narrows the column and swaps the mark;
46
+ // growing the block as well would shove the whole nav down at the moment the rail is trying to give
47
+ // the nav room. Holding it constant also keeps one f exactly when the rail mark is lockup-sized.
48
+ const BLOCK_H = LOCKUP_SIZE + 2 * oneF(LOCKUP_SIZE);
49
+
50
+ const props = withDefaults(defineProps<{
51
+ /** The nav, in the order it should read. Group labels head each section when expanded. */
52
+ groups?: FNavGroup[];
53
+ /** Sub-brand for the lockup — the keycap word. Drops to the keycap badge on the rail. */
54
+ subBrand?: string;
55
+ /**
56
+ * The word ahead of the keycap — `fleet [CONSOLE]`. 'forest' (default) draws the wordmark.
57
+ * Expanded only: the collapsed rail carries the keycap alone.
58
+ */
59
+ lead?: string;
60
+ /**
61
+ * The word on the collapsed rail. Defaults to the sub-brand, which is right while it reads at rail
62
+ * size — `DS` and `OPS` do. Set it where the sub-brand does not: `fleet [CONSOLE]` collapses to `C`,
63
+ * and the app says which letter that is rather than a width threshold deciding for it.
64
+ * A single letter draws the app icon; two or more draw the keycap.
65
+ */
66
+ railWord?: string;
67
+ /** Rail width in the dashboard group's unit, and the drag bounds. */
68
+ width?: number;
69
+ minWidth?: number;
70
+ maxWidth?: number;
71
+ }>(), { groups: () => [], subBrand: 'DS', lead: 'forest', width: 16, minWidth: 14, maxWidth: 22 });
72
+
73
+ const open = defineModel<boolean>('open', { default: false });
74
+ const collapsed = defineModel<boolean>('collapsed', { default: false });
75
+
76
+ // ── The collapsed rail's mark ──
77
+ // The rail word is SHORT by construction — `railWord` exists so it can be — so the mark is sized to
78
+ // the ROW GRID it sits above rather than fitted to the column. 32px is the collapsed nav item's box,
79
+ // which is what the eye compares the mark against; one f either side inside the 4rem rail would hold
80
+ // it to 21px instead, two thirds of the rows below it.
81
+ //
82
+ // ★ THE MODULE YIELDS IN BOTH DIRECTIONS ONCE THE MARK IS SIZED TO THE NAV. One f of a 32px mark is
83
+ // 31.4px, which wants 63px of the 64px column across and a 95px block down — three times the mark, and
84
+ // the nav shoved down by it. The mark centres in what BLOCK_H and the column leave instead, 19.6px
85
+ // down and 16px across for `[C]`. That shortfall is the price of a mark that reads at the nav's scale;
86
+ // the module still sets the block itself, from the expanded lockup.
87
+ //
88
+ // ★ WIDTH IS A CAP, NOT A FIT. `[C]` is 32px wide at full height and `DS` 46.9, so a word past two
89
+ // letters solves its height down instead — `OPS` would want 63.2px and lands at 24px tall. That is the
90
+ // app's signal to pass a shorter `railWord`.
91
+ const RAIL_H = 32; // the collapsed nav item's box — see themes/dashboard-sidebar.ts
92
+ const RAIL_W_MAX = 48; // the rail itself is --sidebar-width-icon (4rem), which :collapsed-size="4" matches
93
+
94
+ // Painted width of a keycap holding `word`, per px of rendered height. Pure maths over the glyph atlas
95
+ // — the same call <FSubLogo> composes through, so the two can never disagree.
96
+ //
97
+ // The `lead` does NOT enter it. keycapGeometry's leadW only sets boxX — where the keycap starts — and
98
+ // the badge viewBox is [boxX - bleed, boxW + 2·bleed], so the lead moves the origin and never the
99
+ // width. The constant below is the drawn wordmark's, and it stays right for any lead.
100
+ const keycapRatio = (word: string) =>
101
+ keycapGeometry(
102
+ WORDMARK_W,
103
+ runInk(setWord(word, { font: 'mohr', size: SUBLOGO_MOHR_SIZE })),
104
+ ).badgeVB[1] / WORDMARK_H;
105
+
106
+ const railKeycap = computed(() => (props.railWord ?? props.subBrand).toUpperCase());
107
+
108
+ // ★ A SINGLE LETTER IS THE APP ICON, NOT A ONE-LETTER KEYCAP. Same idea — first letter, square — and
109
+ // scripts/favicons.ts already writes that exact mark per registered product, so a rail composing its
110
+ // own would put a second shape on one brand. They are close enough to miss and far enough to see:
111
+ // keycapGeometry pads the letter's ink equally on four sides and lands portrait (18.9 × 21.4 plate at
112
+ // 24px), where iconGeometry builds the square first and fits the letter to 62% of its cap (19.5 sq).
113
+ const railIsIcon = computed(() => railKeycap.value.length === 1);
114
+
115
+ // the icon is square; a keycap's width follows the word it holds
116
+ const railRatio = computed(() => (railIsIcon.value ? 1 : keycapRatio(railKeycap.value)));
117
+ const railSize = computed(() => Math.min(RAIL_H, Math.floor(RAIL_W_MAX / railRatio.value)));
118
+ // whatever the block has left around the mark. At 32px that is 19.6px a side rather than the module's
119
+ // 31.4 — the same relaxation the column already makes, and for the same reason: a mark sized to the
120
+ // nav cannot also hold a module scaled to itself inside a 4rem rail.
121
+ const railClear = computed(() => `${((BLOCK_H - railSize.value) / 2).toFixed(2)}px`);
122
+
123
+ // <UNavigationMenu> draws a `separator` between each TOP-LEVEL ARRAY, so the item shape carries the
124
+ // group boundary — and the two states want different shapes:
125
+ //
126
+ // expanded — ONE flat array. Each group already announces itself with an uppercase label; a rule
127
+ // as well is a second divider doing one job.
128
+ // collapsed — an array PER GROUP and no labels, since the labels are hidden at that width and the
129
+ // rule is then the only thing marking where a group ends.
130
+ const toItem = (item: FNavItem) => ({
131
+ label: item.label,
132
+ icon: item.icon,
133
+ to: item.to,
134
+ disabled: !item.to,
135
+ ...(item.wip
136
+ ? { badge: { label: 'WIP', color: 'warning' as const, variant: 'subtle' as const } }
137
+ : !item.to
138
+ ? { badge: { label: 'Soon', color: 'neutral' as const, variant: 'subtle' as const } }
139
+ : {}),
140
+ });
141
+
142
+ const navFlat = computed(() =>
143
+ props.groups.flatMap((group) => [
144
+ { label: group.label, type: 'label' as const },
145
+ ...group.items.map(toItem),
146
+ ]),
147
+ );
148
+ const navGrouped = computed(() => props.groups.map((group) => group.items.map(toItem)));
149
+ </script>
150
+
151
+ <template>
152
+ <!-- Two corrections to the phone menu, which is stock's surface rather than the column's:
153
+ · `toggle-side` — stock opens with the close button at the START, which puts a dismiss control
154
+ where the brand goes and shoves the lockup off the panel's own left edge. The mark leads on
155
+ both surfaces; the close follows it.
156
+ · `content: divide-y-0` — the slideover recipe carries `divide-y divide-default`, drawing a rule
157
+ between the brand block and the nav. Same call as the shell's chrome gutter (see FAppShell's style
158
+ block): the block and the list are one surface, and the label above each group is what
159
+ separates them. -->
160
+ <UDashboardSidebar
161
+ v-model:open="open"
162
+ v-model:collapsed="collapsed"
163
+ collapsible
164
+ resizable
165
+ :collapsed-size="4"
166
+ :default-size="width"
167
+ :min-size="minWidth"
168
+ :max-size="maxWidth"
169
+ mode="slideover"
170
+ toggle-side="right"
171
+ :menu="{ inset: true }"
172
+ :style="{ '--forest-rail-clear': CLEAR, '--forest-rail-clear-icon': railClear }"
173
+ :ui="{
174
+ header: 'h-auto p-6 lg:p-(--forest-rail-clear) group-data-[collapsed=true]:px-0'
175
+ + ' group-data-[collapsed=true]:py-(--forest-rail-clear-icon)',
176
+ content: 'divide-y-0',
177
+ }"
178
+ >
179
+ <template #header="{ collapsed: rail }">
180
+ <slot name="brand" :collapsed="rail">
181
+ <!-- Ink is currentColor, so the mark follows the theme. On the rail the lockup drops to what
182
+ `railWord` says reads at that size — the app icon for one letter, the keycap for more.
183
+
184
+ The NAME does not abbreviate with the mark. FSubLogo builds its label from the word it
185
+ draws, which on the rail is an initial — "fleet C" is what the eye sees and not what the
186
+ mark means. The fallthrough attribute overrides the component's own on the root svg. -->
187
+ <span class="shrink-0 text-[var(--color-forest-950)] dark:text-white" :class="rail && 'mx-auto'">
188
+ <FSubLogo
189
+ :word="rail ? railKeycap : subBrand"
190
+ :aria-label="`${lead} ${subBrand.toUpperCase()}`"
191
+ :lead="lead"
192
+ :variant="rail ? (railIsIcon ? 'icon' : 'badge') : 'lockup'"
193
+ :size="rail ? railSize : LOCKUP_SIZE"
194
+ />
195
+ </span>
196
+ </slot>
197
+ </template>
198
+
199
+ <template #default="{ collapsed: rail }">
200
+ <FScrollShadow>
201
+ <UNavigationMenu
202
+ :items="rail ? navGrouped : navFlat"
203
+ :collapsed="rail"
204
+ orientation="vertical"
205
+ tooltip
206
+ class="w-full"
207
+ />
208
+ </FScrollShadow>
209
+ </template>
210
+
211
+ <template v-if="$slots.footer" #footer="{ collapsed: rail }">
212
+ <slot name="footer" :collapsed="rail" />
213
+ </template>
214
+ </UDashboardSidebar>
215
+ </template>
@@ -0,0 +1,126 @@
1
+ <script setup lang="ts">
2
+ // The page's own toolbar — the filters, search and view controls for the body below it, or the
3
+ // section's sub-route navigation.
4
+ //
5
+ // <FPageToolbar>
6
+ // <FFilterBar v-model="clauses" :schema="schema" />
7
+ // </FPageToolbar>
8
+ //
9
+ // Teleports to the top of <FAppShell>'s inset card. A page renders inside the panel and cannot fill a
10
+ // slot on the shell that contains it — slots flow down. What makes teleport the answer rather than a
11
+ // workaround is whose content this is: a navbar's crumb belongs to the app, but THIS route's filters
12
+ // live in this route's setup, and a v-model has to reach them. Teleport moves the DOM node and leaves
13
+ // the component tree alone, so it does.
14
+ //
15
+ // TWO LAYERS, and the split is the point. The root bleeds to the card's edge so the hairline reads as
16
+ // a rule across the whole card; the container inside carries the well, so the controls line up with
17
+ // the page content rather than with the card. On a 1720px card those are 252px apart.
18
+ //
19
+ // A page needing both nav and filters writes two of these. They stack, each keeping its own rule.
20
+ //
21
+ // Renders IN PLACE when no shell is above it. A page without chrome keeps its controls; they just
22
+ // stop pinning.
23
+ import { computed, inject, onActivated, onBeforeUnmount, onDeactivated, onMounted, ref, useSlots } from 'vue';
24
+ import { TOOLBAR_SEAT_KEY } from './shell';
25
+ import {
26
+ pageToolbarSurface,
27
+ pageToolbarWellClass,
28
+ type PageToolbarVariant,
29
+ type PageToolbarWell,
30
+ } from './pageToolbar';
31
+
32
+ const props = withDefaults(defineProps<{
33
+ /** Which rung the controls align to. Match the page body's own well, or they will not line up. */
34
+ well?: PageToolbarWell;
35
+ /**
36
+ * How the bar draws itself: a hairline rule across the card, or an inset panel sitting on it.
37
+ * A page stacking navigation over filters gives the navigation a rule and the filters a card.
38
+ */
39
+ variant?: PageToolbarVariant;
40
+ }>(), { well: 'default', variant: 'rule' });
41
+
42
+ const seat = inject(TOOLBAR_SEAT_KEY, null);
43
+ const target = computed(() => seat?.toolbar.value ?? null);
44
+ const wellClass = computed(() => pageToolbarWellClass(props.well));
45
+ const surface = computed(() => pageToolbarSurface(props.variant));
46
+
47
+ const slots = useSlots();
48
+
49
+ // What pushes a view's actions to the right, and it moves depending on how the bar was filled. The
50
+ // `right` wrapper carries `ms-auto`, and it only exists in the DEFAULT-slot fallback — a bar filled
51
+ // through the default slot has no such wrapper, so nothing would push the seam over and the actions
52
+ // would strand against the bar's own content.
53
+ //
54
+ // Never both: two auto margins SHARE the free space rather than one taking it, which opens a gap
55
+ // between the bar's own right-hand content and the actions teleported after it.
56
+ const seamPush = computed(() => (slots.default ? 'ms-auto' : ''));
57
+
58
+ const actions = ref<HTMLElement | null>(null);
59
+
60
+ // Claim while on screen, release when off it. The shell keeps claimants in claim order and derives
61
+ // the seam from the first of them, so a section stacking a nav row over a filter row keeps the nav
62
+ // row's seam while it is there, and promotes the filter row if the nav row goes.
63
+ onMounted(() => {
64
+ if (seat && actions.value) seat.claimActions(actions.value);
65
+ });
66
+
67
+ onBeforeUnmount(() => {
68
+ if (seat && actions.value) seat.releaseActions(actions.value);
69
+ });
70
+
71
+ // BOTH pairs, and neither is spare. A toolbar cached by <KeepAlive> — an ordinary tabbed console
72
+ // section — fires activated/deactivated and NEITHER of the pair above. A Teleport leaves its content
73
+ // in the target while its component is cached, so a deactivated toolbar's bar is still sitting in the
74
+ // seat: one that kept its claim would hold the seam on the bar of a tab nobody is looking at, and the
75
+ // active view's actions would land there. Outside <KeepAlive> only the mount pair fires. Claiming and
76
+ // releasing are idempotent, so the two firing together — first appearance, or a cached toolbar
77
+ // finally unmounting — costs nothing.
78
+ //
79
+ // Reappearing claims afresh, at the BACK of the list. A cached nav row returning to a section whose
80
+ // filter row claimed while it was away leaves the seam where it now is.
81
+ onActivated(() => {
82
+ if (seat && actions.value) seat.claimActions(actions.value);
83
+ });
84
+
85
+ onDeactivated(() => {
86
+ if (seat && actions.value) seat.releaseActions(actions.value);
87
+ });
88
+ </script>
89
+
90
+ <template>
91
+ <!-- `disabled` rather than v-if: with no shell above, the bar still renders where it was written,
92
+ it just stops moving. Teleport wants a valid `to` even when disabled, hence the body fallback. -->
93
+ <Teleport :to="target ?? 'body'" :disabled="!target">
94
+ <!-- `px-0 sm:px-0` — BOTH, or tailwind-merge keeps stock's sm:px-6 and the bar is double-guttered.
95
+ The well owns the gutter now.
96
+ `overflow-x-visible`, NOT overflow-visible: tailwind-merge keeps `overflow` and `overflow-x`
97
+ in separate groups, so the plain one leaves stock's overflow-x-auto in place and the bleed
98
+ scrolls instead of the controls.
99
+ The height comes from themes/dashboard-toolbar.ts and is not restated here. -->
100
+ <UDashboardToolbar :ui="{ root: ['px-0 sm:px-0 overflow-x-visible', surface.root] }">
101
+ <!-- The lane <FAppShell> measures on the page's own scrollport (0px when it does not scroll).
102
+ This wrapper carries it, not the root above: the root's rule has to stay full-bleed, but
103
+ the well below has to centre over the same box the scrollport does, or the two drift apart
104
+ the moment a page is long enough to grow a scrollbar. -->
105
+ <div class="w-full pe-(--forest-scrollport-lane)">
106
+ <UContainer :class="wellClass">
107
+ <!-- The row, and the layer the `card` variant fills. It has to be INSIDE the container
108
+ rather than be it: a panel has to start where the content starts, and the container is
109
+ the thing that knows where that is. -->
110
+ <div :class="['flex items-center gap-1.5 overflow-x-auto', surface.row]">
111
+ <slot>
112
+ <div class="flex items-center gap-1.5"><slot name="left" /></div>
113
+ <div class="flex items-center gap-1.5 ms-auto"><slot name="right" /></div>
114
+ </slot>
115
+
116
+ <!-- Filled from below by <FToolbarActions>. LAST, and outside the <slot>, for two reasons:
117
+ a view's controls land after whatever the section put in #right, and a toolbar using
118
+ the default slot — which has no `right` region at all — can still receive them.
119
+ `ms-auto` on `right` pushes that group over, so either way these end up rightmost. -->
120
+ <div ref="actions" :class="['flex items-center gap-1.5 empty:hidden', seamPush]" />
121
+ </div>
122
+ </UContainer>
123
+ </div>
124
+ </UDashboardToolbar>
125
+ </Teleport>
126
+ </template>
@@ -0,0 +1,43 @@
1
+ <script setup lang="ts">
2
+ // Fades the top and bottom edges of the scroll container this sits inside, so a long column reads as
3
+ // "more below" rather than as a hard clipped edge. Wraps Nuxt UI's useScrollShadow, whose mask only
4
+ // appears while the container overflows and has not reached that edge.
5
+ //
6
+ // It is the overflow signal for the sidebar, which hides its scrollbar (themes/dashboard-sidebar.ts) —
7
+ // a rail 64px wide has no room for a bar beside the icons, and expanded it draws hard against the
8
+ // inset panel's edge. Without this the nav loses both, and a long list clips with nothing to say so.
9
+ //
10
+ // The scroller is the PARENT: UDashboardSidebar owns the scrolling `data-slot="body"` div and a child
11
+ // cannot style it through a binding, so this wrapper is `display: contents` — no box of its own — and
12
+ // reaches up. Scoping per mount also covers the sidebar rendering its default slot twice, for the
13
+ // desktop rail and the mobile slideover: each gets its own instance and its own ref.
14
+ import { onMounted, ref, watchEffect } from 'vue';
15
+ import { useScrollShadow } from '@nuxt/ui/composables';
16
+
17
+ const { size = 20 } = defineProps<{
18
+ /** Depth of the fade, in px. */
19
+ size?: number;
20
+ }>();
21
+
22
+ const root = ref<HTMLElement | null>(null);
23
+ const scroller = ref<HTMLElement | null>(null);
24
+ onMounted(() => {
25
+ scroller.value = root.value?.parentElement ?? null;
26
+ });
27
+
28
+ const { style } = useScrollShadow(scroller, { size: () => size });
29
+
30
+ watchEffect(() => {
31
+ const el = scroller.value;
32
+ if (!el) return;
33
+ const mask = style.value?.maskImage ?? '';
34
+ el.style.maskImage = mask;
35
+ el.style.webkitMaskImage = mask;
36
+ });
37
+ </script>
38
+
39
+ <template>
40
+ <div ref="root" style="display: contents">
41
+ <slot />
42
+ </div>
43
+ </template>
@@ -0,0 +1,258 @@
1
+ <script setup lang="ts">
2
+ // A column that belongs to a CONTAINER rather than to the app — a settings sub-nav inside a card, a
3
+ // filter or list panel over a map canvas.
4
+ //
5
+ // <FSidePanel v-model:open="filters" title="Filters">…</FSidePanel> a page's own column
6
+ // <FSidePanel v-model:open="filters" behaviour="overlay" size="lg" title="Filters"> over a canvas
7
+ //
8
+ // ★ WHAT THIS IS NOT. It is not the app's sidebar — that is <FAppSidebar>, which is viewport-scoped,
9
+ // carries navigation, persists its width and hands over to a menu on a phone. This one answers "what am
10
+ // I looking at", not "where am I in the app", and it dies with the view it belongs to.
11
+ //
12
+ // ★ WHY IT EXISTS AT ALL, given <USidebar> is right there. One reason, and it is structural rather than
13
+ // cosmetic: `collapsible="offcanvas"` is `hidden lg:block` and portals itself into a page-level slideover
14
+ // under 1024px. Those are VIEWPORT decisions, and this column's container is a card that can be 600px
15
+ // wide on a 1600px screen — the same panel would get the desktop treatment in one and a slideover flying
16
+ // in from the page edge in the other. No `:ui` override reaches that; it is `collapsible`'s own
17
+ // structure. So this uses `collapsible="none"` — which renders a single flow element, no gap spacer, no
18
+ // fixed container, no rail — and owns the positioning, the open/close travel and the reflow itself.
19
+ //
20
+ // What it still takes from <USidebar>: the header/body/footer skeleton, the divided inner, and Forest's
21
+ // sidebar theme fragment (the MohrAlt title), so a panel's heading and the app rail's
22
+ // heading cannot drift apart.
23
+ //
24
+ // ★ `variant` is PINNED here rather than inherited. The Forest fragment defaults USidebar to `floating`
25
+ // for the app rail's sake; a panel that let that default through would silently move the day the rail's
26
+ // surface is retuned.
27
+ import { computed, onBeforeUnmount, ref, watchEffect } from 'vue';
28
+ import {
29
+ CANVAS_INSET_DURATION_VAR,
30
+ CANVAS_INSET_VAR,
31
+ SIDE_PANEL_GUTTER_PX,
32
+ SIDE_PANEL_WIDTH,
33
+ type SidePanelBehaviour,
34
+ type SidePanelSide,
35
+ type SidePanelSize,
36
+ } from './sidePanel';
37
+
38
+ const props = withDefaults(
39
+ defineProps<{
40
+ /** Does content beside it move over (`push`), or does it lie on top (`overlay`)? */
41
+ behaviour?: SidePanelBehaviour;
42
+ /** Which edge of the container it takes. `end` by default — see the note on the default. */
43
+ side?: SidePanelSide;
44
+ /** `md` matches the HUD's rail; `lg` is for a list of rows. */
45
+ size?: SidePanelSize;
46
+ title?: string;
47
+ description?: string;
48
+ /** The close affordance in the header. On by default for overlay, which has no other way out. */
49
+ close?: boolean;
50
+ }>(),
51
+ // ★ `end` by default, which is the opposite of what a lone column would want. A panel is SECONDARY
52
+ // chrome: the shell it sits in already has FAppSidebar at the start edge, so a panel there stacks two
53
+ // columns on one side and shoves the content across — while the end edge keeps the content beside the
54
+ // navigation and the weight split between two edges. It is what a console with a left rail does
55
+ // everywhere (Linear, Figma, Retool). Pass `side="start"` where the panel IS the navigation for its
56
+ // view, which in practice means a settings sub-nav.
57
+ { behaviour: 'push', side: 'end', size: 'md' },
58
+ );
59
+
60
+ const open = defineModel<boolean>('open', { default: true });
61
+
62
+ const width = computed(() => SIDE_PANEL_WIDTH[props.size].css);
63
+ const overlay = computed(() => props.behaviour === 'overlay');
64
+ const showClose = computed(() => props.close ?? overlay.value);
65
+
66
+ // ── The reflow ────────────────────────────────────────────────────────────────────────────────────
67
+ // ★ Measured off the CONTAINER, never the viewport — the whole reason this component exists. Below the
68
+ // threshold the panel takes the container whole: a 22rem column inside a 24rem card leaves 2rem for what
69
+ // it is filtering, which is worse than covering it outright and being dismissed.
70
+ //
71
+ // A ResizeObserver rather than a CSS container query because the query would have to be declared on the
72
+ // PARENT, and a child cannot stamp that on an element it does not own — a panel dropped into an
73
+ // arbitrary card would silently never reflow. This measures whatever it is actually inside.
74
+ const FULL_WIDTH_BELOW = 480;
75
+ const el = ref<HTMLElement | null>(null);
76
+ const narrow = ref(false);
77
+ let ro: ResizeObserver | null = null;
78
+
79
+ watchEffect(() => {
80
+ ro?.disconnect();
81
+ const parent = el.value?.parentElement;
82
+ if (!parent || typeof ResizeObserver === 'undefined') return;
83
+ ro = new ResizeObserver(() => {
84
+ narrow.value = parent.clientWidth > 0 && parent.clientWidth < FULL_WIDTH_BELOW;
85
+ });
86
+ ro.observe(parent);
87
+ });
88
+
89
+ // ── The inset it reports ──────────────────────────────────────────────────────────────────────────
90
+ // An open overlay panel declares the strip it covers on its PARENT, where <FCanvasHud> reads it and
91
+ // insets its rails on that edge — so a rail card steps aside instead of hiding underneath, with no
92
+ // z-index fight and no bookkeeping at the usage site. Written to the parent because the HUD is a
93
+ // SIBLING: the panel has nothing to pass it, but they share an ancestor.
94
+ //
95
+ // The camera is not reachable this way — `map.setPadding` takes a number — so a map reads the same
96
+ // measure through `sidePanelInsetPx()` instead. One module, two consumers, no third copy.
97
+ const insetVar = computed(() => CANVAS_INSET_VAR[props.side]);
98
+
99
+ watchEffect((onCleanup) => {
100
+ const parent = el.value?.parentElement;
101
+ if (!parent || !overlay.value) return;
102
+ const prop = insetVar.value;
103
+ const covered = open.value && !narrow.value;
104
+ // ★ ONE gutter, not two: this is the strip the panel physically OCCUPIES — its own outer gutter plus
105
+ // its width. The HUD adds it to a safe inset and its areas then apply the chrome gutter as their own
106
+ // padding, so publishing a trailing gutter here would be counted twice and leave a 32px trough
107
+ // between the panel and the first rail item where every other pair of chrome shares 16 (measured).
108
+ // The CAMERA is the opposite case and keeps both gutters — see sidePanelInsetPx: a marker parked
109
+ // flush against the panel's edge reads as hidden behind it, so it wants clearance the rails do not.
110
+ parent.style.setProperty(prop, covered ? `calc(${width.value} + ${SIDE_PANEL_GUTTER_PX}px)` : '0px');
111
+
112
+ // ★ The inset alone makes followers JUMP: they get the new value in one frame while the panel takes its
113
+ // full travel to arrive, so a gesture that is one movement renders as three separate events. Publishing
114
+ // the duration too lets them run on the panel's clock — including its asymmetry, since a fast exit that
115
+ // the HUD glides out of over 240ms trails the panel visibly.
116
+ //
117
+ // Resolved to a time here rather than passed as `var(--motion-slow)`: the map reads this from JS and
118
+ // has no way to resolve a token, and one resolver is better than two that can disagree.
119
+ const token = open.value ? '--motion-slow' : '--motion-fast';
120
+ parent.style.setProperty(
121
+ CANVAS_INSET_DURATION_VAR,
122
+ getComputedStyle(parent).getPropertyValue(token).trim() || '0ms',
123
+ );
124
+
125
+ onCleanup(() => {
126
+ parent.style.removeProperty(prop);
127
+ parent.style.removeProperty(CANVAS_INSET_DURATION_VAR);
128
+ });
129
+ });
130
+
131
+ onBeforeUnmount(() => ro?.disconnect());
132
+
133
+ // ── Placement, and the two motions it implies ─────────────────────────────────────────────────────
134
+ // The behaviours travel differently, so the motion doctrine sorts them into different buckets. Neither
135
+ // is a taste call — both are read off the tokens' own definitions (@humanforest/tokens/motion/motion.css).
136
+ //
137
+ // PUSH collapses by WIDTH, so the content beside it widens in sympathy — but the panel is ENTERING and
138
+ // LEAVING, not resizing. `ease-in-out` belongs to the persistent case the doctrine names (the tabs
139
+ // indicator, the nav resize, the app rail's collapse: an element visible in BOTH states, changing size).
140
+ // Read as travel-along-an-axis this looked like the same thing; it is not, and the difference is
141
+ // audible. ★ MEASURED with ease-in-out (cubic-bezier(0.77, 0, 0.175, 1), an easeInOutQuart): the column
142
+ // moved 50px in the first 92ms, then 158px in the next 41ms — a ~4px/ms burst through the middle, with
143
+ // the pushed text rewrapping all the way through it. That mid-travel snap is what reads as jank; the
144
+ // content was tracking perfectly the whole time. So: `ease-out`, monotonically decelerating, same as the
145
+ // overlay, with the same enter/exit asymmetry.
146
+ //
147
+ // OVERLAY enters and leaves the surface. Enters and exits take `ease-out` — `ease-in-out` would ease
148
+ // IN at the start of the exit, and ease-in is banned outright for starting slow at the moment the user
149
+ // is watching. And the two directions are NOT symmetric: `slow` for the enter (a slideover panel's
150
+ // travel), `fast` for the exit, because the doctrine buckets overlay EXITS there — a dismissal should
151
+ // get out of the way rather than perform its departure.
152
+ //
153
+ // The reduced-motion floor in the motion tokens covers the opt-out for both, globally.
154
+ const wrapperClass = computed(() => [
155
+ overlay.value
156
+ ? [
157
+ 'pointer-events-none absolute inset-y-0 z-20 flex p-(--forest-chrome-gutter)',
158
+ props.side === 'start' ? 'start-0' : 'end-0',
159
+ 'transition-[translate,opacity] ease-(--motion-ease-out)',
160
+ open.value
161
+ ? 'translate-x-0 opacity-100 duration-(--motion-slow)'
162
+ : [
163
+ 'opacity-0 duration-(--motion-fast)',
164
+ props.side === 'start' ? '-translate-x-full rtl:translate-x-full' : 'translate-x-full rtl:-translate-x-full',
165
+ ],
166
+ ]
167
+ : [
168
+ 'h-full shrink-0 overflow-hidden',
169
+ // ★ `side` has to mean the same thing in both behaviours. Overlay anchors itself, but a push
170
+ // panel is a FLOW item, so without this its position would come from where it happens to be
171
+ // authored and `side="end"` would only flip which edge carries the hairline — the prop would
172
+ // silently do nothing about the side. `order-last` puts it after its siblings whatever the
173
+ // markup order, and is inert when it is already last. (Its container is a flex row either
174
+ // way: a push panel has nothing to push otherwise.)
175
+ props.side === 'end' && 'order-last',
176
+ 'transition-[width] ease-(--motion-ease-out)',
177
+ open.value ? 'duration-(--motion-slow)' : 'duration-(--motion-fast)',
178
+ ],
179
+ narrow.value && 'w-full',
180
+ ]);
181
+
182
+ const wrapperStyle = computed(() => {
183
+ if (narrow.value) return undefined;
184
+ return overlay.value ? undefined : { width: open.value ? width.value : '0px' };
185
+ });
186
+
187
+ // The column itself. Overlay takes the canvas-chrome surface (translucent + blurred, so the map stays
188
+ // legible as context through its own controls); push is a flush column with the hairline edge that
189
+ // separates it from the content beside it.
190
+ //
191
+ // ★ THE RING AND THE RADIUS BELONG TO `inner`, AND ONLY THERE. The floating variant already rings the
192
+ // inner and gives it the radius, so adding either to the root draws a SECOND rounded rectangle around
193
+ // the first — visibly, because the same variant's `collapsible="none"` compound also puts `p-4` on the
194
+ // root, holding the two frames 16px apart.
195
+ //
196
+ // ★ `p-0` cancels that compound's padding, and it is a measurement fix rather than a cosmetic one. The
197
+ // root is `w-(--sidebar-width)` and border-box, so its own padding comes OUT of the panel: at `lg` the
198
+ // root measured 352px while the visible column was 320 (measured), i.e. the size rung quietly delivered
199
+ // 20rem where it promised 22, and the strip the camera was told to keep clear no longer matched what was
200
+ // covered. The gutter an overlay floats in is the wrapper's, once — the same --forest-chrome-gutter
201
+ // every HUD card is inset by, which is what puts a panel and a rail card on one line.
202
+ const sidebarUi = computed(() => ({
203
+ root: ['h-full', narrow.value ? 'w-full' : '', overlay.value ? 'pointer-events-auto p-0' : '']
204
+ .filter(Boolean)
205
+ .join(' '),
206
+ // ★ NO DIVIDERS on the canvas cut. Stock's skeleton rules its header, body and footer apart, which is
207
+ // right on a page and wrong over a map: the panel is a pane of glass on a moving, already-busy surface,
208
+ // and every line inside it competes with the map's own. The push cut keeps them — there the panel sits
209
+ // against a static page, where a rule reads as structure rather than as a seam in the glass.
210
+ // ★ The header loses its height FLOOR. Stock's `min-h-(--ui-header-height)` exists so the app rail's
211
+ // header lines up with the navbar beside it — a shell-level alignment. This panel is container-scoped:
212
+ // there is no navbar on its row, so the floor only made its title sit in a 48px box while a card two
213
+ // inches away sat in a 38px one, on the same canvas. `p-4 pb-0` is the card's own header rhythm, so a
214
+ // panel title and a card title share a baseline instead of nearly sharing one.
215
+ header: 'min-h-0 p-4 pb-0',
216
+ inner: overlay.value ? 'forest-canvas-chrome divide-y-0' : '',
217
+ }));
218
+ </script>
219
+
220
+ <template>
221
+ <div ref="el" :class="wrapperClass" :style="wrapperStyle">
222
+ <USidebar
223
+ collapsible="none"
224
+ :variant="overlay ? 'floating' : 'sidebar'"
225
+ :side="side === 'start' ? 'left' : 'right'"
226
+ :title="title"
227
+ :description="description"
228
+ :style="{ '--sidebar-width': narrow ? '100%' : width }"
229
+ :ui="sidebarUi"
230
+ >
231
+ <template v-if="$slots.header" #header="slotProps">
232
+ <slot name="header" v-bind="slotProps" />
233
+ </template>
234
+
235
+ <template v-if="showClose" #actions>
236
+ <slot name="actions" />
237
+ <UButton
238
+ icon="i-lucide-x"
239
+ color="neutral"
240
+ variant="ghost"
241
+ size="xs"
242
+ square
243
+ aria-label="Close panel"
244
+ @click="open = false"
245
+ />
246
+ </template>
247
+ <template v-else-if="$slots.actions" #actions>
248
+ <slot name="actions" />
249
+ </template>
250
+
251
+ <slot />
252
+
253
+ <template v-if="$slots.footer" #footer>
254
+ <slot name="footer" />
255
+ </template>
256
+ </USidebar>
257
+ </div>
258
+ </template>