import * as Colors from "@tamagui/colors"; import { createThemes as tamaguiCreateThemes, type CreateThemesProps, } from "@tamagui/theme-builder"; import { themes as tamaguiThemes } from "@tamagui/themes"; /** * MPO-40. Radix aims a scale's step 11 at the AA text floor against ITS OWN * step 1-2, never against a foreign neutral. mpo paints intent ink on the BASE * theme's surfaces, and on `$color2` — `#f9f9fa`, the ground 35 catalog * surfaces and every gallery board actually paint — light `yellow11` * (`#9e6c00`) measures 4.34:1 and light `green11` (`#218358`) 4.48:1, both * under `colorRules`' `aaTextContrastRatio` of 4.5. So the stock stop is right * upstream and wrong here. * * Only these two stops move. Light `red11` clears at 4.95:1 and every dark * intent stop clears by 8.49:1 or more, measured, so red and both dark ramps * stay untouched stock Radix — as does every other step of yellow and green. * * The replacements are the smallest darkening that clears the floor on the * strictest base surface while holding the hue — dE76 1.94 for warning and * 0.70 for success, both far under the ~2.3 just-noticeable difference, so * neither intent hue visibly shifts. * * THESE ARE QUANTIZED VALUES, and that is not a detail. `createThemes` stores * every palette entry through color2k as `hsla()` with INTEGER degrees and * percentages, so the reachable palette is coarser than hex and a hex chosen * on paper is not what renders. The first pass here picked `#996800` / * `#208156` by minimising CIEDE2000 in hex space; `#208156` quantized to the * SAME `hsla(154, 60%, 32%)` as the stock stop and changed nothing at all, * while `#996800` landed two hundredths off its predicted ratio. Both values * below were searched in the quantized space and confirmed against the built * theme, not against the arithmetic. * * `$color11` is ALSO the solid intent Button's resting fill (`step(10)` in * `defaults/builderOptions.ts`), so this shifts that fill by the same * imperceptible amount. It improves it: the solid warning Button's * luminance-picked on-fill label was itself at 4.48:1 — a second, unpinned AA * miss. The `pickReadableForeground` anchor does not flip for either intent, * so no label changes colour. */ const intentInkFloor = { /** stored `hsla(41, 100%, 30%)`; 4.34:1 -> 4.56:1 on the base `$color2` */ warning: "#996900", /** stored `hsla(155, 64%, 31%)`; 4.48:1 -> 4.56:1 on the base `$color2` */ success: "#1c8257", } as const; /** * Replace a Radix ramp's step-11 ink stop, leaving all eleven other steps * stock. Radix ramps are 12 entries with step N at index N-1; anything else is * not a ramp this rule understands, so it passes through untouched rather than * writing to a position that means something different. */ function withIntentInkFloor(palette: string[], ink: string): string[] { if (palette.length !== 12) return palette; const stepped = [...palette]; stepped[10] = ink; return stepped; } const grayTheme = { dark_gray: tamaguiThemes.dark, dark_gray_active: tamaguiThemes.dark_active, dark_gray_alt1: tamaguiThemes.dark_alt1, dark_gray_alt2: tamaguiThemes.dark_alt2, light_gray: tamaguiThemes.light, light_gray_active: tamaguiThemes.light_active, light_gray_alt1: tamaguiThemes.light_alt1, light_gray_alt2: tamaguiThemes.light_alt2, }; export interface Shadows { shadow1: string; shadow2: string; shadow3: string; shadow4: string; shadow5: string; shadow6: string; } export interface ThemeBuilderDefinition< DarkPalette extends string[] = string[], LightPalette extends string[] = string[], > { darkPalette: DarkPalette; lightPalette: LightPalette; } export interface BaseThemeBuilderDefinition< DarkPalette extends string[] = string[], LightPalette extends string[] = string[], DarkShadows extends Shadows = Shadows, LightShadows extends Shadows = Shadows, > extends ThemeBuilderDefinition { darkShadows?: DarkShadows; lightShadows?: LightShadows; } export interface GetThemeProps { name: string; theme: Record; scheme?: "light" | "dark"; parentName: string; parentNames: string[]; level: number; palette?: string[]; } export interface CreateThemesBuilderOptions { grandChildrenThemes?: CreateThemesProps["grandChildrenThemes"]; getTheme?: (props: GetThemeProps) => Record; } /** * Wrap a `getTheme` so entries whose value is nullish are dropped. Tamagui's * ThemeBuilder spreads the result OVER the template-derived theme * (`{...theme, ...getTheme(props)}`), so a key that resolves to `undefined` * (e.g. a consumer getTheme reading `theme.color5` inside a narrow component * sub-theme that has no colorN steps) would otherwise erase the template's * real value and reach the CSS variable emitter as the literal string * "undefined" — t_Button's `--backgroundPress`/`--borderColorHover` blanked * pressed fills and drew phantom currentColor borders (DF-03). */ function sanitizeGetTheme( getTheme: NonNullable, ): NonNullable { return (props: GetThemeProps) => { const out: Record = {}; for (const [key, value] of Object.entries(getTheme(props))) { if (value != null) out[key] = value; } return out; }; } // Radix hue names whose sub-themes act as decorative TINTS (Tint.tsx cycling, // the storybook `color` global, `theme="red"` field tinting). Under a tint, // surfaces/borders/solids keep the hue but TEXT must stay readable neutral: // muted/secondary text and user-authored content must never render saturated // accent (SB-D-14/SB-D-20). Exported so theme-identity consumers (chart // palette) share the one tint registry. export const tintHueNames = new Set([ "amber", "blue", "bronze", "brown", "crimson", "cyan", "gold", "grass", "gray", "green", "indigo", "lime", "mauve", "mint", "olive", "orange", "pink", "plum", "purple", "red", "sage", "sand", "sky", "slate", "teal", "tomato", "violet", "yellow", ]); // Text-tier tokens re-anchored to the neutral scheme ramp inside tint themes. const tintNeutralTextKeys = [ "color", "colorHover", "colorPress", "colorFocus", "color11", "color12", "placeholderColor", ] as const; /** * Rebuild tint sub-themes (light_purple, dark_red_active, ...) so their * text tiers come from the neutral base scheme while backgrounds, borders * and solid steps (1-10) keep the hue. Only keys the tint theme already * defines are replaced; returns new objects, never mutates inputs. */ function neutralizeTintText( themes: Record>, ): Record> { const out: Record> = { ...themes }; for (const [name, theme] of Object.entries(themes)) { const parts = name.split("_"); if (parts.length < 2) continue; const scheme = parts[0]; if (scheme !== "light" && scheme !== "dark") continue; if (!tintHueNames.has(parts[1])) continue; const base = themes[scheme]; if (!base) continue; const patched: Record = { ...theme }; for (const key of tintNeutralTextKeys) { if (key in patched && base[key] != null) patched[key] = base[key]; } out[name] = patched; } return out; } const defaultLightShadows: Shadows = { shadow1: "rgba(0,0,0,0.12)", shadow2: "rgba(0,0,0,0.16)", shadow3: "rgba(0,0,0,0.22)", shadow4: "rgba(0,0,0,0.28)", shadow5: "rgba(0,0,0,0.34)", shadow6: "rgba(0,0,0,0.4)", }; const defaultDarkShadows: Shadows = { shadow1: "rgba(0,0,0,0.2)", shadow2: "rgba(0,0,0,0.3)", shadow3: "rgba(0,0,0,0.4)", shadow4: "rgba(0,0,0,0.5)", shadow5: "rgba(0,0,0,0.6)", shadow6: "rgba(0,0,0,0.7)", }; export function createThemesBuilder( baseTheme: BaseThemeBuilderDefinition, accentTheme: ThemeBuilderDefinition, options?: CreateThemesBuilderOptions, ) { const getTheme = options?.getTheme ? sanitizeGetTheme(options.getTheme) : undefined; const base = { palette: { dark: baseTheme.darkPalette, light: baseTheme.lightPalette, }, extra: { light: { ...Colors.amber, ...Colors.amberA, ...Colors.blue, ...Colors.blueA, ...Colors.bronze, ...Colors.bronzeA, ...Colors.brown, ...Colors.brownA, ...Colors.crimson, ...Colors.crimsonA, ...Colors.cyan, ...Colors.cyanA, ...Colors.gold, ...Colors.goldA, ...Colors.grass, ...Colors.grassA, ...Colors.gray, ...Colors.grayA, ...Colors.green, ...Colors.greenA, ...Colors.indigo, ...Colors.indigoA, ...Colors.lime, ...Colors.limeA, ...Colors.mauve, ...Colors.mauveA, ...Colors.mint, ...Colors.mintA, ...Colors.olive, ...Colors.oliveA, ...Colors.orange, ...Colors.orangeA, ...Colors.pink, ...Colors.pinkA, ...Colors.plum, ...Colors.plumA, ...Colors.purple, ...Colors.purpleA, ...Colors.red, ...Colors.redA, ...Colors.sage, ...Colors.sageA, ...Colors.sand, ...Colors.sandA, ...Colors.sky, ...Colors.skyA, ...Colors.slate, ...Colors.slateA, ...Colors.teal, ...Colors.tealA, ...Colors.tomato, ...Colors.tomatoA, ...Colors.violet, ...Colors.violetA, ...Colors.yellow, ...Colors.yellowA, ...baseTheme.lightShadows, shadowColor: baseTheme.lightShadows?.shadow1 || defaultLightShadows.shadow1, }, dark: { ...Colors.amberDark, ...Colors.amberDarkA, ...Colors.blueDark, ...Colors.blueDarkA, ...Colors.bronzeDark, ...Colors.bronzeDarkA, ...Colors.brownDark, ...Colors.brownDarkA, ...Colors.crimsonDark, ...Colors.crimsonDarkA, ...Colors.cyanDark, ...Colors.cyanDarkA, ...Colors.goldDark, ...Colors.goldDarkA, ...Colors.grassDark, ...Colors.grassDarkA, ...Colors.grayDark, ...Colors.grayDarkA, ...Colors.greenDark, ...Colors.greenDarkA, ...Colors.indigoDark, ...Colors.indigoDarkA, ...Colors.limeDark, ...Colors.limeDarkA, ...Colors.mauveDark, ...Colors.mauveDarkA, ...Colors.mintDark, ...Colors.mintDarkA, ...Colors.oliveDark, ...Colors.oliveDarkA, ...Colors.orangeDark, ...Colors.orangeDarkA, ...Colors.pinkDark, ...Colors.pinkDarkA, ...Colors.plumDark, ...Colors.plumDarkA, ...Colors.purpleDark, ...Colors.purpleDarkA, ...Colors.redDark, ...Colors.redDarkA, ...Colors.sageDark, ...Colors.sageDarkA, ...Colors.sandDark, ...Colors.sandDarkA, ...Colors.skyDark, ...Colors.skyDarkA, ...Colors.slateDark, ...Colors.slateDarkA, ...Colors.tealDark, ...Colors.tealDarkA, ...Colors.tomatoDark, ...Colors.tomatoDarkA, ...Colors.violetDark, ...Colors.violetDarkA, ...Colors.yellowDark, ...Colors.yellowDarkA, ...baseTheme.darkShadows, shadowColor: baseTheme.darkShadows?.shadow1 || defaultDarkShadows.shadow1, }, }, }; const childrenThemes = { warning: { palette: { dark: Object.values(Colors.yellowDark), light: withIntentInkFloor(Object.values(Colors.yellow), intentInkFloor.warning), }, }, error: { palette: { dark: Object.values(Colors.redDark), light: Object.values(Colors.red), }, }, success: { palette: { dark: Object.values(Colors.greenDark), light: withIntentInkFloor(Object.values(Colors.green), intentInkFloor.success), }, }, }; const createThemesProps: Record = { base, childrenThemes, accent: { palette: { dark: accentTheme.darkPalette, light: accentTheme.lightPalette, }, }, }; if (options?.grandChildrenThemes) { createThemesProps.grandChildrenThemes = options.grandChildrenThemes; } if (getTheme) { createThemesProps.getTheme = getTheme; } const initialTheme = tamaguiCreateThemes( createThemesProps as Parameters[0], ); const themes = neutralizeTintText({ ...grayTheme, ...tamaguiThemes, ...initialTheme, }); return { themes() { return themes; }, addTheme(name: K, theme: ThemeBuilderDefinition) { const addThemeProps: Record = { base, childrenThemes, accent: { palette: { dark: theme.darkPalette, light: theme.lightPalette, }, }, }; if (options?.grandChildrenThemes) { addThemeProps.grandChildrenThemes = options.grandChildrenThemes; } if (getTheme) { addThemeProps.getTheme = getTheme; } const createdTheme = tamaguiCreateThemes( addThemeProps as Parameters[0], ); return { [`dark_${name}`]: createdTheme.dark_accent, [`light_${name}`]: createdTheme.light_accent, } as Record<`dark_${K}` | `light_${K}`, typeof createdTheme.dark_accent>; }, }; }