@multiplatform.one/theme 7.7.6 → 7.8.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/theme",
3
- "version": "7.7.6",
3
+ "version": "7.8.0",
4
4
  "description": "Tamagui theme system for multiplatform.one",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -53,8 +53,8 @@
53
53
  "@tamagui/toast": "2.7.6",
54
54
  "@tamagui/web": "2.7.6",
55
55
  "react-cookie": "^8.1.2",
56
- "@multiplatform.one/platform": "7.7.6",
57
- "@multiplatform.one/store": "7.7.6"
56
+ "@multiplatform.one/store": "7.8.0",
57
+ "@multiplatform.one/platform": "7.8.0"
58
58
  },
59
59
  "devDependencies": {
60
60
  "@tamagui/animations-css": "2.7.6",
@@ -68,8 +68,8 @@
68
68
  "tamagui": "2.7.6",
69
69
  "typescript": "~5.9.3",
70
70
  "vitest": "^4.1.5",
71
- "@multiplatform.one/config": "7.7.6",
72
- "@multiplatform.one/test-utils": "7.7.6"
71
+ "@multiplatform.one/test-utils": "7.8.0",
72
+ "@multiplatform.one/config": "7.8.0"
73
73
  },
74
74
  "peerDependencies": {
75
75
  "@tamagui/animations-css": "^2.0.0-rc",
@@ -0,0 +1,157 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { defaultConfig } from "@tamagui/config/v5";
3
+ import { defaultHeadingFont } from "../theme/defaults/fonts";
4
+ import { defaultKnobs } from "../theme/knobs";
5
+ import { resolveKnobs } from "../theme/resolveKnobs";
6
+ import { GAP_KNOB, GAP_LG_KNOB, runConstraintAudit } from "./constraintAudit";
7
+
8
+ afterEach(() => {
9
+ document.body.innerHTML = "";
10
+ });
11
+
12
+ // Deliberately loses module scope, like Playwright page.evaluate.
13
+ const audit = new Function(
14
+ `return (${runConstraintAudit.toString()})()`,
15
+ ) as typeof runConstraintAudit;
16
+
17
+ function specimen(style: string, attrs: Record<string, string> = {}) {
18
+ const el = document.createElement("div");
19
+ el.id = "specimen";
20
+ el.style.cssText = style;
21
+ for (const [key, value] of Object.entries(attrs)) el.setAttribute(key, value);
22
+ document.body.append(el);
23
+ return el;
24
+ }
25
+
26
+ function measured() {
27
+ return audit().elements.find((el) => el.id === "specimen");
28
+ }
29
+
30
+ function card(radius: string, space: string, tier = "content") {
31
+ return {
32
+ "data-constraint-container": "Card",
33
+ "data-tier": tier,
34
+ "data-radius-knob": radius,
35
+ "data-space-knob": space,
36
+ };
37
+ }
38
+
39
+ describe("Card container cap, independently measured against canonical stops", () => {
40
+ for (const tier of ["content", "elevated"]) {
41
+ for (const [radius, stop] of Object.entries({
42
+ none: 0,
43
+ small: 5,
44
+ medium: 9,
45
+ large: 16,
46
+ full: 50,
47
+ })) {
48
+ for (const [space, inset] of Object.entries({ small: 13, medium: 18, large: 32 })) {
49
+ it(`${tier}/${radius}/${space} accepts all four capped corners`, () => {
50
+ specimen(
51
+ `border-radius:${Math.min(stop, inset)}px;padding:${inset}px`,
52
+ card(radius, space, tier),
53
+ );
54
+ expect(measured()?.violations).toEqual([]);
55
+ expect(measured()?.paddingTop?.value).toBe(inset);
56
+ });
57
+ }
58
+ }
59
+ }
60
+
61
+ it.each([
62
+ ["unrelated 13px", "border-radius:13px;padding:13px", {}],
63
+ [
64
+ "tier alone is not Card provenance",
65
+ "border-radius:13px;padding:13px",
66
+ { "data-tier": "content" },
67
+ ],
68
+ ["16 exceeds 13 inset", "border-radius:16px;padding:13px", card("large", "small")],
69
+ ["wrong last corner", "border-radius:13px 13px 13px 9px;padding:13px", card("full", "small")],
70
+ ["nonzero at none", "border-radius:5px;padding:13px", card("none", "small")],
71
+ ["arbitrary matching inset", "border-radius:14px;padding:14px", card("full", "small")],
72
+ ["wrong canonical inset", "border-radius:18px;padding:18px", card("full", "small")],
73
+ ["missing inset", "border-radius:0;padding:0", card("none", "small")],
74
+ [
75
+ "wrong individual inset",
76
+ "border-radius:13px;padding:13px 18px 13px 13px",
77
+ card("full", "small"),
78
+ ],
79
+ ["percent radius cannot bypass cap", "border-radius:50%;padding:13px", card("full", "small")],
80
+ [
81
+ "elliptical corner cannot bypass cap",
82
+ "border-radius:13px / 16px;padding:13px",
83
+ card("full", "small"),
84
+ ],
85
+ ])("rejects %s", (_label, style, attrs) => {
86
+ specimen(style, attrs);
87
+ expect(measured()?.violations.length).toBeGreaterThan(0);
88
+ });
89
+
90
+ it("leaves ordinary control radius rules intact", () => {
91
+ specimen("border-radius:16px;padding:13px");
92
+ expect(measured()?.violations).toEqual([]);
93
+ });
94
+
95
+ it.each([
96
+ "border-top-left-radius",
97
+ "border-top-right-radius",
98
+ "border-bottom-right-radius",
99
+ "border-bottom-left-radius",
100
+ ])("rejects a wrong %s even when every radius is on the generic scale", (corner) => {
101
+ const el = specimen("border-radius:9px;padding:13px", card("medium", "small"));
102
+ el.style.setProperty(corner, "5px");
103
+ expect(measured()?.borderRadius?.offScale).toBe(true);
104
+ expect(measured()?.violations).toHaveLength(1);
105
+ });
106
+ });
107
+
108
+ describe("canonical gapLg parity in the serialized audit", () => {
109
+ for (const space of ["small", "medium", "large"] as const) {
110
+ it(space, () => {
111
+ const token = resolveKnobs({ ...defaultKnobs, space }).knobProps.gapLg.gap;
112
+ const px = defaultConfig.tokens.space[token as keyof typeof defaultConfig.tokens.space];
113
+ expect(GAP_LG_KNOB[space]).toBe(px);
114
+ const gapToken = resolveKnobs({ ...defaultKnobs, space }).knobProps.gap.gap;
115
+ const gapPx = defaultConfig.tokens.space[gapToken as keyof typeof defaultConfig.tokens.space];
116
+ expect(GAP_KNOB[space]).toBe(gapPx);
117
+ specimen(`row-gap:${px}px;column-gap:${gapPx}px`);
118
+ expect(measured()?.rowGap?.offScale).toBe(false);
119
+ expect(measured()?.columnGap?.offScale).toBe(false);
120
+ });
121
+ }
122
+ it.each([22, 25, 31, 33])("rejects non-recipe gap %i", (gap) => {
123
+ specimen(`row-gap:${gap}px`);
124
+ expect(measured()?.rowGap?.offScale).toBe(true);
125
+ });
126
+ });
127
+
128
+ describe("font weight provenance", () => {
129
+ it("pins the configured heading source behind the self-contained whitelist", () => {
130
+ expect([...new Set(Object.values(defaultHeadingFont.weight).map(Number))].sort()).toEqual([
131
+ 600, 700, 800,
132
+ ]);
133
+ });
134
+ it.each([600, 800])("accepts configured heading weight %i as unitless", (weight) => {
135
+ expect(Object.values(defaultConfig.fonts.heading.weight)).toContain(String(weight));
136
+ specimen(`font-weight:${weight}`, { class: "font_heading" });
137
+ expect(measured()?.fontWeight).toMatchObject({ value: weight, unit: "", offScale: false });
138
+ expect(measured()?.fontWeight?.knob).toContain("configured heading");
139
+ });
140
+ it.each([500, 650, 900])("rejects unconfigured weight %i", (weight) => {
141
+ specimen(`font-weight:${weight}`, { class: "font_heading" });
142
+ expect(measured()?.fontWeight?.offScale).toBe(true);
143
+ expect(measured()?.violations.join(" ")).not.toContain(`${weight}px`);
144
+ });
145
+ it("does not grant arbitrary elements heading weights", () => {
146
+ specimen("font-weight:800");
147
+ expect(measured()?.fontWeight?.offScale).toBe(true);
148
+ });
149
+ it.each([
150
+ ["regular", 700],
151
+ ["bold", 800],
152
+ ["bold", 400],
153
+ ])("checks explicit %s recipe against %i even when configured", (knob, weight) => {
154
+ specimen(`font-weight:${weight}`, { class: "font_heading", "data-font-weight-knob": knob });
155
+ expect(measured()?.fontWeight?.offScale).toBe(true);
156
+ });
157
+ });
@@ -27,6 +27,8 @@ export interface TextPropertyAudit {
27
27
  export interface ElementAudit {
28
28
  /** Human-readable element descriptor */
29
29
  label: string;
30
+ /** Stable DOM position within the audited surface, distinct for sibling controls. */
31
+ key?: string;
30
32
  /** Tag name */
31
33
  tag: string;
32
34
  /** id attribute if present */
@@ -35,6 +37,10 @@ export interface ElementAudit {
35
37
  classes: string[];
36
38
  /** Audited properties (only populated when value is non-default/non-zero) */
37
39
  borderRadius?: PropertyAudit;
40
+ borderTopLeftRadius?: PropertyAudit;
41
+ borderTopRightRadius?: PropertyAudit;
42
+ borderBottomRightRadius?: PropertyAudit;
43
+ borderBottomLeftRadius?: PropertyAudit;
38
44
  borderWidth?: PropertyAudit;
39
45
  gap?: PropertyAudit;
40
46
  rowGap?: PropertyAudit;
@@ -154,22 +160,22 @@ const BORDER_WIDTH_KNOB: Record<string, number> = {
154
160
 
155
161
  /**
156
162
  * Gap (inner spacing) knob → pixel value
157
- * Source: gapMap in resolveKnobs.ts ($2, $3, $4 space tokens)
163
+ * Source: gapMap in resolveKnobs.ts ($2, $4, $5 space tokens)
158
164
  */
159
- const GAP_KNOB: Record<string, number> = {
165
+ export const GAP_KNOB: Record<string, number> = {
160
166
  small: SPACE_TOKENS[2], // 7px
161
- medium: SPACE_TOKENS[3], // 13px
162
- large: SPACE_TOKENS[4], // 18px
167
+ medium: SPACE_TOKENS[4], // 18px
168
+ large: SPACE_TOKENS[5], // 24px
163
169
  };
164
170
 
165
171
  /**
166
172
  * Gap large (gapLg) knob → pixel value
167
- * Source: gapLgMap in resolveKnobs.ts ($3, $4, $5 space tokens)
173
+ * Source: gapLgMap in resolveKnobs.ts ($3, $5, $6 space tokens)
168
174
  */
169
- const GAP_LG_KNOB: Record<string, number> = {
175
+ export const GAP_LG_KNOB: Record<string, number> = {
170
176
  small: SPACE_TOKENS[3], // 13px
171
- medium: SPACE_TOKENS[4], // 18px
172
- large: SPACE_TOKENS[5], // 24px
177
+ medium: SPACE_TOKENS[5], // 24px
178
+ large: SPACE_TOKENS[6], // 32px
173
179
  };
174
180
 
175
181
  /**
@@ -327,7 +333,7 @@ function reverseMapFontFamily(computed: string): { knob: string; offScale: boole
327
333
  *
328
334
  * Returns an AuditReport object describing all structural CSS violations.
329
335
  */
330
- export function runConstraintAudit(): AuditReport {
336
+ export function runConstraintAudit(rootSelector?: string): AuditReport {
331
337
  // ── Inline token definitions (must be self-contained for page.evaluate) ──
332
338
 
333
339
  const _RADIUS_TOKENS: Record<number, number> = {
@@ -369,13 +375,13 @@ export function runConstraintAudit(): AuditReport {
369
375
  };
370
376
  const _GAP_KNOB: Record<string, number> = {
371
377
  small: 7,
372
- medium: 13,
373
- large: 18,
378
+ medium: 18,
379
+ large: 24,
374
380
  };
375
381
  const _GAP_LG_KNOB: Record<string, number> = {
376
382
  small: 13,
377
- medium: 18,
378
- large: 24,
383
+ medium: 24,
384
+ large: 32,
379
385
  };
380
386
  const _PANEL_PADDING_KNOB: Record<string, number> = {
381
387
  small: 13,
@@ -576,6 +582,36 @@ export function runConstraintAudit(): AuditReport {
576
582
  return `${tag}${id}${cls}${role}${testId}` || tag;
577
583
  }
578
584
 
585
+ function elementKey(el: Element, scope: Element | Document): string {
586
+ const path: string[] = [];
587
+ for (let node: Element | null = el; node; node = node.parentElement) {
588
+ const siblings = node.parentElement
589
+ ? Array.from(node.parentElement.children).filter(
590
+ (sibling) => sibling.tagName === node.tagName,
591
+ )
592
+ : [node];
593
+ path.unshift(`${node.tagName.toLowerCase()}:nth-of-type(${siblings.indexOf(node) + 1})`);
594
+ if (node === scope) break;
595
+ }
596
+ return path.join(" > ");
597
+ }
598
+
599
+ function isPainted(el: Element): boolean {
600
+ const ownStyle = getComputedStyle(el);
601
+ // Visibility can be restored by a child, unlike display or opacity.
602
+ if (ownStyle.visibility === "hidden" || ownStyle.visibility === "collapse") return false;
603
+ for (let node: Element | null = el; node; node = node.parentElement) {
604
+ const style = getComputedStyle(node);
605
+ if (
606
+ style.display === "none" ||
607
+ (node !== el && style.contentVisibility === "hidden") ||
608
+ Number.parseFloat(style.opacity) === 0
609
+ )
610
+ return false;
611
+ }
612
+ return true;
613
+ }
614
+
579
615
  // ── Helper: audit a single numeric property ───────────────────────────────
580
616
 
581
617
  function auditProp(
@@ -653,14 +689,19 @@ export function runConstraintAudit(): AuditReport {
653
689
  const DEVTOOLS_SELECTORS = ["#tanstack_devtools", "[data-testid=tanstack_devtools]"];
654
690
  const devtoolsRoots = DEVTOOLS_SELECTORS.flatMap((s) => Array.from(document.querySelectorAll(s)));
655
691
 
656
- const allElements = Array.from(document.querySelectorAll("*"));
692
+ const scope = rootSelector ? document.querySelector(rootSelector) : document;
693
+ if (!scope) throw new Error(`Constraint audit scope not found: ${rootSelector}`);
694
+ const allElements = [
695
+ ...(scope instanceof Element ? [scope] : []),
696
+ ...Array.from(scope.querySelectorAll("*")),
697
+ ];
657
698
  const candidates = allElements.filter((el) => {
658
699
  const tag = el.tagName.toLowerCase();
659
700
  if (SKIP_TAGS.has(tag)) return false;
660
701
  // Skip SVG internals
661
702
  if (el.namespaceURI === "http://www.w3.org/2000/svg" && tag !== "svg") return false;
662
- // Skip hidden elements — display:none means the style has no visual impact
663
- if ((el as HTMLElement).style?.display === "none") return false;
703
+ // A hidden error panel can carry arbitrary framework styles without painting.
704
+ if (!isPainted(el)) return false;
664
705
  // Skip third-party devtools overlays
665
706
  if (devtoolsRoots.some((root) => root.contains(el))) return false;
666
707
  return true;
@@ -677,6 +718,7 @@ export function runConstraintAudit(): AuditReport {
677
718
 
678
719
  const entry: ElementAudit = {
679
720
  label: elementLabel(el),
721
+ key: elementKey(el, scope),
680
722
  tag,
681
723
  id: el.id || "",
682
724
  classes: Array.from(el.classList)
@@ -687,11 +729,73 @@ export function runConstraintAudit(): AuditReport {
687
729
 
688
730
  let hasAnyProp = false;
689
731
 
690
- // ── Border Radius ───────────────────────────────────────────────────────
691
- // Skip percentage-based border-radius (used for circles, not knob system)
692
- if (!isPercentageBorderRadius(el)) {
693
- const brRaw = style.borderTopLeftRadius || style.borderRadius;
694
- const br = parsePx(brRaw);
732
+ // Axiom 1 / DG-RAD-04: only the documented padded Card tiers own this
733
+ // cap. Provenance is declarative; every inset and corner is still measured.
734
+ const cardContainer =
735
+ el.getAttribute("data-constraint-container") === "Card" &&
736
+ ["content", "elevated"].includes(el.getAttribute("data-tier") ?? "");
737
+ if (cardContainer) {
738
+ const radiusKnob = el.getAttribute("data-radius-knob") ?? "";
739
+ const spaceKnob = el.getAttribute("data-space-knob") ?? "";
740
+ const radiusStop = Object.hasOwn(_BORDER_RADIUS_KNOB, radiusKnob)
741
+ ? _BORDER_RADIUS_KNOB[radiusKnob]
742
+ : undefined;
743
+ const inset = Object.hasOwn(_PANEL_PADDING_KNOB, spaceKnob)
744
+ ? _PANEL_PADDING_KNOB[spaceKnob]
745
+ : undefined;
746
+ const paddingSides = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"] as const;
747
+ const measuredInsets = paddingSides.map((prop) => parsePx(style[prop]));
748
+ for (const [index, prop] of paddingSides.entries()) {
749
+ const value = measuredInsets[index];
750
+ const offScale = inset === undefined || value !== inset;
751
+ entry[prop] = {
752
+ value: value ?? 0,
753
+ unit: "px",
754
+ knob: `Card panelPadding:${spaceKnob}`,
755
+ offScale,
756
+ };
757
+ if (offScale)
758
+ entry.violations.push(
759
+ `${prop}: ${style[prop]} does not match Card panelPadding:${spaceKnob} (${inset ?? "unknown"}px)`,
760
+ );
761
+ }
762
+ const expected =
763
+ radiusStop === undefined || inset === undefined || measuredInsets.some((v) => v === null)
764
+ ? undefined
765
+ : Math.min(radiusStop, inset, ...(measuredInsets as number[]));
766
+ const corners = [
767
+ "borderTopLeftRadius",
768
+ "borderTopRightRadius",
769
+ "borderBottomRightRadius",
770
+ "borderBottomLeftRadius",
771
+ ] as const;
772
+ for (const prop of corners) {
773
+ const raw = style[prop];
774
+ // A corner may have two elliptical axes. Percentages are not px caps.
775
+ const axes = raw.trim().split(/\s+/);
776
+ const offScale =
777
+ expected === undefined ||
778
+ axes.some((axis) => !/^\d+(?:\.\d+)?(?:px)?$/.test(axis) || parsePx(axis) !== expected);
779
+ const result = {
780
+ value: parsePx(raw) ?? 0,
781
+ unit: "px",
782
+ knob: `Card min(radius:${radiusKnob}, panelPadding:${spaceKnob})`,
783
+ offScale,
784
+ };
785
+ entry[prop] = result;
786
+ if (offScale)
787
+ entry.violations.push(
788
+ `${prop}: ${raw} does not match Card cap (${expected ?? "unknown"}px)`,
789
+ );
790
+ }
791
+ entry.borderRadius = {
792
+ ...entry.borderTopLeftRadius!,
793
+ offScale: corners.some((prop) => entry[prop]?.offScale),
794
+ };
795
+ hasAnyProp = true;
796
+ } else if (!isPercentageBorderRadius(el)) {
797
+ // Ordinary controls and identity recipes retain their existing scales.
798
+ const br = parsePx(style.borderTopLeftRadius || style.borderRadius);
695
799
  if (br !== null && br > 0) {
696
800
  entry.borderRadius = auditProp(
697
801
  br,
@@ -735,7 +839,7 @@ export function runConstraintAudit(): AuditReport {
735
839
  // Only check padding on elements that look like surfaces (have border-radius
736
840
  // or border-width already audited). Padding on all elements creates too many
737
841
  // false positives from browser defaults and third-party components.
738
- if (entry.borderRadius || entry.borderWidth) {
842
+ if (!cardContainer && (entry.borderRadius || entry.borderWidth)) {
739
843
  const sides = [
740
844
  ["paddingTop", style.paddingTop],
741
845
  ["paddingRight", style.paddingRight],
@@ -757,19 +861,34 @@ export function runConstraintAudit(): AuditReport {
757
861
 
758
862
  // ── Font Weight ─────────────────────────────────────────────────────────
759
863
  const fw = parsePx(style.fontWeight);
760
- if (fw !== null && fw !== 400) {
761
- // Only flag non-regular weights that aren't in our valid set
762
- const result = auditProp(
763
- fw,
764
- _VALID_FONT_WEIGHT,
765
- _reverseFontWeight,
766
- "fontWeight",
767
- entry.violations,
768
- );
769
- if (result) {
770
- entry.fontWeight = result;
771
- hasAnyProp = true;
772
- }
864
+ const weightKnob = el.getAttribute("data-font-weight-knob");
865
+ if (fw !== null && (fw !== 400 || weightKnob !== null)) {
866
+ // defaults/fonts.ts takes heading.weight from @tamagui/config/v5:
867
+ // $0–$5 = 600, $6–$8 = 700, $9+ = 800. FontKnobStyles preserves
868
+ // font_heading while changing its family. These are typography defaults,
869
+ // not alternatives to an explicitly declared regular/bold recipe.
870
+ const configuredHeading =
871
+ el.classList.contains("font_heading") && [600, 700, 800].includes(fw);
872
+ const expected =
873
+ weightKnob !== null && ["regular", "bold"].includes(weightKnob)
874
+ ? _FONT_WEIGHT_KNOB[weightKnob]
875
+ : undefined;
876
+ const offScale =
877
+ weightKnob !== null
878
+ ? expected === undefined || fw !== expected
879
+ : !_VALID_FONT_WEIGHT.has(fw) && !configuredHeading;
880
+ const knob =
881
+ weightKnob !== null
882
+ ? `fontWeight:${weightKnob}`
883
+ : configuredHeading
884
+ ? "configured heading (@tamagui/config/v5)"
885
+ : _reverseFontWeight(fw);
886
+ entry.fontWeight = { value: fw, unit: "", knob, offScale };
887
+ if (offScale)
888
+ entry.violations.push(
889
+ `fontWeight: ${fw} does not match ${weightKnob !== null ? knob : "a configured typography weight"}`,
890
+ );
891
+ hasAnyProp = true;
773
892
  }
774
893
 
775
894
  // ── Font Family ──────────────────────────────────────────────────────────
@@ -0,0 +1,48 @@
1
+ import { afterEach, describe, expect, it } from "vitest";
2
+ import { runConstraintAudit } from "./constraintAudit";
3
+
4
+ const audit = new Function("scope", `return (${runConstraintAudit.toString()})(scope)`) as (
5
+ scope?: string,
6
+ ) => ReturnType<typeof runConstraintAudit>;
7
+
8
+ afterEach(() => {
9
+ document.body.innerHTML = "";
10
+ });
11
+
12
+ describe("constraint audit scope and painted visibility", () => {
13
+ it("audits the requested surface while keeping the document-wide default", () => {
14
+ document.body.innerHTML = `<div id="outside" style="border-radius:14px"></div><main id="story"><button id="inside" style="border-radius:15px">Inside</button></main>`;
15
+ expect(audit("#story").violations.map((entry) => entry.id)).toEqual(["inside"]);
16
+ expect(audit().violations.map((entry) => entry.id)).toEqual(["outside", "inside"]);
17
+ });
18
+
19
+ it("rejects a missing requested surface instead of returning a clean report", () => {
20
+ expect(() => audit("#missing")).toThrow(/scope/i);
21
+ });
22
+
23
+ it.each(["display:none", "visibility:hidden", "opacity:0"])(
24
+ "ignores unpainted descendants under %s and reports them when shown",
25
+ (style) => {
26
+ document.body.innerHTML = `<main id="story"><div id="hidden" style="${style}"><button id="bad" style="border-radius:15px">Hidden error chrome</button></div></main>`;
27
+ expect(audit("#story").violations).toEqual([]);
28
+ document.querySelector("#hidden")!.removeAttribute("style");
29
+ expect(audit("#story").violations.map((entry) => entry.id)).toEqual(["bad"]);
30
+ },
31
+ );
32
+
33
+ it("keeps painted children that explicitly override inherited visibility", () => {
34
+ document.body.innerHTML = `<main id="story" style="visibility:hidden"><button id="shown" style="visibility:visible;border-radius:15px">Visible</button></main>`;
35
+ expect(audit("#story").violations.map((entry) => entry.id)).toEqual(["shown"]);
36
+ });
37
+
38
+ it("retains distinct stable paths for otherwise identical failing controls", () => {
39
+ document.body.innerHTML = `<main id="story"><button style="border-radius:15px">Same</button><button style="border-radius:15px">Same</button></main>`;
40
+ const first = audit("#story").violations;
41
+ expect(first).toHaveLength(2);
42
+ expect(first[0].key).toBeTruthy();
43
+ expect(first[1].key).not.toBe(first[0].key);
44
+ expect(audit("#story").violations.map((entry) => entry.key)).toEqual(
45
+ first.map((entry) => entry.key),
46
+ );
47
+ });
48
+ });
@@ -9,9 +9,21 @@ export { runConstraintAudit } from "./constraintAudit";
9
9
  export type { AuditDiff, ElementDiff, PropertyDiff } from "./diffReport";
10
10
  export { diffAuditReports, formatAuditReport } from "./diffReport";
11
11
  export type {
12
+ ChartKind,
13
+ ChartProofResult,
14
+ ChartProofViolation,
15
+ ChartUnmeasurableReason,
16
+ MatrixChartPaint,
17
+ MatrixChartSample,
12
18
  GeometryDiff,
13
19
  GeometryMove,
20
+ IconContrastResult,
21
+ IconContrastViolation,
22
+ IconExclusion,
23
+ IconUnmeasurableReason,
24
+ IconUnverified,
14
25
  MatrixElementGeometry,
26
+ MatrixIconSample,
15
27
  MatrixRect,
16
28
  MatrixSnapshot,
17
29
  MatrixTextSample,
@@ -25,5 +37,9 @@ export {
25
37
  captureMatrixSnapshot,
26
38
  diffGeometry,
27
39
  evaluateNoBreakage,
40
+ evaluateIconContrast,
41
+ evaluateChartProof,
42
+ chartProofSignatures,
43
+ iconContrastSignatures,
28
44
  evaluateTextContrast,
29
45
  } from "./themeMatrix";