@micha.bigler/ui-core-micha 2.34.0 → 2.35.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.
@@ -1,6 +1,11 @@
1
1
  import { createTheme, getContrastRatio as getMuiContrastRatio } from '@mui/material/styles';
2
2
 
3
- import { STATUS_KEYS, TYPOGRAPHY_VARIANTS } from './tokens';
3
+ import {
4
+ BASELINE_PALETTE,
5
+ BASELINE_STATIC,
6
+ STATUS_KEYS,
7
+ TYPOGRAPHY_VARIANTS,
8
+ } from './tokens';
4
9
  import { KIT_COMPONENT_SX_REGISTRY } from './kitSxRegistry';
5
10
 
6
11
  const MUI_DEFAULT_THEME = createTheme();
@@ -373,18 +378,28 @@ const SAME_ROOT_STYLE_OVERRIDE_SLOTS = {
373
378
  MuiChip: ['root', 'outlined'],
374
379
  };
375
380
 
376
- // MUI's sx prop accepts CSS spacing shorthands (`py`, `px`, `pt`, ...) that
377
- // styleOverrides' plain CSS-in-JS objects do not -- a baseline `padding`
378
- // default and a component's own `sx={{ py: 1 }}` collide on the vertical
379
- // axis even though the two key strings never match literally. Each key is
380
- // expanded to the canonical longhand properties it can affect so the
381
- // disjointness check compares axes, not raw key spellings; a property
381
+ // MUI's sx prop accepts aliases and CSS spacing shorthands that
382
+ // styleOverrides' plain CSS-in-JS objects do not. Each known alias is
383
+ // expanded to the canonical CSS properties it can affect so the
384
+ // disjointness check compares effects, not raw key spellings; a property
382
385
  // outside this table expands to itself.
