@getvitops/utils 0.1.0 → 0.2.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.
@@ -0,0 +1,144 @@
1
+ //#region src/color/colors.d.ts
2
+ interface RgbResult {
3
+ r: number;
4
+ g: number;
5
+ b: number;
6
+ inGamut: boolean;
7
+ /** Max channel overshoot beyond [0,1] before clamping. 0 when in gamut. */
8
+ gamutDistance: number;
9
+ }
10
+ /**
11
+ * Convert OKLCh to sRGB, returning 0-255 clamped values with gamut flag.
12
+ * Fast path for per-pixel canvas rendering (no gamut mapping, just clamp).
13
+ */
14
+ declare function oklchToSrgb(l: number, c: number, h: number): RgbResult;
15
+ /**
16
+ * Convert OKLCh to Display P3, returning 0-255 clamped values with gamut flag.
17
+ * Fast path for per-pixel canvas rendering on P3 displays.
18
+ */
19
+ declare function oklchToP3(l: number, c: number, h: number): RgbResult;
20
+ /**
21
+ * Gamut-map an OKLCh color using the CSS gamut mapping algorithm, return hex.
22
+ * Slower than oklchToSrgb/oklchToP3 (uses iterative reduction), use for single colors only.
23
+ */
24
+ declare function gamutMapOklchToHex(l: number, c: number, h: number, space?: 'srgb' | 'p3'): string;
25
+ declare const STEP_NAMES: readonly [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950];
26
+ type StepName = (typeof STEP_NAMES)[number];
27
+ declare const COLOR_GROUPS: readonly ["neutral", "brand", "primary", "accent", "complement", "success", "warning", "error", "info"];
28
+ type ColorGroup = (typeof COLOR_GROUPS)[number];
29
+ /**
30
+ * Convert hex color to OKLCH components
31
+ */
32
+ declare function hexToOklch(hex: string): {
33
+ l: number;
34
+ c: number;
35
+ h: number;
36
+ };
37
+ /**
38
+ * Convert OKLCH components to hex color
39
+ */
40
+ declare function oklchToHex(oklch: {
41
+ l: number;
42
+ c: number;
43
+ h: number;
44
+ }): string;
45
+ /**
46
+ * Convert OKLCH CSS string to hex color
47
+ * @example oklchStringToHex("oklch(50% 0.15 270)") => "#8b5cf6"
48
+ */
49
+ declare function oklchStringToHex(oklchString: string): string;
50
+ /**
51
+ * Format a color name by separating compound words
52
+ * Examples: "mediumseagreen" -> "Medium Sea Green", "darkblue" -> "Dark Blue"
53
+ */
54
+ declare function formatColorName(name: string): string;
55
+ /**
56
+ * Get the closest CSS color name for a hex color
57
+ */
58
+ declare function getColorName(hex: string): string;
59
+ /**
60
+ * Get color name with hue value
61
+ */
62
+ declare function getColorNameWithHue(hex: string): string;
63
+ declare const HARMONY_TYPES: readonly ["single", "analogous", "triad", "complementary", "split-complementary", "square", "compound"];
64
+ type HarmonyType = (typeof HARMONY_TYPES)[number];
65
+ interface HarmonyDefinition {
66
+ label: string;
67
+ offsets: number[];
68
+ primaryIndex: number;
69
+ }
70
+ declare const HARMONY_DEFINITIONS: Record<HarmonyType, HarmonyDefinition>;
71
+ interface OklchColor {
72
+ l: number;
73
+ c: number;
74
+ h: number;
75
+ }
76
+ /**
77
+ * Parses an OKLCH CSS string into numerical components.
78
+ * @param oklchString - e.g., 'oklch(50% 0.15 270)'
79
+ * @returns Lightness (0-1), Chroma (0-~0.37), Hue (0-360)
80
+ */
81
+ declare function parseOklch(oklchString: string): OklchColor;
82
+ /**
83
+ * Generates a complete 11-step OKLCH palette from one or more colors.
84
+ * Automatically determines which step each color belongs to based on lightness.
85
+ */
86
+ declare function generateOklchPalette(input: string | string[] | Record<string, string>): Record<StepName, string>;
87
+ /** APCA lightness-contrast (Lc) magnitude between a foreground and background. */
88
+ declare function contrastLc(fg: string, bg: string): number;
89
+ /**
90
+ * Pick a tinted near-white or near-black foreground for best contrast on `bg`
91
+ * (a whisper of the background's hue mixed into off-white/off-black).
92
+ */
93
+ declare function pickOn(bg: string, hue?: number): string;
94
+ /**
95
+ * The tinted off-white / off-black endpoints for a hue — white/black with a
96
+ * whisper of the hue mixed in. These are the text/background tints every ramp
97
+ * needs (and the one exception strict brand kits allow).
98
+ */
99
+ declare function tintedEndpoints(hue: number, chroma?: number): {
100
+ light: string;
101
+ dark: string;
102
+ };
103
+ //#endregion
104
+ //#region src/color/color-harmony.d.ts
105
+ /**
106
+ * Color harmony strategies for generating complementary colors
107
+ */
108
+ type HarmonyStrategy = 'complement' | 'analogous-plus' | 'analogous-minus' | 'triadic-plus' | 'triadic-minus' | 'split-plus' | 'split-minus' | 'tetradic-plus' | 'tetradic-minus';
109
+ interface HarmonyOption {
110
+ id: HarmonyStrategy;
111
+ label: string;
112
+ description: string;
113
+ hueShift: number;
114
+ }
115
+ declare const HARMONY_STRATEGIES: HarmonyOption[];
116
+ /**
117
+ * Applies a harmony strategy to a hue value
118
+ */
119
+ declare function applyHarmonyStrategy(baseHue: number, strategy: HarmonyStrategy): number;
120
+ /**
121
+ * Gets all harmony suggestions for a given base hue
122
+ */
123
+ declare function getHarmonySuggestions(baseHue: number): Array<{
124
+ strategy: HarmonyStrategy;
125
+ hue: number;
126
+ label: string;
127
+ description: string;
128
+ }>;
129
+ /**
130
+ * Default functional color hues (if user doesn't specify)
131
+ */
132
+ declare const DEFAULT_FUNCTIONAL_HUES: {
133
+ readonly success: 142;
134
+ readonly warning: 38;
135
+ readonly error: 0;
136
+ readonly info: 200;
137
+ };
138
+ /**
139
+ * Suggests a functional color hue based on existing palette to maintain harmony
140
+ */
141
+ declare function suggestFunctionalColorHue(type: keyof typeof DEFAULT_FUNCTIONAL_HUES, existingHues: number[]): number;
142
+ //#endregion
143
+ export { COLOR_GROUPS, ColorGroup, DEFAULT_FUNCTIONAL_HUES, HARMONY_DEFINITIONS, HARMONY_STRATEGIES, HARMONY_TYPES, HarmonyDefinition, HarmonyOption, HarmonyStrategy, HarmonyType, RgbResult, STEP_NAMES, StepName, applyHarmonyStrategy, contrastLc, formatColorName, gamutMapOklchToHex, generateOklchPalette, getColorName, getColorNameWithHue, getHarmonySuggestions, hexToOklch, oklchStringToHex, oklchToHex, oklchToP3, oklchToSrgb, parseOklch, pickOn, suggestFunctionalColorHue, tintedEndpoints };
144
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../../src/color/colors.ts","../../src/color/color-harmony.ts"],"mappings":";UAkBiB,SAAA;EACf,CAAA;EACA,CAAA;EACA,CAAA;EACA,OAAA;EAHA;EAKA,aAAA;AAAA;;;;AAAa;iBAOC,WAAA,CAAY,CAAA,UAAW,CAAA,UAAW,CAAA,WAAY,SAAS;;;;;iBAmBvD,SAAA,CAAU,CAAA,UAAW,CAAA,UAAW,CAAA,WAAY,SAAS;;;;AAnBE;iBAsCvD,kBAAA,CACd,CAAA,UACA,CAAA,UACA,CAAA,UACA,KAAA;AAAA,cAUW,UAAA;AAAA,KACD,QAAA,WAAmB,UAAU;AAAA,cAE5B,YAAA;AAAA,KAWD,UAAA,WAAqB,YAAY;;;;iBAK7B,UAAA,CAAW,GAAA;EAAgB,CAAA;EAAW,CAAA;EAAW,CAAA;AAAA;;;;iBAajD,UAAA,CAAW,KAAA;EAAS,CAAA;EAAW,CAAA;EAAW,CAAA;AAAA;;;;AAhC+B;iBA8CzE,gBAAA,CAAiB,WAAmB;;;;AA7CX;iBA8DzB,eAAA,CAAgB,IAAY;;;;iBA+G5B,YAAA,CAAa,GAAW;AAhKxC;;;AAAA,iBAsLgB,mBAAA,CAAoB,GAAW;AAAA,cAclC,aAAA;AAAA,KASD,WAAA,WAAsB,aAAa;AAAA,UAE9B,iBAAA;EACf,KAAA;EACA,OAAA;EACA,YAAA;AAAA;AAAA,cAGW,mBAAA,EAAqB,MAAA,CAAO,WAAA,EAAa,iBAAA;AAAA,UAqD5C,UAAA;EACR,CAAA;EACA,CAAA;EACA,CAAA;AAAA;;;;;;iBAQc,UAAA,CAAW,WAAA,WAAsB,UAAU;;AAnQU;AAcrE;;iBAkXgB,oBAAA,CACd,KAAA,sBAA2B,MAAA,mBAC1B,MAAA,CAAO,QAAA;;iBA+GM,UAAA,CAAW,EAAA,UAAY,EAAU;AAldjD;;;;AAAA,iBA0dgB,MAAA,CAAO,EAAA,UAAY,GAAY;AA3W/C;;;;AAAwC;AAAxC,iBAuXgB,eAAA,CAAgB,GAAA,UAAa,MAAA;EAAkB,KAAA;EAAe,IAAA;AAAA;;;;AAtmB9E;;KCdY,eAAA;AAAA,UAWK,aAAA;EACf,EAAA,EAAI,eAAe;EACnB,KAAA;EACA,WAAA;EACA,QAAA;AAAA;AAAA,cAGW,kBAAA,EAAoB,aAAa;ADE/B;AAOf;;AAPe,iBC0DC,oBAAA,CAAqB,OAAA,UAAiB,QAAA,EAAU,eAAe;;;;iBAgB/D,qBAAA,CACd,OAAA,WACC,KAAK;EAAG,QAAA,EAAU,eAAA;EAAiB,GAAA;EAAa,KAAA;EAAe,WAAA;AAAA;;;;cAYrD,uBAAA;EAAA;;;;;;;;iBAUG,yBAAA,CACd,IAAA,eAAmB,uBAAuB,EAC1C,YAAA"}
@@ -0,0 +1,651 @@
1
+ import Color from "colorjs.io";
2
+ import { ColorSpace, OKLCH, P3, clone, sRGB, serialize, to, toGamutCSS } from "colorjs.io/fn";
3
+
4
+ //#region src/color/colors.ts
5
+ ColorSpace.register(OKLCH);
6
+ ColorSpace.register(sRGB);
7
+ ColorSpace.register(P3);
8
+ /**
9
+ * Convert OKLCh to sRGB, returning 0-255 clamped values with gamut flag.
10
+ * Fast path for per-pixel canvas rendering (no gamut mapping, just clamp).
11
+ */
12
+ function oklchToSrgb(l, c, h) {
13
+ const [r, g, b] = to({
14
+ space: OKLCH,
15
+ coords: [
16
+ l,
17
+ c,
18
+ h
19
+ ],
20
+ alpha: 1
21
+ }, sRGB).coords;
22
+ const inGamut = r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
23
+ const gamutDistance = inGamut ? 0 : Math.max(-r, r - 1, -g, g - 1, -b, b - 1, 0);
24
+ return {
25
+ r: Math.max(0, Math.min(255, Math.round(r * 255))),
26
+ g: Math.max(0, Math.min(255, Math.round(g * 255))),
27
+ b: Math.max(0, Math.min(255, Math.round(b * 255))),
28
+ inGamut,
29
+ gamutDistance
30
+ };
31
+ }
32
+ /**
33
+ * Convert OKLCh to Display P3, returning 0-255 clamped values with gamut flag.
34
+ * Fast path for per-pixel canvas rendering on P3 displays.
35
+ */
36
+ function oklchToP3(l, c, h) {
37
+ const [r, g, b] = to({
38
+ space: OKLCH,
39
+ coords: [
40
+ l,
41
+ c,
42
+ h
43
+ ],
44
+ alpha: 1
45
+ }, P3).coords;
46
+ const inGamut = r >= 0 && r <= 1 && g >= 0 && g <= 1 && b >= 0 && b <= 1;
47
+ const gamutDistance = inGamut ? 0 : Math.max(-r, r - 1, -g, g - 1, -b, b - 1, 0);
48
+ return {
49
+ r: Math.max(0, Math.min(255, Math.round(r * 255))),
50
+ g: Math.max(0, Math.min(255, Math.round(g * 255))),
51
+ b: Math.max(0, Math.min(255, Math.round(b * 255))),
52
+ inGamut,
53
+ gamutDistance
54
+ };
55
+ }
56
+ /**
57
+ * Gamut-map an OKLCh color using the CSS gamut mapping algorithm, return hex.
58
+ * Slower than oklchToSrgb/oklchToP3 (uses iterative reduction), use for single colors only.
59
+ */
60
+ function gamutMapOklchToHex(l, c, h, space = "srgb") {
61
+ const col = {
62
+ space: OKLCH,
63
+ coords: [
64
+ l,
65
+ c,
66
+ h
67
+ ],
68
+ alpha: 1
69
+ };
70
+ const targetSpace = space === "p3" ? P3 : sRGB;
71
+ return serialize(to(toGamutCSS(clone(col), { space: targetSpace }), sRGB), { format: "hex" });
72
+ }
73
+ const STEP_NAMES = [
74
+ 50,
75
+ 100,
76
+ 200,
77
+ 300,
78
+ 400,
79
+ 500,
80
+ 600,
81
+ 700,
82
+ 800,
83
+ 900,
84
+ 950
85
+ ];
86
+ const COLOR_GROUPS = [
87
+ "neutral",
88
+ "brand",
89
+ "primary",
90
+ "accent",
91
+ "complement",
92
+ "success",
93
+ "warning",
94
+ "error",
95
+ "info"
96
+ ];
97
+ /**
98
+ * Convert hex color to OKLCH components
99
+ */
100
+ function hexToOklch(hex) {
101
+ const oklch = new Color(hex).to("oklch");
102
+ return {
103
+ l: oklch.coords[0],
104
+ c: oklch.coords[1],
105
+ h: oklch.coords[2] || 0
106
+ };
107
+ }
108
+ /**
109
+ * Convert OKLCH components to hex color
110
+ */
111
+ function oklchToHex(oklch) {
112
+ const srgb = new Color("oklch", [
113
+ oklch.l,
114
+ oklch.c,
115
+ oklch.h
116
+ ]).to("srgb");
117
+ if (!srgb.inGamut()) srgb.toGamut({ method: "css" });
118
+ return srgb.toString({ format: "hex" });
119
+ }
120
+ /**
121
+ * Convert OKLCH CSS string to hex color
122
+ * @example oklchStringToHex("oklch(50% 0.15 270)") => "#8b5cf6"
123
+ */
124
+ function oklchStringToHex(oklchString) {
125
+ try {
126
+ const srgb = new Color(oklchString).to("srgb");
127
+ if (!srgb.inGamut()) srgb.toGamut({ method: "css" });
128
+ return srgb.toString({ format: "hex" });
129
+ } catch {
130
+ return "#808080";
131
+ }
132
+ }
133
+ /**
134
+ * Format a color name by separating compound words
135
+ * Examples: "mediumseagreen" -> "Medium Sea Green", "darkblue" -> "Dark Blue"
136
+ */
137
+ function formatColorName(name) {
138
+ if (!name) return "Unknown";
139
+ const specialCases = {
140
+ aliceblue: "Alice Blue",
141
+ antiquewhite: "Antique White",
142
+ blanchedalmond: "Blanched Almond",
143
+ blueviolet: "Blue Violet",
144
+ burlywood: "Burly Wood",
145
+ cadetblue: "Cadet Blue",
146
+ cornflowerblue: "Cornflower Blue",
147
+ darkblue: "Dark Blue",
148
+ darkcyan: "Dark Cyan",
149
+ darkgoldenrod: "Dark Goldenrod",
150
+ darkgray: "Dark Gray",
151
+ darkgreen: "Dark Green",
152
+ darkgrey: "Dark Grey",
153
+ darkkhaki: "Dark Khaki",
154
+ darkmagenta: "Dark Magenta",
155
+ darkolivegreen: "Dark Olive Green",
156
+ darkorange: "Dark Orange",
157
+ darkorchid: "Dark Orchid",
158
+ darkred: "Dark Red",
159
+ darksalmon: "Dark Salmon",
160
+ darkseagreen: "Dark Sea Green",
161
+ darkslateblue: "Dark Slate Blue",
162
+ darkslategray: "Dark Slate Gray",
163
+ darkslategrey: "Dark Slate Grey",
164
+ darkturquoise: "Dark Turquoise",
165
+ darkviolet: "Dark Violet",
166
+ deeppink: "Deep Pink",
167
+ deepskyblue: "Deep Sky Blue",
168
+ dimgray: "Dim Gray",
169
+ dimgrey: "Dim Grey",
170
+ dodgerblue: "Dodger Blue",
171
+ floralwhite: "Floral White",
172
+ forestgreen: "Forest Green",
173
+ ghostwhite: "Ghost White",
174
+ greenyellow: "Green Yellow",
175
+ hotpink: "Hot Pink",
176
+ indianred: "Indian Red",
177
+ lavenderblush: "Lavender Blush",
178
+ lawngreen: "Lawn Green",
179
+ lemonchiffon: "Lemon Chiffon",
180
+ lightblue: "Light Blue",
181
+ lightcoral: "Light Coral",
182
+ lightcyan: "Light Cyan",
183
+ lightgoldenrodyellow: "Light Goldenrod Yellow",
184
+ lightgray: "Light Gray",
185
+ lightgreen: "Light Green",
186
+ lightgrey: "Light Grey",
187
+ lightpink: "Light Pink",
188
+ lightsalmon: "Light Salmon",
189
+ lightseagreen: "Light Sea Green",
190
+ lightskyblue: "Light Sky Blue",
191
+ lightslategray: "Light Slate Gray",
192
+ lightslategrey: "Light Slate Grey",
193
+ lightsteelblue: "Light Steel Blue",
194
+ lightyellow: "Light Yellow",
195
+ limegreen: "Lime Green",
196
+ mediumaquamarine: "Medium Aquamarine",
197
+ mediumblue: "Medium Blue",
198
+ mediumorchid: "Medium Orchid",
199
+ mediumpurple: "Medium Purple",
200
+ mediumseagreen: "Medium Sea Green",
201
+ mediumslateblue: "Medium Slate Blue",
202
+ mediumspringgreen: "Medium Spring Green",
203
+ mediumturquoise: "Medium Turquoise",
204
+ mediumvioletred: "Medium Violet Red",
205
+ midnightblue: "Midnight Blue",
206
+ mintcream: "Mint Cream",
207
+ mistyrose: "Misty Rose",
208
+ navajowhite: "Navajo White",
209
+ oldlace: "Old Lace",
210
+ olivedrab: "Olive Drab",
211
+ orangered: "Orange Red",
212
+ palegoldenrod: "Pale Goldenrod",
213
+ palegreen: "Pale Green",
214
+ paleturquoise: "Pale Turquoise",
215
+ palevioletred: "Pale Violet Red",
216
+ papayawhip: "Papaya Whip",
217
+ peachpuff: "Peach Puff",
218
+ powderblue: "Powder Blue",
219
+ rosybrown: "Rosy Brown",
220
+ royalblue: "Royal Blue",
221
+ saddlebrown: "Saddle Brown",
222
+ sandybrown: "Sandy Brown",
223
+ seagreen: "Sea Green",
224
+ skyblue: "Sky Blue",
225
+ slateblue: "Slate Blue",
226
+ slategray: "Slate Gray",
227
+ slategrey: "Slate Grey",
228
+ springgreen: "Spring Green",
229
+ steelblue: "Steel Blue",
230
+ whitesmoke: "White Smoke",
231
+ yellowgreen: "Yellow Green"
232
+ };
233
+ const lowerName = name.toLowerCase();
234
+ if (specialCases[lowerName]) return specialCases[lowerName];
235
+ return name.charAt(0).toUpperCase() + name.slice(1);
236
+ }
237
+ /**
238
+ * Get the closest CSS color name for a hex color
239
+ */
240
+ function getColorName(hex) {
241
+ try {
242
+ const hue = new Color(hex).to("oklch").coords[2] || 0;
243
+ if (hue < 30 || hue >= 330) return "Red";
244
+ if (hue < 60) return "Orange";
245
+ if (hue < 90) return "Yellow";
246
+ if (hue < 150) return "Green";
247
+ if (hue < 210) return "Cyan";
248
+ if (hue < 270) return "Blue";
249
+ if (hue < 330) return "Purple";
250
+ return "Unknown";
251
+ } catch {
252
+ return "Unknown";
253
+ }
254
+ }
255
+ /**
256
+ * Get color name with hue value
257
+ */
258
+ function getColorNameWithHue(hex) {
259
+ try {
260
+ const hue = new Color(hex).to("oklch").coords[2] || 0;
261
+ const roundedHue = Math.round(hue / 10) * 10;
262
+ return `${getColorName(hex)} ${roundedHue}`;
263
+ } catch {
264
+ return "Unknown";
265
+ }
266
+ }
267
+ const HARMONY_TYPES = [
268
+ "single",
269
+ "analogous",
270
+ "triad",
271
+ "complementary",
272
+ "split-complementary",
273
+ "square",
274
+ "compound"
275
+ ];
276
+ const HARMONY_DEFINITIONS = {
277
+ single: {
278
+ label: "Single",
279
+ offsets: [0],
280
+ primaryIndex: 0
281
+ },
282
+ analogous: {
283
+ label: "Analogous",
284
+ offsets: [
285
+ -30,
286
+ 0,
287
+ 30
288
+ ],
289
+ primaryIndex: 1
290
+ },
291
+ triad: {
292
+ label: "Triad",
293
+ offsets: [
294
+ 0,
295
+ 120,
296
+ 240
297
+ ],
298
+ primaryIndex: 0
299
+ },
300
+ complementary: {
301
+ label: "Complementary",
302
+ offsets: [0, 180],
303
+ primaryIndex: 0
304
+ },
305
+ "split-complementary": {
306
+ label: "Split Complementary",
307
+ offsets: [
308
+ 0,
309
+ 150,
310
+ 210
311
+ ],
312
+ primaryIndex: 0
313
+ },
314
+ square: {
315
+ label: "Square",
316
+ offsets: [
317
+ 0,
318
+ 90,
319
+ 180,
320
+ 270
321
+ ],
322
+ primaryIndex: 0
323
+ },
324
+ compound: {
325
+ label: "Compound",
326
+ offsets: [
327
+ -30,
328
+ 0,
329
+ 180,
330
+ 210
331
+ ],
332
+ primaryIndex: 1
333
+ }
334
+ };
335
+ const DEFAULT_LIGHTNESS_SCALE = {
336
+ 50: .95,
337
+ 100: .9,
338
+ 200: .8,
339
+ 300: .7,
340
+ 400: .6,
341
+ 500: .5,
342
+ 600: .4,
343
+ 700: .3,
344
+ 800: .2,
345
+ 900: .1,
346
+ 950: .05
347
+ };
348
+ /**
349
+ * Parses an OKLCH CSS string into numerical components.
350
+ * @param oklchString - e.g., 'oklch(50% 0.15 270)'
351
+ * @returns Lightness (0-1), Chroma (0-~0.37), Hue (0-360)
352
+ */
353
+ function parseOklch(oklchString) {
354
+ const parts = oklchString.match(/oklch\(([^ ]+) ([^ ]+) ([^ ]+)\)/);
355
+ if (!parts) throw new Error("Invalid OKLCH string format.");
356
+ const [, lRaw = "", cRaw = "", hRaw = ""] = parts;
357
+ return {
358
+ l: parseFloat(lRaw) / (lRaw.includes("%") ? 100 : 1),
359
+ c: parseFloat(cRaw),
360
+ h: parseFloat(hRaw)
361
+ };
362
+ }
363
+ /**
364
+ * Linearly interpolates a value between a start and end point.
365
+ */
366
+ function lerp(start, end, factor) {
367
+ return start + factor * (end - start);
368
+ }
369
+ /**
370
+ * Transpose the default lightness scale to match the provided anchors.
371
+ */
372
+ function transposeLightnessScale(parsedAnchors, sortedAnchorSteps) {
373
+ const transposedScale = {};
374
+ if (sortedAnchorSteps.length === 1) {
375
+ const anchorStep = sortedAnchorSteps[0];
376
+ const anchorL = parsedAnchors[anchorStep].l;
377
+ const defaultAnchorL = DEFAULT_LIGHTNESS_SCALE[anchorStep];
378
+ for (const step of STEP_NAMES) {
379
+ if (step === anchorStep) {
380
+ transposedScale[step] = anchorL;
381
+ continue;
382
+ }
383
+ const defaultStepL = DEFAULT_LIGHTNESS_SCALE[step];
384
+ if (defaultStepL > defaultAnchorL) transposedScale[step] = lerp(anchorL, .97, (defaultStepL - defaultAnchorL) / (1 - defaultAnchorL));
385
+ else transposedScale[step] = lerp(.05, anchorL, defaultStepL / defaultAnchorL);
386
+ }
387
+ return transposedScale;
388
+ }
389
+ for (const step of STEP_NAMES) {
390
+ const anchored = parsedAnchors[step];
391
+ if (anchored) {
392
+ transposedScale[step] = anchored.l;
393
+ continue;
394
+ }
395
+ let lowerStep = null;
396
+ let upperStep = null;
397
+ for (let i = sortedAnchorSteps.length - 1; i >= 0; i--) {
398
+ const candidate = sortedAnchorSteps[i];
399
+ if (candidate < step) {
400
+ lowerStep = candidate;
401
+ break;
402
+ }
403
+ }
404
+ for (const anchorStep of sortedAnchorSteps) if (anchorStep > step) {
405
+ upperStep = anchorStep;
406
+ break;
407
+ }
408
+ if (lowerStep !== null && upperStep !== null) {
409
+ const lowerL = parsedAnchors[lowerStep].l;
410
+ const upperL = parsedAnchors[upperStep].l;
411
+ const span = upperStep - lowerStep;
412
+ transposedScale[step] = lerp(lowerL, upperL, (step - lowerStep) / span);
413
+ } else if (lowerStep !== null) {
414
+ const lowerL = parsedAnchors[lowerStep].l;
415
+ const defaultLowerL = DEFAULT_LIGHTNESS_SCALE[lowerStep];
416
+ transposedScale[step] = lerp(.05, lowerL, DEFAULT_LIGHTNESS_SCALE[step] / defaultLowerL);
417
+ } else if (upperStep !== null) {
418
+ const upperL = parsedAnchors[upperStep].l;
419
+ const defaultUpperL = DEFAULT_LIGHTNESS_SCALE[upperStep];
420
+ transposedScale[step] = lerp(upperL, .97, (DEFAULT_LIGHTNESS_SCALE[step] - defaultUpperL) / (1 - defaultUpperL));
421
+ }
422
+ }
423
+ return transposedScale;
424
+ }
425
+ /**
426
+ * Automatically assigns a color to the most appropriate step based on its lightness.
427
+ */
428
+ function assignStepByLightness(lightness) {
429
+ let closestStep = 500;
430
+ let minDiff = Math.abs(lightness - DEFAULT_LIGHTNESS_SCALE[500]);
431
+ for (const step of STEP_NAMES) {
432
+ const diff = Math.abs(lightness - DEFAULT_LIGHTNESS_SCALE[step]);
433
+ if (diff < minDiff) {
434
+ minDiff = diff;
435
+ closestStep = step;
436
+ }
437
+ }
438
+ return closestStep;
439
+ }
440
+ /**
441
+ * Generates a complete 11-step OKLCH palette from one or more colors.
442
+ * Automatically determines which step each color belongs to based on lightness.
443
+ */
444
+ function generateOklchPalette(input) {
445
+ let anchors = {};
446
+ if (typeof input === "string") {
447
+ const step = assignStepByLightness(parseOklch(input).l);
448
+ anchors[step] = input;
449
+ } else if (Array.isArray(input)) for (const colorStr of input) {
450
+ const step = assignStepByLightness(parseOklch(colorStr).l);
451
+ anchors[step] = colorStr;
452
+ }
453
+ else if (typeof input === "object") anchors = input;
454
+ else throw new Error("Input must be a color string, array of colors, or object with step assignments");
455
+ const parsedAnchors = {};
456
+ for (const key in anchors) parsedAnchors[parseInt(key)] = parseOklch(anchors[key]);
457
+ const sortedAnchorSteps = Object.keys(parsedAnchors).map(Number).sort((a, b) => a - b);
458
+ const lightnessScale = transposeLightnessScale(parsedAnchors, sortedAnchorSteps);
459
+ const palette = {};
460
+ for (const step of STEP_NAMES) {
461
+ if (parsedAnchors[step]) {
462
+ palette[step] = anchors[step];
463
+ continue;
464
+ }
465
+ let startStep = null;
466
+ let endStep = null;
467
+ for (let i = sortedAnchorSteps.length - 1; i >= 0; i--) {
468
+ const candidate = sortedAnchorSteps[i];
469
+ if (candidate < step) {
470
+ startStep = candidate;
471
+ break;
472
+ }
473
+ }
474
+ for (const anchorStep of sortedAnchorSteps) if (anchorStep > step) {
475
+ endStep = anchorStep;
476
+ break;
477
+ }
478
+ let c_int, h_int;
479
+ if (startStep !== null && endStep !== null) {
480
+ const startColor = parsedAnchors[startStep];
481
+ const endColor = parsedAnchors[endStep];
482
+ const span = endStep - startStep;
483
+ const factor = (step - startStep) / span;
484
+ c_int = lerp(startColor.c, endColor.c, factor);
485
+ h_int = lerp(startColor.h, endColor.h, factor);
486
+ } else if (startStep !== null) {
487
+ const a = parsedAnchors[startStep];
488
+ c_int = a.c;
489
+ h_int = a.h;
490
+ } else if (endStep !== null) {
491
+ const a = parsedAnchors[endStep];
492
+ c_int = a.c;
493
+ h_int = a.h;
494
+ } else {
495
+ c_int = .1;
496
+ h_int = 0;
497
+ }
498
+ const l_int = lightnessScale[step];
499
+ const dampeningFactor = l_int < .25 ? .3 + l_int / .25 * .7 : l_int > .75 ? .3 + (1 - l_int) / .25 * .7 : 1;
500
+ c_int *= dampeningFactor;
501
+ c_int = Math.min(c_int, .37);
502
+ palette[step] = `oklch(${(l_int * 100).toFixed(2) + "%"} ${c_int.toFixed(4)} ${h_int.toFixed(2)})`;
503
+ }
504
+ return palette;
505
+ }
506
+ /** APCA lightness-contrast (Lc) magnitude between a foreground and background. */
507
+ function contrastLc(fg, bg) {
508
+ return Math.abs(new Color(bg).contrast(fg, "APCA"));
509
+ }
510
+ /**
511
+ * Pick a tinted near-white or near-black foreground for best contrast on `bg`
512
+ * (a whisper of the background's hue mixed into off-white/off-black).
513
+ */
514
+ function pickOn(bg, hue) {
515
+ const h = hue ?? hexToOklch(bg).h;
516
+ const white = oklchToHex({
517
+ l: .98,
518
+ c: .01,
519
+ h
520
+ });
521
+ const black = oklchToHex({
522
+ l: .16,
523
+ c: .02,
524
+ h
525
+ });
526
+ return contrastLc(white, bg) >= contrastLc(black, bg) ? white : black;
527
+ }
528
+ /**
529
+ * The tinted off-white / off-black endpoints for a hue — white/black with a
530
+ * whisper of the hue mixed in. These are the text/background tints every ramp
531
+ * needs (and the one exception strict brand kits allow).
532
+ */
533
+ function tintedEndpoints(hue, chroma = .01) {
534
+ return {
535
+ light: oklchToHex({
536
+ l: .98,
537
+ c: Math.min(chroma, .02),
538
+ h: hue
539
+ }),
540
+ dark: oklchToHex({
541
+ l: .16,
542
+ c: Math.min(chroma * 1.5, .03),
543
+ h: hue
544
+ })
545
+ };
546
+ }
547
+
548
+ //#endregion
549
+ //#region src/color/color-harmony.ts
550
+ const HARMONY_STRATEGIES = [
551
+ {
552
+ id: "complement",
553
+ label: "Complement",
554
+ description: "Opposite on color wheel (high contrast)",
555
+ hueShift: 180
556
+ },
557
+ {
558
+ id: "analogous-plus",
559
+ label: "Analogous+",
560
+ description: "Adjacent colors (harmonious)",
561
+ hueShift: 30
562
+ },
563
+ {
564
+ id: "analogous-minus",
565
+ label: "Analogous-",
566
+ description: "Adjacent colors (harmonious)",
567
+ hueShift: -30
568
+ },
569
+ {
570
+ id: "triadic-plus",
571
+ label: "Triadic+",
572
+ description: "Evenly spaced (balanced)",
573
+ hueShift: 120
574
+ },
575
+ {
576
+ id: "triadic-minus",
577
+ label: "Triadic-",
578
+ description: "Evenly spaced (balanced)",
579
+ hueShift: -120
580
+ },
581
+ {
582
+ id: "split-plus",
583
+ label: "Split Complement+",
584
+ description: "Near opposite (softer contrast)",
585
+ hueShift: 150
586
+ },
587
+ {
588
+ id: "split-minus",
589
+ label: "Split Complement-",
590
+ description: "Near opposite (softer contrast)",
591
+ hueShift: -150
592
+ },
593
+ {
594
+ id: "tetradic-plus",
595
+ label: "Tetradic+",
596
+ description: "Square spacing (rich palette)",
597
+ hueShift: 90
598
+ },
599
+ {
600
+ id: "tetradic-minus",
601
+ label: "Tetradic-",
602
+ description: "Square spacing (rich palette)",
603
+ hueShift: -90
604
+ }
605
+ ];
606
+ /**
607
+ * Applies a harmony strategy to a hue value
608
+ */
609
+ function applyHarmonyStrategy(baseHue, strategy) {
610
+ const harmonyOption = HARMONY_STRATEGIES.find((h) => h.id === strategy);
611
+ if (!harmonyOption) return baseHue;
612
+ let newHue = baseHue + harmonyOption.hueShift;
613
+ while (newHue < 0) newHue += 360;
614
+ while (newHue >= 360) newHue -= 360;
615
+ return newHue;
616
+ }
617
+ /**
618
+ * Gets all harmony suggestions for a given base hue
619
+ */
620
+ function getHarmonySuggestions(baseHue) {
621
+ return HARMONY_STRATEGIES.map((strategy) => ({
622
+ strategy: strategy.id,
623
+ hue: applyHarmonyStrategy(baseHue, strategy.id),
624
+ label: strategy.label,
625
+ description: strategy.description
626
+ }));
627
+ }
628
+ /**
629
+ * Default functional color hues (if user doesn't specify)
630
+ */
631
+ const DEFAULT_FUNCTIONAL_HUES = {
632
+ success: 142,
633
+ warning: 38,
634
+ error: 0,
635
+ info: 200
636
+ };
637
+ /**
638
+ * Suggests a functional color hue based on existing palette to maintain harmony
639
+ */
640
+ function suggestFunctionalColorHue(type, existingHues) {
641
+ const defaultHue = DEFAULT_FUNCTIONAL_HUES[type];
642
+ if (existingHues.length === 0) return defaultHue;
643
+ let adjustedHue = defaultHue + (existingHues.reduce((sum, h) => sum + h, 0) / existingHues.length - 200) * .3;
644
+ while (adjustedHue < 0) adjustedHue += 360;
645
+ while (adjustedHue >= 360) adjustedHue -= 360;
646
+ return adjustedHue;
647
+ }
648
+
649
+ //#endregion
650
+ export { COLOR_GROUPS, DEFAULT_FUNCTIONAL_HUES, HARMONY_DEFINITIONS, HARMONY_STRATEGIES, HARMONY_TYPES, STEP_NAMES, applyHarmonyStrategy, contrastLc, formatColorName, gamutMapOklchToHex, generateOklchPalette, getColorName, getColorNameWithHue, getHarmonySuggestions, hexToOklch, oklchStringToHex, oklchToHex, oklchToP3, oklchToSrgb, parseOklch, pickOn, suggestFunctionalColorHue, tintedEndpoints };
651
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +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\n// Register color spaces once for functional API\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\n/**\n * Convert OKLCH components to hex color\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 // Convert to sRGB and clamp to gamut, then format as hex\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 // Handle special cases with proper word separation\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// Default lightness positions (lightest to darkest)\nconst DEFAULT_LIGHTNESS_SCALE: Record<StepName, number> = {\n 50: 0.95,\n 100: 0.9,\n 200: 0.8,\n 300: 0.7,\n 400: 0.6,\n 500: 0.5,\n 600: 0.4,\n 700: 0.3,\n 800: 0.2,\n 900: 0.1,\n 950: 0.05,\n};\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 * Transpose the default lightness scale to match the provided anchors.\n */\nfunction transposeLightnessScale(\n parsedAnchors: Record<StepName, OklchColor>,\n sortedAnchorSteps: StepName[],\n): Record<StepName, number> {\n const transposedScale: Record<number, number> = {};\n\n if (sortedAnchorSteps.length === 1) {\n const anchorStep = sortedAnchorSteps[0] as StepName;\n const anchorL = (parsedAnchors[anchorStep] as OklchColor).l;\n const defaultAnchorL = DEFAULT_LIGHTNESS_SCALE[anchorStep];\n\n for (const step of STEP_NAMES) {\n if (step === anchorStep) {\n transposedScale[step] = anchorL;\n continue;\n }\n\n const defaultStepL = DEFAULT_LIGHTNESS_SCALE[step];\n\n if (defaultStepL > defaultAnchorL) {\n const factor = (defaultStepL - defaultAnchorL) / (1.0 - defaultAnchorL);\n transposedScale[step] = lerp(anchorL, 0.97, factor);\n } else {\n const factor = defaultStepL / defaultAnchorL;\n transposedScale[step] = lerp(0.05, anchorL, factor);\n }\n }\n\n return transposedScale;\n }\n\n for (const step of STEP_NAMES) {\n const anchored = parsedAnchors[step];\n if (anchored) {\n transposedScale[step] = anchored.l;\n continue;\n }\n\n let lowerStep: StepName | null = null;\n let upperStep: StepName | null = null;\n\n for (let i = sortedAnchorSteps.length - 1; i >= 0; i--) {\n const candidate = sortedAnchorSteps[i] as StepName;\n if (candidate < step) {\n lowerStep = candidate;\n break;\n }\n }\n\n for (const anchorStep of sortedAnchorSteps) {\n if (anchorStep > step) {\n upperStep = anchorStep;\n break;\n }\n }\n\n if (lowerStep !== null && upperStep !== null) {\n const lowerL = (parsedAnchors[lowerStep] as OklchColor).l;\n const upperL = (parsedAnchors[upperStep] as OklchColor).l;\n const span = upperStep - lowerStep;\n const position = step - lowerStep;\n const factor = position / span;\n transposedScale[step] = lerp(lowerL, upperL, factor);\n } else if (lowerStep !== null) {\n const lowerL = (parsedAnchors[lowerStep] as OklchColor).l;\n const defaultLowerL = DEFAULT_LIGHTNESS_SCALE[lowerStep];\n const defaultStepL = DEFAULT_LIGHTNESS_SCALE[step];\n const factor = defaultStepL / defaultLowerL;\n transposedScale[step] = lerp(0.05, lowerL, factor);\n } else if (upperStep !== null) {\n const upperL = (parsedAnchors[upperStep] as OklchColor).l;\n const defaultUpperL = DEFAULT_LIGHTNESS_SCALE[upperStep];\n const defaultStepL = DEFAULT_LIGHTNESS_SCALE[step];\n const factor = (defaultStepL - defaultUpperL) / (1.0 - defaultUpperL);\n transposedScale[step] = lerp(upperL, 0.97, factor);\n }\n }\n\n return transposedScale;\n}\n\n/**\n * Automatically assigns a color to the most appropriate step based on its lightness.\n */\nfunction assignStepByLightness(lightness: number): StepName {\n let closestStep: StepName = 500;\n let minDiff = Math.abs(lightness - DEFAULT_LIGHTNESS_SCALE[500]);\n\n for (const step of STEP_NAMES) {\n const diff = Math.abs(lightness - DEFAULT_LIGHTNESS_SCALE[step]);\n if (diff < minDiff) {\n minDiff = diff;\n closestStep = step;\n }\n }\n\n return closestStep;\n}\n\n/**\n * Generates a complete 11-step OKLCH palette from one or more colors.\n * Automatically determines which step each color belongs to based on lightness.\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 const parsed = parseOklch(input);\n const step = assignStepByLightness(parsed.l);\n anchors[step] = input;\n } else if (Array.isArray(input)) {\n for (const colorStr of input) {\n const parsed = parseOklch(colorStr);\n const step = assignStepByLightness(parsed.l);\n anchors[step] = colorStr;\n }\n } else if (typeof input === 'object') {\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\n for (const key in anchors) {\n parsedAnchors[parseInt(key)] = parseOklch(anchors[key] as string);\n }\n\n const sortedAnchorSteps = Object.keys(parsedAnchors)\n .map(Number)\n .sort((a, b) => a - b) as StepName[];\n\n const lightnessScale = transposeLightnessScale(\n parsedAnchors as Record<StepName, OklchColor>,\n sortedAnchorSteps,\n );\n\n const palette: Record<number, string> = {};\n\n for (const step of STEP_NAMES) {\n if (parsedAnchors[step]) {\n palette[step] = anchors[step] as string;\n continue;\n }\n\n let startStep: StepName | null = null;\n let endStep: StepName | null = null;\n\n for (let i = sortedAnchorSteps.length - 1; i >= 0; i--) {\n const candidate = sortedAnchorSteps[i] as StepName;\n if (candidate < step) {\n startStep = candidate;\n break;\n }\n }\n\n for (const anchorStep of sortedAnchorSteps) {\n if (anchorStep > step) {\n endStep = anchorStep;\n break;\n }\n }\n\n let c_int, h_int;\n\n if (startStep !== null && endStep !== null) {\n const startColor = parsedAnchors[startStep] as OklchColor;\n const endColor = parsedAnchors[endStep] as OklchColor;\n const span = endStep - startStep;\n const position = step - startStep;\n const factor = position / span;\n\n c_int = lerp(startColor.c, endColor.c, factor);\n h_int = lerp(startColor.h, endColor.h, factor);\n } else if (startStep !== null) {\n const a = parsedAnchors[startStep] as OklchColor;\n c_int = a.c;\n h_int = a.h;\n } else if (endStep !== null) {\n const a = parsedAnchors[endStep] as OklchColor;\n c_int = a.c;\n h_int = a.h;\n } else {\n c_int = 0.1;\n h_int = 0;\n }\n\n const l_int = lightnessScale[step] as number;\n\n const dampeningFactor =\n l_int < 0.25\n ? 0.3 + (l_int / 0.25) * 0.7\n : l_int > 0.75\n ? 0.3 + ((1 - l_int) / 0.25) * 0.7\n : 1.0;\n\n c_int *= dampeningFactor;\n c_int = Math.min(c_int, 0.37);\n\n const finalL = (l_int * 100).toFixed(2) + '%';\n const finalC = c_int.toFixed(4);\n const finalH = h_int.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 */\nexport function tintedEndpoints(hue: number, chroma = 0.01): { light: string; dark: string } {\n return {\n light: oklchToHex({ l: 0.98, c: Math.min(chroma, 0.02), h: hue }),\n dark: oklchToHex({ l: 0.16, c: Math.min(chroma * 1.5, 0.03), h: hue }),\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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"}
@@ -15,6 +15,37 @@ interface FaviconOptions {
15
15
  * Returns the list of written file paths.
16
16
  */
17
17
  declare function generateFavicons(opts: FaviconOptions): Promise<string[]>;
18
+ interface FaviconLink {
19
+ rel: string;
20
+ href: string;
21
+ type?: string;
22
+ sizes?: string;
23
+ }
24
+ interface FaviconTagOptions {
25
+ /** Whether a favicon.svg was emitted (source was an SVG). */
26
+ hasSvg?: boolean;
27
+ /** Include the `<link rel="manifest">` (default true). */
28
+ manifest?: boolean;
29
+ /** URL base the favicon files are served from (default '' → site root). */
30
+ base?: string;
31
+ }
32
+ /**
33
+ * The ordered `<link>` data matching what `generateFavicons` writes. Pure — no
34
+ * I/O — so it can drive an Astro `<Head />`, the CLI, or plain HTML.
35
+ */
36
+ declare function faviconLinks(opts?: FaviconTagOptions): FaviconLink[];
37
+ interface WebManifestOptions {
38
+ name: string;
39
+ shortName?: string;
40
+ themeColor?: string;
41
+ backgroundColor?: string;
42
+ /** URL base the icon files are served from (default '' → site root). */
43
+ base?: string;
44
+ }
45
+ /** Build the web-app-manifest object (icons match `generateFavicons` output). */
46
+ declare function faviconManifest(opts: WebManifestOptions): Record<string, unknown>;
47
+ /** Write `site.webmanifest` into `dir`. Returns the written path. */
48
+ declare function writeFaviconManifest(dir: string, opts: WebManifestOptions): Promise<string>;
18
49
  //#endregion
19
- export { FaviconOptions, generateFavicons };
50
+ export { FaviconLink, FaviconOptions, FaviconTagOptions, WebManifestOptions, faviconLinks, faviconManifest, generateFavicons, writeFaviconManifest };
20
51
  //# sourceMappingURL=favicon.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"favicon.d.mts","names":[],"sources":["../src/favicon.ts"],"mappings":";UAiBiB,cAAA;EAAA;EAEf,MAAA;;EAEA,YAAA;EAFA;EAIA,SAAA;EAAA;EAEA,QAAA;AAAA;AAAQ;AAsBV;;;;AAtBU,iBAsBY,gBAAA,CAAiB,IAAA,EAAM,cAAA,GAAiB,OAAO"}
1
+ {"version":3,"file":"favicon.d.mts","names":[],"sources":["../src/favicon.ts"],"mappings":";UAiBiB,cAAA;EAAA;EAEf,MAAA;;EAEA,YAAA;EAFA;EAIA,SAAA;EAAA;EAEA,QAAA;AAAA;AAAQ;AAsBV;;;;AAtBU,iBAsBY,gBAAA,CAAiB,IAAA,EAAM,cAAA,GAAiB,OAAO;AAAA,UA8FpD,WAAA;EACf,GAAA;EACA,IAAA;EACA,IAAA;EACA,KAAA;AAAA;AAAA,UAGe,iBAAA;EAPW;EAS1B,MAAA;EAPA;EASA,QAAA;EAPA;EASA,IAAA;AAAA;AANF;;;;AAAA,iBAagB,YAAA,CAAa,IAAA,GAAM,iBAAA,GAAyB,WAAW;AAAA,UAWtD,kBAAA;EACf,IAAA;EACA,SAAA;EACA,UAAA;EACA,eAAA;EAf0B;EAiB1B,IAAA;AAAA;;iBAIc,eAAA,CAAgB,IAAA,EAAM,kBAAA,GAAqB,MAAM;;iBAmB3C,oBAAA,CAAqB,GAAA,UAAa,IAAA,EAAM,kBAAA,GAAqB,OAAO"}
package/dist/favicon.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { mkdirSync } from "node:fs";
2
- import { writeFile } from "node:fs/promises";
2
+ import { copyFile, writeFile } from "node:fs/promises";
3
3
  import { promisify } from "node:util";
4
4
  import cp from "node:child_process";
5
5
  import { join } from "node:path";
@@ -40,8 +40,12 @@ async function generateFavicons(opts) {
40
40
  const optimize = opts.optimize ?? true;
41
41
  mkdirSync(dir, { recursive: true });
42
42
  const out = (name) => join(dir, name);
43
- const [{ default: sharp }, { default: pngToIco }] = await Promise.all([import("sharp"), import("png-to-ico")]);
44
43
  const written = [];
44
+ if (source.endsWith(".svg")) {
45
+ await copyFile(source, out("favicon.svg"));
46
+ written.push(out("favicon.svg"));
47
+ }
48
+ const [{ default: sharp }, { default: pngToIco }] = await Promise.all([import("sharp"), import("png-to-ico")]);
45
49
  await Promise.all([
46
50
  32,
47
51
  192,
@@ -121,7 +125,77 @@ async function generateFavicons(opts) {
121
125
  ].map((image) => optimizePng(out(image), optimize)));
122
126
  return written;
123
127
  }
128
+ /**
129
+ * The ordered `<link>` data matching what `generateFavicons` writes. Pure — no
130
+ * I/O — so it can drive an Astro `<Head />`, the CLI, or plain HTML.
131
+ */
132
+ function faviconLinks(opts = {}) {
133
+ const base = (opts.base ?? "").replace(/\/+$/, "");
134
+ const href = (f) => `${base}/${f}`;
135
+ const links = [{
136
+ rel: "icon",
137
+ href: href("favicon.ico"),
138
+ sizes: "32x32"
139
+ }];
140
+ if (opts.hasSvg) links.push({
141
+ rel: "icon",
142
+ type: "image/svg+xml",
143
+ href: href("favicon.svg")
144
+ });
145
+ links.push({
146
+ rel: "icon",
147
+ type: "image/png",
148
+ sizes: "32x32",
149
+ href: href("icon-32.png")
150
+ });
151
+ links.push({
152
+ rel: "apple-touch-icon",
153
+ href: href("apple-touch-icon.png")
154
+ });
155
+ if (opts.manifest !== false) links.push({
156
+ rel: "manifest",
157
+ href: href("site.webmanifest")
158
+ });
159
+ return links;
160
+ }
161
+ /** Build the web-app-manifest object (icons match `generateFavicons` output). */
162
+ function faviconManifest(opts) {
163
+ const base = (opts.base ?? "").replace(/\/+$/, "");
164
+ const src = (f) => `${base}/${f}`;
165
+ return {
166
+ name: opts.name,
167
+ short_name: opts.shortName ?? opts.name,
168
+ icons: [
169
+ {
170
+ src: src("icon-192.png"),
171
+ sizes: "192x192",
172
+ type: "image/png"
173
+ },
174
+ {
175
+ src: src("icon-512.png"),
176
+ sizes: "512x512",
177
+ type: "image/png"
178
+ },
179
+ {
180
+ src: src("icon-mask.png"),
181
+ sizes: "512x512",
182
+ type: "image/png",
183
+ purpose: "maskable"
184
+ }
185
+ ],
186
+ theme_color: opts.themeColor ?? "#ffffff",
187
+ background_color: opts.backgroundColor ?? "#ffffff",
188
+ display: "standalone",
189
+ start_url: "/"
190
+ };
191
+ }
192
+ /** Write `site.webmanifest` into `dir`. Returns the written path. */
193
+ async function writeFaviconManifest(dir, opts) {
194
+ const path = join(dir.replace(/\/+$/, ""), "site.webmanifest");
195
+ await writeFile(path, `${JSON.stringify(faviconManifest(opts), null, 2)}\n`);
196
+ return path;
197
+ }
124
198
 
125
199
  //#endregion
126
- export { generateFavicons };
200
+ export { faviconLinks, faviconManifest, generateFavicons, writeFaviconManifest };
127
201
  //# sourceMappingURL=favicon.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"favicon.mjs","names":[],"sources":["../src/favicon.ts"],"sourcesContent":["/**\n * Favicon generation — rasterize a source SVG/PNG into the standard favicon set.\n *\n * Ported from the repo's `lib/favicon.ts` so the logic lives in one place instead\n * of being copied per client. `sharp` + `png-to-ico` are loaded lazily (dynamic\n * import) so merely importing this module is cheap; the native `sharp` binary is\n * only touched when `generateFavicons` actually runs. `oxipng` (lossless PNG\n * crush) is used when present on PATH and skipped otherwise — it is optional.\n */\nimport { mkdirSync } from 'node:fs';\nimport { writeFile } from 'node:fs/promises';\nimport { promisify } from 'node:util';\nimport cp from 'node:child_process';\nimport { join } from 'node:path';\n\nconst exec = promisify(cp.exec);\n\nexport interface FaviconOptions {\n /** Source image — an SVG or PNG. */\n source: string;\n /** Optional simplified source for the 16px icon. */\n lowResSource?: string;\n /** Directory to write into (default: current directory). */\n outputDir?: string;\n /** Run `oxipng` on the larger PNGs when available (default: true). */\n optimize?: boolean;\n}\n\nlet warnedNoOxipng = false;\nasync function optimizePng(file: string, enabled: boolean): Promise<void> {\n if (!enabled) return;\n try {\n await exec(`oxipng -o 3 \"${file}\"`);\n } catch {\n // oxipng missing or failed — lossless crush is best-effort, so skip quietly.\n if (!warnedNoOxipng) {\n warnedNoOxipng = true;\n console.warn('favicon: oxipng not available — skipping lossless PNG optimization.');\n }\n }\n}\n\n/**\n * Generate the standard favicon set from an SVG/PNG source into `outputDir`:\n * `favicon.ico`, `icon-{16?,32,192,512}.png`, `apple-touch-icon.png`, `icon-mask.png`.\n * Returns the list of written file paths.\n */\nexport async function generateFavicons(opts: FaviconOptions): Promise<string[]> {\n const { source, lowResSource } = opts;\n if (!source) throw new Error('favicon: no source path provided');\n if (!source.endsWith('.svg') && !source.endsWith('.png'))\n throw new Error('favicon: source must be an SVG or PNG file');\n\n const dir = (opts.outputDir ?? '.').replace(/\\/+$/, '');\n const optimize = opts.optimize ?? true;\n mkdirSync(dir, { recursive: true });\n const out = (name: string) => join(dir, name);\n\n // Lazy-load the native/heavy deps only when actually generating.\n const [{ default: sharp }, { default: pngToIco }] = await Promise.all([\n import('sharp'),\n import('png-to-ico'),\n ]);\n\n const written: string[] = [];\n\n // Rasterize the main source at each size. sharp reads SVG natively via libvips;\n // rendering at high density first keeps small-size edges crisp (ignored for PNG).\n await Promise.all(\n [32, 192, 512].map((size) =>\n sharp(source, { density: 384 })\n .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })\n .png()\n .toFile(out(`icon-${size}.png`))\n .then(() => written.push(out(`icon-${size}.png`))),\n ),\n );\n\n // Optional low-res 16px variant.\n if (lowResSource) {\n await sharp(lowResSource, { density: 384 })\n .resize(16, 16, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })\n .png()\n .toFile(out('icon-16.png'));\n written.push(out('icon-16.png'));\n }\n\n // favicon.ico — sharp can't encode .ico, so png-to-ico packs the PNGs into the\n // multi-resolution container. Browsers pick the best size inside the file.\n const icoSources = [out('icon-32.png')];\n if (lowResSource) icoSources.unshift(out('icon-16.png'));\n await writeFile(out('favicon.ico'), await pngToIco(icoSources));\n written.push(out('favicon.ico'));\n\n // apple-touch-icon: 140x140 logo centered on a 180x180 transparent canvas.\n await sharp(out('icon-512.png'))\n .resize(140, 140, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })\n .extend({\n top: 20,\n bottom: 20,\n left: 20,\n right: 20,\n background: { r: 0, g: 0, b: 0, alpha: 0 },\n })\n .png()\n .toFile(out('apple-touch-icon.png'));\n written.push(out('apple-touch-icon.png'));\n\n // icon-mask (maskable): 409x409 logo centered on a 512x512 canvas.\n await sharp(out('icon-512.png'))\n .resize(409, 409, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })\n .extend({\n top: 52,\n bottom: 51,\n left: 52,\n right: 51,\n background: { r: 0, g: 0, b: 0, alpha: 0 },\n })\n .png()\n .toFile(out('icon-mask.png'));\n written.push(out('icon-mask.png'));\n\n // Lossless crush of the larger PNGs (best-effort; needs oxipng on PATH).\n await Promise.all(\n ['icon-192.png', 'icon-512.png', 'apple-touch-icon.png', 'icon-mask.png'].map((image) =>\n optimizePng(out(image), optimize),\n ),\n );\n\n return written;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"favicon.mjs","names":[],"sources":["../src/favicon.ts"],"sourcesContent":["/**\n * Favicon generation — rasterize a source SVG/PNG into the standard favicon set.\n *\n * Ported from the repo's `lib/favicon.ts` so the logic lives in one place instead\n * of being copied per client. `sharp` + `png-to-ico` are loaded lazily (dynamic\n * import) so merely importing this module is cheap; the native `sharp` binary is\n * only touched when `generateFavicons` actually runs. `oxipng` (lossless PNG\n * crush) is used when present on PATH and skipped otherwise — it is optional.\n */\nimport { mkdirSync } from 'node:fs';\nimport { copyFile, writeFile } from 'node:fs/promises';\nimport { promisify } from 'node:util';\nimport cp from 'node:child_process';\nimport { join } from 'node:path';\n\nconst exec = promisify(cp.exec);\n\nexport interface FaviconOptions {\n /** Source image — an SVG or PNG. */\n source: string;\n /** Optional simplified source for the 16px icon. */\n lowResSource?: string;\n /** Directory to write into (default: current directory). */\n outputDir?: string;\n /** Run `oxipng` on the larger PNGs when available (default: true). */\n optimize?: boolean;\n}\n\nlet warnedNoOxipng = false;\nasync function optimizePng(file: string, enabled: boolean): Promise<void> {\n if (!enabled) return;\n try {\n await exec(`oxipng -o 3 \"${file}\"`);\n } catch {\n // oxipng missing or failed — lossless crush is best-effort, so skip quietly.\n if (!warnedNoOxipng) {\n warnedNoOxipng = true;\n console.warn('favicon: oxipng not available — skipping lossless PNG optimization.');\n }\n }\n}\n\n/**\n * Generate the standard favicon set from an SVG/PNG source into `outputDir`:\n * `favicon.ico`, `icon-{16?,32,192,512}.png`, `apple-touch-icon.png`, `icon-mask.png`.\n * Returns the list of written file paths.\n */\nexport async function generateFavicons(opts: FaviconOptions): Promise<string[]> {\n const { source, lowResSource } = opts;\n if (!source) throw new Error('favicon: no source path provided');\n if (!source.endsWith('.svg') && !source.endsWith('.png'))\n throw new Error('favicon: source must be an SVG or PNG file');\n\n const dir = (opts.outputDir ?? '.').replace(/\\/+$/, '');\n const optimize = opts.optimize ?? true;\n mkdirSync(dir, { recursive: true });\n const out = (name: string) => join(dir, name);\n\n const written: string[] = [];\n\n // Modern browsers prefer a scalable SVG favicon (and it supports dark-mode via\n // CSS media queries) — ship the source verbatim as favicon.svg when it is one.\n if (source.endsWith('.svg')) {\n await copyFile(source, out('favicon.svg'));\n written.push(out('favicon.svg'));\n }\n\n // Lazy-load the native/heavy deps only when actually generating.\n const [{ default: sharp }, { default: pngToIco }] = await Promise.all([\n import('sharp'),\n import('png-to-ico'),\n ]);\n\n // Rasterize the main source at each size. sharp reads SVG natively via libvips;\n // rendering at high density first keeps small-size edges crisp (ignored for PNG).\n await Promise.all(\n [32, 192, 512].map((size) =>\n sharp(source, { density: 384 })\n .resize(size, size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })\n .png()\n .toFile(out(`icon-${size}.png`))\n .then(() => written.push(out(`icon-${size}.png`))),\n ),\n );\n\n // Optional low-res 16px variant.\n if (lowResSource) {\n await sharp(lowResSource, { density: 384 })\n .resize(16, 16, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })\n .png()\n .toFile(out('icon-16.png'));\n written.push(out('icon-16.png'));\n }\n\n // favicon.ico — sharp can't encode .ico, so png-to-ico packs the PNGs into the\n // multi-resolution container. Browsers pick the best size inside the file.\n const icoSources = [out('icon-32.png')];\n if (lowResSource) icoSources.unshift(out('icon-16.png'));\n await writeFile(out('favicon.ico'), await pngToIco(icoSources));\n written.push(out('favicon.ico'));\n\n // apple-touch-icon: 140x140 logo centered on a 180x180 transparent canvas.\n await sharp(out('icon-512.png'))\n .resize(140, 140, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })\n .extend({\n top: 20,\n bottom: 20,\n left: 20,\n right: 20,\n background: { r: 0, g: 0, b: 0, alpha: 0 },\n })\n .png()\n .toFile(out('apple-touch-icon.png'));\n written.push(out('apple-touch-icon.png'));\n\n // icon-mask (maskable): 409x409 logo centered on a 512x512 canvas.\n await sharp(out('icon-512.png'))\n .resize(409, 409, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })\n .extend({\n top: 52,\n bottom: 51,\n left: 52,\n right: 51,\n background: { r: 0, g: 0, b: 0, alpha: 0 },\n })\n .png()\n .toFile(out('icon-mask.png'));\n written.push(out('icon-mask.png'));\n\n // Lossless crush of the larger PNGs (best-effort; needs oxipng on PATH).\n await Promise.all(\n ['icon-192.png', 'icon-512.png', 'apple-touch-icon.png', 'icon-mask.png'].map((image) =>\n optimizePng(out(image), optimize),\n ),\n );\n\n return written;\n}\n\n// ── Head tags + web manifest (the \"tag layer\" over the generated image files) ──\n\nexport interface FaviconLink {\n rel: string;\n href: string;\n type?: string;\n sizes?: string;\n}\n\nexport interface FaviconTagOptions {\n /** Whether a favicon.svg was emitted (source was an SVG). */\n hasSvg?: boolean;\n /** Include the `<link rel=\"manifest\">` (default true). */\n manifest?: boolean;\n /** URL base the favicon files are served from (default '' → site root). */\n base?: string;\n}\n\n/**\n * The ordered `<link>` data matching what `generateFavicons` writes. Pure — no\n * I/O — so it can drive an Astro `<Head />`, the CLI, or plain HTML.\n */\nexport function faviconLinks(opts: FaviconTagOptions = {}): FaviconLink[] {\n const base = (opts.base ?? '').replace(/\\/+$/, '');\n const href = (f: string) => `${base}/${f}`;\n const links: FaviconLink[] = [{ rel: 'icon', href: href('favicon.ico'), sizes: '32x32' }];\n if (opts.hasSvg) links.push({ rel: 'icon', type: 'image/svg+xml', href: href('favicon.svg') });\n links.push({ rel: 'icon', type: 'image/png', sizes: '32x32', href: href('icon-32.png') });\n links.push({ rel: 'apple-touch-icon', href: href('apple-touch-icon.png') });\n if (opts.manifest !== false) links.push({ rel: 'manifest', href: href('site.webmanifest') });\n return links;\n}\n\nexport interface WebManifestOptions {\n name: string;\n shortName?: string;\n themeColor?: string;\n backgroundColor?: string;\n /** URL base the icon files are served from (default '' → site root). */\n base?: string;\n}\n\n/** Build the web-app-manifest object (icons match `generateFavicons` output). */\nexport function faviconManifest(opts: WebManifestOptions): Record<string, unknown> {\n const base = (opts.base ?? '').replace(/\\/+$/, '');\n const src = (f: string) => `${base}/${f}`;\n return {\n name: opts.name,\n short_name: opts.shortName ?? opts.name,\n icons: [\n { src: src('icon-192.png'), sizes: '192x192', type: 'image/png' },\n { src: src('icon-512.png'), sizes: '512x512', type: 'image/png' },\n { src: src('icon-mask.png'), sizes: '512x512', type: 'image/png', purpose: 'maskable' },\n ],\n theme_color: opts.themeColor ?? '#ffffff',\n background_color: opts.backgroundColor ?? '#ffffff',\n display: 'standalone',\n start_url: '/',\n };\n}\n\n/** Write `site.webmanifest` into `dir`. Returns the written path. */\nexport async function writeFaviconManifest(dir: string, opts: WebManifestOptions): Promise<string> {\n const path = join(dir.replace(/\\/+$/, ''), 'site.webmanifest');\n await writeFile(path, `${JSON.stringify(faviconManifest(opts), null, 2)}\\n`);\n return path;\n}\n"],"mappings":"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"}
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { FaviconOptions, generateFavicons } from "./favicon.mjs";
2
- export { type FaviconOptions, generateFavicons };
1
+ import { FaviconLink, FaviconOptions, FaviconTagOptions, WebManifestOptions, faviconLinks, faviconManifest, generateFavicons, writeFaviconManifest } from "./favicon.mjs";
2
+ export { type FaviconLink, type FaviconOptions, type FaviconTagOptions, type WebManifestOptions, faviconLinks, faviconManifest, generateFavicons, writeFaviconManifest };
package/dist/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
- import { generateFavicons } from "./favicon.mjs";
1
+ import { faviconLinks, faviconManifest, generateFavicons, writeFaviconManifest } from "./favicon.mjs";
2
2
 
3
- export { generateFavicons };
3
+ export { faviconLinks, faviconManifest, generateFavicons, writeFaviconManifest };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getvitops/utils",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Shared build-time utilities for the Vitops toolchain — favicon generation and friends.",
5
5
  "license": "MIT",
6
6
  "files": [
@@ -16,13 +16,21 @@
16
16
  "./favicon": {
17
17
  "types": "./dist/favicon.d.mts",
18
18
  "import": "./dist/favicon.mjs"
19
+ },
20
+ "./color": {
21
+ "types": "./dist/color/index.d.mts",
22
+ "import": "./dist/color/index.mjs"
19
23
  }
20
24
  },
21
25
  "publishConfig": {
22
26
  "access": "public"
23
27
  },
24
28
  "dependencies": {
29
+ "colorjs.io": "^0.5.2",
25
30
  "png-to-ico": "^3.0.2",
26
31
  "sharp": "^0.35.3"
32
+ },
33
+ "devDependencies": {
34
+ "vitest": "^4.1.9"
27
35
  }
28
36
  }