@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,604 @@
|
|
|
1
|
+
// colourEngine.ts — framework-free colour math: the canonical Forest colour-generation method.
|
|
2
|
+
// All functions exported; consumed by the Forest colour documentation page.
|
|
3
|
+
|
|
4
|
+
// ---- OKLCH ↔ sRGB (compact port of scales.ts) ----
|
|
5
|
+
export const srgbToLin = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
|
|
6
|
+
export const linToSrgb = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
|
|
7
|
+
export function hexToLin(hex: string): number[] {
|
|
8
|
+
const c = hex.replace('#', '');
|
|
9
|
+
return [0, 2, 4].map((i) => srgbToLin(parseInt(c.slice(i, i + 2), 16) / 255));
|
|
10
|
+
}
|
|
11
|
+
export function linToOklab([r, g, b]: number[]): number[] {
|
|
12
|
+
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
|
13
|
+
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
|
14
|
+
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
|
15
|
+
return [
|
|
16
|
+
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
|
17
|
+
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
|
18
|
+
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
export function oklabToLin([L, a, b]: number[]): number[] {
|
|
22
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
23
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
24
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
25
|
+
return [
|
|
26
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
27
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
28
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
29
|
+
];
|
|
30
|
+
}
|
|
31
|
+
export const inGamut = (rgb: number[]) => rgb.every((v) => v >= -0.001 && v <= 1.001);
|
|
32
|
+
export function oklchToHex(L: number, C: number, H: number): string {
|
|
33
|
+
const rad = (H * Math.PI) / 180;
|
|
34
|
+
let lin = oklabToLin([L, C * Math.cos(rad), C * Math.sin(rad)]);
|
|
35
|
+
if (!inGamut(lin)) {
|
|
36
|
+
let lo = 0, hi = C;
|
|
37
|
+
for (let i = 0; i < 24; i++) {
|
|
38
|
+
const c = (lo + hi) / 2;
|
|
39
|
+
inGamut(oklabToLin([L, c * Math.cos(rad), c * Math.sin(rad)])) ? (lo = c) : (hi = c);
|
|
40
|
+
}
|
|
41
|
+
lin = oklabToLin([L, lo * Math.cos(rad), lo * Math.sin(rad)]);
|
|
42
|
+
}
|
|
43
|
+
const to = (v: number) => Math.round(Math.max(0, Math.min(1, linToSrgb(v))) * 255).toString(16).padStart(2, '0');
|
|
44
|
+
return '#' + to(lin[0]) + to(lin[1]) + to(lin[2]);
|
|
45
|
+
}
|
|
46
|
+
export function hexToOklch(hex: string) {
|
|
47
|
+
const [L, a, b] = linToOklab(hexToLin(hex));
|
|
48
|
+
return { L, C: Math.hypot(a, b), H: ((Math.atan2(b, a) * 180) / Math.PI + 360) % 360 };
|
|
49
|
+
}
|
|
50
|
+
// WCAG relative luminance (0–100) from a hex.
|
|
51
|
+
export const lumF = (hex: string) => (([r, g, b]) => 0.2126 * r + 0.7152 * g + 0.0722 * b)(hexToLin(hex)) * 100;
|
|
52
|
+
export const lum = (hex: string) => Math.round(lumF(hex));
|
|
53
|
+
export const ink = (hex: string) => (lum(hex) < 42 ? '#fff' : '#1c1c1a');
|
|
54
|
+
export const fmtOklch = (L: number, C: number, H: number) => `oklch(${(L * 100).toFixed(1)}% ${C.toFixed(3)} ${(((H % 360) + 360) % 360).toFixed(1)})`;
|
|
55
|
+
export const info = (hex: string) => { const o = hexToOklch(hex); return { hex: hex.toUpperCase(), oklch: fmtOklch(o.L, o.C, o.H), L: Math.round(o.L * 100), C: o.C.toFixed(3), H: Math.round(((o.H % 360) + 360) % 360), lum: lum(hex) }; };
|
|
56
|
+
|
|
57
|
+
// ---- WCAG contrast ----
|
|
58
|
+
export const contrast = (h1: string, h2: string) => { const a = lumF(h1) / 100, b = lumF(h2) / 100; const hi = Math.max(a, b), lo = Math.min(a, b); return (hi + 0.05) / (lo + 0.05); };
|
|
59
|
+
|
|
60
|
+
// ---- APCA (Accessible Perceptual Contrast Algorithm, SA98G / apca-w3 verbatim) ----
|
|
61
|
+
// The contrast method explored for WCAG 3. Polarity-aware: distinguishes dark-on-light
|
|
62
|
+
// from light-on-dark and returns a SIGNED lightness contrast Lc (≈ −108…+106).
|
|
63
|
+
// NOTE: this uses its OWN screen luminance Y — a simple ^2.4 transfer with the SA98G
|
|
64
|
+
// coefficients — NOT lumF (which is WCAG's piecewise linearisation with different weights).
|
|
65
|
+
// Lc is a perceptual value, not a ratio; never convert it to an X:1 figure.
|
|
66
|
+
const APCA = {
|
|
67
|
+
mainTRC: 2.4, sRco: 0.2126729, sGco: 0.7151522, sBco: 0.072175,
|
|
68
|
+
normBG: 0.56, normTXT: 0.57, revTXT: 0.62, revBG: 0.65,
|
|
69
|
+
blkThrs: 0.022, blkClmp: 1.414, scale: 1.14, loOffset: 0.027, loClip: 0.1, deltaYmin: 0.0005,
|
|
70
|
+
};
|
|
71
|
+
// sRGB hex → APCA screen luminance Y (simple ^2.4 gamma; distinct from WCAG lumF).
|
|
72
|
+
export function apcaY(hex: string): number {
|
|
73
|
+
const c = hex.replace('#', '');
|
|
74
|
+
const r = parseInt(c.slice(0, 2), 16) / 255, g = parseInt(c.slice(2, 4), 16) / 255, b = parseInt(c.slice(4, 6), 16) / 255;
|
|
75
|
+
return APCA.sRco * r ** APCA.mainTRC + APCA.sGco * g ** APCA.mainTRC + APCA.sBco * b ** APCA.mainTRC;
|
|
76
|
+
}
|
|
77
|
+
// Signed APCA Lc for text colour on background colour.
|
|
78
|
+
// Validated anchors: apcaLc('#000','#fff') ≈ 106.04, apcaLc('#fff','#000') ≈ −107.88, apcaLc('#888','#fff') ≈ 63.06.
|
|
79
|
+
export function apcaLc(textHex: string, bgHex: string): number {
|
|
80
|
+
let txtY = apcaY(textHex), bgY = apcaY(bgHex);
|
|
81
|
+
if (isNaN(txtY) || isNaN(bgY) || Math.min(txtY, bgY) < 0 || Math.max(txtY, bgY) > 1.1) return 0;
|
|
82
|
+
txtY = txtY > APCA.blkThrs ? txtY : txtY + (APCA.blkThrs - txtY) ** APCA.blkClmp;
|
|
83
|
+
bgY = bgY > APCA.blkThrs ? bgY : bgY + (APCA.blkThrs - bgY) ** APCA.blkClmp;
|
|
84
|
+
if (Math.abs(bgY - txtY) < APCA.deltaYmin) return 0;
|
|
85
|
+
let SAPC = 0, out = 0;
|
|
86
|
+
if (bgY > txtY) { // BoW (dark text on light background) → positive
|
|
87
|
+
SAPC = (bgY ** APCA.normBG - txtY ** APCA.normTXT) * APCA.scale;
|
|
88
|
+
out = SAPC < APCA.loClip ? 0 : SAPC - APCA.loOffset;
|
|
89
|
+
} else { // WoB (light text on dark background) → negative
|
|
90
|
+
SAPC = (bgY ** APCA.revBG - txtY ** APCA.revTXT) * APCA.scale;
|
|
91
|
+
out = SAPC > -APCA.loClip ? 0 : SAPC + APCA.loOffset;
|
|
92
|
+
}
|
|
93
|
+
return out * 100;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---- Perceived lightness (Helmholtz–Kohlrausch via the Fairchild–Pirrotta model) ----
|
|
97
|
+
// Equal OKLab L is NOT equal perceived lightness: saturated blue/red read heavier than
|
|
98
|
+
// their L implies, while warm yellow-greens carry almost none of that extra weight.
|
|
99
|
+
// L** = L* + (2.5 − 0.025·L*)·(0.116·|sin((h−90)/2)| + 0.085)·C* (CIELAB L*, C*, h).
|
|
100
|
+
// AESTHETIC tonal ordering only — judging which swatch reads lighter, building visually
|
|
101
|
+
// even multi-hue rows. NEVER feed this into a contrast check; those stay luminance-based.
|
|
102
|
+
const D65 = { Xn: 0.95047, Yn: 1.0, Zn: 1.08883 };
|
|
103
|
+
// sRGB hex → CIELAB (D65). Returns L* (0–100), a*, b*, chroma C*, hue h (degrees).
|
|
104
|
+
export function hexToLab(hex: string) {
|
|
105
|
+
const [r, g, b] = hexToLin(hex); // already linearised sRGB
|
|
106
|
+
const X = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b;
|
|
107
|
+
const Y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
|
|
108
|
+
const Z = 0.0193339 * r + 0.119192 * g + 0.9503041 * b;
|
|
109
|
+
const f = (t: number) => (t > (6 / 29) ** 3 ? Math.cbrt(t) : (1 / 3) * (29 / 6) ** 2 * t + 4 / 29);
|
|
110
|
+
const fx = f(X / D65.Xn), fy = f(Y / D65.Yn), fz = f(Z / D65.Zn);
|
|
111
|
+
const L = 116 * fy - 16, a = 500 * (fx - fy), bb = 200 * (fy - fz);
|
|
112
|
+
let h = (Math.atan2(bb, a) * 180) / Math.PI; if (h < 0) h += 360;
|
|
113
|
+
return { L, a, b: bb, C: Math.hypot(a, bb), h };
|
|
114
|
+
}
|
|
115
|
+
// Fairchild–Pirrotta perceived lightness in CIELAB L* units (0–100-ish; can exceed 100 for vivid hues).
|
|
116
|
+
export function fairchildPirrotta(hex: string) {
|
|
117
|
+
const { L, C, h } = hexToLab(hex);
|
|
118
|
+
const f2 = 2.5 - 0.025 * L;
|
|
119
|
+
const f1 = 0.116 * Math.abs(Math.sin((((h - 90) * Math.PI) / 180) / 2)) + 0.085;
|
|
120
|
+
const deltaL = f2 * f1 * C;
|
|
121
|
+
return { Lstar: L, C, h, deltaL, Lperceived: L + deltaL };
|
|
122
|
+
}
|
|
123
|
+
// Convenience: just the perceived lightness value.
|
|
124
|
+
export const perceivedL = (hex: string) => fairchildPirrotta(hex).Lperceived;
|
|
125
|
+
|
|
126
|
+
// ---- maxChromaAt ----
|
|
127
|
+
export function maxChromaAt(L: number, H: number) {
|
|
128
|
+
const rad = (H * Math.PI) / 180, co = Math.cos(rad), si = Math.sin(rad);
|
|
129
|
+
let lo = 0, hi = 0.4;
|
|
130
|
+
for (let i = 0; i < 24; i++) { const c = (lo + hi) / 2; inGamut(oklabToLin([L, c * co, c * si])) ? (lo = c) : (hi = c); }
|
|
131
|
+
return lo;
|
|
132
|
+
}
|
|
133
|
+
export const hueOf = (hex: string) => hexToOklch(hex).H;
|
|
134
|
+
|
|
135
|
+
// ---- Display-P3 gamut (wider than sRGB; both D65, so a plain linear matrix maps sRGB→P3) ----
|
|
136
|
+
// Lets ramps reach chroma sRGB can't show, emitted as a color(display-p3 …) primary with a hex fallback.
|
|
137
|
+
const sRGBlinToP3lin = ([r, g, b]: number[]): number[] => [
|
|
138
|
+
0.8224621 * r + 0.177538 * g,
|
|
139
|
+
0.0331941 * r + 0.9668058 * g,
|
|
140
|
+
0.0170827 * r + 0.0723974 * g + 0.9105199 * b,
|
|
141
|
+
];
|
|
142
|
+
export const inGamutP3 = (linsrgb: number[]) => sRGBlinToP3lin(linsrgb).every((v) => v >= -0.001 && v <= 1.001);
|
|
143
|
+
export function maxChromaP3(L: number, H: number) {
|
|
144
|
+
const rad = (H * Math.PI) / 180, co = Math.cos(rad), si = Math.sin(rad);
|
|
145
|
+
let lo = 0, hi = 0.4;
|
|
146
|
+
for (let i = 0; i < 24; i++) { const c = (lo + hi) / 2; inGamutP3(oklabToLin([L, c * co, c * si])) ? (lo = c) : (hi = c); }
|
|
147
|
+
return lo;
|
|
148
|
+
}
|
|
149
|
+
// OKLCH → Display-P3 string, chroma clamped to the P3 cusp. Display-P3 uses the sRGB transfer curve.
|
|
150
|
+
export function oklchToP3(L: number, C: number, H: number): string {
|
|
151
|
+
const rad = (H * Math.PI) / 180, co = Math.cos(rad), si = Math.sin(rad);
|
|
152
|
+
let c = C;
|
|
153
|
+
if (!inGamutP3(oklabToLin([L, c * co, c * si]))) {
|
|
154
|
+
let lo = 0, hi = C;
|
|
155
|
+
for (let i = 0; i < 24; i++) { const m = (lo + hi) / 2; inGamutP3(oklabToLin([L, m * co, m * si])) ? (lo = m) : (hi = m); }
|
|
156
|
+
c = lo;
|
|
157
|
+
}
|
|
158
|
+
const enc = (v: number) => Math.max(0, Math.min(1, linToSrgb(v)));
|
|
159
|
+
const [r, g, b] = sRGBlinToP3lin(oklabToLin([L, c * co, c * si])).map(enc);
|
|
160
|
+
return `color(display-p3 ${r.toFixed(4)} ${g.toFixed(4)} ${b.toFixed(4)})`;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ---- Okhsl chroma model (Ottosson): smooth 3-anchor (C0 / Cmid / Cmax) rational interpolation ----
|
|
164
|
+
export function okhslCuspPoint(H: number) { let bL = 0.35, bc = 0; for (let L = 0.35; L <= 0.99; L += 0.006) { const c = maxChromaAt(L, H); if (c > bc) { bc = c; bL = L; } } return { L: bL, C: bc }; }
|
|
165
|
+
export function getSTmid(a: number, b: number) {
|
|
166
|
+
const S = 0.11516993 + 1 / (7.4477897 + 4.1590124 * b + a * (-2.19557347 + 1.75198401 * b + a * (-2.13704948 - 10.02301043 * b + a * (-4.24894561 + 5.38770819 * b + 4.69891013 * a))));
|
|
167
|
+
const T = 0.11239642 + 1 / (1.6132032 - 0.68124379 * b + a * (0.40370612 + 0.90148123 * b + a * (-0.27087943 + 0.6122399 * b + a * (0.00299215 - 0.45399568 * b - 0.14661872 * a))));
|
|
168
|
+
return [S, T];
|
|
169
|
+
}
|
|
170
|
+
export function okhslChroma(L: number, H: number, s: number) {
|
|
171
|
+
const rad = (H * Math.PI) / 180, a = Math.cos(rad), b = Math.sin(rad);
|
|
172
|
+
const cusp = okhslCuspPoint(H);
|
|
173
|
+
const Smax = cusp.C / cusp.L, Tmax = cusp.C / (1 - cusp.L);
|
|
174
|
+
const Cmax = maxChromaAt(L, H);
|
|
175
|
+
const k = Cmax / Math.min(L * Smax, (1 - L) * Tmax);
|
|
176
|
+
const [Smid, Tmid] = getSTmid(a, b);
|
|
177
|
+
const Ca = L * Smid, Cb = (1 - L) * Tmid;
|
|
178
|
+
const Cmid = 0.9 * k * Math.sqrt(Math.sqrt(1 / (1 / Ca ** 4 + 1 / Cb ** 4)));
|
|
179
|
+
const Ca0 = L * 0.4, Cb0 = (1 - L) * 0.8;
|
|
180
|
+
const C0 = Math.sqrt(1 / (1 / Ca0 ** 2 + 1 / Cb0 ** 2));
|
|
181
|
+
const mid = 0.8, midInv = 1.25;
|
|
182
|
+
let C: number;
|
|
183
|
+
if (s < mid) { const t = midInv * s, k1 = mid * C0, k2 = 1 - k1 / Cmid; C = (t * k1) / (1 - k2 * t); }
|
|
184
|
+
else { const t = (s - mid) / (1 - mid), k0 = Cmid, k1 = ((1 - mid) * Cmid * Cmid * midInv * midInv) / C0, k2 = 1 - k1 / (Cmax - Cmid); C = k0 + (t * k1) / (1 - k2 * t); }
|
|
185
|
+
return Math.min(C, Cmax);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ---- cuspOf ----
|
|
189
|
+
export const cuspOf = (H: number) => { let bL = 0.35, bc = 0; for (let L = 0.35; L <= 0.99; L += 0.006) { const c = maxChromaAt(L, H); if (c > bc) { bc = c; bL = L; } } return { L: bL, C: bc }; };
|
|
190
|
+
|
|
191
|
+
// ---- ΔEok ----
|
|
192
|
+
export const labOf = (hex: string) => linToOklab(hexToLin(hex));
|
|
193
|
+
export const dEok = (h1: string, h2: string) => { const A = labOf(h1), B = labOf(h2); return Math.round(Math.hypot(A[0] - B[0], A[1] - B[1], A[2] - B[2]) * 100); };
|
|
194
|
+
|
|
195
|
+
// ---- CIEDE2000 + CVD gate (data-viz palette validation) ----
|
|
196
|
+
// dEok above is fast but less accurate than CIEDE2000 at predicting perceptual difference (OKLab
|
|
197
|
+
// STRESS ~47 vs CIEDE2000 ~29 on COMBVD). Author scales with dEok; GATE shipped data-viz palettes
|
|
198
|
+
// on dE00 / dE00cvd. See COLOUR-DATAVIZ-RESEARCH.md.
|
|
199
|
+
// CIELAB (D65) from linear-sRGB / hex.
|
|
200
|
+
export function linToLab([r, g, b]: number[]): number[] {
|
|
201
|
+
const X = 0.4124564 * r + 0.3575761 * g + 0.1804375 * b;
|
|
202
|
+
const Y = 0.2126729 * r + 0.7151522 * g + 0.072175 * b;
|
|
203
|
+
const Z = 0.0193339 * r + 0.119192 * g + 0.9503041 * b;
|
|
204
|
+
const f = (t: number) => (t > (6 / 29) ** 3 ? Math.cbrt(t) : t / (3 * (6 / 29) ** 2) + 4 / 29);
|
|
205
|
+
const fx = f(X / 0.95047), fy = f(Y / 1), fz = f(Z / 1.08883);
|
|
206
|
+
return [116 * fy - 16, 500 * (fx - fy), 200 * (fy - fz)];
|
|
207
|
+
}
|
|
208
|
+
export const labCie = (hex: string) => linToLab(hexToLin(hex));
|
|
209
|
+
// CIEDE2000. Verified against the canonical Sharma et al. reference vectors (incl. the 1.0000 hue-wrap traps).
|
|
210
|
+
export function ciede2000([L1, a1, b1]: number[], [L2, a2, b2]: number[]): number {
|
|
211
|
+
const rad = Math.PI / 180, deg = 180 / Math.PI;
|
|
212
|
+
const C1 = Math.hypot(a1, b1), C2 = Math.hypot(a2, b2), Cb = (C1 + C2) / 2;
|
|
213
|
+
const G = 0.5 * (1 - Math.sqrt(Cb ** 7 / (Cb ** 7 + 25 ** 7)));
|
|
214
|
+
const a1p = (1 + G) * a1, a2p = (1 + G) * a2;
|
|
215
|
+
const C1p = Math.hypot(a1p, b1), C2p = Math.hypot(a2p, b2);
|
|
216
|
+
const h1p = (Math.atan2(b1, a1p) * deg + 360) % 360, h2p = (Math.atan2(b2, a2p) * deg + 360) % 360;
|
|
217
|
+
const dLp = L2 - L1, dCp = C2p - C1p;
|
|
218
|
+
let dhp = 0; if (C1p * C2p !== 0) { dhp = h2p - h1p; if (dhp > 180) dhp -= 360; else if (dhp < -180) dhp += 360; }
|
|
219
|
+
const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin((dhp / 2) * rad);
|
|
220
|
+
const Lbp = (L1 + L2) / 2, Cbp = (C1p + C2p) / 2;
|
|
221
|
+
let hbp: number; if (C1p * C2p === 0) hbp = h1p + h2p; else { hbp = (h1p + h2p) / 2; if (Math.abs(h1p - h2p) > 180) hbp += h1p + h2p < 360 ? 180 : -180; }
|
|
222
|
+
const T = 1 - 0.17 * Math.cos((hbp - 30) * rad) + 0.24 * Math.cos(2 * hbp * rad) + 0.32 * Math.cos((3 * hbp + 6) * rad) - 0.2 * Math.cos((4 * hbp - 63) * rad);
|
|
223
|
+
const dTheta = 30 * Math.exp(-(((hbp - 275) / 25) ** 2));
|
|
224
|
+
const Rc = 2 * Math.sqrt(Cbp ** 7 / (Cbp ** 7 + 25 ** 7));
|
|
225
|
+
const Sl = 1 + (0.015 * (Lbp - 50) ** 2) / Math.sqrt(20 + (Lbp - 50) ** 2);
|
|
226
|
+
const Sc = 1 + 0.045 * Cbp, Sh = 1 + 0.015 * Cbp * T, Rt = -Math.sin(2 * dTheta * rad) * Rc;
|
|
227
|
+
return Math.sqrt((dLp / Sl) ** 2 + (dCp / Sc) ** 2 + (dHp / Sh) ** 2 + Rt * (dCp / Sc) * (dHp / Sh));
|
|
228
|
+
}
|
|
229
|
+
export const dE00 = (h1: string, h2: string) => Math.round(ciede2000(labCie(h1), labCie(h2)));
|
|
230
|
+
// CVD simulation — Machado, Oliveira & Fernandes (2009), severity 1.0, applied in linear sRGB.
|
|
231
|
+
export const CVD_TYPES = ['protan', 'deutan', 'tritan'] as const;
|
|
232
|
+
export type CvdType = (typeof CVD_TYPES)[number];
|
|
233
|
+
const CVD_M: Record<CvdType, number[][]> = {
|
|
234
|
+
protan: [[0.152286, 1.052583, -0.204868], [0.114503, 0.786281, 0.099216], [-0.003882, -0.048116, 1.051998]],
|
|
235
|
+
deutan: [[0.367322, 0.860646, -0.227968], [0.280085, 0.672501, 0.047413], [-0.01182, 0.04294, 0.968881]],
|
|
236
|
+
tritan: [[1.255528, -0.076749, -0.178779], [-0.078411, 0.930809, 0.147602], [0.004733, 0.691367, 0.3039]],
|
|
237
|
+
};
|
|
238
|
+
export const simulateCvdLin = (lin: number[], t: CvdType) => CVD_M[t].map((row) => row[0] * lin[0] + row[1] * lin[1] + row[2] * lin[2]);
|
|
239
|
+
export const simulateCvd = (hex: string, t: CvdType) => { const l = simulateCvdLin(hexToLin(hex), t); const to = (v: number) => Math.round(Math.max(0, Math.min(1, linToSrgb(v))) * 255).toString(16).padStart(2, '0'); return '#' + to(l[0]) + to(l[1]) + to(l[2]); };
|
|
240
|
+
// Worst-case ΔE00 across normal + all three dichromacies — the number to gate categorical series on.
|
|
241
|
+
export const dE00cvd = (h1: string, h2: string) => {
|
|
242
|
+
const norm = ciede2000(labCie(h1), labCie(h2));
|
|
243
|
+
const worst = CVD_TYPES.reduce((m, t) => Math.min(m, ciede2000(linToLab(simulateCvdLin(hexToLin(h1), t)), linToLab(simulateCvdLin(hexToLin(h2), t)))), norm);
|
|
244
|
+
return Math.round(worst);
|
|
245
|
+
};
|
|
246
|
+
// Validate a categorical data-viz set: mutual separation (worst-case CVD), distance from brand/
|
|
247
|
+
// semantic anchors, and WCAG 1.4.11 non-text contrast (≥3:1) against each background.
|
|
248
|
+
export interface DataVizGate { minSep: number; minBrand: number; minContrast: number; bgs: string[]; brand: string[]; }
|
|
249
|
+
export function validateDataViz(hexes: string[], g: DataVizGate) {
|
|
250
|
+
const pairs: { a: string; b: string; dE: number }[] = [];
|
|
251
|
+
for (let i = 0; i < hexes.length; i++) for (let j = i + 1; j < hexes.length; j++) pairs.push({ a: hexes[i], b: hexes[j], dE: dE00cvd(hexes[i], hexes[j]) });
|
|
252
|
+
const minMutual = Math.min(...pairs.map((p) => p.dE));
|
|
253
|
+
const brand = hexes.map((h) => ({ h, dE: Math.min(...g.brand.map((b) => dE00(h, b))) }));
|
|
254
|
+
const minBrand = Math.min(...brand.map((b) => b.dE));
|
|
255
|
+
const contrastRows = hexes.map((h) => ({ h, byBg: g.bgs.map((bg) => +contrast(h, bg).toFixed(2)) }));
|
|
256
|
+
const minContrast = Math.min(...contrastRows.flatMap((r) => r.byBg));
|
|
257
|
+
return { pass: minMutual >= g.minSep && minBrand >= g.minBrand && minContrast >= g.minContrast, minMutual, minBrand, minContrast, pairs, brand, contrastRows };
|
|
258
|
+
}
|
|
259
|
+
// The safe categorical set — brand-led, 6 series, dual-mode. Regenerated 2026-07-19 by
|
|
260
|
+
// scripts/brandled.ts and promoted here from the docs page; it replaces the old 5-colour set, which
|
|
261
|
+
// was optimised against pure white and had no real dark variant. Search gate + rationale live in the
|
|
262
|
+
// script header. Series 1–3 lead the brand hierarchy (forest → warm → river); 4–6 are engineered for
|
|
263
|
+
// separation. Keys are the series index, not a hue name — two slots share the warm hue arc (differing
|
|
264
|
+
// in tone), so a hue name would mislead, and the set is consumed by position anyway.
|
|
265
|
+
// Validated: worst-case-CVD ΔE00 12.3 light / 16.3 dark; every colour clears WCAG 1.4.11 ≥ 3:1 against
|
|
266
|
+
// all three real surfaces per mode; dark hue-locked to light so a series keeps its identity across a
|
|
267
|
+
// theme toggle. Four constraints beyond separation, each added because an unconstrained run produced
|
|
268
|
+
// something the metric was happy with and the eye was not:
|
|
269
|
+
// · chroma 0.13–0.20 — a categorical set carries NO ordering, so uneven chroma invents a hierarchy.
|
|
270
|
+
// Uncapped, series 2 ran nearly twice the chroma of series 1 and shouted.
|
|
271
|
+
// · no two hues within 42° — ΔE will happily place two blues 6° apart and separate them by lightness.
|
|
272
|
+
// · brand ΔE ≤ 9 per brand slot — at 15 the warm slot drifted to ochre (ΔE 12) for separation we did
|
|
273
|
+
// not need.
|
|
274
|
+
// · brand anchors and the tonal band are PER MODE — 500/L0.50-0.72 light, 400/L0.52-0.82 dark. Dark
|
|
275
|
+
// was previously anchored to the 500 step, i.e. a light-mode reference on a dark surface, which
|
|
276
|
+
// pulled the whole dark set too dark: measured against the 400 step it was Δ15 forest / Δ18 warm.
|
|
277
|
+
// Light's floor is 0.50 (not 0.44) as a design call: at 0.44 slots 4-6 clustered 16 points below
|
|
278
|
+
// the brand slots and read as a separate family. Lifting them to L50 is free on separation (ΔE 12
|
|
279
|
+
// either way) but slot 2 pays for the crowding — chroma 0.155 → 0.130, brand drift 5 → 8. That
|
|
280
|
+
// trade was made deliberately; it is not the search finding a better optimum.
|
|
281
|
+
// Redundant encoding (shape / dash / direct label) remains MANDATORY.
|
|
282
|
+
export const DATAVIZ = {
|
|
283
|
+
light: { 1: '#009d7c', 2: '#cc7044', 3: '#617de6', 4: '#b0175f', 5: '#8a34ab', 6: '#5b6c00' },
|
|
284
|
+
dark: { 1: '#029e72', 2: '#d46b01', 3: '#89b0ff', 4: '#ff9cb2', 5: '#a663ac', 6: '#becf68' },
|
|
285
|
+
} as const;
|
|
286
|
+
// Brand pairs — the brand-forward categorical set: three brand hues × two ramp tones = 6 series, keyed
|
|
287
|
+
// family-tone. Maximum brand fidelity, but NOT colour-blind-safe — the soft tones converge under
|
|
288
|
+
// deutan and sit low on contrast, so this set REQUIRES direct labels / a legend (like the vivid sets).
|
|
289
|
+
// Mode flips the ramp window: light takes the deep+mid steps (500/300), dark slides UP (400/200) so a
|
|
290
|
+
// mark stays lighter than the dark surface. Keys name the mode-stable TONE (deep/soft), never the ramp
|
|
291
|
+
// step — the step differs by mode, so a step number would be a lie in the other mode.
|
|
292
|
+
export const DATAVIZ_PAIRS = {
|
|
293
|
+
light: { 'forest-deep': '#019f6d', 'forest-soft': '#3bd996', 'warm-deep': '#db6300', 'warm-soft': '#feaf59', 'river-deep': '#3588df', 'river-soft': '#81c0ff' },
|
|
294
|
+
dark: { 'forest-deep': '#0abb7f', 'forest-soft': '#99e9bc', 'warm-deep': '#f48519', 'warm-soft': '#ffd6a6', 'river-deep': '#43a2ff', 'river-soft': '#b2d9ff' },
|
|
295
|
+
} as const;
|
|
296
|
+
// Sequential ramps — ordered low→high. Three flavours:
|
|
297
|
+
// • Brand single-hue (forest, indigo): light→dark (light = low), on-vibe / quiet. Invert in dark
|
|
298
|
+
// mode. forest is the brand ramp; see the note below.
|
|
299
|
+
// • Cartographic multi-hue (moss, magma): ColorBrewer YlGn and matplotlib magma, verbatim. They vary
|
|
300
|
+
// hue as well as lightness, which separates adjacent steps better than a single hue can.
|
|
301
|
+
// • Scientific (cividis, viridis): the canonical, peer-reviewed perceptually-uniform colormaps,
|
|
302
|
+
// verbatim (public domain). Native direction is dark→light (dark = low) — keep it; both work on
|
|
303
|
+
// light and dark backgrounds. cividis is CVD-optimal (blue→yellow, the channel CVD preserves);
|
|
304
|
+
// viridis is the perceptually-uniform default. Prefer these for accuracy-first heatmaps.
|
|
305
|
+
//
|
|
306
|
+
// On "brand": `forest` is the brand ramp and the cell components' default. It is built by the recipe
|
|
307
|
+
// indigo uses — a shared lightness ladder, one hue, triangular chroma — at hue 164.8, with the
|
|
308
|
+
// chroma scaled so stop 7 IS forest-700. The brand colour is a stop on the ramp rather than
|
|
309
|
+
// something it approximates. Measured: L-monotonic, ΔE00 steps 5.8–8.7, hue 0.0–0.1° from brand.
|
|
310
|
+
//
|
|
311
|
+
// A `teal` ramp used to sit here and was retired when forest landed. It was built the same way but
|
|
312
|
+
// at hue 188 — a constant 22.8° from forest-700, nearer cyan than Forest green — so it carried the
|
|
313
|
+
// "brand" label without the brand hue. Beside forest it was close enough to be confusable and no
|
|
314
|
+
// longer earned a slot. moss is the other green: not brand-derived (ColorBrewer YlGn) and the least
|
|
315
|
+
// even steps of the set, kept because a multi-hue ramp separates adjacent steps better than a
|
|
316
|
+
// single hue can.
|
|
317
|
+
export const DATAVIZ_SEQUENTIAL = {
|
|
318
|
+
forest: ['#dcffee', '#bef9de', '#99e8c5', '#69d2a8', '#30b889', '#289c74', '#208361', '#186c4f', '#11563e'],
|
|
319
|
+
indigo: ['#f3f4fe', '#e6e8fd', '#ced0fc', '#b1b3f8', '#9494e8', '#7b78da', '#6763bb', '#54519b', '#43407b'],
|
|
320
|
+
// moss — ColorBrewer YlGn (light→dark, light = low). The public match to Spectrum's "Forest" green
|
|
321
|
+
// sequential, renamed so it doesn't collide with the brand forest family. Inverts in dark mode.
|
|
322
|
+
moss: ['#ffffe5', '#f7fcb9', '#d9f0a3', '#addd8e', '#78c679', '#41ab5d', '#238443', '#006837', '#004529'],
|
|
323
|
+
cividis: ['#00204d', '#00336f', '#39486b', '#575d6d', '#707173', '#8a8779', '#a69d75', '#c4b56c', '#e4cf5b', '#ffea46'],
|
|
324
|
+
viridis: ['#440154', '#482878', '#3e4a89', '#31688e', '#26828e', '#1f9e89', '#35b779', '#6dcd59', '#b4de2c', '#fde725'],
|
|
325
|
+
// magma — matplotlib's perceptually-uniform colormap (public domain / CC0). Spectrum's "Magma" is this
|
|
326
|
+
// map. Native direction dark→low like viridis/cividis; keep it. The warm counterpart to viridis.
|
|
327
|
+
magma: ['#000004', '#1c1044', '#4f127b', '#812581', '#b5367a', '#e65164', '#fb8861', '#fec287', '#fcfdbf'],
|
|
328
|
+
} as const;
|
|
329
|
+
// Diverging ramps (9 stops: low → neutral light centre → high). Centre = a meaningful value (0/mean/
|
|
330
|
+
// median); lightness peaks there. Three distinct axes (NEVER green↔red — the deuteranopia trap, ΔE 8):
|
|
331
|
+
// • forestWarm — brand primary↔secondary (forest green ↔ Acer orange). On-brand; endpoints ΔE00 12
|
|
332
|
+
// worst-case CVD (forest's teal lean keeps it viable) — least CVD-robust of the three, pair w/ labels.
|
|
333
|
+
// • blueOrange — river↔warm. The CVD-safe workhorse (endpoints ΔE00 41). Use when CVD is critical.
|
|
334
|
+
// • purpleGreen — violet↔forest, the classic ColorBrewer-style diverging (ΔE00 27). Distinct flavour.
|
|
335
|
+
// For dark mode, darken the centre stop (a light centre is invisible on the dark-green surface).
|
|
336
|
+
export const DATAVIZ_DIVERGING = {
|
|
337
|
+
forestWarm: ['#155e42', '#238862', '#38b484', '#69deac', '#f6ece8', '#fab18b', '#dc804f', '#b05925', '#7d3a11'],
|
|
338
|
+
blueOrange: ['#12518d', '#3377be', '#5b9fe9', '#9dc8f9', '#f6ece8', '#fab18b', '#dc804f', '#b05925', '#7d3a11'],
|
|
339
|
+
purpleGreen: ['#673682', '#8e5bab', '#b682d5', '#ddaff9', '#e8f1ec', '#69deac', '#38b484', '#238862', '#155e42'],
|
|
340
|
+
// redBlue — ColorBrewer RdBu (9-class), the canonical CVD-safe diverging Spectrum calls "Red-blue".
|
|
341
|
+
// Red↔blue avoids the green↔red deuteranopia trap; both ends stay distinct for every CVD type.
|
|
342
|
+
redBlue: ['#b2182b', '#d6604d', '#f4a582', '#fddbc7', '#f7f7f7', '#d1e5f0', '#92c5de', '#4393c3', '#2166ac'],
|
|
343
|
+
} as const;
|
|
344
|
+
// Vivid categorical sets. 12 hues spaced for maximum colour separation, not for even hue steps; the
|
|
345
|
+
// first SIX are the curated 6. Dual-mode, and every mark clears WCAG 1.4.11 on both surfaces (min
|
|
346
|
+
// 3.09 light / 3.84 dark). Normal-vision distinct (6: ΔE00 ≥23, 12: ≥10) but these are still
|
|
347
|
+
// NOT colour-blind-safe — red/amber/green converge under deuteranopia, and a 12-set is impossible to
|
|
348
|
+
// keep CVD-distinct (true of every system incl. Spectrum). RULE: use ≤6 when you can; 7–12 require
|
|
349
|
+
// direct labels / legend; for colour-blind-critical charts use DATAVIZ (the 6-colour CVD-safe set).
|
|
350
|
+
// Redundant encoding (shape/dash/label) is mandatory regardless. See research §4.
|
|
351
|
+
// Values are Tailwind v4's oklch palette: step 600 in light mode, step 400 in dark. Bespoke hexes
|
|
352
|
+
// used to sit here; they lost WCAG 1.4.11 on both surfaces (3 of the 6 under 3:1) to buy normal-vision
|
|
353
|
+
// separation, which measured as the wrong trade — the Tailwind pick clears 3:1 everywhere (min 3.09
|
|
354
|
+
// light / 3.84 dark), separates BETTER in light (ΔE00 23.6 vs 22.3), and triples worst-case CVD
|
|
355
|
+
// separation (8.7 vs 2.9). Only dark-mode normal-vision headroom drops (27.1 → 20.0), far above
|
|
356
|
+
// threshold. Hues are the max-min-ΔE00 pick over the 15 Tailwind hues that clear the contrast gate;
|
|
357
|
+
// yellow and lime are excluded — they cannot reach 3:1 on the light surface at any usable step.
|
|
358
|
+
// Sequence note: green leads and red sits sixth, so the common 1–3 series case opens on the brand's
|
|
359
|
+
// own hue rather than on an alarm colour — red carries meaning nobody asked for when it arrives first.
|
|
360
|
+
// Slot n and slot n+6 are the same hue family (green/emerald, amber/orange, sky/blue, indigo/purple,
|
|
361
|
+
// pink/fuchsia, red/rose), so a chart that grows past six extends each existing hue instead of
|
|
362
|
+
// introducing six unrelated ones. That alignment is the reason the 12-set separates at ΔE00 9 rather
|
|
363
|
+
// than the 10.8 a free pick reaches — deliberate: the near-neighbours are what make the tail legible
|
|
364
|
+
// as "more of the same series".
|
|
365
|
+
// Green leads rather than emerald even though emerald is the exact brand hue (163°, ΔE00 6.4 to Brand
|
|
366
|
+
// Forest, vs green's 14.2): emerald's lower chroma and its position between green and teal crowd
|
|
367
|
+
// cyan/sky, and emerald↔pink collapse to ΔE00 1.5 under deuteranopia. Leading emerald costs the six
|
|
368
|
+
// half its separation (23.6 → 12.2). The brand hue is still in the set, at slot 7.
|
|
369
|
+
export const DATAVIZ_CATEGORICAL = {
|
|
370
|
+
names: ['green', 'amber', 'sky', 'indigo', 'pink', 'red', 'emerald', 'orange', 'blue', 'purple', 'fuchsia', 'rose'],
|
|
371
|
+
light: ['#00a447', '#da7600', '#0084c8', '#4f39f6', '#e30076', '#e40015', '#009669', '#ec5600', '#155dfc', '#9810fa', '#c500da', '#e60044'],
|
|
372
|
+
dark: ['#05df72', '#fabc00', '#00bbfd', '#7d87ff', '#fb64b6', '#ff6568', '#00d294', '#ff8b1e', '#56a2ff', '#bf7eff', '#ec6dff', '#ff6880'],
|
|
373
|
+
} as const;
|
|
374
|
+
// Convenience: the curated 6 is the first six of the 12.
|
|
375
|
+
export const datavizCategorical = (n: 6 | 12, mode: 'light' | 'dark') => DATAVIZ_CATEGORICAL[mode].slice(0, n);
|
|
376
|
+
|
|
377
|
+
// ---- Ladder constants ----
|
|
378
|
+
export const BRAND_SHADES = [0, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
|
|
379
|
+
export const SHADES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;
|
|
380
|
+
export const TWL: Record<number, number> = { 0: 0.99, 50: 0.97, 100: 0.935, 150: 0.91, 200: 0.87, 300: 0.79, 400: 0.7, 500: 0.62, 600: 0.545, 700: 0.475, 800: 0.405, 900: 0.34, 950: 0.27 };
|
|
381
|
+
// Bright ladder (amber/acid): keep tints light (vivid yellows can't be dark), but converge the mid/dark
|
|
382
|
+
// levels toward the cool ladder so the darks get dark enough to close the warm-hue contrast holes.
|
|
383
|
+
export const TWL_BRIGHT: Record<number, number> = { 0: 0.99, 50: 0.975, 100: 0.95, 150: 0.92, 200: 0.9, 300: 0.83, 400: 0.75, 500: 0.66, 600: 0.57, 700: 0.49, 800: 0.41, 900: 0.34, 950: 0.27 };
|
|
384
|
+
export const BRIGHT = new Set(['amber']); // acid dropped to the normal ladder (Acid Green is L77 = a 300-level lightness)
|
|
385
|
+
// Warm family ladder: re-spaces the 300/400 slots so the wide Peach→Acer pin gap eases instead of kinking.
|
|
386
|
+
export const TWL_WARM: Record<number, number> = { 0: 0.99, 50: 0.97, 100: 0.935, 150: 0.91, 200: 0.87, 300: 0.815, 400: 0.725, 500: 0.62, 600: 0.545, 700: 0.475, 800: 0.405, 900: 0.34, 950: 0.27 };
|
|
387
|
+
export const ladderFor = (fam: string) => (fam === 'warm' ? TWL_WARM : BRIGHT.has(fam) ? TWL_BRIGHT : TWL);
|
|
388
|
+
export const TWFRAC: Record<number, number> = { 0: 0.16, 50: 0.34, 100: 0.62, 200: 0.72, 300: 0.84, 400: 0.9, 500: 0.9, 600: 0.88, 700: 0.84, 800: 0.72, 900: 0.58, 950: 0.44 };
|
|
389
|
+
export const MUTE = 0.8;
|
|
390
|
+
export const nearestOn = (hex: string, ladder: Record<number, number>) => { const L = hexToOklch(hex).L; let b = BRAND_SHADES[0], bd = Infinity; for (const s of BRAND_SHADES) { const d = Math.abs(ladder[s] - L); if (d < bd) { bd = d; b = s; } } return b; };
|
|
391
|
+
export const CMUL: Record<string, Record<number, number>> = {
|
|
392
|
+
acid: { 0: 0.4, 50: 0.45, 100: 0.5, 200: 0.65, 300: 0.82 },
|
|
393
|
+
primary: { 400: 1.15, 600: 1.3, 700: 1.4, 800: 1.4, 900: 1.3 },
|
|
394
|
+
};
|
|
395
|
+
export const HWARM: Record<string, number> = { amber: -22, acid: -6 };
|
|
396
|
+
export const SAT: Record<string, number> = { primary: 0.95, maple: 0.9, warm: 0.9, amber: 0.84, acid: 0.82, river: 0.92 };
|
|
397
|
+
|
|
398
|
+
// ---- Hue torsion: two-sided lean the H(L) spline curves through (pivots at the pin/base hue) ----
|
|
399
|
+
// l = degrees the lighter-than-anchor end leans; d = degrees the darker-than-anchor end leans.
|
|
400
|
+
export const HTORS: Record<string, { l: number; d: number }> = {
|
|
401
|
+
primary: { l: -4, d: 6 }, // subtle: light tints lean yellow-green (fresh), dark shades lean teal (deep); pivots at Brand Forest
|
|
402
|
+
amber: { l: 10, d: -22 }, acid: { l: 6, d: -6 }, maple: { l: -6, d: 2 }, river: { l: -4, d: 8 },
|
|
403
|
+
};
|
|
404
|
+
export const TORS_MULT = 1.5; // global hue-torsion strength dial
|
|
405
|
+
export const torsionFor = (fam: string) => { const t = HTORS[fam] ?? { l: 0, d: (HWARM[fam] ?? 0) }; return { l: t.l * TORS_MULT, d: t.d * TORS_MULT }; };
|
|
406
|
+
|
|
407
|
+
// Per-family per-level fine-tuning for GENERATED families: override lightness (0–1) and/or chroma
|
|
408
|
+
// (OKLab C, clamped to the cusp) at a specific level, without disturbing the shared lightness ladder.
|
|
409
|
+
export const TUNE: Record<string, Record<number, { L?: number; C?: number; H?: number }>> = {};
|
|
410
|
+
|
|
411
|
+
// ---- buildTW: a simple generate-then-stamp ramp (retained as a comparison/utility) ----
|
|
412
|
+
export function buildTW(H: number, pins: Record<number, string> = {}, ladder: Record<number, number> = TWL, cmul?: Record<number, number>, dHue = 0, sat = 0.9) {
|
|
413
|
+
const o: Record<number, string> = {};
|
|
414
|
+
const L5 = ladder[500], Lbot = ladder[950];
|
|
415
|
+
for (const s of BRAND_SHADES) {
|
|
416
|
+
const L = ladder[s];
|
|
417
|
+
const hh = dHue && L < L5 ? H + dHue * ((L5 - L) / (L5 - Lbot || 1)) : H;
|
|
418
|
+
o[s] = oklchToHex(L, okhslChroma(L, hh, sat) * (cmul?.[s] ?? 1), hh);
|
|
419
|
+
}
|
|
420
|
+
for (const [s, hex] of Object.entries(pins)) o[+s] = hex.toLowerCase();
|
|
421
|
+
return o;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ---- PCHIP machinery (monotone-cubic C(L) / H(L) splines through anchors) ----
|
|
425
|
+
export function pchipSlopes(xs: number[], ys: number[]) {
|
|
426
|
+
const n = xs.length, h: number[] = [], d: number[] = [], m: number[] = new Array(n);
|
|
427
|
+
for (let i = 0; i < n - 1; i++) { h[i] = xs[i + 1] - xs[i]; d[i] = (ys[i + 1] - ys[i]) / h[i]; }
|
|
428
|
+
m[0] = d[0]; m[n - 1] = d[n - 2];
|
|
429
|
+
for (let i = 1; i < n - 1; i++) {
|
|
430
|
+
if (d[i - 1] * d[i] <= 0) m[i] = 0;
|
|
431
|
+
else { const w1 = 2 * h[i] + h[i - 1], w2 = h[i] + 2 * h[i - 1]; m[i] = (w1 + w2) / (w1 / d[i - 1] + w2 / d[i]); }
|
|
432
|
+
}
|
|
433
|
+
return m;
|
|
434
|
+
}
|
|
435
|
+
export function makeSpline(pts: { x: number; y: number }[]) {
|
|
436
|
+
const s = [...pts].sort((a, b) => a.x - b.x);
|
|
437
|
+
const xs = s.map((p) => p.x), ys = s.map((p) => p.y), m = pchipSlopes(xs, ys), n = xs.length;
|
|
438
|
+
return (x: number) => {
|
|
439
|
+
if (x <= xs[0]) return ys[0];
|
|
440
|
+
if (x >= xs[n - 1]) return ys[n - 1];
|
|
441
|
+
let i = 0; while (x > xs[i + 1]) i++;
|
|
442
|
+
const hh = xs[i + 1] - xs[i], t = (x - xs[i]) / hh, t2 = t * t, t3 = t2 * t;
|
|
443
|
+
return (2 * t3 - 3 * t2 + 1) * ys[i] + (t3 - 2 * t2 + t) * hh * m[i] + (-2 * t3 + 3 * t2) * ys[i + 1] + (t3 - t2) * hh * m[i + 1];
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
export const normH = (h: number) => ((h % 360) + 360) % 360;
|
|
447
|
+
|
|
448
|
+
// ---- Pin config ----
|
|
449
|
+
// optional 3rd element = explicit level (override the auto by-lightness placement)
|
|
450
|
+
// Brand naming: maple = red (Error), river = blue (Info); primary keeps its key (brand name "forest", Nuxt UI's primary alias).
|
|
451
|
+
export const PINS: Record<string, [string, string, number?][]> = {
|
|
452
|
+
// Brand Forest anchors the mid; Dark Forest + Darkest Forest (the brand's deep greens, also the logo's
|
|
453
|
+
// mark + ink) anchor the dark end — Forest, the primary, owns the system's dark envelope (see FRAC_DARK).
|
|
454
|
+
primary: [['Brand Forest', '#00845C', 600], ['Dark Forest', '#046C4C', 700], ['Darkest Forest', '#002A24', 950]],
|
|
455
|
+
maple: [],
|
|
456
|
+
// warm absorbs the old orange + cream: cream tints, an Acer-orange mid, a bark-brown floor — one ramp.
|
|
457
|
+
warm: [['Ice Cream', '#FFF9F2', 50], ['Peach Cream', '#FFD6A6', 200], ['Acer Orange', '#DB6300', 500], ['Bark Brown', '#44231C', 950]],
|
|
458
|
+
amber: [],
|
|
459
|
+
acid: [['Acid Green', '#B4BD11', 300]], // pinned at 300 on the normal ladder (was 400/bright)
|
|
460
|
+
river: [],
|
|
461
|
+
};
|
|
462
|
+
export const HUE: Record<string, string> = { primary: '#00845C', maple: '#E5484D', warm: '#DB6300', amber: '#FFC53D', acid: '#B4BD11', river: '#0090FF' };
|
|
463
|
+
export const pinsFor = (fam: string) => Object.fromEntries((PINS[fam] || []).map(([, hex, lvl]) => [lvl ?? nearestOn(hex, ladderFor(fam)), hex]));
|
|
464
|
+
export const scaleMarks: Record<string, string> = {};
|
|
465
|
+
for (const [fam, list] of Object.entries(PINS)) for (const [name, hex, lvl] of list) scaleMarks[`${fam}-${lvl ?? nearestOn(hex, ladderFor(fam))}`] = name;
|
|
466
|
+
|
|
467
|
+
// ---- Brand naming + the Nuxt UI alias contract ----
|
|
468
|
+
// We own the primitive scales + this alias mapping; Nuxt UI owns the semantic aliases and all component
|
|
469
|
+
// theming. BRAND_NAMES maps each engine family key to its brand colour name (primary's is "forest";
|
|
470
|
+
// maple/river already carry theirs). NUXT_UI_ALIASES maps each Nuxt UI semantic colour to the brand
|
|
471
|
+
// colour it resolves to — the contract realised in app.config `ui.colors` (web), Flutter theme, and Figma.
|
|
472
|
+
export const BRAND_NAMES: Record<string, string> = { primary: 'forest', maple: 'maple', warm: 'warm', amber: 'amber', acid: 'acid', river: 'river', neutral: 'neutral' };
|
|
473
|
+
export const NUXT_UI_ALIASES: Record<string, string> = { primary: 'forest', success: 'forest', error: 'maple', info: 'river', warning: 'amber', secondary: 'warm', neutral: 'neutral' };
|
|
474
|
+
|
|
475
|
+
// ---- Brand chroma profile: chroma is DERIVED from the pins, not dialed per family ----
|
|
476
|
+
// Two curves, both read straight off the brand pins (PINS):
|
|
477
|
+
// 1. cusp-fraction F(L) — how hard the brand pushes toward the gamut wall at each lightness.
|
|
478
|
+
// Measured: Brand Forest / Accent / Acer / Acid / Peach all ride ~98–100% of their own cusp;
|
|
479
|
+
// Bark Brown rides ~45% (the deliberately muted deep end). So F ≈ 1.0 through the mids,
|
|
480
|
+
// easing to ~0.45 in the darks and tapering at the extreme tints.
|
|
481
|
+
// 2. comfort ceiling K(L) — the brand's absolute chroma envelope (upper hull of pin chroma),
|
|
482
|
+
// peaking ~0.17 (Acer/Acid). Caps wide-gamut hues (green/lime/blue) so their light tints stay
|
|
483
|
+
// rich instead of neon — riding 100% of green's fat cusp would otherwise blow past the brand's feel.
|
|
484
|
+
// chroma(L,H) = min( F(L)·cusp(L,H), K(L) ). Generated families (amber, maple, river) inherit the SAME
|
|
485
|
+
// law the pins obey, so they read as siblings of Forest / Acer / Peach rather than independent picks.
|
|
486
|
+
export const FRAC_TINT = 0.5; // cusp-fraction at the extreme light end (lighter than the lightest pin)
|
|
487
|
+
export const FRAC_DARK = 0.4; // extreme-dark anchor (L0.20, below the floor). The DARK RICHNESS is now set
|
|
488
|
+
// by Forest's own dark pins (Dark Forest @700, Darkest Forest @950, both riding ~0.95 of their cusp): they define
|
|
489
|
+
// the shared dark cusp-fraction, so every family's darks stay rich. This anchor only extrapolates below 950.
|
|
490
|
+
export const CHROMA_HEADROOM = 1.0; // multiplier on the comfort ceiling (1 = exact pin envelope)
|
|
491
|
+
function buildBrandChromaProfile() {
|
|
492
|
+
const allPins = Object.values(PINS).flat() as [string, string, number?][];
|
|
493
|
+
// 1. cusp-fraction curve: pin chroma as a fraction of its own cusp (drop near-white pins — cusp too small).
|
|
494
|
+
// Also drop deliberately-muted dark accents (Bark Brown: f≈0.45 at L0.30): authored low on purpose, it
|
|
495
|
+
// would clash with Forest's rich dark pins (Darkest Forest rides ~0.94) and suppress the system's dark richness.
|
|
496
|
+
// Bark Brown still pins warm-950 exactly; it just doesn't define the shared envelope — Forest's darks do.
|
|
497
|
+
const fr: { L: number; f: number }[] = [];
|
|
498
|
+
for (const [, hex] of allPins) { const o = hexToOklch(hex); const cu = maxChromaAt(o.L, o.H); const f = o.C / cu; if (cu > 0.03 && o.L < 0.95 && !(o.L < 0.35 && f < 0.55)) fr.push({ L: o.L, f }); }
|
|
499
|
+
fr.sort((a, b) => a.L - b.L);
|
|
500
|
+
const frM: { L: number; f: number }[] = [];
|
|
501
|
+
for (const p of fr) { const last = frM[frM.length - 1]; if (last && Math.abs(last.L - p.L) < 0.02) { last.f = (last.f + p.f) / 2; last.L = (last.L + p.L) / 2; } else frM.push({ ...p }); }
|
|
502
|
+
const fracSpline = makeSpline([{ x: 0.2, y: FRAC_DARK }, ...frM.map((p) => ({ x: p.L, y: p.f })), { x: 0.985, y: FRAC_TINT }]);
|
|
503
|
+
// 2. comfort ceiling: upper hull of absolute pin chroma over lightness
|
|
504
|
+
const ca = allPins.map(([, hex]) => hexToOklch(hex)).filter((o) => o.C > 0.02).map((o) => ({ L: o.L, C: o.C })).sort((a, b) => a.L - b.L);
|
|
505
|
+
const caM: { L: number; C: number }[] = [];
|
|
506
|
+
for (const p of ca) { const last = caM[caM.length - 1]; if (last && Math.abs(last.L - p.L) < 0.03) last.C = Math.max(last.C, p.C); else caM.push({ ...p }); }
|
|
507
|
+
const capSpline = makeSpline([{ x: 0.18, y: 0.03 }, ...caM.map((p) => ({ x: p.L, y: p.C })), { x: 0.985, y: 0.015 }]);
|
|
508
|
+
return { fracSpline, capSpline };
|
|
509
|
+
}
|
|
510
|
+
const BRAND_CHROMA = buildBrandChromaProfile();
|
|
511
|
+
// Derived chroma at a lightness+hue: ride the gamut wall at the brand's fraction, capped at brand comfort.
|
|
512
|
+
export function brandChroma(L: number, H: number, gamut: 'srgb' | 'p3' = 'srgb', headroom = CHROMA_HEADROOM) {
|
|
513
|
+
const cusp = (gamut === 'p3' ? maxChromaP3 : maxChromaAt)(L, H);
|
|
514
|
+
return Math.min(BRAND_CHROMA.fracSpline(L) * cusp, BRAND_CHROMA.capSpline(L) * headroom);
|
|
515
|
+
}
|
|
516
|
+
export const cuspFractionAt = (L: number) => BRAND_CHROMA.fracSpline(L); // F(L), for visualisation
|
|
517
|
+
export const chromaComfortAt = (L: number) => BRAND_CHROMA.capSpline(L); // K(L), for visualisation
|
|
518
|
+
|
|
519
|
+
// ---- buildPCHIP: the canonical ramp builder (pins as exact control points) ----
|
|
520
|
+
// ov = runtime lever overrides (for the tuner tool); omitted → uses the module-level config.
|
|
521
|
+
// torsL/torsD are the FINAL lean degrees (already scaled), matching torsionFor()'s output.
|
|
522
|
+
export type PchipOverrides = { sat?: number; chromaMul?: number; headroom?: number; torsL?: number; torsD?: number; tune?: Record<number, { L?: number; C?: number; H?: number }>; pins?: [string, string, number?][]; hue?: string; ladder?: Record<number, number>; steer?: boolean };
|
|
523
|
+
|
|
524
|
+
// ---- House-curve steering: the pinned brand anchors (Primary + Warm) define a reference
|
|
525
|
+
// chroma per level; the flexible semantics (maple / river / amber) ride it — clamped to each gamut —
|
|
526
|
+
// so they read as siblings of the brand, distinct only by hue. Success = primary, so the brand green
|
|
527
|
+
// anchors the set; warm is the brand secondary. Acid was dropped from the anchors: it's a rare tertiary
|
|
528
|
+
// (the Lime mock), so it shouldn't shape the semantics. ----
|
|
529
|
+
const HOUSE_ANCHORS = ['primary', 'warm'];
|
|
530
|
+
const STEERED = new Set(['maple', 'river', 'amber']);
|
|
531
|
+
const P3_TO_SRGBLIN = [[1.2249401, -0.2249404, 0], [-0.0420569, 1.0420571, 0], [-0.0196376, -0.0786361, 1.0982735]];
|
|
532
|
+
// decode a built swatch string (hex or color(display-p3 …)) back to OKLCH.
|
|
533
|
+
export function decodeOklch(s: string) {
|
|
534
|
+
if (s[0] === '#') return hexToOklch(s);
|
|
535
|
+
const m = s.match(/display-p3\s+([\d.]+)\s+([\d.]+)\s+([\d.]+)/);
|
|
536
|
+
if (!m) return { L: 0, C: 0, H: 0 };
|
|
537
|
+
const lin = [srgbToLin(+m[1]), srgbToLin(+m[2]), srgbToLin(+m[3])];
|
|
538
|
+
const r = P3_TO_SRGBLIN[0][0] * lin[0] + P3_TO_SRGBLIN[0][1] * lin[1] + P3_TO_SRGBLIN[0][2] * lin[2];
|
|
539
|
+
const g = P3_TO_SRGBLIN[1][0] * lin[0] + P3_TO_SRGBLIN[1][1] * lin[1] + P3_TO_SRGBLIN[1][2] * lin[2];
|
|
540
|
+
const b = P3_TO_SRGBLIN[2][0] * lin[0] + P3_TO_SRGBLIN[2][1] * lin[1] + P3_TO_SRGBLIN[2][2] * lin[2];
|
|
541
|
+
const [L, A, B] = linToOklab([r, g, b]);
|
|
542
|
+
return { L, C: Math.hypot(A, B), H: ((Math.atan2(B, A) * 180) / Math.PI + 360) % 360 };
|
|
543
|
+
}
|
|
544
|
+
const _houseCache: Record<string, Record<number, number>> = {};
|
|
545
|
+
// average chroma per level across the pinned brand anchors (Primary + Warm) — built canonically (NOT steered → no recursion), cached per gamut.
|
|
546
|
+
export function houseChroma(gamut: 'srgb' | 'p3' = 'srgb'): Record<number, number> {
|
|
547
|
+
if (_houseCache[gamut]) return _houseCache[gamut];
|
|
548
|
+
const ramps = HOUSE_ANCHORS.map((f) => buildPCHIP(f, gamut));
|
|
549
|
+
const h: Record<number, number> = {};
|
|
550
|
+
for (const s of BRAND_SHADES) { const cs = ramps.map((r) => decodeOklch(r[s]).C); h[s] = cs.reduce((a, b) => a + b, 0) / cs.length; }
|
|
551
|
+
_houseCache[gamut] = h;
|
|
552
|
+
return h;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
export function buildPCHIP(fam: string, gamut: 'srgb' | 'p3' = 'srgb', ov?: PchipOverrides) {
|
|
556
|
+
const maxC = gamut === 'p3' ? maxChromaP3 : maxChromaAt;
|
|
557
|
+
const toStr = gamut === 'p3' ? oklchToP3 : oklchToHex;
|
|
558
|
+
const base = hexToOklch(ov?.hue ?? HUE[fam]);
|
|
559
|
+
const H0 = normH(base.H);
|
|
560
|
+
const ladder = ov?.ladder ?? ladderFor(fam);
|
|
561
|
+
const sat = ov?.sat ?? SAT[fam] ?? 0.9;
|
|
562
|
+
const tDef = torsionFor(fam);
|
|
563
|
+
const tors = { l: ov?.torsL ?? tDef.l, d: ov?.torsD ?? tDef.d };
|
|
564
|
+
const list = ov?.pins ?? PINS[fam] ?? [];
|
|
565
|
+
// pin anchors = exact brand colours (own L,C,H)
|
|
566
|
+
const anchors = list.map(([, hex]) => { const o = hexToOklch(hex); return { L: o.L, C: o.C, H: normH(o.H), hex: hex.toLowerCase() }; });
|
|
567
|
+
// seed base hue colour if no pin sits near its lightness — this is the torsion pivot
|
|
568
|
+
if (!anchors.some((a) => Math.abs(a.L - base.L) < 0.04)) anchors.push({ L: base.L, C: base.C, H: H0, hex: '' });
|
|
569
|
+
// synthetic light/dark ends so the spline spans the whole ladder.
|
|
570
|
+
// Two-sided hue torsion: lighter end leans by tors.l, darker end by tors.d, pivoting at the base/pin hue.
|
|
571
|
+
if (!anchors.some((a) => a.L > 0.93)) anchors.push({ L: 0.985, C: 0.012, H: H0 + tors.l, hex: '' });
|
|
572
|
+
if (!anchors.some((a) => a.L < 0.34)) { const Ld = 0.24, hd = H0 + tors.d; anchors.push({ L: Ld, C: Math.min(okhslChroma(Ld, hd, sat), maxC(Ld, hd)), H: hd, hex: '' }); }
|
|
573
|
+
anchors.sort((a, b) => a.L - b.L);
|
|
574
|
+
// unwrap hue to dodge 0/360 wrap in the spline
|
|
575
|
+
for (let i = 1; i < anchors.length; i++) { while (anchors[i].H - anchors[i - 1].H > 180) anchors[i].H -= 360; while (anchors[i].H - anchors[i - 1].H < -180) anchors[i].H += 360; }
|
|
576
|
+
const hS = makeSpline(anchors.map((a) => ({ x: a.L, y: a.H })));
|
|
577
|
+
// pin owns its level's L: override the ladder slot lightness with the pin's true L
|
|
578
|
+
const Lad: Record<number, number> = { ...ladder };
|
|
579
|
+
const pinHex: Record<number, string> = {};
|
|
580
|
+
for (const [, hex, lvl] of list) { const o = hexToOklch(hex); const level = lvl ?? nearestOn(hex, ladder); Lad[level] = o.L; pinHex[level] = hex.toLowerCase(); }
|
|
581
|
+
const o: Record<number, string> = {};
|
|
582
|
+
const tune = ov?.tune ?? TUNE[fam] ?? {};
|
|
583
|
+
const cMul = ov?.chromaMul ?? 1; // global chroma dial — scales the derived curve (then gamut-clamped)
|
|
584
|
+
const headroom = ov?.headroom ?? CHROMA_HEADROOM; // loosen/tighten the brand comfort ceiling
|
|
585
|
+
// The semantics (maple/river/amber) ride the house curve by default; pass steer:false to opt out
|
|
586
|
+
const steered = STEERED.has(fam) && ov?.steer !== false && !ov?.pins;
|
|
587
|
+
const house = steered ? houseChroma(gamut) : null;
|
|
588
|
+
for (const s of BRAND_SHADES) {
|
|
589
|
+
const tu = tune[s];
|
|
590
|
+
const L = s === 0 ? 0.985 : (tu?.L ?? Lad[s]);
|
|
591
|
+
const hh = tu?.H != null ? tu.H : hS(L);
|
|
592
|
+
// chroma: steered families take the house curve; brand families ride the cusp-fraction + comfort ceiling. Clamp to gamut.
|
|
593
|
+
const target = house ? house[s] : brandChroma(L, hh, gamut, headroom);
|
|
594
|
+
let C = Math.min(target * cMul, maxC(L, hh));
|
|
595
|
+
if (tu?.C != null) C = Math.min(tu.C, maxC(L, hh));
|
|
596
|
+
o[s] = toStr(L, C, hh);
|
|
597
|
+
}
|
|
598
|
+
// guarantee exact pin (sRGB brand hex; in p3 mode emit its display-p3 form — same in-gamut colour)
|
|
599
|
+
for (const [lvl, hex] of Object.entries(pinHex)) { const po = hexToOklch(hex); o[+lvl] = gamut === 'p3' ? oklchToP3(po.L, po.C, po.H) : hex; }
|
|
600
|
+
return o;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ---- pinsFor / scaleMarks are exported above ----
|
|
604
|
+
// scaleMarks is populated at module load time (top-level loop)
|