theme-vir 28.24.0 → 28.25.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.
@@ -43,6 +43,17 @@ export type ArrayOrSelectParam<T extends PropertyKey> = ReadonlyArray<T> | Reado
43
43
  export declare const defaultLightThemePair: RequiredAndNotNull<NoRefColorInit>;
44
44
  /** @category Internal */
45
45
  export declare const defaultContrastLevels: Readonly<ArrayOrSelectParam<ContrastLevelName>>;
46
+ /**
47
+ * Extra contrast levels generated by {@link buildColorTheme} beyond the standard `ContrastLevelName`
48
+ * values. `highest-contrast` always picks the palette color with the most contrast against the
49
+ * fixed color; `lowest-contrast` always picks the closest.
50
+ *
51
+ * @category Internal
52
+ */
53
+ export declare enum ExtremeContrastLevel {
54
+ HighestContrast = "highest-contrast",
55
+ LowestContrast = "lowest-contrast"
56
+ }
46
57
  /**
47
58
  * Options for {@link buildColorTheme}.
48
59
  *
@@ -90,9 +90,56 @@ export const defaultLightThemePair = {
90
90
  };
91
91
  /** @category Internal */
92
92
  export const defaultContrastLevels = getEnumValues(ContrastLevelName);
93
- function findColorWithPreference(colors, desiredContrastLevel, preference,
94
- /** Pre-computed contrast-against-white values per color string. Higher = darker. */
95
- lightnessProxies) {
93
+ /**
94
+ * Extra contrast levels generated by {@link buildColorTheme} beyond the standard `ContrastLevelName`
95
+ * values. `highest-contrast` always picks the palette color with the most contrast against the
96
+ * fixed color; `lowest-contrast` always picks the closest.
97
+ *
98
+ * @category Internal
99
+ */
100
+ export var ExtremeContrastLevel;
101
+ (function (ExtremeContrastLevel) {
102
+ ExtremeContrastLevel["HighestContrast"] = "highest-contrast";
103
+ ExtremeContrastLevel["LowestContrast"] = "lowest-contrast";
104
+ })(ExtremeContrastLevel || (ExtremeContrastLevel = {}));
105
+ /**
106
+ * Picks the absolute lightest or darkest palette color based on which extreme produces the most (or
107
+ * least) contrast against the fixed color.
108
+ */
109
+ function resolveExtremeContrastColor({ comparison, isHighestContrast, lightestColorString, darkestColorString, }) {
110
+ const paletteIsBackground = check.isArray(comparison.background);
111
+ const fixedColor = paletteIsBackground
112
+ ? comparison.foreground
113
+ : comparison.background;
114
+ const lightContrastParams = paletteIsBackground
115
+ ? {
116
+ foreground: fixedColor,
117
+ background: lightestColorString,
118
+ }
119
+ : {
120
+ foreground: lightestColorString,
121
+ background: fixedColor,
122
+ };
123
+ const darkContrastParams = paletteIsBackground
124
+ ? {
125
+ foreground: fixedColor,
126
+ background: darkestColorString,
127
+ }
128
+ : {
129
+ foreground: darkestColorString,
130
+ background: fixedColor,
131
+ };
132
+ const lightestContrast = Math.abs(calculateContrast(lightContrastParams).contrast);
133
+ const darkestContrast = Math.abs(calculateContrast(darkContrastParams).contrast);
134
+ return isHighestContrast
135
+ ? lightestContrast > darkestContrast
136
+ ? lightestColorString
137
+ : darkestColorString
138
+ : lightestContrast < darkestContrast
139
+ ? lightestColorString
140
+ : darkestColorString;
141
+ }
142
+ function findColorWithPreference({ colors, desiredContrastLevel, preference, lightnessProxies, }) {
96
143
  const minContrast = contrastLevelNameMap[desiredContrastLevel].min;
97
144
  const candidateColors = check.isArray(colors.foreground)
98
145
  ? colors.foreground
@@ -140,6 +187,11 @@ export function buildColorTheme(colorPalette, { omittedColorValues = defaultOmit
140
187
  const lightThemeColors = {};
141
188
  const darkThemeOverrides = {};
142
189
  // Compute these once outside the loop since they don't change
190
+ const allContrastLevels = [
191
+ ExtremeContrastLevel.HighestContrast,
192
+ ...contrastLevels,
193
+ ExtremeContrastLevel.LowestContrast,
194
+ ];
143
195
  const allCrosses = crossProduct({
144
196
  crossWith: [
145
197
  'color-in-foreground-light-mode',
@@ -151,7 +203,7 @@ export function buildColorTheme(colorPalette, { omittedColorValues = defaultOmit
151
203
  'color-on-self-light-mode',
152
204
  'color-on-self-dark-mode',
153
205
  ],
154
- contrast: contrastLevels,
206
+ contrast: allContrastLevels,
155
207
  });
156
208
  const defaultForegroundString = noRefColorInitToString(defaultTheme.foreground);
157
209
  const defaultBackgroundString = noRefColorInitToString(defaultTheme.background);
@@ -160,10 +212,12 @@ export function buildColorTheme(colorPalette, { omittedColorValues = defaultOmit
160
212
  const colorStrings = colors.map((color) => color.definition.default);
161
213
  const firstColor = colors[0];
162
214
  // Create an object for O(1) color lookup instead of O(n) find()
163
- const colorByDefault = arrayToObject(colors, (color) => ({
164
- key: color.definition.default,
165
- value: color,
166
- }));
215
+ const colorByDefault = arrayToObject(colors, (color) => {
216
+ return {
217
+ key: color.definition.default,
218
+ value: color,
219
+ };
220
+ });
167
221
  /** Pre-computed contrast-against-white per color. Higher value = darker shade. */
168
222
  const lightnessProxies = arrayToObject(colorStrings, (colorString) => {
169
223
  return {
@@ -185,17 +239,36 @@ export function buildColorTheme(colorPalette, { omittedColorValues = defaultOmit
185
239
  * bg. Fixed across all on-self contrast levels.
186
240
  */
187
241
  const lightSelfFgString = assertWrap.isTruthy(findColorWithPreference({
188
- foreground: colorStrings,
189
- background: lightestColorString,
190
- }, ContrastLevelName.SmallBodyText, 'lightest', lightnessProxies), `Failed to find light mode on-self foreground color for ${firstColor.colorName}`);
242
+ colors: {
243
+ foreground: colorStrings,
244
+ background: lightestColorString,
245
+ },
246
+ desiredContrastLevel: ContrastLevelName.SmallBodyText,
247
+ preference: 'lightest',
248
+ lightnessProxies,
249
+ }), `Failed to find light mode on-self foreground color for ${firstColor.colorName}`);
191
250
  /**
192
251
  * On-self dark mode: darkest fg achieving small-body contrast on the darkest palette
193
252
  * bg. Fixed across all on-self contrast levels.
194
253
  */
195
254
  const darkSelfFgString = assertWrap.isTruthy(findColorWithPreference({
196
- foreground: colorStrings,
197
- background: darkestColorString,
198
- }, ContrastLevelName.SmallBodyText, 'darkest', lightnessProxies), `Failed to find dark mode on-self foreground color for ${firstColor.colorName}`);
255
+ colors: {
256
+ foreground: colorStrings,
257
+ background: darkestColorString,
258
+ },
259
+ desiredContrastLevel: ContrastLevelName.SmallBodyText,
260
+ preference: 'darkest',
261
+ lightnessProxies,
262
+ }), `Failed to find dark mode on-self foreground color for ${firstColor.colorName}`);
263
+ /**
264
+ * Reversed palette order for dark mode on-self backgrounds. `findColorAtContrastLevel`
265
+ * picks the last qualifying color in array order. With the natural lightest-to-darkest
266
+ * order, high-contrast backgrounds end up as the darkest palette colors, which are
267
+ * indistinguishable from the dark page background. Reversing the order makes it select
268
+ * the lightest palette color that still achieves the required contrast level, producing
269
+ * backgrounds that are visible against the dark page.
270
+ */
271
+ const reversedColorStrings = colorStrings.toReversed();
199
272
  // Pre-compute base name parts that don't change per cross
200
273
  const baseNameParts = [
201
274
  prefix,
@@ -215,7 +288,7 @@ export function buildColorTheme(colorPalette, { omittedColorValues = defaultOmit
215
288
  : cross.crossWith === 'color-on-self-dark-mode'
216
289
  ? {
217
290
  foreground: darkSelfFgString,
218
- background: colorStrings,
291
+ background: reversedColorStrings,
219
292
  }
220
293
  : cross.crossWith === 'color-on-self-light-mode'
221
294
  ? {
@@ -285,7 +358,15 @@ export function buildColorTheme(colorPalette, { omittedColorValues = defaultOmit
285
358
  if (!comparison) {
286
359
  throw new Error(`Forgot to handle crossWith: '${cross.crossWith}'`);
287
360
  }
288
- const matchedColorString = findColorAtContrastLevel(comparison, cross.contrast);
361
+ const matchedColorString = cross.contrast === ExtremeContrastLevel.HighestContrast ||
362
+ cross.contrast === ExtremeContrastLevel.LowestContrast
363
+ ? resolveExtremeContrastColor({
364
+ comparison,
365
+ isHighestContrast: cross.contrast === ExtremeContrastLevel.HighestContrast,
366
+ lightestColorString,
367
+ darkestColorString,
368
+ })
369
+ : findColorAtContrastLevel(comparison, cross.contrast);
289
370
  const matchedColor = matchedColorString
290
371
  ? colorByDefault[matchedColorString]
291
372
  : undefined;
@@ -8,8 +8,18 @@ import { CSSResult } from 'element-vir';
8
8
  */
9
9
  export function generateThemeCode(theme, options) {
10
10
  const paletteVarName = options?.paletteVarName;
11
- const defaultInitCode = colorInitToCode(theme.init.default, 1, undefined, paletteVarName);
12
- const colorsInitCode = colorThemeInitToCode(theme.init.colors, 1, theme.init.default, paletteVarName);
11
+ const defaultInitCode = colorInitToCode({
12
+ colorInit: theme.init.default,
13
+ indentLevel: 1,
14
+ defaultInit: undefined,
15
+ paletteVarName,
16
+ });
17
+ const colorsInitCode = colorThemeInitToCode({
18
+ colorsInit: theme.init.colors,
19
+ indentLevel: 1,
20
+ defaultInit: theme.init.default,
21
+ paletteVarName,
22
+ });
13
23
  const themeCode = `export const theme = defineColorTheme(\n${defaultInitCode},\n${colorsInitCode},\n);`;
14
24
  const overridesCodes = (options?.overrides || []).map((override) => {
15
25
  return generateOverrideCode(override, paletteVarName);
@@ -63,6 +73,7 @@ function generateOverrideCode(override, paletteVarName) {
63
73
  function tab(level) {
64
74
  return ' '.repeat(level);
65
75
  }
76
+ // eslint-disable-next-line @virmator/prefer-params-object
66
77
  function colorInitValuesEqual(a, b) {
67
78
  if (typeof a !== typeof b) {
68
79
  return false;
@@ -116,7 +127,7 @@ function colorInitValueToCode(value, indentLevel, paletteVarName) {
116
127
  return `'${value.default}'`;
117
128
  }
118
129
  }
119
- function colorInitToCode(colorInit, indentLevel, defaultInit, paletteVarName) {
130
+ function colorInitToCode({ colorInit, indentLevel, defaultInit, paletteVarName, }) {
120
131
  const entries = [];
121
132
  if ('foreground' in colorInit &&
122
133
  (!defaultInit || !colorInitValuesEqual(colorInit.foreground, defaultInit.foreground)) &&
@@ -142,9 +153,14 @@ function colorInitToCode(colorInit, indentLevel, defaultInit, paletteVarName) {
142
153
  }
143
154
  return `${tab(indentLevel)}{\n${entries.join('\n')}\n${tab(indentLevel)}}`;
144
155
  }
145
- function colorThemeInitToCode(colorsInit, indentLevel, defaultInit, paletteVarName) {
156
+ function colorThemeInitToCode({ colorsInit, indentLevel, defaultInit, paletteVarName, }) {
146
157
  const entries = getObjectTypedEntries(colorsInit).map(([colorName, colorInit,]) => {
147
- return `${tab(indentLevel + 1)}'${colorName}': ${colorInitToCode(colorInit, indentLevel + 1, defaultInit, paletteVarName).trimStart()},`;
158
+ return `${tab(indentLevel + 1)}'${colorName}': ${colorInitToCode({
159
+ colorInit,
160
+ indentLevel: indentLevel + 1,
161
+ defaultInit,
162
+ paletteVarName,
163
+ }).trimStart()},`;
148
164
  });
149
165
  return `${tab(indentLevel)}{\n${entries.join('\n')}\n${tab(indentLevel)}}`;
150
166
  }
@@ -16,6 +16,7 @@ export function noRefColorInitToString(init) {
16
16
  *
17
17
  * @category Internal
18
18
  */
19
+ // eslint-disable-next-line @virmator/prefer-params-object
19
20
  export function createColorCssVarDefault(fromName, init, defaultInit, colorsInit) {
20
21
  const defaultForegroundKey = `${defaultInit.prefix}-default-fg`;
21
22
  const defaultBackgroundKey = `${defaultInit.prefix}-default-bg`;
@@ -77,7 +77,13 @@ function defineHeadingElement(options) {
77
77
  const headingSelectors = unsafeCSS(getEnumValues(HeadingLevel).join(', '));
78
78
  return defineElement()({
79
79
  tagName: headingTag,
80
- hostClasses: typedObjectFromEntries(getEnumValues(HeadingLevel).map((headingLevel) => {
80
+ hostClasses: typedObjectFromEntries(
81
+ /**
82
+ * `mapEnumToObject` cannot be used here: host class keys must be prefixed with the
83
+ * element's tag name, not the bare enum values.
84
+ */
85
+ // eslint-disable-next-line @virmator/prefer-map-enum-to-object
86
+ getEnumValues(HeadingLevel).map((headingLevel) => {
81
87
  return [
82
88
  `${headingTag}-${headingLevel}`,
83
89
  ({ inputs }) => inputs.headingLevel === headingLevel,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "theme-vir",
3
- "version": "28.24.0",
3
+ "version": "28.25.1",
4
4
  "description": "Create an entire web theme.",
5
5
  "keywords": [
6
6
  "design",
@@ -22,7 +22,7 @@
22
22
  "type": "git",
23
23
  "url": "git+https://github.com/electrovir/theme-vir.git"
24
24
  },
25
- "license": "(MIT or CC0 1.0)",
25
+ "license": "(MIT OR CC0-1.0)",
26
26
  "author": {
27
27
  "name": "electrovir",
28
28
  "url": "https://github.com/electrovir"
@@ -43,30 +43,30 @@
43
43
  "test:update": "virmator test web update"
44
44
  },
45
45
  "dependencies": {
46
- "@augment-vir/assert": "^31.67.1",
47
- "@augment-vir/common": "^31.67.1",
48
- "@electrovir/color": "^1.7.8",
46
+ "@augment-vir/assert": "^32.2.1",
47
+ "@augment-vir/common": "^32.2.1",
48
+ "@electrovir/color": "^1.7.11",
49
49
  "apca-w3": "^0.1.9",
50
- "lit-css-vars": "^3.5.0",
51
- "type-fest": "^5.4.4"
50
+ "lit-css-vars": "^3.6.3",
51
+ "type-fest": "^5.8.0"
52
52
  },
53
53
  "devDependencies": {
54
- "@augment-vir/test": "^31.67.1",
54
+ "@augment-vir/test": "^32.2.1",
55
55
  "@types/apca-w3": "^0.1.3",
56
- "@web/dev-server-esbuild": "^1.0.5",
57
- "@web/test-runner": "^0.20.2",
58
- "@web/test-runner-commands": "^0.9.0",
59
- "@web/test-runner-playwright": "^0.11.1",
60
- "@web/test-runner-visual-regression": "^0.10.0",
61
- "element-book": "^26.17.0",
62
- "element-vir": "^26.14.5",
63
- "esbuild": "^0.27.3",
56
+ "@web/dev-server-esbuild": "^2.0.0",
57
+ "@web/test-runner": "^1.0.0",
58
+ "@web/test-runner-commands": "^1.0.1",
59
+ "@web/test-runner-playwright": "^1.0.0",
60
+ "@web/test-runner-visual-regression": "^1.0.1",
61
+ "element-book": "^26.17.3",
62
+ "element-vir": "^26.17.1",
63
+ "esbuild": "^0.28.1",
64
64
  "istanbul-smart-text-reporter": "^1.1.5",
65
- "markdown-code-example-inserter": "^3.0.3",
66
- "typedoc": "^0.28.17",
65
+ "markdown-code-example-inserter": "^3.0.6",
66
+ "typedoc": "^0.28.20",
67
67
  "typescript": "5.9.3",
68
- "vira": "^30.6.0",
69
- "vite": "^7.3.1",
68
+ "vira": "^31.28.1",
69
+ "vite": "^8.1.5",
70
70
  "vite-tsconfig-paths": "^6.1.1"
71
71
  },
72
72
  "peerDependencies": {