@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,20 @@
1
+ // Forest's KPI card — one entry, matching ./map.
2
+ //
3
+ // <FKpi> one metric: value, optional delta, optional sparkline visual
4
+ // <FKpiGroup> a subgrid-aligned row of FKpi, optionally acting as a tablist selector
5
+ // formatMetric() Intl-backed value formatting (never pin maximumFractionDigits on compact)
6
+ export { default as FKpi } from './FKpi.vue';
7
+ export { default as FKpiGroup } from './FKpiGroup.vue';
8
+
9
+ export type { KpiSlot, KpiGroupContext } from './kpi.theme';
10
+
11
+ export { formatMetric, formatPercent } from './kpiFormat';
12
+ export type { KpiFormat, FormatOpts } from './kpiFormat';
13
+
14
+ export type { KpiDelta, KpiSentiment, ResolvedDelta } from './kpiDelta';
15
+
16
+ export {
17
+ nextKey,
18
+ isSelected,
19
+ } from './kpiGroupState';
20
+ export type { KpiRegistry } from './kpiGroupState';
@@ -0,0 +1,168 @@
1
+ // Presentation for FKpi. The surface comes from UCard — this recipe owns only what sits inside it.
2
+ //
3
+ // The value's type size is a container query on the tile itself, not a `size` prop. A tile in a
4
+ // narrow column is narrow, and the number should follow the space it has rather than a breakpoint
5
+ // the author guessed at.
6
+ //
7
+ // Row alignment is subgrid: inside a group the tile spans four rows of the group's grid, so labels,
8
+ // values, captions and visuals line up across the row however long any one label runs.
9
+ import type { InjectionKey } from 'vue';
10
+ import { tv } from 'tailwind-variants';
11
+ import type { KpiFormat } from './kpiFormat';
12
+
13
+ export const kpiTheme = tv({
14
+ slots: {
15
+ // The card's own body is display:contents so the four parts can join the group's subgrid, which
16
+ // means the padding has to live here instead. p-4 is the card's own uniform figure.
17
+ // Padding rides --forest-card-pad, the same var the card's concentric-radius maths reads: the
18
+ // rounder the card, the roomier the inset, and a context that steps one steps both (1rem at
19
+ // rest, 1.25rem in mobile and marketing). A hardcoded p-4 would leave the tile tight inside a
20
+ // 32px-radius card. The card's own body padding is display:contents here, so this is the only
21
+ // padding on the tile.
22
+ //
23
+ // One explicit minmax(0,1fr) column: without it the implicit column sizes to max-content, and a
24
+ // chart with an intrinsic width (Unovis defaults to 300px) makes the tile's own grid wider than
25
+ // the tile, clipping the right end of the graphic.
26
+ root: 'relative grid grid-cols-[minmax(0,1fr)] grid-rows-[auto_auto_auto_auto] gap-1 p-[var(--forest-card-pad)] @container/kpi',
27
+ header: 'flex min-w-0 items-center gap-1.5',
28
+ label: 'type-overline truncate text-muted',
29
+ hint: 'shrink-0 text-dimmed',
30
+ body: 'flex flex-wrap items-baseline gap-x-2 gap-y-1',
31
+ // Face and transform ride the --fx-metric traits, so a context re-points them on the card root
32
+ // and the figure follows — the same channel the card title uses for --fx-title. tabular-nums is
33
+ // unconditional: it does what it can on the sans cut and is inert on Mohr, which has no tnum.
34
+ metric:
35
+ '[font-family:var(--fx-metric-family)] [text-transform:var(--fx-metric-transform)] font-bold tabular-nums text-2xl leading-none text-highlighted @[14rem]/kpi:text-3xl @[20rem]/kpi:text-4xl',
36
+ prefix: 'text-muted',
37
+ suffix: 'text-sm text-muted',
38
+ // Bare text on the figure's baseline, not a filled chip. A container makes the delta a second
39
+ // object beside the number: at a comfortable size it competes with the figure it modifies, and
40
+ // shrunk out of the way it stops being readable. Without one it sizes off the figure — one step
41
+ // down through the same container query — and the arrow plus the colour carry the meaning.
42
+ delta: 'inline-flex shrink-0 items-center gap-0.5 font-bold text-xs @[14rem]/kpi:text-sm @[20rem]/kpi:text-base',
43
+ deltaIcon: 'size-3.5 shrink-0 @[20rem]/kpi:size-4',
44
+ caption: 'text-xs text-dimmed',
45
+ // Fit comes from the visualFit variant — a sparkline and a legend want opposite treatments.
46
+ // Common to both: neither min-h-0 nor overflow-hidden, since both zero this element's
47
+ // contribution to the subgrid track, collapsing the row so the graphic overflows upward onto
48
+ // the caption. The tile root already clips, which is what the rounded bottom corners needed.
49
+ visual: 'mt-2',
50
+ footer: 'mt-2',
51
+ },
52
+ variants: {
53
+ // Inside a divided row the group draws the borders, so the tile drops its own fill, ring and
54
+ // radius. Keeping the radius would round each segment's fill inside a joined strip and leave
55
+ // the selected tile's underline looking detached at both ends; the group's own container
56
+ // supplies the outer corners.
57
+ // The tile stays OPAQUE: the group's hairlines are its own 1px grid gaps showing the container
58
+ // colour through, so a transparent tile would leak that colour across the whole strip.
59
+ naked: {
60
+ true: { root: 'forest-kpi-joined bg-default shadow-none ring-0' },
61
+ },
62
+ /**
63
+ * How the `visual` slot meets the card. Two graphics, opposite needs, and the slot used to
64
+ * carry only the first — which left every other visual undoing it with utilities at the call
65
+ * site.
66
+ *
67
+ * `bleed` (the default) is for a graphic that IS data and nothing else, a sparkline being the
68
+ * case: it runs through the tile's gutter to both side edges and sits on the bottom edge, which
69
+ * the tile reaches by zeroing its own bottom padding rather than by a negative margin — a
70
+ * margin would shrink the grid row and pull the graphic up over the caption.
71
+ *
72
+ * `inset` is for a graphic carrying text, FDistributionBar's legend being the case. Text set
73
+ * against the card's edge reads as a mistake, so the padding stays. It also top-aligns, and
74
+ * pins the tile's rows to the top with `content-start`: the tile is a subgrid item WITH
75
+ * padding, so its four tracks are re-sized inside its own content box rather than inheriting
76
+ * the group's positions. Two tiles then distribute their slack differently — a legend that
77
+ * wraps in one and not its neighbour leaves the two bars at different heights — and packing
78
+ * the rows to the top is what makes them agree. Measured: 15.6px apart without it, level with.
79
+ */
80
+ visualFit: {
81
+ bleed: { root: 'pb-0', visual: 'self-end -mx-[var(--forest-card-pad)]' },
82
+ inset: { root: 'content-start', visual: 'self-start' },
83
+ },
84
+ aligned: {
85
+ true: { root: 'row-span-4 grid-rows-subgrid' },
86
+ },
87
+ selectable: {
88
+ true: {
89
+ root: 'cursor-pointer text-left transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary',
90
+ },
91
+ },
92
+ // Selected, hover and focus are three separate treatments.
93
+ selected: {
94
+ true: { root: 'ring-2 ring-primary', label: 'text-highlighted' },
95
+ },
96
+ hovered: {
97
+ true: { root: 'hover:bg-elevated' },
98
+ },
99
+ status: {
100
+ ok: {},
101
+ warn: { metric: 'text-warning' },
102
+ critical: { metric: 'text-error' },
103
+ },
104
+ emphasis: {
105
+ value: {},
106
+ surface: {},
107
+ },
108
+ dimmed: {
109
+ true: { metric: 'text-muted' },
110
+ },
111
+ /** Colour states judgement; the arrow always states direction, so neutral is not a failure. */
112
+ intent: {
113
+ success: { delta: 'text-success' },
114
+ error: { delta: 'text-error' },
115
+ neutral: { delta: 'text-muted' },
116
+ },
117
+ /**
118
+ * Forces a face, overriding whatever the context's --fx-metric traits say. For the exception:
119
+ * a hero figure on a web page that wants the expressive cut, or a mobile table of figures that
120
+ * has to align despite the context. Leave unset and the context decides.
121
+ */
122
+ face: {
123
+ tabular: { metric: '[font-family:var(--font-sans)] [text-transform:none]' },
124
+ display: { metric: '[font-family:var(--font-display)] [text-transform:uppercase]' },
125
+ },
126
+ },
127
+ compoundVariants: [
128
+ // An attached strip marks selection with an underline on the shared edge, not a ring: a ring
129
+ // around one segment of a joined row draws a box that the row's own border already drew, and
130
+ // the two fight. The bar sits on the same edge the chart hangs off, so the tile reads as the
131
+ // chart's tab rather than as a card that happens to be highlighted.
132
+ {
133
+ naked: true,
134
+ selected: true,
135
+ class: {
136
+ root: 'ring-0 bg-elevated after:absolute after:inset-x-0 after:-bottom-px after:h-0.5 after:bg-primary',
137
+ },
138
+ },
139
+ // Surface-painting reaches for the tinted card variants. There is no red accent, so a critical
140
+ // tile paints amber and the value's own colour carries the rest.
141
+ { emphasis: 'surface', status: 'warn', class: { root: 'bg-[var(--forest-card-amber-bg)]' } },
142
+ { emphasis: 'surface', status: 'critical', class: { root: 'bg-[var(--forest-card-amber-bg)]' } },
143
+ ],
144
+ defaultVariants: {
145
+ status: 'ok',
146
+ emphasis: 'value',
147
+ },
148
+ });
149
+
150
+ export type KpiSlot = keyof ReturnType<typeof kpiTheme>;
151
+
152
+ export interface KpiGroupContext {
153
+ register(key: string): void;
154
+ unregister(key: string): void;
155
+ select(key: string): void;
156
+ focusStep(key: string, step: 1 | -1): void;
157
+ isSelected(key: string): boolean;
158
+ /** Roving tabindex: exactly one tile in the row is in the tab order. */
159
+ isFocusable(key: string): boolean;
160
+ selectable: boolean;
161
+ multiple: boolean;
162
+ controls?: string;
163
+ format?: KpiFormat;
164
+ precision?: number;
165
+ naked: boolean;
166
+ }
167
+
168
+ export const KPI_GROUP_KEY: InjectionKey<KpiGroupContext> = Symbol('forest-kpi-group');
@@ -0,0 +1,82 @@
1
+ // Delta resolution. The arrow carries literal direction; the colour carries judgement, and only
2
+ // when the author has declared which way is better. Default is neutral: a bare number that has
3
+ // gone up is not, on its own, good news.
4
+ //
5
+ // The delta is never derived from a sparkline's own series — it is supplied, with the baseline it
6
+ // was measured against, or it is absent.
7
+ import { formatMetric, formatPercent } from './kpiFormat';
8
+
9
+ export type KpiSentiment = 'higher-is-better' | 'lower-is-better' | 'neutral';
10
+
11
+ export interface KpiDelta {
12
+ value: number;
13
+ /** `percent` takes a fraction (0.12 = 12%). `pp` and `absolute` take the figure as written. */
14
+ unit: 'percent' | 'pp' | 'absolute';
15
+ display?: 'relative' | 'absolute' | 'both' | 'none';
16
+ /** What the value was compared against. Required — an undisclosed baseline is unreadable. */
17
+ comparisonLabel: string;
18
+ previous?: number;
19
+ }
20
+
21
+ export interface ResolvedDelta {
22
+ text: string;
23
+ direction: 'up' | 'down' | 'flat';
24
+ intent: 'success' | 'error' | 'neutral';
25
+ /** Visually hidden, and the only place direction is stated in words. */
26
+ sentence: string;
27
+ hidden: boolean;
28
+ }
29
+
30
+ const DEFAULT_LOCALE = 'en-GB';
31
+
32
+ /** Below this the figure has not moved in any way a reader should act on. */
33
+ const FLAT_EPSILON = 0.0005;
34
+
35
+ export const resolveDelta = (
36
+ delta: KpiDelta | undefined,
37
+ {
38
+ sentiment = 'neutral',
39
+ locale = DEFAULT_LOCALE,
40
+ precision,
41
+ }: { sentiment?: KpiSentiment; locale?: string; precision?: number } = {},
42
+ ): ResolvedDelta | null => {
43
+ if (!delta) return null;
44
+
45
+ const { value, unit, comparisonLabel, previous, display = 'relative' } = delta;
46
+
47
+ // A relative change against a zero baseline is undefined, so the absolute figure stands in.
48
+ const relativeIsMeaningless = previous === 0 && value !== 0;
49
+ const effectiveUnit = unit === 'percent' && relativeIsMeaningless ? 'absolute' : unit;
50
+
51
+ const magnitude = Math.abs(value);
52
+ const direction = magnitude < FLAT_EPSILON ? 'flat' : value > 0 ? 'up' : 'down';
53
+
54
+ const intent: ResolvedDelta['intent'] =
55
+ direction === 'flat' || sentiment === 'neutral'
56
+ ? 'neutral'
57
+ : (direction === 'up') === (sentiment === 'higher-is-better')
58
+ ? 'success'
59
+ : 'error';
60
+
61
+ const sign = direction === 'up' ? '+' : direction === 'down' ? '-' : '';
62
+
63
+ const magnitudeText =
64
+ effectiveUnit === 'percent'
65
+ ? formatPercent(magnitude, { locale, precision })
66
+ : effectiveUnit === 'pp'
67
+ ? `${formatMetric(magnitude, { locale, precision })}pp`
68
+ : formatMetric(magnitude, { locale, precision });
69
+
70
+ const text = `${sign}${magnitudeText}`;
71
+
72
+ const spoken =
73
+ direction === 'flat' ? 'No change' : direction === 'up' ? `Up ${magnitudeText}` : `Down ${magnitudeText}`;
74
+
75
+ return {
76
+ text,
77
+ direction,
78
+ intent,
79
+ sentence: `${spoken}, versus ${comparisonLabel}.`,
80
+ hidden: display === 'none',
81
+ };
82
+ };
@@ -0,0 +1,72 @@
1
+ // Number formatting for KPI tiles. Pure Intl, no Vue — the component renders what this returns.
2
+ //
3
+ // Three Intl behaviours drive the shape of this file. Compact notation rounds halfExpand by
4
+ // default, so 999,999 renders as "1M"; it also flips useGrouping to "min2" behind your back, so a
5
+ // shared options object corrupts the full path; and style:'percent' defaults to zero fraction
6
+ // digits, quietly discarding the precision the caller asked for.
7
+
8
+ export type KpiFormat = 'full' | 'compact' | ((v: number) => string);
9
+
10
+ export interface FormatOpts {
11
+ format?: KpiFormat;
12
+ precision?: number;
13
+ locale?: string;
14
+ noValue?: string;
15
+ }
16
+
17
+ const DEFAULT_LOCALE = 'en-GB';
18
+ const DEFAULT_NO_VALUE = '—';
19
+
20
+ /** `roundingMode` is ES2023 and absent from the TypeScript lib this repo compiles against. */
21
+ type NumberFormatOptions = Intl.NumberFormatOptions & { roundingMode?: 'trunc' | 'halfExpand' };
22
+
23
+ // Compact suffixes are house style, not ICU's: lowercase, with the British "bn"/"tn". ICU's en data
24
+ // returns "K"/"M"/"B"/"T", so the suffix is mapped rather than taken as formatted — the rendered
25
+ // glyph is a design decision and must not move with the runtime's CLDR data. Locales ICU
26
+ // abbreviates some other way ("Mrd." in de-DE) fall through unchanged.
27
+ const COMPACT_SUFFIX: Record<string, string> = { K: 'k', M: 'm', B: 'bn', T: 'tn' };
28
+
29
+ export const formatMetric = (
30
+ metric: number | null | undefined,
31
+ { format = 'full', precision, locale = DEFAULT_LOCALE, noValue = DEFAULT_NO_VALUE }: FormatOpts = {},
32
+ ): string => {
33
+ if (metric === null || metric === undefined || Number.isNaN(metric)) return noValue;
34
+ if (typeof format === 'function') return format(metric);
35
+
36
+ if (format === 'compact') {
37
+ const opts: NumberFormatOptions = {
38
+ notation: 'compact',
39
+ compactDisplay: 'short',
40
+ roundingMode: 'trunc',
41
+ // No explicit fraction digits when the caller gave none: Intl's compact default is two
42
+ // significant digits, which is what renders 1,200 as "1.2k" and 999,999 as "999k". Pinning
43
+ // maximumFractionDigits here overrides that and gives "999.9k".
44
+ ...(precision === undefined
45
+ ? {}
46
+ : { minimumFractionDigits: precision, maximumFractionDigits: precision }),
47
+ };
48
+ return new Intl.NumberFormat(locale, opts)
49
+ .formatToParts(metric)
50
+ .map((part) => (part.type === 'compact' ? (COMPACT_SUFFIX[part.value] ?? part.value) : part.value))
51
+ .join('');
52
+ }
53
+
54
+ // Built fresh rather than shared with the compact branch: compact silently switches grouping to
55
+ // "min2", so one reused options object would drop the separator from 1,000 on this path.
56
+ return new Intl.NumberFormat(locale, {
57
+ ...(precision === undefined
58
+ ? {}
59
+ : { minimumFractionDigits: precision, maximumFractionDigits: precision }),
60
+ }).format(metric);
61
+ };
62
+
63
+ /** A fraction (0.034) as a percentage string. Precision is explicit because Intl's default is zero. */
64
+ export const formatPercent = (
65
+ fraction: number,
66
+ { locale = DEFAULT_LOCALE, precision = 1 }: { locale?: string; precision?: number } = {},
67
+ ): string =>
68
+ new Intl.NumberFormat(locale, {
69
+ style: 'percent',
70
+ minimumFractionDigits: 0,
71
+ maximumFractionDigits: precision,
72
+ }).format(fraction);
@@ -0,0 +1,63 @@
1
+ // Selection and focus order for a row of KPI tiles, with no Vue in it so the rules are testable
2
+ // on their own.
3
+ //
4
+ // Registration order — not DOM order — decides arrow-key order. Tiles register on mount, so the
5
+ // two agree, and a v-for that reorders re-registers.
6
+
7
+ export interface KpiRegistry {
8
+ register(key: string): void;
9
+ unregister(key: string): void;
10
+ keys(): string[];
11
+ }
12
+
13
+ export const createKpiRegistry = (): KpiRegistry => {
14
+ const order: string[] = [];
15
+ return {
16
+ register(key) {
17
+ if (!order.includes(key)) order.push(key);
18
+ },
19
+ unregister(key) {
20
+ const i = order.indexOf(key);
21
+ if (i !== -1) order.splice(i, 1);
22
+ },
23
+ keys: () => [...order],
24
+ };
25
+ };
26
+
27
+ /** The tile an arrow key moves focus to. Wraps, per the tabs pattern. */
28
+ export const nextKey = (keys: string[], current: string | undefined, step: 1 | -1): string | undefined => {
29
+ if (!keys.length) return undefined;
30
+ const i = current === undefined ? -1 : keys.indexOf(current);
31
+ if (i === -1) return step === 1 ? keys[0] : keys[keys.length - 1];
32
+ return keys[(i + step + keys.length) % keys.length];
33
+ };
34
+
35
+ export const isSelected = (current: string | string[] | undefined, key: string): boolean =>
36
+ Array.isArray(current) ? current.includes(key) : current === key;
37
+
38
+ /**
39
+ * Single mode replaces and never empties — a tab row with nothing selected has no panel to show.
40
+ * Multiple mode toggles, and may empty: an overlay chart with no series is a legible state.
41
+ */
42
+ export const toggleSelection = (
43
+ current: string | string[] | undefined,
44
+ key: string,
45
+ multiple: boolean,
46
+ ): string | string[] => {
47
+ if (!multiple) return key;
48
+ const list = Array.isArray(current) ? current : current ? [current] : [];
49
+ return list.includes(key) ? list.filter((k) => k !== key) : [...list, key];
50
+ };
51
+
52
+ /** Drops selections naming tiles that are no longer in the row, then falls back. */
53
+ export const resolveInitial = (
54
+ keys: string[],
55
+ model: string | string[] | undefined,
56
+ fallback: string | undefined,
57
+ ): string | string[] | undefined => {
58
+ if (!keys.length) return undefined;
59
+ if (Array.isArray(model)) return model.filter((k) => keys.includes(k));
60
+ if (model !== undefined && keys.includes(model)) return model;
61
+ if (fallback !== undefined && keys.includes(fallback)) return fallback;
62
+ return keys[0];
63
+ };
@@ -0,0 +1,52 @@
1
+ <script setup lang="ts">
2
+ // The Forest logo, composed live from the primitives in @humanforest/tokens — the same source the generated
3
+ // SVGs use. One component for every variant; theme-adaptive (the square is forest-700, the ink — ring
4
+ // + inner + wordmark — flows from currentColor, so a parent text colour drives light/dark). `size` is
5
+ // the rendered height in px; width follows the variant's aspect ratio.
6
+ import { computed } from 'vue';
7
+ import { MARK, MARK_SIZE, WORDMARK_PATHS, WORDMARK_W, WORDMARK_H, LOCKUP_H, LOCKUP_V, F } from '@humanforest/tokens/logo';
8
+
9
+ // `clearspace`: bake the exclusion zone into the SVG as transparent margin. true = one f on every
10
+ // side (the brand rule); a number = that many f. `size` stays the logo's own height — the SVG just
11
+ // grows to include the margin, so the logo never shrinks when you turn clear space on.
12
+ const props = withDefaults(defineProps<{ variant?: 'lockup-h' | 'lockup-v' | 'wordmark' | 'mark'; size?: number; clearspace?: boolean | number }>(), {
13
+ variant: 'lockup-h', size: 64, clearspace: false,
14
+ });
15
+
16
+ const LAYOUT = {
17
+ 'lockup-h': { vb: LOCKUP_H.vb, mark: LOCKUP_H.mark, word: LOCKUP_H.word },
18
+ 'lockup-v': { vb: LOCKUP_V.vb, mark: LOCKUP_V.mark, word: LOCKUP_V.word },
19
+ mark: { vb: [MARK_SIZE, MARK_SIZE] as const, mark: [0, 0] as const, word: null },
20
+ wordmark: { vb: [WORDMARK_W, WORDMARK_H] as const, mark: null, word: [0, 0] as const },
21
+ } as const;
22
+
23
+ const layout = computed(() => LAYOUT[props.variant]);
24
+ // one f of clear space, in artwork units (F.h ≈ 0.454 × mark height — the same module the diagram uses)
25
+ const pad = computed(() => (props.clearspace === true ? 1 : props.clearspace === false ? 0 : props.clearspace) * F.h);
26
+ const viewBox = computed(() => {
27
+ const [w, h] = layout.value.vb, p = pad.value;
28
+ return `${-p} ${-p} ${w + 2 * p} ${h + 2 * p}`;
29
+ });
30
+ // grow the rendered height to include the margins so `size` keeps meaning the logo's own height
31
+ const renderH = computed(() => { const h = layout.value.vb[1]; return props.size * (h + 2 * pad.value) / h; });
32
+ const xf = (t: readonly [number, number] | null) => (t ? `translate(${t[0]} ${t[1]})` : undefined);
33
+ </script>
34
+
35
+ <template>
36
+ <svg
37
+ :viewBox="viewBox" :style="{ height: `${renderH}px`, width: 'auto' }"
38
+ fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"
39
+ >
40
+ <!-- mark: green square + reversing ink ring + white tree + fixed-dark inner -->
41
+ <g v-if="layout.mark" :transform="xf(layout.mark)">
42
+ <path :d="MARK.square" fill="var(--color-forest-700)" />
43
+ <path :d="MARK.ring" fill="currentColor" />
44
+ <path :d="MARK.tree" fill="white" />
45
+ <path :d="MARK.inner" fill="var(--color-forest-950)" />
46
+ </g>
47
+ <!-- wordmark: the ink, follows the theme via currentColor -->
48
+ <g v-if="layout.word" :transform="xf(layout.word)">
49
+ <path v-for="(d, i) in WORDMARK_PATHS" :key="i" :d="d" fill="currentColor" />
50
+ </g>
51
+ </svg>
52
+ </template>