@multiplatform.one/theme 7.4.1 → 7.6.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 +2 -1
- package/package.json +5 -5
- package/src/audit/index.ts +19 -0
- package/src/audit/themeMatrix.spec.ts +274 -0
- package/src/audit/themeMatrix.ts +630 -0
- package/src/figma/figmaTokens.spec.ts +25 -21
- package/src/figma/figmaTokens.ts +2 -2
- package/src/font.ts +37 -25
- package/src/index.ts +12 -0
- package/src/keyboardFocusRing.ts +12 -6
- package/src/theme/FontKnobStyles.tsx +14 -31
- package/src/theme/Tint.tsx +3 -2
- package/src/theme/animations/css.ts +14 -1
- package/src/theme/colorRules.spec.ts +306 -2
- package/src/theme/colorRules.ts +118 -0
- package/src/theme/cornerSmoothing.native.ts +39 -2
- package/src/theme/cornerSmoothing.spec.ts +270 -3
- package/src/theme/cornerSmoothing.ts +147 -10
- package/src/theme/createDefaultThemeConfig.spec.ts +107 -1
- package/src/theme/createDefaultThemeConfig.ts +27 -8
- package/src/theme/defaults/builderOptions.ts +23 -10
- package/src/theme/defaults/categoryFonts.ts +24 -0
- package/src/theme/defaults/fonts.spec.ts +163 -10
- package/src/theme/defaults/fonts.theme.spec.tsx +139 -0
- package/src/theme/defaults/fonts.ts +214 -50
- package/src/theme/defaults/index.ts +1 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +0 -11
- package/src/theme/focusState.spec.ts +119 -1
- package/src/theme/focusState.ts +32 -7
- package/src/theme/fontCategoryMetrics.ts +57 -0
- package/src/theme/fontCategoryStacks.ts +37 -0
- package/src/theme/fontKnobStyles.spec.ts +15 -2
- package/src/theme/glyphPaint.spec.ts +26 -0
- package/src/theme/glyphPaint.ts +46 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/knobs.ts +18 -2
- package/src/theme/layoutTokens.spec.ts +3 -1
- package/src/theme/layoutTokens.ts +47 -4
- package/src/theme/mpo13Proof.spec.ts +3 -3
- package/src/theme/mpo19Proof.spec.ts +285 -0
- package/src/theme/presets.ts +1 -1
- package/src/theme/radiusClass.spec.ts +394 -0
- package/src/theme/radiusClass.ts +203 -4
- package/src/theme/radiusClassTable.generated.ts +121 -0
- package/src/theme/recipeInputs.ts +223 -36
- package/src/theme/recipes.ts +68 -4
- package/src/theme/resolveKnobs.spec.ts +155 -13
- package/src/theme/resolveKnobs.ts +193 -27
- package/src/theme/sizeLadder.spec.ts +84 -0
- package/src/theme/sizeRecipes.spec.ts +19 -6
- package/src/theme/sizeRecipes.ts +43 -13
- package/src/theme/theme.native.tsx +2 -0
- package/src/theme/theme.provider.spec.ts +9 -0
- package/src/theme/theme.tsx +6 -3
- package/src/theme/tint.spec.tsx +36 -35
- package/src/theme/useResolvedKnobs.ts +6 -3
- package/types/audit/index.d.ts +2 -0
- package/types/audit/index.d.ts.map +1 -1
- package/types/audit/themeMatrix.d.ts +172 -0
- package/types/audit/themeMatrix.d.ts.map +1 -0
- package/types/figma/figmaTokens.d.ts +1 -1
- package/types/figma/figmaTokens.d.ts.map +1 -1
- package/types/font.d.ts.map +1 -1
- package/types/index.d.ts +2 -0
- package/types/index.d.ts.map +1 -1
- package/types/keyboardFocusRing.d.ts +11 -5
- package/types/keyboardFocusRing.d.ts.map +1 -1
- package/types/theme/FontKnobStyles.d.ts +8 -12
- package/types/theme/FontKnobStyles.d.ts.map +1 -1
- package/types/theme/Tint.d.ts +2 -1
- package/types/theme/Tint.d.ts.map +1 -1
- package/types/theme/animations/css.d.ts +15 -2
- package/types/theme/animations/css.d.ts.map +1 -1
- package/types/theme/colorRules.d.ts +59 -1
- package/types/theme/colorRules.d.ts.map +1 -1
- package/types/theme/cornerSmoothing.d.ts +42 -20
- package/types/theme/cornerSmoothing.d.ts.map +1 -1
- package/types/theme/cornerSmoothing.native.d.ts +22 -2
- package/types/theme/cornerSmoothing.native.d.ts.map +1 -1
- package/types/theme/createDefaultThemeConfig.d.ts +21 -3
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
- package/types/theme/defaults/builderOptions.d.ts.map +1 -1
- package/types/theme/defaults/categoryFonts.d.ts +23 -0
- package/types/theme/defaults/categoryFonts.d.ts.map +1 -0
- package/types/theme/defaults/fonts.d.ts +140 -2
- package/types/theme/defaults/fonts.d.ts.map +1 -1
- package/types/theme/defaults/index.d.ts +1 -0
- package/types/theme/defaults/index.d.ts.map +1 -1
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
- package/types/theme/focusState.d.ts +32 -6
- package/types/theme/focusState.d.ts.map +1 -1
- package/types/theme/fontCategoryMetrics.d.ts +36 -0
- package/types/theme/fontCategoryMetrics.d.ts.map +1 -0
- package/types/theme/fontCategoryStacks.d.ts +24 -0
- package/types/theme/fontCategoryStacks.d.ts.map +1 -0
- package/types/theme/glyphPaint.d.ts +16 -0
- package/types/theme/glyphPaint.d.ts.map +1 -0
- package/types/theme/index.d.ts +4 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/knobs.d.ts +10 -2
- package/types/theme/knobs.d.ts.map +1 -1
- package/types/theme/layoutTokens.d.ts +46 -4
- package/types/theme/layoutTokens.d.ts.map +1 -1
- package/types/theme/radiusClass.d.ts +96 -4
- package/types/theme/radiusClass.d.ts.map +1 -1
- package/types/theme/radiusClassTable.generated.d.ts +146 -0
- package/types/theme/radiusClassTable.generated.d.ts.map +1 -0
- package/types/theme/recipeInputs.d.ts +85 -14
- package/types/theme/recipeInputs.d.ts.map +1 -1
- package/types/theme/recipes.d.ts +78 -4
- package/types/theme/recipes.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +12 -1
- package/types/theme/resolveKnobs.d.ts.map +1 -1
- package/types/theme/sizeRecipes.d.ts +27 -14
- package/types/theme/sizeRecipes.d.ts.map +1 -1
- package/types/theme/theme.d.ts.map +1 -1
- package/types/theme/theme.native.d.ts.map +1 -1
- package/types/theme/useResolvedKnobs.d.ts +2 -2
- package/types/theme/useResolvedKnobs.d.ts.map +1 -1
|
@@ -1,27 +1,294 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdtempSync, readdirSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createElement, type ReactNode } from "react";
|
|
6
|
+
import { cleanup, render } from "@testing-library/react";
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
8
|
+
import { defaultPreset } from "./presets";
|
|
9
|
+
import { PresetContext, type PresetContextValue } from "./PresetContext";
|
|
2
10
|
import {
|
|
11
|
+
CORNER_SHAPE_CSS_VAR,
|
|
12
|
+
CORNER_SHAPE_ROUND,
|
|
13
|
+
CORNER_SHAPE_ROUND_COMPUTED,
|
|
14
|
+
CORNER_SHAPE_SMOOTH,
|
|
15
|
+
CORNER_SHAPE_SMOOTH_COMPUTED,
|
|
16
|
+
CORNER_SMOOTHING_ROOT_ATTR,
|
|
17
|
+
CORNER_SMOOTHING_STYLE_TAG_ID,
|
|
18
|
+
CornerSmoothingStyles,
|
|
19
|
+
applyRootCornerSmoothing,
|
|
3
20
|
cornerSmoothClassName,
|
|
4
21
|
cornerSmoothingCss,
|
|
22
|
+
cssSupportsCornerShape,
|
|
5
23
|
ensureCornerSmoothingStyles,
|
|
24
|
+
isRoundComputedCornerShape,
|
|
25
|
+
isSmoothComputedCornerShape,
|
|
26
|
+
readComputedCornerShape,
|
|
6
27
|
} from "./cornerSmoothing";
|
|
7
28
|
|
|
29
|
+
/**
|
|
30
|
+
* Playwright's `chrome-headless-shell` builds, newest revision first.
|
|
31
|
+
*
|
|
32
|
+
* This is about coverage, not speed — an installed Chrome reaches `--dump-dom`
|
|
33
|
+
* in ~5s and is fine. The point is that a machine or a CI image with no Chrome
|
|
34
|
+
* installed but a Playwright cache present would otherwise take the held-out
|
|
35
|
+
* branch below and prove nothing, silently.
|
|
36
|
+
*/
|
|
37
|
+
function findHeadlessShells(): string[] {
|
|
38
|
+
const home = process.env.HOME ?? "";
|
|
39
|
+
const roots = [`${home}/Library/Caches/ms-playwright`, `${home}/.cache/ms-playwright`];
|
|
40
|
+
const found: { revision: number; bin: string }[] = [];
|
|
41
|
+
for (const root of roots) {
|
|
42
|
+
if (!existsSync(root)) continue;
|
|
43
|
+
for (const entry of readdirSync(root)) {
|
|
44
|
+
const revision = Number(entry.replace("chromium_headless_shell-", ""));
|
|
45
|
+
if (!entry.startsWith("chromium_headless_shell-") || Number.isNaN(revision)) continue;
|
|
46
|
+
for (const arch of ["mac-arm64", "mac-x64", "linux64"]) {
|
|
47
|
+
const bin = join(root, entry, `chrome-headless-shell-${arch}`, "chrome-headless-shell");
|
|
48
|
+
if (existsSync(bin)) found.push({ revision, bin });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return found.sort((a, b) => b.revision - a.revision).map((f) => f.bin);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findChrome(): string | undefined {
|
|
56
|
+
const candidates = [
|
|
57
|
+
process.env.CHROME_BIN,
|
|
58
|
+
...findHeadlessShells(),
|
|
59
|
+
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
|
|
60
|
+
`${process.env.HOME ?? ""}/.local/bin/google-chrome`,
|
|
61
|
+
"/usr/bin/google-chrome",
|
|
62
|
+
"/usr/bin/chromium",
|
|
63
|
+
].filter((p): p is string => Boolean(p));
|
|
64
|
+
return candidates.find((p) => existsSync(p));
|
|
65
|
+
}
|
|
66
|
+
|
|
8
67
|
describe("cornerSmoothing (Axiom 15 OPTICS)", () => {
|
|
9
68
|
it("scopes the squircle declaration to the fragment class", () => {
|
|
10
69
|
expect(cornerSmoothClassName).toBe("mp-corner-smooth");
|
|
11
70
|
expect(cornerSmoothingCss).toBe(".mp-corner-smooth { corner-shape: squircle; }");
|
|
71
|
+
expect(cornerSmoothingCss).toContain(`corner-shape: ${CORNER_SHAPE_SMOOTH}`);
|
|
12
72
|
});
|
|
13
73
|
|
|
14
74
|
describe("stylesheet mount", () => {
|
|
15
75
|
beforeEach(() => {
|
|
16
|
-
document.getElementById(
|
|
76
|
+
document.getElementById(CORNER_SMOOTHING_STYLE_TAG_ID)?.remove();
|
|
17
77
|
});
|
|
18
78
|
|
|
19
79
|
it("mounts the stylesheet once (idempotent)", () => {
|
|
20
80
|
ensureCornerSmoothingStyles();
|
|
21
81
|
ensureCornerSmoothingStyles();
|
|
22
|
-
const tags = document.querySelectorAll(
|
|
82
|
+
const tags = document.querySelectorAll(`#${CORNER_SMOOTHING_STYLE_TAG_ID}`);
|
|
23
83
|
expect(tags).toHaveLength(1);
|
|
24
84
|
expect(tags[0]?.textContent).toBe(cornerSmoothingCss);
|
|
25
85
|
});
|
|
26
86
|
});
|
|
87
|
+
|
|
88
|
+
describe("support detection (measured, not assumed)", () => {
|
|
89
|
+
it("is false when the engine is missing (null) or has no supports()", () => {
|
|
90
|
+
expect(cssSupportsCornerShape(null)).toBe(false);
|
|
91
|
+
expect(cssSupportsCornerShape({ supports: undefined })).toBe(false);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("is true only when the UA reports the squircle value", () => {
|
|
95
|
+
expect(cssSupportsCornerShape({ supports: () => true })).toBe(true);
|
|
96
|
+
expect(cssSupportsCornerShape({ supports: () => false })).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("swallows a throwing supports() as unsupported", () => {
|
|
100
|
+
expect(
|
|
101
|
+
cssSupportsCornerShape({
|
|
102
|
+
supports: () => {
|
|
103
|
+
throw new Error("nope");
|
|
104
|
+
},
|
|
105
|
+
}),
|
|
106
|
+
).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("computed-shape reader", () => {
|
|
111
|
+
it("prefers getPropertyValue over the camelCase IDL", () => {
|
|
112
|
+
expect(
|
|
113
|
+
readComputedCornerShape({
|
|
114
|
+
getPropertyValue: (name) => (name === "corner-shape" ? "superellipse(2)" : ""),
|
|
115
|
+
cornerShape: "round",
|
|
116
|
+
}),
|
|
117
|
+
).toBe("superellipse(2)");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("falls back to the IDL when the property channel is empty", () => {
|
|
121
|
+
expect(
|
|
122
|
+
readComputedCornerShape({
|
|
123
|
+
getPropertyValue: () => "",
|
|
124
|
+
cornerShape: "squircle",
|
|
125
|
+
}),
|
|
126
|
+
).toBe("squircle");
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("treats specified squircle and computed superellipse(2) as smooth", () => {
|
|
130
|
+
expect(isSmoothComputedCornerShape("squircle")).toBe(true);
|
|
131
|
+
expect(isSmoothComputedCornerShape("superellipse(2)")).toBe(true);
|
|
132
|
+
expect(isSmoothComputedCornerShape(" SuperEllipse(2) ")).toBe(true);
|
|
133
|
+
expect(isSmoothComputedCornerShape("round")).toBe(false);
|
|
134
|
+
expect(isSmoothComputedCornerShape("superellipse(1)")).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("treats round / superellipse(1) / empty as the unsupported-or-round stop", () => {
|
|
138
|
+
expect(isRoundComputedCornerShape("round")).toBe(true);
|
|
139
|
+
expect(isRoundComputedCornerShape("superellipse(1)")).toBe(true);
|
|
140
|
+
expect(isRoundComputedCornerShape("")).toBe(true);
|
|
141
|
+
expect(isRoundComputedCornerShape("normal")).toBe(true);
|
|
142
|
+
expect(isRoundComputedCornerShape("squircle")).toBe(false);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("a class-marked node plus the mounted sheet is classifiable", () => {
|
|
146
|
+
ensureCornerSmoothingStyles();
|
|
147
|
+
const el = document.createElement("div");
|
|
148
|
+
el.className = cornerSmoothClassName;
|
|
149
|
+
el.style.borderRadius = "16px";
|
|
150
|
+
document.body.appendChild(el);
|
|
151
|
+
expect(document.getElementById(CORNER_SMOOTHING_STYLE_TAG_ID)?.textContent).toBe(
|
|
152
|
+
cornerSmoothingCss,
|
|
153
|
+
);
|
|
154
|
+
const computed = readComputedCornerShape(getComputedStyle(el));
|
|
155
|
+
// Measured 2026-08-28: this happy-dom applies `squircle`. An engine
|
|
156
|
+
// that drops the unknown declaration reads empty/round. Either stop
|
|
157
|
+
// is a measured result, not an assumed skip.
|
|
158
|
+
expect(isSmoothComputedCornerShape(computed) || isRoundComputedCornerShape(computed)).toBe(
|
|
159
|
+
true,
|
|
160
|
+
);
|
|
161
|
+
el.remove();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
describe("root channel", () => {
|
|
166
|
+
afterEach(() => {
|
|
167
|
+
document.documentElement.removeAttribute(CORNER_SMOOTHING_ROOT_ATTR);
|
|
168
|
+
document.documentElement.style.removeProperty(CORNER_SHAPE_CSS_VAR);
|
|
169
|
+
cleanup();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("publishes the live stop on <html>", () => {
|
|
173
|
+
applyRootCornerSmoothing("smooth");
|
|
174
|
+
expect(document.documentElement.getAttribute(CORNER_SMOOTHING_ROOT_ATTR)).toBe("smooth");
|
|
175
|
+
expect(document.documentElement.style.getPropertyValue(CORNER_SHAPE_CSS_VAR)).toBe(
|
|
176
|
+
CORNER_SHAPE_SMOOTH,
|
|
177
|
+
);
|
|
178
|
+
applyRootCornerSmoothing("round");
|
|
179
|
+
expect(document.documentElement.getAttribute(CORNER_SMOOTHING_ROOT_ATTR)).toBe("round");
|
|
180
|
+
expect(document.documentElement.style.getPropertyValue(CORNER_SHAPE_CSS_VAR)).toBe(
|
|
181
|
+
CORNER_SHAPE_ROUND,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("CornerSmoothingStyles mounts the stylesheet and follows the preset stop", () => {
|
|
186
|
+
document.getElementById(CORNER_SMOOTHING_STYLE_TAG_ID)?.remove();
|
|
187
|
+
const value: PresetContextValue = {
|
|
188
|
+
preset: {
|
|
189
|
+
...defaultPreset,
|
|
190
|
+
knobs: { ...defaultPreset.knobs, cornerSmoothing: "smooth" },
|
|
191
|
+
},
|
|
192
|
+
};
|
|
193
|
+
render(
|
|
194
|
+
createElement(
|
|
195
|
+
PresetContext.Provider,
|
|
196
|
+
{ value },
|
|
197
|
+
createElement(CornerSmoothingStyles) as ReactNode,
|
|
198
|
+
),
|
|
199
|
+
);
|
|
200
|
+
expect(document.getElementById(CORNER_SMOOTHING_STYLE_TAG_ID)?.textContent).toBe(
|
|
201
|
+
cornerSmoothingCss,
|
|
202
|
+
);
|
|
203
|
+
expect(document.documentElement.getAttribute(CORNER_SMOOTHING_ROOT_ATTR)).toBe("smooth");
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
describe("supporting browser (Chromium)", () => {
|
|
208
|
+
it("computed corner-shape DIFFERS between round and smooth", () => {
|
|
209
|
+
const chrome = findChrome();
|
|
210
|
+
if (!chrome) {
|
|
211
|
+
// Held-out: no Chromium binary. The happy-dom arm above records
|
|
212
|
+
// whatever this harness computes as measured, not assumed.
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const dir = mkdtempSync(join(tmpdir(), "mp-corner-shape-"));
|
|
216
|
+
const htmlPath = join(dir, "probe.html");
|
|
217
|
+
writeFileSync(
|
|
218
|
+
htmlPath,
|
|
219
|
+
`<!doctype html><style>
|
|
220
|
+
html, body { margin: 0; padding: 0; }
|
|
221
|
+
.box { position: absolute; top: 0; width: 132px; height: 84px; border-radius: 16px; }
|
|
222
|
+
.round { left: 0; corner-shape: round; }
|
|
223
|
+
.smooth { left: 200px; corner-shape: squircle; }
|
|
224
|
+
.classy { left: 400px; }
|
|
225
|
+
.${cornerSmoothClassName} { corner-shape: squircle; }
|
|
226
|
+
</style>
|
|
227
|
+
<div id="round" class="box round"></div>
|
|
228
|
+
<div id="smooth" class="box smooth"></div>
|
|
229
|
+
<div id="classy" class="box classy ${cornerSmoothClassName}"></div>
|
|
230
|
+
<pre id="out"></pre>
|
|
231
|
+
<script>
|
|
232
|
+
const read = (el) => {
|
|
233
|
+
const cs = getComputedStyle(el);
|
|
234
|
+
return (cs.getPropertyValue("corner-shape") || cs.cornerShape || "").trim();
|
|
235
|
+
};
|
|
236
|
+
// Walk 1px..8px diagonally in from the top-left. "1" means the box owns that
|
|
237
|
+
// pixel. This reads the PAINTED curve, so it fails if an engine parses
|
|
238
|
+
// corner-shape but still draws the plain arc — a property echo cannot fake it.
|
|
239
|
+
const cornerMask = (id, left) => {
|
|
240
|
+
let bits = "";
|
|
241
|
+
for (let d = 1; d <= 8; d++) {
|
|
242
|
+
const hit = document.elementFromPoint(left + d, d);
|
|
243
|
+
bits += hit && hit.id === id ? "1" : "0";
|
|
244
|
+
}
|
|
245
|
+
return bits;
|
|
246
|
+
};
|
|
247
|
+
const out = {
|
|
248
|
+
supportsSquircle: CSS.supports("corner-shape", "squircle"),
|
|
249
|
+
ua: navigator.userAgent,
|
|
250
|
+
round: read(document.getElementById("round")),
|
|
251
|
+
smooth: read(document.getElementById("smooth")),
|
|
252
|
+
classy: read(document.getElementById("classy")),
|
|
253
|
+
roundCorner: cornerMask("round", 0),
|
|
254
|
+
smoothCorner: cornerMask("smooth", 200),
|
|
255
|
+
classyCorner: cornerMask("classy", 400),
|
|
256
|
+
};
|
|
257
|
+
document.getElementById("out").textContent = JSON.stringify(out);
|
|
258
|
+
</script>`,
|
|
259
|
+
);
|
|
260
|
+
const dump = execFileSync(
|
|
261
|
+
chrome,
|
|
262
|
+
["--headless=new", "--disable-gpu", "--dump-dom", `file://${htmlPath}`],
|
|
263
|
+
{ encoding: "utf8", timeout: 60_000 },
|
|
264
|
+
);
|
|
265
|
+
const match = dump.match(/<pre id="out">([\s\S]*?)<\/pre>/);
|
|
266
|
+
expect(match, "probe <pre id=out> missing from dump-dom").toBeTruthy();
|
|
267
|
+
const result = JSON.parse(match![1]) as {
|
|
268
|
+
supportsSquircle: boolean;
|
|
269
|
+
round: string;
|
|
270
|
+
smooth: string;
|
|
271
|
+
classy: string;
|
|
272
|
+
roundCorner: string;
|
|
273
|
+
smoothCorner: string;
|
|
274
|
+
classyCorner: string;
|
|
275
|
+
};
|
|
276
|
+
expect(result.supportsSquircle).toBe(true);
|
|
277
|
+
expect(isRoundComputedCornerShape(result.round)).toBe(true);
|
|
278
|
+
expect(isSmoothComputedCornerShape(result.smooth)).toBe(true);
|
|
279
|
+
expect(isSmoothComputedCornerShape(result.classy)).toBe(true);
|
|
280
|
+
expect(result.smooth).not.toBe(result.round);
|
|
281
|
+
expect([CORNER_SHAPE_SMOOTH, CORNER_SHAPE_SMOOTH_COMPUTED]).toContain(result.smooth);
|
|
282
|
+
expect([CORNER_SHAPE_ROUND, CORNER_SHAPE_ROUND_COMPUTED]).toContain(result.round);
|
|
283
|
+
|
|
284
|
+
// Geometry, not the property echo: the squircle claims corner pixels the
|
|
285
|
+
// arc leaves empty, and the class-driven cell paints the same curve as
|
|
286
|
+
// the hand-written one. Measured 2026-09-01 on Chromium 143/147/148/151
|
|
287
|
+
// and Chrome 152 — round "00011111", smooth "01111111". See
|
|
288
|
+
// docs/design/boards/W1-corner-smoothing/engines__corner-shape.json.
|
|
289
|
+
expect(result.smoothCorner).not.toBe(result.roundCorner);
|
|
290
|
+
expect(result.classyCorner).toBe(result.smoothCorner);
|
|
291
|
+
expect(result.smoothCorner.indexOf("1")).toBeLessThan(result.roundCorner.indexOf("1"));
|
|
292
|
+
});
|
|
293
|
+
});
|
|
27
294
|
});
|
|
@@ -3,9 +3,6 @@
|
|
|
3
3
|
* circular-arc corners to continuous curvature — CSS
|
|
4
4
|
* `corner-shape: squircle` (≡ `superellipse(2)`, ≈ Apple's continuous
|
|
5
5
|
* corners / Figma smoothing 0.6) — as a WEB PROGRESSIVE ENHANCEMENT.
|
|
6
|
-
* Chromium 139+ paints it; every other engine drops the unknown declaration
|
|
7
|
-
* and keeps today's plain `border-radius` arc, so no `@supports` gate is
|
|
8
|
-
* needed for correctness.
|
|
9
6
|
*
|
|
10
7
|
* Delivery is a class + one mounted stylesheet (the hairline pattern):
|
|
11
8
|
* Tamagui strips unknown keys from the `style` prop (`style` is in
|
|
@@ -13,34 +10,174 @@
|
|
|
13
10
|
* `className` into its atomic classes — so `resolveKnobs` attaches
|
|
14
11
|
* `className: cornerSmoothClassName` to every radius-bearing fragment when
|
|
15
12
|
* the knob is `smooth`, and this module owns the CSS those classes resolve
|
|
16
|
-
* against.
|
|
13
|
+
* against. `CornerSmoothingStyles` (mounted from ThemeProvider, same seat
|
|
14
|
+
* as FontKnobStyles) re-asserts that stylesheet and publishes the live
|
|
15
|
+
* stop on `<html>` so probes do not have to guess whether the channel ran.
|
|
17
16
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
17
|
+
* Measurement (2026-08-28):
|
|
18
|
+
* - Chrome 152: specified `squircle` computes as `superellipse(2)`;
|
|
19
|
+
* specified `round` computes as `superellipse(1)`. Both
|
|
20
|
+
* `getPropertyValue("corner-shape")` and the `cornerShape` IDL agree.
|
|
21
|
+
* - Chromium 147.0.7727.15: `CSS.supports("corner-shape","squircle")` is
|
|
22
|
+
* TRUE — the gallery's old "Chromium 139+ only" caption is wrong. The
|
|
23
|
+
* action-feedback Button strip still computed `round` at both stops
|
|
24
|
+
* because those replicas never carried `.mp-corner-smooth`. The
|
|
25
|
+
* knob-states sheet (which does) records `cornerShape: "squircle"` on
|
|
26
|
+
* the smooth cell. Read computed values with
|
|
27
|
+
* `readComputedCornerShape` and treat `superellipse(2)` as smooth.
|
|
28
|
+
* - happy-dom and engines without the property: `cssSupportsCornerShape()`
|
|
29
|
+
* is false and computed `corner-shape` is empty — the silent fallback,
|
|
30
|
+
* measured rather than assumed. Native has no `corner-shape`.
|
|
31
|
+
*
|
|
32
|
+
* Re-measured 2026-09-01 across seven engines — the matrix and the probe that
|
|
33
|
+
* produced it live in `docs/design/boards/W1-corner-smoothing/`:
|
|
34
|
+
* - The property is live from at least Chromium 143, four minor versions
|
|
35
|
+
* below the 147 the INERT caption was shot on.
|
|
36
|
+
* - The computed serialization is NOT stable: it echoes the specified keyword
|
|
37
|
+
* (`round` / `squircle`) through 151 and normalizes to `superellipse(1)` /
|
|
38
|
+
* `superellipse(2)` from 152. Classify with the two helpers below; never
|
|
39
|
+
* compare a raw string.
|
|
40
|
+
* - The painted corner differs at both stops on every Chromium, read as
|
|
41
|
+
* geometry (`elementFromPoint` walking in from the corner), so the channel
|
|
42
|
+
* is proven to paint and not merely to parse.
|
|
43
|
+
* - WebKit 26 and Firefox 148: `CSS.supports` false, computed `corner-shape`
|
|
44
|
+
* empty, `border-radius` and the box footprint untouched, and the corner
|
|
45
|
+
* byte-identical between the stops. Silent degradation on a real engine.
|
|
46
|
+
*
|
|
47
|
+
* Native twin: `cornerSmoothing.native.ts` (no CSS classes — native
|
|
48
|
+
* resolves to `round` until a SquircleView adoption lands, phase 2).
|
|
20
49
|
*/
|
|
50
|
+
import { useEffect } from "react";
|
|
51
|
+
import type { CornerSmoothing } from "./knobs";
|
|
52
|
+
import { defaultKnobs } from "./knobs";
|
|
53
|
+
import { usePresetContext } from "./PresetContext";
|
|
21
54
|
|
|
22
55
|
/** Class attached to radius-bearing knob fragments at `cornerSmoothing: "smooth"`. */
|
|
23
56
|
export const cornerSmoothClassName = "mp-corner-smooth";
|
|
24
57
|
|
|
25
|
-
const
|
|
58
|
+
export const CORNER_SMOOTHING_STYLE_TAG_ID = "mp-corner-smoothing-styles";
|
|
59
|
+
|
|
60
|
+
/** Live knob stop, published on `<html>` by `CornerSmoothingStyles`. */
|
|
61
|
+
export const CORNER_SMOOTHING_ROOT_ATTR = "data-mp-corner-smoothing";
|
|
62
|
+
|
|
63
|
+
/** Specified `corner-shape` for the live stop, also on `<html>`. */
|
|
64
|
+
export const CORNER_SHAPE_CSS_VAR = "--mp-corner-shape";
|
|
65
|
+
|
|
66
|
+
/** Specified CSS value for `round` (initial / house default). */
|
|
67
|
+
export const CORNER_SHAPE_ROUND = "round";
|
|
68
|
+
|
|
69
|
+
/** Specified CSS value for `smooth` — the keyword the AC names. */
|
|
70
|
+
export const CORNER_SHAPE_SMOOTH = "squircle";
|
|
71
|
+
|
|
72
|
+
/** Chrome 152+ computed form of `round`. */
|
|
73
|
+
export const CORNER_SHAPE_ROUND_COMPUTED = "superellipse(1)";
|
|
74
|
+
|
|
75
|
+
/** Chrome 152+ computed form of `squircle`. */
|
|
76
|
+
export const CORNER_SHAPE_SMOOTH_COMPUTED = "superellipse(2)";
|
|
26
77
|
|
|
27
78
|
/**
|
|
28
79
|
* The squircle declaration. `corner-shape` composes with the element's
|
|
29
80
|
* existing `border-radius` (it reshapes the same corner box), so the
|
|
30
81
|
* fallback in non-supporting engines is automatic. Exported for unit tests.
|
|
31
82
|
*/
|
|
32
|
-
export const cornerSmoothingCss = `.${cornerSmoothClassName} { corner-shape:
|
|
83
|
+
export const cornerSmoothingCss = `.${cornerSmoothClassName} { corner-shape: ${CORNER_SHAPE_SMOOTH}; }`;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* `CSS.supports("corner-shape","squircle")`. Pass `null` to probe a missing
|
|
87
|
+
* engine. Omit the arg to use the global `CSS` (happy-dom's `supports()`
|
|
88
|
+
* currently returns true — a lie; computed `corner-shape` stays empty).
|
|
89
|
+
*/
|
|
90
|
+
export function cssSupportsCornerShape(
|
|
91
|
+
supports?: { supports?(property: string, value: string): boolean } | null,
|
|
92
|
+
): boolean {
|
|
93
|
+
const impl =
|
|
94
|
+
supports === null ? undefined : (supports ?? (typeof CSS !== "undefined" ? CSS : undefined));
|
|
95
|
+
try {
|
|
96
|
+
return Boolean(impl?.supports?.("corner-shape", CORNER_SHAPE_SMOOTH));
|
|
97
|
+
} catch {
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Read the painted `corner-shape`. Prefer the CSS property channel over the
|
|
104
|
+
* camelCase IDL — some Chromium builds expose `css.cornerShape` as the
|
|
105
|
+
* initial `round` even when `getPropertyValue("corner-shape")` has moved.
|
|
106
|
+
*/
|
|
107
|
+
export function readComputedCornerShape(
|
|
108
|
+
style: Pick<CSSStyleDeclaration, "getPropertyValue"> & { cornerShape?: string },
|
|
109
|
+
): string {
|
|
110
|
+
const fromProp = (style.getPropertyValue?.("corner-shape") ?? "").trim();
|
|
111
|
+
const fromIdl = (style.cornerShape ?? "").trim();
|
|
112
|
+
return fromProp || fromIdl;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function normalizeCornerShape(value: string): string {
|
|
116
|
+
return value.trim().toLowerCase().replace(/\s+/g, "");
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** True for specified `squircle` and the measured Chrome computed form `superellipse(2)`. */
|
|
120
|
+
export function isSmoothComputedCornerShape(value: string): boolean {
|
|
121
|
+
const n = normalizeCornerShape(value);
|
|
122
|
+
return n === CORNER_SHAPE_SMOOTH || n === CORNER_SHAPE_SMOOTH_COMPUTED;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** True for specified `round`, computed `superellipse(1)`, empty (unsupported), or `normal`. */
|
|
126
|
+
export function isRoundComputedCornerShape(value: string): boolean {
|
|
127
|
+
const n = normalizeCornerShape(value);
|
|
128
|
+
return (
|
|
129
|
+
n === "" || n === CORNER_SHAPE_ROUND || n === CORNER_SHAPE_ROUND_COMPUTED || n === "normal"
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Publish the live stop on `<html>` so a probe can see the channel ran. */
|
|
134
|
+
export function applyRootCornerSmoothing(stop: CornerSmoothing = "round"): void {
|
|
135
|
+
if (typeof document === "undefined") return;
|
|
136
|
+
const root = document.documentElement;
|
|
137
|
+
root.setAttribute(CORNER_SMOOTHING_ROOT_ATTR, stop);
|
|
138
|
+
root.style.setProperty(
|
|
139
|
+
CORNER_SHAPE_CSS_VAR,
|
|
140
|
+
stop === "smooth" ? CORNER_SHAPE_SMOOTH : CORNER_SHAPE_ROUND,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
33
143
|
|
|
34
144
|
/** Idempotently mount the corner-smoothing stylesheet (no-op off-DOM). */
|
|
35
145
|
export function ensureCornerSmoothingStyles(): void {
|
|
36
146
|
if (typeof document === "undefined") return;
|
|
37
|
-
if (document.getElementById(
|
|
147
|
+
if (document.getElementById(CORNER_SMOOTHING_STYLE_TAG_ID)) return;
|
|
38
148
|
const tag = document.createElement("style");
|
|
39
|
-
tag.id =
|
|
149
|
+
tag.id = CORNER_SMOOTHING_STYLE_TAG_ID;
|
|
40
150
|
tag.textContent = cornerSmoothingCss;
|
|
41
151
|
document.head.appendChild(tag);
|
|
42
152
|
}
|
|
43
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Mount ONCE near the root (inside KnobBridge / PresetContext). Re-asserts
|
|
156
|
+
* the squircle stylesheet and mirrors the live `cornerSmoothing` stop onto
|
|
157
|
+
* `<html>`. Renders nothing.
|
|
158
|
+
*/
|
|
159
|
+
export function CornerSmoothingStyles(): null {
|
|
160
|
+
const presetCtx = usePresetContext();
|
|
161
|
+
const knobs = presetCtx ? { ...presetCtx.preset.knobs, ...presetCtx.overrides } : defaultKnobs;
|
|
162
|
+
const stop = knobs.cornerSmoothing ?? defaultKnobs.cornerSmoothing ?? "round";
|
|
163
|
+
|
|
164
|
+
useEffect(() => {
|
|
165
|
+
ensureCornerSmoothingStyles();
|
|
166
|
+
applyRootCornerSmoothing(stop);
|
|
167
|
+
}, [stop]);
|
|
168
|
+
|
|
169
|
+
useEffect(
|
|
170
|
+
() => () => {
|
|
171
|
+
if (typeof document === "undefined") return;
|
|
172
|
+
document.documentElement.removeAttribute(CORNER_SMOOTHING_ROOT_ATTR);
|
|
173
|
+
document.documentElement.style.removeProperty(CORNER_SHAPE_CSS_VAR);
|
|
174
|
+
},
|
|
175
|
+
[],
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
|
|
44
181
|
// Static CSS (no knob/theme dependency) — mount as soon as the module loads
|
|
45
182
|
// in a DOM environment so the class resolves before any smooth render.
|
|
46
183
|
ensureCornerSmoothingStyles();
|
|
@@ -22,8 +22,13 @@ vi.mock("tamagui", () => ({
|
|
|
22
22
|
// build; themes are what these specs pin, so stub the driver out.
|
|
23
23
|
vi.mock("./animations/index", () => ({ animations: {} }));
|
|
24
24
|
|
|
25
|
+
import {
|
|
26
|
+
defaultConfig as v5DefaultConfig,
|
|
27
|
+
mediaQueryDefaultActive as v5MediaQueryDefaultActive,
|
|
28
|
+
} from "@tamagui/config/v5";
|
|
29
|
+
import { tokens as v5Tokens } from "@tamagui/themes/v5";
|
|
25
30
|
import { contrastRatio, minContrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
|
|
26
|
-
import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
|
|
31
|
+
import { TAMAGUI_ERA, createDefaultThemeConfig } from "./createDefaultThemeConfig";
|
|
27
32
|
import type { GetThemeProps } from "./createThemes";
|
|
28
33
|
import { setSizeRecipeInputs } from "./sizeRecipes";
|
|
29
34
|
|
|
@@ -149,4 +154,105 @@ describe("createDefaultThemeConfig — builderOptions.getTheme composition (DF-0
|
|
|
149
154
|
}
|
|
150
155
|
expect(config.recipeFamilies?.control.boxVariants.$4.height).toBe(44);
|
|
151
156
|
});
|
|
157
|
+
|
|
158
|
+
it("registers serif so headingFont can resolve a family without a per-app font map", () => {
|
|
159
|
+
const config = createDefaultThemeConfig() as unknown as {
|
|
160
|
+
tamagui: { fonts: Record<string, { family?: string | { val?: string } }> };
|
|
161
|
+
};
|
|
162
|
+
const serif = config.tamagui.fonts.serif;
|
|
163
|
+
expect(serif, "serif font").toBeTruthy();
|
|
164
|
+
const family = typeof serif.family === "string" ? serif.family : serif.family?.val;
|
|
165
|
+
expect(family).toMatch(/Georgia/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("keeps consumer heading/body overrides without dropping the serif category", () => {
|
|
169
|
+
const heading = { family: "ConsumerHeading" };
|
|
170
|
+
const body = { family: "ConsumerBody" };
|
|
171
|
+
const config = createDefaultThemeConfig({
|
|
172
|
+
fonts: { heading: heading as never, body: body as never },
|
|
173
|
+
}) as unknown as {
|
|
174
|
+
tamagui: { fonts: Record<string, { family?: string | { val?: string } }> };
|
|
175
|
+
};
|
|
176
|
+
expect(config.tamagui.fonts.heading).toBe(heading);
|
|
177
|
+
expect(config.tamagui.fonts.body).toBe(body);
|
|
178
|
+
const serif = config.tamagui.fonts.serif;
|
|
179
|
+
const family = typeof serif?.family === "string" ? serif.family : serif?.family?.val;
|
|
180
|
+
expect(family).toMatch(/Georgia/);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── Tamagui era pin (MPO-19 X13 / W0-era-pin; rendered reference §12.2 C9) ──
|
|
185
|
+
//
|
|
186
|
+
// v5 is the authoritative era. These specs assert the RESOLVED config, not
|
|
187
|
+
// the imports: a future re-point of createDefaultThemeConfig at another era
|
|
188
|
+
// (v4 media/tokens, or the legacy root entry drifting its values) fails here
|
|
189
|
+
// even if TAMAGUI_ERA is left saying "v5". The color token group is
|
|
190
|
+
// deliberately NOT asserted: v5 moved palettes out of tokens into themes, and
|
|
191
|
+
// the legacy palette color tokens ($blue10, …) remain as a compatibility
|
|
192
|
+
// surface with live call sites across public/ — their fate is an MPO-19 row,
|
|
193
|
+
// not part of the pin.
|
|
194
|
+
|
|
195
|
+
type TokenValue = { val?: unknown } | string | number;
|
|
196
|
+
type RawTokens = Record<string, Record<string, TokenValue>>;
|
|
197
|
+
|
|
198
|
+
/** Flatten one token group to `{ key(without $): primitive value }`. */
|
|
199
|
+
function tokenGroupValues(tokens: RawTokens, group: string): Record<string, unknown> {
|
|
200
|
+
const out: Record<string, unknown> = {};
|
|
201
|
+
for (const [key, value] of Object.entries(tokens[group] ?? {})) {
|
|
202
|
+
const name = key.startsWith("$") ? key.slice(1) : key;
|
|
203
|
+
out[name] =
|
|
204
|
+
value && typeof value === "object" && "val" in value
|
|
205
|
+
? (value as { val: unknown }).val
|
|
206
|
+
: value;
|
|
207
|
+
}
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
describe("createDefaultThemeConfig — Tamagui era pin (MPO-19 X13 / W0-era-pin)", () => {
|
|
212
|
+
const config = createDefaultThemeConfig() as unknown as {
|
|
213
|
+
tamagui: {
|
|
214
|
+
media: Record<string, unknown>;
|
|
215
|
+
settings: Record<string, unknown>;
|
|
216
|
+
tokens: RawTokens;
|
|
217
|
+
};
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
it(`declares the era: ${TAMAGUI_ERA}`, () => {
|
|
221
|
+
expect(TAMAGUI_ERA).toBe("v5");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("resolves the v5 media map, and no dead prior-era key survives", () => {
|
|
225
|
+
expect(config.tamagui.media).toEqual(v5DefaultConfig.media);
|
|
226
|
+
// v5 signature: the sm/…/xxl min-width ladder plus the small container steps.
|
|
227
|
+
for (const key of ["xxxs", "xxs", "xs", "sm", "md", "lg", "xl", "xxl"]) {
|
|
228
|
+
expect(config.tamagui.media, `media.${key}`).toHaveProperty(key);
|
|
229
|
+
}
|
|
230
|
+
// Dead eras: gtSm/gtXs (v3-era config) and 2xl/2xs/maxSm (v4) must not resolve.
|
|
231
|
+
for (const key of ["gtXs", "gtSm", "gtMd", "gtLg", "2xl", "2xs", "maxSm", "max2Xl"]) {
|
|
232
|
+
expect(config.tamagui.media, `media.${key}`).not.toHaveProperty(key);
|
|
233
|
+
}
|
|
234
|
+
expect(config.tamagui.settings.mediaQueryDefaultActive).toEqual(v5MediaQueryDefaultActive);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it("resolves v5 token values exactly for every non-color group", () => {
|
|
238
|
+
const groups = ["size", "space", "radius", "zIndex"] as const;
|
|
239
|
+
for (const group of groups) {
|
|
240
|
+
const resolved = tokenGroupValues(config.tamagui.tokens, group);
|
|
241
|
+
const v5 = tokenGroupValues(v5Tokens as unknown as RawTokens, group);
|
|
242
|
+
expect(Object.keys(v5).length, `${group} group present in v5`).toBeGreaterThan(0);
|
|
243
|
+
expect(resolved, `tokens.${group}`).toEqual(v5);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("anchors radius.true at 9, the v5 base radius", () => {
|
|
248
|
+
const radius = tokenGroupValues(config.tamagui.tokens, "radius");
|
|
249
|
+
expect(radius.true).toBe(9);
|
|
250
|
+
expect(radius["4"]).toBe(9);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it("anchors size.true at 44, the v5 base control height", () => {
|
|
254
|
+
const size = tokenGroupValues(config.tamagui.tokens, "size");
|
|
255
|
+
expect(size.true).toBe(44);
|
|
256
|
+
expect(size["4"]).toBe(44);
|
|
257
|
+
});
|
|
152
258
|
});
|