@multiplatform.one/theme 7.46.0 → 7.47.1

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.
Files changed (86) hide show
  1. package/README.md +33 -0
  2. package/expo.cjs +3 -0
  3. package/package.json +22 -6
  4. package/src/audit/constraintAudit.ts +2 -1
  5. package/src/audit/themeMatrix.ts +1 -0
  6. package/src/expo.spec.ts +83 -0
  7. package/src/expo.ts +57 -0
  8. package/src/expoConfig.spec.ts +67 -0
  9. package/src/figma/figmaTokens.spec.ts +4 -3
  10. package/src/theme/FontKnobStyles.tsx +13 -1
  11. package/src/theme/Heading.tsx +70 -0
  12. package/src/theme/Intent.stories.tsx +2 -2
  13. package/src/theme/Preset.stories.tsx +2 -2
  14. package/src/theme/Preset.tsx +4 -2
  15. package/src/theme/Surface.stories.tsx +2 -1
  16. package/src/theme/Surface.tsx +4 -2
  17. package/src/theme/Text.tsx +152 -0
  18. package/src/theme/ThemeProvider.stories.tsx +2 -1
  19. package/src/theme/Tint.stories.tsx +2 -1
  20. package/src/theme/accentFloor.spec.ts +5 -3
  21. package/src/theme/accentOnSurface.palette.spec.tsx +75 -0
  22. package/src/theme/accentOnSurface.spec.tsx +149 -0
  23. package/src/theme/colorThemeAccent.spec.ts +5 -2
  24. package/src/theme/cookies.spec.ts +2 -2
  25. package/src/theme/createDefaultThemeConfig.spec.ts +2 -2
  26. package/src/theme/createThemes.ts +91 -11
  27. package/src/theme/defaults/builderOptions.ts +18 -0
  28. package/src/theme/devtools/ColorLineVisualizer.tsx +3 -0
  29. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +2 -0
  30. package/src/theme/hairline.native.ts +3 -0
  31. package/src/theme/hairline.ts +9 -0
  32. package/src/theme/hairlineToken.spec.ts +110 -0
  33. package/src/theme/index.ts +4 -0
  34. package/src/theme/knobs.ts +2 -2
  35. package/src/theme/mpo19Proof.spec.ts +4 -1
  36. package/src/theme/overlaySurfaces.tsx +9 -3
  37. package/src/theme/pageGutter.tsx +61 -0
  38. package/src/theme/preset.spec.tsx +29 -1
  39. package/src/theme/presets.spec.ts +1 -1
  40. package/src/theme/presets.ts +2 -2
  41. package/src/theme/ramps.spec.ts +53 -0
  42. package/src/theme/ramps.ts +75 -0
  43. package/src/theme/readableColor.ts +111 -70
  44. package/src/theme/recipes.ts +51 -2
  45. package/src/theme/resolveFontTokenFamily.spec.ts +44 -0
  46. package/src/theme/resolveKnobs.spec.ts +30 -20
  47. package/src/theme/resolveKnobs.ts +87 -20
  48. package/src/theme/selectedState.spec.ts +127 -0
  49. package/types/audit/constraintAudit.d.ts.map +1 -1
  50. package/types/audit/themeMatrix.d.ts.map +1 -1
  51. package/types/expo.d.ts +29 -0
  52. package/types/expo.d.ts.map +1 -0
  53. package/types/theme/FontKnobStyles.d.ts +7 -0
  54. package/types/theme/FontKnobStyles.d.ts.map +1 -1
  55. package/types/theme/Heading.d.ts +10 -0
  56. package/types/theme/Heading.d.ts.map +1 -0
  57. package/types/theme/Preset.d.ts.map +1 -1
  58. package/types/theme/Surface.d.ts +2 -1
  59. package/types/theme/Surface.d.ts.map +1 -1
  60. package/types/theme/Text.d.ts +13 -0
  61. package/types/theme/Text.d.ts.map +1 -0
  62. package/types/theme/colorRules.d.ts +1 -1
  63. package/types/theme/createThemes.d.ts.map +1 -1
  64. package/types/theme/defaults/builderOptions.d.ts.map +1 -1
  65. package/types/theme/defaults/fonts.d.ts +30 -30
  66. package/types/theme/devtools/ColorLineVisualizer.d.ts.map +1 -1
  67. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
  68. package/types/theme/hairline.d.ts +8 -0
  69. package/types/theme/hairline.d.ts.map +1 -1
  70. package/types/theme/hairline.native.d.ts +2 -0
  71. package/types/theme/hairline.native.d.ts.map +1 -1
  72. package/types/theme/index.d.ts +4 -0
  73. package/types/theme/index.d.ts.map +1 -1
  74. package/types/theme/knobs.d.ts +1 -1
  75. package/types/theme/knobs.d.ts.map +1 -1
  76. package/types/theme/overlaySurfaces.d.ts.map +1 -1
  77. package/types/theme/pageGutter.d.ts +41 -0
  78. package/types/theme/pageGutter.d.ts.map +1 -0
  79. package/types/theme/ramps.d.ts +20 -0
  80. package/types/theme/ramps.d.ts.map +1 -0
  81. package/types/theme/readableColor.d.ts +24 -12
  82. package/types/theme/readableColor.d.ts.map +1 -1
  83. package/types/theme/recipes.d.ts +44 -4
  84. package/types/theme/recipes.d.ts.map +1 -1
  85. package/types/theme/resolveKnobs.d.ts +10 -4
  86. package/types/theme/resolveKnobs.d.ts.map +1 -1
