@multiplatform.one/theme 7.4.0 → 7.5.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 (106) hide show
  1. package/README.md +2 -1
  2. package/package.json +5 -5
  3. package/src/figma/figmaTokens.spec.ts +25 -21
  4. package/src/figma/figmaTokens.ts +2 -2
  5. package/src/font.ts +6 -19
  6. package/src/keyboardFocusRing.ts +12 -6
  7. package/src/theme/FontKnobStyles.tsx +5 -3
  8. package/src/theme/Tint.tsx +3 -2
  9. package/src/theme/animations/css.ts +14 -1
  10. package/src/theme/colorRules.spec.ts +233 -2
  11. package/src/theme/colorRules.ts +118 -0
  12. package/src/theme/cornerSmoothing.native.ts +39 -2
  13. package/src/theme/cornerSmoothing.spec.ts +213 -3
  14. package/src/theme/cornerSmoothing.ts +132 -10
  15. package/src/theme/createDefaultThemeConfig.spec.ts +107 -1
  16. package/src/theme/createDefaultThemeConfig.ts +27 -8
  17. package/src/theme/defaults/builderOptions.ts +23 -10
  18. package/src/theme/defaults/categoryFonts.ts +21 -0
  19. package/src/theme/defaults/fonts.spec.ts +93 -10
  20. package/src/theme/defaults/fonts.theme.spec.tsx +128 -0
  21. package/src/theme/defaults/fonts.ts +155 -27
  22. package/src/theme/defaults/index.ts +1 -0
  23. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +0 -11
  24. package/src/theme/focusState.spec.ts +110 -1
  25. package/src/theme/focusState.ts +32 -7
  26. package/src/theme/fontCategoryStacks.ts +24 -0
  27. package/src/theme/fontKnobStyles.spec.ts +15 -2
  28. package/src/theme/glyphPaint.spec.ts +26 -0
  29. package/src/theme/glyphPaint.ts +46 -0
  30. package/src/theme/index.ts +3 -0
  31. package/src/theme/knobs.ts +18 -2
  32. package/src/theme/layoutTokens.spec.ts +3 -1
  33. package/src/theme/layoutTokens.ts +47 -4
  34. package/src/theme/mpo13Proof.spec.ts +3 -3
  35. package/src/theme/mpo19Proof.spec.ts +285 -0
  36. package/src/theme/presets.ts +1 -1
  37. package/src/theme/radiusClass.spec.ts +394 -0
  38. package/src/theme/radiusClass.ts +203 -4
  39. package/src/theme/radiusClassTable.generated.ts +121 -0
  40. package/src/theme/recipeInputs.ts +223 -36
  41. package/src/theme/recipes.ts +68 -4
  42. package/src/theme/resolveKnobs.spec.ts +139 -11
  43. package/src/theme/resolveKnobs.ts +190 -25
  44. package/src/theme/sizeLadder.spec.ts +84 -0
  45. package/src/theme/sizeRecipes.spec.ts +19 -6
  46. package/src/theme/sizeRecipes.ts +43 -13
  47. package/src/theme/theme.native.tsx +2 -0
  48. package/src/theme/theme.provider.spec.ts +9 -0
  49. package/src/theme/theme.tsx +6 -3
  50. package/src/theme/tint.spec.tsx +36 -35
  51. package/src/theme/useResolvedKnobs.ts +6 -3
  52. package/types/figma/figmaTokens.d.ts +1 -1
  53. package/types/figma/figmaTokens.d.ts.map +1 -1
  54. package/types/font.d.ts.map +1 -1
  55. package/types/keyboardFocusRing.d.ts +11 -5
  56. package/types/keyboardFocusRing.d.ts.map +1 -1
  57. package/types/theme/FontKnobStyles.d.ts +2 -1
  58. package/types/theme/FontKnobStyles.d.ts.map +1 -1
  59. package/types/theme/Tint.d.ts +2 -1
  60. package/types/theme/Tint.d.ts.map +1 -1
  61. package/types/theme/animations/css.d.ts +15 -2
  62. package/types/theme/animations/css.d.ts.map +1 -1
  63. package/types/theme/colorRules.d.ts +59 -1
  64. package/types/theme/colorRules.d.ts.map +1 -1
  65. package/types/theme/cornerSmoothing.d.ts +42 -20
  66. package/types/theme/cornerSmoothing.d.ts.map +1 -1
  67. package/types/theme/cornerSmoothing.native.d.ts +22 -2
  68. package/types/theme/cornerSmoothing.native.d.ts.map +1 -1
  69. package/types/theme/createDefaultThemeConfig.d.ts +21 -3
  70. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
  71. package/types/theme/defaults/builderOptions.d.ts.map +1 -1
  72. package/types/theme/defaults/categoryFonts.d.ts +19 -0
  73. package/types/theme/defaults/categoryFonts.d.ts.map +1 -0
  74. package/types/theme/defaults/fonts.d.ts +135 -2
  75. package/types/theme/defaults/fonts.d.ts.map +1 -1
  76. package/types/theme/defaults/index.d.ts +1 -0
  77. package/types/theme/defaults/index.d.ts.map +1 -1
  78. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
  79. package/types/theme/focusState.d.ts +32 -6
  80. package/types/theme/focusState.d.ts.map +1 -1
  81. package/types/theme/fontCategoryStacks.d.ts +11 -0
  82. package/types/theme/fontCategoryStacks.d.ts.map +1 -0
  83. package/types/theme/glyphPaint.d.ts +16 -0
  84. package/types/theme/glyphPaint.d.ts.map +1 -0
  85. package/types/theme/index.d.ts +3 -0
  86. package/types/theme/index.d.ts.map +1 -1
  87. package/types/theme/knobs.d.ts +10 -2
  88. package/types/theme/knobs.d.ts.map +1 -1
  89. package/types/theme/layoutTokens.d.ts +46 -4
  90. package/types/theme/layoutTokens.d.ts.map +1 -1
  91. package/types/theme/radiusClass.d.ts +96 -4
  92. package/types/theme/radiusClass.d.ts.map +1 -1
  93. package/types/theme/radiusClassTable.generated.d.ts +146 -0
  94. package/types/theme/radiusClassTable.generated.d.ts.map +1 -0
  95. package/types/theme/recipeInputs.d.ts +85 -14
  96. package/types/theme/recipeInputs.d.ts.map +1 -1
  97. package/types/theme/recipes.d.ts +78 -4
  98. package/types/theme/recipes.d.ts.map +1 -1
  99. package/types/theme/resolveKnobs.d.ts +12 -1
  100. package/types/theme/resolveKnobs.d.ts.map +1 -1
  101. package/types/theme/sizeRecipes.d.ts +27 -14
  102. package/types/theme/sizeRecipes.d.ts.map +1 -1
  103. package/types/theme/theme.d.ts.map +1 -1
  104. package/types/theme/theme.native.d.ts.map +1 -1
  105. package/types/theme/useResolvedKnobs.d.ts +2 -2
  106. package/types/theme/useResolvedKnobs.d.ts.map +1 -1
