authorgravatar for cam.avarley@gmail.comCameron Varley <cam.avarley@gmail.com> 2026-10-11 02:47:38-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-11 01:47:38-05:00
log5310a4a64fd308236e819e192968a9f9d7bd3c45
tree978d4d48f4b48a8b81df0d6ca89a2d669ef64a27
parent6fa0dd8ccabfee961ba01ea92267eded7d07cc51
signature Signed by PGP key B5690EEEBB952194

fix(web): pass hex theme colors to HTML renders (#16325)


2 files changed, 111 insertions(+), 4 deletions(-)

apps/web/src/hooks/useHtmlRenderTheme.test.ts created+100
...@@ -0,0 +1,100 @@
1import { HTML_RENDER_DEFAULT_FONTS, type HtmlRenderTheme } from "@t3tools/shared/htmlRender";
2import { BUILT_IN_THEME_IDS } from "@t3tools/shared/themePalettes";
3import { act, createElement, useEffect } from "react";
4import { create, type ReactTestRenderer } from "react-test-renderer";
5import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
6
7import {
8 getStandardThemeColors,
9 setEnvironmentThemes,
10 type ThemeAppearance,
11 type ThemeHalves,
12 type ThemePreference,
13} from "../themePalette";
14import { useHtmlRenderTheme } from "./useHtmlRenderTheme";
15
16const selection = vi.hoisted(() => ({
17 theme: "ocean" as ThemePreference,
18 resolvedTheme: "dark" as ThemeAppearance,
19 themeHalves: null as ThemeHalves | null,
20}));
21
22vi.mock("./useTheme", () => ({ useTheme: () => selection }));
23vi.mock("./useSettings", () => ({
24 useClientSettings: (
25 select: (settings: { fontFamilySans: string; fontFamilyCode: string }) => string,
26 ) => select({ fontFamilySans: "", fontFamilyCode: "" }),
27}));
28
29let renderer: ReactTestRenderer | undefined;
30
31beforeEach(() => {
32 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
33 selection.themeHalves = null;
34});
35
36afterEach(() => {
37 act(() => renderer?.unmount());
38 renderer = undefined;
39 setEnvironmentThemes([]);
40 vi.unstubAllGlobals();
41});
42
43/** Returns the committed hook result after React has flushed mount effects. */
44function readRenderTheme(): HtmlRenderTheme {
45 let theme: HtmlRenderTheme | undefined;
46 /** Captures the theme in an effect to avoid mutating test state during render. */
47 function Probe() {
48 const resolved = useHtmlRenderTheme();
49 useEffect(() => {
50 theme = resolved;
51 }, [resolved]);
52 return null;
53 }
54 act(() => {
55 renderer = create(createElement(Probe));
56 });
57 if (!theme) throw new Error("The HTML render theme was not resolved.");
58 return theme;
59}
60
61describe("useHtmlRenderTheme", () => {
62 for (const appearance of ["light", "dark"] as const) {
63 it.each([appearance, ...BUILT_IN_THEME_IDS])(
64 `injects hex colors for %s in ${appearance} mode`,
65 (preference) => {
66 selection.theme = preference;
67 selection.resolvedTheme = appearance;
68
69 const theme = readRenderTheme();
70
71 expect(theme.appearance).toBe(appearance);
72 for (const [variable, value] of Object.entries(theme.variables)) {
73 if (variable === "--radius" || variable.startsWith("--font-")) continue;
74 expect(value, variable).toMatch(/^#[\da-f]{6}(?:[\da-f]{2})?$/i);
75 }
76 expect(theme.variables["--font-sans"]).toBe(HTML_RENDER_DEFAULT_FONTS.sans);
77 expect(theme.variables["--font-mono"]).toBe(HTML_RENDER_DEFAULT_FONTS.mono);
78 expect(theme.variables["--radius"]).toBe("0.625rem");
79 },
80 );
81 }
82
83 it("preserves alpha when converting a published palette", () => {
84 setEnvironmentThemes([
85 {
86 id: "transparent",
87 label: "Transparent",
88 appearance: "dark",
89 colors: { ...getStandardThemeColors("dark"), accent: "oklch(0.5 0 0 / 0.5)" },
90 },
91 ]);
92 selection.theme = "transparent";
93 selection.resolvedTheme = "dark";
94
95 const theme = readRenderTheme();
96
97 expect(theme.variables["--accent"]).toBe("#63636380");
98 expect(theme.variables["--chart-1"]).toBe("#63636380");
99 });
100});
apps/web/src/hooks/useHtmlRenderTheme.ts+11-4
...@@ -8,6 +8,7 @@ import {...@@ -8,6 +8,7 @@ import {
8 getThemeDefinition,8 getThemeDefinition,
9 resolveThemeHalf,9 resolveThemeHalf,
10 subscribeToCustomThemes,10 subscribeToCustomThemes,
11 themeColorToHex,
11 type ThemeAppearance,12 type ThemeAppearance,
12 type ThemeHalves,13 type ThemeHalves,
13 type ThemePreference,14 type ThemePreference,
...@@ -40,10 +41,16 @@ export function useHtmlRenderTheme() {...@@ -40,10 +41,16 @@ export function useHtmlRenderTheme() {
40 const mono = useClientSettings((settings) => settings.fontFamilyCode);41 const mono = useClientSettings((settings) => settings.fontFamilyCode);
41 return useMemo(42 return useMemo(
42 () =>43 () =>
43 htmlRenderTheme(colors, resolvedTheme, {44 htmlRenderTheme(
44 sans: appearanceFontStack(sans, HTML_RENDER_DEFAULT_FONTS.sans),45 Object.fromEntries(
45 mono: appearanceFontStack(mono, HTML_RENDER_DEFAULT_FONTS.mono),46 Object.entries(colors).map(([role, color]) => [role, themeColorToHex(color) ?? color]),
46 }),47 ) as typeof colors,
48 resolvedTheme,
49 {
50 sans: appearanceFontStack(sans, HTML_RENDER_DEFAULT_FONTS.sans),
51 mono: appearanceFontStack(mono, HTML_RENDER_DEFAULT_FONTS.mono),
52 },
53 ),
47 [colors, resolvedTheme, sans, mono],54 [colors, resolvedTheme, sans, mono],
48 );55 );
49}56}