package/README.md CHANGED
@@ -89,6 +89,39 @@ is the same filter as a pure function over a built matrix.
89
89
  Full standard: `docs/standards/frontend/knobs-system.md` in the
90
90
  [multiplatform.one repo](https://git.corp.bitspur.com/multiplatform.one/mpo).
91
91
 
92
+ ## Expo launch backgrounds
93
+
94
+ Expo evaluates app configuration in Node before a React provider exists. Use the
95
+ build-time entry to read the same background tokens as the app's initial preset:
96
+
97
+ ```js
98
+ const { createExpoThemeColors } = require("@multiplatform.one/theme/expo");
99
+ const colors = createExpoThemeColors({ preset: "default" });
100
+ const splash = {
101
+ image: "./assets/splash.png",
102
+ resizeMode: "contain",
103
+ ...colors.splash,
104
+ dark: { ...colors.splash.dark, image: "./assets/splash.png" },
105
+ };
106
+ ```
107
+
108
+ Pass `splash` to the `expo-splash-screen` plugin, or to both `ios.splash` and
109
+ `android.splash` when using legacy Expo splash configuration. Top-level
110
+ `splash` alone does not configure the iOS dark appearance. Spread
111
+ `colors.adaptiveIcon` into `android.adaptiveIcon`, alongside the foreground image.
112
+
113
+ Built-in presets are `default`, `bold`, `hero`, `dense` and `roomy`. For custom
114
+ presets, pass `presets: { name: { theme: "themeName" } }` and the actual built
115
+ `themes` table. Each supplied table replaces its default. A blank theme name
116
+ uses `light.background` and `dark.background`; a named theme uses
117
+ `light_themeName.background` and `dark_themeName.background`. Raw strings and
118
+ Tamagui `{ val: string }` tokens are accepted. Unknown presets, missing tokens
119
+ and non-opaque colors fail configuration.
120
+
121
+ Adaptive icons have one background. `adaptiveIconScheme: "dark"` selects the
122
+ dark token; the default is light. No runtime setting can change these baked-in
123
+ assets: changing tokens or the launch preset requires a new native build.
124
+
92
125
  ## License
93
126
 
94
127
  Apache-2.0
package/expo.cjs ADDED
@@ -0,0 +1,3 @@
1
+ const { createJiti } = require("jiti");
2
+
3
+ module.exports = createJiti(__filename)("./src/expo.ts");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@multiplatform.one/theme",
3
- "version": "7.46.0",
3
+ "version": "7.47.1",
4
4
  "description": "Tamagui theme system for multiplatform.one",
5
5
  "keywords": [
6
6
  "multiplatform",
@@ -19,6 +19,7 @@
19
19
  "files": [
20
20
  "src",
21
21
  "!src/**/__snapshots__",
22
+ "expo.cjs",
22
23
  "types",
23
24
  "!types/.tsbuildinfo"
24
25
  ],
@@ -49,32 +50,47 @@
49
50
  "types": "./types/audit/designTokens.d.ts",
50
51
  "default": "./src/audit/designTokens.ts"
51
52
  },
53
+ "./ramps": {
54
+ "source": "./src/theme/ramps.ts",
55
+ "types": "./types/theme/ramps.d.ts",
56
+ "default": "./src/theme/ramps.ts"
57
+ },
52
58
  "./motion": {
53
59
  "source": "./src/theme/motionTiming.ts",
54
60
  "types": "./types/theme/motionTiming.d.ts",
55
61
  "default": "./src/theme/motionTiming.ts"
62
+ },
63
+ "./expo": {
64
+ "source": "./src/expo.ts",
65
+ "types": "./types/expo.d.ts",
66
+ "react-native": "./src/expo.ts",
67
+ "browser": "./src/expo.ts",
68
+ "default": "./expo.cjs"
56
69
  }
57
70
  },
