@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
@@ -0,0 +1,152 @@
1
+ /**
2
+ * House body-text voices for the components catalog — Text, SizableText,
3
+ * Paragraph and Anchor (MPO-20, LC-70 CURATED-REEXPORT-IS-CATALOG).
4
+ *
5
+ * Before this module the barrel re-exported the RAW tamagui typography
6
+ * primitives under bare names, registered as "typography substrate" rows in
7
+ * ./knobImmuneExports.ts. That registry claim was a lie in three parts:
8
+ * FontKnobStyles rescues family/leading/tracking on WEB ONLY (CSS variables),
9
+ * fontWeight and textAccent reach the primitives on NO platform, and on
10
+ * native nothing reaches them at all. AN-8 ("never render app text through
11
+ * bare typography") was unfollowable — there was no house Text to follow it
12
+ * with.
13
+ *
14
+ * ARCHITECTURE (Button/Spinner shadow precedent): the thinnest legal shape
15
+ * is a wrapper that spreads the COMPLETE knob fragments onto the raw
16
+ * primitive and forwards consumer props LAST (STD-EJECT-LAST). The
17
+ * fragments are props, not CSS, so the same code path styles web AND native
18
+ * — the text node itself carries fontFamily/fontWeight/color on every
19
+ * platform (the MPO-44/48 lesson: the frame is not the text node).
20
+ *
21
+ * WEIGHT RIDES LAST (MPO-446). `size` and `fontFamily` are variants on the
22
+ * raw primitive, and tamagui's getFontSized writes the font ramp's weight
23
+ * (`var(--f-weight-N)`) at the position that key holds in the props object.
24
+ * A knob weight spread before a consumer `size` lost to it, and a consumer
25
+ * `fontWeight` sat at the knob's position too, since a spread key keeps its
26
+ * first slot. So the weight is split off the fragment and written as the
27
+ * final prop: the knob's, or the consumer's when one is passed.
28
+ *
29
+ * Knob channels wired here:
30
+ * - bodyFont → knobProps.body.fontFamily (every platform, not just web)
31
+ * - fontWeight → knobProps.body.fontWeight (regular 400 / bold 700 —
32
+ * 500/600 are not label weights; see tamagui-rendered-reference.md §9)
33
+ * - textAccent → knobProps.textAccentColor (high $color / medium+low
34
+ * $color11, the AA legibility floor) on Text/SizableText/Paragraph.
35
+ * - link ink → Anchor paints the accent, as the most chromatic step of the
36
+ * theme's accent ramp that still clears AA on both page surfaces
37
+ * (`useAccentOnSurface`). House Text owns link chrome (Clay 2026-09-26,
38
+ * MPO-21 G11-A); a consumer `color` still ejects.
39
+ *
40
+ * The default is the registered body font's `$true` stop: 15/23 on web,
41
+ * 17/22 on native, with weight from the fontWeight knob. A consumer
42
+ * `fontSize` brings its own step's leading (`$2` sets 13/20, not 13/23); a
43
+ * numeric size on Text keeps the platform's natural leading (Paragraph's own
44
+ * size variant still leads it). SizableText keeps the size-token scale.
45
+ * Consumer size, fontSize and lineHeight still win.
46
+ *
47
+ * ONE HOME (FZ-7739, LC-137): these live in @multiplatform.one/theme, not
48
+ * components, so forms, web3 and table-primitives, which sit below
49
+ * components, render body text through the same voice. On native a raw
50
+ * tamagui text node carries no family and inherits none, so it ignores
51
+ * bodyFont; `mpo-conventions/no-voiceless-text` keeps public/ on these.
52
+ *
53
+ * Raw primitives stay reachable as TamaguiText / TamaguiSizableText /
54
+ * TamaguiParagraph / TamaguiAnchor (components' ./tamagui.ts escape
55
+ * hatches, LC-45).
56
+ */
57
+ import { useAccentOnSurface } from "./readableColor";
58
+ import { useResolvedKnobs } from "./useResolvedKnobs";
59
+ import {
60
+ Anchor as RawAnchor,
61
+ type AnchorProps,
62
+ Paragraph as RawParagraph,
63
+ type ParagraphProps as RawParagraphProps,
64
+ SizableText as RawSizableText,
65
+ type SizableTextProps,
66
+ Text as RawText,
67
+ type TextProps as RawTextProps,
68
+ } from "tamagui";
69
+
70
+ export type { AnchorProps, SizableTextProps };
71
+
72
+ interface TypographyProps {
73
+ variant?: "body" | "mutedMono";
74
+ tabular?: boolean;
75
+ }
76
+
77
+ export type TextProps = RawTextProps & TypographyProps;
78
+ export type ParagraphProps = RawParagraphProps & TypographyProps;
79
+
80
+ type Weight = TextProps["fontWeight"];
81
+
82
+ /** The leading that matches a consumer font size: its own step, else none. */
83
+ function leadingFor(fontSize: TextProps["fontSize"]): TextProps["lineHeight"] {
84
+ if (fontSize === undefined) return "$true";
85
+ return typeof fontSize === "string" && fontSize.startsWith("$")
86
+ ? (fontSize as TextProps["lineHeight"])
87
+ : undefined;
88
+ }
89
+
90
+ /** The body fragment without its weight, and the weight a voice writes last. */
91
+ function useBodyVoice(fontWeight: Weight, { variant, tabular }: TypographyProps = {}) {
92
+ const { knobProps } = useResolvedKnobs();
93
+ const { fontWeight: knobWeight, ...body } = knobProps.body;
94
+ return {
95
+ body,
96
+ variantProps: variant === "mutedMono" ? knobProps.typography.mutedMono : undefined,
97
+ digitProps: tabular ? knobProps.typography.tabular : undefined,
98
+ color: knobProps.textAccentColor,
99
+ weight: fontWeight ?? (knobWeight as Weight),
100
+ };
101
+ }
102
+
103
+ export function Text({ fontWeight, variant, tabular, style, ...props }: TextProps) {
104
+ const { body, color, weight, variantProps, digitProps } = useBodyVoice(fontWeight, {
105
+ variant,
106
+ tabular: tabular && props.fontVariant === undefined,
107
+ });
108
+ return (
109
+ <RawText
110
+ fontSize="$true"
111
+ lineHeight={leadingFor(props.fontSize)}
112
+ {...body}
113
+ color={color}
114
+ {...variantProps}
115
+ {...digitProps}
116
+ {...props}
117
+ style={digitProps?.style ? [digitProps.style, ...[style].flat()] : style}
118
+ fontWeight={weight}
119
+ />
120
+ );
121
+ }
122
+
123
+ export function SizableText({ fontWeight, ...props }: SizableTextProps) {
124
+ const { body, color, weight } = useBodyVoice(fontWeight);
125
+ return <RawSizableText {...body} color={color} {...props} fontWeight={weight} />;
126
+ }
127
+
128
+ export function Paragraph({ fontWeight, variant, tabular, style, ...props }: ParagraphProps) {
129
+ const { body, color, weight, variantProps, digitProps } = useBodyVoice(fontWeight, {
130
+ variant,
131
+ tabular: tabular && props.fontVariant === undefined,
132
+ });
133
+ return (
134
+ <RawParagraph
135
+ fontSize="$true"
136
+ lineHeight={leadingFor(props.fontSize)}
137
+ {...body}
138
+ color={color}
139
+ {...variantProps}
140
+ {...digitProps}
141
+ {...props}
142
+ style={digitProps?.style ? [digitProps.style, ...[style].flat()] : style}
143
+ fontWeight={weight}
144
+ />
145
+ );
146
+ }
147
+
148
+ export function Anchor({ fontWeight, ...props }: AnchorProps) {
149
+ const { body, weight } = useBodyVoice(fontWeight);
150
+ const linkInk = useAccentOnSurface();
151
+ return <RawAnchor {...body} color={linkInk} {...props} fontWeight={weight} />;
152
+ }
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useState } from "react";
2
- import { SizableText, YStack } from "tamagui";
2
+ import { YStack } from "tamagui";
3
+ import { SizableText } from "./Text";
3
4
 
