@multiplatform.one/theme 8.2.0 → 8.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/theme",
3
- "version": "8.2.0",
3
+ "version": "8.2.2",
4
4
  "description": "Tamagui theme system for multiplatform.one",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -163,8 +163,8 @@
163
163
  "access": "public"
164
164
  },
165
165
  "dependencies": {
166
- "@multiplatform.one/platform": "^8.2.0",
167
- "@multiplatform.one/store": "^8.2.0",
166
+ "@multiplatform.one/platform": "^8.2.2",
167
+ "@multiplatform.one/store": "^8.2.2",
168
168
  "@tamagui/animations-react-native": "2.7.6",
169
169
  "@tamagui/colors": "2.7.6",
170
170
  "@tamagui/config": "2.7.6",
@@ -180,8 +180,8 @@
180
180
  },
181
181
  "devDependencies": {
182
182
  "@expo/prebuild-config": "55.0.18",
183
- "@multiplatform.one/config": "8.2.0",
184
- "@multiplatform.one/test-utils": "8.2.0",
183
+ "@multiplatform.one/config": "8.2.2",
184
+ "@multiplatform.one/test-utils": "8.2.2",
185
185
  "@tamagui/animations-css": "2.7.6",
186
186
  "@tamagui/animations-reanimated": "2.7.6",
187
187
  "@tamagui/build": "2.7.6",
@@ -1,4 +1,4 @@
1
- import { afterEach, describe, expect, it } from "vitest";
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
2
  import { defaultConfig } from "@tamagui/config/v5";
3
3
  import { defaultHeadingFont } from "../theme/defaults/fonts";
4
4
  import { fontCategoryStacks } from "../theme/fontCategoryStacks";
@@ -10,15 +10,30 @@ import { defaultScaleInputs } from "../theme/scale/scaleInputs";
10
10
  import { GAP_KNOB, GAP_LG_KNOB, runConstraintAudit } from "./constraintAudit";
11
11
 
12
12
  afterEach(() => {
13
+ vi.unstubAllGlobals();
13
14
  document.body.innerHTML = "";
14
15
  delete (globalThis as { __MPO_SCALE__?: unknown }).__MPO_SCALE__;
16
+ delete document.documentElement.dataset.preset;
17
+ document.documentElement.className = "";
18
+ document.documentElement.style.colorScheme = "";
19
+ document.cookie = "mp.preset=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
15
20
  });
16
21
 
17
22
  // Deliberately loses module scope, like Playwright page.evaluate.
18
- const audit = new Function(
19
- `return (${runConstraintAudit.toString()})()`,
23
+ const serialized = new Function(
24
+ "rootSelector",
25
+ `return (${runConstraintAudit.toString()})(rootSelector)`,
20
26
  ) as typeof runConstraintAudit;
21
27
 
28
+ const stable = ({ timestamp: _timestamp, ...report }: ReturnType<typeof runConstraintAudit>) =>
29
+ report;
30
+
31
+ function audit(rootSelector?: string) {
32
+ const report = serialized(rootSelector);
33
+ expect(stable(runConstraintAudit(rootSelector))).toEqual(stable(report));
34
+ return report;
35
+ }
36
+
22
37
  function specimen(style: string, attrs: Record<string, string> = {}) {
23
38
  const el = document.createElement("div");
24
39
  el.id = "specimen";
@@ -544,3 +559,220 @@ describe("container and field keys on every scale", () => {
544
559
  expect(measured()?.borderRadius?.knob).toContain("container");
545
560
  });
546
561
  });
562
+
563
+ describe("what the audit walks", () => {
564
+ it("needs a DOM", () => {
565
+ vi.stubGlobal("document", undefined);
566
+ expect(() => runConstraintAudit()).toThrow("runConstraintAudit needs a DOM");
567
+ });
568
+
569
+ it("audits only inside the scope it is given, the scope included", () => {
570
+ const scope = specimen("border-radius:13px");
571
+ scope.id = "scope";
572
+ const inner = document.createElement("div");
573
+ inner.style.cssText = "border-width:3px;border-style:solid";
574
+ scope.append(inner);
575
+ specimen("border-radius:13px").id = "outside";
576
+ const report = audit("#scope");
577
+ expect(report.elements.map((el) => el.id)).toEqual(["scope", ""]);
578
+ expect(report.summary.totalElements).toBe(2);
579
+ });
580
+
581
+ it("refuses a scope that is not on the page", () => {
582
+ expect(() => serialized("#nope")).toThrow("Constraint audit scope not found: #nope");
583
+ expect(() => runConstraintAudit("#nope")).toThrow("Constraint audit scope not found: #nope");
584
+ });
585
+
586
+ it.each([
587
+ ["display:none", "display:none"],
588
+ ["opacity 0", "opacity:0"],
589
+ ["visibility hidden", "visibility:hidden"],
590
+ ])("skips an element under %s, painted styles and all", (_label, hide) => {
591
+ const parent = specimen(hide);
592
+ parent.id = "parent";
593
+ const child = document.createElement("div");
594
+ child.id = "child";
595
+ child.style.cssText = "border-radius:13px";
596
+ parent.append(child);
597
+ const ids = audit().elements.map((el) => el.id);
598
+ expect(ids).not.toContain("child");
599
+ });
600
+
601
+ it("skips third-party devtools and their subtree", () => {
602
+ const tools = specimen("border-radius:13px");
603
+ tools.id = "tanstack_devtools";
604
+ const inner = document.createElement("div");
605
+ inner.style.cssText = "border-radius:13px";
606
+ tools.append(inner);
607
+ expect(audit().elements).toEqual([]);
608
+ });
609
+
610
+ it("audits an svg but not the shapes inside it", () => {
611
+ const host = specimen("");
612
+ host.innerHTML =
613
+ '<svg id="icon" style="border-radius:13px"><rect id="shape" style="border-radius:13px"></rect></svg>';
614
+ expect(audit().elements.map((el) => el.id)).toEqual(["icon"]);
615
+ });
616
+
617
+ it("leaves a percentage radius to the browser", () => {
618
+ specimen("border-radius:50%");
619
+ expect(measured()).toBeUndefined();
620
+ });
621
+ });
622
+
623
+ describe("knob names on the ordinary scales", () => {
624
+ it.each([
625
+ ["border-radius:1000px", "pill"],
626
+ ["border-radius:100000px", "circular"],
627
+ [`border-radius:${tokens.radius[2]}px`, "small"],
628
+ [`border-radius:${tokens.radius[3]}px`, "medium (outer)"],
629
+ ])("names %s as %s", (style, knob) => {
630
+ specimen(style);
631
+ expect(measured()?.borderRadius).toMatchObject({ knob, offScale: false });
632
+ });
633
+
634
+ it.each([
635
+ [0.5, "small"],
636
+ [1, "medium"],
637
+ [2, "large"],
638
+ ])("names a %spx border as %s", (width, knob) => {
639
+ specimen(`border-width:${width}px;border-style:solid`);
640
+ expect(measured()?.borderWidth).toMatchObject({ value: width, knob, offScale: false });
641
+ });
642
+
643
+ it("rejects a border width off the knob scale", () => {
644
+ specimen("border-width:3px;border-style:solid");
645
+ expect(measured()?.violations).toEqual(["borderWidth: 3px is off-scale (no matching token)"]);
646
+ expect(audit().summary.byProperty.borderWidth).toBe(1);
647
+ });
648
+
649
+ it.each([
650
+ [GAP_KNOB.gapSm, "gapSm"],
651
+ [GAP_KNOB.gap, "gap"],
652
+ [GAP_LG_KNOB.gapLg, "gapLg (lg)"],
653
+ [tokens.space.$1, `space-token(${tokens.space.$1}px)`],
654
+ [3.9, "input-internal(3.9px)"],
655
+ [tokens.space["$1.5"], `size-token(${tokens.space["$1.5"]}px)`],
656
+ ])("names a %spx gap as %s", (gap, knob) => {
657
+ specimen(`display:flex;row-gap:${gap}px`);
658
+ expect(measured()?.rowGap).toMatchObject({ value: gap, knob, offScale: false });
659
+ });
660
+
661
+ it("audits a column gap only when it differs from the row gap", () => {
662
+ specimen(`display:flex;row-gap:${GAP_KNOB.gap}px;column-gap:${GAP_LG_KNOB.gapLg}px`);
663
+ expect(measured()?.columnGap).toMatchObject({ knob: "gapLg (lg)", offScale: false });
664
+ document.body.innerHTML = "";
665
+ specimen(`display:flex;row-gap:${GAP_KNOB.gap}px;column-gap:${GAP_KNOB.gap}px`);
666
+ expect(measured()?.columnGap).toBeUndefined();
667
+ });
668
+
669
+ it("counts an off-scale gap in the summary", () => {
670
+ specimen("display:flex;column-gap:19px");
671
+ expect(measured()?.columnGap).toMatchObject({ knob: "off-scale", offScale: true });
672
+ expect(audit().summary.byProperty.gap).toBe(1);
673
+ });
674
+
675
+ it.each([
676
+ [tokens.space.$4, "panelPadding"],
677
+ [tokens.space.$6, "featurePadding"],
678
+ [tokens.space.$1, `space-token(${tokens.space.$1}px)`],
679
+ [tokens.space.$2, `size-token(${tokens.space.$2}px)`],
680
+ ])("names %spx of surface padding as %s", (pad, knob) => {
681
+ specimen(`border-width:1px;border-style:solid;padding:${pad}px`);
682
+ expect(measured()?.paddingTop).toMatchObject({ value: pad, knob, offScale: false });
683
+ });
684
+
685
+ it("checks padding only on a surface, and counts an off-scale one", () => {
686
+ specimen("padding:19px");
687
+ expect(measured()).toBeUndefined();
688
+ document.body.innerHTML = "";
689
+ specimen("border-width:1px;border-style:solid;padding:19px 0 0 0");
690
+ expect(measured()?.paddingTop).toMatchObject({ knob: "off-scale", offScale: true });
691
+ expect(measured()?.paddingRight).toBeUndefined();
692
+ expect(audit().summary.byProperty.padding).toBe(1);
693
+ });
694
+ });
695
+
696
+ describe("font weight knobs", () => {
697
+ it.each([
698
+ [300, "light"],
699
+ [700, "bold"],
700
+ ])("names %i on a plain element as %s", (weight, knob) => {
701
+ specimen(`font-weight:${weight}`);
702
+ expect(measured()?.fontWeight).toMatchObject({ value: weight, knob, offScale: false });
703
+ });
704
+
705
+ it("holds a declared knob to its own weight", () => {
706
+ specimen("font-weight:700", { "data-font-weight-knob": "bold" });
707
+ expect(measured()?.fontWeight).toMatchObject({ knob: "fontWeight:bold", offScale: false });
708
+ document.body.innerHTML = "";
709
+ specimen("font-weight:400", { "data-font-weight-knob": "bold" });
710
+ expect(measured()?.violations).toEqual(["fontWeight: 400 does not match fontWeight:bold"]);
711
+ });
712
+
713
+ it("rejects a knob the audit has no weight for", () => {
714
+ specimen("font-weight:800", { "data-font-weight-knob": "heavy" });
715
+ expect(measured()?.fontWeight).toMatchObject({ knob: "fontWeight:heavy", offScale: true });
716
+ expect(audit().summary.byProperty.fontWeight).toBe(1);
717
+ });
718
+ });
719
+
720
+ describe("inline colours that bypass the theme", () => {
721
+ it.each([
722
+ ["color", "color:rgb(255, 0, 0)"],
723
+ ["backgroundColor", "background-color:rgb(0, 0, 255)"],
724
+ ["borderColor", "border-color:rgb(0, 128, 0)"],
725
+ ])("reports a hardcoded %s", (property, style) => {
726
+ specimen(style);
727
+ expect(measured()?.inlineColor).toMatchObject({
728
+ knob: "off-scale (hardcoded)",
729
+ offScale: true,
730
+ });
731
+ expect(measured()?.inlineColor?.value.startsWith(`${property}: `)).toBe(true);
732
+ expect(measured()?.violations[0]).toMatch(new RegExp(`^inlineColor: ${property}="`));
733
+ expect(audit().summary.byProperty.inlineColor).toBe(1);
734
+ });
735
+
736
+ it.each(["color:var(--color)", "background-color:transparent", "color:inherit"])(
737
+ "accepts %s",
738
+ (style) => {
739
+ specimen(style);
740
+ expect(measured()?.inlineColor).toBeUndefined();
741
+ },
742
+ );
743
+ });
744
+
745
+ describe("the report's page context", () => {
746
+ it("names the preset the root declares, else the cookie's, else unknown", () => {
747
+ specimen("border-radius:13px");
748
+ expect(audit().preset).toBe("unknown");
749
+ document.cookie = "mp.preset=forest";
750
+ expect(audit().preset).toBe("forest");
751
+ document.documentElement.dataset.preset = "ocean";
752
+ expect(audit().preset).toBe("ocean");
753
+ });
754
+
755
+ it("reads the scheme from the Tamagui root class, else the colour scheme", () => {
756
+ expect(audit().scheme).toBe("unknown");
757
+ document.documentElement.style.colorScheme = "dark";
758
+ expect(audit().scheme).toBe("dark");
759
+ document.documentElement.className = "t_light";
760
+ expect(audit().scheme).toBe("light");
761
+ document.documentElement.className = "t_dark";
762
+ expect(audit().scheme).toBe("dark");
763
+ });
764
+
765
+ it("lists only the offending elements as violations and counts them", () => {
766
+ specimen(`border-radius:${tokens.radius[2]}px`).id = "fine";
767
+ specimen("border-radius:13px").id = "off";
768
+ const report = audit();
769
+ expect(report.url).toBe(window.location.href);
770
+ expect(report.elements.map((el) => el.id)).toEqual(["fine", "off"]);
771
+ expect(report.violations.map((el) => el.id)).toEqual(["off"]);
772
+ expect(report.summary).toMatchObject({
773
+ auditedElements: 2,
774
+ totalViolations: 1,
775
+ byProperty: { borderRadius: 1 },
776
+ });
777
+ });
778
+ });
@@ -4,8 +4,9 @@ import { readFileSync } from "node:fs";
4
4
  import { resolve } from "node:path";
5
5
  import { runInNewContext } from "node:vm";
6
6
  import { describe, expect, it } from "vitest";
7
- import { createExpoThemeColors } from "./expo";
7
+ import * as expoEntry from "./expo";
8
8
 
9
+ const { createExpoThemeColors } = expoEntry;
9
10
  const require = createRequire(import.meta.url);
10
11
  const {
11
12
  getIosSplashConfig,
@@ -45,7 +46,12 @@ describe.each(["one", "storybook-expo"])("%s Expo config", (app) => {
45
46
  module,
46
47
  __dirname: resolve(filename, ".."),
47
48
  process: { env: {} },
48
- require: (name: string) => (name === "dotenv" ? { config() {} } : appRequire(name)),
49
+ require: (name: string) =>
50
+ name === "dotenv"
51
+ ? { config() {} }
52
+ : name === "@multiplatform.one/theme/expo"
53
+ ? expoEntry
54
+ : appRequire(name),
49
55
  });
50
56
  const { expo } = module.exports;
51
57
  const colors = createExpoThemeColors();
@@ -0,0 +1,141 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ createThemesBuilder,
4
+ type GetThemeProps,
5
+ houseComponentThemes,
6
+ houseGrandChildrenThemes,
7
+ } from "./createThemes";
8
+ import { defaultAccentTheme } from "./defaults/accent";
9
+ import { defaultBaseTheme } from "./defaults/base";
10
+
11
+ const grey = [
12
+ "#000000",
13
+ "#111111",
14
+ "#222222",
15
+ "#333333",
16
+ "#444444",
17
+ "#555555",
18
+ "#666666",
19
+ "#777777",
20
+ "#888888",
21
+ "#999999",
22
+ "#aaaaaa",
23
+ "#ffffff",
24
+ ];
25
+ const hsla = (lightness: number) => `hsla(0, 0%, ${lightness}%, 1)`;
26
+ const unshadowed = { ...defaultBaseTheme, lightShadows: undefined, darkShadows: undefined };
27
+
28
+ describe("createThemesBuilder scopes", () => {
29
+ const themes = createThemesBuilder(defaultBaseTheme, defaultAccentTheme).themes();
30
+
31
+ it.each(["light", "dark"])("builds every hue, intent and house scope under %s", (scheme) => {
32
+ const scopes = [
33
+ "accent",
34
+ "blue",
35
+ "red",
36
+ "green",
37
+ "yellow",
38
+ "orange",
39
+ "pink",
40
+ "purple",
41
+ "gray",
42
+ "warning",
43
+ "error",
44
+ "success",
45
+ ...Object.keys(houseGrandChildrenThemes),
46
+ ...Object.keys(houseComponentThemes),
47
+ ];
48
+ for (const scope of scopes) expect(themes[`${scheme}_${scope}`], scope).toBeDefined();
49
+ });
50
+
51
+ it("hands back the one built set on every call", () => {
52
+ const builder = createThemesBuilder(defaultBaseTheme, defaultAccentTheme);
53
+ expect(builder.themes()).toBe(builder.themes());
54
+ });
55
+
56
+ it("swaps the house component and grandchild themes for the caller's", () => {
57
+ const custom = createThemesBuilder(defaultBaseTheme, defaultAccentTheme, {
58
+ componentThemes: { Card: { template: "surface1" } },
59
+ grandChildrenThemes: {},
60
+ }).themes();
61
+ expect(custom.light_Card).toBeDefined();
62
+ expect(custom.light_Button).toBeUndefined();
63
+ expect(custom.light_alt1).toBeUndefined();
64
+ });
65
+ });
66
+
67
+ describe("createThemesBuilder shadows", () => {
68
+ it("falls back to the house shadow colour per scheme when the base brings none", () => {
69
+ const themes = createThemesBuilder(unshadowed, defaultAccentTheme).themes();
70
+ expect(themes.light.shadowColor).toBe("rgba(0,0,0,0.12)");
71
+ expect(themes.dark.shadowColor).toBe("rgba(0,0,0,0.33)");
72
+ });
73
+
74
+ it("takes the base theme's own shadow ramp and its third step as the shadow colour", () => {
75
+ const ramp = (alpha: number) => ({
76
+ shadow1: `rgba(1,2,3,${alpha})`,
77
+ shadow2: `rgba(1,2,3,${alpha})`,
78
+ shadow3: `rgba(9,9,9,${alpha})`,
79
+ shadow4: `rgba(1,2,3,${alpha})`,
80
+ shadow5: `rgba(1,2,3,${alpha})`,
81
+ shadow6: `rgba(1,2,3,${alpha})`,
82
+ });
83
+ const themes = createThemesBuilder(
84
+ { ...defaultBaseTheme, lightShadows: ramp(0.1), darkShadows: ramp(0.5) },
85
+ defaultAccentTheme,
86
+ ).themes();
87
+ expect(themes.light).toMatchObject({
88
+ shadow1: "rgba(1,2,3,0.1)",
89
+ shadowColor: "rgba(9,9,9,0.1)",
90
+ });
91
+ expect(themes.dark).toMatchObject({
92
+ shadow1: "rgba(1,2,3,0.5)",
93
+ shadowColor: "rgba(9,9,9,0.5)",
94
+ });
95
+ });
96
+ });
97
+
98
+ describe("createThemesBuilder getTheme", () => {
99
+ const getTheme = ({ name }: GetThemeProps) => ({
100
+ marker: name,
101
+ background: undefined,
102
+ borderColor: null,
103
+ });
104
+
105
+ it("adds what getTheme returns without letting a nullish entry erase the template", () => {
106
+ const plain = createThemesBuilder(defaultBaseTheme, defaultAccentTheme).themes();
107
+ const themes = createThemesBuilder(defaultBaseTheme, defaultAccentTheme, {
108
+ getTheme,
109
+ }).themes();
110
+ expect(themes.light.marker).toBe("light");
111
+ expect(themes.light_Button.marker).toBe("light_Button");
112
+ for (const name of ["light", "dark", "light_Button", "dark_Card"]) {
113
+ expect(themes[name].background, name).toBe(plain[name].background);
114
+ expect(themes[name].borderColor, name).toBe(plain[name].borderColor);
115
+ }
116
+ });
117
+ });
118
+
119
+ describe("createThemesBuilder addTheme", () => {
120
+ it("builds a light and a dark scheme of a named accent from its palettes", () => {
121
+ const builder = createThemesBuilder(unshadowed, defaultAccentTheme);
122
+ const added = builder.addTheme("brand", {
123
+ lightPalette: grey,
124
+ darkPalette: [...grey].reverse(),
125
+ });
126
+ expect(Object.keys(added).sort()).toEqual(["dark_brand", "light_brand"]);
127
+ expect(added.light_brand.color1).toBe(hsla(0));
128
+ expect(added.light_brand.color12).toBe(hsla(100));
129
+ expect(added.dark_brand.color1).toBe(hsla(100));
130
+ expect(added.dark_brand.color12).toBe(hsla(0));
131
+ expect(builder.themes().light_brand).toBeUndefined();
132
+ });
133
+
134
+ it("runs the builder's getTheme over an added theme too", () => {
135
+ const added = createThemesBuilder(defaultBaseTheme, defaultAccentTheme, {
136
+ getTheme: ({ name }) => ({ marker: name, background: undefined }),
137
+ }).addTheme("brand", { lightPalette: grey, darkPalette: grey });
138
+ expect(added.light_brand.marker).toBe("light_accent");
139
+ expect(added.light_brand.background).toBe(hsla(7));
140
+ });
141
+ });