@multiplatform.one/theme 7.7.6 → 7.10.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 (47) hide show
  1. package/package.json +5 -5
  2. package/src/audit/constraintAudit.spec.ts +157 -0
  3. package/src/audit/constraintAudit.ts +154 -35
  4. package/src/audit/constraintScope.spec.ts +48 -0
  5. package/src/audit/index.ts +16 -0
  6. package/src/audit/themeMatrix.spec.ts +292 -1
  7. package/src/audit/themeMatrix.ts +1015 -22
  8. package/src/theme/Intent.stories.tsx +88 -0
  9. package/src/theme/Preset.stories.tsx +80 -0
  10. package/src/theme/Surface.spec.tsx +6 -0
  11. package/src/theme/Surface.stories.tsx +91 -0
  12. package/src/theme/ThemeProvider.stories.tsx +50 -0
  13. package/src/theme/Tint.stories.tsx +98 -0
  14. package/src/theme/chartPalette.spec.ts +128 -5
  15. package/src/theme/chartPalette.ts +105 -22
  16. package/src/theme/colorRules.spec.ts +92 -39
  17. package/src/theme/colorRules.ts +4 -6
  18. package/src/theme/createDefaultThemeConfig.ts +1 -1
  19. package/src/theme/createThemes.ts +58 -3
  20. package/src/theme/devtools/ColorLineVisualizer.stories.tsx +36 -0
  21. package/src/theme/devtools/ThemeDevtoolsPanel.stories.tsx +16 -0
  22. package/src/theme/glyphPaint.spec.ts +14 -0
  23. package/src/theme/glyphPaint.ts +2 -1
  24. package/src/theme/intent.spec.tsx +1 -0
  25. package/src/theme/layoutTokensHooks.spec.tsx +1 -0
  26. package/src/theme/recipeInputs.ts +9 -9
  27. package/src/theme/resolveKnobs.spec.ts +2 -2
  28. package/src/theme/sizeLadder.spec.ts +19 -10
  29. package/src/theme/sizeRecipes.ts +2 -2
  30. package/src/theme/themeValue.spec.ts +2 -2
  31. package/src/theme/useResolvedKnobsBehavior.spec.tsx +6 -0
  32. package/types/audit/constraintAudit.d.ts +17 -1
  33. package/types/audit/constraintAudit.d.ts.map +1 -1
  34. package/types/audit/index.d.ts +2 -2
  35. package/types/audit/index.d.ts.map +1 -1
  36. package/types/audit/themeMatrix.d.ts +135 -2
  37. package/types/audit/themeMatrix.d.ts.map +1 -1
  38. package/types/theme/chartPalette.d.ts +6 -4
  39. package/types/theme/chartPalette.d.ts.map +1 -1
  40. package/types/theme/colorRules.d.ts +3 -3
  41. package/types/theme/colorRules.d.ts.map +1 -1
  42. package/types/theme/createThemes.d.ts +1 -1
  43. package/types/theme/createThemes.d.ts.map +1 -1
  44. package/types/theme/glyphPaint.d.ts.map +1 -1
  45. package/types/theme/recipeInputs.d.ts +8 -8
  46. package/types/theme/recipeInputs.d.ts.map +1 -1
  47. package/types/theme/sizeRecipes.d.ts +2 -2
@@ -5,6 +5,11 @@ import {
5
5
  captureMatrixSnapshot,
6
6
  diffGeometry,
7
7
  evaluateNoBreakage,
8
+ evaluateIconContrast,
9
+ evaluateChartProof,
10
+ type MatrixChartSample,
11
+ type MatrixIconSample,
12
+ type IconUnmeasurableReason,
8
13
  evaluateTextContrast,
9
14
  type MatrixSnapshot,
10
15
  type MatrixTextSample,
@@ -31,9 +36,112 @@ function snapshotFromRecipeInputs(inputs: Partial<SizeRecipeInputs>): MatrixSnap
31
36
  rowGap: `${recipe.gap}px`,
32
37
  },
33
38
  }));
