mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) ?? "",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user