@mlola-ui/engine 1.0.12 → 1.1.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.
@@ -159,6 +159,7 @@ correct. This table is read out of the stylesheet, so it is never stale.
159
159
  | `.ml-button` | `data-size` | `icon`, `lg`, `sm` |
160
160
  | `.ml-button` | `data-state` | `success` |
161
161
  | `.ml-button` | `data-variant` | `danger`, `link`, `outline`, `primary`, `secondary`, `subtle` |
162
+ | `.ml-button` | `data-width` | `full` |
162
163
  | `.ml-calendar-cell` | `data-preview` | _presence only_ |
163
164
  | `.ml-calendar-cell` | `data-range` | `end`, `start` |
164
165
  | `.ml-calendar-day` | `aria-disabled` | `true` |
@@ -212,6 +213,7 @@ correct. This table is read out of the stylesheet, so it is never stale.
212
213
  | `.ml-heatmap-cell` | `data-level` | `1`, `2`, `3`, `4` |
213
214
  | `.ml-heatmap-cell` | `data-outside` | _presence only_ |
214
215
  | `.ml-input` | `aria-invalid` | `true` |
216
+ | `.ml-input` | `data-invalid` | _presence only_ |
215
217
  | `.ml-input` | `data-size` | `lg`, `sm` |
216
218
  | `.ml-input` | `data-variant` | `filled`, `subtle` |
217
219
  | `.ml-input-control` | `data-leading` | _presence only_ |
@@ -87,6 +87,9 @@
87
87
  "primary",
88
88
  "secondary",
89
89
  "subtle"
90
+ ],
91
+ "data-width": [
92
+ "full"
90
93
  ]
91
94
  },