4
5
  export default {
5
6
  title: "Theme/ThemeProvider",
@@ -1,8 +1,9 @@
1
1
  import { type ReactNode, useMemo } from "react";
2
- import { SizableText, XStack, YStack } from "tamagui";
2
+ import { XStack, YStack } from "tamagui";
3
3
  import { PresetContext, usePresetContext } from "./PresetContext";
4
4
  import { defaultPreset } from "./presets";
5
5
  import { Tint, useTintDepth } from "./Tint";
6
+ import { SizableText } from "./Text";
6
7
 
7
8
  export default {
8
9
  title: "Theme/Tint",
@@ -32,8 +32,8 @@ describe("MPO-448 / MPO-449: the built accent pair holds Axiom 12", () => {
32
32
  expect(ratio(fill, theme.color1)).toBeGreaterThanOrEqual(3);
33
33
  });
34
34
 
35
- it(`${scheme} $accentColor reads at 4.5:1 on $color1 through $color3`, () => {
36
- for (const surface of [theme.color1, theme.color2, theme.color3]) {
35
+ it(`${scheme} $accentColor reads at 4.5:1 on $color1 through $color4`, () => {
36
+ for (const surface of [theme.color1, theme.color2, theme.color3, theme.color4]) {
37
37
  expect(ratio(theme.accentColor, surface)).toBeGreaterThanOrEqual(4.5);
38
38
  }
39
39
  });
@@ -63,7 +63,9 @@ describe("MPO-448 / MPO-449: the built accent pair holds Axiom 12", () => {
63
63
  );
64
64
  expect(theme.accentColor).toBe(
65
65
  nearest(stockThemes[scheme].accentColor, steps, (ink) =>
66
- [theme.color1, theme.color2, theme.color3].every((surface) => ratio(ink, surface) >= 4.5),
66
+ [theme.color1, theme.color2, theme.color3, theme.color4].every(
67
+ (surface) => ratio(ink, surface) >= 4.5,
68
+ ),
67
69
  ),
68
70
  );
69
71
  }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * FZ-7783. A config whose accent palette is itself a hue keeps that hue as
3
+ * accent ink with no tint in play. Its own file: Tamagui holds one global
4
+ * config, and the house config in accentOnSurface.spec.tsx would shadow it.
5
+ */
6
+ import * as Colors from "@tamagui/colors";
7
+ import { cleanup, render } from "@testing-library/react";
8
+ import { TamaguiProvider, useTheme } from "tamagui";
9
+ import { afterEach, beforeAll, expect, it } from "vitest";
10
+ import { activateThemeConfig } from "./activateThemeConfig";
11
+ import {
12
+ aaTextContrastRatio,
13
+ contrastRatio,
14
+ normalizeToHex,
15
+ relativeLuminance,
16
+ } from "./colorRules";
17
+ import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
18
+ import { useAccentOnSurface } from "./readableColor";
19
+
20
+ const accented = createDefaultThemeConfig({
21
+ accent: {
22
+ lightPalette: Object.values(Colors.purple),
23
+ darkPalette: Object.values(Colors.purpleDark),
24
+ },
25
+ });
26
+
27
+ function chroma(color: string): number {
28
+ const hex = normalizeToHex(color);
29
+ if (!hex) return 0;
30
+ const rgb = [1, 3, 5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255);
31
+ return Math.max(...rgb) - Math.min(...rgb);
32
+ }
33
+
34
+ function ratio(a: string, b: string): number {
35
+ return contrastRatio(
36
+ relativeLuminance(normalizeToHex(a)!),
37
+ relativeLuminance(normalizeToHex(b)!),
38
+ );
39
+ }
40
+
41
+ interface Seen {
42
+ accent?: string;
43
+ surfaces?: string[];
44
+ accentSteps?: string[];
45
+ }
46
+
47
+ function Probe({ seen }: { seen: Seen }) {
48
+ const theme = useTheme() as unknown as Record<string, { val?: string } | undefined>;
49
+ seen.accent = useAccentOnSurface();
50
+ seen.surfaces = [theme.color1?.val ?? "", theme.color2?.val ?? ""];
51
+ seen.accentSteps = Array.from({ length: 12 }, (_, i) => theme[`accent${i + 1}`]?.val ?? "");
52
+ return null;
53
+ }
54
+
55
+ beforeAll(() => activateThemeConfig(accented));
56
+ afterEach(cleanup);
57
+
58
+ for (const scheme of ["light", "dark"] as const) {
59
+ it(`${scheme} reads the accent ramp's hue`, () => {
60
+ const seen: Seen = {};
61
+ render(
62
+ <TamaguiProvider config={accented.tamagui} defaultTheme={scheme} disableInjectCSS>
63
+ <Probe seen={seen} />
64
+ </TamaguiProvider>,
65
+ );
66
+ expect(chroma(seen.accentSteps![8]), "accent9 is the purple ramp").toBeGreaterThan(0.1);
67
+ expect(chroma(seen.accent!), `${scheme} accent ink ${seen.accent} is grey`).toBeGreaterThan(
68
+ 0.1,
69
+ );
70
+ expect(seen.accentSteps).toContain(seen.accent);
71
+ for (const surface of seen.surfaces!) {
72
+ expect(ratio(seen.accent!, surface)).toBeGreaterThanOrEqual(aaTextContrastRatio);
73
+ }
74
+ });
75
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * FZ-7783, LC-123 COLOUR-OVER-PRESET. Accent ink is the colour theme's hue.
3
+ * A tint re-ramps the neutral family and leaves the accent ramp neutral, so
4
+ * an ink read from the ramp alone came back grey under orange or purple and
5
+ * every consumer (links, Toast, DatePicker, table filters, Button) painted
6
+ * it. Every preset × colour theme × scheme here fails on a grey result.
7
+ */
8
+ import type { ThemeName } from "@tamagui/web";
9
+ import { cleanup, render } from "@testing-library/react";
10
+ import type { ReactElement } from "react";
11
+ import { TamaguiProvider, Theme, useTheme, useThemeName } from "tamagui";
12
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
13
+ import { Preset } from "./Preset";
14
+ import { activateThemeConfig } from "./activateThemeConfig";
15
+ import {
16
+ aaTextContrastRatio,
17
+ contrastRatio,
18
+ normalizeToHex,
19
+ relativeLuminance,
20
+ } from "./colorRules";
21
+ import { createDefaultThemeConfig } from "./createDefaultThemeConfig";
22
+ import { boldPreset, defaultPreset, densePreset, heroPreset, roomyPreset } from "./presets";
23
+ import { useAccentOnSurface, useIdentityInkOnSurface } from "./readableColor";
24
+ import { clearPresets, registerPreset } from "./shared";
25
+
26
+ const presets = {
27
+ default: defaultPreset,
28
+ bold: boldPreset,
29
+ hero: heroPreset,
30
+ dense: densePreset,
31
+ roomy: roomyPreset,
32
+ };
33
+ const colourThemes = ["blue", "red", "green", "yellow", "orange", "pink", "purple"] as const;
34
+ const schemes = ["light", "dark"] as const;
35
+ const house = createDefaultThemeConfig({ presets });
36
+ const chromaFloor = 0.1;
37
+
38
+ function chroma(color: string): number {
39
+ const hex = normalizeToHex(color);
40
+ if (!hex) return 0;
41
+ const rgb = [1, 3, 5].map((offset) => parseInt(hex.slice(offset, offset + 2), 16) / 255);
42
+ return Math.max(...rgb) - Math.min(...rgb);
43
+ }
44
+
45
+ function ratio(a: string, b: string): number {
46
+ return contrastRatio(
47
+ relativeLuminance(normalizeToHex(a)!),
48
+ relativeLuminance(normalizeToHex(b)!),
49
+ );
50
+ }
51
+
52
+ interface Seen {
53
+ name?: string;
54
+ accent?: string;
55
+ identity?: string;
56
+ surfaces?: string[];
57
+ accentSteps?: string[];
58
+ }
59
+
60
+ function Probe({ seen }: { seen: Seen }) {
61
+ const theme = useTheme() as unknown as Record<string, { val?: string } | undefined>;
62
+ seen.name = useThemeName();
63
+ seen.accent = useAccentOnSurface();
64
+ seen.identity = useIdentityInkOnSurface();
65
+ seen.surfaces = [theme.color1?.val ?? "", theme.color2?.val ?? ""];
66
+ seen.accentSteps = Array.from({ length: 12 }, (_, i) => theme[`accent${i + 1}`]?.val ?? "");
67
+ return null;
68
+ }
69
+
70
+ function probe(scheme: string, tree: (node: ReactElement) => ReactElement): Seen {
71
+ const seen: Seen = {};
72
+ render(
73
+ <TamaguiProvider config={house.tamagui} defaultTheme={scheme} disableInjectCSS>
74
+ {tree(<Probe seen={seen} />)}
75
+ </TamaguiProvider>,
76
+ );
77
+ return seen;
78
+ }
79
+
80
+ function expectColouredInk(seen: Seen) {
81
+ expect(seen.accent, `${seen.name} accent ink`).toBeDefined();
82
+ expect(chroma(seen.accent!), `${seen.name} accent ink ${seen.accent} is grey`).toBeGreaterThan(
83
+ chromaFloor,
84
+ );
85
+ for (const surface of seen.surfaces!) {
86
+ expect(ratio(seen.accent!, surface)).toBeGreaterThanOrEqual(aaTextContrastRatio);
87
+ }
88
+ expect(seen.accent).toBe(seen.identity);
89
+ }
90
+
91
+ beforeAll(() => {
92
+ clearPresets();
93
+ for (const [name, preset] of Object.entries(presets)) registerPreset(name, preset);
94
+ activateThemeConfig(house);
95
+ });
96
+ afterEach(cleanup);
97
+ afterAll(clearPresets);
98
+
99
+ describe("accent ink follows the colour theme (FZ-7783, LC-123)", () => {
100
+ for (const scheme of schemes) {
101
+ for (const presetName of Object.keys(presets)) {
102
+ for (const colour of colourThemes) {
103
+ it(`${scheme} ${presetName} × ${colour} paints a ${colour} accent ink`, () => {
104
+ const seen = probe(scheme, (node) => (
105
+ <Preset preset={presetName} theme={colour as ThemeName}>
106
+ {node}
107
+ </Preset>
108
+ ));
109
+ expect(seen.name).toBe(`${scheme}_${colour}`);
110
+ expectColouredInk(seen);
111
+ });
112
+ }
113
+
114
+ const own = presets[presetName as keyof typeof presets].theme;
115
+ if (own) {
116
+ it(`${scheme} ${presetName} with no pick paints its own ${own} accent ink`, () => {
117
+ const seen = probe(scheme, (node) => (
118
+ <Preset preset={presetName} theme={""}>
119
+ {node}
120
+ </Preset>
121
+ ));
122
+ expect(seen.name).toBe(`${scheme}_${own}`);
123
+ expectColouredInk(seen);
124
+ });
125
+ }
126
+ }
127
+
128
+ it(`${scheme} with no colour keeps the neutral accent ramp's least-contrast AA step`, () => {
129
+ const seen = probe(scheme, (node) => <Preset preset="default">{node}</Preset>);
130
+ expect(seen.name).toBe(scheme);
131
+ const passing = seen.accentSteps!.filter((step) =>
132
+ seen.surfaces!.every((surface) => ratio(step, surface) >= aaTextContrastRatio),
133
+ );
134
+ const quietest = passing.reduce((best, step) =>
135
+ Math.min(...seen.surfaces!.map((s) => ratio(step, s))) <
136
+ Math.min(...seen.surfaces!.map((s) => ratio(best, s)))
137
+ ? step
138
+ : best,
139
+ );
140
+ expect(seen.accent).toBe(quietest);
141
+ });
142
+
143
+ it(`${scheme} inside a nested Theme hue paints that hue`, () => {
144
+ const seen = probe(scheme, (node) => <Theme name="purple">{node}</Theme>);
145
+ expect(seen.name).toBe(`${scheme}_purple`);
146
+ expectColouredInk(seen);
147
+ });
148
+ }
149
+ });
@@ -24,10 +24,13 @@ describe("FZ-7715: a colour theme owns its accent", () => {
24
24
  const scope = themes[`${scheme}_${hue}`];
25
25
  const ramp = Array.from({ length: 12 }, (_, i) => themes[scheme][`${hue}${i + 1}`]);
26
26
  expect(ramp).toContain(scope.accentBackground);
27
- expect(ramp).toContain(scope.accentColor);
27
+ const grounds = [scope.color1, scope.color2, scope.color3, scope.color4];
28
+ if (grounds.every((ground) => ratio(ramp[10], ground) >= 4.5))
29
+ expect(scope.accentColor).toBe(ramp[10]);
30
+ else expect(ramp.slice(0, 11)).not.toContain(scope.accentColor);
28
31
  expect(scope.accentBackground).not.toBe(themes[scheme].accentBackground);
29
32
  expect(ratio(scope.accentBackground, scope.color1)).toBeGreaterThanOrEqual(3);
30
- for (const surface of [scope.color1, scope.color2, scope.color3]) {
33
+ for (const surface of grounds) {
31
34
  expect(ratio(scope.accentColor, surface)).toBeGreaterThanOrEqual(4.5);
32
35
  }
33
36
  const selected = config.themes[`${scheme}_${hue}_accent`] as unknown as Record<
@@ -274,13 +274,13 @@ describe("computeOverrides", () => {
274
274
  const current: Knobs = {
275
275
  ...defaultKnobs,
276
276
  fieldLabelPlacement: "side",
277
- tableZebra: "on",
277
+ tableZebra: "off",
278
278
  formAutofocus: "on",
279
279
  };
280
280
  const overrides = computeOverrides(defaultKnobs, current);
281
281
  expect(overrides).toEqual({
282
282
  fieldLabelPlacement: "side",
283
- tableZebra: "on",
283
+ tableZebra: "off",
284
284
  formAutofocus: "on",
285
285
  });
286
286
 
@@ -413,10 +413,10 @@ describe("the v5 theme matrix", () => {
413
413
 
414
414
  // FZ-7715 added one accent solid per hue and scheme (16 themes) so
415
415
  // `theme="accent"` follows a picked colour; the budget moved 260 -> 280 KB.
416
- it("ships 478 themes and the heading-scope category fonts in under 280 KB of CSS (the v4 matrix and stock spread shipped 913 KB)", async () => {
416
+ it("ships 478 themes and the heading-scope category fonts in under 282 KB of CSS (the v4 matrix and stock spread shipped 913 KB)", async () => {
417
417
  const { createTamagui: build } = await vi.importActual<typeof import("tamagui")>("tamagui");
418
418
  const css = build(config.tamagui).getCSS();
419
419
  expect(Object.keys(themes)).toHaveLength(478);
420
- expect(Buffer.byteLength(css, "utf8")).toBeLessThan(280_000);
420
+ expect(Buffer.byteLength(css, "utf8")).toBeLessThan(282_000);
421
421
  });
422
422
  });
@@ -10,6 +10,7 @@ import {
10
10
  pickReadableForeground,
11
11
  relativeLuminance,
12
12
  } from "./colorRules";
13
+ import { themeRampHues } from "./ramps";
13
14
 
14
15
  /**
15
16
  * MPO-40. Radix step 11 targets its own surface, but intent ink also paints on
@@ -270,8 +271,9 @@ function nearestHoldingStep(
270
271
  /**
271
272
  * MPO-448 / MPO-449. Keep the accent fill readable with its better base anchor
272
273
  * (4.5:1), and visible against the page (3:1). Accent ink needs 4.5:1 on
273
- * color1..color3. Walk to the nearest holding accent step; themes with their
274
- * own pair keep it.
274
+ * color1..color4: color4 is the selection surface a selected chip labels in
275
+ * accent ink (LC-131). Walk to the nearest holding accent step; themes with
276
+ * their own pair keep it.
275
277
  */
276
278
  function holdAccentFloor(
277
279
  themes: Record<string, Record<string, string>>,
@@ -282,7 +284,7 @@ function holdAccentFloor(
282
284
  if (!base?.accentBackground || !base.accentColor) continue;
283
285
  const paper = luminanceOf(base.color1);
284
286
  const ink = luminanceOf(base.color12);
285
- const surfaces = [base.color1, base.color2, base.color3].map(luminanceOf);
287
+ const surfaces = [base.color1, base.color2, base.color3, base.color4].map(luminanceOf);
286
288
  if (paper === null || ink === null || surfaces.some((lum) => lum === null)) continue;
287
289
  const steps = Array.from({ length: 12 }, (_, index) => base[`accent${index + 1}`]);
288
290
  const fill = nearestHoldingStep(
@@ -311,6 +313,56 @@ function holdAccentFloor(
311
313
  return out;
312
314
  }
313
315
 
316
+ /**
317
+ * LC-131 SELECTED-STATE. A selected choice part paints a soft accent tint with
318
+ * accent ink. The tint is the accent step nearest the scheme's neutral
319
+ * selection surface ($color4), kept 1.1:1 off both the page and $color4 so it
320
+ * reads as a tint and never as the neutral tier. The ink is the accent step
321
+ * nearest $accentColor that holds 4.5:1 on the tint. Only the scheme roots
322
+ * carry the keys; every child theme inherits them.
323
+ */
324
+ const selectedTintContrastRatio = 1.1;
325
+
326
+ function holdSelectedState(
327
+ themes: Record<string, Record<string, string>>,
328
+ ): Record<string, Record<string, string>> {
329
+ const out: Record<string, Record<string, string>> = { ...themes };
330
+ for (const scheme of ["light", "dark"] as const) {
331
+ const base = themes[scheme];
332
+ if (!base?.accentColor) continue;
333
+ const steps = Array.from({ length: 12 }, (_, index) => base[`accent${index + 1}`]);
334
+ const paint = selectedPaint(base, steps, base.accentColor);
335
+ if (paint) out[scheme] = { ...base, ...paint };
336
+ }
337
+ return out;
338
+ }
339
+
340
+ /** The selected tint and ink for one scope, from the accent ramp `steps`. */
341
+ function selectedPaint(
342
+ scope: Record<string, string>,
343
+ steps: (string | undefined)[],
344
+ accentColor: string,
345
+ ): { selectedBackground: string; selectedColor: string } | undefined {
346
+ const page = luminanceOf(scope.color1);
347
+ const neutral = luminanceOf(scope.color4);
348
+ if (page === null || neutral === null) return undefined;
349
+ const tint = nearestHoldingStep(
350
+ scope.color4,
351
+ steps,
352
+ (lum) =>
353
+ contrastRatio(lum, page) >= selectedTintContrastRatio &&
354
+ contrastRatio(lum, neutral) >= selectedTintContrastRatio,
355
+ );
356
+ const tintLum = luminanceOf(tint);
357
+ if (tint === scope.color4 || tintLum === null) return undefined;
358
+ const ink = nearestHoldingStep(
359
+ accentColor,
360
+ steps,
361
+ (lum) => contrastRatio(lum, tintLum) >= aaTextContrastRatio,
362
+ );
363
+ return { selectedBackground: tint, selectedColor: ink };
364
+ }
365
+
314
366
  /**
315
367
  * FZ-7158 WP-b2 / DF-06. The off Switch track is the first grey step that
316
368
  * holds 3:1 under the white thumb and 1.5:1 from the accent on track, and
@@ -419,7 +471,7 @@ function holdSyntaxFloor(
419
471
  return out;
420
472
  }
421
473
 
422
- const namedInkHues = Object.keys(defaultChildrenThemes).filter((hue) => hue !== "neutral");
474
+ const namedInkHues = themeRampHues;
423
475
 
424
476
  /**
425
477
  * Radix aims a hue's step 11 at 4.5:1 on its own steps 1-2, but status text,
@@ -455,13 +507,33 @@ function holdNamedInkFloor(
455
507
  return out;
456
508
  }
457
509
 
510
+ /**
511
+ * Step 11 walked toward step 12 in twentieths until it holds, so accent ink
512
+ * that misses a floor by a little keeps its hue instead of jumping to step 12.
513
+ */
514
+ function walkToHoldingInk(
515
+ step11: string,
516
+ step12: string,
517
+ holds: (lum: number) => boolean,
518
+ ): string | null {
519
+ const from = normalizeToHex(step11);
520
+ const to = normalizeToHex(step12);
521
+ if (!from || !to) return null;
522
+ if (holds(relativeLuminance(from))) return step11;
523
+ for (let step = 1; step <= 20; step++) {
524
+ const candidate = mixHex(from, to, step / 20);
525
+ if (holds(relativeLuminance(candidate))) return candidate;
526
+ }
527
+ return null;
528
+ }
529
+
458
530
  /**
459
531
  * FZ-7715. A colour theme is the accent. The house accent is the neutral
460
532
  * inverse, and the hue sub-themes (light_green, dark_pink, ...) inherited it,
461
533
  * so `theme="accent"` and `$accentBackground` stayed black under any colour a
462
534
  * user or a preset picked. Each hue scope gets an accent fill from its own
463
535
  * ramp, held to 3:1 on its page with a 4.5:1 label, accent ink held to 4.5:1
464
- * on color1..color3, and a `<scheme>_<hue>_accent` solid that Tamagui resolves
536
+ * on color1..color4, and a `<scheme>_<hue>_accent` solid that Tamagui resolves
465
537
  * for `theme="accent"` (and every component under it) inside the hue.
466
538
  */
467
539
  function holdColorThemeAccent(
@@ -478,7 +550,7 @@ function holdColorThemeAccent(
478
550
  if (!scope || ramp.some((step) => !step)) continue;
479
551
  const paper = luminanceOf(scope.color1);
480
552
  const ink = luminanceOf(scope.color12);
481
- const surfaces = [scope.color1, scope.color2, scope.color3].map(luminanceOf);
553
+ const surfaces = [scope.color1, scope.color2, scope.color3, scope.color4].map(luminanceOf);
482
554
  if (paper === null || ink === null || surfaces.some((lum) => lum === null)) continue;
483
555
  const fill = nearestHoldingStep(
484
556
  ramp[8],
@@ -487,13 +559,19 @@ function holdColorThemeAccent(
487
559
  Math.max(contrastRatio(lum, paper), contrastRatio(lum, ink)) >= aaTextContrastRatio &&
488
560
  contrastRatio(lum, paper) >= minContrastRatio,
489
561
  );
490
- const accentInk = nearestHoldingStep(ramp[10], ramp, (lum) =>
491
- surfaces.every((surface) => contrastRatio(lum, surface as number) >= aaTextContrastRatio),
492
- );
562
+ const holdsInk = (lum: number) =>
563
+ surfaces.every((surface) => contrastRatio(lum, surface as number) >= aaTextContrastRatio);
564
+ const accentInk =
565
+ walkToHoldingInk(ramp[10], ramp[11], holdsInk) ??
566
+ nearestHoldingStep(ramp[10], ramp, holdsInk);
493
567
  const fillStep = ramp.indexOf(fill);
494
568
  const fillHover = ramp[Math.min(fillStep + 1, ramp.length - 1)];
495
569
  const onFill = holdInkFloor(fill, pickReadableForeground(fill, scope.color1, scope.color12));
496
- const accent: Record<string, string> = { accentBackground: fill, accentColor: accentInk };
570
+ const accent: Record<string, string> = {
571
+ accentBackground: fill,
572
+ accentColor: accentInk,
573
+ ...selectedPaint(scope, ramp, accentInk),
574
+ };
497
575
  // A hue fill can sit on the root's off grey (light pink on gray10): walk
498
576
  // outward from the root's step to the nearest one that holds again.
499
577
  const rootOff = Number(
@@ -658,7 +736,9 @@ export function createThemesBuilder(
658
736
  holdSwitchTrack(
659
737
  holdSyntaxFloor(
660
738
  holdNamedInkFloor(
661
- holdAccentFloor(neutralizeSemanticEntryText(neutralizeTintText(initialTheme))),
739
+ holdSelectedState(
740
+ holdAccentFloor(neutralizeSemanticEntryText(neutralizeTintText(initialTheme))),
741
+ ),
662
742
  ),
663
743
  ),
664
744
  ),
@@ -62,8 +62,26 @@ function pickBoundaryColor(theme: Record<string, string>): string {
62
62
  return best;
63
63
  }
64
64
 
65
+ /**
66
+ * LC-148 HAIRLINE-TOKEN: `$borderColor` one ramp step toward the page. Where
67
+ * the `small` border stop can't draw finer than 1px (web), the resolver paints
68
+ * it in this colour so `small` still reads lighter than `medium`. Ramp themes
69
+ * carry it and component sub-themes inherit theirs: a copy on every theme
70
+ * overruns the CSS budget in createDefaultThemeConfig.spec.ts.
71
+ */
72
+ function pickHairlineBorderColor(
73
+ border: string | undefined,
74
+ step: (n: number) => string | undefined,
75
+ ): string | undefined {
76
+ if (!border) return undefined;
77
+ for (let n = 2; n <= 12; n++) if (step(n) === border) return step(n - 1);
78
+ return undefined;
79
+ }
80
+
65
81
  function getBaseTheme(theme: Record<string, string>) {
82
+ const borderColorHairline = pickHairlineBorderColor(theme.borderColor, (n) => theme[`color${n}`]);
66
83
  return {
84
+ ...(borderColorHairline ? { borderColorHairline } : null),
67
85
  outlineColor: pickFocusRingColor(theme),
68
86
  boundaryColor: pickBoundaryColor(theme),
69
87
  switchThumb: "#ffffff",