authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-08-11 17:46:52+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-11 17:46:52+02:00
logb30a9bc4143910f12d9c990c221fb9bc20cbc293
treec81c519d988e370886050a6beecfccb96c4e1539
parentac4780f451f98c10d5b518f2bfa3d035b46645df
signature Signed by PGP key B5690EEEBB952194

feat(web): make environment artwork theme aware (#6183)

Co-authored-by: codex <codex@users.noreply.github.com>

12 files changed, 416 insertions(+), 125 deletions(-)

apps/web/src/components/AppSidebarLayout.tsx+8-2
......@@ -19,7 +19,10 @@ import LegacyThreadSidebar from "./LegacySidebar";
1919import ThreadSidebar from "./Sidebar";
2020import { SettingsSidebarNav } from "./settings/SettingsSidebarNav";
2121import { SidebarChromeHeader } from "./sidebar/SidebarChrome";
22import { useSidebarStageBackdropVariant } from "./SidebarStageBackdrop";
22import {
23 resolveSidebarStageFocusRingOffsetClass,
24 useSidebarStageBackdropVariant,
25} from "./SidebarStageBackdrop";
2326import { useProjects } from "../state/entities";
2427import {
2528 resolveInitialThreadSidebarWidth,
......@@ -105,7 +108,10 @@ function SidebarControl() {
105108 "pointer-events-auto",
106109 isSidebarVisible &&
107110 stageBackdropVariant &&
108 "[: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!",
111 "focus-visible:ring-white/90 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white! [:hover,[data-pressed]]:bg-white/15",
112 isSidebarVisible &&
113 stageBackdropVariant &&
114 resolveSidebarStageFocusRingOffsetClass(stageBackdropVariant),
109115 )}
110116 aria-label="Toggle main sidebar"
111117 />
apps/web/src/components/SidebarStageBackdrop.test.tsx+33
......@@ -4,7 +4,9 @@ import { renderToStaticMarkup } from "react-dom/server";
44import {
55 resolveEnvironmentIdentificationPillLabel,
66 resolveSidebarStageBackdropVariant,
7 resolveSidebarStageFocusRingOffsetClass,
78 StageBackdropArt,
9 StageBackdropButtonArt,
810} from "./SidebarStageBackdrop";
911
1012describe("SidebarStageBackdrop", () => {
......@@ -22,6 +24,15 @@ describe("SidebarStageBackdrop", () => {
2224 expect(resolveEnvironmentIdentificationPillLabel("Alpha")).toBeNull();
2325 });
2426
27 it("matches the focus-ring offset to each artwork palette", () => {
28 expect(resolveSidebarStageFocusRingOffsetClass("nightly")).toBe(
29 "focus-visible:ring-offset-(--stage-night-bottom)",
30 );
31 expect(resolveSidebarStageFocusRingOffsetClass("dev")).toBe(
32 "focus-visible:ring-offset-(--stage-art-bottom)",
33 );
34 });
35
2536 it.each(["nightly", "dev"] as const)(
2637 "uses unique SVG definition ids when %s artwork is rendered more than once",
2738 (variant) => {
......@@ -37,4 +48,26 @@ describe("SidebarStageBackdrop", () => {
3748 expect(new Set(ids).size).toBe(ids.length);
3849 },
3950 );
51
52 it("paints each artwork variant with theme-owned color tokens", () => {
53 const nightlyMarkup = renderToStaticMarkup(<StageBackdropArt variant="nightly" />);
54 const devMarkup = renderToStaticMarkup(<StageBackdropArt variant="dev" />);
55
56 expect(nightlyMarkup).toContain("var(--stage-night-bottom)");
57 expect(nightlyMarkup).toContain("var(--stage-night-line)");
58 expect(devMarkup).toContain("var(--stage-art-bottom)");
59 expect(devMarkup).toContain("var(--stage-art-line)");
60 expect(nightlyMarkup).not.toMatch(/#[0-9a-f]{3,8}/i);
61 expect(devMarkup).not.toMatch(/#[0-9a-f]{3,8}/i);
62 });
63
64 it.each([
65 ["nightly", "96 0 8192 96"],
66 ["dev", "64 0 8192 96"],
67 ] as const)("uses the compact %s crop inside the send button", (variant, viewBox) => {
68 const markup = renderToStaticMarkup(<StageBackdropButtonArt variant={variant} />);
69
70 expect(markup).toContain(`viewBox="${viewBox}"`);
71 expect(markup).toContain(`stage-${variant === "dev" ? "blueprint" : "nightly"}`);
72 });
4073});
apps/web/src/components/SidebarStageBackdrop.tsx+92-35
......@@ -23,6 +23,14 @@ export function resolveSidebarStageBackdropVariant(
2323 return null;
2424}
2525
26export function resolveSidebarStageFocusRingOffsetClass(
27 variant: SidebarStageBackdropVariant,
28): string {
29 return variant === "nightly"
30 ? "focus-visible:ring-offset-(--stage-night-bottom)"
31 : "focus-visible:ring-offset-(--stage-art-bottom)";
32}
33
2634export function resolveEnvironmentIdentificationPillLabel(
2735 stageLabel: string,
2836): EnvironmentIdentificationPillLabel | null {
......@@ -62,6 +70,10 @@ export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVar
6270 return variant === "nightly" ? <NightlySkyArt /> : <DevBlueprintArt />;
6371}
6472
73export function StageBackdropButtonArt({ variant }: { variant: SidebarStageBackdropVariant }) {
74 return variant === "nightly" ? <NightlySkyArt compact /> : <DevBlueprintArt compact />;
75}
76
6577const NIGHTLY_STARS: ReadonlyArray<{
6678 cx: number;
6779 cy: number;
......@@ -93,7 +105,7 @@ const NIGHTLY_SPARKLES: ReadonlyArray<{ x: number; y: number }> = [
93105 { x: 246, y: 26 },
94106];
95107
96function NightlySkyArt() {
108function NightlySkyArt({ compact = false }: { compact?: boolean }) {
97109 const idPrefix = useId().replaceAll(":", "");
98110 const skyId = `${idPrefix}-stage-night-sky`;
99111 const glowId = `${idPrefix}-stage-night-glow`;
......@@ -104,10 +116,10 @@ function NightlySkyArt() {
104116
105117 return (
106118 <svg
107 className="h-full w-full"
119 className="stage-art stage-nightly h-full w-full"
108120 fill="none"
109121 preserveAspectRatio="xMinYMin slice"
110 viewBox={STAGE_BACKDROP_VIEW_BOX}
122 viewBox={compact ? "96 0 8192 96" : STAGE_BACKDROP_VIEW_BOX}
111123 xmlns="http://www.w3.org/2000/svg"
112124 >
113125 <defs>
......@@ -120,9 +132,9 @@ function NightlySkyArt() {
120132 gradientUnits="userSpaceOnUse"
121133 spreadMethod="reflect"
122134 >
123 <stop stopColor="#07152F" />
124 <stop offset="0.5" stopColor="#151443" />
125 <stop offset="1" stopColor="#32155B" />
135 <stop style={{ stopColor: "var(--stage-night-bottom)" }} />
136 <stop offset="0.5" style={{ stopColor: "var(--stage-night-mid)" }} />
137 <stop offset="1" style={{ stopColor: "var(--stage-night-top)" }} />
126138 </linearGradient>
127139 <radialGradient
128140 id={glowId}
......@@ -132,20 +144,28 @@ function NightlySkyArt() {
132144 gradientTransform="translate(216 18) rotate(137) scale(120 84)"
133145 gradientUnits="userSpaceOnUse"
134146 >
135 <stop stopColor="#5165D8" stopOpacity="0.4" />
136 <stop offset="0.5" stopColor="#283075" stopOpacity="0.16" />
137 <stop offset="1" stopColor="#111635" stopOpacity="0" />
147 <stop style={{ stopColor: "var(--stage-night-highlight)" }} stopOpacity="0.4" />
148 <stop
149 offset="0.5"
150 style={{ stopColor: "var(--stage-night-secondary)" }}
151 stopOpacity="0.16"
152 />
153 <stop offset="1" style={{ stopColor: "var(--stage-night-bottom)" }} stopOpacity="0" />
138154 </radialGradient>
139155 <linearGradient id={cloudId} x1="0" y1="60" x2="288" y2="96" gradientUnits="userSpaceOnUse">
140 <stop stopColor="#4EA4FF" stopOpacity="0.5" />
141 <stop offset="0.52" stopColor="#696FEA" stopOpacity="0.62" />
142 <stop offset="1" stopColor="#A85BEA" stopOpacity="0.5" />
156 <stop style={{ stopColor: "var(--stage-night-highlight)" }} stopOpacity="0.5" />
157 <stop
158 offset="0.52"
159 style={{ stopColor: "var(--stage-night-secondary)" }}
160 stopOpacity="0.62"
161 />
162 <stop offset="1" style={{ stopColor: "var(--stage-night-tertiary)" }} stopOpacity="0.5" />
143163 </linearGradient>
144164 <filter id={softId} x="-24" y="-24" width="336" height="144" filterUnits="userSpaceOnUse">
145165 <feGaussianBlur stdDeviation="4" />
146166 </filter>
147167 <pattern id={starsId} width="288" height="96" patternUnits="userSpaceOnUse">
148 <g fill="#E4EAFF">
168 <g style={{ fill: "var(--stage-night-line)" }}>
149169 {NIGHTLY_STARS.map((star) => (
150170 <circle
151171 key={`${star.cx}-${star.cy}`}
......@@ -156,7 +176,12 @@ function NightlySkyArt() {
156176 />
157177 ))}
158178 </g>
159 <g stroke="#C8D7FF" strokeLinecap="round" strokeOpacity="0.7" strokeWidth="0.6">
179 <g
180 style={{ stroke: "var(--stage-night-line)" }}
181 strokeLinecap="round"
182 strokeOpacity="0.7"
183 strokeWidth="0.6"
184 >
160185 {NIGHTLY_SPARKLES.map((sparkle) => (
161186 <g key={`${sparkle.x}-${sparkle.y}`}>
162187 <path d={`M${sparkle.x - 1.5} ${sparkle.y}H${sparkle.x + 1.5}`} />
......@@ -191,7 +216,7 @@ function NightlySkyArt() {
191216 );
192217}
193218
194function DevBlueprintArt() {
219function DevBlueprintArt({ compact = false }: { compact?: boolean }) {
195220 const idPrefix = useId().replaceAll(":", "");
196221 const paperId = `${idPrefix}-stage-bp-paper`;
197222 const glowId = `${idPrefix}-stage-bp-glow`;
......@@ -205,10 +230,10 @@ function DevBlueprintArt() {
205230
206231 return (
207232 <svg
208 className="stage-blueprint h-full w-full"
233 className="stage-art stage-blueprint h-full w-full"
209234 fill="none"
210235 preserveAspectRatio="xMinYMin slice"
211 viewBox={STAGE_BACKDROP_VIEW_BOX}
236 viewBox={compact ? "64 0 8192 96" : STAGE_BACKDROP_VIEW_BOX}
212237 xmlns="http://www.w3.org/2000/svg"
213238 >
214239 <defs>
......@@ -221,9 +246,9 @@ function DevBlueprintArt() {
221246 gradientUnits="userSpaceOnUse"
222247 spreadMethod="reflect"
223248 >
224 <stop style={{ stopColor: "var(--stage-bp-bottom)" }} />
225 <stop offset="0.5" style={{ stopColor: "var(--stage-bp-mid)" }} />
226 <stop offset="1" style={{ stopColor: "var(--stage-bp-top)" }} />
249 <stop style={{ stopColor: "var(--stage-art-bottom)" }} />
250 <stop offset="0.5" style={{ stopColor: "var(--stage-art-mid)" }} />
251 <stop offset="1" style={{ stopColor: "var(--stage-art-top)" }} />
227252 </linearGradient>
228253 <radialGradient
229254 id={glowId}
......@@ -233,9 +258,13 @@ function DevBlueprintArt() {
233258 gradientTransform="translate(216 14) rotate(137) scale(120 84)"
234259 gradientUnits="userSpaceOnUse"
235260 >
236 <stop stopColor="#D4F6FF" stopOpacity="0.4" />
237 <stop offset="0.52" stopColor="#65C8FF" stopOpacity="0.16" />
238 <stop offset="1" stopColor="#276AF1" stopOpacity="0" />
261 <stop style={{ stopColor: "var(--stage-art-highlight)" }} stopOpacity="0.4" />
262 <stop
263 offset="0.52"
264 style={{ stopColor: "var(--stage-art-secondary)" }}
265 stopOpacity="0.16"
266 />
267 <stop offset="1" style={{ stopColor: "var(--stage-art-bottom)" }} stopOpacity="0" />
239268 </radialGradient>
240269 <radialGradient
241270 id={celesteGlowId}
......@@ -245,9 +274,13 @@ function DevBlueprintArt() {
245274 gradientTransform="translate(474 44) rotate(166) scale(156 92)"
246275 gradientUnits="userSpaceOnUse"
247276 >
248 <stop stopColor="#D2FFFF" stopOpacity="0.34" />
249 <stop offset="0.5" stopColor="#48DCF5" stopOpacity="0.18" />
250 <stop offset="1" stopColor="#277EF1" stopOpacity="0" />
277 <stop style={{ stopColor: "var(--stage-art-highlight)" }} stopOpacity="0.34" />
278 <stop
279 offset="0.5"
280 style={{ stopColor: "var(--stage-art-secondary)" }}
281 stopOpacity="0.18"
282 />
283 <stop offset="1" style={{ stopColor: "var(--stage-art-bottom)" }} stopOpacity="0" />
251284 </radialGradient>
252285 <radialGradient
253286 id={violetGlowId}
......@@ -257,20 +290,34 @@ function DevBlueprintArt() {
257290 gradientTransform="translate(704 18) rotate(145) scale(132 88)"
258291 gradientUnits="userSpaceOnUse"
259292 >
260 <stop stopColor="#D9D8FF" stopOpacity="0.3" />
261 <stop offset="0.52" stopColor="#7C8BFF" stopOpacity="0.14" />
262 <stop offset="1" stopColor="#3155DF" stopOpacity="0" />
293 <stop style={{ stopColor: "var(--stage-art-highlight)" }} stopOpacity="0.3" />
294 <stop
295 offset="0.52"
296 style={{ stopColor: "var(--stage-art-tertiary)" }}
297 stopOpacity="0.14"
298 />
299 <stop offset="1" style={{ stopColor: "var(--stage-art-bottom)" }} stopOpacity="0" />
263300 </radialGradient>
264301 <pattern id={minorGridId} width="8" height="8" patternUnits="userSpaceOnUse">
265 <path d="M8 0H0V8" stroke="#EAF6FF" strokeOpacity="0.14" strokeWidth="0.5" />
302 <path
303 d="M8 0H0V8"
304 style={{ stroke: "var(--stage-art-line)" }}
305 strokeOpacity="0.14"
306 strokeWidth="0.5"
307 />
266308 </pattern>
267309 <pattern id={majorGridId} width="32" height="32" patternUnits="userSpaceOnUse">
268 <path d="M32 0H0V32" stroke="#EAF6FF" strokeOpacity="0.26" strokeWidth="0.6" />
310 <path
311 d="M32 0H0V32"
312 style={{ stroke: "var(--stage-art-line)" }}
313 strokeOpacity="0.26"
314 strokeWidth="0.6"
315 />
269316 </pattern>
270317 <pattern id={rulerId} width="32" height="6" patternUnits="userSpaceOnUse">
271318 <path
272319 d="M4 0V2.5M12 0V2.5M20 0V4M28 0V2.5"
273 stroke="#DDF7FF"
320 style={{ stroke: "var(--stage-art-line)" }}
274321 strokeOpacity="0.5"
275322 strokeWidth="0.5"
276323 />
......@@ -281,7 +328,12 @@ function DevBlueprintArt() {
281328 <rect width="768" height="96" fill={`url(#${violetGlowId})`} />
282329 </pattern>
283330 <pattern id={annotationsId} width="768" height="96" patternUnits="userSpaceOnUse">
284 <g stroke="#DDF7FF" strokeLinecap="round" strokeOpacity="0.6" strokeWidth="0.7">
331 <g
332 style={{ stroke: "var(--stage-art-line)" }}
333 strokeLinecap="round"
334 strokeOpacity="0.6"
335 strokeWidth="0.7"
336 >
285337 <path d="M180 64H264" strokeDasharray="5 4" />
286338 <path d="M180 61V67M264 61V67" />
287339 <path d="M276 10V44" strokeDasharray="4 4" strokeOpacity="0.5" />
......@@ -294,7 +346,12 @@ function DevBlueprintArt() {
294346 <path d="M590 67V73M724 67V73" strokeOpacity="0.55" />
295347 </g>
296348
297 <g stroke="#DDF7FF" strokeLinecap="round" strokeOpacity="0.55" strokeWidth="0.6">
349 <g
350 style={{ stroke: "var(--stage-art-line)" }}
351 strokeLinecap="round"
352 strokeOpacity="0.55"
353 strokeWidth="0.6"
354 >
298355 <g>
299356 <path d="M34 60L38 64M38 60L34 64" />
300357 </g>
......@@ -318,7 +375,7 @@ function DevBlueprintArt() {
318375 </g>
319376 </g>
320377
321 <g stroke="#DDF7FF" strokeOpacity="0.35" strokeWidth="0.6">
378 <g style={{ stroke: "var(--stage-art-line)" }} strokeOpacity="0.35" strokeWidth="0.6">
322379 <circle cx="196" cy="38" r="13" strokeDasharray="3.5 4" />
323380 <path d="M196 33V43M191 38H201" strokeOpacity="0.6" strokeWidth="0.4" />
324381 <circle cx="414" cy="64" r="10" strokeDasharray="2.5 3.5" />
apps/web/src/components/chat/ComposerPrimaryActions.test.ts+55-4
......@@ -1,13 +1,18 @@
11import { createElement } from "react";
22import { renderToStaticMarkup } from "react-dom/server";
3import { describe, expect, it, vi } from "vite-plus/test";
3import { afterEach, describe, expect, it, vi } from "vite-plus/test";
4
5const stageArtworkState = vi.hoisted(() => ({
6 mode: "none" as "artwork" | "none",
7 variant: null as "nightly" | "dev" | null,
8}));
49
510vi.mock("~/hooks/useSettings", () => ({
6 useEnvironmentIdentificationMode: () => "none",
11 useEnvironmentIdentificationMode: () => stageArtworkState.mode,
712}));
813vi.mock("../SidebarStageBackdrop", () => ({
9 StageBackdropButtonArt: () => null,
10 useSidebarStageBackdropVariant: () => null,
14 StageBackdropButtonArt: ({ variant }: { variant: string }) => `stage-${variant}`,
15 useSidebarStageBackdropVariant: (enabled = true) => (enabled ? stageArtworkState.variant : null),
1116}));
1217
1318import { ComposerPrimaryActions, formatPendingPrimaryActionLabel } from "./ComposerPrimaryActions";
......@@ -60,6 +65,32 @@ function renderStandaloneStop() {
6065 );
6166}
6267
68function renderSendButton() {
69 return renderToStaticMarkup(
70 createElement(ComposerPrimaryActions, {
71 compact: true,
72 pendingAction: null,
73 isRunning: false,
74 showPlanFollowUpPrompt: false,
75 promptHasText: true,
76 isSendBusy: false,
77 sendDisabledReason: null,
78 isConnecting: false,
79 isEnvironmentUnavailable: false,
80 isPreparingWorktree: false,
81 hasSendableContent: true,
82 onPreviousPendingQuestion: () => {},
83 onInterrupt: () => {},
84 onImplementPlanInNewThread: () => {},
85 }),
86 );
87}
88
89afterEach(() => {
90 stageArtworkState.mode = "none";
91 stageArtworkState.variant = null;
92});
93
6394describe("formatPendingPrimaryActionLabel", () => {
6495 it("returns 'Submitting...' while responding", () => {
6596 expect(
......@@ -164,4 +195,24 @@ describe("ComposerPrimaryActions", () => {
164195 expect(renderStandaloneStop()).toContain("size-8 sm:h-8 sm:w-8");
165196 expect(renderStandaloneStop()).not.toContain("sm:size-7");
166197 });
198
199 it("renders stage artwork inside the send button when artwork identification is active", () => {
200 stageArtworkState.mode = "artwork";
201 stageArtworkState.variant = "nightly";
202
203 const markup = renderSendButton();
204
205 expect(markup).toContain("stage-nightly");
206 expect(markup).toContain("bg-transparent text-white");
207 expect(markup).not.toContain("bg-message-action text-message-action-foreground");
208 });
209
210 it("keeps the normal send-button fill when artwork identification is inactive", () => {
211 stageArtworkState.variant = "nightly";
212
213 const markup = renderSendButton();
214
215 expect(markup).not.toContain("stage-nightly");
216 expect(markup).toContain("bg-message-action text-message-action-foreground");
217 });
167218});
apps/web/src/components/chat/ComposerPrimaryActions.tsx+15-2
......@@ -1,6 +1,8 @@
11import { memo, type PointerEventHandler } from "react";
22import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
3import { useEnvironmentIdentificationMode } from "~/hooks/useSettings";
34import { cn } from "~/lib/utils";
5import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
46import { Button } from "../ui/button";
57import { Menu, MenuItem, MenuPopup, MenuTrigger } from "../ui/menu";
68import { Spinner } from "../ui/spinner";
......@@ -73,7 +75,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
7375 const pointerFocusProps = preserveComposerFocusOnPointerDown
7476 ? { onPointerDown: preventPointerFocus }
7577 : undefined;
78 const environmentIdentificationMode = useEnvironmentIdentificationMode();
7679 const isSendDisabled = sendDisabledReason !== null;
80 const stageBackdropVariant = useSidebarStageBackdropVariant(
81 environmentIdentificationMode === "artwork",
82 );
7783
7884 const renderStopGenerationButton = (insidePendingAction: boolean) => (
7985 <button
......@@ -212,8 +218,10 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
212218 <button
213219 type="submit"
214220 className={cn(
215 "relative isolate flex h-9 w-9 items-center justify-center overflow-hidden rounded-full text-message-action-foreground shadow-xs transition-all duration-150 enabled:cursor-pointer enabled:inset-shadow-[0_1px_--theme(--color-white/16%)] hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none disabled:pointer-events-none disabled:opacity-30 disabled:shadow-none disabled:hover:scale-100 sm:h-8 sm:w-8",
216 "bg-message-action enabled:shadow-message-action/24 hover:bg-message-action-hover",
221 "relative isolate flex h-9 w-9 items-center justify-center overflow-hidden rounded-full shadow-xs transition-all duration-150 enabled:cursor-pointer enabled:inset-shadow-[0_1px_--theme(--color-white/16%)] hover:scale-105 active:inset-shadow-[0_1px_--theme(--color-black/8%)] active:shadow-none disabled:pointer-events-none disabled:opacity-30 disabled:shadow-none disabled:hover:scale-100 sm:h-8 sm:w-8",
222 stageBackdropVariant
223 ? "bg-transparent text-white enabled:shadow-black/24 enabled:hover:brightness-110"
224 : "bg-message-action text-message-action-foreground enabled:shadow-message-action/24 hover:bg-message-action-hover",
217225 )}
218226 {...pointerFocusProps}
219227 disabled={
......@@ -237,6 +245,11 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
237245 : "Send message"
238246 }
239247 >
248 {stageBackdropVariant ? (
249 <span className="absolute inset-0 -z-10" aria-hidden="true">
250 <StageBackdropButtonArt variant={stageBackdropVariant} />
251 </span>
252 ) : null}
240253 {isConnecting || isSendBusy ? (
241254 <Spinner className="size-3.5" aria-hidden="true" />
242255 ) : (
apps/web/src/components/settings/ThemeEditorPanel.tsx+2-40
......@@ -165,7 +165,6 @@ export function ThemeEditorPanel({
165165 const isEditing = editingTheme !== null;
166166 const [name, setName] = useState("");
167167 const [activeAppearance, setActiveAppearance] = useState<ThemeAppearance>(initialAppearance);
168 const [sidebarArtwork, setSidebarArtwork] = useState(false);
169168 const [isAdvanced, setIsAdvanced] = useState(false);
170169 const [colorsByAppearance, setColorsByAppearance] = useState<ThemeEditorColorsByAppearance>(() =>
171170 getThemeEditorColorsByAppearance(),
......@@ -179,7 +178,6 @@ export function ThemeEditorPanel({
179178 const [isInspecting, setIsInspecting] = useState(false);
180179 const [selectedRole, setSelectedRole] = useState<ThemeColorRole | null>(null);
181180 const [usageCount, setUsageCount] = useState<number | null>(null);
182 const previousMergeTargetIdRef = useRef<string | null>(null);
183181 // Null parks the panel at its default corner; a value is a dragged spot,
184182 // kept clamped so the header can always be grabbed again.
185183 const [position, setPosition] = useState<{ x: number; y: number } | null>(null);
......@@ -230,7 +228,6 @@ export function ThemeEditorPanel({
230228 };
231229 window.addEventListener("resize", clamp);
232230 return () => window.removeEventListener("resize", clamp);
233 // oxlint-disable-next-line exhaustive-deps -- clampPosition reads live layout only.
234231 }, [isMinimized, open]);
235232
236233 // The draft only reaches the live app once this open has been seeded;
......@@ -261,9 +258,6 @@ export function ThemeEditorPanel({
261258
262259 setName(editingTheme?.label ?? seedName ?? "");
263260 setActiveAppearance(nextAppearance);
264 // Artwork is opt-in for new themes, including duplicates. Editing keeps
265 // the theme's existing choice.
266 setSidebarArtwork(editingTheme?.sidebarArtwork === true);
267261 // Themes saved by the guided editor carry the managed flag; anything
268262 // else (imports, hand-edited files, older saves) opens in advanced mode
269263 // so guided regeneration cannot silently discard hand-tuned colors. A
......@@ -320,18 +314,6 @@ export function ThemeEditorPanel({
320314 // an explanation instead.
321315 const mergeTargetId = mergeTarget?.id ?? null;
322316 const takenAppearancesKey = takenAppearances.join(",");
323 useEffect(() => {
324 if (previousMergeTargetIdRef.current === mergeTargetId) return;
325 previousMergeTargetIdRef.current = mergeTargetId;
326 // A matching name makes that existing theme the surviving merge target.
327 // Seed theme-level options from it so adding a palette or renaming onto it
328 // does not silently reset them. Leaving the merge restores the edited
329 // theme's option (or the off-by-default choice for a new theme).
330 setSidebarArtwork(
331 mergeTarget ? mergeTarget.sidebarArtwork === true : editingTheme?.sidebarArtwork === true,
332 );
333 }, [editingTheme, mergeTarget, mergeTargetId]);
334
335317 useEffect(() => {
336318 if (isEditing || mergeTargetId === null) return;
337319 const taken = takenAppearancesKey.split(",").filter(Boolean) as ThemeAppearance[];
......@@ -347,8 +329,8 @@ export function ThemeEditorPanel({
347329 // comes back when the editor closes, including on cancel.
348330 useEffect(() => {
349331 if (!open || !isDraftSeeded) return;
350 applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance, sidebarArtwork);
351 }, [activeAppearance, colorsByAppearance, isDraftSeeded, open, sidebarArtwork]);
332 applyThemeColorPreview(colorsByAppearance[activeAppearance], activeAppearance);
333 }, [activeAppearance, colorsByAppearance, isDraftSeeded, open]);
352334
353335 useEffect(() => {
354336 if (!open) return;
......@@ -674,7 +656,6 @@ export function ThemeEditorPanel({
674656 ...mergeTarget.variants,
675657 ...Object.fromEntries(editedModes.map((mode) => [mode, colorsForSave[mode]])),
676658 },
677 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
678659 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
679660 }),
680661 );
......@@ -705,7 +686,6 @@ export function ThemeEditorPanel({
705686 ...(getThemeModes(editingTheme).length > 1
706687 ? { variants: { [variantAppearance]: colorsForSave[variantAppearance] } }
707688 : {}),
708 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
709689 ...(isAdvanced ? {} : { managed: true }),
710690 }),
711691 );
......@@ -732,7 +712,6 @@ export function ThemeEditorPanel({
732712 ...mergeTarget.variants,
733713 [activeAppearance]: colorsForSave[activeAppearance],
734714 },
735 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
736715 ...(mergeTarget.managed === true && !isAdvanced ? { managed: true } : {}),
737716 }),
738717 );
......@@ -743,7 +722,6 @@ export function ThemeEditorPanel({
743722 name,
744723 appearance: activeAppearance,
745724 colors: colorsForSave[activeAppearance],
746 ...(sidebarArtwork ? { sidebarArtwork: true } : {}),
747725 ...(isAdvanced ? {} : { managed: true }),
748726 }),
749727 );
......@@ -794,7 +772,6 @@ export function ThemeEditorPanel({
794772 name,
795773 onOpenChange,
796774 onSaved,
797 sidebarArtwork,
798775 simpleColorsDirtyByAppearance,
799776 takenAppearances,
800777 ]);
......@@ -854,20 +831,6 @@ export function ThemeEditorPanel({
854831 </div>
855832 );
856833
857 const renderSidebarArtworkToggle = () => (
858 <label className="grid cursor-pointer grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-center gap-3">
859 <span className="text-sm font-medium">Sidebar artwork</span>
860 <span className="flex items-center justify-between gap-3 text-xs text-muted-foreground">
861 <span>Show T3 Code environment artwork</span>
862 <Switch
863 aria-label="Allow sidebar artwork with this theme"
864 checked={sidebarArtwork}
865 onCheckedChange={(checked) => setSidebarArtwork(Boolean(checked))}
866 />
867 </span>
868 </label>
869 );
870
871834 const renderColorsHeader = () => (
872835 <div className="grid grid-cols-[minmax(0,1fr)_minmax(0,2fr)] items-start gap-3">
873836 <div>
......@@ -1124,7 +1087,6 @@ export function ThemeEditorPanel({
11241087 </p>
11251088 ) : null}
11261089 {renderAppearanceButtons()}
1127 {renderSidebarArtworkToggle()}
11281090 <div className="space-y-3">
11291091 {renderColorsHeader()}
11301092 {renderColorFields()}
apps/web/src/components/sidebar/SidebarChrome.tsx+3-1
......@@ -8,6 +8,7 @@ import { usePrimaryEnvironment } from "../../state/environments";
88import {
99 resolveEnvironmentIdentificationPillLabel,
1010 resolveSidebarStageBackdropVariant,
11 resolveSidebarStageFocusRingOffsetClass,
1112 SidebarStageBackdrop,
1213 useEnvironmentStageLabel,
1314} from "../SidebarStageBackdrop";
......@@ -52,7 +53,8 @@ export const SidebarChromeHeader = memo(function SidebarChromeHeader({
5253 className={cn(
5354 "relative z-10 md:hidden",
5455 backdropVariant &&
55 "[: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!",
56 "focus-visible:ring-white/90 [&_svg]:stroke-white/90! [&_svg]:opacity-100! [&_svg]:hover:stroke-white! [:hover,[data-pressed]]:bg-white/15",
57 backdropVariant && resolveSidebarStageFocusRingOffsetClass(backdropVariant),
5658 )}
5759 />
5860 <SidebarBrand onBackdrop={backdropVariant !== null} />
apps/web/src/hooks/useSettings.ts+4-4
......@@ -31,6 +31,7 @@ import {
3131 getThemePreviewSidebarArtwork,
3232 resolveThemeHalf,
3333 subscribeToThemePreview,
34 themeAllowsSidebarArtwork,
3435} from "~/themePalette";
3536import * as Struct from "effect/Struct";
3637import { primaryServerSettingsAtom, serverEnvironment } from "~/state/server";
......@@ -236,8 +237,8 @@ export function resolveEnvironmentIdentificationMode(input: {
236237}): EnvironmentIdentificationMode {
237238 // Avoid briefly rendering the default artwork before a persisted pill/none choice loads.
238239 if (!input.settingsHydrated) return "none";
239 // Stage artwork has fixed colors that can clash with palette themes. Keep an
240 // explicit "none", but use the theme-aware pill in place of artwork.
240 // Artwork palettes are maintained for built-ins only. Keep an explicit
241 // "none", but use the theme-aware pill for user-controlled palettes.
241242 return input.paletteThemeActive && !input.paletteThemeAllowsArtwork && input.mode === "artwork"
242243 ? "pill"
243244 : input.mode;
......@@ -258,8 +259,7 @@ export function useEnvironmentIdentificationMode(): EnvironmentIdentificationMod
258259 mode,
259260 settingsHydrated,
260261 paletteThemeActive: previewSidebarArtwork !== null || activeThemeDefinition !== null,
261 paletteThemeAllowsArtwork:
262 previewSidebarArtwork ?? activeThemeDefinition?.sidebarArtwork === true,
262 paletteThemeAllowsArtwork: previewSidebarArtwork ?? themeAllowsSidebarArtwork(activeTheme),
263263 });
264264}
265265
apps/web/src/index.css+163-10
......@@ -265,6 +265,39 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
265265}
266266
267267@layer base {
268 :root {
269 /* Dev artwork defaults. Built-in themes override these seven pigments in
270 the components layer; Nightly derives a darker matching palette. */
271 --stage-art-top: #67c2ff;
272 --stage-art-mid: #347ff8;
273 --stage-art-bottom: #1538d0;
274 --stage-art-highlight: #d4f6ff;
275 --stage-art-secondary: #65c8ff;
276 --stage-art-tertiary: #7c8bff;
277 --stage-art-line: #ddf7ff;
278 --stage-night-top: color-mix(in oklch, var(--stage-art-top) 38%, #32155b);
279 --stage-night-mid: color-mix(in oklch, var(--stage-art-mid) 38%, #151443);
280 --stage-night-bottom: color-mix(in oklch, var(--stage-art-bottom) 38%, #07152f);
281 --stage-night-highlight: var(--stage-art-highlight);
282 --stage-night-secondary: color-mix(
283 in oklch,
284 var(--stage-art-secondary) 55%,
285 var(--stage-night-mid)
286 );
287 --stage-night-tertiary: color-mix(
288 in oklch,
289 var(--stage-art-tertiary) 60%,
290 var(--stage-night-top)
291 );
292 --stage-night-line: color-mix(in oklch, var(--stage-art-line) 82%, white);
293 }
294
295 .dark {
296 --stage-art-top: #3a7ad1;
297 --stage-art-mid: #2050ae;
298 --stage-art-bottom: #101f6e;
299 }
300
268301 * {
269302 @apply border-border outline-ring/50;
270303 }
......@@ -335,16 +368,136 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil
335368 );
336369 }
337370
338 .stage-blueprint {
339 --stage-bp-top: #67c2ff;
340 --stage-bp-mid: #347ff8;
341 --stage-bp-bottom: #1538d0;
342 }
343
344 .dark .stage-blueprint {
345 --stage-bp-top: #3a7ad1;
346 --stage-bp-mid: #2050ae;
347 --stage-bp-bottom: #101f6e;
371 /* Each maintainer palette gives the same line art its own material: rose
372 vellum, forest drafting paper, marine cyanotype, copper, and violet ink.
373 These colors stay deliberately deep at the top edge so the white stage
374 wordmark and controls retain contrast in both appearance modes. */
375 html[data-theme-id="t3-chat"] {
376 --stage-art-top: #f28ac1;
377 --stage-art-mid: #cb3e86;
378 --stage-art-bottom: #73194f;
379 --stage-art-highlight: #ffe6f6;
380 --stage-art-secondary: #ff82bc;
381 --stage-art-tertiary: #c67bf1;
382 --stage-art-line: #ffe7f8;
383 }
384
385 html.dark[data-theme-id="t3-chat"] {
386 --stage-art-top: #a8467e;
387 --stage-art-mid: #742253;
388 --stage-art-bottom: #390e2d;
389 --stage-art-highlight: #ffd8ee;
390 --stage-art-secondary: #de639e;
391 --stage-art-tertiary: #9c64cf;
392 --stage-art-line: #ffe4f4;
393 }
394
395 html[data-theme-id="grove"] {
396 --stage-art-top: #6cc492;
397 --stage-art-mid: #2d8a5e;
398 --stage-art-bottom: #174a33;
399 --stage-art-highlight: #e4fff0;
400 --stage-art-secondary: #79d8a5;
401 --stage-art-tertiary: #d5b563;
402 --stage-art-line: #eafff2;
403 --stage-night-top: #286548;
404 --stage-night-mid: #174a35;
405 --stage-night-bottom: #0a2c21;
406 --stage-night-highlight: #c4eed5;
407 --stage-night-secondary: #54a878;
408 --stage-night-tertiary: #b79b4d;
409 --stage-night-line: #d7f6e2;
410 }
411
412 html.dark[data-theme-id="grove"] {
413 --stage-art-top: #438e65;
414 --stage-art-mid: #286447;
415 --stage-art-bottom: #123525;
416 --stage-art-highlight: #d2fbe2;
417 --stage-art-secondary: #62bf88;
418 --stage-art-tertiary: #c99b45;
419 --stage-art-line: #e0fae9;
420 --stage-night-top: #24523a;
421 --stage-night-mid: #113323;
422 --stage-night-bottom: #071d16;
423 --stage-night-highlight: #b5dfc4;
424 --stage-night-secondary: #478d66;
425 --stage-night-tertiary: #a9853b;
426 --stage-night-line: #d0ead9;
427 }
428
429 html[data-theme-id="ocean"] {
430 --stage-art-top: #77c9e3;
431 --stage-art-mid: #3384bd;
432 --stage-art-bottom: #174d7a;
433 --stage-art-highlight: #e5faff;
434 --stage-art-secondary: #70c9df;
435 --stage-art-tertiary: #5cc7be;
436 --stage-art-line: #e9fbff;
437 }
438
439 html.dark[data-theme-id="ocean"] {
440 --stage-art-top: #4288ac;
441 --stage-art-mid: #285d84;
442 --stage-art-bottom: #132e49;
443 --stage-art-highlight: #d9f5ff;
444 --stage-art-secondary: #61b5d4;
445 --stage-art-tertiary: #51b8b3;
446 --stage-art-line: #dff7ff;
447 }
448
449 html[data-theme-id="ember"] {
450 --stage-art-top: #f4a26e;
451 --stage-art-mid: #d66036;
452 --stage-art-bottom: #7c291e;
453 --stage-art-highlight: #fff0df;
454 --stage-art-secondary: #ffad77;
455 --stage-art-tertiary: #ef6f69;
456 --stage-art-line: #fff3e7;
457 --stage-night-top: #8a442c;
458 --stage-night-mid: #5c271c;
459 --stage-night-bottom: #32100d;
460 --stage-night-highlight: #ffd6ba;
461 --stage-night-secondary: #cb7148;
462 --stage-night-tertiary: #ad4b45;
463 --stage-night-line: #ffe0c9;
464 }
465
466 html.dark[data-theme-id="ember"] {
467 --stage-art-top: #ba6540;
468 --stage-art-mid: #803a29;
469 --stage-art-bottom: #3c1916;
470 --stage-art-highlight: #ffe1cd;
471 --stage-art-secondary: #e68156;
472 --stage-art-tertiary: #d85b58;
473 --stage-art-line: #ffe8d7;
474 --stage-night-top: #6a3425;
475 --stage-night-mid: #3e1b15;
476 --stage-night-bottom: #1d0d0b;
477 --stage-night-highlight: #efc4a9;
478 --stage-night-secondary: #a85e3f;
479 --stage-night-tertiary: #8f413e;
480 --stage-night-line: #f2d2bc;
481 }
482
483 html[data-theme-id="iris"] {
484 --stage-art-top: #b49ae8;
485 --stage-art-mid: #7a5ac4;
486 --stage-art-bottom: #422d80;
487 --stage-art-highlight: #f2eaff;
488 --stage-art-secondary: #b79af1;
489 --stage-art-tertiary: #ec79c7;
490 --stage-art-line: #f6eeff;
491 }
492
493 html.dark[data-theme-id="iris"] {
494 --stage-art-top: #8063c4;
495 --stage-art-mid: #513990;
496 --stage-art-bottom: #261c53;
497 --stage-art-highlight: #e8ddff;
498 --stage-art-secondary: #9e83dc;
499 --stage-art-tertiary: #d96ab7;
500 --stage-art-line: #eee7ff;
348501 }
349502
350503 .workspace-topbar {
apps/web/src/themePalette.test.ts+15-16
......@@ -20,6 +20,7 @@ import {
2020 serializeThemeFile,
2121 subscribeToThemePreview,
2222 subscribeToCustomThemes,
23 themeAllowsSidebarArtwork,
2324 T3_CHAT_THEME,
2425 EMBER_THEME,
2526 GROVE_THEME,
......@@ -206,14 +207,8 @@ describe("theme files", () => {
206207 });
207208 });
208209
209 it("keeps sidebar artwork opt-in through theme files", () => {
210 const withoutArtwork = parseThemeFile({
211 version: THEME_FILE_VERSION,
212 name: "Plain sidebar",
213 appearance: "light",
214 colors: { accent: "#5b6cff" },
215 });
216 const withArtwork = parseThemeFile({
210 it("keeps sidebar artwork disabled for custom theme files", () => {
211 const theme = parseThemeFile({
217212 version: THEME_FILE_VERSION,
218213 name: "Art sidebar",
219214 appearance: "light",
......@@ -221,12 +216,11 @@ describe("theme files", () => {
221216 sidebarArtwork: true,
222217 });
223218
224 expect(withoutArtwork.sidebarArtwork).toBeUndefined();
225 expect(withArtwork.sidebarArtwork).toBe(true);
226 expect(JSON.parse(serializeThemeFile(withArtwork)).sidebarArtwork).toBe(true);
219 expect(theme.sidebarArtwork).toBeUndefined();
220 expect(JSON.parse(serializeThemeFile(theme))).not.toHaveProperty("sidebarArtwork");
227221 });
228222
229 it("publishes sidebar artwork changes from the live theme preview", () => {
223 it("suppresses sidebar artwork during a live custom-theme preview", () => {
230224 const listener = vi.fn();
231225 const unsubscribe = subscribeToThemePreview(listener);
232226 vi.stubGlobal("document", {
......@@ -237,8 +231,8 @@ describe("theme files", () => {
237231 },
238232 });
239233
240 applyThemeColorPreview(T3_CHAT_THEME.colors, "light", true);
241 expect(getThemePreviewSidebarArtwork()).toBe(true);
234 applyThemeColorPreview(T3_CHAT_THEME.colors, "light");
235 expect(getThemePreviewSidebarArtwork()).toBe(false);
242236 expect(listener).toHaveBeenCalledTimes(1);
243237
244238 applyThemePalette("system");
......@@ -325,6 +319,8 @@ describe("theme files", () => {
325319 for (const theme of [T3_CHAT_THEME, GROVE_THEME, OCEAN_THEME, EMBER_THEME, IRIS_THEME]) {
326320 expect(getThemeDefinition(theme.id)).toBe(theme);
327321 expect(getThemeModes(theme)).toEqual(["light", "dark"]);
322 expect(theme.sidebarArtwork).toBe(true);
323 expect(themeAllowsSidebarArtwork(theme.id)).toBe(true);
328324 expect(theme.colors.accent).toMatch(/^#[0-9a-f]{6}$/i);
329325 expect(theme.variants?.dark?.accent).toMatch(/^#[0-9a-f]{6}$/i);
330326
......@@ -359,6 +355,7 @@ describe("theme files", () => {
359355 );
360356 }
361357 }
358 expect(themeAllowsSidebarArtwork("my-custom-theme")).toBe(false);
362359 });
363360
364361 it("rejects a variant that repeats the base appearance", () => {
......@@ -451,15 +448,17 @@ describe("theme files", () => {
451448 expect(updatedTheme).toMatchObject({
452449 id: "aurora",
453450 label: "Aurora Night",
454 sidebarArtwork: true,
455451 });
452 expect(updatedTheme).not.toHaveProperty("sidebarArtwork");
456453 invalidateCustomThemes();
457454 expect(getCustomThemes()).toEqual([updatedTheme]);
458455 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).toMatchObject({
459456 id: "aurora",
460457 label: "Aurora Night",
461 sidebarArtwork: true,
462458 });
459 expect(JSON.parse(stored.get(CUSTOM_THEMES_STORAGE_KEY) ?? "[]")[0]).not.toHaveProperty(
460 "sidebarArtwork",
461 );
463462
464463 vi.unstubAllGlobals();
465464 invalidateCustomThemes();
apps/web/src/themePalette.ts+19-11
......@@ -96,7 +96,7 @@ export type ThemeDefinition = Readonly<{
9696 appearance: ThemeAppearance;
9797 colors: ThemeColors;
9898 variants?: ThemeVariants;
99 /** Allows fixed Dev/Nightly artwork to render over this theme's sidebar. */
99 /** Allows Dev/Nightly artwork to render over a maintainer-controlled sidebar. */
100100 sidebarArtwork?: boolean;
101101 /** True when the palette was generated by the guided editor from its
102102 * canvas and accent; such themes reopen in guided mode. */
......@@ -109,7 +109,6 @@ export type ThemeFile = Readonly<{
109109 appearance: ThemeAppearance;
110110 colors: ThemeColorOverrides;
111111 variants?: ThemeVariantOverrides;
112 sidebarArtwork?: boolean;
113112 managed?: boolean;
114113}>;
115114
......@@ -223,7 +222,6 @@ function parseStoredTheme(value: unknown): ThemeDefinition | null {
223222 appearance: value.appearance,
224223 colors,
225224 ...(variants ? { variants } : {}),
226 ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}),
227225 ...(value.managed === true ? { managed: true } : {}),
228226 };
229227}
......@@ -1299,6 +1297,7 @@ export const T3_CHAT_THEME: ThemeDefinition = {
12991297 variants: {
13001298 dark: T3_CHAT_DARK_COLORS,
13011299 },
1300 sidebarArtwork: true,
13021301};
13031302
13041303/** Theme-file defaults follow the flagship palette for the requested mode. */
......@@ -1341,6 +1340,7 @@ export const GROVE_THEME: ThemeDefinition = {
13411340 ...themeActionColors("#e3b34e"),
13421341 },
13431342 },
1343 sidebarArtwork: true,
13441344};
13451345
13461346export const OCEAN_THEME: ThemeDefinition = {
......@@ -1357,6 +1357,7 @@ export const OCEAN_THEME: ThemeDefinition = {
13571357 ...themeActionColors("#5bd0d6"),
13581358 },
13591359 },
1360 sidebarArtwork: true,
13601361};
13611362
13621363export const EMBER_THEME: ThemeDefinition = {
......@@ -1373,6 +1374,7 @@ export const EMBER_THEME: ThemeDefinition = {
13731374 ...themeActionColors("#f78a7a"),
13741375 },
13751376 },
1377 sidebarArtwork: true,
13761378};
13771379
13781380export const IRIS_THEME: ThemeDefinition = {
......@@ -1389,6 +1391,7 @@ export const IRIS_THEME: ThemeDefinition = {
13891391 ...themeActionColors("#f099d8"),
13901392 },
13911393 },
1394 sidebarArtwork: true,
13921395};
13931396
13941397const BUILT_IN_THEME_DEFINITIONS: ReadonlyArray<ThemeDefinition> = [
......@@ -1408,6 +1411,15 @@ export function getThemeDefinition(theme: ThemePreference): ThemeDefinition | nu
14081411 );
14091412}
14101413
1414/** Artwork palettes are reviewed alongside built-ins; user themes always use the pill fallback. */
1415export function themeAllowsSidebarArtwork(theme: ThemePreference): boolean {
1416 const themeId = themeIdFromPreference(theme);
1417 return (
1418 BUILT_IN_THEME_DEFINITIONS.find((definition) => definition.id === themeId)?.sidebarArtwork ===
1419 true
1420 );
1421}
1422
14111423export function getThemeColorsForMode(
14121424 theme: ThemeDefinition,
14131425 mode: ThemeAppearance,
......@@ -1569,7 +1581,6 @@ export function parseThemeFile(value: unknown): ThemeDefinition {
15691581 appearance,
15701582 colors: { ...fallback, ...overrides },
15711583 ...(Object.keys(variants).length > 0 ? { variants } : {}),
1572 ...(value.sidebarArtwork === true ? { sidebarArtwork: true } : {}),
15731584 ...(value.managed === true ? { managed: true } : {}),
15741585 };
15751586}
......@@ -1582,7 +1593,6 @@ export function serializeThemeFile(theme: ThemeDefinition): string {
15821593 appearance: theme.appearance,
15831594 colors: theme.colors,
15841595 ...(theme.variants ? { variants: theme.variants } : {}),
1585 ...(theme.sidebarArtwork ? { sidebarArtwork: true } : {}),
15861596 ...(theme.managed ? { managed: true } : {}),
15871597 };
15881598 return `${JSON.stringify(file, null, 2)}\n`;
......@@ -1660,16 +1670,14 @@ export const THEME_PREVIEW_ID = "__preview";
16601670 * can be judged against the real interface instead of a miniature. Callers
16611671 * restore the stored theme (refreshTheme) when the draft goes away.
16621672 */
1663export function applyThemeColorPreview(
1664 colors: ThemeColors,
1665 appearance: ThemeAppearance,
1666 sidebarArtwork = false,
1667): void {
1673export function applyThemeColorPreview(colors: ThemeColors, appearance: ThemeAppearance): void {
16681674 if (typeof document === "undefined") return;
16691675 const root = document.documentElement;
16701676 if (!root?.style) return;
16711677
1672 setThemePreviewSidebarArtwork(sidebarArtwork);
1678 // Drafts become user-controlled themes when saved, so their preview keeps
1679 // the fixed stage artwork hidden even when it was seeded from a built-in.
1680 setThemePreviewSidebarArtwork(false);
16731681 root.dataset.themeId = THEME_PREVIEW_ID;
16741682 root.classList.toggle("dark", appearance === "dark");
16751683 for (const [role, value] of Object.entries(colors) as Array<[ThemeColorRole, string]>) {
docs/user/thread-sidebar.md+7
......@@ -11,3 +11,10 @@ other connected devices.
1111If reordering is unavailable for one environment, update the T3 Code server running in that
1212environment. Older servers can still pin and unpin threads, but do not understand synced ordering;
1313their pinned threads keep the default newest-first order below the ones you have arranged.
14
15## Environment artwork
16
17Dev and Nightly environments can identify themselves with artwork at the top of the sidebar and in
18the send button. Choose **Artwork**, **Version pill**, or **None** in Settings under environment
19identification. Artwork is recolored to match each built-in theme. Custom themes use the **Version
20pill** fallback because their colors are not controlled by T3 Code.