@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/contract.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { renderAllLibraryCss, renderCatalogCss, renderLibraryRecipesCss } from "./library-recipes.mjs";
|
|
2
|
+
import { behaviors } from "./behavior-spec.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The portable contract.
|
|
6
|
+
*
|
|
7
|
+
* Styling is already framework-free: recipes react only to `data-*` and
|
|
8
|
+
* `aria-*`. So the attribute half of the contract is derived from the CSS
|
|
9
|
+
* rather than written by hand, which means it cannot drift from what the
|
|
10
|
+
* library actually renders. Any renderer in any language that emits these
|
|
11
|
+
* classes and attributes gets the correct visuals.
|
|
12
|
+
*
|
|
13
|
+
* The behaviour half — which attribute flips on which event, and the keyboard
|
|
14
|
+
* map — cannot be read out of CSS, so it is authored in behavior-spec.mjs and
|
|
15
|
+
* audited against this derived data.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const SELECTOR_ATTRIBUTES =
|
|
19
|
+
/\.(ml-[a-z0-9-]+)((?:\[[a-zA-Z-]+(?:=(?:"[^"]*"|'[^']*'))?\])+)/g;
|
|
20
|
+
const SINGLE_ATTRIBUTE = /\[([a-zA-Z-]+)(?:=(?:"([^"]*)"|'([^']*)'))?\]/g;
|
|
21
|
+
|
|
22
|
+
function stripComments(css) {
|
|
23
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function selectorsOf(css) {
|
|
27
|
+
const selectors = [];
|
|
28
|
+
for (const match of stripComments(css).matchAll(/([^{}]+)\{[^{}]*\}/g)) {
|
|
29
|
+
const head = match[1].trim();
|
|
30
|
+
if (!head || head.startsWith("@")) continue;
|
|
31
|
+
for (const part of head.split(",")) selectors.push(part.trim());
|
|
32
|
+
}
|
|
33
|
+
return selectors;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* class -> attribute -> sorted values ("" means the attribute is a flag).
|
|
38
|
+
* By default it reads the published stylesheet: the free library. Audits pass
|
|
39
|
+
* the whole library, Pro included.
|
|
40
|
+
*/
|
|
41
|
+
export function deriveAttributeContract(css = renderLibraryRecipesCss()) {
|
|
42
|
+
const contract = new Map();
|
|
43
|
+
for (const selector of selectorsOf(css)) {
|
|
44
|
+
for (const match of selector.matchAll(SELECTOR_ATTRIBUTES)) {
|
|
45
|
+
const [, className, attributeBlock] = match;
|
|
46
|
+
for (const attribute of attributeBlock.matchAll(SINGLE_ATTRIBUTE)) {
|
|
47
|
+
const name = attribute[1];
|
|
48
|
+
if (!name.startsWith("data-") && !name.startsWith("aria-")) continue;
|
|
49
|
+
const value = attribute[2] ?? attribute[3] ?? "";
|
|
50
|
+
if (!contract.has(className)) contract.set(className, new Map());
|
|
51
|
+
const attributes = contract.get(className);
|
|
52
|
+
if (!attributes.has(name)) attributes.set(name, new Set());
|
|
53
|
+
if (value) attributes.get(name).add(value);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return contract;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function toElements(derived) {
|
|
61
|
+
const elements = {};
|
|
62
|
+
for (const [className, attributes] of [...derived].sort(([a], [b]) => a.localeCompare(b))) {
|
|
63
|
+
const entry = {};
|
|
64
|
+
for (const [name, values] of [...attributes].sort(([a], [b]) => a.localeCompare(b))) {
|
|
65
|
+
entry[name] = [...values].sort();
|
|
66
|
+
}
|
|
67
|
+
elements[className] = entry;
|
|
68
|
+
}
|
|
69
|
+
return elements;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The published contract: every class and attribute of the free library. */
|
|
73
|
+
export function renderContract() {
|
|
74
|
+
const elements = toElements(deriveAttributeContract());
|
|
75
|
+
return {
|
|
76
|
+
$schema: "./contract.schema.json",
|
|
77
|
+
version: 1,
|
|
78
|
+
description:
|
|
79
|
+
"Classes and the data-/aria- attributes the stylesheet reacts to. Emit these from any language or framework and the visuals are correct. Derived from the stylesheet, not hand written.",
|
|
80
|
+
styling: { attributeSource: "data-theme, data-mode", elements },
|
|
81
|
+
behavior: behaviors,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The Pro contract: only the classes the free library does not style. It is
|
|
87
|
+
* written beside the Pro catalog, never into the published engine, so the
|
|
88
|
+
* shape of Pro's API travels with Pro source.
|
|
89
|
+
*/
|
|
90
|
+
export function proAttributeContract() {
|
|
91
|
+
// A class the published stylesheet names at all is public, attributes or not.
|
|
92
|
+
const published = new Set([...stripComments(renderLibraryRecipesCss()).matchAll(/\.(ml-[a-z0-9-]+)/g)].map((match) => match[1]));
|
|
93
|
+
const everything = deriveAttributeContract(`${renderAllLibraryCss()}\n${renderCatalogCss()}`);
|
|
94
|
+
return new Map([...everything].filter(([className]) => !published.has(className)));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function renderProContract() {
|
|
98
|
+
return {
|
|
99
|
+
version: 1,
|
|
100
|
+
description: "Mlola Pro classes and the data-/aria- attributes their stylesheets react to. Not published; delivered with Pro source.",
|
|
101
|
+
styling: { attributeSource: "data-theme, data-mode", elements: toElements(proAttributeContract()) },
|
|
102
|
+
};
|
|
103
|
+
}
|
package/src/contrast.mjs
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour math for the accessibility gate.
|
|
3
|
+
*
|
|
4
|
+
* The palettes are curated by hand, so "meets WCAG AA" has to be a measured
|
|
5
|
+
* fact rather than a comment. This converts the engine's OKLCH tokens to sRGB,
|
|
6
|
+
* composites translucent surfaces over their backdrop, and returns a contrast
|
|
7
|
+
* ratio the way WCAG 2.2 defines it.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const clamp01 = (value) => Math.min(1, Math.max(0, value));
|
|
11
|
+
|
|
12
|
+
/** `oklch(L C H / A)` (alpha optional) to OKLab. */
|
|
13
|
+
export function parseOklch(value) {
|
|
14
|
+
const match = String(value).match(
|
|
15
|
+
/^oklch\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+%?))?\s*\)$/i,
|
|
16
|
+
);
|
|
17
|
+
if (!match) return null;
|
|
18
|
+
const read = (raw, scale = 1) =>
|
|
19
|
+
raw.endsWith("%") ? (Number.parseFloat(raw) / 100) * scale : Number.parseFloat(raw);
|
|
20
|
+
return {
|
|
21
|
+
L: read(match[1], 1),
|
|
22
|
+
C: read(match[2], 0.4),
|
|
23
|
+
H: Number.parseFloat(match[3]),
|
|
24
|
+
alpha: match[4] === undefined ? 1 : read(match[4], 1),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** OKLCH to gamma-encoded sRGB, clamped into gamut. */
|
|
29
|
+
export function oklchToSrgb({ L, C, H }) {
|
|
30
|
+
const radians = (H * Math.PI) / 180;
|
|
31
|
+
const a = C * Math.cos(radians);
|
|
32
|
+
const b = C * Math.sin(radians);
|
|
33
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
34
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
35
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
36
|
+
const linear = [
|
|
37
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
38
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
39
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
40
|
+
];
|
|
41
|
+
return linear.map((channel) => {
|
|
42
|
+
const encoded = channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055;
|
|
43
|
+
return clamp01(encoded);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Composite a possibly translucent colour over an opaque backdrop. */
|
|
48
|
+
function composite([top, alpha], [bottom]) {
|
|
49
|
+
return top.map((channel, index) => channel * alpha + bottom[index] * (1 - alpha));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** WCAG relative luminance from gamma-encoded sRGB. */
|
|
53
|
+
export function relativeLuminance([r, g, b]) {
|
|
54
|
+
const lin = (channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4);
|
|
55
|
+
return 0.2126 * lin(r) + 0.7152 * lin(g) + 0.0722 * lin(b);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function contrastRatio(foreground, background) {
|
|
59
|
+
const fg = parseOklch(foreground);
|
|
60
|
+
const bg = parseOklch(background);
|
|
61
|
+
if (!fg || !bg) return null;
|
|
62
|
+
const backdrop = oklchToSrgb(bg);
|
|
63
|
+
const top = oklchToSrgb(fg);
|
|
64
|
+
const color = fg.alpha < 1 ? composite([top, fg.alpha], [backdrop, 1]) : top;
|
|
65
|
+
const lighter = Math.max(relativeLuminance(color), relativeLuminance(backdrop));
|
|
66
|
+
const darker = Math.min(relativeLuminance(color), relativeLuminance(backdrop));
|
|
67
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
68
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { derive } from "./config.mjs";
|
|
2
|
+
import { CHANNELS, resolveFonts } from "./spec.mjs";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The derived custom properties a theme emits, besides its palette.
|
|
6
|
+
*
|
|
7
|
+
* Canonical themes, project themes and prompt-generated themes all come
|
|
8
|
+
* through here, so every theme gets the same radius, density, depth, motion
|
|
9
|
+
* and material tokens. Recipes read only these tokens — never a theme's name —
|
|
10
|
+
* which is what lets a theme nobody has seen yet look finished.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const round = (value, places = 3) => Number(value.toFixed(places));
|
|
14
|
+
const px = (value) => `${round(value)}px`;
|
|
15
|
+
const rem = (value) => `${round(value, 4)}rem`;
|
|
16
|
+
const ms = (value) => `${Math.round(value)}ms`;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Dark surfaces swallow shadow, so the same elevation needs a denser one to
|
|
20
|
+
* read at all. One multiplier keeps the two modes on the same curve.
|
|
21
|
+
*/
|
|
22
|
+
const DARK_SHADOW_GAIN = 2.6;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Layered elevation: a tight contact shadow that anchors the surface, plus an
|
|
26
|
+
* ambient one that grows with the step. A single blurred shadow reads as a
|
|
27
|
+
* glow; two read as an object resting on a plane.
|
|
28
|
+
*/
|
|
29
|
+
export function shadowDeclarations(derived, gain = 1) {
|
|
30
|
+
const alpha = (factor) => `oklch(0 0 0 / ${round(Math.min(0.6, derived.shadowAlpha * factor * gain), 4)})`;
|
|
31
|
+
const { shadowY: y, shadowBlur: blur } = derived;
|
|
32
|
+
return [
|
|
33
|
+
`--ml-shadow-xs: 0 1px 2px ${alpha(0.55)}`,
|
|
34
|
+
`--ml-shadow-sm: 0 1px 2px ${alpha(0.45)}, 0 ${px(y * 0.3)} ${px(blur * 0.4)} -1px ${alpha(0.6)}`,
|
|
35
|
+
`--ml-shadow-md: 0 1px 2px ${alpha(0.4)}, 0 ${px(y * 0.6)} ${px(blur * 0.9)} -2px ${alpha(0.8)}`,
|
|
36
|
+
`--ml-shadow-lg: 0 2px 4px ${alpha(0.35)}, 0 ${px(y * 1.2)} ${px(blur * 1.6)} -4px ${alpha(1)}`,
|
|
37
|
+
`--ml-shadow-xl: 0 4px 8px ${alpha(0.3)}, 0 ${px(y * 2.2)} ${px(blur * 2.8)} -8px ${alpha(1.25)}`,
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function darkShadowDeclarations(vector) {
|
|
42
|
+
return shadowDeclarations(derive({ vector }), DARK_SHADOW_GAIN);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Surface material as tokens. Every surface recipe applies all four, so a
|
|
47
|
+
* theme's material changes cards, menus, dialogs and bars at once without a
|
|
48
|
+
* single theme-specific selector.
|
|
49
|
+
*/
|
|
50
|
+
export function materialDeclarations(material, vector) {
|
|
51
|
+
const none = {
|
|
52
|
+
"--ml-surface-alpha": "100%",
|
|
53
|
+
"--ml-surface-blur": "none",
|
|
54
|
+
"--ml-surface-grain": "none",
|
|
55
|
+
"--ml-surface-highlight": "0 0 #0000",
|
|
56
|
+
};
|
|
57
|
+
const tokens = { ...none };
|
|
58
|
+
if (material === "glass") {
|
|
59
|
+
tokens["--ml-surface-alpha"] = "78%";
|
|
60
|
+
tokens["--ml-surface-blur"] = `blur(${px(10 + 12 * vector.depth)}) saturate(150%)`;
|
|
61
|
+
tokens["--ml-surface-highlight"] = "inset 0 1px color-mix(in oklab, oklch(1 0 0) 22%, transparent)";
|
|
62
|
+
} else if (material === "paper") {
|
|
63
|
+
tokens["--ml-surface-grain"] =
|
|
64
|
+
"radial-gradient(color-mix(in srgb, var(--ml-text) calc(var(--ml-texture-opacity) * 100%), transparent) 0.6px, transparent 0.7px)";
|
|
65
|
+
} else if (material === "anodized") {
|
|
66
|
+
tokens["--ml-surface-highlight"] = "inset 0 1px color-mix(in oklab, var(--ml-text) 10%, transparent)";
|
|
67
|
+
}
|
|
68
|
+
return [`--ml-material: ${material}`, ...Object.entries(tokens).map(([name, value]) => `${name}: ${value}`)];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Geometry, type, density, motion, depth and material for one spec. */
|
|
72
|
+
export function themeDeclarations(spec) {
|
|
73
|
+
const derived = derive(spec);
|
|
74
|
+
const fonts = resolveFonts(spec.fonts);
|
|
75
|
+
const control = derived.controlHeight;
|
|
76
|
+
return [
|
|
77
|
+
`--ml-theme: "${spec.id}"`,
|
|
78
|
+
...CHANNELS.map((channel) => `--ml-theme-${channel}: ${spec.vector[channel]}`),
|
|
79
|
+
`--ml-radius-xs: ${px(Math.max(2, derived.radius * 0.38))}`,
|
|
80
|
+
`--ml-radius-sm: ${px(derived.radius * 0.68)}`,
|
|
81
|
+
`--ml-radius-md: ${px(derived.radius)}`,
|
|
82
|
+
`--ml-radius-lg: ${px(derived.radius * 1.48)}`,
|
|
83
|
+
`--ml-radius-pill: 9999px`,
|
|
84
|
+
`--ml-border-width: ${derived.border >= 1.4 ? "1.5px" : "1px"}`,
|
|
85
|
+
`--ml-control-sm: ${rem(control - 0.25)}`,
|
|
86
|
+
`--ml-control-md: ${rem(control)}`,
|
|
87
|
+
`--ml-control-lg: ${rem(control + 0.5)}`,
|
|
88
|
+
`--ml-panel-padding: ${rem(derived.panelPadding)}`,
|
|
89
|
+
`--ml-target-min: 2.75rem`,
|
|
90
|
+
`--ml-font-sans: ${fonts.sans}`,
|
|
91
|
+
`--ml-font-display: ${fonts.display}`,
|
|
92
|
+
`--ml-font-mono: ${fonts.mono}`,
|
|
93
|
+
`--ml-display-weight: ${derived.displayWeight}`,
|
|
94
|
+
`--ml-body-leading: ${round(derived.bodyLeading)}`,
|
|
95
|
+
`--ml-tracking: ${round(derived.tracking, 4)}em`,
|
|
96
|
+
`--ml-icon-stroke: ${round(derived.iconStroke)}`,
|
|
97
|
+
`--ml-duration-fast: ${ms(derived.durationFast)}`,
|
|
98
|
+
`--ml-duration-normal: ${ms(derived.durationNormal)}`,
|
|
99
|
+
`--ml-duration-slow: ${ms(derived.durationSlow)}`,
|
|
100
|
+
`--ml-duration-reveal: ${ms(derived.durationReveal)}`,
|
|
101
|
+
`--ml-ease-standard: cubic-bezier(0.2, 0, 0, 1)`,
|
|
102
|
+
`--ml-ease-spring: cubic-bezier(0.16, 1, 0.3, 1)`,
|
|
103
|
+
`--ml-ease-bounce: cubic-bezier(0.34, 1.4, 0.64, 1)`,
|
|
104
|
+
`--ml-texture-opacity: ${round(derived.textureOpacity, 4)}`,
|
|
105
|
+
...materialDeclarations(spec.material, spec.vector),
|
|
106
|
+
...shadowDeclarations(derived),
|
|
107
|
+
];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Tokens that are pure functions of the palette. Same formula in every theme
|
|
112
|
+
* and mode, so they are declared once per block instead of stored per palette.
|
|
113
|
+
*/
|
|
114
|
+
export const PALETTE_DERIVED = [
|
|
115
|
+
"--ml-focus: var(--ml-primary-text)",
|
|
116
|
+
// Hover and pressed fills move a colour toward the page, so they lighten in
|
|
117
|
+
// light mode and darken in dark mode without a second palette.
|
|
118
|
+
"--ml-primary-hover: color-mix(in oklab, var(--ml-primary) 88%, var(--ml-background))",
|
|
119
|
+
"--ml-fill-hover: color-mix(in oklab, var(--ml-text) 5%, transparent)",
|
|
120
|
+
"--ml-fill-active: color-mix(in oklab, var(--ml-text) 9%, transparent)",
|
|
121
|
+
// A soft, translucent track for switches, meters and sliders that stays
|
|
122
|
+
// visible on any surface it sits on.
|
|
123
|
+
"--ml-track: color-mix(in oklab, var(--ml-text) 12%, transparent)",
|
|
124
|
+
// Checkbox, radio and similar boundaries must reach 3:1 against the page.
|
|
125
|
+
"--ml-control-border: var(--ml-text-faint)",
|
|
126
|
+
"--ml-ring: 0 0 0 3px color-mix(in oklab, var(--ml-focus) 24%, transparent)",
|
|
127
|
+
// The veil behind overlays: the page's own background taken near black, so
|
|
128
|
+
// it carries the theme's tint, and more opaque the darker the page, so it
|
|
129
|
+
// reads as a light veil by day and still separates layers at night.
|
|
130
|
+
"--ml-scrim: oklch(from var(--ml-background) calc(l * 0.22) c h / calc(0.54 - l * 0.28))",
|
|
131
|
+
];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy aliases, tracked with a removal version.
|
|
3
|
+
*
|
|
4
|
+
* A compatibility alias without a deadline is a permanent second API. Each
|
|
5
|
+
* entry here is emitted today and must be gone by `removeIn`; the test in
|
|
6
|
+
* `tests/deprecations.test.mjs` fails if the package version reaches that
|
|
7
|
+
* version with the alias still present, or if the alias is already gone but the
|
|
8
|
+
* ledger entry remains.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const CURRENT_VERSION = "0.3.0";
|
|
12
|
+
|
|
13
|
+
export const DEPRECATIONS = [
|
|
14
|
+
// 1.0 removed data-skin, the shadcn --background/--chart-*/--sidebar-*
|
|
15
|
+
// aliases, the .ml-button-* class variants and every other duplicate
|
|
16
|
+
// spelling in one break. New aliases go here with a removal version.
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
/** Numeric semver comparison. Returns -1, 0, or 1. */
|
|
20
|
+
export function compareVersions(left, right) {
|
|
21
|
+
const parse = (value) => String(value).split(".").map((part) => Number.parseInt(part, 10) || 0);
|
|
22
|
+
const a = parse(left);
|
|
23
|
+
const b = parse(right);
|
|
24
|
+
for (let index = 0; index < Math.max(a.length, b.length); index += 1) {
|
|
25
|
+
const diff = (a[index] ?? 0) - (b[index] ?? 0);
|
|
26
|
+
if (diff !== 0) return diff < 0 ? -1 : 1;
|
|
27
|
+
}
|
|
28
|
+
return 0;
|
|
29
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Assemble the library's stylesheets from the CSS files their owners keep.
|
|
7
|
+
*
|
|
8
|
+
* Each component carries its own `<name>.css` next to its source, so a
|
|
9
|
+
* component's markup, behaviour and styling are reviewed and copied together.
|
|
10
|
+
* The engine only concatenates them, in a stable order, into the published
|
|
11
|
+
* layers:
|
|
12
|
+
*
|
|
13
|
+
* - recipes: the engine's composition primitives (`css/base.css`) and every
|
|
14
|
+
* free component. This ships in `@mlola-ui/engine`.
|
|
15
|
+
* - Pro: Pro components' styles, and the catalog's, are assembled into the
|
|
16
|
+
* unpublished catalog stylesheet or loaded by the component itself; they
|
|
17
|
+
* never ship in the engine.
|
|
18
|
+
* - catalog: blocks, pages and templates. These are the commercial catalog,
|
|
19
|
+
* so their CSS is assembled separately and never published by the engine.
|
|
20
|
+
*
|
|
21
|
+
* Node-only: this reads the filesystem at build time. Browser code renders
|
|
22
|
+
* themes through `theme-css.mjs`, which has no such dependency.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const engineDirectory = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
26
|
+
const packagesDirectory = dirname(engineDirectory);
|
|
27
|
+
|
|
28
|
+
function ownedCss(root) {
|
|
29
|
+
let entries = [];
|
|
30
|
+
try {
|
|
31
|
+
entries = readdirSync(root).sort();
|
|
32
|
+
} catch {
|
|
33
|
+
return [];
|
|
34
|
+
}
|
|
35
|
+
const files = [];
|
|
36
|
+
for (const entry of entries) {
|
|
37
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
38
|
+
const folder = join(root, entry);
|
|
39
|
+
if (!statSync(folder).isDirectory()) continue;
|
|
40
|
+
const own = join(folder, `${entry.replace(/^_/, "")}.css`);
|
|
41
|
+
try {
|
|
42
|
+
files.push({ owner: `${root.split("/").pop()}/${entry}`, css: readFileSync(own, "utf8") });
|
|
43
|
+
} catch {
|
|
44
|
+
// A folder without its own stylesheet contributes nothing.
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return files;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const section = ({ owner, css }) => `/* ${owner} */\n${css.trim()}`;
|
|
51
|
+
|
|
52
|
+
export function readEngineCss(name) {
|
|
53
|
+
return readFileSync(join(engineDirectory, "css", `${name}.css`), "utf8").trim();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Pro components are the ones the commercial catalog's index lists. Their
|
|
58
|
+
* styles never ship in the published engine: they travel with Pro source.
|
|
59
|
+
*/
|
|
60
|
+
function proComponents() {
|
|
61
|
+
try {
|
|
62
|
+
return new Set(JSON.parse(readFileSync(join(packagesDirectory, "registry", "catalog", "index.json"), "utf8")).components ?? []);
|
|
63
|
+
} catch {
|
|
64
|
+
return new Set();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A component that imports its own stylesheet (`import "./name.css"`) loads
|
|
70
|
+
* it only where it renders, so no aggregate carries it. The canvas family does
|
|
71
|
+
* this; such a stylesheet wraps itself in the recipes layer.
|
|
72
|
+
*/
|
|
73
|
+
function loadsOwnCss(name) {
|
|
74
|
+
try {
|
|
75
|
+
return readFileSync(join(packagesDirectory, "components", name, `${name}.tsx`), "utf8").includes(`import "./${name}.css"`);
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const componentCss = () => ownedCss(join(packagesDirectory, "components"));
|
|
82
|
+
const componentName = (owner) => owner.split("/").pop();
|
|
83
|
+
|
|
84
|
+
/** Composition primitives and every free component: the published, critical bundle. */
|
|
85
|
+
export function renderLibraryRecipesCss() {
|
|
86
|
+
const pro = proComponents();
|
|
87
|
+
const parts = [{ owner: "engine/base", css: readEngineCss("base") }, ...componentCss().filter((part) => !pro.has(componentName(part.owner)))];
|
|
88
|
+
return `/* Library recipes — owned by Mlola, no utility framework. */\n\n${parts.map(section).join("\n\n")}\n`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Every component stylesheet, free and Pro, unlayered: what audits and the contract read. */
|
|
92
|
+
export function renderAllLibraryCss() {
|
|
93
|
+
const parts = [{ owner: "engine/base", css: readEngineCss("base") }, ...componentCss()];
|
|
94
|
+
return `${parts.map(section).join("\n\n")}\n`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Pro components (those not loading their own CSS), blocks, pages and templates: the Pro styles. */
|
|
98
|
+
export function renderCatalogCss() {
|
|
99
|
+
const pro = proComponents();
|
|
100
|
+
const parts = [
|
|
101
|
+
...componentCss().filter((part) => pro.has(componentName(part.owner)) && !loadsOwnCss(componentName(part.owner))),
|
|
102
|
+
...["blocks", "pages", "templates"].flatMap((group) => ownedCss(join(packagesDirectory, group))),
|
|
103
|
+
];
|
|
104
|
+
return `/* Mlola Pro styles — Pro components, blocks, pages and templates. Not published by @mlola-ui/engine. */\n@layer mlola.recipes {\n\n${parts.map(section).join("\n\n")}\n\n}\n`;
|
|
105
|
+
}
|