@multiplatform.one/theme 7.11.0 → 7.16.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 +22 -2
- package/package.json +6 -6
- package/src/audit/constraintAudit.spec.ts +180 -0
- package/src/audit/constraintAudit.ts +116 -37
- package/src/audit/themeMatrix.ts +4 -1
- package/src/figma/figmaTokens.spec.ts +1 -2
- package/src/numbers.spec.ts +27 -1
- package/src/numbers.ts +40 -0
- package/src/theme/Surface.spec.tsx +9 -4
- package/src/theme/createDefaultThemeConfig.ts +8 -1
- package/src/theme/createThemes.ts +43 -6
- package/src/theme/index.ts +3 -0
- package/src/theme/intent.spec.tsx +9 -7
- package/src/theme/layoutTokensHooks.spec.tsx +3 -2
- package/src/theme/radiusClass.ts +18 -0
- package/src/theme/resolveKnobs.spec.ts +73 -7
- package/src/theme/resolveKnobs.ts +99 -25
- package/src/theme/semanticEntryText.spec.ts +85 -0
- package/src/theme/sizeRecipes.ts +8 -5
- package/src/theme/subsetThemes.spec.ts +102 -0
- package/src/theme/subsetThemes.ts +56 -0
- package/src/theme/theme.tsx +15 -5
- package/src/theme/transitionProps.native.ts +15 -0
- package/src/theme/transitionProps.spec.ts +14 -0
- package/src/theme/transitionProps.ts +16 -0
- package/src/theme/useResolvedKnobs.hydration.spec.tsx +141 -0
- package/src/theme/useResolvedKnobs.ts +28 -22
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +15 -1
- package/src/theme/useTouchSurface.ts +8 -0
- package/types/audit/constraintAudit.d.ts.map +1 -1
- package/types/audit/themeMatrix.d.ts.map +1 -1
- package/types/numbers.d.ts +12 -0
- package/types/numbers.d.ts.map +1 -1
- package/types/theme/createDefaultThemeConfig.d.ts +7 -0
- package/types/theme/createDefaultThemeConfig.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/index.d.ts +3 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/radiusClass.d.ts +14 -0
- package/types/theme/radiusClass.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +35 -1
- package/types/theme/resolveKnobs.d.ts.map +1 -1
- package/types/theme/sizeRecipes.d.ts +8 -5
- package/types/theme/sizeRecipes.d.ts.map +1 -1
- package/types/theme/subsetThemes.d.ts +39 -0
- package/types/theme/subsetThemes.d.ts.map +1 -0
- package/types/theme/theme.d.ts.map +1 -1
- package/types/theme/transitionProps.d.ts +14 -0
- package/types/theme/transitionProps.d.ts.map +1 -0
- package/types/theme/transitionProps.native.d.ts +13 -0
- package/types/theme/transitionProps.native.d.ts.map +1 -0
- package/types/theme/useResolvedKnobs.d.ts.map +1 -1
- package/types/theme/useTouchSurface.d.ts +3 -0
- package/types/theme/useTouchSurface.d.ts.map +1 -0
package/README.md
CHANGED
|
@@ -66,8 +66,28 @@ Rules that keep knobs sound:
|
|
|
66
66
|
- Resolution order: Preset → intent → intent+component → size → density →
|
|
67
67
|
reduced-motion → `resolveKnobs()` override callback.
|
|
68
68
|
|
|
69
|
-
|
|
70
|
-
|
|
69
|
+
A surface that only reaches part of the theme matrix ships only that part.
|
|
70
|
+
Themes are CSS in the served document, so the full 1,580-theme matrix is paid
|
|
71
|
+
for on every page. `subset` filters which themes exist, never what one
|
|
72
|
+
contains:
|
|
73
|
+
|
|
74
|
+
```ts
|
|
75
|
+
import { createDefaultThemeConfig } from "@multiplatform.one/theme";
|
|
76
|
+
|
|
77
|
+
export const themeConfig = createDefaultThemeConfig({
|
|
78
|
+
subset: {
|
|
79
|
+
components: ["Button", "Input", "TextArea", "Card", "ListItem"],
|
|
80
|
+
tints: ["accent", "active", "alt1", "alt2", "error", "success", "warning"],
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
A dropped component sub-theme does not crash, it renders in its parent's
|
|
86
|
+
colours, so list everything the surface renders and enters. `subsetThemes`
|
|
87
|
+
is the same filter as a pure function over a built matrix.
|
|
88
|
+
|
|
89
|
+
Full standard: `docs/standards/frontend/knobs-system.md` in the
|
|
90
|
+
[multiplatform.one repo](https://git.corp.bitspur.com/multiplatform.one/mpo).
|
|
71
91
|
|
|
72
92
|
## License
|
|
73
93
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@multiplatform.one/theme",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.16.0",
|
|
4
4
|
"description": "Tamagui theme system for multiplatform.one",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"multiplatform",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"author": "BitSpur",
|
|
13
13
|
"repository": {
|
|
14
14
|
"type": "git",
|
|
15
|
-
"url": "git+https://
|
|
15
|
+
"url": "git+https://git.corp.bitspur.com/multiplatform.one/mpo.git",
|
|
16
16
|
"directory": "public/theme"
|
|
17
17
|
},
|
|
18
18
|
"source": "src/index.ts",
|
|
@@ -44,8 +44,8 @@
|
|
|
44
44
|
"access": "public"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@multiplatform.one/platform": "7.
|
|
48
|
-
"@multiplatform.one/store": "7.
|
|
47
|
+
"@multiplatform.one/platform": "7.16.0",
|
|
48
|
+
"@multiplatform.one/store": "7.16.0",
|
|
49
49
|
"@tamagui/colors": "2.7.6",
|
|
50
50
|
"@tamagui/config": "2.7.6",
|
|
51
51
|
"@tamagui/get-token": "2.7.6",
|
|
@@ -57,8 +57,8 @@
|
|
|
57
57
|
"react-cookie": "^8.1.2"
|
|
58
58
|
},
|
|
59
59
|
"devDependencies": {
|
|
60
|
-
"@multiplatform.one/config": "7.
|
|
61
|
-
"@multiplatform.one/test-utils": "7.
|
|
60
|
+
"@multiplatform.one/config": "7.16.0",
|
|
61
|
+
"@multiplatform.one/test-utils": "7.16.0",
|
|
62
62
|
"@tamagui/animations-css": "2.7.6",
|
|
63
63
|
"@tamagui/animations-reanimated": "2.7.6",
|
|
64
64
|
"@tamagui/font-inter": "2.7.6",
|
|
@@ -155,3 +155,183 @@ describe("font weight provenance", () => {
|
|
|
155
155
|
expect(measured()?.fontWeight?.offScale).toBe(true);
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
|
+
|
|
159
|
+
function laidOut(el: HTMLElement, width: number, height: number) {
|
|
160
|
+
Object.defineProperty(el, "offsetWidth", { configurable: true, value: width });
|
|
161
|
+
Object.defineProperty(el, "offsetHeight", { configurable: true, value: height });
|
|
162
|
+
return el;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
describe("BINARY parts measure 0 or their own h/2 (DG-RAD-03)", () => {
|
|
166
|
+
const binary = { "data-radius-resolution": "BINARY" };
|
|
167
|
+
|
|
168
|
+
it.each([
|
|
169
|
+
["switch track", 88, 44, 22],
|
|
170
|
+
["bold thumb", 40, 40, 20],
|
|
171
|
+
["default thumb", 38, 38, 19],
|
|
172
|
+
])("accepts a %s at h/2", (_part, width, height, radius) => {
|
|
173
|
+
laidOut(specimen(`border-radius:${radius}px`, binary), width, height);
|
|
174
|
+
expect(measured()?.violations).toEqual([]);
|
|
175
|
+
expect(measured()?.borderRadius?.knob).toBe("BINARY 0 | h/2");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("accepts a square BINARY part at none", () => {
|
|
179
|
+
laidOut(specimen("border-radius:0", binary), 88, 44);
|
|
180
|
+
expect(measured()?.violations ?? []).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it.each([
|
|
184
|
+
["an intermediate radius", "border-radius:9px"],
|
|
185
|
+
["the uncapped token", "border-radius:50px"],
|
|
186
|
+
["h/2 of the longer edge", "border-radius:44px"],
|
|
187
|
+
["one corner off h/2", "border-radius:22px 22px 22px 16px"],
|
|
188
|
+
["a percentage", "border-radius:50%"],
|
|
189
|
+
])("rejects %s", (_label, style) => {
|
|
190
|
+
laidOut(specimen(style, binary), 88, 44);
|
|
191
|
+
expect(measured()?.borderRadius?.offScale).toBe(true);
|
|
192
|
+
expect(measured()?.violations.length).toBeGreaterThan(0);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("keeps an undeclared h/2 radius off-scale", () => {
|
|
196
|
+
laidOut(specimen("border-radius:22px"), 88, 44);
|
|
197
|
+
expect(measured()?.borderRadius?.offScale).toBe(true);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
describe("declared capped containers beyond Card (DG-RAD-04)", () => {
|
|
202
|
+
const declared = (container: string, radius: string, space: string) => ({
|
|
203
|
+
"data-constraint-container": container,
|
|
204
|
+
"data-radius-knob": radius,
|
|
205
|
+
"data-space-knob": space,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("accepts a PageSection surface capped at its own panelPadding", () => {
|
|
209
|
+
specimen("border-radius:13px;padding:13px", declared("PageSection", "full", "small"));
|
|
210
|
+
expect(measured()?.violations).toEqual([]);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("measures a PageSection surface inset like a Card's", () => {
|
|
214
|
+
specimen("border-radius:13px;padding:12px", declared("PageSection", "full", "small"));
|
|
215
|
+
expect(measured()?.violations.join(" ")).toContain("PageSection panelPadding:small");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("accepts a TextArea box at the cap whatever its field inset", () => {
|
|
219
|
+
specimen("border-radius:13px;padding:0", declared("TextArea", "full", "small"));
|
|
220
|
+
expect(measured()?.violations).toEqual([]);
|
|
221
|
+
expect(measured()?.borderRadius?.knob).toBe("TextArea min(radius:full, panelPadding:small)");
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it("audits a TextArea box's own padding on the ordinary scale", () => {
|
|
225
|
+
specimen("border-radius:9px;padding:15px", declared("TextArea", "medium", "medium"));
|
|
226
|
+
expect(measured()?.violations.join(" ")).toContain("paddingTop: 15px is off-scale");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it.each([
|
|
230
|
+
["the uncapped token", "border-radius:50px", declared("TextArea", "full", "small")],
|
|
231
|
+
["a cap for another space", "border-radius:18px", declared("TextArea", "full", "small")],
|
|
232
|
+
["the token past the cap", "border-radius:16px", declared("TextArea", "large", "small")],
|
|
233
|
+
["an unknown radius stop", "border-radius:13px", declared("TextArea", "round", "small")],
|
|
234
|
+
])("rejects %s", (_label, style, attrs) => {
|
|
235
|
+
specimen(style, attrs);
|
|
236
|
+
expect(measured()?.borderRadius?.offScale).toBe(true);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("names a container the audit does not know instead of trusting it", () => {
|
|
240
|
+
specimen("border-radius:13px;padding:13px", declared("Tile", "full", "small"));
|
|
241
|
+
expect(measured()?.violations.join(" ")).toContain('"Tile" is not a container');
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
describe("stacked rows cap their outer corners and square the inner ones (MPO-56)", () => {
|
|
246
|
+
const row = (position: string, radius: string, space: string) => ({
|
|
247
|
+
"data-constraint-container": "StackedRow",
|
|
248
|
+
"data-stack-position": position,
|
|
249
|
+
"data-radius-knob": radius,
|
|
250
|
+
"data-space-knob": space,
|
|
251
|
+
});
|
|
252
|
+
const corners = (tl: number, tr: number, br: number, bl: number) =>
|
|
253
|
+
`border-top-left-radius:${tl}px;border-top-right-radius:${tr}px;` +
|
|
254
|
+
`border-bottom-right-radius:${br}px;border-bottom-left-radius:${bl}px;padding:7px 13px`;
|
|
255
|
+
|
|
256
|
+
it.each([
|
|
257
|
+
["first", corners(13, 13, 0, 0)],
|
|
258
|
+
["middle", corners(0, 0, 0, 0)],
|
|
259
|
+
["last", corners(0, 0, 13, 13)],
|
|
260
|
+
["only", corners(13, 13, 13, 13)],
|
|
261
|
+
])("accepts a %s row capped at the row padding", (position, style) => {
|
|
262
|
+
specimen(style, row(position, "large", "small"));
|
|
263
|
+
expect(measured()?.violations).toEqual([]);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("names the stop it resolved on an outer corner", () => {
|
|
267
|
+
specimen(corners(18, 18, 0, 0), row("first", "full", "medium"));
|
|
268
|
+
expect(measured()?.borderTopLeftRadius?.knob).toBe(
|
|
269
|
+
"StackedRow min(radius:full, panelPadding:medium)",
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it.each([
|
|
274
|
+
["an uncapped outer corner", corners(16, 16, 0, 0), row("first", "large", "small")],
|
|
275
|
+
["a rounded inner corner", corners(13, 13, 13, 0), row("first", "large", "small")],
|
|
276
|
+
["a rounded middle row", corners(13, 0, 0, 0), row("middle", "large", "small")],
|
|
277
|
+
["a missing position", corners(13, 13, 13, 13), row("", "large", "small")],
|
|
278
|
+
])("rejects %s", (_label, style, attrs) => {
|
|
279
|
+
specimen(style, attrs);
|
|
280
|
+
expect(measured()?.borderRadius?.offScale).toBe(true);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("says which corner is inner", () => {
|
|
284
|
+
specimen(corners(0, 0, 13, 9), row("last", "large", "small"));
|
|
285
|
+
expect(measured()?.violations).toEqual([
|
|
286
|
+
"borderBottomLeftRadius: 9px does not match StackedRow cap (13px)",
|
|
287
|
+
]);
|
|
288
|
+
specimen(corners(9, 0, 13, 13), row("last", "large", "small")).id = "other";
|
|
289
|
+
const other = audit().elements.find((el) => el.id === "other");
|
|
290
|
+
expect(other?.violations).toEqual([
|
|
291
|
+
"borderTopLeftRadius: 9px is an inner corner of a last StackedRow (0px)",
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
describe("element labels hold nothing a knob or a mount writes", () => {
|
|
297
|
+
it("drops React useId ids and Tamagui atomic classes", () => {
|
|
298
|
+
const el = specimen("border-radius:12px");
|
|
299
|
+
el.id = "_r_3i_";
|
|
300
|
+
el.className = "is_View _borderStartStartRadius-0hover-c-radius-12 _outlineWidth-0focus-0px";
|
|
301
|
+
expect(measured()).toBeUndefined();
|
|
302
|
+
const found = audit().elements.find((entry) => entry.label.startsWith("div"));
|
|
303
|
+
expect(found?.label).toBe("div.is_View");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("keeps an authored id", () => {
|
|
307
|
+
specimen("border-radius:12px");
|
|
308
|
+
expect(audit().elements[0]?.label).toBe("div#specimen");
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("a fixed-padding container caps at the padding it has (DG-RAD-04, MPO-56)", () => {
|
|
313
|
+
const own = (radius: string) => ({
|
|
314
|
+
"data-constraint-container": "OwnInset",
|
|
315
|
+
"data-radius-knob": radius,
|
|
316
|
+
"data-space-knob": "medium",
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
it("accepts full capped at its own 13px padding whatever the space stop", () => {
|
|
320
|
+
specimen("border-radius:13px;padding:13px", own("full"));
|
|
321
|
+
expect(measured()?.violations).toEqual([]);
|
|
322
|
+
expect(measured()?.borderRadius?.knob).toBe("OwnInset min(radius:full, own padding)");
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it("accepts the token while it fits", () => {
|
|
326
|
+
specimen("border-radius:9px;padding:13px", own("medium"));
|
|
327
|
+
expect(measured()?.violations).toEqual([]);
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
it.each([
|
|
331
|
+
["the panelPadding cap instead of its own", "border-radius:18px;padding:13px", own("full")],
|
|
332
|
+
["the token past its padding", "border-radius:16px;padding:13px", own("large")],
|
|
333
|
+
])("rejects %s", (_label, style, attrs) => {
|
|
334
|
+
specimen(style, attrs);
|
|
335
|
+
expect(measured()?.borderRadius?.offScale).toBe(true);
|
|
336
|
+
});
|
|
337
|
+
});
|
|
@@ -568,10 +568,15 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
|
|
|
568
568
|
|
|
569
569
|
function elementLabel(el: Element): string {
|
|
570
570
|
const tag = el.tagName.toLowerCase();
|
|
571
|
-
|
|
572
|
-
//
|
|
571
|
+
// Labels are part of the no-breakage signature, so nothing a knob or a
|
|
572
|
+
// render order writes may reach them: React useId ids renumber per mount,
|
|
573
|
+
// and Tamagui atomic classes (always `_`-prefixed) spell resolved values.
|
|
574
|
+
const generatedId = /^(?:_r_[0-9a-z]+_|:r[0-9a-z]+:|«r[0-9a-z]+»)$/.test(el.id);
|
|
575
|
+
const id = el.id && !generatedId ? `#${el.id}` : "";
|
|
573
576
|
const classes = Array.from(el.classList)
|
|
574
|
-
.filter(
|
|
577
|
+
.filter(
|
|
578
|
+
(c) => c.length > 3 && !c.startsWith("_") && !/^[_a-z0-9]{1,8}-/.test(c) && !/^\d/.test(c),
|
|
579
|
+
)
|
|
575
580
|
.slice(0, 3)
|
|
576
581
|
.join(".");
|
|
577
582
|
const cls = classes ? `.${classes}` : "";
|
|
@@ -729,48 +734,90 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
|
|
|
729
734
|
|
|
730
735
|
let hasAnyProp = false;
|
|
731
736
|
|
|
732
|
-
// Axiom 1 / DG-RAD-04: only
|
|
733
|
-
//
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
737
|
+
// Axiom 1 / DG-RAD-04: only a declared padded container owns the cap.
|
|
738
|
+
// Provenance is declarative; every corner is still measured, and so is the
|
|
739
|
+
// inset of the containers whose own padding IS the panelPadding knob.
|
|
740
|
+
const containerName = el.getAttribute("data-constraint-container");
|
|
741
|
+
const ownsInset = containerName === "Card" || containerName === "PageSection";
|
|
742
|
+
const stackedRow = containerName === "StackedRow";
|
|
743
|
+
// A container padded with a fixed token rather than the panelPadding knob
|
|
744
|
+
// caps at the padding it actually has.
|
|
745
|
+
const ownInset = containerName === "OwnInset";
|
|
746
|
+
const capContainer =
|
|
747
|
+
containerName === "Card"
|
|
748
|
+
? ["content", "elevated"].includes(el.getAttribute("data-tier") ?? "")
|
|
749
|
+
: containerName === "PageSection" || containerName === "TextArea" || stackedRow || ownInset;
|
|
750
|
+
if (containerName !== null && containerName !== "Card" && !capContainer) {
|
|
751
|
+
entry.violations.push(
|
|
752
|
+
`data-constraint-container: "${containerName}" is not a container the cap audit knows`,
|
|
753
|
+
);
|
|
754
|
+
}
|
|
755
|
+
const binaryPart = el.getAttribute("data-radius-resolution") === "BINARY";
|
|
756
|
+
const corners = [
|
|
757
|
+
"borderTopLeftRadius",
|
|
758
|
+
"borderTopRightRadius",
|
|
759
|
+
"borderBottomRightRadius",
|
|
760
|
+
"borderBottomLeftRadius",
|
|
761
|
+
] as const;
|
|
762
|
+
// MPO-56: a row in a vertical stack caps its OUTER corners and squares
|
|
763
|
+
// the inner ones (LC-72), so the cap applies per corner by position.
|
|
764
|
+
const stackPosition = el.getAttribute("data-stack-position") ?? "";
|
|
765
|
+
const outerCorners: Record<string, readonly (typeof corners)[number][]> = {
|
|
766
|
+
only: corners,
|
|
767
|
+
first: ["borderTopLeftRadius", "borderTopRightRadius"],
|
|
768
|
+
middle: [],
|
|
769
|
+
last: ["borderBottomRightRadius", "borderBottomLeftRadius"],
|
|
770
|
+
};
|
|
771
|
+
const stackOuter = Object.hasOwn(outerCorners, stackPosition)
|
|
772
|
+
? outerCorners[stackPosition]
|
|
773
|
+
: undefined;
|
|
774
|
+
if (stackedRow && !stackOuter) {
|
|
775
|
+
entry.violations.push(
|
|
776
|
+
`data-stack-position: "${stackPosition}" is not a StackedRow position (only, first, middle, last)`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
if (capContainer) {
|
|
738
780
|
const radiusKnob = el.getAttribute("data-radius-knob") ?? "";
|
|
739
781
|
const spaceKnob = el.getAttribute("data-space-knob") ?? "";
|
|
740
782
|
const radiusStop = Object.hasOwn(_BORDER_RADIUS_KNOB, radiusKnob)
|
|
741
783
|
? _BORDER_RADIUS_KNOB[radiusKnob]
|
|
742
784
|
: undefined;
|
|
743
|
-
const inset = Object.hasOwn(_PANEL_PADDING_KNOB, spaceKnob)
|
|
744
|
-
? _PANEL_PADDING_KNOB[spaceKnob]
|
|
745
|
-
: undefined;
|
|
746
785
|
const paddingSides = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"] as const;
|
|
747
786
|
const measuredInsets = paddingSides.map((prop) => parsePx(style[prop]));
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
787
|
+
const inset = ownInset
|
|
788
|
+
? measuredInsets.some((v) => v === null)
|
|
789
|
+
? undefined
|
|
790
|
+
: Math.min(...(measuredInsets as number[]))
|
|
791
|
+
: Object.hasOwn(_PANEL_PADDING_KNOB, spaceKnob)
|
|
792
|
+
? _PANEL_PADDING_KNOB[spaceKnob]
|
|
793
|
+
: undefined;
|
|
794
|
+
if (ownsInset) {
|
|
795
|
+
for (const [index, prop] of paddingSides.entries()) {
|
|
796
|
+
const value = measuredInsets[index];
|
|
797
|
+
const offScale = inset === undefined || value !== inset;
|
|
798
|
+
entry[prop] = {
|
|
799
|
+
value: value ?? 0,
|
|
800
|
+
unit: "px",
|
|
801
|
+
knob: `${containerName} panelPadding:${spaceKnob}`,
|
|
802
|
+
offScale,
|
|
803
|
+
};
|
|
804
|
+
if (offScale)
|
|
805
|
+
entry.violations.push(
|
|
806
|
+
`${prop}: ${style[prop]} does not match ${containerName} panelPadding:${spaceKnob} (${inset ?? "unknown"}px)`,
|
|
807
|
+
);
|
|
808
|
+
}
|
|
761
809
|
}
|
|
762
|
-
const
|
|
763
|
-
radiusStop === undefined ||
|
|
810
|
+
const cap =
|
|
811
|
+
radiusStop === undefined ||
|
|
812
|
+
inset === undefined ||
|
|
813
|
+
(stackedRow && !stackOuter) ||
|
|
814
|
+
(ownsInset && measuredInsets.some((v) => v === null))
|
|
764
815
|
? undefined
|
|
765
|
-
: Math.min(radiusStop, inset, ...(measuredInsets as number[]));
|
|
766
|
-
const corners = [
|
|
767
|
-
"borderTopLeftRadius",
|
|
768
|
-
"borderTopRightRadius",
|
|
769
|
-
"borderBottomRightRadius",
|
|
770
|
-
"borderBottomLeftRadius",
|
|
771
|
-
] as const;
|
|
816
|
+
: Math.min(radiusStop, inset, ...(ownsInset ? (measuredInsets as number[]) : []));
|
|
772
817
|
for (const prop of corners) {
|
|
773
818
|
const raw = style[prop];
|
|
819
|
+
const inner = stackedRow && stackOuter !== undefined && !stackOuter.includes(prop);
|
|
820
|
+
const expected = inner ? 0 : cap;
|
|
774
821
|
// A corner may have two elliptical axes. Percentages are not px caps.
|
|
775
822
|
const axes = raw.trim().split(/\s+/);
|
|
776
823
|
const offScale =
|
|
@@ -779,13 +826,19 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
|
|
|
779
826
|
const result = {
|
|
780
827
|
value: parsePx(raw) ?? 0,
|
|
781
828
|
unit: "px",
|
|
782
|
-
knob:
|
|
829
|
+
knob: inner
|
|
830
|
+
? `${containerName} ${stackPosition} inner corner 0`
|
|
831
|
+
: ownInset
|
|
832
|
+
? `${containerName} min(radius:${radiusKnob}, own padding)`
|
|
833
|
+
: `${containerName} min(radius:${radiusKnob}, panelPadding:${spaceKnob})`,
|
|
783
834
|
offScale,
|
|
784
835
|
};
|
|
785
836
|
entry[prop] = result;
|
|
786
837
|
if (offScale)
|
|
787
838
|
entry.violations.push(
|
|
788
|
-
|
|
839
|
+
inner
|
|
840
|
+
? `${prop}: ${raw} is an inner corner of a ${stackPosition} StackedRow (0px)`
|
|
841
|
+
: `${prop}: ${raw} does not match ${containerName} cap (${expected ?? "unknown"}px)`,
|
|
789
842
|
);
|
|
790
843
|
}
|
|
791
844
|
entry.borderRadius = {
|
|
@@ -793,6 +846,32 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
|
|
|
793
846
|
offScale: corners.some((prop) => entry[prop]?.offScale),
|
|
794
847
|
};
|
|
795
848
|
hasAnyProp = true;
|
|
849
|
+
} else if (binaryPart) {
|
|
850
|
+
// DG-RAD-03: square, or a pill/circle of the part's own shorter edge,
|
|
851
|
+
// at every stop and in every state. Layout size, not the transformed
|
|
852
|
+
// box, so a translated thumb measures the same as a resting one.
|
|
853
|
+
const box = el as HTMLElement;
|
|
854
|
+
const half = Math.min(box.offsetWidth, box.offsetHeight) / 2;
|
|
855
|
+
const measuredCorners = corners.map((prop) => style[prop].trim().split(/\s+/));
|
|
856
|
+
if (measuredCorners.some((axes) => axes.some((axis) => parsePx(axis) !== 0))) {
|
|
857
|
+
for (const [index, prop] of corners.entries()) {
|
|
858
|
+
const raw = style[prop];
|
|
859
|
+
const offScale = measuredCorners[index].some((axis) => {
|
|
860
|
+
if (!/^\d+(?:\.\d+)?(?:px)?$/.test(axis)) return true;
|
|
861
|
+
const value = parsePx(axis) ?? 0;
|
|
862
|
+
// offsetWidth/offsetHeight round to whole px; the painted h/2 does not.
|
|
863
|
+
return value !== 0 && Math.abs(value - half) > 0.5;
|
|
864
|
+
});
|
|
865
|
+
entry[prop] = { value: parsePx(raw) ?? 0, unit: "px", knob: "BINARY 0 | h/2", offScale };
|
|
866
|
+
if (offScale)
|
|
867
|
+
entry.violations.push(`${prop}: ${raw} is neither 0 nor BINARY h/2 (${half}px)`);
|
|
868
|
+
}
|
|
869
|
+
entry.borderRadius = {
|
|
870
|
+
...entry.borderTopLeftRadius!,
|
|
871
|
+
offScale: corners.some((prop) => entry[prop]?.offScale),
|
|
872
|
+
};
|
|
873
|
+
hasAnyProp = true;
|
|
874
|
+
}
|
|
796
875
|
} else if (!isPercentageBorderRadius(el)) {
|
|
797
876
|
// Ordinary controls and identity recipes retain their existing scales.
|
|
798
877
|
const br = parsePx(style.borderTopLeftRadius || style.borderRadius);
|
|
@@ -839,7 +918,7 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
|
|
|
839
918
|
// Only check padding on elements that look like surfaces (have border-radius
|
|
840
919
|
// or border-width already audited). Padding on all elements creates too many
|
|
841
920
|
// false positives from browser defaults and third-party components.
|
|
842
|
-
if (!
|
|
921
|
+
if (!(capContainer && ownsInset) && (entry.borderRadius || entry.borderWidth)) {
|
|
843
922
|
const sides = [
|
|
844
923
|
["paddingTop", style.paddingTop],
|
|
845
924
|
["paddingRight", style.paddingRight],
|
package/src/audit/themeMatrix.ts
CHANGED
|
@@ -969,7 +969,10 @@ export function captureMatrixSnapshot(): MatrixSnapshot {
|
|
|
969
969
|
marks.length > 0 &&
|
|
970
970
|
marks.every(
|
|
971
971
|
(mark, index) =>
|
|
972
|
-
|
|
972
|
+
// A bar path that declares its box (value-end rounding, MPO-56)
|
|
973
|
+
// paints inside that box just as a rect does.
|
|
974
|
+
(mark.tagName.toLowerCase() === "rect" ||
|
|
975
|
+
(mark.tagName.toLowerCase() === "path" && mark.hasAttribute("data-mpo-chart-bar"))) &&
|
|
973
976
|
getComputedStyle(mark).stroke === "none" &&
|
|
974
977
|
!sample.marks[index].unmeasurable,
|
|
975
978
|
)
|
|
@@ -183,8 +183,7 @@ describe("buildFigmaTokens", () => {
|
|
|
183
183
|
it("exports platform-normalized elevation modes", () => {
|
|
184
184
|
const { modes } = result.knobs.elevation;
|
|
185
185
|
expect(modes.none).toEqual({ sizeToken: null, px: null });
|
|
186
|
-
expect(modes.small
|
|
187
|
-
expect(modes.small.px).toBe((defaultConfig.tokens.size as Record<string, number>).$1);
|
|
186
|
+
expect(modes.small).toEqual({ sizeToken: null, px: null });
|
|
188
187
|
expect(modes.medium.sizeToken).toBe("$2");
|
|
189
188
|
expect(modes.large.sizeToken).toBe("$4");
|
|
190
189
|
});
|
package/src/numbers.spec.ts
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
currencyAffix,
|
|
4
|
+
formatCurrency,
|
|
5
|
+
formatNumber,
|
|
6
|
+
formatPercent,
|
|
7
|
+
resolveCurrencyCode,
|
|
8
|
+
} from "./numbers";
|
|
3
9
|
|
|
4
10
|
// Pin locale so assertions don't depend on the host machine.
|
|
5
11
|
const enUS = { locale: "en-US" } as const;
|
|
@@ -110,3 +116,23 @@ describe("resolveCurrencyCode", () => {
|
|
|
110
116
|
expect(resolveCurrencyCode(undefined)).toBeUndefined();
|
|
111
117
|
});
|
|
112
118
|
});
|
|
119
|
+
|
|
120
|
+
describe("currencyAffix (money editor glyph, MPO-221)", () => {
|
|
121
|
+
it("takes the glyph and its side from the locale's money register", () => {
|
|
122
|
+
expect(currencyAffix("USD", "en-US")).toEqual({ glyph: "$", position: "leading" });
|
|
123
|
+
expect(currencyAffix("EUR", "de-DE")).toEqual({ glyph: "€", position: "trailing" });
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("resolves a Frappe-style symbol through its code", () => {
|
|
127
|
+
expect(currencyAffix("₹", "en-IN")).toEqual({ glyph: "₹", position: "leading" });
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("returns a symbol with no known code as itself, leading", () => {
|
|
131
|
+
expect(currencyAffix("Fr", "en-US")).toEqual({ glyph: "Fr", position: "leading" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns nothing without a currency", () => {
|
|
135
|
+
expect(currencyAffix(undefined)).toBeUndefined();
|
|
136
|
+
expect(currencyAffix("")).toBeUndefined();
|
|
137
|
+
});
|
|
138
|
+
});
|
package/src/numbers.ts
CHANGED
|
@@ -76,6 +76,46 @@ export function resolveCurrencyCode(symbol: string | undefined): string | undefi
|
|
|
76
76
|
return CURRENCY_SYMBOL_TO_CODE[symbol];
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
export interface CurrencyAffix {
|
|
80
|
+
glyph: string;
|
|
81
|
+
/** Which side of the amount the locale writes the glyph on. */
|
|
82
|
+
position: "leading" | "trailing";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The glyph a money field writes beside its amount ("$", "€", "₹"), and on
|
|
87
|
+
* which side, for a code or a Frappe-style symbol. The money register's own
|
|
88
|
+
* placement, so an editor and `formatCurrency` agree. A symbol with no known
|
|
89
|
+
* code comes back as itself, leading.
|
|
90
|
+
*/
|
|
91
|
+
export function currencyAffix(
|
|
92
|
+
currency: string | undefined,
|
|
93
|
+
locale?: string,
|
|
94
|
+
): CurrencyAffix | undefined {
|
|
95
|
+
if (!currency) return undefined;
|
|
96
|
+
const code = resolveCurrencyCode(currency);
|
|
97
|
+
if (code) {
|
|
98
|
+
try {
|
|
99
|
+
const parts = new Intl.NumberFormat(locale, {
|
|
100
|
+
style: "currency",
|
|
101
|
+
currency: code,
|
|
102
|
+
currencyDisplay: "narrowSymbol",
|
|
103
|
+
}).formatToParts(1);
|
|
104
|
+
const glyphIndex = parts.findIndex((part) => part.type === "currency");
|
|
105
|
+
const amountIndex = parts.findIndex((part) => part.type === "integer");
|
|
106
|
+
if (glyphIndex !== -1) {
|
|
107
|
+
return {
|
|
108
|
+
glyph: parts[glyphIndex].value,
|
|
109
|
+
position: amountIndex !== -1 && glyphIndex > amountIndex ? "trailing" : "leading",
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
} catch {
|
|
113
|
+
// A malformed code falls through to the literal glyph below.
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return { glyph: currency, position: "leading" };
|
|
117
|
+
}
|
|
118
|
+
|
|
79
119
|
interface CoercedNumber {
|
|
80
120
|
num: number;
|
|
81
121
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TestProviders } from "@multiplatform.one/test-utils";
|
|
1
2
|
/**
|
|
2
3
|
* Surface context — nested size/density step-down (LC-68).
|
|
3
4
|
*/
|
|
@@ -117,7 +118,11 @@ describe("Surface nesting (LC-68)", () => {
|
|
|
117
118
|
describe("useResolvedKnobs inside Surface", () => {
|
|
118
119
|
it("size=small density=compact → sizeToken $3, compact gap, small desktop control height", () => {
|
|
119
120
|
const wrapper = ({ children }: { children: ReactNode }) =>
|
|
120
|
-
createElement(
|
|
121
|
+
createElement(
|
|
122
|
+
TestProviders,
|
|
123
|
+
null,
|
|
124
|
+
createElement(Surface, { size: "small", density: "compact" }, children),
|
|
125
|
+
);
|
|
121
126
|
const { result } = renderHook(() => useResolvedKnobs(), { wrapper });
|
|
122
127
|
expect(result.current.knobProps.size).toBe("small");
|
|
123
128
|
expect(result.current.knobProps.sizeToken).toBe("$3");
|
|
@@ -128,7 +133,7 @@ describe("useResolvedKnobs inside Surface", () => {
|
|
|
128
133
|
|
|
129
134
|
it("a large size request inside a medium Surface resolves medium", () => {
|
|
130
135
|
const wrapper = ({ children }: { children: ReactNode }) =>
|
|
131
|
-
createElement(Surface, { size: "medium" }, children);
|
|
136
|
+
createElement(TestProviders, null, createElement(Surface, { size: "medium" }, children));
|
|
132
137
|
const { result } = renderHook(() => useResolvedKnobs({ size: "$5" }), { wrapper });
|
|
133
138
|
expect(result.current.knobProps.size).toBe("medium");
|
|
134
139
|
expect(result.current.knobProps.sizeToken).toBe("$4");
|
|
@@ -136,7 +141,7 @@ describe("useResolvedKnobs inside Surface", () => {
|
|
|
136
141
|
|
|
137
142
|
it("Surface size is inherited as the subtree default (lg over global medium)", () => {
|
|
138
143
|
const wrapper = ({ children }: { children: ReactNode }) =>
|
|
139
|
-
createElement(Surface, { size: "lg" }, children);
|
|
144
|
+
createElement(TestProviders, null, createElement(Surface, { size: "lg" }, children));
|
|
140
145
|
const { result } = renderHook(() => useResolvedKnobs(), { wrapper });
|
|
141
146
|
expect(result.current.knobProps.size).toBe("large");
|
|
142
147
|
expect(result.current.knobProps.sizeToken).toBe("$5");
|
|
@@ -144,7 +149,7 @@ describe("useResolvedKnobs inside Surface", () => {
|
|
|
144
149
|
|
|
145
150
|
it("compact:false inside a compact Surface stays compact and still steps space", () => {
|
|
146
151
|
const wrapper = ({ children }: { children: ReactNode }) =>
|
|
147
|
-
createElement(Surface, { density: "compact" }, children);
|
|
152
|
+
createElement(TestProviders, null, createElement(Surface, { density: "compact" }, children));
|
|
148
153
|
const { result } = renderHook(() => useResolvedKnobs({ compact: false }), { wrapper });
|
|
149
154
|
expect(result.current.knobProps.density).toBe("compact");
|
|
150
155
|
expect(result.current.knobProps.gap).toEqual({ gap: "$2" });
|
|
@@ -18,6 +18,7 @@ import { type SizeRecipeInputs } from "./recipeInputs";
|
|
|
18
18
|
import { type ThemeConfig, createThemeConfig } from "./shared";
|
|
19
19
|
import { assertResolvableThemeValues } from "./themeValue";
|
|
20
20
|
import { getGeneratedSizeRecipes, getSizeRecipeInputs, setSizeRecipeInputs } from "./sizeRecipes";
|
|
21
|
+
import { type ThemeSubset, subsetThemes } from "./subsetThemes";
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* The Tamagui era this theme config resolves against (MPO-19 X13; rendered
|
|
@@ -66,6 +67,12 @@ export interface CreateDefaultThemeConfigOptions {
|
|
|
66
67
|
* theme-builder).
|
|
67
68
|
*/
|
|
68
69
|
recipeInputs?: Partial<SizeRecipeInputs>;
|
|
70
|
+
/**
|
|
71
|
+
* Ship only the themes this surface can reach (`10` AC-6). Applied to the
|
|
72
|
+
* built matrix, after `additionalThemes`, so a subset filters which themes
|
|
73
|
+
* exist and never changes what one contains. See `subsetThemes`.
|
|
74
|
+
*/
|
|
75
|
+
subset?: ThemeSubset;
|
|
69
76
|
}
|
|
70
77
|
|
|
71
78
|
/**
|
|
@@ -136,7 +143,7 @@ export function createDefaultThemeConfig(
|
|
|
136
143
|
// theming. This optimization breaks builds served outside Tamagui's SSR
|
|
137
144
|
// pipeline (e.g. the Frappe SPA) where runtime JS themes are required.
|
|
138
145
|
// Always keep themes to ensure TamaguiProvider has valid theme data.
|
|
139
|
-
const themes = builtThemes;
|
|
146
|
+
const themes = options.subset ? subsetThemes(builtThemes, options.subset) : builtThemes;
|
|
140
147
|
return createThemeConfig(
|
|
141
148
|
{
|
|
142
149
|
...defaultConfig,
|