From 88c7304e0bcc390fa67433903e2f516ed836733d Mon Sep 17 00:00:00 2001 From: Jake Howell Date: Mon, 27 Jul 2026 18:54:28 +1000 Subject: [PATCH] feat: add `AppearanceProvider` to decouple `externalImages` from theme (#27197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit > 🤖 This PR was modified by Coder Agents on behalf of Jake Howell. ## What Introduces an `AppearanceProvider` / `useAppearance` context that publishes **user** appearance values derived from the active site theme, and migrates the current consumers of `theme.externalImages` (`Avatar`, `ExternalImage`, `IconsPage`) to read from it. ## Why Today, per-user appearance concerns like `externalImages` are smuggled onto the Emotion theme object, which forces components to pull in `useTheme` purely to reach a single appearance value. That couples "how something looks for this user" to "how the styling engine happens to be wired", and means every new user-appearance value has to be bolted onto the theme. This context gives user appearance a home of its own, decoupled from Emotion. Components ask for what they actually need (`const { externalImages } = useAppearance()`) instead of reaching through the theme. ## Scope: user appearance, not admin appearance To be explicit, this provider is about **user-level** appearance — the per-user, theme-derived rendering concerns. It is deliberately *not* the deployment-level `AppearanceConfig` (application name/logo, service banners, support/docs links) that admins configure; that is a separate concern with its own data source and shouldn't be folded in here. ## Future scope `externalImages` is the **first** value to move here, not the only one. `Appearance` is deliberately modelled as an open interface so future *user* appearance state can live in one place without touching every consumer or overloading the theme again. Likely candidates are other per-user, theme-derived values, e.g.: - Terminal font / other typography preferences currently surfaced via user appearance settings. - Theme mode and other theme-derived rendering styles that follow the same "read one value off the theme" pattern as `externalImages`. - Accessibility-oriented rendering preferences (e.g. reduced motion) as they're added. Centralising these behind a single provider keeps consumers stable as the surface grows and avoids re-litigating the `useTheme` coupling each time (laziness now, less maintenance later). ## Changes - Add `site/src/theme/appearance.tsx` (`AppearanceProvider`, `useAppearance`), defaulting `externalImages` to `forDarkThemes` to match `DEFAULT_THEME`. - Wrap children with `AppearanceProvider` in `ThemeOverride` and in the Storybook preview decorator. - Migrate `Avatar`, `ExternalImage`, and `IconsPage` off `theme.externalImages` and onto `useAppearance`. ## Notes - Kept as a **draft** pending the go-ahead to open for review. - No behavioural change intended; this is a plumbing/refactor step. --- site/.storybook/preview.tsx | 13 +++-- site/src/components/Avatar/Avatar.tsx | 6 +- .../ExternalImage/ExternalImage.tsx | 6 +- site/src/contexts/ThemeProvider.tsx | 5 +- site/src/pages/IconsPage/IconsPage.tsx | 6 +- site/src/theme/appearance.tsx | 56 +++++++++++++++++++ 6 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 site/src/theme/appearance.tsx 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 ( {alt} = ({ 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; +};