@@ -77,6 +77,124 @@ export const minContrastRatio = 3;
77
77
  /** WCAG AA contrast floor for normal-size body/label text. */
78
78
  export const aaTextContrastRatio = 4.5;
79
79
 
80
+ // ── Contrast floor assertion (DG-A11Y-01 / DG-COL-01) ─────────
81
+ //
82
+ // `aaTextContrastRatio` declared a floor that no code enforced: MPO-40's
83
+ // outlined warning $color11 (#9e6c00, 4.34:1) and success (#218358, 4.48:1)
84
+ // and MPO-48's dark accent toast action pill (#46349d on #161519, 1.94:1)
85
+ // all shipped through review, tests and a design pass. This is THE one
86
+ // enforcement — theme specs here, shc's contrast.spec.ts and MPO-18's
87
+ // theme matrix all call it instead of re-deriving the arithmetic.
88
+
89
+ /** A foreground/background pair held to a WCAG contrast floor. */
90
+ export interface ContrastPair {
91
+ /** Text/glyph color, in any format `normalizeToHex` accepts. */
92
+ foreground: string;
93
+ /** The fill the foreground sits on. */
94
+ background: string;
95
+ /** Floor the pair owes. Defaults to `aaTextContrastRatio` (4.5). */
96
+ floor?: number;
97
+ /** Names the pair in reports, e.g. "light_accent: color on background". */
98
+ label?: string;
99
+ }
100
+
101
+ /** Measured outcome for one {@link ContrastPair}. */
102
+ export interface ContrastReport {
103
+ label: string;
104
+ /** The colors actually measured, as normalized 6-digit hex. */
105
+ foreground: string;
106
+ background: string;
107
+ /** WCAG 2.x ratio rounded to 2 decimals — the number a report reads. */
108
+ ratio: number;
109
+ floor: number;
110
+ pass: boolean;
111
+ }
112
+
113
+ /**
114
+ * Measure one pair against its floor. Throws when a color cannot be reduced
115
+ * to an opaque hex: an alpha color has no contrast of its own and the house
116
+ * ramps are fully opaque, so an unmeasurable pair is a genuine failure,
117
+ * never a skip. The verdict compares the REPORTED (2-decimal) ratio, so
118
+ * pass/fail always matches the printed number.
119
+ */
120
+ export function measureContrast(pair: ContrastPair): ContrastReport {
121
+ const floor = pair.floor ?? aaTextContrastRatio;
122
+ const label = pair.label ?? `${pair.foreground} on ${pair.background}`;
123
+ const foreground = normalizeToHex(pair.foreground);
124
+ if (!foreground) {
125
+ throw new Error(`${label}: foreground is not an opaque color: ${pair.foreground}`);
126
+ }
127
+ const background = normalizeToHex(pair.background);
128
+ if (!background) {
129
+ throw new Error(`${label}: background is not an opaque color: ${pair.background}`);
130
+ }
131
+ const ratio =
132
+ Math.round(contrastRatio(relativeLuminance(foreground), relativeLuminance(background)) * 100) /
133
+ 100;
134
+ return { label, foreground, background, ratio, floor, pass: ratio >= floor };
135
+ }
136
+
137
+ /**
138
+ * The contrast floor assertion. Measures every pair and throws ONE error
139
+ * naming every pair under its floor — a sweep reports the whole damage, not
140
+ * the first miss. Returns the reports when all pass, so callers can attach
141
+ * the measured numbers as proof.
142
+ */
143
+ export function assertContrast(pairs: ContrastPair | ContrastPair[]): ContrastReport[] {
144
+ const reports = (Array.isArray(pairs) ? pairs : [pairs]).map(measureContrast);
145
+ const misses = reports.filter((report) => !report.pass);
146
+ if (misses.length > 0) {
147
+ const lines = misses.map(
148
+ (report) =>
149
+ `${report.label}: ${report.foreground} on ${report.background} = ` +
150
+ `${report.ratio}:1 — under the ${report.floor}:1 floor`,
151
+ );
152
+ throw new Error(
153
+ `contrast floor missed (${misses.length} of ${reports.length} pairs):\n${lines.join("\n")}`,
154
+ );
155
+ }
156
+ return reports;
157
+ }
158
+
159
+ /** Addresses a {@link ContrastPair} inside built themes by name and keys. */
160
+ export interface ThemeContrastPair {
161
+ /** Theme name, e.g. "light_accent" or "dark". */
162
+ theme: string;
163
+ /** Theme key carrying the text/glyph color, e.g. "color" or "color11". */
164
+ foreground: string;
165
+ /** Theme key carrying the fill, e.g. "background" or "color2". */
166
+ background: string;
167
+ /** Floor the pair owes. Defaults to `aaTextContrastRatio` (4.5). */
168
+ floor?: number;
169
+ }
170
+
171
+ /**
172
+ * Resolve a pair out of BUILT themes — the record `createThemesBuilder`
173
+ * returns, the same objects handed to createTamagui. Asserting on built
174
+ * themes rather than source ramps is the point: the pairing that fails is
175
+ * produced layers away by the theme-builder template, so reading a palette
176
+ * file tells you nothing about what lands under a label. Addressed by theme
177
+ * name + key so a failure names the thing a designer would change; a missing
178
+ * theme or key throws rather than skips.
179
+ */
180
+ export function resolveThemeContrast(
181
+ themes: Record<string, Record<string, string> | undefined>,
182
+ pair: ThemeContrastPair,
183
+ ): ContrastPair {
184
+ const theme = themes[pair.theme];
185
+ if (!theme) throw new Error(`no such theme: ${pair.theme}`);
186
+ const foreground = theme[pair.foreground];
187
+ if (!foreground) throw new Error(`${pair.theme} has no ${pair.foreground}`);
188
+ const background = theme[pair.background];
189
+ if (!background) throw new Error(`${pair.theme} has no ${pair.background}`);
190
+ return {
191
+ foreground,
192
+ background,
193
+ floor: pair.floor,
194
+ label: `${pair.theme}: ${pair.foreground} on ${pair.background}`,
195
+ };
196
+ }
197
+
80
198
  /**
81
199
  * Axiom 15 OPTICS — pick whichever candidate foreground carries more WCAG
82
200
  * contrast on a given `fill`. The canonical "readable foreground on a fill"
@@ -2,15 +2,52 @@
2
2
  * OPTICS corner smoothing (Axiom 15) — native twin of `cornerSmoothing.ts`.
3
3
  *
4
4
  * React Native has no `corner-shape`; native resolves `cornerSmoothing` to
5
- * `round` (resolveKnobs emits no class on native) until a SquircleView
6
- * adoption lands (phase 2). Exports stay shape-compatible for shared code.
5
+ * `round` until a SquircleView adoption lands (phase 2). Exports stay
6
+ * shape-compatible so shared code and tests can import either file.
7
7
  */
