@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.
Files changed (42) hide show
  1. package/DESIGN.md +5 -5
  2. package/WORK_ORDERS.md +6 -0
  3. package/dev/entries.jsx +167 -1
  4. package/dev/main.jsx +10 -3
  5. package/dist/components/charts/BarChart.js +8 -6
  6. package/dist/components/charts/LineChart.js +23 -6
  7. package/dist/components/charts/TimeSeriesChart.js +12 -3
  8. package/dist/components/charts/chartDefaults.js +80 -0
  9. package/dist/components/charts/chartLabels.js +31 -0
  10. package/dist/components/charts/palette.js +7 -3
  11. package/dist/components/charts/yearTickInterval.js +7 -0
  12. package/dist/index.js +4 -0
  13. package/dist/theme/createAppTheme.js +168 -0
  14. package/dist/theme/index.js +4 -0
  15. package/dist/theme/themeCompleteness.js +320 -0
  16. package/dist/theme/tokens.js +325 -0
  17. package/package.json +4 -1
  18. package/src/components/charts/BarChart.jsx +37 -9
  19. package/src/components/charts/LineChart.jsx +61 -9
  20. package/src/components/charts/TimeSeriesChart.jsx +21 -0
  21. package/src/components/charts/chartDefaults.js +111 -0
  22. package/src/components/charts/chartLabels.js +36 -0
  23. package/src/components/charts/palette.js +6 -3
  24. package/src/components/charts/yearTickInterval.js +7 -0
  25. package/src/index.js +16 -0
  26. package/src/theme/createAppTheme.js +212 -0
  27. package/src/theme/index.js +14 -0
  28. package/src/theme/themeCompleteness.js +355 -0
  29. package/src/theme/tokens.js +376 -0
  30. package/tests/BarChart.test.jsx +4 -1
  31. package/tests/LineChart.test.jsx +5 -2
  32. package/tests/chartDefaults.test.js +177 -0
  33. package/tests/chartLabels.test.js +50 -0
  34. package/tests/chartsPalette.test.js +40 -0
  35. package/tests/createAppTheme.test.js +103 -0
  36. package/tests/themeCompleteness.test.js +63 -0
  37. package/tests/timeSeriesLegend.test.js +99 -0
  38. package/work-orders/CHART-6.md +428 -0
  39. package/work-orders/SHELL-2.md +197 -0
  40. package/work-orders/THEME-1.md +587 -0
  41. package/work-orders/assets/SHELL-2-account-navigation.html +1159 -0
  42. package/work-orders/assets/THEME-1-baseline-reference-sheet.html +1352 -0