383
386
  const SPACING_SHORTHAND_LONGHANDS = {
387
+ bgcolor: ['backgroundColor'],
388
+ typography: [
389
+ 'fontFamily',
390
+ 'fontSize',
391
+ 'fontStyle',
392
+ 'fontWeight',
393
+ 'letterSpacing',
394
+ 'lineHeight',
395
+ 'textTransform',
396
+ ],
384
397
  p: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
385
398
  padding: ['paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft'],
386
399
  px: ['paddingLeft', 'paddingRight'],
400
+ paddingX: ['paddingLeft', 'paddingRight'],
387
401
  py: ['paddingTop', 'paddingBottom'],
402
+ paddingY: ['paddingTop', 'paddingBottom'],
388
403
  pt: ['paddingTop'],
389
404
  pr: ['paddingRight'],
390
405
  pb: ['paddingBottom'],
@@ -392,7 +407,9 @@ const SPACING_SHORTHAND_LONGHANDS = {
392
407
  m: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'],
393
408
  margin: ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'],
394
409
  mx: ['marginLeft', 'marginRight'],
410
+ marginX: ['marginLeft', 'marginRight'],
395
411
  my: ['marginTop', 'marginBottom'],
412
+ marginY: ['marginTop', 'marginBottom'],
396
413
  mt: ['marginTop'],
397
414
  mr: ['marginRight'],
398
415
  mb: ['marginBottom'],
@@ -415,14 +432,12 @@ function styleOverrideKeys(componentConfig, slots = ['root']) {
415
432
 
416
433
  /**
417
434
  * Reports top-level property collisions between registered kit sx objects and
418
- * the baseline MUI component they target. Spacing shorthands (`py`/`px`/...)
419
- * are compared by the longhand axis they affect, not by literal key name --
420
- * see `SPACING_SHORTHAND_LONGHANDS`.
435
+ * the baseline MUI component they target. Known sx aliases are compared by
436
+ * the CSS properties they affect, not by literal key name.
421
437
  *
422
- * This is deliberately a lower bound on shadowing: conditional sx, nested sx,
423
- * callback results, and nested selector properties are not inspected. A clean
424
- * result therefore proves only that the registered top-level key sets are
425
- * disjoint; it is not proof that no component can shadow the baseline.
438
+ * This is deliberately a lower bound on shadowing: unknown normalisation gaps
439
+ * remain. A clean result proves only that the registered top-level key sets
440
+ * are disjoint; it is not proof that no component can shadow the baseline.
426
441
  */
427
442
  export function assertKitSxDisjoint(
428
443
  theme,
@@ -593,6 +608,214 @@ export function reportKitSxBypasses(sources = [], { registry } = {}) {
593
608
  return { findings };
594
609
  }
595
610
 
611
+ const BASIC_CSS_COLOUR_NAMES = new Set([
612
+ 'aqua',
613
+ 'black',
614
+ 'blue',
615
+ 'fuchsia',
616
+ 'gray',
617
+ 'green',
618
+ 'grey',
619
+ 'lime',
620
+ 'maroon',
621
+ 'navy',
622
+ 'olive',
623
+ 'purple',
624
+ 'red',
625
+ 'silver',
626
+ 'teal',
627
+ 'white',
628
+ 'yellow',
629
+ ]);
630
+
631
+ const MUI_DEFAULT_NUMERIC_RAMP_NAMES = new Set([
632
+ 'amber',
633
+ 'blue',
634
+ 'bluegrey',
635
+ 'brown',
636
+ 'cyan',
637
+ 'deeporange',
638
+ 'deeppurple',
639
+ 'green',
640
+ 'grey',
641
+ 'indigo',
642
+ 'lightblue',
643
+ 'lightgreen',
644
+ 'lime',
645
+ 'orange',
646
+ 'pink',
647
+ 'purple',
648
+ 'red',
649
+ 'teal',
650
+ 'yellow',
651
+ ]);
652
+
653
+ const HEX_COLOUR_PATTERN = /#(?:[\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})(?![\da-f])/gi;
654
+ const RGB_COLOUR_PATTERN = /rgba?\(\s*[\d.]+\s*,\s*[\d.]+\s*,\s*[\d.]+(?:\s*,\s*[\d.]+)?\s*\)/gi;
655
+ const NAMED_COLOUR_PATTERN = new RegExp(
656
+ `\\b(?:${[...BASIC_CSS_COLOUR_NAMES].join('|')})\\b`,
657
+ 'gi',
658
+ );
659
+ const COLOUR_PROPERTY_PATTERN = /\b(?:background(?:Color)?|bgcolor|border(?:Top|Right|Bottom|Left)?(?:Color)?|boxShadow|caretColor|color|fill|fillStyle|outline(?:Color)?|stroke|strokeStyle|textShadow|WebkitTextFillColor)\s*(?::|=)\s*$/;
660
+
661
+ function colourKey(value) {
662
+ const colour = parseColour(value);
663
+ return colour ? `${colour.r},${colour.g},${colour.b},${colour.a}` : null;
664
+ }
665
+
666
+ function collectBaselineColourKeys(...roots) {
667
+ const keys = new Set();
668
+ const visit = (value) => {
669
+ if (typeof value === 'string') {
670
+ for (const match of value.matchAll(HEX_COLOUR_PATTERN)) {
671
+ const key = colourKey(match[0]);
672
+ if (key) keys.add(key);
673
+ }
674
+ for (const match of value.matchAll(RGB_COLOUR_PATTERN)) {
675
+ const key = colourKey(match[0]);
676
+ if (key) keys.add(key);
677
+ }
678
+ return;
679
+ }
680
+ if (Array.isArray(value)) {
681
+ value.forEach(visit);
682
+ return;
683
+ }
684
+ if (value && typeof value === 'object') Object.values(value).forEach(visit);
685
+ };
686
+ roots.forEach(visit);
687
+ return keys;
688
+ }
689
+
690
+ function maskComments(source) {
691
+ const characters = [...source];
692
+ let quote = null;
693
+ for (let index = 0; index < characters.length; index += 1) {
694
+ const character = characters[index];
695
+ if (quote) {
696
+ if (character === quote && characters[index - 1] !== '\\') quote = null;
697
+ continue;
698
+ }
699
+ if (character === '"' || character === "'" || character === '`') {
700
+ quote = character;
701
+ continue;
702
+ }
703
+ if (character === '/' && characters[index + 1] === '/') {
704
+ for (; index < characters.length && characters[index] !== '\n'; index += 1) {
705
+ characters[index] = ' ';
706
+ }
707
+ index -= 1;
708
+ } else if (character === '/' && characters[index + 1] === '*') {
709
+ characters[index] = ' ';
710
+ characters[index + 1] = ' ';
711
+ index += 2;
712
+ while (index < characters.length
713
+ && !(characters[index] === '*' && characters[index + 1] === '/')) {
714
+ if (characters[index] !== '\n' && characters[index] !== '\r') characters[index] = ' ';
715
+ index += 1;
716
+ }
717
+ if (index < characters.length) {
718
+ characters[index] = ' ';
719
+ characters[index + 1] = ' ';
720
+ index += 1;
721
+ }
722
+ }
723
+ }
724
+ return characters.join('');
725
+ }
726
+
727
+ function maskQrPrintDocument(source, path) {
728
+ if (!path.replaceAll('\\', '/').endsWith('/QrSignupManager.jsx')) return source;
729
+ const call = /\bprintWindow\.document\.write\s*\(\s*`/g.exec(source);
730
+ if (!call) return source;
731
+ const start = call.index + call[0].lastIndexOf('`');
732
+ let end = start + 1;
733
+ while (end < source.length) {
734
+ if (source[end] === '`' && source[end - 1] !== '\\') break;
735
+ end += 1;
736
+ }
737
+ if (end >= source.length) return source;
738
+
739
+ // The generated print document is standalone HTML rendered outside the
740
+ // React tree, where no application theme is available. Mask the region as
741
+ // one unit instead of accumulating permanent per-colour exemptions.
742
+ return source.slice(0, start)
743
+ + source.slice(start, end + 1).replace(/[^\r\n]/g, ' ')
744
+ + source.slice(end + 1);
745
+ }
746
+
747
+ function normalizedSources(sources) {
748
+ return sources.map((entry, index) =>
749
+ typeof entry === 'string'
750
+ ? { path: `source-${index + 1}`, source: entry }
751
+ : { path: entry.path || `source-${index + 1}`, source: entry.source || '' },
752
+ );
753
+ }
754
+
755
+ /**
756
+ * Reports colour literals in component source that do not come from the
757
+ * baseline palette, plus report-only uses of MUI's untouched numeric ramps.
758
+ */
759
+ export function reportOffPaletteColours(
760
+ sources = [],
761
+ { palette = BASELINE_PALETTE, baselineStatic = BASELINE_STATIC } = {},
762
+ ) {
763
+ const allowedColours = collectBaselineColourKeys(palette, baselineStatic);
764
+ const findings = [];
765
+ const stringPattern = /(["'`])((?:\\.|(?!\1)[\s\S])*)\1/g;
766
+
767
+ for (const { path, source } of normalizedSources(sources)) {
768
+ const normalizedPath = path.replaceAll('\\', '/');
769
+ if (/(^|\/)src\/theme\//.test(normalizedPath)) continue;
770
+ const scanSource = maskComments(maskQrPrintDocument(source, normalizedPath));
771
+
772
+ for (const match of scanSource.matchAll(stringPattern)) {
773
+ const value = match[2].trim();
774
+ const line = source.slice(0, match.index).split(/\r?\n/).length;
775
+ const surface = `${path}:${line}.colour`;
776
+
777
+ for (const hex of value.matchAll(HEX_COLOUR_PATTERN)) {
778
+ if (!allowedColours.has(colourKey(hex[0]))) {
779
+ findings.push({
780
+ surface,
781
+ reason: `Off-palette hex colour "${hex[0]}" is not derived from the baseline palette.`,
782
+ });
783
+ }
784
+ }
785
+
786
+ for (const rgb of value.matchAll(RGB_COLOUR_PATTERN)) {
787
+ if (!allowedColours.has(colourKey(rgb[0]))) {
788
+ findings.push({
789
+ surface,
790
+ reason: `Off-palette rgb colour "${rgb[0]}" is not derived from the baseline palette.`,
791
+ });
792
+ }
793
+ }
794
+
795
+ const before = scanSource.slice(Math.max(0, match.index - 40), match.index);
796
+ const isColourProperty = COLOUR_PROPERTY_PATTERN.test(before);
797
+ const numericRamp = value.match(/^([a-z]+)\.(\d+)$/i);
798
+ if (isColourProperty && !numericRamp) {
799
+ for (const namedColour of value.matchAll(NAMED_COLOUR_PATTERN)) {
800
+ findings.push({
801
+ surface,
802
+ reason: `Named CSS colour "${namedColour[0]}" bypasses the baseline palette.`,
803
+ });
804
+ }
805
+ }
806
+
807
+ if (numericRamp && MUI_DEFAULT_NUMERIC_RAMP_NAMES.has(numericRamp[1].toLowerCase())) {
808
+ findings.push({
809
+ surface,
810
+ reason: `Report only: "${value}" uses MUI's untouched numeric palette ramp, not a baseline token.`,
811
+ });
812
+ }
813
+ }
814
+ }
815
+
816
+ return { findings };
817
+ }
818
+
596
819
  /** Reports adoption signals as numbers; it never changes completeness. */
597
820
  export function reportThemeAdoption(sources = []) {
598
821
  const normalized = sources.map((entry, index) =>
@@ -154,6 +154,11 @@ describe('ChartFrame', () => {
154
154
 
155
155
  fireEvent.click(screen.getByRole('button', { name: 'Export PNG' }));
156
156
  await vi.waitFor(() => expect(chartExport.exportChartPng).toHaveBeenCalledOnce());
157
+ expect(chartExport.exportChartPng).toHaveBeenCalledWith(
158
+ expect.any(HTMLElement),
159
+ 'chart.png',
160
+ '#fff',
161
+ );
157
162
  await vi.waitFor(() => expect(onExportPng).toHaveBeenCalledOnce());
158
163
  });
159
164
 
@@ -10,15 +10,17 @@ import {
10
10
  calculateContrastRatio,
11
11
  createAppTheme,
12
12
  KIT_COMPONENT_SX_REGISTRY,
13
+ reportOffPaletteColours,
13
14
  reportKitSxBypasses,
14
15
  THEME_COMPLETENESS_SURFACES,
15
16
  } from '../src/theme';
17
+ import { BASELINE_PALETTE } from '../src/theme/tokens';
16
18
 
17
- function jsxSources(directory) {
19
+ function sourceFiles(directory) {
18
20
  return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
19
21
  const entryPath = path.join(directory, entry.name);
20
- if (entry.isDirectory()) return jsxSources(entryPath);
21
- if (!entry.name.endsWith('.jsx')) return [];
22
+ if (entry.isDirectory()) return sourceFiles(entryPath);
23
+ if (!/\.jsx?$/.test(entry.name)) return [];
22
24
  return [{ path: path.relative(process.cwd(), entryPath), source: fs.readFileSync(entryPath, 'utf8') }];
23
25
  });
24
26
  }
@@ -209,6 +211,27 @@ describe('kit sx disjointness', () => {
209
211
  expect(bottomNavActionResult.findings).toEqual([]);
210
212
  });
211
213
 
214
+ it('normalises bgcolor against a backgroundColor baseline default', () => {
215
+ const result = assertKitSxDisjoint({
216
+ components: {
217
+ MuiPaper: {
218
+ styleOverrides: { root: { backgroundColor: '#FFFFFF' } },
219
+ },
220
+ },
221
+ }, {
222
+ registry: [{
223
+ component: 'FixturePaper',
224
+ muiComponent: 'MuiPaper',
225
+ sx: { bgcolor: '#FFFFFF' },
226
+ }],
227
+ });
228
+
229
+ expect(result.findings).toEqual([{
230
+ surface: 'FixturePaper.MuiPaper.bgcolor',
231
+ reason: expect.stringMatching(/FixturePaper.*MuiPaper.*bgcolor/),
232
+ }]);
233
+ });
234
+
212
235
  it('keeps every registered kit sx object disjoint from the baseline', () => {
213
236
  expect(KIT_COMPONENT_SX_REGISTRY.length).toBeGreaterThan(2);
214
237
  expect(assertKitSxDisjoint(theme, {
@@ -290,7 +313,7 @@ describe('kit sx disjointness', () => {
290
313
 
291
314
  it('does not let current kit source bypass the exported-object convention', () => {
292
315
  expect(reportKitSxBypasses(
293
- jsxSources(path.resolve('src')),
316
+ sourceFiles(path.resolve('src')),
294
317
  { registry: KIT_COMPONENT_SX_REGISTRY },
295
318
  ).findings).toEqual([]);
296
319
  });
@@ -353,3 +376,132 @@ describe('kit sx disjointness', () => {
353
376
  ]);
354
377
  });
355
378
  });
379
+
380
+ describe('off-palette colour reporting', () => {
381
+ it('flags an unknown hex literal and accepts a baseline palette value', () => {
382
+ const result = reportOffPaletteColours([
383
+ { path: 'Unknown.jsx', source: "const sx = { bgcolor: '#123456' };" },
384
+ { path: 'Baseline.jsx', source: "const sx = { bgcolor: '#F4F5F6' };" },
385
+ { path: 'Token.jsx', source: "const sx = { bgcolor: 'background.subtle' };" },
386
+ ]);
387
+
388
+ expect(result.findings).toEqual([{
389
+ surface: 'Unknown.jsx:1.colour',
390
+ reason: expect.stringMatching(/#123456.*baseline palette/i),
391
+ }]);
392
+ });
393
+
394
+ it('flags a named CSS colour used by sx', () => {
395
+ const result = reportOffPaletteColours([{
396
+ path: 'Named.jsx',
397
+ source: "const sx = { bgcolor: 'white' };",
398
+ }]);
399
+
400
+ expect(result.findings).toEqual([{
401
+ surface: 'Named.jsx:1.colour',
402
+ reason: expect.stringMatching(/named CSS colour.*white.*baseline palette/i),
403
+ }]);
404
+ });
405
+
406
+ it('flags rgba literals and named Canvas fill colours', () => {
407
+ const result = reportOffPaletteColours([
408
+ {
409
+ path: 'Border.jsx',
410
+ source: "const sx = { border: '1px solid rgba(0,0,0,0.2)' };",
411
+ },
412
+ {
413
+ path: 'exportChart.js',
414
+ source: "context.fillStyle = 'white';",
415
+ },
416
+ {
417
+ path: 'NamedBorder.jsx',
418
+ source: "const sx = { border: '1px solid white', boxShadow: '0 0 1px black' };",
419
+ },
420
+ ]);
421
+
422
+ expect(result.findings).toEqual([
423
+ {
424
+ surface: 'Border.jsx:1.colour',
425
+ reason: expect.stringMatching(/rgba\(0,0,0,0\.2\).*baseline palette/i),
426
+ },
427
+ {
428
+ surface: 'exportChart.js:1.colour',
429
+ reason: expect.stringMatching(/named CSS colour.*white.*baseline palette/i),
430
+ },
431
+ {
432
+ surface: 'NamedBorder.jsx:1.colour',
433
+ reason: expect.stringMatching(/named CSS colour.*white.*baseline palette/i),
434
+ },
435
+ {
436
+ surface: 'NamedBorder.jsx:1.colour',
437
+ reason: expect.stringMatching(/named CSS colour.*black.*baseline palette/i),
438
+ },
439
+ ]);
440
+ });
441
+
442
+ it('excludes only QrSignupManager print-document HTML', () => {
443
+ const result = reportOffPaletteColours([{
444
+ path: 'src/components/QrSignupManager.jsx',
445
+ source: [
446
+ "const panel = { bgcolor: '#123456' };",
447
+ 'printWindow.document.write(`',
448
+ ' <style>body { background: #654321; }</style>',
449
+ '`);',
450
+ ].join('\n'),
451
+ }]);
452
+
453
+ expect(result.findings).toHaveLength(1);
454
+ expect(result.findings[0]).toEqual({
455
+ surface: 'src/components/QrSignupManager.jsx:1.colour',
456
+ reason: expect.stringMatching(/#123456/),
457
+ });
458
+ });
459
+
460
+ it('derives its allowlist from the supplied palette', () => {
461
+ const source = [{ path: 'NewToken.jsx', source: "const sx = { bgcolor: '#123456' };" }];
462
+ const extendedPalette = {
463
+ ...BASELINE_PALETTE,
464
+ fixture: { main: '#123456' },
465
+ };
466
+
467
+ expect(reportOffPaletteColours(source).findings).toHaveLength(1);
468
+ expect(reportOffPaletteColours(source, { palette: extendedPalette }).findings).toEqual([]);
469
+ });
470
+
471
+ it('reports MUI numeric ramps separately without treating token paths as literals', () => {
472
+ const result = reportOffPaletteColours([{
473
+ path: 'Ramp.jsx',
474
+ source: "const sx = { bgcolor: 'grey.50', borderColor: 'divider', color: 'success.bg', transition: 'release.50' };",
475
+ }]);
476
+
477
+ expect(result.findings).toEqual([{
478
+ surface: 'Ramp.jsx:1.colour',
479
+ reason: expect.stringMatching(/^Report only:.*grey\.50.*untouched numeric palette ramp/i),
480
+ }]);
481
+ });
482
+
483
+ it('ignores comment issue numbers and theme implementation sources', () => {
484
+ const result = reportOffPaletteColours([
485
+ {
486
+ path: 'src/components/charts/chartLabels.js',
487
+ source: '// MUI issues mui-x#18768 and #18399',
488
+ },
489
+ {
490
+ path: 'src/theme/createAppTheme.js',
491
+ source: "const contrastSurface = '#123456';",
492
+ },
493
+ ]);
494
+
495
+ expect(result.findings).toEqual([]);
496
+ });
497
+
498
+ it('keeps current kit source free of hard off-palette findings', () => {
499
+ const result = reportOffPaletteColours(sourceFiles(path.resolve('src')));
500
+ const hardFindings = result.findings.filter(
501
+ ({ reason }) => !reason.startsWith('Report only:'),
502
+ );
503
+
504
+ expect(hardFindings).toEqual([]);
505
+ expect(result.findings.some(({ reason }) => reason.includes('grey.50'))).toBe(true);
506
+ });
507
+ });