8
+ import type { CornerSmoothing } from "./knobs";
8
9
 
9
10
  /** Class attached on web only — never emitted into native fragments. */
10
11
  export const cornerSmoothClassName = "mp-corner-smooth";
11
12
 
13
+ export const CORNER_SMOOTHING_STYLE_TAG_ID = "mp-corner-smoothing-styles";
14
+ export const CORNER_SMOOTHING_ROOT_ATTR = "data-mp-corner-smoothing";
15
+ export const CORNER_SHAPE_CSS_VAR = "--mp-corner-shape";
16
+ export const CORNER_SHAPE_ROUND = "round";
17
+ export const CORNER_SHAPE_SMOOTH = "squircle";
18
+ export const CORNER_SHAPE_ROUND_COMPUTED = "superellipse(1)";
19
+ export const CORNER_SHAPE_SMOOTH_COMPUTED = "superellipse(2)";
20
+
12
21
  /** Web-only stylesheet — empty on native. */
13
22
  export const cornerSmoothingCss = "";
14
23
 
24
+ /** Native has no CSS.supports corner-shape — measured no-op, not assumed. */
25
+ export function cssSupportsCornerShape(_supports?: {
26
+ supports?(property: string, value: string): boolean;
27
+ }): boolean {
28
+ return false;
29
+ }
30
+
31
+ export function readComputedCornerShape(
32
+ _style?: Pick<CSSStyleDeclaration, "getPropertyValue"> & { cornerShape?: string },
33
+ ): string {
34
+ return "";
35
+ }
36
+
37
+ export function isSmoothComputedCornerShape(_value: string): boolean {
38
+ return false;
39
+ }
40
+
41
+ export function isRoundComputedCornerShape(_value: string): boolean {
42
+ return true;
43
+ }
44
+
45
+ export function applyRootCornerSmoothing(_stop?: CornerSmoothing): void {}
46
+
15
47
  /** Web-only stylesheet mount — no-op on native. */
