@multiplatform.one/theme 7.20.0 → 7.22.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/theme",
3
- "version": "7.20.0",
3
+ "version": "7.22.0",
4
4
  "description": "Tamagui theme system for multiplatform.one",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -44,8 +44,8 @@
44
44
  "access": "public"
45
45
  },
46
46
  "dependencies": {
47
- "@multiplatform.one/platform": "7.20.0",
48
- "@multiplatform.one/store": "7.20.0",
47
+ "@multiplatform.one/platform": "7.22.0",
48
+ "@multiplatform.one/store": "7.22.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.20.0",
61
- "@multiplatform.one/test-utils": "7.20.0",
60
+ "@multiplatform.one/config": "7.22.0",
61
+ "@multiplatform.one/test-utils": "7.22.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",
@@ -0,0 +1,107 @@
1
+ /**
2
+ * MPO-95, ruled C by Clay 2026-09-26: mpo's neutral ramp is its own hue-270
3
+ * ramp, signed off as a deliberate divergence from tamagui.dev's achromatic
4
+ * grays. This pins the design (the stops in `base.ts`), what the builder makes
5
+ * of it (it quantizes each stop to integer hsla, so 56.5% renders as 56%), and
6
+ * the file the gallery boards and their verifiers read. Changing the ramp is a
7
+ * new ruling: update the rulebook, the JSON and the gallery token block with it.
8
+ */
9
+
10
+ import { readFileSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { describe, expect, it } from "vitest";
13
+ import { createThemesBuilder } from "../createThemes";
14
+ import { defaultAccentTheme } from "./accent";
15
+ import { defaultBaseTheme } from "./base";
16
+ import { defaultBuilderOptions } from "./builderOptions";
17
+
18
+ const signedOffLightness = {
19
+ light: [99.5, 98.2, 95.2, 92.5, 90, 87, 82.5, 75, 56.5, 46, 40, 14],
20
+ dark: [8.7, 10.5, 13.5, 16, 18.5, 21.5, 25.5, 32, 43, 54, 62.5, 94],
21
+ } as const;
22
+
23
+ /**
24
+ * Read off the rendered FileUpload dropzone (Storybook, Chromium, DPR 2) when
25
+ * MPO-95 was filed. These are what a user sees; everything else is derived.
26
+ */
27
+ const measured = [
28
+ ["light", 3, "rgb(243, 242, 243)"],
29
+ ["light", 6, "rgb(222, 220, 224)"],
30
+ ["light", 9, "rgb(143, 136, 150)"],
31
+ ["dark", 9, "rgb(110, 103, 116)"],
32
+ ["light", 12, "rgb(36, 34, 38)"],
33
+ ["dark", 12, "rgb(240, 239, 241)"],
34
+ ] as const;
35
+
36
+ type Scheme = "light" | "dark";
37
+ type Themes = Record<string, Record<string, string>>;
38
+
39
+ const built = createThemesBuilder(
40
+ defaultBaseTheme,
41
+ defaultAccentTheme,
42
+ defaultBuilderOptions,
43
+ ).themes() as Themes;
44
+
45
+ // Vite rewrites import.meta.url to an http URL; resolve from cwd (public/theme).
46
+ const rampFile = JSON.parse(
47
+ readFileSync(join(process.cwd(), "../../docs/design/measured/mpo-neutral-ramp.json"), "utf8"),
48
+ ) as Record<Scheme, string[]>;
49
+
50
+ const OPAQUE_HSL = /^hsla?\(\s*([\d.]+),\s*([\d.]+)%,\s*([\d.]+)%(?:,\s*1)?\s*\)$/;
51
+
52
+ function hslaToRgb(value: string): [number, number, number] {
53
+ const match = value.match(OPAQUE_HSL);
54
+ if (!match) throw new Error(`not an opaque hsl(a) colour: ${value}`);
55
+ const [h, s, l] = [Number(match[1]), Number(match[2]) / 100, Number(match[3]) / 100];
56
+ const chroma = (1 - Math.abs(2 * l - 1)) * s;
57
+ const x = chroma * (1 - Math.abs(((h / 60) % 2) - 1));
58
+ const m = l - chroma / 2;
59
+ const sector = Math.floor(h / 60) % 6;
60
+ const [r, g, b] = [
61
+ [chroma, x, 0],
62
+ [x, chroma, 0],
63
+ [0, chroma, x],
64
+ [0, x, chroma],
65
+ [x, 0, chroma],
66
+ [chroma, 0, x],
67
+ ][sector];
68
+ return [r, g, b].map((channel) => Math.round((channel + m) * 255)) as [number, number, number];
69
+ }
70
+
71
+ const toHex = (value: string) =>
72
+ `#${hslaToRgb(value)
73
+ .map((channel) => channel.toString(16).padStart(2, "0"))
74
+ .join("")}`;
75
+
76
+ const builtRamp = (scheme: Scheme) =>
77
+ Array.from({ length: 12 }, (_, i) => built[scheme][`color${i + 1}`]);
78
+
79
+ describe("MPO-95: the neutral ramp is mpo's hue-270 ramp", () => {
80
+ it("base.ts declares the signed-off stops: hue 270, 6% saturation, twelve lightnesses", () => {
81
+ expect(defaultBaseTheme.lightPalette).toEqual(
82
+ signedOffLightness.light.map((l) => `hsla(270, 6%, ${l}%, 1)`),
83
+ );
84
+ expect(defaultBaseTheme.darkPalette).toEqual(
85
+ signedOffLightness.dark.map((l) => `hsla(270, 6%, ${l}%, 1)`),
86
+ );
87
+ });
88
+
89
+ for (const [scheme, step, rgb] of measured) {
90
+ it(`${scheme} $color${step} builds to the measured ${rgb}`, () => {
91
+ expect(`rgb(${hslaToRgb(built[scheme][`color${step}`]).join(", ")})`).toBe(rgb);
92
+ });
93
+ }
94
+
95
+ for (const scheme of ["light", "dark"] as const) {
96
+ it(`the built ${scheme} ramp is mpo-neutral-ramp.json, step for step`, () => {
97
+ expect(builtRamp(scheme).map(toHex)).toEqual(rampFile[scheme]);
98
+ });
99
+ }
100
+
101
+ it("the tint survives the builder's rounding: only light $color1 lands on pure white", () => {
102
+ const achromatic = (hex: string) =>
103
+ new Set([hex.slice(1, 3), hex.slice(3, 5), hex.slice(5, 7)]).size === 1;
104
+ expect(rampFile.light.filter(achromatic)).toEqual(["#ffffff"]);
105
+ expect(rampFile.dark.filter(achromatic)).toEqual([]);
106
+ });
107
+ });
@@ -577,6 +577,74 @@ describe("resolveKnobs", () => {
577
577
  expect(knobProps.featureSurface.shadowRadius).toBe(12);
578
578
  expect(knobProps.featureSurface).not.toHaveProperty("borderWidth");
579
579
  });
580
+
581
+ it("featureSurface keeps $10 / $6 at the default knobs and every other stop moves them (MPO-21 GL-59)", () => {
582
+ expect(defaultKnobs.borderRadius).toBe("medium");
583
+ expect(defaultKnobs.space).toBe("medium");
584
+ for (const borderRadius of ["none", "small", "large", "full"] as const) {
585
+ expect(
586
+ resolveKnobs(knobsWith({ borderRadius })).knobProps.featureSurface.borderRadius,
587
+ borderRadius,
588
+ ).not.toBe("$10");
589
+ }
590
+ for (const space of ["small", "large"] as const) {
591
+ expect(resolveKnobs(knobsWith({ space })).knobProps.featureSurface.padding, space).not.toBe(
592
+ "$6",
593
+ );
594
+ }
595
+ });
596
+
597
+ it("featureSurface radius follows the radius knob, one token per stop around $10, uncapped by space", () => {
598
+ const expected = {
599
+ none: "$0",
600
+ small: "$9",
601
+ medium: "$10",
602
+ large: "$11",
603
+ full: "$12",
604
+ } as const;
605
+ for (const [borderRadius, token] of Object.entries(expected)) {
606
+ for (const space of ["small", "medium", "large"] as const) {
607
+ expect(
608
+ resolveKnobs(knobsWith({ borderRadius: borderRadius as Knobs["borderRadius"], space }))
609
+ .knobProps.featureSurface.borderRadius,
610
+ `${borderRadius}/${space}`,
611
+ ).toBe(token);
612
+ }
613
+ }
614
+ });
615
+
616
+ it("featureSurface padding follows the space knob, one token per stop around $6", () => {
617
+ const expected = { small: "$5", medium: "$6", large: "$7" } as const;
618
+ for (const [space, token] of Object.entries(expected)) {
619
+ for (const borderRadius of ["none", "small", "medium", "large", "full"] as const) {
620
+ expect(
621
+ resolveKnobs(knobsWith({ borderRadius, space: space as Knobs["space"] })).knobProps
622
+ .featureSurface.padding,
623
+ `${borderRadius}/${space}`,
624
+ ).toBe(token);
625
+ }
626
+ }
627
+ });
628
+
629
+ it("compact density steps featureSurface padding through the space knob", () => {
630
+ const { knobProps } = resolveKnobs(applyDensity(knobsWith({ density: "compact" })));
631
+ expect(knobProps.featureSurface.padding).toBe("$5");
632
+ expect(knobProps.featureSurface.borderRadius).toBe("$10");
633
+ });
634
+
635
+ it("featureSurface never clips a child at its inset: padding >= radius x (1 - 1/sqrt 2) at every stop pair", () => {
636
+ for (const borderRadius of ["none", "small", "medium", "large", "full"] as const) {
637
+ for (const space of ["small", "medium", "large"] as const) {
638
+ const { featureSurface } = resolveKnobs(knobsWith({ borderRadius, space })).knobProps;
639
+ const radiusPx = (tokens.radius as any)[featureSurface.borderRadius.slice(1)]
640
+ .val as number;
641
+ const paddingPx = (tokens.space as any)[featureSurface.padding.slice(1)].val as number;
642
+ expect(paddingPx, `${borderRadius}/${space}`).toBeGreaterThanOrEqual(
643
+ radiusPx * (1 - Math.SQRT1_2),
644
+ );
645
+ }
646
+ }
647
+ });
580
648
  });
