@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.
- package/README.md +4 -0
- package/WORK_ORDERS.md +6 -4
- package/dist/components/SoftChip.js +69 -0
- package/dist/components/StatTile.js +20 -0
- package/dist/index.js +3 -1
- package/dist/theme/index.js +1 -1
- package/dist/theme/kitSxRegistry.js +7 -0
- package/dist/theme/themeCompleteness.js +202 -0
- package/package.json +1 -1
- package/src/components/SoftChip.jsx +93 -0
- package/src/components/StatTile.jsx +39 -0
- package/src/index.js +3 -0
- package/src/theme/index.js +1 -0
- package/src/theme/kitSxRegistry.js +11 -0
- package/src/theme/themeCompleteness.js +201 -0
- package/tests/SoftChip.test.jsx +105 -0
- package/tests/StatTile.test.jsx +57 -0
- package/tests/themeCompleteness.test.js +104 -0
- package/work-orders/PRIM-1.md +177 -0
- package/work-orders/THEME-6.md +247 -0
- package/work-orders/assets/PRIM-1-dashboard-primitives.html +624 -0
|
@@ -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';
|
package/src/theme/index.js
CHANGED
|
@@ -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
|
+
});
|