@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.
- package/LICENSE +67 -0
- package/README.md +208 -0
- package/app.config.ts +10 -0
- package/assets/icons/forest/bike-asterisk.svg +21 -0
- package/assets/icons/forest/bike-down.svg +19 -0
- package/assets/icons/forest/bike-off.svg +20 -0
- package/assets/icons/forest/bike-up.svg +19 -0
- package/assets/icons/forest/bike-x.svg +19 -0
- package/assets/icons/forest/bike.svg +18 -0
- package/assets/icons/forest/mark-fill.svg +6 -0
- package/assets/icons/forest/mark.svg +6 -0
- package/assets/icons/forest/parking.svg +3 -0
- package/assets/icons/forest/star-fill.svg +4 -0
- package/assets/icons/forest/star.svg +4 -0
- package/assets/icons/forest/traffic-light-caution.svg +8 -0
- package/assets/icons/forest/traffic-light-go.svg +8 -0
- package/assets/icons/forest/traffic-light-lit.svg +8 -0
- package/assets/icons/forest/traffic-light-stop.svg +8 -0
- package/assets/icons/forest/traffic-light.svg +8 -0
- package/nuxt.config.ts +81 -0
- package/package.json +35 -0
- package/packages/charts/src/FBoroughShape.vue +52 -0
- package/packages/charts/src/FCalendarGrid.vue +332 -0
- package/packages/charts/src/FCellLegend.vue +54 -0
- package/packages/charts/src/FChartFrame.vue +201 -0
- package/packages/charts/src/FChartFrameSingle.vue +91 -0
- package/packages/charts/src/FChartLegend.vue +26 -0
- package/packages/charts/src/FDistributionBar.vue +224 -0
- package/packages/charts/src/FDottedMap.vue +335 -0
- package/packages/charts/src/FLondonMap.vue +399 -0
- package/packages/charts/src/FSparkline.vue +198 -0
- package/packages/charts/src/FStatusTrack.vue +151 -0
- package/packages/charts/src/cellSize.ts +46 -0
- package/packages/charts/src/distribution.ts +99 -0
- package/packages/charts/src/engine.ts +15 -0
- package/packages/charts/src/forestTooltip.ts +110 -0
- package/packages/charts/src/fromCategories.ts +43 -0
- package/packages/charts/src/index.ts +87 -0
- package/packages/charts/src/londonAreas.ts +75 -0
- package/packages/charts/src/londonGrid.ts +67 -0
- package/packages/charts/src/motionDuration.ts +49 -0
- package/packages/charts/src/presets.ts +143 -0
- package/packages/charts/src/resolveVar.ts +21 -0
- package/packages/charts/src/snapTooltip.ts +154 -0
- package/packages/charts/src/tooltipStandIn.ts +58 -0
- package/packages/charts/src/useCellScale.ts +155 -0
- package/packages/charts/src/useChartPalette.ts +61 -0
- package/packages/charts/src/useChartRepaintKey.ts +22 -0
- package/packages/charts/src/useThemeVersion.ts +17 -0
- package/packages/frames/src/FBrowserFrame.vue +53 -0
- package/packages/frames/src/FDeviceFrame.vue +242 -0
- package/packages/frames/src/FFitFrame.vue +83 -0
- package/packages/frames/src/art/iphone-11.svg +124 -0
- package/packages/frames/src/art/iphone-12.svg +601 -0
- package/packages/frames/src/art/iphone-13-pro.svg +820 -0
- package/packages/frames/src/art/iphone-13.svg +849 -0
- package/packages/frames/src/art/iphone-14.svg +681 -0
- package/packages/frames/src/art/iphone-15-pro.svg +633 -0
- package/packages/frames/src/art/iphone-15.svg +713 -0
- package/packages/frames/src/art/iphone-16-plus.svg +685 -0
- package/packages/frames/src/art/iphone-16-pro-max.svg +611 -0
- package/packages/frames/src/art/iphone-16-pro.svg +631 -0
- package/packages/frames/src/art/iphone-16.svg +713 -0
- package/packages/frames/src/art/iphone-17-pro-max.svg +355 -0
- package/packages/frames/src/art/iphone-17.svg +305 -0
- package/packages/frames/src/art/iphone-air.svg +389 -0
- package/packages/frames/src/art/iphone-se-3.svg +473 -0
- package/packages/frames/src/art/iphone-x.svg +652 -0
- package/packages/frames/src/art/pixel-10-pro-xl.svg +32 -0
- package/packages/frames/src/art/pixel-10-pro.svg +32 -0
- package/packages/frames/src/art/pixel-10.svg +32 -0
- package/packages/frames/src/art/pixel-8-pro.svg +208 -0
- package/packages/frames/src/art/pixel-8.svg +208 -0
- package/packages/frames/src/art/pixel-8a.svg +8 -0
- package/packages/frames/src/art/pixel-9-pro-xl.svg +32 -0
- package/packages/frames/src/art/pixel-9-pro.svg +34 -0
- package/packages/frames/src/art/pixel-9a.svg +25 -0
- package/packages/frames/src/art.ts +29 -0
- package/packages/frames/src/devices.ts +337 -0
- package/packages/frames/src/index.ts +15 -0
- package/packages/tokens/geo/london-boroughs.json +1 -0
- package/packages/tokens/geo/london-dots-coarse.json +1 -0
- package/packages/tokens/geo/london-dots-fine.json +1 -0
- package/packages/tokens/geo/london-dots-medium.json +1 -0
- package/packages/tokens/geo/london-dots-ultra.json +1 -0
- package/packages/tokens/geo/london-wards.json +1 -0
- package/packages/tokens/glyphs/glyphs.json +1 -0
- package/packages/tokens/logos/f-rot.svg +4 -0
- package/packages/tokens/logos/f.svg +4 -0
- package/packages/tokens/logos/favicon-c2w.svg +9 -0
- package/packages/tokens/logos/favicon-console.svg +9 -0
- package/packages/tokens/logos/favicon-ds.svg +9 -0
- package/packages/tokens/logos/favicon-fleet.svg +9 -0
- package/packages/tokens/logos/favicon-flex.svg +9 -0
- package/packages/tokens/logos/favicon-plus.svg +9 -0
- package/packages/tokens/logos/favicon-radar.svg +9 -0
- package/packages/tokens/logos/favicon-tower.svg +9 -0
- package/packages/tokens/logos/lockup-horizontal.svg +17 -0
- package/packages/tokens/logos/lockup-vertical.svg +17 -0
- package/packages/tokens/logos/mark-mono.svg +4 -0
- package/packages/tokens/logos/mark.svg +7 -0
- package/packages/tokens/logos/wordmark.svg +9 -0
- package/packages/tokens/src/colourEngine.ts +604 -0
- package/packages/tokens/src/glyphs.ts +127 -0
- package/packages/tokens/src/logo.ts +50 -0
- package/packages/tokens/src/subbrands.ts +44 -0
- package/packages/tokens/src/sublogo.ts +80 -0
- package/packages/ui/icons/forest/map/forest-place-count.svg +21 -0
- package/packages/ui/icons/forest/map/forest-place-marker.svg +17 -0
- package/packages/ui/icons/forest/map/forest-vehicle-marker.svg +73 -0
- package/packages/ui/icons/forest/map/manifest.json +373 -0
- package/packages/ui/icons/forest/map/place-count-manifest.json +83 -0
- package/packages/ui/icons/forest/map/place-manifest.json +74 -0
- package/packages/ui/src/address/address.ts +125 -0
- package/packages/ui/src/address/autofill.ts +78 -0
- package/packages/ui/src/address/geocode.ts +55 -0
- package/packages/ui/src/address/index.ts +31 -0
- package/packages/ui/src/address/validate.ts +91 -0
- package/packages/ui/src/canvas/FCanvasCard.vue +97 -0
- package/packages/ui/src/canvas/FCanvasControl.vue +121 -0
- package/packages/ui/src/canvas/FCanvasControlGroup.vue +100 -0
- package/packages/ui/src/canvas/FCanvasFlyout.vue +216 -0
- package/packages/ui/src/canvas/FCanvasHud.vue +402 -0
- package/packages/ui/src/canvas/canvasSurface.ts +39 -0
- package/packages/ui/src/canvas/index.ts +17 -0
- package/packages/ui/src/contexts/ForestContext.vue +53 -0
- package/packages/ui/src/contexts/contexts.ts +153 -0
- package/packages/ui/src/contexts/index.ts +23 -0
- package/packages/ui/src/contexts/marketing-theme.ts +214 -0
- package/packages/ui/src/contexts/mobile-theme.ts +402 -0
- package/packages/ui/src/filter/FFilterBar.vue +72 -0
- package/packages/ui/src/filter/FFilterChip.vue +362 -0
- package/packages/ui/src/filter/FFilterClauses.vue +104 -0
- package/packages/ui/src/filter/FFilterTrigger.vue +183 -0
- package/packages/ui/src/filter/filter.theme.ts +27 -0
- package/packages/ui/src/filter/filterClause.ts +265 -0
- package/packages/ui/src/filter/filterPredicate.ts +134 -0
- package/packages/ui/src/filter/filterSchema.ts +82 -0
- package/packages/ui/src/filter/index.ts +36 -0
- package/packages/ui/src/forest-preset.ts +68 -0
- package/packages/ui/src/icons.ts +79 -0
- package/packages/ui/src/index.ts +17 -0
- package/packages/ui/src/kpi/FKpi.vue +253 -0
- package/packages/ui/src/kpi/FKpiGroup.vue +161 -0
- package/packages/ui/src/kpi/index.ts +20 -0
- package/packages/ui/src/kpi/kpi.theme.ts +168 -0
- package/packages/ui/src/kpi/kpiDelta.ts +82 -0
- package/packages/ui/src/kpi/kpiFormat.ts +72 -0
- package/packages/ui/src/kpi/kpiGroupState.ts +63 -0
- package/packages/ui/src/logo/FLogo.vue +52 -0
- package/packages/ui/src/logo/FSubLogo.vue +214 -0
- package/packages/ui/src/logo/index.ts +8 -0
- package/packages/ui/src/map/FMap.vue +355 -0
- package/packages/ui/src/map/FMapCompass.vue +154 -0
- package/packages/ui/src/map/FMapCoordinates.vue +134 -0
- package/packages/ui/src/map/FMapDrawTools.vue +116 -0
- package/packages/ui/src/map/FMapFullscreen.vue +92 -0
- package/packages/ui/src/map/FMapGeolocate.vue +208 -0
- package/packages/ui/src/map/FMapLayers.vue +75 -0
- package/packages/ui/src/map/FMapLegend.vue +45 -0
- package/packages/ui/src/map/FMapPopup.vue +129 -0
- package/packages/ui/src/map/FMapRamp.vue +69 -0
- package/packages/ui/src/map/FMapRegion.vue +77 -0
- package/packages/ui/src/map/FMapScale.vue +107 -0
- package/packages/ui/src/map/FMapSearch.vue +362 -0
- package/packages/ui/src/map/FMapTilt.vue +87 -0
- package/packages/ui/src/map/FMapTools.vue +61 -0
- package/packages/ui/src/map/FMapZoom.vue +81 -0
- package/packages/ui/src/map/FMarker.vue +218 -0
- package/packages/ui/src/map/FTrackScrubber.vue +94 -0
- package/packages/ui/src/map/FVehicleLegend.vue +72 -0
- package/packages/ui/src/map/FVehicleMark.vue +166 -0
- package/packages/ui/src/map/bin.ts +196 -0
- package/packages/ui/src/map/coordinates.ts +83 -0
- package/packages/ui/src/map/density.ts +401 -0
- package/packages/ui/src/map/draw.ts +356 -0
- package/packages/ui/src/map/ease.ts +67 -0
- package/packages/ui/src/map/fullscreen.ts +59 -0
- package/packages/ui/src/map/heat.ts +160 -0
- package/packages/ui/src/map/index.ts +351 -0
- package/packages/ui/src/map/liveMarkers.ts +60 -0
- package/packages/ui/src/map/map.css +116 -0
- package/packages/ui/src/map/mapColor.ts +31 -0
- package/packages/ui/src/map/mapContext.ts +12 -0
- package/packages/ui/src/map/mapLayers.ts +55 -0
- package/packages/ui/src/map/mapLegend.ts +70 -0
- package/packages/ui/src/map/mapSearch.theme.ts +79 -0
- package/packages/ui/src/map/mapSearch.ts +279 -0
- package/packages/ui/src/map/mapUrl.ts +117 -0
- package/packages/ui/src/map/markerAsset.ts +3 -0
- package/packages/ui/src/map/padding.ts +39 -0
- package/packages/ui/src/map/place.ts +192 -0
- package/packages/ui/src/map/ramp.ts +95 -0
- package/packages/ui/src/map/regionSelect.ts +163 -0
- package/packages/ui/src/map/route.ts +167 -0
- package/packages/ui/src/map/scale.ts +329 -0
- package/packages/ui/src/map/scaleBar.ts +67 -0
- package/packages/ui/src/map/selectionModel.ts +50 -0
- package/packages/ui/src/map/sources/coordinates.ts +58 -0
- package/packages/ui/src/map/sources/mapboxPlaces.ts +71 -0
- package/packages/ui/src/map/style.ts +14 -0
- package/packages/ui/src/map/track.ts +279 -0
- package/packages/ui/src/map/useFeatureSelect.ts +230 -0
- package/packages/ui/src/map/useLiveMarkers.ts +84 -0
- package/packages/ui/src/map/useMap.ts +150 -0
- package/packages/ui/src/map/useMapDraw.ts +776 -0
- package/packages/ui/src/map/useMapOverlay.ts +421 -0
- package/packages/ui/src/map/useMapTableSelection.ts +101 -0
- package/packages/ui/src/map/useMapUrlState.ts +155 -0
- package/packages/ui/src/map/useMarkerSource.ts +163 -0
- package/packages/ui/src/map/useRegionSelect.ts +309 -0
- package/packages/ui/src/map/useTokenMode.ts +61 -0
- package/packages/ui/src/map/useTrackPlayback.ts +131 -0
- package/packages/ui/src/map/useVehicleFilter.ts +51 -0
- package/packages/ui/src/map/useVisibleFeatures.ts +124 -0
- package/packages/ui/src/map/vehicle.ts +807 -0
- package/packages/ui/src/map/vehicleFilter.ts +284 -0
- package/packages/ui/src/map/vehicleLegend.ts +155 -0
- package/packages/ui/src/map/visibleFeatures.ts +192 -0
- package/packages/ui/src/map/zone.ts +291 -0
- package/packages/ui/src/shell/FAppNavbar.vue +68 -0
- package/packages/ui/src/shell/FAppShell.vue +163 -0
- package/packages/ui/src/shell/FAppSidebar.vue +215 -0
- package/packages/ui/src/shell/FPageToolbar.vue +126 -0
- package/packages/ui/src/shell/FScrollShadow.vue +43 -0
- package/packages/ui/src/shell/FSidePanel.vue +258 -0
- package/packages/ui/src/shell/FToolbarActions.vue +32 -0
- package/packages/ui/src/shell/index.ts +33 -0
- package/packages/ui/src/shell/nav.ts +37 -0
- package/packages/ui/src/shell/pageToolbar.ts +69 -0
- package/packages/ui/src/shell/shell.ts +74 -0
- package/packages/ui/src/shell/sidePanel.ts +115 -0
- package/packages/ui/src/themes/accordion.ts +36 -0
- package/packages/ui/src/themes/alert.ts +16 -0
- package/packages/ui/src/themes/auth-form.ts +19 -0
- package/packages/ui/src/themes/avatar-group.ts +9 -0
- package/packages/ui/src/themes/avatar.ts +18 -0
- package/packages/ui/src/themes/badge.ts +51 -0
- package/packages/ui/src/themes/banner.ts +21 -0
- package/packages/ui/src/themes/breadcrumb.ts +10 -0
- package/packages/ui/src/themes/button.mechanics.ts +191 -0
- package/packages/ui/src/themes/button.ts +136 -0
- package/packages/ui/src/themes/calendar.ts +29 -0
- package/packages/ui/src/themes/card.ts +67 -0
- package/packages/ui/src/themes/carousel.ts +41 -0
- package/packages/ui/src/themes/checkbox-group.ts +20 -0
- package/packages/ui/src/themes/checkbox.ts +47 -0
- package/packages/ui/src/themes/chip.ts +50 -0
- package/packages/ui/src/themes/collapsible.ts +26 -0
- package/packages/ui/src/themes/color-picker.ts +13 -0
- package/packages/ui/src/themes/command-palette.ts +37 -0
- package/packages/ui/src/themes/container.ts +14 -0
- package/packages/ui/src/themes/context-menu.ts +38 -0
- package/packages/ui/src/themes/dashboard-group.ts +11 -0
- package/packages/ui/src/themes/dashboard-navbar.ts +10 -0
- package/packages/ui/src/themes/dashboard-panel.ts +18 -0
- package/packages/ui/src/themes/dashboard-resize-handle.ts +35 -0
- package/packages/ui/src/themes/dashboard-sidebar-collapse.ts +7 -0
- package/packages/ui/src/themes/dashboard-sidebar-toggle.ts +7 -0
- package/packages/ui/src/themes/dashboard-sidebar.ts +58 -0
- package/packages/ui/src/themes/dashboard-toolbar.ts +15 -0
- package/packages/ui/src/themes/drawer.ts +45 -0
- package/packages/ui/src/themes/dropdown-menu.ts +38 -0
- package/packages/ui/src/themes/empty.ts +43 -0
- package/packages/ui/src/themes/field-group.ts +52 -0
- package/packages/ui/src/themes/file-upload.ts +38 -0
- package/packages/ui/src/themes/footer-columns.ts +13 -0
- package/packages/ui/src/themes/footer.ts +9 -0
- package/packages/ui/src/themes/form-field.ts +25 -0
- package/packages/ui/src/themes/form.ts +22 -0
- package/packages/ui/src/themes/header.ts +12 -0
- package/packages/ui/src/themes/icon.ts +61 -0
- package/packages/ui/src/themes/index.ts +99 -0
- package/packages/ui/src/themes/input-date.ts +73 -0
- package/packages/ui/src/themes/input-menu.ts +66 -0
- package/packages/ui/src/themes/input-number.ts +58 -0
- package/packages/ui/src/themes/input-rating.ts +25 -0
- package/packages/ui/src/themes/input-tags.ts +63 -0
- package/packages/ui/src/themes/input-time.ts +70 -0
- package/packages/ui/src/themes/input.ts +148 -0
- package/packages/ui/src/themes/kbd.ts +27 -0
- package/packages/ui/src/themes/link.ts +27 -0
- package/packages/ui/src/themes/listbox.ts +48 -0
- package/packages/ui/src/themes/main.ts +6 -0
- package/packages/ui/src/themes/marquee.ts +45 -0
- package/packages/ui/src/themes/modal.ts +46 -0
- package/packages/ui/src/themes/navigation-menu.ts +80 -0
- package/packages/ui/src/themes/page-anchors.ts +7 -0
- package/packages/ui/src/themes/page-aside.ts +7 -0
- package/packages/ui/src/themes/page-card.ts +22 -0
- package/packages/ui/src/themes/page-cta.ts +18 -0
- package/packages/ui/src/themes/page-feature.ts +13 -0
- package/packages/ui/src/themes/page-header.ts +16 -0
- package/packages/ui/src/themes/page-hero.ts +15 -0
- package/packages/ui/src/themes/page-links.ts +12 -0
- package/packages/ui/src/themes/page-section.ts +14 -0
- package/packages/ui/src/themes/page.ts +7 -0
- package/packages/ui/src/themes/pagination.ts +17 -0
- package/packages/ui/src/themes/pin-input.ts +73 -0
- package/packages/ui/src/themes/popover.ts +30 -0
- package/packages/ui/src/themes/progress.ts +29 -0
- package/packages/ui/src/themes/prose.ts +45 -0
- package/packages/ui/src/themes/radio-group.ts +39 -0
- package/packages/ui/src/themes/scroll-area.ts +23 -0
- package/packages/ui/src/themes/select-menu.ts +71 -0
- package/packages/ui/src/themes/select.ts +72 -0
- package/packages/ui/src/themes/separator.ts +28 -0
- package/packages/ui/src/themes/sidebar.ts +127 -0
- package/packages/ui/src/themes/skeleton.ts +15 -0
- package/packages/ui/src/themes/slideover.ts +87 -0
- package/packages/ui/src/themes/slider.ts +48 -0
- package/packages/ui/src/themes/stepper.ts +41 -0
- package/packages/ui/src/themes/switch.ts +63 -0
- package/packages/ui/src/themes/table.ts +34 -0
- package/packages/ui/src/themes/tabs.ts +74 -0
- package/packages/ui/src/themes/textarea.ts +77 -0
- package/packages/ui/src/themes/timeline.ts +25 -0
- package/packages/ui/src/themes/toast.ts +29 -0
- package/packages/ui/src/themes/toaster.ts +66 -0
- package/packages/ui/src/themes/tooltip.ts +31 -0
- package/packages/ui/src/themes/tree.ts +41 -0
- package/packages/ui/src/themes/user.ts +28 -0
- package/styles.cdn.gen.css +2047 -0
- 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>
|