581
649
 
582
650
  // ── container radius cap (Axiom 1 CLIP) ──────────────────
@@ -695,7 +763,7 @@ describe("resolveKnobs", () => {
695
763
  }
696
764
  });
697
765
 
698
- it("smooth × none: nothing to smooth except the fixed-radius featureSurface", () => {
766
+ it("smooth × none: nothing to smooth, featureSurface included", () => {
699
767
  const { knobProps } = resolveKnobs(
700
768
  knobsWith({ cornerSmoothing: "smooth", borderRadius: "none" }),
701
769
  );
@@ -705,9 +773,7 @@ describe("resolveKnobs", () => {
705
773
  expect(knobProps.containerRadius).not.toHaveProperty("className");
706
774
  expect(knobProps.cardSurface).not.toHaveProperty("className");
707
775
  expect(knobProps.elevatedSurface).not.toHaveProperty("className");
708
- // featureSurface's radius is fixed ($10, knob-independent) — its arc
709
- // exists at every knob value, so it smooths.
710
- expect(knobProps.featureSurface.className).toBe(cornerSmoothClassName);
776
+ expect(knobProps.featureSurface).not.toHaveProperty("className");
711
777
  });
712
778
 
713
779
  it("smooth × full: pill/circle identity fragments ignore smoothing (R-BINARY/R-PILL)", () => {
@@ -114,6 +114,24 @@ const panelPaddingMap = {
114
114
  large: "$6",
115
115
  } as const;
116
116
 
117
+ // Feature tier (SF-FEATURE, MPO-21 GL-59 ruling B): the Bento recipe's $10
118
+ // radius and $6 inset at the default stops, one token per stop either side.
119
+ // Uncapped by padding: the tightest pair (full 50 over small 24) still keeps
120
+ // a child corner at the inset inside the arc (padding >= radius x 0.29).
121
+ const featureRadiusMap = {
122
+ none: "$0",
123
+ small: "$9",
124
+ medium: "$10",
125
+ large: "$11",
126
+ full: "$12",
127
+ } as const;
128
+
129
+ const featurePaddingMap = {
130
+ small: "$5",
131
+ medium: "$6",
132
+ large: "$7",
133
+ } as const;
134
+
117
135
  // ── Page-title step (D-11 hero-H1 dial) ───────────────────────
118
136
  // The one canonical definition of how big a page title is. On the heading
119
137
  // scale (1.4x Inter, defaults/fonts.ts) $8 = 32px and $10 = 64px, so
@@ -590,8 +608,7 @@ export function resolveKnobs(
590
608
  // Capped container fragments stay plain arcs at every knob (the Axiom 1
591
609
  // cap = padding px, which superellipse corners under-fill, so the cap
592
610
  // remains a safe over-approximation) and smooth whenever a nonzero arc
593
- // exists. featureSurface's radius is fixed ($10, knob-independent), so it
594
- // smooths whenever the knob says smooth.
611
+ // exists. featureSurface smooths on the same rule: `none` has no arc.
595
612
  const smoothing = cornerSmoothingStyle[knobs.cornerSmoothing ?? "round"];
596
613
  const scaleSmoothing =
597
614
  knobs.borderRadius === "none" || knobs.borderRadius === "full" ? undefined : smoothing;
@@ -704,15 +721,15 @@ export function resolveKnobs(
704
721
  },
705
722
  featureSurface: {
706
723
  backgroundColor: "$background",
707
- borderRadius: "$10",
708
- padding: "$6",
724
+ borderRadius: featureRadiusMap[knobs.borderRadius],
725
+ padding: featurePaddingMap[knobs.space],
709
726
  // Bento marketing/auth card shadow: ~0px 9px 12px rgba(0,0,0,0.02) —
710
727
  // softer/wider than any elevationMap token, so explicit shadow props.
711
728
  shadowColor: "rgba(0,0,0,0.02)",
712
729
  shadowOffset: { width: 0, height: 9 },
713
730
  shadowOpacity: 1,
714
731
  shadowRadius: 12,
715
- ...smoothing,
732
+ ...containerSmoothing,
716
733
  },
717
734
  inputSurface: { borderWidth: borderWidthValue },
718
735
  panelPadding: { padding: paddingToken },
@@ -1 +1 @@
1
- {"version":3,"file":"resolveKnobs.d.ts","sourceRoot":"","sources":["../../src/theme/resolveKnobs.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAkB,MAAM,SAAS,CAAC;AAU9D,OAAO,KAAK,EAA8B,KAAK,EAAE,cAAc,EAAc,MAAM,SAAS,CAAC;AAE7F,OAAO,KAAK,EAOV,iBAAiB,EAEjB,aAAa,EAEd,MAAM,WAAW,CAAC;AAKnB;;;GAGG;AACH,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAI/D,CAAC;AAEF,qDAAqD;AACrD,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,KAAK,CAAC,OAAO,CAAC,CAIlE,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,KAAK,EAAE,eAAe,CAAC,EAAE,OAAO,GAAG,KAAK,CAU3E;AAID,eAAO,MAAM,eAAe;;;;;;CAMlB,CAAC;AAqDX;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,cAAc,GAAG;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE,CAErF;AA0BD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,KAAK,CAAC,cAAc,CAAC,EACnC,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,GACpB,MAAM,GAAG,MAAM,CAIjB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,CAAC,cAAc,CAAC,GAAG,SAAS,CAIrF;AAED;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,UAAU,GAAG,YAAY,GAAG,UAAU,CAAC;AAErF,MAAM,WAAW,uBAAuB;IACtC,2BAA2B,EAAE,eAAe,CAAC;IAC7C,kBAAkB,EAAE,KAAK,CAAC,cAAc,CAAC,CAAC;IAC1C,iBAAiB,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;CACnC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,eAAe,EAC1B,WAAW,EAAE,OAAO,EACpB,KAAK,EAAE,MAAM,GACZ,uBAAuB,GAAG,SAAS,CAQrC;AAuFD,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEL,CAAC;AAEjE,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEnB,CAAC;AAE1D,sFAAsF;AACtF,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,CAAC;AA8O3C,wBAAgB,YAAY,CAC1B,KAAK,EAAE,KAAK,EACZ,QAAQ,CAAC,EAAE,iBAAiB,EAC5B,MAAM,GAAE,WAAqB,EAC7B,WAAW,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAChC,aAAa,CAyNf"}
1
+ {"version":3,"file":"resolveKnobs.d.ts","sourceRoot":"","sources":["../../src/theme/resolveKnobs.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAkB,MAAM,SAAS,CAAC;AAU9D,OAAO,KAAK,EAA8B,KAAK,EAAE,cAAc,EAAc,MAAM,SAAS,CAAC;AAE7F,OAAO,KAAK,EAOV,iBAAiB,EAEjB,aAAa,EAEd,MAAM,WAAW,CAAC;AAKnB;;;GAGG;AACH,eAAO,MAAM,cAAc,EAAE,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,KAAK,CAAC,MAAM,CAAC,CAI/D,CAAC;AAEF,qDAAqD;AACrD,eAAO,MAAM,eAAe,EAAE,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,EAAE,KAAK,CAAC,OAAO,CAAC,CAIlE,CAAC;AAEF;;;;;;;GAOG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,KAAK,EAAE,eAAe,CAAC,EAAE,OAAO,GAAG,KAAK,CAU3E;AAID,eAAO,MAAM,eAAe;;;;;;CAMlB,CAAC;AAuEX;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,cAAc,GAAG;IAAE,IAAI,EAAE,cAAc,CAAA;CAAE,CAErF;AA0BD;;;;GAIG;AACH,wBAAgB,kBAAkB,CAChC,YAAY,EAAE,KAAK,CAAC,cAAc,CAAC,EACnC,KAAK,EAAE,KAAK,CAAC,OAAO,CAAC,GACpB,MAAM,GAAG,MAAM,CAIjB;AAED;;;;GAIG;AACH,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,CAAC,cAAc,CAAC,GAAG,SAAS,CAIrF;AAED;;;;;;GAMG;AACH,MAAM,MAAM,eAAe,GAAG,aAAa,GAAG,UAAU,GAAG,YAAY,GAAG,UAAU,CAAC;AAErF,MAAM,WAAW,uBAAuB;IACtC,2BAA2B,EAAE,eAAe,CAAC;IAC7C,kBAAkB,EAAE,KAAK,CAAC,cAAc,CAAC,CAAC;IAC1C,iBAAiB,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;CACnC;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,iBAAiB,CAC/B,SAAS,EAAE,eAAe,EAC1B,WAAW,EAAE,OAAO,EACpB,KAAK,EAAE,MAAM,GACZ,uBAAuB,GAAG,SAAS,CAQrC;AAuFD,eAAO,MAAM,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEL,CAAC;AAEjE,eAAO,MAAM,mBAAmB,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAEnB,CAAC;AAE1D,sFAAsF;AACtF,MAAM,MAAM,WAAW,GAAG,OAAO,GAAG,MAAM,CAAC;AA8O3C,wBAAgB,YAAY,CAC1B,KAAK,EAAE,KAAK,EACZ,QAAQ,CAAC,EAAE,iBAAiB,EAC5B,MAAM,GAAE,WAAqB,EAC7B,WAAW,CAAC,EAAE;IAAE,KAAK,CAAC,EAAE,OAAO,CAAA;CAAE,GAChC,aAAa,CAwNf"}