@micha.bigler/ui-core-micha 2.37.1 → 2.39.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.
@@ -0,0 +1,39 @@
1
+ import { Box, Typography } from '@mui/material';
2
+
3
+ /**
4
+ * A small bordered KPI tile: muted label, one bold tabular value, optional caption.
5
+ * Promoted from two independent builds (hram's `ResearchComponents/StatTile.jsx`, the inline
6
+ * version in fitness-monitor's `BodyHistoryPage.jsx`) -- `flex: 1` with a `minWidth` floor covers
7
+ * both builds' row behaviours with one rule, and `tabular-nums` is unconditional so a row of
8
+ * tiles reads as a row.
9
+ */
10
+ export default function StatTile({ label, value, caption, accent = false, children, minWidth = 120 }) {
11
+ return (
12
+ <Box
13
+ sx={{
14
+ flex: 1,
15
+ minWidth,
16
+ border: '1px solid',
17
+ borderColor: accent ? 'primary.main' : 'divider',
18
+ borderWidth: accent ? 2 : 1,
19
+ borderRadius: 1,
20
+ p: { xs: 1, sm: 1.5 },
21
+ }}
22
+ >
23
+ <Typography variant="caption" color="text.secondary" display="block">
24
+ {label}
25
+ </Typography>
26
+ {value !== undefined && (
27
+ <Typography variant="h5" component="div" sx={{ fontVariantNumeric: 'tabular-nums' }}>
28
+ {value}
29
+ </Typography>
30
+ )}
31
+ {caption && (
32
+ <Typography variant="caption" color="text.secondary" display="block">
33
+ {caption}
34
+ </Typography>
35
+ )}
36
+ {children}
37
+ </Box>
38
+ );
39
+ }
package/src/index.js CHANGED
@@ -17,6 +17,7 @@ export {
17
17
  KIT_COMPONENT_SX_REGISTRY,
18
18
  reportOffPaletteColours,
19
19
  reportKitSxBypasses,
20
+ reportRedundantThemeValues,
20
21
  reportThemeAdoption,
21
22
  THEME_COMPLETENESS_SURFACES,
22
23
  } from './theme';
@@ -62,6 +63,8 @@ export { AuthFactorRequirementCard } from './components/AuthFactorRequirementCar
62
63
  export { AccessCodeSingleUseToggle } from './components/AccessCodeSingleUseToggle';
63
64
  export { QrSignupManager } from './components/QrSignupManager';
64
65
  export { MobileBottomNav } from './components/MobileBottomNav';
66
+ export { default as StatTile } from './components/StatTile';
67
+ export { default as SoftChip } from './components/SoftChip';
65
68
 
66
69
  // --- 6. Charts ---
67
70
  export { ChartFrame } from './components/charts/ChartFrame';
@@ -5,6 +5,7 @@ export {
5
5
  calculateContrastRatio,
6
6
  reportOffPaletteColours,
7
7
  reportKitSxBypasses,
8
+ reportRedundantThemeValues,
8
9
  reportThemeAdoption,
9
10
  THEME_COMPLETENESS_SURFACES,
10
11
  } from './themeCompleteness';
@@ -41,6 +41,11 @@ import {
41
41
  REGISTRATION_METHODS_INFO_SX,
42
42
  } from '../components/RegistrationMethodsManager';
43
43
  import { SECURITY_ALERT_SX, SECURITY_DIVIDER_SX } from '../components/SecurityComponent';
