@mlola-ui/engine 1.0.13 → 1.1.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.
@@ -233,7 +233,7 @@
233
233
  },
234
234
  {
235
235
  "name": "ml-brand-mark",
236
- "note": ""
236
+ "note": "A brand's own logo takes the mark's place: no chip, the text color, the mark's size."
237
237
  },
238
238
  {
239
239
  "name": "ml-brand-name",
@@ -42,7 +42,8 @@ Set `data-theme` and `data-mode` on any ancestor. Nothing else changes.
42
42
  | `aerogel` | Aerogel Glass |
43
43
  | `nordic` | Nordic Earth |
44
44
 
45
- `data-mode` is `light` or `dark`.
45
+ `data-mode` is `light`, `dark`, or `system`, which follows the reader's own setting.
46
+ The themes' type reads `--font-inter`, `--font-newsreader` and `--font-jetbrains` before its fallbacks: load those fonts (next/font's `variable` option, or @font-face) and set the variables on the root, or the page uses the system's fonts.
46
47
 
47
48
  A project defines its own theme in one file, `mlola.theme.json`, which
48
49
  overrides fonts, colors, geometry, the spacing and type scale, and any
@@ -129,6 +130,7 @@ component, so a page built from them themes with the rest.
129
130
 
130
131
  `.ml-actions` `.ml-brand` `.ml-brand-mark` `.ml-brand-name` `.ml-chart` `.ml-chart-bar` `.ml-chart-bars` `.ml-chart-heading` `.ml-check-list` `.ml-cluster` `.ml-definition-list` `.ml-display` `.ml-divider` `.ml-empty-state` `.ml-eyebrow` `.ml-filter-chip` `.ml-filter-group` `.ml-fine-print` `.ml-form` `.ml-form-message` `.ml-form-options` `.ml-grid` `.ml-heading` `.ml-icon-chip` `.ml-inline-form` `.ml-inline-form-field` `.ml-label` `.ml-lede` `.ml-link` `.ml-page-shell` `.ml-person` `.ml-person-copy` `.ml-positive` `.ml-price` `.ml-required-mark` `.ml-section` `.ml-section-description` `.ml-section-header` `.ml-section-header-centered` `.ml-section-muted` `.ml-section-shell` `.ml-stack` `.ml-stat` `.ml-stat-card` `.ml-stat-grid` `.ml-stat-list` `.ml-stat-meta` `.ml-stat-value` `.ml-text-primary` `.ml-value`
131
132
 
133
+ - `.ml-brand-mark` — A brand's own logo takes the mark's place: no chip, the text color, the mark's size.
132
134
  - `.ml-check-list` — A list of benefits, each after a check. Pricing tiers and feature rows share it.
133
135
  - `.ml-link` — A glyph inside a link flows with the text instead of breaking the line.
134
136
  - `.ml-page-shell` — The navigation, main and footer are grid items; they may be narrower than their widest content (a table that scrolls), so the page never does.
@@ -151,6 +153,7 @@ correct. This table is read out of the stylesheet, so it is never stale.
151
153
  | `.ml-badge` | `data-size` | `lg`, `sm` |
152
154
  | `.ml-badge` | `data-tone` | `danger`, `info`, `primary`, `success`, `warning` |
153
155
  | `.ml-badge` | `data-variant` | `outline`, `solid` |
156
+ | `.ml-brand-mark` | `data-kind` | `logo` |
154
157
  | `.ml-breadcrumb-item` | `data-collapse-indicator` | _presence only_ |
155
158
  | `.ml-breadcrumb-item` | `data-collapsible` | _presence only_ |
156
159
  | `.ml-button` | `aria-disabled` | `true` |
@@ -159,6 +162,7 @@ correct. This table is read out of the stylesheet, so it is never stale.
159
162
  | `.ml-button` | `data-size` | `icon`, `lg`, `sm` |
160
163
  | `.ml-button` | `data-state` | `success` |
161
164
  | `.ml-button` | `data-variant` | `danger`, `link`, `outline`, `primary`, `secondary`, `subtle` |
165
+ | `.ml-button` | `data-width` | `full` |
162
166
  | `.ml-calendar-cell` | `data-preview` | _presence only_ |
163
167
  | `.ml-calendar-cell` | `data-range` | `end`, `start` |
164
168
  | `.ml-calendar-day` | `aria-disabled` | `true` |
@@ -212,6 +216,7 @@ correct. This table is read out of the stylesheet, so it is never stale.
212
216
  | `.ml-heatmap-cell` | `data-level` | `1`, `2`, `3`, `4` |
213
217
  | `.ml-heatmap-cell` | `data-outside` | _presence only_ |
214
218
  | `.ml-input` | `aria-invalid` | `true` |
219
+ | `.ml-input` | `data-invalid` | _presence only_ |
215
220
  | `.ml-input` | `data-size` | `lg`, `sm` |
216
221
  | `.ml-input` | `data-variant` | `filled`, `subtle` |
217
222
  | `.ml-input-control` | `data-leading` | _presence only_ |
@@ -60,6 +60,11 @@
60
60
  "solid"
61
61
  ]
62
62
  },
63
+ "ml-brand-mark": {
64
+ "data-kind": [
65
+ "logo"
66
+ ]
67
+ },
63
68
  "ml-breadcrumb-item": {
64
69
  "data-collapse-indicator": [],
65
70
  "data-collapsible": []
@@ -87,6 +92,9 @@
87
92
  "primary",
88
93
  "secondary",
89
94
  "subtle"
95
+ ],
96
+ "data-width": [
97
+ "full"
90
98
  ]
91
99
  },
