@humanforest/nuxt-layer 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (324) hide show
  1. package/LICENSE +67 -0
  2. package/README.md +208 -0
  3. package/app.config.ts +10 -0
  4. package/assets/icons/forest/bike-asterisk.svg +21 -0
  5. package/assets/icons/forest/bike-down.svg +19 -0
  6. package/assets/icons/forest/bike-off.svg +20 -0
  7. package/assets/icons/forest/bike-up.svg +19 -0
  8. package/assets/icons/forest/bike-x.svg +19 -0
  9. package/assets/icons/forest/bike.svg +18 -0
  10. package/assets/icons/forest/mark-fill.svg +6 -0
  11. package/assets/icons/forest/mark.svg +6 -0
  12. package/assets/icons/forest/parking.svg +3 -0
  13. package/assets/icons/forest/star-fill.svg +4 -0
  14. package/assets/icons/forest/star.svg +4 -0
  15. package/assets/icons/forest/traffic-light-caution.svg +8 -0
  16. package/assets/icons/forest/traffic-light-go.svg +8 -0
  17. package/assets/icons/forest/traffic-light-lit.svg +8 -0
  18. package/assets/icons/forest/traffic-light-stop.svg +8 -0
  19. package/assets/icons/forest/traffic-light.svg +8 -0
  20. package/nuxt.config.ts +81 -0
  21. package/package.json +35 -0
  22. package/packages/charts/src/FBoroughShape.vue +52 -0
  23. package/packages/charts/src/FCalendarGrid.vue +332 -0
  24. package/packages/charts/src/FCellLegend.vue +54 -0
  25. package/packages/charts/src/FChartFrame.vue +201 -0
  26. package/packages/charts/src/FChartFrameSingle.vue +91 -0
  27. package/packages/charts/src/FChartLegend.vue +26 -0
  28. package/packages/charts/src/FDistributionBar.vue +224 -0
  29. package/packages/charts/src/FDottedMap.vue +335 -0
  30. package/packages/charts/src/FLondonMap.vue +399 -0
  31. package/packages/charts/src/FSparkline.vue +198 -0
  32. package/packages/charts/src/FStatusTrack.vue +151 -0
  33. package/packages/charts/src/cellSize.ts +46 -0
  34. package/packages/charts/src/distribution.ts +99 -0
  35. package/packages/charts/src/engine.ts +15 -0
  36. package/packages/charts/src/forestTooltip.ts +110 -0
  37. package/packages/charts/src/fromCategories.ts +43 -0
  38. package/packages/charts/src/index.ts +87 -0
  39. package/packages/charts/src/londonAreas.ts +75 -0
  40. package/packages/charts/src/londonGrid.ts +67 -0
  41. package/packages/charts/src/motionDuration.ts +49 -0
  42. package/packages/charts/src/presets.ts +143 -0
  43. package/packages/charts/src/resolveVar.ts +21 -0
  44. package/packages/charts/src/snapTooltip.ts +154 -0
  45. package/packages/charts/src/tooltipStandIn.ts +58 -0
  46. package/packages/charts/src/useCellScale.ts +155 -0
  47. package/packages/charts/src/useChartPalette.ts +61 -0
  48. package/packages/charts/src/useChartRepaintKey.ts +22 -0
  49. package/packages/charts/src/useThemeVersion.ts +17 -0
  50. package/packages/frames/src/FBrowserFrame.vue +53 -0
  51. package/packages/frames/src/FDeviceFrame.vue +242 -0
  52. package/packages/frames/src/FFitFrame.vue +83 -0
  53. package/packages/frames/src/art/iphone-11.svg +124 -0
  54. package/packages/frames/src/art/iphone-12.svg +601 -0
  55. package/packages/frames/src/art/iphone-13-pro.svg +820 -0
  56. package/packages/frames/src/art/iphone-13.svg +849 -0
  57. package/packages/frames/src/art/iphone-14.svg +681 -0
  58. package/packages/frames/src/art/iphone-15-pro.svg +633 -0
  59. package/packages/frames/src/art/iphone-15.svg +713 -0
  60. package/packages/frames/src/art/iphone-16-plus.svg +685 -0
  61. package/packages/frames/src/art/iphone-16-pro-max.svg +611 -0
  62. package/packages/frames/src/art/iphone-16-pro.svg +631 -0
  63. package/packages/frames/src/art/iphone-16.svg +713 -0
  64. package/packages/frames/src/art/iphone-17-pro-max.svg +355 -0
  65. package/packages/frames/src/art/iphone-17.svg +305 -0
  66. package/packages/frames/src/art/iphone-air.svg +389 -0
  67. package/packages/frames/src/art/iphone-se-3.svg +473 -0
  68. package/packages/frames/src/art/iphone-x.svg +652 -0
  69. package/packages/frames/src/art/pixel-10-pro-xl.svg +32 -0
  70. package/packages/frames/src/art/pixel-10-pro.svg +32 -0
  71. package/packages/frames/src/art/pixel-10.svg +32 -0
  72. package/packages/frames/src/art/pixel-8-pro.svg +208 -0
  73. package/packages/frames/src/art/pixel-8.svg +208 -0
  74. package/packages/frames/src/art/pixel-8a.svg +8 -0
  75. package/packages/frames/src/art/pixel-9-pro-xl.svg +32 -0
  76. package/packages/frames/src/art/pixel-9-pro.svg +34 -0
  77. package/packages/frames/src/art/pixel-9a.svg +25 -0
  78. package/packages/frames/src/art.ts +29 -0
  79. package/packages/frames/src/devices.ts +337 -0
  80. package/packages/frames/src/index.ts +15 -0
  81. package/packages/tokens/geo/london-boroughs.json +1 -0
  82. package/packages/tokens/geo/london-dots-coarse.json +1 -0
  83. package/packages/tokens/geo/london-dots-fine.json +1 -0
  84. package/packages/tokens/geo/london-dots-medium.json +1 -0
  85. package/packages/tokens/geo/london-dots-ultra.json +1 -0
  86. package/packages/tokens/geo/london-wards.json +1 -0
  87. package/packages/tokens/glyphs/glyphs.json +1 -0
  88. package/packages/tokens/logos/f-rot.svg +4 -0
  89. package/packages/tokens/logos/f.svg +4 -0
  90. package/packages/tokens/logos/favicon-c2w.svg +9 -0
  91. package/packages/tokens/logos/favicon-console.svg +9 -0
  92. package/packages/tokens/logos/favicon-ds.svg +9 -0
  93. package/packages/tokens/logos/favicon-fleet.svg +9 -0
  94. package/packages/tokens/logos/favicon-flex.svg +9 -0
  95. package/packages/tokens/logos/favicon-plus.svg +9 -0
  96. package/packages/tokens/logos/favicon-radar.svg +9 -0
  97. package/packages/tokens/logos/favicon-tower.svg +9 -0
  98. package/packages/tokens/logos/lockup-horizontal.svg +17 -0
  99. package/packages/tokens/logos/lockup-vertical.svg +17 -0
  100. package/packages/tokens/logos/mark-mono.svg +4 -0
  101. package/packages/tokens/logos/mark.svg +7 -0
  102. package/packages/tokens/logos/wordmark.svg +9 -0
  103. package/packages/tokens/src/colourEngine.ts +604 -0
  104. package/packages/tokens/src/glyphs.ts +127 -0
  105. package/packages/tokens/src/logo.ts +50 -0
  106. package/packages/tokens/src/subbrands.ts +44 -0
  107. package/packages/tokens/src/sublogo.ts +80 -0
  108. package/packages/ui/icons/forest/map/forest-place-count.svg +21 -0
  109. package/packages/ui/icons/forest/map/forest-place-marker.svg +17 -0
  110. package/packages/ui/icons/forest/map/forest-vehicle-marker.svg +73 -0
  111. package/packages/ui/icons/forest/map/manifest.json +373 -0
  112. package/packages/ui/icons/forest/map/place-count-manifest.json +83 -0
  113. package/packages/ui/icons/forest/map/place-manifest.json +74 -0
  114. package/packages/ui/src/address/address.ts +125 -0
  115. package/packages/ui/src/address/autofill.ts +78 -0
  116. package/packages/ui/src/address/geocode.ts +55 -0
  117. package/packages/ui/src/address/index.ts +31 -0
  118. package/packages/ui/src/address/validate.ts +91 -0
  119. package/packages/ui/src/canvas/FCanvasCard.vue +97 -0
  120. package/packages/ui/src/canvas/FCanvasControl.vue +121 -0
  121. package/packages/ui/src/canvas/FCanvasControlGroup.vue +100 -0
  122. package/packages/ui/src/canvas/FCanvasFlyout.vue +216 -0
  123. package/packages/ui/src/canvas/FCanvasHud.vue +402 -0
  124. package/packages/ui/src/canvas/canvasSurface.ts +39 -0
  125. package/packages/ui/src/canvas/index.ts +17 -0
  126. package/packages/ui/src/contexts/ForestContext.vue +53 -0
  127. package/packages/ui/src/contexts/contexts.ts +153 -0
  128. package/packages/ui/src/contexts/index.ts +23 -0
  129. package/packages/ui/src/contexts/marketing-theme.ts +214 -0
  130. package/packages/ui/src/contexts/mobile-theme.ts +402 -0
  131. package/packages/ui/src/filter/FFilterBar.vue +72 -0
  132. package/packages/ui/src/filter/FFilterChip.vue +362 -0
  133. package/packages/ui/src/filter/FFilterClauses.vue +104 -0
  134. package/packages/ui/src/filter/FFilterTrigger.vue +183 -0
  135. package/packages/ui/src/filter/filter.theme.ts +27 -0
  136. package/packages/ui/src/filter/filterClause.ts +265 -0
  137. package/packages/ui/src/filter/filterPredicate.ts +134 -0
  138. package/packages/ui/src/filter/filterSchema.ts +82 -0
  139. package/packages/ui/src/filter/index.ts +36 -0
  140. package/packages/ui/src/forest-preset.ts +68 -0
  141. package/packages/ui/src/icons.ts +79 -0
  142. package/packages/ui/src/index.ts +17 -0
  143. package/packages/ui/src/kpi/FKpi.vue +253 -0
  144. package/packages/ui/src/kpi/FKpiGroup.vue +161 -0
  145. package/packages/ui/src/kpi/index.ts +20 -0
  146. package/packages/ui/src/kpi/kpi.theme.ts +168 -0
  147. package/packages/ui/src/kpi/kpiDelta.ts +82 -0
  148. package/packages/ui/src/kpi/kpiFormat.ts +72 -0
  149. package/packages/ui/src/kpi/kpiGroupState.ts +63 -0
  150. package/packages/ui/src/logo/FLogo.vue +52 -0
  151. package/packages/ui/src/logo/FSubLogo.vue +214 -0
  152. package/packages/ui/src/logo/index.ts +8 -0
  153. package/packages/ui/src/map/FMap.vue +355 -0
  154. package/packages/ui/src/map/FMapCompass.vue +154 -0
  155. package/packages/ui/src/map/FMapCoordinates.vue +134 -0
  156. package/packages/ui/src/map/FMapDrawTools.vue +116 -0
  157. package/packages/ui/src/map/FMapFullscreen.vue +92 -0
  158. package/packages/ui/src/map/FMapGeolocate.vue +208 -0
  159. package/packages/ui/src/map/FMapLayers.vue +75 -0
  160. package/packages/ui/src/map/FMapLegend.vue +45 -0
  161. package/packages/ui/src/map/FMapPopup.vue +129 -0
  162. package/packages/ui/src/map/FMapRamp.vue +69 -0
  163. package/packages/ui/src/map/FMapRegion.vue +77 -0
  164. package/packages/ui/src/map/FMapScale.vue +107 -0
  165. package/packages/ui/src/map/FMapSearch.vue +362 -0
  166. package/packages/ui/src/map/FMapTilt.vue +87 -0
  167. package/packages/ui/src/map/FMapTools.vue +61 -0
  168. package/packages/ui/src/map/FMapZoom.vue +81 -0
  169. package/packages/ui/src/map/FMarker.vue +218 -0
  170. package/packages/ui/src/map/FTrackScrubber.vue +94 -0
  171. package/packages/ui/src/map/FVehicleLegend.vue +72 -0
  172. package/packages/ui/src/map/FVehicleMark.vue +166 -0
  173. package/packages/ui/src/map/bin.ts +196 -0
  174. package/packages/ui/src/map/coordinates.ts +83 -0
  175. package/packages/ui/src/map/density.ts +401 -0
  176. package/packages/ui/src/map/draw.ts +356 -0
  177. package/packages/ui/src/map/ease.ts +67 -0
  178. package/packages/ui/src/map/fullscreen.ts +59 -0
  179. package/packages/ui/src/map/heat.ts +160 -0
  180. package/packages/ui/src/map/index.ts +351 -0
  181. package/packages/ui/src/map/liveMarkers.ts +60 -0
  182. package/packages/ui/src/map/map.css +116 -0
  183. package/packages/ui/src/map/mapColor.ts +31 -0
  184. package/packages/ui/src/map/mapContext.ts +12 -0
  185. package/packages/ui/src/map/mapLayers.ts +55 -0
  186. package/packages/ui/src/map/mapLegend.ts +70 -0
  187. package/packages/ui/src/map/mapSearch.theme.ts +79 -0
  188. package/packages/ui/src/map/mapSearch.ts +279 -0
  189. package/packages/ui/src/map/mapUrl.ts +117 -0
  190. package/packages/ui/src/map/markerAsset.ts +3 -0
  191. package/packages/ui/src/map/padding.ts +39 -0
  192. package/packages/ui/src/map/place.ts +192 -0
  193. package/packages/ui/src/map/ramp.ts +95 -0
  194. package/packages/ui/src/map/regionSelect.ts +163 -0
  195. package/packages/ui/src/map/route.ts +167 -0
  196. package/packages/ui/src/map/scale.ts +329 -0
  197. package/packages/ui/src/map/scaleBar.ts +67 -0
  198. package/packages/ui/src/map/selectionModel.ts +50 -0
  199. package/packages/ui/src/map/sources/coordinates.ts +58 -0
  200. package/packages/ui/src/map/sources/mapboxPlaces.ts +71 -0
  201. package/packages/ui/src/map/style.ts +14 -0
  202. package/packages/ui/src/map/track.ts +279 -0
  203. package/packages/ui/src/map/useFeatureSelect.ts +230 -0
  204. package/packages/ui/src/map/useLiveMarkers.ts +84 -0
  205. package/packages/ui/src/map/useMap.ts +150 -0
  206. package/packages/ui/src/map/useMapDraw.ts +776 -0
  207. package/packages/ui/src/map/useMapOverlay.ts +421 -0
  208. package/packages/ui/src/map/useMapTableSelection.ts +101 -0
  209. package/packages/ui/src/map/useMapUrlState.ts +155 -0
  210. package/packages/ui/src/map/useMarkerSource.ts +163 -0
  211. package/packages/ui/src/map/useRegionSelect.ts +309 -0
  212. package/packages/ui/src/map/useTokenMode.ts +61 -0
  213. package/packages/ui/src/map/useTrackPlayback.ts +131 -0
  214. package/packages/ui/src/map/useVehicleFilter.ts +51 -0
  215. package/packages/ui/src/map/useVisibleFeatures.ts +124 -0
  216. package/packages/ui/src/map/vehicle.ts +807 -0
  217. package/packages/ui/src/map/vehicleFilter.ts +284 -0
  218. package/packages/ui/src/map/vehicleLegend.ts +155 -0
  219. package/packages/ui/src/map/visibleFeatures.ts +192 -0
  220. package/packages/ui/src/map/zone.ts +291 -0
  221. package/packages/ui/src/shell/FAppNavbar.vue +68 -0
  222. package/packages/ui/src/shell/FAppShell.vue +163 -0
  223. package/packages/ui/src/shell/FAppSidebar.vue +215 -0
  224. package/packages/ui/src/shell/FPageToolbar.vue +126 -0
  225. package/packages/ui/src/shell/FScrollShadow.vue +43 -0
  226. package/packages/ui/src/shell/FSidePanel.vue +258 -0
  227. package/packages/ui/src/shell/FToolbarActions.vue +32 -0
  228. package/packages/ui/src/shell/index.ts +33 -0
  229. package/packages/ui/src/shell/nav.ts +37 -0
  230. package/packages/ui/src/shell/pageToolbar.ts +69 -0
  231. package/packages/ui/src/shell/shell.ts +74 -0
  232. package/packages/ui/src/shell/sidePanel.ts +115 -0
  233. package/packages/ui/src/themes/accordion.ts +36 -0
  234. package/packages/ui/src/themes/alert.ts +16 -0
  235. package/packages/ui/src/themes/auth-form.ts +19 -0
  236. package/packages/ui/src/themes/avatar-group.ts +9 -0
  237. package/packages/ui/src/themes/avatar.ts +18 -0
  238. package/packages/ui/src/themes/badge.ts +51 -0
  239. package/packages/ui/src/themes/banner.ts +21 -0
  240. package/packages/ui/src/themes/breadcrumb.ts +10 -0
  241. package/packages/ui/src/themes/button.mechanics.ts +191 -0
  242. package/packages/ui/src/themes/button.ts +136 -0
  243. package/packages/ui/src/themes/calendar.ts +29 -0
  244. package/packages/ui/src/themes/card.ts +67 -0
  245. package/packages/ui/src/themes/carousel.ts +41 -0
  246. package/packages/ui/src/themes/checkbox-group.ts +20 -0
  247. package/packages/ui/src/themes/checkbox.ts +47 -0
  248. package/packages/ui/src/themes/chip.ts +50 -0
  249. package/packages/ui/src/themes/collapsible.ts +26 -0
  250. package/packages/ui/src/themes/color-picker.ts +13 -0
  251. package/packages/ui/src/themes/command-palette.ts +37 -0
  252. package/packages/ui/src/themes/container.ts +14 -0
  253. package/packages/ui/src/themes/context-menu.ts +38 -0
  254. package/packages/ui/src/themes/dashboard-group.ts +11 -0
  255. package/packages/ui/src/themes/dashboard-navbar.ts +10 -0
  256. package/packages/ui/src/themes/dashboard-panel.ts +18 -0
  257. package/packages/ui/src/themes/dashboard-resize-handle.ts +35 -0
  258. package/packages/ui/src/themes/dashboard-sidebar-collapse.ts +7 -0
  259. package/packages/ui/src/themes/dashboard-sidebar-toggle.ts +7 -0
  260. package/packages/ui/src/themes/dashboard-sidebar.ts +58 -0
  261. package/packages/ui/src/themes/dashboard-toolbar.ts +15 -0
  262. package/packages/ui/src/themes/drawer.ts +45 -0
  263. package/packages/ui/src/themes/dropdown-menu.ts +38 -0
  264. package/packages/ui/src/themes/empty.ts +43 -0
  265. package/packages/ui/src/themes/field-group.ts +52 -0
  266. package/packages/ui/src/themes/file-upload.ts +38 -0
  267. package/packages/ui/src/themes/footer-columns.ts +13 -0
  268. package/packages/ui/src/themes/footer.ts +9 -0
  269. package/packages/ui/src/themes/form-field.ts +25 -0
  270. package/packages/ui/src/themes/form.ts +22 -0
  271. package/packages/ui/src/themes/header.ts +12 -0
  272. package/packages/ui/src/themes/icon.ts +61 -0
  273. package/packages/ui/src/themes/index.ts +99 -0
  274. package/packages/ui/src/themes/input-date.ts +73 -0
  275. package/packages/ui/src/themes/input-menu.ts +66 -0
  276. package/packages/ui/src/themes/input-number.ts +58 -0
  277. package/packages/ui/src/themes/input-rating.ts +25 -0
  278. package/packages/ui/src/themes/input-tags.ts +63 -0
  279. package/packages/ui/src/themes/input-time.ts +70 -0
  280. package/packages/ui/src/themes/input.ts +148 -0
  281. package/packages/ui/src/themes/kbd.ts +27 -0
  282. package/packages/ui/src/themes/link.ts +27 -0
  283. package/packages/ui/src/themes/listbox.ts +48 -0
  284. package/packages/ui/src/themes/main.ts +6 -0
  285. package/packages/ui/src/themes/marquee.ts +45 -0
  286. package/packages/ui/src/themes/modal.ts +46 -0
  287. package/packages/ui/src/themes/navigation-menu.ts +80 -0
  288. package/packages/ui/src/themes/page-anchors.ts +7 -0
  289. package/packages/ui/src/themes/page-aside.ts +7 -0
  290. package/packages/ui/src/themes/page-card.ts +22 -0
  291. package/packages/ui/src/themes/page-cta.ts +18 -0
  292. package/packages/ui/src/themes/page-feature.ts +13 -0
  293. package/packages/ui/src/themes/page-header.ts +16 -0
  294. package/packages/ui/src/themes/page-hero.ts +15 -0
  295. package/packages/ui/src/themes/page-links.ts +12 -0
  296. package/packages/ui/src/themes/page-section.ts +14 -0
  297. package/packages/ui/src/themes/page.ts +7 -0
  298. package/packages/ui/src/themes/pagination.ts +17 -0
  299. package/packages/ui/src/themes/pin-input.ts +73 -0
  300. package/packages/ui/src/themes/popover.ts +30 -0
  301. package/packages/ui/src/themes/progress.ts +29 -0
  302. package/packages/ui/src/themes/prose.ts +45 -0
  303. package/packages/ui/src/themes/radio-group.ts +39 -0
  304. package/packages/ui/src/themes/scroll-area.ts +23 -0
  305. package/packages/ui/src/themes/select-menu.ts +71 -0
  306. package/packages/ui/src/themes/select.ts +72 -0
  307. package/packages/ui/src/themes/separator.ts +28 -0
  308. package/packages/ui/src/themes/sidebar.ts +127 -0
  309. package/packages/ui/src/themes/skeleton.ts +15 -0
  310. package/packages/ui/src/themes/slideover.ts +87 -0
  311. package/packages/ui/src/themes/slider.ts +48 -0
  312. package/packages/ui/src/themes/stepper.ts +41 -0
  313. package/packages/ui/src/themes/switch.ts +63 -0
  314. package/packages/ui/src/themes/table.ts +34 -0
  315. package/packages/ui/src/themes/tabs.ts +74 -0
  316. package/packages/ui/src/themes/textarea.ts +77 -0
  317. package/packages/ui/src/themes/timeline.ts +25 -0
  318. package/packages/ui/src/themes/toast.ts +29 -0
  319. package/packages/ui/src/themes/toaster.ts +66 -0
  320. package/packages/ui/src/themes/tooltip.ts +31 -0
  321. package/packages/ui/src/themes/tree.ts +41 -0
  322. package/packages/ui/src/themes/user.ts +28 -0
  323. package/styles.cdn.gen.css +2047 -0
  324. package/theme.gen.json +1 -0
@@ -0,0 +1,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)