@mlola-ui/engine 1.0.6 → 1.0.8

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.8",
4
4
  "description": "Framework-free CSS, spring easings, and the machine-readable contract for Mlola UI",
5
5
  "type": "module",
6
6
  "sideEffects": [
package/src/color.mjs CHANGED
@@ -117,7 +117,7 @@ export function contrast(foreground, background) {
117
117
  return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
118
118
  }
119
119
 
120
- /** Serialise an OKLCH color, gamut-mapped, at stable precision. */
120
+ /** Serialize an OKLCH color, gamut-mapped, at stable precision. */
121
121
  export function formatColor(color) {
122
122
  const mapped = toGamut(color);
123
123
  const chroma = round(mapped.C, 3);
@@ -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 },
@@ -175,7 +183,7 @@ function statusFor(name, mode, neutrals) {
175
183
  * Six categorical colors for charts, apart from the status roles so a series
176
184
  * never reads as "good" or "bad" by accident. The first follows the brand's
177
185
  * hue (blue for a monochrome brand); the rest are the hues furthest from those
178
- * already taken, so neighbours never share a family. Each is solved to 3:1
186
+ * already taken, so neighbors never share a family. Each is solved to 3:1
179
187
  * against the surface (WCAG 1.4.11 for graphics).
180
188
  */
181
189
  const CHART_HUES = [255, 185, 300, 50, 10, 145];
@@ -202,7 +210,19 @@ function chartPalette(spec, mode, neutrals) {
202
210
  });
203
211
  }
204
212
 
205
- /** Every palette token for one mode, as serialised OKLCH strings. */
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
+
225
+ /** Every palette token for one mode, as serialized OKLCH strings. */
206
226
  export function derivePalette(spec, mode = "light") {
207
227
  const neutrals = mode === "dark" ? neutralsDark(spec) : neutralsLight(spec);
208
228
  const primary = primaryFor(spec, mode, neutrals);
@@ -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",
package/src/theme.mjs CHANGED
@@ -11,7 +11,7 @@ import { renderSpecCss } from "./theme-css.mjs";
11
11
  * - `inherit` names the canonical theme whose decisions fill anything the
12
12
  * file leaves out.
13
13
  * - `scale` overrides spacing or type steps, and `extend` adds raw custom
14
- * properties, for anything the engine has not modelled yet. Both are
14
+ * properties, for anything the engine has not modeled yet. Both are
15
15
  * emitted after the derived tokens, so they win.
16
16
  *
17
17
  * The spec itself renders through the same function as every canonical theme.