16
48
  export function ensureCornerSmoothingStyles(): void {}
49
+
50
+ /** Native no-op. Web mounts the squircle stylesheet from ThemeProvider. */
51
+ export function CornerSmoothingStyles(): null {
52
+ return null;
53
+ }
@@ -1,27 +1,237 @@
1
- import { beforeEach, describe, expect, it } from "vitest";
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync, mkdtempSync, 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
+ function findChrome(): string | undefined {
30
+ const candidates = [
31
+ process.env.CHROME_BIN,
32
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
33
+ `${process.env.HOME ?? ""}/.local/bin/google-chrome`,
34
+ ].filter((p): p is string => Boolean(p));
35
+ return candidates.find((p) => existsSync(p));
36
+ }
37
+
8
38
  describe("cornerSmoothing (Axiom 15 OPTICS)", () => {
9
39
  it("scopes the squircle declaration to the fragment class", () => {
10
40
  expect(cornerSmoothClassName).toBe("mp-corner-smooth");
11
41
  expect(cornerSmoothingCss).toBe(".mp-corner-smooth { corner-shape: squircle; }");
42
+ expect(cornerSmoothingCss).toContain(`corner-shape: ${CORNER_SHAPE_SMOOTH}`);
12
43
  });
13
44
 
14
45
  describe("stylesheet mount", () => {
15
46
  beforeEach(() => {
16
- document.getElementById("mp-corner-smoothing-styles")?.remove();
47
+ document.getElementById(CORNER_SMOOTHING_STYLE_TAG_ID)?.remove();
17
48
  });
18
49
 
19
50
  it("mounts the stylesheet once (idempotent)", () => {
20
51
  ensureCornerSmoothingStyles();
21
52
  ensureCornerSmoothingStyles();
22
- const tags = document.querySelectorAll("#mp-corner-smoothing-styles");
53
+ const tags = document.querySelectorAll(`#${CORNER_SMOOTHING_STYLE_TAG_ID}`);
23
54
  expect(tags).toHaveLength(1);
24
55
  expect(tags[0]?.textContent).toBe(cornerSmoothingCss);
25
56
  });
26
57
  });
58
+
59
+ describe("support detection (measured, not assumed)", () => {
60
+ it("is false when the engine is missing (null) or has no supports()", () => {
61
+ expect(cssSupportsCornerShape(null)).toBe(false);
62
+ expect(cssSupportsCornerShape({ supports: undefined })).toBe(false);
63
+ });
64
+
65
+ it("is true only when the UA reports the squircle value", () => {
66
+ expect(cssSupportsCornerShape({ supports: () => true })).toBe(true);
67
+ expect(cssSupportsCornerShape({ supports: () => false })).toBe(false);
68
+ });
69
+
70
+ it("swallows a throwing supports() as unsupported", () => {
71
+ expect(
72
+ cssSupportsCornerShape({
73
+ supports: () => {
74
+ throw new Error("nope");
75
+ },
76
+ }),
77
+ ).toBe(false);
78
+ });
79
+ });
80
+
81
+ describe("computed-shape reader", () => {
82
+ it("prefers getPropertyValue over the camelCase IDL", () => {
83
+ expect(
84
+ readComputedCornerShape({
85
+ getPropertyValue: (name) => (name === "corner-shape" ? "superellipse(2)" : ""),
86
+ cornerShape: "round",
87
+ }),
88
+ ).toBe("superellipse(2)");
89
+ });
90
+
91
+ it("falls back to the IDL when the property channel is empty", () => {
92
+ expect(
93
+ readComputedCornerShape({
94
+ getPropertyValue: () => "",
95
+ cornerShape: "squircle",
96
+ }),
97
+ ).toBe("squircle");
98
+ });
99
+
100
+ it("treats specified squircle and computed superellipse(2) as smooth", () => {
101
+ expect(isSmoothComputedCornerShape("squircle")).toBe(true);
102
+ expect(isSmoothComputedCornerShape("superellipse(2)")).toBe(true);
103
+ expect(isSmoothComputedCornerShape(" SuperEllipse(2) ")).toBe(true);
104
+ expect(isSmoothComputedCornerShape("round")).toBe(false);
105
+ expect(isSmoothComputedCornerShape("superellipse(1)")).toBe(false);
106
+ });
107
+
108
+ it("treats round / superellipse(1) / empty as the unsupported-or-round stop", () => {
109
+ expect(isRoundComputedCornerShape("round")).toBe(true);
110
+ expect(isRoundComputedCornerShape("superellipse(1)")).toBe(true);
111
+ expect(isRoundComputedCornerShape("")).toBe(true);
112
+ expect(isRoundComputedCornerShape("normal")).toBe(true);
113
+ expect(isRoundComputedCornerShape("squircle")).toBe(false);
114
+ });
115
+
116
+ it("a class-marked node plus the mounted sheet is classifiable", () => {
117
+ ensureCornerSmoothingStyles();
118
+ const el = document.createElement("div");
119
+ el.className = cornerSmoothClassName;
120
+ el.style.borderRadius = "16px";
121
+ document.body.appendChild(el);
122
+ expect(document.getElementById(CORNER_SMOOTHING_STYLE_TAG_ID)?.textContent).toBe(
123
+ cornerSmoothingCss,
124
+ );
125
+ const computed = readComputedCornerShape(getComputedStyle(el));
126
+ // Measured 2026-08-28: this happy-dom applies `squircle`. An engine
127
+ // that drops the unknown declaration reads empty/round. Either stop
128
+ // is a measured result, not an assumed skip.
129
+ expect(isSmoothComputedCornerShape(computed) || isRoundComputedCornerShape(computed)).toBe(
130
+ true,
131
+ );
132
+ el.remove();
133
+ });
134
+ });
135
+
136
+ describe("root channel", () => {
137
+ afterEach(() => {
138
+ document.documentElement.removeAttribute(CORNER_SMOOTHING_ROOT_ATTR);
139
+ document.documentElement.style.removeProperty(CORNER_SHAPE_CSS_VAR);
140
+ cleanup();
141
+ });
142
+
143
+ it("publishes the live stop on <html>", () => {
144
+ applyRootCornerSmoothing("smooth");
145
+ expect(document.documentElement.getAttribute(CORNER_SMOOTHING_ROOT_ATTR)).toBe("smooth");
146
+ expect(document.documentElement.style.getPropertyValue(CORNER_SHAPE_CSS_VAR)).toBe(
147
+ CORNER_SHAPE_SMOOTH,
148
+ );
149
+ applyRootCornerSmoothing("round");
150
+ expect(document.documentElement.getAttribute(CORNER_SMOOTHING_ROOT_ATTR)).toBe("round");
151
+ expect(document.documentElement.style.getPropertyValue(CORNER_SHAPE_CSS_VAR)).toBe(
152
+ CORNER_SHAPE_ROUND,
153
+ );
154
+ });
155
+
156
+ it("CornerSmoothingStyles mounts the stylesheet and follows the preset stop", () => {
157
+ document.getElementById(CORNER_SMOOTHING_STYLE_TAG_ID)?.remove();
158
+ const value: PresetContextValue = {
159
+ preset: {
160
+ ...defaultPreset,
161
+ knobs: { ...defaultPreset.knobs, cornerSmoothing: "smooth" },
162
+ },
163
+ };
164
+ render(
165
+ createElement(
166
+ PresetContext.Provider,
167
+ { value },
168
+ createElement(CornerSmoothingStyles) as ReactNode,
169
+ ),
170
+ );
171
+ expect(document.getElementById(CORNER_SMOOTHING_STYLE_TAG_ID)?.textContent).toBe(
172
+ cornerSmoothingCss,
173
+ );
174
+ expect(document.documentElement.getAttribute(CORNER_SMOOTHING_ROOT_ATTR)).toBe("smooth");
175
+ });
176
+ });
177
+
178
+ describe("supporting browser (Chromium)", () => {
179
+ it("computed corner-shape DIFFERS between round and smooth", () => {
180
+ const chrome = findChrome();
181
+ if (!chrome) {
182
+ // Held-out: no Chromium binary. The happy-dom arm above records
183
+ // whatever this harness computes as measured, not assumed.
184
+ return;
185
+ }
186
+ const dir = mkdtempSync(join(tmpdir(), "mp-corner-shape-"));
187
+ const htmlPath = join(dir, "probe.html");
188
+ writeFileSync(
189
+ htmlPath,
190
+ `<!doctype html><style>
191
+ .box { width: 132px; height: 84px; border-radius: 16px; }
192
+ .round { corner-shape: round; }
193
+ .smooth { corner-shape: squircle; }
194
+ .${cornerSmoothClassName} { corner-shape: squircle; }
195
+ </style>
196
+ <div id="round" class="box round"></div>
197
+ <div id="smooth" class="box smooth"></div>
198
+ <div id="classy" class="box ${cornerSmoothClassName}"></div>
199
+ <pre id="out"></pre>
200
+ <script>
201
+ const read = (el) => {
202
+ const cs = getComputedStyle(el);
203
+ return (cs.getPropertyValue("corner-shape") || cs.cornerShape || "").trim();
204
+ };
205
+ const out = {
206
+ supportsSquircle: CSS.supports("corner-shape", "squircle"),
207
+ ua: navigator.userAgent,
208
+ round: read(document.getElementById("round")),
209
+ smooth: read(document.getElementById("smooth")),
210
+ classy: read(document.getElementById("classy")),
211
+ };
212
+ document.getElementById("out").textContent = JSON.stringify(out);
213
+ </script>`,
214
+ );
215
+ const dump = execFileSync(
216
+ chrome,
217
+ ["--headless=new", "--disable-gpu", "--dump-dom", `file://${htmlPath}`],
218
+ { encoding: "utf8", timeout: 25_000 },
219
+ );
220
+ const match = dump.match(/<pre id="out">([\s\S]*?)<\/pre>/);
221
+ expect(match, "probe <pre id=out> missing from dump-dom").toBeTruthy();
222
+ const result = JSON.parse(match![1]) as {
223
+ supportsSquircle: boolean;
224
+ round: string;
225
+ smooth: string;
226
+ classy: string;
227
+ };
228
+ expect(result.supportsSquircle).toBe(true);
229
+ expect(isRoundComputedCornerShape(result.round)).toBe(true);
230
+ expect(isSmoothComputedCornerShape(result.smooth)).toBe(true);
231
+ expect(isSmoothComputedCornerShape(result.classy)).toBe(true);
232
+ expect(result.smooth).not.toBe(result.round);
233
+ expect([CORNER_SHAPE_SMOOTH, CORNER_SHAPE_SMOOTH_COMPUTED]).toContain(result.smooth);
234
+ expect([CORNER_SHAPE_ROUND, CORNER_SHAPE_ROUND_COMPUTED]).toContain(result.round);
235
+ });
236
+ });
27
237
  });
