@@ -1124,7 +1087,6 @@ export function ThemeEditorPanel({
) : null}
{renderAppearanceButtons()}
- {renderSidebarArtworkToggle()}
{renderColorsHeader()}
{renderColorFields()}
diff --git a/apps/web/src/components/sidebar/SidebarChrome.tsx b/apps/web/src/components/sidebar/SidebarChrome.tsx
index 8ce42cf45df89c0902915c22113c9e9fae442621..e9d3dcbc4362294384fc236aa9975cf93767e968 100644
--- a/apps/web/src/components/sidebar/SidebarChrome.tsx
+++ b/apps/web/src/components/sidebar/SidebarChrome.tsx
@@ -8,6 +8,7 @@ import { usePrimaryEnvironment } from "../../state/environments";
import {
resolveEnvironmentIdentificationPillLabel,
resolveSidebarStageBackdropVariant,
+ resolveSidebarStageFocusRingOffsetClass,
SidebarStageBackdrop,
useEnvironmentStageLabel,
} from "../SidebarStageBackdrop";
@@ -52,7 +53,8 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
className={cn(
"relative z-10 md:hidden",
backdropVariant &&
- "[:hover,[data-pressed]]:bg-white/15 focus-visible:ring-white/90 focus-visible:ring-offset-blue-700 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white!",
+ "focus-visible:ring-white/90 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white! [:hover,[data-pressed]]:bg-white/15",
+ backdropVariant && resolveSidebarStageFocusRingOffsetClass(backdropVariant),
)}
/>
diff --git a/apps/web/src/hooks/useSettings.ts b/apps/web/src/hooks/useSettings.ts
index bf273879dc43193c55a543908da52f450a0063d2..590949c09d9bd33b9706d9ccf114ac0f815f8924 100644
--- a/apps/web/src/hooks/useSettings.ts
+++ b/apps/web/src/hooks/useSettings.ts
@@ -31,6 +31,7 @@ import {
getThemePreviewSidebarArtwork,
resolveThemeHalf,
subscribeToThemePreview,
+ themeAllowsSidebarArtwork,
} from "~/themePalette";
import * as Struct from "effect/Struct";
import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
@@ -236,8 +237,8 @@ export function resolveEnvironmentIdentificationMode(input: {
}): EnvironmentIdentificationMode {
// Avoid briefly rendering the default artwork before a persisted pill/none choice loads.
if (!input.settingsHydrated) return "none";
- // Stage artwork has fixed colors that can clash with palette themes. Keep an
- // explicit "none", but use the theme-aware pill in place of artwork.
+ // Artwork palettes are maintained for built-ins only. Keep an explicit
+ // "none", but use the theme-aware pill for user-controlled palettes.
return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork"
? "pill"
: input.mode;
@@ -258,8 +259,7 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod
mode,
settingsHydrated,
paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null,
- paletteThemeAllowsArtwork:
- previewSidebarArtwork ?? activeThemeDefinition?.sidebarArtwork === true,
+ paletteThemeAllowsArtwork: previewSidebarArtwork ?? themeAllowsSidebarArtwork(activeTheme),
});
}
diff --git a/apps/web/src/index.css b/apps/web/src/index.css
index 8600d0048546f848aa62f30617ce55232b4875ad..09743241a82f9446e0578b8775b9341dd6c1f2aa 100644
--- a/apps/web/src/index.css
+++ b/apps/web/src/index.css
@@ -265,6 +265,39 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
}
@layer base {
+ :root {
+ /* Dev artwork defaults. Built-in themes override these seven pigments in
+ the components layer; Nightly derives a darker matching palette. */
+ --stage-art-top: #67c2ff;
+ --stage-art-mid: #347ff8;
+ --stage-art-bottom: #1538d0;
+ --stage-art-highlight: #d4f6ff;
+ --stage-art-secondary: #65c8ff;
+ --stage-art-tertiary: #7c8bff;
+ --stage-art-line: #ddf7ff;
+ --stage-night-top: color-mix(in oklch, var(--stage-art-top) 38%, #32155b);
+ --stage-night-mid: color-mix(in oklch, var(--stage-art-mid) 38%, #151443);
+ --stage-night-bottom: color-mix(in oklch, var(--stage-art-bottom) 38%, #07152f);
+ --stage-night-highlight: var(--stage-art-highlight);
+ --stage-night-secondary: color-mix(
+ in oklch,
+ var(--stage-art-secondary) 55%,
+ var(--stage-night-mid)
+ );
+ --stage-night-tertiary: color-mix(
+ in oklch,
+ var(--stage-art-tertiary) 60%,
+ var(--stage-night-top)
+ );
+ --stage-night-line: color-mix(in oklch, var(--stage-art-line) 82%, white);
+ }
+
+ .dark {
+ --stage-art-top: #3a7ad1;
+ --stage-art-mid: #2050ae;
+ --stage-art-bottom: #101f6e;
+ }
+
* {
@apply border-border outline-ring/50;
}
@@ -335,16 +368,136 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
);
}
- .stage-blueprint {
- --stage-bp-top: #67c2ff;
- --stage-bp-mid: #347ff8;
- --stage-bp-bottom: #1538d0;
+ /* Each maintainer palette gives the same line art its own material: rose
+ vellum, forest drafting paper, marine cyanotype, copper, and violet ink.
+ These colors stay deliberately deep at the top edge so the white stage
+ wordmark and controls retain contrast in both appearance modes. */
+ html[data-theme-id="t3-chat"] {
+ --stage-art-top: #f28ac1;
+ --stage-art-mid: #cb3e86;
+ --stage-art-bottom: #73194f;
+ --stage-art-highlight: #ffe6f6;
+ --stage-art-secondary: #ff82bc;
+ --stage-art-tertiary: #c67bf1;
+ --stage-art-line: #ffe7f8;
}
- .dark .stage-blueprint {
- --stage-bp-top: #3a7ad1;
- --stage-bp-mid: #2050ae;
- --stage-bp-bottom: #101f6e;
+ html.dark[data-theme-id="t3-chat"] {
+ --stage-art-top: #a8467e;
+ --stage-art-mid: #742253;
+ --stage-art-bottom: #390e2d;
+ --stage-art-highlight: #ffd8ee;
+ --stage-art-secondary: #de639e;
+ --stage-art-tertiary: #9c64cf;
+ --stage-art-line: #ffe4f4;
+ }
+
+ html[data-theme-id="grove"] {
+ --stage-art-top: #6cc492;
+ --stage-art-mid: #2d8a5e;
+ --stage-art-bottom: #174a33;
+ --stage-art-highlight: #e4fff0;
+ --stage-art-secondary: #79d8a5;
+ --stage-art-tertiary: #d5b563;
+ --stage-art-line: #eafff2;
+ --stage-night-top: #286548;
+ --stage-night-mid: #174a35;
+ --stage-night-bottom: #0a2c21;
+ --stage-night-highlight: #c4eed5;
+ --stage-night-secondary: #54a878;
+ --stage-night-tertiary: #b79b4d;
+ --stage-night-line: #d7f6e2;
+ }
+
+ html.dark[data-theme-id="grove"] {
+ --stage-art-top: #438e65;
+ --stage-art-mid: #286447;
+ --stage-art-bottom: #123525;
+ --stage-art-highlight: #d2fbe2;
+ --stage-art-secondary: #62bf88;
+ --stage-art-tertiary: #c99b45;
+ --stage-art-line: #e0fae9;
+ --stage-night-top: #24523a;
+ --stage-night-mid: #113323;
+ --stage-night-bottom: #071d16;
+ --stage-night-highlight: #b5dfc4;
+ --stage-night-secondary: #478d66;
+ --stage-night-tertiary: #a9853b;
+ --stage-night-line: #d0ead9;
+ }
+
+ html[data-theme-id="ocean"] {
+ --stage-art-top: #77c9e3;
+ --stage-art-mid: #3384bd;
+ --stage-art-bottom: #174d7a;
+ --stage-art-highlight: #e5faff;
+ --stage-art-secondary: #70c9df;
+ --stage-art-tertiary: #5cc7be;
+ --stage-art-line: #e9fbff;
+ }
+
+ html.dark[data-theme-id="ocean"] {
+ --stage-art-top: #4288ac;
+ --stage-art-mid: #285d84;
+ --stage-art-bottom: #132e49;
+ --stage-art-highlight: #d9f5ff;
+ --stage-art-secondary: #61b5d4;
+ --stage-art-tertiary: #51b8b3;
+ --stage-art-line: #dff7ff;
+ }
+
+ html[data-theme-id="ember"] {
+ --stage-art-top: #f4a26e;
+ --stage-art-mid: #d66036;
+ --stage-art-bottom: #7c291e;
+ --stage-art-highlight: #fff0df;
+ --stage-art-secondary: #ffad77;
+ --stage-art-tertiary: #ef6f69;
+ --stage-art-line: #fff3e7;
+ --stage-night-top: #8a442c;
+ --stage-night-mid: #5c271c;
+ --stage-night-bottom: #32100d;
+ --stage-night-highlight: #ffd6ba;
+ --stage-night-secondary: #cb7148;
+ --stage-night-tertiary: #ad4b45;
+ --stage-night-line: #ffe0c9;
+ }
+
+ html.dark[data-theme-id="ember"] {
+ --stage-art-top: #ba6540;
+ --stage-art-mid: #803a29;
+ --stage-art-bottom: #3c1916;
+ --stage-art-highlight: #ffe1cd;
+ --stage-art-secondary: #e68156;
+ --stage-art-tertiary: #d85b58;
+ --stage-art-line: #ffe8d7;
+ --stage-night-top: #6a3425;
+ --stage-night-mid: #3e1b15;
+ --stage-night-bottom: #1d0d0b;
+ --stage-night-highlight: #efc4a9;
+ --stage-night-secondary: #a85e3f;
+ --stage-night-tertiary: #8f413e;
+ --stage-night-line: #f2d2bc;
+ }
+
+ html[data-theme-id="iris"] {
+ --stage-art-top: #b49ae8;
+ --stage-art-mid: #7a5ac4;
+ --stage-art-bottom: #422d80;
+ --stage-art-highlight: #f2eaff;
+ --stage-art-secondary: #b79af1;
+ --stage-art-tertiary: #ec79c7;
+ --stage-art-line: #f6eeff;
+ }
+
+ html.dark[data-theme-id="iris"] {
+ --stage-art-top: #8063c4;
+ --stage-art-mid: #513990;
+ --stage-art-bottom: #261c53;
+ --stage-art-highlight: #e8ddff;
+ --stage-art-secondary: #9e83dc;
+ --stage-art-tertiary: #d96ab7;
+ --stage-art-line: #eee7ff;
}
.workspace-topbar {
diff --git a/apps/web/src/themePalette.test.ts b/apps/web/src/themePalette.test.ts
index 0e47cfdd9dc95ef19afd6ec609d427339d0e1e06..5cae0a8a74b93e2f2a7dccc1c87c57c1998155e3 100644
--- a/apps/web/src/themePalette.test.ts
+++ b/apps/web/src/themePalette.test.ts
@@ -20,6 +20,7 @@ import {
serializeThemeFile,
subscribeToThemePreview,
subscribeToCustomThemes,
+ themeAllowsSidebarArtwork,
T3_CHAT_THEME,
EMBER_THEME,
GROVE_THEME,
@@ -206,14 +207,8 @@ describe("theme files", () => {
});
});
- it("keeps sidebar artwork opt-in through theme files", () => {
- const withoutArtwork = parseThemeFile({
- version: THEME_FILE_VERSION,
- name: "Plain sidebar",
- appearance: "light",
- colors: { accent: "#5b6cff" },
- });
- const withArtwork = parseThemeFile({
+ it("keeps sidebar artwork disabled for custom theme files", () => {
+ const theme = parseThemeFile({
version: THEME_FILE_VERSION,
name: "Art sidebar",
appearance: "light",
@@ -221,12 +216,11 @@ describe("theme files", () => {
sidebarArtwork: true,
});
- expect(withoutArtwork.sidebarArtwork).toBeUndefined();
- expect(withArtwork.sidebarArtwork).toBe(true);
- expect(JSON.parse(serializeThemeFile(withArtwork)).sidebarArtwork).toBe(true);
+ expect(theme.sidebarArtwork).toBeUndefined();
+ expect(JSON.parse(serializeThemeFile(theme))).not.toHaveProperty("sidebarArtwork");
});
- it("publishes sidebar artwork changes from the live theme preview", () => {
+ it("suppresses sidebar artwork during a live custom-theme preview", () => {
const listener = vi.fn();
const unsubscribe = subscribeToThemePreview(listener);
vi.stubGlobal("document", {
@@ -237,8 +231,8 @@ describe("theme files", () => {
},
});
- applyThemeColorPreview(T3_CHAT_THEME.colors, "light", true);
- expect(getThemePreviewSidebarArtwork()).toBe(true);
+ applyThemeColorPreview(T3_CHAT_THEME.colors, "light");
+ expect(getThemePreviewSidebarArtwork()).toBe(false);
expect(listener).toHaveBeenCalledTimes(1);
applyThemePalette("system");
@@ -325,6 +319,8 @@ describe("theme files", () => {
for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) {
expect(getThemeDefinition(theme.id)).toBe(theme);
expect(getThemeModes(theme)).toEqual(["light", "dark"]);
+ expect(theme.sidebarArtwork).toBe(true);
+ expect(themeAllowsSidebarArtwork(theme.id)).toBe(true);
expect(theme.colors.accent).toMatch(/^#[0-9a-f]{6}$/i);
expect(theme.variants?.dark?.accent).toMatch(/^#[0-9a-f]{6}$/i);
@@ -359,6 +355,7 @@ describe("theme files", () => {
);
}
}
+ expect(themeAllowsSidebarArtwork("my-custom-theme")).toBe(false);
});
it("rejects a variant that repeats the base appearance", () => {
@@ -451,15 +448,17 @@ describe("theme files", () => {
expect(updatedTheme).toMatchObject({
id: "aurora",
label: "Aurora Night",
- sidebarArtwork: true,
});
+ expect(updatedTheme).not.toHaveProperty("sidebarArtwork");
invalidateCustomThemes();
expect(getCustomThemes()).toEqual([updatedTheme]);
expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({
id: "aurora",
label: "Aurora Night",
- sidebarArtwork: true,
});
+ expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).not.toHaveProperty(
+ "sidebarArtwork",
+ );
vi.unstubAllGlobals();
invalidateCustomThemes();
diff --git a/apps/web/src/themePalette.ts b/apps/web/src/themePalette.ts
index d1db01ffeb4e6e85557e117f809d58ae39007bff..5b58e879b57072ef8faa3eb01abd5a8cc241f9f9 100644
--- a/apps/web/src/themePalette.ts
+++ b/apps/web/src/themePalette.ts
@@ -96,7 +96,7 @@ export type ThemeDefinition = Readonly<{
appearance: ThemeAppearance;
colors: ThemeColors;
variants?: ThemeVariants;
- /** Allows fixed Dev/Nightly artwork to render over this theme's sidebar. */
+ /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */
sidebarArtwork?: boolean;
/** True when the palette was generated by the guided editor from its
* canvas and accent; such themes reopen in guided mode. */
@@ -109,7 +109,6 @@ export type ThemeFile = Readonly<{
appearance: ThemeAppearance;
colors: ThemeColorOverrides;
variants?: ThemeVariantOverrides;
- sidebarArtwork?: boolean;
managed?: boolean;
}>;
@@ -223,7 +222,6 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {
appearance: value.appearance,
colors,
...(variants ? { variants } : {}),
- ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}),
...(value.managed === true ? { managed: true } : {}),
};
}
@@ -1299,6 +1297,7 @@ export const T3_CHAT_THEME: ThemeDefinition = {
variants: {
dark: T3_CHAT_DARK_COLORS,
},
+ sidebarArtwork: true,
};
/** Theme-file defaults follow the flagship palette for the requested mode. */
@@ -1341,6 +1340,7 @@ export const GROVE_THEME: ThemeDefinition = {
...themeActionColors("#e3b34e"),
},
},
+ sidebarArtwork: true,
};
export const OCEAN_THEME: ThemeDefinition = {
@@ -1357,6 +1357,7 @@ export const OCEAN_THEME: ThemeDefinition = {
...themeActionColors("#5bd0d6"),
},
},
+ sidebarArtwork: true,
};
export const EMBER_THEME: ThemeDefinition = {
@@ -1373,6 +1374,7 @@ export const EMBER_THEME: ThemeDefinition = {
...themeActionColors("#f78a7a"),
},
},
+ sidebarArtwork: true,
};
export const IRIS_THEME: ThemeDefinition = {
@@ -1389,6 +1391,7 @@ export const IRIS_THEME: ThemeDefinition = {
...themeActionColors("#f099d8"),
},
},
+ sidebarArtwork: true,
};
const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray = [
@@ -1408,6 +1411,15 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu
);
}
+/** Artwork palettes are reviewed alongside built-ins; user themes always use the pill fallback. */
+export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean {
+ const themeId = themeIdFromPreference(theme);
+ return (
+ BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId)?.sidebarArtwork ===
+ true
+ );
+}
+
export function getThemeColorsForMode(
theme: ThemeDefinition,
mode: ThemeAppearance,
@@ -1569,7 +1581,6 @@ export function parseThemeFile(value: unknown): ThemeDefinition {
appearance,
colors: { ...fallback, ...overrides },
...(Object.keys(variants).length > 0 ? { variants } : {}),
- ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}),
...(value.managed === true ? { managed: true } : {}),
};
}
@@ -1582,7 +1593,6 @@ export function serializeThemeFile(theme: ThemeDefinition): string {
appearance: theme.appearance,
colors: theme.colors,
...(theme.variants ? { variants: theme.variants } : {}),
- ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}),
...(theme.managed ? { managed: true } : {}),
};
return `${JSON.stringify(file, null, 2)}\n`;
@@ -1660,16 +1670,14 @@ export const THEME_PREVIEW_ID = "__preview";
* can be judged against the real interface instead of a miniature. Callers
* restore the stored theme (refreshTheme) when the draft goes away.
*/
-export function applyThemeColorPreview(
- colors: ThemeColors,
- appearance: ThemeAppearance,
- sidebarArtwork = false,
-): void {
+export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void {
if (typeof document === "undefined") return;
const root = document.documentElement;
if (!root?.style) return;
- setThemePreviewSidebarArtwork(sidebarArtwork);
+ // Drafts become user-controlled themes when saved, so their preview keeps
+ // the fixed stage artwork hidden even when it was seeded from a built-in.
+ setThemePreviewSidebarArtwork(false);
root.dataset.themeId = THEME_PREVIEW_ID;
root.classList.toggle("dark", appearance === "dark");
for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) {
diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md
index 99c180bafdd2f7ad4bac68ab71bd60e7e40dd1b7..3ff286055d68abafdb2ee3e7e37dbad868631b4c 100644
--- a/docs/user/thread-sidebar.md
+++ b/docs/user/thread-sidebar.md
@@ -11,3 +11,10 @@ other connected devices.
If reordering is unavailable for one environment, update the T3 Code server running in that
environment. Older servers can still pin and unpin threads, but do not understand synced ordering;
their pinned threads keep the default newest-first order below the ones you have arranged.
+
+## Environment artwork
+
+Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in
+the send button. Choose **Artwork**, **Version pill**, or **None** in Settings under environment
+identification. Artwork is recolored to match each built-in theme. Custom themes use the **Version
+pill** fallback because their colors are not controlled by T3 Code.