# Files changed · PR \#23861 · pingdotgg/t3code

[View on GitCafe](https://git.cafe/pingdotgg/t3code/pull/23861/diffs)

Repository: [pingdotgg/t3code](https://git.cafe/pingdotgg/t3code)

Visibility: public

[fix\(web\): keep environment themes on reload](https://git.cafe/pingdotgg/t3code/pull/23861.md)

Version: 3

Head commit: 005ad22101207e347e5b8c91576b77de8cf19996

Base commit: 2c8bdf6177159d8fcd12a897d7c9311f6eb93237

Comparison base: 8d3b061121fe65669d5d43d011b48cd3027a4108

Showing up to 20 entries from this revision. If the pull request changes, reopen the first page before continuing.

## apps/web/index.html

Status: modified; +12; −5

```diff
@@ -15,6 +15,7 @@
         const LIGHT_BACKGROUND = "#ffffff";
         const DARK_BACKGROUND = "#0a0a0a";
         const CUSTOM_THEMES_STORAGE_KEY = "t3code:themes:v1";
+        const ENVIRONMENT_THEMES_STORAGE_KEY = "t3code:environment-themes:v1";
         const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system";
         const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode";
         const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1";
@@ -201,12 +202,9 @@
               (appearance === value.appearance || isRecord(colors)),
           );
         };
+        const findStoredTheme = (storageKey, themeId) => {
-        const findCustomTheme = (themeId) => {
-          if (RESERVED_THEME_IDS.includes(themeId)) return null;
           try {
+            const parsed = JSON.parse(window.localStorage.getItem(storageKey) ?? "null");
-            const parsed = JSON.parse(
-              window.localStorage.getItem(CUSTOM_THEMES_STORAGE_KEY) ?? "null",
-            );
             if (!Array.isArray(parsed)) return null;
             return (
               parsed.find((value) => isStoredCustomTheme(value) && value.id === themeId) ?? null
@@ -215,6 +213,15 @@
             return null;
           }
         };
+        // A saved custom theme outranks an environment theme with the same id,
+        // as in the runtime's getThemeDefinition.
+        const findCustomTheme = (themeId) => {
+          if (RESERVED_THEME_IDS.includes(themeId)) return null;
+          return (
+            findStoredTheme(CUSTOM_THEMES_STORAGE_KEY, themeId) ??
+            findStoredTheme(ENVIRONMENT_THEMES_STORAGE_KEY, themeId)
+          );
+        };
 
         try {
           const storedTheme = window.localStorage.getItem("t3code:theme");
```

## apps/web/src/hooks/useEnvironmentTheme.ts

Status: modified; +2; −0

```diff
@@ -95,6 +95,8 @@
   const lastPublished = useRef<ReadonlyArray<EnvironmentTheme> | null>(null);
 
   useEffect(() => {
+    // Keep the stored snapshot painted until the server says what it publishes.
+    if (published === null) return;
     // Every reconnect snapshot delivers a fresh but usually identical array;
     // regenerating palettes and repainting the document for it is exactly the
     // wasted-frame class this codebase audits for.
```

## apps/web/src/state/server.ts

Status: modified; +12; −2

```diff
@@ -173,9 +173,19 @@
 
 const EMPTY_ENVIRONMENT_THEMES: ReadonlyArray<EnvironmentTheme> = [];
 
+/**
+ * Null until the server has said what it publishes: snapshots never carry
+ * themes, so a capable server's config has none until its theme event lands.
+ */
 export const primaryServerEnvironmentThemesAtom = Atom.make(
-  (get): ReadonlyArray<EnvironmentTheme> =>
+  (get): ReadonlyArray<EnvironmentTheme> | null => {
-    get(primaryServerConfigAtom)?.environmentThemes ?? EMPTY_ENVIRONMENT_THEMES,
+    const config = get(primaryServerConfigAtom);
+    if (!config) return null;
+    if (config.environmentThemes) return config.environmentThemes;
+    return config.environment.capabilities.environmentThemes === true
+      ? null
+      : EMPTY_ENVIRONMENT_THEMES;
+  },
 ).pipe(Atom.withLabel("web-primary-server-environment-themes"));
 
 /** Providers for views that do not show `checkedAt`; see {@link reuseProvidersIgnoringCheckedAt}. */
```

## apps/web/src/themeBoot.test.ts

Status: modified; +36; −0

```diff
@@ -1,6 +1,7 @@
 import { describe, expect, it, vi } from "vite-plus/test";
 
 import indexHtml from "../index.html?raw";
+import { environmentThemeDefinition } from "./hooks/useEnvironmentTheme";
 import {
   CUSTOM_THEMES_STORAGE_KEY,
   getDefaultThemeColors,
@@ -8,6 +9,7 @@
   invalidateCustomThemes,
   isKnownThemePreference,
   resolveThemeAppearance,
+  setEnvironmentThemes,
   T3_CHAT_THEME,
   EMBER_THEME,
   GROVE_THEME,
@@ -346,6 +348,40 @@
     );
   });
 
+  it("paints the last published environment theme before the server connects", () => {
+    const storage: Record<string, string> = {
+      [THEME_STORAGE_KEY]: "system",
+      [THEME_APPEARANCE_MODE_STORAGE_KEY]: "dark",
+      "t3code:theme-halves:v1": JSON.stringify({ light: "ghostty", dark: "ghostty" }),
+    };
+    const published = environmentThemeDefinition({
+      id: "ghostty",
+      name: "Ghostty",
+      appearance: "light",
+      colors: { canvas: "#f5f5f5", chrome: "#ececec" },
+      variants: { dark: { canvas: "#282c34", chrome: "#21252b", text: "#dcdfe4" } },
+    });
+    vi.stubGlobal("window", {
+      localStorage: {
+        getItem: (key: string) => storage[key] ?? null,
+        setItem: (key: string, value: string) => {
+          storage[key] = value;
+        },
+      },
+    });
+    try {
+      setEnvironmentThemes([published]);
+    } finally {
+      vi.unstubAllGlobals();
+    }
+
+    const boot = runBootScript({ storage, prefersDark: true });
+    const dark = getThemeColorsForMode(published, "dark")!;
+    expect(boot.themeId).toBe("ghostty");
+    expect(boot.bootVariables["--boot-background"]).toBe(dark.canvas);
+    expect(boot.backgroundColor).toBe(dark.chrome);
+  });
+
   it("uses runtime defaults for malformed custom roles", () => {
     const boot = runBootScript({
       storage: {
```

## apps/web/src/themePalette.ts

Status: modified; +25; −5

```diff
@@ -33,6 +33,7 @@
 export const THEME_FOLLOW_SYSTEM_STORAGE_KEY = "t3code:theme-follow-system";
 export const THEME_APPEARANCE_MODE_STORAGE_KEY = "t3code:theme-appearance-mode";
 export const THEME_HALVES_STORAGE_KEY = "t3code:theme-halves:v1";
+const ENVIRONMENT_THEMES_STORAGE_KEY = "t3code:environment-themes:v1";
 
 const LEGACY_T3_CHAT_DARK_THEME_ID = "t3-chat-dark";
 
@@ -56,10 +57,11 @@
 }>;
 
 /**
- * The environment's palettes are not saved: they are republished by the server
- * on every change and would go stale the moment the machine's theme moved on.
+ * The server republishes the environment's palettes on every change, so the
+ * stored copy is only the last snapshot: it lets boot paint the selected
+ * palette before the server connects, and the next publish replaces it.
  */
-let environmentThemeDefinitions: ReadonlyArray<ThemeDefinition> = [];
+let environmentThemeDefinitions: ReadonlyArray<ThemeDefinition> | null = null;
 
 const customThemeListeners = new Set<() => void>();
 type CustomThemeLibrarySnapshot =
@@ -240,7 +242,20 @@
   return snapshot.status === "ready" ? snapshot.themes : EMPTY_CUSTOM_THEMES;
 }
 
+function readStoredEnvironmentThemes(): ReadonlyArray<ThemeDefinition> {
+  if (typeof window === "undefined") return [];
+  try {
+    const parsed: unknown = JSON.parse(
+      window.localStorage.getItem(ENVIRONMENT_THEMES_STORAGE_KEY) ?? "null",
+    );
+    return Array.isArray(parsed) ? parseStoredThemes(parsed) : [];
+  } catch {
+    return [];
+  }
+}
+
 export function getEnvironmentThemes(): ReadonlyArray<ThemeDefinition> {
+  environmentThemeDefinitions ??= readStoredEnvironmentThemes();
   return environmentThemeDefinitions;
 }
 
@@ -250,8 +265,13 @@
  * kind of wasted work users of this product notice.
  */
 export function setEnvironmentThemes(themes: ReadonlyArray<ThemeDefinition>): boolean {
-  if (Equal.equals(environmentThemeDefinitions, themes)) return false;
+  if (Equal.equals(getEnvironmentThemes(), themes)) return false;
   environmentThemeDefinitions = themes;
+  try {
+    window.localStorage.setItem(ENVIRONMENT_THEMES_STORAGE_KEY, JSON.stringify(themes));
+  } catch {
+    // Only boot reads the copy; without it the next launch paints stock colors until connect.
+  }
   notifyCustomThemeListeners();
   return true;
 }
@@ -1032,7 +1052,7 @@
   return (
     BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId) ??
     getCustomThemes().find((definition) => definition.id === themeId) ??
+    getEnvironmentThemes().find((definition) => definition.id === themeId) ??
-    environmentThemeDefinitions.find((definition) => definition.id === themeId) ??
     null
   );
 }
```

## packages/client-runtime/src/rpc/session.test.ts

Status: modified; +2; −2

```diff
@@ -588,7 +588,7 @@
         for (const event of replay) {
           projection = applyServerConfigProjection(projection, event);
         }
-        expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined();
+        expect(Option.getOrThrow(projection).config.environmentThemes).toEqual([]);
       }),
     ),
   );
@@ -702,7 +702,7 @@
         for (const event of recovered.slice(1)) {
           projection = applyServerConfigProjection(projection, event);
         }
-        expect(Option.getOrThrow(projection).config.environmentThemes).toBeUndefined();
+        expect(Option.getOrThrow(projection).config.environmentThemes).toEqual([]);
         expect(Option.getOrThrow(projection).config.usageLimitSources).toBeUndefined();
       }),
     ),
```

## packages/client-runtime/src/state/serverConfigProjection.ts

Status: modified; +2; −1

```diff
@@ -124,7 +124,8 @@
       return Option.map(current, (projection) => ({
         config: {
           ...projection.config,
+          // Kept even when empty: undefined means the event has not arrived yet.
+          environmentThemes: event.payload.themes,
-          environmentThemes: event.payload.themes.length > 0 ? event.payload.themes : undefined,
         },
         latestEvent: event,
         source: "live",
```
