@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.
- package/README.md +33 -0
- package/expo.cjs +3 -0
- package/package.json +22 -6
- package/src/audit/constraintAudit.ts +2 -1
- package/src/audit/themeMatrix.ts +1 -0
- package/src/expo.spec.ts +83 -0
- package/src/expo.ts +57 -0
- package/src/expoConfig.spec.ts +67 -0
- package/src/figma/figmaTokens.spec.ts +4 -3
- package/src/theme/FontKnobStyles.tsx +13 -1
- package/src/theme/Heading.tsx +70 -0
- package/src/theme/Intent.stories.tsx +2 -2
- package/src/theme/Preset.stories.tsx +2 -2
- package/src/theme/Preset.tsx +4 -2
- package/src/theme/Surface.stories.tsx +2 -1
- package/src/theme/Surface.tsx +4 -2
- package/src/theme/Text.tsx +152 -0
- package/src/theme/ThemeProvider.stories.tsx +2 -1
- package/src/theme/Tint.stories.tsx +2 -1
- package/src/theme/accentFloor.spec.ts +5 -3
- package/src/theme/accentOnSurface.palette.spec.tsx +75 -0
- package/src/theme/accentOnSurface.spec.tsx +149 -0
- package/src/theme/colorThemeAccent.spec.ts +5 -2
- package/src/theme/cookies.spec.ts +2 -2
- package/src/theme/createDefaultThemeConfig.spec.ts +2 -2
- package/src/theme/createThemes.ts +91 -11
- package/src/theme/defaults/builderOptions.ts +18 -0
- package/src/theme/devtools/ColorLineVisualizer.tsx +3 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.tsx +2 -0
- package/src/theme/hairline.native.ts +3 -0
- package/src/theme/hairline.ts +9 -0
- package/src/theme/hairlineToken.spec.ts +110 -0
- package/src/theme/index.ts +4 -0
- package/src/theme/knobs.ts +2 -2
- package/src/theme/mpo19Proof.spec.ts +4 -1
- package/src/theme/overlaySurfaces.tsx +9 -3
- package/src/theme/pageGutter.tsx +61 -0
- package/src/theme/preset.spec.tsx +29 -1
- package/src/theme/presets.spec.ts +1 -1
- package/src/theme/presets.ts +2 -2
- package/src/theme/ramps.spec.ts +53 -0
- package/src/theme/ramps.ts +75 -0
- package/src/theme/readableColor.ts +111 -70
- package/src/theme/recipes.ts +51 -2
- package/src/theme/resolveFontTokenFamily.spec.ts +44 -0
- package/src/theme/resolveKnobs.spec.ts +30 -20
- package/src/theme/resolveKnobs.ts +87 -20
- package/src/theme/selectedState.spec.ts +127 -0
- package/types/audit/constraintAudit.d.ts.map +1 -1
- package/types/audit/themeMatrix.d.ts.map +1 -1
- package/types/expo.d.ts +29 -0
- package/types/expo.d.ts.map +1 -0
- package/types/theme/FontKnobStyles.d.ts +7 -0
- package/types/theme/FontKnobStyles.d.ts.map +1 -1
- package/types/theme/Heading.d.ts +10 -0
- package/types/theme/Heading.d.ts.map +1 -0
- package/types/theme/Preset.d.ts.map +1 -1
- package/types/theme/Surface.d.ts +2 -1
- package/types/theme/Surface.d.ts.map +1 -1
- package/types/theme/Text.d.ts +13 -0
- package/types/theme/Text.d.ts.map +1 -0
- package/types/theme/colorRules.d.ts +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/defaults/builderOptions.d.ts.map +1 -1
- package/types/theme/defaults/fonts.d.ts +30 -30
- package/types/theme/devtools/ColorLineVisualizer.d.ts.map +1 -1
- package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -1
- package/types/theme/hairline.d.ts +8 -0
- package/types/theme/hairline.d.ts.map +1 -1
- package/types/theme/hairline.native.d.ts +2 -0
- package/types/theme/hairline.native.d.ts.map +1 -1
- package/types/theme/index.d.ts +4 -0
- package/types/theme/index.d.ts.map +1 -1
- package/types/theme/knobs.d.ts +1 -1
- package/types/theme/knobs.d.ts.map +1 -1
- package/types/theme/overlaySurfaces.d.ts.map +1 -1
- package/types/theme/pageGutter.d.ts +41 -0
- package/types/theme/pageGutter.d.ts.map +1 -0
- package/types/theme/ramps.d.ts +20 -0
- package/types/theme/ramps.d.ts.map +1 -0
- package/types/theme/readableColor.d.ts +24 -12
- package/types/theme/readableColor.d.ts.map +1 -1
- package/types/theme/recipes.d.ts +44 -4
- package/types/theme/recipes.d.ts.map +1 -1
- package/types/theme/resolveKnobs.d.ts +10 -4
- 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,8 +1,9 @@
|
|
|
1
1
|
import { type ReactNode, useMemo } from "react";
|
|
2
|
-
import {
|
|
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 $
|
|
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(
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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: "
|
|
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
|
|
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(
|
|
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..
|
|
274
|
-
*
|
|
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 =
|
|
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..
|
|
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
|
|
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> = {
|
|
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
|
-
|
|
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",
|