@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,214 @@
1
+ <script setup lang="ts">
2
+ // Sub-brand lockup — "<lead> [WORD]", composed from the glyph atlas in @humanforest/tokens. Any word,
3
+ // any lead, no build step and no font dependency: the SVG carries OUTLINES only, never <text>, so it
4
+ // renders the same in a browser with no brand fonts, in a downloaded file, and pasted into Figma.
5
+ //
6
+ // The default lead is the drawn wordmark. Name any other and it's set in GT Haptik Bold at the
7
+ // wordmark's own size — with the drawn f substituted for every f, and GT's flat ascenders (b d h k l)
8
+ // trimmed level with the t, which is where the drawn f stops. Both rules live in setWord().
9
+ //
10
+ // Variants: lockup (lead + keycap) · badge (keycap alone) · icon (square, first letter) · joined
11
+ // (lead + sub-brand butted on as one word).
12
+ import { computed } from 'vue';
13
+ import { WORDMARK_PATHS, WORDMARK_W, WORDMARK_H } from '@humanforest/tokens/logo';
14
+ import { setWord, runInk } from '@humanforest/tokens/glyphs';
15
+ import {
16
+ SUBLOGO, SUBLOGO_MOHR_SIZE, SUBLOGO_GT_SIZE, SUBLOGO_BASELINE,
17
+ keycapGeometry, iconGeometry, skewTransform,
18
+ } from '@humanforest/tokens/sublogo';
19
+
20
+ const props = withDefaults(defineProps<{
21
+ /** The sub-brand — set in the keycap. Upper-cased; Mohr is only ever used uppercase. */
22
+ word: string;
23
+ /** The word ahead of the keycap. 'forest' (default) draws the wordmark; anything else is set. */
24
+ lead?: string;
25
+ /** Colour ramp the keycap lives on. */
26
+ family?: string;
27
+ /** Keycap fill level. */
28
+ face?: number;
29
+ /** Keycap word level — omit for white, set it when the face is too light to hold white. */
30
+ ink?: number | null;
31
+ /** Border level — omit to sit on 950 and lighten to 300 in dark. */
32
+ line?: number | null;
33
+ /** Border level in dark, when a pinned `line` should shift rather than hold. */
34
+ lineDark?: number | null;
35
+ variant?: 'lockup' | 'badge' | 'icon' | 'joined';
36
+ /** Rendered height in px; width follows. */
37
+ size?: number;
38
+ }>(), {
39
+ lead: 'forest', family: 'forest', face: SUBLOGO.faceLevel, ink: null, line: null, lineDark: null,
40
+ variant: 'lockup', size: 40,
41
+ });
42
+
43
+ const word = computed(() => props.word.toUpperCase());
44
+ const isWordmarkLead = computed(() => props.lead.toLowerCase() === 'forest');
45
+ const isIcon = computed(() => props.variant === 'icon');
46
+ const isJoined = computed(() => props.variant === 'joined');
47
+
48
+ // ── The runs ── all synchronous: the outlines are data, so nothing waits on a font to load. That also
49
+ // means an instance mounted in an app shell, before any stylesheet lands, draws correctly first frame.
50
+ const gtRun = (text: string, x = 0) =>
51
+ setWord(text, { font: 'gt', size: SUBLOGO_GT_SIZE, x, baseline: SUBLOGO_BASELINE, brandF: true });
52
+
53
+ const lead = computed(() => (isWordmarkLead.value ? null : gtRun(props.lead.toLowerCase())));
54
+ // the keycap gap is measured off the lead's INK; the joined sub-brand off its ADVANCE, so the letter
55
+ // spacing carries through the seam rather than butting ink to ink
56
+ const leadW = computed(() => lead.value?.inkRight ?? WORDMARK_W);
57
+ const leadAdvance = computed(() => lead.value?.advance ?? WORDMARK_W);
58
+
59
+ // keycap word, set at the origin and moved into place once the box it sits in is known
60
+ const keycap = computed(() => setWord(word.value, { font: 'mohr', size: SUBLOGO_MOHR_SIZE }));
61
+ const key = computed(() => keycapGeometry(leadW.value, runInk(keycap.value)));
62
+
63
+ const joined = computed(() => gtRun(word.value.toLowerCase(), leadAdvance.value));
64
+
65
+ const ic = iconGeometry();
66
+ // the icon letter is sized to the square's cap height, so it's set twice: once to measure, once at size
67
+ const icon = computed(() => {
68
+ const probe = setWord(word.value[0], { font: 'mohr', size: SUBLOGO_MOHR_SIZE });
69
+ const size = (SUBLOGO_MOHR_SIZE * ic.letterCap) / (probe.inkBottom - probe.inkTop);
70
+ const run = setWord(word.value[0], { font: 'mohr', size });
71
+ return {
72
+ run,
73
+ x: ic.cx - (run.inkLeft + (run.inkRight - run.inkLeft) / 2),
74
+ y: ic.cy - (run.inkTop + (run.inkBottom - run.inkTop) / 2),
75
+ };
76
+ });
77
+
78
+ // ── Geometry ──
79
+ const viewBox = computed(() => {
80
+ if (isIcon.value) return `0 0 ${ic.side} ${ic.side}`;
81
+ if (isJoined.value) return `0 0 ${Math.ceil(joined.value.inkRight + 6)} ${WORDMARK_H}`;
82
+ if (props.variant === 'badge') return `${key.value.badgeVB[0]} 0 ${key.value.badgeVB[1]} ${WORDMARK_H}`;
83
+ return `0 0 ${key.value.fullW} ${WORDMARK_H}`;
84
+ });
85
+ const rect = computed(() => {
86
+ const k = key.value;
87
+ return isIcon.value
88
+ ? { x: ic.off, y: ic.off, w: ic.sq, h: ic.sq, t: skewTransform(ic.cx, ic.cy) }
89
+ : { x: k.boxX, y: k.boxY, w: k.boxW, h: k.boxH, t: skewTransform(k.cx, k.cy) };
90
+ });
91
+
92
+ // One clip per instance trims the flat ascenders. The id must be unique per mark — two lockups on a
93
+ // page would otherwise share a def, and a downloaded file has to carry its own.
94
+ const clipId = `fsub-cut-${++uid}`;
95
+ // the trim line: the t's ink top, which is where the drawn f stops
96
+ const CUT_Y = setWord('t', { font: 'gt', size: SUBLOGO_GT_SIZE, baseline: SUBLOGO_BASELINE }).cutY;
97
+
98
+ // ── Colour ── emitted as light/dark PAIRS resolved in CSS below, so a mark follows a forced .light or
99
+ // .dark island as well as the page theme; a value derived in script computes once and strands islands.
100
+ const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
101
+ const ramp = (lvl: number) => `var(--color-${props.family}-${lvl})`;
102
+ const faceVar = computed(() => ramp(props.face));
103
+ const inkVar = computed(() => (props.ink != null ? ramp(props.ink) : 'white'));
104
+ const lineLight = computed(() => ramp(props.line ?? SUBLOGO.lineLevel));
105
+ const lineDarkVar = computed(() => ramp(props.lineDark ?? (props.line != null ? props.line : 300)));
106
+ // JOINED sets the sub-brand as TYPE on the page surface, so it contrasts with the PAGE rather than sit
107
+ // on a face of its own. A seed pins `ink` exactly when its face is too light to hold white — and a face
108
+ // that light is unreadable as type on a light page. Those seeds own a pair already, so take whichever
109
+ // opposes the surface; the rest step one level lighter in dark to keep their pop.
110
+ const joinedLight = computed(() => ramp(props.ink != null ? props.ink : props.face));
111
+ const joinedDark = computed(() => {
112
+ if (props.ink != null) return ramp(props.face);
113
+ const i = STEPS.indexOf(props.face);
114
+ return ramp(i > 0 ? STEPS[i - 1] : props.face);
115
+ });
116
+
117
+ const vars = computed(() => ({
118
+ '--fsub-line-l': lineLight.value,
119
+ '--fsub-line-d': lineDarkVar.value,
120
+ '--fsub-joined-l': joinedLight.value,
121
+ '--fsub-joined-d': joinedDark.value,
122
+ height: `${props.size}px`,
123
+ width: 'auto',
124
+ overflow: isJoined.value ? 'visible' : undefined,
125
+ }));
126
+
127
+ const label = computed(() => `${props.lead} ${word.value}`);
128
+ </script>
129
+
130
+ <script lang="ts">
131
+ // module-scoped so every instance on a page gets its own clip id
132
+ let uid = 0;
133
+ </script>
134
+
135
+ <template>
136
+ <svg
137
+ class="fsub" :viewBox="viewBox" :style="vars"
138
+ fill="none" xmlns="http://www.w3.org/2000/svg" :aria-label="label"
139
+ >
140
+ <defs>
141
+ <clipPath :id="clipId">
142
+ <!-- trims the flat ascenders level with the t; everything from that line down survives -->
143
+ <rect :x="-WORDMARK_W" :y="CUT_Y" :width="WORDMARK_W * 8" :height="WORDMARK_H * 4" />
144
+ </clipPath>
145
+ </defs>
146
+
147
+ <!-- Each glyph is wrapped so the clip lands in the SVG's own space. `clip-path` resolves against
148
+ the user space of the element carrying it, and the glyph paths carry their own translate+scale
149
+ — clipping them directly would measure the trim line in glyph units. -->
150
+ <!-- JOINED — lead in the theme ink, sub-brand butted on in the face colour -->
151
+ <template v-if="isJoined">
152
+ <template v-if="isWordmarkLead">
153
+ <path v-for="(p, i) in WORDMARK_PATHS" :key="i" :d="p" fill="currentColor" />
154
+ </template>
155
+ <g
156
+ v-for="(g, i) in lead?.glyphs ?? []" :key="`l${i}`"
157
+ fill="currentColor" :clip-path="g.cut ? `url(#${clipId})` : undefined"
158
+ ><path :d="g.d" :transform="g.transform" /></g>
159
+ <g
160
+ v-for="(g, i) in joined.glyphs" :key="`j${i}`"
161
+ class="fsub-joined" :clip-path="g.cut ? `url(#${clipId})` : undefined"
162
+ ><path :d="g.d" :transform="g.transform" /></g>
163
+ </template>
164
+
165
+ <template v-else>
166
+ <!-- lead word — reverses with the theme via currentColor (lockup only) -->
167
+ <template v-if="variant === 'lockup'">
168
+ <template v-if="isWordmarkLead">
169
+ <path v-for="(p, i) in WORDMARK_PATHS" :key="i" :d="p" fill="currentColor" />
170
+ </template>
171
+ <g
172
+ v-for="(g, i) in lead?.glyphs ?? []" :key="`l${i}`"
173
+ fill="currentColor" :clip-path="g.cut ? `url(#${clipId})` : undefined"
174
+ ><path :d="g.d" :transform="g.transform" /></g>
175
+ </template>
176
+
177
+ <!-- keycap: coloured face, theme-aware border, the word set on top -->
178
+ <rect
179
+ :transform="rect.t" :x="rect.x" :y="rect.y" :width="rect.w" :height="rect.h"
180
+ :rx="SUBLOGO.radius" :fill="faceVar" class="fsub-line" :stroke-width="SUBLOGO.border"
181
+ />
182
+ <g v-if="isIcon" :transform="`translate(${icon.x} ${icon.y})`" :fill="inkVar">
183
+ <path v-for="(g, i) in icon.run.glyphs" :key="i" :d="g.d" :transform="g.transform" />
184
+ </g>
185
+ <g v-else :transform="`translate(${key.wordAt.x} ${key.wordAt.y})`" :fill="inkVar">
186
+ <path v-for="(g, i) in keycap.glyphs" :key="i" :d="g.d" :transform="g.transform" />
187
+ </g>
188
+ </template>
189
+ </svg>
190
+ </template>
191
+
192
+ <!-- NOT scoped, deliberately. The mode switch needs `.dark .fsub` — an ancestor plus the element — and
193
+ Vue's scoped compiler rewrites `:global(.dark) .fsub` to a bare `.dark`, dropping the descendant.
194
+ That lands the variable on <html>, where the element's own declaration then out-specifies it and
195
+ every mark stays stuck in light. The classes are namespaced instead. -->
196
+ <style>
197
+ /* Light/dark resolved in CSS, not JS: a value derived in script computes once and strands any forced
198
+ .light or .dark island on the page's own mode. `.light` comes last so a light island inside a dark
199
+ page wins the tie. */
200
+ .fsub {
201
+ --fsub-line: var(--fsub-line-l);
202
+ --fsub-joined: var(--fsub-joined-l);
203
+ }
204
+ .dark .fsub {
205
+ --fsub-line: var(--fsub-line-d);
206
+ --fsub-joined: var(--fsub-joined-d);
207
+ }
208
+ .light .fsub {
209
+ --fsub-line: var(--fsub-line-l);
210
+ --fsub-joined: var(--fsub-joined-l);
211
+ }
212
+ .fsub-line { stroke: var(--fsub-line); }
213
+ .fsub-joined { fill: var(--fsub-joined); }
214
+ </style>
@@ -0,0 +1,8 @@
1
+ // The brand mark as a component. The primitives it draws from live in @humanforest/tokens
2
+ // (src/logo.ts), beside the generator that bakes the same paths into the shipped SVGs, so the
3
+ // component and packages/tokens/logos/*.svg cannot disagree about the shape of the mark.
4
+ export { default as FLogo } from './FLogo.vue';
5
+ // The sub-brand lockup composes any lead + word from the glyph atlas in @humanforest/tokens. It emits
6
+ // OUTLINES, never <text>, so it needs no font loaded and no build step — and the SVG exporter composes
7
+ // from the same atlas, which is why a downloaded mark is the same shapes rather than a second render.
8
+ export { default as FSubLogo } from './FSubLogo.vue';
@@ -0,0 +1,355 @@
1
+ <script setup lang="ts">
2
+ // FMap — Forest's map primitive. Wraps Mapbox GL JS with the Forest style (which imports Standard) and
3
+ // owns the boilerplate
4
+ // every surface would otherwise repeat: the access-token guard (with a setup fallback), a lazy WebGL
5
+ // init (the context is created only when the frame nears the viewport, so a page with several maps
6
+ // doesn't pay for all of them up front), the load fade, cooperative gestures (so a page scrolls past
7
+ // the map), and teardown. Emits `ready(map)` once the style has loaded — add sources, layers, config
8
+ // and interactions in that handler.
9
+ //
10
+ // Token: pass `access-token`, or set `mapboxgl.accessToken` once at app start. Public `pk.…` tokens
11
+ // ship in client code by design — restrict them per surface.
12
+ //
13
+ // ★ The map CONTAINER never carries a dynamic `:class` — mapbox-gl writes its own classes onto that
14
+ // element and a Vue class patch wipes them (positioning breaks). Height + fade live on the WRAPPER;
15
+ // the container keeps only static classes.
16
+ //
17
+ // ★ The canvas is kept in sync with its container by a ResizeObserver, not by Mapbox's own
18
+ // window-resize tracking — see watchSize() for why that isn't enough.
19
+ import { computed, onBeforeUnmount, onMounted, provide, ref, shallowRef, watch } from 'vue';
20
+ import mapboxgl from 'mapbox-gl';
21
+ import { FOREST_MAP } from './mapContext';
22
+ import { readEasing, readMs } from './ease';
23
+ import { resolveInset, withGap } from './padding';
24
+ import { useTokenMode } from './useTokenMode';
25
+ import { FOREST_MAP_STYLE } from './style';
26
+ import 'mapbox-gl/dist/mapbox-gl.css';
27
+ // After mapbox-gl.css: re-points the #fff/#333/3px literals mapbox bakes into popups, controls,
28
+ // attribution and scale at Forest tokens. Order matters — equal specificity, last import wins.
29
+ import './map.css';
30
+
31
+ const props = withDefaults(
32
+ defineProps<{
33
+ accessToken?: string;
34
+ /** override the house style. Rarely right — see FOREST_MAP_STYLE */
35
+ style?: string;
36
+ center?: [number, number];
37
+ zoom?: number;
38
+ pitch?: number;
39
+ bearing?: number;
40
+ /** frame a box instead of a point. Takes precedence over `center`/`zoom` at creation */
41
+ bounds?: mapboxgl.LngLatBoundsLike;
42
+ /** room to leave around `bounds`, in px — on top of `padding`, not instead of it */
43
+ fitPadding?: number;
44
+ /** hard pan limit — the London clamp. Pair with `minZoom` or a user can zoom out past it */
45
+ maxBounds?: mapboxgl.LngLatBoundsLike;
46
+ minZoom?: number;
47
+ maxZoom?: number;
48
+ /**
49
+ * Viewport inset the camera keeps clear, in px. This is how a bottom sheet or a side panel stops
50
+ * covering what the map just flew to: every camera move centres inside the remaining box.
51
+ */
52
+ padding?: { top?: number; bottom?: number; left?: number; right?: number };
53
+ /** false makes the map a picture: no pan, no zoom, no rotate, and nothing focusable */
54
+ interactive?: boolean;
55
+ /**
56
+ * Keep the WebGL drawing buffer readable after a frame, so `getCanvas()` can be sampled.
57
+ *
58
+ * ★ OFF by default and it should stay off: the browser cannot discard the buffer, which costs memory and
59
+ * a copy per frame on every map that asks for it. This exists for MEASUREMENT — a probe that has to read
60
+ * what the renderer actually painted rather than what the style asked for. Without it a canvas read comes
61
+ * back blank, which reads as "nothing painted" and is the wrong conclusion.
62
+ */
63
+ preserveDrawingBuffer?: boolean;
64
+ /** basemap config, e.g. { lightPreset: 'night', theme: 'monochrome' } */
65
+ config?: Record<string, unknown>;
66
+ /**
67
+ * Which of the basemap's four lighting presets to use. `auto` follows the app's colour mode —
68
+ * night in dark, day in light — which is what a map inside a themed surface almost always wants.
69
+ * Pass a preset to pin one, or set `lightPreset` in `config` for the same effect.
70
+ */
71
+ lightPreset?: 'auto' | 'day' | 'dawn' | 'dusk' | 'night';
72
+ /** tailwind height utility for the frame. `h-full` fills a sized parent */
73
+ height?: string;
74
+ /** Mapbox's attribution control. Its terms require the attribution to be visible somewhere on
75
+ * the surface — only turn this off when the surface carries it elsewhere (e.g. an app's legal
76
+ * screen, or chrome drawn above a bottom sheet). */
77
+ attribution?: boolean;
78
+ /** ⌘/ctrl-scroll to zoom — on by default so a page scrolls past the map */
79
+ cooperative?: boolean;
80
+ /** create the WebGL context only when the frame nears the viewport */
81
+ lazy?: boolean;
82
+ /** draw a rounded border card around the map. Off by default: the map fills what it is given,
83
+ * and a surface that wants a card supplies its own (a shell canvas, a FlushCard, a code preview).
84
+ * Turn it on for a map that stands alone on a page with nothing else drawing its edge. */
85
+ frame?: boolean;
86
+ }>(),
87
+ {
88
+ accessToken: undefined,
89
+ style: FOREST_MAP_STYLE,
90
+ center: () => [-0.1527, 51.5286],
91
+ zoom: 13.5,
92
+ pitch: 0,
93
+ bearing: 0,
94
+ bounds: undefined,
95
+ fitPadding: 48,
96
+ maxBounds: undefined,
97
+ minZoom: undefined,
98
+ maxZoom: undefined,
99
+ padding: undefined,
100
+ interactive: true,
101
+ config: undefined,
102
+ lightPreset: 'auto',
103
+ height: 'h-105',
104
+ attribution: true,
105
+ cooperative: true,
106
+ lazy: true,
107
+ frame: false,
108
+ },
109
+ );
110
+
111
+ const emit = defineEmits<{ ready: [map: mapboxgl.Map] }>();
112
+
113
+ const token = props.accessToken || mapboxgl.accessToken;
114
+ const wrap = ref<HTMLElement | null>(null);
115
+ const canvas = ref<HTMLDivElement | null>(null);
116
+ const map = shallowRef<mapboxgl.Map | null>(null);
117
+ const loaded = ref(false);
118
+ // A style that never arrives leaves a blank rectangle otherwise: a bad style id, a revoked token, a
119
+ // network the surface cannot reach. Held as a message so the frame can say which.
120
+ const styleError = ref<string | null>(null);
121
+
122
+ // Nested primitives (<FMarker>, and <FMapPopup> through it) attach themselves off this. It stays
123
+ // null until the lazy init runs, so they watch it rather than reading it once on mount.
124
+ provide(FOREST_MAP, map);
125
+
126
+ // Mapbox reads its container's size once at construction and re-reads only on WINDOW resize
127
+ // (`trackResize`). A container that changes size while the window doesn't — a sidebar collapsing, a
128
+ // resize handle dragging, a container query reflowing, a panel animating — leaves the canvas at its
129
+ // old dimensions, which shows up as a strip of dead space down one edge. Observing the container
130
+ // instead covers all of those AND window resize, so it is the only handler needed.
131
+ //
132
+ // ★ THE RESIZE IS TAKEN OUT OF THE OBSERVER CALLBACK, and that is what stops the flicker. Mapbox's
133
+ // resize() writes canvas.width/height, which re-allocates the WebGL drawing buffer and CLEARS it; the
134
+ // re-render is scheduled for the next frame. ResizeObserver runs AFTER layout and BEFORE paint, so a
135
+ // resize taken inline paints an empty canvas for that frame. One frame passes unseen — but a container
136
+ // that TRAVELS fires the observer on every frame of the travel (the app rail collapsing is 240ms of
137
+ // them), and the map blanks for the whole transition.
138
+ //
139
+ // A timeout runs as a task, after the frame it was queued in has painted, so mapbox re-renders into the
140
+ // new buffer before anything is shown. The cost is that the canvas geometry trails the container by a
141
+ // frame: a hairline of the frame's background at the moving edge, against a map that stays lit.
142
+ //
143
+ // Coalesced, because a live transition queues one of these per frame and only the last size matters.
144
+ let ro: ResizeObserver | null = null;
145
+ let pending: ReturnType<typeof setTimeout> | null = null;
146
+ function watchSize(m: mapboxgl.Map, el: HTMLElement) {
147
+ if (typeof ResizeObserver === 'undefined') return;
148
+ ro = new ResizeObserver(() => {
149
+ if (pending) return;
150
+ pending = setTimeout(() => {
151
+ pending = null;
152
+ if (map.value) m.resize();
153
+ }, 0);
154
+ });
155
+ ro.observe(el);
156
+ }
157
+
158
+ function init() {
159
+ if (map.value || !token || !canvas.value) return;
160
+ mapboxgl.accessToken = token;
161
+ // ★ THE TWO PADDINGS ARE SUMMED HERE, because fitBounds REPLACES the transform's padding rather than
162
+ // adding to it — the same measurement useMapCamera.fit is built on (GL JS 3.24.1: `_extendPadding`
163
+ // falls back to `transform.padding` only when the option is absent). Passing `fitPadding` alone framed
164
+ // a `:padding="{ left: 400 }"` map against the whole element, and since the fit resolves at creation
165
+ // and setPadding runs after it, the first paint was then visibly shoved sideways by the padding it
166
+ // should have been framed inside.
167
+ const inset = resolveInset(props.padding);
168
+ const m = new mapboxgl.Map({
169
+ container: canvas.value,
170
+ style: props.style,
171
+ center: props.center,
172
+ zoom: props.zoom,
173
+ pitch: props.pitch,
174
+ bearing: props.bearing,
175
+ // `bounds` wins over center/zoom when both are given — mapbox resolves it at creation, so the
176
+ // first paint is already framed rather than flying there.
177
+ ...(props.bounds
178
+ ? { bounds: props.bounds, fitBoundsOptions: { padding: withGap(props.padding, props.fitPadding) } }
179
+ : {}),
180
+ ...(props.maxBounds ? { maxBounds: props.maxBounds } : {}),
181
+ ...(props.minZoom != null ? { minZoom: props.minZoom } : {}),
182
+ ...(props.maxZoom != null ? { maxZoom: props.maxZoom } : {}),
183
+ interactive: props.interactive,
184
+ ...(props.preserveDrawingBuffer ? { preserveDrawingBuffer: true } : {}),
185
+ cooperativeGestures: props.cooperative,
186
+ attributionControl: props.attribution,
187
+ // Creation-time too: applyConfig only runs once the style has loaded, and a map that paints in day
188
+ // and corrects itself a beat later is a flash of the wrong mode on every load.
189
+ config: { basemap: { lightPreset: resolvedPreset.value, ...(props.config ?? {}) } },
190
+ });
191
+ // Takes the uniform gap back off the transform, so later moves inset by the prop alone rather than by
192
+ // the prop plus a fit's worth of room — and so getPadding() reports what was asked for. Costs nothing
193
+ // on screen: `fitPadding` is equal on all four sides, and the centre mapbox renders at depends on the
194
+ // DIFFERENCE between opposite sides, which the sum leaves untouched.
195
+ if (props.padding) m.setPadding(inset);
196
+ m.on('style.load', () => {
197
+ styleError.value = null;
198
+ loaded.value = true;
199
+ emit('ready', m);
200
+ });
201
+ // Only a STYLE failure is fatal to the frame. Tile and source errors are routine — one 404 during a
202
+ // pan is not a broken map — so they stay in the console rather than replacing what is on screen.
203
+ m.on('error', (e) => {
204
+ if (loaded.value) return;
205
+ const message = (e as { error?: Error }).error?.message ?? 'The map style failed to load.';
206
+ if (/style|token|401|403|404/i.test(message)) styleError.value = message;
207
+ });
208
+ map.value = m;
209
+ watchSize(m, canvas.value);
210
+ }
211
+
212
+ onMounted(() => {
213
+ if (!token || !wrap.value) return;
214
+ if (!props.lazy) {
215
+ init();
216
+ return;
217
+ }
218
+ const io = new IntersectionObserver(
219
+ (entries) => {
220
+ if (entries.some((e) => e.isIntersecting)) {
221
+ init();
222
+ io.disconnect();
223
+ }
224
+ },
225
+ { rootMargin: '250px' },
226
+ );
227
+ io.observe(wrap.value);
228
+ onBeforeUnmount(() => io.disconnect());
229
+ });
230
+
231
+ // Camera padding is the one prop that changes AFTER creation in normal use — a bottom sheet opening
232
+ // or a side panel expanding. Applied on the live instance so the next camera move already respects it.
233
+ //
234
+ // ★ EASED, not set. `setPadding` re-centres the camera in one frame, so a panel that takes 240ms to slide
235
+ // open leaves the map underneath it JUMPING at the moment the panel starts moving — one gesture rendered
236
+ // as two events. `easeTo` runs the same re-centring over the panel's own travel, and the map reads as the
237
+ // thing the panel is moving aside rather than as a separate surface reacting to it.
238
+ //
239
+ // Where the timing comes from, in order: the panel publishes its own travel on the container it shares
240
+ // with the canvas (--forest-canvas-inset-duration), so the two agree in BOTH directions — a fast exit
241
+ // stays fast. Falling back to --motion-slow keeps a bare `:padding` change eased rather than instant, and
242
+ // to 0 (a plain set) if neither token resolves, which is what a non-Forest host would get.
243
+ //
244
+ // The curve is read from --motion-ease-out and sampled by ease.ts, so the camera and the CSS transition
245
+ // are on ONE curve rather than two that look similar.
246
+ watch(
247
+ () => props.padding,
248
+ (p) => {
249
+ const m = map.value;
250
+ if (!p || !m) return;
251
+ const ms = readMs(wrap.value, '--forest-canvas-inset-duration') || readMs(wrap.value, '--motion-slow');
252
+ // Reduced motion is a CSS floor, which a JS camera move sails straight past — so it is honoured here
253
+ // explicitly. Same outcome as the floor gives a transition: the end state, with no travel.
254
+ const reduce =
255
+ typeof matchMedia !== 'undefined' && matchMedia('(prefers-reduced-motion: reduce)').matches;
256
+ if (!ms || reduce) {
257
+ m.setPadding(p);
258
+ return;
259
+ }
260
+ m.easeTo({ padding: p, duration: ms, easing: readEasing(wrap.value, '--motion-ease-out') });
261
+ },
262
+ { deep: true },
263
+ );
264
+
265
+ // ★ THE BASEMAP FOLLOWS THE COLOUR MODE ON ITS OWN. Mapbox's `lightPreset` is what makes a map read as
266
+ // dark, and every surface used to wire it by hand — eight consumers in this repo did it four different
267
+ // ways, and the ones that forgot shipped a daylight map on a dark page. It belongs here: a map inside a
268
+ // themed surface almost always wants to match it, and the one that does not says so with a prop.
269
+ //
270
+ // ★ Keyed off useTokenMode, NOT an isDark ref. The ref flips before the class lands on <html>, so
271
+ // anything reading the resolved mode from a watcher is one toggle behind — the bug that composable
272
+ // exists to document. This reads the DOM after the change, like resolveMapColor does.
273
+ const mode = useTokenMode();
274
+ const resolvedPreset = computed(() => {
275
+ void mode.value;
276
+ // An explicit prop wins; `config.lightPreset` still wins over both, since a caller spelling it out in
277
+ // the config object means it deliberately.
278
+ if (props.lightPreset !== 'auto') return props.lightPreset;
279
+ if (typeof document === 'undefined') return 'day';
280
+ // ★ THE CLASS, and never the OS preference. Forest's dark mode is class-based — every token on the
281
+ // page resolves from `.dark` on <html> — so a map asking `prefers-color-scheme` would disagree with
282
+ // the surface around it. MEASURED: on a machine whose OS prefers dark, switching the docs to light
283
+ // left the basemap at night, because the media query outvoted the class the rest of the page obeys.
284
+ // Whatever decides `.dark` has already accounted for the OS.
285
+ const el = document.documentElement;
286
+ const dark = el.classList.contains('dark') || el.dataset.theme === 'dark';
287
+ return dark ? 'night' : 'day';
288
+ });
289
+
290
+ // ★ `config` also has to survive creation. Mapbox only reads the `config` option once, so a basemap
291
+ // setting bound to reactive state — `lightPreset` following the colour mode being the obvious one —
292
+ // applied on the first paint and then never again: the map stayed in day while the rest of the page
293
+ // went dark. Re-applied per key on the live instance instead.
294
+ //
295
+ // Guarded on `loaded`: setConfigProperty needs the style, and calling it earlier throws. A map created
296
+ // lazily may not have a style yet when the mode flips, so the ready path re-applies too.
297
+ function applyConfig(cfg?: Record<string, unknown>) {
298
+ const m = map.value;
299
+ if (!m || !loaded.value) return;
300
+ // The preset first, so an explicit `config.lightPreset` lands after it and wins.
301
+ m.setConfigProperty('basemap', 'lightPreset', resolvedPreset.value);
302
+ if (!cfg) return;
303
+ for (const [key, value] of Object.entries(cfg)) m.setConfigProperty('basemap', key, value);
304
+ }
305
+
306
+ watch(() => props.config, (cfg) => applyConfig(cfg), { deep: true });
307
+ watch(loaded, (ok) => ok && applyConfig(props.config));
308
+ watch(resolvedPreset, () => applyConfig(props.config));
309
+
310
+ onBeforeUnmount(() => {
311
+ ro?.disconnect();
312
+ if (pending) clearTimeout(pending);
313
+ map.value?.remove();
314
+ });
315
+
316
+ defineExpose({ map });
317
+ </script>
318
+
319
+ <template>
320
+ <!-- Height sits on the outer element and everything below fills it, so `height="h-full"` can
321
+ inherit a sized parent (a phone screen, a panel) instead of only fixed utilities. -->
322
+ <div ref="wrap" :class="height">
323
+ <div class="relative h-full" :class="frame ? 'overflow-hidden rounded-xl border border-default' : ''">
324
+ <!-- Fade on the wrapper, NOT the map container (see script note). -->
325
+ <div v-if="token" class="h-full transition-opacity" :class="{ 'opacity-0': !loaded }">
326
+ <div ref="canvas" class="h-full w-full" />
327
+ <!-- Children render nothing here: <FMarker> teleports its content into the element mapbox
328
+ positions. The slot exposes the map for anything that would rather drive it directly. -->
329
+ <slot :map="map" />
330
+ </div>
331
+ <div v-else class="flex h-full w-full items-center justify-center bg-elevated/30">
332
+ <div class="max-w-sm space-y-3 px-6 text-center">
333
+ <UIcon name="i-lucide-map-pin-off" class="mx-auto size-8 text-dimmed" />
334
+ <p class="type-body font-medium text-highlighted">No Mapbox access token</p>
335
+ <p class="type-body text-muted">
336
+ Pass <code>:access-token</code> or set <code>mapboxgl.accessToken</code> once at app
337
+ start. Use a public <code>pk.…</code> token restricted to this surface.
338
+ </p>
339
+ </div>
340
+ </div>
341
+ <!-- Style failure: over the canvas rather than instead of it, because the map element must stay
342
+ mounted for a later retry (and for whatever a surface already attached to it). -->
343
+ <div
344
+ v-if="styleError"
345
+ class="absolute inset-0 flex items-center justify-center bg-elevated/80 backdrop-blur"
346
+ >
347
+ <div class="max-w-sm space-y-3 px-6 text-center">
348
+ <UIcon name="i-lucide-triangle-alert" class="mx-auto size-8 text-warning" />
349
+ <p class="type-body font-medium text-highlighted">The map couldn't load</p>
350
+ <p class="type-body text-muted">{{ styleError }}</p>
351
+ </div>
352
+ </div>
353
+ </div>
354
+ </div>
355
+ </template>