@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
@@ -33,6 +33,7 @@ function useDomThemeColors(): string[] {
33
33
  const [colors, setColors] = useState<string[]>(() => fallbackGradient());
34
34
 
35
35
  useEffect(() => {
36
+ if (typeof document === "undefined") return;
36
37
  const readColors = () => {
37
38
  let result = readColorsFromElement(document.documentElement);
38
39
  if (!result && document.body) {
@@ -184,6 +185,8 @@ function createVizStyles(isDark: boolean) {
184
185
  width: "fit-content" as const,
185
186
  fontSize: 11,
186
187
  fontFamily: "system-ui, -apple-system, sans-serif",
188
+ // Not the host page's tracking, which follows the app's bodyFont knob.
189
+ letterSpacing: "normal",
187
190
  color: foreground,
188
191
  },
189
192
  title: {
@@ -237,6 +237,8 @@ function createStyles(isDark: boolean) {
237
237
  return {
238
238
  panel: {
239
239
  fontFamily: "system-ui, -apple-system, sans-serif",
240
+ // Not the host page's tracking, which follows the app's bodyFont knob.
241
+ letterSpacing: "normal",
240
242
  fontSize: 13,
241
243
  color: isDark ? "#e8e8e8" : "#1a1a1a",
242
244
  padding: 12,
@@ -10,6 +10,9 @@ import { StyleSheet } from "react-native";
10
10
  /** Divider line width: one device pixel for the current screen density. */
11
11
  export const hairlineWidth: number = StyleSheet.hairlineWidth;
12
12
 
13
+ /** LC-148: native draws the `small` border stop as a true one-device-pixel hairline. */
14
+ export const hairlineBorderWidth: number = StyleSheet.hairlineWidth;
15
+
13
16
  /** Spreadable divider treatments — see `hairline.ts` for usage. */
14
17
  export const hairline = {
15
18
  /** Standalone horizontal rule as a filled hairline-height line. */
@@ -24,6 +24,15 @@
24
24
  /** Divider line width: half a CSS pixel = one device pixel on 2x displays. */
25
25
  export const hairlineWidth: number = 0.5;
26
26
 
27
+ /**
28
+ * LC-148 HAIRLINE-TOKEN: the width the `borderWidth` knob's `small` stop draws.
29
+ * Blink floors any border between 0 and 1 CSS px to 1px at every device pixel
30
+ * ratio, so on web the stop is 1px and `resolveKnobs` paints it in
31
+ * `$borderColorHairline`, one ramp step toward the page, so `small` still reads
32
+ * finer than `medium`.
33
+ */
34
+ export const hairlineBorderWidth: number = 1;
35
+
27
36
  /**
28
37
  * Spreadable divider treatments. Each entry pairs the 0.5px style with the
29
38
  * fallback class the low-DPI media query targets:
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { normalizeToHex, relativeLuminance } from "./colorRules";
3
+ import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
4
+ import { hairlineBorderWidth } from "./hairline";
5
+ import { defaultKnobs, type Knobs } from "./knobs";
6
+ import { edgeColorAt, resolveKnobs } from "./resolveKnobs";
7
+
8
+ vi.mock("react-native", () => ({ StyleSheet: { hairlineWidth: 1 / 3 } }));
9
+
10
+ const knobs = (overrides: Partial<Knobs>): Knobs => ({ ...defaultKnobs, ...overrides });
11
+ const edges = (overrides: Partial<Knobs>, scheme: "light" | "dark" = "light") => {
12
+ const { knobProps } = resolveKnobs(knobs(overrides), undefined, scheme);
13
+ return {
14
+ borderRadius: knobProps.borderRadius.borderColor,
15
+ surface: knobProps.surface.borderColor,
16
+ controlBorderOutlined: knobProps.controlBorderOutlined.borderColor,
17
+ elevatedSurface: knobProps.elevatedSurface.borderColor,
18
+ };
19
+ };
20
+
21
+ const themes = createDefaultThemeConfig({}).tamagui.themes as unknown as Record<
22
+ string,
23
+ Record<string, { val: string } | undefined>
24
+ >;
25
+ const rampThemes = Object.keys(themes).filter((name) =>
26
+ /^(light|dark)(_(accent|blue|gray|green|orange|pink|purple|red|yellow|warning|error|success))?$/.test(
27
+ name,
28
+ ),
29
+ );
30
+ const lum = (raw: string) => relativeLuminance(normalizeToHex(raw)!);
31
+
32
+ describe("LC-148 HAIRLINE-TOKEN: borderWidth small is the theme's hairline", () => {
33
+ it("web draws the small stop at 1px, because Blink floors sub-pixel borders", () => {
34
+ expect(hairlineBorderWidth).toBe(1);
35
+ expect(resolveKnobs(knobs({ borderWidth: "small" })).knobProps.borderRadius.borderWidth).toBe(
36
+ hairlineBorderWidth,
37
+ );
38
+ });
39
+
40
+ it("native draws the small stop as one device pixel", async () => {
41
+ const native = await import("./hairline.native");
42
+ expect(native.hairlineBorderWidth).toBe(1 / 3);
43
+ expect(native.hairlineBorderWidth).toBeLessThan(1);
44
+ });
45
+
46
+ it("a small edge no finer than medium paints one ramp step toward the page", () => {
47
+ expect(edges({ borderWidth: "small" })).toEqual({
48
+ borderRadius: "$borderColorHairline",
49
+ surface: "$borderColorHairline",
50
+ controlBorderOutlined: "$borderColorHairline",
51
+ elevatedSurface: "$borderColorHairline",
52
+ });
53
+ expect(edges({ borderWidth: "small" }, "dark").elevatedSurface).toBe("$color5");
54
+ expect(edges({ borderWidth: "none", fillStyle: "outlined" }).borderRadius).toBe(
55
+ "$borderColorHairline",
56
+ );
57
+ });
58
+
59
+ it("every other stop keeps the theme's own edge colour", () => {
60
+ for (const borderWidth of ["none", "medium", "large"] as const) {
61
+ expect(edges({ borderWidth })).toEqual({
62
+ borderRadius: "$borderColor",
63
+ surface: "$borderColor",
64
+ controlBorderOutlined: "$borderColor",
65
+ elevatedSurface: "$borderColor",
66
+ });
67
+ expect(edges({ borderWidth }, "dark").elevatedSurface).toBe("$color6");
68
+ }
69
+ });
70
+
71
+ it("a component's own edge colour takes the same step at small, and only there (FZ-7797)", () => {
72
+ expect(edgeColorAt("$borderColor", "small")).toBe("$borderColorHairline");
73
+ expect(edgeColorAt("$color6", "small")).toBe("$color5");
74
+ expect(edgeColorAt("$color8", "small")).toBe("$color7");
75
+ for (const signal of [
76
+ "$color1",
77
+ "$color9",
78
+ "$color11",
79
+ "$red10",
80
+ "$boundaryColor",
81
+ "$borderColorFocus",
82
+ "transparent",
83
+ ])
84
+ expect(edgeColorAt(signal, "small"), signal).toBe(signal);
85
+ for (const stop of ["none", "medium", "large"] as const)
86
+ expect(edgeColorAt("$color6", stop)).toBe("$color6");
87
+ });
88
+
89
+ it("the keepLabel disabled surface edge takes the step too", () => {
90
+ const disabled = (borderWidth: Knobs["borderWidth"]) =>
91
+ resolveKnobs(knobs({ borderWidth, disabledStyle: "keepLabel" })).disabledState;
92
+ expect(disabled("small").surfaceKnobProps.borderColor).toBe("$color5");
93
+ expect(disabled("small").selectedSurfaceKnobProps.borderColor).toBe("$color7");
94
+ expect(disabled("medium").surfaceKnobProps.borderColor).toBe("$color6");
95
+ expect(disabled("medium").selectedSurfaceKnobProps.borderColor).toBe("$color8");
96
+ });
97
+
98
+ it("every ramp theme defines $borderColorHairline one step fainter than $borderColor", () => {
99
+ expect(rampThemes.length).toBeGreaterThanOrEqual(26);
100
+ for (const name of rampThemes) {
101
+ const theme = themes[name]!;
102
+ const ramp = Array.from({ length: 12 }, (_, i) => theme[`color${i + 1}`]?.val);
103
+ const border = theme.borderColor!.val;
104
+ const hairline = theme.borderColorHairline?.val;
105
+ expect(hairline, name).toBe(ramp[ramp.indexOf(border) - 1]);
106
+ const page = lum(theme.color1!.val);
107
+ expect(Math.abs(lum(hairline!) - page), name).toBeLessThan(Math.abs(lum(border) - page));
108
+ }
109
+ });
110
+ });
@@ -16,10 +16,12 @@ export * from "./fontCategoryStacks";
16
16
  export * from "./FontKnobStyles";
17
17
  export * from "./groupGeometry";
18
18
  export * from "./hairline";
19
+ export * from "./Heading";
19
20
  export * from "./Intent";
20
21
  export * from "./intents";
21
22
  export * from "./knobs";
22
23
  export * from "./layoutTokens";
24
+ export * from "./pageGutter";
23
25
  export * from "./Preset";
24
26
  export type { Preset as PresetDefinition, IntentOverride } from "./preset.types";
25
27
  export * from "./PresetContext";
@@ -29,6 +31,7 @@ export * from "./presetStore";
29
31
  export * from "./presetUserSync";
30
32
  export * from "./radiusClass";
31
33
  export * from "./radiusClassTable.generated";
34
+ export * from "./ramps";
32
35
  export * from "./readableColor";
33
36
  export * from "./recipes";
34
37
  export * from "./resolveKnobs";
@@ -39,6 +42,7 @@ export * from "./themeValue";
39
42
  export * from "./sizeRecipes";
40
43
  export * from "./subsetThemes";
41
44
  export * from "./Surface";
45
+ export * from "./Text";
42
46
  export * from "./tileSizes";
43
47
  export * from "./transitionProps";
44
48
 
@@ -77,7 +77,7 @@ export const RequiredMarking = {
77
77
  } as const;
78
78
  export type RequiredMarking = (typeof RequiredMarking)[keyof typeof RequiredMarking];
79
79
 
80
- /** Alternating table row backgrounds. Default: off. */
80
+ /** Alternating table row backgrounds. Default: on (LC-144 TABLE-ZEBRA). */
81
81
  export const TableZebra = {
82
82
  On: "on",
83
83
  Off: "off",
@@ -279,7 +279,7 @@ export const defaultKnobs: Knobs = {
279
279
  animation: "quick",
280
280
  fieldLabelPlacement: "top",
281
281
  requiredMarking: "minority",
282
- tableZebra: "off",
282
+ tableZebra: "on",
283
283
  bulkBarPlacement: "top",
284
284
  selectAllScope: "page",
285
285
  timestampStyle: "absolute",
@@ -33,6 +33,7 @@ import {
33
33
  FOCUS_VISIBLE_RING,
34
34
  ensureFocusVisibleRing,
35
35
  } from "./focusState";
36
+ import { hairlineBorderWidth } from "./hairline";
36
37
  import { defaultKnobs } from "./knobs";
37
38
  import { layoutBreakpoints, OVERLAY_BREAKPOINT } from "./layoutTokens";
38
39
  import { resolveKnobs } from "./resolveKnobs";
@@ -141,7 +142,9 @@ describe("MPO-19 X3 — a filled control's border is transparent, not $borderCol
141
142
 
142
143
  it("the width resolves through the borderWidth knob, not a literal", () => {
143
144
  expect(knobProps({ borderWidth: "none" }).controlBorderFilled.borderWidth).toBe(0);
144
- expect(knobProps({ borderWidth: "small" }).controlBorderFilled.borderWidth).toBe(0.5);
145
+ expect(knobProps({ borderWidth: "small" }).controlBorderFilled.borderWidth).toBe(
146
+ hairlineBorderWidth,
147
+ );
145
148
  expect(knobProps({ borderWidth: "medium" }).controlBorderFilled.borderWidth).toBe(1);
146
149
  expect(knobProps({ borderWidth: "large" }).controlBorderFilled.borderWidth).toBe(2);
147
150
  // Outlined is the one that clamps: the outline IS the affordance, so it
@@ -45,7 +45,13 @@ const overlayFrameFocusStyle = {
45
45
  function isTabbableWithin(node: HTMLElement, container: HTMLElement): boolean {
46
46
  if (node.tabIndex < 0 || node.hidden || node.tagName === "A") return false;
47
47
  if ((node as HTMLInputElement).disabled) return false;
48
- if (node instanceof HTMLInputElement && node.type === "hidden") return false;
48
+ if (
49
+ typeof HTMLInputElement !== "undefined" &&
50
+ node instanceof HTMLInputElement &&
51
+ node.type === "hidden"
52
+ ) {
53
+ return false;
54
+ }
49
55
  if (getComputedStyle(node).visibility === "hidden") return false;
50
56
  for (let el: HTMLElement | null = node; el && el !== container; el = el.parentElement) {
51
57
  if (getComputedStyle(el).display === "none") return false;
@@ -62,7 +68,7 @@ function focusFirstTabbableOnOpen(event: Event): void {
62
68
  if (!first) return;
63
69
  event.preventDefault();
64
70
  first.focus({ preventScroll: true });
65
- if (first instanceof HTMLInputElement) first.select();
71
+ if (typeof HTMLInputElement !== "undefined" && first instanceof HTMLInputElement) first.select();
66
72
  }
67
73
 
68
74
  type OpenAutoFocus = (event: Event) => void;
@@ -93,7 +99,7 @@ function useDialogShowFocus(onOpenAutoFocus: OpenAutoFocus): void {
93
99
  if (!open || !contentId || !isWeb || typeof document === "undefined") return;
94
100
  const content = document.getElementById(contentId);
95
101
  const focused = document.activeElement;
96
- if (!content || !(focused instanceof HTMLElement)) return;
102
+ if (!content || typeof HTMLElement === "undefined" || !(focused instanceof HTMLElement)) return;
97
103
  if (!content.contains(focused) || focused.tabIndex >= 0) return;
98
104
  content.addEventListener(DIALOG_SHOW_FOCUS_EVENT, onOpenAutoFocus, { once: true });
99
105
  content.dispatchEvent(new CustomEvent(DIALOG_SHOW_FOCUS_EVENT, { cancelable: true }));
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Page gutter: the horizontal inset the page shell puts between the screen
3
+ * edge and its content, carried down the tree so content that sits directly
4
+ * on the page line knows how far the edge is.
5
+ *
6
+ * LC-146 PHONE-TABLE-BLEED: below `$sm` a table drops that gutter and runs
7
+ * edge to edge, and its first and last cells carry the gutter as their own
8
+ * inset, so text still starts on the page line (Axiom 16) while rows, stripes
9
+ * and rules reach the screen edge. A surface that pads its own content
10
+ * (a card, a sheet) resets the gutter to null, because a table inside it is
11
+ * not on the page line.
12
+ */
13
+
14
+ import type { ReactNode } from "react";
15
+ import { createContext, useContext } from "react";
16
+ import { useMedia } from "tamagui";
17
+
18
+ const PageGutterContext = createContext<string | null>(null);
19
+
20
+ export interface PageGutterProps {
21
+ /**
22
+ * The space token the host pads its content by (`knobProps.panelPadding.padding`),
23
+ * or null where content is not on the page line.
24
+ */
25
+ inset: string | null;
26
+ children: ReactNode;
27
+ }
28
+
29
+ /** Declares the gutter the surrounding page pads its content by. Renders no DOM. */
30
+ export function PageGutter({ inset, children }: PageGutterProps) {
31
+ return <PageGutterContext.Provider value={inset}>{children}</PageGutterContext.Provider>;
32
+ }
33
+
34
+ /** The nearest page gutter token, or null off the page line. */
35
+ export function usePageGutter(): string | null {
36
+ return useContext(PageGutterContext);
37
+ }
38
+
39
+ /** `$4` → `$-4`. */
40
+ export function negateSpaceToken(token: string): string {
41
+ if (token.startsWith("$-")) return `$${token.slice(2)}`;
42
+ return `$-${token.startsWith("$") ? token.slice(1) : token}`;
43
+ }
44
+
45
+ export interface PhoneTableBleed {
46
+ /** The gutter token the first and last cells take as their inset. */
47
+ inset: string;
48
+ /** Pulls the table's outer frame out to the screen edges. */
49
+ frame: { marginHorizontal: string };
50
+ }
51
+
52
+ /**
53
+ * LC-146: the bleed a table applies below `$sm` when it sits on the page line,
54
+ * else null (wide screens, or no gutter above it).
55
+ */
56
+ export function usePhoneTableBleed(): PhoneTableBleed | null {
57
+ const inset = usePageGutter();
58
+ const { sm } = useMedia();
59
+ if (!inset || sm !== false) return null;
60
+ return { inset, frame: { marginHorizontal: negateSpaceToken(inset) } };
61
+ }
@@ -1,5 +1,6 @@
1
- import { type ReactNode, createElement } from "react";
1
+ import { type ReactNode, createElement, useEffect } from "react";
2
2
  import { renderToString } from "react-dom/server";
3
+ import { render } from "@testing-library/react";
3
4
  import { beforeEach, describe, expect, it, vi } from "vitest";
4
5
 
5
6
  // Mock Tamagui dependencies to avoid needing a full Tamagui config
@@ -233,6 +234,33 @@ describe("Preset component", () => {
233
234
  expect(capture.result?.preset.tints).toEqual(boldPreset.tints);
234
235
  });
235
236
 
237
+ it("keeps its subtree mounted while a picked colour comes and goes", () => {
238
+ let mounts = 0;
239
+ function Probe() {
240
+ useEffect(() => {
241
+ mounts += 1;
242
+ }, []);
243
+ return null;
244
+ }
245
+ const view = render(
246
+ <Preset theme={"" as ThemeName}>
247
+ <Probe />
248
+ </Preset>,
249
+ );
250
+ view.rerender(
251
+ <Preset theme={"green" as ThemeName}>
252
+ <Probe />
253
+ </Preset>,
254
+ );
255
+ view.rerender(
256
+ <Preset theme={"" as ThemeName}>
257
+ <Probe />
258
+ </Preset>,
259
+ );
260
+ expect(mounts).toBe(1);
261
+ view.unmount();
262
+ });
263
+
236
264
  it("does not wrap with Tamagui <Theme /> when theme prop is absent", () => {
237
265
  const capture = createPresetContextCapture();
238
266
 
@@ -23,7 +23,7 @@ import { defaultIntents } from "./intents";
23
23
  const houseKnobDefaults = {
24
24
  fieldLabelPlacement: "top",
25
25
  requiredMarking: "minority",
26
- tableZebra: "off",
26
+ tableZebra: "on",
27
27
  bulkBarPlacement: "top",
28
28
  selectAllScope: "page",
29
29
  timestampStyle: "absolute",
@@ -19,7 +19,7 @@ export const defaultPreset: Preset = {
19
19
  animation: "quick",
20
20
  fieldLabelPlacement: "top",
21
21
  requiredMarking: "minority",
22
- tableZebra: "off",
22
+ tableZebra: "on",
23
23
  bulkBarPlacement: "top",
24
24
  selectAllScope: "page",
25
25
  timestampStyle: "absolute",
@@ -51,7 +51,7 @@ export const boldPreset: Preset = {
51
51
  animation: "bouncy",
52
52
  fieldLabelPlacement: "top",
53
53
  requiredMarking: "minority",
54
- tableZebra: "off",
54
+ tableZebra: "on",
55
55
  bulkBarPlacement: "top",
56
56
  selectAllScope: "page",
57
57
  timestampStyle: "absolute",
@@ -0,0 +1,53 @@
1
+ import { defaultChildrenThemes } from "@tamagui/themes/v5";
2
+ import { describe, expect, it } from "vitest";
3
+ import { createThemesBuilder } from "./createThemes";
4
+ import { defaultAccentTheme } from "./defaults/accent";
5
+ import { defaultBaseTheme } from "./defaults/base";
6
+ import { defaultBuilderOptions } from "./defaults/builderOptions";
7
+ import { isThemeRampHue, themeRampColor, themeRampHues } from "./ramps";
8
+
9
+ const themes = createThemesBuilder(
10
+ defaultBaseTheme,
11
+ defaultAccentTheme,
12
+ defaultBuilderOptions,
13
+ ).themes() as Record<string, Record<string, string>>;
14
+
15
+ const stock = defaultChildrenThemes as unknown as Record<
16
+ string,
17
+ Record<"light" | "dark", Record<string, string>>
18
+ >;
19
+ const steps = Array.from({ length: 12 }, (_, index) => index + 1);
20
+
21
+ describe("LC-147 theme ramps", () => {
22
+ it("names exactly v5's children palettes, less the neutral control grey", () => {
23
+ expect([...themeRampHues].sort()).toEqual(
24
+ Object.keys(defaultChildrenThemes)
25
+ .filter((hue) => hue !== "neutral")
26
+ .sort(),
27
+ );
28
+ });
29
+
30
+ it("resolves every step of every ramp in both base schemes, which every sub-theme inherits", () => {
31
+ const missing: string[] = [];
32
+ for (const name of ["light", "dark"]) {
33
+ const theme = themes[name];
34
+ for (const hue of themeRampHues)
35
+ for (const step of steps)
36
+ if (!theme[`${hue}${step}`]) missing.push(`${name}.$${hue}${step}`);
37
+ }
38
+ expect(missing).toEqual([]);
39
+ });
40
+
41
+ it("hands out the stock step the theme builder splats in", () => {
42
+ for (const hue of themeRampHues)
43
+ for (const scheme of ["light", "dark"] as const)
44
+ for (const step of steps)
45
+ expect(themeRampColor(hue, step as 1, scheme)).toBe(stock[hue][scheme][`${hue}${step}`]);
46
+ });
47
+
48
+ it("rejects a Radix hue no built theme carries", () => {
49
+ expect(isThemeRampHue("green")).toBe(true);
50
+ for (const hue of ["grass", "tomato", "lime", "sand", "neutral", undefined])
51
+ expect(isThemeRampHue(hue)).toBe(false);
52
+ });
53
+ });
@@ -0,0 +1,75 @@
1
+ import {
2
+ blue,
3
+ blueDark,
4
+ gray,
5
+ grayDark,
6
+ green,
7
+ greenDark,
8
+ orange,
9
+ orangeDark,
10
+ pink,
11
+ pinkDark,
12
+ purple,
13
+ purpleDark,
14
+ red,
15
+ redDark,
16
+ teal,
17
+ tealDark,
18
+ yellow,
19
+ yellowDark,
20
+ } from "@tamagui/colors";
21
+
22
+ /**
23
+ * The named colour ramps every built theme carries: `$blue1` through
24
+ * `$blue12` and the rest resolve in every scheme, tint and preset, because
25
+ * the theme builder splats v5's children palettes into each base theme.
26
+ * Data that picks a colour (a category, a type, a facet value) picks one of
27
+ * these. A Radix hue outside the list (`grass`, `tomato`, `lime`) has no
28
+ * token in a built theme and paints nothing (LC-147).
29
+ */
30
+ export const themeRampHues = [
31
+ "gray",
32
+ "blue",
33
+ "red",
34
+ "yellow",
35
+ "green",
36
+ "orange",
37
+ "pink",
38
+ "purple",
39
+ "teal",
40
+ ] as const;
41
+
42
+ export type ThemeRampHue = (typeof themeRampHues)[number];
43
+
44
+ export type ThemeRampStep = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
45
+
46
+ const ramps: Record<ThemeRampHue, { light: Record<string, string>; dark: Record<string, string> }> =
47
+ {
48
+ gray: { light: gray, dark: grayDark },
49
+ blue: { light: blue, dark: blueDark },
50
+ red: { light: red, dark: redDark },
51
+ yellow: { light: yellow, dark: yellowDark },
52
+ green: { light: green, dark: greenDark },
53
+ orange: { light: orange, dark: orangeDark },
54
+ pink: { light: pink, dark: pinkDark },
55
+ purple: { light: purple, dark: purpleDark },
56
+ teal: { light: teal, dark: tealDark },
57
+ };
58
+
59
+ export function isThemeRampHue(value: unknown): value is ThemeRampHue {
60
+ return typeof value === "string" && (themeRampHues as readonly string[]).includes(value);
61
+ }
62
+
63
+ /**
64
+ * A ramp step's stock colour, for a surface no theme runtime reaches (a
65
+ * content script on a third-party page). Inside the provider, paint the
66
+ * `$<hue><step>` token instead: the built theme may lift a step to its
67
+ * contrast floor, and only the token carries that.
68
+ */
69
+ export function themeRampColor(
70
+ hue: ThemeRampHue,
71
+ step: ThemeRampStep,
72
+ scheme: "light" | "dark" = "light",
73
+ ): string {
74
+ return ramps[hue][scheme][`${hue}${step}`];
75
+ }