@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,61 @@
1
+ // Chart palette helpers for Unovis. Returns `categories` maps whose colours are
2
+ // var(--dataviz-*) STRINGS, not hexes — CSS resolves them, so dark mode flips charts with zero JS
3
+ // and the engine stays the only place values live. Engine hex imports are allowed in chart pages
4
+ // ONLY for CVD-simulation demos (simulateCvd needs real hexes).
5
+ import type { BulletLegendItemInterface } from '@unovis/ts';
6
+ import { DATAVIZ, DATAVIZ_PAIRS, DATAVIZ_CATEGORICAL, DATAVIZ_SEQUENTIAL, DATAVIZ_DIVERGING } from './engine';
7
+
8
+ type Categories = Record<string, BulletLegendItemInterface>;
9
+ export interface SeriesDef { key: string; name: string }
10
+
11
+ // Each accessor is named after the palette it returns, matching the tier names on the docs page.
12
+ // There is deliberately NO generic `categorical()` — the generic name used to return the VIVID set,
13
+ // which meant the one palette that is not colour-blind-safe owned the most authoritative-sounding name.
14
+ const SAFE = Object.keys(DATAVIZ.light); // 1..6 — brand-led CVD-safe, consumed by index
15
+ const VIVID = DATAVIZ_CATEGORICAL.names; // green, amber, red, … (first 6 = the curated 6)
16
+ const PAIRS = Object.keys(DATAVIZ_PAIRS.light); // forest-deep, forest-soft, warm-deep, … (family-grouped)
17
+
18
+ export const safeVar = (i: number) => `var(--dataviz-categorical-safe-${SAFE[i % SAFE.length]})`;
19
+ export const vividVar = (i: number) => `var(--dataviz-categorical-vivid-${VIVID[i % VIVID.length]})`;
20
+ export const pairsVar = (i: number) => `var(--dataviz-categorical-pairs-${PAIRS[i % PAIRS.length]})`;
21
+
22
+ /** The three categorical sets, named. Only for callers that choose a set at runtime. */
23
+ export type CategoricalPalette = 'cvdSafe' | 'vivid' | 'pairs';
24
+ const CATEGORICAL_VARS: Record<CategoricalPalette, (i: number) => string> = {
25
+ cvdSafe: safeVar,
26
+ vivid: vividVar,
27
+ pairs: pairsVar,
28
+ };
29
+ export const categoricalVar = (p: CategoricalPalette, i: number) => CATEGORICAL_VARS[p](i);
30
+ export const sequentialVar = (name: keyof typeof DATAVIZ_SEQUENTIAL, i: number) => `var(--dataviz-sequential-${name}-${i})`;
31
+ const kebab = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase();
32
+ export const divergingVar = (name: keyof typeof DATAVIZ_DIVERGING, i: number) => `var(--dataviz-diverging-${kebab(name)}-${i})`;
33
+
34
+ const build = (series: SeriesDef[], color: (i: number) => string): Categories =>
35
+ Object.fromEntries(series.map((s, i) => [s.key, { name: s.name, color: color(i) }]));
36
+
37
+ export function useChartPalette() {
38
+ return {
39
+ /** The 6-colour brand-led CVD-safe set — what the Unovis adapter already ships on --vis-color0..5.
40
+ * The default: colour alone separates the series, so it needs no second channel. */
41
+ cvdSafe: (series: SeriesDef[]) => build(series, safeVar),
42
+ /** Vivid categorical (palette order). ≤6 where you can; 7–12 are label-only.
43
+ * NOT CVD-safe — the caller MUST add a second channel (legend, direct labels, dash or marker). */
44
+ vivid: (series: SeriesDef[]) => build(series, vividVar),
45
+ /** Brand pairs — 3 brand hues × deep/soft, family-grouped. Brand-forward work: decks, marketing.
46
+ * NOT CVD-safe — the caller MUST add a second channel (legend, direct labels, dash or marker). */
47
+ pairs: (series: SeriesDef[]) => build(series, pairsVar),
48
+ /** One of the three sets, chosen at runtime. For the docs' palette picker — production code
49
+ * should name the set it means, so the CVD contract above is visible at the call site. */
50
+ byName: (p: CategoricalPalette, series: SeriesDef[]) =>
51
+ build(series, (i) => categoricalVar(p, i)),
52
+ /** n evenly-spaced stops from a sequential ramp (low → high). */
53
+ sequential: (name: keyof typeof DATAVIZ_SEQUENTIAL, n: number) => {
54
+ const len = DATAVIZ_SEQUENTIAL[name].length;
55
+ return Array.from({ length: n }, (_, i) => sequentialVar(name, Math.round((i * (len - 1)) / Math.max(n - 1, 1))));
56
+ },
57
+ /** All 9 stops of a diverging ramp (low → centre → high). */
58
+ diverging: (name: keyof typeof DATAVIZ_DIVERGING) =>
59
+ DATAVIZ_DIVERGING[name].map((_, i) => divergingVar(name, i)),
60
+ };
61
+ }
@@ -0,0 +1,22 @@
1
+ import { computed, toValue, type ComputedRef, type MaybeRefOrGetter } from 'vue';
2
+
3
+ /**
4
+ * Remount key for Unovis charts whose series colours change identity at runtime.
5
+ *
6
+ * The bug it works around: Unovis caches series colours on mount — Bar, Donut and Bubble keep
7
+ * painting the colours they mounted with even after `categories` hands them new ones (Line
8
+ * re-reads and repaints without help, which is why the trap is easy to miss). Bind the returned
9
+ * key as `:key` on the chart component so a flip of any signal remounts it:
10
+ *
11
+ * ```ts
12
+ * const chartKey = useChartRepaintKey(mode, vision, chartSet);
13
+ * // <VisSingleContainer :key="chartKey" …>
14
+ * ```
15
+ *
16
+ * Signals are passed in as refs or getters — colour mode, CVD simulation, palette choice —
17
+ * rather than read from an app composable, so the package carries no app internals; the docs
18
+ * app passes its own colour-mode ref.
19
+ */
20
+ export function useChartRepaintKey(...signals: MaybeRefOrGetter<unknown>[]): ComputedRef<string> {
21
+ return computed(() => signals.map((s) => String(toValue(s))).join('-'));
22
+ }
@@ -0,0 +1,17 @@
1
+ // Frame-owned repaint signal: bumps when the documentElement's class or data-theme attribute
2
+ // changes (i.e. on a colour-mode flip). Unovis Bar/Donut/Bubble cache resolved colours on mount,
3
+ // so a var(--dataviz-*) colour does NOT repaint them when dark mode lands — remounting on this
4
+ // signal is the workaround, owned by the frame instead of every call site.
5
+ import { onScopeDispose, ref, type Ref } from 'vue';
6
+
7
+ export function useThemeVersion(): Ref<number> {
8
+ const version = ref(0);
9
+ if (typeof window === 'undefined' || typeof MutationObserver === 'undefined') return version;
10
+ const observer = new MutationObserver(() => { version.value++; });
11
+ observer.observe(document.documentElement, {
12
+ attributes: true,
13
+ attributeFilter: ['class', 'data-theme'],
14
+ });
15
+ onScopeDispose(() => observer.disconnect());
16
+ return version;
17
+ }
@@ -0,0 +1,53 @@
1
+ <script setup lang="ts">
2
+ import { computed } from 'vue';
3
+ // Desktop browser chrome — the web counterpart to DeviceFrame. A neutral macOS-style window
4
+ // (traffic lights + address pill) wrapping a live app screen rendered at the chosen logical width.
5
+ // The screen is a `@container`, so the slotted shell reflows against the frame width (not the
6
+ // viewport) — switching the Viewport control genuinely resizes the layout. Flip `dark` to theme the
7
+ // screen subtree (adds `.dark` over `bg-default text-default`, exactly like DeviceFrame's screen).
8
+ // ★ The SCREEN carries a real display aspect — 16:10 or 16:9, the two shapes a desktop actually comes in
9
+ // — and the height is derived from it rather than typed in. A hand-picked height drifts into ratios no
10
+ // monitor has, which makes a specimen's reflow decisions untestable against anything real.
11
+ const props = withDefaults(
12
+ defineProps<{
13
+ width: number;
14
+ aspect?: '16/10' | '16/9';
15
+ /** Overrides the aspect. Only for a screen that is deliberately not a whole display. */
16
+ height?: number;
17
+ dark?: boolean;
18
+ url?: string;
19
+ }>(),
20
+ { aspect: '16/10', dark: false, url: 'console.forest.bike/fleet' },
21
+ );
22
+
23
+ const screenH = computed(() => props.height ?? Math.round(props.width * (props.aspect === '16/9' ? 9 / 16 : 10 / 16)));
24
+ </script>
25
+
26
+ <template>
27
+ <div
28
+ class="shrink-0 overflow-hidden rounded-xl border border-default bg-default"
29
+ :style="{ width: width + 'px', filter: 'drop-shadow(0 25px 45px rgba(0,0,0,.28))' }"
30
+ >
31
+ <!-- title bar -->
32
+ <div class="flex items-center gap-3 border-b border-default bg-elevated/60 px-4 py-2.5">
33
+ <div class="flex items-center gap-1.5">
34
+ <span class="size-3 rounded-full bg-[#ff5f57]"></span>
35
+ <span class="size-3 rounded-full bg-[#febc2e]"></span>
36
+ <span class="size-3 rounded-full bg-[#28c840]"></span>
37
+ </div>
38
+ <div class="mx-auto flex w-full max-w-xs items-center justify-center gap-1.5 rounded-md border border-default bg-default px-3 py-1 text-xs text-muted">
39
+ <UIcon name="i-lucide-lock" class="size-3 text-dimmed" />
40
+ {{ url }}
41
+ </div>
42
+ <UIcon name="i-lucide-plus" class="size-4 shrink-0 text-dimmed" />
43
+ </div>
44
+
45
+ <!-- live screen — its own theming context + container -->
46
+ <div
47
+ :class="['@container relative overflow-hidden bg-default text-default', { dark }]"
48
+ :style="{ height: screenH + 'px' }"
49
+ >
50
+ <slot />
51
+ </div>
52
+ </div>
53
+ </template>
@@ -0,0 +1,242 @@
1
+ <script setup lang="ts">
2
+ // Photoreal device shell: an unmodified Wikimedia SVG render is the hardware; the live HTML screen is
3
+ // overlaid at the SVG's measured screen insets and clipped to the SVG's own screen path. We draw the
4
+ // cutout (island/notch/punch-hole) + an OS-authentic status bar on top.
5
+ //
6
+ // Status bar matches the platform: iOS uses the system font (SF Pro via system-ui) with hand-drawn
7
+ // SF-style glyphs (cellular bars, wifi cone, capsule battery); Android uses Roboto with Material
8
+ // Symbols glyphs. Sizes follow each platform's status bar. Rendered at true 1× logical points.
9
+ import { computed, useId } from 'vue';
10
+ import { findDevice, type DeviceModel } from './devices';
11
+ import { artUrl } from './art';
12
+
13
+ const clipId = `screenclip-${useId()}`;
14
+
15
+ const props = withDefaults(
16
+ defineProps<{
17
+ model: string | DeviceModel;
18
+ dark?: boolean;
19
+ statusBar?: boolean;
20
+ time?: string;
21
+ /** Overlay the safe-area insets (top/bottom) for inspection. */
22
+ showSafe?: boolean;
23
+ /** Slot content runs edge-to-edge under the status bar (maps, camera). Pad with `safe.top`. */
24
+ fullBleed?: boolean;
25
+ /** A real phone is a COARSE pointer. Opt in to stamp `[data-force-coarse]` on the screen so the
26
+ * preset's `coarse:` touch bumps fire for the content inside — the truthful render for a
27
+ * web-cut surface (standard/marketing) viewed on a device. The mobile cut stays put on its own
28
+ * (its `cut-fixed` opts the bump out — it already baked its size), and components with no
29
+ * `coarse:` bump are unaffected. Default off so existing frames don't shift. */
30
+ touch?: boolean;
31
+ }>(),
32
+ { dark: false, statusBar: true, time: '9:41', showSafe: false, fullBleed: false, touch: false },
33
+ );
34
+
35
+ const device = computed<DeviceModel>(() =>
36
+ typeof props.model === 'string' ? findDevice(props.model) : props.model,
37
+ );
38
+ const art = computed(() => device.value.art!);
39
+ const artSrc = computed(() => artUrl(device.value));
40
+ const os = computed<'ios' | 'android'>(() => (device.value.brand === 'Apple' ? 'ios' : 'android'));
41
+
42
+ const dims = computed(() => {
43
+ const a = art.value;
44
+ const w = device.value.screen.w / (1 - (a.inset.left + a.inset.right) / 100);
45
+ return { w, h: w * a.aspect };
46
+ });
47
+
48
+ const screenStyle = computed(() => {
49
+ const a = art.value;
50
+ return {
51
+ top: a.inset.top + '%',
52
+ left: a.inset.left + '%',
53
+ right: a.inset.right + '%',
54
+ bottom: a.inset.bottom + '%',
55
+ ...(a.clip
56
+ ? { clipPath: `url(#${clipId})` }
57
+ : { borderRadius: (device.value.screen.w * (a.radiusPct ?? 0)) / 100 + 'px' }),
58
+ };
59
+ });
60
+
61
+ const screenW = computed(() => device.value.screen.w);
62
+ const statusBarH = computed(() => device.value.safe.top);
63
+ const cutout = computed(() => device.value.cutout);
64
+ const island = computed(() => ({ w: Math.round(screenW.value * 0.3), h: 36, top: 10 }));
65
+ const notch = computed(() => ({ w: Math.round(screenW.value * 0.42), h: 30 }));
66
+ const compact = computed(() => cutout.value === 'home' || cutout.value === 'none');
67
+
68
+ // Safe-area bridge. A mock phone has no notch for the browser to report, so `env(safe-area-inset-*)`
69
+ // resolves to 0 in here and chrome would tuck under the island. The frame publishes the device's
70
+ // measured insets as the vars FCanvasHud reads first; on a real device nothing sets them and the
71
+ // `env()` fallback takes over. Top is only owed when the content runs full-bleed — otherwise the
72
+ // wrapper is already offset below the status bar and charging for it twice would double the gap.
73
+ const safeVars = computed(() => ({
74
+ '--forest-safe-top': (props.fullBleed ? device.value.safe.top : 0) + 'px',
75
+ '--forest-safe-bottom': device.value.safe.bottom + 'px',
76
+ }));
77
+
78
+ // Status-bar metrics, computed per device so the clock/icons sit at the cutout's vertical centre and
79
+ // clear the rounded-corner curve (no per-device hand-tuning, no clipped glyphs).
80
+ const sb = computed(() => {
81
+ const d = device.value;
82
+ const ios = os.value === 'ios';
83
+ // vertical centre of the row = centre of the cutout (island/punch-hole) or middle of a short bar
84
+ const centerY =
85
+ cutout.value === 'island' ? island.value.top + island.value.h / 2
86
+ : cutout.value === 'punch-hole' ? 19
87
+ : statusBarH.value * 0.5;
88
+ // screen corner radius (px) → how far the corner curves in at centerY
89
+ const cornerR = d.art.radiusPct ? (d.screen.w * d.art.radiusPct) / 100 : d.screen.w * (ios ? 0.13 : 0.12);
90
+ const cornerInset = centerY >= cornerR ? 0 : cornerR - Math.sqrt(cornerR * cornerR - (cornerR - centerY) ** 2);
91
+ const basePad = ios ? (compact.value ? 16 : 22) : 14;
92
+ const padX = Math.max(basePad, Math.ceil(cornerInset) + 8);
93
+ return ios
94
+ ? { padX, barH: centerY * 2, clockPx: 15, clockFont: '-apple-system, "SF Pro Text", system-ui, sans-serif', clockWeight: 600, iconGap: 5 }
95
+ : { padX, barH: centerY * 2, clockPx: 14, clockFont: 'Roboto, system-ui, sans-serif', clockWeight: 500, iconGap: 6 };
96
+ });
97
+
98
+ </script>
99
+
100
+ <template>
101
+ <div
102
+ class="device relative shrink-0"
103
+ :style="{ width: dims.w + 'px', height: dims.h + 'px', filter: 'drop-shadow(0 25px 45px rgba(0,0,0,.28))' }"
104
+ >
105
+ <img :src="artSrc" alt="" class="pointer-events-none absolute inset-0 h-full w-full select-none" draggable="false" />
106
+
107
+ <svg v-if="art.clip" class="absolute size-0" aria-hidden="true">
108
+ <defs>
109
+ <clipPath :id="clipId" clipPathUnits="objectBoundingBox"><path :d="art.clip" /></clipPath>
110
+ </defs>
111
+ </svg>
112
+
113
+ <!-- LIVE SCREEN -->
114
+ <div :class="['screen absolute overflow-hidden bg-default text-default', { dark }]" :style="screenStyle">
115
+ <!-- cutout -->
116
+ <div
117
+ v-if="cutout === 'island'"
118
+ class="absolute z-30 -translate-x-1/2 rounded-full bg-black"
119
+ :style="{ left: '50%', top: island.top + 'px', width: island.w + 'px', height: island.h + 'px' }"
120
+ ></div>
121
+ <div
122
+ v-else-if="cutout === 'notch' && !art.clip"
123
+ class="absolute left-1/2 top-0 z-30 -translate-x-1/2 bg-black"
124
+ :style="{ width: notch.w + 'px', height: notch.h + 'px', borderRadius: '0 0 18px 18px' }"
125
+ ></div>
126
+ <div
127
+ v-else-if="cutout === 'punch-hole'"
128
+ class="absolute left-1/2 top-3 z-30 size-3.5 -translate-x-1/2 rounded-full bg-black"
129
+ ></div>
130
+
131
+ <!-- OS-authentic status bar -->
132
+ <div
133
+ v-if="statusBar"
134
+ class="absolute inset-x-0 top-0 z-20 flex items-center justify-between text-highlighted"
135
+ :style="{ height: sb.barH + 'px', paddingInline: sb.padX + 'px' }"
136
+ >
137
+ <span
138
+ class="tabular-nums"
139
+ :style="{ fontFamily: sb.clockFont, fontSize: sb.clockPx + 'px', fontWeight: sb.clockWeight, letterSpacing: os === 'ios' ? '.01em' : '0' }"
140
+ >{{ time }}</span>
141
+
142
+ <!-- iOS glyphs: cellular bars · wifi cone · capsule battery -->
143
+ <div v-if="os === 'ios'" class="flex items-center" :style="{ gap: sb.iconGap + 'px' }">
144
+ <svg viewBox="0 0 18 12" height="11" fill="currentColor" aria-hidden="true">
145
+ <rect x="0" y="8" width="3" height="4" rx="1" /><rect x="5" y="5.5" width="3" height="6.5" rx="1" />
146
+ <rect x="10" y="3" width="3" height="9" rx="1" /><rect x="15" y="0" width="3" height="12" rx="1" />
147
+ </svg>
148
+ <svg viewBox="0 0 18 13" height="12" fill="currentColor" aria-hidden="true">
149
+ <path d="M9 2.4c2.9 0 5.6 1.05 7.7 2.9a.6.6 0 0 1 .03.87L9.5 13.5a.7.7 0 0 1-1 0L1.27 6.17a.6.6 0 0 1 .03-.87C3.4 3.45 6.1 2.4 9 2.4Z" />
150
+ </svg>
151
+ <svg viewBox="0 0 28 13" height="12" aria-hidden="true">
152
+ <rect x=".5" y=".5" width="23" height="12" rx="3.6" fill="none" stroke="currentColor" stroke-opacity=".4" />
153
+ <rect x="2" y="2" width="18" height="9" rx="2.2" fill="currentColor" />
154
+ <path d="M25.3 4.3c1.1.25 1.1 4.15 0 4.4Z" fill="currentColor" fill-opacity=".4" />
155
+ </svg>
156
+ </div>
157
+
158
+ <!-- Android glyphs: Material Symbols (cellular · wifi · battery) -->
159
+ <div v-else class="flex items-center" :style="{ gap: sb.iconGap + 'px' }">
160
+ <svg viewBox="0 -960 960 960" height="15" fill="currentColor" aria-hidden="true">
161
+ <path d="M260-160q-25 0-42.5-17.5T200-220v-120q0-25 17.5-42.5T260-400q25 0 42.5 17.5T320-340v120q0 25-17.5 42.5T260-160Zm240 0q-25 0-42.5-17.5T440-220v-320q0-25 17.5-42.5T500-600q25 0 42.5 17.5T560-540v320q0 25-17.5 42.5T500-160Zm240 0q-25 0-42.5-17.5T680-220v-520q0-25 17.5-42.5T740-800q25 0 42.5 17.5T800-740v520q0 25-17.5 42.5T740-160Z" />
162
+ </svg>
163
+ <svg viewBox="0 -960 960 960" height="15" fill="currentColor" aria-hidden="true">
164
+ <path d="M174-540q67-48 145-74t161-26q83 0 161 26t145 74l58-58q-79-60-172-91t-192-31q-99 0-192 31t-172 91l58 58Zm249 363L61-539q-12-12-18-27t-6-30q0-17 7-32.5T65-656q82-71 195-107.5T480-800q107 0 220 36.5T895-656q14 12 21 27.5t7 32.5q0 15-6 30t-18 27L537-177q-12 12-27 18t-30 6q-15 0-30-6t-27-18Z" />
165
+ </svg>
166
+ <svg viewBox="0 -960 960 960" height="15" fill="currentColor" aria-hidden="true">
167
+ <path d="M320-80q-17 0-28.5-11.5T280-120v-640q0-17 11.5-28.5T320-800h80v-40q0-17 11.5-28.5T440-880h80q17 0 28.5 11.5T560-840v40h80q17 0 28.5 11.5T680-760v640q0 17-11.5 28.5T640-80H320Z" />
168
+ </svg>
169
+ </div>
170
+ </div>
171
+
172
+ <!-- screen content — starts below the status bar unless fullBleed (maps, camera), in which
173
+ case it runs edge-to-edge and the status bar/cutout (z-20/z-30) draw over it -->
174
+ <div
175
+ class="absolute inset-x-0 bottom-0 overflow-hidden"
176
+ :style="{ top: fullBleed ? '0px' : statusBarH + 'px', ...safeVars }"
177
+ :data-force-coarse="touch ? '' : undefined"
178
+ >
179
+ <slot :device="device" :safe="device.safe" />
180
+ </div>
181
+
182
+ <!-- home indicator -->
183
+ <div
184
+ v-if="!compact"
185
+ class="absolute bottom-2 left-1/2 z-20 h-1 -translate-x-1/2 rounded-full bg-highlighted/40"
186
+ :style="{ width: screenW * 0.36 + 'px' }"
187
+ ></div>
188
+
189
+ <!-- safe-area overlay (toggle) -->
190
+ <template v-if="showSafe">
191
+ <div
192
+ class="pointer-events-none absolute inset-x-0 top-0 z-40 flex items-start justify-center"
193
+ :style="{ height: device.safe.top + 'px', background: 'rgba(56,189,248,.28)', borderBottom: '1px dashed rgba(2,132,199,.9)' }"
194
+ >
195
+ <span class="mt-0.5 rounded bg-sky-600 px-1 text-[9px] font-semibold text-white">{{ device.safe.top }}pt</span>
196
+ </div>
197
+ <div
198
+ v-if="device.safe.bottom > 0"
199
+ class="pointer-events-none absolute inset-x-0 bottom-0 z-40 flex items-end justify-center"
200
+ :style="{ height: device.safe.bottom + 'px', background: 'rgba(56,189,248,.28)', borderTop: '1px dashed rgba(2,132,199,.9)' }"
201
+ >
202
+ <span class="mb-0.5 rounded bg-sky-600 px-1 text-[9px] font-semibold text-white">{{ device.safe.bottom }}pt</span>
203
+ </div>
204
+ </template>
205
+ </div>
206
+ </div>
207
+ </template>
208
+
209
+ <style scoped>
210
+ /* A touch glyph over the live screen, so a mouse reading a phone mock reads as a finger, not a
211
+ pointer: a solid grey contact dot inside a translucent halo, the shape a fingertip makes. Forced
212
+ onto descendants too — otherwise every button's own `cursor: pointer` wins back.
213
+ The SVG is baked static (no tokens, no `currentColor`), so the two modes are two encoded copies —
214
+ dark grey on a light screen, light grey on a dark one. Fine pointers only.
215
+ 32px is the ceiling, not a taste call: Chrome refuses a cursor bitmap larger than 32×32 on
216
+ Windows and silently falls back to the arrow. */
217
+ @media (hover: hover) and (pointer: fine) {
218
+ .screen,
219
+ .screen :deep(*) {
220
+ cursor:
221
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%2352525B' fill-opacity='.28'/%3E%3Ccircle cx='16' cy='16' r='9.5' fill='%2352525B'/%3E%3C/svg%3E")
222
+ 16 16,
223
+ pointer;
224
+ }
225
+
226
+ /* Two ways a screen ends up dark: the frame's own `dark` prop, or the whole docs app being in
227
+ dark mode (`html.dark`) — the screen inherits that through `bg-default` without ever carrying
228
+ the class itself. Both need the light-grey glyph.
229
+ Plain descendant selectors, NOT `:global(.dark) .screen` — Vue compiles that down to bare
230
+ `.dark`, which is on `<html>`, and `cursor` inherits to the whole page. Scoping only stamps the
231
+ last compound, so an unscoped ancestor matches on its own. */
232
+ .screen.dark,
233
+ .screen.dark :deep(*),
234
+ .dark .screen,
235
+ .dark .screen :deep(*) {
236
+ cursor:
237
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='32' height='32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%23D4D4D8' fill-opacity='.28'/%3E%3Ccircle cx='16' cy='16' r='9.5' fill='%23D4D4D8'/%3E%3C/svg%3E")
238
+ 16 16,
239
+ pointer;
240
+ }
241
+ }
242
+ </style>
@@ -0,0 +1,83 @@
1
+ <script setup lang="ts">
2
+ // Lays its slot out at a fixed LOGICAL width and scales the result down to fit the space available.
3
+ //
4
+ // Why a specimen wants this: a shell only reflows honestly at a real width, and its measures are absolute
5
+ // — `railTop: 17rem` is 20% of 1360px and 65% of a 420px diagram box. Laying out at the real width and
6
+ // shrinking the picture keeps every proportion true; shrinking the width instead changes the design.
7
+ //
8
+ // ★ SCALED WITH `transform`, NEVER WITH `zoom`. Both shrink the frame; only one keeps a Mapbox canvas
9
+ // correct. Mapbox sizes its canvas from the container's LAYOUT box (clientWidth) and writes that back as
10
+ // the canvas's CSS size. `zoom` changes the layout box, so the factor lands twice: MEASURED at zoom 0.72,
11
+ // an 862px container held a 621px canvas — dead strips down two edges, and `map.resize()` cannot fix it
12
+ // because it recomputes the same wrong value. A `transform` leaves the layout box alone, so Mapbox writes
13
+ // the logical size and the transform scales the result once.
14
+ //
15
+ // The cost of a transform is that the element still OCCUPIES its unscaled box, which is what the rest of
16
+ // this component is for.
17
+ import { computed, onBeforeUnmount, ref, watch } from 'vue';
18
+
19
+ const props = withDefaults(
20
+ defineProps<{
21
+ /** Logical width the slot lays out at, whatever the space available. */
22
+ width: number;
23
+ /** Largest scale to draw at, so a wide page does not render the specimen at 1:1. */
24
+ maxScale?: number;
25
+ }>(),
26
+ { maxScale: 1 },
27
+ );
28
+
29
+ // The wrapper's own padding, added back to the height it reserves. Weighted downwards because a frame's
30
+ // shadow is cast downwards, so that is the side that needs the room.
31
+ const PAD_TOP = 8;
32
+ const PAD_BOTTOM = 24;
33
+
34
+ const inner = ref<HTMLElement | null>(null);
35
+ const wrapper = ref<HTMLElement | null>(null);
36
+ const naturalH = ref(0);
37
+ const availW = ref(0);
38
+
39
+ // Watched rather than read in onMounted: the refs are not guaranteed to be populated by the time this
40
+ // component's mounted hook runs, and a missed attach leaves the wrapper unsized with no visible error.
41
+ // No loop between the two: the slot's unscaled height is fixed by its own content, and the wrapper's
42
+ // width is its block width — neither depends on the scale derived from them.
43
+ const innerRO = new ResizeObserver(([e]) => { naturalH.value = e.contentRect.height; });
44
+ const wrapRO = new ResizeObserver(([e]) => { availW.value = e.contentRect.width; });
45
+ watch(inner, (el) => { innerRO.disconnect(); if (el) innerRO.observe(el); }, { immediate: true, flush: 'post' });
46
+ watch(wrapper, (el) => { wrapRO.disconnect(); if (el) wrapRO.observe(el); }, { immediate: true, flush: 'post' });
47
+ onBeforeUnmount(() => { innerRO.disconnect(); wrapRO.disconnect(); });
48
+
49
+ // ★ SCALE TO FIT, capped. A fixed factor overflows its container the moment the page is narrower than
50
+ // width × scale — the slot's layout width never changes, so nothing else absorbs it.
51
+ const fit = computed(() => (availW.value ? Math.min(props.maxScale, availW.value / props.width) : props.maxScale));
52
+
53
+ // Height the scaled frame actually takes on the page. 0 until measured, so nothing jumps.
54
+ const reservedH = computed(() =>
55
+ naturalH.value ? Math.round(naturalH.value * fit.value) + PAD_TOP + PAD_BOTTOM : 0,
56
+ );
57
+ </script>
58
+
59
+ <template>
60
+ <!-- The wrapper reserves the SCALED height and the shadow's room in its own padding. It does NOT clip:
61
+ the height already stops the unscaled leftover box from pushing the page around, and that leftover
62
+ box paints nothing. Any `overflow` value here costs the shadow — `hidden` cuts it off square, and
63
+ `hidden`/`clip` on one axis forces the other to `auto`, which adds a scrollbar. -->
64
+ <div
65
+ ref="wrapper"
66
+ class="flex justify-center pt-2 pb-6"
67
+ :style="reservedH ? { height: `${reservedH}px` } : undefined"
68
+ >
69
+ <!-- `self-start` is load-bearing: without it the flex default stretches this to the wrapper's
70
+ reserved height, which the wrapper derives from THIS element's height — a loop that collapses
71
+ both to nothing. `shrink-0` only guards the main axis.
72
+ `pointer-events-none` because this box keeps its UNSCALED height, so part of it hangs past the
73
+ frame over whatever follows. Transparent, but it would still swallow clicks and text selection
74
+ there. The slot opts back in. -->
75
+ <div
76
+ ref="inner"
77
+ class="pointer-events-none shrink-0 self-start [&>*]:pointer-events-auto"
78
+ :style="{ transform: `scale(${fit})`, transformOrigin: 'top center' }"
79
+ >
80
+ <slot />
81
+ </div>
82
+ </div>
83
+ </template>
@@ -0,0 +1,124 @@
1
+ <svg width="360" height="722" version="1.1" viewBox="0 0 95.25 191.02918" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
2
+ <defs>
3
+ <linearGradient id="linearGradient941">
4
+ <stop stop-color="#dedede" stop-opacity=".27450982" offset="0"/>
5
+ <stop stop-color="#dedede" offset=".05"/>
6
+ <stop stop-color="#dddedf" offset=".94999999"/>
7
+ <stop stop-color="#dedede" stop-opacity=".27450982" offset="1"/>
8
+ </linearGradient>
9
+ <pattern id="pattern3129" patternTransform="matrix(.26458334 0 0 .26458334 -76.445683 -5.7812651)" xlink:href="#pattern2874-7"/>
10
+ <linearGradient id="linearGradient5697" x1="-13.205618" x2="-13.205618" y1="26.258963" y2="47.30072" gradientTransform="translate(13.877701,13.831199)" gradientUnits="userSpaceOnUse" xlink:href="#linearGradient941"/>
11
+ <clipPath id="clipPath5738-4">
12
+ <path d="m0.45113544 39.688016c-0.24986192 0-0.4511331183 0.200755-0.4511331183 0.450619v20.944606c0 0.249864 0.2012711983 0.451135 0.4511331183 0.451135h3.47420686c0.2498645 0 0.4511357-0.201271 0.4511357-0.451135v-20.944606c0-0.249864-0.2012712-0.450619-0.4511357-0.450619z" color="#000000"/>
13
+ </clipPath>
14
+ <linearGradient id="linearGradient5475" x1="-13.205618" x2="-13.205618" y1="26.258963" y2="32.87096" gradientUnits="userSpaceOnUse" xlink:href="#linearGradient941"/>
15
+ <clipPath id="clipPath5401-9">
16
+ <rect x="-13.8777" y="25.856594" width="4.3765388" height="7.4167299" ry=".45101735" color="#000000"/>
17
+ </clipPath>
18
+ <filter id="filter5465" x="-.32505909" y="-.025913246" width="1.6501182" height="1.0518265" color-interpolation-filters="sRGB">
19
+ <feGaussianBlur stdDeviation="0.07139097"/>
20
+ </filter>
21
+ <linearGradient id="linearGradient5475-9" x1="-13.205618" x2="-13.205618" y1="26.258963" y2="39.268726" gradientTransform="translate(13.877701,13.831199)" gradientUnits="userSpaceOnUse" xlink:href="#linearGradient941"/>
22
+ <clipPath id="clipPath5589-3">
23
+ <path d="m0.45113526 39.688017c-0.2498636 0-0.45113526 0.200756-0.45113526 0.45062v12.912612c0 0.249864 0.20127166 0.451136 0.45113526 0.451136h3.47420684c0.2498635 0 0.4511352-0.201272 0.4511352-0.451136v-12.912612c0-0.249864-0.2012717-0.45062-0.4511352-0.45062z" color="#000000"/>
24
+ </clipPath>
25
+ <filter id="filter5465-7" x="-.32505909" y="-.025913246" width="1.6501182" height="1.0518265" color-interpolation-filters="sRGB">
26
+ <feGaussianBlur stdDeviation="0.07139097"/>
27
+ </filter>
28
+ <clipPath id="clipPath5589-7-8">
29
+ <path d="m0.45113526 39.688017c-0.2498636 0-0.45113526 0.200756-0.45113526 0.45062v12.912612c0 0.249864 0.20127166 0.451136 0.45113526 0.451136h3.47420684c0.2498635 0 0.4511352-0.201272 0.4511352-0.451136v-12.912612c0-0.249864-0.2012717-0.45062-0.4511352-0.45062z" color="#000000"/>
30
+ </clipPath>
31
+ <filter id="filter5465-7-8" x="-.32505909" y="-.025913246" width="1.6501182" height="1.0518265" color-interpolation-filters="sRGB">
32
+ <feGaussianBlur stdDeviation="0.07139097"/>
33
+ </filter>
34
+ <linearGradient id="linearGradient5226" x1="340.65216" x2="349.57422" y1="361.12305" y2="361.12305" gradientUnits="userSpaceOnUse" xlink:href="#linearGradient5220"/>
35
+ <linearGradient id="linearGradient5220">
36
+ <stop stop-color="#404040" offset="0"/>
37
+ <stop stop-color="#838383" offset=".59999961"/>
38
+ <stop stop-color="#a7a7a7" offset="1"/>
39
+ </linearGradient>
40
+ <linearGradient id="linearGradient5226-6" x1="340.65216" x2="349.57422" y1="361.12305" y2="361.12305" gradientTransform="matrix(-.26458334 0 0 .26458334 95.47647 7.5449331e-7)" gradientUnits="userSpaceOnUse" xlink:href="#linearGradient5220"/>
41
+ <pattern id="pattern2874-7" width="1.171848" height="1.1718804" patternTransform="matrix(.26458334 0 0 .26458334 -76.445686 -5.7812643)" patternUnits="userSpaceOnUse">
42
+ <g transform="matrix(3.7795274,0,0,3.7795274,288.92857,-26.720982)">
43
+ <rect x="-76.445686" y="7.0699267" width=".15502979" height=".15502979" fill="#212121"/>
44
+ <rect x="-76.290665" y="7.224957" width=".15502979" height=".15502979" fill="#212121"/>
45
+ <rect x="-76.290665" y="7.0699267" width=".15502979" height=".15502979" fill="#060606"/>
46
+ <rect x="-76.445686" y="7.224957" width=".15502979" height=".15502979" fill="#060606"/>
47
+ </g>
48
+ </pattern>
49
+ <filter id="filter5343" x="-.19227619" y="-.082464501" width="1.3845524" height="1.164929" color-interpolation-filters="sRGB">
50
+ <feGaussianBlur stdDeviation="0.030983682"/>
51
+ </filter>
52
+ <filter id="filter5347" x="-.10877967" y="-.082313925" width="1.2175593" height="1.1646278" color-interpolation-filters="sRGB">
53
+ <feGaussianBlur stdDeviation="0.030983682"/>
54
+ </filter>
55
+ <linearGradient id="linearGradient5832" x1="8.3152552" x2="86.934753" y1="8.4801025" y2="8.4801025" gradientTransform="translate(.06681739 -2.6058787)" gradientUnits="userSpaceOnUse" xlink:href="#linearGradient5830"/>
56
+ <linearGradient id="linearGradient5830">
57
+ <stop stop-color="#4f4f4f" stop-opacity=".13084112" offset="0"/>
58
+ <stop stop-color="#4f4f4f" offset=".07"/>
59
+ <stop stop-color="#4f4f4f" stop-opacity=".95327103" offset=".93699998"/>
60
+ <stop stop-color="#4f4f4f" stop-opacity="0" offset="1"/>
61
+ </linearGradient>
62
+ <linearGradient id="linearGradient5832-3" x1="8.3152552" x2="86.934753" y1="8.4801025" y2="8.4801025" gradientTransform="matrix(.99999998 0 0 -.99999998 .0668174 193.56681)" gradientUnits="userSpaceOnUse" xlink:href="#linearGradient5830"/>
63
+ <linearGradient id="linearGradient2973" x1="-41.981419" x2="137.23141" y1="95.480484" y2="95.480484" gradientTransform="rotate(90,47.625001,95.48048)" gradientUnits="userSpaceOnUse">
64
+ <stop stop-color="#303030" offset="0"/>
65
+ <stop stop-color="#171717" offset="1"/>
66
+ </linearGradient>
67
+ <mask id="mask1062-8" maskUnits="userSpaceOnUse">
68
+ <path d="m42.746746 7.2620904c-0.399166 0-0.72037 0.3212029-0.72037 0.7203695 0 0.3991665 0.321204 0.7203694 0.72037 0.7203694h9.921876c0.399166 0 0.72037-0.3212029 0.72037-0.7203694 0-0.3991666-0.321204-0.7203695-0.72037-0.7203695z" color="#000000" fill="none" stroke="#fff" stroke-linecap="round" stroke-width=".05291667"/>
69
+ </mask>
70
+ <filter id="filter1214" x="-.09973029" y="-2.5665836" width="1.1994606" height="6.1331673" color-interpolation-filters="sRGB">
71
+ <feGaussianBlur stdDeviation="0.49125426"/>
72
+ </filter>
73
+ <filter id="filter960" x="-.31917548" y="-.0079953911" width="1.638351" height="1.0159908" color-interpolation-filters="sRGB">
74
+ <feGaussianBlur stdDeviation="0.070098782"/>
75
+ </filter>
76
+ </defs>
77
+ <g>
78
+ <path d="m94.798868 44.206385c0.249863 0 0.451135 0.200755 0.451135 0.45062v20.944605c0 0.249865-0.201272 0.451136-0.451135 0.451136h-3.474207c-0.249863 0-0.451135-0.201271-0.451135-0.451136v-20.944605c0-0.249865 0.201272-0.45062 0.451135-0.45062z" color="#000000" fill="#b4b4b4"/>
79
+ <path transform="matrix(-1,0,0,1,95.250003,4.5183682)" d="m0.547026 40.090161c-0.222752 0-0.402042 0.17929-0.402042 0.402042v20.237673c0 0.222752 0.17929 0.402042 0.402042 0.402042h0.125057v-21.041757z" clip-path="url(#clipPath5738-4)" color="#000000" fill="url(#linearGradient5697)" filter="url(#filter960)"/>
80
+ <g>
81
+ <rect x="-5.539465e-10" y="25.856596" width="4.3765388" height="7.4167299" ry=".45101735" color="#000000" fill="#b4b4b4"/>
82
+ <path transform="translate(13.877701)" d="m-13.330675 26.258962c-0.222752 0-0.402042 0.17929-0.402042 0.402042v5.807914c0 0.222752 0.17929 0.402042 0.402042 0.402042h0.125057v-6.611998z" clip-path="url(#clipPath5401-9)" color="#000000" fill="url(#linearGradient5475)" filter="url(#filter5465)"/>
83
+ <path d="m0.45113526 39.688017c-0.2498636 0-0.45113526 0.200755-0.45113526 0.45062v12.912611c0 0.249865 0.20127166 0.451136 0.45113526 0.451136h3.47420674c0.2498635 0 0.4511353-0.201271 0.4511353-0.451136v-12.912611c0-0.249865-0.2012718-0.45062-0.4511353-0.45062z" color="#000000" fill="#b4b4b4"/>
84
+ </g>
85
+ <g>
86
+ <g>
87
+ <path d="m0.547026 40.090161c-0.222752 0-0.402042 0.17929-0.402042 0.402042v12.205679c0 0.222752 0.17929 0.402042 0.402042 0.402042h0.125057v-13.009763z" clip-path="url(#clipPath5589-3)" color="#000000" fill="url(#linearGradient5475-9)" filter="url(#filter5465-7)"/>
88
+ <path d="m0.45113526 57.060536c-0.2498636 0-0.45113526 0.200755-0.45113526 0.45062v12.912611c0 0.249865 0.20127166 0.451136 0.45113526 0.451136h3.47420674c0.2498635 0 0.4511353-0.201271 0.4511353-0.451136v-12.912611c0-0.249865-0.2012718-0.45062-0.4511353-0.45062z" color="#000000" fill="#b4b4b4"/>
89
+ <path transform="translate(0,17.372519)" d="m0.547026 40.090161c-0.222752 0-0.402042 0.17929-0.402042 0.402042v12.205679c0 0.222752 0.17929 0.402042 0.402042 0.402042h0.125057v-13.009763z" clip-path="url(#clipPath5589-7-8)" color="#000000" fill="url(#linearGradient5475-9)" filter="url(#filter5465-7-8)"/>
90
+ <path d="m14.483352 0.19895344c-7.7365204 0-13.96452034 6.22800506-13.96452034 13.96452256v162.633474c0 7.73652 6.22799994 13.96504 13.96452034 13.96504h28.3006l0.57826-0.81958h2.052579 4.42041 2.052072l0.578778 0.81958h28.300591c7.73652 0 13.964529-6.22852 13.964529-13.96504v-162.633474c0-7.7365175-6.228009-13.96452256-13.964529-13.96452256z" color="#000000" fill="#c6c6c6"/>
91
+ </g>
92
+ <rect x="1.4644364" y="1.0885143" width="92.644859" height="188.66818" ry="13.323661" fill-opacity=".95794391"/>
93
+ <path transform="scale(.26458334)" d="m56.734375 2.265625c-29.67855 0-53.5722656 23.893695-53.5722656 53.572266v14.171875h4.1660156v-15.785157c0-26.582161 21.40025-47.9804684 47.982422-47.9804684h249.378903c26.58218 0 47.98243 21.3983074 47.98243 47.9804684v15.785157h4.16601v-14.171875c0-29.678571-23.89368-53.572266-53.57227-53.572266zm-53.5722656 72.667969v572.124996h4.1660156v-572.124996zm349.5097706 0v572.124996h4.16601v-572.124996zm-349.5097706 577.050786v14.29687c0 29.67855 23.8937156 53.57227 53.5722656 53.57227h246.531245c29.67859 0 53.57227-23.89372 53.57227-53.57227v-14.29687h-4.16601v16.47656c0 26.58217-21.40025 47.98242-47.98243 47.98242h-249.378903c-26.582172 0-47.982422-21.40025-47.982422-47.98242v-16.47656z" color="#000000" fill="#dedede"/>
94
+ <path transform="scale(.26458334)" d="m284.4707 2.265625v3.9785156h4.92383v-3.9785156zm-281.3085906 67.744141v4.923828h4.1660156v-4.923828zm349.5097706 0v4.923828h4.16601v-4.923828zm-349.5097706 577.048824v4.92579h4.1660156v-4.92579zm349.5097706 0v4.92579h4.16601v-4.92579zm-282.066411 69.38477v3.41016h4.923828v-3.41016z" color="#000000" fill="#c3c3c3"/>
95
+ </g>
96
+ <g transform="translate(-.11323174)">
97
+ <path transform="scale(.26458334)" d="m325.58008 15.363281c-1.17457-0.116312-0.62011 1.054663 2.01562 3.496094 8.69619 8.055118 14.09571 19.714101 14.09571 32.691406v309.572269 309.57226c0 12.97731-5.39952 24.63629-14.09571 32.69141-2.63573 2.44142-3.19019 3.6125-2.01562 3.49609 0.70474-0.0698 2.03219-0.60282 3.9043-1.60156 12.70949-6.7804 20.08984-21.40851 20.08984-36.94531v-307.21289-307.212894c0-15.536781-7.38036-30.164944-20.08984-36.945312-1.87211-0.998744-3.19956-1.531776-3.9043-1.601563z" color="#000000" fill="url(#linearGradient5226)"/>
98
+ <path d="m9.3334021 4.0648688c0.310772-0.03077 0.164071 0.279046-0.533299 0.925008-2.300868 2.13125-3.729491 5.2160232-3.729491 8.6496012v81.907666 81.907656c0 3.43358 1.428623 6.51836 3.729491 8.64961 0.69737 0.64596 0.844071 0.9558 0.533299 0.925-0.186462-0.0185-0.537684-0.15949-1.033013-0.42374-3.362719-1.79398-5.315437-5.66434-5.315437-9.77512v-81.283406-81.283414c0-4.110774 1.952721-7.9811422 5.315437-9.7751142 0.495329-0.264251 0.846551-0.405283 1.033013-0.423747z" color="#000000" fill="url(#linearGradient5226-6)"/>
99
+ </g>
100
+ <g>
101
+ <rect x="42.02623" y="7.2618642" width="11.36291" height="1.4410343" ry=".72051716" color="#000000" fill="url(#pattern3129)"/>
102
+ <circle cx="57.914883" cy="7.8897233" r="1.3446997" color="#000000" fill="#161616"/>
103
+ <circle cx="57.914883" cy="7.8897233" r="1.0774301" color="#000000" fill="#0a0d13"/>
104
+ <circle cx="57.914883" cy="7.8897233" r=".82091707" color="#000000" fill="#091427"/>
105
+ </g>
106
+ <path d="m57.349775 7.3405077c-0.09735 0.010246-0.235679 0.1588277-0.245928 0.4508659-0.01024 0.292038 0.117842 0.4508656 0.189571 0.4508656 0.07173 0 0.368889-0.4252483 0.05635-0.9017315z" fill="#235a91" fill-opacity=".55607473" fill-rule="evenodd" filter="url(#filter5343)"/>
107
+ <path d="m58.048878 7.2382402c-0.153553 0.0472469-0.183083 0.2952938-0.177178 0.4370348 0.0059 0.1417412 0.206706 0.5138116 0.448847 0.4311293 0.242142-0.0826823 0.283483-0.3897882 0.183084-0.5964939-0.100401-0.2067057-0.283483-0.3543525-0.454753-0.2716702z" fill="#235a91" fill-opacity=".55607473" fill-rule="evenodd" filter="url(#filter5347)"/>
108
+ <g>
109
+ <path d="m14.624068 2.5772674c-2.29857 0-4.436824 0.6574018-6.2419957 1.7921386v1.5048178c1.7203777-1.1284623 3.7800397-1.7854209 6.0001507-1.7854209h26.411827 13.795537 26.411825c2.220111 0 4.279774 0.6569586 6.000153 1.7854209v-1.5048178c-1.805173-1.1347368-3.943427-1.7921386-6.241998-1.7921386h-26.16998-13.795537z" color="#000000" fill="url(#linearGradient5832)"/>
110
+ <path d="m14.624068 188.38368c-2.29857 0-4.436824-0.65742-6.2419957-1.79215v-1.50481c1.7203777 1.12845 3.7800397 1.78541 6.0001507 1.78541h26.411827 13.795539 26.411823c2.220111 0 4.279774-0.65696 6.000153-1.78541v1.50481c-1.805173 1.13473-3.943427 1.79215-6.241998 1.79215h-26.169978-13.795539z" color="#000000" fill="url(#linearGradient5832-3)"/>
111
+ <path d="m14.758273 5.8740621c-4.70114 0-8.4857901 3.7846491-8.4857901 8.4857869v162.241251c0 4.70114 3.7846501 8.4858 8.4857901 8.4858h65.733457c4.701138 0 8.485788-3.78466 8.485788-8.4858v-162.241251c0-4.7011378-3.78465-8.4857869-8.485788-8.4857869h-8.300784a1.4175986 1.4175986 0 0 0-1.417487 1.4174841v0.6526739c0 2.5541609-2.055892 4.6105709-4.610052 4.6105709h-37.076805c-2.554169 0-4.610059-2.05641-4.610059-4.6105709v-0.6526739a1.4175986 1.4175986 0 0 0-1.41748-1.4174841z" color="#000000" fill="url(#linearGradient2973)"/>
112
+ </g>
113
+ <g fill="#fff" fill-opacity=".06542059">
114
+ <circle cx="29.091516" cy="7.8897238" r=".89769351"/>
115
+ <circle cx="65.011909" cy="7.8897238" r="1.6063988"/>
116
+ <circle cx="36.640068" cy="7.8897238" r="1.3229167"/>
117
+ </g>
118
+ <path d="m42.746746 7.2620885c-0.399166 0-0.72037 0.3212029-0.72037 0.7203695 0 0.3991665 0.321204 0.7203694 0.72037 0.7203694h9.921876c0.399166 0 0.72037-0.3212029 0.72037-0.7203694 0-0.3991666-0.321204-0.7203695-0.72037-0.7203695z" color="#000000" fill="none" stroke="#292929" stroke-linecap="round" stroke-width=".05291667"/>
119
+ <g fill="#fff" filter="url(#filter1214)" mask="url(#mask1062-8)">
120
+ <path d="m42.25606 7.9824588a0.22968481 0.22968481 0 0 1-0.229685 0.2296847 0.22968481 0.22968481 0 0 1-0.229685-0.2296847 0.22968481 0.22968481 0 0 1 0.229685-0.2296848 0.22968481 0.22968481 0 0 1 0.229685 0.2296848z"/>
121
+ <path d="m53.618677 7.9824588a0.22968481 0.22968481 0 0 1-0.229685 0.2296847 0.22968481 0.22968481 0 0 1-0.229685-0.2296847 0.22968481 0.22968481 0 0 1 0.229685-0.2296848 0.22968481 0.22968481 0 0 1 0.229685 0.2296848z"/>
122
+ </g>
123
+ </g>
124
+ </svg>