92
100
  "ml-calendar-cell": {
@@ -283,6 +291,7 @@
283
291
  "aria-invalid": [
284
292
  "true"
285
293
  ],
294
+ "data-invalid": [],
286
295
  "data-size": [
287
296
  "lg",
288
297
  "sm"
@@ -374,6 +374,18 @@
374
374
  font-weight: 800;
375
375
  }
376
376
 
377
+ /* A brand's own logo takes the mark's place: no chip, the text color, the mark's size. */
378
+ .ml-brand-mark[data-kind="logo"] {
379
+ background: none;
380
+ color: var(--ml-text);
381
+ }
382
+
383
+ .ml-brand-mark[data-kind="logo"] > :is(svg, img) {
384
+ width: 100%;
385
+ height: 100%;
386
+ object-fit: contain;
387
+ }
388
+
377
389
  .ml-brand-name {
378
390
  font-size: var(--ml-type-md);
379
391
  letter-spacing: -.02em;
@@ -1513,6 +1525,7 @@ button.ml-breadcrumb-link {
1513
1525
  .ml-button {
1514
1526
  position: relative;
1515
1527
  display: inline-flex;
1528
+ max-width: 100%;
1516
1529
  min-height: var(--ml-control-md);
1517
1530
  align-items: center;
1518
1531
  justify-content: center;
@@ -1528,7 +1541,6 @@ button.ml-breadcrumb-link {
1528
1541
  line-height: 1;
1529
1542
  letter-spacing: -.006em;
1530
1543
  text-decoration: none;
1531
- white-space: nowrap;
1532
1544
  cursor: pointer;
1533
1545
  user-select: none;
1534
1546
  -webkit-tap-highlight-color: transparent;
@@ -1649,10 +1661,22 @@ button.ml-breadcrumb-link {
1649
1661
  padding: 0;
1650
1662
  }
1651
1663
 
1664
+ /* A label too long for the space wraps onto balanced lines instead of
1665
+ spilling out. The taller line height is taken back by the margins, so a
1666
+ one-line button is exactly as tall as before. */
1652
1667
  .ml-button-label {
1653
1668
  display: inline-flex;
1654
1669
  align-items: center;
1655
1670
  gap: var(--ml-space-1-5);
1671
+ margin-block: -.1em;
1672
+ line-height: 1.2;
1673
+ text-align: center;
1674
+ text-wrap: balance;
1675
+ }
1676
+
1677
+ .ml-button[data-width="full"] {
1678
+ display: flex;
1679
+ width: 100%;
1656
1680
  }
1657
1681
 
1658
1682
  .ml-button[data-state="success"] {
@@ -3493,13 +3517,16 @@ button.ml-breadcrumb-link {
3493
3517
  font-size: var(--ml-type-md);
3494
3518
  }
3495
3519
 
3520
+ /* A field that is a button or a group says it is invalid with data-invalid: aria-invalid belongs to inputs. */
3496
3521
  .ml-input-field[data-invalid] .ml-input,
3497
- .ml-input[aria-invalid="true"] {
3522
+ .ml-input[aria-invalid="true"],
3523
+ .ml-input[data-invalid] {
3498
3524
  border-color: var(--ml-danger);
3499
3525
  }
3500
3526
 
3501
3527
  .ml-input-field[data-invalid] .ml-input:focus,
3502
- .ml-input[aria-invalid="true"]:focus {
3528
+ .ml-input[aria-invalid="true"]:focus,
3529
+ .ml-input[data-invalid]:focus {
3503
3530
  box-shadow: 0 0 0 3px color-mix(in oklab,var(--ml-danger) 20%,transparent);
3504
3531
  }
3505
3532
 
@@ -105,6 +105,14 @@
105
105
  ],
106
106
  "description": "Optional different seed for dark mode."
107
107
  },
108
+ "primaryForeground": {
109
+ "enum": [
110
+ "auto",
111
+ "light",
112
+ "dark"
113
+ ],
114
+ "description": "The label on primary fills in light mode. \"auto\" lets the engine choose; \"light\" or \"dark\" moves the fill as far as it must for that label to meet contrast."
115
+ },
108
116
  "neutral": {
109
117
  "type": "object",
110
118
  "properties": {
@@ -35,6 +35,7 @@
35
35
  "color": {
36
36
  "primary": "oklch(0.21 0.006 286)",
37
37
  "primaryDark": null,
38
+ "primaryForeground": "auto",
38
39
  "neutral": {
39
40
  "hue": 286,
40
41
  "chroma": 0.004
@@ -83,6 +84,7 @@
83
84
  "color": {
84
85
  "primary": "oklch(0.55 0.16 58)",
85
86
  "primaryDark": "oklch(0.75 0.16 68)",
87
+ "primaryForeground": "auto",
86
88
  "neutral": {
87
89
  "hue": 80,
88
90
  "chroma": 0.016
@@ -134,6 +136,7 @@
134
136
  "color": {
135
137
  "primary": "oklch(0.22 0.01 240)",
136
138
  "primaryDark": "oklch(0.78 0.15 210)",
139
+ "primaryForeground": "auto",
137
140
  "neutral": {
138
141
  "hue": 240,
139
142
  "chroma": 0.004
@@ -185,6 +188,7 @@
185
188
  "color": {
186
189
  "primary": "oklch(0.55 0.24 285)",
187
190
  "primaryDark": "oklch(0.75 0.2 285)",
191
+ "primaryForeground": "auto",
188
192
  "neutral": {
189
193
  "hue": 260,
190
194
  "chroma": 0.018
@@ -236,6 +240,7 @@
236
240
  "color": {
237
241
  "primary": "oklch(0.5 0.14 150)",
238
242
  "primaryDark": "oklch(0.72 0.15 150)",
243
+ "primaryForeground": "auto",
239
244
  "neutral": {
240
245
  "hue": 135,
241
246
  "chroma": 0.018
@@ -69,7 +69,7 @@
69
69
  --ml-font-sans: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
70
70
  --ml-font-display: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
71
71
  --ml-font-mono: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
72
- --ml-display-weight: 742;
72
+ --ml-display-weight: 700;
73
73
  --ml-body-leading: 1.495;
74
74
  --ml-tracking: -0.0088em;
75
75
  --ml-icon-stroke: 1.974;
@@ -200,6 +200,68 @@
200
200
  --ml-sheen: linear-gradient(135deg, color-mix(in oklab, var(--ml-highlight) 18%, transparent), transparent 46%, oklch(0 0 0 / 0.05));
201
201
  }
202
202
 
203
+ /* data-mode="system" follows the reader's setting: light by default, dark when they ask for it. */
204
+ @media (prefers-color-scheme: dark) {
205
+ :root[data-mode="system"],
206
+ :root[data-theme="graphite"][data-mode="system"],
207
+ [data-theme="graphite"][data-mode="system"] {
208
+ color-scheme: dark;
209
+ --ml-background: oklch(0.148 0.005 286);
210
+ --ml-background-subtle: oklch(0.206 0.005 286);
211
+ --ml-surface: oklch(0.18 0.005 286);
212
+ --ml-surface-elevated: oklch(0.215 0.005 286);
213
+ --ml-text: oklch(0.959 0.004 286);
214
+ --ml-text-muted: oklch(0.71 0.01 286);
215
+ --ml-text-faint: oklch(0.64 0.008 286);
216
+ --ml-border: oklch(0.31 0.006 286);
217
+ --ml-border-subtle: oklch(0.263 0.005 286);
218
+ --ml-primary: oklch(0.959 0.006 286);
219
+ --ml-primary-foreground: oklch(0.18 0.02 286);
220
+ --ml-primary-hover: oklch(0.862 0.005 286);
221
+ --ml-primary-text: oklch(0.959 0.006 286);
222
+ --ml-primary-subtle: oklch(0.236 0.005 286);
223
+ --ml-success: oklch(0.72 0.15 150);
224
+ --ml-success-foreground: oklch(0.18 0.02 150);
225
+ --ml-success-hover: oklch(0.651 0.132 150);
226
+ --ml-success-text: oklch(0.72 0.15 150);
227
+ --ml-warning: oklch(0.8 0.16 75);
228
+ --ml-warning-foreground: oklch(0.18 0.02 75);
229
+ --ml-warning-hover: oklch(0.722 0.141 75);
230
+ --ml-warning-text: oklch(0.8 0.16 75);
231
+ --ml-danger: oklch(0.7 0.191 25);
232
+ --ml-danger-foreground: oklch(0.18 0.02 25);
233
+ --ml-danger-hover: oklch(0.75 0.151 25);
234
+ --ml-danger-text: oklch(0.72 0.174 25);
235
+ --ml-info: oklch(0.74 0.14 250);
236
+ --ml-info-foreground: oklch(0.18 0.02 250);
237
+ --ml-info-hover: oklch(0.669 0.141 250);
238
+ --ml-info-text: oklch(0.74 0.14 250);
239
+ --ml-chart-1: oklch(0.74 0.136 255);
240
+ --ml-chart-2: oklch(0.74 0.138 50);
241
+ --ml-chart-3: oklch(0.74 0.138 145);
242
+ --ml-chart-4: oklch(0.74 0.138 300);
243
+ --ml-chart-5: oklch(0.74 0.13 185);
244
+ --ml-chart-6: oklch(0.74 0.138 10);
245
+ --ml-shadow-xs: 0 1px 2px oklch(0 0 0 / 0.1087);
246
+ --ml-shadow-sm: 0 1px 2px oklch(0 0 0 / 0.0889), 0 2.052px 5.92px -1px oklch(0 0 0 / 0.1186);
247
+ --ml-shadow-md: 0 1px 2px oklch(0 0 0 / 0.079), 0 4.103px 13.32px -2px oklch(0 0 0 / 0.1581);
248
+ --ml-shadow-lg: 0 2px 4px oklch(0 0 0 / 0.0692), 0 8.206px 23.68px -4px oklch(0 0 0 / 0.1976);
249
+ --ml-shadow-xl: 0 4px 8px oklch(0 0 0 / 0.0593), 0 15.044px 41.44px -8px oklch(0 0 0 / 0.247);
250
+ --ml-shadow-tint: oklch(0 0 0 / 0.1581);
251
+ --ml-focus: var(--ml-primary-text);
252
+ --ml-fill-hover: color-mix(in oklab, var(--ml-text) 5%, transparent);
253
+ --ml-fill-active: color-mix(in oklab, var(--ml-text) 9%, transparent);
254
+ --ml-track: color-mix(in oklab, var(--ml-text) 12%, transparent);
255
+ --ml-control-border: var(--ml-text-faint);
256
+ --ml-ring: 0 0 0 3px color-mix(in oklab, var(--ml-focus) 24%, transparent);
257
+ --ml-scrim: oklch(from var(--ml-background) calc(l * 0.22) c h / calc(0.54 - l * 0.28));
258
+ --ml-highlight: oklch(1 0 0);
259
+ --ml-knob: var(--ml-highlight);
260
+ --ml-knob-shadow: 0 0 0 1px oklch(0 0 0 / 0.25), 0 1px 4px oklch(0 0 0 / 0.3);
261
+ --ml-sheen: linear-gradient(135deg, color-mix(in oklab, var(--ml-highlight) 18%, transparent), transparent 46%, oklch(0 0 0 / 0.05));
262
+ }
263
+ }
264
+
203
265
  @supports (transition-timing-function: linear(0, 1)) {
204
266
  :root,
205
267
  :root[data-theme="graphite"],
@@ -235,7 +297,7 @@
235
297
  --ml-font-sans: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
236
298
  --ml-font-display: var(--font-newsreader, "Newsreader"), "Iowan Old Style", Georgia, serif;
237
299
  --ml-font-mono: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
238
- --ml-display-weight: 828;
300
+ --ml-display-weight: 800;
239
301
  --ml-body-leading: 1.536;
240
302
  --ml-tracking: -0.0133em;
241
303
  --ml-icon-stroke: 1.687;
@@ -365,6 +427,67 @@
365
427
  --ml-sheen: linear-gradient(135deg, color-mix(in oklab, var(--ml-highlight) 18%, transparent), transparent 46%, oklch(0 0 0 / 0.05));
366
428
  }
367
429
 
430
+ /* data-mode="system" follows the reader's setting: light by default, dark when they ask for it. */
431
+ @media (prefers-color-scheme: dark) {
432
+ :root[data-theme="atelier"][data-mode="system"],
433
+ [data-theme="atelier"][data-mode="system"] {
434
+ color-scheme: dark;
435
+ --ml-background: oklch(0.134 0.022 260);
436
+ --ml-background-subtle: oklch(0.192 0.024 260);
437
+ --ml-surface: oklch(0.171 0.022 260);
438
+ --ml-surface-elevated: oklch(0.206 0.024 260);
439
+ --ml-text: oklch(0.954 0.01 80);
440
+ --ml-text-muted: oklch(0.705 0.022 80);
441
+ --ml-text-faint: oklch(0.635 0.016 80);
442
+ --ml-border: oklch(0.306 0.028 260);
443
+ --ml-border-subtle: oklch(0.257 0.024 260);
444
+ --ml-primary: oklch(0.75 0.16 68);
445
+ --ml-primary-foreground: oklch(0.18 0.02 68);
446
+ --ml-primary-hover: oklch(0.676 0.141 68);
447
+ --ml-primary-text: oklch(0.75 0.16 68);
448
+ --ml-primary-subtle: oklch(0.222 0.049 68);
449
+ --ml-success: oklch(0.72 0.15 150);
450
+ --ml-success-foreground: oklch(0.18 0.02 150);
451
+ --ml-success-hover: oklch(0.65 0.132 150);
452
+ --ml-success-text: oklch(0.72 0.15 150);
453
+ --ml-warning: oklch(0.8 0.16 75);
454
+ --ml-warning-foreground: oklch(0.18 0.02 75);
455
+ --ml-warning-hover: oklch(0.72 0.141 75);
456
+ --ml-warning-text: oklch(0.8 0.16 75);
457
+ --ml-danger: oklch(0.7 0.191 25);
458
+ --ml-danger-foreground: oklch(0.18 0.02 25);
459
+ --ml-danger-hover: oklch(0.75 0.151 25);
460
+ --ml-danger-text: oklch(0.72 0.174 25);
461
+ --ml-info: oklch(0.74 0.14 250);
462
+ --ml-info-foreground: oklch(0.18 0.02 250);
463
+ --ml-info-hover: oklch(0.667 0.141 250);
464
+ --ml-info-text: oklch(0.74 0.14 250);
465
+ --ml-chart-1: oklch(0.74 0.147 58);
466
+ --ml-chart-2: oklch(0.74 0.136 255);
467
+ --ml-chart-3: oklch(0.74 0.147 145);
468
+ --ml-chart-4: oklch(0.74 0.147 10);
469
+ --ml-chart-5: oklch(0.74 0.147 300);
470
+ --ml-chart-6: oklch(0.74 0.13 185);
471
+ --ml-shadow-xs: 0 1px 2px oklch(0 0 0 / 0.1735);
472
+ --ml-shadow-sm: 0 1px 2px oklch(0 0 0 / 0.142), 0 3.189px 10.974px -1px oklch(0 0 0 / 0.1893);
473
+ --ml-shadow-md: 0 1px 2px oklch(0 0 0 / 0.1262), 0 6.378px 24.692px -2px oklch(0 0 0 / 0.2524);
474
+ --ml-shadow-lg: 0 2px 4px oklch(0 0 0 / 0.1104), 0 12.756px 43.898px -4px oklch(0 0 0 / 0.3155);
475
+ --ml-shadow-xl: 0 4px 8px oklch(0 0 0 / 0.0946), 0 23.387px 76.821px -8px oklch(0 0 0 / 0.3944);
476
+ --ml-shadow-tint: oklch(0 0 0 / 0.2524);
477
+ --ml-focus: var(--ml-primary-text);
478
+ --ml-fill-hover: color-mix(in oklab, var(--ml-text) 5%, transparent);
479
+ --ml-fill-active: color-mix(in oklab, var(--ml-text) 9%, transparent);
480
+ --ml-track: color-mix(in oklab, var(--ml-text) 12%, transparent);
481
+ --ml-control-border: var(--ml-text-faint);
482
+ --ml-ring: 0 0 0 3px color-mix(in oklab, var(--ml-focus) 24%, transparent);
483
+ --ml-scrim: oklch(from var(--ml-background) calc(l * 0.22) c h / calc(0.54 - l * 0.28));
484
+ --ml-highlight: oklch(1 0 0);
485
+ --ml-knob: var(--ml-highlight);
486
+ --ml-knob-shadow: 0 0 0 1px oklch(0 0 0 / 0.25), 0 1px 4px oklch(0 0 0 / 0.3);
487
+ --ml-sheen: linear-gradient(135deg, color-mix(in oklab, var(--ml-highlight) 18%, transparent), transparent 46%, oklch(0 0 0 / 0.05));
488
+ }
489
+ }
490
+
368
491
  @supports (transition-timing-function: linear(0, 1)) {
369
492
  :root[data-theme="atelier"],
370
493
  [data-theme="atelier"] {
@@ -399,7 +522,7 @@
399
522
  --ml-font-sans: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
400
523
  --ml-font-display: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
401
524
  --ml-font-mono: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
402
- --ml-display-weight: 723;
525
+ --ml-display-weight: 700;
403
526
  --ml-body-leading: 1.47;
404
527
  --ml-tracking: -0.0076em;
405
528
  --ml-icon-stroke: 2.049;
@@ -529,6 +652,67 @@
529
652
  --ml-sheen: linear-gradient(135deg, color-mix(in oklab, var(--ml-highlight) 18%, transparent), transparent 46%, oklch(0 0 0 / 0.05));
530
653
  }
531
654
 
655
+ /* data-mode="system" follows the reader's setting: light by default, dark when they ask for it. */
656
+ @media (prefers-color-scheme: dark) {
657
+ :root[data-theme="machined"][data-mode="system"],
658
+ [data-theme="machined"][data-mode="system"] {
659
+ color-scheme: dark;
660
+ --ml-background: oklch(0.145 0.012 240);
661
+ --ml-background-subtle: oklch(0.203 0.013 240);
662
+ --ml-surface: oklch(0.177 0.012 240);
663
+ --ml-surface-elevated: oklch(0.212 0.013 240);
664
+ --ml-text: oklch(0.958 0.004 240);
665
+ --ml-text-muted: oklch(0.708 0.01 240);
666
+ --ml-text-faint: oklch(0.638 0.008 240);
667
+ --ml-border: oklch(0.309 0.016 240);
668
+ --ml-border-subtle: oklch(0.261 0.013 240);
669
+ --ml-primary: oklch(0.78 0.135 210);
670
+ --ml-primary-foreground: oklch(0.18 0.02 210);
671
+ --ml-primary-hover: oklch(0.704 0.122 210);
672
+ --ml-primary-text: oklch(0.78 0.135 210);
673
+ --ml-primary-subtle: oklch(0.233 0.04 210);
674
+ --ml-success: oklch(0.72 0.15 150);
675
+ --ml-success-foreground: oklch(0.18 0.02 150);
676
+ --ml-success-hover: oklch(0.651 0.132 150);
677
+ --ml-success-text: oklch(0.72 0.15 150);
678
+ --ml-warning: oklch(0.8 0.16 75);
679
+ --ml-warning-foreground: oklch(0.18 0.02 75);
680
+ --ml-warning-hover: oklch(0.721 0.141 75);
681
+ --ml-warning-text: oklch(0.8 0.16 75);
682
+ --ml-danger: oklch(0.7 0.191 25);
683
+ --ml-danger-foreground: oklch(0.18 0.02 25);
684
+ --ml-danger-hover: oklch(0.75 0.151 25);
685
+ --ml-danger-text: oklch(0.72 0.174 25);
686
+ --ml-info: oklch(0.74 0.14 250);
687
+ --ml-info-foreground: oklch(0.18 0.02 250);
688
+ --ml-info-hover: oklch(0.669 0.141 250);
689
+ --ml-info-text: oklch(0.74 0.14 250);
690
+ --ml-chart-1: oklch(0.74 0.136 255);
691
+ --ml-chart-2: oklch(0.74 0.138 50);
692
+ --ml-chart-3: oklch(0.74 0.138 145);
693
+ --ml-chart-4: oklch(0.74 0.138 300);
694
+ --ml-chart-5: oklch(0.74 0.13 185);
695
+ --ml-chart-6: oklch(0.74 0.138 10);
696
+ --ml-shadow-xs: 0 1px 2px oklch(0 0 0 / 0.1093);
697
+ --ml-shadow-sm: 0 1px 2px oklch(0 0 0 / 0.0894), 0 1.914px 5.925px -1px oklch(0 0 0 / 0.1193);
698
+ --ml-shadow-md: 0 1px 2px oklch(0 0 0 / 0.0795), 0 3.828px 13.331px -2px oklch(0 0 0 / 0.159);
699
+ --ml-shadow-lg: 0 2px 4px oklch(0 0 0 / 0.0696), 0 7.655px 23.699px -4px oklch(0 0 0 / 0.1988);
700
+ --ml-shadow-xl: 0 4px 8px oklch(0 0 0 / 0.0596), 0 14.034px 41.474px -8px oklch(0 0 0 / 0.2485);
701
+ --ml-shadow-tint: oklch(0 0 0 / 0.159);
702
+ --ml-focus: var(--ml-primary-text);
703
+ --ml-fill-hover: color-mix(in oklab, var(--ml-text) 5%, transparent);
704
+ --ml-fill-active: color-mix(in oklab, var(--ml-text) 9%, transparent);
705
+ --ml-track: color-mix(in oklab, var(--ml-text) 12%, transparent);
706
+ --ml-control-border: var(--ml-text-faint);
707
+ --ml-ring: 0 0 0 3px color-mix(in oklab, var(--ml-focus) 24%, transparent);
708
+ --ml-scrim: oklch(from var(--ml-background) calc(l * 0.22) c h / calc(0.54 - l * 0.28));
709
+ --ml-highlight: oklch(1 0 0);
710
+ --ml-knob: var(--ml-highlight);
711
+ --ml-knob-shadow: 0 0 0 1px oklch(0 0 0 / 0.25), 0 1px 4px oklch(0 0 0 / 0.3);
712
+ --ml-sheen: linear-gradient(135deg, color-mix(in oklab, var(--ml-highlight) 18%, transparent), transparent 46%, oklch(0 0 0 / 0.05));
713
+ }
714
+ }
715
+
532
716
  @supports (transition-timing-function: linear(0, 1)) {
533
717
  :root[data-theme="machined"],
534
718
  [data-theme="machined"] {
@@ -563,7 +747,7 @@
563
747
  --ml-font-sans: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
564
748
  --ml-font-display: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
565
749
  --ml-font-mono: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
566
- --ml-display-weight: 782;
750
+ --ml-display-weight: 800;
567
751
  --ml-body-leading: 1.558;
568
752
  --ml-tracking: -0.009em;
569
753
  --ml-icon-stroke: 1.736;
@@ -693,6 +877,67 @@
693
877
  --ml-sheen: linear-gradient(135deg, color-mix(in oklab, var(--ml-highlight) 18%, transparent), transparent 46%, oklch(0 0 0 / 0.05));
694
878
  }
695
879
 
880
+ /* data-mode="system" follows the reader's setting: light by default, dark when they ask for it. */
881
+ @media (prefers-color-scheme: dark) {
882
+ :root[data-theme="aerogel"][data-mode="system"],
883
+ [data-theme="aerogel"][data-mode="system"] {
884
+ color-scheme: dark;
885
+ --ml-background: oklch(0.141 0.034 270);
886
+ --ml-background-subtle: oklch(0.199 0.037 270);
887
+ --ml-surface: oklch(0.185 0.034 270);
888
+ --ml-surface-elevated: oklch(0.22 0.037 270);
889
+ --ml-text: oklch(0.956 0.01 260);
890
+ --ml-text-muted: oklch(0.712 0.025 260);
891
+ --ml-text-faint: oklch(0.642 0.018 260);
892
+ --ml-border: oklch(0.309 0.044 270);
893
+ --ml-border-subtle: oklch(0.261 0.037 270);
894
+ --ml-primary: oklch(0.75 0.134 285);
895
+ --ml-primary-foreground: oklch(0.18 0.02 285);
896
+ --ml-primary-hover: oklch(0.677 0.176 285);
897
+ --ml-primary-text: oklch(0.75 0.134 285);
898
+ --ml-primary-subtle: oklch(0.229 0.07 285);
899
+ --ml-success: oklch(0.72 0.15 150);
900
+ --ml-success-foreground: oklch(0.18 0.02 150);
901
+ --ml-success-hover: oklch(0.651 0.132 150);
902
+ --ml-success-text: oklch(0.72 0.15 150);
903
+ --ml-warning: oklch(0.8 0.16 75);
904
+ --ml-warning-foreground: oklch(0.18 0.02 75);
905
+ --ml-warning-hover: oklch(0.721 0.141 75);
906
+ --ml-warning-text: oklch(0.8 0.16 75);
907
+ --ml-danger: oklch(0.7 0.191 25);
908
+ --ml-danger-foreground: oklch(0.18 0.02 25);
909
+ --ml-danger-hover: oklch(0.75 0.151 25);
910
+ --ml-danger-text: oklch(0.72 0.174 25);
911
+ --ml-info: oklch(0.74 0.14 250);
912
+ --ml-info-foreground: oklch(0.18 0.02 250);
913
+ --ml-info-hover: oklch(0.668 0.141 250);
914
+ --ml-info-text: oklch(0.74 0.14 250);
915
+ --ml-chart-1: oklch(0.74 0.14 285);
916
+ --ml-chart-2: oklch(0.74 0.156 145);
917
+ --ml-chart-3: oklch(0.74 0.156 50);
918
+ --ml-chart-4: oklch(0.74 0.156 10);
919
+ --ml-chart-5: oklch(0.74 0.13 185);
920
+ --ml-chart-6: oklch(0.74 0.136 255);
921
+ --ml-shadow-xs: 0 1px 2px oklch(0 0 0 / 0.2121);
922
+ --ml-shadow-sm: 0 1px 2px oklch(0 0 0 / 0.1735), 0 4.909px 14.309px -1px oklch(0 0 0 / 0.2313);
923
+ --ml-shadow-md: 0 1px 2px oklch(0 0 0 / 0.1542), 0 9.818px 32.195px -2px oklch(0 0 0 / 0.3084);
924
+ --ml-shadow-lg: 0 2px 4px oklch(0 0 0 / 0.1349), 0 19.636px 57.235px -4px oklch(0 0 0 / 0.3855);
925
+ --ml-shadow-xl: 0 4px 8px oklch(0 0 0 / 0.1157), 0 35.999px 100.162px -8px oklch(0 0 0 / 0.4819);
926
+ --ml-shadow-tint: oklch(0 0 0 / 0.3084);
927
+ --ml-focus: var(--ml-primary-text);
928
+ --ml-fill-hover: color-mix(in oklab, var(--ml-text) 5%, transparent);
929
+ --ml-fill-active: color-mix(in oklab, var(--ml-text) 9%, transparent);
930
+ --ml-track: color-mix(in oklab, var(--ml-text) 12%, transparent);
931
+ --ml-control-border: var(--ml-text-faint);
932
+ --ml-ring: 0 0 0 3px color-mix(in oklab, var(--ml-focus) 24%, transparent);
933
+ --ml-scrim: oklch(from var(--ml-background) calc(l * 0.22) c h / calc(0.54 - l * 0.28));
934
+ --ml-highlight: oklch(1 0 0);
935
+ --ml-knob: var(--ml-highlight);
936
+ --ml-knob-shadow: 0 0 0 1px oklch(0 0 0 / 0.25), 0 1px 4px oklch(0 0 0 / 0.3);
937
+ --ml-sheen: linear-gradient(135deg, color-mix(in oklab, var(--ml-highlight) 18%, transparent), transparent 46%, oklch(0 0 0 / 0.05));
938
+ }
939
+ }
940
+
696
941
  @supports (transition-timing-function: linear(0, 1)) {
697
942
  :root[data-theme="aerogel"],
698
943
  [data-theme="aerogel"] {
@@ -727,7 +972,7 @@
727
972
  --ml-font-sans: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
728
973
  --ml-font-display: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
729
974
  --ml-font-mono: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
730
- --ml-display-weight: 792;
975
+ --ml-display-weight: 800;
731
976
  --ml-body-leading: 1.541;
732
977
  --ml-tracking: -0.011em;
733
978
  --ml-icon-stroke: 1.834;
@@ -857,6 +1102,67 @@
857
1102
  --ml-sheen: linear-gradient(135deg, color-mix(in oklab, var(--ml-highlight) 18%, transparent), transparent 46%, oklch(0 0 0 / 0.05));
858
1103
  }
859
1104
 
1105
+ /* data-mode="system" follows the reader's setting: light by default, dark when they ask for it. */
1106
+ @media (prefers-color-scheme: dark) {
1107
+ :root[data-theme="nordic"][data-mode="system"],
1108
+ [data-theme="nordic"][data-mode="system"] {
1109
+ color-scheme: dark;
1110
+ --ml-background: oklch(0.133 0.029 145);
1111
+ --ml-background-subtle: oklch(0.191 0.032 145);
1112
+ --ml-surface: oklch(0.167 0.029 145);
1113
+ --ml-surface-elevated: oklch(0.202 0.032 145);
1114
+ --ml-text: oklch(0.953 0.01 135);
1115
+ --ml-text-muted: oklch(0.701 0.025 135);
1116
+ --ml-text-faint: oklch(0.632 0.018 135);
1117
+ --ml-border: oklch(0.302 0.037 145);
1118
+ --ml-border-subtle: oklch(0.255 0.032 145);
1119
+ --ml-primary: oklch(0.72 0.15 150);
1120
+ --ml-primary-foreground: oklch(0.18 0.02 150);
1121
+ --ml-primary-hover: oklch(0.65 0.132 150);
1122
+ --ml-primary-text: oklch(0.72 0.15 150);
1123
+ --ml-primary-subtle: oklch(0.221 0.061 150);
1124
+ --ml-success: oklch(0.72 0.15 150);
1125
+ --ml-success-foreground: oklch(0.18 0.02 150);
1126
+ --ml-success-hover: oklch(0.65 0.132 150);
1127
+ --ml-success-text: oklch(0.72 0.15 150);
1128
+ --ml-warning: oklch(0.8 0.16 75);
1129
+ --ml-warning-foreground: oklch(0.18 0.02 75);
1130
+ --ml-warning-hover: oklch(0.72 0.141 75);
1131
+ --ml-warning-text: oklch(0.8 0.16 75);
1132
+ --ml-danger: oklch(0.7 0.191 25);
1133
+ --ml-danger-foreground: oklch(0.18 0.02 25);
1134
+ --ml-danger-hover: oklch(0.75 0.151 25);
1135
+ --ml-danger-text: oklch(0.72 0.174 25);
1136
+ --ml-info: oklch(0.74 0.14 250);
1137
+ --ml-info-foreground: oklch(0.18 0.02 250);
1138
+ --ml-info-hover: oklch(0.667 0.141 250);
1139
+ --ml-info-text: oklch(0.74 0.14 250);
1140
+ --ml-chart-1: oklch(0.74 0.129 150);
1141
+ --ml-chart-2: oklch(0.74 0.129 300);
1142
+ --ml-chart-3: oklch(0.74 0.129 50);
1143
+ --ml-chart-4: oklch(0.74 0.129 255);
1144
+ --ml-chart-5: oklch(0.74 0.129 10);
1145
+ --ml-chart-6: oklch(0.74 0.129 185);
1146
+ --ml-shadow-xs: 0 1px 2px oklch(0 0 0 / 0.1456);
1147
+ --ml-shadow-sm: 0 1px 2px oklch(0 0 0 / 0.1191), 0 2.444px 8.702px -1px oklch(0 0 0 / 0.1588);
1148
+ --ml-shadow-md: 0 1px 2px oklch(0 0 0 / 0.1059), 0 4.887px 19.58px -2px oklch(0 0 0 / 0.2118);
1149
+ --ml-shadow-lg: 0 2px 4px oklch(0 0 0 / 0.0927), 0 9.775px 34.81px -4px oklch(0 0 0 / 0.2647);
1150
+ --ml-shadow-xl: 0 4px 8px oklch(0 0 0 / 0.0794), 0 17.92px 60.917px -8px oklch(0 0 0 / 0.3309);
1151
+ --ml-shadow-tint: oklch(0 0 0 / 0.2118);
1152
+ --ml-focus: var(--ml-primary-text);
1153
+ --ml-fill-hover: color-mix(in oklab, var(--ml-text) 5%, transparent);
1154
+ --ml-fill-active: color-mix(in oklab, var(--ml-text) 9%, transparent);
1155
+ --ml-track: color-mix(in oklab, var(--ml-text) 12%, transparent);
1156
+ --ml-control-border: var(--ml-text-faint);
1157
+ --ml-ring: 0 0 0 3px color-mix(in oklab, var(--ml-focus) 24%, transparent);
1158
+ --ml-scrim: oklch(from var(--ml-background) calc(l * 0.22) c h / calc(0.54 - l * 0.28));
1159
+ --ml-highlight: oklch(1 0 0);
1160
+ --ml-knob: var(--ml-highlight);
1161
+ --ml-knob-shadow: 0 0 0 1px oklch(0 0 0 / 0.25), 0 1px 4px oklch(0 0 0 / 0.3);
1162
+ --ml-sheen: linear-gradient(135deg, color-mix(in oklab, var(--ml-highlight) 18%, transparent), transparent 46%, oklch(0 0 0 / 0.05));
1163
+ }
1164
+ }
1165
+
860
1166
  @supports (transition-timing-function: linear(0, 1)) {
861
1167
  :root[data-theme="nordic"],
862
1168
  [data-theme="nordic"] {
@@ -145,6 +145,7 @@
145
145
  "color": {
146
146
  "primary": "oklch(0.21 0.006 286)",
147
147
  "primaryDark": null,
148
+ "primaryForeground": "auto",
148
149
  "neutral": {
149
150
  "hue": 286,
150
151
  "chroma": 0.004
@@ -1187,6 +1188,7 @@
1187
1188
  "color": {
1188
1189
  "primary": "oklch(0.55 0.16 58)",
1189
1190
  "primaryDark": "oklch(0.75 0.16 68)",
1191
+ "primaryForeground": "auto",
1190
1192
  "neutral": {
1191
1193
  "hue": 80,
1192
1194
  "chroma": 0.016
@@ -2232,6 +2234,7 @@
2232
2234
  "color": {
2233
2235
  "primary": "oklch(0.22 0.01 240)",
2234
2236
  "primaryDark": "oklch(0.78 0.15 210)",
2237
+ "primaryForeground": "auto",
2235
2238
  "neutral": {
2236
2239
  "hue": 240,
2237
2240
  "chroma": 0.004
@@ -3277,6 +3280,7 @@
3277
3280
  "color": {
3278
3281
  "primary": "oklch(0.55 0.24 285)",
3279
3282
  "primaryDark": "oklch(0.75 0.2 285)",
3283
+ "primaryForeground": "auto",
3280
3284
  "neutral": {
3281
3285
  "hue": 260,
3282
3286
  "chroma": 0.018
@@ -4322,6 +4326,7 @@
4322
4326
  "color": {
4323
4327
  "primary": "oklch(0.5 0.14 150)",
4324
4328
  "primaryDark": "oklch(0.72 0.15 150)",
4329
+ "primaryForeground": "auto",
4325
4330
  "neutral": {
4326
4331
  "hue": 135,
4327
4332
  "chroma": 0.018
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mlola-ui/engine",
3
- "version": "1.0.13",
3
+ "version": "1.1.1",
4
4
  "description": "Framework-free CSS, spring easings, and the machine-readable contract for Mlola UI",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -21,7 +21,11 @@
21
21
  "./motion.css": "./generated/motion.css",
22
22
  "./accessibility.css": "./generated/accessibility.css",
23
23
  "./tokens.json": "./generated/tokens.json",
24
- "./manifest.json": "./generated/manifest.json"
24
+ "./manifest.json": "./generated/manifest.json",
25
+ "./theme": {
26
+ "types": "./src/theme.d.ts",
27
+ "default": "./src/theme.mjs"
28
+ }
25
29
  },
26
30
  "scripts": {
27
31
  "build": "node build.mjs",
@@ -92,7 +92,9 @@ export function themeDeclarations(spec) {
92
92
  `--ml-font-sans: ${fonts.sans}`,
93
93
  `--ml-font-display: ${fonts.display}`,
94
94
  `--ml-font-mono: ${fonts.mono}`,
95
- `--ml-display-weight: ${derived.displayWeight}`,
95
+ // Written as a whole hundred: a font that ships only static weights
96
+ // (400, 700, 900) would render 737 as the next weight up, often a heavy 900.
97
+ `--ml-display-weight: ${Math.round(derived.displayWeight / 100) * 100}`,
96
98
  `--ml-body-leading: ${round(derived.bodyLeading)}`,
97
99
  `--ml-tracking: ${round(derived.tracking, 4)}em`,
98
100
  `--ml-icon-stroke: ${round(derived.iconStroke)}`,
package/src/palette.mjs CHANGED
@@ -63,22 +63,28 @@ const inkOn = (hue) => ({ L: 0.18, C: 0.02, H: hue });
63
63
 
64
64
  /**
65
65
  * Pick whichever of white or dark ink reads better on `fill`. If neither
66
- * reaches the target, move the fill until the better one does.
66
+ * reaches the target, move the fill until the better one does: it is the
67
+ * closer of the two, so the fill moves least. `preferDark` settles a tie.
67
68
  */
68
- function fillPair(fill, target, preferLight) {
69
- let base = fill;
69
+ function fillPair(fill, target, preferDark) {
70
70
  const dark = inkOn(fill.H);
71
- const light = contrast(WHITE, base);
72
- const deep = contrast(dark, base);
73
- if (Math.max(light, deep) >= target) {
74
- return { fill: base, foreground: light >= deep ? WHITE : dark };
75
- }
76
- if (preferLight ? deep > light : light >= deep) {
77
- base = solveLightness(base, dark, target, "lighter");
78
- return { fill: base, foreground: dark };
79
- }
80
- base = solveLightness(base, WHITE, target, "darker");
81
- return { fill: base, foreground: WHITE };
71
+ const light = contrast(WHITE, fill);
72
+ const deep = contrast(dark, fill);
73
+ const useDark = deep > light || (deep === light && preferDark);
74
+ if (Math.max(light, deep) >= target) return { fill, foreground: useDark ? dark : WHITE };
75
+ if (useDark) return { fill: solveLightness(fill, dark, target, "lighter"), foreground: dark };
76
+ return { fill: solveLightness(fill, WHITE, target, "darker"), foreground: WHITE };
77
+ }
78
+
79
+ /**
80
+ * The label a theme asked for, and the fill moved (darker for white, lighter
81
+ * for dark ink) only as far as that label needs to meet the target. Light
82
+ * mode only: at night a fill dark enough for white would sink into the page.
83
+ */
84
+ function fillFor(fill, foreground, target) {
85
+ const ink = foreground === "light" ? WHITE : inkOn(fill.H);
86
+ const moved = contrast(ink, fill) >= target ? fill : solveLightness(fill, ink, target, foreground === "light" ? "darker" : "lighter");
87
+ return { fill: moved, foreground: ink };
82
88
  }
83
89
 
84
90
  const tint = (hue, chroma, L) => ({ L, C: chroma, H: hue });
@@ -156,7 +162,9 @@ function primaryFor(spec, mode, neutrals) {
156
162
  fill = solveLightness(fill, neutrals.background, TARGETS.separation, mode === "light" ? "darker" : "lighter");
157
163
  }
158
164
  fill = deepenForWhite(fill, mode);
159
- const pair = fillPair(fill, TARGETS.body, mode === "dark");
165
+ const pair = mode === "light" && spec.color.primaryForeground && spec.color.primaryForeground !== "auto"
166
+ ? fillFor(fill, spec.color.primaryForeground, TARGETS.body)
167
+ : fillPair(fill, TARGETS.body, mode === "dark");
160
168
  // Links and focus rings follow the brand. A monochrome brand inverted for
161
169
  // the night reads as the inverted ink, not a mid gray lifted from the day's.
162
170
  const textSeed = mode === "dark" && !spec.color.primaryDark && isAchromatic(seed) ? fill : seed;
package/src/spec.mjs CHANGED
@@ -133,6 +133,9 @@ export const PALETTE_TOKENS = [
133
133
 
134
134
  const ID_PATTERN = /^[a-z][a-z0-9-]{0,47}$/;
135
135
 
136
+ /** What a theme may ask for on primary fills: the engine's choice, white, or dark ink. */
137
+ const FOREGROUNDS = ["auto", "light", "dark"];
138
+
136
139
  const DEFAULT_SPEC = {
137
140
  specVersion: SPEC_VERSION,
138
141
  id: "custom",
@@ -141,6 +144,7 @@ const DEFAULT_SPEC = {
141
144
  color: {
142
145
  primary: "oklch(0.21 0.006 286)",
143
146
  primaryDark: null,
147
+ primaryForeground: "auto",
144
148
  neutral: { hue: 286, chroma: 0.004 },
145
149
  ink: null,
146
150
  },
@@ -227,6 +231,7 @@ export function normalizeSpec(input = {}, base = DEFAULT_SPEC) {
227
231
  color: {
228
232
  primary: readColor(color.primary ?? legacySignal, base.color.primary),
229
233
  primaryDark: readColor(color.primaryDark ?? legacySignalDark, base.color.primaryDark ?? null),
234
+ primaryForeground: FOREGROUNDS.includes(color.primaryForeground) ? color.primaryForeground : (base.color.primaryForeground ?? "auto"),
230
235
  neutral: readTint(color.neutral, base.color.neutral),
231
236
  ink: color.ink === null ? null : readTint(color.ink, base.color.ink ?? null),
232
237
  },
@@ -255,6 +260,9 @@ export function validateSpec(input) {
255
260
  if (input.color?.primaryDark != null && !parseColor(input.color.primaryDark)) {
256
261
  problems.push("color.primaryDark must be an oklch() or hex color, or null.");
257
262
  }
263
+ if (input.color?.primaryForeground !== undefined && !FOREGROUNDS.includes(input.color.primaryForeground)) {
264
+ problems.push(`color.primaryForeground must be one of ${FOREGROUNDS.join(", ")}.`);
265
+ }
258
266
  const neutral = input.color?.neutral;
259
267
  if (!neutral || typeof neutral.hue !== "number" || typeof neutral.chroma !== "number" || neutral.chroma < 0 || neutral.chroma > 0.06) {
260
268
  problems.push("color.neutral needs a hue and a chroma from 0 to 0.06.");
@@ -302,6 +310,10 @@ export function renderSpecSchema() {
302
310
  properties: {
303
311
  primary: { type: "string", description: "Seed for the primary color, oklch() or hex. Lightness is adjusted to meet contrast." },
304
312
  primaryDark: { type: ["string", "null"], description: "Optional different seed for dark mode." },
313
+ primaryForeground: {
314
+ enum: FOREGROUNDS,
315
+ description: "The label on primary fills in light mode. \"auto\" lets the engine choose; \"light\" or \"dark\" moves the fill as far as it must for that label to meet contrast.",
316
+ },
305
317
  neutral: { ...tint, description: "Tint of the page and surfaces." },
306
318
  ink: { anyOf: [tint, { type: "null" }], description: "Tint of text and dark-mode surfaces. Defaults to neutral." },
307
319
  },
package/src/theme-css.mjs CHANGED
@@ -17,13 +17,14 @@ function paletteLines(palette) {
17
17
  return Object.entries(palette).map(([token, value]) => `--ml-${token}: ${value}`);
18
18
  }
19
19
 
20
- function selectorList(spec, { root, aliases }, dark) {
20
+ /** The theme's selectors, for light (no mode), or for a mode: "dark", or "system" (dark only when the reader asks for it). */
21
+ function selectorList(spec, { root, aliases }, mode) {
21
22
  const attributes = [spec.id, ...aliases].map((name) => `[data-theme="${name}"]`);
22
23
  const list = [];
23
24
  if (root) list.push(":root");
24
25
  for (const attribute of attributes) list.push(`:root${attribute}`, attribute);
25
- if (!dark) return list.join(",\n");
26
- return list.map((selector) => (selector === ":root" ? ':root[data-mode="dark"]' : `${selector}[data-mode="dark"]`)).join(",\n");
26
+ if (!mode) return list.join(",\n");
27
+ return list.map((selector) => `${selector}[data-mode="${mode}"]`).join(",\n");
27
28
  }
28
29
 
29
30
  /**
@@ -45,6 +46,7 @@ export function renderSpecCss(spec, { root = false, aliases = [], banner = true
45
46
  const options = { root, aliases };
46
47
 
47
48
  const title = spec.label.replace(/\*\//g, "* /");
49
+ const darkBlock = ` color-scheme: dark;\n${indent(paletteLines(dark))}\n${indent(darkShadowDeclarations(spec.vector))}\n${indent(PALETTE_DERIVED)}`;
48
50
  return `${banner ? `/* ${title} — generated by @mlola-ui/engine from its theme spec. Do not hand edit. */\n` : ""}${selectorList(spec, options, false)} {
49
51
  color-scheme: light;
50
52
  ${indent(themeDeclarations(spec))}
@@ -52,11 +54,15 @@ ${indent(paletteLines(light))}
52
54
  ${indent(PALETTE_DERIVED)}
53
55
  }
54
56
 
55
- ${selectorList(spec, options, true)} {
56
- color-scheme: dark;
57
- ${indent(paletteLines(dark))}
58
- ${indent(darkShadowDeclarations(spec.vector))}
59
- ${indent(PALETTE_DERIVED)}
57
+ ${selectorList(spec, options, "dark")} {
58
+ ${darkBlock}
59
+ }
60
+
61
+ /* data-mode="system" follows the reader's setting: light by default, dark when they ask for it. */
62
+ @media (prefers-color-scheme: dark) {
63
+ ${selectorList(spec, options, "system")} {
64
+ ${darkBlock}
65
+ }
60
66
  }
61
67
 
62
68
  @supports (transition-timing-function: linear(0, 1)) {
package/src/theme.d.ts ADDED
@@ -0,0 +1,17 @@
1
+ /** A project theme: a theme spec plus `inherit`, `scale` and `extend`. See https://ui.mlola.com/docs/theming#custom. */
2
+ export type ThemeInput = Record<string, unknown>;
3
+
4
+ export interface ProjectTheme extends Record<string, unknown> {
5
+ /** The theme's id: its `data-theme` value. */
6
+ id: string;
7
+ label: string;
8
+ /** The canonical theme whose decisions fill what the file leaves out. */
9
+ inherit: string;
10
+ }
11
+
12
+ export const THEME_CHANNELS: readonly string[];
13
+ /** The spec with every default filled in. */
14
+ export function defineTheme(input?: ThemeInput): ProjectTheme;
15
+ /** The theme's CSS, on the same `data-theme` contract as every canonical theme. */
16
+ export function renderThemeCss(input: ThemeInput): string;
17
+ export function renderThemeManifest(projectThemeInput?: ThemeInput | null): Array<Record<string, unknown>>;