@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.
- package/README.md +2 -1
- package/package.json +5 -5
- package/src/figma/figmaTokens.spec.ts +25 -21
- package/src/figma/figmaTokens.ts +2 -2
- package/src/font.ts +6 -19
- package/src/keyboardFocusRing.ts +12 -6
- package/src/theme/FontKnobStyles.tsx +5 -3
- package/src/theme/Tint.tsx +3 -2
- package/src/theme/animations/css.ts +14 -1
- package/src/theme/colorRules.spec.ts +233 -2
- package/src/theme/colorRules.ts +118 -0
- package/src/theme/cornerSmoothing.native.ts +39 -2
- package/src/theme/cornerSmoothing.spec.ts +213 -3
- package/src/theme/cornerSmoothing.ts +132 -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 +21 -0
- package/src/theme/defaults/fonts.spec.ts +93 -10
- package/src/theme/defaults/fonts.theme.spec.tsx +128 -0
- package/src/theme/defaults/fonts.ts +155 -27
- package/src/theme/defaults/index.ts +1 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +0 -11
- package/src/theme/focusState.spec.ts +110 -1
- package/src/theme/focusState.ts +32 -7
- package/src/theme/fontCategoryStacks.ts +24 -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 +3 -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 +139 -11
- package/src/theme/resolveKnobs.ts +190 -25
- 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/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/keyboardFocusRing.d.ts +11 -5
- package/types/keyboardFocusRing.d.ts.map +1 -1
- package/types/theme/FontKnobStyles.d.ts +2 -1
- 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 +19 -0
- package/types/theme/defaults/categoryFonts.d.ts.map +1 -0
- package/types/theme/defaults/fonts.d.ts +135 -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/fontCategoryStacks.d.ts +11 -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 +3 -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
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
5
|
+
import { afterAll, describe, expect, it } from "vitest";
|
|
6
|
+
import type { BorderRadius } from "./knobs";
|
|
7
|
+
import { radiusOverrideFor, resolveRadiusClass, resolveRadiusForPart } from "./radiusClass";
|
|
8
|
+
import {
|
|
9
|
+
OPEN_RADIUS_ADJUDICATIONS,
|
|
10
|
+
RADIUS_OVERRIDE_TABLE,
|
|
11
|
+
RADIUS_RESOLUTION_COMPOSITION,
|
|
12
|
+
RADIUS_RESOLUTION_STOPS,
|
|
13
|
+
RADIUS_RESOLUTION_TABLE,
|
|
14
|
+
} from "./radiusClassTable.generated";
|
|
15
|
+
import { capContainerRadius } from "./resolveKnobs";
|
|
16
|
+
|
|
17
|
+
// The generator is the doctrine's reading end — the spec imports it directly so a
|
|
18
|
+
// doctrine edit and a generated-table edit are compared in the same run. Vite
|
|
19
|
+
// rewrites import.meta.url to an http URL, so we resolve from cwd (public/theme)
|
|
20
|
+
// and force a file: specifier the ESM loader will accept.
|
|
21
|
+
const registry = await import(
|
|
22
|
+
pathToFileURL(join(process.cwd(), "../../scripts/radius-class-registry.mjs")).href
|
|
23
|
+
);
|
|
24
|
+
const doctrine = registry.readRadiusClassDoctrine();
|
|
25
|
+
|
|
26
|
+
const STOPS = ["none", "small", "medium", "large", "full"] as const;
|
|
27
|
+
|
|
28
|
+
// Sample geometry: a 44px control (h/2 = 22) and the three space-stop paddings
|
|
29
|
+
// the doctrine names (13/18/32 — panelPaddingPxMap).
|
|
30
|
+
const H = 44;
|
|
31
|
+
const PADS = { small: 13, medium: 18, large: 32 } as const;
|
|
32
|
+
|
|
33
|
+
describe("the doctrine table, cell by cell (DG-RAD-01..04)", () => {
|
|
34
|
+
const expected: Record<string, Record<BorderRadius, number>> = {
|
|
35
|
+
DEFAULT: { none: 0, small: 5, medium: 9, large: 16, full: 50 },
|
|
36
|
+
"CIRCULAR-AT-FULL": { none: 0, small: 5, medium: 9, large: 16, full: H / 2 },
|
|
37
|
+
BINARY: { none: 0, small: H / 2, medium: H / 2, large: H / 2, full: H / 2 },
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
for (const [radiusClass, row] of Object.entries(expected)) {
|
|
41
|
+
for (const stop of STOPS) {
|
|
42
|
+
it(`${radiusClass} at ${stop} resolves to ${row[stop]}`, () => {
|
|
43
|
+
expect(
|
|
44
|
+
resolveRadiusClass(radiusClass as keyof typeof RADIUS_RESOLUTION_TABLE, stop, {
|
|
45
|
+
heightPx: H,
|
|
46
|
+
}),
|
|
47
|
+
).toBe(row[stop]);
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const containerCap: Record<keyof typeof PADS, Record<BorderRadius, number>> = {
|
|
53
|
+
small: { none: 0, small: 5, medium: 9, large: 13, full: 13 },
|
|
54
|
+
medium: { none: 0, small: 5, medium: 9, large: 16, full: 18 },
|
|
55
|
+
large: { none: 0, small: 5, medium: 9, large: 16, full: 32 },
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
for (const space of Object.keys(containerCap) as (keyof typeof PADS)[]) {
|
|
59
|
+
for (const stop of STOPS) {
|
|
60
|
+
it(`CONTAINER-CAP at ${stop} under space=${space} resolves to ${containerCap[space][stop]}`, () => {
|
|
61
|
+
expect(resolveRadiusClass("CONTAINER-CAP", stop, { paddingPx: PADS[space] })).toBe(
|
|
62
|
+
containerCap[space][stop],
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("capContainerRadius agrees with the CONTAINER-CAP column (DG-RAD-04, unchanged)", () => {
|
|
70
|
+
// capContainerRadius returns the TOKEN while it fits and the padding px once
|
|
71
|
+
// it would exceed; mirror the token px scale to compare resolutions.
|
|
72
|
+
const tokenPx: Record<string, number> = { $0: 0, $2: 5, $4: 9, $6: 16, $12: 50 };
|
|
73
|
+
const asPx = (value: string | number): number =>
|
|
74
|
+
typeof value === "number" ? value : tokenPx[value];
|
|
75
|
+
|
|
76
|
+
for (const space of Object.keys(PADS) as (keyof typeof PADS)[]) {
|
|
77
|
+
for (const stop of STOPS) {
|
|
78
|
+
it(`at ${stop} under space=${space}`, () => {
|
|
79
|
+
expect(asPx(capContainerRadius(stop, space))).toBe(
|
|
80
|
+
resolveRadiusClass("CONTAINER-CAP", stop, { paddingPx: PADS[space] }),
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
it("the cap is the container's own padding at full, under every space", () => {
|
|
87
|
+
expect(capContainerRadius("full", "small")).toBe(13);
|
|
88
|
+
expect(capContainerRadius("full", "medium")).toBe(18);
|
|
89
|
+
expect(capContainerRadius("full", "large")).toBe(32);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("the cap bites at large under space=small (16 > 13) and nowhere else below full", () => {
|
|
93
|
+
expect(capContainerRadius("large", "small")).toBe(13);
|
|
94
|
+
expect(capContainerRadius("large", "medium")).toBe("$6");
|
|
95
|
+
expect(capContainerRadius("large", "large")).toBe("$6");
|
|
96
|
+
expect(capContainerRadius("medium", "small")).toBe("$4");
|
|
97
|
+
expect(capContainerRadius("small", "small")).toBe("$2");
|
|
98
|
+
expect(capContainerRadius("none", "small")).toBe("$0");
|
|
99
|
+
});
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
describe("the generated table is the doctrine, not a hand-kept copy", () => {
|
|
103
|
+
it("RADIUS_RESOLUTION_TABLE matches a fresh parse of docs/design-guidelines.md", () => {
|
|
104
|
+
expect(JSON.parse(JSON.stringify(RADIUS_RESOLUTION_TABLE))).toEqual(doctrine.classes);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("RADIUS_OVERRIDE_TABLE matches the doctrine's DG-RAD-05 table", () => {
|
|
108
|
+
expect(JSON.parse(JSON.stringify(RADIUS_OVERRIDE_TABLE))).toEqual(doctrine.overrides);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("OPEN_RADIUS_ADJUDICATIONS matches the doctrine's open list", () => {
|
|
112
|
+
expect(JSON.parse(JSON.stringify(OPEN_RADIUS_ADJUDICATIONS))).toEqual(
|
|
113
|
+
doctrine.openAdjudications,
|
|
114
|
+
);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("the stops and the composition order are the doctrine's", () => {
|
|
118
|
+
expect([...RADIUS_RESOLUTION_STOPS]).toEqual(doctrine.stops);
|
|
119
|
+
expect([...RADIUS_RESOLUTION_COMPOSITION]).toEqual(doctrine.composition);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("the class set is exactly the four ruled classes — no silent widening", () => {
|
|
123
|
+
expect(Object.keys(RADIUS_RESOLUTION_TABLE).sort()).toEqual([
|
|
124
|
+
"BINARY",
|
|
125
|
+
"CIRCULAR-AT-FULL",
|
|
126
|
+
"CONTAINER-CAP",
|
|
127
|
+
"DEFAULT",
|
|
128
|
+
]);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
describe("a doctrine edit the code cannot spell FAILS loudly", () => {
|
|
133
|
+
const tempPaths: string[] = [];
|
|
134
|
+
afterAll(() => {
|
|
135
|
+
for (const path of tempPaths) rmSync(path, { force: true });
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const parseMutated = (mutate: (source: string) => string) => {
|
|
139
|
+
const source = readFileSync(registry.DOCTRINE_PATH, "utf-8");
|
|
140
|
+
const mutated = mutate(source);
|
|
141
|
+
expect(mutated).not.toBe(source); // the mutation must have landed
|
|
142
|
+
const path = join(tmpdir(), `dg-rad-spec-${process.pid}-${tempPaths.length}.md`);
|
|
143
|
+
writeFileSync(path, mutated);
|
|
144
|
+
tempPaths.push(path);
|
|
145
|
+
return () => registry.readRadiusClassDoctrine(path);
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
it("rejects a cell spelling outside the closed grammar", () => {
|
|
149
|
+
expect(
|
|
150
|
+
parseMutated((s) =>
|
|
151
|
+
s.replace("| BINARY | 0 | h/2", "| BINARY | 0 | thumb-ish"),
|
|
152
|
+
),
|
|
153
|
+
).toThrow(/not a spelling the radius registry can read/);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("rejects a class that resolves `none` to nonzero (DG-RAD-01)", () => {
|
|
157
|
+
expect(
|
|
158
|
+
parseMutated((s) => s.replace("| BINARY | 0 |", "| BINARY | 3 |")),
|
|
159
|
+
).toThrow(/does not resolve `none` to 0/);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("rejects a table that loses one of the four ruled classes", () => {
|
|
163
|
+
expect(
|
|
164
|
+
parseMutated((s) => s.replace("| CIRCULAR-AT-FULL | 0", "| CIRCLE-ISH-MAYBE | 0")),
|
|
165
|
+
).toThrow(/missing CIRCULAR-AT-FULL/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("rejects a Checkbox override spelled h/2 — R2b is a ruling, not a cell", () => {
|
|
169
|
+
expect(
|
|
170
|
+
parseMutated((s) =>
|
|
171
|
+
s.replace(
|
|
172
|
+
"| Checkbox | 0 | 5 | 5 | 5 | 5",
|
|
173
|
+
"| Checkbox | 0 | 5 | 5 | 5 | h/2",
|
|
174
|
+
),
|
|
175
|
+
),
|
|
176
|
+
).toThrow(/Checkbox override spells h\/2/);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("rejects a Radio disc override that is not h/2 — radio is always round (R2c)", () => {
|
|
180
|
+
expect(
|
|
181
|
+
parseMutated((s) =>
|
|
182
|
+
s.replace(
|
|
183
|
+
"| Radio disc | h/2 | h/2 | h/2 | h/2 | h/2",
|
|
184
|
+
"| Radio disc | 0 | h/2 | h/2 | h/2 | h/2",
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
).toThrow(/Radio disc override is not h\/2/);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("rejects a doctrine that drops the composition order sentence", () => {
|
|
191
|
+
expect(
|
|
192
|
+
parseMutated((s) =>
|
|
193
|
+
s.replace(
|
|
194
|
+
"immunity wins, then a declared override, then the class",
|
|
195
|
+
"the class wins, probably",
|
|
196
|
+
),
|
|
197
|
+
),
|
|
198
|
+
).toThrow(/composition note no longer spells the order/);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("resolveRadiusClass throws on a class the doctrine never ruled", () => {
|
|
202
|
+
expect(() => resolveRadiusClass("R-VIBES" as never, "full")).toThrow(/not a class/);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("resolveRadiusClass throws on a stop the table does not know", () => {
|
|
206
|
+
expect(() => resolveRadiusClass("DEFAULT", "extra-full" as never)).toThrow(/not a radius stop/);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it("h/2 cells demand the part's own painted height", () => {
|
|
210
|
+
expect(() => resolveRadiusClass("BINARY", "small")).toThrow(/needs geometry\.heightPx/);
|
|
211
|
+
expect(() => resolveRadiusClass("CIRCULAR-AT-FULL", "full", {})).toThrow(
|
|
212
|
+
/needs geometry\.heightPx/,
|
|
213
|
+
);
|
|
214
|
+
expect(() => resolveRadiusClass("BINARY", "small", { heightPx: 0 })).toThrow(
|
|
215
|
+
/needs geometry\.heightPx/,
|
|
216
|
+
);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("CONTAINER-CAP cells demand the container's own padding", () => {
|
|
220
|
+
expect(() => resolveRadiusClass("CONTAINER-CAP", "full")).toThrow(/needs geometry\.paddingPx/);
|
|
221
|
+
expect(() => resolveRadiusClass("CONTAINER-CAP", "small", { heightPx: H })).toThrow(
|
|
222
|
+
/needs geometry\.paddingPx/,
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe("the override layer wins over the class layer (DG-RAD-05)", () => {
|
|
228
|
+
it("Checkbox measures a square-cornered box at every stop, never h/2 (R2b)", () => {
|
|
229
|
+
const glyph = 20; // the checkbox glyph is 20×20, so h/2 would be 10
|
|
230
|
+
for (const stop of STOPS) {
|
|
231
|
+
const resolved = resolveRadiusForPart({
|
|
232
|
+
part: "Checkbox",
|
|
233
|
+
radiusClass: "CIRCULAR-AT-FULL", // its kin class by shape — the override must beat it
|
|
234
|
+
stop,
|
|
235
|
+
heightPx: glyph,
|
|
236
|
+
});
|
|
237
|
+
expect(resolved.source).toBe("override");
|
|
238
|
+
expect(resolved.px).toBe(stop === "none" ? 0 : 5);
|
|
239
|
+
expect(resolved.px).not.toBe(glyph / 2);
|
|
240
|
+
expect(resolved.overrideWhy).toMatch(/reads as a radio/);
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it("Checkbox at full is the clamp (5), not the kin circle (10) and not the token (50)", () => {
|
|
245
|
+
const resolved = resolveRadiusForPart({
|
|
246
|
+
part: "Checkbox",
|
|
247
|
+
radiusClass: "CIRCULAR-AT-FULL",
|
|
248
|
+
stop: "full",
|
|
249
|
+
heightPx: 20,
|
|
250
|
+
});
|
|
251
|
+
expect(resolved.px).toBe(5);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("the Radio disc is round at every stop, including none (R2c — R2b inverted)", () => {
|
|
255
|
+
const disc = 20;
|
|
256
|
+
for (const stop of STOPS) {
|
|
257
|
+
const resolved = resolveRadiusForPart({
|
|
258
|
+
part: "Radio disc",
|
|
259
|
+
radiusClass: "BINARY",
|
|
260
|
+
stop,
|
|
261
|
+
heightPx: disc,
|
|
262
|
+
});
|
|
263
|
+
expect(resolved.source).toBe("override");
|
|
264
|
+
expect(resolved.px).toBe(disc / 2);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("override lookup matches the doctrine spelling case-insensitively", () => {
|
|
269
|
+
expect(radiusOverrideFor("checkbox")?.component).toBe("Checkbox");
|
|
270
|
+
expect(radiusOverrideFor("RADIO DISC")?.component).toBe("Radio disc");
|
|
271
|
+
expect(radiusOverrideFor("Button")).toBeUndefined();
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("an UNDECLARED part still resolves by its class", () => {
|
|
275
|
+
const resolved = resolveRadiusForPart({ part: "Button", radiusClass: "DEFAULT", stop: "full" });
|
|
276
|
+
expect(resolved.source).toBe("class");
|
|
277
|
+
expect(resolved.px).toBe(50);
|
|
278
|
+
expect(resolved.overrideWhy).toBeUndefined();
|
|
279
|
+
// and with no part at all, the class is the resolution
|
|
280
|
+
expect(resolveRadiusForPart({ radiusClass: "BINARY", stop: "medium", heightPx: H }).px).toBe(
|
|
281
|
+
H / 2,
|
|
282
|
+
);
|
|
283
|
+
});
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
describe("knob-immunity COMPOSES above resolution (LC-60 / D-09, R2a)", () => {
|
|
287
|
+
it("a declared R-PILL mark stays round at none — the knob never reaches it", () => {
|
|
288
|
+
const dot = 8;
|
|
289
|
+
const resolved = resolveRadiusForPart({
|
|
290
|
+
part: "Badge count/dot",
|
|
291
|
+
radiusClass: "DEFAULT",
|
|
292
|
+
immunity: "R-PILL",
|
|
293
|
+
stop: "none",
|
|
294
|
+
heightPx: dot,
|
|
295
|
+
});
|
|
296
|
+
expect(resolved.source).toBe("immunity");
|
|
297
|
+
expect(resolved.px).toBe(dot / 2); // round, not the class table's 0
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it("a declared R-IDENTITY part stays round at every stop", () => {
|
|
301
|
+
for (const stop of STOPS) {
|
|
302
|
+
const resolved = resolveRadiusForPart({
|
|
303
|
+
part: "Avatar",
|
|
304
|
+
radiusClass: "CIRCULAR-AT-FULL",
|
|
305
|
+
immunity: "R-IDENTITY",
|
|
306
|
+
stop,
|
|
307
|
+
heightPx: 40,
|
|
308
|
+
});
|
|
309
|
+
expect(resolved.source).toBe("immunity");
|
|
310
|
+
expect(resolved.px).toBe(20);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("immunity wins over a declared override — composed, never replaced", () => {
|
|
315
|
+
const resolved = resolveRadiusForPart({
|
|
316
|
+
part: "Checkbox",
|
|
317
|
+
immunity: "R-IDENTITY",
|
|
318
|
+
stop: "full",
|
|
319
|
+
heightPx: 20,
|
|
320
|
+
});
|
|
321
|
+
expect(resolved.source).toBe("immunity");
|
|
322
|
+
expect(resolved.px).toBe(10);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("immunity is round geometry, so it demands the painted height", () => {
|
|
326
|
+
expect(() =>
|
|
327
|
+
resolveRadiusForPart({ part: "Badge count/dot", immunity: "R-PILL", stop: "none" }),
|
|
328
|
+
).toThrow(/needs geometry\.heightPx/);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it("the doctrine's layering order is immunity, then override, then class", () => {
|
|
332
|
+
expect([...RADIUS_RESOLUTION_COMPOSITION]).toEqual(["immunity", "override", "class"]);
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
describe("open adjudications are REPORTED, never silently skipped (MPO-56 AC-6)", () => {
|
|
337
|
+
it("the exclusion list is empty — R2 leftovers closed by RULINGS-2026-08-28", () => {
|
|
338
|
+
expect(OPEN_RADIUS_ADJUDICATIONS).toEqual([]);
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe("closed R2 leftovers (RULINGS-2026-08-28)", () => {
|
|
343
|
+
it("a single-line control at full is the DEFAULT pill token (50), not clampRadiusForControl's 16 (R2d)", () => {
|
|
344
|
+
const resolved = resolveRadiusForPart({
|
|
345
|
+
part: "Input",
|
|
346
|
+
radiusClass: "DEFAULT",
|
|
347
|
+
stop: "full",
|
|
348
|
+
heightPx: 44,
|
|
349
|
+
});
|
|
350
|
+
expect(resolved.source).toBe("class");
|
|
351
|
+
expect(resolved.px).toBe(50);
|
|
352
|
+
expect(resolved.px).not.toBe(16);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("an icon well rides DEFAULT — square at none, token on the scale, no override (R2e)", () => {
|
|
356
|
+
expect(radiusOverrideFor("icon well")).toBeUndefined();
|
|
357
|
+
expect(radiusOverrideFor("empty icon well")).toBeUndefined();
|
|
358
|
+
const atNone = resolveRadiusForPart({
|
|
359
|
+
part: "empty icon well",
|
|
360
|
+
radiusClass: "DEFAULT",
|
|
361
|
+
stop: "none",
|
|
362
|
+
heightPx: 44,
|
|
363
|
+
});
|
|
364
|
+
expect(atNone.source).toBe("class");
|
|
365
|
+
expect(atNone.px).toBe(0);
|
|
366
|
+
expect(
|
|
367
|
+
resolveRadiusForPart({ part: "empty icon well", radiusClass: "DEFAULT", stop: "medium" }).px,
|
|
368
|
+
).toBe(9);
|
|
369
|
+
expect(
|
|
370
|
+
resolveRadiusForPart({ part: "empty icon well", radiusClass: "DEFAULT", stop: "full" }).px,
|
|
371
|
+
).toBe(50);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it("BINARY at none is 0 for thumb AND track — they square together (R2f)", () => {
|
|
375
|
+
for (const part of ["Switch thumb", "Switch track", "Slider thumb", "Slider rail"]) {
|
|
376
|
+
const resolved = resolveRadiusForPart({
|
|
377
|
+
part,
|
|
378
|
+
radiusClass: "BINARY",
|
|
379
|
+
stop: "none",
|
|
380
|
+
heightPx: 28,
|
|
381
|
+
});
|
|
382
|
+
expect(resolved.source).toBe("class");
|
|
383
|
+
expect(resolved.px).toBe(0);
|
|
384
|
+
}
|
|
385
|
+
expect(
|
|
386
|
+
resolveRadiusForPart({
|
|
387
|
+
part: "Switch track",
|
|
388
|
+
radiusClass: "BINARY",
|
|
389
|
+
stop: "small",
|
|
390
|
+
heightPx: 28,
|
|
391
|
+
}).px,
|
|
392
|
+
).toBe(14);
|
|
393
|
+
});
|
|
394
|
+
});
|
package/src/theme/radiusClass.ts
CHANGED
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Radius-class declarations (D-09 / LC-60)
|
|
2
|
+
* Radius-class declarations (D-09 / LC-60) and the radius RESOLUTION resolver
|
|
3
|
+
* (DG-RAD-01..05, Clay ruling 2026-08-28).
|
|
4
|
+
*
|
|
5
|
+
* Two axes live here, and they COMPOSE rather than replace each other:
|
|
6
|
+
*
|
|
7
|
+
* 1. Knob-IMMUNITY (`R-PILL` / `R-IDENTITY`, LC-60/D-09): the knob does not
|
|
8
|
+
* reach the part at all — roundness is the part's identity, so it stays
|
|
9
|
+
* round at every stop including `none`. Declared via `radiusClassProps`,
|
|
10
|
+
* registered in `docs/theme-propagation-spec.md` § Radius.
|
|
11
|
+
* 2. RESOLUTION (DG-RAD, `docs/design-guidelines.md` §13): given that a part
|
|
12
|
+
* listens to the knob, what a stop resolves to. Four classes (DEFAULT /
|
|
13
|
+
* CIRCULAR-AT-FULL / BINARY / CONTAINER-CAP), with a per-component
|
|
14
|
+
* override layer on top where meaning demands it (DG-RAD-05 — Checkbox
|
|
15
|
+
* never becomes a circle; the Radio disc never stops being one).
|
|
16
|
+
*
|
|
17
|
+
* `resolveRadiusForPart` layers them in doctrine order: immunity wins, then a
|
|
18
|
+
* declared override, then the class. The tables are GENERATED from the DG-RAD
|
|
19
|
+
* doctrine section (`scripts/radius-class-registry.mjs` --emit/--check), so
|
|
20
|
+
* code and doctrine cannot drift — the same contract the immunity axis has
|
|
21
|
+
* with `scripts/radius-identity-registry.mjs`.
|
|
3
22
|
*
|
|
4
23
|
* Most parts prove they obey the radius knob by simply going square at
|
|
5
24
|
* `borderRadius:none`. A few legitimately do not — an ornamental dot has no
|
|
@@ -15,13 +34,25 @@
|
|
|
15
34
|
* module is the writing end; `docs/theme-propagation-spec.md` is the registry.
|
|
16
35
|
*/
|
|
17
36
|
|
|
37
|
+
import type { BorderRadius } from "./knobs";
|
|
38
|
+
import {
|
|
39
|
+
RADIUS_OVERRIDE_TABLE,
|
|
40
|
+
RADIUS_RESOLUTION_COMPOSITION,
|
|
41
|
+
RADIUS_RESOLUTION_TABLE,
|
|
42
|
+
type RadiusOverrideEntry,
|
|
43
|
+
type RadiusResolutionCell,
|
|
44
|
+
type RadiusResolutionClass,
|
|
45
|
+
} from "./radiusClassTable.generated";
|
|
46
|
+
|
|
18
47
|
/**
|
|
19
48
|
* The radius classes whose parts stay round at `borderRadius:none`.
|
|
20
49
|
*
|
|
21
50
|
* - `R-PILL` — a pill/circle at every radius value (ornamental dots, badge
|
|
22
|
-
* indicators
|
|
23
|
-
*
|
|
24
|
-
*
|
|
51
|
+
* indicators). Size still tracks the size knob. Icon wells are DEFAULT,
|
|
52
|
+
* not R-PILL (R2e) — unless the well IS a count badge.
|
|
53
|
+
* - `R-IDENTITY` — the shape IS the part's identity (Avatar, brand marks,
|
|
54
|
+
* Radio disc). Membership is enumerated in the spec table and enforced
|
|
55
|
+
* per part.
|
|
25
56
|
*/
|
|
26
57
|
export type KnobImmuneRadiusClass = "R-PILL" | "R-IDENTITY";
|
|
27
58
|
|
|
@@ -49,3 +80,171 @@ export function radiusClassProps(
|
|
|
49
80
|
): RadiusClassDeclaration {
|
|
50
81
|
return { "data-radius-class": radiusClass, "data-radius-part": part };
|
|
51
82
|
}
|
|
83
|
+
|
|
84
|
+
// ── Radius resolution (DG-RAD-01..05) ─────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The geometry a doctrine cell may need. `h/2` cells need the part's own
|
|
88
|
+
* painted height (shorter edge); CONTAINER-CAP cells need the container's own
|
|
89
|
+
* padding at the current space stop (13/18/32 — `panelPaddingPxMap`).
|
|
90
|
+
*/
|
|
91
|
+
export interface RadiusClassGeometry {
|
|
92
|
+
heightPx?: number;
|
|
93
|
+
paddingPx?: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function resolveCell(
|
|
97
|
+
cell: RadiusResolutionCell,
|
|
98
|
+
geometry: RadiusClassGeometry,
|
|
99
|
+
where: string,
|
|
100
|
+
): number {
|
|
101
|
+
switch (cell.kind) {
|
|
102
|
+
case "px":
|
|
103
|
+
return cell.px;
|
|
104
|
+
case "half-height": {
|
|
105
|
+
if (geometry.heightPx === undefined || geometry.heightPx <= 0) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`resolveRadiusClass: ${where} resolves to h/2 and needs geometry.heightPx — the part's own painted height (shorter edge)`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return geometry.heightPx / 2;
|
|
111
|
+
}
|
|
112
|
+
case "min-token-padding": {
|
|
113
|
+
if (geometry.paddingPx === undefined || geometry.paddingPx < 0) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`resolveRadiusClass: ${where} caps at the container's own padding and needs geometry.paddingPx`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return Math.min(cell.tokenPx, geometry.paddingPx);
|
|
119
|
+
}
|
|
120
|
+
case "padding": {
|
|
121
|
+
if (geometry.paddingPx === undefined || geometry.paddingPx < 0) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`resolveRadiusClass: ${where} resolves to the container's own padding and needs geometry.paddingPx`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return geometry.paddingPx;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Resolve one radius-resolution class at one knob stop to px (DG-RAD-01..04).
|
|
133
|
+
*
|
|
134
|
+
* This is the CLASS layer only — the doctrine's DEFAULT resolution. Parts with
|
|
135
|
+
* a declared per-component override or a knob-immunity declaration resolve
|
|
136
|
+
* through `resolveRadiusForPart`, which layers all three.
|
|
137
|
+
*
|
|
138
|
+
* Fails loudly on anything the doctrine table cannot spell: an unknown class,
|
|
139
|
+
* an unknown stop, or an `h/2`/padding cell without its geometry. Silent
|
|
140
|
+
* defaults are how tables widen.
|
|
141
|
+
*/
|
|
142
|
+
export function resolveRadiusClass(
|
|
143
|
+
radiusClass: RadiusResolutionClass,
|
|
144
|
+
stop: BorderRadius,
|
|
145
|
+
geometry: RadiusClassGeometry = {},
|
|
146
|
+
): number {
|
|
147
|
+
const row = RADIUS_RESOLUTION_TABLE[radiusClass];
|
|
148
|
+
if (!row) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`resolveRadiusClass: "${radiusClass}" is not a class in the DG-RAD doctrine table (docs/design-guidelines.md §13)`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
const cell = row[stop];
|
|
154
|
+
if (!cell) {
|
|
155
|
+
throw new Error(
|
|
156
|
+
`resolveRadiusClass: "${stop}" is not a radius stop the DG-RAD doctrine table knows`,
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
return resolveCell(cell, geometry, `${radiusClass} at ${stop}`);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Look up a part's declared per-component override (DG-RAD-05), if any.
|
|
164
|
+
* Matching is by the doctrine's component name, case-insensitive — the same
|
|
165
|
+
* contract the R-IDENTITY membership registry uses. Sweeps use this to PASS a
|
|
166
|
+
* declared override instead of reporting it as a violation.
|
|
167
|
+
*/
|
|
168
|
+
export function radiusOverrideFor(part: string): RadiusOverrideEntry | undefined {
|
|
169
|
+
const needle = part.trim().toLowerCase();
|
|
170
|
+
return RADIUS_OVERRIDE_TABLE.find((entry) => entry.component.toLowerCase() === needle);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export type ResolvedRadiusSource = (typeof RADIUS_RESOLUTION_COMPOSITION)[number];
|
|
174
|
+
|
|
175
|
+
export interface ResolveRadiusForPartOptions extends RadiusClassGeometry {
|
|
176
|
+
/** the knob stop being resolved */
|
|
177
|
+
stop: BorderRadius;
|
|
178
|
+
/**
|
|
179
|
+
* the part's resolution class as doctrine assigns it — DEFAULT unless
|
|
180
|
+
* assigned otherwise (knobs-enumerated.md Part 2b / DG-RAD)
|
|
181
|
+
*/
|
|
182
|
+
radiusClass?: RadiusResolutionClass;
|
|
183
|
+
/**
|
|
184
|
+
* the part name as the doctrine's override table spells it (`Checkbox`,
|
|
185
|
+
* `Radio disc`) — enables the DG-RAD-05 override lookup
|
|
186
|
+
*/
|
|
187
|
+
part?: string;
|
|
188
|
+
/**
|
|
189
|
+
* the part's declared knob-immunity (`R-PILL` / `R-IDENTITY`, LC-60/D-09),
|
|
190
|
+
* if it carries one. Immunity COMPOSES above both other layers: the knob
|
|
191
|
+
* never reaches the part, so it stays round at every stop including `none`.
|
|
192
|
+
*/
|
|
193
|
+
immunity?: KnobImmuneRadiusClass;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export interface ResolvedPartRadius {
|
|
197
|
+
px: number;
|
|
198
|
+
/** which layer resolved it: declared immunity > declared override > class */
|
|
199
|
+
source: ResolvedRadiusSource;
|
|
200
|
+
/** the class the part would resolve by, for sweeps that report both */
|
|
201
|
+
radiusClass: RadiusResolutionClass;
|
|
202
|
+
/** the recorded DG-RAD-05 reason, when an override won */
|
|
203
|
+
overrideWhy?: string;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Resolve a part's radius at a knob stop through all three doctrine layers,
|
|
208
|
+
* in doctrine order (DG-RAD-05 composition note): a declared knob-immunity
|
|
209
|
+
* wins, then a declared per-component override, then the resolution class.
|
|
210
|
+
*
|
|
211
|
+
* A declared immunity is COMPOSED with, never replaced: even at `none` the
|
|
212
|
+
* part measures h/2, because roundness is its identity, not a radius (Clay
|
|
213
|
+
* 2026-08-28 resolving R2a — badge count pill, status dots, DotIndicator
|
|
214
|
+
* marks, presence pip stay round in the square world).
|
|
215
|
+
*/
|
|
216
|
+
export function resolveRadiusForPart(options: ResolveRadiusForPartOptions): ResolvedPartRadius {
|
|
217
|
+
const radiusClass: RadiusResolutionClass = options.radiusClass ?? "DEFAULT";
|
|
218
|
+
const geometry: RadiusClassGeometry = {
|
|
219
|
+
heightPx: options.heightPx,
|
|
220
|
+
paddingPx: options.paddingPx,
|
|
221
|
+
};
|
|
222
|
+
if (options.immunity) {
|
|
223
|
+
if (options.heightPx === undefined || options.heightPx <= 0) {
|
|
224
|
+
throw new Error(
|
|
225
|
+
`resolveRadiusForPart: a declared ${options.immunity} part is round at every stop (h/2) and needs geometry.heightPx`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
return { px: options.heightPx / 2, source: "immunity", radiusClass };
|
|
229
|
+
}
|
|
230
|
+
const override = options.part === undefined ? undefined : radiusOverrideFor(options.part);
|
|
231
|
+
if (override) {
|
|
232
|
+
const cell = override.cells[options.stop];
|
|
233
|
+
if (!cell) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`resolveRadiusForPart: "${options.stop}" is not a radius stop the DG-RAD override table knows`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
px: resolveCell(cell, geometry, `override ${override.component} at ${options.stop}`),
|
|
240
|
+
source: "override",
|
|
241
|
+
radiusClass,
|
|
242
|
+
overrideWhy: override.why,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
px: resolveRadiusClass(radiusClass, options.stop, geometry),
|
|
247
|
+
source: "class",
|
|
248
|
+
radiusClass,
|
|
249
|
+
};
|
|
250
|
+
}
|