@mlola-ui/engine 1.0.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.
- package/LICENSE +21 -0
- package/README.md +51 -0
- package/build.mjs +123 -0
- package/generated/agents.md +369 -0
- package/generated/assets.json +729 -0
- package/generated/contract.json +1234 -0
- package/generated/foundations.css +154 -0
- package/generated/manifest.json +5 -0
- package/generated/materials.css +55 -0
- package/generated/mlola.css +10 -0
- package/generated/motion.css +93 -0
- package/generated/recipes.css +6504 -0
- package/generated/theme-spec.schema.json +199 -0
- package/generated/theme.css +1 -0
- package/generated/themes.json +258 -0
- package/generated/tokens.css +812 -0
- package/generated/tokens.json +4751 -0
- package/package.json +56 -0
- package/src/behavior-spec.mjs +291 -0
- package/src/color.mjs +150 -0
- package/src/config.mjs +170 -0
- package/src/contract.mjs +103 -0
- package/src/contrast.mjs +68 -0
- package/src/declarations.mjs +131 -0
- package/src/deprecations.mjs +29 -0
- package/src/library-recipes.mjs +105 -0
- package/src/palette.mjs +286 -0
- package/src/render.mjs +190 -0
- package/src/spec.mjs +318 -0
- package/src/spring.mjs +46 -0
- package/src/theme-css.mjs +69 -0
- package/src/theme-distance.mjs +107 -0
- package/src/theme.mjs +97 -0
package/src/spec.mjs
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
import { parseColor } from "./color.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The theme specification.
|
|
5
|
+
*
|
|
6
|
+
* A theme is a handful of decisions, not a list of tokens. Everything a page
|
|
7
|
+
* renders — palettes in both modes, radius, depth, motion, type — is derived
|
|
8
|
+
* from this spec by the engine, and contrast is solved rather than hoped for.
|
|
9
|
+
* That is what lets a person, a script, or a model author a theme without being
|
|
10
|
+
* able to break accessibility.
|
|
11
|
+
*
|
|
12
|
+
* Every field is one atomic decision, so it can be answered by one typed
|
|
13
|
+
* question: a channel is a score on an ordered scale, a material or a font set
|
|
14
|
+
* is a choice among named options, a colour is either given or chosen from a
|
|
15
|
+
* hue family.
|
|
16
|
+
*
|
|
17
|
+
* Deliberate choices, because this has to survive a decade:
|
|
18
|
+
* - Plain JSON. A theme is readable by a human, a script, or a model.
|
|
19
|
+
* - Unknown keys are ignored and missing keys inherit, so a theme written for
|
|
20
|
+
* today's engine keeps working when the engine grows.
|
|
21
|
+
* - `normalizeSpec` never throws. `validateSpec` is the strict gate for
|
|
22
|
+
* machine-authored themes.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export const SPEC_VERSION = 1;
|
|
26
|
+
|
|
27
|
+
/** The eight expression channels, in vector order. */
|
|
28
|
+
export const CHANNELS = ["type", "geometry", "density", "depth", "motion", "texture", "rhythm", "icon"];
|
|
29
|
+
|
|
30
|
+
/** Each channel as an ordered scale; the words are the poles a score moves between. */
|
|
31
|
+
export const CHANNEL_SCALES = {
|
|
32
|
+
type: ["utilitarian", "editorial"],
|
|
33
|
+
geometry: ["rectilinear", "organic"],
|
|
34
|
+
density: ["compact", "spacious"],
|
|
35
|
+
depth: ["flat", "layered"],
|
|
36
|
+
motion: ["still", "kinetic"],
|
|
37
|
+
texture: ["polished", "tactile"],
|
|
38
|
+
rhythm: ["regular", "syncopated"],
|
|
39
|
+
icon: ["systematic", "expressive"],
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** How surfaces are made. One choice, applied by every surface recipe. */
|
|
43
|
+
export const MATERIALS = {
|
|
44
|
+
solid: "Opaque surfaces with soft, layered shadow.",
|
|
45
|
+
glass: "Translucent surfaces that blur what is behind them.",
|
|
46
|
+
paper: "Opaque surfaces with a faint printed grain.",
|
|
47
|
+
anodized: "Opaque surfaces with a machined top highlight.",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Hue families a colour can be chosen from, as OKLCH hue angles. */
|
|
51
|
+
export const HUE_FAMILIES = {
|
|
52
|
+
red: 25,
|
|
53
|
+
orange: 50,
|
|
54
|
+
amber: 70,
|
|
55
|
+
yellow: 95,
|
|
56
|
+
lime: 125,
|
|
57
|
+
green: 150,
|
|
58
|
+
teal: 180,
|
|
59
|
+
cyan: 210,
|
|
60
|
+
blue: 250,
|
|
61
|
+
indigo: 270,
|
|
62
|
+
violet: 290,
|
|
63
|
+
purple: 310,
|
|
64
|
+
pink: 350,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const INTER = 'var(--font-inter, "Inter"), ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif';
|
|
68
|
+
const MONO = 'var(--font-jetbrains, "JetBrains Mono"), ui-monospace, "SFMono-Regular", Consolas, monospace';
|
|
69
|
+
|
|
70
|
+
/** Curated font pairings. A model picks one; it never invents a family name. */
|
|
71
|
+
export const FONT_SETS = {
|
|
72
|
+
neutral: { label: "Neutral sans", sans: INTER, display: INTER, mono: MONO },
|
|
73
|
+
editorial: {
|
|
74
|
+
label: "Serif display, sans body",
|
|
75
|
+
sans: INTER,
|
|
76
|
+
display: 'var(--font-newsreader, "Newsreader"), "Iowan Old Style", Georgia, serif',
|
|
77
|
+
mono: MONO,
|
|
78
|
+
},
|
|
79
|
+
technical: { label: "Monospace throughout", sans: MONO, display: MONO, mono: MONO },
|
|
80
|
+
humanist: {
|
|
81
|
+
label: "Humanist sans",
|
|
82
|
+
sans: '"Source Sans 3", "Segoe UI", ui-sans-serif, system-ui, sans-serif',
|
|
83
|
+
display: '"Source Sans 3", "Segoe UI", ui-sans-serif, system-ui, sans-serif',
|
|
84
|
+
mono: MONO,
|
|
85
|
+
},
|
|
86
|
+
geometric: {
|
|
87
|
+
label: "Geometric sans",
|
|
88
|
+
sans: '"Manrope", "Avenir Next", ui-sans-serif, system-ui, sans-serif',
|
|
89
|
+
display: '"Manrope", "Avenir Next", ui-sans-serif, system-ui, sans-serif',
|
|
90
|
+
mono: MONO,
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** Tokens a palette defines. Anything else is derived in CSS from these. */
|
|
95
|
+
export const PALETTE_TOKENS = [
|
|
96
|
+
"background",
|
|
97
|
+
"background-subtle",
|
|
98
|
+
"surface",
|
|
99
|
+
"surface-elevated",
|
|
100
|
+
"text",
|
|
101
|
+
"text-muted",
|
|
102
|
+
"text-faint",
|
|
103
|
+
"border",
|
|
104
|
+
"border-subtle",
|
|
105
|
+
"primary",
|
|
106
|
+
"primary-foreground",
|
|
107
|
+
"primary-text",
|
|
108
|
+
"primary-subtle",
|
|
109
|
+
"success",
|
|
110
|
+
"success-foreground",
|
|
111
|
+
"success-text",
|
|
112
|
+
"warning",
|
|
113
|
+
"warning-foreground",
|
|
114
|
+
"warning-text",
|
|
115
|
+
"danger",
|
|
116
|
+
"danger-foreground",
|
|
117
|
+
"danger-text",
|
|
118
|
+
"info",
|
|
119
|
+
"info-foreground",
|
|
120
|
+
"info-text",
|
|
121
|
+
"chart-1",
|
|
122
|
+
"chart-2",
|
|
123
|
+
"chart-3",
|
|
124
|
+
"chart-4",
|
|
125
|
+
"chart-5",
|
|
126
|
+
"chart-6",
|
|
127
|
+
];
|
|
128
|
+
|
|
129
|
+
const ID_PATTERN = /^[a-z][a-z0-9-]{0,47}$/;
|
|
130
|
+
|
|
131
|
+
const DEFAULT_SPEC = {
|
|
132
|
+
specVersion: SPEC_VERSION,
|
|
133
|
+
id: "custom",
|
|
134
|
+
label: "Custom",
|
|
135
|
+
vector: { type: 0.34, geometry: 0.4, density: 0.44, depth: 0.36, motion: 0.36, texture: 0, rhythm: 0.32, icon: 0.36 },
|
|
136
|
+
color: {
|
|
137
|
+
primary: "oklch(0.21 0.006 286)",
|
|
138
|
+
primaryDark: null,
|
|
139
|
+
neutral: { hue: 286, chroma: 0.004 },
|
|
140
|
+
ink: null,
|
|
141
|
+
},
|
|
142
|
+
material: "solid",
|
|
143
|
+
fonts: "neutral",
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const clamp01 = (value, fallback) =>
|
|
147
|
+
typeof value === "number" && Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : fallback;
|
|
148
|
+
|
|
149
|
+
function readVector(input, base) {
|
|
150
|
+
const vector = { ...base };
|
|
151
|
+
if (Array.isArray(input)) {
|
|
152
|
+
CHANNELS.forEach((channel, index) => {
|
|
153
|
+
vector[channel] = clamp01(input[index], base[channel]);
|
|
154
|
+
});
|
|
155
|
+
} else if (input && typeof input === "object") {
|
|
156
|
+
for (const channel of CHANNELS) vector[channel] = clamp01(input[channel], base[channel]);
|
|
157
|
+
}
|
|
158
|
+
return vector;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function readTint(input, base) {
|
|
162
|
+
if (!input || typeof input !== "object") return base;
|
|
163
|
+
const hue = typeof input.hue === "number" && Number.isFinite(input.hue) ? ((input.hue % 360) + 360) % 360 : base?.hue ?? 0;
|
|
164
|
+
const chroma = typeof input.chroma === "number" && Number.isFinite(input.chroma)
|
|
165
|
+
? Math.min(0.06, Math.max(0, input.chroma))
|
|
166
|
+
: base?.chroma ?? 0;
|
|
167
|
+
return { hue, chroma };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const readColor = (value, fallback) => (typeof value === "string" && parseColor(value) ? value.trim() : fallback);
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Font stacks are written into stylesheets verbatim, and Studio themes come
|
|
174
|
+
* from strangers, so a stack may only hold family names, quotes, commas and
|
|
175
|
+
* `var(--token)` references: nothing that can end a declaration or a block.
|
|
176
|
+
*/
|
|
177
|
+
export function isSafeFontStack(value) {
|
|
178
|
+
if (typeof value !== "string" || !value.trim() || value.length > 300) return false;
|
|
179
|
+
if (!/^[\w\s"',.()-]+$/.test(value)) return false;
|
|
180
|
+
if (/\(/.test(value.replace(/var\(--[\w-]+/g, ""))) return false;
|
|
181
|
+
const count = (character) => value.split(character).length - 1;
|
|
182
|
+
return count('"') % 2 === 0 && count("'") % 2 === 0 && count("(") === count(")");
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function readFonts(input, base) {
|
|
186
|
+
if (typeof input === "string" && FONT_SETS[input]) return input;
|
|
187
|
+
if (input && typeof input === "object") {
|
|
188
|
+
const set = FONT_SETS[typeof base === "string" ? base : "neutral"] ?? FONT_SETS.neutral;
|
|
189
|
+
const pick = (key) => (isSafeFontStack(input[key]) ? input[key].trim() : set[key]);
|
|
190
|
+
return { sans: pick("sans"), display: pick("display"), mono: pick("mono") };
|
|
191
|
+
}
|
|
192
|
+
return base;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Resolve a font choice or an explicit stack to three font stacks. */
|
|
196
|
+
export function resolveFonts(fonts) {
|
|
197
|
+
if (typeof fonts === "string") {
|
|
198
|
+
const set = FONT_SETS[fonts] ?? FONT_SETS.neutral;
|
|
199
|
+
return { sans: set.sans, display: set.display, mono: set.mono };
|
|
200
|
+
}
|
|
201
|
+
return fonts;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Normalise any theme input into a complete spec. Never throws: a partial
|
|
206
|
+
* theme inherits the rest from `base`, which defaults to a neutral spec.
|
|
207
|
+
*
|
|
208
|
+
* Also reads the pre-1.0 theme file shape (`theme` for the vector, `inherit`,
|
|
209
|
+
* and `colors.light.signal`), so existing theme files keep working.
|
|
210
|
+
*/
|
|
211
|
+
export function normalizeSpec(input = {}, base = DEFAULT_SPEC) {
|
|
212
|
+
const source = input && typeof input === "object" ? input : {};
|
|
213
|
+
const legacySignal = source.colors?.light?.signal;
|
|
214
|
+
const legacySignalDark = source.colors?.dark?.signal;
|
|
215
|
+
const color = source.color && typeof source.color === "object" ? source.color : {};
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
specVersion: SPEC_VERSION,
|
|
219
|
+
id: typeof source.id === "string" && ID_PATTERN.test(source.id) ? source.id : base.id,
|
|
220
|
+
label: typeof source.label === "string" && source.label.trim() ? source.label.trim().slice(0, 80) : base.label,
|
|
221
|
+
vector: readVector(source.vector ?? source.theme, base.vector),
|
|
222
|
+
color: {
|
|
223
|
+
primary: readColor(color.primary ?? legacySignal, base.color.primary),
|
|
224
|
+
primaryDark: readColor(color.primaryDark ?? legacySignalDark, base.color.primaryDark ?? null),
|
|
225
|
+
neutral: readTint(color.neutral, base.color.neutral),
|
|
226
|
+
ink: color.ink === null ? null : readTint(color.ink, base.color.ink ?? null),
|
|
227
|
+
},
|
|
228
|
+
material: typeof source.material === "string" && MATERIALS[source.material] ? source.material : base.material,
|
|
229
|
+
fonts: readFonts(source.fonts, base.fonts),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The strict gate for machine-authored themes. Returns a list of problems;
|
|
235
|
+
* an empty list means the spec can be stored as written.
|
|
236
|
+
*/
|
|
237
|
+
export function validateSpec(input) {
|
|
238
|
+
const problems = [];
|
|
239
|
+
if (!input || typeof input !== "object") return ["A theme spec must be an object."];
|
|
240
|
+
if (input.specVersion !== SPEC_VERSION) problems.push(`specVersion must be ${SPEC_VERSION}.`);
|
|
241
|
+
if (typeof input.id !== "string" || !ID_PATTERN.test(input.id)) {
|
|
242
|
+
problems.push("id must start with a letter and use lowercase letters, digits and dashes (max 48).");
|
|
243
|
+
}
|
|
244
|
+
if (typeof input.label !== "string" || !input.label.trim()) problems.push("label is required.");
|
|
245
|
+
for (const channel of CHANNELS) {
|
|
246
|
+
const value = input.vector?.[channel];
|
|
247
|
+
if (typeof value !== "number" || value < 0 || value > 1) problems.push(`vector.${channel} must be a number from 0 to 1.`);
|
|
248
|
+
}
|
|
249
|
+
if (!parseColor(input.color?.primary)) problems.push("color.primary must be an oklch() or hex colour.");
|
|
250
|
+
if (input.color?.primaryDark != null && !parseColor(input.color.primaryDark)) {
|
|
251
|
+
problems.push("color.primaryDark must be an oklch() or hex colour, or null.");
|
|
252
|
+
}
|
|
253
|
+
const neutral = input.color?.neutral;
|
|
254
|
+
if (!neutral || typeof neutral.hue !== "number" || typeof neutral.chroma !== "number" || neutral.chroma < 0 || neutral.chroma > 0.06) {
|
|
255
|
+
problems.push("color.neutral needs a hue and a chroma from 0 to 0.06.");
|
|
256
|
+
}
|
|
257
|
+
if (!MATERIALS[input.material]) problems.push(`material must be one of ${Object.keys(MATERIALS).join(", ")}.`);
|
|
258
|
+
if (typeof input.fonts === "string" ? !FONT_SETS[input.fonts] : !["sans", "display", "mono"].every((key) => isSafeFontStack(input.fonts?.[key]))) {
|
|
259
|
+
problems.push(`fonts must be one of ${Object.keys(FONT_SETS).join(", ")}, or an object with sans, display and mono font stacks.`);
|
|
260
|
+
}
|
|
261
|
+
return problems;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** JSON Schema for the spec, published so editors and models can validate it. */
|
|
265
|
+
export function renderSpecSchema() {
|
|
266
|
+
const unit = { type: "number", minimum: 0, maximum: 1 };
|
|
267
|
+
const tint = {
|
|
268
|
+
type: "object",
|
|
269
|
+
properties: { hue: { type: "number", minimum: 0, maximum: 360 }, chroma: { type: "number", minimum: 0, maximum: 0.06 } },
|
|
270
|
+
required: ["hue", "chroma"],
|
|
271
|
+
};
|
|
272
|
+
return {
|
|
273
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
274
|
+
$id: "https://ui.mlola.com/schema/theme-spec.json",
|
|
275
|
+
title: "Mlola theme spec",
|
|
276
|
+
description: "A theme as a few decisions. The engine derives every token and solves contrast in both modes.",
|
|
277
|
+
type: "object",
|
|
278
|
+
properties: {
|
|
279
|
+
$schema: { type: "string" },
|
|
280
|
+
studio: {
|
|
281
|
+
type: "object",
|
|
282
|
+
description: "Where a theme pulled from the Theme Studio came from. Informational; the engine ignores it.",
|
|
283
|
+
properties: { id: { type: "string" }, version: { type: "integer", minimum: 1 }, source: { type: "string", format: "uri" } },
|
|
284
|
+
},
|
|
285
|
+
specVersion: { const: SPEC_VERSION },
|
|
286
|
+
id: { type: "string", pattern: ID_PATTERN.source, description: "Used as data-theme=\"<id>\"." },
|
|
287
|
+
label: { type: "string", maxLength: 80 },
|
|
288
|
+
vector: {
|
|
289
|
+
type: "object",
|
|
290
|
+
description: "Eight expression channels from 0 to 1.",
|
|
291
|
+
properties: Object.fromEntries(
|
|
292
|
+
CHANNELS.map((channel) => [channel, { ...unit, description: `${CHANNEL_SCALES[channel][0]} (0) to ${CHANNEL_SCALES[channel][1]} (1)` }]),
|
|
293
|
+
),
|
|
294
|
+
},
|
|
295
|
+
color: {
|
|
296
|
+
type: "object",
|
|
297
|
+
properties: {
|
|
298
|
+
primary: { type: "string", description: "Seed for the primary colour, oklch() or hex. Lightness is adjusted to meet contrast." },
|
|
299
|
+
primaryDark: { type: ["string", "null"], description: "Optional different seed for dark mode." },
|
|
300
|
+
neutral: { ...tint, description: "Tint of the page and surfaces." },
|
|
301
|
+
ink: { anyOf: [tint, { type: "null" }], description: "Tint of text and dark-mode surfaces. Defaults to neutral." },
|
|
302
|
+
},
|
|
303
|
+
required: ["primary"],
|
|
304
|
+
},
|
|
305
|
+
material: { enum: Object.keys(MATERIALS) },
|
|
306
|
+
fonts: {
|
|
307
|
+
anyOf: [
|
|
308
|
+
{ enum: Object.keys(FONT_SETS) },
|
|
309
|
+
{
|
|
310
|
+
type: "object",
|
|
311
|
+
properties: { sans: { type: "string" }, display: { type: "string" }, mono: { type: "string" } },
|
|
312
|
+
},
|
|
313
|
+
],
|
|
314
|
+
},
|
|
315
|
+
},
|
|
316
|
+
required: ["id"],
|
|
317
|
+
};
|
|
318
|
+
}
|
package/src/spring.mjs
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CSS `linear()` easing sampled from a damped oscillator.
|
|
3
|
+
*
|
|
4
|
+
* The engine publishes a spring profile per theme (natural frequency and
|
|
5
|
+
* damping ratio). Sampling that profile into a `linear()` function is what lets
|
|
6
|
+
* a static transition carry real kinetic character without any JavaScript
|
|
7
|
+
* animation runtime: the browser plays the curve the physics defines.
|
|
8
|
+
*
|
|
9
|
+
* The model is the unit step response of
|
|
10
|
+
*
|
|
11
|
+
* x'' + 2 ζω x' + ω² x = ω² u(t)
|
|
12
|
+
*
|
|
13
|
+
* which is
|
|
14
|
+
*
|
|
15
|
+
* y(t) = 1 − e^(−ζωt) · ( cos(ω_d t) + (ζω / ω_d) · sin(ω_d t) ),
|
|
16
|
+
* ω_d = ω · √(1 − ζ²)
|
|
17
|
+
*
|
|
18
|
+
* sampled over the 2% settling window `t_s ≈ 4 / (ζω)` and pinned to land on
|
|
19
|
+
* exactly 0 and 1. Underdamped profiles (ζ < 1) therefore overshoot, which is
|
|
20
|
+
* what "springy" means, and the overshoot is bounded by `exp(−ζπ / √(1−ζ²))`.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
const SETTLING_BAND = 4;
|
|
24
|
+
|
|
25
|
+
/** A finite, deterministic list of easing stops for the profile. */
|
|
26
|
+
export function springPoints({ omega, damping }, samples = 24) {
|
|
27
|
+
const zeta = Math.min(0.98, Math.max(0.05, damping));
|
|
28
|
+
const frequency = Math.max(0.1, omega);
|
|
29
|
+
const damped = frequency * Math.sqrt(1 - zeta * zeta);
|
|
30
|
+
const duration = SETTLING_BAND / (zeta * frequency);
|
|
31
|
+
const points = [0];
|
|
32
|
+
for (let index = 1; index <= samples; index += 1) {
|
|
33
|
+
const time = (index / samples) * duration;
|
|
34
|
+
const envelope = Math.exp(-zeta * frequency * time);
|
|
35
|
+
const value =
|
|
36
|
+
1 - envelope * (Math.cos(damped * time) + ((zeta * frequency) / damped) * Math.sin(damped * time));
|
|
37
|
+
points.push(Number(value.toFixed(4)));
|
|
38
|
+
}
|
|
39
|
+
points[points.length - 1] = 1;
|
|
40
|
+
return points;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** The same samples as a CSS `linear()` easing function. */
|
|
44
|
+
export function springEasing(profile, samples = 24) {
|
|
45
|
+
return `linear(${springPoints(profile, samples).join(", ")})`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { derive } from "./config.mjs";
|
|
2
|
+
import { darkShadowDeclarations, PALETTE_DERIVED, themeDeclarations } from "./declarations.mjs";
|
|
3
|
+
import { derivePalette } from "./palette.mjs";
|
|
4
|
+
import { springEasing } from "./spring.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The one way a theme becomes CSS.
|
|
8
|
+
*
|
|
9
|
+
* Canonical themes, `mlola.theme.json`, and themes generated in the Studio all
|
|
10
|
+
* render through this function, in Node at build time or in the browser for a
|
|
11
|
+
* live preview. It has no dependencies beyond the engine's own pure modules.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const indent = (lines) => lines.map((line) => ` ${line};`).join("\n");
|
|
15
|
+
|
|
16
|
+
function paletteLines(palette) {
|
|
17
|
+
return Object.entries(palette).map(([token, value]) => `--ml-${token}: ${value}`);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function selectorList(spec, { root, aliases }, dark) {
|
|
21
|
+
const attributes = [spec.id, ...aliases].map((name) => `[data-theme="${name}"]`);
|
|
22
|
+
const list = [];
|
|
23
|
+
if (root) list.push(":root");
|
|
24
|
+
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");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Render a normalised spec as a self-contained stylesheet.
|
|
31
|
+
*
|
|
32
|
+
* `root` also applies the theme to `:root`, which only the default theme
|
|
33
|
+
* does. `aliases` are further `data-theme` names for the same theme, such as
|
|
34
|
+
* the readable name a Studio theme carries beside its unique id.
|
|
35
|
+
*
|
|
36
|
+
* @param {any} spec
|
|
37
|
+
* @param {{ root?: boolean, aliases?: string[], banner?: boolean }} [options]
|
|
38
|
+
*/
|
|
39
|
+
export function renderSpecCss(spec, { root = false, aliases = [], banner = true } = {}) {
|
|
40
|
+
const derived = derive(spec);
|
|
41
|
+
const light = derivePalette(spec, "light");
|
|
42
|
+
const dark = derivePalette(spec, "dark");
|
|
43
|
+
const easing = springEasing({ omega: derived.springOmega, damping: derived.springDamping });
|
|
44
|
+
const bounce = springEasing({ omega: derived.springOmega, damping: derived.springBounceDamping }, 28);
|
|
45
|
+
const options = { root, aliases };
|
|
46
|
+
|
|
47
|
+
const title = spec.label.replace(/\*\//g, "* /");
|
|
48
|
+
return `${banner ? `/* ${title} — generated by @mlola-ui/engine from its theme spec. Do not hand edit. */\n` : ""}${selectorList(spec, options, false)} {
|
|
49
|
+
color-scheme: light;
|
|
50
|
+
${indent(themeDeclarations(spec))}
|
|
51
|
+
${indent(paletteLines(light))}
|
|
52
|
+
${indent(PALETTE_DERIVED)}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
${selectorList(spec, options, true)} {
|
|
56
|
+
color-scheme: dark;
|
|
57
|
+
${indent(paletteLines(dark))}
|
|
58
|
+
${indent(darkShadowDeclarations(spec.vector))}
|
|
59
|
+
${indent(PALETTE_DERIVED)}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
@supports (transition-timing-function: linear(0, 1)) {
|
|
63
|
+
${selectorList(spec, options, false)} {
|
|
64
|
+
--ml-ease-spring: ${easing};
|
|
65
|
+
--ml-ease-bounce: ${bounce};
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { derive } from "./config.mjs";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The theme distance the quality contract defines, implemented.
|
|
5
|
+
*
|
|
6
|
+
* Each expression channel is observed through the foundations it actually
|
|
7
|
+
* moves. `delta(k, a, b)` is the mean normalized change across that channel's
|
|
8
|
+
* observables, and the pairwise distance is
|
|
9
|
+
*
|
|
10
|
+
* D(a, b) = sqrt( Σ weight(k) · delta(k, a, b)² )
|
|
11
|
+
*
|
|
12
|
+
* so a profile that only changes one foundation cannot look "far" from another
|
|
13
|
+
* just because the vector moved. The observables and scales are product gates:
|
|
14
|
+
* they are calibrated against the canonical corpus and change only with
|
|
15
|
+
* evidence, exactly as `docs/quality.md` says.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export const CHANNEL_OBSERVABLES = {
|
|
19
|
+
type: [
|
|
20
|
+
["displayWeight", 150],
|
|
21
|
+
["tracking", 0.02],
|
|
22
|
+
["bodyLeading", 0.16],
|
|
23
|
+
],
|
|
24
|
+
geometry: [
|
|
25
|
+
["radius", 16],
|
|
26
|
+
["border", 0.65],
|
|
27
|
+
],
|
|
28
|
+
density: [
|
|
29
|
+
["spacing", 1.5],
|
|
30
|
+
],
|
|
31
|
+
depth: [
|
|
32
|
+
["shadowY", 12],
|
|
33
|
+
["shadowBlur", 30],
|
|
34
|
+
["shadowAlpha", 0.14],
|
|
35
|
+
],
|
|
36
|
+
motion: [
|
|
37
|
+
["durationNormal", 140],
|
|
38
|
+
["springDamping", 0.5],
|
|
39
|
+
],
|
|
40
|
+
texture: [
|
|
41
|
+
["textureOpacity", 0.075],
|
|
42
|
+
["border", 0.65],
|
|
43
|
+
],
|
|
44
|
+
rhythm: [
|
|
45
|
+
["tracking", 0.02],
|
|
46
|
+
["durationNormal", 140],
|
|
47
|
+
["spacing", 1.5],
|
|
48
|
+
],
|
|
49
|
+
icon: [
|
|
50
|
+
["iconStroke", 0.55],
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const CHANNEL_WEIGHTS = {
|
|
55
|
+
type: 1,
|
|
56
|
+
geometry: 1,
|
|
57
|
+
density: 1,
|
|
58
|
+
depth: 1,
|
|
59
|
+
motion: 1,
|
|
60
|
+
texture: 1,
|
|
61
|
+
rhythm: 1,
|
|
62
|
+
// Optical stroke moves less of the page than type, motion, or depth, so it
|
|
63
|
+
// carries less weight when judging whether two themes are distinct.
|
|
64
|
+
icon: 0.7,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
|
68
|
+
|
|
69
|
+
/** Per-channel normalized observable change between two derived foundations. */
|
|
70
|
+
export function channelDeltas(left, right, observables = CHANNEL_OBSERVABLES) {
|
|
71
|
+
const deltas = {};
|
|
72
|
+
for (const [channel, entries] of Object.entries(observables)) {
|
|
73
|
+
const values = entries.map(([key, scale]) =>
|
|
74
|
+
clamp01(Math.abs(left[key] - right[key]) / scale),
|
|
75
|
+
);
|
|
76
|
+
deltas[channel] = values.reduce((sum, value) => sum + value, 0) / values.length;
|
|
77
|
+
}
|
|
78
|
+
return deltas;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** `D(a, b)`, optionally with a channel weight map. */
|
|
82
|
+
export function themeDistance(left, right, weights = CHANNEL_WEIGHTS) {
|
|
83
|
+
const deltas = channelDeltas(left, right);
|
|
84
|
+
const sum = Object.entries(deltas).reduce(
|
|
85
|
+
(total, [channel, delta]) => total + (weights[channel] ?? 1) * delta ** 2,
|
|
86
|
+
0,
|
|
87
|
+
);
|
|
88
|
+
return { distance: Math.sqrt(sum), deltas };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function profileDistance(leftProfile, rightProfile, options) {
|
|
92
|
+
return themeDistance(derive(leftProfile), derive(rightProfile), options);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* How many channels clear `threshold`, and the largest single channel's share
|
|
97
|
+
* of the squared distance. These are the other two release criteria.
|
|
98
|
+
*/
|
|
99
|
+
export function distanceShape({ deltas }, threshold = 0.075, weights = CHANNEL_WEIGHTS) {
|
|
100
|
+
const weighted = Object.entries(deltas).map(
|
|
101
|
+
([channel, value]) => (weights[channel] ?? 1) * value ** 2,
|
|
102
|
+
);
|
|
103
|
+
const total = weighted.reduce((sum, value) => sum + value, 0) || 1;
|
|
104
|
+
const channelsAbove = Object.values(deltas).filter((value) => value >= threshold).length;
|
|
105
|
+
const largestShare = Math.max(...weighted) / total;
|
|
106
|
+
return { channelsAbove, largestShare };
|
|
107
|
+
}
|
package/src/theme.mjs
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { DEFAULT_THEME, profiles } from "./config.mjs";
|
|
2
|
+
import { derivePalette } from "./palette.mjs";
|
|
3
|
+
import { CHANNELS, normalizeSpec } from "./spec.mjs";
|
|
4
|
+
import { renderSpecCss } from "./theme-css.mjs";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A project's own theme, from `mlola.theme.json`.
|
|
8
|
+
*
|
|
9
|
+
* The file is a theme spec (see spec.mjs) plus two escape hatches:
|
|
10
|
+
*
|
|
11
|
+
* - `inherit` names the canonical theme whose decisions fill anything the
|
|
12
|
+
* file leaves out.
|
|
13
|
+
* - `scale` overrides spacing or type steps, and `extend` adds raw custom
|
|
14
|
+
* properties, for anything the engine has not modelled yet. Both are
|
|
15
|
+
* emitted after the derived tokens, so they win.
|
|
16
|
+
*
|
|
17
|
+
* The spec itself renders through the same function as every canonical theme.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export const THEME_CHANNELS = CHANNELS;
|
|
21
|
+
|
|
22
|
+
export function defineTheme(input = {}) {
|
|
23
|
+
const source = input && typeof input === "object" ? input : {};
|
|
24
|
+
const inherit = typeof source.inherit === "string" && profiles[source.inherit] ? source.inherit : DEFAULT_THEME;
|
|
25
|
+
const base = { ...profiles[inherit].spec, id: "custom", label: "" };
|
|
26
|
+
const spec = normalizeSpec(source, base);
|
|
27
|
+
return {
|
|
28
|
+
...spec,
|
|
29
|
+
label: spec.label || spec.id,
|
|
30
|
+
inherit,
|
|
31
|
+
scale: source.scale && typeof source.scale === "object" ? { ...source.scale } : {},
|
|
32
|
+
extend: source.extend && typeof source.extend === "object" ? { ...source.extend } : {},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function overrideLines(theme) {
|
|
37
|
+
const lines = [];
|
|
38
|
+
for (const [name, value] of Object.entries(theme.scale)) {
|
|
39
|
+
lines.push(` --ml-${name.replace(/^--ml-/, "")}: ${value};`);
|
|
40
|
+
}
|
|
41
|
+
for (const [name, value] of Object.entries(theme.extend)) {
|
|
42
|
+
lines.push(` ${name.startsWith("--") ? name : `--${name}`}: ${value};`);
|
|
43
|
+
}
|
|
44
|
+
return lines;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Emit the project theme on the same `data-theme` contract as every theme. */
|
|
48
|
+
export function renderThemeCss(input) {
|
|
49
|
+
const theme = defineTheme(input);
|
|
50
|
+
const overrides = overrideLines(theme);
|
|
51
|
+
const selector = `[data-theme="${theme.id}"]`;
|
|
52
|
+
const tail = overrides.length ? `\n:root${selector},\n${selector} {\n${overrides.join("\n")}\n}\n` : "";
|
|
53
|
+
return `/* Generated from the project theme file. Do not hand edit. */\n${renderSpecCss(theme, { banner: false })}${tail}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function manifestEntry(id, spec, extra) {
|
|
57
|
+
return {
|
|
58
|
+
id,
|
|
59
|
+
label: spec.label,
|
|
60
|
+
name: spec.label,
|
|
61
|
+
swatch: derivePalette(spec, "light").primary,
|
|
62
|
+
material: spec.material,
|
|
63
|
+
theme: { ...spec.vector },
|
|
64
|
+
spec,
|
|
65
|
+
...extra,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Describe every theme this build can render. The workbench and any other
|
|
71
|
+
* consumer read this instead of keeping their own list, so adding a theme is
|
|
72
|
+
* one spec and nothing else.
|
|
73
|
+
*/
|
|
74
|
+
export function renderThemeManifest(projectThemeInput = null) {
|
|
75
|
+
const canonical = Object.entries(profiles).map(([id, profile]) =>
|
|
76
|
+
manifestEntry(id, profile.spec, {
|
|
77
|
+
genre: profile.genre,
|
|
78
|
+
flavor: profile.flavor,
|
|
79
|
+
bone: profile.bone,
|
|
80
|
+
typography: profile.typography,
|
|
81
|
+
origin: "canonical",
|
|
82
|
+
}),
|
|
83
|
+
);
|
|
84
|
+
if (!projectThemeInput) return canonical;
|
|
85
|
+
const theme = defineTheme(projectThemeInput);
|
|
86
|
+
return [
|
|
87
|
+
...canonical,
|
|
88
|
+
manifestEntry(theme.id, theme, {
|
|
89
|
+
genre: "Project",
|
|
90
|
+
flavor: `Defined in mlola.theme.json, inheriting ${theme.inherit}`,
|
|
91
|
+
bone: "From your theme",
|
|
92
|
+
typography: "From your theme",
|
|
93
|
+
origin: "project",
|
|
94
|
+
inherit: theme.inherit,
|
|
95
|
+
}),
|
|
96
|
+
];
|
|
97
|
+
}
|