44
+ import {
45
+ SOFT_CHIP_CAVEAT_SX,
46
+ SOFT_CHIP_ROOT_SX,
47
+ SOFT_CHIP_STATUS_SX,
48
+ } from '../components/SoftChip';
44
49
  import {
45
50
  SUPPORT_RECOVERY_AGENT_BUTTON_SX,
46
51
  SUPPORT_RECOVERY_AGENT_PAPER_SX,
@@ -148,6 +153,9 @@ const SX_EXPORTS = {
148
153
  SECURITY_DIVIDER_SX,
149
154
  SECTION_NAV_DRAWER_PAPER_SX,
150
155
  SECTION_NAV_GROUP_PAPER_SX,
156
+ SOFT_CHIP_CAVEAT_SX,
157
+ SOFT_CHIP_ROOT_SX,
158
+ SOFT_CHIP_STATUS_SX,
151
159
  SIGNUP_CONFIRM_PAGE_ALERT_SX,
152
160
  SIGN_UP_PAGE_ALERT_SX,
153
161
  SUPPORT_RECOVERY_AGENT_BUTTON_SX,
@@ -213,6 +221,9 @@ export const KIT_COMPONENT_SX_REGISTRY = [
213
221
  entry('RegistrationMethodsManager.info', 'MuiAlert', REGISTRATION_METHODS_INFO_SX),
214
222
  entry('SecurityComponent.alert', 'MuiAlert', SECURITY_ALERT_SX),
215
223
  entry('SecurityComponent.divider', 'MuiDivider', SECURITY_DIVIDER_SX),
224
+ entry('SoftChip.root', 'MuiChip', SOFT_CHIP_ROOT_SX),
225
+ entry('SoftChip.caveat', 'MuiChip', SOFT_CHIP_CAVEAT_SX),
226
+ entry('SoftChip.status', 'MuiChip', SOFT_CHIP_STATUS_SX),
216
227
  entry('SupportRecoveryRequestsTab.agentPaper', 'MuiPaper', SUPPORT_RECOVERY_AGENT_PAPER_SX),
217
228
  entry('SupportRecoveryRequestsTab.alert', 'MuiAlert', SUPPORT_RECOVERY_ALERT_SX),
218
229
  entry('SupportRecoveryRequestsTab.agentAction', 'MuiButton', SUPPORT_RECOVERY_AGENT_BUTTON_SX),
@@ -816,6 +816,207 @@ export function reportOffPaletteColours(
816
816
  return { findings };
817
817
  }
818
818
 
819
+ function balancedBraceRegion(text, start) {
820
+ let depth = 0;
821
+ let quote = null;
822
+ for (let index = start; index < text.length; index += 1) {
823
+ const character = text[index];
824
+ if (quote) {
825
+ if (character === quote && text[index - 1] !== '\\') quote = null;
826
+ continue;
827
+ }
828
+ if (character === '"' || character === "'" || character === '`') {
829
+ quote = character;
830
+ continue;
831
+ }
832
+ if (character === '{') depth += 1;
833
+ else if (character === '}') {
834
+ depth -= 1;
835
+ if (depth === 0) return text.slice(start, index + 1);
836
+ }
837
+ }
838
+ return text.slice(start);
839
+ }
840
+
841
+ function topLevelObjectEntries(objectText) {
842
+ const inner = objectText.slice(1, -1);
843
+ const entries = [];
844
+ let depth = 0;
845
+ let quote = null;
846
+ let entryStart = 0;
847
+ const push = (raw) => {
848
+ const trimmed = raw.trim();
849
+ if (trimmed) entries.push(trimmed);
850
+ };
851
+ for (let index = 0; index < inner.length; index += 1) {
852
+ const character = inner[index];
853
+ if (quote) {
854
+ if (character === quote && inner[index - 1] !== '\\') quote = null;
855
+ continue;
856
+ }
857
+ if (character === '"' || character === "'" || character === '`') {
858
+ quote = character;
859
+ continue;
860
+ }
861
+ if (character === '{' || character === '[' || character === '(') depth += 1;
862
+ else if (character === '}' || character === ']' || character === ')') depth -= 1;
863
+ else if (character === ',' && depth === 0) {
864
+ push(inner.slice(entryStart, index));
865
+ entryStart = index + 1;
866
+ }
867
+ }
868
+ push(inner.slice(entryStart));
869
+ return entries;
870
+ }
871
+
872
+ function splitSxEntry(entry) {
873
+ let depth = 0;
874
+ let quote = null;
875
+ for (let index = 0; index < entry.length; index += 1) {
876
+ const character = entry[index];
877
+ if (quote) {
878
+ if (character === quote && entry[index - 1] !== '\\') quote = null;
879
+ continue;
880
+ }
881
+ if (character === '"' || character === "'" || character === '`') {
882
+ quote = character;
883
+ continue;
884
+ }
885
+ if (character === '{' || character === '[' || character === '(') depth += 1;
886
+ else if (character === '}' || character === ']' || character === ')') depth -= 1;
887
+ else if (character === ':' && depth === 0) {
888
+ return { rawKey: entry.slice(0, index).trim(), rawValue: entry.slice(index + 1).trim() };
889
+ }
890
+ }
891
+ return null;
892
+ }
893
+
894
+ const SIMPLE_IDENTIFIER_PATTERN = /^[A-Za-z_$][\w$]*$/;
895
+ const QUOTED_STRING_PATTERN = /^(['"])((?:\\.|(?!\1)[\s\S])*)\1$/;
896
+ const NUMERIC_LITERAL_PATTERN = /^-?\d+(?:\.\d+)?$/;
897
+
898
+ /**
899
+ * Reads a top-level `sx={{ ... }}` object literal on a baseline-styled tag
900
+ * and returns its simple `{ property, value }` entries: identifier keys only
901
+ * (a quoted key is a nested selector like `'&:hover'` and is skipped, not a
902
+ * property), values that are a plain quoted string or bare number only. A
903
+ * template literal, identifier reference, spread, or function call is out of
904
+ * scope by design -- this is a lower bound, not a JS evaluator.
905
+ */
906
+ function literalSxEntries(sxValueText) {
907
+ if (!/^\{\s*\{/.test(sxValueText)) return [];
908
+ const objectText = balancedBraceRegion(sxValueText, sxValueText.indexOf('{', 1));
909
+ const entries = [];
910
+ for (const rawEntry of topLevelObjectEntries(objectText)) {
911
+ const split = splitSxEntry(rawEntry);
912
+ if (!split) continue;
913
+ const { rawKey, rawValue } = split;
914
+ if (!SIMPLE_IDENTIFIER_PATTERN.test(rawKey)) continue;
915
+
916
+ const quoted = rawValue.match(QUOTED_STRING_PATTERN);
917
+ if (quoted) {
918
+ entries.push({ property: rawKey, value: quoted[2] });
919
+ continue;
920
+ }
921
+ if (NUMERIC_LITERAL_PATTERN.test(rawValue)) {
922
+ entries.push({ property: rawKey, value: Number(rawValue) });
923
+ }
924
+ }
925
+ return entries;
926
+ }
927
+
928
+ // MUI's sx system multiplies a bare NUMBER by a theme scale factor for these
929
+ // properties (`theme.shape.borderRadius` for borderRadius, `theme.spacing()`
930
+ // for every spacing alias) before it ever reaches the DOM -- a raw
931
+ // `styleOverrides.root` value is already-resolved CSS, unscaled. Comparing
932
+ // the two numbers directly would be a false positive (e.g. `borderRadius: 8`
933
+ // in sx renders as `theme.shape.borderRadius * 8`, not `8`), and false
934
+ // positives are the failure mode this whole check exists to avoid. Numeric
935
+ // literals on these properties are therefore skipped, not compared -- a
936
+ // false negative (this check's accepted lower bound) is the safe direction
937
+ // to err in, a false positive is not.
938
+ const MUI_NUMERIC_SCALE_PROPERTIES = new Set([
939
+ 'borderRadius',
940
+ ...Object.keys(SPACING_SHORTHAND_LONGHANDS).filter((key) => key !== 'bgcolor' && key !== 'typography'),
941
+ ]);
942
+
943
+ /**
944
+ * Resolves what an sx value would actually render as, for comparison against
945
+ * a theme styleOverride value already resolved to a real CSS value. A string
946
+ * sx value is tried as a `theme.palette.<value>` token path first (MUI's own
947
+ * resolution for palette-aware sx properties, e.g. `borderColor: 'divider'`
948
+ * -> `theme.palette.divider`); if that path is undefined, the raw string is
949
+ * used as-is (a plain CSS keyword like `'none'` is not a token path).
950
+ */
951
+ function resolveSxValue(theme, value) {
952
+ if (typeof value !== 'string') return value;
953
+ const paletteValue = getPath(theme, `palette.${value}`);
954
+ return paletteValue !== undefined ? paletteValue : value;
955
+ }
956
+
957
+ /**
958
+ * Reports an app's own `sx` value that already equals what its theme
959
+ * resolves for that MUI component and property -- not "you overrode the
960
+ * baseline" (in an app, that is the intended mechanism), but "you re-stated
961
+ * a value you already had", which silently diverges the next time the token
962
+ * changes. Attribution is by JSX tag name against the same
963
+ * `BASELINE_STYLED_MUI_COMPONENTS` list `reportKitSxBypasses` uses, checked
964
+ * against `theme.components.Mui<Tag>.styleOverrides.root.<property>` only
965
+ * (no other slot). Report-only; there is no exemption arm yet -- each
966
+ * adopting app makes this a hard assertion in its own follow-up WO once it
967
+ * has acted on the findings.
968
+ *
969
+ * This is a lower bound, and the bound is open, not enumerated: a `styled()`
970
+ * component, a component rendered through `component={...}` (so its JSX tag
971
+ * name is not the MUI component it becomes), an aliased import
972
+ * (`import { Paper as Surface }`), array/callback/conditional `sx`, and a
973
+ * template-literal or identifier-reference value are all unattributed or
974
+ * unresolved by this method. A numeric `sx` value on a property MUI scales
975
+ * by a theme factor (`borderRadius`, every spacing alias) is deliberately
976
+ * SKIPPED rather than compared -- see `MUI_NUMERIC_SCALE_PROPERTIES` -- since
977
+ * comparing the raw numbers would be a false positive, not a missed finding.
978
+ * A clean result means "nothing found by this method", not "no redundant
979
+ * value exists".
980
+ */
981
+ export function reportRedundantThemeValues(sources = [], { theme } = {}) {
982
+ const findings = [];
983
+ if (!theme) return { findings };
984
+ const componentPattern = BASELINE_STYLED_MUI_COMPONENTS.join('|');
985
+ const tagPattern = new RegExp(`<(${componentPattern})\\b`, 'g');
986
+
987
+ for (const { path, source } of normalizedSources(sources)) {
988
+ const scanSource = maskComments(maskQrPrintDocument(source, path.replaceAll('\\', '/')));
989
+
990
+ for (const match of scanSource.matchAll(tagPattern)) {
991
+ const tagName = match[1];
992
+ const tag = jsxOpeningTag(scanSource, match.index);
993
+ const sxValueText = topLevelSxValue(tag);
994
+ const line = source.slice(0, match.index).split(/\r?\n/).length;
995
+
996
+ for (const { property, value } of literalSxEntries(sxValueText)) {
997
+ if (typeof value === 'number' && MUI_NUMERIC_SCALE_PROPERTIES.has(property)) continue;
998
+ const [canonicalProperty] = expandShorthandProperty(property);
999
+ const lookupProperty = expandShorthandProperty(property).length === 1
1000
+ ? canonicalProperty
1001
+ : property;
1002
+ const themeValue = getPath(
1003
+ theme,
1004
+ `components.Mui${tagName}.styleOverrides.root.${lookupProperty}`,
1005
+ );
1006
+ if (themeValue === undefined) continue;
1007
+ if (sameValue(resolveSxValue(theme, value), themeValue)) {
1008
+ findings.push({
1009
+ surface: `${path}:${line}.Mui${tagName}.${property}`,
1010
+ reason: `Mui${tagName}'s sx sets "${property}" to a value the theme already resolves to the same thing.`,
1011
+ });
1012
+ }
1013
+ }
1014
+ }
1015
+ }
1016
+
1017
+ return { findings };
1018
+ }
1019
+
819
1020
  /** Reports adoption signals as numbers; it never changes completeness. */
820
1021
  export function reportThemeAdoption(sources = []) {
821
1022
  const normalized = sources.map((entry, index) =>
@@ -0,0 +1,105 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
4
+ import { ThemeProvider, alpha, darken } from '@mui/material/styles';
5
+
6
+ import { createAppTheme } from '../src/theme';
7
+ import SoftChip from '../src/components/SoftChip';
8
+
9
+ const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
10
+ const TONES = ['success', 'warning', 'error', 'info'];
11
+
12
+ function renderChip(props) {
13
+ return render(<ThemeProvider theme={theme}><SoftChip {...props} /></ThemeProvider>);
14
+ }
15
+
16
+ function rgb(color) {
17
+ const probe = document.createElement('div');
18
+ probe.style.color = color;
19
+ document.body.appendChild(probe);
20
+ const resolved = window.getComputedStyle(probe).color;
21
+ probe.remove();
22
+ return resolved;
23
+ }
24
+
25
+ afterEach(() => cleanup());
26
+
27
+ describe('SoftChip', () => {
28
+ it.each(TONES)('derives fill/border/text for tone "%s" from the theme, not a literal', (tone) => {
29
+ renderChip({ label: 'Provisional ranges', tone });
30
+
31
+ const chip = screen.getByText('Provisional ranges').closest('.MuiChip-root');
32
+ const style = window.getComputedStyle(chip);
33
+ const palette = theme.palette[tone];
34
+
35
+ expect(style.backgroundColor).toBe(rgb(alpha(palette.main, 0.12)));
36
+ expect(style.borderColor).toBe(rgb(alpha(palette.main, 0.3)));
37
+ expect(style.color).toBe(rgb(darken(palette.main, 0.3)));
38
+ });
39
+
40
+ it('differs between status and caveat in radius and typography', () => {
41
+ renderChip({ label: 'passed', tone: 'success', variant: 'status' });
42
+ const statusChip = screen.getByText('passed').closest('.MuiChip-root');
43
+ const statusLabel = window.getComputedStyle(screen.getByText('passed'));
44
+ expect(window.getComputedStyle(statusChip).borderRadius).toBe('999px');
45
+ // status uses the baseline's `overline` variant verbatim -- 11/600, uppercase, .4px tracking
46
+ expect(statusLabel.textTransform).toBe('uppercase');
47
+ expect(statusLabel.fontSize).toBe(theme.typography.overline.fontSize);
48
+ expect(statusLabel.fontWeight).toBe(String(theme.typography.overline.fontWeight));
49
+ expect(statusLabel.letterSpacing).toBe(theme.typography.overline.letterSpacing);
50
+
51
+ cleanup();
52
+
53
+ renderChip({ label: 'Provisional ranges', tone: 'warning', variant: 'caveat' });
54
+ const caveatChip = screen.getByText('Provisional ranges').closest('.MuiChip-root');
55
+ const caveatLabel = window.getComputedStyle(screen.getByText('Provisional ranges'));
56
+ expect(window.getComputedStyle(caveatChip).borderRadius).not.toBe('999px');
57
+ // caveat keeps its raw 12/500 -- the PRIM-1 token delta, deliberately not on a theme variant
58
+ expect(caveatLabel.fontSize).toBe('12px');
59
+ expect(caveatLabel.fontWeight).toBe('500');
60
+ });
61
+
62
+ it('defaults the leading element to a dot and swaps it for a passed icon', () => {
63
+ const { container: withDot } = renderChip({ label: 'passed', tone: 'success', variant: 'status' });
64
+ expect(withDot.querySelector('.MuiChip-icon')).toBeTruthy();
65
+
66
+ cleanup();
67
+
68
+ const { container: withIcon } = renderChip({
69
+ label: 'recurring',
70
+ tone: 'info',
71
+ variant: 'status',
72
+ icon: <span data-testid="custom-icon" />,
73
+ });
74
+ expect(withIcon.querySelector('[data-testid="custom-icon"]')).toBeTruthy();
75
+ });
76
+
77
+ it('toggles the border', () => {
78
+ renderChip({ label: 'failed', tone: 'error', variant: 'status', border: false });
79
+
80
+ const chip = screen.getByText('failed').closest('.MuiChip-root');
81
+ expect(window.getComputedStyle(chip).borderColor).toBe(rgb('transparent'));
82
+ });
83
+
84
+ it('renders title as a tooltip, reachable on a touch pointer with no delay', async () => {
85
+ renderChip({ label: 'queued', tone: 'info', variant: 'status', title: 'Waiting on the sandbox' });
86
+
87
+ expect(screen.queryByText('Waiting on the sandbox')).toBeNull();
88
+
89
+ const chip = screen.getByText('queued').closest('.MuiChip-root');
90
+ fireEvent.touchStart(chip);
91
+
92
+ // `enterTouchDelay={0}` is the fix for the AUTH-4 failure mode -- a tooltip a touch pointer
93
+ // can never trigger. waitFor's default timeout (1000ms) is generous headroom around "no
94
+ // delay"; a regression to the ~700ms default would still likely pass, so this is a floor, not
95
+ // a precise timing assertion -- the source is the actual guarantee, this proves it's wired.
96
+ await waitFor(() => expect(screen.getByText('Waiting on the sandbox')).toBeTruthy());
97
+ });
98
+
99
+ it('omits the tooltip wrapper entirely when there is no title', () => {
100
+ renderChip({ label: 'queued', tone: 'info', variant: 'status' });
101
+
102
+ const chip = screen.getByText('queued').closest('.MuiChip-root');
103
+ expect(chip.getAttribute('aria-label')).toBeNull();
104
+ });
105
+ });
@@ -0,0 +1,57 @@
1
+ // @vitest-environment jsdom
2
+ import { afterEach, describe, expect, it } from 'vitest';
3
+ import { cleanup, render, screen } from '@testing-library/react';
4
+ import { ThemeProvider } from '@mui/material/styles';
5
+
6
+ import { createAppTheme } from '../src/theme';
7
+ import StatTile from '../src/components/StatTile';
8
+
9
+ const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
10
+
11
+ function renderTile(props) {
12
+ return render(<ThemeProvider theme={theme}><StatTile {...props} /></ThemeProvider>);
13
+ }
14
+
15
+ afterEach(() => cleanup());
16
+
17
+ describe('StatTile', () => {
18
+ it('renders label, value and caption', () => {
19
+ renderTile({ label: 'Coverage at budget', value: '71.4 %', caption: 'of maximum realizable' });
20
+
21
+ expect(screen.getByText('Coverage at budget')).toBeTruthy();
22
+ expect(screen.getByText('71.4 %')).toBeTruthy();
23
+ expect(screen.getByText('of maximum realizable')).toBeTruthy();
24
+ });
25
+
26
+ it('omits the value row when value is undefined', () => {
27
+ renderTile({ label: 'Recovered' });
28
+
29
+ expect(screen.getByText('Recovered')).toBeTruthy();
30
+ expect(screen.queryByText('undefined')).toBeNull();
31
+ });
32
+
33
+ it('renders children', () => {
34
+ renderTile({ label: 'Deaths averted', value: '63.8 %', children: <span>children slot</span> });
35
+
36
+ expect(screen.getByText('children slot')).toBeTruthy();
37
+ });
38
+
39
+ it('applies the accent border only when accent is set', () => {
40
+ const { container: plain } = renderTile({ label: 'Body fat', value: '18.4 %' });
41
+ const plainStyle = window.getComputedStyle(plain.firstChild);
42
+ expect(plainStyle.borderWidth).toBe('1px');
43
+
44
+ cleanup();
45
+
46
+ const { container: accented } = renderTile({ label: 'Lean mass', value: '62.1 kg', accent: true });
47
+ const accentedStyle = window.getComputedStyle(accented.firstChild);
48
+ expect(accentedStyle.borderWidth).toBe('2px');
49
+ });
50
+
51
+ it('renders the value with tabular-nums', () => {
52
+ renderTile({ label: 'theta*', value: '0.8421' });
53
+
54
+ const value = screen.getByText('0.8421');
55
+ expect(window.getComputedStyle(value).fontVariantNumeric).toBe('tabular-nums');
56
+ });
57
+ });
@@ -12,6 +12,7 @@ import {
12
12
  KIT_COMPONENT_SX_REGISTRY,
13
13
  reportOffPaletteColours,
14
14
  reportKitSxBypasses,
15
+ reportRedundantThemeValues,
15
16
  THEME_COMPLETENESS_SURFACES,
16
17
  } from '../src/theme';
17
18
  import { BASELINE_PALETTE } from '../src/theme/tokens';
@@ -244,6 +245,22 @@ describe('kit sx disjointness', () => {
244
245
  surface: 'UserListComponent.emptyCell.MuiTableCell.py',
245
246
  reason: 'The empty-state message intentionally uses more vertical breathing room than the baseline TableCell default.',
246
247
  },
248
+ {
249
+ surface: 'SoftChip.root.MuiChip.height',
250
+ reason: 'SoftChip is a small annotation chip, deliberately auto-height rather than the baseline interactive Chip\'s fixed 32px.',
251
+ },
252
+ {
253
+ surface: 'SoftChip.caveat.MuiChip.borderRadius',
254
+ reason: 'The caveat variant uses the shared square-ish radius token (shape.borderRadius), not the baseline Chip\'s pill radius.',
255
+ },
256
+ {
257
+ surface: 'SoftChip.caveat.MuiChip.fontSize',
258
+ reason: 'PRIM-1 token delta: caveat text is 12/500, one weight step above the nearest baseline variant (caption, 12/400). Adding a variant for this is an app-level baseline decision out of scope for this promotion; deliberately left unresolved.',
259
+ },
260
+ {
261
+ surface: 'SoftChip.status.MuiChip.borderRadius',
262
+ reason: 'The status variant is a pill, matching cockpit\'s existing lane-status pill shape, not the baseline Chip\'s default radius.',
263
+ },
247
264
  ],
248
265
  }).findings).toEqual([]);
249
266
  });
@@ -505,3 +522,90 @@ describe('off-palette colour reporting', () => {
505
522
  expect(result.findings.some(({ reason }) => reason.includes('grey.50'))).toBe(true);
506
523
  });
507
524
  });
525
+
526
+ describe('redundant app-side theme values', () => {
527
+ const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
528
+
529
+ it('flags an sx value that already equals the theme, naming the file, line, component and property', () => {
530
+ const result = reportRedundantThemeValues(
531
+ [{
532
+ path: 'src/components/Example.jsx',
533
+ source: "export function Example() {\n return <Paper sx={{ borderColor: 'divider' }} />;\n}",
534
+ }],
535
+ { theme },
536
+ );
537
+
538
+ expect(result.findings).toEqual([{
539
+ surface: 'src/components/Example.jsx:2.MuiPaper.borderColor',
540
+ reason: expect.stringMatching(/MuiPaper.*borderColor.*theme already resolves/),
541
+ }]);
542
+ });
543
+
544
+ it('does not flag a genuinely different value', () => {
545
+ const result = reportRedundantThemeValues(
546
+ [{
547
+ path: 'src/components/Example.jsx',
548
+ source: "<Paper sx={{ borderColor: 'error.main' }} />",
549
+ }],
550
+ { theme },
551
+ );
552
+
553
+ expect(result.findings).toEqual([]);
554
+ });
555
+
556
+ it('does not flag a component tag outside the attribution list at all', () => {
557
+ const result = reportRedundantThemeValues(
558
+ [{
559
+ path: 'src/components/Example.jsx',
560
+ source: "<Box sx={{ borderColor: 'divider' }} />",
561
+ }],
562
+ { theme },
563
+ );
564
+
565
+ expect(result.findings).toEqual([]);
566
+ });
567
+
568
+ it('does not flag a property the theme does not style on an attributable component', () => {
569
+ // MuiTextField's baseline styleOverrides.root has no borderColor, so this
570
+ // must fall through the themeValue === undefined branch, not match.
571
+ const result = reportRedundantThemeValues(
572
+ [{
573
+ path: 'src/components/Example.jsx',
574
+ source: "<TextField sx={{ borderColor: 'divider' }} />",
575
+ }],
576
+ { theme },
577
+ );
578
+
579
+ expect(result.findings).toEqual([]);
580
+ });
581
+
582
+ it('skips a numeric borderRadius rather than false-positive on MUI\'s theme-scale multiplier', () => {
583
+ // sx={{ borderRadius: 8 }} on a Card actually renders as
584
+ // theme.shape.borderRadius * 8, not the raw baseline styleOverride value
585
+ // -- comparing the two numbers directly would be a false positive.
586
+ const result = reportRedundantThemeValues(
587
+ [{
588
+ path: 'src/components/Example.jsx',
589
+ source: `<Card sx={{ borderRadius: ${theme.components.MuiCard.styleOverrides.root.borderRadius} }} />`,
590
+ }],
591
+ { theme },
592
+ );
593
+
594
+ expect(result.findings).toEqual([]);
595
+ });
596
+
597
+ it('matches the finding shape of its sibling checks', () => {
598
+ const result = reportRedundantThemeValues(
599
+ [{ path: 'a.jsx', source: "<Paper sx={{ borderColor: 'divider' }} />" }],
600
+ { theme },
601
+ );
602
+
603
+ expect(result).toEqual({ findings: expect.any(Array) });
604
+ expect(result.findings[0]).toEqual({ surface: expect.any(String), reason: expect.any(String) });
605
+ });
606
+
607
+ it('returns no findings and requires a theme', () => {
608
+ expect(reportRedundantThemeValues([{ path: 'a.jsx', source: "<Paper sx={{ borderColor: 'divider' }} />" }]))
609
+ .toEqual({ findings: [] });
610
+ });
611
+ });