@mlola-ui/engine 1.0.6 → 1.0.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mlola-ui/engine",
3
- "version": "1.0.6",
3
+ "version": "1.0.7",
4
4
  "description": "Framework-free CSS, spring easings, and the machine-readable contract for Mlola UI",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -115,9 +115,9 @@ export function themeDeclarations(spec) {
115
115
  */
116
116
  export const PALETTE_DERIVED = [
117
117
  "--ml-focus: var(--ml-primary-text)",
118
- // Hover and pressed fills move a color toward the page, so they lighten in
119
- // light mode and darken in dark mode without a second palette.
120
- "--ml-primary-hover: color-mix(in oklab, var(--ml-primary) 88%, var(--ml-background))",
118
+ // Neutral hover and pressed fills are the ink at low strength. A colored
119
+ // fill's hover (--ml-primary-hover, --ml-danger-hover…) is solved in the
120
+ // palette, so hovering never costs its label contrast.
121
121
  "--ml-fill-hover: color-mix(in oklab, var(--ml-text) 5%, transparent)",
122
122
  "--ml-fill-active: color-mix(in oklab, var(--ml-text) 9%, transparent)",
123
123
  // A soft, translucent track for switches, meters and sliders that stays
package/src/palette.mjs CHANGED
@@ -124,6 +124,19 @@ function neutralsDark(spec) {
124
124
 
125
125
  const isAchromatic = (color) => color.C < 0.03;
126
126
 
127
+ /**
128
+ * WCAG 2's ratio favours dark ink on mid-tone saturated colors, but eyes read
129
+ * white on a violet, blue, green or red far better. In light mode a chromatic,
130
+ * non-warm fill darkens a little (at most 0.12 L) so white clears the target;
131
+ * yellows and oranges keep dark ink, as convention expects.
132
+ */
133
+ function deepenForWhite(fill, mode) {
134
+ const warm = fill.H > 40 && fill.H < 130;
135
+ if (mode !== "light" || isAchromatic(fill) || warm || contrast(WHITE, fill) >= TARGETS.body) return fill;
136
+ const deeper = solveLightness(fill, WHITE, TARGETS.body, "darker");
137
+ return fill.L - deeper.L <= 0.12 ? deeper : fill;
138
+ }
139
+
127
140
  function primaryFor(spec, mode, neutrals) {
128
141
  const seedText = mode === "dark" ? spec.color.primaryDark ?? spec.color.primary : spec.color.primary;
129
142
  const seed = parseColor(seedText) ?? { L: 0.21, C: 0.006, H: 286 };
@@ -140,17 +153,12 @@ function primaryFor(spec, mode, neutrals) {
140
153
  if (contrast(fill, neutrals.background) < TARGETS.separation) {
141
154
  fill = solveLightness(fill, neutrals.background, TARGETS.separation, mode === "light" ? "darker" : "lighter");
142
155
  }
143
- // WCAG 2's ratio favours dark ink on mid-tone saturated colors, but eyes read
144
- // white on a violet, blue or red far better. In light mode a chromatic,
145
- // non-warm brand darkens a little (at most 0.12 L) so white clears the
146
- // target; yellows and oranges keep dark ink, as convention expects.
147
- const warm = fill.H > 40 && fill.H < 130;
148
- if (mode === "light" && !isAchromatic(fill) && !warm && contrast(WHITE, fill) < TARGETS.body) {
149
- const deeper = solveLightness(fill, WHITE, TARGETS.body, "darker");
150
- if (fill.L - deeper.L <= 0.12) fill = deeper;
151
- }
156
+ fill = deepenForWhite(fill, mode);
152
157
  const pair = fillPair(fill, TARGETS.body, mode === "dark");
153
- const text = solveText({ L: seed.L, C: seed.C, H: seed.H }, neutrals, TARGETS.body, mode === "light" ? "darker" : "lighter");
158
+ // Links and focus rings follow the brand. A monochrome brand inverted for
159
+ // the night reads as the inverted ink, not a mid gray lifted from the day's.
160
+ const textSeed = mode === "dark" && !spec.color.primaryDark && isAchromatic(seed) ? fill : seed;
161
+ const text = solveText({ L: textSeed.L, C: textSeed.C, H: textSeed.H }, neutrals, TARGETS.body, mode === "light" ? "darker" : "lighter");
154
162
  const subtleChroma = isAchromatic(seed) ? Math.max(spec.color.neutral.chroma, 0.003) : Math.min(seed.C * 0.35, 0.06);
155
163
  const subtle = mode === "light"
156
164
  ? { L: neutrals.subtle.L + 0.01, C: subtleChroma, H: seed.H }
@@ -160,7 +168,7 @@ function primaryFor(spec, mode, neutrals) {
160
168
 
161
169
  function statusFor(name, mode, neutrals) {
162
170
  const { H, C, light, dark } = STATUS[name];
163
- const start = { L: mode === "light" ? light : dark, C, H };
171
+ const start = deepenForWhite({ L: mode === "light" ? light : dark, C, H }, mode);
164
172
  const pair = fillPair(start, TARGETS.body, mode === "dark" || name === "warning");
165
173
  const text = solveText(
166
174
  { L: mode === "light" ? Math.min(light, 0.55) : Math.max(dark, 0.72), C, H },
@@ -202,6 +210,18 @@ function chartPalette(spec, mode, neutrals) {
202
210
  });
203
211
  }
204
212
 
213
+ /**
214
+ * The fill under a pointer. It moves a little toward the page, as a hover
215
+ * does, unless that would cost its label contrast; then it moves away from
216
+ * the label instead. Either way hovering never drops below the target.
217
+ */
218
+ function hoverFor(fill, foreground, neutrals) {
219
+ const toward = { L: fill.L + (neutrals.background.L - fill.L) * 0.12, C: fill.C * 0.88, H: fill.H };
220
+ if (contrast(foreground, toward) >= TARGETS.body) return toward;
221
+ const away = foreground.L > fill.L ? -0.05 : 0.05;
222
+ return solveLightness({ L: Math.min(1, Math.max(0, fill.L + away)), C: fill.C, H: fill.H }, foreground, TARGETS.body, away < 0 ? "darker" : "lighter");
223
+ }
224
+
205
225
  /** Every palette token for one mode, as serialised OKLCH strings. */
206
226
  export function derivePalette(spec, mode = "light") {
207
227
  const neutrals = mode === "dark" ? neutralsDark(spec) : neutralsLight(spec);
@@ -218,6 +238,7 @@ export function derivePalette(spec, mode = "light") {
218
238
  "border-subtle": neutrals.borderSubtle,
219
239
  primary: primary.primary,
220
240
  "primary-foreground": primary.foreground,
241
+ "primary-hover": hoverFor(primary.primary, primary.foreground, neutrals),
221
242
  "primary-text": primary.text,
222
243
  "primary-subtle": primary.subtle,
223
244
  };
@@ -225,6 +246,7 @@ export function derivePalette(spec, mode = "light") {
225
246
  const status = statusFor(name, mode, neutrals);
226
247
  palette[name] = status.fill;
227
248
  palette[`${name}-foreground`] = status.foreground;
249
+ palette[`${name}-hover`] = hoverFor(status.fill, status.foreground, neutrals);
228
250
  palette[`${name}-text`] = status.text;
229
251
  }
230
252
  chartPalette(spec, mode, neutrals).forEach((color, index) => {
@@ -259,6 +281,11 @@ export const GUARANTEED_PAIRS = [
259
281
  ["primary-text", "background", 4.5],
260
282
  ["primary-text", "surface", 4.5],
261
283
  ["primary-foreground", "primary", 4.5],
284
+ ["primary-foreground", "primary-hover", 4.5],
285
+ ["success-foreground", "success-hover", 4.5],
286
+ ["warning-foreground", "warning-hover", 4.5],
287
+ ["danger-foreground", "danger-hover", 4.5],
288
+ ["info-foreground", "info-hover", 4.5],
262
289
  ["success-foreground", "success", 4.5],
263
290
  ["warning-foreground", "warning", 4.5],
264
291
  ["danger-foreground", "danger", 4.5],
package/src/spec.mjs CHANGED
@@ -104,19 +104,24 @@ export const PALETTE_TOKENS = [
104
104
  "border-subtle",
105
105
  "primary",
106
106
  "primary-foreground",
107
+ "primary-hover",
107
108
  "primary-text",
108
109
  "primary-subtle",
109
110
  "success",
110
111
  "success-foreground",
112
+ "success-hover",
111
113
  "success-text",
112
114
  "warning",
113
115
  "warning-foreground",
116
+ "warning-hover",
114
117
  "warning-text",
115
118
  "danger",
116
119
  "danger-foreground",
120
+ "danger-hover",
117
121
  "danger-text",
118
122
  "info",
119
123
  "info-foreground",
124
+ "info-hover",
120
125
  "info-text",
121
126
  "chart-1",
122
127
  "chart-2",