@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/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mlola-ui/engine",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Framework-free CSS, spring easings, and the machine-readable contract for Mlola UI",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"sideEffects": [
|
|
7
|
+
"*.css",
|
|
8
|
+
"generated/*.css"
|
|
9
|
+
],
|
|
10
|
+
"files": [
|
|
11
|
+
"generated",
|
|
12
|
+
"src",
|
|
13
|
+
"build.mjs"
|
|
14
|
+
],
|
|
15
|
+
"exports": {
|
|
16
|
+
".": "./generated/mlola.css",
|
|
17
|
+
"./tokens.css": "./generated/tokens.css",
|
|
18
|
+
"./foundations.css": "./generated/foundations.css",
|
|
19
|
+
"./materials.css": "./generated/materials.css",
|
|
20
|
+
"./recipes.css": "./generated/recipes.css",
|
|
21
|
+
"./motion.css": "./generated/motion.css",
|
|
22
|
+
"./tokens.json": "./generated/tokens.json",
|
|
23
|
+
"./manifest.json": "./generated/manifest.json"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "node build.mjs",
|
|
27
|
+
"check": "node build.mjs --check"
|
|
28
|
+
},
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"author": "Mlola",
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/mlolahq/mlola-ui.git",
|
|
37
|
+
"directory": "packages/engine"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://ui.mlola.com",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/mlolahq/mlola-ui/issues"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"mlola",
|
|
45
|
+
"ui",
|
|
46
|
+
"design-system",
|
|
47
|
+
"css",
|
|
48
|
+
"design-tokens",
|
|
49
|
+
"theme",
|
|
50
|
+
"oklch",
|
|
51
|
+
"accessibility"
|
|
52
|
+
],
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"access": "public"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a renderer must *do*, as data.
|
|
3
|
+
*
|
|
4
|
+
* The stylesheet says what an attribute looks like; this says when to set it.
|
|
5
|
+
* Kept as plain data so it can be read by a human implementing the library in
|
|
6
|
+
* Svelte, Vue, Rails or a Go template, by the framework-free runtime in
|
|
7
|
+
* packages/behavior, and by the audit that proves the two agree.
|
|
8
|
+
*
|
|
9
|
+
* Components built on native form controls are deliberately absent: checkbox
|
|
10
|
+
* and radio are real inputs, so the browser already owns their behaviour and
|
|
11
|
+
* nothing here can rot. Only the indeterminate flag on a checkbox needs script,
|
|
12
|
+
* because HTML exposes it as a property rather than an attribute.
|
|
13
|
+
*/
|
|
14
|
+
export const behaviors = {
|
|
15
|
+
accordion: {
|
|
16
|
+
summary: "Disclosure list. One panel open at a time, or several.",
|
|
17
|
+
root: "ml-accordion",
|
|
18
|
+
parts: {
|
|
19
|
+
item: "ml-accordion-item",
|
|
20
|
+
trigger: "ml-accordion-trigger",
|
|
21
|
+
panel: "ml-accordion-panel",
|
|
22
|
+
},
|
|
23
|
+
state: { "data-state": ["open", "closed"] },
|
|
24
|
+
aria: {
|
|
25
|
+
trigger: {
|
|
26
|
+
role: "button (a real <button> is preferred)",
|
|
27
|
+
"aria-expanded": "true when the item is open",
|
|
28
|
+
"aria-controls": "id of the panel",
|
|
29
|
+
},
|
|
30
|
+
panel: { role: "region", "aria-labelledby": "id of the trigger" },
|
|
31
|
+
},
|
|
32
|
+
keyboard: [
|
|
33
|
+
{ keys: ["Enter", "Space"], does: "Toggle the focused item." },
|
|
34
|
+
{ keys: ["ArrowDown", "ArrowUp"], does: "Move focus between triggers." },
|
|
35
|
+
{ keys: ["Home", "End"], does: "Focus the first or last trigger." },
|
|
36
|
+
],
|
|
37
|
+
transitions: [
|
|
38
|
+
{
|
|
39
|
+
on: "click or activate a trigger",
|
|
40
|
+
set: "data-state on the item, trigger and panel",
|
|
41
|
+
to: "open, or closed when it was open and collapsing is allowed",
|
|
42
|
+
also: "hide the closed panel from the accessibility tree",
|
|
43
|
+
},
|
|
44
|
+
],
|
|
45
|
+
options: {
|
|
46
|
+
multiple: "Allow more than one open item.",
|
|
47
|
+
collapsible: "Allow closing the last open item. Default true.",
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
tabs: {
|
|
52
|
+
summary: "One panel visible at a time, selected by a tab strip.",
|
|
53
|
+
root: "ml-tabs",
|
|
54
|
+
parts: {
|
|
55
|
+
list: "ml-tabs-list",
|
|
56
|
+
trigger: "ml-tabs-trigger",
|
|
57
|
+
content: "ml-tabs-content",
|
|
58
|
+
},
|
|
59
|
+
state: { "data-state": ["active", "inactive"], "data-orientation": ["horizontal", "vertical"] },
|
|
60
|
+
aria: {
|
|
61
|
+
list: { role: "tablist", "aria-orientation": "matches data-orientation" },
|
|
62
|
+
trigger: {
|
|
63
|
+
role: "tab",
|
|
64
|
+
"aria-selected": "true on the active tab",
|
|
65
|
+
"aria-controls": "id of the panel",
|
|
66
|
+
tabindex: "0 on the active tab, -1 on the rest",
|
|
67
|
+
},
|
|
68
|
+
content: { role: "tabpanel", "aria-labelledby": "id of the tab" },
|
|
69
|
+
},
|
|
70
|
+
keyboard: [
|
|
71
|
+
{
|
|
72
|
+
keys: ["ArrowRight", "ArrowLeft"],
|
|
73
|
+
does: "Move to the next or previous enabled tab when horizontal, wrapping around.",
|
|
74
|
+
},
|
|
75
|
+
{ keys: ["ArrowDown", "ArrowUp"], does: "The same when vertical." },
|
|
76
|
+
{ keys: ["Home", "End"], does: "Move to the first or last enabled tab." },
|
|
77
|
+
],
|
|
78
|
+
transitions: [
|
|
79
|
+
{
|
|
80
|
+
on: "select a tab by pointer or key",
|
|
81
|
+
set: "data-state and aria-selected on triggers, data-state on panels",
|
|
82
|
+
to: "active for the chosen pair, inactive for the rest",
|
|
83
|
+
also: "move focus to the newly selected tab",
|
|
84
|
+
},
|
|
85
|
+
],
|
|
86
|
+
notes: "Selection follows focus. Disabled tabs are skipped, never focused.",
|
|
87
|
+
},
|
|
88
|
+
|
|
89
|
+
"dropdown-menu": {
|
|
90
|
+
summary: "A menu anchored to a trigger.",
|
|
91
|
+
root: "ml-dropdown",
|
|
92
|
+
parts: {
|
|
93
|
+
trigger: "ml-dropdown-trigger",
|
|
94
|
+
menu: "ml-dropdown-menu",
|
|
95
|
+
item: "ml-dropdown-item",
|
|
96
|
+
},
|
|
97
|
+
state: { "data-align": ["start", "end"] },
|
|
98
|
+
signals: { "data-state": ["open", "closed"], "data-highlighted": ["present on the active item"] },
|
|
99
|
+
aria: {
|
|
100
|
+
trigger: { "aria-haspopup": "menu", "aria-expanded": "true while open" },
|
|
101
|
+
menu: { role: "menu" },
|
|
102
|
+
item: { role: "menuitem", "data-highlighted": "present on the active item" },
|
|
103
|
+
},
|
|
104
|
+
keyboard: [
|
|
105
|
+
{ keys: ["ArrowDown", "ArrowUp"], does: "Open the menu, then move the highlight." },
|
|
106
|
+
{ keys: ["Enter", "Space"], does: "Activate the highlighted item." },
|
|
107
|
+
{ keys: ["Escape"], does: "Close and return focus to the trigger." },
|
|
108
|
+
{ keys: ["Tab"], does: "Close without activating." },
|
|
109
|
+
],
|
|
110
|
+
transitions: [
|
|
111
|
+
{ on: "click the trigger", set: "data-state", to: "open or closed" },
|
|
112
|
+
{ on: "pointer over an item", set: "data-highlighted", to: "that item only" },
|
|
113
|
+
{ on: "pointer down outside the root", set: "data-state", to: "closed" },
|
|
114
|
+
],
|
|
115
|
+
notes: "Disabled items are skipped by the highlight and cannot be activated.",
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
select: {
|
|
119
|
+
summary: "A listbox behind a combobox trigger.",
|
|
120
|
+
root: "ml-select-root",
|
|
121
|
+
parts: {
|
|
122
|
+
trigger: "ml-select",
|
|
123
|
+
popover: "ml-select-popover",
|
|
124
|
+
list: "ml-select-list",
|
|
125
|
+
option: "ml-select-option",
|
|
126
|
+
search: "ml-select-search",
|
|
127
|
+
},
|
|
128
|
+
state: { "data-state": ["open", "closed", "checked", "unchecked"] },
|
|
129
|
+
aria: {
|
|
130
|
+
trigger: {
|
|
131
|
+
role: "combobox",
|
|
132
|
+
"aria-expanded": "true while open",
|
|
133
|
+
"aria-controls": "id of the listbox",
|
|
134
|
+
"aria-activedescendant": "id of the highlighted option while open",
|
|
135
|
+
},
|
|
136
|
+
list: { role: "listbox", "aria-multiselectable": "true when multiple" },
|
|
137
|
+
option: {
|
|
138
|
+
role: "option",
|
|
139
|
+
"aria-selected": "true when chosen",
|
|
140
|
+
"data-highlighted": "present on the active option",
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
keyboard: [
|
|
144
|
+
{ keys: ["ArrowDown", "ArrowUp"], does: "Open, then move the highlight past disabled options." },
|
|
145
|
+
{ keys: ["Enter", "Space"], does: "Choose the highlighted option. Space types when a search field has focus." },
|
|
146
|
+
{ keys: ["Home", "End"], does: "Highlight the first or last enabled option." },
|
|
147
|
+
{ keys: ["Escape"], does: "Close and return focus to the trigger." },
|
|
148
|
+
{ keys: ["Tab"], does: "Close without choosing." },
|
|
149
|
+
],
|
|
150
|
+
transitions: [
|
|
151
|
+
{
|
|
152
|
+
on: "choose an option",
|
|
153
|
+
set: "aria-selected and data-state on options",
|
|
154
|
+
to: "checked for the chosen option",
|
|
155
|
+
also: "single select closes and restores focus; multiple select stays open",
|
|
156
|
+
},
|
|
157
|
+
],
|
|
158
|
+
options: { multiple: "Toggle several values and keep the list open." },
|
|
159
|
+
},
|
|
160
|
+
|
|
161
|
+
modal: {
|
|
162
|
+
summary: "A dialog over the page that owns focus while open.",
|
|
163
|
+
root: "ml-modal",
|
|
164
|
+
parts: { overlay: "ml-modal-overlay", close: "ml-modal-close" },
|
|
165
|
+
state: { "data-size": ["sm", "md", "lg", "xl", "full"] },
|
|
166
|
+
signals: { "data-state": ["open", "closed"] },
|
|
167
|
+
aria: {
|
|
168
|
+
root: {
|
|
169
|
+
role: "dialog",
|
|
170
|
+
"aria-modal": "true",
|
|
171
|
+
"aria-labelledby": "id of the title, or aria-label",
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
keyboard: [
|
|
175
|
+
{ keys: ["Escape"], does: "Close, unless closing on Escape is disabled." },
|
|
176
|
+
{ keys: ["Tab", "Shift+Tab"], does: "Cycle focus inside the dialog and never leave it." },
|
|
177
|
+
],
|
|
178
|
+
transitions: [
|
|
179
|
+
{ on: "open", set: "focus", to: "the first focusable element inside", also: "lock page scroll" },
|
|
180
|
+
{ on: "close", set: "focus", to: "the element that opened the dialog", also: "release page scroll" },
|
|
181
|
+
{ on: "pointer down on the backdrop", set: "closed", to: "unless closing on backdrop is disabled" },
|
|
182
|
+
],
|
|
183
|
+
},
|
|
184
|
+
|
|
185
|
+
sheet: {
|
|
186
|
+
summary: "A dialog anchored to one edge of the viewport.",
|
|
187
|
+
root: "ml-sheet-panel",
|
|
188
|
+
parts: { overlay: "ml-sheet-overlay", close: "ml-sheet-close" },
|
|
189
|
+
state: {
|
|
190
|
+
"data-side": ["left", "right", "top", "bottom"],
|
|
191
|
+
"data-size": ["sm", "md", "lg"],
|
|
192
|
+
},
|
|
193
|
+
signals: { "data-state": ["open", "closed"] },
|
|
194
|
+
aria: { root: { role: "dialog", "aria-modal": "true" } },
|
|
195
|
+
keyboard: [
|
|
196
|
+
{ keys: ["Escape"], does: "Close, unless closing on Escape is disabled." },
|
|
197
|
+
{ keys: ["Tab", "Shift+Tab"], does: "Cycle focus inside the panel." },
|
|
198
|
+
],
|
|
199
|
+
transitions: [
|
|
200
|
+
{ on: "open", set: "focus", to: "inside the panel", also: "lock page scroll" },
|
|
201
|
+
{ on: "close", set: "focus", to: "the trigger", also: "release page scroll" },
|
|
202
|
+
],
|
|
203
|
+
notes: "Identical to modal apart from which edge it is anchored to.",
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
tooltip: {
|
|
207
|
+
summary: "A short label shown on hover or focus.",
|
|
208
|
+
root: "ml-tooltip-root",
|
|
209
|
+
parts: { tooltip: "ml-tooltip", arrow: "ml-tooltip-arrow" },
|
|
210
|
+
state: { "data-side": ["top", "right", "bottom", "left"] },
|
|
211
|
+
signals: { "data-state": ["open"] },
|
|
212
|
+
aria: {
|
|
213
|
+
tooltip: { role: "tooltip" },
|
|
214
|
+
trigger: { "aria-describedby": "id of the tooltip while it is open" },
|
|
215
|
+
},
|
|
216
|
+
keyboard: [{ keys: ["Escape"], does: "Hide the tooltip." }],
|
|
217
|
+
transitions: [
|
|
218
|
+
{ on: "pointer enter or focus the trigger", set: "visible", to: "after the delay" },
|
|
219
|
+
{ on: "pointer leave or blur", set: "hidden", to: "immediately, cancelling any pending delay" },
|
|
220
|
+
],
|
|
221
|
+
notes: "Never put essential information or interactive content in a tooltip.",
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
toast: {
|
|
225
|
+
summary: "Transient messages in a live region.",
|
|
226
|
+
root: "ml-toaster",
|
|
227
|
+
parts: { toast: "ml-toast", action: "ml-toast-action", close: "ml-toast-close" },
|
|
228
|
+
state: {
|
|
229
|
+
"data-tone": ["neutral", "info", "success", "warning", "danger"],
|
|
230
|
+
"data-position": ["top-right", "top-center", "bottom-right", "bottom-center"],
|
|
231
|
+
},
|
|
232
|
+
aria: {
|
|
233
|
+
root: { role: "region", "aria-label": "distinct per region, so several are distinguishable" },
|
|
234
|
+
toast: {
|
|
235
|
+
role: "status for ordinary messages, alert for danger and warning",
|
|
236
|
+
"aria-live": "polite, or assertive for danger and warning",
|
|
237
|
+
"aria-busy": "true while it waits on work (a loading toast)",
|
|
238
|
+
},
|
|
239
|
+
},
|
|
240
|
+
keyboard: [{ keys: ["Tab"], does: "Reach the action and dismiss controls." }],
|
|
241
|
+
transitions: [
|
|
242
|
+
{ on: "push", set: "a toast into the region matching its position", to: "visible" },
|
|
243
|
+
{ on: "duration elapsed", set: "removed", to: "unless the duration is zero" },
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
|
|
247
|
+
switch: {
|
|
248
|
+
summary: "An on/off control that is not a native checkbox.",
|
|
249
|
+
root: "ml-toggle",
|
|
250
|
+
parts: { thumb: "ml-toggle-thumb" },
|
|
251
|
+
state: { "data-state": ["checked", "unchecked"], "data-size": ["sm", "md", "lg"] },
|
|
252
|
+
aria: { root: { role: "switch", "aria-checked": "true or false" } },
|
|
253
|
+
keyboard: [{ keys: ["Enter", "Space"], does: "Toggle." }],
|
|
254
|
+
transitions: [{ on: "activate", set: "aria-checked and data-state", to: "the opposite value" }],
|
|
255
|
+
notes: "Prefer a native checkbox unless the control genuinely reads as a switch.",
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
slider: {
|
|
259
|
+
summary: "A single value chosen from a range.",
|
|
260
|
+
root: "ml-slider-field",
|
|
261
|
+
parts: {
|
|
262
|
+
control: "ml-slider",
|
|
263
|
+
track: "ml-slider-track",
|
|
264
|
+
range: "ml-slider-range",
|
|
265
|
+
thumb: "ml-slider-thumb",
|
|
266
|
+
},
|
|
267
|
+
state: { "data-size": ["sm", "md"] },
|
|
268
|
+
aria: {
|
|
269
|
+
thumb: {
|
|
270
|
+
role: "slider",
|
|
271
|
+
"aria-valuenow": "current value",
|
|
272
|
+
"aria-valuemin": "minimum",
|
|
273
|
+
"aria-valuemax": "maximum",
|
|
274
|
+
tabindex: "0 unless disabled",
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
keyboard: [
|
|
278
|
+
{ keys: ["ArrowRight", "ArrowUp"], does: "Increase by one step." },
|
|
279
|
+
{ keys: ["ArrowLeft", "ArrowDown"], does: "Decrease by one step." },
|
|
280
|
+
{ keys: ["Home", "End"], does: "Jump to the minimum or maximum." },
|
|
281
|
+
{ keys: ["PageUp", "PageDown"], does: "Move by a larger step." },
|
|
282
|
+
],
|
|
283
|
+
transitions: [
|
|
284
|
+
{
|
|
285
|
+
on: "pointer down on the track, or drag the thumb",
|
|
286
|
+
set: "the value from the pointer position, snapped to the step",
|
|
287
|
+
to: "within min and max",
|
|
288
|
+
},
|
|
289
|
+
],
|
|
290
|
+
},
|
|
291
|
+
};
|
package/src/color.mjs
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Colour math the engine can rely on, in the browser or in Node.
|
|
3
|
+
*
|
|
4
|
+
* Everything is OKLCH in and OKLCH out. A colour outside sRGB is brought into
|
|
5
|
+
* gamut by reducing chroma at a fixed lightness and hue, the same strategy CSS
|
|
6
|
+
* Color 4 uses, so the value the engine measures is the value the browser
|
|
7
|
+
* paints. Contrast follows WCAG 2.2.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
|
11
|
+
const round = (value, places) => Number(value.toFixed(places));
|
|
12
|
+
|
|
13
|
+
/** Parse `oklch(L C H / A)` or `#rgb`, `#rrggbb`. Returns null when unreadable. */
|
|
14
|
+
export function parseColor(value) {
|
|
15
|
+
const text = String(value ?? "").trim();
|
|
16
|
+
const oklch = text.match(
|
|
17
|
+
/^oklch\(\s*([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+)(?:deg)?\s*(?:\/\s*([\d.]+%?))?\s*\)$/i,
|
|
18
|
+
);
|
|
19
|
+
if (oklch) {
|
|
20
|
+
const read = (raw, scale) =>
|
|
21
|
+
raw.endsWith("%") ? (Number.parseFloat(raw) / 100) * scale : Number.parseFloat(raw);
|
|
22
|
+
return {
|
|
23
|
+
L: clamp(read(oklch[1], 1), 0, 1),
|
|
24
|
+
C: Math.max(0, read(oklch[2], 0.4)),
|
|
25
|
+
H: ((Number.parseFloat(oklch[3]) % 360) + 360) % 360,
|
|
26
|
+
alpha: oklch[4] === undefined ? 1 : clamp(read(oklch[4], 1), 0, 1),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const hex = text.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
|
|
30
|
+
if (hex) {
|
|
31
|
+
const digits = hex[1].length === 3 ? [...hex[1]].map((digit) => digit + digit).join("") : hex[1];
|
|
32
|
+
const channels = [0, 2, 4].map((offset) => Number.parseInt(digits.slice(offset, offset + 2), 16) / 255);
|
|
33
|
+
return { ...srgbToOklch(channels), alpha: 1 };
|
|
34
|
+
}
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const decode = (channel) =>
|
|
39
|
+
channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
|
40
|
+
const encode = (channel) =>
|
|
41
|
+
channel <= 0.0031308 ? 12.92 * channel : 1.055 * channel ** (1 / 2.4) - 0.055;
|
|
42
|
+
|
|
43
|
+
function srgbToOklch([r, g, b]) {
|
|
44
|
+
const [lr, lg, lb] = [r, g, b].map(decode);
|
|
45
|
+
const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
|
|
46
|
+
const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
|
|
47
|
+
const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
|
|
48
|
+
const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
|
|
49
|
+
const a = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
|
|
50
|
+
const bb = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
|
|
51
|
+
const C = Math.hypot(a, bb);
|
|
52
|
+
const H = C < 1e-6 ? 0 : ((Math.atan2(bb, a) * 180) / Math.PI + 360) % 360;
|
|
53
|
+
return { L, C, H };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Linear sRGB for an OKLCH colour, not clamped. */
|
|
57
|
+
function toLinear({ L, C, H }) {
|
|
58
|
+
const radians = (H * Math.PI) / 180;
|
|
59
|
+
const a = C * Math.cos(radians);
|
|
60
|
+
const b = C * Math.sin(radians);
|
|
61
|
+
const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
62
|
+
const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
63
|
+
const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
64
|
+
return [
|
|
65
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
66
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
67
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const EPSILON = 1e-5;
|
|
72
|
+
const inGamut = (color) => toLinear(color).every((channel) => channel >= -EPSILON && channel <= 1 + EPSILON);
|
|
73
|
+
|
|
74
|
+
/** Reduce chroma until the colour fits sRGB. Lightness and hue are kept. */
|
|
75
|
+
export function toGamut(color) {
|
|
76
|
+
const L = clamp(color.L, 0, 1);
|
|
77
|
+
const candidate = { ...color, L };
|
|
78
|
+
if (inGamut(candidate)) return candidate;
|
|
79
|
+
let low = 0;
|
|
80
|
+
let high = candidate.C;
|
|
81
|
+
for (let step = 0; step < 24; step += 1) {
|
|
82
|
+
const middle = (low + high) / 2;
|
|
83
|
+
if (inGamut({ ...candidate, C: middle })) low = middle;
|
|
84
|
+
else high = middle;
|
|
85
|
+
}
|
|
86
|
+
return { ...candidate, C: low };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Gamma-encoded sRGB in 0..1, for an in-gamut colour. */
|
|
90
|
+
export function toSrgb(color) {
|
|
91
|
+
return toLinear(toGamut(color)).map((channel) => clamp(encode(clamp(channel, 0, 1)), 0, 1));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** WCAG relative luminance. */
|
|
95
|
+
export function luminance(color) {
|
|
96
|
+
const [r, g, b] = toLinear(toGamut(color)).map((channel) => clamp(channel, 0, 1));
|
|
97
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function composite(top, bottom) {
|
|
101
|
+
const alpha = top.alpha ?? 1;
|
|
102
|
+
if (alpha >= 1) return top;
|
|
103
|
+
const over = toSrgb(top);
|
|
104
|
+
const under = toSrgb(bottom);
|
|
105
|
+
const mixed = over.map((channel, index) => channel * alpha + under[index] * (1 - alpha));
|
|
106
|
+
return { ...srgbToOklch(mixed), alpha: 1 };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** WCAG 2.2 contrast ratio. Accepts colour strings or parsed colours. */
|
|
110
|
+
export function contrast(foreground, background) {
|
|
111
|
+
const fg = typeof foreground === "string" ? parseColor(foreground) : foreground;
|
|
112
|
+
const bg = typeof background === "string" ? parseColor(background) : background;
|
|
113
|
+
if (!fg || !bg) return null;
|
|
114
|
+
const top = composite(fg, bg);
|
|
115
|
+
const a = luminance(top);
|
|
116
|
+
const b = luminance(bg);
|
|
117
|
+
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Serialise an OKLCH colour, gamut-mapped, at stable precision. */
|
|
121
|
+
export function formatColor(color) {
|
|
122
|
+
const mapped = toGamut(color);
|
|
123
|
+
const chroma = round(mapped.C, 3);
|
|
124
|
+
const hue = chroma === 0 ? 0 : round(mapped.H, 1);
|
|
125
|
+
const alpha = mapped.alpha ?? 1;
|
|
126
|
+
const body = `${round(mapped.L, 3)} ${chroma} ${hue}`;
|
|
127
|
+
return alpha < 1 ? `oklch(${body} / ${round(alpha, 3)})` : `oklch(${body})`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Find the lightness closest to `color.L` that reaches `target` contrast
|
|
132
|
+
* against `against`, moving only in `direction`. Hue and requested chroma are
|
|
133
|
+
* kept; chroma is reduced only where the gamut demands it. Returns the
|
|
134
|
+
* extreme when even that cannot reach the target, so callers can detect it.
|
|
135
|
+
*/
|
|
136
|
+
export function solveLightness(color, against, target, direction) {
|
|
137
|
+
const meets = (L) => contrast({ ...color, L, alpha: 1 }, against) >= target;
|
|
138
|
+
const start = clamp(color.L, 0, 1);
|
|
139
|
+
if (meets(start)) return { ...color, L: start };
|
|
140
|
+
const limit = direction === "darker" ? 0 : 1;
|
|
141
|
+
if (!meets(limit)) return { ...color, L: limit };
|
|
142
|
+
let near = start;
|
|
143
|
+
let far = limit;
|
|
144
|
+
for (let step = 0; step < 32; step += 1) {
|
|
145
|
+
const middle = (near + far) / 2;
|
|
146
|
+
if (meets(middle)) far = middle;
|
|
147
|
+
else near = middle;
|
|
148
|
+
}
|
|
149
|
+
return { ...color, L: far };
|
|
150
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { CHANNELS, normalizeSpec } from "./spec.mjs";
|
|
2
|
+
|
|
3
|
+
export const dimensions = [
|
|
4
|
+
["type", "utilitarian", "editorial"],
|
|
5
|
+
["geometry", "rectilinear", "organic"],
|
|
6
|
+
["density", "compact", "spacious"],
|
|
7
|
+
["depth", "flat", "layered"],
|
|
8
|
+
["motion", "still", "kinetic"],
|
|
9
|
+
["texture", "polished", "tactile"],
|
|
10
|
+
["rhythm", "regular", "syncopated"],
|
|
11
|
+
["icon", "systematic", "expressive"],
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The theme a page gets when it names none. Every consumer (the `:root`
|
|
16
|
+
* selector, `normalizeSpec` inheritance, the CLI, the registry, the preview)
|
|
17
|
+
* reads this instead of hard-coding a name.
|
|
18
|
+
*/
|
|
19
|
+
export const DEFAULT_THEME = "graphite";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The canonical themes, as specs.
|
|
23
|
+
*
|
|
24
|
+
* None of them carries a palette. Each is a handful of decisions — eight
|
|
25
|
+
* channels, a primary seed, the tint of the neutrals, a material and a font
|
|
26
|
+
* set — and the engine derives every token from those, exactly as it does for
|
|
27
|
+
* a project theme or one generated from a prompt. There is one path, so the
|
|
28
|
+
* canonical themes are also the proof that the path works.
|
|
29
|
+
*/
|
|
30
|
+
export const canonicalSpecs = {
|
|
31
|
+
graphite: {
|
|
32
|
+
id: "graphite",
|
|
33
|
+
label: "Graphite",
|
|
34
|
+
vector: { type: 0.34, geometry: 0.4, density: 0.44, depth: 0.36, motion: 0.36, texture: 0, rhythm: 0.32, icon: 0.36 },
|
|
35
|
+
color: { primary: "oklch(0.21 0.006 286)", neutral: { hue: 286, chroma: 0.004 } },
|
|
36
|
+
material: "solid",
|
|
37
|
+
fonts: "neutral",
|
|
38
|
+
},
|
|
39
|
+
atelier: {
|
|
40
|
+
id: "atelier",
|
|
41
|
+
label: "Atelier Umami",
|
|
42
|
+
vector: { type: 0.82, geometry: 0.68, density: 0.58, depth: 0.62, motion: 0.48, texture: 0.78, rhythm: 0.7, icon: 0.82 },
|
|
43
|
+
color: {
|
|
44
|
+
primary: "oklch(0.55 0.16 58)",
|
|
45
|
+
primaryDark: "oklch(0.75 0.16 68)",
|
|
46
|
+
neutral: { hue: 80, chroma: 0.016 },
|
|
47
|
+
ink: { hue: 260, chroma: 0.018 },
|
|
48
|
+
},
|
|
49
|
+
material: "paper",
|
|
50
|
+
fonts: "editorial",
|
|
51
|
+
},
|
|
52
|
+
machined: {
|
|
53
|
+
id: "machined",
|
|
54
|
+
label: "Machined Titanium",
|
|
55
|
+
vector: { type: 0.22, geometry: 0.05, density: 0.3, depth: 0.34, motion: 0.22, texture: 0.18, rhythm: 0.24, icon: 0.3 },
|
|
56
|
+
color: {
|
|
57
|
+
primary: "oklch(0.22 0.01 240)",
|
|
58
|
+
primaryDark: "oklch(0.78 0.15 210)",
|
|
59
|
+
neutral: { hue: 240, chroma: 0.004 },
|
|
60
|
+
ink: { hue: 240, chroma: 0.01 },
|
|
61
|
+
},
|
|
62
|
+
material: "anodized",
|
|
63
|
+
fonts: "technical",
|
|
64
|
+
},
|
|
65
|
+
aerogel: {
|
|
66
|
+
id: "aerogel",
|
|
67
|
+
label: "Aerogel Glass",
|
|
68
|
+
vector: { type: 0.54, geometry: 0.92, density: 0.76, depth: 0.94, motion: 0.82, texture: 0.38, rhythm: 0.76, icon: 0.68 },
|
|
69
|
+
color: {
|
|
70
|
+
primary: "oklch(0.55 0.24 285)",
|
|
71
|
+
primaryDark: "oklch(0.75 0.2 285)",
|
|
72
|
+
neutral: { hue: 260, chroma: 0.018 },
|
|
73
|
+
ink: { hue: 270, chroma: 0.028 },
|
|
74
|
+
},
|
|
75
|
+
material: "glass",
|
|
76
|
+
fonts: "neutral",
|
|
77
|
+
},
|
|
78
|
+
nordic: {
|
|
79
|
+
id: "nordic",
|
|
80
|
+
label: "Nordic Earth",
|
|
81
|
+
vector: { type: 0.62, geometry: 0.74, density: 0.66, depth: 0.46, motion: 0.34, texture: 0.86, rhythm: 0.62, icon: 0.54 },
|
|
82
|
+
color: {
|
|
83
|
+
primary: "oklch(0.5 0.14 150)",
|
|
84
|
+
primaryDark: "oklch(0.72 0.15 150)",
|
|
85
|
+
neutral: { hue: 135, chroma: 0.018 },
|
|
86
|
+
ink: { hue: 145, chroma: 0.024 },
|
|
87
|
+
},
|
|
88
|
+
material: "solid",
|
|
89
|
+
fonts: "neutral",
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/** Descriptive copy for the docs and the theme menu. Not read by the engine. */
|
|
94
|
+
const canonicalMeta = {
|
|
95
|
+
graphite: { genre: "Product & AI", flavor: "Neutral graphite, crisp hairlines, and ink that stays out of the way.", bone: "Tight corners", typography: "Neutral sans throughout" },
|
|
96
|
+
atelier: { genre: "Editorial & Craft", flavor: "Warm cotton paper, artisanal ink, and an amber signal.", bone: "Soft corners", typography: "Serif display + sans body" },
|
|
97
|
+
machined: { genre: "Industrial & Telemetry", flavor: "Aerospace telemetry, compact precision, and high contrast.", bone: "Hard corners", typography: "Monospace throughout" },
|
|
98
|
+
aerogel: { genre: "Liquid AI & Optics", flavor: "Optical luminescence, liquid refraction, and specular depth.", bone: "Fluid radius", typography: "Airy sans" },
|
|
99
|
+
nordic: { genre: "Organic & Minimal", flavor: "Forest moss, fjord stone, terracotta, and organic calm.", bone: "Organic radius", typography: "Even sans rhythm" },
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Canonical themes in the shape the rest of the build reads: the normalised
|
|
104
|
+
* spec, its vector as an ordered array, and the descriptive copy.
|
|
105
|
+
*/
|
|
106
|
+
export const profiles = Object.fromEntries(
|
|
107
|
+
Object.entries(canonicalSpecs).map(([id, input]) => {
|
|
108
|
+
const spec = normalizeSpec(input);
|
|
109
|
+
return [id, { ...canonicalMeta[id], aliases: [], label: spec.label, spec, vector: CHANNELS.map((channel) => spec.vector[channel]) }];
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
export const coupling = {
|
|
114
|
+
"type×rhythm": 0.14,
|
|
115
|
+
"geometry×texture": 0.18,
|
|
116
|
+
"density×rhythm": -0.1,
|
|
117
|
+
"depth×texture": 0.2,
|
|
118
|
+
"depth×motion": 0.16,
|
|
119
|
+
"motion×rhythm": 0.12,
|
|
120
|
+
"icon×type": 0.08,
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/** Accepts a vector as the ordered array or as an object keyed by channel. */
|
|
124
|
+
function vectorOf(input) {
|
|
125
|
+
const vector = input.vector ?? input;
|
|
126
|
+
return Array.isArray(vector) ? vector : CHANNELS.map((channel) => vector[channel] ?? 0);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function derive(profile) {
|
|
130
|
+
const [type, geometry, density, depth, motion, texture, rhythm, icon] = vectorOf(profile);
|
|
131
|
+
const gt = geometry * texture;
|
|
132
|
+
const dt = depth * texture;
|
|
133
|
+
const dm = depth * motion;
|
|
134
|
+
const mr = motion * rhythm;
|
|
135
|
+
const tr = type * rhythm;
|
|
136
|
+
const durationNormal = 180 + 140 * motion + 35 * mr;
|
|
137
|
+
|
|
138
|
+
// The damped oscillator behind every spatial transition. The motion axis
|
|
139
|
+
// sets the damping ratio (still ↔ kinetic becomes damped ↔ springy) and the
|
|
140
|
+
// natural frequency is chosen so the spring settles inside the normal
|
|
141
|
+
// duration, which keeps CSS transitions and pointer kinetics in step.
|
|
142
|
+
const springDamping = 0.95 - 0.5 * motion;
|
|
143
|
+
const springOmega = 4 / (Math.max(0.2, springDamping) * (durationNormal / 1000));
|
|
144
|
+
|
|
145
|
+
return {
|
|
146
|
+
spacing: 3.5 + 1.5 * density - 0.3 * density * rhythm,
|
|
147
|
+
radius: 2 + 12 * geometry + 4 * gt,
|
|
148
|
+
border: 1 + 0.45 * (1 - geometry) + 0.2 * texture,
|
|
149
|
+
shadowY: 2 + 12 * depth + 4 * dm,
|
|
150
|
+
shadowBlur: 4 + 30 * depth + 10 * dt,
|
|
151
|
+
shadowAlpha: 0.04 + 0.1 * depth + 0.04 * dt,
|
|
152
|
+
durationFast: 110 + 80 * motion + 20 * mr,
|
|
153
|
+
durationNormal,
|
|
154
|
+
durationSlow: 300 + 260 * motion + 60 * dm,
|
|
155
|
+
// Entrances that draw something in (a chart, a bar filling): twice the slow step.
|
|
156
|
+
durationReveal: 2 * (300 + 260 * motion + 60 * dm),
|
|
157
|
+
springOmega,
|
|
158
|
+
springDamping,
|
|
159
|
+
springBounceDamping: Math.max(0.22, springDamping * 0.55),
|
|
160
|
+
displayWeight: Math.round(690 + 140 * type + 40 * tr),
|
|
161
|
+
bodyLeading: 1.42 + 0.16 * density + 0.04 * tr,
|
|
162
|
+
tracking: -0.006 - 0.014 * type + 0.006 * rhythm,
|
|
163
|
+
iconStroke: 2.1 - 0.55 * icon + 0.12 * (1 - geometry),
|
|
164
|
+
textureOpacity: 0.01 + 0.055 * texture + 0.02 * dt,
|
|
165
|
+
// Density sets how much room a control and a panel take. The spacing scale
|
|
166
|
+
// stays theme-invariant so composed pages still line up.
|
|
167
|
+
controlHeight: 2 + 0.5 * density - 0.06 * density * rhythm,
|
|
168
|
+
panelPadding: 1 + 0.75 * density,
|
|
169
|
+
};
|
|
170
|
+
}
|