58
71
  "publishConfig": {
59
72
  "access": "public"
60
73
  },
61
74
  "dependencies": {
62
- "@multiplatform.one/platform": "^7.46.0",
63
- "@multiplatform.one/store": "^7.46.0",
75
+ "@multiplatform.one/platform": "^7.47.1",
76
+ "@multiplatform.one/store": "^7.47.1",
64
77
  "@tamagui/animations-react-native": "2.7.6",
65
78
  "@tamagui/colors": "2.7.6",
66
79
  "@tamagui/config": "2.7.6",
67
80
  "@tamagui/get-token": "2.7.6",
81
+ "@tamagui/normalize-css-color": "2.7.6",
68
82
  "@tamagui/shorthands": "2.7.6",
69
83
  "@tamagui/theme-builder": "2.7.6",
70
84
  "@tamagui/themes": "2.7.6",
71
85
  "@tamagui/toast": "2.7.6",
72
86
  "@tamagui/web": "2.7.6",
87
+ "jiti": "^2.7.0",
73
88
  "react-cookie": "^8.1.2"
74
89
  },
75
90
  "devDependencies": {
76
- "@multiplatform.one/config": "7.46.0",
77
- "@multiplatform.one/test-utils": "7.46.0",
91
+ "@expo/prebuild-config": "55.0.18",
92
+ "@multiplatform.one/config": "7.47.1",
93
+ "@multiplatform.one/test-utils": "7.47.1",
78
94
  "@tamagui/animations-css": "2.7.6",
79
95
  "@tamagui/animations-reanimated": "2.7.6",
80
96
  "@tamagui/font-inter": "2.7.6",
@@ -112,6 +128,6 @@
112
128
  "scripts": {
113
129
  "test": "vitest run --coverage --coverage.provider=v8 --coverage.reporter=text --coverage.reporter=lcov --coverage.reporter=html",
114
130
  "typecheck": "tsgo --noEmit",
115
- "build": "rm -rf types 2>/dev/null && tsc -p tsconfig.build.json"
131
+ "build": "tsc -p tsconfig.build.json"
116
132
  }
117
133
  }
