@scattered-light/loomiere 0.0.0-stage → 0.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.
Files changed (73) hide show
  1. package/README.md +60 -2
  2. package/bin/loomiere.mjs +112 -0
  3. package/build/build-tokens.mjs +485 -0
  4. package/exporters/README.md +45 -0
  5. package/exporters/ag-grid-settings.mjs +177 -0
  6. package/exporters/ag-grid.mjs +112 -0
  7. package/exporters/chakra.mjs +630 -0
  8. package/exporters/component-styles.mjs +207 -0
  9. package/exporters/dtcg.mjs +31 -0
  10. package/exporters/figma.mjs +100 -0
  11. package/exporters/index.mjs +38 -0
  12. package/exporters/mui.mjs +666 -0
  13. package/exporters/tailwind.mjs +35 -0
  14. package/exporters/theme.mjs +93 -0
  15. package/generator/brand.mjs +328 -0
  16. package/generator/brand.schema.json +657 -0
  17. package/generator/color.mjs +130 -0
  18. package/generator/data-colors.mjs +104 -0
  19. package/generator/format-brand.mjs +60 -0
  20. package/generator/generate.mjs +43 -0
  21. package/generator/pairs.mjs +236 -0
  22. package/generator/roles.mjs +258 -0
  23. package/generator/vision.mjs +167 -0
  24. package/lib/config.mjs +71 -0
  25. package/lib/contrast.mjs +134 -0
  26. package/lib/lab.mjs +108 -0
  27. package/lib/studio.mjs +127 -0
  28. package/lib/tool-server.mjs +96 -0
  29. package/package.json +51 -4
  30. package/templates/brand.json +104 -0
  31. package/tokens/README.md +219 -0
  32. package/tokens/component/avatar.json +10 -0
  33. package/tokens/component/button.json +12 -0
  34. package/tokens/component/drawer.json +26 -0
  35. package/tokens/component/navigation-menu.json +13 -0
  36. package/tokens/component/popover.json +13 -0
  37. package/tokens/component/preview-card.json +13 -0
  38. package/tokens/component/slider.json +13 -0
  39. package/tokens/component/switch.json +41 -0
  40. package/tokens/component/toast.json +21 -0
  41. package/tokens/component/toggle.json +9 -0
  42. package/tokens/component/toolbar.json +9 -0
  43. package/tokens/component/tooltip.json +9 -0
  44. package/tokens/foundation/layering.json +57 -0
  45. package/tokens/foundation/layout.json +75 -0
  46. package/tokens/foundation/motion.json +41 -0
  47. package/tokens/foundation/type.json +92 -0
  48. package/tokens/semantic/accessibility.json +39 -0
  49. package/tokens/semantic/bar.json +45 -0
  50. package/tokens/semantic/check.json +57 -0
  51. package/tokens/semantic/color.json +270 -0
  52. package/tokens/semantic/content.json +124 -0
  53. package/tokens/semantic/disclosure.json +49 -0
  54. package/tokens/semantic/elevation.json +72 -0
  55. package/tokens/semantic/field.json +77 -0
  56. package/tokens/semantic/focus.json +36 -0
  57. package/tokens/semantic/gauge.json +45 -0
  58. package/tokens/semantic/icon-button.json +33 -0
  59. package/tokens/semantic/icon.json +30 -0
  60. package/tokens/semantic/item.json +65 -0
  61. package/tokens/semantic/modal.json +86 -0
  62. package/tokens/semantic/motion.json +20 -0
  63. package/tokens/semantic/notice.json +37 -0
  64. package/tokens/semantic/popup.json +89 -0
  65. package/tokens/semantic/scheme.json +11 -0
  66. package/tokens/semantic/shape.json +54 -0
  67. package/tokens/semantic/spacing.json +110 -0
  68. package/tokens/semantic/table.json +73 -0
  69. package/tokens/semantic/typography.json +312 -0
  70. package/tools/brand-builder/builder.js +1119 -0
  71. package/tools/brand-builder/index.html +329 -0
  72. package/tools/pair-lab/index.html +119 -0
  73. package/tools/pair-lab/lab.js +249 -0
