@micha.bigler/ui-core-micha 2.29.2 → 2.31.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/DESIGN.md +5 -5
- package/WORK_ORDERS.md +6 -0
- package/dev/entries.jsx +167 -1
- package/dev/main.jsx +10 -3
- package/dist/components/charts/BarChart.js +8 -6
- package/dist/components/charts/LineChart.js +23 -6
- package/dist/components/charts/TimeSeriesChart.js +12 -3
- package/dist/components/charts/chartDefaults.js +80 -0
- package/dist/components/charts/chartLabels.js +31 -0
- package/dist/components/charts/palette.js +7 -3
- package/dist/components/charts/yearTickInterval.js +7 -0
- package/dist/index.js +4 -0
- package/dist/theme/createAppTheme.js +168 -0
- package/dist/theme/index.js +4 -0
- package/dist/theme/themeCompleteness.js +320 -0
- package/dist/theme/tokens.js +325 -0
- package/package.json +4 -1
- package/src/components/charts/BarChart.jsx +37 -9
- package/src/components/charts/LineChart.jsx +61 -9
- package/src/components/charts/TimeSeriesChart.jsx +21 -0
- package/src/components/charts/chartDefaults.js +111 -0
- package/src/components/charts/chartLabels.js +36 -0
- package/src/components/charts/palette.js +6 -3
- package/src/components/charts/yearTickInterval.js +7 -0
- package/src/index.js +16 -0
- package/src/theme/createAppTheme.js +212 -0
- package/src/theme/index.js +14 -0
- package/src/theme/themeCompleteness.js +355 -0
- package/src/theme/tokens.js +376 -0
- package/tests/BarChart.test.jsx +4 -1
- package/tests/LineChart.test.jsx +5 -2
- package/tests/chartDefaults.test.js +177 -0
- package/tests/chartLabels.test.js +50 -0
- package/tests/chartsPalette.test.js +40 -0
- package/tests/createAppTheme.test.js +103 -0
- package/tests/themeCompleteness.test.js +63 -0
- package/tests/timeSeriesLegend.test.js +99 -0
- package/work-orders/CHART-6.md +428 -0
- package/work-orders/SHELL-2.md +197 -0
- package/work-orders/THEME-1.md +587 -0
- package/work-orders/assets/SHELL-2-account-navigation.html +1159 -0
- package/work-orders/assets/THEME-1-baseline-reference-sheet.html +1352 -0
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Short, granularity-aware x-axis tick labels.
|
|
2
|
+
//
|
|
3
|
+
// Not a style choice -- MUI X-Charts has a known, unfixed bug (mui-x#18768,
|
|
4
|
+
// duplicate of #18399): when the measured space for a tick label gets
|
|
5
|
+
// tight, its ellipsize() logic doesn't truncate the text, it collapses it
|
|
6
|
+
// to an empty string (the <tspan> stays in the DOM with no content). It
|
|
7
|
+
// only reproduces with long label text ("08/05, 10:00 AM"-style, ~16
|
|
8
|
+
// characters) combined with this app's actual font metrics (DM Sans) --
|
|
9
|
+
// short labels like "05.08." or "10:00" leave enough margin that the bug
|
|
10
|
+
// never triggers. Same fix already proven in jg-ferien's ActivitySection.
|
|
11
|
+
|
|
12
|
+
// `locale` defaults to "de-CH" only as a fallback for callers that don't
|
|
13
|
+
// pass the app's active i18n language (found in review: an earlier version
|
|
14
|
+
// hardcoded "de-CH" always, showing German month names to en/fr users).
|
|
15
|
+
|
|
16
|
+
export function formatShortTime(date, locale = 'de-CH') {
|
|
17
|
+
// hour12 forced false regardless of locale -- an English 12-hour label
|
|
18
|
+
// ("10:00 AM") is exactly the longer style that triggered the original
|
|
19
|
+
// collapse bug; staying short is the point, not locale fidelity here.
|
|
20
|
+
return date.toLocaleTimeString(locale, { hour: '2-digit', minute: '2-digit', hour12: false });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function formatShortDate(date, locale = 'de-CH') {
|
|
24
|
+
return date.toLocaleDateString(locale, { day: '2-digit', month: '2-digit' });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function formatShortMonth(date, locale = 'de-CH') {
|
|
28
|
+
return date.toLocaleDateString(locale, { month: 'short', year: '2-digit' });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// "All" spans years -- day/month labels repeat (e.g. every "01.04." shows up
|
|
32
|
+
// once per year) and read as meaningless noise; a bare year number is the
|
|
33
|
+
// only granularity dense multi-year history can show cleanly.
|
|
34
|
+
export function formatShortYear(date, locale = 'de-CH') {
|
|
35
|
+
return date.toLocaleDateString(locale, { year: 'numeric' });
|
|
36
|
+
}
|
|
@@ -7,11 +7,14 @@ import { useTheme } from '@mui/material/styles';
|
|
|
7
7
|
export function getNeutralChartPalette(theme) {
|
|
8
8
|
const palette = theme.palette;
|
|
9
9
|
const isDark = palette.mode === 'dark';
|
|
10
|
+
const dataSeries = palette.dataSeries?.categorical;
|
|
10
11
|
|
|
11
12
|
return {
|
|
12
|
-
categorical:
|
|
13
|
-
? [
|
|
14
|
-
:
|
|
13
|
+
categorical: Array.isArray(dataSeries) && dataSeries.length > 0
|
|
14
|
+
? [...dataSeries]
|
|
15
|
+
: isDark
|
|
16
|
+
? [palette.primary.light, palette.secondary.light, palette.info.light, palette.success.light, palette.warning.light]
|
|
17
|
+
: [palette.primary.main, palette.secondary.main, palette.info.main, palette.success.main, palette.warning.main],
|
|
15
18
|
sequential: isDark
|
|
16
19
|
? [palette.action.disabled, palette.text.secondary, palette.primary.light, palette.primary.main, palette.primary.dark]
|
|
17
20
|
: [palette.action.disabled, palette.divider, palette.text.secondary, palette.primary.light, palette.primary.main],
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Return one January tick for every calendar year represented in dates. */
|
|
2
|
+
export function yearTickInterval(dates) {
|
|
3
|
+
const years = new Set(dates.map((date) => date.getFullYear()));
|
|
4
|
+
return [...years]
|
|
5
|
+
.sort((first, second) => first - second)
|
|
6
|
+
.map((year) => new Date(year, 0, 1));
|
|
7
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
// index.js (Entry Point deiner Library)
|
|
2
2
|
|
|
3
|
+
// --- 0. Theme ---
|
|
4
|
+
export {
|
|
5
|
+
createAppTheme,
|
|
6
|
+
assertThemeComplete,
|
|
7
|
+
calculateContrastRatio,
|
|
8
|
+
reportThemeAdoption,
|
|
9
|
+
THEME_COMPLETENESS_SURFACES,
|
|
10
|
+
} from './theme';
|
|
11
|
+
|
|
3
12
|
// --- 1. Auth Context (Essentiell für den Wrapper) ---
|
|
4
13
|
export { AuthContext, AuthProvider } from './auth/AuthContext';
|
|
5
14
|
export { UserMenu } from './auth/UserMenu';
|
|
@@ -46,6 +55,13 @@ export { BarChart } from './components/charts/BarChart';
|
|
|
46
55
|
export { LineChart } from './components/charts/LineChart';
|
|
47
56
|
export { TimeSeriesChart } from './components/charts/TimeSeriesChart';
|
|
48
57
|
export { getNeutralChartPalette, useNeutralChartPalette } from './components/charts/palette';
|
|
58
|
+
export {
|
|
59
|
+
formatShortTime,
|
|
60
|
+
formatShortDate,
|
|
61
|
+
formatShortMonth,
|
|
62
|
+
formatShortYear,
|
|
63
|
+
} from './components/charts/chartLabels';
|
|
64
|
+
export { yearTickInterval } from './components/charts/yearTickInterval';
|
|
49
65
|
export {
|
|
50
66
|
formatPercentage,
|
|
51
67
|
formatCompact,
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import '@fontsource/dm-sans/400.css';
|
|
2
|
+
import '@fontsource/dm-sans/500.css';
|
|
3
|
+
import '@fontsource/dm-sans/600.css';
|
|
4
|
+
|
|
5
|
+
import { createTheme, darken, getContrastRatio } from '@mui/material/styles';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS,
|
|
9
|
+
BASELINE_PALETTE,
|
|
10
|
+
BASELINE_STATIC,
|
|
11
|
+
MOTION,
|
|
12
|
+
} from './tokens';
|
|
13
|
+
|
|
14
|
+
function findFunction(value, path) {
|
|
15
|
+
if (typeof value === 'function') return path;
|
|
16
|
+
if (!value || typeof value !== 'object') return null;
|
|
17
|
+
|
|
18
|
+
for (const [key, child] of Object.entries(value)) {
|
|
19
|
+
const found = findFunction(child, `${path}.${key}`);
|
|
20
|
+
if (found) return found;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function validateStyleOverrides(components = {}) {
|
|
26
|
+
for (const [componentName, component] of Object.entries(components)) {
|
|
27
|
+
if (!component?.styleOverrides) continue;
|
|
28
|
+
const slot = findFunction(
|
|
29
|
+
component.styleOverrides,
|
|
30
|
+
`components.${componentName}.styleOverrides`,
|
|
31
|
+
);
|
|
32
|
+
if (slot) {
|
|
33
|
+
throw new TypeError(
|
|
34
|
+
`createAppTheme: ${slot} must be an object, not a function. ` +
|
|
35
|
+
'Use object styleOverrides and MUI variants for prop- or state-dependent styles.',
|
|
36
|
+
);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function clearsContrast(colour, surfaces) {
|
|
42
|
+
return surfaces.every((surface) => getContrastRatio(colour, surface) >= 3);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Darkened until it clears 3:1 against BOTH '#FFFFFF' and background.default
|
|
46
|
+
// -- the exact two surfaces assertThemeComplete's contrastFindings checks --
|
|
47
|
+
// so deriving against background.paper alone (which need not be white for an
|
|
48
|
+
// app that overrides it) could pass here and still fail there.
|
|
49
|
+
function deriveFocusColour(primary, surfaces) {
|
|
50
|
+
let focus = primary;
|
|
51
|
+
for (let coefficient = 0.05; !clearsContrast(focus, surfaces); coefficient += 0.05) {
|
|
52
|
+
focus = darken(primary, Math.min(coefficient, 0.9));
|
|
53
|
+
if (coefficient >= 0.9) break;
|
|
54
|
+
}
|
|
55
|
+
return focus;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function createPaletteAwareComponents(palette) {
|
|
59
|
+
const stateTransition = `border-color ${MOTION.duration.fast}ms ${MOTION.easing.state}, color ${MOTION.duration.fast}ms ${MOTION.easing.state}, background-color ${MOTION.duration.fast}ms ${MOTION.easing.state}`;
|
|
60
|
+
const autofill = {
|
|
61
|
+
'&:-webkit-autofill': {
|
|
62
|
+
WebkitBoxShadow: `0 0 0 100px ${palette.background.paper} inset`,
|
|
63
|
+
WebkitTextFillColor: palette.ink.primary,
|
|
64
|
+
caretColor: palette.ink.primary,
|
|
65
|
+
},
|
|
66
|
+
'&:autofill': {
|
|
67
|
+
boxShadow: `0 0 0 100px ${palette.background.paper} inset`,
|
|
68
|
+
WebkitTextFillColor: palette.ink.primary,
|
|
69
|
+
caretColor: palette.ink.primary,
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
const alertStyles = Object.fromEntries(
|
|
73
|
+
['success', 'warning', 'error', 'info'].map((status) => [
|
|
74
|
+
`standard${status[0].toUpperCase()}${status.slice(1)}`,
|
|
75
|
+
{
|
|
76
|
+
color: palette[status].text,
|
|
77
|
+
backgroundColor: palette[status].bg,
|
|
78
|
+
'& .MuiAlert-icon': { color: palette[status].text },
|
|
79
|
+
},
|
|
80
|
+
]),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
MuiButton: {
|
|
85
|
+
styleOverrides: {
|
|
86
|
+
root: { transition: stateTransition },
|
|
87
|
+
outlined: {
|
|
88
|
+
borderColor: palette.controlBorder.main,
|
|
89
|
+
'&:hover': { borderColor: palette.controlBorder.hover },
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
MuiDivider: {
|
|
94
|
+
styleOverrides: { root: { borderColor: palette.divider } },
|
|
95
|
+
},
|
|
96
|
+
MuiCheckbox: {
|
|
97
|
+
styleOverrides: {
|
|
98
|
+
root: {
|
|
99
|
+
color: palette.controlBorder.main,
|
|
100
|
+
transition: stateTransition,
|
|
101
|
+
'&:hover': { color: palette.controlBorder.hover },
|
|
102
|
+
'&.Mui-checked': { color: palette.primary.main },
|
|
103
|
+
'&.MuiCheckbox-indeterminate': { color: palette.primary.main },
|
|
104
|
+
'&.Mui-error': { color: palette.controlBorder.error },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
MuiPaper: {
|
|
109
|
+
styleOverrides: { root: { borderColor: palette.divider } },
|
|
110
|
+
},
|
|
111
|
+
MuiCard: {
|
|
112
|
+
styleOverrides: { root: { borderColor: palette.divider } },
|
|
113
|
+
},
|
|
114
|
+
MuiChip: {
|
|
115
|
+
styleOverrides: {
|
|
116
|
+
outlined: { borderColor: palette.controlBorder.main },
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
MuiOutlinedInput: {
|
|
120
|
+
styleOverrides: {
|
|
121
|
+
root: {
|
|
122
|
+
transition: stateTransition,
|
|
123
|
+
'& .MuiOutlinedInput-notchedOutline': {
|
|
124
|
+
borderColor: palette.controlBorder.main,
|
|
125
|
+
},
|
|
126
|
+
'&:hover .MuiOutlinedInput-notchedOutline': {
|
|
127
|
+
borderColor: palette.controlBorder.hover,
|
|
128
|
+
},
|
|
129
|
+
'&.Mui-focused .MuiOutlinedInput-notchedOutline': {
|
|
130
|
+
borderColor: palette.controlBorder.focus,
|
|
131
|
+
borderWidth: 2,
|
|
132
|
+
},
|
|
133
|
+
'&.Mui-error .MuiOutlinedInput-notchedOutline': {
|
|
134
|
+
borderColor: palette.controlBorder.error,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
input: autofill,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
MuiFilledInput: {
|
|
141
|
+
styleOverrides: { input: autofill },
|
|
142
|
+
},
|
|
143
|
+
MuiInput: {
|
|
144
|
+
styleOverrides: { input: autofill },
|
|
145
|
+
},
|
|
146
|
+
MuiAlert: {
|
|
147
|
+
styleOverrides: alertStyles,
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Builds the shared application theme while leaving app identity in primary
|
|
154
|
+
* and fontFamily. Palette resolution happens before palette-aware component
|
|
155
|
+
* objects are created, so runtime app/site overrides cannot leave stale values.
|
|
156
|
+
*/
|
|
157
|
+
export function createAppTheme(appConfig = {}) {
|
|
158
|
+
if (!appConfig?.palette?.primary) {
|
|
159
|
+
throw new TypeError('createAppTheme: appConfig.palette.primary is required.');
|
|
160
|
+
}
|
|
161
|
+
validateStyleOverrides(appConfig.components);
|
|
162
|
+
|
|
163
|
+
const paletteTheme = createTheme(
|
|
164
|
+
{ palette: appConfig.palette },
|
|
165
|
+
{ palette: BASELINE_PALETTE },
|
|
166
|
+
{ palette: appConfig.palette },
|
|
167
|
+
);
|
|
168
|
+
const finalPalette = paletteTheme.palette;
|
|
169
|
+
const computedPalette = {
|
|
170
|
+
controlBorder: {
|
|
171
|
+
...finalPalette.controlBorder,
|
|
172
|
+
focus: deriveFocusColour(finalPalette.primary.main, [
|
|
173
|
+
'#FFFFFF',
|
|
174
|
+
finalPalette.background.default,
|
|
175
|
+
]),
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
const resolvedPalette = { ...finalPalette, ...computedPalette };
|
|
179
|
+
const fontFamily = appConfig.typography?.fontFamily || BASELINE_STATIC.typography.fontFamily;
|
|
180
|
+
|
|
181
|
+
return createTheme(
|
|
182
|
+
{
|
|
183
|
+
...BASELINE_STATIC,
|
|
184
|
+
typography: {
|
|
185
|
+
...BASELINE_STATIC.typography,
|
|
186
|
+
fontFamily,
|
|
187
|
+
},
|
|
188
|
+
fontLoading: {
|
|
189
|
+
...BASELINE_STATIC.fontLoading,
|
|
190
|
+
weights: [...BASELINE_STATIC.fontLoading.weights],
|
|
191
|
+
},
|
|
192
|
+
palette: resolvedPalette,
|
|
193
|
+
themeCompleteness: {
|
|
194
|
+
baseline: true,
|
|
195
|
+
exemptions: BASELINE_INTENTIONAL_DEFAULT_EXEMPTIONS.map((exemption) => ({
|
|
196
|
+
...exemption,
|
|
197
|
+
})),
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
components: createPaletteAwareComponents(resolvedPalette),
|
|
202
|
+
},
|
|
203
|
+
appConfig,
|
|
204
|
+
{
|
|
205
|
+
palette: {
|
|
206
|
+
dataSeries: {
|
|
207
|
+
categorical: [...resolvedPalette.dataSeries.categorical],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
},
|
|
211
|
+
);
|
|
212
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export { createAppTheme } from './createAppTheme';
|
|
2
|
+
export {
|
|
3
|
+
assertThemeComplete,
|
|
4
|
+
calculateContrastRatio,
|
|
5
|
+
reportThemeAdoption,
|
|
6
|
+
THEME_COMPLETENESS_SURFACES,
|
|
7
|
+
} from './themeCompleteness';
|
|
8
|
+
export {
|
|
9
|
+
formatShortTime,
|
|
10
|
+
formatShortDate,
|
|
11
|
+
formatShortMonth,
|
|
12
|
+
formatShortYear,
|
|
13
|
+
} from '../components/charts/chartLabels';
|
|
14
|
+
export { yearTickInterval } from '../components/charts/yearTickInterval';
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
import { createTheme, getContrastRatio as getMuiContrastRatio } from '@mui/material/styles';
|
|
2
|
+
|
|
3
|
+
import { STATUS_KEYS, TYPOGRAPHY_VARIANTS } from './tokens';
|
|
4
|
+
|
|
5
|
+
const MUI_DEFAULT_THEME = createTheme();
|
|
6
|
+
|
|
7
|
+
function getPath(value, path) {
|
|
8
|
+
return path.split('.').reduce((current, key) => current?.[key], value);
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function sameValue(left, right) {
|
|
12
|
+
if (left === right) return true;
|
|
13
|
+
if (left === undefined || right === undefined) return false;
|
|
14
|
+
return JSON.stringify(left) === JSON.stringify(right);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const pathSurface = (surface, path = surface) => ({
|
|
18
|
+
surface,
|
|
19
|
+
get: (theme) => getPath(theme, path),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
const keyedSurface = (surface, get) => ({ surface, get });
|
|
23
|
+
|
|
24
|
+
const STATUS_SURFACES = STATUS_KEYS.flatMap((status) =>
|
|
25
|
+
['text', 'fill', 'fillText', 'bg'].map((token) =>
|
|
26
|
+
pathSurface(`palette.${status}.${token}`),
|
|
27
|
+
),
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const TYPOGRAPHY_SURFACES = TYPOGRAPHY_VARIANTS.flatMap((variant) =>
|
|
31
|
+
['fontSize', 'fontWeight', 'lineHeight', 'letterSpacing'].map((token) =>
|
|
32
|
+
pathSurface(`typography.${variant}.${token}`),
|
|
33
|
+
),
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const componentPath = (component, path) =>
|
|
37
|
+
pathSurface(`components.${component}.${path}`);
|
|
38
|
+
|
|
39
|
+
const componentKeyLeaf = (component, path, key, leaf) => keyedSurface(
|
|
40
|
+
`components.${component}.${path}.${key}.${leaf}`,
|
|
41
|
+
(theme) => getPath(theme, `components.${component}.${path}`)?.[key]?.[leaf],
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const COMPONENT_SURFACES = [
|
|
45
|
+
keyedSurface(
|
|
46
|
+
'components.MuiCssBaseline.reduced-motion.transitionDuration',
|
|
47
|
+
(theme) => theme.components?.MuiCssBaseline?.styleOverrides
|
|
48
|
+
?.['@media (prefers-reduced-motion: reduce)']
|
|
49
|
+
?.['*, *::before, *::after']?.transitionDuration,
|
|
50
|
+
),
|
|
51
|
+
componentPath('MuiButton', 'defaultProps.disableElevation'),
|
|
52
|
+
componentPath('MuiButton', 'styleOverrides.root.minHeight'),
|
|
53
|
+
componentPath('MuiButton', 'styleOverrides.root.borderRadius'),
|
|
54
|
+
componentPath('MuiButton', 'styleOverrides.root.textTransform'),
|
|
55
|
+
componentKeyLeaf('MuiButton', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minHeight'),
|
|
56
|
+
componentKeyLeaf('MuiButton', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minWidth'),
|
|
57
|
+
componentPath('MuiButton', 'styleOverrides.contained.boxShadow'),
|
|
58
|
+
componentPath('MuiButton', 'styleOverrides.outlined.borderColor'),
|
|
59
|
+
componentPath('MuiTableCell', 'styleOverrides.root.padding'),
|
|
60
|
+
componentPath('MuiIconButton', 'styleOverrides.root.minHeight'),
|
|
61
|
+
componentPath('MuiIconButton', 'styleOverrides.root.minWidth'),
|
|
62
|
+
componentKeyLeaf('MuiIconButton', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minHeight'),
|
|
63
|
+
componentKeyLeaf('MuiIconButton', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minWidth'),
|
|
64
|
+
componentPath('MuiDivider', 'styleOverrides.root.borderColor'),
|
|
65
|
+
componentPath('MuiTooltip', 'styleOverrides.tooltip.boxShadow'),
|
|
66
|
+
componentPath('MuiTooltip', 'styleOverrides.tooltip.transitionDuration'),
|
|
67
|
+
componentPath('MuiTooltip', 'defaultProps.slotProps.transition.timeout.enter'),
|
|
68
|
+
componentPath('MuiTooltip', 'defaultProps.slotProps.transition.timeout.exit'),
|
|
69
|
+
componentPath('MuiSelect', 'defaultProps.variant'),
|
|
70
|
+
componentPath('MuiSelect', 'styleOverrides.select.minHeight'),
|
|
71
|
+
componentKeyLeaf('MuiSelect', 'styleOverrides.select', '@media (any-pointer: coarse)', 'minHeight'),
|
|
72
|
+
componentPath('MuiCheckbox', 'styleOverrides.root.color'),
|
|
73
|
+
componentKeyLeaf('MuiCheckbox', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minHeight'),
|
|
74
|
+
componentKeyLeaf('MuiCheckbox', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minWidth'),
|
|
75
|
+
componentPath('MuiFormControlLabel', 'styleOverrides.root.minHeight'),
|
|
76
|
+
componentKeyLeaf('MuiFormControlLabel', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minHeight'),
|
|
77
|
+
componentKeyLeaf('MuiFormControlLabel', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minWidth'),
|
|
78
|
+
componentPath('MuiFormControlLabel', 'styleOverrides.label.fontSize'),
|
|
79
|
+
componentPath('MuiContainer', 'styleOverrides.root.paddingLeft'),
|
|
80
|
+
componentPath('MuiContainer', 'styleOverrides.root.paddingRight'),
|
|
81
|
+
componentKeyLeaf('MuiContainer', 'styleOverrides.root', '@media (min-width:1200px)', 'paddingLeft'),
|
|
82
|
+
componentKeyLeaf('MuiContainer', 'styleOverrides.root', '@media (min-width:1200px)', 'paddingRight'),
|
|
83
|
+
componentPath('MuiPaper', 'defaultProps.elevation'),
|
|
84
|
+
componentPath('MuiPaper', 'defaultProps.variant'),
|
|
85
|
+
componentPath('MuiPaper', 'styleOverrides.root.borderRadius'),
|
|
86
|
+
componentPath('MuiPaper', 'styleOverrides.root.boxShadow'),
|
|
87
|
+
componentPath('MuiCard', 'defaultProps.elevation'),
|
|
88
|
+
componentPath('MuiCard', 'defaultProps.variant'),
|
|
89
|
+
componentPath('MuiCard', 'styleOverrides.root.borderRadius'),
|
|
90
|
+
componentPath('MuiCard', 'styleOverrides.root.boxShadow'),
|
|
91
|
+
componentPath('MuiChip', 'styleOverrides.root.height'),
|
|
92
|
+
componentPath('MuiChip', 'styleOverrides.root.borderRadius'),
|
|
93
|
+
componentPath('MuiChip', 'styleOverrides.root.fontSize'),
|
|
94
|
+
keyedSurface(
|
|
95
|
+
'components.MuiChip.styleOverrides.root.coarse-hit-area.inset',
|
|
96
|
+
(theme) => theme.components?.MuiChip?.styleOverrides?.root
|
|
97
|
+
?.['@media (any-pointer: coarse)']?.['&::after']?.inset,
|
|
98
|
+
),
|
|
99
|
+
componentPath('MuiTextField', 'defaultProps.variant'),
|
|
100
|
+
componentPath('MuiTextField', 'styleOverrides.root.minHeight'),
|
|
101
|
+
componentKeyLeaf('MuiTextField', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minHeight'),
|
|
102
|
+
componentKeyLeaf('MuiTextField', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minWidth'),
|
|
103
|
+
componentPath('MuiOutlinedInput', 'styleOverrides.root.minHeight'),
|
|
104
|
+
componentPath('MuiOutlinedInput', 'styleOverrides.root.borderRadius'),
|
|
105
|
+
keyedSurface(
|
|
106
|
+
'components.MuiOutlinedInput.variants.non-multiline-height',
|
|
107
|
+
(theme) => theme.components?.MuiOutlinedInput?.variants
|
|
108
|
+
?.find(({ props }) => props?.multiline === false)?.style?.height,
|
|
109
|
+
),
|
|
110
|
+
componentKeyLeaf('MuiOutlinedInput', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minHeight'),
|
|
111
|
+
componentKeyLeaf('MuiOutlinedInput', 'styleOverrides.root', '@media (any-pointer: coarse)', 'minWidth'),
|
|
112
|
+
componentKeyLeaf('MuiOutlinedInput', 'styleOverrides.root', '& .MuiOutlinedInput-notchedOutline', 'borderColor'),
|
|
113
|
+
componentKeyLeaf('MuiOutlinedInput', 'styleOverrides.root', '&:hover .MuiOutlinedInput-notchedOutline', 'borderColor'),
|
|
114
|
+
componentKeyLeaf('MuiOutlinedInput', 'styleOverrides.root', '&.Mui-focused .MuiOutlinedInput-notchedOutline', 'borderColor'),
|
|
115
|
+
componentKeyLeaf('MuiOutlinedInput', 'styleOverrides.root', '&.Mui-error .MuiOutlinedInput-notchedOutline', 'borderColor'),
|
|
116
|
+
componentPath('MuiDialog', 'defaultProps.transitionDuration.enter'),
|
|
117
|
+
componentPath('MuiDialog', 'defaultProps.transitionDuration.exit'),
|
|
118
|
+
componentPath('MuiDialog', 'styleOverrides.paper.borderRadius'),
|
|
119
|
+
componentPath('MuiDialog', 'styleOverrides.paper.boxShadow'),
|
|
120
|
+
componentPath('MuiDrawer', 'styleOverrides.paper.boxShadow'),
|
|
121
|
+
componentPath('MuiMenu', 'styleOverrides.paper.boxShadow'),
|
|
122
|
+
...['success', 'warning', 'error', 'info'].flatMap((status) => {
|
|
123
|
+
const slot = `standard${status[0].toUpperCase()}${status.slice(1)}`;
|
|
124
|
+
return [
|
|
125
|
+
componentPath('MuiAlert', `styleOverrides.${slot}.color`),
|
|
126
|
+
componentPath('MuiAlert', `styleOverrides.${slot}.backgroundColor`),
|
|
127
|
+
componentKeyLeaf('MuiAlert', `styleOverrides.${slot}`, '& .MuiAlert-icon', 'color'),
|
|
128
|
+
];
|
|
129
|
+
}),
|
|
130
|
+
...['MuiOutlinedInput', 'MuiFilledInput', 'MuiInput'].flatMap((component) => [
|
|
131
|
+
componentKeyLeaf(component, 'styleOverrides.input', '&:-webkit-autofill', 'WebkitBoxShadow'),
|
|
132
|
+
componentKeyLeaf(component, 'styleOverrides.input', '&:-webkit-autofill', 'WebkitTextFillColor'),
|
|
133
|
+
componentKeyLeaf(component, 'styleOverrides.input', '&:autofill', 'boxShadow'),
|
|
134
|
+
componentKeyLeaf(component, 'styleOverrides.input', '&:autofill', 'WebkitTextFillColor'),
|
|
135
|
+
]),
|
|
136
|
+
];
|
|
137
|
+
|
|
138
|
+
/** The versioned surface inventory used by every adopting application. */
|
|
139
|
+
export const THEME_COMPLETENESS_SURFACES = [
|
|
140
|
+
pathSurface('palette.primary.main'),
|
|
141
|
+
...STATUS_SURFACES,
|
|
142
|
+
pathSurface('palette.ink.primary'),
|
|
143
|
+
pathSurface('palette.ink.secondary'),
|
|
144
|
+
pathSurface('palette.ink.muted'),
|
|
145
|
+
pathSurface('palette.background.default'),
|
|
146
|
+
pathSurface('palette.background.paper'),
|
|
147
|
+
pathSurface('palette.divider'),
|
|
148
|
+
pathSurface('palette.controlBorder.main'),
|
|
149
|
+
pathSurface('palette.controlBorder.hover'),
|
|
150
|
+
pathSurface('palette.controlBorder.focus'),
|
|
151
|
+
pathSurface('palette.controlBorder.error'),
|
|
152
|
+
pathSurface('palette.dataSeries.categorical'),
|
|
153
|
+
pathSurface('radius.control'),
|
|
154
|
+
pathSurface('radius.card'),
|
|
155
|
+
pathSurface('typography.fontFamily'),
|
|
156
|
+
...TYPOGRAPHY_SURFACES,
|
|
157
|
+
pathSurface('shape.borderRadius'),
|
|
158
|
+
{
|
|
159
|
+
surface: 'spacing.unit',
|
|
160
|
+
get: (theme) => (typeof theme.spacing === 'function' ? theme.spacing(1) : undefined),
|
|
161
|
+
},
|
|
162
|
+
pathSurface('breakpoints.values'),
|
|
163
|
+
pathSurface('shadow.rest'),
|
|
164
|
+
pathSurface('shadow.overlay'),
|
|
165
|
+
pathSurface('motion.duration.fast'),
|
|
166
|
+
pathSurface('motion.duration.base'),
|
|
167
|
+
pathSurface('motion.duration.overlayEnter'),
|
|
168
|
+
pathSurface('motion.duration.overlayExit'),
|
|
169
|
+
pathSurface('motion.duration.chart'),
|
|
170
|
+
pathSurface('motion.easing.enter'),
|
|
171
|
+
pathSurface('motion.easing.exit'),
|
|
172
|
+
pathSurface('motion.easing.state'),
|
|
173
|
+
pathSurface('motion.reduced'),
|
|
174
|
+
pathSurface('fontLoading.family'),
|
|
175
|
+
pathSurface('fontLoading.weights'),
|
|
176
|
+
...COMPONENT_SURFACES,
|
|
177
|
+
];
|
|
178
|
+
|
|
179
|
+
function parseColour(colour) {
|
|
180
|
+
if (typeof colour !== 'string') return null;
|
|
181
|
+
const value = colour.trim();
|
|
182
|
+
if (value.startsWith('#')) {
|
|
183
|
+
const hex = value.slice(1);
|
|
184
|
+
const expanded = hex.length === 3
|
|
185
|
+
? hex.split('').map((part) => part + part).join('')
|
|
186
|
+
: hex;
|
|
187
|
+
if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(expanded)) return null;
|
|
188
|
+
return {
|
|
189
|
+
r: parseInt(expanded.slice(0, 2), 16),
|
|
190
|
+
g: parseInt(expanded.slice(2, 4), 16),
|
|
191
|
+
b: parseInt(expanded.slice(4, 6), 16),
|
|
192
|
+
a: expanded.length === 8 ? parseInt(expanded.slice(6, 8), 16) / 255 : 1,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const match = value.match(/^rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/i);
|
|
197
|
+
if (!match) return null;
|
|
198
|
+
return {
|
|
199
|
+
r: Number(match[1]),
|
|
200
|
+
g: Number(match[2]),
|
|
201
|
+
b: Number(match[3]),
|
|
202
|
+
a: match[4] === undefined ? 1 : Number(match[4]),
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function composite(foreground, background) {
|
|
207
|
+
const alpha = foreground.a + background.a * (1 - foreground.a);
|
|
208
|
+
if (alpha === 0) return { r: 0, g: 0, b: 0, a: 0 };
|
|
209
|
+
return {
|
|
210
|
+
r: (foreground.r * foreground.a + background.r * background.a * (1 - foreground.a)) / alpha,
|
|
211
|
+
g: (foreground.g * foreground.a + background.g * background.a * (1 - foreground.a)) / alpha,
|
|
212
|
+
b: (foreground.b * foreground.a + background.b * background.a * (1 - foreground.a)) / alpha,
|
|
213
|
+
a: alpha,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function luminance(colour) {
|
|
218
|
+
const channels = [colour.r, colour.g, colour.b].map((channel) => {
|
|
219
|
+
const value = channel / 255;
|
|
220
|
+
return value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
221
|
+
});
|
|
222
|
+
return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Calculates WCAG contrast, including alpha compositing over the background. */
|
|
226
|
+
export function calculateContrastRatio(foreground, background) {
|
|
227
|
+
const foregroundColour = parseColour(foreground);
|
|
228
|
+
const backgroundColour = parseColour(background);
|
|
229
|
+
if (!foregroundColour || !backgroundColour) {
|
|
230
|
+
try {
|
|
231
|
+
const ratio = getMuiContrastRatio(foreground, background);
|
|
232
|
+
return Number.isFinite(ratio) ? ratio : Number.NaN;
|
|
233
|
+
} catch {
|
|
234
|
+
return Number.NaN;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const opaqueBackground = backgroundColour.a < 1
|
|
239
|
+
? composite(backgroundColour, { r: 255, g: 255, b: 255, a: 1 })
|
|
240
|
+
: backgroundColour;
|
|
241
|
+
const opaqueForeground = foregroundColour.a < 1
|
|
242
|
+
? composite(foregroundColour, opaqueBackground)
|
|
243
|
+
: foregroundColour;
|
|
244
|
+
const light = Math.max(luminance(opaqueForeground), luminance(opaqueBackground));
|
|
245
|
+
const dark = Math.min(luminance(opaqueForeground), luminance(opaqueBackground));
|
|
246
|
+
return (light + 0.05) / (dark + 0.05);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function contrastFindings(theme) {
|
|
250
|
+
const findings = [];
|
|
251
|
+
for (const status of STATUS_KEYS) {
|
|
252
|
+
const tokens = theme.palette?.[status];
|
|
253
|
+
if (!tokens) continue;
|
|
254
|
+
const textRatio = calculateContrastRatio(tokens.text, tokens.bg);
|
|
255
|
+
if (!Number.isFinite(textRatio) || textRatio < 4.5) {
|
|
256
|
+
findings.push({
|
|
257
|
+
surface: `contrast.${status}.text-on-bg`,
|
|
258
|
+
reason: Number.isFinite(textRatio)
|
|
259
|
+
? `Expected at least 4.5:1; received ${textRatio.toFixed(2)}:1.`
|
|
260
|
+
: 'Contrast could not be calculated from the declared colours.',
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
const fillRatio = calculateContrastRatio(tokens.fillText, tokens.fill);
|
|
264
|
+
if (!Number.isFinite(fillRatio) || fillRatio < 4.5) {
|
|
265
|
+
findings.push({
|
|
266
|
+
surface: `contrast.${status}.fillText-on-fill`,
|
|
267
|
+
reason: Number.isFinite(fillRatio)
|
|
268
|
+
? `Expected at least 4.5:1; received ${fillRatio.toFixed(2)}:1.`
|
|
269
|
+
: 'Contrast could not be calculated from the declared colours.',
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const backgrounds = {
|
|
275
|
+
white: '#FFFFFF',
|
|
276
|
+
page: theme.palette?.background?.default,
|
|
277
|
+
};
|
|
278
|
+
for (const state of ['main', 'hover', 'focus', 'error']) {
|
|
279
|
+
const colour = theme.palette?.controlBorder?.[state];
|
|
280
|
+
if (!colour) continue;
|
|
281
|
+
for (const [surface, background] of Object.entries(backgrounds)) {
|
|
282
|
+
const ratio = calculateContrastRatio(colour, background);
|
|
283
|
+
if (!Number.isFinite(ratio) || ratio < 3) {
|
|
284
|
+
findings.push({
|
|
285
|
+
surface: `contrast.controlBorder.${state}-on-${surface}`,
|
|
286
|
+
reason: Number.isFinite(ratio)
|
|
287
|
+
? `Expected at least 3:1; received ${ratio.toFixed(2)}:1.`
|
|
288
|
+
: 'Contrast could not be calculated from the declared colours.',
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
return findings;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Returns completeness findings. Adopting applications make this a hard CI
|
|
298
|
+
* assertion in their own suite; non-adopters may call it for reporting only.
|
|
299
|
+
*/
|
|
300
|
+
export function assertThemeComplete(theme, { exemptions = [] } = {}) {
|
|
301
|
+
const declaredExemptions = [
|
|
302
|
+
...(theme?.themeCompleteness?.exemptions || []),
|
|
303
|
+
...exemptions,
|
|
304
|
+
];
|
|
305
|
+
const validExemptions = new Set();
|
|
306
|
+
const findings = [];
|
|
307
|
+
|
|
308
|
+
for (const exemption of declaredExemptions) {
|
|
309
|
+
if (!exemption?.surface || !exemption?.reason?.trim()) {
|
|
310
|
+
findings.push({
|
|
311
|
+
surface: `exemption.${exemption?.surface || 'unknown'}`,
|
|
312
|
+
reason: 'A completeness exemption must name a surface and include a reason.',
|
|
313
|
+
});
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
validExemptions.add(exemption.surface);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
for (const descriptor of THEME_COMPLETENESS_SURFACES) {
|
|
320
|
+
const value = descriptor.get(theme);
|
|
321
|
+
const defaultValue = descriptor.get(MUI_DEFAULT_THEME);
|
|
322
|
+
if ((value === undefined || sameValue(value, defaultValue)) && !validExemptions.has(descriptor.surface)) {
|
|
323
|
+
findings.push({
|
|
324
|
+
surface: descriptor.surface,
|
|
325
|
+
reason: value === undefined
|
|
326
|
+
? 'The surface is not deliberately defined.'
|
|
327
|
+
: 'The surface still equals MUI\'s untouched default.',
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
findings.push(...contrastFindings(theme));
|
|
333
|
+
return { findings };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Reports adoption signals as numbers; it never changes completeness. */
|
|
337
|
+
export function reportThemeAdoption(sources = []) {
|
|
338
|
+
const normalized = sources.map((entry, index) =>
|
|
339
|
+
typeof entry === 'string'
|
|
340
|
+
? { path: `source-${index + 1}`, source: entry }
|
|
341
|
+
: { path: entry.path || `source-${index + 1}`, source: entry.source || '' },
|
|
342
|
+
);
|
|
343
|
+
const themeBypassPaths = normalized
|
|
344
|
+
.filter(({ source }) => /\bcreateTheme\s*\(/.test(source) && !/\bcreateAppTheme\s*\(/.test(source))
|
|
345
|
+
.map(({ path }) => path);
|
|
346
|
+
const inlineHexCount = normalized.reduce(
|
|
347
|
+
(count, { source }) => count + (source.match(/#[\da-f]{3,8}\b/gi)?.length || 0),
|
|
348
|
+
0,
|
|
349
|
+
);
|
|
350
|
+
return {
|
|
351
|
+
inlineHexCount,
|
|
352
|
+
themeBypassPathCount: themeBypassPaths.length,
|
|
353
|
+
themeBypassPaths,
|
|
354
|
+
};
|
|
355
|
+
}
|