@tenphi/glaze 0.1.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.mts CHANGED
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * Finds the closest OKHSL lightness that satisfies a WCAG 2 contrast target
6
6
  * against a base color. Used by glaze when resolving dependent colors
7
- * with `ensureContrast`.
7
+ * with `contrast`.
8
8
  */
9
9
  type ContrastPreset = 'AA' | 'AAA' | 'AA-large' | 'AAA-large';
10
10
  type MinContrast$1 = number | ContrastPreset;
@@ -17,8 +17,8 @@ interface FindLightnessForContrastOptions {
17
17
  preferredLightness: number;
18
18
  /** Base/reference color as linear sRGB (channels may be outside 0–1 before clamp). */
19
19
  baseLinearRgb: [number, number, number];
20
- /** Ensures the WCAG contrast ratio meets a target floor. */
21
- ensureContrast: MinContrast$1;
20
+ /** WCAG contrast ratio target floor. */
21
+ contrast: MinContrast$1;
22
22
  /** Search bounds for lightness. Default: [0, 1]. */
23
23
  lightnessRange?: [number, number];
24
24
  /** Convergence threshold. Default: 1e-4. */
@@ -48,6 +48,8 @@ declare function findLightnessForContrast(options: FindLightnessForContrastOptio
48
48
  type HCPair<T> = T | [T, T];
49
49
  type MinContrast = number | ContrastPreset;
50
50
  type AdaptationMode = 'auto' | 'fixed' | 'static';
51
+ /** A signed relative offset string, e.g. '+20' or '-15.5'. */
52
+ type RelativeValue = `+${number}` | `-${number}`;
51
53
  /** Color format for output. */
52
54
  type GlazeColorFormat = 'okhsl' | 'rgb' | 'hsl' | 'oklch';
53
55
  /**
@@ -61,16 +63,24 @@ interface GlazeOutputModes {
61
63
  highContrast?: boolean;
62
64
  }
63
65
  interface ColorDef {
64
- /** Lightness in the light scheme (0–100). Root color. */
65
- l?: HCPair<number>;
66
+ /**
67
+ * Lightness value (0–100).
68
+ * - Number: absolute lightness.
69
+ * - String ('+N' / '-N'): relative to base color's lightness (requires `base`).
70
+ */
71
+ lightness?: HCPair<number | RelativeValue>;
66
72
  /** Saturation factor applied to the seed saturation (0–1, default: 1). */
67
- sat?: number;
73
+ saturation?: number;
74
+ /**
75
+ * Hue override for this color.
76
+ * - Number: absolute hue (0–360).
77
+ * - String ('+N' / '-N'): relative to the theme seed hue.
78
+ */
79
+ hue?: number | RelativeValue;
68
80
  /** Name of another color in the same theme (dependent color). */
69
81
  base?: string;
70
- /** Lightness delta from the base color. */
71
- contrast?: HCPair<number>;
72
- /** Ensures the WCAG contrast ratio meets a target floor against the base. */
73
- ensureContrast?: HCPair<MinContrast>;
82
+ /** WCAG contrast ratio floor against the base color. */
83
+ contrast?: HCPair<MinContrast>;
74
84
  /** Adaptation mode. Default: 'auto'. */
75
85
  mode?: AdaptationMode;
76
86
  }
@@ -125,8 +135,8 @@ interface GlazeThemeExport {
125
135
  interface GlazeColorInput {
126
136
  hue: number;
127
137
  saturation: number;
128
- l: HCPair<number>;
129
- sat?: number;
138
+ lightness: HCPair<number>;
139
+ saturationFactor?: number;
130
140
  mode?: AdaptationMode;
131
141
  }
132
142
  /** Return type for `glaze.color()`. */
@@ -167,6 +177,8 @@ interface GlazeTheme {
167
177
  tokens(options?: GlazeTokenOptions): Record<string, Record<string, string>>;
168
178
  /** Export as plain JSON. */
169
179
  json(options?: GlazeJsonOptions): Record<string, Record<string, string>>;
180
+ /** Export as CSS custom property declarations. */
181
+ css(options?: GlazeCssOptions): GlazeCssResult;
170
182
  }
171
183
  interface GlazeExtendOptions {
172
184
  hue?: number;
@@ -192,6 +204,19 @@ interface GlazeJsonOptions {
192
204
  /** Output color format. Default: 'okhsl'. */
193
205
  format?: GlazeColorFormat;
194
206
  }
207
+ interface GlazeCssOptions {
208
+ /** Output color format. Default: 'rgb'. */
209
+ format?: GlazeColorFormat;
210
+ /** Suffix appended to each CSS custom property name. Default: '-color'. */
211
+ suffix?: string;
212
+ }
213
+ /** CSS custom property declarations grouped by scheme variant. */
214
+ interface GlazeCssResult {
215
+ light: string;
216
+ dark: string;
217
+ lightContrast: string;
218
+ darkContrast: string;
219
+ }
195
220
  interface GlazePalette {
196
221
  /** Export all themes as a combined token map. */
197
222
  tokens(options?: GlazeTokenOptions): Record<string, Record<string, string>>;
@@ -199,6 +224,10 @@ interface GlazePalette {
199
224
  json(options?: GlazeJsonOptions & {
200
225
  prefix?: boolean | Record<string, string>;
201
226
  }): Record<string, Record<string, Record<string, string>>>;
227
+ /** Export all themes as CSS custom property declarations. */
228
+ css(options?: GlazeCssOptions & {
229
+ prefix?: boolean | Record<string, string>;
230
+ }): GlazeCssResult;
202
231
  }
203
232
  //#endregion
204
233
  //#region src/glaze.d.ts
@@ -224,6 +253,9 @@ declare namespace glaze {
224
253
  json(options?: GlazeJsonOptions & {
225
254
  prefix?: boolean | Record<string, string>;
226
255
  }): Record<string, Record<string, Record<string, string>>>;
256
+ css(options?: GlazeCssOptions & {
257
+ prefix?: boolean | Record<string, string>;
258
+ }): GlazeCssResult;
227
259
  };
228
260
  var from: (data: GlazeThemeExport) => GlazeTheme;
229
261
  var color: (input: GlazeColorInput) => GlazeColorToken;
@@ -294,5 +326,5 @@ declare function formatHsl(h: number, s: number, l: number): string;
294
326
  */
295
327
  declare function formatOklch(h: number, s: number, l: number): string;
296
328
  //#endregion
297
- export { type AdaptationMode, type ColorDef, type ColorMap, type ContrastPreset, type FindLightnessForContrastOptions, type FindLightnessForContrastResult, type GlazeColorFormat, type GlazeColorInput, type GlazeColorToken, type GlazeConfig, type GlazeExtendOptions, type GlazeJsonOptions, type GlazeOutputModes, type GlazePalette, type GlazeTheme, type GlazeThemeExport, type GlazeTokenOptions, type HCPair, type MinContrast, type ResolvedColor, type ResolvedColorVariant, contrastRatioFromLuminance, findLightnessForContrast, formatHsl, formatOkhsl, formatOklch, formatRgb, glaze, okhslToLinearSrgb, okhslToOklab, okhslToSrgb, parseHex, relativeLuminanceFromLinearRgb, resolveMinContrast, srgbToOkhsl };
329
+ export { type AdaptationMode, type ColorDef, type ColorMap, type ContrastPreset, type FindLightnessForContrastOptions, type FindLightnessForContrastResult, type GlazeColorFormat, type GlazeColorInput, type GlazeColorToken, type GlazeConfig, type GlazeCssOptions, type GlazeCssResult, type GlazeExtendOptions, type GlazeJsonOptions, type GlazeOutputModes, type GlazePalette, type GlazeTheme, type GlazeThemeExport, type GlazeTokenOptions, type HCPair, type MinContrast, type RelativeValue, type ResolvedColor, type ResolvedColorVariant, contrastRatioFromLuminance, findLightnessForContrast, formatHsl, formatOkhsl, formatOklch, formatRgb, glaze, okhslToLinearSrgb, okhslToOklab, okhslToSrgb, parseHex, relativeLuminanceFromLinearRgb, resolveMinContrast, srgbToOkhsl };
298
330
  //# sourceMappingURL=index.d.mts.map
package/dist/index.mjs CHANGED
@@ -493,7 +493,7 @@ function formatOklch(h, s, l) {
493
493
  *
494
494
  * Finds the closest OKHSL lightness that satisfies a WCAG 2 contrast target
495
495
  * against a base color. Used by glaze when resolving dependent colors
496
- * with `ensureContrast`.
496
+ * with `contrast`.
497
497
  */
498
498
  const CONTRAST_PRESETS = {
499
499
  AA: 4.5,
@@ -631,8 +631,8 @@ function coarseScan(h, s, lo, hi, yBase, target, epsilon, maxIter) {
631
631
  * against a base color, staying as close to `preferredLightness` as possible.
632
632
  */
633
633
  function findLightnessForContrast(options) {
634
- const { hue, saturation, preferredLightness, baseLinearRgb, ensureContrast: ensureContrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
635
- const target = resolveMinContrast(ensureContrastInput);
634
+ const { hue, saturation, preferredLightness, baseLinearRgb, contrast: contrastInput, lightnessRange = [0, 1], epsilon = 1e-4, maxIterations = 14 } = options;
635
+ const target = resolveMinContrast(contrastInput);
636
636
  const yBase = relativeLuminanceFromLinearRgb(baseLinearRgb);
637
637
  const crPref = contrastRatioFromLuminance(cachedLuminance(hue, saturation, preferredLightness), yBase);
638
638
  if (crPref >= target) return {
@@ -713,9 +713,10 @@ function validateColorDefs(defs) {
713
713
  const names = new Set(Object.keys(defs));
714
714
  for (const [name, def] of Object.entries(defs)) {
715
715
  if (def.contrast !== void 0 && !def.base) throw new Error(`glaze: color "${name}" has "contrast" without "base".`);
716
- if (def.l !== void 0 && def.base !== void 0) console.warn(`glaze: color "${name}" has both "l" and "base". "l" takes precedence.`);
716
+ if (def.lightness !== void 0 && !isAbsoluteLightness(def.lightness) && !def.base) throw new Error(`glaze: color "${name}" has relative "lightness" without "base".`);
717
+ if (isAbsoluteLightness(def.lightness) && def.base !== void 0) console.warn(`glaze: color "${name}" has absolute "lightness" and "base". Absolute lightness takes precedence.`);
717
718
  if (def.base && !names.has(def.base)) throw new Error(`glaze: color "${name}" references non-existent base "${def.base}".`);
718
- if (def.l === void 0 && def.base === void 0) throw new Error(`glaze: color "${name}" must have either "l" (root) or "base" + "contrast" (dependent).`);
719
+ if (!isAbsoluteLightness(def.lightness) && def.base === void 0) throw new Error(`glaze: color "${name}" must have either absolute "lightness" (root) or "base" (dependent).`);
719
720
  }
720
721
  const visited = /* @__PURE__ */ new Set();
721
722
  const inStack = /* @__PURE__ */ new Set();
@@ -724,7 +725,7 @@ function validateColorDefs(defs) {
724
725
  if (visited.has(name)) return;
725
726
  inStack.add(name);
726
727
  const def = defs[name];
727
- if (def.base && def.l === void 0) dfs(def.base);
728
+ if (def.base && !isAbsoluteLightness(def.lightness)) dfs(def.base);
728
729
  inStack.delete(name);
729
730
  visited.add(name);
730
731
  }
@@ -737,7 +738,7 @@ function topoSort(defs) {
737
738
  if (visited.has(name)) return;
738
739
  visited.add(name);
739
740
  const def = defs[name];
740
- if (def.base && def.l === void 0) visit(def.base);
741
+ if (def.base && !isAbsoluteLightness(def.lightness)) visit(def.base);
741
742
  result.push(name);
742
743
  }
743
744
  for (const name of Object.keys(defs)) visit(name);
@@ -753,44 +754,75 @@ function mapSaturationDark(s, mode) {
753
754
  if (mode === "static") return s;
754
755
  return s * (1 - globalConfig.darkDesaturation);
755
756
  }
757
+ function clamp(v, min, max) {
758
+ return Math.max(min, Math.min(max, v));
759
+ }
756
760
  /**
757
- * Resolve the effective lightness from a contrast delta.
761
+ * Parse a value that can be absolute (number) or relative (signed string).
762
+ * Returns the numeric value and whether it's relative.
758
763
  */
759
- function resolveContrastLightness(baseLightness, contrast) {
760
- if (contrast < 0) return clamp(baseLightness + contrast, 0, 100);
761
- const candidate = baseLightness + contrast;
762
- if (candidate > 100) return clamp(baseLightness - contrast, 0, 100);
763
- return clamp(candidate, 0, 100);
764
+ function parseRelativeOrAbsolute(value) {
765
+ if (typeof value === "number") return {
766
+ value,
767
+ relative: false
768
+ };
769
+ return {
770
+ value: parseFloat(value),
771
+ relative: true
772
+ };
764
773
  }
765
- function clamp(v, min, max) {
766
- return Math.max(min, Math.min(max, v));
774
+ /**
775
+ * Compute the effective hue for a color, given the theme seed hue
776
+ * and an optional per-color hue override.
777
+ */
778
+ function resolveEffectiveHue(seedHue, defHue) {
779
+ if (defHue === void 0) return seedHue;
780
+ const parsed = parseRelativeOrAbsolute(defHue);
781
+ if (parsed.relative) return ((seedHue + parsed.value) % 360 + 360) % 360;
782
+ return (parsed.value % 360 + 360) % 360;
783
+ }
784
+ /**
785
+ * Check whether a lightness value represents an absolute root definition
786
+ * (i.e. a number, not a relative string).
787
+ */
788
+ function isAbsoluteLightness(lightness) {
789
+ if (lightness === void 0) return false;
790
+ return typeof (Array.isArray(lightness) ? lightness[0] : lightness) === "number";
767
791
  }
768
792
  function resolveRootColor(_name, def, _ctx, isHighContrast) {
769
- const rawL = def.l;
793
+ const rawL = def.lightness;
770
794
  return {
771
- lightL: clamp(isHighContrast ? pairHC(rawL) : pairNormal(rawL), 0, 100),
772
- sat: clamp(def.sat ?? 1, 0, 1)
795
+ lightL: clamp(parseRelativeOrAbsolute(isHighContrast ? pairHC(rawL) : pairNormal(rawL)).value, 0, 100),
796
+ satFactor: clamp(def.saturation ?? 1, 0, 1)
773
797
  };
774
798
  }
775
- function resolveDependentColor(name, def, ctx, isHighContrast, isDark) {
799
+ function resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue) {
776
800
  const baseName = def.base;
777
801
  const baseResolved = ctx.resolved.get(baseName);
778
802
  if (!baseResolved) throw new Error(`glaze: base "${baseName}" not yet resolved for "${name}".`);
779
803
  const mode = def.mode ?? "auto";
780
- const sat = clamp(def.sat ?? 1, 0, 1);
804
+ const satFactor = clamp(def.saturation ?? 1, 0, 1);
781
805
  let baseL;
782
806
  if (isDark && isHighContrast) baseL = baseResolved.darkContrast.l * 100;
783
807
  else if (isDark) baseL = baseResolved.dark.l * 100;
784
808
  else if (isHighContrast) baseL = baseResolved.lightContrast.l * 100;
785
809
  else baseL = baseResolved.light.l * 100;
786
- const rawContrast = def.contrast ?? 0;
787
- let contrast = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
788
- if (isDark && mode === "auto") contrast = -contrast;
789
- const preferredL = resolveContrastLightness(baseL, contrast);
790
- const rawEnsureContrast = def.ensureContrast;
791
- if (rawEnsureContrast !== void 0) {
792
- const minCr = isHighContrast ? pairHC(rawEnsureContrast) : pairNormal(rawEnsureContrast);
793
- const effectiveSat = isDark ? mapSaturationDark(sat * ctx.saturation / 100, mode) : sat * ctx.saturation / 100;
810
+ let preferredL;
811
+ const rawLightness = def.lightness;
812
+ if (rawLightness === void 0) preferredL = baseL;
813
+ else {
814
+ const parsed = parseRelativeOrAbsolute(isHighContrast ? pairHC(rawLightness) : pairNormal(rawLightness));
815
+ if (parsed.relative) {
816
+ let delta = parsed.value;
817
+ if (isDark && mode === "auto") delta = -delta;
818
+ preferredL = clamp(baseL + delta, 0, 100);
819
+ } else if (isDark) preferredL = mapLightnessDark(parsed.value, mode);
820
+ else preferredL = clamp(parsed.value, 0, 100);
821
+ }
822
+ const rawContrast = def.contrast;
823
+ if (rawContrast !== void 0) {
824
+ const minCr = isHighContrast ? pairHC(rawContrast) : pairNormal(rawContrast);
825
+ const effectiveSat = isDark ? mapSaturationDark(satFactor * ctx.saturation / 100, mode) : satFactor * ctx.saturation / 100;
794
826
  let baseH;
795
827
  let baseS;
796
828
  let baseLNorm;
@@ -814,48 +846,49 @@ function resolveDependentColor(name, def, ctx, isHighContrast, isDark) {
814
846
  const baseLinearRgb = okhslToLinearSrgb(baseH, baseS, baseLNorm);
815
847
  return {
816
848
  l: findLightnessForContrast({
817
- hue: ctx.hue,
849
+ hue: effectiveHue,
818
850
  saturation: effectiveSat,
819
851
  preferredLightness: preferredL / 100,
820
852
  baseLinearRgb,
821
- ensureContrast: minCr
853
+ contrast: minCr
822
854
  }).lightness * 100,
823
- sat
855
+ satFactor
824
856
  };
825
857
  }
826
858
  return {
827
859
  l: clamp(preferredL, 0, 100),
828
- sat
860
+ satFactor
829
861
  };
830
862
  }
831
863
  function resolveColorForScheme(name, def, ctx, isDark, isHighContrast) {
832
864
  const mode = def.mode ?? "auto";
833
- const isRoot = def.l !== void 0;
865
+ const isRoot = isAbsoluteLightness(def.lightness) && !def.base;
866
+ const effectiveHue = resolveEffectiveHue(ctx.hue, def.hue);
834
867
  let lightL;
835
- let sat;
868
+ let satFactor;
836
869
  if (isRoot) {
837
870
  const root = resolveRootColor(name, def, ctx, isHighContrast);
838
871
  lightL = root.lightL;
839
- sat = root.sat;
872
+ satFactor = root.satFactor;
840
873
  } else {
841
- const dep = resolveDependentColor(name, def, ctx, isHighContrast, isDark);
874
+ const dep = resolveDependentColor(name, def, ctx, isHighContrast, isDark, effectiveHue);
842
875
  lightL = dep.l;
843
- sat = dep.sat;
876
+ satFactor = dep.satFactor;
844
877
  }
845
878
  let finalL;
846
879
  let finalSat;
847
880
  if (isDark && isRoot) {
848
881
  finalL = mapLightnessDark(lightL, mode);
849
- finalSat = mapSaturationDark(sat * ctx.saturation / 100, mode);
882
+ finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
850
883
  } else if (isDark && !isRoot) {
851
884
  finalL = lightL;
852
- finalSat = mapSaturationDark(sat * ctx.saturation / 100, mode);
885
+ finalSat = mapSaturationDark(satFactor * ctx.saturation / 100, mode);
853
886
  } else {
854
887
  finalL = lightL;
855
- finalSat = sat * ctx.saturation / 100;
888
+ finalSat = satFactor * ctx.saturation / 100;
856
889
  }
857
890
  return {
858
- h: ctx.hue,
891
+ h: effectiveHue,
859
892
  s: clamp(finalSat, 0, 1),
860
893
  l: clamp(finalL / 100, 0, 1)
861
894
  };
@@ -974,6 +1007,27 @@ function buildJsonMap(resolved, modes, format = "okhsl") {
974
1007
  }
975
1008
  return result;
976
1009
  }
1010
+ function buildCssMap(resolved, prefix, suffix, format) {
1011
+ const lines = {
1012
+ light: [],
1013
+ dark: [],
1014
+ lightContrast: [],
1015
+ darkContrast: []
1016
+ };
1017
+ for (const [name, color] of resolved) {
1018
+ const prop = `--${prefix}${name}${suffix}`;
1019
+ lines.light.push(`${prop}: ${formatVariant(color.light, format)};`);
1020
+ lines.dark.push(`${prop}: ${formatVariant(color.dark, format)};`);
1021
+ lines.lightContrast.push(`${prop}: ${formatVariant(color.lightContrast, format)};`);
1022
+ lines.darkContrast.push(`${prop}: ${formatVariant(color.darkContrast, format)};`);
1023
+ }
1024
+ return {
1025
+ light: lines.light.join("\n"),
1026
+ dark: lines.dark.join("\n"),
1027
+ lightContrast: lines.lightContrast.join("\n"),
1028
+ darkContrast: lines.darkContrast.join("\n")
1029
+ };
1030
+ }
977
1031
  function createTheme(hue, saturation, initialColors) {
978
1032
  let colorDefs = initialColors ? { ...initialColors } : {};
979
1033
  return {
@@ -1030,6 +1084,9 @@ function createTheme(hue, saturation, initialColors) {
1030
1084
  },
1031
1085
  json(options) {
1032
1086
  return buildJsonMap(resolveAllColors(hue, saturation, colorDefs), resolveModes(options?.modes), options?.format);
1087
+ },
1088
+ css(options) {
1089
+ return buildCssMap(resolveAllColors(hue, saturation, colorDefs), "", options?.suffix ?? "-color", options?.format ?? "rgb");
1033
1090
  }
1034
1091
  };
1035
1092
  }
@@ -1057,13 +1114,42 @@ function createPalette(themes) {
1057
1114
  const result = {};
1058
1115
  for (const [themeName, theme] of Object.entries(themes)) result[themeName] = buildJsonMap(theme.resolve(), modes, options?.format);
1059
1116
  return result;
1117
+ },
1118
+ css(options) {
1119
+ const suffix = options?.suffix ?? "-color";
1120
+ const format = options?.format ?? "rgb";
1121
+ const allLines = {
1122
+ light: [],
1123
+ dark: [],
1124
+ lightContrast: [],
1125
+ darkContrast: []
1126
+ };
1127
+ for (const [themeName, theme] of Object.entries(themes)) {
1128
+ const resolved = theme.resolve();
1129
+ let prefix = "";
1130
+ if (options?.prefix === true) prefix = `${themeName}-`;
1131
+ else if (typeof options?.prefix === "object" && options.prefix !== null) prefix = options.prefix[themeName] ?? `${themeName}-`;
1132
+ const css = buildCssMap(resolved, prefix, suffix, format);
1133
+ for (const key of [
1134
+ "light",
1135
+ "dark",
1136
+ "lightContrast",
1137
+ "darkContrast"
1138
+ ]) if (css[key]) allLines[key].push(css[key]);
1139
+ }
1140
+ return {
1141
+ light: allLines.light.join("\n"),
1142
+ dark: allLines.dark.join("\n"),
1143
+ lightContrast: allLines.lightContrast.join("\n"),
1144
+ darkContrast: allLines.darkContrast.join("\n")
1145
+ };
1060
1146
  }
1061
1147
  };
1062
1148
  }
1063
1149
  function createColorToken(input) {
1064
1150
  const defs = { __color__: {
1065
- l: input.l,
1066
- sat: input.sat,
1151
+ lightness: input.lightness,
1152
+ saturation: input.saturationFactor,
1067
1153
  mode: input.mode
1068
1154
  } };
1069
1155
  return {