@getvitops/utils 8.2.0 → 8.3.0-alpha.1
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/CHANGELOG.md +83 -0
- package/dist/audit/index.d.mts +204 -0
- package/dist/audit/index.d.mts.map +1 -0
- package/dist/audit/index.mjs +572 -0
- package/dist/audit/index.mjs.map +1 -0
- package/dist/color/index.d.mts +6 -1
- package/dist/color/index.d.mts.map +1 -1
- package/dist/color/index.mjs +8 -1
- package/dist/color/index.mjs.map +1 -1
- package/dist/plan-CddqsmJJ.d.mts +82 -0
- package/dist/plan-CddqsmJJ.d.mts.map +1 -0
- package/dist/plan-DpHv6NO8.mjs +93 -0
- package/dist/plan-DpHv6NO8.mjs.map +1 -0
- package/dist/reviews/index.d.mts +2 -81
- package/dist/reviews/index.d.mts.map +1 -1
- package/dist/reviews/index.mjs +1 -91
- package/dist/reviews/index.mjs.map +1 -1
- package/dist/reviews/select.d.mts +2 -0
- package/dist/reviews/select.mjs +3 -0
- package/dist/turnstile/index.d.mts +105 -0
- package/dist/turnstile/index.d.mts.map +1 -0
- package/dist/turnstile/index.mjs +281 -0
- package/dist/turnstile/index.mjs.map +1 -0
- package/package.json +35 -3
package/dist/color/index.d.mts
CHANGED
|
@@ -151,8 +151,13 @@ export declare function contrastLc(fg: string, bg: string): number;
|
|
|
151
151
|
/**
|
|
152
152
|
* Pick a tinted near-white or near-black foreground for best contrast on `bg`
|
|
153
153
|
* (a whisper of the background's hue mixed into off-white/off-black).
|
|
154
|
+
*
|
|
155
|
+
* `prefer` forces the polarity instead of choosing the better one — for a role
|
|
156
|
+
* whose author wants dark text on a bright fill (or the reverse). Whether that
|
|
157
|
+
* pairing is legible is the caller's contrast check to decide, not this
|
|
158
|
+
* function's.
|
|
154
159
|
*/
|
|
155
|
-
export declare function pickOn(bg: string, hue?: number): string;
|
|
160
|
+
export declare function pickOn(bg: string, hue?: number, prefer?: 'light' | 'dark'): string;
|
|
156
161
|
/**
|
|
157
162
|
* The tinted off-white / off-black endpoints for a hue — white/black with a
|
|
158
163
|
* whisper of the hue mixed in. These are the text/background tints every ramp
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/color/colors.ts","../../src/color/color-harmony.ts"],"mappings":"A,iBAiBiB;EACf;EACA;EACA;EACA;;EAEA;;;;;;wBAOc,YAAY,WAAW,WAAW,YAAY;;;;;wBAmB9C,UAAU,WAAW,WAAW,YAAY;;;;;wBAmB5C,mBACd,WACA,WACA,WACA;qBAUW;YACD,mBAAmB;qBAElB;YAWD,qBAAqB;;;;wBAKjB,WAAW;EAAgB;EAAW;EAAW;;wBAUjD,WAAW;EAAS;EAAW;EAAW;;;;;;wBAa1C,iBAAiB;;;;;wBAiBjB,gBAAgB;;;;wBA8GhB,aAAa;;;;wBAsBb,oBAAoB;qBAcvB;YASD,sBAAsB;iBAEjB;EACf;EACA;EACA;;qBAGW,qBAAqB,OAAO,aAAa;;;;;;;;;;;;;;;qBAoDzC,kBAAkB,OAAO;;;;;;;;;;;;;;;;;;;;qBAiCzB;;;;UAEH;EACR;EACA;EACA;;;;;;;wBAQc,WAAW,sBAAsB;;;;;wBAgCjC,sBAAsB,oBAAoB;;;;;;;;;wBAuB1C,gBAAgB,MAAM,UAAU;;;;;;;;;;;;;;;;;;;wBAyBhC,qBACd,2BAA2B,yBAC1B,OAAO;;wBAsFM,WAAW,YAAY
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/color/colors.ts","../../src/color/color-harmony.ts"],"mappings":"A,iBAiBiB;EACf;EACA;EACA;EACA;;EAEA;;;;;;wBAOc,YAAY,WAAW,WAAW,YAAY;;;;;wBAmB9C,UAAU,WAAW,WAAW,YAAY;;;;;wBAmB5C,mBACd,WACA,WACA,WACA;qBAUW;YACD,mBAAmB;qBAElB;YAWD,qBAAqB;;;;wBAKjB,WAAW;EAAgB;EAAW;EAAW;;wBAUjD,WAAW;EAAS;EAAW;EAAW;;;;;;wBAa1C,iBAAiB;;;;;wBAiBjB,gBAAgB;;;;wBA8GhB,aAAa;;;;wBAsBb,oBAAoB;qBAcvB;YASD,sBAAsB;iBAEjB;EACf;EACA;EACA;;qBAGW,qBAAqB,OAAO,aAAa;;;;;;;;;;;;;;;qBAoDzC,kBAAkB,OAAO;;;;;;;;;;;;;;;;;;;;qBAiCzB;;;;UAEH;EACR;EACA;EACA;;;;;;;wBAQc,WAAW,sBAAsB;;;;;wBAgCjC,sBAAsB,oBAAoB;;;;;;;;;wBAuB1C,gBAAgB,MAAM,UAAU;;;;;;;;;;;;;;;;;;;wBAyBhC,qBACd,2BAA2B,yBAC1B,OAAO;;wBAsFM,WAAW,YAAY;;;;;;;;;;wBAavB,OAAO,YAAY,cAAc;;;;;;;;;;;wBAmBjC,gBAAgB,aAAa;EAAoB;EAAe;;;;;YCtlBpE;iBAWK;EACf,IAAI;EACJ;EACA;EACA;;qBAGW,oBAAoB;;;;wBA4DjB,qBAAqB,iBAAiB,UAAU;;;;wBAgBhD,sBACd,kBACC;EAAQ,UAAU;EAAiB;EAAa;EAAe;;;;;qBAYrD;;;;;;;;;wBAUG,0BACd,mBAAmB,yBACnB"}
|
package/dist/color/index.mjs
CHANGED
|
@@ -508,8 +508,13 @@ function contrastLc(fg, bg) {
|
|
|
508
508
|
/**
|
|
509
509
|
* Pick a tinted near-white or near-black foreground for best contrast on `bg`
|
|
510
510
|
* (a whisper of the background's hue mixed into off-white/off-black).
|
|
511
|
+
*
|
|
512
|
+
* `prefer` forces the polarity instead of choosing the better one — for a role
|
|
513
|
+
* whose author wants dark text on a bright fill (or the reverse). Whether that
|
|
514
|
+
* pairing is legible is the caller's contrast check to decide, not this
|
|
515
|
+
* function's.
|
|
511
516
|
*/
|
|
512
|
-
function pickOn(bg, hue) {
|
|
517
|
+
function pickOn(bg, hue, prefer) {
|
|
513
518
|
const h = hue ?? hexToOklch(bg).h;
|
|
514
519
|
const white = oklchToHex({
|
|
515
520
|
l: .98,
|
|
@@ -521,6 +526,8 @@ function pickOn(bg, hue) {
|
|
|
521
526
|
c: .02,
|
|
522
527
|
h
|
|
523
528
|
});
|
|
529
|
+
if (prefer === "light") return white;
|
|
530
|
+
if (prefer === "dark") return black;
|
|
524
531
|
return contrastLc(white, bg) >= contrastLc(black, bg) ? white : black;
|
|
525
532
|
}
|
|
526
533
|
/**
|
package/dist/color/index.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":["OKLCH_Space","sRGB_Space","P3_Space"],"sources":["../../src/color/colors.ts","../../src/color/color-harmony.ts"],"sourcesContent":["import Color from 'colorjs.io';\nimport {\n OKLCH as OKLCH_Space,\n sRGB as sRGB_Space,\n P3 as P3_Space,\n ColorSpace,\n to,\n toGamutCSS,\n clone,\n serialize,\n type PlainColorObject,\n} from 'colorjs.io/fn';\n\nColorSpace.register(OKLCH_Space);\nColorSpace.register(sRGB_Space);\nColorSpace.register(P3_Space);\n\nexport interface RgbResult {\n r: number;\n g: number;\n b: number;\n inGamut: boolean;\n /** Max channel overshoot beyond [0,1] before clamping. 0 when in gamut. */\n gamutDistance: number;\n}\n\n/**\n * Convert OKLCh to sRGB, returning 0-255 clamped values with gamut flag.\n * Fast path for per-pixel canvas rendering (no gamut mapping, just clamp).\n */\nexport function oklchToSrgb(l: number, c: number, h: number): RgbResult {\n const col: PlainColorObject = { space: OKLCH_Space, coords: [l, c, h], alpha: 1 };\n const rgb = to(col, sRGB_Space);\n const [r, g, b] = rgb.coords;\n const inGamut = r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;\n const gamutDistance = inGamut ? 0 : Math.max(-r, r - 1, -g, g - 1, -b, b - 1, 0);\n return {\n r: Math.max(0, Math.min(255, Math.round(r * 255))),\n g: Math.max(0, Math.min(255, Math.round(g * 255))),\n b: Math.max(0, Math.min(255, Math.round(b * 255))),\n inGamut,\n gamutDistance,\n };\n}\n\n/**\n * Convert OKLCh to Display P3, returning 0-255 clamped values with gamut flag.\n * Fast path for per-pixel canvas rendering on P3 displays.\n */\nexport function oklchToP3(l: number, c: number, h: number): RgbResult {\n const col: PlainColorObject = { space: OKLCH_Space, coords: [l, c, h], alpha: 1 };\n const rgb = to(col, P3_Space);\n const [r, g, b] = rgb.coords;\n const inGamut = r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;\n const gamutDistance = inGamut ? 0 : Math.max(-r, r - 1, -g, g - 1, -b, b - 1, 0);\n return {\n r: Math.max(0, Math.min(255, Math.round(r * 255))),\n g: Math.max(0, Math.min(255, Math.round(g * 255))),\n b: Math.max(0, Math.min(255, Math.round(b * 255))),\n inGamut,\n gamutDistance,\n };\n}\n\n/**\n * Gamut-map an OKLCh color using the CSS gamut mapping algorithm, return hex.\n * Slower than oklchToSrgb/oklchToP3 (uses iterative reduction), use for single colors only.\n */\nexport function gamutMapOklchToHex(\n l: number,\n c: number,\n h: number,\n space: 'srgb' | 'p3' = 'srgb',\n): string {\n const col: PlainColorObject = { space: OKLCH_Space, coords: [l, c, h], alpha: 1 };\n const targetSpace = space === 'p3' ? P3_Space : sRGB_Space;\n const mapped = toGamutCSS(clone(col), { space: targetSpace });\n const rgb = to(mapped, sRGB_Space);\n return serialize(rgb, { format: 'hex' });\n}\n\n// Standard 11-step scale - will be transposed based on input anchors\nexport const STEP_NAMES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;\nexport type StepName = (typeof STEP_NAMES)[number];\n\nexport const COLOR_GROUPS = [\n 'neutral',\n 'brand',\n 'primary',\n 'accent',\n 'complement',\n 'success',\n 'warning',\n 'error',\n 'info',\n] as const;\nexport type ColorGroup = (typeof COLOR_GROUPS)[number];\n\n/**\n * Convert hex color to OKLCH components\n */\nexport function hexToOklch(hex: string): { l: number; c: number; h: number } {\n const color = new Color(hex);\n const oklch = color.to('oklch');\n return {\n l: oklch.coords[0],\n c: oklch.coords[1],\n h: oklch.coords[2] || 0,\n };\n}\n\nexport function oklchToHex(oklch: { l: number; c: number; h: number }): string {\n const color = new Color('oklch', [oklch.l, oklch.c, oklch.h]);\n const srgb = color.to('srgb');\n if (!srgb.inGamut()) {\n srgb.toGamut({ method: 'css' });\n }\n return srgb.toString({ format: 'hex' });\n}\n\n/**\n * Convert OKLCH CSS string to hex color\n * @example oklchStringToHex(\"oklch(50% 0.15 270)\") => \"#8b5cf6\"\n */\nexport function oklchStringToHex(oklchString: string): string {\n try {\n const color = new Color(oklchString);\n const srgb = color.to('srgb');\n if (!srgb.inGamut()) {\n srgb.toGamut({ method: 'css' });\n }\n return srgb.toString({ format: 'hex' });\n } catch {\n return '#808080';\n }\n}\n\n/**\n * Format a color name by separating compound words\n * Examples: \"mediumseagreen\" -> \"Medium Sea Green\", \"darkblue\" -> \"Dark Blue\"\n */\nexport function formatColorName(name: string): string {\n if (!name) return 'Unknown';\n\n const specialCases: Record<string, string> = {\n aliceblue: 'Alice Blue',\n antiquewhite: 'Antique White',\n blanchedalmond: 'Blanched Almond',\n blueviolet: 'Blue Violet',\n burlywood: 'Burly Wood',\n cadetblue: 'Cadet Blue',\n cornflowerblue: 'Cornflower Blue',\n darkblue: 'Dark Blue',\n darkcyan: 'Dark Cyan',\n darkgoldenrod: 'Dark Goldenrod',\n darkgray: 'Dark Gray',\n darkgreen: 'Dark Green',\n darkgrey: 'Dark Grey',\n darkkhaki: 'Dark Khaki',\n darkmagenta: 'Dark Magenta',\n darkolivegreen: 'Dark Olive Green',\n darkorange: 'Dark Orange',\n darkorchid: 'Dark Orchid',\n darkred: 'Dark Red',\n darksalmon: 'Dark Salmon',\n darkseagreen: 'Dark Sea Green',\n darkslateblue: 'Dark Slate Blue',\n darkslategray: 'Dark Slate Gray',\n darkslategrey: 'Dark Slate Grey',\n darkturquoise: 'Dark Turquoise',\n darkviolet: 'Dark Violet',\n deeppink: 'Deep Pink',\n deepskyblue: 'Deep Sky Blue',\n dimgray: 'Dim Gray',\n dimgrey: 'Dim Grey',\n dodgerblue: 'Dodger Blue',\n floralwhite: 'Floral White',\n forestgreen: 'Forest Green',\n ghostwhite: 'Ghost White',\n greenyellow: 'Green Yellow',\n hotpink: 'Hot Pink',\n indianred: 'Indian Red',\n lavenderblush: 'Lavender Blush',\n lawngreen: 'Lawn Green',\n lemonchiffon: 'Lemon Chiffon',\n lightblue: 'Light Blue',\n lightcoral: 'Light Coral',\n lightcyan: 'Light Cyan',\n lightgoldenrodyellow: 'Light Goldenrod Yellow',\n lightgray: 'Light Gray',\n lightgreen: 'Light Green',\n lightgrey: 'Light Grey',\n lightpink: 'Light Pink',\n lightsalmon: 'Light Salmon',\n lightseagreen: 'Light Sea Green',\n lightskyblue: 'Light Sky Blue',\n lightslategray: 'Light Slate Gray',\n lightslategrey: 'Light Slate Grey',\n lightsteelblue: 'Light Steel Blue',\n lightyellow: 'Light Yellow',\n limegreen: 'Lime Green',\n mediumaquamarine: 'Medium Aquamarine',\n mediumblue: 'Medium Blue',\n mediumorchid: 'Medium Orchid',\n mediumpurple: 'Medium Purple',\n mediumseagreen: 'Medium Sea Green',\n mediumslateblue: 'Medium Slate Blue',\n mediumspringgreen: 'Medium Spring Green',\n mediumturquoise: 'Medium Turquoise',\n mediumvioletred: 'Medium Violet Red',\n midnightblue: 'Midnight Blue',\n mintcream: 'Mint Cream',\n mistyrose: 'Misty Rose',\n navajowhite: 'Navajo White',\n oldlace: 'Old Lace',\n olivedrab: 'Olive Drab',\n orangered: 'Orange Red',\n palegoldenrod: 'Pale Goldenrod',\n palegreen: 'Pale Green',\n paleturquoise: 'Pale Turquoise',\n palevioletred: 'Pale Violet Red',\n papayawhip: 'Papaya Whip',\n peachpuff: 'Peach Puff',\n powderblue: 'Powder Blue',\n rosybrown: 'Rosy Brown',\n royalblue: 'Royal Blue',\n saddlebrown: 'Saddle Brown',\n sandybrown: 'Sandy Brown',\n seagreen: 'Sea Green',\n skyblue: 'Sky Blue',\n slateblue: 'Slate Blue',\n slategray: 'Slate Gray',\n slategrey: 'Slate Grey',\n springgreen: 'Spring Green',\n steelblue: 'Steel Blue',\n whitesmoke: 'White Smoke',\n yellowgreen: 'Yellow Green',\n };\n\n const lowerName = name.toLowerCase();\n if (specialCases[lowerName]) {\n return specialCases[lowerName];\n }\n\n // Default: just capitalize first letter\n return name.charAt(0).toUpperCase() + name.slice(1);\n}\n\n/**\n * Get the closest CSS color name for a hex color\n */\nexport function getColorName(hex: string): string {\n try {\n const color = new Color(hex);\n const oklch = color.to('oklch');\n const hue = oklch.coords[2] || 0;\n\n if (hue < 30 || hue >= 330) return 'Red';\n if (hue < 60) return 'Orange';\n if (hue < 90) return 'Yellow';\n if (hue < 150) return 'Green';\n if (hue < 210) return 'Cyan';\n if (hue < 270) return 'Blue';\n if (hue < 330) return 'Purple';\n return 'Unknown';\n } catch {\n return 'Unknown';\n }\n}\n\n/**\n * Get color name with hue value\n */\nexport function getColorNameWithHue(hex: string): string {\n try {\n const color = new Color(hex);\n const oklch = color.to('oklch');\n const hue = oklch.coords[2] || 0;\n const roundedHue = Math.round(hue / 10) * 10;\n const name = getColorName(hex);\n return `${name} ${roundedHue}`;\n } catch {\n return 'Unknown';\n }\n}\n\n// Color harmony definitions\nexport const HARMONY_TYPES = [\n 'single',\n 'analogous',\n 'triad',\n 'complementary',\n 'split-complementary',\n 'square',\n 'compound',\n] as const;\nexport type HarmonyType = (typeof HARMONY_TYPES)[number];\n\nexport interface HarmonyDefinition {\n label: string;\n offsets: number[];\n primaryIndex: number;\n}\n\nexport const HARMONY_DEFINITIONS: Record<HarmonyType, HarmonyDefinition> = {\n single: {\n label: 'Single',\n offsets: [0],\n primaryIndex: 0,\n },\n analogous: {\n label: 'Analogous',\n offsets: [-30, 0, 30],\n primaryIndex: 1,\n },\n triad: {\n label: 'Triad',\n offsets: [0, 120, 240],\n primaryIndex: 0,\n },\n complementary: {\n label: 'Complementary',\n offsets: [0, 180],\n primaryIndex: 0,\n },\n 'split-complementary': {\n label: 'Split Complementary',\n offsets: [0, 150, 210],\n primaryIndex: 0,\n },\n square: {\n label: 'Square',\n offsets: [0, 90, 180, 270],\n primaryIndex: 0,\n },\n compound: {\n label: 'Compound',\n offsets: [-30, 0, 180, 210],\n primaryIndex: 1,\n },\n};\n\n/**\n * The shared lightness ladder every ramp is built on (lightest → darkest).\n *\n * Fixed, NOT seed-derived. Two hues at the same step have to read equally light,\n * otherwise one contrast table cannot serve every hue and a `muted` variant means\n * something different depending on which role you ask. Only chroma and hue vary\n * between ramps.\n *\n * The previous engine transposed this curve onto each seed, which let relative\n * luminance at step 300 range from 0.253 (rust) to 0.384 (amber) across the\n * shipped palette — the drift this replaces.\n *\n * Anchored steps are the one deliberate exception: see `generateOklchPalette`.\n */\nexport const LIGHTNESS_LADDER: Record<StepName, number> = {\n 50: 0.98,\n 100: 0.95,\n 200: 0.9,\n 300: 0.83,\n 400: 0.74,\n 500: 0.65,\n 600: 0.54,\n 700: 0.47,\n 800: 0.38,\n 900: 0.29,\n 950: 0.21,\n};\n\n/**\n * Chroma a ramp decays *towards* beyond its outermost anchors, so the near-white\n * and near-black ends keep a whisper of the hue instead of going flat grey. The\n * dark end carries slightly more because chroma reads weaker against black.\n *\n * It is a CEILING, not a target — `Math.min(ENDPOINT_CHROMA.x, anchor.c)`. That\n * distinction is the whole reason a neutral is authorable. The interpolation\n * factor reaches exactly 1 at steps 50 and 950 for every anchor position, so as a\n * target it made the endpoints these constants *unconditionally*: seeds at chroma\n * 0.001, 0.002, 0.05 and 0.2 all produced a byte-identical step 50. A brand\n * wanting plain white with grey panels had no seed that worked, and chroma 0 was\n * worst of all — colorjs returns NaN hue for a true achromatic colour, which\n * `hexToOklch` collapses to 0, so the \"neutral\" came out pink.\n *\n * As a ceiling, a low-chroma seed simply stays low and a chroma-0 seed stays 0 at\n * every unanchored step, which makes the NaN-hue collapse invisible rather than\n * something `hexToOklch` has to solve (its `|| 0` is load-bearing for\n * `getColorName`).\n */\nexport const ENDPOINT_CHROMA = { light: 0.008, dark: 0.015 } as const;\n\ninterface OklchColor {\n l: number;\n c: number;\n h: number;\n}\n\n/**\n * Parses an OKLCH CSS string into numerical components.\n * @param oklchString - e.g., 'oklch(50% 0.15 270)'\n * @returns Lightness (0-1), Chroma (0-~0.37), Hue (0-360)\n */\nexport function parseOklch(oklchString: string): OklchColor {\n const parts = oklchString.match(/oklch\\(([^ ]+) ([^ ]+) ([^ ]+)\\)/);\n if (!parts) throw new Error('Invalid OKLCH string format.');\n\n const [, lRaw = '', cRaw = '', hRaw = ''] = parts;\n const l = parseFloat(lRaw) / (lRaw.includes('%') ? 100 : 1);\n const c = parseFloat(cRaw);\n const h = parseFloat(hRaw);\n\n return { l, c, h };\n}\n\n/**\n * Linearly interpolates a value between a start and end point.\n */\nfunction lerp(start: number, end: number, factor: number): number {\n return start + factor * (end - start);\n}\n\n/**\n * Interpolate hue the short way round the wheel. A ramp anchored at 350° and 10°\n * spans 20°, not 340° — the naive lerp sent it the long way through cyan.\n */\nfunction lerpHue(start: number, end: number, factor: number): number {\n const delta = ((((end - start) % 360) + 540) % 360) - 180;\n return (start + delta * factor + 360) % 360;\n}\n\n/**\n * The ladder step whose lightness is closest to `lightness` — i.e. where a\n * supplied colour naturally belongs.\n */\nexport function assignStepByLightness(lightness: number): StepName {\n let closestStep: StepName = 500;\n let minDiff = Math.abs(lightness - LIGHTNESS_LADDER[500]);\n\n for (const step of STEP_NAMES) {\n const diff = Math.abs(lightness - LIGHTNESS_LADDER[step]);\n if (diff < minDiff) {\n minDiff = diff;\n closestStep = step;\n }\n }\n\n return closestStep;\n}\n\n/**\n * How far an anchored colour sits from its step's ladder lightness.\n *\n * The generator surfaces this as a warning: a large deviation is legal (the\n * caller asked for that exact colour) but it means the hue will read heavier or\n * lighter than its siblings at that step, which is worth knowing rather than\n * discovering later.\n */\nexport function ladderDeviation(step: StepName, lightness: number): number {\n return lightness - LIGHTNESS_LADDER[step];\n}\n\n/** Absolute chroma ceiling — beyond this nothing survives gamut mapping anyway. */\nconst MAX_CHROMA = 0.37;\n\n/**\n * Build a complete 11-step OKLCH ramp from one or more anchor colours.\n *\n * **Ladder-first with exact anchors.** Every anchor is reproduced *verbatim* at\n * its step; every other step takes its lightness from `LIGHTNESS_LADDER`, with\n * chroma and hue interpolated between the flanking anchors and decayed toward\n * `ENDPOINT_CHROMA` beyond the outermost ones.\n *\n * The consequence is the point: a client's brand hex survives byte-for-byte,\n * while the other ten steps stay directly comparable to every other hue. The\n * deviation from the ladder is bounded and *local to anchored steps* — unlike\n * the previous engine, which warped the whole ramp to fit its anchors so every\n * step drifted.\n *\n * Chroma may come out beyond the sRGB gamut here; that is deliberate.\n * `oklchStringToHex` gamut-maps with the CSS algorithm, which reduces chroma\n * while holding lightness, so the ladder survives the conversion.\n */\nexport function generateOklchPalette(\n input: string | string[] | Record<string, string>,\n): Record<StepName, string> {\n let anchors: Record<number, string> = {};\n\n if (typeof input === 'string') {\n anchors[assignStepByLightness(parseOklch(input).l)] = input;\n } else if (Array.isArray(input)) {\n for (const colorStr of input) {\n anchors[assignStepByLightness(parseOklch(colorStr).l)] = colorStr;\n }\n } else if (typeof input === 'object' && input !== null) {\n anchors = { ...input };\n } else {\n throw new Error(\n 'Input must be a color string, array of colors, or object with step assignments',\n );\n }\n\n const parsedAnchors: Record<number, OklchColor> = {};\n for (const [key, value] of Object.entries(anchors)) {\n parsedAnchors[Number(key)] = parseOklch(value);\n }\n\n const anchorSteps = Object.keys(parsedAnchors)\n .map(Number)\n .sort((a, b) => a - b) as StepName[];\n\n // Interpolate across step *index*, not the numeric label: the labels aren't\n // evenly spaced (50→100 is a smaller jump than 100→200), so lerping on them\n // would bunch the ramp up at the light end.\n const idx = (step: number) => STEP_NAMES.indexOf(step as StepName);\n const lastIdx = STEP_NAMES.length - 1;\n\n const palette: Record<number, string> = {};\n\n for (const step of STEP_NAMES) {\n // An anchor is the caller's exact colour. Emit it untouched.\n if (parsedAnchors[step]) {\n palette[step] = anchors[step] as string;\n continue;\n }\n\n // `lighter` is the nearest anchor with a smaller step (50 is lightest);\n // `darker` the nearest with a larger one.\n const lighter = [...anchorSteps].reverse().find((s) => s < step);\n const darker = anchorSteps.find((s) => s > step);\n\n let chroma: number;\n let hue: number;\n\n if (lighter !== undefined && darker !== undefined) {\n const a = parsedAnchors[lighter] as OklchColor;\n const b = parsedAnchors[darker] as OklchColor;\n const factor = (idx(step) - idx(lighter)) / (idx(darker) - idx(lighter));\n chroma = lerp(a.c, b.c, factor);\n hue = lerpHue(a.h, b.h, factor);\n } else if (lighter !== undefined) {\n // Past the darkest anchor — fade toward the tinted near-black end.\n const a = parsedAnchors[lighter] as OklchColor;\n const factor = (idx(step) - idx(lighter)) / (lastIdx - idx(lighter));\n chroma = lerp(a.c, Math.min(ENDPOINT_CHROMA.dark, a.c), factor);\n hue = a.h;\n } else if (darker !== undefined) {\n // Past the lightest anchor — fade toward the tinted near-white end.\n const b = parsedAnchors[darker] as OklchColor;\n const factor = (idx(darker) - idx(step)) / idx(darker);\n chroma = lerp(b.c, Math.min(ENDPOINT_CHROMA.light, b.c), factor);\n hue = b.h;\n } else {\n chroma = 0.1;\n hue = 0;\n }\n\n const lightness = LIGHTNESS_LADDER[step];\n const finalL = (lightness * 100).toFixed(2) + '%';\n const finalC = Math.min(chroma, MAX_CHROMA).toFixed(4);\n const finalH = hue.toFixed(2);\n\n palette[step] = `oklch(${finalL} ${finalC} ${finalH})`;\n }\n\n return palette;\n}\n\n// ── Contrast + endpoints (for the functional-token layer) ────────────────────\n\n/** APCA lightness-contrast (Lc) magnitude between a foreground and background. */\nexport function contrastLc(fg: string, bg: string): number {\n return Math.abs(new Color(bg).contrast(fg, 'APCA'));\n}\n\n/**\n * Pick a tinted near-white or near-black foreground for best contrast on `bg`\n * (a whisper of the background's hue mixed into off-white/off-black).\n */\nexport function pickOn(bg: string, hue?: number): string {\n const h = hue ?? hexToOklch(bg).h;\n const white = oklchToHex({ l: 0.98, c: 0.01, h });\n const black = oklchToHex({ l: 0.16, c: 0.02, h });\n return contrastLc(white, bg) >= contrastLc(black, bg) ? white : black;\n}\n\n/**\n * The tinted off-white / off-black endpoints for a hue — white/black with a\n * whisper of the hue mixed in. These are the text/background tints every ramp\n * needs (and the one exception strict brand kits allow).\n *\n * Derived from `LIGHTNESS_LADDER` and `ENDPOINT_CHROMA` rather than its own\n * literals, so a strict tone kit's endpoints land on the same ladder rungs a\n * generated ramp would have used. This is the single definition — callers must\n * not hand-write their own near-white/near-black.\n */\nexport function tintedEndpoints(hue: number, chroma?: number): { light: string; dark: string } {\n return {\n light: oklchToHex({\n l: LIGHTNESS_LADDER[50],\n c: Math.min(chroma ?? ENDPOINT_CHROMA.light, 0.02),\n h: hue,\n }),\n dark: oklchToHex({\n l: LIGHTNESS_LADDER[950],\n c: Math.min((chroma ?? ENDPOINT_CHROMA.dark) * 1.5, 0.03),\n h: hue,\n }),\n };\n}\n","/**\n * Color harmony strategies for generating complementary colors\n */\n\nexport type HarmonyStrategy =\n | 'complement'\n | 'analogous-plus'\n | 'analogous-minus'\n | 'triadic-plus'\n | 'triadic-minus'\n | 'split-plus'\n | 'split-minus'\n | 'tetradic-plus'\n | 'tetradic-minus';\n\nexport interface HarmonyOption {\n id: HarmonyStrategy;\n label: string;\n description: string;\n hueShift: number;\n}\n\nexport const HARMONY_STRATEGIES: HarmonyOption[] = [\n {\n id: 'complement',\n label: 'Complement',\n description: 'Opposite on color wheel (high contrast)',\n hueShift: 180,\n },\n {\n id: 'analogous-plus',\n label: 'Analogous+',\n description: 'Adjacent colors (harmonious)',\n hueShift: 30,\n },\n {\n id: 'analogous-minus',\n label: 'Analogous-',\n description: 'Adjacent colors (harmonious)',\n hueShift: -30,\n },\n {\n id: 'triadic-plus',\n label: 'Triadic+',\n description: 'Evenly spaced (balanced)',\n hueShift: 120,\n },\n {\n id: 'triadic-minus',\n label: 'Triadic-',\n description: 'Evenly spaced (balanced)',\n hueShift: -120,\n },\n {\n id: 'split-plus',\n label: 'Split Complement+',\n description: 'Near opposite (softer contrast)',\n hueShift: 150,\n },\n {\n id: 'split-minus',\n label: 'Split Complement-',\n description: 'Near opposite (softer contrast)',\n hueShift: -150,\n },\n {\n id: 'tetradic-plus',\n label: 'Tetradic+',\n description: 'Square spacing (rich palette)',\n hueShift: 90,\n },\n {\n id: 'tetradic-minus',\n label: 'Tetradic-',\n description: 'Square spacing (rich palette)',\n hueShift: -90,\n },\n];\n\n/**\n * Applies a harmony strategy to a hue value\n */\nexport function applyHarmonyStrategy(baseHue: number, strategy: HarmonyStrategy): number {\n const harmonyOption = HARMONY_STRATEGIES.find((h) => h.id === strategy);\n if (!harmonyOption) return baseHue;\n\n let newHue = baseHue + harmonyOption.hueShift;\n\n // Normalize to 0-360 range\n while (newHue < 0) newHue += 360;\n while (newHue >= 360) newHue -= 360;\n\n return newHue;\n}\n\n/**\n * Gets all harmony suggestions for a given base hue\n */\nexport function getHarmonySuggestions(\n baseHue: number,\n): Array<{ strategy: HarmonyStrategy; hue: number; label: string; description: string }> {\n return HARMONY_STRATEGIES.map((strategy) => ({\n strategy: strategy.id,\n hue: applyHarmonyStrategy(baseHue, strategy.id),\n label: strategy.label,\n description: strategy.description,\n }));\n}\n\n/**\n * Default functional color hues (if user doesn't specify)\n */\nexport const DEFAULT_FUNCTIONAL_HUES = {\n success: 142, // Green\n warning: 38, // Orange/Amber\n error: 0, // Red\n info: 200, // Blue\n} as const;\n\n/**\n * Suggests a functional color hue based on existing palette to maintain harmony\n */\nexport function suggestFunctionalColorHue(\n type: keyof typeof DEFAULT_FUNCTIONAL_HUES,\n existingHues: number[],\n): number {\n const defaultHue = DEFAULT_FUNCTIONAL_HUES[type];\n\n if (existingHues.length === 0) {\n return defaultHue;\n }\n\n // Find the closest existing hue and adjust slightly to maintain harmony\n const avgHue = existingHues.reduce((sum, h) => sum + h, 0) / existingHues.length;\n\n // Rotate the default hue to be more in line with the palette's color temperature\n const hueShift = avgHue - 200; // Assuming 200 is neutral blue\n let adjustedHue = defaultHue + hueShift * 0.3; // 30% influence\n\n // Normalize\n while (adjustedHue < 0) adjustedHue += 360;\n while (adjustedHue >= 360) adjustedHue -= 360;\n\n return adjustedHue;\n}\n"],"mappings":";;;;AAaA,WAAW,SAASA,KAAW;AAC/B,WAAW,SAASC,IAAU;AAC9B,WAAW,SAASC,EAAQ;;;;;AAe5B,SAAgB,YAAY,GAAW,GAAW,GAAsB;CAGtE,MAAM,CAAC,GAAG,GAAG,KADD,GAAG;EADiB,OAAOF;EAAa,QAAQ;GAAC;GAAG;GAAG;EAAC;EAAG,OAAO;CAC7D,GAAGC,IACA,CAAC,CAAC;CACtB,MAAM,UAAU,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK;CACvE,MAAM,gBAAgB,UAAU,IAAI,KAAK,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC;CAC/E,OAAO;EACL,GAAG,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,KAAK,MAAM,IAAI,GAAG,CAAC,CAAC;EACjD,GAAG,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,KAAK,MAAM,IAAI,GAAG,CAAC,CAAC;EACjD,GAAG,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,KAAK,MAAM,IAAI,GAAG,CAAC,CAAC;EACjD;EACA;CACF;AACF;;;;;AAMA,SAAgB,UAAU,GAAW,GAAW,GAAsB;CAGpE,MAAM,CAAC,GAAG,GAAG,KADD,GAAG;EADiB,OAAOD;EAAa,QAAQ;GAAC;GAAG;GAAG;EAAC;EAAG,OAAO;CAC7D,GAAGE,EACA,CAAC,CAAC;CACtB,MAAM,UAAU,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK,KAAK;CACvE,MAAM,gBAAgB,UAAU,IAAI,KAAK,IAAI,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC,GAAG,IAAI,GAAG,CAAC;CAC/E,OAAO;EACL,GAAG,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,KAAK,MAAM,IAAI,GAAG,CAAC,CAAC;EACjD,GAAG,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,KAAK,MAAM,IAAI,GAAG,CAAC,CAAC;EACjD,GAAG,KAAK,IAAI,GAAG,KAAK,IAAI,KAAK,KAAK,MAAM,IAAI,GAAG,CAAC,CAAC;EACjD;EACA;CACF;AACF;;;;;AAMA,SAAgB,mBACd,GACA,GACA,GACA,QAAuB,QACf;CACR,MAAM,MAAwB;EAAE,OAAOF;EAAa,QAAQ;GAAC;GAAG;GAAG;EAAC;EAAG,OAAO;CAAE;CAChF,MAAM,cAAc,UAAU,OAAOE,KAAWD;CAChD,MAAM,SAAS,WAAW,MAAM,GAAG,GAAG,EAAE,OAAO,YAAY,CAAC;CAC5D,MAAM,MAAM,GAAG,QAAQA,IAAU;CACjC,OAAO,UAAU,KAAK,EAAE,QAAQ,MAAM,CAAC;AACzC;AAGA,MAAa,aAAa;CAAC;CAAI;CAAK;CAAK;CAAK;CAAK;CAAK;CAAK;CAAK;CAAK;CAAK;AAAG;AAG/E,MAAa,eAAe;CAC1B;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;AAMA,SAAgB,WAAW,KAAkD;CAE3E,MAAM,QAAQ,IADI,MAAM,GACN,CAAC,CAAC,GAAG,OAAO;CAC9B,OAAO;EACL,GAAG,MAAM,OAAO;EAChB,GAAG,MAAM,OAAO;EAChB,GAAG,MAAM,OAAO,MAAM;CACxB;AACF;AAEA,SAAgB,WAAW,OAAoD;CAE7E,MAAM,OAAO,IADK,MAAM,SAAS;EAAC,MAAM;EAAG,MAAM;EAAG,MAAM;CAAC,CAC1C,CAAC,CAAC,GAAG,MAAM;CAC5B,IAAI,CAAC,KAAK,QAAQ,GAChB,KAAK,QAAQ,EAAE,QAAQ,MAAM,CAAC;CAEhC,OAAO,KAAK,SAAS,EAAE,QAAQ,MAAM,CAAC;AACxC;;;;;AAMA,SAAgB,iBAAiB,aAA6B;CAC5D,IAAI;EAEF,MAAM,OAAO,IADK,MAAM,WACP,CAAC,CAAC,GAAG,MAAM;EAC5B,IAAI,CAAC,KAAK,QAAQ,GAChB,KAAK,QAAQ,EAAE,QAAQ,MAAM,CAAC;EAEhC,OAAO,KAAK,SAAS,EAAE,QAAQ,MAAM,CAAC;CACxC,QAAQ;EACN,OAAO;CACT;AACF;;;;;AAMA,SAAgB,gBAAgB,MAAsB;CACpD,IAAI,CAAC,MAAM,OAAO;CAElB,MAAM,eAAuC;EAC3C,WAAW;EACX,cAAc;EACd,gBAAgB;EAChB,YAAY;EACZ,WAAW;EACX,WAAW;EACX,gBAAgB;EAChB,UAAU;EACV,UAAU;EACV,eAAe;EACf,UAAU;EACV,WAAW;EACX,UAAU;EACV,WAAW;EACX,aAAa;EACb,gBAAgB;EAChB,YAAY;EACZ,YAAY;EACZ,SAAS;EACT,YAAY;EACZ,cAAc;EACd,eAAe;EACf,eAAe;EACf,eAAe;EACf,eAAe;EACf,YAAY;EACZ,UAAU;EACV,aAAa;EACb,SAAS;EACT,SAAS;EACT,YAAY;EACZ,aAAa;EACb,aAAa;EACb,YAAY;EACZ,aAAa;EACb,SAAS;EACT,WAAW;EACX,eAAe;EACf,WAAW;EACX,cAAc;EACd,WAAW;EACX,YAAY;EACZ,WAAW;EACX,sBAAsB;EACtB,WAAW;EACX,YAAY;EACZ,WAAW;EACX,WAAW;EACX,aAAa;EACb,eAAe;EACf,cAAc;EACd,gBAAgB;EAChB,gBAAgB;EAChB,gBAAgB;EAChB,aAAa;EACb,WAAW;EACX,kBAAkB;EAClB,YAAY;EACZ,cAAc;EACd,cAAc;EACd,gBAAgB;EAChB,iBAAiB;EACjB,mBAAmB;EACnB,iBAAiB;EACjB,iBAAiB;EACjB,cAAc;EACd,WAAW;EACX,WAAW;EACX,aAAa;EACb,SAAS;EACT,WAAW;EACX,WAAW;EACX,eAAe;EACf,WAAW;EACX,eAAe;EACf,eAAe;EACf,YAAY;EACZ,WAAW;EACX,YAAY;EACZ,WAAW;EACX,WAAW;EACX,aAAa;EACb,YAAY;EACZ,UAAU;EACV,SAAS;EACT,WAAW;EACX,WAAW;EACX,WAAW;EACX,aAAa;EACb,WAAW;EACX,YAAY;EACZ,aAAa;CACf;CAEA,MAAM,YAAY,KAAK,YAAY;CACnC,IAAI,aAAa,YACf,OAAO,aAAa;CAItB,OAAO,KAAK,OAAO,CAAC,CAAC,CAAC,YAAY,IAAI,KAAK,MAAM,CAAC;AACpD;;;;AAKA,SAAgB,aAAa,KAAqB;CAChD,IAAI;EAGF,MAAM,MADQ,IADI,MAAM,GACN,CAAC,CAAC,GAAG,OACP,CAAC,CAAC,OAAO,MAAM;EAE/B,IAAI,MAAM,MAAM,OAAO,KAAK,OAAO;EACnC,IAAI,MAAM,IAAI,OAAO;EACrB,IAAI,MAAM,IAAI,OAAO;EACrB,IAAI,MAAM,KAAK,OAAO;EACtB,IAAI,MAAM,KAAK,OAAO;EACtB,IAAI,MAAM,KAAK,OAAO;EACtB,IAAI,MAAM,KAAK,OAAO;EACtB,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;;;AAKA,SAAgB,oBAAoB,KAAqB;CACvD,IAAI;EAGF,MAAM,MADQ,IADI,MAAM,GACN,CAAC,CAAC,GAAG,OACP,CAAC,CAAC,OAAO,MAAM;EAC/B,MAAM,aAAa,KAAK,MAAM,MAAM,EAAE,IAAI;EAE1C,OAAO,GADM,aAAa,GACb,EAAE,GAAG;CACpB,QAAQ;EACN,OAAO;CACT;AACF;AAGA,MAAa,gBAAgB;CAC3B;CACA;CACA;CACA;CACA;CACA;CACA;AACF;AASA,MAAa,sBAA8D;CACzE,QAAQ;EACN,OAAO;EACP,SAAS,CAAC,CAAC;EACX,cAAc;CAChB;CACA,WAAW;EACT,OAAO;EACP,SAAS;GAAC;GAAK;GAAG;EAAE;EACpB,cAAc;CAChB;CACA,OAAO;EACL,OAAO;EACP,SAAS;GAAC;GAAG;GAAK;EAAG;EACrB,cAAc;CAChB;CACA,eAAe;EACb,OAAO;EACP,SAAS,CAAC,GAAG,GAAG;EAChB,cAAc;CAChB;CACA,uBAAuB;EACrB,OAAO;EACP,SAAS;GAAC;GAAG;GAAK;EAAG;EACrB,cAAc;CAChB;CACA,QAAQ;EACN,OAAO;EACP,SAAS;GAAC;GAAG;GAAI;GAAK;EAAG;EACzB,cAAc;CAChB;CACA,UAAU;EACR,OAAO;EACP,SAAS;GAAC;GAAK;GAAG;GAAK;EAAG;EAC1B,cAAc;CAChB;AACF;;;;;;;;;;;;;;;AAgBA,MAAa,mBAA6C;CACxD,IAAI;CACJ,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;CACL,KAAK;AACP;;;;;;;;;;;;;;;;;;;;AAqBA,MAAa,kBAAkB;CAAE,OAAO;CAAO,MAAM;AAAM;;;;;;AAa3D,SAAgB,WAAW,aAAiC;CAC1D,MAAM,QAAQ,YAAY,MAAM,kCAAkC;CAClE,IAAI,CAAC,OAAO,MAAM,IAAI,MAAM,8BAA8B;CAE1D,MAAM,GAAG,OAAO,IAAI,OAAO,IAAI,OAAO,MAAM;CAK5C,OAAO;EAAE,GAJC,WAAW,IAAI,KAAK,KAAK,SAAS,GAAG,IAAI,MAAM;EAI7C,GAHF,WAAW,IAGT;EAAG,GAFL,WAAW,IAEN;CAAE;AACnB;;;;AAKA,SAAS,KAAK,OAAe,KAAa,QAAwB;CAChE,OAAO,QAAQ,UAAU,MAAM;AACjC;;;;;AAMA,SAAS,QAAQ,OAAe,KAAa,QAAwB;CAEnE,QAAQ,WADU,MAAM,SAAS,MAAO,OAAO,MAAO,OAC9B,SAAS,OAAO;AAC1C;;;;;AAMA,SAAgB,sBAAsB,WAA6B;CACjE,IAAI,cAAwB;CAC5B,IAAI,UAAU,KAAK,IAAI,YAAY,iBAAiB,IAAI;CAExD,KAAK,MAAM,QAAQ,YAAY;EAC7B,MAAM,OAAO,KAAK,IAAI,YAAY,iBAAiB,KAAK;EACxD,IAAI,OAAO,SAAS;GAClB,UAAU;GACV,cAAc;EAChB;CACF;CAEA,OAAO;AACT;;;;;;;;;AAUA,SAAgB,gBAAgB,MAAgB,WAA2B;CACzE,OAAO,YAAY,iBAAiB;AACtC;;AAGA,MAAM,aAAa;;;;;;;;;;;;;;;;;;;AAoBnB,SAAgB,qBACd,OAC0B;CAC1B,IAAI,UAAkC,CAAC;CAEvC,IAAI,OAAO,UAAU,UACnB,QAAQ,sBAAsB,WAAW,KAAK,CAAC,CAAC,CAAC,KAAK;MACjD,IAAI,MAAM,QAAQ,KAAK,GAC5B,KAAK,MAAM,YAAY,OACrB,QAAQ,sBAAsB,WAAW,QAAQ,CAAC,CAAC,CAAC,KAAK;MAEtD,IAAI,OAAO,UAAU,YAAY,UAAU,MAChD,UAAU,EAAE,GAAG,MAAM;MAErB,MAAM,IAAI,MACR,gFACF;CAGF,MAAM,gBAA4C,CAAC;CACnD,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,OAAO,GAC/C,cAAc,OAAO,GAAG,KAAK,WAAW,KAAK;CAG/C,MAAM,cAAc,OAAO,KAAK,aAAa,CAAC,CAC3C,IAAI,MAAM,CAAC,CACX,MAAM,GAAG,MAAM,IAAI,CAAC;CAKvB,MAAM,OAAO,SAAiB,WAAW,QAAQ,IAAgB;CACjE,MAAM,UAAU,WAAW,SAAS;CAEpC,MAAM,UAAkC,CAAC;CAEzC,KAAK,MAAM,QAAQ,YAAY;EAE7B,IAAI,cAAc,OAAO;GACvB,QAAQ,QAAQ,QAAQ;GACxB;EACF;EAIA,MAAM,UAAU,CAAC,GAAG,WAAW,CAAC,CAAC,QAAQ,CAAC,CAAC,MAAM,MAAM,IAAI,IAAI;EAC/D,MAAM,SAAS,YAAY,MAAM,MAAM,IAAI,IAAI;EAE/C,IAAI;EACJ,IAAI;EAEJ,IAAI,YAAY,UAAa,WAAW,QAAW;GACjD,MAAM,IAAI,cAAc;GACxB,MAAM,IAAI,cAAc;GACxB,MAAM,UAAU,IAAI,IAAI,IAAI,IAAI,OAAO,MAAM,IAAI,MAAM,IAAI,IAAI,OAAO;GACtE,SAAS,KAAK,EAAE,GAAG,EAAE,GAAG,MAAM;GAC9B,MAAM,QAAQ,EAAE,GAAG,EAAE,GAAG,MAAM;EAChC,OAAO,IAAI,YAAY,QAAW;GAEhC,MAAM,IAAI,cAAc;GACxB,MAAM,UAAU,IAAI,IAAI,IAAI,IAAI,OAAO,MAAM,UAAU,IAAI,OAAO;GAClE,SAAS,KAAK,EAAE,GAAG,KAAK,IAAI,gBAAgB,MAAM,EAAE,CAAC,GAAG,MAAM;GAC9D,MAAM,EAAE;EACV,OAAO,IAAI,WAAW,QAAW;GAE/B,MAAM,IAAI,cAAc;GACxB,MAAM,UAAU,IAAI,MAAM,IAAI,IAAI,IAAI,KAAK,IAAI,MAAM;GACrD,SAAS,KAAK,EAAE,GAAG,KAAK,IAAI,gBAAgB,OAAO,EAAE,CAAC,GAAG,MAAM;GAC/D,MAAM,EAAE;EACV,OAAO;GACL,SAAS;GACT,MAAM;EACR;EAOA,QAAQ,QAAQ,UALE,iBAAiB,QACP,IAAG,CAAE,QAAQ,CAAC,IAAI,IAId,GAHjB,KAAK,IAAI,QAAQ,UAAU,CAAC,CAAC,QAAQ,CAGZ,EAAE,GAF3B,IAAI,QAAQ,CAEuB,EAAE;CACtD;CAEA,OAAO;AACT;;AAKA,SAAgB,WAAW,IAAY,IAAoB;CACzD,OAAO,KAAK,IAAI,IAAI,MAAM,EAAE,CAAC,CAAC,SAAS,IAAI,MAAM,CAAC;AACpD;;;;;AAMA,SAAgB,OAAO,IAAY,KAAsB;CACvD,MAAM,IAAI,OAAO,WAAW,EAAE,CAAC,CAAC;CAChC,MAAM,QAAQ,WAAW;EAAE,GAAG;EAAM,GAAG;EAAM;CAAE,CAAC;CAChD,MAAM,QAAQ,WAAW;EAAE,GAAG;EAAM,GAAG;EAAM;CAAE,CAAC;CAChD,OAAO,WAAW,OAAO,EAAE,KAAK,WAAW,OAAO,EAAE,IAAI,QAAQ;AAClE;;;;;;;;;;;AAYA,SAAgB,gBAAgB,KAAa,QAAkD;CAC7F,OAAO;EACL,OAAO,WAAW;GAChB,GAAG,iBAAiB;GACpB,GAAG,KAAK,IAAI,UAAU,gBAAgB,OAAO,GAAI;GACjD,GAAG;EACL,CAAC;EACD,MAAM,WAAW;GACf,GAAG,iBAAiB;GACpB,GAAG,KAAK,KAAK,UAAU,gBAAgB,QAAQ,KAAK,GAAI;GACxD,GAAG;EACL,CAAC;CACH;AACF;;;AC1kBA,MAAa,qBAAsC;CACjD;EACE,IAAI;EACJ,OAAO;EACP,aAAa;EACb,UAAU;CACZ;CACA;EACE,IAAI;EACJ,OAAO;EACP,aAAa;EACb,UAAU;CACZ;CACA;EACE,IAAI;EACJ,OAAO;EACP,aAAa;EACb,UAAU;CACZ;CACA;EACE,IAAI;EACJ,OAAO;EACP,aAAa;EACb,UAAU;CACZ;CACA;EACE,IAAI;EACJ,OAAO;EACP,aAAa;EACb,UAAU;CACZ;CACA;EACE,IAAI;EACJ,OAAO;EACP,aAAa;EACb,UAAU;CACZ;CACA;EACE,IAAI;EACJ,OAAO;EACP,aAAa;EACb,UAAU;CACZ;CACA;EACE,IAAI;EACJ,OAAO;EACP,aAAa;EACb,UAAU;CACZ;CACA;EACE,IAAI;EACJ,OAAO;EACP,aAAa;EACb,UAAU;CACZ;AACF;;;;AAKA,SAAgB,qBAAqB,SAAiB,UAAmC;CACvF,MAAM,gBAAgB,mBAAmB,MAAM,MAAM,EAAE,OAAO,QAAQ;CACtE,IAAI,CAAC,eAAe,OAAO;CAE3B,IAAI,SAAS,UAAU,cAAc;CAGrC,OAAO,SAAS,GAAG,UAAU;CAC7B,OAAO,UAAU,KAAK,UAAU;CAEhC,OAAO;AACT;;;;AAKA,SAAgB,sBACd,SACuF;CACvF,OAAO,mBAAmB,KAAK,cAAc;EAC3C,UAAU,SAAS;EACnB,KAAK,qBAAqB,SAAS,SAAS,EAAE;EAC9C,OAAO,SAAS;EAChB,aAAa,SAAS;CACxB,EAAE;AACJ;;;;AAKA,MAAa,0BAA0B;CACrC,SAAS;CACT,SAAS;CACT,OAAO;CACP,MAAM;AACR;;;;AAKA,SAAgB,0BACd,MACA,cACQ;CACR,MAAM,aAAa,wBAAwB;CAE3C,IAAI,aAAa,WAAW,GAC1B,OAAO;CAQT,IAAI,cAAc,cAJH,aAAa,QAAQ,KAAK,MAAM,MAAM,GAAG,CAAC,IAAI,aAAa,SAGhD,OACgB;CAG1C,OAAO,cAAc,GAAG,eAAe;CACvC,OAAO,eAAe,KAAK,eAAe;CAE1C,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"index.mjs","names":["OKLCH_Space","sRGB_Space","P3_Space"],"sources":["../../src/color/colors.ts","../../src/color/color-harmony.ts"],"sourcesContent":["import Color from 'colorjs.io';\nimport {\n OKLCH as OKLCH_Space,\n sRGB as sRGB_Space,\n P3 as P3_Space,\n ColorSpace,\n to,\n toGamutCSS,\n clone,\n serialize,\n type PlainColorObject,\n} from 'colorjs.io/fn';\n\nColorSpace.register(OKLCH_Space);\nColorSpace.register(sRGB_Space);\nColorSpace.register(P3_Space);\n\nexport interface RgbResult {\n r: number;\n g: number;\n b: number;\n inGamut: boolean;\n /** Max channel overshoot beyond [0,1] before clamping. 0 when in gamut. */\n gamutDistance: number;\n}\n\n/**\n * Convert OKLCh to sRGB, returning 0-255 clamped values with gamut flag.\n * Fast path for per-pixel canvas rendering (no gamut mapping, just clamp).\n */\nexport function oklchToSrgb(l: number, c: number, h: number): RgbResult {\n const col: PlainColorObject = { space: OKLCH_Space, coords: [l, c, h], alpha: 1 };\n const rgb = to(col, sRGB_Space);\n const [r, g, b] = rgb.coords;\n const inGamut = r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;\n const gamutDistance = inGamut ? 0 : Math.max(-r, r - 1, -g, g - 1, -b, b - 1, 0);\n return {\n r: Math.max(0, Math.min(255, Math.round(r * 255))),\n g: Math.max(0, Math.min(255, Math.round(g * 255))),\n b: Math.max(0, Math.min(255, Math.round(b * 255))),\n inGamut,\n gamutDistance,\n };\n}\n\n/**\n * Convert OKLCh to Display P3, returning 0-255 clamped values with gamut flag.\n * Fast path for per-pixel canvas rendering on P3 displays.\n */\nexport function oklchToP3(l: number, c: number, h: number): RgbResult {\n const col: PlainColorObject = { space: OKLCH_Space, coords: [l, c, h], alpha: 1 };\n const rgb = to(col, P3_Space);\n const [r, g, b] = rgb.coords;\n const inGamut = r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;\n const gamutDistance = inGamut ? 0 : Math.max(-r, r - 1, -g, g - 1, -b, b - 1, 0);\n return {\n r: Math.max(0, Math.min(255, Math.round(r * 255))),\n g: Math.max(0, Math.min(255, Math.round(g * 255))),\n b: Math.max(0, Math.min(255, Math.round(b * 255))),\n inGamut,\n gamutDistance,\n };\n}\n\n/**\n * Gamut-map an OKLCh color using the CSS gamut mapping algorithm, return hex.\n * Slower than oklchToSrgb/oklchToP3 (uses iterative reduction), use for single colors only.\n */\nexport function gamutMapOklchToHex(\n l: number,\n c: number,\n h: number,\n space: 'srgb' | 'p3' = 'srgb',\n): string {\n const col: PlainColorObject = { space: OKLCH_Space, coords: [l, c, h], alpha: 1 };\n const targetSpace = space === 'p3' ? P3_Space : sRGB_Space;\n const mapped = toGamutCSS(clone(col), { space: targetSpace });\n const rgb = to(mapped, sRGB_Space);\n return serialize(rgb, { format: 'hex' });\n}\n\n// Standard 11-step scale - will be transposed based on input anchors\nexport const STEP_NAMES = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const;\nexport type StepName = (typeof STEP_NAMES)[number];\n\nexport const COLOR_GROUPS = [\n 'neutral',\n 'brand',\n 'primary',\n 'accent',\n 'complement',\n 'success',\n 'warning',\n 'error',\n 'info',\n] as const;\nexport type ColorGroup = (typeof COLOR_GROUPS)[number];\n\n/**\n * Convert hex color to OKLCH components\n */\nexport function hexToOklch(hex: string): { l: number; c: number; h: number } {\n const color = new Color(hex);\n const oklch = color.to('oklch');\n return {\n l: oklch.coords[0],\n c: oklch.coords[1],\n h: oklch.coords[2] || 0,\n };\n}\n\nexport function oklchToHex(oklch: { l: number; c: number; h: number }): string {\n const color = new Color('oklch', [oklch.l, oklch.c, oklch.h]);\n const srgb = color.to('srgb');\n if (!srgb.inGamut()) {\n srgb.toGamut({ method: 'css' });\n }\n return srgb.toString({ format: 'hex' });\n}\n\n/**\n * Convert OKLCH CSS string to hex color\n * @example oklchStringToHex(\"oklch(50% 0.15 270)\") => \"#8b5cf6\"\n */\nexport function oklchStringToHex(oklchString: string): string {\n try {\n const color = new Color(oklchString);\n const srgb = color.to('srgb');\n if (!srgb.inGamut()) {\n srgb.toGamut({ method: 'css' });\n }\n return srgb.toString({ format: 'hex' });\n } catch {\n return '#808080';\n }\n}\n\n/**\n * Format a color name by separating compound words\n * Examples: \"mediumseagreen\" -> \"Medium Sea Green\", \"darkblue\" -> \"Dark Blue\"\n */\nexport function formatColorName(name: string): string {\n if (!name) return 'Unknown';\n\n const specialCases: Record<string, string> = {\n aliceblue: 'Alice Blue',\n antiquewhite: 'Antique White',\n blanchedalmond: 'Blanched Almond',\n blueviolet: 'Blue Violet',\n burlywood: 'Burly Wood',\n cadetblue: 'Cadet Blue',\n cornflowerblue: 'Cornflower Blue',\n darkblue: 'Dark Blue',\n darkcyan: 'Dark Cyan',\n darkgoldenrod: 'Dark Goldenrod',\n darkgray: 'Dark Gray',\n darkgreen: 'Dark Green',\n darkgrey: 'Dark Grey',\n darkkhaki: 'Dark Khaki',\n darkmagenta: 'Dark Magenta',\n darkolivegreen: 'Dark Olive Green',\n darkorange: 'Dark Orange',\n darkorchid: 'Dark Orchid',\n darkred: 'Dark Red',\n darksalmon: 'Dark Salmon',\n darkseagreen: 'Dark Sea Green',\n darkslateblue: 'Dark Slate Blue',\n darkslategray: 'Dark Slate Gray',\n darkslategrey: 'Dark Slate Grey',\n darkturquoise: 'Dark Turquoise',\n darkviolet: 'Dark Violet',\n deeppink: 'Deep Pink',\n deepskyblue: 'Deep Sky Blue',\n dimgray: 'Dim Gray',\n dimgrey: 'Dim Grey',\n dodgerblue: 'Dodger Blue',\n floralwhite: 'Floral White',\n forestgreen: 'Forest Green',\n ghostwhite: 'Ghost White',\n greenyellow: 'Green Yellow',\n hotpink: 'Hot Pink',\n indianred: 'Indian Red',\n lavenderblush: 'Lavender Blush',\n lawngreen: 'Lawn Green',\n lemonchiffon: 'Lemon Chiffon',\n lightblue: 'Light Blue',\n lightcoral: 'Light Coral',\n lightcyan: 'Light Cyan',\n lightgoldenrodyellow: 'Light Goldenrod Yellow',\n lightgray: 'Light Gray',\n lightgreen: 'Light Green',\n lightgrey: 'Light Grey',\n lightpink: 'Light Pink',\n lightsalmon: 'Light Salmon',\n lightseagreen: 'Light Sea Green',\n lightskyblue: 'Light Sky Blue',\n lightslategray: 'Light Slate Gray',\n lightslategrey: 'Light Slate Grey',\n lightsteelblue: 'Light Steel Blue',\n lightyellow: 'Light Yellow',\n limegreen: 'Lime Green',\n mediumaquamarine: 'Medium Aquamarine',\n mediumblue: 'Medium Blue',\n mediumorchid: 'Medium Orchid',\n mediumpurple: 'Medium Purple',\n mediumseagreen: 'Medium Sea Green',\n mediumslateblue: 'Medium Slate Blue',\n mediumspringgreen: 'Medium Spring Green',\n mediumturquoise: 'Medium Turquoise',\n mediumvioletred: 'Medium Violet Red',\n midnightblue: 'Midnight Blue',\n mintcream: 'Mint Cream',\n mistyrose: 'Misty Rose',\n navajowhite: 'Navajo White',\n oldlace: 'Old Lace',\n olivedrab: 'Olive Drab',\n orangered: 'Orange Red',\n palegoldenrod: 'Pale Goldenrod',\n palegreen: 'Pale Green',\n paleturquoise: 'Pale Turquoise',\n palevioletred: 'Pale Violet Red',\n papayawhip: 'Papaya Whip',\n peachpuff: 'Peach Puff',\n powderblue: 'Powder Blue',\n rosybrown: 'Rosy Brown',\n royalblue: 'Royal Blue',\n saddlebrown: 'Saddle Brown',\n sandybrown: 'Sandy Brown',\n seagreen: 'Sea Green',\n skyblue: 'Sky Blue',\n slateblue: 'Slate Blue',\n slategray: 'Slate Gray',\n slategrey: 'Slate Grey',\n springgreen: 'Spring Green',\n steelblue: 'Steel Blue',\n whitesmoke: 'White Smoke',\n yellowgreen: 'Yellow Green',\n };\n\n const lowerName = name.toLowerCase();\n if (specialCases[lowerName]) {\n return specialCases[lowerName];\n }\n\n // Default: just capitalize first letter\n return name.charAt(0).toUpperCase() + name.slice(1);\n}\n\n/**\n * Get the closest CSS color name for a hex color\n */\nexport function getColorName(hex: string): string {\n try {\n const color = new Color(hex);\n const oklch = color.to('oklch');\n const hue = oklch.coords[2] || 0;\n\n if (hue < 30 || hue >= 330) return 'Red';\n if (hue < 60) return 'Orange';\n if (hue < 90) return 'Yellow';\n if (hue < 150) return 'Green';\n if (hue < 210) return 'Cyan';\n if (hue < 270) return 'Blue';\n if (hue < 330) return 'Purple';\n return 'Unknown';\n } catch {\n return 'Unknown';\n }\n}\n\n/**\n * Get color name with hue value\n */\nexport function getColorNameWithHue(hex: string): string {\n try {\n const color = new Color(hex);\n const oklch = color.to('oklch');\n const hue = oklch.coords[2] || 0;\n const roundedHue = Math.round(hue / 10) * 10;\n const name = getColorName(hex);\n return `${name} ${roundedHue}`;\n } catch {\n return 'Unknown';\n }\n}\n\n// Color harmony definitions\nexport const HARMONY_TYPES = [\n 'single',\n 'analogous',\n 'triad',\n 'complementary',\n 'split-complementary',\n 'square',\n 'compound',\n] as const;\nexport type HarmonyType = (typeof HARMONY_TYPES)[number];\n\nexport interface HarmonyDefinition {\n label: string;\n offsets: number[];\n primaryIndex: number;\n}\n\nexport const HARMONY_DEFINITIONS: Record<HarmonyType, HarmonyDefinition> = {\n single: {\n label: 'Single',\n offsets: [0],\n primaryIndex: 0,\n },\n analogous: {\n label: 'Analogous',\n offsets: [-30, 0, 30],\n primaryIndex: 1,\n },\n triad: {\n label: 'Triad',\n offsets: [0, 120, 240],\n primaryIndex: 0,\n },\n complementary: {\n label: 'Complementary',\n offsets: [0, 180],\n primaryIndex: 0,\n },\n 'split-complementary': {\n label: 'Split Complementary',\n offsets: [0, 150, 210],\n primaryIndex: 0,\n },\n square: {\n label: 'Square',\n offsets: [0, 90, 180, 270],\n primaryIndex: 0,\n },\n compound: {\n label: 'Compound',\n offsets: [-30, 0, 180, 210],\n primaryIndex: 1,\n },\n};\n\n/**\n * The shared lightness ladder every ramp is built on (lightest → darkest).\n *\n * Fixed, NOT seed-derived. Two hues at the same step have to read equally light,\n * otherwise one contrast table cannot serve every hue and a `muted` variant means\n * something different depending on which role you ask. Only chroma and hue vary\n * between ramps.\n *\n * The previous engine transposed this curve onto each seed, which let relative\n * luminance at step 300 range from 0.253 (rust) to 0.384 (amber) across the\n * shipped palette — the drift this replaces.\n *\n * Anchored steps are the one deliberate exception: see `generateOklchPalette`.\n */\nexport const LIGHTNESS_LADDER: Record<StepName, number> = {\n 50: 0.98,\n 100: 0.95,\n 200: 0.9,\n 300: 0.83,\n 400: 0.74,\n 500: 0.65,\n 600: 0.54,\n 700: 0.47,\n 800: 0.38,\n 900: 0.29,\n 950: 0.21,\n};\n\n/**\n * Chroma a ramp decays *towards* beyond its outermost anchors, so the near-white\n * and near-black ends keep a whisper of the hue instead of going flat grey. The\n * dark end carries slightly more because chroma reads weaker against black.\n *\n * It is a CEILING, not a target — `Math.min(ENDPOINT_CHROMA.x, anchor.c)`. That\n * distinction is the whole reason a neutral is authorable. The interpolation\n * factor reaches exactly 1 at steps 50 and 950 for every anchor position, so as a\n * target it made the endpoints these constants *unconditionally*: seeds at chroma\n * 0.001, 0.002, 0.05 and 0.2 all produced a byte-identical step 50. A brand\n * wanting plain white with grey panels had no seed that worked, and chroma 0 was\n * worst of all — colorjs returns NaN hue for a true achromatic colour, which\n * `hexToOklch` collapses to 0, so the \"neutral\" came out pink.\n *\n * As a ceiling, a low-chroma seed simply stays low and a chroma-0 seed stays 0 at\n * every unanchored step, which makes the NaN-hue collapse invisible rather than\n * something `hexToOklch` has to solve (its `|| 0` is load-bearing for\n * `getColorName`).\n */\nexport const ENDPOINT_CHROMA = { light: 0.008, dark: 0.015 } as const;\n\ninterface OklchColor {\n l: number;\n c: number;\n h: number;\n}\n\n/**\n * Parses an OKLCH CSS string into numerical components.\n * @param oklchString - e.g., 'oklch(50% 0.15 270)'\n * @returns Lightness (0-1), Chroma (0-~0.37), Hue (0-360)\n */\nexport function parseOklch(oklchString: string): OklchColor {\n const parts = oklchString.match(/oklch\\(([^ ]+) ([^ ]+) ([^ ]+)\\)/);\n if (!parts) throw new Error('Invalid OKLCH string format.');\n\n const [, lRaw = '', cRaw = '', hRaw = ''] = parts;\n const l = parseFloat(lRaw) / (lRaw.includes('%') ? 100 : 1);\n const c = parseFloat(cRaw);\n const h = parseFloat(hRaw);\n\n return { l, c, h };\n}\n\n/**\n * Linearly interpolates a value between a start and end point.\n */\nfunction lerp(start: number, end: number, factor: number): number {\n return start + factor * (end - start);\n}\n\n/**\n * Interpolate hue the short way round the wheel. A ramp anchored at 350° and 10°\n * spans 20°, not 340° — the naive lerp sent it the long way through cyan.\n */\nfunction lerpHue(start: number, end: number, factor: number): number {\n const delta = ((((end - start) % 360) + 540) % 360) - 180;\n return (start + delta * factor + 360) % 360;\n}\n\n/**\n * The ladder step whose lightness is closest to `lightness` — i.e. where a\n * supplied colour naturally belongs.\n */\nexport function assignStepByLightness(lightness: number): StepName {\n let closestStep: StepName = 500;\n let minDiff = Math.abs(lightness - LIGHTNESS_LADDER[500]);\n\n for (const step of STEP_NAMES) {\n const diff = Math.abs(lightness - LIGHTNESS_LADDER[step]);\n if (diff < minDiff) {\n minDiff = diff;\n closestStep = step;\n }\n }\n\n return closestStep;\n}\n\n/**\n * How far an anchored colour sits from its step's ladder lightness.\n *\n * The generator surfaces this as a warning: a large deviation is legal (the\n * caller asked for that exact colour) but it means the hue will read heavier or\n * lighter than its siblings at that step, which is worth knowing rather than\n * discovering later.\n */\nexport function ladderDeviation(step: StepName, lightness: number): number {\n return lightness - LIGHTNESS_LADDER[step];\n}\n\n/** Absolute chroma ceiling — beyond this nothing survives gamut mapping anyway. */\nconst MAX_CHROMA = 0.37;\n\n/**\n * Build a complete 11-step OKLCH ramp from one or more anchor colours.\n *\n * **Ladder-first with exact anchors.** Every anchor is reproduced *verbatim* at\n * its step; every other step takes its lightness from `LIGHTNESS_LADDER`, with\n * chroma and hue interpolated between the flanking anchors and decayed toward\n * `ENDPOINT_CHROMA` beyond the outermost ones.\n *\n * The consequence is the point: a client's brand hex survives byte-for-byte,\n * while the other ten steps stay directly comparable to every other hue. The\n * deviation from the ladder is bounded and *local to anchored steps* — unlike\n * the previous engine, which warped the whole ramp to fit its anchors so every\n * step drifted.\n *\n * Chroma may come out beyond the sRGB gamut here; that is deliberate.\n * `oklchStringToHex` gamut-maps with the CSS algorithm, which reduces chroma\n * while holding lightness, so the ladder survives the conversion.\n */\nexport function generateOklchPalette(\n input: string | string[] | Record<string, string>,\n): Record<StepName, string> {\n let anchors: Record<number, string> = {};\n\n if (typeof input === 'string') {\n anchors[assignStepByLightness(parseOklch(input).l)] = input;\n } else if (Array.isArray(input)) {\n for (const colorStr of input) {\n anchors[assignStepByLightness(parseOklch(colorStr).l)] = colorStr;\n }\n } else if (typeof input === 'object' && input !== null) {\n anchors = { ...input };\n } else {\n throw new Error(\n 'Input must be a color string, array of colors, or object with step assignments',\n );\n }\n\n const parsedAnchors: Record<number, OklchColor> = {};\n for (const [key, value] of Object.entries(anchors)) {\n parsedAnchors[Number(key)] = parseOklch(value);\n }\n\n const anchorSteps = Object.keys(parsedAnchors)\n .map(Number)\n .sort((a, b) => a - b) as StepName[];\n\n // Interpolate across step *index*, not the numeric label: the labels aren't\n // evenly spaced (50→100 is a smaller jump than 100→200), so lerping on them\n // would bunch the ramp up at the light end.\n const idx = (step: number) => STEP_NAMES.indexOf(step as StepName);\n const lastIdx = STEP_NAMES.length - 1;\n\n const palette: Record<number, string> = {};\n\n for (const step of STEP_NAMES) {\n // An anchor is the caller's exact colour. Emit it untouched.\n if (parsedAnchors[step]) {\n palette[step] = anchors[step] as string;\n continue;\n }\n\n // `lighter` is the nearest anchor with a smaller step (50 is lightest);\n // `darker` the nearest with a larger one.\n const lighter = [...anchorSteps].reverse().find((s) => s < step);\n const darker = anchorSteps.find((s) => s > step);\n\n let chroma: number;\n let hue: number;\n\n if (lighter !== undefined && darker !== undefined) {\n const a = parsedAnchors[lighter] as OklchColor;\n const b = parsedAnchors[darker] as OklchColor;\n const factor = (idx(step) - idx(lighter)) / (idx(darker) - idx(lighter));\n chroma = lerp(a.c, b.c, factor);\n hue = lerpHue(a.h, b.h, factor);\n } else if (lighter !== undefined) {\n // Past the darkest anchor — fade toward the tinted near-black end.\n const a = parsedAnchors[lighter] as OklchColor;\n const factor = (idx(step) - idx(lighter)) / (lastIdx - idx(lighter));\n chroma = lerp(a.c, Math.min(ENDPOINT_CHROMA.dark, a.c), factor);\n hue = a.h;\n } else if (darker !== undefined) {\n // Past the lightest anchor — fade toward the tinted near-white end.\n const b = parsedAnchors[darker] as OklchColor;\n const factor = (idx(darker) - idx(step)) / idx(darker);\n chroma = lerp(b.c, Math.min(ENDPOINT_CHROMA.light, b.c), factor);\n hue = b.h;\n } else {\n chroma = 0.1;\n hue = 0;\n }\n\n const lightness = LIGHTNESS_LADDER[step];\n const finalL = (lightness * 100).toFixed(2) + '%';\n const finalC = Math.min(chroma, MAX_CHROMA).toFixed(4);\n const finalH = hue.toFixed(2);\n\n palette[step] = `oklch(${finalL} ${finalC} ${finalH})`;\n }\n\n return palette;\n}\n\n// ── Contrast + endpoints (for the functional-token layer) ────────────────────\n\n/** APCA lightness-contrast (Lc) magnitude between a foreground and background. */\nexport function contrastLc(fg: string, bg: string): number {\n return Math.abs(new Color(bg).contrast(fg, 'APCA'));\n}\n\n/**\n * Pick a tinted near-white or near-black foreground for best contrast on `bg`\n * (a whisper of the background's hue mixed into off-white/off-black).\n *\n * `prefer` forces the polarity instead of choosing the better one — for a role\n * whose author wants dark text on a bright fill (or the reverse). Whether that\n * pairing is legible is the caller's contrast check to decide, not this\n * function's.\n */\nexport function pickOn(bg: string, hue?: number, prefer?: 'light' | 'dark'): string {\n const h = hue ?? hexToOklch(bg).h;\n const white = oklchToHex({ l: 0.98, c: 0.01, h });\n const black = oklchToHex({ l: 0.16, c: 0.02, h });\n if (prefer === 'light') return white;\n if (prefer === 'dark') return black;\n return contrastLc(white, bg) >= contrastLc(black, bg) ? white : black;\n}\n\n/**\n * The tinted off-white / off-black endpoints for a hue — white/black with a\n * whisper of the hue mixed in. These are the text/background tints every ramp\n * needs (and the one exception strict brand kits allow).\n *\n * Derived from `LIGHTNESS_LADDER` and `ENDPOINT_CHROMA` rather than its own\n * literals, so a strict tone kit's endpoints land on the same ladder rungs a\n * generated ramp would have used. This is the single definition — callers must\n * not hand-write their own near-white/near-black.\n */\nexport function tintedEndpoints(hue: number, chroma?: number): { light: string; dark: string } {\n return {\n light: oklchToHex({\n l: LIGHTNESS_LADDER[50],\n c: Math.min(chroma ?? ENDPOINT_CHROMA.light, 0.02),\n h: hue,\n }),\n dark: oklchToHex({\n l: LIGHTNESS_LADDER[950],\n c: Math.min((chroma ?? ENDPOINT_CHROMA.dark) * 1.5, 0.03),\n h: hue,\n }),\n };\n}\n","/**\n * Color harmony strategies for generating complementary colors\n */\n\nexport type HarmonyStrategy =\n | 'complement'\n | 'analogous-plus'\n | 'analogous-minus'\n | 'triadic-plus'\n | 'triadic-minus'\n | 'split-plus'\n | 'split-minus'\n | 'tetradic-plus'\n | 'tetradic-minus';\n\nexport interface HarmonyOption {\n id: HarmonyStrategy;\n label: string;\n description: string;\n hueShift: number;\n}\n\nexport const HARMONY_STRATEGIES: HarmonyOption[] = [\n {\n id: 'complement',\n label: 'Complement',\n description: 'Opposite on color wheel (high contrast)',\n hueShift: 180,\n },\n {\n id: 'analogous-plus',\n label: 'Analogous+',\n description: 'Adjacent colors (harmonious)',\n hueShift: 30,\n },\n {\n id: 'analogous-minus',\n label: 'Analogous-',\n description: 'Adjacent colors (harmonious)',\n hueShift: -30,\n },\n {\n id: 'triadic-plus',\n label: 'Triadic+',\n description: 'Evenly spaced (balanced)',\n hueShift: 120,\n },\n {\n id: 'triadic-minus',\n label: 'Triadic-',\n description: 'Evenly spaced (balanced)',\n hueShift: -120,\n },\n {\n id: 'split-plus',\n label: 'Split Complement+',\n description: 'Near opposite (softer contrast)',\n hueShift: 150,\n },\n {\n id: 'split-minus',\n label: 'Split Complement-',\n description: 'Near opposite (softer contrast)',\n hueShift: -150,\n },\n {\n id: 'tetradic-plus',\n label: 'Tetradic+',\n description: 'Square spacing (rich palette)',\n hueShift: 90,\n },\n {\n id: 'tetradic-minus',\n label: 'Tetradic-',\n description: 'Square spacing (rich palette)',\n hueShift: -90,\n },\n];\n\n/**\n * Applies a harmony strategy to a hue value\n */\nexport function applyHarmonyStrategy(baseHue: number, strategy: HarmonyStrategy): number {\n const harmonyOption = HARMONY_STRATEGIES.find((h) => h.id === strategy);\n if (!harmonyOption) return baseHue;\n\n let newHue = baseHue + harmonyOption.hueShift;\n\n // Normalize to 0-360 range\n while (newHue < 0) newHue += 360;\n while (newHue >= 360) newHue -= 360;\n\n return newHue;\n}\n\n/**\n * Gets all harmony suggestions for a given base hue\n */\nexport function getHarmonySuggestions(\n baseHue: number,\n): Array<{ strategy: HarmonyStrategy; hue: number; label: string; description: string }> {\n return HARMONY_STRATEGIES.map((strategy) => ({\n strategy: strategy.id,\n hue: applyHarmonyStrategy(baseHue, strategy.id),\n label: strategy.label,\n description: strategy.description,\n }));\n}\n\n/**\n * Default functional color hues (if user doesn't specify)\n */\nexport const DEFAULT_FUNCTIONAL_HUES = {\n success: 142, // Green\n warning: 38, // Orange/Amber\n error: 0, // Red\n info: 200, // Blue\n} as const;\n\n/**\n * Suggests a functional color hue based on existing palette to maintain harmony\n */\nexport function suggestFunctionalColorHue(\n type: keyof typeof DEFAULT_FUNCTIONAL_HUES,\n existingHues: number[],\n): number {\n const defaultHue = DEFAULT_FUNCTIONAL_HUES[type];\n\n if (existingHues.length === 0) {\n return defaultHue;\n }\n\n // Find the closest existing hue and adjust slightly to maintain harmony\n const avgHue = existingHues.reduce((sum, h) => sum + h, 0) / existingHues.length;\n\n // Rotate the default hue to be more in line with the palette's color temperature\n const hueShift = avgHue - 200; // Assuming 200 is neutral blue\n let adjustedHue = defaultHue + hueShift * 0.3; // 30% influence\n\n // Normalize\n while (adjustedHue < 0) adjustedHue += 360;\n while (adjustedHue >= 360) adjustedHue -= 360;\n\n return adjustedHue;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { ReviewLocationConfig, ReviewsConfig } from "@getvitops/config";
|
|
2
|
+
/** One review, as GBP returns it and the cache stores it. */
|
|
3
|
+
interface Review {
|
|
4
|
+
id: string;
|
|
5
|
+
/** The `organization.locations[]` key this review belongs to. */
|
|
6
|
+
location: string;
|
|
7
|
+
/** 1–5. */
|
|
8
|
+
rating: number;
|
|
9
|
+
author: string;
|
|
10
|
+
authorPhoto?: string | undefined;
|
|
11
|
+
text?: string | undefined;
|
|
12
|
+
publishedAt: string;
|
|
13
|
+
updatedAt: string;
|
|
14
|
+
reply?: {
|
|
15
|
+
text: string;
|
|
16
|
+
at: string;
|
|
17
|
+
} | undefined;
|
|
18
|
+
/** Link back to the review on Google — never omit when linking to it. */
|
|
19
|
+
url?: string | undefined;
|
|
20
|
+
}
|
|
21
|
+
/** What a fetch of one location produced. */
|
|
22
|
+
interface LocationReviews {
|
|
23
|
+
locationId: string;
|
|
24
|
+
accountId: string;
|
|
25
|
+
fetchedAt: string;
|
|
26
|
+
averageRating?: number | undefined;
|
|
27
|
+
totalReviewCount?: number | undefined;
|
|
28
|
+
reviews: Review[];
|
|
29
|
+
}
|
|
30
|
+
/** The on-disk cache, as written by `writeCache`. */
|
|
31
|
+
interface ReviewCache {
|
|
32
|
+
version: 1;
|
|
33
|
+
locations: Record<string, LocationReviews>;
|
|
34
|
+
}
|
|
35
|
+
/** One location's fetch plan, or the reason it was skipped. */
|
|
36
|
+
interface LocationPlan {
|
|
37
|
+
key: string;
|
|
38
|
+
accountId?: string;
|
|
39
|
+
locationId?: string;
|
|
40
|
+
/** Present iff this location will not be fetched. */
|
|
41
|
+
skip?: string;
|
|
42
|
+
}
|
|
43
|
+
interface ReviewsPlan {
|
|
44
|
+
/** Set when the whole run is refused — nothing is fetched. */
|
|
45
|
+
blocked?: string;
|
|
46
|
+
locations: LocationPlan[];
|
|
47
|
+
notes: string[];
|
|
48
|
+
}
|
|
49
|
+
interface PlanInput {
|
|
50
|
+
config: ReviewsConfig | undefined;
|
|
51
|
+
/** Location keys with a Google listing configured (`organization.locations[].listings.google`). */
|
|
52
|
+
knownLocations: Set<string>;
|
|
53
|
+
/** `--location`: fetch exactly this one, skipping the rest. */
|
|
54
|
+
onlyLocation?: string | undefined;
|
|
55
|
+
/** Whether a Google credential was resolved. */
|
|
56
|
+
hasCredential: boolean;
|
|
57
|
+
}
|
|
58
|
+
/** Decide which locations to fetch, and why any are skipped. */
|
|
59
|
+
declare function plan(input: PlanInput): ReviewsPlan;
|
|
60
|
+
/** Render a plan as the human-readable block `--dry` prints. */
|
|
61
|
+
declare function formatPlan(p: ReviewsPlan): string;
|
|
62
|
+
/**
|
|
63
|
+
* Filter and order one location's reviews for display.
|
|
64
|
+
*
|
|
65
|
+
* Featured ids always win and keep the author's order — an explicit choice
|
|
66
|
+
* overrides any automatic ranking. The remainder is filtered by `minRating`
|
|
67
|
+
* (default 4 — a star-rating widget showing a 1-star review it did not have to
|
|
68
|
+
* is a design choice a config field should not have to spell out every time),
|
|
69
|
+
* sorted, then capped by `limit`.
|
|
70
|
+
*/
|
|
71
|
+
declare function selectReviews(reviews: Review[], config: Partial<ReviewLocationConfig>): Review[];
|
|
72
|
+
/**
|
|
73
|
+
* Merge a fresh fetch into the existing cache.
|
|
74
|
+
*
|
|
75
|
+
* Only the locations actually fetched this run are replaced; every other
|
|
76
|
+
* location's prior reviews pass through untouched. This is what makes a partial
|
|
77
|
+
* failure (one location's fetch errors) non-destructive — `cmdReviews` merges
|
|
78
|
+
* only the locations that succeeded and leaves the rest of the cache alone.
|
|
79
|
+
*/
|
|
80
|
+
declare function mergeCache(existing: ReviewCache | undefined, fetched: Record<string, LocationReviews>): ReviewCache;
|
|
81
|
+
export { mergeCache as a, LocationReviews as c, ReviewLocationConfig as d, ReviewsConfig as f, formatPlan as i, Review as l, PlanInput as n, plan as o, ReviewsPlan as r, selectReviews as s, LocationPlan as t, ReviewCache as u };
|
|
82
|
+
//# sourceMappingURL=plan-CddqsmJJ.d.mts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan-CddqsmJJ.d.mts","names":[],"sources":["../src/reviews/types.ts","../src/reviews/plan.ts"],"mappings":";;UAkBiB;EACf;;EAEA;;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;IAAU;IAAc;;;EAExB;;;UAIe;EACf;EACA;EACA;EACA;EACA;EACA,SAAS;;;UAIM;EACf;EACA,WAAW,eAAe;;;UC5BX;EACf;EACA;EACA;;EAEA;;UAGe;;EAEf;EACA,WAAW;EACX;;UAGe;EACf,QAAQ;;EAER,gBAAgB;;EAEhB;;EAEA;;;iBAIc,KAAK,OAAO,YAAY;;iBAuDxB,WAAW,GAAG;;;;;;;;;;iBAmBd,cACd,SAAS,UAGT,QAAQ,QAAQ,wBACf;;;;;;;;;iBA2Ba,WACd,UAAU,yBACV,SAAS,eAAe,mBACvB"}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// src/reviews/plan.ts
|
|
2
|
+
/** Decide which locations to fetch, and why any are skipped. */
|
|
3
|
+
function plan(input) {
|
|
4
|
+
const { config, knownLocations, onlyLocation, hasCredential } = input;
|
|
5
|
+
const notes = [];
|
|
6
|
+
if (!config || Object.keys(config.locations).length === 0) return {
|
|
7
|
+
blocked: "no site.reviews.locations configured — nothing to fetch",
|
|
8
|
+
locations: [],
|
|
9
|
+
notes
|
|
10
|
+
};
|
|
11
|
+
if (!hasCredential) return {
|
|
12
|
+
blocked: "no Google credential — set VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN, or run `gcloud auth application-default login`",
|
|
13
|
+
locations: [],
|
|
14
|
+
notes
|
|
15
|
+
};
|
|
16
|
+
const entries = onlyLocation ? Object.entries(config.locations).filter(([key]) => key === onlyLocation) : Object.entries(config.locations);
|
|
17
|
+
if (onlyLocation && entries.length === 0) return {
|
|
18
|
+
blocked: `--location "${onlyLocation}" is not in site.reviews.locations`,
|
|
19
|
+
locations: [],
|
|
20
|
+
notes
|
|
21
|
+
};
|
|
22
|
+
const locations = entries.map(([key, loc]) => {
|
|
23
|
+
if (!knownLocations.has(key)) return {
|
|
24
|
+
key,
|
|
25
|
+
skip: `organization.locations has no entry keyed "${key}" with a listings.google id`
|
|
26
|
+
};
|
|
27
|
+
const accountId = loc.accountId ?? config.accountId;
|
|
28
|
+
if (!accountId) return {
|
|
29
|
+
key,
|
|
30
|
+
skip: `no accountId — set site.reviews.accountId or locations.${key}.accountId`
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
key,
|
|
34
|
+
accountId,
|
|
35
|
+
locationId: loc.locationId
|
|
36
|
+
};
|
|
37
|
+
});
|
|
38
|
+
const blocked = locations.filter((l) => l.skip);
|
|
39
|
+
if (blocked.length) for (const l of blocked) notes.push(`${l.key}: skipped — ${l.skip}`);
|
|
40
|
+
return {
|
|
41
|
+
locations,
|
|
42
|
+
notes
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/** Render a plan as the human-readable block `--dry` prints. */
|
|
46
|
+
function formatPlan(p) {
|
|
47
|
+
if (p.blocked) return `✗ blocked — ${p.blocked}`;
|
|
48
|
+
const lines = [];
|
|
49
|
+
const active = p.locations.filter((l) => !l.skip);
|
|
50
|
+
lines.push(`locations ${active.length} of ${p.locations.length} will be fetched`);
|
|
51
|
+
for (const l of active) lines.push(` ${l.key} ${l.accountId}/${l.locationId}`);
|
|
52
|
+
for (const n of p.notes) lines.push(` ! ${n}`);
|
|
53
|
+
return lines.join("\n");
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Filter and order one location's reviews for display.
|
|
57
|
+
*
|
|
58
|
+
* Featured ids always win and keep the author's order — an explicit choice
|
|
59
|
+
* overrides any automatic ranking. The remainder is filtered by `minRating`
|
|
60
|
+
* (default 4 — a star-rating widget showing a 1-star review it did not have to
|
|
61
|
+
* is a design choice a config field should not have to spell out every time),
|
|
62
|
+
* sorted, then capped by `limit`.
|
|
63
|
+
*/
|
|
64
|
+
function selectReviews(reviews, config) {
|
|
65
|
+
const featuredIds = config.featured ?? [];
|
|
66
|
+
const byId = new Map(reviews.map((r) => [r.id, r]));
|
|
67
|
+
const featured = featuredIds.map((id) => byId.get(id)).filter((r) => !!r);
|
|
68
|
+
const featuredSet = new Set(featured.map((r) => r.id));
|
|
69
|
+
const minRating = config.minRating ?? 4;
|
|
70
|
+
const rest = reviews.filter((r) => !featuredSet.has(r.id) && r.rating >= minRating).sort((a, b) => config.sort === "rating" ? b.rating - a.rating : new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
|
|
71
|
+
const combined = [...featured, ...rest];
|
|
72
|
+
return config.limit !== void 0 ? combined.slice(0, config.limit) : combined;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Merge a fresh fetch into the existing cache.
|
|
76
|
+
*
|
|
77
|
+
* Only the locations actually fetched this run are replaced; every other
|
|
78
|
+
* location's prior reviews pass through untouched. This is what makes a partial
|
|
79
|
+
* failure (one location's fetch errors) non-destructive — `cmdReviews` merges
|
|
80
|
+
* only the locations that succeeded and leaves the rest of the cache alone.
|
|
81
|
+
*/
|
|
82
|
+
function mergeCache(existing, fetched) {
|
|
83
|
+
return {
|
|
84
|
+
version: 1,
|
|
85
|
+
locations: {
|
|
86
|
+
...existing?.locations,
|
|
87
|
+
...fetched
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export { selectReviews as i, mergeCache as n, plan as r, formatPlan as t };
|
|
93
|
+
//# sourceMappingURL=plan-DpHv6NO8.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"plan-DpHv6NO8.mjs","names":[],"sources":["../src/reviews/plan.ts"],"sourcesContent":["/**\n * Every decision `vitops reviews` makes, as pure functions.\n *\n * Same split as `../indexing/plan.ts`: no `fetch`, no `fs`, no clock. Which\n * locations to fetch (and why one is blocked), and how a location's reviews are\n * filtered down to what a site actually displays, are decided here — `gbp.ts`\n * and `cache.ts` do what they're told and nothing else. That is what lets Astro's\n * `<ReviewCard>` and a future Bricks renderer both call `selectReviews` instead\n * of each re-implementing \"top 5 by rating, minus the ones already featured\".\n */\nimport type {\n LocationReviews,\n Review,\n ReviewCache,\n ReviewLocationConfig,\n ReviewsConfig,\n} from './types.ts';\n\n/** One location's fetch plan, or the reason it was skipped. */\nexport interface LocationPlan {\n key: string;\n accountId?: string;\n locationId?: string;\n /** Present iff this location will not be fetched. */\n skip?: string;\n}\n\nexport interface ReviewsPlan {\n /** Set when the whole run is refused — nothing is fetched. */\n blocked?: string;\n locations: LocationPlan[];\n notes: string[];\n}\n\nexport interface PlanInput {\n config: ReviewsConfig | undefined;\n /** Location keys with a Google listing configured (`organization.locations[].listings.google`). */\n knownLocations: Set<string>;\n /** `--location`: fetch exactly this one, skipping the rest. */\n onlyLocation?: string | undefined;\n /** Whether a Google credential was resolved. */\n hasCredential: boolean;\n}\n\n/** Decide which locations to fetch, and why any are skipped. */\nexport function plan(input: PlanInput): ReviewsPlan {\n const { config, knownLocations, onlyLocation, hasCredential } = input;\n const notes: string[] = [];\n\n if (!config || Object.keys(config.locations).length === 0) {\n return {\n blocked: 'no site.reviews.locations configured — nothing to fetch',\n locations: [],\n notes,\n };\n }\n\n if (!hasCredential) {\n return {\n blocked:\n 'no Google credential — set VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN, or run `gcloud auth application-default login`',\n locations: [],\n notes,\n };\n }\n\n const entries = onlyLocation\n ? Object.entries(config.locations).filter(([key]) => key === onlyLocation)\n : Object.entries(config.locations);\n\n if (onlyLocation && entries.length === 0) {\n return {\n blocked: `--location \"${onlyLocation}\" is not in site.reviews.locations`,\n locations: [],\n notes,\n };\n }\n\n const locations: LocationPlan[] = entries.map(([key, loc]) => {\n if (!knownLocations.has(key))\n return {\n key,\n skip: `organization.locations has no entry keyed \"${key}\" with a listings.google id`,\n };\n const accountId = loc.accountId ?? config.accountId;\n if (!accountId)\n return {\n key,\n skip: `no accountId — set site.reviews.accountId or locations.${key}.accountId`,\n };\n return { key, accountId, locationId: loc.locationId };\n });\n\n const blocked = locations.filter((l) => l.skip);\n if (blocked.length) for (const l of blocked) notes.push(`${l.key}: skipped — ${l.skip}`);\n\n return { locations, notes };\n}\n\n/** Render a plan as the human-readable block `--dry` prints. */\nexport function formatPlan(p: ReviewsPlan): string {\n if (p.blocked) return `✗ blocked — ${p.blocked}`;\n const lines: string[] = [];\n const active = p.locations.filter((l) => !l.skip);\n lines.push(`locations ${active.length} of ${p.locations.length} will be fetched`);\n for (const l of active) lines.push(` ${l.key} ${l.accountId}/${l.locationId}`);\n for (const n of p.notes) lines.push(` ! ${n}`);\n return lines.join('\\n');\n}\n\n/**\n * Filter and order one location's reviews for display.\n *\n * Featured ids always win and keep the author's order — an explicit choice\n * overrides any automatic ranking. The remainder is filtered by `minRating`\n * (default 4 — a star-rating widget showing a 1-star review it did not have to\n * is a design choice a config field should not have to spell out every time),\n * sorted, then capped by `limit`.\n */\nexport function selectReviews(\n reviews: Review[],\n // Partial: only the display fields are read. A renderer not tied to one configured location\n // (the EmDash block, which takes them from its own fields) has no `locationId` to supply.\n config: Partial<ReviewLocationConfig>,\n): Review[] {\n const featuredIds = config.featured ?? [];\n const byId = new Map(reviews.map((r) => [r.id, r]));\n const featured = featuredIds.map((id) => byId.get(id)).filter((r): r is Review => !!r);\n const featuredSet = new Set(featured.map((r) => r.id));\n\n const minRating = config.minRating ?? 4;\n const rest = reviews\n .filter((r) => !featuredSet.has(r.id) && r.rating >= minRating)\n .sort((a, b) =>\n config.sort === 'rating'\n ? b.rating - a.rating\n : new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime(),\n );\n\n const combined = [...featured, ...rest];\n return config.limit !== undefined ? combined.slice(0, config.limit) : combined;\n}\n\n/**\n * Merge a fresh fetch into the existing cache.\n *\n * Only the locations actually fetched this run are replaced; every other\n * location's prior reviews pass through untouched. This is what makes a partial\n * failure (one location's fetch errors) non-destructive — `cmdReviews` merges\n * only the locations that succeeded and leaves the rest of the cache alone.\n */\nexport function mergeCache(\n existing: ReviewCache | undefined,\n fetched: Record<string, LocationReviews>,\n): ReviewCache {\n return {\n version: 1,\n locations: { ...existing?.locations, ...fetched },\n };\n}\n"],"mappings":";;AA6CA,SAAgB,KAAK,OAA+B;CAClD,MAAM,EAAE,QAAQ,gBAAgB,cAAc,kBAAkB;CAChE,MAAM,QAAkB,CAAC;CAEzB,IAAI,CAAC,UAAU,OAAO,KAAK,OAAO,SAAS,CAAC,CAAC,WAAW,GACtD,OAAO;EACL,SAAS;EACT,WAAW,CAAC;EACZ;CACF;CAGF,IAAI,CAAC,eACH,OAAO;EACL,SACE;EACF,WAAW,CAAC;EACZ;CACF;CAGF,MAAM,UAAU,eACZ,OAAO,QAAQ,OAAO,SAAS,CAAC,CAAC,QAAQ,CAAC,SAAS,QAAQ,YAAY,IACvE,OAAO,QAAQ,OAAO,SAAS;CAEnC,IAAI,gBAAgB,QAAQ,WAAW,GACrC,OAAO;EACL,SAAS,eAAe,aAAa;EACrC,WAAW,CAAC;EACZ;CACF;CAGF,MAAM,YAA4B,QAAQ,KAAK,CAAC,KAAK,SAAS;EAC5D,IAAI,CAAC,eAAe,IAAI,GAAG,GACzB,OAAO;GACL;GACA,MAAM,8CAA8C,IAAI;EAC1D;EACF,MAAM,YAAY,IAAI,aAAa,OAAO;EAC1C,IAAI,CAAC,WACH,OAAO;GACL;GACA,MAAM,0DAA0D,IAAI;EACtE;EACF,OAAO;GAAE;GAAK;GAAW,YAAY,IAAI;EAAW;CACtD,CAAC;CAED,MAAM,UAAU,UAAU,QAAQ,MAAM,EAAE,IAAI;CAC9C,IAAI,QAAQ,QAAQ,KAAK,MAAM,KAAK,SAAS,MAAM,KAAK,GAAG,EAAE,IAAI,cAAc,EAAE,MAAM;CAEvF,OAAO;EAAE;EAAW;CAAM;AAC5B;;AAGA,SAAgB,WAAW,GAAwB;CACjD,IAAI,EAAE,SAAS,OAAO,eAAe,EAAE;CACvC,MAAM,QAAkB,CAAC;CACzB,MAAM,SAAS,EAAE,UAAU,QAAQ,MAAM,CAAC,EAAE,IAAI;CAChD,MAAM,KAAK,cAAc,OAAO,OAAO,MAAM,EAAE,UAAU,OAAO,iBAAiB;CACjF,KAAK,MAAM,KAAK,QAAQ,MAAM,KAAK,KAAK,EAAE,IAAI,IAAI,EAAE,UAAU,GAAG,EAAE,YAAY;CAC/E,KAAK,MAAM,KAAK,EAAE,OAAO,MAAM,KAAK,OAAO,GAAG;CAC9C,OAAO,MAAM,KAAK,IAAI;AACxB;;;;;;;;;;AAWA,SAAgB,cACd,SAGA,QACU;CACV,MAAM,cAAc,OAAO,YAAY,CAAC;CACxC,MAAM,OAAO,IAAI,IAAI,QAAQ,KAAK,MAAM,CAAC,EAAE,IAAI,CAAC,CAAC,CAAC;CAClD,MAAM,WAAW,YAAY,KAAK,OAAO,KAAK,IAAI,EAAE,CAAC,CAAC,CAAC,QAAQ,MAAmB,CAAC,CAAC,CAAC;CACrF,MAAM,cAAc,IAAI,IAAI,SAAS,KAAK,MAAM,EAAE,EAAE,CAAC;CAErD,MAAM,YAAY,OAAO,aAAa;CACtC,MAAM,OAAO,QACV,QAAQ,MAAM,CAAC,YAAY,IAAI,EAAE,EAAE,KAAK,EAAE,UAAU,SAAS,CAAC,CAC9D,MAAM,GAAG,MACR,OAAO,SAAS,WACZ,EAAE,SAAS,EAAE,SACb,IAAI,KAAK,EAAE,WAAW,CAAC,CAAC,QAAQ,IAAI,IAAI,KAAK,EAAE,WAAW,CAAC,CAAC,QAAQ,CAC1E;CAEF,MAAM,WAAW,CAAC,GAAG,UAAU,GAAG,IAAI;CACtC,OAAO,OAAO,UAAU,SAAY,SAAS,MAAM,GAAG,OAAO,KAAK,IAAI;AACxE;;;;;;;;;AAUA,SAAgB,WACd,UACA,SACa;CACb,OAAO;EACL,SAAS;EACT,WAAW;GAAE,GAAG,UAAU;GAAW,GAAG;EAAQ;CAClD;AACF"}
|
package/dist/reviews/index.d.mts
CHANGED
|
@@ -1,85 +1,6 @@
|
|
|
1
1
|
import { a as SCOPES, c as googleHeaders, i as GoogleOAuth, n as GoogleAuthLike, r as GoogleCredential, s as googleAccessToken, t as GoogleAuth } from "../token-CHR1O4w5.mjs";
|
|
2
2
|
import { n as adcQuotaProject, r as parseAdcUser, t as adcCredentialsPath } from "../adc-DvX3-P7a.mjs";
|
|
3
|
-
import { ReviewLocationConfig, ReviewsConfig } from "
|
|
4
|
-
/** One review, as GBP returns it and the cache stores it. */
|
|
5
|
-
interface Review {
|
|
6
|
-
id: string;
|
|
7
|
-
/** The `organization.locations[]` key this review belongs to. */
|
|
8
|
-
location: string;
|
|
9
|
-
/** 1–5. */
|
|
10
|
-
rating: number;
|
|
11
|
-
author: string;
|
|
12
|
-
authorPhoto?: string | undefined;
|
|
13
|
-
text?: string | undefined;
|
|
14
|
-
publishedAt: string;
|
|
15
|
-
updatedAt: string;
|
|
16
|
-
reply?: {
|
|
17
|
-
text: string;
|
|
18
|
-
at: string;
|
|
19
|
-
} | undefined;
|
|
20
|
-
/** Link back to the review on Google — never omit when linking to it. */
|
|
21
|
-
url?: string | undefined;
|
|
22
|
-
}
|
|
23
|
-
/** What a fetch of one location produced. */
|
|
24
|
-
interface LocationReviews {
|
|
25
|
-
locationId: string;
|
|
26
|
-
accountId: string;
|
|
27
|
-
fetchedAt: string;
|
|
28
|
-
averageRating?: number | undefined;
|
|
29
|
-
totalReviewCount?: number | undefined;
|
|
30
|
-
reviews: Review[];
|
|
31
|
-
}
|
|
32
|
-
/** The on-disk cache, as written by `writeCache`. */
|
|
33
|
-
interface ReviewCache {
|
|
34
|
-
version: 1;
|
|
35
|
-
locations: Record<string, LocationReviews>;
|
|
36
|
-
}
|
|
37
|
-
/** One location's fetch plan, or the reason it was skipped. */
|
|
38
|
-
interface LocationPlan {
|
|
39
|
-
key: string;
|
|
40
|
-
accountId?: string;
|
|
41
|
-
locationId?: string;
|
|
42
|
-
/** Present iff this location will not be fetched. */
|
|
43
|
-
skip?: string;
|
|
44
|
-
}
|
|
45
|
-
interface ReviewsPlan {
|
|
46
|
-
/** Set when the whole run is refused — nothing is fetched. */
|
|
47
|
-
blocked?: string;
|
|
48
|
-
locations: LocationPlan[];
|
|
49
|
-
notes: string[];
|
|
50
|
-
}
|
|
51
|
-
interface PlanInput {
|
|
52
|
-
config: ReviewsConfig | undefined;
|
|
53
|
-
/** Location keys with a Google listing configured (`organization.locations[].listings.google`). */
|
|
54
|
-
knownLocations: Set<string>;
|
|
55
|
-
/** `--location`: fetch exactly this one, skipping the rest. */
|
|
56
|
-
onlyLocation?: string | undefined;
|
|
57
|
-
/** Whether a Google credential was resolved. */
|
|
58
|
-
hasCredential: boolean;
|
|
59
|
-
}
|
|
60
|
-
/** Decide which locations to fetch, and why any are skipped. */
|
|
61
|
-
export declare function plan(input: PlanInput): ReviewsPlan;
|
|
62
|
-
/** Render a plan as the human-readable block `--dry` prints. */
|
|
63
|
-
export declare function formatPlan(p: ReviewsPlan): string;
|
|
64
|
-
/**
|
|
65
|
-
* Filter and order one location's reviews for display.
|
|
66
|
-
*
|
|
67
|
-
* Featured ids always win and keep the author's order — an explicit choice
|
|
68
|
-
* overrides any automatic ranking. The remainder is filtered by `minRating`
|
|
69
|
-
* (default 4 — a star-rating widget showing a 1-star review it did not have to
|
|
70
|
-
* is a design choice a config field should not have to spell out every time),
|
|
71
|
-
* sorted, then capped by `limit`.
|
|
72
|
-
*/
|
|
73
|
-
export declare function selectReviews(reviews: Review[], config: ReviewLocationConfig): Review[];
|
|
74
|
-
/**
|
|
75
|
-
* Merge a fresh fetch into the existing cache.
|
|
76
|
-
*
|
|
77
|
-
* Only the locations actually fetched this run are replaced; every other
|
|
78
|
-
* location's prior reviews pass through untouched. This is what makes a partial
|
|
79
|
-
* failure (one location's fetch errors) non-destructive — `cmdReviews` merges
|
|
80
|
-
* only the locations that succeeded and leaves the rest of the cache alone.
|
|
81
|
-
*/
|
|
82
|
-
export declare function mergeCache(existing: ReviewCache | undefined, fetched: Record<string, LocationReviews>): ReviewCache;
|
|
3
|
+
import { a as mergeCache, c as LocationReviews, d as ReviewLocationConfig, f as ReviewsConfig, i as formatPlan, l as Review, n as PlanInput, o as plan, r as ReviewsPlan, s as selectReviews, t as LocationPlan, u as ReviewCache } from "../plan-CddqsmJJ.mjs";
|
|
83
4
|
/** Default location, relative to the consumer's project root. */
|
|
84
5
|
export declare const REVIEWS_CACHE_PATH = ".vitops/reviews.json";
|
|
85
6
|
/**
|
|
@@ -112,5 +33,5 @@ interface FetchReviewsResult {
|
|
|
112
33
|
* `../indexing/gsc.ts`'s `inspectUrl` keeps.
|
|
113
34
|
*/
|
|
114
35
|
export declare function fetchReviews(auth: GoogleAuthLike, accountId: string, locationId: string, fetchImpl?: typeof fetch): Promise<FetchReviewsResult>;
|
|
115
|
-
export { type FetchReviewsResult, type GoogleAuth, type GoogleAuthLike, type GoogleCredential, type GoogleOAuth, type LocationPlan, type LocationReviews, type PlanInput, type Review, type ReviewCache, type ReviewLocationConfig, type ReviewsConfig, type ReviewsPlan, SCOPES, adcCredentialsPath, adcQuotaProject, googleAccessToken, googleHeaders, parseAdcUser };
|
|
36
|
+
export { type FetchReviewsResult, type GoogleAuth, type GoogleAuthLike, type GoogleCredential, type GoogleOAuth, type LocationPlan, type LocationReviews, type PlanInput, type Review, type ReviewCache, type ReviewLocationConfig, type ReviewsConfig, type ReviewsPlan, SCOPES, adcCredentialsPath, adcQuotaProject, formatPlan, googleAccessToken, googleHeaders, mergeCache, parseAdcUser, plan, selectReviews };
|
|
116
37
|
//# sourceMappingURL=index.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/reviews/
|
|
1
|
+
{"version":3,"file":"index.d.mts","names":[],"sources":["../../src/reviews/cache.ts","../../src/reviews/gbp.ts"],"mappings":";;;;qBAca;;;;;;;;;wBAUG,UAAU,eAAe;;wBAKzB,QAAQ,WAAW,eAAe,mBAAmB;;wBAarD,WAAW,cAAc,OAAO;;UCoB/B;EACf;EACA,SAAS;EACT;EACA;EACA;EACA;;;;;;;;;wBAUoB,aACpB,MAAM,gBACN,mBACA,oBACA,mBAAkB,QACjB,QAAQ"}
|
package/dist/reviews/index.mjs
CHANGED
|
@@ -1,97 +1,7 @@
|
|
|
1
1
|
import { n as writeVersioned, t as readVersioned } from "../json-store-VJKEaoVX.mjs";
|
|
2
2
|
import { a as googleHeaders, i as googleAccessToken, t as SCOPES } from "../token-CJSl9eIS.mjs";
|
|
3
3
|
import { n as adcQuotaProject, r as parseAdcUser, t as adcCredentialsPath } from "../adc-BiW9BzqA.mjs";
|
|
4
|
-
|
|
5
|
-
// src/reviews/plan.ts
|
|
6
|
-
/** Decide which locations to fetch, and why any are skipped. */
|
|
7
|
-
function plan(input) {
|
|
8
|
-
const { config, knownLocations, onlyLocation, hasCredential } = input;
|
|
9
|
-
const notes = [];
|
|
10
|
-
if (!config || Object.keys(config.locations).length === 0) return {
|
|
11
|
-
blocked: "no site.reviews.locations configured — nothing to fetch",
|
|
12
|
-
locations: [],
|
|
13
|
-
notes
|
|
14
|
-
};
|
|
15
|
-
if (!hasCredential) return {
|
|
16
|
-
blocked: "no Google credential — set VITOPS_GOOGLE_CLIENT_ID / _CLIENT_SECRET / _REFRESH_TOKEN, or run `gcloud auth application-default login`",
|
|
17
|
-
locations: [],
|
|
18
|
-
notes
|
|
19
|
-
};
|
|
20
|
-
const entries = onlyLocation ? Object.entries(config.locations).filter(([key]) => key === onlyLocation) : Object.entries(config.locations);
|
|
21
|
-
if (onlyLocation && entries.length === 0) return {
|
|
22
|
-
blocked: `--location "${onlyLocation}" is not in site.reviews.locations`,
|
|
23
|
-
locations: [],
|
|
24
|
-
notes
|
|
25
|
-
};
|
|
26
|
-
const locations = entries.map(([key, loc]) => {
|
|
27
|
-
if (!knownLocations.has(key)) return {
|
|
28
|
-
key,
|
|
29
|
-
skip: `organization.locations has no entry keyed "${key}" with a listings.google id`
|
|
30
|
-
};
|
|
31
|
-
const accountId = loc.accountId ?? config.accountId;
|
|
32
|
-
if (!accountId) return {
|
|
33
|
-
key,
|
|
34
|
-
skip: `no accountId — set site.reviews.accountId or locations.${key}.accountId`
|
|
35
|
-
};
|
|
36
|
-
return {
|
|
37
|
-
key,
|
|
38
|
-
accountId,
|
|
39
|
-
locationId: loc.locationId
|
|
40
|
-
};
|
|
41
|
-
});
|
|
42
|
-
const blocked = locations.filter((l) => l.skip);
|
|
43
|
-
if (blocked.length) for (const l of blocked) notes.push(`${l.key}: skipped — ${l.skip}`);
|
|
44
|
-
return {
|
|
45
|
-
locations,
|
|
46
|
-
notes
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
/** Render a plan as the human-readable block `--dry` prints. */
|
|
50
|
-
function formatPlan(p) {
|
|
51
|
-
if (p.blocked) return `✗ blocked — ${p.blocked}`;
|
|
52
|
-
const lines = [];
|
|
53
|
-
const active = p.locations.filter((l) => !l.skip);
|
|
54
|
-
lines.push(`locations ${active.length} of ${p.locations.length} will be fetched`);
|
|
55
|
-
for (const l of active) lines.push(` ${l.key} ${l.accountId}/${l.locationId}`);
|
|
56
|
-
for (const n of p.notes) lines.push(` ! ${n}`);
|
|
57
|
-
return lines.join("\n");
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* Filter and order one location's reviews for display.
|
|
61
|
-
*
|
|
62
|
-
* Featured ids always win and keep the author's order — an explicit choice
|
|
63
|
-
* overrides any automatic ranking. The remainder is filtered by `minRating`
|
|
64
|
-
* (default 4 — a star-rating widget showing a 1-star review it did not have to
|
|
65
|
-
* is a design choice a config field should not have to spell out every time),
|
|
66
|
-
* sorted, then capped by `limit`.
|
|
67
|
-
*/
|
|
68
|
-
function selectReviews(reviews, config) {
|
|
69
|
-
const featuredIds = config.featured ?? [];
|
|
70
|
-
const byId = new Map(reviews.map((r) => [r.id, r]));
|
|
71
|
-
const featured = featuredIds.map((id) => byId.get(id)).filter((r) => !!r);
|
|
72
|
-
const featuredSet = new Set(featured.map((r) => r.id));
|
|
73
|
-
const minRating = config.minRating ?? 4;
|
|
74
|
-
const rest = reviews.filter((r) => !featuredSet.has(r.id) && r.rating >= minRating).sort((a, b) => config.sort === "rating" ? b.rating - a.rating : new Date(b.publishedAt).getTime() - new Date(a.publishedAt).getTime());
|
|
75
|
-
const combined = [...featured, ...rest];
|
|
76
|
-
return config.limit !== void 0 ? combined.slice(0, config.limit) : combined;
|
|
77
|
-
}
|
|
78
|
-
/**
|
|
79
|
-
* Merge a fresh fetch into the existing cache.
|
|
80
|
-
*
|
|
81
|
-
* Only the locations actually fetched this run are replaced; every other
|
|
82
|
-
* location's prior reviews pass through untouched. This is what makes a partial
|
|
83
|
-
* failure (one location's fetch errors) non-destructive — `cmdReviews` merges
|
|
84
|
-
* only the locations that succeeded and leaves the rest of the cache alone.
|
|
85
|
-
*/
|
|
86
|
-
function mergeCache(existing, fetched) {
|
|
87
|
-
return {
|
|
88
|
-
version: 1,
|
|
89
|
-
locations: {
|
|
90
|
-
...existing?.locations,
|
|
91
|
-
...fetched
|
|
92
|
-
}
|
|
93
|
-
};
|
|
94
|
-
}
|
|
4
|
+
import { i as selectReviews, n as mergeCache, r as plan, t as formatPlan } from "../plan-DpHv6NO8.mjs";
|
|
95
5
|
|
|
96
6
|
// src/reviews/cache.ts
|
|
97
7
|
/** Default location, relative to the consumer's project root. */
|