diff --git a/apps/web/src/hooks/useHtmlRenderTheme.test.ts b/apps/web/src/hooks/useHtmlRenderTheme.test.ts
new file mode 100644
index 0000000000000000000000000000000000000000..0da9b2d111ec0730b7dffeda6dfde1f0d1348ecc
--- /dev/null
+++ b/apps/web/src/hooks/useHtmlRenderTheme.test.ts
@@ -0,0 +1,100 @@
+import { HTML_RENDER_DEFAULT_FONTS, type HtmlRenderTheme } from "@t3tools/shared/htmlRender";
+import { BUILT_IN_THEME_IDS } from "@t3tools/shared/themePalettes";
+import { act, createElement, useEffect } from "react";
+import { create, type ReactTestRenderer } from "react-test-renderer";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
+
+import {
+ getStandardThemeColors,
+ setEnvironmentThemes,
+ type ThemeAppearance,
+ type ThemeHalves,
+ type ThemePreference,
+} from "../themePalette";
+import { useHtmlRenderTheme } from "./useHtmlRenderTheme";
+
+const selection = vi.hoisted(() => ({
+ theme: "ocean" as ThemePreference,
+ resolvedTheme: "dark" as ThemeAppearance,
+ themeHalves: null as ThemeHalves | null,
+}));
+
+vi.mock("./useTheme", () => ({ useTheme: () => selection }));
+vi.mock("./useSettings", () => ({
+ useClientSettings: (
+ select: (settings: { fontFamilySans: string; fontFamilyCode: string }) => string,
+ ) => select({ fontFamilySans: "", fontFamilyCode: "" }),
+}));
+
+let renderer: ReactTestRenderer | undefined;
+
+beforeEach(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ selection.themeHalves = null;
+});
+
+afterEach(() => {
+ act(() => renderer?.unmount());
+ renderer = undefined;
+ setEnvironmentThemes([]);
+ vi.unstubAllGlobals();
+});
+
+/** Returns the committed hook result after React has flushed mount effects. */
+function readRenderTheme(): HtmlRenderTheme {
+ let theme: HtmlRenderTheme | undefined;
+ /** Captures the theme in an effect to avoid mutating test state during render. */
+ function Probe() {
+ const resolved = useHtmlRenderTheme();
+ useEffect(() => {
+ theme = resolved;
+ }, [resolved]);
+ return null;
+ }
+ act(() => {
+ renderer = create(createElement(Probe));
+ });
+ if (!theme) throw new Error("The HTML render theme was not resolved.");
+ return theme;
+}
+
+describe("useHtmlRenderTheme", () => {
+ for (const appearance of ["light", "dark"] as const) {
+ it.each([appearance, ...BUILT_IN_THEME_IDS])(
+ `injects hex colors for %s in ${appearance} mode`,
+ (preference) => {
+ selection.theme = preference;
+ selection.resolvedTheme = appearance;
+
+ const theme = readRenderTheme();
+
+ expect(theme.appearance).toBe(appearance);
+ for (const [variable, value] of Object.entries(theme.variables)) {
+ if (variable === "--radius" || variable.startsWith("--font-")) continue;
+ expect(value, variable).toMatch(/^#[\da-f]{6}(?:[\da-f]{2})?$/i);
+ }
+ expect(theme.variables["--font-sans"]).toBe(HTML_RENDER_DEFAULT_FONTS.sans);
+ expect(theme.variables["--font-mono"]).toBe(HTML_RENDER_DEFAULT_FONTS.mono);
+ expect(theme.variables["--radius"]).toBe("0.625rem");
+ },
+ );
+ }
+
+ it("preserves alpha when converting a published palette", () => {
+ setEnvironmentThemes([
+ {
+ id: "transparent",
+ label: "Transparent",
+ appearance: "dark",
+ colors: { ...getStandardThemeColors("dark"), accent: "oklch(0.5 0 0 / 0.5)" },
+ },
+ ]);
+ selection.theme = "transparent";
+ selection.resolvedTheme = "dark";
+
+ const theme = readRenderTheme();
+
+ expect(theme.variables["--accent"]).toBe("#63636380");
+ expect(theme.variables["--chart-1"]).toBe("#63636380");
+ });
+});
diff --git a/apps/web/src/hooks/useHtmlRenderTheme.ts b/apps/web/src/hooks/useHtmlRenderTheme.ts
index d7492eb6f9b87c117d9b77ba58cfab75d8c3baba..838ec6308aba6da31e0efeb1e129efa83f26dae4 100644
--- a/apps/web/src/hooks/useHtmlRenderTheme.ts
+++ b/apps/web/src/hooks/useHtmlRenderTheme.ts
@@ -8,6 +8,7 @@ import {
getThemeDefinition,
resolveThemeHalf,
subscribeToCustomThemes,
+ themeColorToHex,
type ThemeAppearance,
type ThemeHalves,
type ThemePreference,
@@ -40,10 +41,16 @@ export function useHtmlRenderTheme() {
const mono = useClientSettings((settings) => settings.fontFamilyCode);
return useMemo(
() =>
- htmlRenderTheme(colors, resolvedTheme, {
- sans: appearanceFontStack(sans, HTML_RENDER_DEFAULT_FONTS.sans),
- mono: appearanceFontStack(mono, HTML_RENDER_DEFAULT_FONTS.mono),
- }),
+ htmlRenderTheme(
+ Object.fromEntries(
+ Object.entries(colors).map(([role, color]) => [role, themeColorToHex(color) ?? color]),
+ ) as typeof colors,
+ resolvedTheme,
+ {
+ sans: appearanceFontStack(sans, HTML_RENDER_DEFAULT_FONTS.sans),
+ mono: appearanceFontStack(mono, HTML_RENDER_DEFAULT_FONTS.mono),
+ },
+ ),
[colors, resolvedTheme, sans, mono],
);
}