@@ -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,159 @@
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
- * Native twin: `cornerSmoothing.native.ts` (no CSS classes — native resolves
19
- * to `round` until a SquircleView adoption lands, phase 2).
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
+ * Native twin: `cornerSmoothing.native.ts` (no CSS classes — native
33
+ * resolves to `round` until a SquircleView adoption lands, phase 2).
20
34
  */
35
+ import { useEffect } from "react";
36
+ import type { CornerSmoothing } from "./knobs";
37
+ import { defaultKnobs } from "./knobs";
38
+ import { usePresetContext } from "./PresetContext";
21
39
 
22
40
  /** Class attached to radius-bearing knob fragments at `cornerSmoothing: "smooth"`. */
23
41
  export const cornerSmoothClassName = "mp-corner-smooth";
24
42
 
25
- const STYLE_TAG_ID = "mp-corner-smoothing-styles";
43
+ export const CORNER_SMOOTHING_STYLE_TAG_ID = "mp-corner-smoothing-styles";
44
+
45
+ /** Live knob stop, published on `<html>` by `CornerSmoothingStyles`. */
46
+ export const CORNER_SMOOTHING_ROOT_ATTR = "data-mp-corner-smoothing";
47
+
48
+ /** Specified `corner-shape` for the live stop, also on `<html>`. */
49
+ export const CORNER_SHAPE_CSS_VAR = "--mp-corner-shape";
50
+
51
+ /** Specified CSS value for `round` (initial / house default). */
52
+ export const CORNER_SHAPE_ROUND = "round";
53
+
54
+ /** Specified CSS value for `smooth` — the keyword the AC names. */
55
+ export const CORNER_SHAPE_SMOOTH = "squircle";
56
+
57
+ /** Chrome 152+ computed form of `round`. */
58
+ export const CORNER_SHAPE_ROUND_COMPUTED = "superellipse(1)";
59
+
60
+ /** Chrome 152+ computed form of `squircle`. */
61
+ export const CORNER_SHAPE_SMOOTH_COMPUTED = "superellipse(2)";
26
62
 
