@multiplatform.one/theme 7.11.0 → 7.15.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 +102 -0
- package/src/audit/constraintAudit.ts +78 -33
- 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/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 +84 -25
- 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/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/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 +23 -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.15.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.15.0",
|
|
48
|
+
"@multiplatform.one/store": "7.15.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.15.0",
|
|
61
|
+
"@multiplatform.one/test-utils": "7.15.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,105 @@ 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("element labels hold nothing a knob or a mount writes", () => {
|
|
246
|
+
it("drops React useId ids and Tamagui atomic classes", () => {
|
|
247
|
+
const el = specimen("border-radius:12px");
|
|
248
|
+
el.id = "_r_3i_";
|
|
249
|
+
el.className = "is_View _borderStartStartRadius-0hover-c-radius-12 _outlineWidth-0focus-0px";
|
|
250
|
+
expect(measured()).toBeUndefined();
|
|
251
|
+
const found = audit().elements.find((entry) => entry.label.startsWith("div"));
|
|
252
|
+
expect(found?.label).toBe("div.is_View");
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("keeps an authored id", () => {
|
|
256
|
+
specimen("border-radius:12px");
|
|
257
|
+
expect(audit().elements[0]?.label).toBe("div#specimen");
|
|
258
|
+
});
|
|
259
|
+
});
|
|
@@ -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,12 +734,28 @@ 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 capContainer =
|
|
743
|
+
containerName === "Card"
|
|
744
|
+
? ["content", "elevated"].includes(el.getAttribute("data-tier") ?? "")
|
|
745
|
+
: containerName === "PageSection" || containerName === "TextArea";
|
|
746
|
+
if (containerName !== null && containerName !== "Card" && !capContainer) {
|
|
747
|
+
entry.violations.push(
|
|
748
|
+
`data-constraint-container: "${containerName}" is not a container the cap audit knows`,
|
|
749
|
+
);
|
|
750
|
+
}
|
|
751
|
+
const binaryPart = el.getAttribute("data-radius-resolution") === "BINARY";
|
|
752
|
+
const corners = [
|
|
753
|
+
"borderTopLeftRadius",
|
|
754
|
+
"borderTopRightRadius",
|
|
755
|
+
"borderBottomRightRadius",
|
|
756
|
+
"borderBottomLeftRadius",
|
|
757
|
+
] as const;
|
|
758
|
+
if (capContainer) {
|
|
738
759
|
const radiusKnob = el.getAttribute("data-radius-knob") ?? "";
|
|
739
760
|
const spaceKnob = el.getAttribute("data-space-knob") ?? "";
|
|
740
761
|
const radiusStop = Object.hasOwn(_BORDER_RADIUS_KNOB, radiusKnob)
|
|
@@ -745,30 +766,28 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
|
|
|
745
766
|
: undefined;
|
|
746
767
|
const paddingSides = ["paddingTop", "paddingRight", "paddingBottom", "paddingLeft"] as const;
|
|
747
768
|
const measuredInsets = paddingSides.map((prop) => parsePx(style[prop]));
|
|
748
|
-
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
769
|
+
if (ownsInset) {
|
|
770
|
+
for (const [index, prop] of paddingSides.entries()) {
|
|
771
|
+
const value = measuredInsets[index];
|
|
772
|
+
const offScale = inset === undefined || value !== inset;
|
|
773
|
+
entry[prop] = {
|
|
774
|
+
value: value ?? 0,
|
|
775
|
+
unit: "px",
|
|
776
|
+
knob: `${containerName} panelPadding:${spaceKnob}`,
|
|
777
|
+
offScale,
|
|
778
|
+
};
|
|
779
|
+
if (offScale)
|
|
780
|
+
entry.violations.push(
|
|
781
|
+
`${prop}: ${style[prop]} does not match ${containerName} panelPadding:${spaceKnob} (${inset ?? "unknown"}px)`,
|
|
782
|
+
);
|
|
783
|
+
}
|
|
761
784
|
}
|
|
762
785
|
const expected =
|
|
763
|
-
radiusStop === undefined ||
|
|
786
|
+
radiusStop === undefined ||
|
|
787
|
+
inset === undefined ||
|
|
788
|
+
(ownsInset && measuredInsets.some((v) => v === null))
|
|
764
789
|
? undefined
|
|
765
|
-
: Math.min(radiusStop, inset, ...(measuredInsets as number[]));
|
|
766
|
-
const corners = [
|
|
767
|
-
"borderTopLeftRadius",
|
|
768
|
-
"borderTopRightRadius",
|
|
769
|
-
"borderBottomRightRadius",
|
|
770
|
-
"borderBottomLeftRadius",
|
|
771
|
-
] as const;
|
|
790
|
+
: Math.min(radiusStop, inset, ...(ownsInset ? (measuredInsets as number[]) : []));
|
|
772
791
|
for (const prop of corners) {
|
|
773
792
|
const raw = style[prop];
|
|
774
793
|
// A corner may have two elliptical axes. Percentages are not px caps.
|
|
@@ -779,13 +798,13 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
|
|
|
779
798
|
const result = {
|
|
780
799
|
value: parsePx(raw) ?? 0,
|
|
781
800
|
unit: "px",
|
|
782
|
-
knob:
|
|
801
|
+
knob: `${containerName} min(radius:${radiusKnob}, panelPadding:${spaceKnob})`,
|
|
783
802
|
offScale,
|
|
784
803
|
};
|
|
785
804
|
entry[prop] = result;
|
|
786
805
|
if (offScale)
|
|
787
806
|
entry.violations.push(
|
|
788
|
-
`${prop}: ${raw} does not match
|
|
807
|
+
`${prop}: ${raw} does not match ${containerName} cap (${expected ?? "unknown"}px)`,
|
|
789
808
|
);
|
|
790
809
|
}
|
|
791
810
|
entry.borderRadius = {
|
|
@@ -793,6 +812,32 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
|
|
|
793
812
|
offScale: corners.some((prop) => entry[prop]?.offScale),
|
|
794
813
|
};
|
|
795
814
|
hasAnyProp = true;
|
|
815
|
+
} else if (binaryPart) {
|
|
816
|
+
// DG-RAD-03: square, or a pill/circle of the part's own shorter edge,
|
|
817
|
+
// at every stop and in every state. Layout size, not the transformed
|
|
818
|
+
// box, so a translated thumb measures the same as a resting one.
|
|
819
|
+
const box = el as HTMLElement;
|
|
820
|
+
const half = Math.min(box.offsetWidth, box.offsetHeight) / 2;
|
|
821
|
+
const measuredCorners = corners.map((prop) => style[prop].trim().split(/\s+/));
|
|
822
|
+
if (measuredCorners.some((axes) => axes.some((axis) => parsePx(axis) !== 0))) {
|
|
823
|
+
for (const [index, prop] of corners.entries()) {
|
|
824
|
+
const raw = style[prop];
|
|
825
|
+
const offScale = measuredCorners[index].some((axis) => {
|
|
826
|
+
if (!/^\d+(?:\.\d+)?(?:px)?$/.test(axis)) return true;
|
|
827
|
+
const value = parsePx(axis) ?? 0;
|
|
828
|
+
// offsetWidth/offsetHeight round to whole px; the painted h/2 does not.
|
|
829
|
+
return value !== 0 && Math.abs(value - half) > 0.5;
|
|
830
|
+
});
|
|
831
|
+
entry[prop] = { value: parsePx(raw) ?? 0, unit: "px", knob: "BINARY 0 | h/2", offScale };
|
|
832
|
+
if (offScale)
|
|
833
|
+
entry.violations.push(`${prop}: ${raw} is neither 0 nor BINARY h/2 (${half}px)`);
|
|
834
|
+
}
|
|
835
|
+
entry.borderRadius = {
|
|
836
|
+
...entry.borderTopLeftRadius!,
|
|
837
|
+
offScale: corners.some((prop) => entry[prop]?.offScale),
|
|
838
|
+
};
|
|
839
|
+
hasAnyProp = true;
|
|
840
|
+
}
|
|
796
841
|
} else if (!isPercentageBorderRadius(el)) {
|
|
797
842
|
// Ordinary controls and identity recipes retain their existing scales.
|
|
798
843
|
const br = parsePx(style.borderTopLeftRadius || style.borderRadius);
|
|
@@ -839,7 +884,7 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
|
|
|
839
884
|
// Only check padding on elements that look like surfaces (have border-radius
|
|
840
885
|
// or border-width already audited). Padding on all elements creates too many
|
|
841
886
|
// false positives from browser defaults and third-party components.
|
|
842
|
-
if (!
|
|
887
|
+
if (!(capContainer && ownsInset) && (entry.borderRadius || entry.borderWidth)) {
|
|
843
888
|
const sides = [
|
|
844
889
|
["paddingTop", style.paddingTop],
|
|
845
890
|
["paddingRight", style.paddingRight],
|
|
@@ -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,
|
package/src/theme/index.ts
CHANGED
|
@@ -30,10 +30,13 @@ export * from "./resolveKnobs";
|
|
|
30
30
|
export * from "./shared";
|
|
31
31
|
export * from "./themeValue";
|
|
32
32
|
export * from "./sizeRecipes";
|
|
33
|
+
export * from "./subsetThemes";
|
|
33
34
|
export * from "./Surface";
|
|
34
35
|
export * from "./tileSizes";
|
|
36
|
+
export * from "./transitionProps";
|
|
35
37
|
|
|
36
38
|
export * from "./theme";
|
|
37
39
|
export * from "./Tint";
|
|
38
40
|
export * from "./useResolvedKnobs";
|
|
39
41
|
export * from "./useTheme";
|
|
42
|
+
export * from "./useTouchSurface";
|
|
@@ -4,7 +4,9 @@ import { renderToString } from "react-dom/server";
|
|
|
4
4
|
import { describe, expect, it, vi } from "vitest";
|
|
5
5
|
|
|
6
6
|
// Mock Tamagui's <Theme> so we can inspect the chosen theme name
|
|
7
|
-
vi.mock("tamagui", () => ({
|
|
7
|
+
vi.mock("tamagui", async (importOriginal) => ({
|
|
8
|
+
useDidFinishSSR: (await importOriginal<typeof import("tamagui")>()).useDidFinishSSR,
|
|
9
|
+
// These tests inspect intent names with a fixed light scheme.
|
|
8
10
|
useThemeName: () => "light",
|
|
9
11
|
Theme: ({ name, children }: { name: string; children: ReactNode }) =>
|
|
10
12
|
createElement("div", { "data-tamagui-theme": name }, children),
|
|
@@ -214,8 +216,8 @@ describe("useResolvedKnobs with Intent", () => {
|
|
|
214
216
|
),
|
|
215
217
|
);
|
|
216
218
|
|
|
217
|
-
// "error" → Button: { elevation: "small" }
|
|
218
|
-
expect(capture.result.knobProps.elevation).
|
|
219
|
+
// "error" → Button: { elevation: "small" }, and small paints no control shadow (MPO-96)
|
|
220
|
+
expect(capture.result.knobProps.elevation).toBeUndefined();
|
|
219
221
|
});
|
|
220
222
|
|
|
221
223
|
it("resolves component-specific intent overrides via explicit option", () => {
|
|
@@ -223,8 +225,8 @@ describe("useResolvedKnobs with Intent", () => {
|
|
|
223
225
|
|
|
224
226
|
renderToHtml(wrapWithPreset(makePreset(), createElement(capture.Probe)));
|
|
225
227
|
|
|
226
|
-
// "error" → Button: { elevation: "small" }
|
|
227
|
-
expect(capture.result.knobProps.elevation).
|
|
228
|
+
// "error" → Button: { elevation: "small" }, and small paints no control shadow (MPO-96)
|
|
229
|
+
expect(capture.result.knobProps.elevation).toBeUndefined();
|
|
228
230
|
});
|
|
229
231
|
|
|
230
232
|
it("preset intent overrides take precedence over defaultIntents", () => {
|
|
@@ -287,8 +289,8 @@ describe("useResolvedKnobs with Intent", () => {
|
|
|
287
289
|
|
|
288
290
|
renderToHtml(wrapWithPreset(makePreset(), createElement(capture.Probe)));
|
|
289
291
|
|
|
290
|
-
// Default knobs: elevation "small"
|
|
291
|
-
expect(capture.result.knobProps.elevation).
|
|
292
|
+
// Default knobs: elevation "small" paints no control shadow (MPO-96)
|
|
293
|
+
expect(capture.result.knobProps.elevation).toBeUndefined();
|
|
292
294
|
expect(capture.result.knobProps.textAccent).toBe("high");
|
|
293
295
|
});
|
|
294
296
|
});
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { TestProviders } from "@multiplatform.one/test-utils";
|
|
1
2
|
/**
|
|
2
3
|
* Layout hook specs — the live half of layoutTokens (DG-LAY-01/03): the
|
|
3
4
|
* viewport-driven `useLayoutSizeClass`, the pane budget `useMultiPane`, and
|
|
@@ -148,8 +149,8 @@ describe("useMultiPane", () => {
|
|
|
148
149
|
describe("useSemanticGaps", () => {
|
|
149
150
|
it("maps within-group to knob gap and between-groups to the large gap", () => {
|
|
150
151
|
setViewportWidth(1000);
|
|
151
|
-
const { result: knobs } = renderHook(() => useResolvedKnobs());
|
|
152
|
-
const { result } = renderHook(() => useSemanticGaps());
|
|
152
|
+
const { result: knobs } = renderHook(() => useResolvedKnobs(), { wrapper: TestProviders });
|
|
153
|
+
const { result } = renderHook(() => useSemanticGaps(), { wrapper: TestProviders });
|
|
153
154
|
expect(result.current.withinGroup).toEqual(knobs.current.knobProps.gap);
|
|
154
155
|
expect(result.current.betweenGroups).toEqual(knobs.current.knobProps.gapLg);
|
|
155
156
|
// The roles must differ, or grouping hierarchy is invisible.
|
package/src/theme/radiusClass.ts
CHANGED
|
@@ -81,6 +81,24 @@ export function radiusClassProps(
|
|
|
81
81
|
return { "data-radius-class": radiusClass, "data-radius-part": part };
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
+
export interface RadiusResolutionDeclaration {
|
|
85
|
+
"data-radius-resolution": "BINARY";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Declare that a part resolves through the BINARY row (DG-RAD-03), so the
|
|
90
|
+
* constraint audit measures it as 0 or half its own shorter edge. The token
|
|
91
|
+
* scale has no h/2 value, so an undeclared thumb or track reads off-scale at
|
|
92
|
+
* every stop. Spread it only where the painted radius IS the resolved one: a
|
|
93
|
+
* consumer radius override ejects the part from the class.
|
|
94
|
+
*
|
|
95
|
+
* @example
|
|
96
|
+
* <StyledSwitch.Thumb {...radiusResolutionProps("BINARY")} borderRadius={thumbRadius} />
|
|
97
|
+
*/
|
|
98
|
+
export function radiusResolutionProps(radiusClass: "BINARY"): RadiusResolutionDeclaration {
|
|
99
|
+
return { "data-radius-resolution": radiusClass };
|
|
100
|
+
}
|
|
101
|
+
|
|
84
102
|
// ── Radius resolution (DG-RAD-01..05) ─────────────────────────
|
|
85
103
|
|
|
86
104
|
/**
|