diff --git a/site/.storybook/preview.tsx b/site/.storybook/preview.tsx
index ebfc1b383c..3810e033c9 100644
--- a/site/.storybook/preview.tsx
+++ b/site/.storybook/preview.tsx
@@ -14,6 +14,7 @@ import { QueryClient, QueryClientProvider } from "react-query";
import { withRouter } from "storybook-addon-remix-react-router";
import { TooltipProvider } from "../src/components/Tooltip/Tooltip";
import themes, { baseModeFor, isConcreteThemeName } from "../src/theme";
+import { AppearanceProvider } from "../src/theme/appearance";
DecoratorHelpers.initializeThemeState(Object.keys(themes), "dark");
@@ -115,10 +116,14 @@ const withTheme: Decorator = (Story, context) => {
-
-
-
-
+
+
+
+
+
+
diff --git a/site/src/components/Avatar/Avatar.tsx b/site/src/components/Avatar/Avatar.tsx
index 53f83b840c..d080ebed31 100644
--- a/site/src/components/Avatar/Avatar.tsx
+++ b/site/src/components/Avatar/Avatar.tsx
@@ -9,9 +9,9 @@
* It was also simplified to make usage easier and reduce boilerplate.
* @see {@link https://github.com/coder/coder/pull/15930#issuecomment-2552292440}
*/
-import { useTheme } from "@emotion/react";
import { cva, type VariantProps } from "class-variance-authority";
import { Avatar as AvatarPrimitive } from "radix-ui";
+import { useAppearance } from "#/theme/appearance";
import { getExternalImageStylesFromUrl } from "#/theme/externalImages";
import { cn } from "#/utils/cn";
@@ -75,7 +75,7 @@ export const Avatar: React.FC = ({
children,
...props
}) => {
- const theme = useTheme();
+ const { externalImages } = useAppearance();
return (
= ({
src={src}
alt={alt}
className="aspect-square size-full object-contain"
- style={getExternalImageStylesFromUrl(theme.externalImages, src)}
+ style={getExternalImageStylesFromUrl(externalImages, src)}
/>
{fallback && (
diff --git a/site/src/components/ExternalImage/ExternalImage.tsx b/site/src/components/ExternalImage/ExternalImage.tsx
index 339cbd6eb3..add8e223c6 100644
--- a/site/src/components/ExternalImage/ExternalImage.tsx
+++ b/site/src/components/ExternalImage/ExternalImage.tsx
@@ -1,4 +1,4 @@
-import { useTheme } from "@emotion/react";
+import { useAppearance } from "#/theme/appearance";
import { getExternalImageStylesFromUrl } from "#/theme/externalImages";
export const ExternalImage: React.FC> = ({
@@ -6,13 +6,13 @@ export const ExternalImage: React.FC> = ({
alt = "",
...props
}) => {
- const theme = useTheme();
+ const { externalImages } = useAppearance();
return (
= ({ theme, children }) => {
- {children}
+
+ {children}
+
diff --git a/site/src/pages/IconsPage/IconsPage.tsx b/site/src/pages/IconsPage/IconsPage.tsx
index b0de079354..5e022d18e6 100644
--- a/site/src/pages/IconsPage/IconsPage.tsx
+++ b/site/src/pages/IconsPage/IconsPage.tsx
@@ -1,4 +1,3 @@
-import { useTheme } from "@emotion/react";
import { SearchIcon, XIcon } from "lucide-react";
import { type FC, type ReactNode, useMemo, useState } from "react";
import uFuzzy from "ufuzzy";
@@ -18,6 +17,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "#/components/Tooltip/Tooltip";
+import { useAppearance } from "#/theme/appearance";
import { DEPRECATED_ICONS } from "#/theme/deprecatedIcons";
import {
defaultParametersForBuiltinIcons,
@@ -40,7 +40,7 @@ const fuzzyFinder = new uFuzzy({
});
const IconsPage: FC = () => {
- const theme = useTheme();
+ const { externalImages } = useAppearance();
const [searchInputText, setSearchInputText] = useState("");
const searchText = searchInputText.trim();
@@ -145,7 +145,7 @@ const IconsPage: FC = () => {
src={icon.url}
className="size-16 object-contain pointer-events-none p-3"
style={parseImageParameters(
- theme.externalImages,
+ externalImages,
defaultParametersForBuiltinIcons.get(icon.url) ?? "",
)}
/>
diff --git a/site/src/theme/appearance.tsx b/site/src/theme/appearance.tsx
new file mode 100644
index 0000000000..40120856c7
--- /dev/null
+++ b/site/src/theme/appearance.tsx
@@ -0,0 +1,56 @@
+import {
+ createContext,
+ type FC,
+ type ReactNode,
+ useContext,
+ useMemo,
+} from "react";
+import type { ExternalImageModeStyles } from "#/theme/externalImages";
+
+/**
+ * Publishes *user* appearance values derived from the active site theme so
+ * components can consume them without depending on Emotion's `useTheme`.
+ *
+ * This is the client-side, theme-derived appearance surface (for example, how
+ * external images should be tinted for the active theme). It is intentionally
+ * distinct from the user `appearanceSettings` query (theme, terminal font) and
+ * from the deployment-level `AppearanceConfig` (application name, logo, service
+ * banners) that admins configure.
+ *
+ * Values are provided by the surrounding `AppearanceProvider` (see
+ * `ThemeOverride` and the Storybook preview decorator).
+ */
+interface Appearance {
+ externalImages: ExternalImageModeStyles;
+}
+
+const AppearanceContext = createContext(undefined);
+
+interface AppearanceProviderProps {
+ externalImages: ExternalImageModeStyles;
+ children: ReactNode;
+}
+
+export const AppearanceProvider: FC = ({
+ externalImages,
+ children,
+}) => {
+ const value = useMemo(
+ () => ({ externalImages }),
+ [externalImages],
+ );
+
+ return (
+
+ {children}
+
+ );
+};
+
+export const useAppearance = (): Appearance => {
+ const appearance = useContext(AppearanceContext);
+ if (appearance === undefined) {
+ throw new Error("useAppearance must be used within an AppearanceProvider");
+ }
+ return appearance;
+};