@@ -0,0 +1,103 @@
1
+ import { createTheme } from '@mui/material/styles';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import { assertThemeComplete, createAppTheme } from '../src/index';
5
+ import { BASELINE_STATIC } from '../src/theme/tokens';
6
+
7
+ describe('createAppTheme', () => {
8
+ it('builds a complete baseline from only an app primary colour', () => {
9
+ const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
10
+
11
+ expect(assertThemeComplete(theme).findings).toEqual([]);
12
+ expect(theme.typography.body1.fontSize).toBe('14px');
13
+ expect(theme.shape.borderRadius).toBe(3);
14
+ expect(theme.components.MuiPaper.defaultProps).toMatchObject({ elevation: 0, variant: 'outlined' });
15
+ expect(theme.palette.dataSeries.categorical).toHaveLength(6);
16
+ expect(theme.palette.primary.light).not.toBe(createTheme().palette.primary.light);
17
+ // MUI's createTheme(options, ...args) only augments the FIRST argument's
18
+ // palette; every later arg is deep-merged raw, so a status entry missing
19
+ // `main`/`contrastText` silently keeps MUI's stock hue for color="success"
20
+ // etc. -- main/contrastText must equal the canonical fill/fillText tones.
21
+ for (const status of ['success', 'warning', 'error', 'info']) {
22
+ expect(theme.palette[status].main).toBe(theme.palette[status].fill);
23
+ expect(theme.palette[status].contrastText).toBe(theme.palette[status].fillText);
24
+ }
25
+ });
26
+
27
+ it('throws when palette.primary is missing', () => {
28
+ expect(() => createAppTheme()).toThrow(/palette\.primary.*required/i);
29
+ });
30
+
31
+ it('throws on a function in an app styleOverrides slot', () => {
32
+ expect(() => createAppTheme({
33
+ palette: { primary: { main: '#0F62FE' } },
34
+ components: { MuiButton: { styleOverrides: { root: () => ({ color: 'red' }) } } },
35
+ })).toThrow(/components\.MuiButton\.styleOverrides\.root.*object.*variants/i);
36
+ });
37
+
38
+ it('computes component values from the final app status palette', () => {
39
+ const theme = createAppTheme({
40
+ palette: {
41
+ primary: { main: '#0F62FE' },
42
+ success: { main: '#14532D', text: '#14532D', bg: '#DCFCE7' },
43
+ },
44
+ });
45
+
46
+ expect(theme.components.MuiAlert.styleOverrides.standardSuccess).toEqual({
47
+ color: '#14532D',
48
+ backgroundColor: '#DCFCE7',
49
+ '& .MuiAlert-icon': { color: '#14532D' },
50
+ });
51
+ });
52
+
53
+ it('deep-merges app object overrides without dropping baseline properties', () => {
54
+ const theme = createAppTheme({
55
+ palette: { primary: { main: '#0F62FE' } },
56
+ typography: { fontFamily: "'App Sans', sans-serif" },
57
+ components: { MuiButton: { styleOverrides: { root: { fontSize: '15px' } } } },
58
+ });
59
+
60
+ expect(theme.components.MuiButton.styleOverrides.root).toMatchObject({
61
+ minHeight: 40,
62
+ borderRadius: 3,
63
+ fontSize: '15px',
64
+ });
65
+ expect(theme.typography.body1.fontFamily).toBe("'App Sans', sans-serif");
66
+ expect(theme.components.MuiOutlinedInput.styleOverrides.root.height).toBeUndefined();
67
+ // variants is a sibling of styleOverrides (MUI matches it independently by
68
+ // props), not nested inside it -- see src/theme/tokens.js for why.
69
+ expect(theme.components.MuiOutlinedInput.variants).toContainEqual({
70
+ props: { multiline: false },
71
+ style: { height: 40 },
72
+ });
73
+ });
74
+
75
+ it('returns independent themes for runtime palettes in one process', () => {
76
+ const blue = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
77
+ const purple = createAppTheme({ palette: { primary: { main: '#432CA1' } } });
78
+
79
+ expect(blue).not.toBe(purple);
80
+ expect(blue.palette).not.toBe(purple.palette);
81
+ expect(blue.palette.dataSeries.categorical).not.toBe(purple.palette.dataSeries.categorical);
82
+ expect(blue.palette.primary.main).toBe('#0F62FE');
83
+ expect(purple.palette.primary.main).toBe('#432CA1');
84
+ expect(blue.palette.controlBorder.focus).not.toBe(purple.palette.controlBorder.focus);
85
+ });
86
+
87
+ it('contains no function anywhere in the baseline components tree', () => {
88
+ const functionPaths = [];
89
+ const visit = (value, path) => {
90
+ if (typeof value === 'function') functionPaths.push(path);
91
+ else if (value && typeof value === 'object') {
92
+ Object.entries(value).forEach(([key, child]) => visit(child, `${path}.${key}`));
93
+ }
94
+ };
95
+
96
+ visit(BASELINE_STATIC.components, 'baseline.components');
97
+ visit(
98
+ createAppTheme({ palette: { primary: { main: '#0F62FE' } } }).components,
99
+ 'theme.components',
100
+ );
101
+ expect(functionPaths).toEqual([]);
102
+ });
103
+ });
@@ -0,0 +1,63 @@
1
+ import { createTheme } from '@mui/material/styles';
2
+ import { describe, expect, it } from 'vitest';
3
+
4
+ import {
5
+ assertThemeComplete,
6
+ calculateContrastRatio,
7
+ createAppTheme,
8
+ } from '../src/index';
9
+
10
+ describe('theme completeness', () => {
11
+ it('finds untouched MUI surfaces and accepts createAppTheme', () => {
12
+ const bare = assertThemeComplete(createTheme());
13
+ const baseline = assertThemeComplete(
14
+ createAppTheme({ palette: { primary: { main: '#0F62FE' } } }),
15
+ );
16
+
17
+ expect(bare.findings.length).toBeGreaterThan(0);
18
+ expect(bare.findings.map(({ surface }) => surface)).toContain('shape.borderRadius');
19
+ expect(assertThemeComplete(createTheme({ components: { MuiButton: {} } })).findings
20
+ .map(({ surface }) => surface))
21
+ .toContain('components.MuiButton.styleOverrides.root.minHeight');
22
+ expect(baseline.findings).toEqual([]);
23
+ });
24
+
25
+ it('suppresses exactly one surface when an exemption includes a reason', () => {
26
+ const theme = createTheme();
27
+ const before = assertThemeComplete(theme).findings;
28
+ const after = assertThemeComplete(theme, {
29
+ exemptions: [{
30
+ surface: 'palette.background.paper',
31
+ reason: 'This application deliberately uses white as its surface.',
32
+ }],
33
+ }).findings;
34
+
35
+ expect(after).toHaveLength(before.length - 1);
36
+ expect(after.map(({ surface }) => surface)).not.toContain('palette.background.paper');
37
+ });
38
+
39
+ it('reports an exemption without a reason as its own finding', () => {
40
+ const result = assertThemeComplete(createTheme(), {
41
+ exemptions: [{ surface: 'shape.borderRadius' }],
42
+ });
43
+
44
+ expect(result.findings.map(({ surface }) => surface)).toContain('exemption.shape.borderRadius');
45
+ expect(result.findings.map(({ surface }) => surface)).toContain('shape.borderRadius');
46
+ });
47
+
48
+ it('meets status-on-tint and control-boundary contrast thresholds', () => {
49
+ const theme = createAppTheme({ palette: { primary: { main: '#8AB4F8' } } });
50
+
51
+ for (const status of ['success', 'warning', 'error', 'info', 'stale']) {
52
+ expect(calculateContrastRatio(theme.palette[status].text, theme.palette[status].bg)).toBeGreaterThanOrEqual(4.5);
53
+ }
54
+ for (const state of ['main', 'hover', 'focus', 'error']) {
55
+ expect(calculateContrastRatio(theme.palette.controlBorder[state], '#FFFFFF')).toBeGreaterThanOrEqual(3);
56
+ expect(calculateContrastRatio(theme.palette.controlBorder[state], theme.palette.background.default)).toBeGreaterThanOrEqual(3);
57
+ }
58
+
59
+ theme.palette.success.text = 'not-a-colour';
60
+ expect(assertThemeComplete(theme).findings.map(({ surface }) => surface))
61
+ .toContain('contrast.success.text-on-bg');
62
+ });
63
+ });
@@ -0,0 +1,99 @@
1
+ // @vitest-environment jsdom
2
+ import React from 'react';
3
+ import i18next from 'i18next';
4
+ import { afterEach, describe, expect, it, vi } from 'vitest';
5
+ import { cleanup, render, screen } from '@testing-library/react';
6
+ import { I18nextProvider } from 'react-i18next';
7
+ import { ThemeProvider, createTheme } from '@mui/material/styles';
8
+
9
+ const chartSpy = vi.hoisted(() => vi.fn(() => null));
10
+ vi.mock('@mui/x-charts/BarChart', () => ({ BarChart: chartSpy }));
11
+
12
+ import { TimeSeriesChart } from '../src/components/charts/TimeSeriesChart';
13
+
14
+ chartSpy.mockImplementation((props) => React.createElement(
15
+ 'div',
16
+ { 'data-testid': 'mui-bar-chart' },
17
+ !props.hideLegend
18
+ ? React.createElement('div', { 'data-testid': 'mui-legend' }, 'MUI legend')
19
+ : null,
20
+ ));
21
+
22
+ const i18n = i18next.createInstance();
23
+ i18n.init({
24
+ lng: 'en',
25
+ resources: {
26
+ en: {
27
+ translation: {
28
+ 'TimeSeriesChart.RANGE_LABEL': 'Range',
29
+ 'TimeSeriesChart.RANGE_1_DAY': '1 day',
30
+ 'TimeSeriesChart.RANGE_1_WEEK': '1 week',
31
+ 'TimeSeriesChart.RANGE_1_MONTH': '1 month',
32
+ 'TimeSeriesChart.RANGE_1_YEAR': '1 year',
33
+ 'TimeSeriesChart.SERIES_LABEL': 'Series',
34
+ 'ChartFrame.EMPTY_DEFAULT': 'No data available.',
35
+ 'ChartFrame.LOADING': 'Loading chart.',
36
+ 'ChartFrame.ERROR_DEFAULT': 'The chart could not be loaded.',
37
+ },
38
+ },
39
+ },
40
+ interpolation: { escapeValue: false },
41
+ });
42
+
43
+ const MULTI_SERIES = {
44
+ xLabels: ['Mon', 'Tue'],
45
+ series: [
46
+ { key: 'users', label: 'Users', data: [1, 2] },
47
+ { key: 'hours', label: 'Presence hours', data: [3, 4] },
48
+ ],
49
+ };
50
+
51
+ function renderChart(data = MULTI_SERIES) {
52
+ return render(React.createElement(
53
+ ThemeProvider,
54
+ { theme: createTheme() },
55
+ React.createElement(
56
+ I18nextProvider,
57
+ { i18n },
58
+ React.createElement(TimeSeriesChart, {
59
+ title: 'Activity',
60
+ xAxisLabel: 'Day',
61
+ yAxisLabel: 'Count',
62
+ data,
63
+ }),
64
+ ),
65
+ ),
66
+ );
67
+ }
68
+
69
+ describe('TimeSeriesChart toolbar legend', () => {
70
+ afterEach(() => {
71
+ cleanup();
72
+ vi.clearAllMocks();
73
+ });
74
+
75
+ it('removes the MUI legend when the toolbar names and toggles multiple series', () => {
76
+ renderChart();
77
+
78
+ expect(chartSpy.mock.calls.at(-1)[0].hideLegend).toBe(true);
79
+ expect(screen.queryByTestId('mui-legend')).toBeNull();
80
+ });
81
+
82
+ it('keeps an accessible name on every toolbar series toggle', () => {
83
+ renderChart();
84
+
85
+ expect(screen.getByRole('checkbox', { name: 'Users' })).toBeTruthy();
86
+ expect(screen.getByRole('checkbox', { name: 'Presence hours' })).toBeTruthy();
87
+ expect(screen.getAllByTestId('series-colour-dot')).toHaveLength(2);
88
+ });
89
+
90
+ it('still hides the redundant MUI legend for a single series', () => {
91
+ renderChart({
92
+ xLabels: ['Mon', 'Tue'],
93
+ series: [{ key: 'users', label: 'Users', data: [1, 2] }],
94
+ });
95
+
96
+ expect(chartSpy.mock.calls.at(-1)[0].hideLegend).toBe(true);
97
+ expect(screen.queryByTestId('mui-legend')).toBeNull();
98
+ });
99
+ });