34
- return { storyRendered: true, geometry, textSamples: [] };
39
+ return {
40
+ storyRendered: true,
41
+ geometry,
42
+ textSamples: [],
43
+ iconSamples: [],
44
+ chartSamples: [],
45
+ svgRootCount: 0,
46
+ };
35
47
  }
36
48
 
49
+ describe("declared chart proof keeps uncertainty visible", () => {
50
+ const sample = (overrides: Partial<MatrixChartSample> = {}): MatrixChartSample => ({
51
+ key: "chart:0",
52
+ kind: "pie",
53
+ context: "Revenue",
54
+ label: "Revenue by region",
55
+ background: "#111111",
56
+ backgroundResolved: true,
57
+ marks: [
58
+ { key: "slice:0", label: "North", paint: "#bbbbbb", background: "#111111" },
59
+ { key: "slice:1", label: "South", paint: "#dddddd", background: "#111111" },
60
+ ],
61
+ labels: [],
62
+ separation: { declared: false },
63
+ datumText: { declared: true, expected: 2, observed: 2, complete: true },
64
+ ...overrides,
65
+ });
66
+
67
+ it("names adjacent slice contrast and does not accept a boolean gap claim", () => {
68
+ const result = evaluateChartProof([sample({ separation: { declared: true } })]);
69
+ expect(result.ok).toBe(false);
70
+ expect(result.violations.some((item) => item.rule === "adjacent-pair")).toBe(true);
71
+ expect(result.unverified.some((item) => item.reason === "unverified-separation")).toBe(true);
72
+ });
73
+
74
+ it("requires two measured physical pixels between bars", () => {
75
+ const separated = sample({
76
+ kind: "bar",
77
+ separation: { declared: true, method: "rect-bounds", minimumGapPx: 2 },
78
+ });
79
+ expect(evaluateChartProof([separated]).ok).toBe(true);
80
+ expect(
81
+ evaluateChartProof([
82
+ { ...separated, separation: { declared: true, method: "rect-bounds", minimumGapPx: 1.99 } },
83
+ ]).ok,
84
+ ).toBe(false);
85
+ });
86
+
87
+ it("does not round a below-floor paint pair green", () => {
88
+ const result = evaluateChartProof([
89
+ sample({
90
+ marks: [{ key: "almost", label: "Almost", paint: "#000000", background: "#595959" }],
91
+ }),
92
+ ]);
93
+ const violation = result.violations.find((item) => item.rule === "mark-backdrop");
94
+ expect(violation?.ratio).toBeGreaterThan(2.995);
95
+ expect(violation?.ratio).toBeLessThan(3);
96
+ });
97
+
98
+ it("never treats a rect certificate as proof of pie center separation", () => {
99
+ expect(
100
+ evaluateChartProof([
101
+ sample({ separation: { declared: true, method: "rect-bounds", minimumGapPx: 8 } }),
102
+ ]).ok,
103
+ ).toBe(false);
104
+ });
105
+
106
+ it.each(["unknown-kind", "no-marks", "unsupported-paint"] as const)(
107
+ "keeps %s named and red",
108
+ (reason) => {
109
+ const input =
110
+ reason === "unknown-kind"
111
+ ? sample({ kind: "radar" })
112
+ : reason === "no-marks"
113
+ ? sample({ marks: [] })
114
+ : sample({
115
+ marks: [
116
+ {
117
+ key: "gradient",
118
+ label: "North",
119
+ background: "#111111",
120
+ unmeasurable: "unsupported-paint",
121
+ },
122
+ ],
123
+ });
124
+ expect(evaluateChartProof([input]).unverified.some((item) => item.reason === reason)).toBe(
125
+ true,
126
+ );
127
+ },
128
+ );
129
+
130
+ it("requires a root label, readable axis text and a complete datum alternative", () => {
131
+ const result = evaluateChartProof([
132
+ sample({
133
+ label: "",
134
+ labels: [{ key: "tick", label: "May", paint: "#333333", background: "#111111" }],
135
+ datumText: { declared: true, expected: 13, observed: 12, complete: false },
136
+ }),
137
+ ]);
138
+ expect(result.violations.map((item) => item.rule)).toEqual(
139
+ expect.arrayContaining(["missing-label", "axis-text"]),
140
+ );
141
+ expect(result.unverified.some((item) => item.reason === "incomplete-datum-text")).toBe(true);
142
+ });
143
+ });
144
+
37
145
  describe("theme-axis geometry invariance (LC-80 tripwire)", () => {
38
146
  it("identical recipe inputs across theme colours keep the diff clean", () => {
39
147
  const base = snapshotFromRecipeInputs({});
@@ -87,6 +195,8 @@ describe("theme-axis geometry invariance (LC-80 tripwire)", () => {
87
195
  });
88
196
  });
89
197
 
198
+ const cleanIcons = evaluateIconContrast([]);
199
+
90
200
  describe("preset-axis no-breakage (two assertions, never one)", () => {
91
201
  const cleanContrast = { measured: 10, unmeasurable: 0, violations: [] };
92
202
 
@@ -99,6 +209,8 @@ describe("preset-axis no-breakage (two assertions, never one)", () => {
99
209
  expect(diffGeometry(defaultCell, boldCell).identical).toBe(false);
100
210
 
101
211
  const result = evaluateNoBreakage({
212
+ icons: cleanIcons,
213
+ baselineIcons: cleanIcons,
102
214
  storyRendered: true,
103
215
  offScaleViolations: 0,
104
216
  baselineOffScaleViolations: 0,
@@ -110,6 +222,8 @@ describe("preset-axis no-breakage (two assertions, never one)", () => {
110
222
 
111
223
  it("FAILS when a preset cell falls off the knob scales beyond the default cell", () => {
112
224
  const result = evaluateNoBreakage({
225
+ icons: cleanIcons,
226
+ baselineIcons: cleanIcons,
113
227
  storyRendered: true,
114
228
  offScaleViolations: 3,
115
229
  baselineOffScaleViolations: 1,
@@ -122,6 +236,8 @@ describe("preset-axis no-breakage (two assertions, never one)", () => {
122
236
 
123
237
  it("FAILS when a preset cell loses contrast the default cell had", () => {
124
238
  const result = evaluateNoBreakage({
239
+ icons: cleanIcons,
240
+ baselineIcons: cleanIcons,
125
241
  storyRendered: true,
126
242
  offScaleViolations: 0,
127
243
  baselineOffScaleViolations: 0,
@@ -147,6 +263,8 @@ describe("preset-axis no-breakage (two assertions, never one)", () => {
147
263
 
148
264
  it("FAILS when the story does not render at all", () => {
149
265
  const result = evaluateNoBreakage({
266
+ icons: cleanIcons,
267
+ baselineIcons: cleanIcons,
150
268
  storyRendered: false,
151
269
  offScaleViolations: 0,
152
270
  baselineOffScaleViolations: 0,
@@ -156,6 +274,60 @@ describe("preset-axis no-breakage (two assertions, never one)", () => {
156
274
  expect(result.ok).toBe(false);
157
275
  expect(result.failures[0]).toContain("render");
158
276
  });
277
+
278
+ it("names a replacement structural failure even when the count stays equal or falls", () => {
279
+ const input = {
280
+ storyRendered: true,
281
+ offScaleViolations: 1,
282
+ baselineOffScaleViolations: 1,
283
+ offScaleSignatures: ["new control :: borderRadius: 15px is off-scale"],
284
+ baselineOffScaleSignatures: ["old control :: borderRadius: 14px is off-scale"],
285
+ contrast: cleanContrast,
286
+ baselineContrastViolations: 0,
287
+ };
288
+ for (const baselineOffScaleViolations of [1, 2]) {
289
+ const result = evaluateNoBreakage({ ...input, baselineOffScaleViolations });
290
+ expect(result.ok).toBe(false);
291
+ expect(result.failures[0]).toContain("new control");
292
+ expect(result.failures[0]).not.toContain("old control");
293
+ }
294
+ expect(
295
+ evaluateNoBreakage({ ...input, offScaleSignatures: input.baselineOffScaleSignatures }).ok,
296
+ ).toBe(true);
297
+ });
298
+
299
+ it("names replacement text failures regardless of count and preserves repeated identities", () => {
300
+ const old = {
301
+ key: "old text",
302
+ text: "Old",
303
+ color: "#aaaaaa",
304
+ background: "#ffffff",
305
+ ratio: 2.32,
306
+ required: 4.5,
307
+ };
308
+ const replacement = { ...old, key: "new text", text: "New" };
309
+ const input = {
310
+ storyRendered: true,
311
+ offScaleViolations: 0,
312
+ baselineOffScaleViolations: 0,
313
+ contrast: { ...cleanContrast, violations: [replacement] },
314
+ baselineContrastViolations: 1,
315
+ baselineContrast: { ...cleanContrast, violations: [old] },
316
+ };
317
+ for (const baseline of [[old], [old, old]]) {
318
+ const result = evaluateNoBreakage({
319
+ ...input,
320
+ baselineContrastViolations: baseline.length,
321
+ baselineContrast: { ...cleanContrast, violations: baseline },
322
+ });
323
+ expect(result.ok).toBe(false);
324
+ expect(result.failures[0]).toContain("new text");
325
+ }
326
+ expect(evaluateNoBreakage({ ...input, contrast: input.baselineContrast }).ok).toBe(true);
327
+ expect(
328
+ evaluateNoBreakage({ ...input, contrast: { ...cleanContrast, violations: [old, old] } }).ok,
329
+ ).toBe(false);
330
+ });
159
331
  });
160
332
 
161
333
  describe("evaluateTextContrast (the assertion downstream repos call)", () => {
@@ -184,6 +356,14 @@ describe("evaluateTextContrast (the assertion downstream repos call)", () => {
184
356
  expect(result.violations[0].required).toBe(4.5);
185
357
  });
186
358
 
359
+ it("keeps a just-below-AA text sample in the violations", () => {
360
+ const result = evaluateTextContrast([sample({ color: "#77767c" })]);
361
+ expect(result.measured).toBe(1);
362
+ expect(result.violations).toHaveLength(1);
363
+ expect(result.violations[0].ratio).toBeGreaterThan(4.495);
364
+ expect(result.violations[0].ratio).toBeLessThan(4.5);
365
+ });
366
+
187
367
  it("holds large text (>=24px, or >=18.66px bold) to the 3:1 floor instead", () => {
188
368
  // #949494 on white is ~3.5:1 — fails body text, clears large text.
189
369
  const failing = evaluateTextContrast([sample({ color: "#949494" })]);
@@ -272,3 +452,114 @@ describe("captureMatrixSnapshot keys survive the theme axis", () => {
272
452
  expect(captureMatrixSnapshot().storyRendered).toBe(false);
273
453
  });
274
454
  });
455
+
456
+ describe("icon contrast keeps unsupported conveying paint unverified", () => {
457
+ const sample = (overrides: Partial<MatrixIconSample> = {}): MatrixIconSample => ({
458
+ key: "button:0/svg:0",
459
+ context: "Open",
460
+ status: "conveying",
461
+ paint: "#ffffff",
462
+ background: "#111111",
463
+ backgroundResolved: true,
464
+ overImage: false,
465
+ shapeCount: 1,
466
+ distinctPaints: 1,
467
+ paintKinds: ["fill"],
468
+ rect: { x: 0, y: 0, w: 24, h: 24 },
469
+ ...overrides,
470
+ });
471
+
472
+ it("holds real glyph paint to exactly 3:1 regardless of text rules", () => {
473
+ expect(evaluateIconContrast([sample()]).ok).toBe(true);
474
+ const result = evaluateIconContrast([sample({ paint: "#222222" })]);
475
+ expect(result.ok).toBe(false);
476
+ expect(result.measured).toBe(1);
477
+ expect(result.violations[0].required).toBe(3);
478
+ expect(result.violations[0].context).toBe("Open");
479
+ });
480
+
481
+ it("keeps a just-below-3 icon sample in the violations", () => {
482
+ const result = evaluateIconContrast([sample({ paint: "#000000", background: "#595959" })]);
483
+ expect(result.ok).toBe(false);
484
+ expect(result.measured).toBe(1);
485
+ expect(result.violations).toHaveLength(1);
486
+ expect(result.violations[0].ratio).toBeGreaterThan(2.995);
487
+ expect(result.violations[0].ratio).toBeLessThan(3);
488
+ });
489
+
490
+ it.each<IconUnmeasurableReason>([
491
+ "unresolved-backdrop",
492
+ "masked-or-filtered",
493
+ "use-indirection",
494
+ "unsupported-paint",
495
+ "unsupported-compositing",
496
+ "multicolor",
497
+ "no-paint",
498
+ ])("does not pass a candidate with %s", (reason) => {
499
+ const result = evaluateIconContrast([sample({ unmeasurable: reason })]);
500
+ expect(result.ok).toBe(false);
501
+ expect(result.measured).toBe(0);
502
+ expect(result.candidates).toBe(1);
503
+ expect(result.unverified).toEqual([{ key: "button:0/svg:0", context: "Open", reason }]);
504
+ });
505
+
506
+ it("accounts for every candidate and exclusion, including invalid color", () => {
507
+ const result = evaluateIconContrast([
508
+ sample(),
509
+ sample({ paint: "not-a-color" }),
510
+ sample({ status: "disabled-control" }),
511
+ sample({ status: "decorative-standalone" }),
512
+ sample({ status: "hidden" }),
513
+ ]);
514
+ expect(result.candidates).toBe(result.measured + result.unverified.length);
515
+ expect(
516
+ result.candidates +
517
+ result.excludedDisabled +
518
+ result.excludedDecorative +
519
+ result.excludedHidden,
520
+ ).toBe(5);
521
+ expect(result.ok).toBe(false);
522
+ expect(evaluateIconContrast([]).ok).toBe(true);
523
+ });
524
+
525
+ it("names a replacement icon failure even when total findings stay equal", () => {
526
+ const baselineIcons = evaluateIconContrast([sample({ context: "Old", paint: "#222222" })]);
527
+ const icons = evaluateIconContrast([sample({ context: "New", paint: "#222222" })]);
528
+ const result = evaluateNoBreakage({
529
+ storyRendered: true,
530
+ offScaleViolations: 0,
531
+ baselineOffScaleViolations: 0,
532
+ contrast: { measured: 1, unmeasurable: 0, violations: [] },
533
+ baselineContrastViolations: 0,
534
+ icons,
535
+ baselineIcons,
536
+ });
537
+ expect(result.ok).toBe(false);
538
+ expect(result.failures[0]).toContain("New");
539
+ expect(result.failures[0]).not.toContain("Old");
540
+ const sameDebt = evaluateNoBreakage({
541
+ storyRendered: true,
542
+ offScaleViolations: 0,
543
+ baselineOffScaleViolations: 0,
544
+ contrast: { measured: 1, unmeasurable: 0, violations: [] },
545
+ baselineContrastViolations: 0,
546
+ icons: baselineIcons,
547
+ baselineIcons,
548
+ });
549
+ expect(sameDebt.ok).toBe(true); // Attribution only. Absolute icons.ok still fails.
550
+ expect(baselineIcons.ok).toBe(false);
551
+ });
552
+
553
+ it("preserves legacy text-only calls but rejects an incomplete icon pair", () => {
554
+ const textOnly = {
555
+ storyRendered: true,
556
+ offScaleViolations: 0,
557
+ baselineOffScaleViolations: 0,
558
+ contrast: { measured: 1, unmeasurable: 0, violations: [] },
559
+ baselineContrastViolations: 0,
560
+ };
561
+ expect(evaluateNoBreakage(textOnly).ok).toBe(true);
562
+ expect(evaluateNoBreakage({ ...textOnly, icons: cleanIcons }).ok).toBe(false);
563
+ expect(evaluateNoBreakage({ ...textOnly, baselineIcons: cleanIcons }).ok).toBe(false);
564
+ });
565
+ });