92
95
  "ml-calendar-cell": {
@@ -283,6 +286,7 @@
283
286
  "aria-invalid": [
284
287
  "true"
285
288
  ],
289
+ "data-invalid": [],
286
290
  "data-size": [
287
291
  "lg",
288
292
  "sm"
@@ -1513,6 +1513,7 @@ button.ml-breadcrumb-link {
1513
1513
  .ml-button {
1514
1514
  position: relative;
1515
1515
  display: inline-flex;
1516
+ max-width: 100%;
1516
1517
  min-height: var(--ml-control-md);
1517
1518
  align-items: center;
1518
1519
  justify-content: center;
@@ -1528,7 +1529,6 @@ button.ml-breadcrumb-link {
1528
1529
  line-height: 1;
1529
1530
  letter-spacing: -.006em;
1530
1531
  text-decoration: none;
1531
- white-space: nowrap;
1532
1532
  cursor: pointer;
1533
1533
  user-select: none;
1534
1534
  -webkit-tap-highlight-color: transparent;
@@ -1649,10 +1649,22 @@ button.ml-breadcrumb-link {
1649
1649
  padding: 0;
1650
1650
  }
1651
1651
 
1652
+ /* A label too long for the space wraps onto balanced lines instead of
1653
+ spilling out. The taller line height is taken back by the margins, so a
1654
+ one-line button is exactly as tall as before. */
1652
1655
  .ml-button-label {
1653
1656
  display: inline-flex;
1654
1657
  align-items: center;
1655
1658
  gap: var(--ml-space-1-5);
1659
+ margin-block: -.1em;
1660
+ line-height: 1.2;
1661
+ text-align: center;
1662
+ text-wrap: balance;
1663
+ }
1664
+
1665
+ .ml-button[data-width="full"] {
1666
+ display: flex;
1667
+ width: 100%;
1656
1668
  }
1657
1669
 
1658
1670
  .ml-button[data-state="success"] {
@@ -3493,13 +3505,16 @@ button.ml-breadcrumb-link {
3493
3505
  font-size: var(--ml-type-md);
3494
3506
  }
3495
3507
 
3508
+ /* A field that is a button or a group says it is invalid with data-invalid: aria-invalid belongs to inputs. */
3496
3509
  .ml-input-field[data-invalid] .ml-input,
3497
- .ml-input[aria-invalid="true"] {
3510
+ .ml-input[aria-invalid="true"],
3511
+ .ml-input[data-invalid] {
3498
3512
  border-color: var(--ml-danger);
3499
3513
  }
3500
3514
 
3501
3515
  .ml-input-field[data-invalid] .ml-input:focus,
3502
- .ml-input[aria-invalid="true"]:focus {
3516
+ .ml-input[aria-invalid="true"]:focus,
3517
+ .ml-input[data-invalid]:focus {
3503
3518
  box-shadow: 0 0 0 3px color-mix(in oklab,var(--ml-danger) 20%,transparent);
3504
3519
  }
3505
3520
 
@@ -6532,8 +6547,12 @@ button.ml-stepper-target:focus-visible {
6532
6547
  font-size: var(--ml-type-xs);
6533
6548
  font-weight: 500;
6534
6549
  line-height: 1.4;
6535
- white-space: nowrap;
6550
+ /* One line when it fits; a long tip wraps within the window instead of running past it. */
6551
+ width: max-content;
6552
+ max-width: min(20rem, calc(100vw - 2 * var(--ml-space-2)));
6553
+ white-space: normal;
6536
6554
  pointer-events: none;
6555
+ /* Placement uses `translate`, so the pop-in's transform adds to it instead of replacing it mid-animation. */
6537
6556
  animation: ml-pop-in var(--ml-duration-fast) var(--ml-ease-spring);
6538
6557
  }
6539
6558
 
@@ -6541,25 +6560,25 @@ button.ml-stepper-target:focus-visible {
6541
6560
  .ml-tooltip:not([data-side]) {
6542
6561
  bottom: calc(100% + var(--ml-space-2));
6543
6562
  left: 50%;
6544
- transform: translateX(-50%);
6563
+ translate: calc(-50% + var(--ml-tooltip-shift, 0px)) 0;
6545
6564
  }
6546
6565
 
6547
6566
  .ml-tooltip[data-side="bottom"] {
6548
6567
  top: calc(100% + var(--ml-space-2));
6549
6568
  left: 50%;
6550
- transform: translateX(-50%);
6569
+ translate: calc(-50% + var(--ml-tooltip-shift, 0px)) 0;
6551
6570
  }
6552
6571
 
6553
6572
  .ml-tooltip[data-side="left"] {
6554
6573
  right: calc(100% + var(--ml-space-2));
6555
6574
  top: 50%;
6556
- transform: translateY(-50%);
6575
+ translate: 0 calc(-50% + var(--ml-tooltip-shift, 0px));
6557
6576
  }
6558
6577
 
6559
6578
  .ml-tooltip[data-side="right"] {
6560
6579
  left: calc(100% + var(--ml-space-2));
6561
6580
  top: 50%;
6562
- transform: translateY(-50%);
6581
+ translate: 0 calc(-50% + var(--ml-tooltip-shift, 0px));
6563
6582
  }
6564
6583
 
6565
6584
  .ml-tooltip-arrow {
@@ -6574,13 +6593,26 @@ button.ml-stepper-target:focus-visible {
6574
6593
  .ml-tooltip:not([data-side]) .ml-tooltip-arrow {
6575
6594
  bottom: -.2rem;
6576
6595
  left: 50%;
6577
- margin-left: calc(var(--ml-space-1) * -1);
6596
+ margin-left: calc(var(--ml-space-1) * -1 - var(--ml-tooltip-shift, 0px));
6578
6597
  }
6579
6598
 
6580
6599
  .ml-tooltip[data-side="bottom"] .ml-tooltip-arrow {
6581
6600
  top: -.2rem;
6582
6601
  left: 50%;
6583
- margin-left: calc(var(--ml-space-1) * -1);
6602
+ margin-left: calc(var(--ml-space-1) * -1 - var(--ml-tooltip-shift, 0px));
6603
+ }
6604
+
6605
+ /* The arrow slides back by the tooltip's shift, so it keeps pointing at the trigger. */
6606
+ .ml-tooltip[data-side="left"] .ml-tooltip-arrow {
6607
+ right: -.2rem;
6608
+ top: 50%;
6609
+ margin-top: calc(var(--ml-space-1) * -1 - var(--ml-tooltip-shift, 0px));
6610
+ }
6611
+
6612
+ .ml-tooltip[data-side="right"] .ml-tooltip-arrow {
6613
+ left: -.2rem;
6614
+ top: 50%;
6615
+ margin-top: calc(var(--ml-space-1) * -1 - var(--ml-tooltip-shift, 0px));
6584
6616
  }
6585
6617
 
6586
6618
  /* components/tour */
@@ -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;
@@ -235,7 +235,7 @@
235
235
  --ml-font-sans: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
236
236
  --ml-font-display: var(--font-newsreader, "Newsreader"), "Iowan Old Style", Georgia, serif;
237
237
  --ml-font-mono: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
238
- --ml-display-weight: 828;
238
+ --ml-display-weight: 800;
239
239
  --ml-body-leading: 1.536;
240
240
  --ml-tracking: -0.0133em;
241
241
  --ml-icon-stroke: 1.687;
@@ -399,7 +399,7 @@
399
399
  --ml-font-sans: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
400
400
  --ml-font-display: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
401
401
  --ml-font-mono: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
402
- --ml-display-weight: 723;
402
+ --ml-display-weight: 700;
403
403
  --ml-body-leading: 1.47;
404
404
  --ml-tracking: -0.0076em;
405
405
  --ml-icon-stroke: 2.049;
@@ -563,7 +563,7 @@
563
563
  --ml-font-sans: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
564
564
  --ml-font-display: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
565
565
  --ml-font-mono: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
566
- --ml-display-weight: 782;
566
+ --ml-display-weight: 800;
567
567
  --ml-body-leading: 1.558;
568
568
  --ml-tracking: -0.009em;
569
569
  --ml-icon-stroke: 1.736;
@@ -727,7 +727,7 @@
727
727
  --ml-font-sans: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
728
728
  --ml-font-display: var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
729
729
  --ml-font-mono: var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace;
730
- --ml-display-weight: 792;
730
+ --ml-display-weight: 800;
731
731
  --ml-body-leading: 1.541;
732
732
  --ml-tracking: -0.011em;
733
733
  --ml-icon-stroke: 1.834;
@@ -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.12",
3
+ "version": "1.1.0",
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.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>>;