@@ -0,0 +1,666 @@
1
+ /**
2
+ * MUI (Material UI 6, 7 and 9) theme options: the palette for MUI's own color math, plus
3
+ * component overrides that give MUI's components the same look as loomiere-base-ui: Button
4
+ * variants are the brand's emphasis levels, inputs are the field concept, menus are popups
5
+ * and rows, and so on. Overrides read the theme's --sl- variables, so load tokens.css too.
6
+ * import { createTheme, ThemeProvider } from '@mui/material/styles';
7
+ * import options from './theme/exports/mui.js';
8
+ * const theme = createTheme(options);
9
+ */
10
+ import { px } from './theme.mjs';
11
+ import { raw, styleKit, toSource } from './component-styles.mjs';
12
+
13
+ const MUI_GREYS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900];
14
+ /** MUI typography variant → text style. */
15
+ const TYPE = {
16
+ h1: 'maximize-4xl',
17
+ h2: 'maximize-3xl',
18
+ h3: 'maximize-2xl',
19
+ h4: 'maximize-xl',
20
+ h5: 'maximize-lg',
21
+ h6: 'maximize-md',
22
+ subtitle1: 'magnify',
23
+ subtitle2: 'base-label',
24
+ body1: 'base',
25
+ body2: 'condense',
26
+ caption: 'minimize',
27
+ button: 'base-trigger',
28
+ overline: 'condense-subtitle-label',
29
+ };
30
+ /** MUI size → control tier. */
31
+ const SIZE = { small: 'sm', medium: 'md', large: 'lg' };
32
+ /** Button variant → emphasis level, as data-variant does for the Base UI Button. */
33
+ const VARIANT_LEVEL = { contained: 'bold', outlined: 'standard', text: 'quiet' };
34
+ const STATUS = ['error', 'warning', 'info', 'success'];
35
+
36
+ /** The checkbox and radio drawn the way loomiere-base-ui draws them (MUI's own are icon fonts in SVG). */
37
+ const ICONS = `const check = (mark) => h('span', { className: 'LoomiereCheck', 'aria-hidden': true }, mark);
38
+ const svg = (child) => h('svg', { viewBox: '0 0 10 10', className: 'LoomiereCheckMark' }, child);
39
+ const icons = {
40
+ checkbox: check(null),
41
+ checkboxChecked: check(svg(h('path', { fill: 'currentColor', d: 'M9.16 1.122a.75.75 0 0 1 .218 1.038L5.136 8.66a.75.75 0 0 1-1.13.146L1.247 6.308a.75.75 0 1 1 1.006-1.113l2.108 1.91L8.123 1.34a.75.75 0 0 1 1.037-.218Z' }))),
42
+ checkboxIndeterminate: check(svg(h('path', { d: 'M1.5 5h7', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round' }))),
43
+ radio: h('span', { className: 'LoomiereRadio', 'aria-hidden': true }),
44
+ radioChecked: h('span', { className: 'LoomiereRadio', 'data-checked': '', 'aria-hidden': true }),
45
+ };`;
46
+
47
+ export default {
48
+ id: 'mui',
49
+ name: 'MUI (Material UI)',
50
+ file: 'mui.js',
51
+ for: 'React apps on @mui/material 6, 7 or 9: pass to createTheme(), and load tokens.css.',
52
+ build(theme) {
53
+ const { scopes, palettes, roles } = theme;
54
+ const k = styleKit(theme.prefix ?? 'sl', roles);
55
+ const { v, text } = k;
56
+
57
+ // MUI's palette: real colors, which MUI's own color math (alpha, channels) needs.
58
+ const color = (name, mode) => {
59
+ const s = scopes[name]?.[mode];
60
+ if (!s) return undefined;
61
+ return { main: s.solid, light: s['solid-hover'], dark: s['solid-active'], contrastText: s['on-solid'] };
62
+ };
63
+ // MUI's secondary is the next brand color that isn't the accent.
64
+ const secondary = ['secondary', 'primary', 'tertiary'].find((p) => p !== roles.accent && scopes[p]) ?? 'chrome';
65
+ const palette = (mode) => {
66
+ const m = theme.modes[mode];
67
+ const chrome = scopes.chrome[mode];
68
+ return {
69
+ primary: color('accent', mode),
70
+ secondary: color(secondary, mode),
71
+ ...Object.fromEntries(STATUS.filter((p) => scopes[p]).map((p) => [p, color(p, mode)])),
72
+ grey: Object.fromEntries(MUI_GREYS.map((s) => [s, palettes[roles.chrome][s]]).filter(([, x]) => x)),
73
+ background: { default: m.bg, paper: m.raised },
74
+ text: { primary: m.text, secondary: m.textMuted, disabled: m.textDisabled },
75
+ divider: m.border,
76
+ action: { hover: chrome.soft, selected: chrome['soft-hover'], focus: chrome['soft-active'] },
77
+ };
78
+ };
79
+
80
+ /** MUI color prop → palette scope. "primary" (MUI's default) keeps each level's own color. */
81
+ const COLOR_SCOPE = { primary: undefined, secondary, inherit: 'chrome', ...Object.fromEntries(STATUS.filter((p) => scopes[p]).map((p) => [p, p])) };
82
+ const gaugeColor = (c) => (c === 'primary' ? 'accent' : COLOR_SCOPE[c]);
83
+
84
+ const buttonLevel = (level, palette) => {
85
+ const e = k.emphasis(level, palette);
86
+ const rest = { background: e.bg, color: e.fg, borderColor: e.border };
87
+ return {
88
+ ...rest,
89
+ '&:hover': { background: e['bg-hover'], borderColor: e['border-hover'] },
90
+ '&:active': { background: e['bg-active'] },
91
+ '&.Mui-focusVisible': {
92
+ outline: `${v('a11y-focus-outline-width')} solid ${e.focus}`,
93
+ outlineOffset: v('a11y-focus-outline-offset'),
94
+ boxShadow: `0 0 0 ${v('focus-halo-spread')} ${e.halo}`,
95
+ },
96
+ '&.Mui-disabled': { ...rest, opacity: v('button-opacity-disabled') },
97
+ };
98
+ };
99
+ const sizeVariants = (fn) => Object.entries(SIZE).map(([size, tier]) => ({ props: { size }, style: fn(tier) }));
100
+
101
+ const field = (tier) => ({
102
+ [`& .MuiInputBase-input`]: {
103
+ boxSizing: 'border-box',
104
+ height: v(`control-height-${tier}`),
105
+ paddingBlock: 0,
106
+ paddingInline: v(`control-padding-inline${tier === 'md' ? '' : `-${tier}`}`),
107
+ },
108
+ ...text(tier === 'sm' ? 'condense' : tier === 'lg' ? 'magnify' : 'base'),
109
+ });
110
+
111
+ const components = {
112
+ MuiButtonBase: { defaultProps: { disableRipple: true } },
113
+
114
+ MuiButton: {
115
+ defaultProps: { disableElevation: true },
116
+ styleOverrides: {
117
+ root: {
118
+ ...k.control('md', { trigger: true }),
119
+ minWidth: 0,
120
+ textTransform: 'none',
121
+ border: `${v('stroke-default')} solid transparent`,
122
+ borderRadius: v('button-radius'),
123
+ boxShadow: 'none',
124
+ transition: v('transition-button'),
125
+ '&:hover': { boxShadow: 'none' },
126
+ '& svg': { width: k.iconSize('md'), height: k.iconSize('md') },
127
+ '& .MuiButton-startIcon, & .MuiButton-endIcon': { margin: 0 },
128
+ variants: [
129
+ ...Object.entries(VARIANT_LEVEL).flatMap(([variant, level]) =>
130
+ Object.entries(COLOR_SCOPE).map(([c, palette]) => ({ props: { variant, color: c }, style: buttonLevel(level, palette) })),
131
+ ),
132
+ ...sizeVariants((tier) => ({ ...k.control(tier, { trigger: true }), '& svg': { width: k.iconSize(tier), height: k.iconSize(tier) } })),
133
+ ],
134
+ },
135
+ },
136
+ },
137
+
138
+ MuiIconButton: {
139
+ styleOverrides: {
140
+ root: {
141
+ ...k.iconButton,
142
+ '&:hover': k.iconButtonHover,
143
+ '&.Mui-focusVisible': k.focusRingInset,
144
+ },
145
+ },
146
+ },
147
+
148
+ MuiToggleButton: {
149
+ styleOverrides: {
150
+ root: {
151
+ ...k.control('md', { trigger: true }),
152
+ textTransform: 'none',
153
+ color: v('text-primary'),
154
+ background: v('surface-base'),
155
+ border: `${v('stroke-default')} solid ${v('border-default')}`,
156
+ borderRadius: v('radius-chrome'),
157
+ transition: v('transition-button'),
158
+ '&:hover': { background: v('surface-raised'), borderColor: v('border-strong') },
159
+ '&.Mui-selected, &.Mui-selected:hover': { color: v('interactive-default'), borderColor: v('border-accent') },
160
+ '&.Mui-selected': { background: v('interactive-tint-hover') },
161
+ '&.Mui-selected:hover': { background: v('interactive-tint-strong') },
162
+ '&.Mui-focusVisible': k.focusRing,
163
+ '&.Mui-disabled': { opacity: v('toggle-opacity-disabled'), color: v('text-primary') },
164
+ variants: sizeVariants((tier) => ({ ...k.control(tier, { trigger: true }), borderRadius: v(tier === 'sm' ? 'radius-item' : tier === 'lg' ? 'radius-surface' : 'radius-chrome') })),
165
+ },
166
+ },
167
+ },
168
+ MuiToggleButtonGroup: {
169
+ styleOverrides: {
170
+ root: { ...k.barSurface, display: 'inline-flex' },
171
+ grouped: {
172
+ margin: 0,
173
+ borderRadius: v('radius-item'),
174
+ '&:not(:first-of-type)': { marginLeft: 0, borderLeft: `${v('stroke-default')} solid ${v('border-default')}`, borderRadius: v('radius-item') },
175
+ '&:not(:last-of-type)': { borderRadius: v('radius-item') },
176
+ },
177
+ },
178
+ },
179
+
180
+ // Fields: the label sits above the control, as in Base UI, so it never notches the border.
181
+ MuiFormControl: { styleOverrides: { root: { gap: 0 } } },
182
+ MuiInputLabel: {
183
+ defaultProps: { shrink: true },
184
+ styleOverrides: {
185
+ root: {
186
+ ...text('base-label'),
187
+ position: 'relative',
188
+ transform: 'none',
189
+ maxWidth: 'none',
190
+ // FieldRoot's gap plus the label's own spacing.
191
+ marginBottom: `calc(${v('field-gap')} + ${v('field-label-spacing')})`,
192
+ color: v('text-primary'),
193
+ '&.Mui-focused, &.Mui-error': { color: v('text-primary') },
194
+ // A disabled field fades as a whole, as a disabled Base UI Field.Root does.
195
+ '&.Mui-disabled': { color: v('text-primary'), opacity: v('field-opacity-disabled') },
196
+ },
197
+ },
198
+ },
199
+ MuiFormLabel: {
200
+ styleOverrides: {
201
+ root: {
202
+ ...text('base-label'),
203
+ color: v('text-primary'),
204
+ '&.Mui-focused, &.Mui-error': { color: v('text-primary') },
205
+ '&.Mui-disabled': { color: v('text-disabled') },
206
+ },
207
+ },
208
+ },
209
+ MuiFormHelperText: {
210
+ styleOverrides: {
211
+ root: {
212
+ ...text('condense'),
213
+ margin: `calc(${v('field-gap')} + ${v('field-message-spacing')}) 0 0`,
214
+ color: v('text-secondary'),
215
+ '&.Mui-error': { ...text('base-label'), color: v('status-error') },
216
+ },
217
+ },
218
+ },
219
+ MuiInputBase: {
220
+ styleOverrides: {
221
+ root: { ...text('base'), color: v('field-color') },
222
+ input: { '&::placeholder': k.fieldStates.placeholder, '&.Mui-disabled': { WebkitTextFillColor: v('field-color-disabled') } },
223
+ },
224
+ },
225
+ MuiOutlinedInput: {
226
+ defaultProps: { notched: false },
227
+ styleOverrides: {
228
+ root: {
229
+ background: v('field-background'),
230
+ borderRadius: v('field-radius'),
231
+ transition: v('field-transition'),
232
+ '& .MuiOutlinedInput-notchedOutline': { top: 0, borderWidth: v('field-border-width'), borderColor: v('field-border-color'), '& legend': { display: 'none' } },
233
+ '&:hover:not(.Mui-focused):not(.Mui-disabled):not(.Mui-error) .MuiOutlinedInput-notchedOutline': k.fieldStates.hover,
234
+ '&.Mui-focused': { boxShadow: v('field-shadow-focus') },
235
+ '&.Mui-focused .MuiOutlinedInput-notchedOutline': { borderWidth: v('field-border-width'), borderColor: v('field-border-color-focus') },
236
+ '&.Mui-error .MuiOutlinedInput-notchedOutline': k.fieldStates.error,
237
+ '&.Mui-error.Mui-focused': { boxShadow: v('field-shadow-focus-error') },
238
+ '&.Mui-disabled': { background: v('field-background-disabled'), color: v('field-color-disabled'), opacity: v('field-opacity-disabled') },
239
+ '&.Mui-disabled .MuiOutlinedInput-notchedOutline': { borderColor: v('field-border-color') },
240
+ ...field('md'),
241
+ '&.MuiInputBase-multiline': { padding: 0 },
242
+ '& .MuiInputBase-inputMultiline': { height: 'auto', minHeight: '80px', paddingBlock: v('control-padding-block') },
243
+ '&.MuiInputBase-adornedStart': { paddingLeft: v('control-padding-inline') },
244
+ '&.MuiInputBase-adornedStart .MuiInputBase-input': { paddingLeft: 0 },
245
+ '&.MuiInputBase-adornedEnd': { paddingRight: v('control-padding-inline') },
246
+ '&.MuiInputBase-adornedEnd .MuiInputBase-input': { paddingRight: 0 },
247
+ variants: [{ props: { size: 'small' }, style: field('sm') }],
248
+ },
249
+ },
250
+ },
251
+ MuiSelect: {
252
+ styleOverrides: {
253
+ select: { display: 'flex', alignItems: 'center' },
254
+ icon: { color: v('text-tertiary'), right: v('control-padding-inline') },
255
+ },
256
+ },
257
+ MuiInputAdornment: { styleOverrides: { root: { color: v('text-tertiary') } } },
258
+
259
+ // Popups and rows.
260
+ MuiPaper: { styleOverrides: { root: { backgroundImage: 'none' } } },
261
+ MuiPopover: { styleOverrides: { paper: k.popupSurface } },
262
+ MuiMenu: {
263
+ styleOverrides: {
264
+ paper: { ...k.popupSurface, minWidth: v('popup-min-width') },
265
+ list: { padding: v('popup-padding') },
266
+ },
267
+ },
268
+ MuiList: { styleOverrides: { root: { '.MuiPopover-paper > &': { padding: v('popup-padding') } } } },
269
+ MuiMenuItem: {
270
+ styleOverrides: {
271
+ root: {
272
+ ...k.itemRow,
273
+ '&:hover, &.Mui-focusVisible': k.itemStates.hover,
274
+ '&.Mui-selected': k.itemStates.selected,
275
+ '&.Mui-selected:hover, &.Mui-selected.Mui-focusVisible': { ...k.itemStates.selected, background: v('item-background-selected') },
276
+ '&.Mui-disabled': { ...k.itemStates.disabled, opacity: v('item-opacity-disabled') },
277
+ '& .MuiListItemIcon-root': { minWidth: 0, color: v('item-indicator-color') },
278
+ // Menu rows are the dense rows of a Base UI Menu.
279
+ '.MuiMenu-list > &': { paddingBlock: v('item-padding-block-dense') },
280
+ },
281
+ },
282
+ },
283
+ MuiListSubheader: { styleOverrides: { root: { ...k.groupLabel, margin: 0, lineHeight: v('font-condense-subtitle-label-line-height') } } },
284
+ MuiDivider: {
285
+ styleOverrides: {
286
+ root: {
287
+ borderColor: v('border-default'),
288
+ '.MuiMenu-list > &, .MuiAutocomplete-listbox > &': { margin: `${v('popup-separator-spacing')} 0`, borderColor: v('popup-separator-color') },
289
+ },
290
+ },
291
+ },
292
+ MuiAutocomplete: {
293
+ styleOverrides: {
294
+ inputRoot: { '&.MuiOutlinedInput-root': { paddingBlock: 0, paddingLeft: v('control-padding-inline') }, '& .MuiAutocomplete-input': { paddingInline: 0 } },
295
+ paper: { ...k.popupSurface, borderRadius: v('popup-radius-field'), marginTop: v('space-1') },
296
+ listbox: { padding: v('popup-padding'), maxHeight: v('popup-list-max-height') },
297
+ option: {
298
+ ...k.itemRow,
299
+ '&.Mui-focused': k.itemStates.hover,
300
+ '&[aria-selected="true"], &[aria-selected="true"].Mui-focused': k.itemStates.selected,
301
+ '&[aria-disabled="true"]': k.itemStates.disabled,
302
+ },
303
+ groupLabel: { ...k.groupLabel, lineHeight: v('font-condense-subtitle-label-line-height') },
304
+ noOptions: { ...text('condense'), color: v('text-tertiary'), textAlign: 'center', paddingBlock: v('popup-empty-padding-block') },
305
+ clearIndicator: { ...k.iconButton, '&:hover': k.iconButtonHover },
306
+ popupIndicator: { color: v('text-tertiary') },
307
+ tag: { margin: v('space-0-5') },
308
+ },
309
+ },
310
+
311
+ // Checks: the box, the radio circle and the switch track of loomiere-base-ui.
312
+ MuiFormControlLabel: {
313
+ styleOverrides: {
314
+ root: {
315
+ gap: v('gap-control'),
316
+ marginLeft: 0,
317
+ marginRight: 0,
318
+ color: v('text-primary'),
319
+ '&.Mui-disabled': { cursor: 'not-allowed' },
320
+ // A switch's label uses the label style, as SwitchLabel does.
321
+ '&:has(.MuiSwitch-root) .MuiFormControlLabel-label': text('base-label'),
322
+ },
323
+ label: { ...text('base'), '&.Mui-disabled': { color: v('text-disabled') } },
324
+ // A switch's label is the label style, as SwitchLabel is.
325
+
326
+ },
327
+ },
328
+ MuiCheckbox: {
329
+ defaultProps: { icon: raw('icons.checkbox'), checkedIcon: raw('icons.checkboxChecked'), indeterminateIcon: raw('icons.checkboxIndeterminate') },
330
+ styleOverrides: {
331
+ root: {
332
+ '--loomiere-check-size': k.iconSize('md'),
333
+ '--loomiere-mark-size': v('icon-size-condense'),
334
+ padding: 0,
335
+ color: 'inherit',
336
+ '&:hover': { background: 'transparent' },
337
+ '& .LoomiereCheck': { ...k.checkBox, width: 'var(--loomiere-check-size)', height: 'var(--loomiere-check-size)', borderRadius: v('check-radius') },
338
+ '& .LoomiereCheckMark': { display: 'block', width: 'var(--loomiere-mark-size)', height: 'var(--loomiere-mark-size)' },
339
+ '&:hover .LoomiereCheck': k.checkStates.hover,
340
+ '&.Mui-checked .LoomiereCheck, &.MuiCheckbox-indeterminate .LoomiereCheck': k.checkStates.checked,
341
+ '&.Mui-focusVisible .LoomiereCheck': k.focusRing,
342
+ '&.Mui-disabled .LoomiereCheck': k.checkStates.disabled,
343
+ variants: [
344
+ { props: { size: 'small' }, style: { '--loomiere-check-size': k.iconSize('sm'), '--loomiere-mark-size': v('icon-size-minimize') } },
345
+ { props: { size: 'large' }, style: { '--loomiere-check-size': k.iconSize('lg'), '--loomiere-mark-size': v('icon-size-base') } },
346
+ ],
347
+ },
348
+ },
349
+ },
350
+ MuiRadio: {
351
+ defaultProps: { icon: raw('icons.radio'), checkedIcon: raw('icons.radioChecked') },
352
+ styleOverrides: {
353
+ root: {
354
+ '--loomiere-check-size': k.iconSize('md'),
355
+ padding: 0,
356
+ color: 'inherit',
357
+ '&:hover': { background: 'transparent' },
358
+ '& .LoomiereRadio': { ...k.checkBox, width: 'var(--loomiere-check-size)', height: 'var(--loomiere-check-size)', borderRadius: v('radius-pill') },
359
+ '& .LoomiereRadio[data-checked]': k.checkStates.checked,
360
+ '& .LoomiereRadio[data-checked]::before': {
361
+ content: '""',
362
+ width: `round(var(--loomiere-check-size) * ${v('check-dot-scale')}, 1px)`,
363
+ height: `round(var(--loomiere-check-size) * ${v('check-dot-scale')}, 1px)`,
364
+ borderRadius: v('radius-pill'),
365
+ background: v('check-mark-color'),
366
+ },
367
+ '&:hover .LoomiereRadio:not([data-checked])': k.checkStates.hover,
368
+ '&.Mui-focusVisible .LoomiereRadio': k.focusRing,
369
+ '&.Mui-disabled .LoomiereRadio': k.checkStates.disabled,
370
+ variants: [
371
+ { props: { size: 'small' }, style: { '--loomiere-check-size': k.iconSize('sm') } },
372
+ { props: { size: 'large' }, style: { '--loomiere-check-size': k.iconSize('lg') } },
373
+ ],
374
+ },
375
+ },
376
+ },
377
+ MuiRadioGroup: { styleOverrides: { root: { gap: v('gap-stack-sm') } } },
378
+ MuiSwitch: {
379
+ styleOverrides: {
380
+ root: {
381
+ '--loomiere-switch-width': v('switch-track-width'),
382
+ '--loomiere-switch-height': v('switch-track-height'),
383
+ '--loomiere-switch-text': v('switch-track-text-font-size'),
384
+ width: 'var(--loomiere-switch-width)',
385
+ height: 'var(--loomiere-switch-height)',
386
+ padding: 0,
387
+ overflow: 'visible',
388
+ variants: [
389
+ { props: { size: 'small' }, style: { '--loomiere-switch-width': v('switch-track-width-sm'), '--loomiere-switch-height': v('switch-track-height-sm'), '--loomiere-switch-text': v('switch-track-text-font-size-sm') } },
390
+ ],
391
+ },
392
+ switchBase: {
393
+ padding: `calc(${v('switch-track-padding')} + ${v('check-border-width')})`,
394
+ '&:hover': { background: 'transparent' },
395
+ '&.Mui-checked': { transform: 'translateX(calc(var(--loomiere-switch-width) - var(--loomiere-switch-height)))' },
396
+ '&.Mui-checked + .MuiSwitch-track': { opacity: 1, background: v('check-background-checked'), borderColor: v('check-border-color-checked') },
397
+ '&.Mui-checked + .MuiSwitch-track::before': { content: '"on"', right: 'auto', left: v('switch-track-padding'), color: v('check-mark-color') },
398
+ '&:hover:not(.Mui-disabled):not(.Mui-checked) + .MuiSwitch-track': { background: v('fill-strong') },
399
+ '&.Mui-checked:hover:not(.Mui-disabled) + .MuiSwitch-track': { background: v('interactive-hover') },
400
+ '&.Mui-focusVisible + .MuiSwitch-track': k.focusRing,
401
+ '&.Mui-disabled + .MuiSwitch-track': { opacity: v('check-opacity-disabled') },
402
+ '&.Mui-disabled .MuiSwitch-thumb': { color: v('check-background') },
403
+ },
404
+ thumb: {
405
+ width: `calc(var(--loomiere-switch-height) - 2 * (${v('switch-track-padding')} + ${v('check-border-width')}))`,
406
+ height: `calc(var(--loomiere-switch-height) - 2 * (${v('switch-track-padding')} + ${v('check-border-width')}))`,
407
+ color: v('check-background'),
408
+ boxShadow: v('elevation-sm'),
409
+ },
410
+ track: {
411
+ position: 'relative',
412
+ boxSizing: 'border-box',
413
+ opacity: 1,
414
+ borderRadius: v('radius-pill'),
415
+ background: v('fill-default'),
416
+ border: `${v('check-border-width')} solid ${v('check-border-color')}`,
417
+ transition: `background-color ${v('duration-normal')} ${v('easing-in-out')}`,
418
+ '&::before': {
419
+ ...text('condense-subtitle-label'),
420
+ content: '"off"',
421
+ position: 'absolute',
422
+ insetBlock: 0,
423
+ right: v('switch-track-padding'),
424
+ width: 'calc(var(--loomiere-switch-width) - var(--loomiere-switch-height))',
425
+ display: 'flex',
426
+ alignItems: 'center',
427
+ justifyContent: 'center',
428
+ fontSize: 'var(--loomiere-switch-text)',
429
+ lineHeight: 1,
430
+ color: v('text-secondary'),
431
+ },
432
+ },
433
+ },
434
+ },
435
+ MuiSlider: {
436
+ styleOverrides: {
437
+ root: {
438
+ '--loomiere-fill': v('scope-solid'),
439
+ '--loomiere-halo': v('scope-soft-hover'),
440
+ '--loomiere-halo-active': v('scope-soft-active'),
441
+ height: v('gauge-height-sm'),
442
+ padding: `calc(${v('slider-thumb-size')} / 2) 0`,
443
+ color: 'var(--loomiere-fill)',
444
+ '&.Mui-disabled': { opacity: v('check-opacity-disabled'), color: 'var(--loomiere-fill)' },
445
+ variants: Object.entries(COLOR_SCOPE).map(([c]) => ({
446
+ props: { color: c },
447
+ style: { '--loomiere-fill': k.scope(gaugeColor(c) ?? 'accent', 'solid'), '--loomiere-halo': k.scope(gaugeColor(c) ?? 'accent', 'soft-hover'), '--loomiere-halo-active': k.scope(gaugeColor(c) ?? 'accent', 'soft-active') },
448
+ })),
449
+ },
450
+ rail: { opacity: 1, background: v('gauge-track-color'), borderRadius: v('gauge-radius') },
451
+ track: { border: 'none', background: 'var(--loomiere-fill)', borderRadius: v('gauge-radius') },
452
+ thumb: {
453
+ width: v('slider-thumb-size'),
454
+ height: v('slider-thumb-size'),
455
+ boxSizing: 'border-box',
456
+ background: v('field-background'),
457
+ border: `${v('stroke-emphasis')} solid var(--loomiere-fill)`,
458
+ boxShadow: 'none',
459
+ transition: `box-shadow ${v('duration-normal')} ${v('easing-default')}`,
460
+ '&::before': { boxShadow: 'none' },
461
+ '&:hover': { boxShadow: `0 0 0 ${v('focus-halo-spread')} var(--loomiere-halo)` },
462
+ '&.Mui-focusVisible': k.focusRing,
463
+ '&.Mui-active': { boxShadow: `0 0 0 calc(${v('focus-halo-spread')} * 1.5) var(--loomiere-halo-active)` },
464
+ },
465
+ mark: { width: v('stroke-emphasis'), height: `calc(${v('gauge-height-sm')} * 2)`, background: v('fill-strong'), borderRadius: v('radius-pill') },
466
+ markActive: { background: v('fill-strong'), opacity: 1 },
467
+ valueLabel: { ...text('condense'), background: v('surface-inverse'), color: v('text-on-inverse-surface'), borderRadius: v('radius-chrome') },
468
+ },
469
+ },
470
+
471
+ // Disclosure.
472
+ MuiTabs: {
473
+ styleOverrides: {
474
+ root: { minHeight: 0, borderBottom: `${v('stroke-default')} solid ${v('disclosure-divider-color')}` },
475
+ indicator: { height: v('disclosure-indicator-width'), background: v('disclosure-indicator-color') },
476
+ },
477
+ },
478
+ MuiTab: {
479
+ styleOverrides: {
480
+ root: {
481
+ ...text('base'),
482
+ textTransform: 'none',
483
+ minHeight: 0,
484
+ minWidth: 0,
485
+ padding: `${v('space-2')} ${v('space-4')}`,
486
+ color: v('text-secondary'),
487
+ opacity: 1,
488
+ transition: v('transition-button'),
489
+ '&:hover:not(.Mui-disabled)': { color: v('text-primary'), background: v('disclosure-trigger-background-hover') },
490
+ '&.Mui-selected': { ...text('base-selected'), color: v('disclosure-selected-color') },
491
+ '&.Mui-focusVisible': k.focusRingInset,
492
+ '&.Mui-disabled': { color: v('text-disabled'), opacity: v('disclosure-opacity-disabled') },
493
+ },
494
+ },
495
+ },
496
+ MuiAccordion: {
497
+ defaultProps: { disableGutters: true, elevation: 0, square: true },
498
+ styleOverrides: {
499
+ root: {
500
+ background: 'transparent',
501
+ color: v('text-primary'),
502
+ borderBottom: `${v('stroke-default')} solid ${v('disclosure-divider-color')}`,
503
+ '&::before': { display: 'none' },
504
+ '&:last-of-type': { borderBottom: 'none' },
505
+ '&.Mui-disabled': { background: 'transparent' },
506
+ },
507
+ },
508
+ },
509
+ MuiAccordionSummary: {
510
+ styleOverrides: {
511
+ root: {
512
+ ...text('maximize-md'),
513
+ gap: v('space-4'),
514
+ minHeight: 0,
515
+ padding: `${v('space-4')} ${v('space-6')}`,
516
+ transition: `background-color ${v('disclosure-animation-duration')} ${v('disclosure-animation-easing')}`,
517
+ '&:hover:not(.Mui-disabled)': { background: v('disclosure-trigger-background-hover') },
518
+ '&:active:not(.Mui-disabled)': { background: v('disclosure-trigger-background-active') },
519
+ '&.Mui-focusVisible': { ...k.focusRingInset, background: 'transparent', borderRadius: v('radius-item') },
520
+ '&.Mui-disabled': { opacity: v('disclosure-opacity-disabled') },
521
+ },
522
+ content: { margin: 0, '&.Mui-expanded': { margin: 0 } },
523
+ expandIconWrapper: { color: v('disclosure-icon-color') },
524
+ },
525
+ },
526
+ MuiAccordionDetails: { styleOverrides: { root: { ...text('base'), padding: `0 ${v('space-6')} ${v('space-4')}`, color: v('disclosure-content-color') } } },
527
+
528
+ // Overlays.
529
+ MuiTooltip: {
530
+ styleOverrides: {
531
+ tooltip: {
532
+ ...text('condense'),
533
+ maxWidth: v('tooltip-max-width'),
534
+ padding: `${v('space-1-5')} ${v('space-2-5')}`,
535
+ borderRadius: v('radius-chrome'),
536
+ background: v('surface-inverse'),
537
+ color: v('text-on-inverse-surface'),
538
+ boxShadow: v('popup-shadow'),
539
+ },
540
+ arrow: { color: v('surface-inverse') },
541
+ },
542
+ },
543
+ MuiBackdrop: { styleOverrides: { root: { variants: [{ props: { invisible: false }, style: { background: v('modal-backdrop') } }] } } },
544
+ MuiDialog: {
545
+ styleOverrides: {
546
+ paper: { ...k.modalSurface, width: v('modal-width'), maxWidth: v('modal-max-width'), maxHeight: v('modal-max-height'), margin: v('space-4') },
547
+ },
548
+ },
549
+ MuiDialogTitle: { styleOverrides: { root: { ...text('maximize-xl'), padding: 0, color: v('modal-title-color') } } },
550
+ MuiDialogContent: { styleOverrides: { root: { padding: 0, '.MuiDialogTitle-root + &': { paddingTop: 0 } } } },
551
+ MuiDialogContentText: { styleOverrides: { root: { ...text('base'), color: v('modal-description-color') } } },
552
+ MuiDialogActions: {
553
+ styleOverrides: { root: { padding: 0, gap: v('modal-actions-gap'), marginTop: v('modal-actions-spacing'), '& > :not(style) ~ :not(style)': { marginLeft: 0 } } },
554
+ },
555
+ MuiDrawer: {
556
+ styleOverrides: { paper: { background: v('modal-background'), color: v('text-primary'), borderColor: v('modal-border-color'), boxShadow: v('modal-shadow'), backgroundImage: 'none' } },
557
+ },
558
+
559
+ // Feedback.
560
+ MuiSnackbarContent: { styleOverrides: { root: { ...k.popupSurface, ...text('base'), padding: v('notice-padding') } } },
561
+ MuiAlert: {
562
+ styleOverrides: {
563
+ root: {
564
+ ...text('base'),
565
+ padding: v('notice-padding'),
566
+ gap: v('notice-gap'),
567
+ borderRadius: v('popup-radius'),
568
+ border: `${v('popup-border-width')} solid transparent`,
569
+ '& .MuiAlert-icon': { padding: 0, marginRight: 0, opacity: 1, color: 'inherit' },
570
+ '& .MuiAlert-message': { padding: 0 },
571
+ '& .MuiAlert-action': { paddingTop: 0 },
572
+ variants: STATUS.filter((p) => scopes[p]).flatMap((s) => [
573
+ { props: { severity: s, variant: 'standard' }, style: { background: k.scope(s, 'soft'), borderColor: k.scope(s, 'border'), color: k.scope(s, 'on-soft') } },
574
+ { props: { severity: s, variant: 'outlined' }, style: { background: 'transparent', borderColor: k.scope(s, 'border'), color: k.scope(s, 'fg') } },
575
+ { props: { severity: s, variant: 'filled' }, style: { background: k.scope(s, 'solid'), borderColor: k.scope(s, 'solid'), color: k.scope(s, 'on-solid') } },
576
+ ]),
577
+ },
578
+ },
579
+ },
580
+ MuiAlertTitle: { styleOverrides: { root: { ...text('maximize-md'), margin: `0 0 ${v('notice-text-gap')}` } } },
581
+ MuiLinearProgress: {
582
+ styleOverrides: {
583
+ root: {
584
+ height: v('gauge-height'),
585
+ borderRadius: v('gauge-radius'),
586
+ background: v('gauge-track-color'),
587
+ variants: Object.keys(COLOR_SCOPE).map((c) => ({ props: { color: c }, style: { '--loomiere-fill': k.scope(gaugeColor(c) ?? 'accent', 'solid'), background: v('gauge-track-color') } })),
588
+ },
589
+ bar: { background: 'var(--loomiere-fill)', borderRadius: v('gauge-radius') },
590
+ },
591
+ },
592
+ MuiCircularProgress: {
593
+ styleOverrides: {
594
+ root: { variants: Object.keys(COLOR_SCOPE).map((c) => ({ props: { color: c }, style: { color: k.scope(gaugeColor(c) ?? 'accent', 'solid') } })) },
595
+ },
596
+ },
597
+
598
+ // Display.
599
+ MuiAvatar: {
600
+ styleOverrides: {
601
+ root: {
602
+ ...text('maximize-md'),
603
+ width: v('avatar-size-md'),
604
+ height: v('avatar-size-md'),
605
+ boxSizing: 'border-box',
606
+ border: `${v('stroke-emphasis')} solid ${v('surface-canvas')}`,
607
+ boxShadow: v('elevation-sm'),
608
+ textTransform: 'uppercase',
609
+ },
610
+ colorDefault: { background: v('scope-solid'), color: v('scope-on-solid') },
611
+ },
612
+ },
613
+ MuiAvatarGroup: { styleOverrides: { avatar: { border: `${v('stroke-emphasis')} solid ${v('surface-canvas')}`, boxSizing: 'border-box' } } },
614
+ MuiTableCell: {
615
+ styleOverrides: {
616
+ root: { ...text('condense'), color: v('table-color'), borderBottom: `${v('stroke-default')} solid ${v('table-row-border-color')}`, paddingInline: v('table-cell-padding-inline') },
617
+ head: { ...text('base-label'), background: v('table-header-background'), color: v('table-header-color') },
618
+ },
619
+ },
620
+ MuiTableRow: { styleOverrides: { root: { '&.MuiTableRow-hover:hover': { background: v('table-row-hover') }, '&.Mui-selected, &.Mui-selected:hover': { background: v('table-row-selected') } } } },
621
+ };
622
+
623
+ const options = {
624
+ cssVariables: { colorSchemeSelector: 'data-theme' },
625
+ colorSchemes: { light: { palette: palette('light') }, dark: { palette: palette('dark') } },
626
+ shape: { borderRadius: px(theme.radii.md) ?? 6 },
627
+ spacing: px(theme.space['1']) ?? 4,
628
+ typography: {
629
+ fontFamily: theme.fonts.body,
630
+ ...Object.fromEntries(Object.entries(TYPE).map(([variant, style]) => [variant, text(style)])),
631
+ button: { ...text('base-trigger'), textTransform: 'none' },
632
+ },
633
+ components,
634
+ };
635
+
636
+ const js = `/**
637
+ * MUI theme options for "${theme.brand}". Generated by loomiere build; do not edit.
638
+ * Works with @mui/material 6, 7 and 9. Load the theme's tokens.css as well: the component
639
+ * overrides read its --${theme.prefix ?? 'sl'}- variables, so they match loomiere-base-ui in light and dark.
640
+ *
641
+ * import './tokens.css';
642
+ * import { createTheme, ThemeProvider } from '@mui/material/styles';
643
+ * import options from './mui.js';
644
+ * const theme = createTheme(options);
645
+ *
646
+ * Dark mode: MUI writes data-theme="dark" on <html>, which tokens.css also follows.
647
+ * Button variants are the brand's emphasis levels (contained = bold, outlined = standard,
648
+ * text = quiet); color="primary" keeps each level's own color, other colors re-color it.
649
+ */
650
+ import { createElement as h } from 'react';
651
+
652
+ ${ICONS}
653
+
654
+ const options = ${toSource(options)};
655
+
656
+ export default options;
657
+ `;
658
+ const dts = `/** MUI theme options for "${theme.brand}". Generated by loomiere build; do not edit. */
659
+ import type { ThemeOptions } from '@mui/material/styles';
660
+
661
+ declare const options: ThemeOptions;
662
+ export default options;
663
+ `;
664
+ return { 'mui.js': js, 'mui.d.ts': dts };
665
+ },
666
+ };