@multiplatform.one/theme 7.7.6 → 7.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -5
- package/src/audit/constraintAudit.spec.ts +157 -0
- package/src/audit/constraintAudit.ts +154 -35
- package/src/audit/constraintScope.spec.ts +48 -0
- package/src/audit/index.ts +16 -0
- package/src/audit/themeMatrix.spec.ts +292 -1
- package/src/audit/themeMatrix.ts +1015 -22
- package/src/theme/Intent.stories.tsx +88 -0
- package/src/theme/Preset.stories.tsx +80 -0
- package/src/theme/Surface.spec.tsx +6 -0
- package/src/theme/Surface.stories.tsx +91 -0
- package/src/theme/ThemeProvider.stories.tsx +50 -0
- package/src/theme/Tint.stories.tsx +98 -0
- package/src/theme/chartPalette.spec.ts +128 -5
- package/src/theme/chartPalette.ts +105 -22
- package/src/theme/colorRules.spec.ts +92 -39
- package/src/theme/colorRules.ts +4 -6
- package/src/theme/createDefaultThemeConfig.ts +1 -1
- package/src/theme/createThemes.ts +58 -3
- package/src/theme/devtools/ColorLineVisualizer.stories.tsx +36 -0
- package/src/theme/devtools/ThemeDevtoolsPanel.stories.tsx +16 -0
- package/src/theme/glyphPaint.spec.ts +14 -0
- package/src/theme/glyphPaint.ts +2 -1
- package/src/theme/intent.spec.tsx +1 -0
- package/src/theme/layoutTokensHooks.spec.tsx +1 -0
- package/src/theme/recipeInputs.ts +9 -9
- package/src/theme/resolveKnobs.spec.ts +2 -2
- package/src/theme/sizeLadder.spec.ts +19 -10
- package/src/theme/sizeRecipes.ts +2 -2
- package/src/theme/themeValue.spec.ts +2 -2
- package/src/theme/useResolvedKnobsBehavior.spec.tsx +6 -0
- package/types/audit/constraintAudit.d.ts +17 -1
- package/types/audit/constraintAudit.d.ts.map +1 -1
- package/types/audit/index.d.ts +2 -2
- package/types/audit/index.d.ts.map +1 -1
- package/types/audit/themeMatrix.d.ts +135 -2
- package/types/audit/themeMatrix.d.ts.map +1 -1
- package/types/theme/chartPalette.d.ts +6 -4
- package/types/theme/chartPalette.d.ts.map +1 -1
- package/types/theme/colorRules.d.ts +3 -3
- package/types/theme/colorRules.d.ts.map +1 -1
- package/types/theme/createThemes.d.ts +1 -1
- package/types/theme/createThemes.d.ts.map +1 -1
- package/types/theme/glyphPaint.d.ts.map +1 -1
- package/types/theme/recipeInputs.d.ts +8 -8
- package/types/theme/recipeInputs.d.ts.map +1 -1
- package/types/theme/sizeRecipes.d.ts +2 -2
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { SizableText, XStack, YStack } from "tamagui";
|
|
2
|
+
import { Intent, type IntentProps } from "./Intent";
|
|
3
|
+
import { useIntentContext, useResolvedKnobs } from "./useResolvedKnobs";
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
title: "Theme/Intent",
|
|
7
|
+
component: Intent,
|
|
8
|
+
parameters: {
|
|
9
|
+
status: { type: "beta" },
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"Intent activates the named Tamagui sub-theme (layer 1) and publishes the intent name through IntentContext so useResolvedKnobs merges the preset's intent overrides (layer 2). The probe is a Tamagui stack painting $background / $color and spreading the resolved surface.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const intents: IntentProps["name"][] = ["accent", "error", "warning", "success"];
|
|
20
|
+
|
|
21
|
+
function Probe({ label }: { label: string }) {
|
|
22
|
+
const intent = useIntentContext();
|
|
23
|
+
const { knobProps } = useResolvedKnobs();
|
|
24
|
+
const attrs = {
|
|
25
|
+
"data-intent": intent ?? "none",
|
|
26
|
+
"data-outlined": String(Boolean(knobProps.outlined)),
|
|
27
|
+
"data-fill-style": knobProps.outlined ? "outlined" : "filled",
|
|
28
|
+
} as Record<string, unknown>;
|
|
29
|
+
return (
|
|
30
|
+
<YStack
|
|
31
|
+
{...knobProps.surface}
|
|
32
|
+
{...knobProps.borderRadius}
|
|
33
|
+
backgroundColor={knobProps.outlined ? "transparent" : "$background"}
|
|
34
|
+
borderColor="$borderColor"
|
|
35
|
+
padding="$3"
|
|
36
|
+
minWidth={140}
|
|
37
|
+
testID={`intent-probe-${label}`}
|
|
38
|
+
{...attrs}
|
|
39
|
+
>
|
|
40
|
+
<SizableText color="$color" testID={`intent-text-${label}`}>
|
|
41
|
+
{label}
|
|
42
|
+
</SizableText>
|
|
43
|
+
<SizableText color="$color11" fontSize="$2">
|
|
44
|
+
context: {intent ?? "none"}
|
|
45
|
+
</SizableText>
|
|
46
|
+
</YStack>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The four intents beside an un-wrapped baseline. */
|
|
51
|
+
export const main = {
|
|
52
|
+
name: "Main",
|
|
53
|
+
render: () => (
|
|
54
|
+
<XStack gap="$4" flexWrap="wrap" alignItems="flex-start">
|
|
55
|
+
<Probe label="none" />
|
|
56
|
+
{intents.map((name) => (
|
|
57
|
+
<Intent key={name} name={name}>
|
|
58
|
+
<Probe label={name} />
|
|
59
|
+
</Intent>
|
|
60
|
+
))}
|
|
61
|
+
</XStack>
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Nesting replaces, never merges: the innermost intent wins. */
|
|
66
|
+
export const nested = {
|
|
67
|
+
name: "Nested",
|
|
68
|
+
render: () => (
|
|
69
|
+
<Intent name="error">
|
|
70
|
+
<XStack gap="$4" flexWrap="wrap" alignItems="flex-start">
|
|
71
|
+
<Probe label="outer-error" />
|
|
72
|
+
<Intent name="success">
|
|
73
|
+
<Probe label="inner-success" />
|
|
74
|
+
</Intent>
|
|
75
|
+
</XStack>
|
|
76
|
+
</Intent>
|
|
77
|
+
),
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** An explicit `theme` keeps the intent context but paints another Tamagui theme. */
|
|
81
|
+
export const explicitTheme = {
|
|
82
|
+
name: "Explicit theme",
|
|
83
|
+
render: () => (
|
|
84
|
+
<Intent name="accent" theme="blue">
|
|
85
|
+
<Probe label="accent-as-blue" />
|
|
86
|
+
</Intent>
|
|
87
|
+
),
|
|
88
|
+
};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { SizableText, XStack, YStack } from "tamagui";
|
|
2
|
+
import { Preset } from "./Preset";
|
|
3
|
+
import { useResolvedKnobs } from "./useResolvedKnobs";
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
title: "Theme/Preset",
|
|
7
|
+
component: Preset,
|
|
8
|
+
parameters: {
|
|
9
|
+
status: { type: "beta" },
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"Preset layers knob overrides on the nearest preset (cascade, the default) or replaces it (cascade={false}). The probe is a Tamagui stack spreading the resolved surface and radius, so what is measured is the stack.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
function Probe({ label }: { label: string }) {
|
|
20
|
+
const { knobProps } = useResolvedKnobs();
|
|
21
|
+
const attrs = {
|
|
22
|
+
"data-border-radius": String(knobProps.borderRadius.borderRadius),
|
|
23
|
+
"data-border-width": String(knobProps.surface.borderWidth),
|
|
24
|
+
"data-fill-style": knobProps.outlined ? "outlined" : "filled",
|
|
25
|
+
"data-space": String(knobProps.space),
|
|
26
|
+
} as Record<string, unknown>;
|
|
27
|
+
return (
|
|
28
|
+
<YStack
|
|
29
|
+
{...knobProps.surface}
|
|
30
|
+
{...knobProps.borderRadius}
|
|
31
|
+
backgroundColor={knobProps.outlined ? "transparent" : "$background"}
|
|
32
|
+
borderColor="$borderColor"
|
|
33
|
+
elevation={knobProps.elevation}
|
|
34
|
+
padding="$3"
|
|
35
|
+
minWidth={160}
|
|
36
|
+
testID={`preset-probe-${label}`}
|
|
37
|
+
{...attrs}
|
|
38
|
+
>
|
|
39
|
+
<SizableText>{label}</SizableText>
|
|
40
|
+
<SizableText color="$color11" fontSize="$2">
|
|
41
|
+
radius {String(knobProps.borderRadius.borderRadius)}, border{" "}
|
|
42
|
+
{String(knobProps.surface.borderWidth)}, {knobProps.outlined ? "outlined" : "filled"}
|
|
43
|
+
</SizableText>
|
|
44
|
+
</YStack>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Overrides on top of whatever the page's preset resolved. */
|
|
49
|
+
export const main = {
|
|
50
|
+
name: "Main",
|
|
51
|
+
render: () => (
|
|
52
|
+
<XStack gap="$4" flexWrap="wrap" alignItems="flex-start">
|
|
53
|
+
<Probe label="inherited" />
|
|
54
|
+
<Preset overrides={{ borderRadius: "full", borderWidth: "large" }}>
|
|
55
|
+
<Probe label="full-radius" />
|
|
56
|
+
</Preset>
|
|
57
|
+
<Preset overrides={{ fillStyle: "outlined" }}>
|
|
58
|
+
<Probe label="outlined" />
|
|
59
|
+
</Preset>
|
|
60
|
+
</XStack>
|
|
61
|
+
),
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Cascade carries the parent's overrides forward; cascade={false} starts from defaults. */
|
|
65
|
+
export const cascade = {
|
|
66
|
+
name: "Cascade",
|
|
67
|
+
render: () => (
|
|
68
|
+
<Preset overrides={{ borderRadius: "full" }}>
|
|
69
|
+
<XStack gap="$4" flexWrap="wrap" alignItems="flex-start">
|
|
70
|
+
<Probe label="parent-full" />
|
|
71
|
+
<Preset overrides={{ borderWidth: "large" }}>
|
|
72
|
+
<Probe label="child-cascade" />
|
|
73
|
+
</Preset>
|
|
74
|
+
<Preset cascade={false}>
|
|
75
|
+
<Probe label="child-reset" />
|
|
76
|
+
</Preset>
|
|
77
|
+
</XStack>
|
|
78
|
+
</Preset>
|
|
79
|
+
),
|
|
80
|
+
};
|
|
@@ -14,6 +14,12 @@ import {
|
|
|
14
14
|
} from "./Surface";
|
|
15
15
|
import { useResolvedKnobs } from "./useResolvedKnobs";
|
|
16
16
|
|
|
17
|
+
// These structural context tests hold the color scheme constant.
|
|
18
|
+
vi.mock(import("tamagui"), async (importOriginal) => ({
|
|
19
|
+
...(await importOriginal()),
|
|
20
|
+
useThemeName: () => "light",
|
|
21
|
+
}));
|
|
22
|
+
|
|
17
23
|
let originalMatchMedia: typeof window.matchMedia;
|
|
18
24
|
|
|
19
25
|
beforeEach(() => {
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { SizableText, XStack, YStack } from "tamagui";
|
|
2
|
+
import { Surface } from "./Surface";
|
|
3
|
+
import { useResolvedKnobs } from "./useResolvedKnobs";
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
title: "Theme/Surface",
|
|
7
|
+
component: Surface,
|
|
8
|
+
parameters: {
|
|
9
|
+
status: { type: "beta" },
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
"Surface paints nothing. It declares size/density intent for a subtree and nested Surfaces clamp step-down only (LC-68). The probe below is a Tamagui stack that spreads what useResolvedKnobs resolved inside the wrapper, so the measured part is the stack, never the wrapper.",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** Spreads the resolved recipe on a stack so the wrapper's effect is measurable. */
|
|
20
|
+
function Probe({ label }: { label: string }) {
|
|
21
|
+
const { knobProps } = useResolvedKnobs();
|
|
22
|
+
const attrs = {
|
|
23
|
+
"data-size": String(knobProps.size),
|
|
24
|
+
"data-space": String(knobProps.space),
|
|
25
|
+
"data-size-token": String(knobProps.sizeToken),
|
|
26
|
+
"data-gap": String(knobProps.gap.gap),
|
|
27
|
+
} as Record<string, unknown>;
|
|
28
|
+
return (
|
|
29
|
+
<YStack
|
|
30
|
+
{...knobProps.surface}
|
|
31
|
+
{...knobProps.borderRadius}
|
|
32
|
+
{...knobProps.gap}
|
|
33
|
+
padding={knobProps.gap.gap}
|
|
34
|
+
testID={`surface-probe-${label}`}
|
|
35
|
+
{...attrs}
|
|
36
|
+
>
|
|
37
|
+
<SizableText fontSize={knobProps.sizeToken as never} testID={`surface-text-${label}`}>
|
|
38
|
+
{label}: token {String(knobProps.sizeToken)}, gap {String(knobProps.gap.gap)}
|
|
39
|
+
</SizableText>
|
|
40
|
+
</YStack>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const main = {
|
|
45
|
+
name: "Main",
|
|
46
|
+
render: () => (
|
|
47
|
+
<XStack gap="$4" flexWrap="wrap" alignItems="flex-start">
|
|
48
|
+
<Probe label="outside" />
|
|
49
|
+
<Surface size="small">
|
|
50
|
+
<Probe label="small" />
|
|
51
|
+
</Surface>
|
|
52
|
+
<Surface size="large">
|
|
53
|
+
<Probe label="large" />
|
|
54
|
+
</Surface>
|
|
55
|
+
</XStack>
|
|
56
|
+
),
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** LC-68: a large request inside a small Surface resolves small; small inside large stays small. */
|
|
60
|
+
export const nestedClamp = {
|
|
61
|
+
name: "Nested clamp",
|
|
62
|
+
render: () => (
|
|
63
|
+
<XStack gap="$4" flexWrap="wrap" alignItems="flex-start">
|
|
64
|
+
<Surface size="small">
|
|
65
|
+
<Surface size="large">
|
|
66
|
+
<Probe label="large-in-small" />
|
|
67
|
+
</Surface>
|
|
68
|
+
</Surface>
|
|
69
|
+
<Surface size="large">
|
|
70
|
+
<Surface size="small">
|
|
71
|
+
<Probe label="small-in-large" />
|
|
72
|
+
</Surface>
|
|
73
|
+
</Surface>
|
|
74
|
+
</XStack>
|
|
75
|
+
),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
/** Density steps the gap, not the control height. */
|
|
79
|
+
export const density = {
|
|
80
|
+
name: "Density",
|
|
81
|
+
render: () => (
|
|
82
|
+
<XStack gap="$4" flexWrap="wrap" alignItems="flex-start">
|
|
83
|
+
<Surface density="comfortable">
|
|
84
|
+
<Probe label="comfortable" />
|
|
85
|
+
</Surface>
|
|
86
|
+
<Surface density="compact">
|
|
87
|
+
<Probe label="compact" />
|
|
88
|
+
</Surface>
|
|
89
|
+
</XStack>
|
|
90
|
+
),
|
|
91
|
+
};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { SizableText, YStack } from "tamagui";
|
|
3
|
+
|
|
4
|
+
export default {
|
|
5
|
+
title: "Theme/ThemeProvider",
|
|
6
|
+
parameters: {
|
|
7
|
+
status: { type: "beta" },
|
|
8
|
+
docs: {
|
|
9
|
+
description: {
|
|
10
|
+
component:
|
|
11
|
+
"ThemeProvider is the Storybook preview wrapper (TamaguiProvider + KnobBridge). This story measures that live provider: the html theme class and the FontKnobStyles stylesheet KnobBridge mounts. Remounting ThemeProvider inside the preview would nest TamaguiProvider, so the probe reads the one already on the page.",
|
|
12
|
+
},
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function Probe() {
|
|
18
|
+
const [themeClass, setThemeClass] = useState("pending");
|
|
19
|
+
const [fontStyles, setFontStyles] = useState("pending");
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
const html = document.documentElement;
|
|
22
|
+
setThemeClass(
|
|
23
|
+
html.classList.contains("t_dark")
|
|
24
|
+
? "t_dark"
|
|
25
|
+
: html.classList.contains("t_light")
|
|
26
|
+
? "t_light"
|
|
27
|
+
: "none",
|
|
28
|
+
);
|
|
29
|
+
setFontStyles(document.getElementById("mp-font-knob-styles") ? "present" : "missing");
|
|
30
|
+
}, []);
|
|
31
|
+
return (
|
|
32
|
+
<YStack
|
|
33
|
+
testID="theme-provider-probe"
|
|
34
|
+
gap="$2"
|
|
35
|
+
padding="$4"
|
|
36
|
+
{...({
|
|
37
|
+
"data-theme-class": themeClass,
|
|
38
|
+
"data-font-styles": fontStyles,
|
|
39
|
+
} as Record<string, unknown>)}
|
|
40
|
+
>
|
|
41
|
+
<SizableText>html class: {themeClass}</SizableText>
|
|
42
|
+
<SizableText>FontKnobStyles: {fontStyles}</SizableText>
|
|
43
|
+
</YStack>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const main = {
|
|
48
|
+
name: "Main",
|
|
49
|
+
render: () => <Probe />,
|
|
50
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type ReactNode, useMemo } from "react";
|
|
2
|
+
import { SizableText, XStack, YStack } from "tamagui";
|
|
3
|
+
import { PresetContext, usePresetContext } from "./PresetContext";
|
|
4
|
+
import { defaultPreset } from "./presets";
|
|
5
|
+
import { Tint, useTintDepth } from "./Tint";
|
|
6
|
+
|
|
7
|
+
export default {
|
|
8
|
+
title: "Theme/Tint",
|
|
9
|
+
component: Tint,
|
|
10
|
+
parameters: {
|
|
11
|
+
status: { type: "beta" },
|
|
12
|
+
docs: {
|
|
13
|
+
description: {
|
|
14
|
+
component:
|
|
15
|
+
"Tint selects tints[(depth - 1) % tints.length] from the preset and wraps children in that Tamagui sub-theme. The swatch is a Tamagui stack painting $color5 / $color11, so the hue you see is the sub-theme, not the wrapper.",
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Keeps the surrounding knobs, pins the tint family to defaultPreset's four names. */
|
|
22
|
+
function WithDefaultTints({ children }: { children: ReactNode }) {
|
|
23
|
+
const parent = usePresetContext();
|
|
24
|
+
const value = useMemo(
|
|
25
|
+
() => ({
|
|
26
|
+
preset: { ...(parent?.preset ?? defaultPreset), tints: defaultPreset.tints },
|
|
27
|
+
overrides: parent?.overrides,
|
|
28
|
+
}),
|
|
29
|
+
[parent],
|
|
30
|
+
);
|
|
31
|
+
return <PresetContext.Provider value={value}>{children}</PresetContext.Provider>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function Swatch({ label }: { label?: string }) {
|
|
35
|
+
const depth = useTintDepth();
|
|
36
|
+
return (
|
|
37
|
+
<YStack
|
|
38
|
+
backgroundColor="$color5"
|
|
39
|
+
borderColor="$color8"
|
|
40
|
+
borderWidth={1}
|
|
41
|
+
borderRadius="$3"
|
|
42
|
+
padding="$3"
|
|
43
|
+
gap="$2"
|
|
44
|
+
testID={`tint-swatch-${label ?? depth}`}
|
|
45
|
+
>
|
|
46
|
+
<SizableText color="$color11">
|
|
47
|
+
{label ?? "depth"} {depth}
|
|
48
|
+
</SizableText>
|
|
49
|
+
</YStack>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Five nested Tints: orange, blue, purple, pink, then orange again (modulo). */
|
|
54
|
+
export const main = {
|
|
55
|
+
name: "Main",
|
|
56
|
+
render: () => (
|
|
57
|
+
<WithDefaultTints>
|
|
58
|
+
<Tint>
|
|
59
|
+
<Swatch />
|
|
60
|
+
<Tint>
|
|
61
|
+
<Swatch />
|
|
62
|
+
<Tint>
|
|
63
|
+
<Swatch />
|
|
64
|
+
<Tint>
|
|
65
|
+
<Swatch />
|
|
66
|
+
<Tint>
|
|
67
|
+
<Swatch />
|
|
68
|
+
</Tint>
|
|
69
|
+
</Tint>
|
|
70
|
+
</Tint>
|
|
71
|
+
</Tint>
|
|
72
|
+
</Tint>
|
|
73
|
+
</WithDefaultTints>
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** `alt` offsets the depth; `disable` keeps the depth but drops the Theme wrapper. */
|
|
78
|
+
export const altAndDisable = {
|
|
79
|
+
name: "Alt and disable",
|
|
80
|
+
render: () => (
|
|
81
|
+
<WithDefaultTints>
|
|
82
|
+
<XStack gap="$4" flexWrap="wrap" alignItems="flex-start">
|
|
83
|
+
<Tint>
|
|
84
|
+
<Swatch label="plain" />
|
|
85
|
+
</Tint>
|
|
86
|
+
<Tint alt={1}>
|
|
87
|
+
<Swatch label="alt-1" />
|
|
88
|
+
</Tint>
|
|
89
|
+
<Tint alt={2}>
|
|
90
|
+
<Swatch label="alt-2" />
|
|
91
|
+
</Tint>
|
|
92
|
+
<Tint disable>
|
|
93
|
+
<Swatch label="disabled" />
|
|
94
|
+
</Tint>
|
|
95
|
+
</XStack>
|
|
96
|
+
</WithDefaultTints>
|
|
97
|
+
),
|
|
98
|
+
};
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as Colors from "@tamagui/colors";
|
|
2
|
+
import { themes as stockThemes } from "@tamagui/themes";
|
|
2
3
|
import { describe, expect, it } from "vitest";
|
|
3
4
|
import { resolveChartPalette } from "./chartPalette";
|
|
4
5
|
import { contrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
|
|
6
|
+
import { tintHueNames } from "./createThemes";
|
|
5
7
|
|
|
6
8
|
// The default accent solid (accentBackground) from defaults/accent.ts.
|
|
7
9
|
const lightAccent = "hsla(250, 50%, 54%, 1)";
|
|
@@ -53,6 +55,126 @@ function adjacentDistinguishable(a: string, b: string): boolean {
|
|
|
53
55
|
}
|
|
54
56
|
|
|
55
57
|
describe("resolveChartPalette", () => {
|
|
58
|
+
it("floors every categorical mark across the registered tint identities", () => {
|
|
59
|
+
for (const scheme of ["light", "dark"] as const) {
|
|
60
|
+
const cardSurfaces = Object.entries(stockThemes)
|
|
61
|
+
.filter(
|
|
62
|
+
([name]) =>
|
|
63
|
+
name.startsWith(`${scheme}_`) &&
|
|
64
|
+
name.endsWith("_Card") &&
|
|
65
|
+
tintHueNames.has(name.split("_")[1]) &&
|
|
66
|
+
name.split("_").length === 3,
|
|
67
|
+
)
|
|
68
|
+
.map(([, theme]) => theme.background);
|
|
69
|
+
expect(cardSurfaces.length).toBeGreaterThan(0);
|
|
70
|
+
const surfaces = [
|
|
71
|
+
...cardSurfaces,
|
|
72
|
+
...(scheme === "light"
|
|
73
|
+
? ["#ffffff", Colors.gray.gray2, Colors.mauve.mauve2]
|
|
74
|
+
: [Colors.grayDark.gray2, Colors.mauveDark.mauve2]),
|
|
75
|
+
];
|
|
76
|
+
for (const hue of tintHueNames) {
|
|
77
|
+
const ramp = (Colors as unknown as Record<string, Record<string, string>>)[
|
|
78
|
+
scheme === "dark" ? `${hue}Dark` : hue
|
|
79
|
+
];
|
|
80
|
+
const palette = resolveChartPalette({
|
|
81
|
+
scheme,
|
|
82
|
+
identitySolid: ramp[`${hue}9`],
|
|
83
|
+
identityCandidates: [ramp[`${hue}10`], ramp[`${hue}11`]],
|
|
84
|
+
});
|
|
85
|
+
for (let i = 0; i < palette.categorical.length; i++) {
|
|
86
|
+
expect(
|
|
87
|
+
adjacentDistinguishable(
|
|
88
|
+
palette.categorical[i],
|
|
89
|
+
palette.categorical[(i + 1) % palette.categorical.length],
|
|
90
|
+
),
|
|
91
|
+
`${scheme}/${hue}: adjacent series ${i}`,
|
|
92
|
+
).toBe(true);
|
|
93
|
+
}
|
|
94
|
+
for (const paint of [...palette.categorical, ...Object.values(palette.semantic)]) {
|
|
95
|
+
for (const surface of surfaces) {
|
|
96
|
+
const ratio = contrastRatio(
|
|
97
|
+
relativeLuminance(normalizeToHex(paint)!),
|
|
98
|
+
relativeLuminance(normalizeToHex(surface)!),
|
|
99
|
+
);
|
|
100
|
+
expect(ratio, `${scheme}/${hue}: ${paint} on ${surface}`).toBeGreaterThanOrEqual(3);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("retains the dark cycle order while deepening its failing violet slot", () => {
|
|
108
|
+
const palette = resolveChartPalette({ scheme: "dark", identitySolid: "#aaaaaa" });
|
|
109
|
+
expect(palette.categorical.slice(1)).toEqual([
|
|
110
|
+
Colors.blueDark.blue9,
|
|
111
|
+
Colors.orangeDark.orange9,
|
|
112
|
+
Colors.greenDark.green9,
|
|
113
|
+
Colors.amberDark.amber9,
|
|
114
|
+
Colors.pinkDark.pink9,
|
|
115
|
+
Colors.tealDark.teal9,
|
|
116
|
+
Colors.violetDark.violet10,
|
|
117
|
+
Colors.redDark.red9,
|
|
118
|
+
]);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("keeps the valid default light order and every grass-tint series", () => {
|
|
122
|
+
expect(
|
|
123
|
+
resolveChartPalette({ scheme: "light", identitySolid: lightAccent }).categorical,
|
|
124
|
+
).toEqual([
|
|
125
|
+
lightAccent,
|
|
126
|
+
Colors.blue.blue10,
|
|
127
|
+
Colors.orange.orange11,
|
|
128
|
+
Colors.green.green10,
|
|
129
|
+
Colors.amber.amber11,
|
|
130
|
+
Colors.pink.pink9,
|
|
131
|
+
Colors.teal.teal10,
|
|
132
|
+
Colors.red.red9,
|
|
133
|
+
]);
|
|
134
|
+
const grass = resolveChartPalette({
|
|
135
|
+
scheme: "light",
|
|
136
|
+
identitySolid: Colors.grass.grass9,
|
|
137
|
+
identityCandidates: [Colors.grass.grass10, Colors.grass.grass11],
|
|
138
|
+
});
|
|
139
|
+
expect(grass.categorical[0]).toBe(Colors.grass.grass10);
|
|
140
|
+
expect([...grass.categorical].sort()).toEqual(
|
|
141
|
+
[
|
|
142
|
+
Colors.grass.grass10,
|
|
143
|
+
Colors.blue.blue10,
|
|
144
|
+
Colors.orange.orange11,
|
|
145
|
+
Colors.amber.amber11,
|
|
146
|
+
Colors.pink.pink9,
|
|
147
|
+
Colors.teal.teal10,
|
|
148
|
+
Colors.violet.violet9,
|
|
149
|
+
Colors.red.red9,
|
|
150
|
+
].sort(),
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("uses the first readable identity candidate and retains a readable original", () => {
|
|
155
|
+
expect(
|
|
156
|
+
resolveChartPalette({
|
|
157
|
+
scheme: "light",
|
|
158
|
+
identitySolid: "#ffffff",
|
|
159
|
+
identityCandidates: ["var(--ink)", "#777777", "#333333"],
|
|
160
|
+
}).single,
|
|
161
|
+
).toBe("#777777");
|
|
162
|
+
expect(
|
|
163
|
+
resolveChartPalette({
|
|
164
|
+
scheme: "light",
|
|
165
|
+
identitySolid: lightAccent,
|
|
166
|
+
identityCandidates: ["#333333"],
|
|
167
|
+
}).single,
|
|
168
|
+
).toBe(lightAccent);
|
|
169
|
+
expect(
|
|
170
|
+
resolveChartPalette({
|
|
171
|
+
scheme: "light",
|
|
172
|
+
identitySolid: "#ffffff",
|
|
173
|
+
identityCandidates: ["#eeeeee"],
|
|
174
|
+
}).single,
|
|
175
|
+
).toBe(Colors.violet.violet9);
|
|
176
|
+
});
|
|
177
|
+
|
|
56
178
|
it("single-series takes the theme identity solid", () => {
|
|
57
179
|
expect(resolveChartPalette({ scheme: "light", identitySolid: lightAccent }).single).toBe(
|
|
58
180
|
lightAccent,
|
|
@@ -70,7 +192,7 @@ describe("resolveChartPalette", () => {
|
|
|
70
192
|
it("drops the cycle hue confusable with the accent identity (violet)", () => {
|
|
71
193
|
const palette = resolveChartPalette({ scheme: "light", identitySolid: lightAccent });
|
|
72
194
|
expect(palette.categorical).not.toContain(Colors.violet.violet9);
|
|
73
|
-
expect(palette.categorical).toContain(Colors.blue.
|
|
195
|
+
expect(palette.categorical).toContain(Colors.blue.blue10);
|
|
74
196
|
expect(palette.categorical).toHaveLength(8);
|
|
75
197
|
});
|
|
76
198
|
|
|
@@ -88,11 +210,12 @@ describe("resolveChartPalette", () => {
|
|
|
88
210
|
expect(palette.categorical).toContain(Colors.red.red9);
|
|
89
211
|
});
|
|
90
212
|
|
|
91
|
-
it("drops every cycle hue within the confusable band (tomato drops red and
|
|
213
|
+
it("drops every cycle hue within the confusable band (tomato drops red, orange and amber)", () => {
|
|
92
214
|
const palette = resolveChartPalette({ scheme: "light", identitySolid: tomatoTintSolid });
|
|
93
215
|
expect(palette.categorical).not.toContain(Colors.red.red9);
|
|
94
|
-
expect(palette.categorical).not.toContain(Colors.orange.
|
|
95
|
-
expect(palette.categorical).
|
|
216
|
+
expect(palette.categorical).not.toContain(Colors.orange.orange11);
|
|
217
|
+
expect(palette.categorical).not.toContain(Colors.amber.amber11);
|
|
218
|
+
expect(palette.categorical).toHaveLength(6);
|
|
96
219
|
});
|
|
97
220
|
|
|
98
221
|
it("keeps the full cycle behind a neutral identity (gray tint)", () => {
|
|
@@ -117,7 +240,7 @@ describe("resolveChartPalette", () => {
|
|
|
117
240
|
const light = resolveChartPalette({ scheme: "light", identitySolid: lightAccent });
|
|
118
241
|
const dark = resolveChartPalette({ scheme: "dark", identitySolid: darkAccent });
|
|
119
242
|
expect(light.semantic.error).toBe(Colors.red.red9);
|
|
120
|
-
expect(light.semantic.success).toBe(Colors.green.
|
|
243
|
+
expect(light.semantic.success).toBe(Colors.green.green10);
|
|
121
244
|
expect(light.semantic.warning).toBe(Colors.yellow.yellow11);
|
|
122
245
|
expect(dark.semantic.error).toBe(Colors.redDark.red9);
|
|
123
246
|
expect(dark.semantic.success).toBe(Colors.greenDark.green9);
|