feat: add AppearanceProvider to decouple externalImages from theme (#27197)

> 🤖 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.
This commit is contained in:
Jake Howell
2026-07-27 08:54:28 +00:00
committed by GitHub
parent 54bdb48cac
commit 88c7304e0b
6 changed files with 78 additions and 14 deletions
+9 -4
View File
@@ -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) => {
<StyledEngineProvider injectFirst>
<MuiThemeProvider theme={themes[concreteName]}>
<EmotionThemeProvider theme={themes[concreteName]}>
<TooltipProvider delayDuration={100}>
<CssBaseline />
<Story />
</TooltipProvider>
<AppearanceProvider
externalImages={themes[concreteName].externalImages}
>
<TooltipProvider delayDuration={100}>
<CssBaseline />
<Story />
</TooltipProvider>
</AppearanceProvider>
</EmotionThemeProvider>
</MuiThemeProvider>
</StyledEngineProvider>
+3 -3
View File
@@ -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<AvatarProps> = ({
children,
...props
}) => {
const theme = useTheme();
const { externalImages } = useAppearance();
return (
<AvatarPrimitive.Root
@@ -86,7 +86,7 @@ export const Avatar: React.FC<AvatarProps> = ({
src={src}
alt={alt}
className="aspect-square size-full object-contain"
style={getExternalImageStylesFromUrl(theme.externalImages, src)}
style={getExternalImageStylesFromUrl(externalImages, src)}
/>
{fallback && (
<AvatarPrimitive.Fallback className="flex h-full w-full items-center justify-center rounded-full">
@@ -1,4 +1,4 @@
import { useTheme } from "@emotion/react";
import { useAppearance } from "#/theme/appearance";
import { getExternalImageStylesFromUrl } from "#/theme/externalImages";
export const ExternalImage: React.FC<React.ComponentPropsWithRef<"img">> = ({
@@ -6,13 +6,13 @@ export const ExternalImage: React.FC<React.ComponentPropsWithRef<"img">> = ({
alt = "",
...props
}) => {
const theme = useTheme();
const { externalImages } = useAppearance();
return (
<img
alt={alt}
style={{
...getExternalImageStylesFromUrl(theme.externalImages, props.src),
...getExternalImageStylesFromUrl(externalImages, props.src),
...style,
}}
{...props}
+4 -1
View File
@@ -21,6 +21,7 @@ import { useQuery } from "react-query";
import { appearanceSettings } from "#/api/queries/users";
import { useEmbeddedMetadata } from "#/hooks/useEmbeddedMetadata";
import themes, { baseModeFor, CONCRETE_THEMES, type Theme } from "#/theme";
import { AppearanceProvider } from "#/theme/appearance";
import {
migrateLegacyPreference,
resolveActiveThemeName,
@@ -81,7 +82,9 @@ export const ThemeOverride: FC<ThemeOverrideProps> = ({ theme, children }) => {
<MuiThemeProvider theme={theme}>
<EmotionThemeProvider theme={theme}>
<CssBaseline enableColorScheme />
{children}
<AppearanceProvider externalImages={theme.externalImages}>
{children}
</AppearanceProvider>
</EmotionThemeProvider>
</MuiThemeProvider>
</CacheProvider>
+3 -3
View File
@@ -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) ?? "",
)}
/>
+56
View File
@@ -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<Appearance | undefined>(undefined);
interface AppearanceProviderProps {
externalImages: ExternalImageModeStyles;
children: ReactNode;
}
export const AppearanceProvider: FC<AppearanceProviderProps> = ({
externalImages,
children,
}) => {
const value = useMemo<Appearance>(
() => ({ externalImages }),
[externalImages],
);
return (
<AppearanceContext.Provider value={value}>
{children}
</AppearanceContext.Provider>
);
};
export const useAppearance = (): Appearance => {
const appearance = useContext(AppearanceContext);
if (appearance === undefined) {
throw new Error("useAppearance must be used within an AppearanceProvider");
}
return appearance;
};