27
63
  /**
28
64
  * The squircle declaration. `corner-shape` composes with the element's
29
65
  * existing `border-radius` (it reshapes the same corner box), so the
30
66
  * fallback in non-supporting engines is automatic. Exported for unit tests.
31
67
  */
32
- export const cornerSmoothingCss = `.${cornerSmoothClassName} { corner-shape: squircle; }`;
68
+ export const cornerSmoothingCss = `.${cornerSmoothClassName} { corner-shape: ${CORNER_SHAPE_SMOOTH}; }`;
69
+
70
+ /**
71
+ * `CSS.supports("corner-shape","squircle")`. Pass `null` to probe a missing
72
+ * engine. Omit the arg to use the global `CSS` (happy-dom's `supports()`
73
+ * currently returns true — a lie; computed `corner-shape` stays empty).
74
+ */
75
+ export function cssSupportsCornerShape(
76
+ supports?: { supports?(property: string, value: string): boolean } | null,
77
+ ): boolean {
78
+ const impl =
79
+ supports === null ? undefined : (supports ?? (typeof CSS !== "undefined" ? CSS : undefined));
80
+ try {
81
+ return Boolean(impl?.supports?.("corner-shape", CORNER_SHAPE_SMOOTH));
82
+ } catch {
83
+ return false;
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Read the painted `corner-shape`. Prefer the CSS property channel over the
89
+ * camelCase IDL — some Chromium builds expose `css.cornerShape` as the
90
+ * initial `round` even when `getPropertyValue("corner-shape")` has moved.
91
+ */
92
+ export function readComputedCornerShape(
93
+ style: Pick<CSSStyleDeclaration, "getPropertyValue"> & { cornerShape?: string },
94
+ ): string {
95
+ const fromProp = (style.getPropertyValue?.("corner-shape") ?? "").trim();
96
+ const fromIdl = (style.cornerShape ?? "").trim();
97
+ return fromProp || fromIdl;
98
+ }
99
+
100
+ function normalizeCornerShape(value: string): string {
101
+ return value.trim().toLowerCase().replace(/\s+/g, "");
102
+ }
103
+
104
+ /** True for specified `squircle` and the measured Chrome computed form `superellipse(2)`. */
105
+ export function isSmoothComputedCornerShape(value: string): boolean {
106
+ const n = normalizeCornerShape(value);
107
+ return n === CORNER_SHAPE_SMOOTH || n === CORNER_SHAPE_SMOOTH_COMPUTED;
108
+ }
109
+
110
+ /** True for specified `round`, computed `superellipse(1)`, empty (unsupported), or `normal`. */
111
+ export function isRoundComputedCornerShape(value: string): boolean {
112
+ const n = normalizeCornerShape(value);
113
+ return (
114
+ n === "" || n === CORNER_SHAPE_ROUND || n === CORNER_SHAPE_ROUND_COMPUTED || n === "normal"
115
+ );
116
+ }
117
+
118
+ /** Publish the live stop on `<html>` so a probe can see the channel ran. */
119
+ export function applyRootCornerSmoothing(stop: CornerSmoothing = "round"): void {
120
+ if (typeof document === "undefined") return;
121
+ const root = document.documentElement;
122
+ root.setAttribute(CORNER_SMOOTHING_ROOT_ATTR, stop);
123
+ root.style.setProperty(
124
+ CORNER_SHAPE_CSS_VAR,
125
+ stop === "smooth" ? CORNER_SHAPE_SMOOTH : CORNER_SHAPE_ROUND,
126
+ );
127
+ }
33
128
 
34
129
  /** Idempotently mount the corner-smoothing stylesheet (no-op off-DOM). */
35
130
  export function ensureCornerSmoothingStyles(): void {
36
131
  if (typeof document === "undefined") return;
37
- if (document.getElementById(STYLE_TAG_ID)) return;
132
+ if (document.getElementById(CORNER_SMOOTHING_STYLE_TAG_ID)) return;
38
133
  const tag = document.createElement("style");
39
- tag.id = STYLE_TAG_ID;
134
+ tag.id = CORNER_SMOOTHING_STYLE_TAG_ID;
40
135
  tag.textContent = cornerSmoothingCss;
41
136
  document.head.appendChild(tag);
42
137
  }
43
138
 
139
+ /**
140
+ * Mount ONCE near the root (inside KnobBridge / PresetContext). Re-asserts
141
+ * the squircle stylesheet and mirrors the live `cornerSmoothing` stop onto
142
+ * `<html>`. Renders nothing.
143
+ */
144
+ export function CornerSmoothingStyles(): null {
145
+ const presetCtx = usePresetContext();
146
+ const knobs = presetCtx ? { ...presetCtx.preset.knobs, ...presetCtx.overrides } : defaultKnobs;
147
+ const stop = knobs.cornerSmoothing ?? defaultKnobs.cornerSmoothing ?? "round";
148
+
149
+ useEffect(() => {
150
+ ensureCornerSmoothingStyles();
151
+ applyRootCornerSmoothing(stop);
152
+ }, [stop]);
153
+
154
+ useEffect(
155
+ () => () => {
156
+ if (typeof document === "undefined") return;
157
+ document.documentElement.removeAttribute(CORNER_SMOOTHING_ROOT_ATTR);
158
+ document.documentElement.style.removeProperty(CORNER_SHAPE_CSS_VAR);
159
+ },
160
+ [],
161
+ );
162
+
163
+ return null;
164
+ }
165
+
44
166
  // Static CSS (no knob/theme dependency) — mount as soon as the module loads
45
167
  // in a DOM environment so the class resolves before any smooth render.
46
168
  ensureCornerSmoothingStyles();