@@ -321,6 +321,7 @@ function reverseMapFontFamily(computed: string): { knob: string; offScale: boole
321
321
  * Returns an AuditReport object describing all structural CSS violations.
322
322
  */
323
323
  export function runConstraintAudit(rootSelector?: string): AuditReport {
324
+ if (typeof document === "undefined") throw new Error("runConstraintAudit needs a DOM");
324
325
  // ── Inline token definitions (must be self-contained for page.evaluate) ──
325
326
  // The page's published scale wins; the second argument is the default
326
327
  // scale's value, for pages that publish none.
@@ -709,7 +710,7 @@ export function runConstraintAudit(rootSelector?: string): AuditReport {
709
710
  const scope = rootSelector ? document.querySelector(rootSelector) : document;
710
711
  if (!scope) throw new Error(`Constraint audit scope not found: ${rootSelector}`);
711
712
  const allElements = [
712
- ...(scope instanceof Element ? [scope] : []),
713
+ ...(typeof Element !== "undefined" && scope instanceof Element ? [scope] : []),
713
714
  ...Array.from(scope.querySelectorAll("*")),
714
715
  ];
715
716
  const candidates = allElements.filter((el) => {
@@ -256,6 +256,7 @@ export interface TextContrastResult {
256
256
  * `page.evaluate(captureMatrixSnapshot)`.
257
257
  */
258
258
  export function captureMatrixSnapshot(): MatrixSnapshot {
259
+ if (typeof document === "undefined") throw new Error("captureMatrixSnapshot needs a DOM");
259
260
  const SKIP_TAGS = new Set([
260
261
  "script",
261
262
  "style",
@@ -0,0 +1,83 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { createExpoThemeColors } from "./expo";
3
+ import { createThemesBuilder } from "./theme/createThemes";
4
+ import { defaultBaseTheme } from "./theme/defaults/base";
5
+ import { defaultAccentTheme } from "./theme/defaults/accent";
6
+ import { defaultBuilderOptions } from "./theme/defaults/builderOptions";
7
+ import * as presets from "./theme/presets";
8
+ import { normalizeToHex } from "./theme/colorRules";
9
+
10
+ const themes = createThemesBuilder(
11
+ defaultBaseTheme,
12
+ defaultAccentTheme,
13
+ defaultBuilderOptions,
14
+ ).themes();
15
+
16
+ describe("Expo theme colors", () => {
17
+ it.each(["purple", "blue", "orange", "gray"])("supports the app preset hue %s", (theme) => {
18
+ const result = createExpoThemeColors({ preset: "app", presets: { app: { theme } } });
19
+ expect(result.splash.backgroundColor).toBe(normalizeToHex(themes[`light_${theme}`].background));
20
+ expect(result.splash.dark.backgroundColor).toBe(
21
+ normalizeToHex(themes[`dark_${theme}`].background),
22
+ );
23
+ });
24
+
25
+ it("rejects an invalid adaptive-icon scheme from JavaScript configs", () => {
26
+ expect(() =>
27
+ createExpoThemeColors({
28
+ adaptiveIconScheme: "system" as "light",
29
+ }),
30
+ ).toThrow(/adaptiveIconScheme/);
31
+ });
32
+
33
+ it.each(Object.entries(presets))("resolves both background tokens for %s", (name, preset) => {
34
+ const presetName = name.replace(/Preset$/, "");
35
+ const suffix = preset.theme ? `_${preset.theme}` : "";
36
+ const result = createExpoThemeColors({ preset: presetName });
37
+ expect(result.splash.backgroundColor).toBe(normalizeToHex(themes[`light${suffix}`].background));
38
+ expect(result.splash.dark.backgroundColor).toBe(
39
+ normalizeToHex(themes[`dark${suffix}`].background),
40
+ );
41
+ expect(result.adaptiveIcon.backgroundColor).toBe(result.splash.backgroundColor);
42
+ });
43
+
44
+ it("reads changed custom tokens on every call", () => {
45
+ const custom = {
46
+ light_brand: { background: "rgb(240, 230, 220)" },
47
+ dark_brand: { background: { val: "hsl(210, 50%, 20%)" } },
48
+ };
49
+ const options = {
50
+ preset: "brand",
51
+ presets: { brand: { theme: "brand" } },
52
+ themes: custom,
53
+ };
54
+ expect(createExpoThemeColors(options).splash).toEqual({
55
+ backgroundColor: "#f0e6dc",
56
+ dark: { backgroundColor: "#19334d" },
57
+ });
58
+ custom.light_brand.background = "#abcdef";
59
+ expect(createExpoThemeColors(options).splash.backgroundColor).toBe("#abcdef");
60
+ custom.dark_brand.background.val = "#123456";
61
+ expect(createExpoThemeColors({ ...options, adaptiveIconScheme: "dark" }).adaptiveIcon).toEqual({
62
+ backgroundColor: "#123456",
63
+ });
64
+ });
65
+
66
+ it("rejects an unknown preset or missing scheme instead of baking in a fallback", () => {
67
+ expect(() => createExpoThemeColors({ preset: "typo" })).toThrow(/preset.*typo/i);
68
+ expect(() => createExpoThemeColors({ themes: { light: { background: "#fff" } } })).toThrow(
69
+ /dark.*background/i,
70
+ );
71
+ });
72
+
73
+ it.each(["transparent", "rgba(1, 2, 3, 0.5)", "var(--background)", "$color2", "#xyz"])(
74
+ "rejects an unresolved or non-opaque background: %s",
75
+ (background) => {
76
+ expect(() =>
77
+ createExpoThemeColors({
78
+ themes: { light: { background }, dark: { background: "#000" } },
79
+ }),
80
+ ).toThrow(/light.*background/i);
81
+ },
82
+ );
83
+ });
package/src/expo.ts ADDED
@@ -0,0 +1,57 @@
1
+ import { normalizeCSSColor } from "@tamagui/normalize-css-color";
2
+ import { createThemesBuilder } from "./theme/createThemes";
3
+ import { defaultAccentTheme } from "./theme/defaults/accent";
4
+ import { defaultBaseTheme } from "./theme/defaults/base";
5
+ import { defaultBuilderOptions } from "./theme/defaults/builderOptions";
6
+ import { boldPreset, defaultPreset, densePreset, heroPreset, roomyPreset } from "./theme/presets";
7
+
8
+ export { createThemesBuilder, defaultBaseTheme, defaultAccentTheme, defaultBuilderOptions };
9
+
10
+ export interface ExpoThemeColorOptions {
11
+ preset?: string;
12
+ presets?: Record<string, { theme: string }>;
13
+ themes?: Record<string, { background?: string | { val: string } }>;
14
+ adaptiveIconScheme?: "light" | "dark";
15
+ }
16
+
17
+ const builtinPresets = {
18
+ default: defaultPreset,
19
+ bold: boldPreset,
20
+ dense: densePreset,
21
+ hero: heroPreset,
22
+ roomy: roomyPreset,
23
+ };
24
+
25
+ export function createExpoThemeColors({
26
+ preset = "default",
27
+ presets = builtinPresets,
28
+ themes = createThemesBuilder(
29
+ defaultBaseTheme,
30
+ defaultAccentTheme,
31
+ defaultBuilderOptions,
32
+ ).themes(),
33
+ adaptiveIconScheme = "light",
34
+ }: ExpoThemeColorOptions = {}) {
35
+ if (adaptiveIconScheme !== "light" && adaptiveIconScheme !== "dark") {
36
+ throw new Error('Expo adaptiveIconScheme must be "light" or "dark".');
37
+ }
38
+ const selected = Object.hasOwn(presets, preset) ? presets[preset] : undefined;
39
+ if (!selected) throw new Error(`Unknown Expo theme preset "${preset}".`);
40
+ const suffix = selected.theme ? `_${selected.theme}` : "";
41
+ const background = (scheme: "light" | "dark") => {
42
+ const name = `${scheme}${suffix}`;
43
+ const token = themes[name]?.background;
44
+ const value = typeof token === "string" ? token : token?.val;
45
+ const color = typeof value === "string" ? normalizeCSSColor(value) : null;
46
+ if (color == null || (color & 0xff) !== 0xff) {
47
+ throw new Error(`Expo theme "${name}" needs an opaque, resolved background token.`);
48
+ }
49
+ return `#${(color >>> 8).toString(16).padStart(6, "0")}`;
50
+ };
51
+ const light = background("light");
52
+ const dark = background("dark");
53
+ return {
54
+ splash: { backgroundColor: light, dark: { backgroundColor: dark } },
55
+ adaptiveIcon: { backgroundColor: adaptiveIconScheme === "dark" ? dark : light },
56
+ };
57
+ }
@@ -0,0 +1,67 @@
1
+ import { createRequire } from "node:module";
2
+ import { execFileSync } from "node:child_process";
3
+ import { readFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { runInNewContext } from "node:vm";
6
+ import { describe, expect, it } from "vitest";
7
+ import { createExpoThemeColors } from "./expo";
8
+
9
+ const require = createRequire(import.meta.url);
10
+ const {
11
+ getIosSplashConfig,
12
+ } = require("@expo/prebuild-config/build/plugins/unversioned/expo-splash-screen/getIosSplashConfig");
13
+ const {
14
+ getAndroidSplashConfig,
15
+ getAndroidDarkSplashConfig,
16
+ } = require("@expo/prebuild-config/build/plugins/unversioned/expo-splash-screen/getAndroidSplashConfig");
17
+
18
+ it("loads the published CommonJS entry in plain Node without React", () => {
19
+ const result = JSON.parse(
20
+ execFileSync(
21
+ process.execPath,
22
+ [
23
+ "-e",
24
+ `
25
+ const { createExpoThemeColors } = require("@multiplatform.one/theme/expo");
26
+ const colors = createExpoThemeColors();
27
+ const runtime = Object.keys(require.cache).filter(path =>
28
+ /node_modules\\/(react|react-native|tamagui|@tamagui\\/web)\\//.test(path));
29
+ process.stdout.write(JSON.stringify({ colors, runtime }));
30
+ `,
31
+ ],
32
+ { cwd: resolve(__dirname, ".."), encoding: "utf8" },
33
+ ),
34
+ );
35
+ expect(result.colors).toEqual(createExpoThemeColors());
36
+ expect(result.runtime).toEqual([]);
37
+ });
38
+
39
+ describe.each(["one", "storybook-expo"])("%s Expo config", (app) => {
40
+ it("passes light/dark tokens and the original image to native prebuild", () => {
41
+ const filename = resolve(__dirname, "../../../apps", app, "app.config.js");
42
+ const appRequire = createRequire(filename);
43
+ const module = { exports: {} as { expo: Record<string, any> } };
44
+ runInNewContext(readFileSync(filename, "utf8"), {
45
+ module,
46
+ __dirname: resolve(filename, ".."),
47
+ process: { env: {} },
48
+ require: (name: string) => (name === "dotenv" ? { config() {} } : appRequire(name)),
49
+ });
50
+ const { expo } = module.exports;
51
+ const colors = createExpoThemeColors();
52
+ const ios = getIosSplashConfig(expo);
53
+ const android = getAndroidSplashConfig(expo);
54
+ const androidDark = getAndroidDarkSplashConfig(expo);
55
+ expect(ios.backgroundColor).toBe(colors.splash.backgroundColor);
56
+ expect(ios.dark.backgroundColor).toBe(colors.splash.dark.backgroundColor);
57
+ expect(android.backgroundColor).toBe(colors.splash.backgroundColor);
58
+ expect(androidDark.backgroundColor).toBe(colors.splash.dark.backgroundColor);
59
+ expect(ios.image).toBe("./assets/splash.png");
60
+ expect(android.image).toBe(ios.image);
61
+ expect(androidDark.image).toBe(ios.image);
62
+ expect(expo.android.adaptiveIcon).toEqual({
63
+ foregroundImage: "./assets/adaptive-icon.png",
64
+ ...colors.adaptiveIcon,
65
+ });
66
+ });
67
+ });
@@ -6,6 +6,7 @@ import { normalizeToHex } from "../theme/colorRules";
6
6
  import { defaultAccentTheme } from "../theme/defaults/accent";
7
7
  import { defaultBaseTheme } from "../theme/defaults/base";
8
8
  import { interTrackingPx } from "../theme/defaults/fonts";
9
+ import { hairlineBorderWidth } from "../theme/hairline";
9
10
  import { FIGMA_THEME_MODES, buildFigmaTokens, figmaTokenName } from "./figmaTokens";
10
11
 
11
12
  const fontFamilies = {
@@ -148,9 +149,9 @@ describe("buildFigmaTokens", () => {
148
149
  expect(result.sizing["space-1-5"]).toBe((tokens.space as Record<string, number>)["$1.5"]);
149
150
  });
150
151
 
151
- it("exports knob-shaped borderWidth values including the 0.5 hairline", () => {
152
+ it("exports knob-shaped borderWidth values with the web hairline token (LC-148)", () => {
152
153
  expect(result.sizing["borderWidth-none"]).toBe(0);
153
- expect(result.sizing["borderWidth-small"]).toBe(0.5);
154
+ expect(result.sizing["borderWidth-small"]).toBe(hairlineBorderWidth);
154
155
  expect(result.sizing["borderWidth-medium"]).toBe(1);
155
156
  expect(result.sizing["borderWidth-large"]).toBe(2);
156
157
  });
@@ -174,7 +175,7 @@ describe("buildFigmaTokens", () => {
174
175
  it("exports borderWidth knob modes", () => {
175
176
  expect(result.knobs.borderWidth.modes).toEqual({
176
177
  none: 0,
177
- small: 0.5,
178
+ small: hairlineBorderWidth,
178
179
  medium: 1,
179
180
  large: 2,
180
181
  });
@@ -76,14 +76,26 @@ export interface FontScopeTables {
76
76
  * `--f-family` — live SHC console had the style tag and skipped the family).
77
77
  */
78
78
  export function getConfiguredFontFamily(category: FontCategory): string | undefined {
79
+ return resolveFontTokenFamily(`$${category}`);
80
+ }
81
+
82
+ /**
83
+ * The family stack a resolved font token names in the Tamagui config, for
84
+ * text no Tamagui font reaches: `resolveFontTokenFamily(knobProps.body.fontFamily)`
85
+ * hands a mermaid diagram's SVG labels the face the bodyFont knob picked.
86
+ * Undefined outside a configured runtime or for a token the config lacks.
87
+ */
88
+ export function resolveFontTokenFamily(token: unknown): string | undefined {
89
+ if (typeof token !== "string" || !token) return undefined;
79
90
  let config: ReturnType<typeof getConfig> | undefined;
80
91
  try {
81
92
  config = getConfig();
82
93
  } catch {
83
94
  return undefined;
84
95
  }
96
+ const key = token.startsWith("$") ? token.slice(1) : token;
85
97
  const fontsParsed = (config as { fontsParsed?: Record<string, unknown> }).fontsParsed;
86
- const font = (fontsParsed?.[`$${category}`] ?? config.fonts?.[category]) as
98
+ const font = (fontsParsed?.[`$${key}`] ?? config.fonts?.[key]) as
87
99
  | { family?: string | { val?: string } }
88
100
  | undefined;
89
101
  const family = font?.family;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Every heading takes headingFont on every platform. Regular keeps the heading
3
+ * weight table; bold writes 700. Consumer weight wins and rides last.
4
+ * H1 alone takes pageTitle: moderate $10 (40 px), display $12 (52 px), one
5
+ * token less below $sm (LC-143).
6
+ */
7
+ import { useResolvedKnobs } from "./useResolvedKnobs";
8
+ import {
9
+ H1 as RawH1,
10
+ H2 as RawH2,
11
+ H3 as RawH3,
12
+ H4 as RawH4,
13
+ H5 as RawH5,
14
+ H6 as RawH6,
15
+ Heading as RawHeading,
16
+ type HeadingProps,
17
+ } from "tamagui";
18
+
19
+ export type { HeadingProps };
20
+
21
+ type Weight = HeadingProps["fontWeight"];
22
+
23
+ function useHeadingVoice(fontWeight: Weight) {
24
+ const { knobProps } = useResolvedKnobs();
25
+ const { fontWeight: knobWeight, ...heading } = knobProps.heading;
26
+ const resolvedWeight = fontWeight ?? (knobWeight as Weight);
27
+ return {
28
+ heading,
29
+ pageTitle: knobProps.pageTitle,
30
+ weight: resolvedWeight === undefined ? {} : { fontWeight: resolvedWeight },
31
+ };
32
+ }
33
+
34
+ export function Heading({ fontWeight, ...props }: HeadingProps) {
35
+ const { heading, weight } = useHeadingVoice(fontWeight);
36
+ return <RawHeading {...heading} {...props} {...weight} />;
37
+ }
38
+
39
+ export function H1({ fontWeight, ...props }: HeadingProps) {
40
+ const { heading, pageTitle, weight } = useHeadingVoice(fontWeight);
41
+ // An explicit size ejects the whole step. Spread anyway, the knob's compact
42
+ // `$max-sm` entry would still override that size on a phone.
43
+ const step = props.size === undefined ? pageTitle : null;
44
+ return <RawH1 {...step} {...heading} {...props} {...weight} />;
45
+ }
46
+
47
+ export function H2({ fontWeight, ...props }: HeadingProps) {
48
+ const { heading, weight } = useHeadingVoice(fontWeight);
49
+ return <RawH2 {...heading} {...props} {...weight} />;
50
+ }
51
+
52
+ export function H3({ fontWeight, ...props }: HeadingProps) {
53
+ const { heading, weight } = useHeadingVoice(fontWeight);
54
+ return <RawH3 {...heading} {...props} {...weight} />;
55
+ }
56
+
57
+ export function H4({ fontWeight, ...props }: HeadingProps) {
58
+ const { heading, weight } = useHeadingVoice(fontWeight);
59
+ return <RawH4 {...heading} {...props} {...weight} />;
60
+ }
61
+
62
+ export function H5({ fontWeight, ...props }: HeadingProps) {
63
+ const { heading, weight } = useHeadingVoice(fontWeight);
64
+ return <RawH5 {...heading} {...props} {...weight} />;
65
+ }
66
+
67
+ export function H6({ fontWeight, ...props }: HeadingProps) {
68
+ const { heading, weight } = useHeadingVoice(fontWeight);
69
+ return <RawH6 {...heading} {...props} {...weight} />;
70
+ }
@@ -1,6 +1,7 @@
1
- import { SizableText, XStack, YStack } from "tamagui";
1
+ import { XStack, YStack } from "tamagui";
2
2
  import { Intent, type IntentProps } from "./Intent";
3
3
  import { useIntentContext, useResolvedKnobs } from "./useResolvedKnobs";
4
+ import { SizableText } from "./Text";
4
5
 
5
6
  export default {
6
7
  title: "Theme/Intent",
@@ -31,7 +32,6 @@ function Probe({ label }: { label: string }) {
31
32
  {...knobProps.surface}
32
33
  {...knobProps.borderRadius}
33
34
  backgroundColor={knobProps.outlined ? "transparent" : "$background"}
34
- borderColor="$borderColor"
35
35
  padding="$3"
36
36
  minWidth={140}
37
37
  testID={`intent-probe-${label}`}
@@ -1,6 +1,7 @@
1
- import { SizableText, XStack, YStack } from "tamagui";
1
+ import { XStack, YStack } from "tamagui";
2
2
  import { Preset } from "./Preset";
3
3
  import { useResolvedKnobs } from "./useResolvedKnobs";
4
+ import { SizableText } from "./Text";
4
5
 
5
6
  export default {
6
7
  title: "Theme/Preset",
@@ -28,7 +29,6 @@ function Probe({ label }: { label: string }) {
28
29
  {...knobProps.surface}
29
30
  {...knobProps.borderRadius}
30
31
  backgroundColor={knobProps.outlined ? "transparent" : "$background"}
31
- borderColor="$borderColor"
32
32
  elevation={knobProps.elevation}
33
33
  padding="$3"
34
34
  minWidth={160}
@@ -114,9 +114,11 @@ export function Preset({
114
114
  let element = <PresetContext.Provider value={ctxValue}>{children}</PresetContext.Provider>;
115
115
 
116
116
  // An explicit colour wraps in its Tamagui theme; `""` re-applies the preset's own.
117
+ // A Preset that takes `theme` keeps the wrapper even while it names nothing:
118
+ // adding it when a colour is picked re-parents the subtree and remounts it.
117
119
  const wrap = theme ? theme : theme === "" ? ctxValue.preset.theme : undefined;
118
- if (wrap) {
119
- element = <TamaguiTheme name={wrap}>{element}</TamaguiTheme>;
120
+ if (theme !== undefined) {
121
+ element = <TamaguiTheme name={wrap || undefined}>{element}</TamaguiTheme>;
120
122
  }
121
123
 
122
124
  return element;
@@ -1,6 +1,7 @@
1
- import { SizableText, XStack, YStack } from "tamagui";
1
+ import { XStack, YStack } from "tamagui";
2
2
  import { Surface } from "./Surface";
3
3
  import { useResolvedKnobs } from "./useResolvedKnobs";
4
+ import { SizableText } from "./Text";
4
5
 
5
6
  export default {
6
7
  title: "Theme/Surface",
@@ -1,5 +1,6 @@
1
1
  import type { ReactNode } from "react";
2
2
  import { createStyledContext, type SizeTokens } from "tamagui";
3
+ import { PageGutter } from "./pageGutter";
3
4
  import { SIZE_KEYS } from "./scale/scaleInputs";
4
5
 
5
6
  /** A Tamagui size key ("$3", "$true") or `unset` for no ceiling. */
@@ -92,7 +93,8 @@ export function clampSurfaceSize(requested: SurfaceSize, parent: SurfaceSize): S
92
93
  * Forwards a size token to a subtree: every knob reader under it renders its
93
94
  * controls at `size` unless it asks for a smaller one (LC-78 ceiling).
94
95
  * `painted` marks (true) or resets (false) the painted-ancestor flag; omitted
95
- * inherits it.
96
+ * inherits it. Either way a painted value opens a new surface with its own
97
+ * inset, so the page gutter does not reach inside it (LC-146).
96
98
  *
97
99
  * @example A table cell's editors at the row's control height
98
100
  * <Surface size="$3">{cell}</Surface>
@@ -111,7 +113,7 @@ export function Surface({
111
113
  const nextPainted = painted ?? parent.painted ?? false;
112
114
  return (
113
115
  <SurfaceContext.Provider size={nextSize} painted={nextPainted}>
114
- {children}
116
+ {painted === undefined ? children : <PageGutter inset={null}>{children}</PageGutter>}
115
117
  </SurfaceContext.Provider>
116
118
  );
117
119
  }