@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.
- package/package.json +5 -5
- package/src/audit/constraintAudit.spec.ts +157 -0
- package/src/audit/constraintAudit.ts +154 -35
- package/src/audit/constraintScope.spec.ts +48 -0
- package/src/audit/index.ts +16 -0
- package/src/audit/themeMatrix.spec.ts +292 -1
- package/src/audit/themeMatrix.ts +1015 -22
- package/src/theme/Intent.stories.tsx +88 -0
- package/src/theme/Preset.stories.tsx +80 -0
- package/src/theme/Surface.spec.tsx +6 -0
- package/src/theme/Surface.stories.tsx +91 -0
- package/src/theme/ThemeProvider.stories.tsx +50 -0
- package/src/theme/Tint.stories.tsx +98 -0
- package/src/theme/chartPalette.spec.ts +128 -5
- package/src/theme/chartPalette.ts +105 -22
- package/src/theme/colorRules.spec.ts +92 -39
- package/src/theme/colorRules.ts +4 -6
- package/src/theme/createDefaultThemeConfig.ts +1 -1
- package/src/theme/createThemes.ts +58 -3
- package/src/theme/devtools/ColorLineVisualizer.stories.tsx +36 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.stories.tsx +16 -0
- package/src/theme/glyphPaint.spec.ts +14 -0
- package/src/theme/glyphPaint.ts +2 -1
- package/src/theme/intent.spec.tsx +1 -0
- package/src/theme/layoutTokensHooks.spec.tsx +1 -0
- package/src/theme/recipeInputs.ts +9 -9
- package/src/theme/resolveKnobs.spec.ts +2 -2
- package/src/theme/sizeLadder.spec.ts +19 -10
- package/src/theme/sizeRecipes.ts +2 -2
- package/src/theme/themeValue.spec.ts +2 -2
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +6 -0
- package/types/audit/constraintAudit.d.ts +17 -1
- package/types/audit/constraintAudit.d.ts.map +1 -1
- package/types/audit/index.d.ts +2 -2
- package/types/audit/index.d.ts.map +1 -1
- package/types/audit/themeMatrix.d.ts +135 -2
- package/types/audit/themeMatrix.d.ts.map +1 -1
- package/types/theme/chartPalette.d.ts +6 -4
- package/types/theme/chartPalette.d.ts.map +1 -1
- package/types/theme/colorRules.d.ts +3 -3
- package/types/theme/colorRules.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/glyphPaint.d.ts.map +1 -1
- package/types/theme/recipeInputs.d.ts +8 -8
- package/types/theme/recipeInputs.d.ts.map +1 -1
- 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.
|
|
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/
|
|
57
|
-
"@multiplatform.one/
|
|
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/
|
|
72
|
-
"@multiplatform.one/
|
|
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, $
|
|
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[
|
|
162
|
-
large: SPACE_TOKENS[
|
|
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, $
|
|
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[
|
|
172
|
-
large: SPACE_TOKENS[
|
|
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:
|
|
373
|
-
large:
|
|
378
|
+
medium: 18,
|
|
379
|
+
large: 24,
|
|
374
380
|
};
|
|
375
381
|
const _GAP_LG_KNOB: Record<string, number> = {
|
|
376
382
|
small: 13,
|
|
377
|
-
medium:
|
|
378
|
-
large:
|
|
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
|
|
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
|
-
//
|
|
663
|
-
if ((el
|
|
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
|
-
//
|
|
691
|
-
//
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
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
|
+
});
|
package/src/audit/index.ts
CHANGED
|
@@ -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";
|